mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
The new experience (#201)
* new launcher and save converts and pipeline * fixing bugs
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
-- Launcher-side mod surface (18/launcher redesign): the mods panel runs
|
||||
-- BEFORE Game:load, so this NEVER loads a mod entry chunk -- it scans
|
||||
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
|
||||
-- load at boot; this reads the same options.mods enable-state the loader
|
||||
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
|
||||
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree.
|
||||
--
|
||||
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
|
||||
-- no filesystem, so the engine tier can table-drive it; the discovery and
|
||||
-- install paths reach for love.filesystem and SaveData.
|
||||
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local ManagerState = require("src.mods.ManagerState")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local LauncherMods = {}
|
||||
|
||||
-- ------- pure status derivation
|
||||
|
||||
-- A hard-dependency / conflict / version verdict for one manifest. mods is
|
||||
-- the id -> validated-manifest map resolveToggle reads (its dependencySpecs,
|
||||
-- conflictSpecs, version and game_version are exactly the fields the loader's
|
||||
-- Manifest.validate produced); enabledSet is the current desired enable-set.
|
||||
local function statusFor(mods, id, enabledSet, enabled)
|
||||
local m = mods[id]
|
||||
-- conflict only bites an enabled mod: resolveToggle's conflict list is
|
||||
-- bidirectional (this mod's conflicts spec vs an enabled other, and an
|
||||
-- enabled other's spec vs this mod), which is exactly the launcher chip.
|
||||
if enabled then
|
||||
local r = ManagerState.resolveToggle(mods, id, true, enabledSet)
|
||||
if #r.conflicts > 0 then
|
||||
local otherId = r.conflicts[1]
|
||||
local other = mods[otherId]
|
||||
return "conflict",
|
||||
"Conflicts with " .. ((other and other.name) or otherId)
|
||||
end
|
||||
end
|
||||
-- warn: the engine is outside the mod's game_version range
|
||||
if m.game_version
|
||||
and not Semver.satisfies(Version.engine, m.game_version) then
|
||||
return "warn", "Needs engine " .. m.game_version
|
||||
.. " (have " .. Version.engine .. ")"
|
||||
end
|
||||
-- warn: a hard dependency is absent, switched off, or the wrong version.
|
||||
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
|
||||
-- it, so the disabled case is judged straight off the manifest here.
|
||||
for _, spec in ipairs(m.dependencySpecs or {}) do
|
||||
local dep = mods[spec.id]
|
||||
if not dep then
|
||||
return "warn", "Needs " .. spec.id .. " (not installed)"
|
||||
elseif not enabledSet[spec.id] then
|
||||
return "warn", "Needs " .. spec.id .. " (disabled)"
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.version, spec.range) then
|
||||
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
||||
end
|
||||
end
|
||||
return "ok", "Ready"
|
||||
end
|
||||
|
||||
-- deriveList(manifests, options) -> the panel row list, pure.
|
||||
-- manifests is an array of validated manifests (Manifest.validate output);
|
||||
-- options is the options table (only options.mods is read). Rows come back
|
||||
-- sorted by id so the panel order is stable.
|
||||
function LauncherMods.deriveList(manifests, options)
|
||||
local mods = options and options.mods or {}
|
||||
local ordered = {}
|
||||
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
|
||||
table.sort(ordered, function(a, b) return a.id < b.id end)
|
||||
|
||||
local byId, enabledSet = {}, {}
|
||||
for _, m in ipairs(ordered) do
|
||||
byId[m.id] = m
|
||||
-- missing entry means enabled, matching the loader's default
|
||||
if mods[m.id] ~= false then enabledSet[m.id] = true end
|
||||
end
|
||||
|
||||
local out = {}
|
||||
for _, m in ipairs(ordered) do
|
||||
local enabled = enabledSet[m.id] == true
|
||||
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
|
||||
local raw = m.raw or {}
|
||||
out[#out + 1] = {
|
||||
id = m.id,
|
||||
name = m.name or m.id,
|
||||
version = m.version,
|
||||
-- category, then profile, then a generic fallback -- uppercased
|
||||
badge = tostring(raw.category or m.profile or "MOD"):upper(),
|
||||
description = m.description or "",
|
||||
enabled = enabled,
|
||||
status = status,
|
||||
statusDetail = detail,
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- locateRoot(paths) -> the mod-root prefix inside a mounted archive, pure.
|
||||
-- paths is a shallow listing: top-level file names as-is, and for a top-level
|
||||
-- directory a "<dir>/manifest.json" entry when it holds one. Returns "" when
|
||||
-- the manifest sits at the archive root, "<dir>" when a single top-level
|
||||
-- folder holds it, or nil + a user-presentable reason.
|
||||
function LauncherMods.locateRoot(paths)
|
||||
for _, p in ipairs(paths) do
|
||||
if p == "manifest.json" then return "" end
|
||||
end
|
||||
local topDirs, seen, hasManifest = {}, {}, {}
|
||||
for _, p in ipairs(paths) do
|
||||
local top, rest = p:match("^([^/]+)/(.+)$")
|
||||
if top then
|
||||
if not seen[top] then
|
||||
seen[top] = true
|
||||
topDirs[#topDirs + 1] = top
|
||||
end
|
||||
if rest == "manifest.json" then hasManifest[top] = true end
|
||||
end
|
||||
end
|
||||
if #topDirs == 1 and hasManifest[topDirs[1]] then return topDirs[1] end
|
||||
if #topDirs > 1 then
|
||||
return nil, "the .zip must contain a single mod folder"
|
||||
end
|
||||
return nil, "no manifest.json found in the .zip"
|
||||
end
|
||||
|
||||
-- ------- discovery (love.filesystem)
|
||||
|
||||
local function decodeManifest(raw, path)
|
||||
local Json = require("src.link.Json")
|
||||
local data, decodeErr = Json.decode(raw)
|
||||
if not data then return nil, decodeErr end
|
||||
local ok, manifest = pcall(Manifest.validate, data, path)
|
||||
if not ok then return nil, manifest end
|
||||
return manifest
|
||||
end
|
||||
|
||||
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
|
||||
-- but validates only -- no entry chunk is ever loaded). First id wins on a
|
||||
-- duplicate. Returns an array of validated manifests.
|
||||
local function discover()
|
||||
local fs = love and love.filesystem
|
||||
local out = {}
|
||||
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
||||
if not fs.getInfo("mods") then return out end
|
||||
local seen = {}
|
||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||
local path = "mods/" .. name
|
||||
local info = fs.getInfo(path)
|
||||
if info and info.type == "directory" then
|
||||
local raw = fs.read(path .. "/manifest.json")
|
||||
if raw then
|
||||
local manifest = decodeManifest(raw, path)
|
||||
if manifest and not seen[manifest.id] then
|
||||
seen[manifest.id] = true
|
||||
out[#out + 1] = manifest
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- list() -> the mods-panel rows for the current install. Reads the same
|
||||
-- options.mods enable-state the loader persists, so a toggle here is what the
|
||||
-- game sees on its next boot.
|
||||
function LauncherMods.list()
|
||||
local options = SaveData.loadOptions()
|
||||
return LauncherMods.deriveList(discover(), options)
|
||||
end
|
||||
|
||||
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
|
||||
-- Loader:_saveState writes (a plain boolean), so the running game and the
|
||||
-- in-game ManagerState pick it up unchanged.
|
||||
function LauncherMods.setEnabled(id, enabled)
|
||||
local options = SaveData.loadOptions()
|
||||
options.mods = options.mods or {}
|
||||
options.mods[id] = enabled and true or false
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- install (love.filesystem)
|
||||
|
||||
-- Read a .zip source into bytes. A string is an external absolute path (like
|
||||
-- a chosen ROM) read with io.*, falling back to a save-dir-relative
|
||||
-- love.filesystem read; a love DroppedFile is opened the way RomImporter
|
||||
-- ingests dropped ROMs.
|
||||
local function readArchive(source)
|
||||
local t = type(source)
|
||||
if (t == "userdata" or t == "table") and type(source.open) == "function" then
|
||||
local ok = source:open("r")
|
||||
if not ok then return nil, "could not open the dropped file" end
|
||||
local data = source:read(source:getSize())
|
||||
source:close()
|
||||
if not data then return nil, "the dropped file could not be read" end
|
||||
return data
|
||||
end
|
||||
if t == "string" then
|
||||
local f = io.open(source, "rb")
|
||||
if f then
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
if not data then return nil, "could not read " .. source end
|
||||
return data
|
||||
end
|
||||
if love and love.filesystem then
|
||||
local data = love.filesystem.read(source)
|
||||
if data then return data end
|
||||
end
|
||||
return nil, "could not open " .. source
|
||||
end
|
||||
return nil, "unsupported archive source"
|
||||
end
|
||||
|
||||
-- Shallow listing of a mounted archive shaped for locateRoot: files by name,
|
||||
-- and for each top-level directory a "<dir>/manifest.json" marker only when it
|
||||
-- actually holds one (so a lone folder with no manifest still reads as empty).
|
||||
local function topLevelPaths(mount)
|
||||
local fs = love.filesystem
|
||||
local paths = {}
|
||||
for _, name in ipairs(fs.getDirectoryItems(mount)) do
|
||||
local info = fs.getInfo(mount .. "/" .. name)
|
||||
if info and info.type == "directory" then
|
||||
if fs.getInfo(mount .. "/" .. name .. "/manifest.json", "file") then
|
||||
paths[#paths + 1] = name .. "/manifest.json"
|
||||
end
|
||||
else
|
||||
paths[#paths + 1] = name
|
||||
end
|
||||
end
|
||||
return paths
|
||||
end
|
||||
|
||||
local function copyTree(src, dst)
|
||||
local fs = love.filesystem
|
||||
if not fs.createDirectory(dst) then
|
||||
return nil, "could not create " .. dst
|
||||
end
|
||||
for _, name in ipairs(fs.getDirectoryItems(src)) do
|
||||
local s = src .. "/" .. name
|
||||
local d = dst .. "/" .. name
|
||||
local info = fs.getInfo(s)
|
||||
if info and info.type == "directory" then
|
||||
local ok, err = copyTree(s, d)
|
||||
if not ok then return nil, err end
|
||||
else
|
||||
local data = fs.read(s)
|
||||
if data == nil then return nil, "could not read " .. name end
|
||||
local ok, err = fs.write(d, data)
|
||||
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function removeTree(path)
|
||||
local fs = love.filesystem
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return end
|
||||
if info.type == "directory" then
|
||||
for _, child in ipairs(fs.getDirectoryItems(path)) do
|
||||
removeTree(path .. "/" .. child)
|
||||
end
|
||||
end
|
||||
fs.remove(path)
|
||||
end
|
||||
|
||||
-- installZip(source) -> true, id | nil, errString
|
||||
-- source is an external path or a love DroppedFile. The archive is validated
|
||||
-- BEFORE anything is copied; every path unmounts and clears the staged temp
|
||||
-- file, and a failed copy rolls its partial tree back. A dropped file outside
|
||||
-- the save dir is staged into a save-dir temp first, because
|
||||
-- love.filesystem.mount only reaches a save-directory-relative path.
|
||||
function LauncherMods.installZip(source)
|
||||
if not (love and love.filesystem) then
|
||||
return nil, "mod install needs LOVE"
|
||||
end
|
||||
local fs = love.filesystem
|
||||
local data, readErr = readArchive(source)
|
||||
if not data then return nil, readErr end
|
||||
|
||||
-- stage into a save-dir temp so mount can reach it
|
||||
local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
|
||||
local ok, writeErr = fs.write(tmp, data)
|
||||
if not ok then
|
||||
return nil, "could not stage the .zip: " .. tostring(writeErr)
|
||||
end
|
||||
local mount = "mod_import_mount"
|
||||
if not fs.mount(tmp, mount) then
|
||||
fs.remove(tmp)
|
||||
return nil, "that .zip could not be opened"
|
||||
end
|
||||
local function cleanup()
|
||||
pcall(fs.unmount, tmp)
|
||||
fs.remove(tmp)
|
||||
end
|
||||
|
||||
local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount))
|
||||
if not prefix then
|
||||
cleanup()
|
||||
return nil, rootErr
|
||||
end
|
||||
local root = prefix == "" and mount or (mount .. "/" .. prefix)
|
||||
|
||||
local raw = fs.read(root .. "/manifest.json")
|
||||
if not raw then
|
||||
cleanup()
|
||||
return nil, "the .zip has no readable manifest.json"
|
||||
end
|
||||
local manifest, manifestErr = decodeManifest(raw, root)
|
||||
if not manifest then
|
||||
cleanup()
|
||||
return nil, "invalid mod manifest: " .. tostring(manifestErr)
|
||||
end
|
||||
|
||||
-- reject a duplicate before touching the mods tree
|
||||
local dest = "mods/" .. manifest.id
|
||||
if fs.getInfo(dest) then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
|
||||
fs.createDirectory("mods")
|
||||
local copied, copyErr = copyTree(root, dest)
|
||||
if not copied then
|
||||
removeTree(dest)
|
||||
cleanup()
|
||||
return nil, copyErr or "could not copy the mod files"
|
||||
end
|
||||
cleanup()
|
||||
return true, manifest.id
|
||||
end
|
||||
|
||||
return LauncherMods
|
||||
@@ -40,6 +40,19 @@ function f.int(min, max)
|
||||
end }
|
||||
end
|
||||
|
||||
-- a bounded float; like f.int but keeps the fractional part (scales,
|
||||
-- gains). Rejects out-of-range with the "expected number a..b" message.
|
||||
function f.numRange(min, max)
|
||||
local desc = "number"
|
||||
if min and max then desc = ("number %s..%s"):format(min, max)
|
||||
elseif min then desc = ("number >= %s"):format(min) end
|
||||
return { kind = "num", min = min, max = max, desc = desc,
|
||||
check = function(v)
|
||||
return type(v) == "number"
|
||||
and (min == nil or v >= min) and (max == nil or v <= max)
|
||||
end }
|
||||
end
|
||||
|
||||
function f.enum(values)
|
||||
local set = {}
|
||||
for _, value in ipairs(values) do set[value] = true end
|
||||
@@ -420,6 +433,12 @@ R.pokemon = {
|
||||
frames = f.opt(f.int(1)) } }),
|
||||
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
|
||||
trueColor = f.opt(f.bool),
|
||||
-- battle-pic scale overrides for this species' own pics: front is the
|
||||
-- enemy pic (default 1x), back is the player pic (default 2x). An
|
||||
-- image-level battle_sprite_scales entry for the same path beats these.
|
||||
-- The pic stays grounded (feet pinned) at any scale; see docs/modding.md.
|
||||
battleScaleFront = f.opt(f.numRange(0.25, 4.0)),
|
||||
battleScaleBack = f.opt(f.numRange(0.25, 4.0)),
|
||||
},
|
||||
example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })',
|
||||
}
|
||||
@@ -791,6 +810,118 @@ R.transitions = {
|
||||
example = 'mod.content.transitions:register("dissolve", { frames = 30, draw = fn })',
|
||||
}
|
||||
|
||||
-- ------- rendering pipelines
|
||||
--
|
||||
-- A pipeline is a display mode that owns part of the frame: it may replace
|
||||
-- the overworld's world pass with geometry of its own (drawWorld) and/or
|
||||
-- post-process the finished composite (present). Everything around that --
|
||||
-- the OFF/1/2/3 ladder, its options row, its hotkey, persistence in
|
||||
-- save.options.pipelines and the gating that keeps it out of battles and
|
||||
-- menus -- is engine plumbing driven from this record, so a renderer mod
|
||||
-- declares what it is and writes only the two draw functions.
|
||||
--
|
||||
-- Both callbacks are optional and independent: a present-only pipeline is a
|
||||
-- post-process (bloom, tilt-shift, a CRT curve) that leaves whatever
|
||||
-- rendered the frame alone, and a drawWorld-only pipeline is a world
|
||||
-- renderer that composites straight. See src/render/Pipelines.lua for the
|
||||
-- ctx each receives and docs/modding.md for the worked example.
|
||||
R.render_pipelines = {
|
||||
semantics = "record", target = "render_pipelines",
|
||||
fields = {
|
||||
-- shown in the options menu; the ladder labels default to OFF/ON
|
||||
label = f.str,
|
||||
levels = f.opt(f.list(f.str)),
|
||||
-- keyboard key that cycles the ladder, checked after the engine's own
|
||||
-- display hotkeys so a pipeline can never shadow one
|
||||
hotkey = f.opt(f.str),
|
||||
-- higher wins when two world pipelines are somehow active at once;
|
||||
-- also the options-row order, so a mode and its post-process sort
|
||||
-- together instead of by id
|
||||
priority = f.opt(f.num),
|
||||
-- hardware/driver gate, checked every frame: false keeps the vanilla
|
||||
-- 2D path, which is what a headless run and a driver with no depth
|
||||
-- canvas both get
|
||||
available = f.opt(f.fn),
|
||||
-- (top, overworld) -> boolean: whether the player may CHANGE the mode
|
||||
-- right now. Defaults to the survey-zoom gate (free-roam overworld
|
||||
-- only), which keeps a hotkey press from switching modes mid-warp or
|
||||
-- mid-cutscene. It has no say over whether an already-on mode draws:
|
||||
-- a mode that stopped rendering during a warp would flash the flat 2D
|
||||
-- world every time the player walked through a door.
|
||||
gate = f.opt(f.fn),
|
||||
-- (dt, level): presentational tweens, ticked on real frame time
|
||||
update = f.opt(f.fn),
|
||||
-- (ctx) -> canvas | nil: render the world. nil falls back to the
|
||||
-- vanilla flat/tilt draw for this frame.
|
||||
drawWorld = f.opt(f.fn),
|
||||
-- (canvas, ctx) -> canvas: post-process the WORLD image, before the UI
|
||||
-- composites over it -- a depth-of-field or colour grade that must not
|
||||
-- touch the dialog boxes and menus sitting on top. Only runs when some
|
||||
-- pipeline rendered the world, since the vanilla world pass has no
|
||||
-- single finished image to hand over.
|
||||
worldPresent = f.opt(f.fn),
|
||||
-- (canvas, ctx) -> canvas: post-process the whole finished composite,
|
||||
-- world and UI alike (a CRT curve, a full-screen grade). Must return a
|
||||
-- canvas; the input unchanged is the correct answer when the effect is
|
||||
-- off.
|
||||
present = f.opt(f.fn),
|
||||
-- drop GPU objects (window resize, hot reload, mode switch)
|
||||
invalidate = f.opt(f.fn),
|
||||
},
|
||||
-- a pipeline that does neither half is dead weight and would silently
|
||||
-- occupy an options row and a hotkey
|
||||
extra = function(_, value)
|
||||
if value.drawWorld == nil and value.present == nil
|
||||
and value.worldPresent == nil then
|
||||
return "a render pipeline needs drawWorld, worldPresent or present"
|
||||
end
|
||||
end,
|
||||
-- A pipeline callback fails at play time, long after the load phase has
|
||||
-- handed its report to the mod manager, so the merge leaves behind who
|
||||
-- wrote each record for Pipelines to name in the failure -- the same
|
||||
-- provenance trick the audio registries use (Loader.stampAudioOwners).
|
||||
-- Placement is otherwise the default record merge.
|
||||
write = function(target, registry)
|
||||
local owners, tombstones = {}, {}
|
||||
for id in pairs(registry.ops) do
|
||||
local value = registry:get(id)
|
||||
if value == nil then
|
||||
tombstones[#tombstones + 1] = id
|
||||
else
|
||||
target[id] = value
|
||||
local owner = registry.owners[id]
|
||||
if owner and owner ~= Schemas.ENGINE then owners[id] = owner end
|
||||
end
|
||||
end
|
||||
for _, id in ipairs(tombstones) do target[id] = nil end
|
||||
target._owners = owners
|
||||
end,
|
||||
example = 'mod.content.render_pipelines:register("voxel", ' ..
|
||||
'{ label = "VOXEL", levels = { "OFF", "15", "35", "50" }, drawWorld = fn })',
|
||||
}
|
||||
|
||||
-- ------- battle sprite scales
|
||||
--
|
||||
-- Per-image battle-pic scale overrides, keyed by record id and consulted
|
||||
-- by asset path at draw time. Where a species' battleScaleFront /
|
||||
-- battleScaleBack scales its own front/back pic, this scales ANY battle
|
||||
-- pic by the path it is drawn from -- the only handle on the non-species
|
||||
-- pics like the player's trainer back sprite. Image-level beats
|
||||
-- species-level; both compose with the send-out grow and keep the sprite
|
||||
-- grounded (feet pinned) at whatever scale. See docs/modding.md.
|
||||
R.battle_sprite_scales = {
|
||||
semantics = "record", target = "battle_sprite_scales",
|
||||
fields = {
|
||||
-- the asset path exactly as data references it, e.g.
|
||||
-- "assets/generated/battle/back/abrab.png"
|
||||
path = f.path,
|
||||
-- 1 = native pixels; the drawn size relative to the pic's own pixels
|
||||
scale = f.numRange(0.25, 4.0),
|
||||
},
|
||||
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
|
||||
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
|
||||
}
|
||||
|
||||
-- ------- progression
|
||||
|
||||
R.evolution_methods = {
|
||||
|
||||
Reference in New Issue
Block a user