Files
psxrecomp/runtime/src/mod_packages.cpp
T
Matt Stanley ac71aec441 Merge pull request #685 from boburning/fix/portable-generated-texture-defaults
Keep runtime-generated texture-pack defaults portable across installs
2026-10-19 11:44:00 -07:00

4323 lines
198 KiB
C++

#include "mod_packages.h"
#include "host_launch_timing.h"
#include "crc32.h"
#include "mod_plugins.h"
#include "mod_media.h"
#include "psx_sha256.h"
#include "toml.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <cstdio>
#include <fstream>
#include <iomanip>
#include <limits>
#include <set>
#include <sstream>
#include <system_error>
#include <tuple>
namespace fs = std::filesystem;
namespace PSXRecompV4 {
namespace {
constexpr uint32_t kMinFormatVersion = 1;
constexpr uint32_t kMaxFormatVersion = 10;
constexpr uint64_t kMaxArchiveBytes = 256ull * 1024ull * 1024ull;
constexpr uint32_t kMaxArchiveFiles = 4096;
std::map<std::string, ModBuiltinResolver>& builtin_resolvers() {
static std::map<std::string, ModBuiltinResolver> value;
return value;
}
struct MediaPreparer { ModMediaPreparer prepare, probe; };
std::map<std::string, MediaPreparer>& media_preparers() {
static std::map<std::string, MediaPreparer> value;
return value;
}
struct RegisteredPlugin {
void (*savestate)(void) = nullptr;
PSXModActivationCallback activation = nullptr;
PSXModActivationCallback session_reset = nullptr;
PSXModVBlankCallback vblank = nullptr;
/* Generated-function entry hooks, keyed by guest address. One id may
* observe several functions. */
std::vector<ModFunctionEntryHook> function_entries;
std::vector<ModFunctionEntryHook> guest_functions;
std::vector<ModInstructionHook> instructions;
};
std::map<std::string, RegisteredPlugin>& registered_plugins() {
static std::map<std::string, RegisteredPlugin> value;
return value;
}
void set_error(std::string* out, const std::string& value) {
if (out) *out = value;
}
bool valid_id(const std::string& value) {
if (value.empty() || value.size() > 96) return false;
for (unsigned char c : value) {
if (!(std::islower(c) || std::isdigit(c) || c == '.' || c == '-' || c == '_'))
return false;
}
return value.front() != '.' && value.back() != '.';
}
bool valid_sha256(const std::string& value) {
return value.size() == 64 &&
std::all_of(value.begin(), value.end(), [](unsigned char c) {
return std::isdigit(c) || (c >= 'a' && c <= 'f');
});
}
bool valid_web_url(const std::string& value) {
return value.rfind("https://", 0) == 0 || value.rfind("http://", 0) == 0;
}
bool parse_hex_bytes(const std::string& text, std::vector<uint8_t>& out) {
std::string compact;
compact.reserve(text.size());
for (unsigned char c : text) {
if (!std::isspace(c) && c != '_') compact.push_back((char)c);
}
if (compact.size() % 2 != 0) return false;
out.clear();
out.reserve(compact.size() / 2);
auto nibble = [](unsigned char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
c = (unsigned char)std::tolower(c);
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
return -1;
};
for (size_t i = 0; i < compact.size(); i += 2) {
const int hi = nibble((unsigned char)compact[i]);
const int lo = nibble((unsigned char)compact[i + 1]);
if (hi < 0 || lo < 0) return false;
out.push_back((uint8_t)((hi << 4) | lo));
}
return true;
}
bool parse_value_encoding(const std::string& text, ModValueEncoding& out) {
static const std::pair<const char*, ModValueEncoding> values[] = {
{"u8", ModValueEncoding::U8},
{"u16le", ModValueEncoding::U16LE},
{"u32le", ModValueEncoding::U32LE},
{"mips_lui_ori_u32", ModValueEncoding::MipsLuiOriU32},
};
for (const auto& [name, encoding] : values) {
if (text == name) {
out = encoding;
return true;
}
}
return false;
}
bool parse_integer_predicate_op(const std::string& text,
ModIntegerPredicateOp& out) {
static const std::pair<const char*, ModIntegerPredicateOp> values[] = {
{"eq", ModIntegerPredicateOp::Equal},
{"ne", ModIntegerPredicateOp::NotEqual},
{"lt", ModIntegerPredicateOp::Less},
{"le", ModIntegerPredicateOp::LessEqual},
{"gt", ModIntegerPredicateOp::Greater},
{"ge", ModIntegerPredicateOp::GreaterEqual},
};
for (const auto& [name, op] : values) {
if (text == name) {
out = op;
return true;
}
}
return false;
}
uint32_t value_encoding_size(ModValueEncoding encoding) {
switch (encoding) {
case ModValueEncoding::U8:
return 1;
case ModValueEncoding::U16LE:
return 2;
case ModValueEncoding::U32LE:
return 4;
case ModValueEncoding::MipsLuiOriU32:
return 8;
}
return 0;
}
bool valid_mips_lui_ori_guard(const std::vector<uint8_t>& expected,
uint64_t offset) {
if (offset > expected.size() || expected.size() - offset < 8)
return false;
const uint8_t* bytes =
expected.data() + static_cast<size_t>(offset);
const uint32_t lui =
static_cast<uint32_t>(bytes[0]) |
(static_cast<uint32_t>(bytes[1]) << 8) |
(static_cast<uint32_t>(bytes[2]) << 16) |
(static_cast<uint32_t>(bytes[3]) << 24);
const uint32_t ori =
static_cast<uint32_t>(bytes[4]) |
(static_cast<uint32_t>(bytes[5]) << 8) |
(static_cast<uint32_t>(bytes[6]) << 16) |
(static_cast<uint32_t>(bytes[7]) << 24);
const uint32_t lui_rs = (lui >> 21) & 0x1Fu;
const uint32_t lui_rt = (lui >> 16) & 0x1Fu;
const uint32_t ori_rs = (ori >> 21) & 0x1Fu;
const uint32_t ori_rt = (ori >> 16) & 0x1Fu;
return (lui >> 26) == 0x0Fu && lui_rs == 0 &&
lui_rt != 0 && (ori >> 26) == 0x0Du &&
ori_rs == lui_rt && ori_rt == lui_rt;
}
struct RelativeRange {
uint64_t offset = 0;
uint64_t size = 0;
};
std::vector<RelativeRange> patch_field_ranges(const ModPatchField& field) {
if (!field.replacement.empty())
return {{field.offset, field.replacement.size()}};
if (field.replace_encoding == ModValueEncoding::MipsLuiOriU32)
return {{field.offset, 2}, {field.offset + 4, 2}};
return {{field.offset, value_encoding_size(field.replace_encoding)}};
}
std::string hex_bytes(const std::vector<uint8_t>& bytes) {
std::ostringstream out;
for (uint8_t byte : bytes)
out << std::hex << std::setw(2) << std::setfill('0') << (unsigned)byte;
return out.str();
}
struct SemVer {
int64_t major = 0, minor = 0, patch = 0;
std::string suffix;
bool valid = false;
};
SemVer parse_semver(const std::string& text) {
SemVer out;
std::string core = text;
const size_t dash = core.find('-');
if (dash != std::string::npos) {
out.suffix = core.substr(dash + 1);
core.resize(dash);
}
std::array<int64_t*, 3> parts = {&out.major, &out.minor, &out.patch};
size_t at = 0;
for (size_t i = 0; i < parts.size(); ++i) {
const size_t end = core.find('.', at);
const std::string token = core.substr(at, end == std::string::npos ? end : end - at);
if (token.empty() ||
!std::all_of(token.begin(), token.end(), [](unsigned char c) { return std::isdigit(c); }))
return out;
try {
*parts[i] = std::stoll(token);
} catch (...) {
return out;
}
if (end == std::string::npos) {
if (i != 2) return out;
at = core.size();
} else {
at = end + 1;
}
}
if (at != core.size()) return out;
out.valid = true;
return out;
}
int compare_semver(const std::string& a, const std::string& b) {
const SemVer av = parse_semver(a), bv = parse_semver(b);
if (!av.valid || !bv.valid) return a.compare(b);
if (av.major != bv.major) return av.major < bv.major ? -1 : 1;
if (av.minor != bv.minor) return av.minor < bv.minor ? -1 : 1;
if (av.patch != bv.patch) return av.patch < bv.patch ? -1 : 1;
if (av.suffix.empty() != bv.suffix.empty()) return av.suffix.empty() ? 1 : -1;
return av.suffix.compare(bv.suffix);
}
bool version_satisfies(const std::string& actual, const std::string& requirement) {
if (requirement.empty() || requirement == "*") return true;
if (requirement.rfind(">=", 0) == 0)
return compare_semver(actual, requirement.substr(2)) >= 0;
if (requirement.rfind("<=", 0) == 0)
return compare_semver(actual, requirement.substr(2)) <= 0;
if (requirement.rfind(">", 0) == 0)
return compare_semver(actual, requirement.substr(1)) > 0;
if (requirement.rfind("<", 0) == 0)
return compare_semver(actual, requirement.substr(1)) < 0;
if (requirement.rfind("^", 0) == 0) {
const SemVer base = parse_semver(requirement.substr(1));
const SemVer got = parse_semver(actual);
return base.valid && got.valid && got.major == base.major &&
compare_semver(actual, requirement.substr(1)) >= 0;
}
return actual == requirement;
}
std::string quote_toml(const std::string& value) {
std::string out = "\"";
for (unsigned char c : value) {
if (c == '\\' || c == '"') out.push_back('\\');
if (c == '\n') out += "\\n";
else if (c == '\r') out += "\\r";
else out.push_back((char)c);
}
out.push_back('"');
return out;
}
bool read_file(const fs::path& path, std::vector<uint8_t>& out, std::string* error) {
std::ifstream in(path, std::ios::binary);
if (!in) {
set_error(error, "cannot open " + path.string());
return false;
}
in.seekg(0, std::ios::end);
const std::streamoff size = in.tellg();
if (size < 0 || (uint64_t)size > kMaxArchiveBytes) {
set_error(error, "archive is too large");
return false;
}
in.seekg(0);
out.resize((size_t)size);
if (!out.empty() && !in.read((char*)out.data(), size)) {
set_error(error, "cannot read " + path.string());
return false;
}
return true;
}
uint16_t le16(const uint8_t* p) {
return (uint16_t)(p[0] | ((uint16_t)p[1] << 8));
}
uint32_t le32(const uint8_t* p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
struct ZipEntry {
std::string name;
uint16_t method = 0;
uint32_t crc = 0;
uint32_t compressed_size = 0;
uint32_t size = 0;
uint32_t local_offset = 0;
bool directory = false;
};
bool safe_archive_name(const std::string& name) {
if (name.empty() || name.size() > 512 || name[0] == '/' || name[0] == '\\')
return false;
if (name.size() >= 2 && std::isalpha((unsigned char)name[0]) && name[1] == ':')
return false;
fs::path p = fs::path(name).lexically_normal();
for (const auto& part : p) {
const std::string s = part.string();
if (s == ".." || s == "." || s.empty()) return false;
}
return true;
}
bool parse_zip(const std::vector<uint8_t>& bytes, std::vector<ZipEntry>& entries,
std::string* error) {
if (bytes.size() < 22) {
set_error(error, "not a ZIP archive");
return false;
}
size_t eocd = std::string::npos;
const size_t floor = bytes.size() > 65557 ? bytes.size() - 65557 : 0;
for (size_t pos = bytes.size() - 22;; --pos) {
if (le32(bytes.data() + pos) == 0x06054b50u) { eocd = pos; break; }
if (pos == floor) break;
}
if (eocd == std::string::npos) {
set_error(error, "ZIP end record is missing");
return false;
}
const uint16_t count = le16(bytes.data() + eocd + 10);
const uint32_t central_size = le32(bytes.data() + eocd + 12);
const uint32_t central_offset = le32(bytes.data() + eocd + 16);
if (count > kMaxArchiveFiles || (uint64_t)central_offset + central_size > bytes.size()) {
set_error(error, "ZIP central directory is invalid");
return false;
}
size_t at = central_offset;
uint64_t expanded = 0;
for (uint32_t i = 0; i < count; ++i) {
if (at + 46 > bytes.size() || le32(bytes.data() + at) != 0x02014b50u) {
set_error(error, "ZIP entry record is invalid");
return false;
}
const uint16_t flags = le16(bytes.data() + at + 8);
ZipEntry e;
e.method = le16(bytes.data() + at + 10);
e.crc = le32(bytes.data() + at + 16);
e.compressed_size = le32(bytes.data() + at + 20);
e.size = le32(bytes.data() + at + 24);
const uint16_t name_len = le16(bytes.data() + at + 28);
const uint16_t extra_len = le16(bytes.data() + at + 30);
const uint16_t comment_len = le16(bytes.data() + at + 32);
e.local_offset = le32(bytes.data() + at + 42);
if (flags & 1u) {
set_error(error, "encrypted ZIP entries are not supported");
return false;
}
if (e.method != 0 && e.method != 8) {
set_error(error, "ZIP compression method is not supported");
return false;
}
if (at + 46ull + name_len + extra_len + comment_len > bytes.size()) {
set_error(error, "ZIP entry name is truncated");
return false;
}
e.name.assign((const char*)bytes.data() + at + 46, name_len);
std::replace(e.name.begin(), e.name.end(), '\\', '/');
e.directory = !e.name.empty() && e.name.back() == '/';
if (!safe_archive_name(e.directory ? e.name.substr(0, e.name.size() - 1) : e.name)) {
set_error(error, "unsafe ZIP path: " + e.name);
return false;
}
expanded += e.size;
if (expanded > kMaxArchiveBytes) {
set_error(error, "expanded archive exceeds the size limit");
return false;
}
entries.push_back(std::move(e));
at += 46ull + name_len + extra_len + comment_len;
}
return true;
}
struct DeflateBits {
const uint8_t* at = nullptr;
const uint8_t* end = nullptr;
uint64_t hold = 0;
unsigned bits = 0;
bool read(unsigned count, uint32_t& out) {
while (bits < count) {
if (at == end) return false;
hold |= (uint64_t)*at++ << bits;
bits += 8;
}
out = count == 32 ? (uint32_t)hold :
(uint32_t)(hold & ((1ull << count) - 1));
hold >>= count;
bits -= count;
return true;
}
void align_byte() {
const unsigned drop = bits & 7u;
hold >>= drop;
bits -= drop;
}
};
struct DeflateHuffman {
std::array<uint16_t, 16> count{};
std::vector<uint16_t> symbols;
};
bool build_huffman(const std::vector<uint8_t>& lengths, DeflateHuffman& out) {
out = {};
for (uint8_t length : lengths) {
if (length > 15) return false;
out.count[length]++;
}
if (out.count[0] == lengths.size()) return false;
int left = 1;
for (int length = 1; length <= 15; ++length) {
left <<= 1;
left -= out.count[(size_t)length];
if (left < 0) return false;
}
std::array<uint16_t, 16> offsets{};
for (size_t length = 1; length < 15; ++length)
offsets[length + 1] = offsets[length] + out.count[length];
out.symbols.resize(lengths.size() - out.count[0]);
for (uint16_t symbol = 0; symbol < lengths.size(); ++symbol)
if (lengths[symbol])
out.symbols[offsets[lengths[symbol]]++] = symbol;
return true;
}
bool decode_symbol(DeflateBits& bits, const DeflateHuffman& table, uint16_t& symbol) {
uint32_t code = 0, first = 0, index = 0;
for (uint32_t length = 1; length <= 15; ++length) {
uint32_t bit = 0;
if (!bits.read(1, bit)) return false;
code |= bit;
const uint32_t count = table.count[length];
if (code < first + count) {
const uint32_t slot = index + code - first;
if (slot >= table.symbols.size()) return false;
symbol = table.symbols[slot];
return true;
}
index += count;
first = (first + count) << 1;
code <<= 1;
}
return false;
}
bool inflate_deflate(const uint8_t* data, size_t size, size_t expected,
std::vector<uint8_t>& out) {
static const uint16_t length_base[29] = {
3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,
115,131,163,195,227,258};
static const uint8_t length_extra[29] = {
0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0};
static const uint16_t distance_base[30] = {
1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,
1025,1537,2049,3073,4097,6145,8193,12289,16385,24577};
static const uint8_t distance_extra[30] = {
0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,
12,12,13,13};
DeflateBits input{data, data + size};
out.clear();
out.reserve(expected);
bool final = false;
while (!final) {
uint32_t final_bit = 0, type = 0;
if (!input.read(1, final_bit) || !input.read(2, type)) return false;
final = final_bit != 0;
if (type == 0) {
input.align_byte();
uint32_t length = 0, complement = 0;
if (!input.read(16, length) || !input.read(16, complement) ||
(length ^ 0xffffu) != complement ||
out.size() + length > expected) return false;
for (uint32_t i = 0; i < length; ++i) {
uint32_t byte = 0;
if (!input.read(8, byte)) return false;
out.push_back((uint8_t)byte);
}
continue;
}
if (type == 3) return false;
std::vector<uint8_t> literal_lengths;
std::vector<uint8_t> distance_lengths;
if (type == 1) {
literal_lengths.resize(288);
for (size_t i = 0; i <= 143; ++i) literal_lengths[i] = 8;
for (size_t i = 144; i <= 255; ++i) literal_lengths[i] = 9;
for (size_t i = 256; i <= 279; ++i) literal_lengths[i] = 7;
for (size_t i = 280; i <= 287; ++i) literal_lengths[i] = 8;
distance_lengths.assign(32, 5);
} else {
uint32_t hlit = 0, hdist = 0, hclen = 0;
if (!input.read(5, hlit) || !input.read(5, hdist) ||
!input.read(4, hclen)) return false;
hlit += 257; hdist += 1; hclen += 4;
static const uint8_t order[19] =
{16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15};
std::vector<uint8_t> code_lengths(19, 0);
for (uint32_t i = 0; i < hclen; ++i) {
uint32_t value = 0;
if (!input.read(3, value)) return false;
code_lengths[order[i]] = (uint8_t)value;
}
DeflateHuffman code_table;
if (!build_huffman(code_lengths, code_table)) return false;
std::vector<uint8_t> lengths;
lengths.reserve(hlit + hdist);
while (lengths.size() < hlit + hdist) {
uint16_t symbol = 0;
if (!decode_symbol(input, code_table, symbol)) return false;
if (symbol <= 15) {
lengths.push_back((uint8_t)symbol);
continue;
}
uint32_t repeat = 0, extra = 0;
uint8_t value = 0;
if (symbol == 16) {
if (lengths.empty() || !input.read(2, extra)) return false;
repeat = extra + 3;
value = lengths.back();
} else if (symbol == 17) {
if (!input.read(3, extra)) return false;
repeat = extra + 3;
} else if (symbol == 18) {
if (!input.read(7, extra)) return false;
repeat = extra + 11;
} else return false;
if (lengths.size() + repeat > hlit + hdist) return false;
lengths.insert(lengths.end(), repeat, value);
}
literal_lengths.assign(lengths.begin(), lengths.begin() + hlit);
distance_lengths.assign(lengths.begin() + hlit, lengths.end());
}
DeflateHuffman literals, distances;
if (!build_huffman(literal_lengths, literals) ||
!build_huffman(distance_lengths, distances)) return false;
for (;;) {
uint16_t symbol = 0;
if (!decode_symbol(input, literals, symbol)) return false;
if (symbol < 256) {
if (out.size() >= expected) return false;
out.push_back((uint8_t)symbol);
continue;
}
if (symbol == 256) break;
if (symbol < 257 || symbol > 285) return false;
const unsigned length_index = symbol - 257;
uint32_t extra = 0;
if (!input.read(length_extra[length_index], extra)) return false;
const size_t length = length_base[length_index] + extra;
uint16_t distance_symbol = 0;
if (!decode_symbol(input, distances, distance_symbol) ||
distance_symbol >= 30) return false;
if (!input.read(distance_extra[distance_symbol], extra)) return false;
const size_t distance = distance_base[distance_symbol] + extra;
if (distance == 0 || distance > out.size() ||
out.size() + length > expected) return false;
for (size_t i = 0; i < length; ++i)
out.push_back(out[out.size() - distance]);
}
}
return out.size() == expected;
}
bool extract_zip(const std::vector<uint8_t>& bytes,
const std::vector<ZipEntry>& entries,
const fs::path& target, std::string* error) {
std::error_code ec;
fs::create_directories(target, ec);
if (ec) {
set_error(error, "cannot create staging directory: " + ec.message());
return false;
}
for (const ZipEntry& e : entries) {
const fs::path out = target / fs::path(e.name);
if (e.directory) {
fs::create_directories(out, ec);
if (ec) {
set_error(error, "cannot create archive directory: " + ec.message());
return false;
}
continue;
}
if ((uint64_t)e.local_offset + 30 > bytes.size() ||
le32(bytes.data() + e.local_offset) != 0x04034b50u) {
set_error(error, "ZIP local entry is invalid");
return false;
}
const uint16_t name_len = le16(bytes.data() + e.local_offset + 26);
const uint16_t extra_len = le16(bytes.data() + e.local_offset + 28);
const uint64_t data_at = (uint64_t)e.local_offset + 30 + name_len + extra_len;
if (data_at + e.compressed_size > bytes.size()) {
set_error(error, "ZIP entry payload is invalid");
return false;
}
const uint8_t* compressed = bytes.data() + data_at;
std::vector<uint8_t> expanded;
const uint8_t* data = compressed;
if (e.method == 0) {
if (e.compressed_size != e.size) {
set_error(error, "stored ZIP entry has inconsistent size");
return false;
}
} else {
if (!inflate_deflate(compressed, e.compressed_size, e.size, expanded)) {
set_error(error, "cannot inflate ZIP entry: " + e.name);
return false;
}
data = expanded.data();
}
if (crc32_compute(data, e.size) != e.crc) {
set_error(error, "ZIP entry checksum failed: " + e.name);
return false;
}
fs::create_directories(out.parent_path(), ec);
if (ec) {
set_error(error, "cannot create archive parent directory: " + ec.message());
return false;
}
std::ofstream file(out, std::ios::binary | std::ios::trunc);
if (!file || (e.size && !file.write((const char*)data, e.size))) {
set_error(error, "cannot extract archive entry: " + e.name);
return false;
}
}
return true;
}
const ModPackage* find_selected(
const std::map<std::string, std::map<std::string, ModPackage>>& packages,
const std::string& id, const ModSelection& selection) {
const auto p = packages.find(id);
if (p == packages.end() || p->second.empty()) return nullptr;
if (!selection.version.empty()) {
const auto v = p->second.find(selection.version);
return v == p->second.end() ? nullptr : &v->second;
}
const ModPackage* best = nullptr;
for (const auto& [version, package] : p->second)
if (!best || compare_semver(version, best->version) > 0) best = &package;
return best;
}
bool target_matches(const ModPackage& package, const std::string& game,
const std::string& exe, const std::string& disc) {
if (package.targets.empty()) return false;
for (const ModTarget& target : package.targets) {
/* "*" targets every game. Framework-owned mods (loading speed, and
* anything else that is a property of the emulator rather than of a
* particular disc) ship once and apply everywhere, instead of every
* title carrying a copy of the same manifest. An empty target list
* still matches nothing, so a malformed manifest fails loudly. */
if (target.game_id != "*" && target.game_id != game) continue;
if (!target.exe_sha256.empty() && target.exe_sha256 != exe) continue;
if (!target.disc_sha256.empty() && target.disc_sha256 != disc) continue;
return true;
}
return false;
}
std::string canonical_resolution(const std::vector<const ModPackage*>& ordered,
const std::map<std::string, ModSelection>& selections,
const std::vector<ModResolution::Write>& writes,
const std::vector<ModResolution::Overlay>& overlays,
const std::vector<ModResolution::DerivedDisc>& derived_discs,
const std::vector<ModResolution::Plugin>& plugins,
const std::vector<ModResolution::Resource>& resources,
const std::string& source_disc_sha256) {
std::ostringstream out;
out << "source_disc=" << source_disc_sha256 << '\n';
for (const ModPackage* package : ordered) {
out << package->id << '@' << package->version << '\n';
const auto sit = selections.find(package->id);
for (const ModFeature& feature : package->features) {
bool enabled = feature.default_enabled;
const std::map<std::string, std::string>* values = nullptr;
if (sit != selections.end()) {
if (feature.legacy) {
enabled = sit->second.enabled;
values = &sit->second.values;
} else {
const auto selected = sit->second.features.find(feature.id);
if (selected != sit->second.features.end()) {
if (selected->second.has_enabled)
enabled = selected->second.enabled;
values = &selected->second.values;
}
}
}
out << "feature:" << feature.id << '='
<< (enabled ? "enabled" : "disabled") << '\n';
for (const ModOption& option : package->options) {
if (option.feature_id != feature.id) continue;
const auto selected = values ? values->find(option.id) :
std::map<std::string, std::string>::const_iterator{};
const std::string value =
values && selected != values->end()
? selected->second : option.default_value;
out << "feature:" << feature.id << ':' << option.id
<< '=' << value << '\n';
}
}
}
for (const ModResolution::Write& write : writes) {
const char* target =
write.target == ModPatchTarget::MainExe ? "main_exe" :
write.target == ModPatchTarget::DiscRaw ? "disc_raw" :
"disc_user";
if (write.fields.empty()) {
out << target << '@' << std::hex << write.location << std::dec
<< ':' << hex_bytes(write.expected) << '>'
<< hex_bytes(write.replacement) << ':' << write.package_id
<< ':' << write.feature_id << '\n';
} else {
out << "sparse_" << target << '@' << std::hex
<< write.location << std::dec << ":guard="
<< hex_bytes(write.expected) << ":owned=";
for (const ModResolution::Write::Field& field : write.fields)
out << std::hex << field.offset << std::dec << '='
<< hex_bytes(field.replacement) << ',';
out << ':' << write.package_id << ':' << write.feature_id
<< '\n';
}
}
for (const ModResolution::Overlay& overlay : overlays) {
out << (overlay.target == ModPatchTarget::DiscRaw
? "disc_raw_overlay" : "disc_user_overlay")
<< '@' << std::hex << overlay.location << std::dec << ':'
<< overlay.payload_sha256 << ':' << overlay.expected_sha256 << ':'
<< overlay.package_id << ':' << overlay.feature_id << '\n';
}
for (const ModResolution::DerivedDisc& derived : derived_discs) {
out << "derived_disc:" << derived.kind << ':'
<< derived.patch_sha256 << ':' << derived.output_size << ':'
<< derived.output_sha256 << ':' << derived.package_id << '\n';
}
for (const ModResolution::Plugin& plugin : plugins) {
out << "plugin:" << plugin.id << ':' << plugin.package_id << ':'
<< plugin.feature_id << '\n';
}
for (const ModResolution::Resource& resource : resources) {
out << "resource:" << resource.package_id << ':' << resource.feature_id
<< ':' << resource.id << '=';
if (resource.bytes)
out << resource.format << ':' << resource.bytes->size() << ':'
<< resource.sha256;
else
out << resource.path.u8string();
out << '\n';
}
return out.str();
}
const ModFeature* find_feature(const ModPackage& package, const std::string& id) {
const auto found = std::find_if(package.features.begin(), package.features.end(),
[&](const ModFeature& feature) { return feature.id == id; });
return found == package.features.end() ? nullptr : &*found;
}
const ModOption* find_option(const ModPackage& package,
const std::string& feature_id,
const std::string& id) {
const auto option = std::find_if(package.options.begin(), package.options.end(),
[&](const ModOption& item) {
return item.feature_id == feature_id && item.id == id;
});
return option == package.options.end() ? nullptr : &*option;
}
const ModResource* find_resource(const ModPackage& package,
const std::string& feature_id,
const std::string& id) {
const auto resource = std::find_if(
package.resources.begin(), package.resources.end(),
[&](const ModResource& item) {
return item.feature_id == feature_id && item.id == id;
});
return resource == package.resources.end() ? nullptr : &*resource;
}
bool constraint_satisfied(
const ModPackage& package, const ModConstraint& constraint,
const std::function<std::string(const std::string&)>& value_for,
std::string* reason);
const ModFeatureSelection* find_feature_selection(const ModPackage& package,
const ModSelection& selection,
const std::string& feature_id) {
const ModFeature* feature = find_feature(package, feature_id);
if (!feature) return nullptr;
if (feature->legacy) return nullptr;
const auto found = selection.features.find(feature_id);
return found == selection.features.end() ? nullptr : &found->second;
}
bool is_feature_enabled(const ModPackage& package, const ModSelection& selection,
const ModFeature& feature) {
if (feature.legacy) return selection.enabled;
const ModFeatureSelection* selected =
find_feature_selection(package, selection, feature.id);
return selected && selected->has_enabled
? selected->enabled : feature.default_enabled;
}
bool has_enabled_feature(const ModPackage& package,
const ModSelection& selection) {
return std::any_of(package.features.begin(), package.features.end(),
[&](const ModFeature& feature) {
return is_feature_enabled(package, selection, feature);
});
}
std::string effective_option_value(const ModPackage& package,
const ModSelection& selection,
const std::string& feature_id,
const std::string& id) {
const ModFeature* feature = find_feature(package, feature_id);
if (feature && feature->legacy) {
const auto selected = selection.values.find(id);
if (selected != selection.values.end()) return selected->second;
} else {
const ModFeatureSelection* selected =
find_feature_selection(package, selection, feature_id);
if (selected) {
const auto value = selected->values.find(id);
if (value != selected->values.end()) return value->second;
}
}
const ModOption* option = find_option(package, feature_id, id);
return option ? option->default_value : std::string();
}
/* A resource's package-relative default, only when it exists with the
* resource's kind (folder or file) and its real path (symlinks resolved)
* stays inside the package: the manifest check is text-only, and a symlink
* the package ships could point anywhere. {} otherwise (the resource is then
* unset, so a required one blocks launch as before). */
static fs::path package_default_path(const ModPackage& package, const ModResource& resource) {
std::error_code ec;
const fs::path candidate = package.root / fs::u8path(resource.default_path);
const bool folder = resource.format == "directory" || resource.format == "folder";
if (folder ? !fs::is_directory(candidate, ec) : !fs::is_regular_file(candidate, ec))
return {};
const fs::path real = fs::canonical(candidate, ec);
if (ec) return {};
const fs::path base = fs::canonical(package.root, ec);
if (ec) return {};
const auto rel = real.lexically_relative(base);
if (rel.empty() || rel.is_absolute() || *rel.begin() == "..") return {};
return candidate;
}
fs::path effective_resource_path(const ModPackage& package,
const ModSelection& selection,
const std::string& feature_id,
const std::string& id,
const fs::path& generated_default = {}) {
const ModFeature* feature = find_feature(package, feature_id);
if (!feature || feature->legacy) return {};
const ModFeatureSelection* selected =
find_feature_selection(package, selection, feature_id);
if (selected) {
const auto resource = selected->resources.find(id);
if (resource != selected->resources.end() && !resource->second.empty())
return fs::u8path(resource->second);
}
const ModResource* declared = find_resource(package, feature_id, id);
if (declared && !declared->default_path.empty() && !package.root.empty())
return package_default_path(package, *declared);
return generated_default;
}
bool prospective_feature_enabled(
const ModPackage& package, const ModSelection& selection,
const std::string& feature_id, const std::string* override_feature_id,
const bool* override_enabled) {
if (override_feature_id && override_enabled &&
feature_id == *override_feature_id)
return *override_enabled;
const ModFeature* feature = find_feature(package, feature_id);
return feature && is_feature_enabled(package, selection, *feature);
}
std::string prospective_option_value(
const ModPackage& package, const ModSelection& selection,
const std::string& feature_id, const std::string& option_id,
const std::string* override_feature_id,
const std::string* override_option_id,
const std::string* override_value) {
if (override_feature_id && override_option_id && override_value &&
feature_id == *override_feature_id && option_id == *override_option_id)
return *override_value;
return effective_option_value(package, selection, feature_id, option_id);
}
bool runtime_constraint_satisfied(
const ModPackage& package, const ModSelection& selection,
const ModConstraint& constraint,
const std::string* override_feature_id, const bool* override_enabled,
const std::string* override_option_feature_id,
const std::string* override_option_id,
const std::string* override_value, std::string* reason) {
if (!prospective_feature_enabled(
package, selection, constraint.feature_id, override_feature_id,
override_enabled))
return true;
if (constraint.kind == ModConstraintKind::OrderedInteger) {
return constraint_satisfied(
package, constraint,
[&](const std::string& id) {
return prospective_option_value(
package, selection, constraint.feature_id, id,
override_option_feature_id, override_option_id,
override_value);
},
reason);
}
if (constraint.kind == ModConstraintKind::RequiresFeature) {
const ModFeature* required =
find_feature(package, constraint.required_feature_id);
const std::string required_name =
required && !required->name.empty()
? required->name
: constraint.required_feature_id;
if (!prospective_feature_enabled(
package, selection, constraint.required_feature_id,
override_feature_id, override_enabled)) {
if (reason) *reason = "requires " + required_name + " to be enabled";
return false;
}
if (!constraint.required_option_id.empty()) {
const std::string actual = prospective_option_value(
package, selection, constraint.required_feature_id,
constraint.required_option_id, override_option_feature_id,
override_option_id, override_value);
if (actual != constraint.required_value) {
const ModOption* option = find_option(
package, constraint.required_feature_id,
constraint.required_option_id);
if (reason) {
*reason =
"requires " + required_name + " " +
(option && !option->label.empty()
? option->label
: constraint.required_option_id) +
" = " + constraint.required_value;
}
return false;
}
}
return true;
}
if (reason) *reason = "unsupported constraint kind";
return false;
}
bool package_constraints_satisfied(
const ModPackage& package, const ModSelection& selection,
const std::string* override_feature_id, const bool* override_enabled,
const std::string* override_option_feature_id,
const std::string* override_option_id,
const std::string* override_value,
std::string* failing_feature_id, std::string* reason) {
for (const ModConstraint& constraint : package.constraints) {
std::string local_reason;
if (!runtime_constraint_satisfied(
package, selection, constraint, override_feature_id,
override_enabled, override_option_feature_id,
override_option_id, override_value, &local_reason)) {
if (failing_feature_id) *failing_feature_id = constraint.feature_id;
if (reason) *reason = local_reason;
return false;
}
}
return true;
}
void set_feature_selected(ModSelection& selection,
const std::string& feature_id, bool enabled) {
ModFeatureSelection& feature = selection.features[feature_id];
feature.enabled = enabled;
feature.has_enabled = true;
}
bool apply_feature_requirements(const ModPackage& package,
ModSelection& selection,
const std::string& feature_id,
std::set<std::string>& visiting,
std::string* error) {
if (!visiting.insert(feature_id).second) return true;
for (const ModConstraint& constraint : package.constraints) {
if (constraint.kind != ModConstraintKind::RequiresFeature ||
constraint.feature_id != feature_id)
continue;
const ModFeature* required =
find_feature(package, constraint.required_feature_id);
if (!required || required->legacy) {
set_error(error, feature_id + ": has an invalid feature requirement");
visiting.erase(feature_id);
return false;
}
set_feature_selected(selection, constraint.required_feature_id, true);
if (!constraint.required_option_id.empty()) {
selection.features[constraint.required_feature_id]
.values[constraint.required_option_id] =
constraint.required_value;
}
if (!apply_feature_requirements(
package, selection, constraint.required_feature_id,
visiting, error)) {
visiting.erase(feature_id);
return false;
}
}
visiting.erase(feature_id);
return true;
}
void cascade_unsatisfied_feature_requirements(const ModPackage& package,
ModSelection& selection) {
bool changed = false;
do {
changed = false;
for (const ModConstraint& constraint : package.constraints) {
if (constraint.kind != ModConstraintKind::RequiresFeature)
continue;
const ModFeature* dependent =
find_feature(package, constraint.feature_id);
if (!dependent || dependent->legacy ||
!is_feature_enabled(package, selection, *dependent))
continue;
if (runtime_constraint_satisfied(
package, selection, constraint, nullptr, nullptr,
nullptr, nullptr, nullptr, nullptr))
continue;
set_feature_selected(selection, constraint.feature_id, false);
changed = true;
}
} while (changed);
}
bool conditions_match(const ModPackage& package, const ModSelection& selection,
const std::string& feature_id,
const std::map<std::string, std::string>& conditions) {
for (const auto& [id, value] : conditions) {
if (effective_option_value(package, selection, feature_id, id) != value)
return false;
}
return true;
}
bool feature_predicate_matches(
const ModOverlay::FeaturePredicate& predicate,
const std::map<std::string, const ModPackage*>& active_packages,
const std::map<std::string, ModSelection>& selections) {
if (!predicate.present) return true;
bool enabled = false;
const auto package_it = active_packages.find(predicate.package_id);
if (package_it != active_packages.end()) {
const ModPackage& package = *package_it->second;
const ModFeature* feature =
find_feature(package, predicate.feature_id);
if (feature) {
const auto selection_it = selections.find(predicate.package_id);
const ModSelection blank;
const ModSelection& selection =
selection_it == selections.end() ? blank : selection_it->second;
enabled = is_feature_enabled(package, selection, *feature);
}
}
return enabled == predicate.enabled;
}
bool valid_option_value(const ModOption& option, const std::string& value);
void read_conditions(const toml::value& value, const std::vector<ModOption>& options,
const std::string& feature_id,
std::map<std::string, std::string>& when,
const char* label) {
const std::string when_option =
value.contains("when_option") ? toml::find<std::string>(value, "when_option") : "";
const std::string when_value =
value.contains("when_value") ? toml::find<std::string>(value, "when_value") : "";
if (when_option.empty() != when_value.empty())
throw std::runtime_error(
std::string(label) + " condition requires both when_option and when_value");
if (!when_option.empty()) when[when_option] = when_value;
if (value.contains("when")) {
const auto table = toml::find<std::map<std::string, std::string>>(value, "when");
for (const auto& [id, condition_value] : table) {
if (when.find(id) != when.end() && when[id] != condition_value)
throw std::runtime_error(
std::string(label) + " has conflicting conditions for " + id);
when[id] = condition_value;
}
}
/* Bind map entries to real locals before the lambda — capturing a
* structured binding is a C++20 extension and breaks MinGW C++17 builds. */
for (const auto& entry : when) {
const std::string& id = entry.first;
const std::string& condition_value = entry.second;
const auto option = std::find_if(
options.begin(), options.end(),
[&](const ModOption& item) {
return item.feature_id == feature_id && item.id == id;
});
if (option == options.end())
throw std::runtime_error(std::string(label) + " references unknown option");
if (!valid_option_value(*option, condition_value))
throw std::runtime_error(
std::string(label) + " condition has invalid option value");
}
}
bool ranges_overlap(ModPatchTarget a_target, uint64_t a_location, size_t a_size,
ModPatchTarget b_target, uint64_t b_location, size_t b_size);
template <typename Fn>
void for_each_owned_span(const ModResolution::Write& write, Fn&& fn) {
if (write.fields.empty()) {
fn(write.location, write.expected.data(), write.replacement.data(),
write.replacement.size());
return;
}
for (const ModResolution::Write::Field& field : write.fields) {
fn(write.location + field.offset,
write.expected.data() + static_cast<size_t>(field.offset),
field.replacement.data(), field.replacement.size());
}
}
bool writes_overlap(const ModResolution::Write& a, const ModResolution::Write& b) {
if (a.target != b.target) return false;
bool overlap = false;
for_each_owned_span(
a, [&](uint64_t a_location, const uint8_t*, const uint8_t*,
size_t a_size) {
for_each_owned_span(
b, [&](uint64_t b_location, const uint8_t*, const uint8_t*,
size_t b_size) {
overlap = overlap || ranges_overlap(
a.target, a_location, a_size,
b.target, b_location, b_size);
});
});
return overlap;
}
bool ranges_overlap(ModPatchTarget a_target, uint64_t a_location, size_t a_size,
ModPatchTarget b_target, uint64_t b_location, size_t b_size) {
if (a_target != b_target) return false;
const uint64_t a_end = a_location + a_size;
const uint64_t b_end = b_location + b_size;
return a_location < b_end && b_location < a_end;
}
bool identical_write(const ModResolution::Write& a, const ModResolution::Write& b) {
if (a.target != b.target || a.location != b.location ||
a.expected != b.expected || a.replacement != b.replacement ||
a.fields.size() != b.fields.size())
return false;
for (size_t i = 0; i < a.fields.size(); ++i) {
if (a.fields[i].offset != b.fields[i].offset ||
a.fields[i].replacement != b.fields[i].replacement)
return false;
}
return true;
}
bool write_overlap_mismatch(const ModResolution::Write& a,
const ModResolution::Write& b,
uint64_t& mismatch) {
bool found = false;
for_each_owned_span(
a, [&](uint64_t a_location, const uint8_t* a_expected,
const uint8_t* a_replacement, size_t a_size) {
for_each_owned_span(
b, [&](uint64_t b_location, const uint8_t* b_expected,
const uint8_t* b_replacement, size_t b_size) {
const uint64_t begin =
std::max(a_location, b_location);
const uint64_t end = std::min(
a_location + a_size, b_location + b_size);
for (uint64_t at = begin; !found && at < end; ++at) {
const size_t ai =
static_cast<size_t>(at - a_location);
const size_t bi =
static_cast<size_t>(at - b_location);
if (a_expected[ai] != b_expected[bi] ||
a_replacement[ai] != b_replacement[bi]) {
mismatch = at;
found = true;
}
}
});
});
return found;
}
bool write_guard_overlap_mismatch(const ModResolution::Write& a,
const ModResolution::Write& b,
uint64_t& mismatch) {
if (a.target != b.target) return false;
const uint64_t begin = std::max(a.location, b.location);
const uint64_t end = std::min(
a.location + a.expected.size(), b.location + b.expected.size());
for (uint64_t at = begin; at < end; ++at) {
if (a.expected[static_cast<size_t>(at - a.location)] !=
b.expected[static_cast<size_t>(at - b.location)]) {
mismatch = at;
return true;
}
}
return false;
}
bool write_overlaps_range(const ModResolution::Write& write,
ModPatchTarget target, uint64_t location,
size_t size) {
if (write.target != target) return false;
bool overlap = false;
for_each_owned_span(
write, [&](uint64_t owned_location, const uint8_t*,
const uint8_t*, size_t owned_size) {
overlap = overlap || ranges_overlap(
write.target, owned_location, owned_size,
target, location, size);
});
return overlap;
}
bool write_overlay_mismatch(const ModResolution::Write& write,
const ModResolution::Overlay& overlay,
uint64_t& mismatch) {
bool found = false;
for_each_owned_span(
write, [&](uint64_t location, const uint8_t*,
const uint8_t* replacement, size_t size) {
const uint64_t begin = std::max(location, overlay.location);
const uint64_t end = std::min(
location + size, overlay.location + overlay.payload.size());
for (uint64_t at = begin; !found && at < end; ++at) {
if (replacement[static_cast<size_t>(at - location)] !=
overlay.payload[
static_cast<size_t>(at - overlay.location)]) {
mismatch = at;
found = true;
}
}
});
return found;
}
bool overlay_overlap_mismatch(const ModResolution::Overlay& a,
const ModResolution::Overlay& b,
uint64_t& mismatch) {
const uint64_t begin = std::max(a.location, b.location);
const uint64_t end = std::min(
a.location + a.payload.size(), b.location + b.payload.size());
for (uint64_t at = begin; at < end; ++at) {
if (a.payload[(size_t)(at - a.location)] !=
b.payload[(size_t)(at - b.location)]) {
mismatch = at;
return true;
}
}
return false;
}
std::string overlap_resource(ModPatchTarget target,
uint64_t a_location, size_t a_size,
uint64_t b_location, size_t b_size) {
const char* name = target == ModPatchTarget::MainExe ? "main_exe" :
target == ModPatchTarget::DiscRaw ? "disc_raw" :
"disc_user";
const uint64_t begin = std::max(a_location, b_location);
const uint64_t end = std::min(
a_location + a_size, b_location + b_size);
std::ostringstream out;
out << name << ":0x" << std::hex << begin << "-0x" << end;
return out.str();
}
std::string byte_resource(ModPatchTarget target, uint64_t location) {
return overlap_resource(target, location, 1, location, 1);
}
bool parse_canonical_int64(const std::string& value, int64_t& parsed) {
try {
size_t used = 0;
parsed = std::stoll(value, &used);
return used == value.size() && std::to_string(parsed) == value;
} catch (...) {
return false;
}
}
bool integer_step_aligned(int64_t value, int64_t minimum, int64_t step) {
if (value < minimum || step <= 0) return false;
const uint64_t distance =
static_cast<uint64_t>(value) - static_cast<uint64_t>(minimum);
return distance % static_cast<uint64_t>(step) == 0;
}
bool valid_option_value(const ModOption& option, const std::string& value) {
if (option.type == ModOptionType::Boolean)
return value == "true" || value == "false";
if (option.type == ModOptionType::Choice)
return std::any_of(option.choices.begin(), option.choices.end(),
[&](const ModChoice& choice) { return choice.value == value; });
int64_t parsed = 0;
return parse_canonical_int64(value, parsed) &&
parsed >= option.min_value && parsed <= option.max_value &&
integer_step_aligned(parsed, option.min_value, option.step);
}
bool integer_predicate_matches(
const ModPackage& package, const ModSelection& selection,
const std::string& feature_id,
const ModIntegerPredicate& predicate) {
if (!predicate.present) return true;
int64_t selected = 0;
if (!parse_canonical_int64(
effective_option_value(
package, selection, feature_id, predicate.option),
selected))
return false;
switch (predicate.op) {
case ModIntegerPredicateOp::Equal:
return selected == predicate.value;
case ModIntegerPredicateOp::NotEqual:
return selected != predicate.value;
case ModIntegerPredicateOp::Less:
return selected < predicate.value;
case ModIntegerPredicateOp::LessEqual:
return selected <= predicate.value;
case ModIntegerPredicateOp::Greater:
return selected > predicate.value;
case ModIntegerPredicateOp::GreaterEqual:
return selected >= predicate.value;
}
return false;
}
bool constraint_satisfied(
const ModPackage& package, const ModConstraint& constraint,
const std::function<std::string(const std::string&)>& value_for,
std::string* reason) {
if (constraint.kind != ModConstraintKind::OrderedInteger) return false;
for (size_t index = 1; index < constraint.options.size(); ++index) {
const std::string& previous_id = constraint.options[index - 1];
const std::string& current_id = constraint.options[index];
int64_t previous = 0;
int64_t current = 0;
if (!parse_canonical_int64(value_for(previous_id), previous) ||
!parse_canonical_int64(value_for(current_id), current)) {
if (reason)
*reason = "constraint references a non-integer value";
return false;
}
const bool violated =
constraint.direction == ModConstraintDirection::Nondecreasing
? previous > current
: previous < current;
if (violated) {
if (reason) {
const ModOption* previous_option = find_option(
package, constraint.feature_id, previous_id);
const ModOption* current_option = find_option(
package, constraint.feature_id, current_id);
*reason =
(previous_option ? previous_option->label : previous_id) +
(constraint.direction ==
ModConstraintDirection::Nondecreasing
? " must be less than or equal to "
: " must be greater than or equal to ") +
(current_option ? current_option->label : current_id);
}
return false;
}
}
return true;
}
bool checked_add_int64(int64_t left, int64_t right, int64_t& out) {
if ((right > 0 && left > std::numeric_limits<int64_t>::max() - right) ||
(right < 0 && left < std::numeric_limits<int64_t>::min() - right))
return false;
out = left + right;
return true;
}
uint64_t value_encoding_max(ModValueEncoding encoding) {
switch (encoding) {
case ModValueEncoding::U8:
return UINT8_MAX;
case ModValueEncoding::U16LE:
return UINT16_MAX;
case ModValueEncoding::U32LE:
return UINT32_MAX;
case ModValueEncoding::MipsLuiOriU32:
return UINT32_MAX;
}
return 0;
}
bool option_range_fits_encoding(const ModOption& option,
ModValueEncoding encoding,
int64_t addend) {
int64_t low = 0;
int64_t high = 0;
return checked_add_int64(option.min_value, addend, low) &&
checked_add_int64(option.max_value, addend, high) &&
low >= 0 &&
static_cast<uint64_t>(high) <= value_encoding_max(encoding);
}
bool encode_unsigned_value(ModValueEncoding encoding, int64_t value,
std::vector<uint8_t>& out) {
if (value < 0 ||
static_cast<uint64_t>(value) > value_encoding_max(encoding))
return false;
const uint32_t size = value_encoding_size(encoding);
out.resize(size);
const uint64_t encoded = static_cast<uint64_t>(value);
if (encoding == ModValueEncoding::MipsLuiOriU32) return false;
for (uint32_t i = 0; i < size; ++i)
out[i] = static_cast<uint8_t>(encoded >> (i * 8));
return true;
}
std::string fingerprint_text(const std::string& text) {
uint8_t digest[32];
psx_sha256_compute((const uint8_t*)text.data(), text.size(), digest);
std::ostringstream out;
for (uint8_t byte : digest)
out << std::hex << std::setw(2) << std::setfill('0') << (unsigned)byte;
return out.str();
}
} // namespace
bool mod_register_media_preparer(const std::string& id, ModMediaPreparer callback, ModMediaPreparer cache_probe) {
return valid_id(id) && callback && media_preparers().emplace(id, MediaPreparer{std::move(callback), std::move(cache_probe)}).second;
}
bool mod_register_builtin_resolver(const std::string& id, ModBuiltinResolver resolver) {
if (!valid_id(id) || !resolver) return false;
return builtin_resolvers().emplace(id, std::move(resolver)).second;
}
void mod_clear_builtin_resolvers_for_tests() {
builtin_resolvers().clear();
}
bool mod_register_activation_plugin(const std::string& id, void (*callback)(void)) {
if (!valid_id(id) || !callback) return false;
RegisteredPlugin& plugin = registered_plugins()[id];
if (plugin.activation) return false;
plugin.activation = callback;
return true;
}
bool mod_register_session_reset_plugin(const std::string& id, void (*callback)(void)) {
if (!valid_id(id) || !callback) return false;
RegisteredPlugin& plugin = registered_plugins()[id];
if (plugin.session_reset) return false;
plugin.session_reset = callback;
return true;
}
void mod_reset_plugin_sessions() {
for (const auto& entry : registered_plugins())
if (entry.second.session_reset) entry.second.session_reset();
}
bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)) {
if (!valid_id(id) || !callback) return false;
RegisteredPlugin& plugin = registered_plugins()[id];
if (plugin.vblank) return false;
plugin.vblank = callback;
return true;
}
bool mod_register_function_entry_plugin(const std::string& id, uint32_t address,
PSXModFunctionEntryCallback callback) {
if (!valid_id(id) || !address || !callback) return false;
RegisteredPlugin& plugin = registered_plugins()[id];
/* Code addresses alias across KUSEG/KSEG0/KSEG1; one function is one hook. */
for (const auto& hook : plugin.function_entries)
if (((hook.address ^ address) & 0x1FFFFFFFu) == 0u) return false;
plugin.function_entries.push_back({address, callback, nullptr});
return true;
}
bool mod_register_function_filter_plugin(const std::string& id, uint32_t address,
PSXModFunctionFilterCallback callback) {
if (!valid_id(id) || !address || !callback) return false;
RegisteredPlugin& plugin = registered_plugins()[id];
for (const auto& hook : plugin.function_entries)
if (((hook.address ^ address) & 0x1FFFFFFFu) == 0u) return false;
plugin.function_entries.push_back({address, nullptr, callback});
return true;
}
bool mod_plugin_registered(const std::string& id) {
const auto found = registered_plugins().find(id);
return found != registered_plugins().end() &&
(found->second.activation || found->second.session_reset || found->second.vblank || found->second.savestate ||
!found->second.function_entries.empty() || !found->second.guest_functions.empty() ||
!found->second.instructions.empty());
}
bool mod_register_guest_function_plugin(const std::string& id, uint32_t address,
PSXModFunctionEntryCallback callback) {
const uint32_t key = address & 0x1FFFFFFFu;
if (!valid_id(id) || !callback || (address & 3u) ||
key < 0x0F000000u || key >= 0x10000000u || address >= 0xC0000000u)
return false;
for (const auto& plugin : registered_plugins())
for (const auto& function : plugin.second.guest_functions)
if (function.address == key) return false;
registered_plugins()[id].guest_functions.push_back({key, callback, nullptr});
return true;
}
std::vector<ModFunctionEntryHook> mod_guest_functions(const std::string& id) {
const auto found = registered_plugins().find(id);
return found == registered_plugins().end()
? std::vector<ModFunctionEntryHook>{} : found->second.guest_functions;
}
bool mod_register_instruction_plugin(const std::string& id, uint32_t address,
uint32_t expected, PSXModFunctionEntryCallback callback) {
if (!valid_id(id) || !callback || (address & 3u) || address >= 0xC0000000u ||
(address & 0x1FFFFFFFu) >= 0x00800000u) return false;
auto& hooks = registered_plugins()[id].instructions;
const uint32_t key = address & 0x1FFFFFFFu;
for (const auto& hook : hooks)
if (hook.address == key) return false;
hooks.push_back({key, expected, callback});
return true;
}
std::vector<ModInstructionHook> mod_instruction_hooks(const std::string& id) {
const auto found = registered_plugins().find(id);
return found == registered_plugins().end()
? std::vector<ModInstructionHook>{} : found->second.instructions;
}
bool mod_register_savestate_plugin(const std::string& id, void (*callback)(void)) {
if (!valid_id(id) || !callback) return false;
RegisteredPlugin& plugin = registered_plugins()[id];
if (plugin.savestate) return false;
plugin.savestate = callback;
return true;
}
void mod_invoke_savestate_plugin(const std::string& id) {
const auto found = registered_plugins().find(id);
if (found != registered_plugins().end() && found->second.savestate)
found->second.savestate();
}
void mod_invoke_activation_plugin(const std::string& id) {
const auto found = registered_plugins().find(id);
if (found != registered_plugins().end() && found->second.activation)
found->second.activation();
}
void mod_invoke_vblank_plugin(const std::string& id) {
const auto found = registered_plugins().find(id);
if (found != registered_plugins().end() && found->second.vblank)
found->second.vblank();
}
std::vector<ModFunctionEntryHook> mod_function_entry_hooks(const std::string& id) {
std::vector<ModFunctionEntryHook> hooks;
const auto found = registered_plugins().find(id);
if (found == registered_plugins().end()) return hooks;
return found->second.function_entries;
}
void mod_clear_plugins_for_tests() {
registered_plugins().clear();
}
std::vector<std::string> mod_registered_plugin_ids() {
std::vector<std::string> ids;
for (const auto& entry : registered_plugins())
if (entry.second.activation || entry.second.session_reset || entry.second.vblank ||
!entry.second.function_entries.empty() || !entry.second.guest_functions.empty())
ids.push_back(entry.first);
return ids; /* std::map keeps them sorted */
}
ModPluginAudit mod_audit_registered_plugins(
const std::vector<fs::path>& manifest_roots) {
ModPluginAudit audit;
std::set<std::string> declared;
for (const fs::path& root : manifest_roots) {
std::error_code ec;
if (!fs::is_directory(root, ec)) {
audit.errors.push_back(root.string() + ": not a directory");
continue;
}
for (fs::recursive_directory_iterator it(root, ec), end;
!ec && it != end; it.increment(ec)) {
if (!it->is_regular_file(ec) || it->path().filename() != "manifest.toml")
continue;
ModPackage package;
std::string error;
if (!ModPackageManager::read_manifest(it->path(), package, &error)) {
audit.errors.push_back(it->path().string() + ": " + error);
continue;
}
++audit.manifests;
for (const ModPlugin& plugin : package.plugins)
declared.insert(plugin.id);
}
if (ec) audit.errors.push_back(root.string() + ": " + ec.message());
}
audit.declared.assign(declared.begin(), declared.end());
for (const std::string& id : mod_registered_plugin_ids())
if (!declared.count(id)) audit.undeclared.push_back(id);
return audit;
}
const char* mod_channel_name(ModChannel channel) {
switch (channel) {
case ModChannel::Experimental: return "experimental";
case ModChannel::Developer: return "developer";
case ModChannel::Stable: break;
}
return "stable";
}
bool mod_channel_from_name(const std::string& name, ModChannel& out) {
if (name == "stable") { out = ModChannel::Stable; return true; }
if (name == "experimental") { out = ModChannel::Experimental; return true; }
if (name == "developer") { out = ModChannel::Developer; return true; }
return false;
}
namespace {
/* Remove every developer-channel feature and everything that only existed to
* serve one. "Developer does not ship" means absent, not hidden: a release
* build must not carry the operations either, or a stale state.toml naming a
* dropped feature could still reach them.
*
* Returns false when nothing is left, so the caller can drop the package
* rather than list one with no features. */
bool strip_developer_features(ModPackage& package) {
std::set<std::string> dropped;
for (const ModFeature& feature : package.features)
if (feature.channel == ModChannel::Developer)
dropped.insert(feature.id);
if (dropped.empty()) return !package.features.empty();
const auto orphaned = [&dropped](const std::string& feature_id) {
return dropped.count(feature_id) != 0;
};
const auto prune = [&orphaned](auto& entries) {
entries.erase(std::remove_if(entries.begin(), entries.end(),
[&orphaned](const auto& entry) {
return orphaned(entry.feature_id);
}),
entries.end());
};
package.features.erase(
std::remove_if(package.features.begin(), package.features.end(),
[&orphaned](const ModFeature& feature) {
return orphaned(feature.id);
}),
package.features.end());
prune(package.options);
prune(package.constraints);
prune(package.requirements);
prune(package.patches);
prune(package.overlays);
prune(package.plugins);
prune(package.resources);
return !package.features.empty();
}
} // namespace
ModPackageManager::ModPackageManager(fs::path mods_root) : root_(std::move(mods_root)) {}
void ModPackageManager::set_root(fs::path mods_root) {
root_ = std::move(mods_root);
packages_.clear();
selections_.clear();
sources_.clear();
generated_resource_defaults_.clear();
}
bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out,
std::string* error) {
try {
const toml::value cfg = toml::parse(path.string());
out = {};
out.format_version = (uint32_t)toml::find<int64_t>(cfg, "format_version");
out.id = toml::find<std::string>(cfg, "id");
out.version = toml::find<std::string>(cfg, "version");
out.name = toml::find<std::string>(cfg, "name");
out.author = cfg.contains("author") ? toml::find<std::string>(cfg, "author") : "";
if (cfg.contains("author_link")) {
std::set<std::string> linked_authors;
for (const toml::value& v : toml::find(cfg, "author_link").as_array()) {
ModAuthorLink link;
link.name = toml::find<std::string>(v, "name");
link.url = toml::find<std::string>(v, "url");
if (link.name.empty() || !linked_authors.insert(link.name).second)
throw std::runtime_error("empty or duplicate author link name");
if (!valid_web_url(link.url))
throw std::runtime_error("author link URL must use http or https");
out.author_links.push_back(std::move(link));
}
}
out.description =
cfg.contains("description") ? toml::find<std::string>(cfg, "description") : "";
out.license = cfg.contains("license") ? toml::find<std::string>(cfg, "license") : "";
out.source_name =
cfg.contains("source_name") ? toml::find<std::string>(cfg, "source_name") : "";
out.source_url =
cfg.contains("source_url") ? toml::find<std::string>(cfg, "source_url") : "";
if (!out.source_url.empty() && !valid_web_url(out.source_url))
throw std::runtime_error("source URL must use http or https");
if (!out.source_url.empty() && out.source_name.empty())
out.source_name = "Project page";
out.resolver =
cfg.contains("resolver") ? toml::find<std::string>(cfg, "resolver") : "declarative";
out.save_compatibility = cfg.contains("save_compatibility")
? toml::find<std::string>(cfg, "save_compatibility") : "shared";
/* Accepted at every format version: v5 manifests already carried a
* package-level channel, enforced then only by a packaging grep over
* the manifest text. Now it parses, and it seeds the per-feature
* default rather than quarantining the whole package. */
if (cfg.contains("channel")) {
const std::string name = toml::find<std::string>(cfg, "channel");
if (!mod_channel_from_name(name, out.channel))
throw std::runtime_error(
"channel must be stable, experimental or developer");
}
out.root = path.parent_path();
if (out.format_version < kMinFormatVersion ||
out.format_version > kMaxFormatVersion)
throw std::runtime_error("unsupported format_version");
if (!valid_id(out.id)) throw std::runtime_error("invalid package id");
if (!parse_semver(out.version).valid) throw std::runtime_error("invalid semantic version");
if (out.name.empty()) throw std::runtime_error("package name is empty");
if (out.resolver != "declarative" && out.resolver.rfind("builtin:", 0) != 0)
throw std::runtime_error("resolver must be declarative or builtin:<id>");
if (out.save_compatibility != "shared" && out.save_compatibility != "isolated")
throw std::runtime_error("save_compatibility must be shared or isolated");
out.prepare = toml::find_or<std::string>(cfg, "prepare", "");
if (!out.prepare.empty() && (out.format_version < 9 || !valid_id(out.prepare)))
throw std::runtime_error("media preparation requires format_version 9 and a valid provider id");
if (cfg.contains("target")) {
for (const toml::value& v : toml::find(cfg, "target").as_array()) {
ModTarget target;
target.game_id = toml::find<std::string>(v, "game_id");
target.exe_sha256 =
v.contains("exe_sha256") ? toml::find<std::string>(v, "exe_sha256") : "";
target.disc_sha256 =
v.contains("disc_sha256") ? toml::find<std::string>(v, "disc_sha256") : "";
if (target.game_id.empty()) throw std::runtime_error("target game_id is empty");
out.targets.push_back(std::move(target));
}
}
if (out.targets.empty()) throw std::runtime_error("package has no [[target]] entries");
if (cfg.contains("dependency")) {
for (const toml::value& v : toml::find(cfg, "dependency").as_array()) {
ModRequirement dep;
dep.id = toml::find<std::string>(v, "id");
dep.version = v.contains("version") ? toml::find<std::string>(v, "version") : "*";
if (!valid_id(dep.id)) throw std::runtime_error("invalid dependency id");
out.dependencies.push_back(std::move(dep));
}
}
if (cfg.contains("conflicts"))
out.conflicts = toml::find<std::vector<std::string>>(cfg, "conflicts");
for (const std::string& id : out.conflicts)
if (!valid_id(id)) throw std::runtime_error("invalid conflict id");
const bool feature_style = cfg.contains("feature");
if (feature_style) {
std::set<std::string> feature_ids;
for (const toml::value& v : toml::find(cfg, "feature").as_array()) {
ModFeature feature;
feature.id = toml::find<std::string>(v, "id");
feature.name = toml::find<std::string>(v, "name");
feature.author = v.contains("author")
? toml::find<std::string>(v, "author") : "";
feature.description = v.contains("description")
? toml::find<std::string>(v, "description") : "";
feature.group = v.contains("group")
? toml::find<std::string>(v, "group") : "General";
feature.default_enabled =
toml::find_or<bool>(v, "default_enabled", false);
feature.hidden = toml::find_or<bool>(v, "hidden", false);
feature.channel = out.channel;
if (v.contains("channel")) {
if (out.format_version < 6)
throw std::runtime_error(
"per-feature channel requires format_version 6");
const std::string name = toml::find<std::string>(v, "channel");
if (!mod_channel_from_name(name, feature.channel))
throw std::runtime_error(
"channel must be stable, experimental or developer");
}
if (!valid_id(feature.id) ||
!feature_ids.insert(feature.id).second)
throw std::runtime_error("invalid or duplicate feature id");
if (feature.name.empty())
throw std::runtime_error("feature name is empty");
out.features.push_back(std::move(feature));
}
if (out.features.empty())
throw std::runtime_error("package has no [[feature]] entries");
} else {
ModFeature feature;
feature.id = "legacy";
feature.name = out.name;
feature.author = out.author;
feature.description = out.description;
feature.channel = out.channel;
feature.legacy = true;
out.features.push_back(std::move(feature));
}
if (cfg.contains("option")) {
std::set<std::pair<std::string, std::string>> option_ids;
for (const toml::value& v : toml::find(cfg, "option").as_array()) {
ModOption option;
option.feature_id = feature_style
? toml::find<std::string>(v, "feature") : "legacy";
option.id = toml::find<std::string>(v, "id");
option.label = toml::find<std::string>(v, "label");
option.description =
v.contains("description") ? toml::find<std::string>(v, "description") : "";
option.group = v.contains("group") ? toml::find<std::string>(v, "group") : "General";
option.disabled_by =
v.contains("disabled_by") ? toml::find<std::string>(v, "disabled_by") : "";
const std::string type = toml::find<std::string>(v, "type");
if (!find_feature(out, option.feature_id))
throw std::runtime_error("option references unknown feature");
if (!valid_id(option.id) ||
!option_ids.insert({option.feature_id, option.id}).second)
throw std::runtime_error("invalid or duplicate option id");
if (type == "boolean") {
option.type = ModOptionType::Boolean;
option.default_value = toml::find_or<std::string>(v, "default", "false");
if (option.default_value != "true" && option.default_value != "false")
throw std::runtime_error("boolean default must be true or false");
} else if (type == "choice") {
option.type = ModOptionType::Choice;
option.default_value = toml::find<std::string>(v, "default");
for (const toml::value& c : toml::find(v, "choice").as_array()) {
ModChoice choice;
choice.value = toml::find<std::string>(c, "value");
choice.label = toml::find<std::string>(c, "label");
option.choices.push_back(std::move(choice));
}
const auto found = std::find_if(option.choices.begin(), option.choices.end(),
[&](const ModChoice& c) { return c.value == option.default_value; });
if (found == option.choices.end())
throw std::runtime_error("choice default is not declared");
} else if (type == "integer") {
option.type = ModOptionType::Integer;
option.min_value = toml::find<int64_t>(v, "min");
option.max_value = toml::find<int64_t>(v, "max");
option.step = toml::find_or<int64_t>(v, "step", 1);
const int64_t def = toml::find<int64_t>(v, "default");
if (option.min_value > option.max_value || option.step <= 0 ||
def < option.min_value || def > option.max_value ||
!integer_step_aligned(
def, option.min_value, option.step))
throw std::runtime_error("invalid integer bounds/default");
option.default_value = std::to_string(def);
} else {
throw std::runtime_error("unknown option type");
}
out.options.push_back(std::move(option));
}
/* Resolve disabled_by AFTER the whole list is read, so it may name
* an option declared later. A dangling or non-boolean reference is
* a manifest bug that would silently leave the control always
* enabled, so reject it here rather than at render time. */
for (const ModOption& option : out.options) {
if (option.disabled_by.empty()) continue;
if (option.disabled_by == option.id)
throw std::runtime_error("option disabled_by references itself");
const auto owner = std::find_if(
out.options.begin(), out.options.end(),
[&](const ModOption& o) {
return o.feature_id == option.feature_id &&
o.id == option.disabled_by;
});
if (owner == out.options.end() ||
owner->type != ModOptionType::Boolean)
throw std::runtime_error(
"option disabled_by must name a boolean option "
"in the same feature");
}
}
if (cfg.contains("resource")) {
if (out.format_version < 5)
throw std::runtime_error(
"resources require format_version 5");
std::set<std::pair<std::string, std::string>> resource_ids;
for (const toml::value& v :
toml::find(cfg, "resource").as_array()) {
ModResource resource;
resource.feature_id = feature_style
? toml::find<std::string>(v, "feature") : "legacy";
resource.id = toml::find<std::string>(v, "id");
resource.label = toml::find<std::string>(v, "label");
resource.description =
toml::find_or<std::string>(v, "description", "");
resource.file_patterns =
toml::find_or<std::string>(v, "file_patterns", "");
resource.file_description =
toml::find_or<std::string>(v, "file_description", "");
resource.format =
toml::find_or<std::string>(v, "format", "file");
resource.required =
toml::find_or<bool>(v, "required", false);
if (v.contains("size") || v.contains("sha256") ||
resource.format == "n64-rom" || resource.format == "psx-disc") {
if (out.format_version < 8)
throw std::runtime_error("verified media requires format_version 8");
const int64_t size = toml::find<int64_t>(v, "size");
resource.sha256 = toml::find<std::string>(v, "sha256");
if (size <= 0 || size > 512ll * 1024 * 1024 ||
!valid_sha256(resource.sha256) ||
(resource.format != "file" && resource.format != "n64-rom" &&
resource.format != "psx-disc"))
throw std::runtime_error("invalid verified media identity or format");
resource.size = static_cast<uint64_t>(size);
}
resource.shared_source = toml::find_or<std::string>(v, "shared_source", "");
resource.input_only = toml::find_or<bool>(v, "input_only", false);
resource.hidden = toml::find_or<bool>(v, "hidden", false);
resource.default_path = toml::find_or<std::string>(v, "default", "");
if (!resource.default_path.empty() &&
(!safe_archive_name(resource.default_path) ||
!resource.sha256.empty() || resource.input_only ||
!resource.shared_source.empty() ||
(resource.format != "file" && resource.format != "directory" &&
resource.format != "folder")))
throw std::runtime_error(
"resource default must be a safe package-relative file or folder");
if ((resource.input_only || resource.hidden || !resource.shared_source.empty()) && out.format_version < 9)
throw std::runtime_error("source bindings require format_version 9");
if (!resource.shared_source.empty() && (!resource.input_only || !valid_id(resource.shared_source)))
throw std::runtime_error("shared_source requires an input resource and valid id");
if (resource.input_only && out.prepare.empty())
throw std::runtime_error("source inputs require a trusted preparer");
if (!find_feature(out, resource.feature_id))
throw std::runtime_error(
"resource references unknown feature");
if (!valid_id(resource.id) ||
!resource_ids.insert(
{resource.feature_id, resource.id}).second)
throw std::runtime_error(
"invalid or duplicate resource id");
if (resource.label.empty())
throw std::runtime_error("resource label is empty");
if (resource.format != "file" &&
resource.format != "directory" &&
resource.format != "folder" &&
resource.format != "n64-rom" &&
resource.format != "psx-disc")
throw std::runtime_error(
"unsupported resource format");
out.resources.push_back(std::move(resource));
}
}
if (cfg.contains("constraint")) {
if (out.format_version < 3)
throw std::runtime_error(
"constraints require format_version 3");
for (const toml::value& v :
toml::find(cfg, "constraint").as_array()) {
ModConstraint constraint;
constraint.feature_id =
toml::find<std::string>(v, "feature");
const std::string kind =
toml::find<std::string>(v, "kind");
if (!find_feature(out, constraint.feature_id))
throw std::runtime_error(
"constraint references unknown feature");
if (kind == "ordered_integer") {
const std::string direction =
toml::find<std::string>(v, "direction");
constraint.options =
toml::find<std::vector<std::string>>(v, "options");
if (direction == "nondecreasing") {
constraint.direction =
ModConstraintDirection::Nondecreasing;
} else if (direction == "nonincreasing") {
constraint.direction =
ModConstraintDirection::Nonincreasing;
} else {
throw std::runtime_error(
"ordered_integer direction must be "
"nondecreasing or nonincreasing");
}
if (constraint.options.size() < 2)
throw std::runtime_error(
"ordered_integer requires at least two options");
std::set<std::string> option_ids;
for (const std::string& id : constraint.options) {
const ModOption* option =
find_option(out, constraint.feature_id, id);
if (!option || option->type != ModOptionType::Integer)
throw std::runtime_error(
"ordered_integer must reference same-feature "
"integer options");
if (!option_ids.insert(id).second)
throw std::runtime_error(
"ordered_integer contains a duplicate option");
}
std::string reason;
if (!constraint_satisfied(
out, constraint,
[&](const std::string& id) {
return find_option(
out, constraint.feature_id, id)
->default_value;
},
&reason))
throw std::runtime_error(
"constraint defaults are invalid: " + reason);
} else if (kind == "requires_feature") {
if (out.format_version < 4)
throw std::runtime_error(
"requires_feature constraints require format_version 4");
constraint.kind = ModConstraintKind::RequiresFeature;
constraint.required_feature_id =
toml::find<std::string>(v, "requires_feature");
if (!find_feature(out, constraint.required_feature_id))
throw std::runtime_error(
"requires_feature references unknown feature");
constraint.required_option_id =
toml::find_or<std::string>(v, "requires_option", "");
constraint.required_value =
toml::find_or<std::string>(v, "requires_value", "");
if (constraint.required_option_id.empty() !=
constraint.required_value.empty())
throw std::runtime_error(
"requires_feature option constraint requires both "
"requires_option and requires_value");
if (!constraint.required_option_id.empty()) {
const ModOption* option = find_option(
out, constraint.required_feature_id,
constraint.required_option_id);
if (!option)
throw std::runtime_error(
"requires_feature references unknown option");
if (!valid_option_value(
*option, constraint.required_value))
throw std::runtime_error(
"requires_feature references invalid option value");
}
} else {
throw std::runtime_error("unknown constraint kind");
}
out.constraints.push_back(std::move(constraint));
}
}
if (cfg.contains("requirement")) {
if (!feature_style)
throw std::runtime_error(
"requirements require explicit [[feature]] ownership");
if (out.format_version < 7)
throw std::runtime_error(
"requirements require format_version 7");
for (const toml::value& v :
toml::find(cfg, "requirement").as_array()) {
for (const auto& [key, unused] : v.as_table()) {
(void)unused;
if (key != "feature" && key != "package" &&
key != "version" && key != "requires_feature" &&
key != "when" && key != "when_option" &&
key != "when_value")
throw std::runtime_error(
"requirement has unknown field: " + key);
}
ModFeatureRequirement requirement;
requirement.feature_id =
toml::find<std::string>(v, "feature");
if (!find_feature(out, requirement.feature_id))
throw std::runtime_error(
"requirement references unknown feature");
requirement.package_id =
toml::find<std::string>(v, "package");
requirement.required_feature_id =
toml::find<std::string>(v, "requires_feature");
requirement.version =
toml::find_or<std::string>(v, "version", "*");
if (!valid_id(requirement.package_id) ||
!valid_id(requirement.required_feature_id))
throw std::runtime_error(
"requirement references an invalid id");
/* One mechanism per relationship: a feature of the same
* package is a requires_feature [[constraint]], which the
* launcher enables explicitly and persists. */
if (requirement.package_id == out.id)
throw std::runtime_error(
"requirement names its own package; use a "
"requires_feature [[constraint]] instead");
if (requirement.version.empty())
throw std::runtime_error(
"requirement version is empty");
read_conditions(v, out.options, requirement.feature_id,
requirement.when, "requirement");
out.requirements.push_back(std::move(requirement));
}
}
if (cfg.contains("patch")) {
size_t declaration_index = 0;
for (const toml::value& v : toml::find(cfg, "patch").as_array()) {
ModPatch patch;
patch.feature_id = feature_style
? toml::find<std::string>(v, "feature") : "legacy";
if (!find_feature(out, patch.feature_id))
throw std::runtime_error("patch references unknown feature");
const std::string target = toml::find<std::string>(v, "target");
if (target == "main_exe") {
patch.target = ModPatchTarget::MainExe;
const int64_t address = toml::find<int64_t>(v, "address");
if (address < 0) throw std::runtime_error("patch address is negative");
patch.location = (uint64_t)address;
} else if (target == "disc_raw" || target == "disc") {
patch.target = ModPatchTarget::DiscRaw;
const int64_t offset = toml::find<int64_t>(v, "offset");
if (offset < 0) throw std::runtime_error("patch offset is negative");
patch.location = (uint64_t)offset;
} else if (target == "disc_user") {
patch.target = ModPatchTarget::DiscUser;
const int64_t offset = toml::find<int64_t>(v, "offset");
if (offset < 0) throw std::runtime_error("patch offset is negative");
patch.location = (uint64_t)offset;
} else {
throw std::runtime_error(
"patch target must be main_exe, disc_raw, or disc_user");
}
const std::string expected = toml::find<std::string>(v, "expected");
if (!parse_hex_bytes(expected, patch.expected) ||
patch.expected.empty())
throw std::runtime_error(
"patch expected must be non-empty hex");
const bool has_static_replace = v.contains("replace");
const bool has_dynamic_replace = v.contains("replace_from");
const bool has_sparse_fields = v.contains("fields");
const unsigned replacement_forms =
static_cast<unsigned>(has_static_replace) +
static_cast<unsigned>(has_dynamic_replace) +
static_cast<unsigned>(has_sparse_fields);
if (replacement_forms != 1)
throw std::runtime_error(
"patch requires exactly one of replace, replace_from, "
"or fields");
if (has_static_replace) {
const std::string replacement =
toml::find<std::string>(v, "replace");
if (!parse_hex_bytes(replacement, patch.replacement) ||
patch.expected.size() != patch.replacement.size())
throw std::runtime_error(
"patch expected/replace must be equal-length non-empty hex");
} else if (has_dynamic_replace) {
if (out.format_version < 2)
throw std::runtime_error(
"replace_from requires format_version 2");
const toml::value& replacement =
toml::find(v, "replace_from");
const auto& table = replacement.as_table();
for (const auto& [key, unused] : table) {
(void)unused;
if (key != "option" && key != "encoding" &&
key != "offset" && key != "addend" &&
key != "omit_when_default")
throw std::runtime_error(
"replace_from has unknown field: " + key);
}
patch.replace_from_option =
toml::find<std::string>(replacement, "option");
const std::string encoding =
toml::find<std::string>(replacement, "encoding");
const int64_t replace_offset =
toml::find_or<int64_t>(replacement, "offset", 0);
if (replace_offset < 0)
throw std::runtime_error(
"replace_from offset must not be negative");
patch.replace_offset =
static_cast<uint64_t>(replace_offset);
patch.replace_addend =
toml::find_or<int64_t>(replacement, "addend", 0);
patch.replace_omit_when_default =
toml::find_or<bool>(
replacement, "omit_when_default", false);
if (!parse_value_encoding(
encoding, patch.replace_encoding))
throw std::runtime_error(
"replace_from encoding is unsupported");
const bool mips_pair_encoding =
patch.replace_encoding ==
ModValueEncoding::MipsLuiOriU32;
if ((mips_pair_encoding ||
patch.replace_omit_when_default) &&
out.format_version < 3)
throw std::runtime_error(
"typed MIPS encodings and omit_when_default "
"require format_version 3");
const ModOption* option = find_option(
out, patch.feature_id, patch.replace_from_option);
if (!option || option->type != ModOptionType::Integer)
throw std::runtime_error(
"replace_from must reference a same-feature integer option");
const uint64_t encoded_size =
value_encoding_size(patch.replace_encoding);
if (patch.replace_offset > patch.expected.size() ||
encoded_size >
patch.expected.size() - patch.replace_offset)
throw std::runtime_error(
"replace_from value exceeds expected byte range");
if (!option_range_fits_encoding(
*option, patch.replace_encoding,
patch.replace_addend))
throw std::runtime_error(
"replace_from option range/addend does not fit encoding");
if (mips_pair_encoding) {
if (patch.target != ModPatchTarget::MainExe ||
patch.expected.size() != 8 ||
patch.replace_offset != 0 ||
patch.replace_addend != 0 ||
patch.location % 4 != 0)
throw std::runtime_error(
"typed MIPS LUI/ORI encoding requires one "
"aligned complete main_exe instruction pair "
"at offset zero without an addend");
const uint32_t lui =
static_cast<uint32_t>(patch.expected[0]) |
(static_cast<uint32_t>(patch.expected[1]) << 8) |
(static_cast<uint32_t>(patch.expected[2]) << 16) |
(static_cast<uint32_t>(patch.expected[3]) << 24);
const uint32_t ori =
static_cast<uint32_t>(patch.expected[4]) |
(static_cast<uint32_t>(patch.expected[5]) << 8) |
(static_cast<uint32_t>(patch.expected[6]) << 16) |
(static_cast<uint32_t>(patch.expected[7]) << 24);
const uint32_t lui_rs = (lui >> 21) & 0x1Fu;
const uint32_t lui_rt = (lui >> 16) & 0x1Fu;
const uint32_t ori_rs = (ori >> 21) & 0x1Fu;
const uint32_t ori_rt = (ori >> 16) & 0x1Fu;
if ((lui >> 26) != 0x0Fu || lui_rs != 0 ||
lui_rt == 0 || (ori >> 26) != 0x0Du ||
ori_rs != lui_rt || ori_rt != lui_rt)
throw std::runtime_error(
"typed MIPS encoding guard is not a linked "
"LUI/ORI register pair");
}
} else {
if (out.format_version < 4)
throw std::runtime_error(
"sparse patch fields require format_version 4");
const toml::value fields_value =
toml::find(v, "fields");
const toml::array& fields =
fields_value.as_array();
if (fields.empty())
throw std::runtime_error(
"sparse patch fields must not be empty");
std::vector<RelativeRange> claimed;
for (const toml::value& field_value : fields) {
const auto& table = field_value.as_table();
for (const auto& [key, unused] : table) {
(void)unused;
if (key != "offset" && key != "replace" &&
key != "option" && key != "encoding" &&
key != "addend")
throw std::runtime_error(
"sparse patch field has unknown field: " +
key);
}
ModPatchField field;
const int64_t field_offset =
toml::find_or<int64_t>(
field_value, "offset", 0);
if (field_offset < 0)
throw std::runtime_error(
"sparse patch field offset must not be "
"negative");
field.offset =
static_cast<uint64_t>(field_offset);
const bool literal =
field_value.contains("replace");
const bool dynamic =
field_value.contains("option") ||
field_value.contains("encoding") ||
field_value.contains("addend");
if (literal == dynamic)
throw std::runtime_error(
"sparse patch field requires exactly one of "
"literal replace or option encoding");
if (literal) {
if (!parse_hex_bytes(
toml::find<std::string>(
field_value, "replace"),
field.replacement) ||
field.replacement.empty())
throw std::runtime_error(
"sparse patch literal field must be "
"non-empty hex");
} else {
if (!field_value.contains("option") ||
!field_value.contains("encoding"))
throw std::runtime_error(
"sparse dynamic field requires option "
"and encoding");
field.replace_from_option =
toml::find<std::string>(
field_value, "option");
if (!parse_value_encoding(
toml::find<std::string>(
field_value, "encoding"),
field.replace_encoding))
throw std::runtime_error(
"sparse dynamic field encoding is "
"unsupported");
field.replace_addend =
toml::find_or<int64_t>(
field_value, "addend", 0);
const ModOption* option = find_option(
out, patch.feature_id,
field.replace_from_option);
if (!option ||
option->type != ModOptionType::Integer)
throw std::runtime_error(
"sparse dynamic field must reference a "
"same-feature integer option");
if (!option_range_fits_encoding(
*option, field.replace_encoding,
field.replace_addend))
throw std::runtime_error(
"sparse dynamic field option range/"
"addend does not fit encoding");
if (field.replace_encoding ==
ModValueEncoding::MipsLuiOriU32) {
if (patch.target !=
ModPatchTarget::MainExe ||
field.replace_addend != 0 ||
(patch.location + field.offset) % 4 != 0 ||
!valid_mips_lui_ori_guard(
patch.expected, field.offset))
throw std::runtime_error(
"sparse typed MIPS field requires an "
"aligned linked main_exe LUI/ORI "
"guard without an addend");
}
}
const uint64_t guard_span =
field.replacement.empty()
? value_encoding_size(
field.replace_encoding)
: field.replacement.size();
if (field.offset > patch.expected.size() ||
guard_span >
patch.expected.size() - field.offset)
throw std::runtime_error(
"sparse patch field exceeds expected guard");
for (const RelativeRange& range :
patch_field_ranges(field)) {
for (const RelativeRange& previous : claimed) {
if (range.offset <
previous.offset + previous.size &&
previous.offset <
range.offset + range.size)
throw std::runtime_error(
"sparse patch fields overlap");
}
claimed.push_back(range);
}
patch.fields.push_back(std::move(field));
}
}
const uint64_t sector_size =
patch.target == ModPatchTarget::DiscRaw ? 2352 :
patch.target == ModPatchTarget::DiscUser ? 2048 : 0;
if (sector_size != 0 &&
patch.location % sector_size + patch.expected.size() > sector_size)
throw std::runtime_error(
"disc patch may not cross a sector boundary");
if (patch.target == ModPatchTarget::MainExe &&
(patch.location > UINT32_MAX ||
patch.expected.size() >
static_cast<uint64_t>(UINT32_MAX) + 1 -
patch.location))
throw std::runtime_error(
"main_exe patch exceeds 32-bit guest address space");
if (sector_size != 0 &&
patch.location / sector_size > UINT32_MAX)
throw std::runtime_error(
"disc patch LBA exceeds runtime index range");
patch.order = toml::find_or<int64_t>(
v, "order", (int64_t)declaration_index);
read_conditions(v, out.options, patch.feature_id, patch.when, "patch");
if (!patch.when.empty()) {
patch.when_option = patch.when.begin()->first;
patch.when_value = patch.when.begin()->second;
}
if (v.contains("when_integer")) {
if (out.format_version < 4)
throw std::runtime_error(
"when_integer requires format_version 4");
const toml::value predicate =
toml::find(v, "when_integer");
const auto& table = predicate.as_table();
for (const auto& [key, unused] : table) {
(void)unused;
if (key != "option" && key != "op" &&
key != "value")
throw std::runtime_error(
"when_integer has unknown field: " + key);
}
patch.when_integer.present = true;
patch.when_integer.option =
toml::find<std::string>(predicate, "option");
if (!parse_integer_predicate_op(
toml::find<std::string>(predicate, "op"),
patch.when_integer.op))
throw std::runtime_error(
"when_integer op must be eq, ne, lt, le, gt, "
"or ge");
patch.when_integer.value =
toml::find<int64_t>(predicate, "value");
const ModOption* option = find_option(
out, patch.feature_id,
patch.when_integer.option);
if (!option ||
option->type != ModOptionType::Integer)
throw std::runtime_error(
"when_integer must reference a same-feature "
"integer option");
if (patch.when_integer.value < option->min_value ||
patch.when_integer.value > option->max_value)
throw std::runtime_error(
"when_integer value is outside option bounds");
if ((patch.when_integer.op ==
ModIntegerPredicateOp::Equal ||
patch.when_integer.op ==
ModIntegerPredicateOp::NotEqual) &&
!integer_step_aligned(
patch.when_integer.value,
option->min_value, option->step))
throw std::runtime_error(
"when_integer equality value is not selectable");
}
out.patches.push_back(std::move(patch));
++declaration_index;
}
}
if (cfg.contains("overlay")) {
if (!feature_style)
throw std::runtime_error(
"disc overlays require explicit [[feature]] ownership");
size_t declaration_index = 0;
for (const toml::value& v : toml::find(cfg, "overlay").as_array()) {
ModOverlay overlay;
overlay.feature_id = toml::find<std::string>(v, "feature");
if (!find_feature(out, overlay.feature_id))
throw std::runtime_error("overlay references unknown feature");
const std::string target = toml::find<std::string>(v, "target");
if (target == "disc_raw")
overlay.target = ModPatchTarget::DiscRaw;
else if (target == "disc_user")
overlay.target = ModPatchTarget::DiscUser;
else
throw std::runtime_error(
"overlay target must be disc_raw or disc_user");
const int64_t offset = toml::find<int64_t>(v, "offset");
if (offset < 0)
throw std::runtime_error("overlay offset is negative");
overlay.location = (uint64_t)offset;
if (v.contains("file") == v.contains("resource"))
throw std::runtime_error("overlay requires exactly one file or resource");
if (v.contains("resource")) {
if (out.format_version < 10)
throw std::runtime_error("resource overlays require format_version 10");
overlay.resource_id = toml::find<std::string>(v, "resource");
const auto* resource = find_resource(out, overlay.feature_id, overlay.resource_id);
if (!resource || resource->input_only || resource->format != "file" ||
resource->size == 0 || !valid_sha256(resource->sha256))
throw std::runtime_error("overlay requires a verified same-feature file resource");
const int64_t source_offset = toml::find_or<int64_t>(v, "resource_offset", 0);
const int64_t size = toml::find<int64_t>(v, "size");
if (source_offset < 0 || size <= 0 || uint64_t(source_offset) > resource->size ||
uint64_t(size) > resource->size - uint64_t(source_offset))
throw std::runtime_error("overlay resource slice is out of bounds");
overlay.resource_offset = uint64_t(source_offset);
overlay.size = uint64_t(size);
} else {
const std::string relative_file = toml::find<std::string>(v, "file");
if (!safe_archive_name(relative_file))
throw std::runtime_error("overlay file path is unsafe");
overlay.file = out.root / fs::path(relative_file);
}
overlay.sha256 = toml::find<std::string>(v, "sha256");
overlay.expected_sha256 = v.contains("expected_sha256")
? toml::find<std::string>(v, "expected_sha256") : "";
if (!valid_sha256(overlay.sha256) ||
(!overlay.expected_sha256.empty() &&
!valid_sha256(overlay.expected_sha256)))
throw std::runtime_error(
"overlay hashes must be lowercase SHA-256");
if (overlay.resource_id.empty()) {
std::string file_error;
std::vector<uint8_t> payload;
if (!read_file(overlay.file, payload, &file_error))
throw std::runtime_error(file_error);
if (payload.empty())
throw std::runtime_error("overlay payload is empty");
const std::string actual = fingerprint_text(std::string(
(const char*)payload.data(), payload.size()));
if (actual != overlay.sha256)
throw std::runtime_error("overlay payload checksum failed");
overlay.size = payload.size();
} else if (overlay.expected_sha256.empty()) {
throw std::runtime_error("resource overlay requires expected_sha256");
}
if (overlay.location >
std::numeric_limits<uint64_t>::max() - overlay.size)
throw std::runtime_error("overlay range overflows");
overlay.order = toml::find_or<int64_t>(
v, "order", (int64_t)declaration_index);
read_conditions(v, out.options, overlay.feature_id,
overlay.when, "overlay");
if (v.contains("when_feature")) {
if (out.format_version < 4)
throw std::runtime_error(
"overlay when_feature requires format_version 4");
const toml::value& predicate =
toml::find(v, "when_feature");
for (const auto& [key, unused] : predicate.as_table()) {
(void)unused;
if (key != "package" && key != "feature" &&
key != "enabled")
throw std::runtime_error(
"overlay when_feature has unknown field: " +
key);
}
overlay.when_feature.present = true;
overlay.when_feature.package_id =
toml::find<std::string>(predicate, "package");
overlay.when_feature.feature_id =
toml::find<std::string>(predicate, "feature");
overlay.when_feature.enabled =
toml::find<bool>(predicate, "enabled");
if (!valid_id(overlay.when_feature.package_id) ||
!valid_id(overlay.when_feature.feature_id))
throw std::runtime_error(
"overlay when_feature references an invalid id");
}
out.overlays.push_back(std::move(overlay));
++declaration_index;
}
const bool guarded_stock = std::all_of(
out.targets.begin(), out.targets.end(),
[](const ModTarget& target) {
return valid_sha256(target.disc_sha256);
});
if (!out.overlays.empty() && !guarded_stock)
throw std::runtime_error(
"feature disc overlays require an exact disc_sha256 "
"on every [[target]]");
}
if (cfg.contains("plugin")) {
if (!feature_style)
throw std::runtime_error(
"plugins require explicit [[feature]] ownership");
if (out.format_version < 5)
throw std::runtime_error(
"plugins require format_version 5");
size_t declaration_index = 0;
for (const toml::value& v :
toml::find(cfg, "plugin").as_array()) {
ModPlugin plugin;
plugin.feature_id =
toml::find<std::string>(v, "feature");
if (!find_feature(out, plugin.feature_id))
throw std::runtime_error(
"plugin references unknown feature");
plugin.id = toml::find<std::string>(v, "id");
if (!valid_id(plugin.id))
throw std::runtime_error("invalid plugin id");
plugin.order = toml::find_or<int64_t>(
v, "order", (int64_t)declaration_index);
{
const std::string netplay =
toml::find_or<std::string>(v, "netplay", "");
if (!netplay.empty() && netplay != "local_view" &&
netplay != "input" && netplay != "host_output")
throw std::runtime_error(
"plugin netplay must be \"local_view\", \"input\" "
"or \"host_output\"");
plugin.netplay_local_view = netplay == "local_view";
plugin.netplay_input = netplay == "input";
plugin.netplay_host_output = netplay == "host_output";
}
read_conditions(v, out.options, plugin.feature_id,
plugin.when, "plugin");
out.plugins.push_back(std::move(plugin));
++declaration_index;
}
}
if (cfg.contains("derived_disc")) {
if (feature_style)
throw std::runtime_error(
"derived_disc is a legacy conversion artifact and may not "
"be used by feature-style packages");
for (const toml::value& v : toml::find(cfg, "derived_disc").as_array()) {
ModDerivedDisc derived;
derived.kind = toml::find_or<std::string>(v, "kind", "vcdiff");
const std::string relative_patch = toml::find<std::string>(v, "patch");
derived.patch_sha256 = toml::find<std::string>(v, "patch_sha256");
const int64_t output_size = toml::find<int64_t>(v, "output_size");
derived.output_sha256 = toml::find<std::string>(v, "output_sha256");
if (derived.kind != "vcdiff")
throw std::runtime_error("derived_disc kind must be vcdiff");
if (!safe_archive_name(relative_patch))
throw std::runtime_error("derived_disc patch path is unsafe");
if (!valid_sha256(derived.patch_sha256) ||
!valid_sha256(derived.output_sha256))
throw std::runtime_error(
"derived_disc hashes must be lowercase SHA-256");
if (output_size <= 0)
throw std::runtime_error("derived_disc output_size must be positive");
derived.output_size = (uint64_t)output_size;
read_conditions(v, out.options, "legacy",
derived.when, "derived_disc");
if (!derived.when.empty()) {
derived.when_option = derived.when.begin()->first;
derived.when_value = derived.when.begin()->second;
}
derived.patch = out.root / fs::path(relative_patch);
if (!fs::is_regular_file(derived.patch))
throw std::runtime_error(
"derived_disc patch asset is missing: " + relative_patch);
out.derived_discs.push_back(std::move(derived));
}
}
return true;
} catch (const std::exception& ex) {
set_error(error, path.string() + ": " + ex.what());
return false;
}
}
void ModPackageManager::migrate_legacy_root() {
std::error_code ec;
const fs::path legacy = root_ / "packages";
if (!fs::exists(legacy, ec)) return;
const fs::path installed = installed_root();
const fs::path bundled = bundled_root();
size_t stranded = 0;
for (const fs::directory_entry& id_dir : fs::directory_iterator(legacy, ec)) {
if (ec) break;
if (!id_dir.is_directory()) continue;
const std::string id = id_dir.path().filename().string();
for (const fs::directory_entry& version_dir :
fs::directory_iterator(id_dir.path(), ec)) {
if (ec) break;
if (!version_dir.is_directory()) continue;
const std::string version = version_dir.path().filename().string();
std::error_code move_ec;
/* Already staged by the build: this copy is redundant output, not
* anything the player owns. */
if (fs::exists(bundled / id / version, move_ec)) {
fs::remove_all(version_dir.path(), move_ec);
continue;
}
const fs::path destination = installed / id / version;
if (fs::exists(destination, move_ec)) {
fs::remove_all(version_dir.path(), move_ec);
continue;
}
fs::create_directories(destination.parent_path(), move_ec);
if (move_ec) { ++stranded; continue; }
fs::rename(version_dir.path(), destination, move_ec);
if (move_ec) {
/* Cross-device or a locked file: copy rather than lose it. */
move_ec.clear();
fs::copy(version_dir.path(), destination,
fs::copy_options::recursive, move_ec);
if (move_ec) {
++stranded;
continue;
}
fs::remove_all(version_dir.path(), move_ec);
}
}
}
/* Drop the legacy tree only when every version directory was dealt with.
* A blanket remove_all here would be the very data loss this migration
* exists to end, so anything that could not be moved stays exactly where
* it is and is reported instead. */
if (stranded != 0 || ec) {
scan_errors_.push_back(
legacy.string() + ": could not migrate " +
std::to_string(stranded) + " package version(s) to " +
installed.string() + "; the old catalog was left in place");
return;
}
ec.clear();
fs::remove_all(legacy, ec);
}
bool ModPackageManager::scan_root(const fs::path& packages_root,
ModPackageOrigin origin, std::string* error) {
std::error_code ec;
if (!fs::exists(packages_root, ec)) return true;
for (const fs::directory_entry& id_dir : fs::directory_iterator(packages_root, ec)) {
if (ec) break;
if (!id_dir.is_directory()) continue;
for (const fs::directory_entry& version_dir : fs::directory_iterator(id_dir.path(), ec)) {
if (ec) break;
if (!version_dir.is_directory()) continue;
const fs::path manifest = version_dir.path() / "manifest.toml";
if (!fs::exists(manifest)) continue;
ModPackage package;
std::string parse_error;
if (!read_manifest(manifest, package, &parse_error)) {
/* A package that cannot be parsed must say so. Silently
* skipping it leaves a mod author with a mod that simply does
* not exist and nothing anywhere explaining why. */
scan_errors_.push_back(manifest.string() + ": " + parse_error);
continue;
}
if (package.id != id_dir.path().filename().string() ||
package.version != version_dir.path().filename().string()) {
scan_errors_.push_back(
manifest.string() +
": package path does not match manifest id/version");
continue;
}
if (std::find(disabled_packages_.begin(), disabled_packages_.end(), package.id) != disabled_packages_.end())
continue;
if (!developer_channel_ && !strip_developer_features(package)) {
/* Every feature was developer-channel, so on this build the
* package has nothing to offer. Not an error: it is the
* intended outcome of shipping without them. */
continue;
}
package.origin = origin;
if (origin == ModPackageOrigin::Installed) {
const auto existing = packages_.find(package.id);
package.shadows_bundled =
existing != packages_.end() &&
std::any_of(existing->second.begin(), existing->second.end(),
[](const std::pair<const std::string, ModPackage>& e) {
return e.second.origin == ModPackageOrigin::Bundled;
});
}
packages_[package.id][package.version] = std::move(package);
}
}
if (ec) {
set_error(error, "cannot scan packages: " + ec.message());
return false;
}
return true;
}
bool ModPackageManager::scan(std::string* error) {
packages_.clear();
scan_errors_.clear();
migrate_legacy_root();
/* Bundled first, then installed: an installed package of the same id and
* version deliberately shadows the build-staged one, and records that it
* did so, rather than the two racing on directory-iteration order. */
if (!scan_root(bundled_root(), ModPackageOrigin::Bundled, error)) return false;
if (!scan_root(installed_root(), ModPackageOrigin::Installed, error)) return false;
return true;
}
bool ModPackageManager::load_state(std::string* error) {
selections_.clear();
sources_.clear();
const fs::path path = root_ / "state.toml";
if (!fs::exists(path)) return true;
try {
const toml::value cfg = toml::parse(path.string());
const int64_t version = toml::find<int64_t>(cfg, "format_version");
if (version != 1 && version != 2)
throw std::runtime_error("unsupported state format_version");
if (cfg.contains("sources")) {
for (const auto& [id, value] : toml::find(cfg, "sources").as_table()) {
if (!valid_id(id) || !value.is_string()) throw std::runtime_error("invalid shared source binding");
sources_[id] = toml::get<std::string>(value);
}
}
if (cfg.contains("package")) {
for (const toml::value& v : toml::find(cfg, "package").as_array()) {
const std::string id = toml::find<std::string>(v, "id");
if (!valid_id(id)) throw std::runtime_error("invalid state package id");
ModSelection selection;
selection.enabled = toml::find_or<bool>(v, "enabled", false);
selection.version = toml::find_or<std::string>(v, "version", "");
if (v.contains("values")) {
for (const auto& [key, value] : toml::find(v, "values").as_table()) {
if (value.is_string())
selection.values[key] = toml::get<std::string>(value);
else if (value.is_boolean())
selection.values[key] =
toml::get<bool>(value) ? "true" : "false";
else if (value.is_integer())
selection.values[key] =
std::to_string(toml::get<int64_t>(value));
else
throw std::runtime_error(
"state option values must be scalar");
}
}
selections_[id] = std::move(selection);
}
}
if (version == 2 && cfg.contains("feature")) {
for (const toml::value& v : toml::find(cfg, "feature").as_array()) {
const std::string package_id =
toml::find<std::string>(v, "package_id");
const std::string feature_id = toml::find<std::string>(v, "id");
if (!valid_id(package_id) || !valid_id(feature_id))
throw std::runtime_error("invalid state feature identity");
ModFeatureSelection feature;
feature.enabled = toml::find<bool>(v, "enabled");
feature.has_enabled = true;
if (v.contains("values")) {
for (const auto& [key, value] :
toml::find(v, "values").as_table()) {
if (value.is_string())
feature.values[key] = toml::get<std::string>(value);
else if (value.is_boolean())
feature.values[key] =
toml::get<bool>(value) ? "true" : "false";
else if (value.is_integer())
feature.values[key] =
std::to_string(toml::get<int64_t>(value));
else
throw std::runtime_error(
"state feature option values must be scalar");
}
}
if (v.contains("resources")) {
for (const auto& [key, value] :
toml::find(v, "resources").as_table()) {
if (!valid_id(key))
throw std::runtime_error(
"invalid state feature resource id");
if (!value.is_string())
throw std::runtime_error(
"state feature resource paths must be strings");
feature.resources[key] = toml::get<std::string>(value);
}
}
selections_[package_id].features[feature_id] =
std::move(feature);
}
}
return true;
} catch (const std::exception& ex) {
set_error(error, path.string() + ": " + ex.what());
return false;
}
}
bool ModPackageManager::save_state(std::string* error) const {
std::error_code ec;
fs::create_directories(root_, ec);
if (ec) {
set_error(error, "cannot create mods directory: " + ec.message());
return false;
}
const fs::path temp = root_ / "state.toml.tmp";
const fs::path final = root_ / "state.toml";
std::ofstream out(temp, std::ios::trunc);
if (!out) {
set_error(error, "cannot write " + temp.string());
return false;
}
out << "format_version = 2\n";
if (!sources_.empty()) {
out << "\n[sources]\n";
for (const auto& [id, path] : sources_)
out << quote_toml(id) << " = " << quote_toml(path.string()) << "\n";
}
for (const auto& [id, selection] : selections_) {
out << "\n[[package]]\n";
out << "id = " << quote_toml(id) << "\n";
if (!selection.version.empty())
out << "version = " << quote_toml(selection.version) << "\n";
const ModPackage* package = find_selected(packages_, id, selection);
const bool legacy = package && package->features.size() == 1 &&
package->features.front().legacy;
if (legacy) {
out << "enabled = " << (selection.enabled ? "true" : "false") << "\n";
}
if (legacy && !selection.values.empty()) {
out << "[package.values]\n";
for (const auto& [key, value] : selection.values)
out << key << " = " << quote_toml(value) << "\n";
}
}
for (const auto& [package_id, selection] : selections_) {
for (const auto& [feature_id, feature] : selection.features) {
bool enabled = feature.enabled;
if (!feature.has_enabled) {
const ModPackage* package =
find_selected(packages_, package_id, selection);
const ModFeature* manifest_feature =
package ? find_feature(*package, feature_id) : nullptr;
if (manifest_feature)
enabled = manifest_feature->default_enabled;
}
out << "\n[[feature]]\n";
out << "package_id = " << quote_toml(package_id) << "\n";
out << "id = " << quote_toml(feature_id) << "\n";
out << "enabled = " << (enabled ? "true" : "false") << "\n";
if (!feature.values.empty()) {
out << "[feature.values]\n";
for (const auto& [key, value] : feature.values)
out << key << " = " << quote_toml(value) << "\n";
}
if (!feature.resources.empty()) {
out << "[feature.resources]\n";
for (const auto& [key, value] : feature.resources)
out << key << " = " << quote_toml(value) << "\n";
}
}
}
out.close();
if (!out) {
set_error(error, "cannot finish " + temp.string());
return false;
}
fs::rename(temp, final, ec);
if (ec) {
fs::remove(final, ec);
ec.clear();
fs::rename(temp, final, ec);
}
if (ec) {
set_error(error, "cannot publish state: " + ec.message());
return false;
}
return true;
}
bool ModPackageManager::install_archive(const fs::path& archive,
std::string* installed_id,
std::string* installed_version,
std::string* error) {
std::vector<uint8_t> bytes;
std::vector<ZipEntry> entries;
if (!read_file(archive, bytes, error) || !parse_zip(bytes, entries, error))
return false;
const auto manifest_entry = std::find_if(entries.begin(), entries.end(),
[](const ZipEntry& e) { return e.name == "manifest.toml" && !e.directory; });
if (manifest_entry == entries.end()) {
set_error(error, "archive root does not contain manifest.toml");
return false;
}
std::error_code ec;
fs::create_directories(root_ / ".staging", ec);
if (ec) {
set_error(error, "cannot create install staging root: " + ec.message());
return false;
}
const std::string token =
std::to_string((unsigned long long)crc32_compute(bytes.data(), bytes.size()));
const fs::path staging = root_ / ".staging" / ("install-" + token);
if (fs::exists(staging)) {
set_error(error, "install staging path already exists; remove " + staging.string());
return false;
}
if (!extract_zip(bytes, entries, staging, error)) {
fs::remove_all(staging, ec);
return false;
}
ModPackage package;
if (!read_manifest(staging / "manifest.toml", package, error)) {
fs::remove_all(staging, ec);
return false;
}
if (!developer_channel_ && !strip_developer_features(package)) {
fs::remove_all(staging, ec);
set_error(error,
"this package contains only developer-channel features, "
"which are not available in this build");
return false;
}
/* Player installs land in the launcher-owned root. They must never share
* a tree with the build-staged catalog, which every build wipes. */
const fs::path destination = installed_root() / package.id / package.version;
if (fs::exists(destination)) {
fs::remove_all(staging, ec);
set_error(error, "package version is already installed");
return false;
}
fs::create_directories(destination.parent_path(), ec);
if (ec) {
fs::remove_all(staging, ec);
set_error(error, "cannot create package directory: " + ec.message());
return false;
}
fs::rename(staging, destination, ec);
if (ec) {
fs::remove_all(staging, ec);
set_error(error, "cannot publish installed package: " + ec.message());
return false;
}
package.root = destination;
package.origin = ModPackageOrigin::Installed;
packages_[package.id][package.version] = package;
if (installed_id) *installed_id = package.id;
if (installed_version) *installed_version = package.version;
return true;
}
bool ModPackageManager::remove_version(const std::string& id, const std::string& version,
std::string* error) {
/* Active means active in the effective selection: a version another
* feature's [[requirement]] activates is in use although state.toml does
* not enable it. */
std::vector<ModResolution::ImplicitFeature> implicit;
const std::map<std::string, ModSelection> effective =
effective_selections(&implicit, nullptr, nullptr);
const auto sit = effective.find(id);
const ModSelection blank;
const ModSelection& current =
sit == effective.end() ? blank : sit->second;
const ModPackage* selected = find_selected(packages_, id, current);
if (selected && selected->version == version &&
has_enabled_feature(*selected, current)) {
for (const ModResolution::ImplicitFeature& item : implicit) {
if (item.package_id != id) continue;
set_error(error, "cannot remove a version required by " +
item.required_by_package_id + "/" +
item.required_by_feature_id);
return false;
}
set_error(error, "cannot remove an active package version");
return false;
}
for (const auto& [other_id, versions] : packages_) {
(void)versions;
const auto other_selection = effective.find(other_id);
const ModSelection& selection =
other_selection == effective.end() ? blank :
other_selection->second;
const ModPackage* package =
find_selected(packages_, other_id, selection);
if (!package || other_id == id ||
!has_enabled_feature(*package, selection)) continue;
for (const ModRequirement& dep : package->dependencies) {
if (dep.id == id && version_satisfies(version, dep.version)) {
set_error(error, "cannot remove a version required by " + other_id);
return false;
}
}
}
const auto pit = packages_.find(id);
if (pit == packages_.end() || pit->second.find(version) == pit->second.end()) {
set_error(error, "package version is not installed");
return false;
}
if (pit->second.at(version).origin == ModPackageOrigin::Bundled) {
/* Deleting build output would succeed and then be undone by the next
* build, which is not a model any player can reason about. */
set_error(error, "cannot remove a bundled package; it ships with the game");
return false;
}
const fs::path path = pit->second.at(version).root;
std::error_code ec;
fs::remove_all(path, ec);
if (ec) {
set_error(error, "cannot remove package version: " + ec.message());
return false;
}
packages_[id].erase(version);
if (packages_[id].empty()) packages_.erase(id);
return true;
}
bool ModPackageManager::set_enabled(const std::string& id, bool enabled, std::string* error) {
const auto pit = packages_.find(id);
if (pit == packages_.end()) {
set_error(error, "package is not installed");
return false;
}
const ModPackage* package = selected_package(id);
if (!package) {
set_error(error, "package/version is not installed");
return false;
}
if (package->features.size() != 1 || !package->features.front().legacy) {
set_error(error,
"feature-style packages must be enabled per feature");
return false;
}
selections_[id].enabled = enabled;
return true;
}
bool ModPackageManager::select_version(const std::string& id, const std::string& version,
std::string* error) {
const auto pit = packages_.find(id);
if (pit == packages_.end() || pit->second.find(version) == pit->second.end()) {
set_error(error, "package version is not installed");
return false;
}
selections_[id].version = version;
return true;
}
bool ModPackageManager::set_option(const std::string& id, const std::string& option_id,
const std::string& value, std::string* error) {
const auto sit = selections_.find(id);
const ModSelection empty;
const ModSelection& selection = sit == selections_.end() ? empty : sit->second;
const ModPackage* package = find_selected(packages_, id, selection);
if (!package) {
set_error(error, "package/version is not installed");
return false;
}
if (package->features.size() != 1 || !package->features.front().legacy) {
set_error(error,
"feature-style package options must name a feature");
return false;
}
const auto oit = std::find_if(package->options.begin(), package->options.end(),
[&](const ModOption& option) {
return option.feature_id == "legacy" && option.id == option_id;
});
if (oit == package->options.end()) {
set_error(error, "unknown package option");
return false;
}
if (!valid_option_value(*oit, value)) {
set_error(error, "invalid option value");
return false;
}
selections_[id].values[option_id] = value;
return true;
}
bool ModPackageManager::set_feature_enabled(const std::string& package_id,
const std::string& feature_id,
bool enabled,
std::string* error) {
const ModPackage* package = selected_package(package_id);
const ModFeature* feature =
package ? find_feature(*package, feature_id) : nullptr;
if (!feature || feature->legacy) {
set_error(error, "unknown package feature");
return false;
}
ModSelection package_selection = selections_[package_id];
if (enabled) {
std::set<std::string> visiting;
if (!apply_feature_requirements(
*package, package_selection, feature_id, visiting, error))
return false;
}
set_feature_selected(package_selection, feature_id, enabled);
if (!enabled)
cascade_unsatisfied_feature_requirements(*package, package_selection);
std::string failing_feature;
std::string reason;
if (!package_constraints_satisfied(
*package, package_selection, nullptr, nullptr, nullptr, nullptr,
nullptr, &failing_feature, &reason)) {
set_error(error, package_id + "/" +
(failing_feature.empty() ? feature_id : failing_feature) +
": " + reason);
return false;
}
selections_[package_id] = std::move(package_selection);
return true;
}
bool ModPackageManager::set_feature_option(const std::string& package_id,
const std::string& feature_id,
const std::string& option_id,
const std::string& value,
std::string* error) {
const ModPackage* package = selected_package(package_id);
const ModFeature* feature =
package ? find_feature(*package, feature_id) : nullptr;
const ModOption* option =
package ? find_option(*package, feature_id, option_id) : nullptr;
if (!feature || feature->legacy || !option) {
set_error(error, "unknown feature option");
return false;
}
if (!valid_option_value(*option, value)) {
set_error(error, "invalid feature option value");
return false;
}
ModSelection package_selection = selections_[package_id];
package_selection.features[feature_id].values[option_id] = value;
cascade_unsatisfied_feature_requirements(*package, package_selection);
std::string failing_feature;
std::string reason;
if (!package_constraints_satisfied(
*package, package_selection, nullptr, nullptr, nullptr, nullptr,
nullptr, &failing_feature, &reason)) {
set_error(error, package_id + "/" +
(failing_feature.empty() ? feature_id : failing_feature) +
": " + reason);
return false;
}
selections_[package_id] = std::move(package_selection);
return true;
}
bool ModPackageManager::set_feature_resource_path(
const std::string& package_id,
const std::string& feature_id,
const std::string& resource_id,
const fs::path& path,
std::string* error) {
const ModPackage* package = selected_package(package_id);
const ModFeature* feature =
package ? find_feature(*package, feature_id) : nullptr;
const ModResource* resource =
package ? find_resource(*package, feature_id, resource_id) : nullptr;
if (!feature || feature->legacy || !resource) {
set_error(error, "unknown feature resource");
return false;
}
if (path.empty()) {
set_error(error, "resource path is empty");
return false;
}
std::error_code ec;
const bool valid = resource->format == "directory" ||
resource->format == "folder"
? fs::is_directory(path, ec)
: fs::is_regular_file(path, ec);
if (!valid) {
set_error(error,
resource->format == "directory" || resource->format == "folder"
? "selected resource path is not a directory"
: "selected resource path is not a file");
return false;
}
if (!resource->shared_source.empty()) sources_[resource->shared_source] = path;
else selections_[package_id].features[feature_id].resources[resource_id] = path.u8string();
return true;
}
std::vector<std::string> ModPackageManager::dormant_selections() const {
std::vector<std::string> dormant;
for (const auto& [id, selection] : selections_) {
(void)selection;
const auto found = packages_.find(id);
if (found == packages_.end() || found->second.empty())
dormant.push_back(id);
}
return dormant;
}
const ModPackage* ModPackageManager::selected_package(const std::string& id) const {
const auto selection = selections_.find(id);
const ModSelection blank;
return find_selected(packages_, id,
selection == selections_.end() ? blank : selection->second);
}
const ModFeature* ModPackageManager::selected_feature(
const std::string& package_id, const std::string& feature_id) const {
const ModPackage* package = selected_package(package_id);
return package ? find_feature(*package, feature_id) : nullptr;
}
bool ModPackageManager::feature_enabled(const std::string& package_id,
const std::string& feature_id) const {
const ModPackage* package = selected_package(package_id);
const ModFeature* feature =
package ? find_feature(*package, feature_id) : nullptr;
if (!package || !feature) return false;
const auto found = selections_.find(package_id);
const ModSelection blank;
return is_feature_enabled(
*package, found == selections_.end() ? blank : found->second, *feature);
}
std::string ModPackageManager::feature_option_value(
const std::string& package_id, const std::string& feature_id,
const std::string& option_id) const {
const ModPackage* package = selected_package(package_id);
if (!package) return {};
const auto found = selections_.find(package_id);
const ModSelection blank;
return effective_option_value(
*package, found == selections_.end() ? blank : found->second,
feature_id, option_id);
}
bool ModPackageManager::set_generated_feature_resource_default(
const std::string& package_id, const std::string& feature_id,
const std::string& resource_id, const fs::path& relative_path,
std::string* error) {
const auto* package = selected_package(package_id);
const auto* resource = package ? find_resource(*package, feature_id, resource_id) : nullptr;
if (!resource || !resource->shared_source.empty() || relative_path.empty() ||
relative_path.is_absolute() || relative_path.has_root_name() || relative_path.has_root_directory()) {
set_error(error, "invalid generated resource default");
return false;
}
for (const auto& component : relative_path) if (component == "." || component == ".." || component.empty()) {
set_error(error, "generated resource default must stay relative to mods root");
return false;
}
std::error_code ec;
const auto path = fs::absolute(root_ / relative_path, ec);
if (ec) { set_error(error, ec.message()); return false; }
generated_resource_defaults_[{package_id, feature_id, resource_id}] = path;
return true;
}
fs::path ModPackageManager::generated_resource_default(
const std::string& package_id, const std::string& feature_id,
const std::string& resource_id) const {
const auto found = generated_resource_defaults_.find({package_id, feature_id, resource_id});
return found == generated_resource_defaults_.end() ? fs::path() : found->second;
}
fs::path ModPackageManager::feature_resource_path(
const std::string& package_id,
const std::string& feature_id,
const std::string& resource_id) const {
const ModPackage* package = selected_package(package_id);
if (!package) return {};
const auto* resource = find_resource(*package, feature_id, resource_id);
if (resource && !resource->shared_source.empty()) {
const auto source = sources_.find(resource->shared_source);
if (source != sources_.end()) return source->second;
// Adopt an earlier per-feature selection as the visible default. The
// next edit persists one shared binding and wins over every old value.
for (const auto& [id, versions] : packages_) {
(void)versions;
const auto* other = selected_package(id);
if (!other) continue;
const auto selected = selections_.find(id);
if (selected == selections_.end()) continue;
for (const auto& r : other->resources) if(r.shared_source == resource->shared_source) {
auto path = effective_resource_path(*other, selected->second, r.feature_id, r.id,
generated_resource_default(other->id, r.feature_id, r.id));
if (!path.empty()) return path;
}
}
return {};
}
const auto found = selections_.find(package_id);
const ModSelection blank;
return effective_resource_path(
*package, found == selections_.end() ? blank : found->second,
feature_id, resource_id,
generated_resource_default(package_id, feature_id, resource_id));
}
std::map<std::string, ModSelection> ModPackageManager::effective_selections(
std::vector<ModResolution::ImplicitFeature>* implicit,
std::vector<ModResolution::Diagnostic>* diagnostics,
std::vector<std::string>* errors) const {
std::map<std::string, ModSelection> effective = selections_;
const ModSelection blank;
const auto selection_of = [&](const std::string& id) -> const ModSelection& {
const auto found = effective.find(id);
return found == effective.end() ? blank : found->second;
};
/* Each requirement is evaluated once it is active; an unmet one is
* reported once. Activation only ever turns features ON, so the loop
* reaches a fixed point after at most one pass per derivable feature. */
std::set<std::tuple<std::string, std::string, std::string, std::string>>
settled;
const auto fail = [&](const std::string& package_id,
const ModFeatureRequirement& requirement,
const std::string& why) {
ModResolution::Diagnostic diagnostic;
diagnostic.resource = "requirement:" + requirement.package_id + "/" +
requirement.required_feature_id;
diagnostic.package_id = package_id;
diagnostic.feature_id = requirement.feature_id;
diagnostic.other_package_id = requirement.package_id;
diagnostic.other_feature_id = requirement.required_feature_id;
diagnostic.message = package_id + "/" + requirement.feature_id +
" requires " + requirement.package_id + "/" +
requirement.required_feature_id + ", but " + why;
if (errors) errors->push_back(diagnostic.message);
if (diagnostics) diagnostics->push_back(std::move(diagnostic));
};
bool changed = true;
while (changed) {
changed = false;
for (const auto& [id, versions] : packages_) {
(void)versions;
const ModPackage* package =
find_selected(packages_, id, selection_of(id));
if (!package) continue;
for (const ModFeatureRequirement& requirement :
package->requirements) {
const ModSelection& owner_selection = selection_of(id);
const ModFeature* owner =
find_feature(*package, requirement.feature_id);
if (!owner ||
!is_feature_enabled(*package, owner_selection, *owner) ||
!conditions_match(*package, owner_selection,
requirement.feature_id,
requirement.when))
continue;
if (!settled.emplace(id, requirement.feature_id,
requirement.package_id,
requirement.required_feature_id)
.second)
continue;
const auto provider = packages_.find(requirement.package_id);
if (provider == packages_.end() || provider->second.empty()) {
fail(id, requirement,
"package " + requirement.package_id +
" is not in this build's mod catalog");
continue;
}
const ModPackage* target = find_selected(
packages_, requirement.package_id,
selection_of(requirement.package_id));
if (!target) {
fail(id, requirement,
"the selected version of " + requirement.package_id +
" is not installed");
continue;
}
if (!version_satisfies(target->version, requirement.version)) {
fail(id, requirement,
requirement.package_id + " " + target->version +
" does not satisfy " + requirement.version);
continue;
}
const ModFeature* required =
find_feature(*target, requirement.required_feature_id);
if (!required || required->legacy) {
fail(id, requirement,
requirement.package_id + " " + target->version +
" has no feature " +
requirement.required_feature_id);
continue;
}
/* The player's own choice already covers it. */
if (is_feature_enabled(*target,
selection_of(requirement.package_id),
*required))
continue;
ModSelection derived = selection_of(requirement.package_id);
set_feature_selected(derived, required->id, true);
std::set<std::string> visiting;
std::string requirement_error;
if (!apply_feature_requirements(*target, derived, required->id,
visiting, &requirement_error)) {
fail(id, requirement, requirement_error);
continue;
}
effective[requirement.package_id] = std::move(derived);
if (implicit) {
implicit->push_back({requirement.package_id, required->id,
id, requirement.feature_id});
}
changed = true;
}
}
}
return effective;
}
bool ModPackageManager::feature_implicitly_enabled(
const std::string& package_id, const std::string& feature_id) const {
if (feature_enabled(package_id, feature_id)) return false;
std::vector<ModResolution::ImplicitFeature> implicit;
effective_selections(&implicit, nullptr, nullptr);
return std::any_of(implicit.begin(), implicit.end(),
[&](const ModResolution::ImplicitFeature& item) {
return item.package_id == package_id &&
item.feature_id == feature_id;
});
}
std::string ModPackageManager::feature_option_value(
const ModResolution& plan, const std::string& package_id,
const std::string& feature_id, const std::string& option_id) const {
const ModPackage* package = selected_package(package_id);
if (!package) return {};
const auto found = plan.selections.find(package_id);
const ModSelection blank;
return effective_option_value(
*package, found == plan.selections.end() ? blank : found->second,
feature_id, option_id);
}
bool ModPackageManager::prepare_resources(const std::string& game_id,
const fs::path& disc_path, const fs::path& cache_root, std::string* error, bool cached_only,
const ModPrepareProgress& progress) {
auto effective = effective_selections(nullptr, nullptr, nullptr);
auto pending = selections_;
try {
for (const auto& [id, versions] : packages_) {
(void)versions;
const auto* package = selected_package(id);
if (!package || package->prepare.empty()) continue;
bool target = std::any_of(package->targets.begin(), package->targets.end(),
[&](const ModTarget& t){return t.game_id == "*" || t.game_id == game_id;});
if (!target) continue;
for (const auto& feature : package->features) {
if (!is_feature_enabled(*package, effective[id], feature)) continue;
const auto provider = media_preparers().find(package->prepare);
if (provider == media_preparers().end())
throw std::runtime_error(package->name + ": media preparer is not installed");
if (progress && !progress(-1.0f,"Preparing " + feature.name))
throw std::runtime_error("Preparation canceled.");
ModPrepareContext context{*package, feature.id, disc_path, cache_root, root_.parent_path(), {}, progress};
for (const auto& r : package->resources) if (r.feature_id == feature.id && r.input_only) {
auto path = feature_resource_path(id, feature.id, r.id);
if (path.empty() && r.required)
throw std::runtime_error(package->name + ": select " + r.label);
context.inputs[r.id] = path;
}
std::map<std::string, fs::path> outputs;
std::string reason;
{
HostLaunchTimingScope timing(HOST_LAUNCH_MEDIA_PROVIDER,
package->id.c_str(), feature.id.c_str());
const auto& callback = cached_only ? provider->second.probe : provider->second.prepare;
if (!callback || !callback(context, outputs, reason))
throw std::runtime_error(package->name + ": " + reason);
timing.success();
}
for (const auto& [name, path] : outputs) {
const auto* resource = find_resource(*package, feature.id, name);
if (!resource || resource->input_only || resource->sha256.empty())
throw std::runtime_error("preparer returned an undeclared or unverified output");
pending[id].features[feature.id].resources[name] = path.u8string();
}
for (const auto& r : package->resources)
if (r.feature_id == feature.id && !r.input_only && r.required && !outputs.count(r.id))
throw std::runtime_error(package->name + ": preparer omitted " + r.label);
}
}
} catch (const std::exception& ex) { set_error(error, ex.what()); return false; }
if (progress && !progress(-1.0f,"Verifying prepared content")) {
set_error(error,"Preparation canceled."); return false;
}
selections_ = std::move(pending);
return true;
}
ModResolution ModPackageManager::resolve(const std::string& game_id,
const std::string& exe_sha256,
const std::string& disc_sha256,
bool defer_prepared_resources) const {
ModResolution result;
/* Everything below reads the EFFECTIVE selection: the player's state plus
* the features active [[requirement]]s derive. selections itself is never
* modified, so save_state() persists only what the player chose. */
const std::map<std::string, ModSelection> selections =
effective_selections(&result.implicit_features, &result.diagnostics,
&result.errors);
std::map<std::string, const ModPackage*> active;
for (const auto& [id, versions] : packages_) {
(void)versions;
const auto selected = selections.find(id);
const ModSelection blank;
const ModSelection& selection =
selected == selections.end() ? blank : selected->second;
const ModPackage* package = find_selected(packages_, id, selection);
if (!package) {
if (selected != selections.end())
result.errors.push_back(
"selected package/version is not installed: " + id);
continue;
}
if (!has_enabled_feature(*package, selection)) continue;
if (!target_matches(*package, game_id, exe_sha256, disc_sha256)) {
result.errors.push_back("package does not target this game/image: " + id);
continue;
}
active[id] = package;
}
for (const auto& [id, package] : active) {
for (const ModRequirement& dep : package->dependencies) {
const auto found = active.find(dep.id);
if (found == active.end())
result.errors.push_back(id + " requires enabled package " + dep.id);
else if (!version_satisfies(found->second->version, dep.version))
result.errors.push_back(id + " requires " + dep.id + " " + dep.version);
}
for (const std::string& conflict : package->conflicts)
if (active.find(conflict) != active.end())
result.errors.push_back(id + " conflicts with " + conflict);
const auto selection = selections.find(id);
const ModSelection blank_selection;
const ModSelection& effective_selection =
selection == selections.end()
? blank_selection
: selection->second;
std::string failing_feature;
std::string reason;
if (!package_constraints_satisfied(
*package, effective_selection, nullptr, nullptr,
nullptr, nullptr, nullptr, &failing_feature, &reason)) {
result.errors.push_back(
id + "/" + failing_feature + ": " + reason);
}
if (selection != selections.end()) {
for (const ModOption& option : package->options) {
const ModFeature* feature =
find_feature(*package, option.feature_id);
if (!feature ||
!is_feature_enabled(*package, selection->second, *feature))
continue;
const std::map<std::string, std::string>* values = nullptr;
if (feature->legacy) {
values = &selection->second.values;
} else {
const auto feature_selection =
selection->second.features.find(feature->id);
if (feature_selection != selection->second.features.end())
values = &feature_selection->second.values;
}
if (!values) continue;
const auto value = values->find(option.id);
if (value != values->end() &&
!valid_option_value(option, value->second))
result.errors.push_back(
id + "/" + feature->id +
": invalid value for " + option.id);
}
}
if (package->resolver.rfind("builtin:", 0) == 0) {
const std::string resolver_id = package->resolver.substr(8);
const auto resolver = builtin_resolvers().find(resolver_id);
if (resolver == builtin_resolvers().end())
result.errors.push_back(id + ": built-in resolver is unavailable: " + resolver_id);
}
}
enum class Visit { None, Active, Done };
std::map<std::string, Visit> visits;
std::function<void(const std::string&)> visit = [&](const std::string& id) {
if (visits[id] == Visit::Done) return;
if (visits[id] == Visit::Active) {
result.errors.push_back("dependency cycle includes " + id);
return;
}
visits[id] = Visit::Active;
const ModPackage* package = active.at(id);
std::vector<std::string> deps;
for (const ModRequirement& dep : package->dependencies)
if (active.find(dep.id) != active.end()) deps.push_back(dep.id);
std::sort(deps.begin(), deps.end());
for (const std::string& dep : deps) visit(dep);
visits[id] = Visit::Done;
result.ordered.push_back(package);
};
for (const auto& [id, package] : active) visit(id);
if (!result.errors.empty()) {
result.ordered.clear();
return result;
}
ModBuiltinResolverContext resolver_context;
resolver_context.active_packages = &active;
resolver_context.selections = &selections;
for (const ModPackage* package : result.ordered) {
const auto selected_it = selections.find(package->id);
const ModSelection blank;
const ModSelection& selected =
selected_it == selections.end() ? blank : selected_it->second;
std::map<std::pair<std::string,std::string>,
std::shared_ptr<const std::vector<uint8_t>>> resource_snapshots;
auto load_resource = [&](const ModResource& resource,
std::shared_ptr<const std::vector<uint8_t>>& bytes,
std::string& reason) {
const auto key=std::make_pair(resource.feature_id,resource.id);
const auto found=resource_snapshots.find(key);
if(found!=resource_snapshots.end()) { bytes=found->second; return true; }
const auto path=effective_resource_path(*package,selected,resource.feature_id,resource.id);
if(!load_mod_media(path,resource.format,resource.size,resource.sha256,bytes,&reason))
return false;
resource_snapshots.emplace(key,bytes);
return true;
};
if (package->resolver == "declarative") {
for (const ModDerivedDisc& derived : package->derived_discs) {
const ModFeature& legacy = package->features.front();
if (!legacy.legacy ||
!is_feature_enabled(*package, selected, legacy) ||
!conditions_match(*package, selected, "legacy", derived.when))
continue;
ModResolution::DerivedDisc resolved;
resolved.kind = derived.kind;
resolved.patch = derived.patch;
resolved.patch_sha256 = derived.patch_sha256;
resolved.output_size = derived.output_size;
resolved.output_sha256 = derived.output_sha256;
resolved.package_id = package->id;
result.derived_discs.push_back(std::move(resolved));
}
std::vector<const ModPatch*> patches;
patches.reserve(package->patches.size());
for (const ModPatch& patch : package->patches) {
const ModFeature* feature =
find_feature(*package, patch.feature_id);
if (!feature ||
!is_feature_enabled(*package, selected, *feature) ||
!conditions_match(*package, selected,
patch.feature_id, patch.when) ||
!integer_predicate_matches(
*package, selected, patch.feature_id,
patch.when_integer))
continue;
patches.push_back(&patch);
}
std::stable_sort(patches.begin(), patches.end(),
[](const ModPatch* a, const ModPatch* b) { return a->order < b->order; });
for (const ModPatch* patch : patches) {
ModResolution::Write write;
write.target = patch->target;
write.location = patch->location;
write.expected = patch->expected;
if (!patch->fields.empty()) {
bool encode_failed = false;
for (const ModPatchField& field : patch->fields) {
std::vector<uint8_t> encoded = field.replacement;
if (encoded.empty()) {
const std::string selected_value =
effective_option_value(
*package, selected, patch->feature_id,
field.replace_from_option);
int64_t parsed = 0;
int64_t adjusted = 0;
if (!parse_canonical_int64(
selected_value, parsed) ||
!checked_add_int64(
parsed, field.replace_addend,
adjusted)) {
result.errors.push_back(
package->id + "/" +
patch->feature_id +
": could not encode sparse field option " +
field.replace_from_option);
encode_failed = true;
break;
}
if (field.replace_encoding ==
ModValueEncoding::MipsLuiOriU32) {
if (adjusted < 0 ||
static_cast<uint64_t>(adjusted) >
UINT32_MAX) {
result.errors.push_back(
package->id + "/" +
patch->feature_id +
": could not encode sparse field "
"option " +
field.replace_from_option);
encode_failed = true;
break;
}
const uint32_t value =
static_cast<uint32_t>(adjusted);
const std::array<
std::pair<uint64_t,
std::vector<uint8_t>>, 2>
halves{{
{field.offset,
{
static_cast<uint8_t>(
value >> 16),
static_cast<uint8_t>(
value >> 24),
}},
{field.offset + 4,
{
static_cast<uint8_t>(value),
static_cast<uint8_t>(
value >> 8),
}},
}};
for (const auto& [offset, bytes] : halves) {
if (std::equal(
bytes.begin(), bytes.end(),
write.expected.begin() +
static_cast<size_t>(
offset)))
continue;
ModResolution::Write::Field resolved;
resolved.offset = offset;
resolved.replacement = bytes;
write.fields.push_back(
std::move(resolved));
}
continue;
}
if (!encode_unsigned_value(
field.replace_encoding, adjusted,
encoded)) {
result.errors.push_back(
package->id + "/" +
patch->feature_id +
": could not encode sparse field option " +
field.replace_from_option);
encode_failed = true;
break;
}
}
if (std::equal(
encoded.begin(), encoded.end(),
write.expected.begin() +
static_cast<size_t>(field.offset)))
continue;
ModResolution::Write::Field resolved;
resolved.offset = field.offset;
resolved.replacement = std::move(encoded);
write.fields.push_back(std::move(resolved));
}
if (encode_failed) continue;
if (write.fields.empty()) continue;
std::sort(
write.fields.begin(), write.fields.end(),
[](const ModResolution::Write::Field& a,
const ModResolution::Write::Field& b) {
return a.offset < b.offset;
});
} else if (patch->replace_from_option.empty()) {
write.replacement = patch->replacement;
} else {
const std::string selected_value =
effective_option_value(
*package, selected, patch->feature_id,
patch->replace_from_option);
const ModOption* source_option = find_option(
*package, patch->feature_id,
patch->replace_from_option);
if (patch->replace_omit_when_default &&
source_option &&
selected_value == source_option->default_value)
continue;
int64_t parsed = 0;
int64_t adjusted = 0;
std::vector<uint8_t> encoded;
if (!parse_canonical_int64(selected_value, parsed) ||
!checked_add_int64(
parsed, patch->replace_addend, adjusted)) {
result.errors.push_back(
package->id + "/" + patch->feature_id +
": could not encode replace_from option " +
patch->replace_from_option);
continue;
}
write.replacement = write.expected;
if (patch->replace_encoding ==
ModValueEncoding::MipsLuiOriU32) {
if (adjusted < 0 ||
static_cast<uint64_t>(adjusted) > UINT32_MAX) {
result.errors.push_back(
package->id + "/" + patch->feature_id +
": could not encode replace_from option " +
patch->replace_from_option);
continue;
}
const uint32_t value =
static_cast<uint32_t>(adjusted);
write.replacement[0] =
static_cast<uint8_t>(value >> 16);
write.replacement[1] =
static_cast<uint8_t>(value >> 24);
write.replacement[4] =
static_cast<uint8_t>(value);
write.replacement[5] =
static_cast<uint8_t>(value >> 8);
} else {
if (!encode_unsigned_value(
patch->replace_encoding, adjusted,
encoded)) {
result.errors.push_back(
package->id + "/" + patch->feature_id +
": could not encode replace_from option " +
patch->replace_from_option);
continue;
}
std::copy(
encoded.begin(), encoded.end(),
write.replacement.begin() +
static_cast<size_t>(
patch->replace_offset));
}
if (write.replacement == write.expected &&
!patch->replace_omit_when_default)
continue;
}
write.package_id = package->id;
write.feature_id = patch->feature_id;
result.writes.push_back(std::move(write));
}
} else {
const std::string resolver_id = package->resolver.substr(8);
const auto resolver = builtin_resolvers().find(resolver_id);
if (resolver != builtin_resolvers().end() &&
!resolver->second(
*package, selected, resolver_context,
result.writes, result.errors) &&
result.errors.empty())
result.errors.push_back(package->id + ": built-in resolver failed");
}
std::vector<const ModOverlay*> overlays;
overlays.reserve(package->overlays.size());
for (const ModOverlay& overlay : package->overlays) {
if (defer_prepared_resources && !package->prepare.empty() && !overlay.resource_id.empty())
continue;
const ModFeature* feature =
find_feature(*package, overlay.feature_id);
if (!feature ||
!is_feature_enabled(*package, selected, *feature) ||
!conditions_match(*package, selected,
overlay.feature_id, overlay.when) ||
!feature_predicate_matches(
overlay.when_feature, active, selections))
continue;
overlays.push_back(&overlay);
}
std::stable_sort(overlays.begin(), overlays.end(),
[](const ModOverlay* a, const ModOverlay* b) {
return a->order < b->order;
});
for (const ModOverlay* overlay : overlays) {
std::vector<uint8_t> payload;
std::string payload_error;
bool loaded = false;
if (overlay->resource_id.empty()) {
loaded = read_file(overlay->file, payload, &payload_error);
} else {
const auto* resource = find_resource(*package, overlay->feature_id, overlay->resource_id);
std::shared_ptr<const std::vector<uint8_t>> bytes;
if (resource && load_resource(*resource, bytes, payload_error) && bytes &&
overlay->resource_offset <= bytes->size() &&
overlay->size <= bytes->size() - overlay->resource_offset) {
const auto begin = bytes->begin() + size_t(overlay->resource_offset);
payload.assign(begin, begin + size_t(overlay->size));
loaded = true;
}
if (!loaded && payload_error.empty()) payload_error = "overlay resource is unavailable or out of bounds";
}
if (!loaded) {
result.errors.push_back(
package->id + "/" + overlay->feature_id + ": " +
payload_error);
continue;
}
const std::string actual = fingerprint_text(std::string(
(const char*)payload.data(), payload.size()));
if (payload.size() != overlay->size ||
actual != overlay->sha256) {
result.errors.push_back(
package->id + "/" + overlay->feature_id +
": overlay payload changed after installation");
continue;
}
ModResolution::Overlay resolved;
resolved.target = overlay->target;
resolved.location = overlay->location;
resolved.payload = std::move(payload);
resolved.payload_sha256 = overlay->sha256;
resolved.expected_sha256 = overlay->expected_sha256;
resolved.package_id = package->id;
resolved.feature_id = overlay->feature_id;
result.overlays.push_back(std::move(resolved));
}
std::vector<const ModPlugin*> plugins;
plugins.reserve(package->plugins.size());
for (const ModPlugin& plugin : package->plugins) {
const ModFeature* feature =
find_feature(*package, plugin.feature_id);
if (!feature ||
!is_feature_enabled(*package, selected, *feature) ||
!conditions_match(*package, selected,
plugin.feature_id, plugin.when))
continue;
plugins.push_back(&plugin);
}
std::stable_sort(
plugins.begin(), plugins.end(),
[](const ModPlugin* a, const ModPlugin* b) {
return a->order < b->order;
});
for (const ModPlugin* plugin : plugins) {
if (!mod_plugin_registered(plugin->id)) {
result.errors.push_back(
package->id + "/" + plugin->feature_id +
": trusted plugin is unavailable: " + plugin->id);
continue;
}
ModResolution::Plugin resolved;
resolved.id = plugin->id;
resolved.package_id = package->id;
resolved.feature_id = plugin->feature_id;
resolved.netplay_local_view = plugin->netplay_local_view;
resolved.netplay_input = plugin->netplay_input;
resolved.netplay_host_output = plugin->netplay_host_output;
result.plugins.push_back(std::move(resolved));
}
for (const ModResource& resource : package->resources) {
// Launcher previews must not ask players to select files that the
// preparer creates on Play. Runtime commits always validate them.
if (resource.input_only ||
(defer_prepared_resources && !package->prepare.empty())) continue;
const ModFeature* feature =
find_feature(*package, resource.feature_id);
if (!feature ||
!is_feature_enabled(*package, selected, *feature))
continue;
const fs::path path = effective_resource_path(
*package, selected, resource.feature_id, resource.id,
generated_resource_default(package->id, resource.feature_id, resource.id));
if (path.empty()) {
if (resource.required) {
result.errors.push_back(
package->id + "/" + resource.feature_id +
": required resource not selected: " +
resource.id);
}
continue;
}
ModResolution::Resource resolved;
resolved.package_id = package->id;
resolved.feature_id = resource.feature_id;
resolved.id = resource.id;
resolved.path = path;
resolved.format = resource.format;
resolved.sha256 = resource.sha256;
if (!resource.sha256.empty()) {
std::string media_error;
if (!load_resource(resource, resolved.bytes, media_error)) {
result.errors.push_back(package->id + "/" + resource.feature_id +
": " + resource.id + ": " + media_error);
continue;
}
}
result.resources.push_back(std::move(resolved));
}
}
if (result.derived_discs.size() > 1) {
std::string providers;
for (const auto& derived : result.derived_discs) {
if (!providers.empty()) providers += ", ";
providers += derived.package_id;
}
result.errors.push_back(
"more than one derived-disc provider is active: " + providers);
}
std::vector<ModResolution::Plugin> coalesced_plugins;
coalesced_plugins.reserve(result.plugins.size());
for (const ModResolution::Plugin& plugin : result.plugins) {
bool claimed = false;
for (const ModResolution::Plugin& previous : coalesced_plugins) {
if (plugin.id != previous.id) continue;
if (plugin.package_id == previous.package_id &&
plugin.feature_id == previous.feature_id) {
claimed = true;
break;
}
ModResolution::Diagnostic diagnostic;
diagnostic.resource = "plugin:" + plugin.id;
diagnostic.package_id = plugin.package_id;
diagnostic.feature_id = plugin.feature_id;
diagnostic.other_package_id = previous.package_id;
diagnostic.other_feature_id = previous.feature_id;
diagnostic.message =
plugin.package_id + "/" + plugin.feature_id +
" collides at " + diagnostic.resource + " with " +
previous.package_id + "/" + previous.feature_id;
result.diagnostics.push_back(diagnostic);
result.errors.push_back(diagnostic.message);
claimed = true;
break;
}
if (!claimed) coalesced_plugins.push_back(plugin);
}
result.plugins = std::move(coalesced_plugins);
std::vector<ModResolution::Write> coalesced;
coalesced.reserve(result.writes.size());
for (const ModResolution::Write& write : result.writes) {
bool valid_write = !write.expected.empty();
if (write.fields.empty()) {
valid_write =
valid_write &&
write.expected.size() == write.replacement.size();
} else {
valid_write = valid_write && write.replacement.empty();
uint64_t previous_end = 0;
for (const ModResolution::Write::Field& field : write.fields) {
if (field.replacement.empty() ||
field.offset > write.expected.size() ||
field.replacement.size() >
write.expected.size() - field.offset ||
field.offset < previous_end) {
valid_write = false;
break;
}
previous_end =
field.offset + field.replacement.size();
}
}
if (!valid_write) {
result.errors.push_back(
write.package_id + "/" + write.feature_id +
": resolver emitted invalid write");
continue;
}
bool duplicate = false;
for (const ModResolution::Write& previous : coalesced) {
if (identical_write(previous, write)) {
duplicate = true;
break;
}
uint64_t guard_mismatch = 0;
if (write_guard_overlap_mismatch(
previous, write, guard_mismatch)) {
ModResolution::Diagnostic diagnostic;
diagnostic.resource =
byte_resource(write.target, guard_mismatch);
diagnostic.package_id = write.package_id;
diagnostic.feature_id = write.feature_id;
diagnostic.other_package_id = previous.package_id;
diagnostic.other_feature_id = previous.feature_id;
diagnostic.message =
write.package_id + "/" + write.feature_id +
" has an incompatible guard at " +
diagnostic.resource + " with " +
previous.package_id + "/" +
previous.feature_id;
result.diagnostics.push_back(diagnostic);
result.errors.push_back(diagnostic.message);
duplicate = true;
break;
}
if (!writes_overlap(previous, write)) continue;
uint64_t mismatch = 0;
if (!write_overlap_mismatch(previous, write, mismatch))
continue;
ModResolution::Diagnostic diagnostic;
diagnostic.resource = byte_resource(write.target, mismatch);
diagnostic.package_id = write.package_id;
diagnostic.feature_id = write.feature_id;
diagnostic.other_package_id = previous.package_id;
diagnostic.other_feature_id = previous.feature_id;
diagnostic.message =
write.package_id + "/" + write.feature_id +
" collides at " + diagnostic.resource + " with " +
previous.package_id + "/" + previous.feature_id;
result.diagnostics.push_back(diagnostic);
result.errors.push_back(diagnostic.message);
duplicate = true;
break;
}
if (!duplicate) coalesced.push_back(write);
}
result.writes = std::move(coalesced);
std::vector<ModResolution::Overlay> coalesced_overlays;
coalesced_overlays.reserve(result.overlays.size());
for (const ModResolution::Overlay& overlay : result.overlays) {
bool claimed = false;
for (const ModResolution::Write& write : result.writes) {
if (!write_overlaps_range(
write, overlay.target, overlay.location,
overlay.payload.size()))
continue;
uint64_t mismatch = 0;
if (!write_overlay_mismatch(write, overlay, mismatch))
continue;
ModResolution::Diagnostic diagnostic;
diagnostic.resource = byte_resource(overlay.target, mismatch);
diagnostic.package_id = overlay.package_id;
diagnostic.feature_id = overlay.feature_id;
diagnostic.other_package_id = write.package_id;
diagnostic.other_feature_id = write.feature_id;
diagnostic.message =
overlay.package_id + "/" + overlay.feature_id +
" collides at " + diagnostic.resource + " with " +
write.package_id + "/" + write.feature_id;
result.diagnostics.push_back(diagnostic);
result.errors.push_back(diagnostic.message);
claimed = true;
break;
}
if (claimed) continue;
for (const ModResolution::Overlay& previous : coalesced_overlays) {
if (!ranges_overlap(
overlay.target, overlay.location, overlay.payload.size(),
previous.target, previous.location, previous.payload.size()))
continue;
if (overlay.target == previous.target &&
overlay.location == previous.location &&
overlay.payload == previous.payload &&
overlay.expected_sha256 == previous.expected_sha256) {
claimed = true;
break;
}
uint64_t mismatch = 0;
if (!overlay_overlap_mismatch(previous, overlay, mismatch))
continue;
ModResolution::Diagnostic diagnostic;
diagnostic.resource = byte_resource(overlay.target, mismatch);
diagnostic.package_id = overlay.package_id;
diagnostic.feature_id = overlay.feature_id;
diagnostic.other_package_id = previous.package_id;
diagnostic.other_feature_id = previous.feature_id;
diagnostic.message =
overlay.package_id + "/" + overlay.feature_id +
" collides at " + diagnostic.resource + " with " +
previous.package_id + "/" + previous.feature_id;
result.diagnostics.push_back(diagnostic);
result.errors.push_back(diagnostic.message);
claimed = true;
break;
}
if (!claimed) coalesced_overlays.push_back(overlay);
}
result.overlays = std::move(coalesced_overlays);
if (!result.errors.empty()) {
result.ordered.clear();
result.writes.clear();
result.overlays.clear();
result.derived_discs.clear();
result.plugins.clear();
result.resources.clear();
return result;
}
result.fingerprint = fingerprint_text(
canonical_resolution(
result.ordered, selections, result.writes, result.overlays,
result.derived_discs, result.plugins, result.resources,
disc_sha256));
result.selections = selections;
result.ok = true;
return result;
}
} // namespace PSXRecompV4
extern "C" int psx_mod_register_vblank_plugin(
const char* id, PSXModVBlankCallback callback) {
return id &&
PSXRecompV4::mod_register_vblank_plugin(id, callback) ? 1 : 0;
}
extern "C" int psx_mod_register_activation_plugin(
const char* id, PSXModActivationCallback callback) {
return id &&
PSXRecompV4::mod_register_activation_plugin(id, callback) ? 1 : 0;
}
extern "C" int psx_mod_register_session_reset_plugin(
const char* id, PSXModActivationCallback callback) {
return id &&
PSXRecompV4::mod_register_session_reset_plugin(id, callback) ? 1 : 0;
}
extern "C" int psx_mod_register_savestate_plugin(
const char* id, PSXModActivationCallback callback) {
return id &&
PSXRecompV4::mod_register_savestate_plugin(id, callback) ? 1 : 0;
}