mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
big ass modding update
This commit is contained in:
+85
-14
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user