big ass modding update

This commit is contained in:
bryanthaboi
2026-07-19 16:18:18 -04:00
parent b5a673b252
commit 47923d95b3
258 changed files with 31048 additions and 2310 deletions
+85 -14
View File
@@ -1,4 +1,5 @@
local bit = require("bit")
local Assets = require("src.render.Assets")
local ChipAudio = {}
@@ -48,6 +49,22 @@ local function loadBanks(data)
return banks
end
-- A def-local program (ChipAsm output) is mounted as pseudo-bank 0 next to
-- the ROM banks, so the 0x4000-window byte reader and every call/loop
-- target work unchanged. The ROM's own cached bank table is never touched
-- because bank 0 differs per def, and a blob that carries its own waves and
-- drums renders even where programs.bin is unreadable.
local function engineBanks(data, chip)
if not chip then return loadBanks(data) end
local banks = {}
local ok, romBanks = pcall(loadBanks, data)
if ok then
for bank, bytes in pairs(romBanks) do banks[bank] = bytes end
end
banks[0] = chip.blob
return banks
end
local function romByte(banks, bank, address)
local bytes = assert(banks[bank], "uncached audio bank " .. tostring(bank))
local value = bytes:byte(address - 0x4000 + 1)
@@ -497,6 +514,8 @@ function Channel:sample()
if event.wave then
local wave = self.engine.waves[
math.min(event.waveInstrument + 1, #self.engine.waves)]
-- a def-local program may omit its wave table entirely
if not wave then return 0 end
local index = math.min(32, math.floor(phase * 32) + 1)
return wave[index] * event.waveLevel * 0.55
end
@@ -511,6 +530,9 @@ local Engine = {}
Engine.__index = Engine
function Engine:noiseInstrument(number)
-- a def-local drum wins over the ROM engine's table for that id
local custom = self.customDrums and self.customDrums[number]
if custom then return custom end
local cached = self.noiseInstruments[number]
if cached then return cached end
@@ -571,20 +593,55 @@ local function readWaves(banks, audio, engineNumber)
return waves
end
-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own
-- units) or as the -1..1 samples readWaves produces; the synth wants the
-- latter
local function normalizeWaves(source)
local waves = {}
for index, values in ipairs(source) do
local nibbles = false
for _, value in ipairs(values) do
if value > 1 or value < -1 then nibbles = true break end
end
local wave = {}
for position, value in ipairs(values) do
wave[position] = nibbles and (value - 7.5) / 7.5 or value
end
waves[index] = wave
end
return waves
end
function Engine.new(data, header, options)
options = options or {}
local banks = loadBanks(data)
local audio = data.audio or {}
-- shape dispatch: a def-local chip program supplies its own channels and
-- may supply its own waves/drums, falling back to a ROM engine's tables
local chip = header.chip
local banks = engineBanks(data, chip)
local engineNumber = chip and (chip.engine or 1) or header.engine
local waves
if chip and chip.waves then
waves = normalizeWaves(chip.waves)
elseif chip then
local ok, romWaves = pcall(readWaves, banks, audio, engineNumber)
waves = ok and romWaves or {}
else
waves = readWaves(banks, audio, engineNumber)
end
local engine = setmetatable({
banks = banks,
tempo = 0x100,
pan = 0xFF,
waves = readWaves(banks, data.audio, header.engine),
noiseHeaders = data.audio.noiseHeaders
and data.audio.noiseHeaders[tostring(header.engine)] or {},
waves = waves,
noiseHeaders = audio.noiseHeaders
and audio.noiseHeaders[tostring(engineNumber)] or {},
customDrums = chip and chip.drums or nil,
noiseInstruments = {},
channels = {},
}, Engine)
for _, spec in ipairs(headerChannels(banks, header)) do
for _, spec in ipairs(chip and chip.channels
or headerChannels(banks, header)) do
local frameTicks = options.frameTicks
local hardware = (spec.number - 1) % 4 + 1
if hardware == 4 then
@@ -593,7 +650,7 @@ function Engine.new(data, header, options)
frameTicks = 0x80 + options.cryLength
end
engine.channels[#engine.channels + 1] = Channel.new(engine, spec, {
bank = header.bank,
bank = chip and 0 or header.bank,
sfx = options.sfx,
allowLoops = options.allowLoops,
frequencyOffset = options.frequencyOffset,
@@ -663,14 +720,14 @@ local function fillMusic()
end
function ChipAudio.playMusic(data, header, allowLoops)
ChipAudio.stopMusic()
-- build before tearing down: a def that fails to compile (bad addresses,
-- unreadable blob) must leave the outgoing song sounding
local engine = Engine.new(data, header, { allowLoops = allowLoops })
local ok, source = pcall(
love.audio.newQueueableSource, SAMPLE_RATE, 16, 2, MUSIC_BUFFER_COUNT)
if not ok then return nil, source end
currentMusic = {
source = source,
engine = Engine.new(data, header, { allowLoops = allowLoops }),
}
ChipAudio.stopMusic()
currentMusic = { source = source, engine = engine }
fillMusic()
source:play()
return source
@@ -700,6 +757,17 @@ function ChipAudio.stopMusic()
currentMusic = nil
end
-- hot reload: the next play re-reads programs.bin (a mod may have swapped
-- the file out from under the single-slot bank cache)
function ChipAudio.invalidate()
ChipAudio.stopMusic()
cachedProgramFile, cachedBanks = nil, nil
end
-- a stale song must not keep sounding past the flush that replaced its
-- program (20 §2 cache contract, chip music row)
Assets.register(ChipAudio.invalidate)
local function renderEffect(data, header, options)
if not header then return nil end
options = options or {}
@@ -796,10 +864,13 @@ function ChipAudio.newSfx(data, name, pitch, tempo, header)
})
end
function ChipAudio.newCry(data, species)
local cry = data.audio.cries[species]
-- `resolved` is a {header|chip, pitch, length} def the caller already worked
-- out -- a derived cry borrowing another species' header with its own
-- modifiers, which no registry lookup under `species` could find
function ChipAudio.newCry(data, species, resolved)
local cry = resolved or (data.audio.cries and data.audio.cries[species])
if not cry then return nil end
return renderEffect(data, cry.header, {
return renderEffect(data, cry.chip and cry or cry.header, {
frequencyOffset = cry.pitch,
cryLength = cry.length,
})
+141 -2
View File
@@ -14,10 +14,109 @@ local MODULES = {
-- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" }
-- The rules the engine still carries as literals. The constants registry
-- deep-merges over these, so a value has to exist before a mod can patch
-- it; each one is the number the engine hard-codes today, so seeding them
-- changes nothing on a mod-free boot.
local CONSTANT_DEFAULTS = {
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
moveMax = 4,
levelCap = 100,
coinCap = 9999, -- MAX_COINS (src/ui/SlotMachine.lua)
-- move-slot repair when a scrub empties a mon (src/core/SaveData.lua);
-- a total conversion without TACKLE patches this to its own floor
fallbackMove = "TACKLE",
hmMoves = { "CUT", "FLY", "SURF", "STRENGTH", "FLASH" }, -- IsMoveHM
-- gym order (data/scripts/victories.lua); list position is the badge
-- number the trainer card draws
badges = {
{ id = "BOULDERBADGE" }, { id = "CASCADEBADGE" }, { id = "THUNDERBADGE" },
{ id = "RAINBOWBADGE" }, { id = "SOULBADGE" }, { id = "MARSHBADGE" },
{ id = "VOLCANOBADGE" }, { id = "EARTHBADGE" },
},
}
-- field.boot is the total-conversion override point for the new game; the
-- values match what SaveData.newGame and the Oak speech used to inline.
local BOOT_DEFAULTS = {
startMap = "PALLET_TOWN", startX = 5, startY = 6, startFacing = "down",
playerName = "RED", rivalName = "BLUE",
startMoney = 3000,
screens = { splash = "IntroMovie", title = "TitleState", newGame = "OakSpeech" },
}
local function copy(value)
if type(value) ~= "table" then return value end
local out = {}
for k, v in pairs(value) do out[k] = copy(v) end
return out
end
-- Fills only what the cache is missing, so an importer that learns to
-- stamp one of these keys silently takes over from the engine.
function Data:seedDefaults()
local constants = self.constants
for key, value in pairs(CONSTANT_DEFAULTS) do
if constants[key] == nil then constants[key] = copy(value) end
end
-- derived, not literal: a dataset with a different roster gets the right
-- upper bound without 151 being written down anywhere
if constants.dexSize == nil then
local highest = 0
for _, def in pairs(self.pokemon) do
if def.dex and def.dex > highest then highest = def.dex end
end
constants.dexSize = highest
end
if constants.dexDigits == nil then
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
end
local boot = self.field.boot
if boot == nil then
boot = {}
self.field.boot = boot
end
for key, value in pairs(BOOT_DEFAULTS) do
if boot[key] == nil then boot[key] = copy(value) end
end
-- the naming screen presets the importer already extracts but nothing
-- ever read (field.presetNames)
if boot.namePresets == nil then
local presets = self.field.presetNames or {}
boot.namePresets = {
player = copy(presets.player) or { "RED", "ASH", "JACK" },
rival = copy(presets.rival) or { "BLUE", "GARY", "JOHN" },
}
end
-- the overworld's Kanto literals, same fill-if-absent contract; required
-- here rather than at the top so core keeps out of src/world at load time
require("src.world.FieldDefaults").seed(self)
end
-- POKEPORT_DATA_DIR points a test runner at another dataset root (the
-- ROM-free fixture set, tests/fixture_data); unset -- every shipped build
-- -- the generated modules load exactly as before. loadfile skips the
-- require cache, so each overridden load hands back fresh tables.
local function loadModule(dir, name)
if dir then
local chunk, err = loadfile(dir .. "/" .. name .. ".lua")
if not chunk then return false, err end
return pcall(chunk)
end
return pcall(require, "data.generated." .. name)
end
function Data:load()
local dir = os.getenv("POKEPORT_DATA_DIR")
for _, name in ipairs(MODULES) do
local ok, mod = pcall(require, "data.generated." .. name)
local ok, mod = loadModule(dir, name)
if not ok then
if dir then
error(("missing data module '%s/%s.lua' (POKEPORT_DATA_DIR).\n(%s)")
:format(dir, name, mod))
end
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
"Import the ROM again or rebuild developer data.\n(%s)")
:format(name, mod))
@@ -25,18 +124,58 @@ function Data:load()
self[name] = mod
end
for _, name in ipairs(OPTIONAL) do
local ok, mod = pcall(require, "data.generated." .. name)
local ok, mod = loadModule(dir, name)
self[name] = ok and mod or nil
if not ok then
Logger.warn("optional data module '%s' missing (feature disabled)", name)
end
end
-- before the mod loader runs: the deep registries fold over these
self:seedDefaults()
-- the top-level keys a pristine load leaves behind, so reloadGenerated can
-- strip whatever a mod merge added since; kept on self (assigned before the
-- scan so it counts itself) because tests load other tables through this
-- method, and a shared upvalue would let them clobber the singleton's set
local pristine = {}
self._pristineKeys = pristine
for key in pairs(self) do pristine[key] = true end
Logger.info("generated data loaded (%d maps, %d species, %d moves)",
(function() local n = 0 for _ in pairs(self.maps) do n = n + 1 end return n end)(),
(function() local n = 0 for _ in pairs(self.pokemon) do n = n + 1 end return n end)(),
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
end
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
-- mod merge created, then re-require the generated modules so base records
-- return to their on-disk values even where a mod edited them in place
function Data:reloadGenerated()
local pristine = self._pristineKeys
if pristine then
for key in pairs(self) do
if not pristine[key] then self[key] = nil end
end
end
for _, name in ipairs(MODULES) do
package.loaded["data.generated." .. name] = nil
end
for _, name in ipairs(OPTIONAL) do
package.loaded["data.generated." .. name] = nil
end
self:load()
end
-- Resolve a dotted target path, creating empty tables on the way. Only the
-- mod merge calls this; a vanilla boot never does, so an unmodded Data
-- table is byte-identical to a pre-registry-v2 one.
function Data.ensure(data, path)
local node = data
for key in path:gmatch("[^%.]+") do
if node[key] == nil then node[key] = {} end
node = node[key]
end
return node
end
-- Resolve a TEXT_* constant on a map to a plain string (or nil if the text
-- needs a hand-ported script; see data/scripts/).
function Data:resolveText(mapLabel, textConst)
+136 -18
View File
@@ -10,9 +10,22 @@ local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TouchInput = require("src.core.TouchInput")
local ModLoader = require("src.mods.Loader")
local ModRuntime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Game = {}
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev
-- module unloaded, so a player boot never touches a byte of dev code
local devMode = os.getenv("POKEPORT_DEV") == "1"
-- the boot screen ids (field.boot.screens); a plain function so the
-- headless harness can borrow makeTitleState onto a stub game
local function bootScreens(game)
local boot = game.data and game.data.field and game.data.field.boot
return (boot and boot.screens) or {}
end
function Game:load()
self.data = Data
Data:load()
@@ -35,11 +48,17 @@ function Game:load()
Renderer:init()
require("src.render.Font").load(Data)
-- menu cursor/border/geometry constants; field.theme restyles them
require("src.ui.Theme").load(Data)
self.stack = StateStack
StateStack:init()
self.save = SaveData.newGame()
self.save = SaveData.newGame(self:bootConfig())
-- seed=true keeps what entry chunks wrote through mod.save before any
-- save existed; the skeleton fires save.created exactly once
self:adoptSave(self.save, true)
ModRuntime.emit("save.created", { save = self.save })
-- apply the persisted audio + display options before anything plays
self:applyOptions(self.save.options)
@@ -49,6 +68,10 @@ function Game:load()
local OverworldState = require("src.world.OverworldController")
self.overworld = OverworldState
-- every service is up but nothing is on the stack yet; this payload is
-- the sanctioned way for a mod to obtain the Game object
ModRuntime.emit("game.ready", { game = self })
-- boot into the title screen (engine/movie/title.asm); NEW GAME runs
-- the Oak speech + naming, CONTINUE restores the save. The headless
-- autopilot skips straight into the overworld.
@@ -58,37 +81,48 @@ function Game:load()
else
local titleState = self:makeTitleState()
-- the copyright splash + Nidorino-vs-Gengar attract movie plays
-- before the title (engine/movie/splash.asm + intro.asm)
local IntroMovie = require("src.ui.IntroMovie")
StateStack:push(IntroMovie.new(self, function()
-- before the title (engine/movie/splash.asm + intro.asm); the ids come
-- from field.boot.screens so a total conversion owns the whole boot
Screens.push(self, bootScreens(self).splash or "IntroMovie", function()
StateStack:push(titleState)
end))
end)
end
Logger.info("game loaded")
end
-- the merged field.boot: spawn, names, money and the naming presets a
-- total conversion overrides. Threaded into SaveData so persistence stays
-- free of a Data dependency.
function Game:bootConfig()
return self.data and self.data.field and self.data.field.boot
end
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
-- and by the START-menu QUIT confirmation
function Game:makeTitleState()
local TitleState = require("src.ui.TitleState")
local OverworldState = require("src.world.OverworldController")
return TitleState.new(self, {
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
return factory.new(self, {
onNewGame = function()
while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences
self.save = SaveData.newGame()
self.save = SaveData.newGame(self:bootConfig())
-- no bucket carry-over: mod state from an abandoned session must
-- not leak into a fresh slot; mods seed via save.created instead
self:adoptSave(self.save)
ModRuntime.emit("save.created", { save = self.save })
self:applyOptions(self.save.options)
self.stack:push(OverworldState, self.save.player.map,
self.save.player.x, self.save.player.y,
self.save.player.facing)
local OakSpeech = require("src.ui.OakSpeech")
self.stack:push(OakSpeech.new(self, function() end))
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
function() end)
end,
onContinue = function()
local loaded = SaveData.load()
local loaded, recovered = SaveData.load()
if loaded then
self:restoreSave(loaded)
self:restoreSave(loaded, recovered)
end
end,
})
@@ -127,6 +161,10 @@ function Game:update(dt)
require("src.render.Tilt").update(dt)
end
-- render.zones' identity default: unhooked, the zone list reaches the blit
-- exactly as the owning state computed it
local function sameZones(_, zones) return zones end
function Game:draw()
-- the UI canvas clears transparent when the overworld's world pass
-- shows through beneath it; opaque full-screen states get the classic
@@ -146,6 +184,11 @@ function Game:draw()
break
end
end
-- 14's render.zones: weather/lighting overlays and custom colorization
-- recolor or add zones before the blit
if ModRuntime.wantsHook("render.zones") then
zones = ModRuntime.call("render.zones", sameZones, self, zones)
end
if worldBelow and self.overworld.sgbWorldZones then
worldZones = self.overworld:sgbWorldZones()
end
@@ -172,17 +215,31 @@ function Game:keypressed(key)
self.stack:top():onKeyPressed(key)
return
end
if devMode and key == "f5" then
require("src.dev.HotReload").run(self)
return
end
if devMode and key == "`" then
self.stack:push(require("src.dev.Console").new(self))
return
end
if key == "f10" then
local ManagerState = require("src.mods.ManagerState")
self.stack:push(ManagerState.new(self))
-- toggle: the manager no longer swallows the keyboard, so a second
-- press reaches this branch and closes it instead of stacking another
local top = self.stack:top()
if top and top.screenId == "ManagerState" then
self.stack:pop()
else
Screens.push(self, "ManagerState")
end
return
end
if key == "f1" then
self:writeSave()
return
elseif key == "f2" then
local loaded = SaveData.load()
if loaded then self:restoreSave(loaded) end
local loaded, recovered = SaveData.load()
if loaded then self:restoreSave(loaded, recovered) end
return
elseif key == "-" then
self:zoomStep(-1)
@@ -228,6 +285,12 @@ function Game:keyreleased(key)
end
function Game:gamepadpressed(joystick, button)
-- BindingsMenu's pad capture rides the same top-state routing as keys
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
top:onGamepadPressed(button)
return
end
Input:gamepadpressed(joystick, button)
end
@@ -251,12 +314,35 @@ function Game:touchreleased(id, x, y)
TouchInput:touchreleased(id, x, y)
end
-- Point the loader's mod.save backing at this save's modData so per-mod
-- state persists with the slot. seedBuckets is boot-only: it keeps what
-- entry chunks wrote before any save existed, while NEW GAME and
-- CONTINUE replace the backing outright.
function Game:adoptSave(save, seedBuckets)
save.modData = save.modData or {}
local loader = self.mods
if not loader then return end
if seedBuckets then
for id, bucket in pairs(loader.modSave or {}) do
if save.modData[id] == nil then save.modData[id] = bucket end
end
end
loader.modSave = save.modData
end
-- Capture the live world state into the save table and persist it.
-- Options are flushed to options.lua as part of SaveData.save.
function Game:writeSave()
if self.overworld and self.overworld.captureSave then
self.overworld:captureSave(self.save)
end
-- stamp here so the save.writing payload carries the exact meta the
-- file gets; mods snapshot runtime state into their namespace now
self.save.meta = SaveData.buildMeta(
self.modStatus and self.modStatus.loaded, self.save.meta)
if ModRuntime.wants("save.writing") then
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
end
SaveData.save(self.save)
end
@@ -279,11 +365,25 @@ function Game:applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts)
end
function Game:restoreSave(loaded)
function Game:restoreSave(loaded, recovered)
if ModRuntime.wants("save.loading") then
ModRuntime.emit("save.loading", { raw = loaded })
end
-- mod chains replay before validation so a mod repairs its own data
-- instead of watching it get quarantined; core steps already ran in
-- SaveData.load and skip on the format guard
local activeMods = self.modStatus and self.modStatus.loaded
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
local modsDiff = SaveData.modsDiff(loaded, activeMods)
local report = SaveData.validate(loaded, self.data)
report.recovered = recovered
report.modsDiff = modsDiff
self.save = loaded
self:adoptSave(loaded)
-- SaveData.load already attached the standalone options.lua table
self:applyOptions(loaded.options)
-- saves from before OT/ID stamping: backfill with the player's
-- saves from before OT/ID stamping: backfill with the player's (after
-- the scrub, so every mon the stamp loop sees is known)
local stamp = require("src.battle.BattleState").stampOT
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
for _, box in ipairs(loaded.boxes or {}) do
@@ -293,6 +393,24 @@ function Game:restoreSave(loaded)
while self.stack:top() do self.stack:pop() end
self.stack:push(self.overworld, loaded.player.map,
loaded.player.x, loaded.player.y, loaded.player.facing)
self.saveReport = report
if not SaveData.emptyReport(report) then
-- the report screen is a Screens id so mods (or the ui milestone) own
-- its looks; until one exists the log keeps a quarantine from being
-- silent
local ok = pcall(Screens.push, self, "QuarantineReport", report)
if not ok then
Logger.warn("load report: %d mons quarantined, %d items removed, %d maps remapped%s",
#report.lostMons, #report.lostItems, #report.remappedMaps,
recovered and (", recovered from " .. recovered) or "")
local notice = SaveData.modsDiffNotice(modsDiff, loaded.meta)
if notice then Logger.warn("%s", notice) end
end
end
if ModRuntime.wants("save.loaded") then
ModRuntime.emit("save.loaded",
{ save = loaded, meta = loaded.meta, modsDiff = modsDiff })
end
end
return Game
+155 -68
View File
@@ -1,11 +1,14 @@
-- Music playback supports compact ROM channel programs synthesized live by
-- ChipAudio and legacy pre-rendered WAV definitions. Songs with split WAVs
-- chain def.file into def.loopFile in Music.update().
-- ChipAudio, def-local chip programs (ChipAsm), and file definitions. The
-- branch is chosen per song definition, never by a global import flag, so a
-- file-backed song and a chip song coexist in one dataset. Songs with split
-- files chain def.file into def.loopFile in Music.update().
-- Map themes switch on map change; battles override with the battle
-- theme and restore afterwards; riding the bike overrides outdoor map
-- themes with Music_BikeRiding until dismount.
-- themes with the bike song until dismount.
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Music = {}
@@ -37,8 +40,8 @@ local function applyFilter(src)
end
local state = {
enabled = true,
current = nil, -- song label
chip = false, -- the playing song is a synthesized channel program
source = nil, -- currently playing source
loopSource = nil, -- pre-loaded loop body waiting for the intro to end
mapSong = nil, -- song to restore after a battle
@@ -48,6 +51,7 @@ local state = {
fanfare = nil, -- fanfare SFX source; the song pauses while it plays
fanfareResume = false, -- start/resume state.source when the fanfare ends
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
failed = {}, -- labels whose def could not be started; logged once
}
-- Is a fanfare SFX (Sound.lua's FANFARES) still sounding?
@@ -64,7 +68,7 @@ end
-- channels on the Game Boy, so the current song halts and resumes when
-- the jingle ends (see update()).
function Music.duckForFanfare(src)
if not state.enabled or not src then return end
if not src then return end
state.fanfare = src
if state.source then
local ok, playing = pcall(state.source.isPlaying, state.source)
@@ -78,6 +82,8 @@ end
-- Overworld themes where the bike can be ridden (outdoor maps plus the
-- caves/dungeons where gen-1 allows cycling). Indoor themes such as
-- Pokecenter/Gym/SilphCo never get replaced by the bike theme.
-- data.audio.outdoorSongs supersedes this; the copy stays as the fallback
-- for caches built before the importer wrote the table.
local OUTDOOR = {
Music_PalletTown = true,
Music_Cities1 = true,
@@ -97,10 +103,52 @@ local OUTDOOR = {
Music_Dungeon3 = true,
}
-- Scene themes the engine asks for by role rather than by label, so a total
-- conversion can rename every song. data.audio.special supersedes this.
local SPECIAL = {
heal = "Music_PkmnHealed",
title = "Music_TitleScreen",
credits = "Music_Credits",
hallOfFame = "Music_HallOfFame",
introBattle = "Music_IntroBattle",
oakRoute = "Music_Routes2",
bike = "Music_BikeRiding",
surf = "Music_Surfing",
}
-- the label a scene role resolves to; call sites keep their own presence
-- guard on the resolved label
function Music.special(data, key)
local special = data and data.audio and data.audio.special
local label = special and special[key]
if label ~= nil then return label end
return SPECIAL[key]
end
local function outdoorSongs(data)
return data and data.audio and data.audio.outdoorSongs or OUTDOOR
end
local function songDef(data, song)
return data and data.audio and data.audio.songs and data.audio.songs[song]
end
-- which mod put this label in the registry, for attributed failure logs
local function songOwner(data, song)
local owners = data and data.audio and data.audio._owners
local songs = owners and owners.songs
return songs and songs[song] or "base"
end
-- one log line, plus an entry in the loader's error feed when a mod owns the
-- def, so the manager's errors screen can flag that mod
local function reportBadDef(data, song, err)
local who = songOwner(data, song)
Logger.warn("audio: bad song def %q (mod %s): %s", song, who, tostring(err))
Runtime.reportError(who,
("audio: bad song def %q: %s"):format(song, tostring(err)))
end
local function stopSource(src)
if src then pcall(src.stop, src) end
end
@@ -108,50 +156,73 @@ end
local function newSource(file)
local ok, src = pcall(love.audio.newSource, file, "stream")
if ok and src then return src end
Logger.warn("music: cannot load %s", tostring(file))
return nil
return nil, ok and "no source" or tostring(src)
end
function Music.play(data, song, loop)
if not state.enabled or not song or song == state.current then return end
if not love.audio then -- headless test stub
state.enabled = false
-- Build the new song's sources; the caller only tears the old song down
-- once this succeeded, so a broken def costs nothing but a log line.
-- Returns src, loopSrc, isChip -- or nil plus the reason.
local function startSong(data, def, wantLoop)
if def.chip or (def.address and def.bank) then
local ok, src = pcall(
require("src.core.ChipAudio").playMusic, data, def, wantLoop)
if ok and src then return src, nil, true end
return nil, nil, nil, ok and "no source" or tostring(src)
elseif def.file then
local src, err = newSource(def.file)
if not src then return nil, nil, nil, err end
-- a missing loop body degrades to the intro file alone
local loopSrc = def.loopFile and newSource(def.loopFile) or nil
return src, loopSrc, false
end
return nil, nil, nil, "no chip program and no file"
end
-- the single choke point every song choice passes through, so one hook
-- covers map themes, battle themes, jingles and scene music
local function selectSong(song, ctx)
if not Runtime.wantsHook("music.select") then return song end
return Runtime.call("music.select", function(chosen) return chosen end, song, {
reason = ctx and ctx.reason or "direct",
mapId = ctx and ctx.mapId,
mapSong = state.mapSong,
onBike = state.onBike,
surfing = state.surfing,
kind = ctx and ctx.kind,
battleKind = ctx and ctx.kind,
trainerId = ctx and ctx.trainerId,
})
end
function Music.play(data, song, loop, ctx)
if not song then return end
if not love.audio then return end -- headless test stub
song = selectSong(song, ctx)
-- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against
if not song or song == state.current then return end
local def = songDef(data, song)
if not def or state.failed[song] then return end
local wantLoop = loop ~= false
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
if not src then
state.failed[song] = true
reportBadDef(data, song, err)
return
end
local def = songDef(data, song)
local runtime = data and data.audio and data.audio.runtime
if not def or (not runtime and not def.file) then return end
stopSource(state.source)
stopSource(state.loopSource)
if runtime then require("src.core.ChipAudio").stopMusic() end
state.source, state.loopSource, state.fade = nil, nil, nil
local wantLoop = loop ~= false
local src
if runtime then
local ok, generated = pcall(
require("src.core.ChipAudio").playMusic, data, def, wantLoop)
if ok then src = generated end
else
src = newSource(def.file)
end
if not src then
state.enabled = false
state.current = nil
return
end
if not runtime and def.loopFile then
-- intro file plays once, then update() chains to the loop body
-- a chip song holds the streaming source; ChipAudio.playMusic already
-- swapped it when the new song is chip-backed too
if state.chip and not isChip then require("src.core.ChipAudio").stopMusic() end
state.fade = nil
if loopSrc then
-- intro plays once, then update() chains to the loop body
-- (for one-shot jingles the body plays once and doesn't repeat)
pcall(src.setLooping, src, false)
local loopSrc = newSource(def.loopFile)
if loopSrc then
pcall(loopSrc.setLooping, loopSrc, wantLoop)
applyVolume(loopSrc)
applyFilter(loopSrc)
state.loopSource = loopSrc
else
pcall(src.setLooping, src, wantLoop) -- degrade: intro file only
end
pcall(loopSrc.setLooping, loopSrc, wantLoop)
applyVolume(loopSrc)
applyFilter(loopSrc)
else
pcall(src.setLooping, src, wantLoop)
end
@@ -164,15 +235,34 @@ function Music.play(data, song, loop)
else
pcall(src.play, src)
end
state.source = src
local previous = state.current
state.source, state.loopSource, state.chip = src, loopSrc, isChip
state.current = song
if Runtime.wants("music.started") then
Runtime.emit("music.started", {
song = song, previous = previous, chip = isChip,
reason = ctx and ctx.reason or "direct",
})
end
end
function Music.stop()
local previous = state.current
stopSource(state.source)
stopSource(state.loopSource)
require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.chip = false
if previous and Runtime.wants("music.stopped") then
Runtime.emit("music.stopped", { song = previous })
end
end
-- hot reload: forget the failed defs and the playing label so the next cue
-- re-resolves against the freshly merged registries
function Music.reload()
state.failed = {}
Music.stop()
end
-- Ramp the current song's volume to silence, then stop it, mirroring the
@@ -183,7 +273,6 @@ end
-- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames
-- to silence). Ticked once per frame from Music.update().
function Music.fadeOut(control)
if not state.enabled then return end
if not state.source then Music.stop() return end
control = math.max(1, control or 10)
state.fade = {
@@ -196,13 +285,14 @@ end
-- the song a map should currently play, honoring the bike/surf overrides
local function effectiveMapSong(data, song)
if state.onBike and song and OUTDOOR[song]
and songDef(data, "Music_BikeRiding") then
return "Music_BikeRiding"
if not song or not outdoorSongs(data)[song] then return song end
if state.onBike then
local bike = Music.special(data, "bike")
if bike and songDef(data, bike) then return bike end
end
if state.surfing and song and OUTDOOR[song]
and songDef(data, "Music_Surfing") then
return "Music_Surfing"
if state.surfing then
local surf = Music.special(data, "surf")
if surf and songDef(data, surf) then return surf end
end
return song
end
@@ -216,20 +306,23 @@ function Music.playMap(data, mapId, onBike, surfing)
state.onBike = not not onBike
state.surfing = not not surfing
local play = effectiveMapSong(data, song)
if play then Music.play(data, play) end
if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end
end
-- toggle the surf override mid-map (starting/ending a surf)
function Music.setSurfing(data, surfing)
state.surfing = not not surfing
local play = effectiveMapSong(data, state.mapSong)
if play then Music.play(data, play) end
if play then Music.play(data, play, nil, { reason = "map" }) end
end
-- battle themes; kind = "wild"|"trainer"|"gym"|"final"
function Music.playBattle(data, kind)
function Music.playBattle(data, kind, trainerId)
local b = data.audio and data.audio.battle
if b then Music.play(data, b[kind] or b.wild) end
if b then
Music.play(data, b[kind] or b.wild, nil,
{ reason = "battle", kind = kind, trainerId = trainerId })
end
end
-- victory theme (Music_DefeatedWildMon/Trainer/GymLeader): starts the
@@ -237,12 +330,12 @@ end
-- (each Defeated* song ends in `sound_loop 0, .mainloop`); the battle's
-- finish() restores the map theme, like the overworld reload's
-- PlayDefaultMusicFadeOutCurrent. Returns true if the theme started.
function Music.playVictory(data, kind)
function Music.playVictory(data, kind, trainerId)
local b = data.audio and data.audio.battle
local jingle = b and b[kind .. "Win"]
local def = jingle and songDef(data, jingle)
if def and (def.file or (data.audio and data.audio.runtime)) then
Music.play(data, jingle)
if jingle and songDef(data, jingle) then
Music.play(data, jingle, nil,
{ reason = "victory", kind = kind, trainerId = trainerId })
return true
end
return false
@@ -251,11 +344,8 @@ end
-- one-shot jingle (PkmnHealed, Jigglypuff's song): the map theme
-- resumes when it ends, via update()
function Music.playOnce(data, song)
local def = songDef(data, song)
if not (def and (def.file or (data.audio and data.audio.runtime))) then
return false
end
Music.play(data, song, false)
if not songDef(data, song) then return false end
Music.play(data, song, false, { reason = "once" })
state.pendingRestore = true
return true
end
@@ -274,7 +364,7 @@ function Music.restoreMap(data)
state.current = nil
state.pendingRestore = nil
local play = effectiveMapSong(data, state.mapSong)
if play then Music.play(data, play) end
if play then Music.play(data, play, nil, { reason = "map" }) end
end
-- 0-7 music volume (0 mutes), applied to the playing song and the
@@ -308,10 +398,7 @@ end
-- call once per frame: chains a finished intro into its loop body and
-- restores the map theme after a one-shot jingle
function Music.update(data)
if data and data.audio and data.audio.runtime then
require("src.core.ChipAudio").update()
end
if not state.enabled then return end
if state.chip then require("src.core.ChipAudio").update() end
-- volume ramp (Music.fadeOut): hold the current level for `control`
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
-- nibbles when its counter reaches 0); at level 0 the music stops.
@@ -344,7 +431,7 @@ function Music.update(data)
end
state.fanfareResume = false
end
if data and data.audio and data.audio.runtime and not state.fanfare then
if state.chip and not state.fanfare then
require("src.core.ChipAudio").ensureMusicPlaying()
end
if state.loopSource and sourceStopped(state.source) then
+565 -112
View File
@@ -2,14 +2,30 @@
-- Options (audio, display, battle preferences) live in a separate
-- options.lua so they survive New Game and aren't tied to a save slot.
-- Both are plain Lua tables serialized as Lua source (deterministic
-- key order).
-- key order) and read back through SaveSerializer's data-only parser,
-- so a save can never execute code.
--
-- The load pipeline is read -> parse -> migrate -> validate/quarantine
-- -> restore; Game:restoreSave drives the last two phases with the
-- merged Data threaded in, because this module must not reach into
-- Data itself.
local Logger = require("src.core.Logger")
local Version = require("src.core.Version")
local SaveSerializer = require("src.core.SaveSerializer")
local Runtime = require("src.mods.Runtime")
local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag")
local SaveData = {}
local FILENAME = "save.lua"
local OPTIONS_FILENAME = "options.lua"
-- one rolling backup plus the staged-write witness; load promotes either
-- when the main file is missing or fails to parse
local BACKUP_FILENAME = FILENAME .. ".bak"
local TMP_FILENAME = FILENAME .. ".tmp"
-- Port + original Options menu defaults. Missing keys on load are filled
-- from this table so old options.lua files stay compatible.
@@ -47,153 +63,584 @@ function SaveData.mergeOptions(loaded)
return opts
end
local function serialize(v, indent)
indent = indent or 0
local pad = string.rep(" ", indent)
local t = type(v)
if t == "number" or t == "boolean" then
return tostring(v)
elseif t == "string" then
return string.format("%q", v)
elseif t == "table" then
local keys = {}
for k in pairs(v) do table.insert(keys, k) end
table.sort(keys, function(a, b)
local ta, tb = type(a), type(b)
if ta ~= tb then return ta < tb end
return a < b
end)
if next(v) == nil then return "{}" end
local parts = {}
for _, k in ipairs(keys) do
local key
if type(k) == "string" and k:match("^[%a_][%w_]*$") then
key = k
else
key = "[" .. serialize(k) .. "]"
end
table.insert(parts, pad .. " " .. key .. " = " .. serialize(v[k], indent + 1))
end
return "{\n" .. table.concat(parts, ",\n") .. ",\n" .. pad .. "}"
end
error("cannot serialize " .. t)
end
function SaveData.encode(data)
return "return " .. serialize(data) .. "\n"
return SaveSerializer.encode(data)
end
function SaveData.decode(str)
local loader = loadstring or load
local chunk, err = loader(str, "@save.lua")
if not chunk then return nil, err end
local ok, data = pcall(chunk)
if not ok then return nil, data end
if type(data) ~= "table" then return nil, "save root must be a table" end
return data
return SaveSerializer.decode(str)
end
function SaveData.saveOptions(opts)
local function readTable(fs, name)
if not fs.getInfo(name) then return nil, "no file: " .. name end
local body = fs.read(name)
if type(body) ~= "string" then return nil, "unreadable: " .. name end
return SaveSerializer.decode(body)
end
-- the stub filesystem some headless harnesses inject has no remove; a
-- lingering tmp/bak there is harmless
local function remove(fs, name)
if fs.remove then fs.remove(name) end
end
-- ------- options
-- Both take an optional fs (write/getInfo/read) defaulting to
-- love.filesystem, so the mod loader's injected filesystem can carry the
-- options round-trip headless (no love global).
function SaveData.saveOptions(opts, fs)
fs = fs or love.filesystem
opts = SaveData.mergeOptions(opts)
local ok, err = love.filesystem.write(OPTIONS_FILENAME, SaveData.encode(opts))
-- modOptions is per-mod nested state: fold the on-disk sub-tree
-- underneath (newest value winning per key) so one caller's partial
-- write cannot clobber another mod's persisted keys. Every other
-- option stays on the shallow path.
local onDisk = readTable(fs, OPTIONS_FILENAME)
if onDisk and type(onDisk.modOptions) == "table" then
local merged = {}
for modId, bucket in pairs(onDisk.modOptions) do
merged[modId] = bucket
end
for modId, bucket in pairs(opts.modOptions or {}) do
if type(bucket) == "table" and type(merged[modId]) == "table" then
for k, v in pairs(bucket) do merged[modId][k] = v end
else
merged[modId] = bucket
end
end
opts.modOptions = merged
end
local ok, err = fs.write(OPTIONS_FILENAME, SaveSerializer.encode(opts))
if not ok then
Logger.error("options save failed: %s", tostring(err))
end
return ok and opts or nil
end
function SaveData.loadOptions()
if not love.filesystem.getInfo(OPTIONS_FILENAME) then
return SaveData.defaultOptions()
end
local chunk, err = love.filesystem.load(OPTIONS_FILENAME)
if not chunk then
Logger.error("options load failed: %s", tostring(err))
return SaveData.defaultOptions()
end
local ok, data = pcall(chunk)
if not ok or type(data) ~= "table" then
Logger.error("options load failed: %s", tostring(data))
function SaveData.loadOptions(fs)
fs = fs or love.filesystem
local data, err = readTable(fs, OPTIONS_FILENAME)
if not data then
if fs.getInfo(OPTIONS_FILENAME) then
Logger.error("options load failed: %s", tostring(err))
end
return SaveData.defaultOptions()
end
return SaveData.mergeOptions(data)
end
-- Game progress only; options are written separately via saveOptions.
-- If `data.options` is present it is also flushed to options.lua so an
-- F1 / in-game save keeps the live settings in sync, then stripped from
-- the game file.
function SaveData.save(data)
if data.options then
SaveData.saveOptions(data.options)
end
local gameOnly = {}
for k, v in pairs(data) do
if k ~= "options" then gameOnly[k] = v end
end
local ok, err = love.filesystem.write(FILENAME, SaveData.encode(gameOnly))
if ok then
Logger.info("saved game")
-- ------- meta
-- the version/engine/mod-set stamp every v2 save carries; mods is the
-- loaded list sorted by id and is the ground truth for the load-time
-- mod-set diff. A nil mods list keeps the previous stamp's set so a
-- headless writer (the save editor) never wipes it.
function SaveData.buildMeta(mods, previous)
local list
if mods ~= nil then
list = {}
for _, mod in ipairs(mods) do
list[#list + 1] = { id = mod.id, version = mod.version, api = mod.api }
end
table.sort(list, function(a, b) return a.id < b.id end)
else
Logger.error("save failed: %s", tostring(err))
list = (type(previous) == "table" and previous.mods) or {}
end
return ok
return {
format = Version.saveFormat,
engine = Version.engine,
savedAt = os.time(),
mods = list,
}
end
function SaveData.load()
if not love.filesystem.getInfo(FILENAME) then
return nil
-- {added, removed, changed} between the set that wrote the save
-- (meta.mods) and the active loaded set; all three empty on a vanilla
-- load under vanilla
function SaveData.modsDiff(save, activeMods)
local stored = {}
for _, entry in ipairs((save.meta and save.meta.mods) or {}) do
if type(entry) == "table" and entry.id then
stored[entry.id] = entry.version or ""
end
end
local chunk, err = love.filesystem.load(FILENAME)
if not chunk then
Logger.error("load failed: %s", tostring(err))
return nil
local diff = { added = {}, removed = {}, changed = {} }
for _, mod in ipairs(activeMods or {}) do
local was = stored[mod.id]
if was == nil then
diff.added[#diff.added + 1] = mod.id
elseif was ~= mod.version then
diff.changed[#diff.changed + 1] = { id = mod.id, from = was, to = mod.version }
end
stored[mod.id] = nil
end
local ok, data = pcall(chunk)
if not ok then
Logger.error("load failed: %s", tostring(data))
return nil
for id in pairs(stored) do diff.removed[#diff.removed + 1] = id end
table.sort(diff.added)
table.sort(diff.removed)
table.sort(diff.changed, function(a, b) return a.id < b.id end)
return diff
end
-- one-line load notice for a non-empty diff ("This save was made with
-- 2 mods; 1 is no longer active"); nil when empty so a vanilla load
-- stays silent
function SaveData.modsDiffNotice(diff, meta)
if type(diff) ~= "table" then return nil end
local removed = #(diff.removed or {})
local changed = #(diff.changed or {})
local added = #(diff.added or {})
if removed == 0 and changed == 0 and added == 0 then return nil end
local wrote = #((type(meta) == "table" and meta.mods) or {})
local parts = {}
if removed > 0 then
parts[#parts + 1] = removed .. (removed == 1 and " is" or " are") .. " no longer active"
end
-- saves from before the trainer ID existed: backfill once on load
-- (like the OT backfill for old saves)
if data.player and not data.player.id then
data.player.id = math.random(0, 65535)
if changed > 0 then
parts[#parts + 1] = changed .. " changed version"
end
-- saves from before EVENT_BEAT_ROUTE12/16_SNORLAX existed: the object
-- was already hidden (Snorlax beaten) but the flag was never added,
-- and it can never be set again since the hidden object is
-- unreachable -- backfill it from the toggle so it isn't stuck forever
if data.objectToggles and data.flags then
if added > 0 then
parts[#parts + 1] = added .. " newly active"
end
return ("This save was made with %d mod%s; %s"):format(
wrote, wrote == 1 and "" or "s", table.concat(parts, ", "))
end
-- ------- migrations
-- Ordered engine steps keyed on meta.format, each reproducing the inline
-- migration it replaced; a save already at the current format skips them
-- all. Mod chains (recorded by Loader from mod.migrations:add) replay
-- against the version stored in meta.mods, in semver order, before the
-- validation pass -- so a mod repairs its own data instead of watching
-- it get quarantined.
local coreMigrations = {}
function SaveData.addCoreMigration(fromFormat, fn)
coreMigrations[#coreMigrations + 1] =
{ from = fromFormat, seq = #coreMigrations + 1, fn = fn }
end
local function storedVersion(save, modId)
for _, entry in ipairs((save.meta and save.meta.mods) or {}) do
if type(entry) == "table" and entry.id == modId then
return entry.version
end
end
return nil
end
local function semverLt(a, b)
local order = Semver.compare(a, b)
return order ~= nil and order < 0
end
function SaveData.runMigrations(save, modChains, activeMods)
table.sort(coreMigrations, function(a, b)
if a.from ~= b.from then return a.from < b.from end
return a.seq < b.seq
end)
-- every step whose from-format the save has not passed yet runs, in
-- (from, registration) order; a save at the current format runs none
local fmt = (save.meta and save.meta.format) or 1
for _, m in ipairs(coreMigrations) do
if m.from >= fmt then m.fn(save) end
end
-- a save that predates meta records an empty mod set: an old vanilla
-- save becomes a v2 vanilla save
save.meta = save.meta or { mods = {} }
save.meta.format = Version.saveFormat
for _, active in ipairs(activeMods or {}) do
local modSave = save.modData and save.modData[active.id]
local recorded = modChains and modChains[active.id]
if modSave and recorded then
local chain = {}
for _, m in ipairs(recorded) do chain[#chain + 1] = m end
table.sort(chain, function(a, b) return semverLt(a.since, b.since) end)
local stored = storedVersion(save, active.id) or "0.0.0"
for _, m in ipairs(chain) do
if semverLt(stored, m.since) and not semverLt(active.version, m.since) then
-- a throwing migration is skipped, not fatal: it would otherwise
-- re-raise on every load and lock the player out of the save
local ok, err = pcall(m.apply, modSave, save)
if not ok then
Logger.error("[%s] migration %s: %s -- skipped",
active.id, tostring(m.since), tostring(err))
break
end
end
end
end
end
return save
end
-- saves from before the trainer ID existed: backfill once on load
-- (like the OT backfill for old saves)
SaveData.addCoreMigration(1, function(save)
if save.player and not save.player.id then
save.player.id = math.random(0, 65535)
end
end)
-- saves from before EVENT_BEAT_ROUTE12/16_SNORLAX existed: the object
-- was already hidden (Snorlax beaten) but the flag was never added,
-- and it can never be set again since the hidden object is
-- unreachable -- backfill it from the toggle so it isn't stuck forever
SaveData.addCoreMigration(1, function(save)
if save.objectToggles and save.flags then
local snorlaxRoutes = {
{ map = "ROUTE_12", obj = "ROUTE12_SNORLAX", flag = "EVENT_BEAT_ROUTE12_SNORLAX" },
{ map = "ROUTE_16", obj = "ROUTE16_SNORLAX", flag = "EVENT_BEAT_ROUTE16_SNORLAX" },
}
for _, r in ipairs(snorlaxRoutes) do
local toggles = data.objectToggles[r.map]
if toggles and toggles[r.obj] == false and not data.flags[r.flag] then
data.flags[r.flag] = true
local toggles = save.objectToggles[r.map]
if toggles and toggles[r.obj] == false and not save.flags[r.flag] then
save.flags[r.flag] = true
end
end
end
-- Migrate options that still live inside an old save.lua into the
-- standalone options file (once), then always prefer options.lua.
if type(data.options) == "table" and not love.filesystem.getInfo(OPTIONS_FILENAME) then
end)
-- Migrate options that still live inside an old save.lua into the
-- standalone options file (once); load always re-attaches options.lua
-- afterwards either way
SaveData.addCoreMigration(1, function(save)
if type(save.options) == "table"
and not love.filesystem.getInfo(OPTIONS_FILENAME) then
SaveData.saveOptions(save.options)
end
end)
-- settle the box shape (single `box` list -> 12 boxes) before the
-- validation pass walks it; Boxes keeps the lazy ensure for play paths
SaveData.addCoreMigration(1, function(save)
Boxes.ensure(save)
end)
-- ------- write
-- Game progress only; options are written separately via saveOptions.
-- If `data.options` is present it is also flushed to options.lua so an
-- F1 / in-game save keeps the live settings in sync, then stripped from
-- the game file. mods (when given) refreshes the meta stamp; the write
-- itself rolls the last good save into .bak and stages the new bytes as
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
function SaveData.save(data, mods)
if data.options then
SaveData.saveOptions(data.options)
end
data.options = SaveData.loadOptions()
Logger.info("loaded save")
return data
if mods ~= nil or data.meta == nil then
data.meta = SaveData.buildMeta(mods, data.meta)
end
local gameOnly = {}
for k, v in pairs(data) do
if k ~= "options" then gameOnly[k] = v end
end
local encoded = SaveSerializer.encode(gameOnly)
local fs = love.filesystem
if fs.getInfo(FILENAME) then
local prev = fs.read(FILENAME)
if prev then fs.write(BACKUP_FILENAME, prev) end
end
local ok, err = fs.write(TMP_FILENAME, encoded)
if not ok then
Logger.error("save failed: %s", tostring(err))
return false
end
-- love.filesystem has no atomic rename: remove + rewrite, with the
-- .tmp copy as the recovery witness in between
remove(fs, FILENAME)
ok, err = fs.write(FILENAME, encoded)
if not ok then
Logger.error("save failed: %s", tostring(err))
return false
end
remove(fs, TMP_FILENAME)
Logger.info("saved game")
return true
end
function SaveData.newGame()
return {
-- ------- read
-- returns the parsed save plus "tmp"/"bak" when the main file was gone
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
-- recovery on the load report
function SaveData.load()
local fs = love.filesystem
local data, err = readTable(fs, FILENAME)
local recovered
if not data then
local tmp = readTable(fs, TMP_FILENAME)
if tmp then
data, recovered = tmp, "tmp"
else
local bak = readTable(fs, BACKUP_FILENAME)
if bak then data, recovered = bak, "bak" end
end
if data then
Logger.warn("save.lua %s; recovered from %s copy",
fs.getInfo(FILENAME) and "corrupt" or "missing", recovered)
fs.write(FILENAME, SaveSerializer.encode(data))
end
end
if not data then
if fs.getInfo(FILENAME) then
Logger.error("load failed: %s", tostring(err))
end
return nil
end
SaveData.runMigrations(data)
data.options = SaveData.loadOptions()
Logger.info("loaded save")
return data, recovered
end
-- ------- validation and quarantine
local function known(tbl, id)
return id ~= nil and type(tbl) == "table" and tbl[id] ~= nil
end
-- only out-of-range values move; a vanilla save passes through untouched
local function clamp(n, lo, hi, fallback)
if type(n) ~= "number" then return fallback end
if n < lo then return lo end
if n > hi then return hi end
return n
end
local function ensureOrphaned(save)
if not save.orphaned then
save.orphaned = { mons = {}, items = {} }
end
save.orphaned.mons = save.orphaned.mons or {}
save.orphaned.items = save.orphaned.items or {}
return save.orphaned
end
-- quarantined ids whose content reappeared (mod re-enabled) go home
-- again: mons through the PC deposit, items through the bag with the PC
-- as overflow
local function reclaim(save, data, report)
local orphaned = save.orphaned
if not orphaned then return end
for i = #(orphaned.mons or {}), 1, -1 do
local mon = orphaned.mons[i]
if type(mon) == "table" and known(data.pokemon, mon.species) then
table.remove(orphaned.mons, i)
local box = Boxes.deposit(save, mon)
if box then
report.restoredMons[#report.restoredMons + 1] =
{ species = mon.species, box = box }
else
-- every box full: stays quarantined rather than vanishing
table.insert(orphaned.mons, i, mon)
end
end
end
for i = #(orphaned.items or {}), 1, -1 do
local entry = orphaned.items[i]
if type(entry) == "table" and known(data.items, entry.id) then
table.remove(orphaned.items, i)
if entry.from == "pcItems" or type(save.inventory) ~= "table"
or not Bag.add(save, entry.id, entry.count or 1) then
save.pcItems = save.pcItems or {}
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
end
report.restoredItems[#report.restoredItems + 1] =
{ id = entry.id, count = entry.count or 1 }
end
end
end
-- mirrors Protocol.unpackMon's clamp discipline for the fields play
-- indexes; the level floor widens to 1 because a freshly caught level-1
-- mon can legitimately sit in a save
local function scrubKnownMon(mon, data)
if type(mon.dvs) == "table" then
for stat, v in pairs(mon.dvs) do mon.dvs[stat] = clamp(v, 0, 15, 0) end
end
if type(mon.statExp) == "table" then
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
end
mon.level = clamp(mon.level, 1, 100, 1)
local moves = mon.moves
if type(moves) ~= "table" then return end
local hadMoves = #moves > 0
for j = #moves, 1, -1 do
local slot = moves[j]
local id = type(slot) == "table" and slot.id or slot
if not known(data.moves, id) then table.remove(moves, j) end
end
while #moves > 4 do table.remove(moves) end
if hadMoves and #moves == 0 then
-- data-driven repair so a total conversion without TACKLE still heals
local fallback = (data.constants and data.constants.fallbackMove) or "TACKLE"
local def = data.moves and data.moves[fallback]
if def then
moves[1] = { id = fallback, pp = def.pp }
end
end
end
local function scrubMonList(list, where, save, data, report)
if type(list) ~= "table" then return end
for i = #list, 1, -1 do
local mon = list[i]
if type(mon) ~= "table" or not known(data.pokemon, mon.species) then
table.remove(list, i)
ensureOrphaned(save)
save.orphaned.mons[#save.orphaned.mons + 1] = mon
report.lostMons[#report.lostMons + 1] =
{ species = type(mon) == "table" and mon.species or nil, from = where }
else
scrubKnownMon(mon, data)
end
end
end
local function scrubItemMap(map, where, save, data, report)
if type(map) ~= "table" then return end
for id, count in pairs(map) do
if not known(data.items, id) then
map[id] = nil
ensureOrphaned(save)
save.orphaned.items[#save.orphaned.items + 1] =
{ id = id, count = count, from = where }
report.lostItems[#report.lostItems + 1] =
{ id = id, count = count, from = where }
end
end
end
local function scrubMaps(save, data, report)
local boot = (data.field and data.field.boot) or {}
local spawn = { map = boot.startMap or "PALLET_TOWN",
x = boot.startX or 5, y = boot.startY or 6 }
-- heal point first, so the player fallback below always lands somewhere
-- valid; boot's heal cell (threaded from field.boot) is the last resort
if save.lastHeal and not known(data.maps, save.lastHeal.map) then
local heal = boot.lastHeal or spawn
report.remappedMaps[#report.remappedMaps + 1] =
{ id = save.lastHeal.map, to = heal.map, field = "lastHeal" }
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
end
if save.player and not known(data.maps, save.player.map) then
local heal = save.lastHeal or spawn
report.remappedMaps[#report.remappedMaps + 1] =
{ id = save.player.map, to = heal.map, field = "player" }
save.player.map, save.player.x, save.player.y = heal.map, heal.x, heal.y
end
if save.lastOutdoor and not known(data.maps, save.lastOutdoor.id) then
report.remappedMaps[#report.remappedMaps + 1] =
{ id = save.lastOutdoor.id, field = "lastOutdoor" }
save.lastOutdoor = nil
end
if save.lastHeal and type(save.lastHeal.outdoor) == "table"
and not known(data.maps, save.lastHeal.outdoor.id) then
save.lastHeal.outdoor = nil
end
end
-- Walks every content id the save references against the merged data and
-- quarantines unknowns instead of letting them nil-index later: mons move
-- to save.orphaned (the LOST box), items are removed with a report row,
-- locations fall back to the heal point. Reclaims quarantined content
-- whose id reappeared first. On a mod-free save every membership test
-- passes and the save comes back untouched.
function SaveData.validate(save, data)
local report = { lostMons = {}, lostItems = {}, remappedMaps = {},
restoredMons = {}, restoredItems = {} }
reclaim(save, data, report)
scrubMonList(save.party, "party", save, data, report)
for b, box in ipairs(save.boxes or {}) do
scrubMonList(box, "box " .. b, save, data, report)
end
local daycare = save.daycare
if type(daycare) == "table" and type(daycare.mon) == "table" then
if not known(data.pokemon, daycare.mon.species) then
ensureOrphaned(save)
save.orphaned.mons[#save.orphaned.mons + 1] = daycare.mon
report.lostMons[#report.lostMons + 1] =
{ species = daycare.mon.species, from = "daycare" }
daycare.mon = nil
else
scrubKnownMon(daycare.mon, data)
end
end
scrubItemMap(save.inventory, "inventory", save, data, report)
scrubItemMap(save.pcItems, "pcItems", save, data, report)
if type(save.bagOrder) == "table" then
for i = #save.bagOrder, 1, -1 do
if not known(data.items, save.bagOrder[i]) then
table.remove(save.bagOrder, i)
end
end
end
scrubMaps(save, data, report)
local dex = save.pokedex
if type(dex) == "table" then
for _, key in ipairs({ "seen", "owned" }) do
if type(dex[key]) == "table" then
for id in pairs(dex[key]) do
if not known(data.pokemon, id) then dex[key][id] = nil end
end
end
end
end
-- hall of fame rosters keep their shape: an unknown species blanks in
-- place so the team stays the size it won at, with the rest of the mon
-- (level etc.) intact for display
for _, entry in ipairs(save.hallOfFame or {}) do
if type(entry) == "table" then
for i = 1, #entry do
local mon = entry[i]
if type(mon) == "table" and mon.species ~= nil
and not known(data.pokemon, mon.species) then
mon.species = nil
end
end
end
end
-- an empty quarantine leaves no residue, so a vanilla save re-encodes
-- byte-identically
local orphaned = save.orphaned
if orphaned and #(orphaned.mons or {}) == 0 and #(orphaned.items or {}) == 0 then
save.orphaned = nil
end
return report
end
function SaveData.emptyReport(report)
-- a bare validate report (the save editor's probe) carries no modsDiff;
-- restoreSave attaches one so a version bump alone still surfaces
local diff = report.modsDiff
return #report.lostMons == 0 and #report.lostItems == 0
and #report.remappedMaps == 0 and #report.restoredMons == 0
and #report.restoredItems == 0 and not report.recovered
and (not diff or (#diff.added == 0 and #diff.removed == 0 and #diff.changed == 0))
end
-- ------- new game
-- boot is Data.field.boot, threaded in by Game: this module must not reach
-- into Data itself. Every read falls back to the Red literal it replaced,
-- so an absent or partial config still produces the vanilla new game.
function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "PALLET_TOWN"
local x, y = boot.startX or 5, boot.startY or 6
local heal = boot.lastHeal or {}
local save = {
meta = { format = Version.saveFormat, mods = {} },
player = {
map = "PALLET_TOWN",
x = 5,
y = 6,
facing = "down",
name = "RED",
rival = "BLUE",
map = map,
x = x,
y = y,
facing = boot.startFacing or "down",
name = boot.playerName or "RED",
rival = boot.rivalName or "BLUE",
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from
-- hRandomAdd in OakSpeech)
id = math.random(0, 65535),
@@ -202,16 +649,22 @@ function SaveData.newGame()
inventory = {},
party = {},
box = {},
money = 3000,
money = boot.startMoney or 3000,
defeatedTrainers = {},
pokedex = { seen = {}, owned = {} },
-- where blackouts and ESCAPE ROPE return to (updated by nurses)
lastHeal = { map = "PALLET_TOWN", x = 5, y = 6 },
-- where blackouts and ESCAPE ROPE return to (updated by nurses);
-- copied, never aliased, so a save never writes back into Data
lastHeal = { map = heal.map or map, x = heal.x or x, y = heal.y or y },
repelSteps = 0,
-- per-mod persistence (mod.save) lives under here, keyed by mod id
modData = {},
-- Live options from options.lua (or defaults); New Game keeps the
-- player's audio/display/battle preferences.
options = SaveData.loadOptions(),
}
-- a total conversion reshapes the skeleton (spawn, party, money)
-- before anything reads it; unhooked this returns save unchanged
return Runtime.call("save.new_game", function(s) return s end, save)
end
return SaveData
+219
View File
@@ -0,0 +1,219 @@
-- Save-file serialization: the deterministic Lua-source writer (moved
-- verbatim from SaveData so output stays byte-identical) and a
-- restricted-grammar reader that replaces load() on save bytes. The
-- writer is the grammar's specification -- literals, %q strings and keyed
-- tables only -- so a hand-tampered or malicious save fails to parse
-- instead of executing.
local SaveSerializer = {}
-- ------- writer
local function serialize(v, indent)
indent = indent or 0
local pad = string.rep(" ", indent)
local t = type(v)
if t == "number" or t == "boolean" then
return tostring(v)
elseif t == "string" then
return string.format("%q", v)
elseif t == "table" then
local keys = {}
for k in pairs(v) do table.insert(keys, k) end
table.sort(keys, function(a, b)
local ta, tb = type(a), type(b)
if ta ~= tb then return ta < tb end
return a < b
end)
if next(v) == nil then return "{}" end
local parts = {}
for _, k in ipairs(keys) do
local key
if type(k) == "string" and k:match("^[%a_][%w_]*$") then
key = k
else
key = "[" .. serialize(k) .. "]"
end
table.insert(parts, pad .. " " .. key .. " = " .. serialize(v[k], indent + 1))
end
return "{\n" .. table.concat(parts, ",\n") .. ",\n" .. pad .. "}"
end
error("cannot serialize " .. t)
end
function SaveSerializer.encode(data)
return "return " .. serialize(data) .. "\n"
end
-- ------- reader
-- letter escapes %q has emitted across the Lua 5.x family; LuaJIT writes
-- control characters as \ddd decimal escapes, handled separately below
local ESCAPES = {
['"'] = '"', ["\\"] = "\\", ["n"] = "\n", ["r"] = "\r", ["t"] = "\t",
["a"] = "\a", ["b"] = "\b", ["f"] = "\f", ["v"] = "\v",
["\n"] = "\n", ["\r"] = "\n",
}
-- recursion cap: a crafted file nesting thousands of braces must fail
-- closed, not blow the interpreter stack
local MAX_DEPTH = 128
local function fail(state, why)
error(("parse error at byte %d: %s"):format(state.pos, why), 0)
end
local function skip(state)
local _, last = state.src:find("^[ \t\r\n]*", state.pos)
state.pos = last + 1
end
local function peek(state)
return state.src:sub(state.pos, state.pos)
end
local function readString(state)
local src = state.src
local out = {}
local i = state.pos + 1
while true do
local c = src:sub(i, i)
if c == "" then
state.pos = i
fail(state, "unterminated string")
elseif c == '"' then
state.pos = i + 1
return table.concat(out)
elseif c == "\\" then
local nxt = src:sub(i + 1, i + 1)
if nxt:match("%d") then
local digits = src:match("^%d%d?%d?", i + 1)
local code = tonumber(digits)
if code > 255 then
state.pos = i
fail(state, "escape out of range")
end
out[#out + 1] = string.char(code)
i = i + 1 + #digits
elseif ESCAPES[nxt] then
out[#out + 1] = ESCAPES[nxt]
i = i + 2
else
state.pos = i
fail(state, "bad string escape")
end
else
out[#out + 1] = c
i = i + 1
end
end
end
-- a number runs to the next delimiter; tonumber is the judge of what the
-- writer's tostring could have produced ("0.1", "-2", "1e+300")
local function readNumber(state)
local token = state.src:match("^[^,%]}%s]+", state.pos)
local value = token and tonumber(token)
if value == nil then fail(state, "malformed number") end
state.pos = state.pos + #token
return value
end
local function readIdent(state)
local ident = state.src:match("^[%a_][%w_]*", state.pos)
if not ident then fail(state, "expected name") end
state.pos = state.pos + #ident
return ident
end
local readValue
local function readTable(state)
state.depth = state.depth + 1
if state.depth > MAX_DEPTH then fail(state, "table nesting too deep") end
state.pos = state.pos + 1
local out = {}
skip(state)
if peek(state) == "}" then
state.pos = state.pos + 1
state.depth = state.depth - 1
return out
end
while true do
skip(state)
local key
local c = peek(state)
if c == "[" then
state.pos = state.pos + 1
key = readValue(state)
skip(state)
if peek(state) ~= "]" then fail(state, "expected ]") end
state.pos = state.pos + 1
elseif c:match("[%a_]") then
key = readIdent(state)
else
fail(state, "expected key")
end
skip(state)
if peek(state) ~= "=" then fail(state, "expected =") end
state.pos = state.pos + 1
out[key] = readValue(state)
skip(state)
local sep = peek(state)
if sep == "," then
state.pos = state.pos + 1
skip(state)
if peek(state) == "}" then
state.pos = state.pos + 1
break
end
elseif sep == "}" then
state.pos = state.pos + 1
break
else
fail(state, "expected , or }")
end
end
state.depth = state.depth - 1
return out
end
readValue = function(state)
skip(state)
local c = peek(state)
if c == '"' then
return readString(state)
elseif c == "{" then
return readTable(state)
elseif c:match("[%a_]") then
-- the only bare words in the grammar are the boolean literals
local word = readIdent(state)
if word == "true" then return true end
if word == "false" then return false end
state.pos = state.pos - #word
fail(state, "unexpected name '" .. word .. "'")
elseif c:match("[%-%d%.]") then
return readNumber(state)
end
fail(state, c == "" and "unexpected end of input" or "unexpected character")
end
function SaveSerializer.decode(str)
if type(str) ~= "string" then return nil, "save must be a string" end
local state = { src = str, pos = 1, depth = 0 }
local ok, result = pcall(function()
skip(state)
local word = state.src:match("^[%a_][%w_]*", state.pos)
if word ~= "return" then fail(state, "expected return") end
state.pos = state.pos + #word
local value = readValue(state)
skip(state)
if state.pos <= #state.src then fail(state, "trailing content") end
return value
end)
if not ok then return nil, result end
if type(result) ~= "table" then return nil, "save root must be a table" end
return result
end
return SaveSerializer
+191 -57
View File
@@ -1,11 +1,17 @@
-- Sound effects and cries synthesized from compact ROM channel programs or
-- loaded from legacy static audio definitions. Sources are cached; headless
-- Sound effects and cries synthesized from compact ROM channel programs,
-- from def-local chip programs (ChipAsm), or loaded from file definitions --
-- the branch is chosen per definition, not by a global import flag. Sources
-- are cached; a definition that fails to load caches as `false` so it is
-- logged once and skipped, never disabling the rest of the audio. Headless
-- use is a safe no-op.
local Assets = require("src.render.Assets")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Sound = {}
local cache = {}
local enabled = true
-- port addition: 0-7 SFX volume from save.options.sfxVol (OptionsMenu),
-- scaling the 0.8 base every source gets
local BASE_VOLUME = 0.8
@@ -19,6 +25,9 @@ local volumeScale = 1
-- (engine/items/item_effects.asm). Music.lua pauses the current song
-- while one of these plays and resumes it afterwards. Ordinary short
-- SFX (menu beeps, hits, cries) stay overlaid.
-- data.audio.fanfares supersedes this; the copy stays as the fallback for
-- caches built before the importer wrote the table, and a def may claim the
-- behavior for itself with fanfare = true.
local FANFARES = {
Level_Up = true,
Caught_Mon = true,
@@ -30,20 +39,56 @@ local FANFARES = {
Pokeflute = true,
}
local function playPath(data, key, path, pitch, tempo)
if not enabled or not love.audio or not path then return nil end
-- which mod put this key in the registry, for attributed failure logs
local function owner(data, kind, key)
local owners = data and data.audio and data.audio._owners
local map = owners and owners[kind]
return map and map[key] or "base"
end
-- one log line, plus an entry in the loader's error feed when a mod owns the
-- def, so the manager's errors screen can flag that mod
local function reportBadDef(kind, key, who, err)
Logger.warn("audio: bad %s def %q (mod %s): %s", kind, key, who, tostring(err))
Runtime.reportError(who,
("audio: bad %s def %q: %s"):format(kind, key, tostring(err)))
end
local function isChipDef(def)
return type(def) == "table" and (def.chip ~= nil or def.address ~= nil)
end
-- a file def carries an optional playback rate; a bare string is shorthand
-- for { file = <string> }
local function newFileSource(def)
local file = type(def) == "table" and def.file or def
if type(file) ~= "string" then return nil, "no chip program and no file" end
local ok, s = pcall(love.audio.newSource, file, "static")
if not ok or not s then return nil, ok and "no source" or tostring(s) end
if type(def) == "table" and def.pitch then pcall(s.setPitch, s, def.pitch) end
return s
end
local function newSfxSource(data, key, def, pitch, tempo)
if isChipDef(def) then
local ok, s = pcall(require("src.core.ChipAudio").newSfx,
data, key:match("^([^@]+)") or key, pitch, tempo, def)
if not ok then return nil, tostring(s) end
if not s then return nil, "no source" end
return s
end
return newFileSource(def)
end
local function playPath(data, key, def, pitch, tempo)
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 ok, s
if data.audio and data.audio.runtime and type(path) == "table" then
ok, s = pcall(
require("src.core.ChipAudio").newSfx,
data, key:match("^([^@]+)") or key, pitch, tempo, path)
else
ok, s = pcall(love.audio.newSource, path, "static")
end
if not ok or not s then
enabled = false
local s, err = newSfxSource(data, key, def, pitch, tempo)
if not s then
cache[key] = false
reportBadDef("sfx", key, owner(data, "sfx", key), err)
return nil
end
s:setVolume(BASE_VOLUME * volumeScale)
@@ -55,12 +100,26 @@ local function playPath(data, key, path, pitch, tempo)
return src
end
local function ducks(data, name, def)
if type(def) == "table" and def.fanfare then return true end
local fanfares = data.audio and data.audio.fanfares or FANFARES
return fanfares[name] and true or false
end
local function played(kind, name, species)
if not Runtime.wants("sound.played") then return end
Runtime.emit("sound.played", { kind = kind, name = name, species = species })
end
function Sound.play(data, name)
local sfx = data.audio and data.audio.sfx
local src = playPath(data, name, sfx and sfx[name])
if src and FANFARES[name] then
local def = sfx and sfx[name]
local src = playPath(data, name, def)
if not src then return end
if ducks(data, name, def) then
require("src.core.Music").duckForFanfare(src)
end
played("sfx", name)
end
-- Play a move's sound with its MoveSoundTable pitch/tempo modifiers
@@ -78,42 +137,87 @@ function Sound.playMove(data, anim)
if not sfx then return end
local name = anim.sound
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
if data.audio.runtime and sfx[name] then
playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
sfx[name], pitch, tempo)
-- a chip program synthesizes the modified variant on demand; a file def
-- can only reach for a pre-rendered one
if isChipDef(sfx[name]) then
if playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
sfx[name], pitch, tempo) then
played("move", name)
end
return
end
if pitch ~= 0 or tempo ~= 0x80 then
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
if sfx[key] then
playPath(data, key, sfx[key])
if playPath(data, key, sfx[key]) then played("move", name) end
return
end
end
playPath(data, name, sfx[name])
if playPath(data, name, sfx[name]) then played("move", name) end
end
function Sound.playCry(data, species)
-- A derived cry ({ base = "RHYDON", pitch, length }) borrows another
-- species' program and applies its own modifiers, so a new species needs no
-- assets at all. Chains are followed; the modifiers nearest the caller win.
local function resolveCry(data, def, depth)
if type(def) ~= "table" or not def.base then return def end
if depth > 8 then return nil, "cry base chain too deep" end
local cries = data.audio and data.audio.cries
-- returns the source (nil headless) so callers that block on the cry
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
local definition = cries and cries[species]
if data.audio and data.audio.runtime and definition then
local key = "cry:" .. tostring(species)
local src = cache[key]
if not src then
local ok, generated = pcall(
require("src.core.ChipAudio").newCry, data, species)
if not ok or not generated then return nil end
generated:setVolume(BASE_VOLUME * volumeScale)
cache[key] = generated
src = generated
end
src:stop()
src:play()
return src
local baseDef = cries and cries[def.base]
if not baseDef then
return nil, "unknown base cry " .. tostring(def.base)
end
return playPath(data, "cry:" .. tostring(species), definition)
local resolved, err = resolveCry(data, baseDef, depth + 1)
if not resolved then return nil, err end
if type(resolved) ~= "table" or not (resolved.header or resolved.chip) then
return nil, "base cry " .. tostring(def.base) .. " is not a chip program"
end
return {
header = resolved.header, chip = resolved.chip,
pitch = def.pitch or resolved.pitch,
length = def.length or resolved.length,
}
end
local function newCrySource(data, species, def)
local resolved, err = resolveCry(data, def, 0)
if not resolved then return nil, err end
if type(resolved) == "table" and (resolved.header or resolved.chip) then
local ok, s = pcall(
require("src.core.ChipAudio").newCry, data, species, resolved)
if not ok then return nil, tostring(s) end
if not s then return nil, "no source" end
return s
end
return newFileSource(resolved)
end
-- returns the source (nil headless) so callers that block on the cry
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
function Sound.playCry(data, species)
if not love.audio then return nil end
local cries = data.audio and data.audio.cries
local def = cries and cries[species]
if not def then return nil end
local key = "cry:" .. tostring(species)
local src = cache[key]
if src == false then return nil end
if not src then
local s, err = newCrySource(data, species, def)
if not s then
cache[key] = false
reportBadDef("cry", tostring(species),
owner(data, "cries", species), err)
return nil
end
s:setVolume(BASE_VOLUME * volumeScale)
cache[key] = s
src = s
end
src:stop()
src:play()
played("cry", species, species)
return src
end
-- GROWL/ROAR are the only two moves that play a cry (IsCryMove checks
@@ -159,25 +263,28 @@ local looping = {}
function Sound.startLoop(data, name)
if looping[name] then return end
if not love.audio then return end
local sfx = data.audio and data.audio.sfx
local path = sfx and sfx[name]
local runtimeAlarm = data.audio and data.audio.runtime
and name == "Low_Health_Alarm"
if not enabled or not love.audio or (not path and not runtimeAlarm) then
return
end
local def = sfx and sfx[name]
local alarm = not def and name == "Low_Health_Alarm"
if not def and not alarm then return end
local src = loopCache[name]
if src == false then return end
if not src then
local ok, s
if data.audio.runtime and name == "Low_Health_Alarm" then
ok, s = pcall(require("src.core.ChipAudio").newLowHealthAlarm)
elseif data.audio.runtime and type(path) == "table" then
ok, s = pcall(
require("src.core.ChipAudio").newSfx, data, name)
local s, err
if alarm then
-- the synthesized siren is the default, not the rule: a registered
-- Low_Health_Alarm def of any shape replaces it
local ok, generated = pcall(require("src.core.ChipAudio").newLowHealthAlarm)
if ok then s = generated else err = tostring(generated) end
else
ok, s = pcall(love.audio.newSource, path, "static")
s, err = newSfxSource(data, name, def)
end
if not s then
loopCache[name] = false
reportBadDef("sfx", name, owner(data, "sfx", name), err or "no source")
return
end
if not ok then return end
s:setLooping(true)
s:setVolume(BASE_VOLUME * volumeScale)
loopCache[name] = s
@@ -206,13 +313,40 @@ end
function Sound.setVolumeLevel(level)
volumeScale = math.max(0, math.min(7, level or 7)) / 7
for _, src in pairs(cache) do
pcall(src.setVolume, src, BASE_VOLUME * volumeScale)
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
end
for _, src in pairs(loopCache) do
pcall(src.setVolume, src, BASE_VOLUME * volumeScale)
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
end
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)
local function evict(store, key)
local src = store[key]
if src then pcall(src.stop, src) end
store[key] = nil
end
for _, store in ipairs({ cache, loopCache }) do
for key in pairs(store) do
if not name or key == name or key:sub(1, #name + 1) == name .. "@" then
evict(store, key)
end
end
end
for key, src in pairs(looping) do
if not name or key == name then
pcall(src.stop, src)
looping[key] = nil
end
end
end
-- the flush fan-out calls with no key, dropping everything, so an edited
-- def is re-resolved on the next play (20 §2 cache contract, audio row)
Assets.register(Sound.invalidate)
-- re-apply persisted audio options (Game calls this on boot and after
-- loading a save)
function Sound.applyOptions(opts)
+11
View File
@@ -2,20 +2,31 @@
-- (so a text box can overlay the overworld, a battle replaces it, etc).
-- States are tables with optional enter/exit/update/draw/isOpaque.
local Runtime = require("src.mods.Runtime")
local StateStack = {}
function StateStack:init()
self.states = {}
end
-- screen.pushed/popped fire after enter/exit so listeners observe the
-- settled state; the wants guard keeps the no-listener path allocation-free
function StateStack:push(state, ...)
table.insert(self.states, state)
if state.enter then state:enter(...) end
if Runtime.wants("screen.pushed") then
Runtime.emit("screen.pushed", { state = state })
end
end
function StateStack:pop()
local state = table.remove(self.states)
if state and state.exit then state:exit() end
if state and Runtime.wants("screen.popped") then
Runtime.emit("screen.popped", { state = state })
end
return state
end
+20
View File
@@ -0,0 +1,20 @@
-- Single source of every compatibility-relevant number: engine release,
-- mod API major, link protocol, save format and ROM cache generation. Zero
-- requires so it loads during love.conf and under plain Lua for tools and
-- tests.
local Version = {
engine = "1.0.0", -- game/engine release (semver triple)
modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 2, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
}
-- "Pokemon Red (Gen 1 Recompilation Project) v1.0.0"
function Version.title(base)
return (base or "Pokemon Red (Gen 1 Recompilation Project)")
.. " v" .. Version.engine
end
return Version