feat: add feature-oriented mod resolution

This commit is contained in:
Matthew Stanley
2026-07-23 09:27:25 -07:00
parent 50d4f456bf
commit 968c9c23ce
6 changed files with 1506 additions and 250 deletions
+121 -127
View File
@@ -1,176 +1,170 @@
# PSXRecomp mod packages
# PSXRecomp mod packages and features
PSXRecomp games may expose a shared Dear ImGui **Mods** view backed by versioned
`.psxmod` packages. A package is a ZIP archive with `manifest.toml` at its root.
Packages are installed under `mods/packages/<id>/<version>/`; the selected
versions and option values live in `mods/state.toml`.
A `.psxmod` is a versioned installation, provenance, and trust boundary. A
package may contribute any number of independently configurable **features**.
The launcher presents those features as the primary Mods list; package
installation, version selection, and removal are a secondary management view.
Mods are resolved and fingerprinted before boot. They never rewrite the user's
disc or the recomp executable.
Feature identity is always `(package_id, feature_id)`. Enabling one feature
never enables, disables, or reconfigures another feature.
## Minimal manifest
The player selects a verified stock BIN/CUE. Resolution produces guarded native
operations and sparse disc overlays without rewriting or replacing that stock
image.
## Feature manifest
```toml
format_version = 1
id = "example.faster-charge"
id = "example.localization"
version = "1.2.0"
name = "Faster Charge"
name = "Example Localization Pack"
author = "Example Author"
description = "Shortens the charge delay."
description = "Independent title and script features."
license = "MIT"
resolver = "declarative"
save_compatibility = "shared" # or "isolated"
conflicts = ["example.incompatible"]
[[target]]
game_id = "SLUS-00000"
# Optional. When present, the selected image must have this digest.
exe_sha256 = "..."
disc_sha256 = "..."
# Required for disc overlays. Use the digest of the supported stock image.
disc_sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
[[dependency]]
id = "example.core"
version = "^1.0.0"
[[feature]]
id = "title-screen"
name = "Title Screen"
description = "Selects the title-screen artwork."
group = "Localization"
default_enabled = false
[[feature]]
id = "retranslation"
name = "Retranslation"
description = "Uses the revised English script."
group = "Localization"
[[option]]
id = "delay"
label = "Charge delay"
description = "Delay in frames."
group = "Game balance"
feature = "title-screen"
id = "variant"
label = "Title artwork"
type = "choice"
default = "normal"
default = "rockman"
[[option.choice]]
value = "normal"
label = "Normal"
value = "mega-man"
label = "Mega Man X6 (USA)"
[[option.choice]]
value = "fast"
label = "Fast"
value = "rockman"
label = "Rockman X6 (Japan)"
[[patch]]
feature = "title-screen"
target = "main_exe"
address = 0x80041234
expected = "2a 00 02 24"
replace = "0e 00 02 24"
when_option = "delay"
when_value = "fast"
order = 10
when = { variant = "rockman" }
# Optional: use this for structural changes that cannot be represented as
# equal-size sector writes. Multiple entries may select different recipes from
# one launcher option, but exactly one may resolve in the complete mod plan.
[[derived_disc]]
kind = "vcdiff"
patch = "assets/fast.xdelta3"
patch_sha256 = "..."
output_size = 600000000
output_sha256 = "..."
when_option = "delay"
when_value = "fast"
# For option matrices, use a condition table. All listed option values must
# match. `when_option` / `when_value` remain supported for single-option cases.
[[derived_disc]]
kind = "vcdiff"
patch = "assets/fast-rockman.xdelta3"
patch_sha256 = "..."
output_size = 600000000
output_sha256 = "..."
when = { delay = "fast", title_screen = "rockman_japan" }
[[overlay]]
feature = "retranslation"
target = "disc_raw"
offset = 123456
file = "assets/retranslated-script.bin"
sha256 = "..."
# Optional additional guard over the same range in the stock image.
expected_sha256 = "..."
```
Option types are `boolean`, `choice`, and bounded `integer`. `when_option` /
`when_value` are optional; `when = { option = "value", ... }` can be used when a
patch or derived-disc recipe depends on multiple option values. An unconditional
patch omits both condition forms.
Every `[[option]]`, `[[patch]]`, and `[[overlay]]` in a feature-style manifest
must name its owning feature. Ambiguous operations are rejected.
## Patch targets
Option types are `boolean`, `choice`, and bounded `integer`. Conditions are
feature-local: `when = { option = "value", ... }` requires every listed option
to match. The legacy `when_option`/`when_value` pair remains accepted for a
single condition.
- `main_exe`: `address` is a PSX guest virtual address. All expected bytes are
checked after the BIOS loads the PS-X EXE. The complete main-EXE plan is then
applied before the configured entry point executes.
- `disc_raw`: `offset` is in the canonical 2352-byte raw-sector stream
(`lba * 2352 + byte_in_sector`).
- `disc_user`: `offset` is in the canonical 2048-byte user-data stream
(`lba * 2048 + byte_in_sector`).
## Native operations
A disc operation may not cross a sector boundary. Use multiple operations.
Expected and replacement data are equal-length hexadecimal byte strings.
`main_exe` writes use PSX guest virtual addresses. Expected bytes are checked
after the BIOS loads the executable, then the complete write plan is applied
before its entry point. Changed executable ranges use the existing dirty-RAM
interpreter/native-overlay machinery; untouched functions remain on the static
native path.
Changed main-EXE code is deliberately not represented by a precompiled
permutation. PSXRecomp's exact text-image guard sees the changed live RAM and
routes that code through the existing dirty-RAM interpreter/native overlay
cache. Untouched functions stay on the static native path. This makes runtime
cost proportional to the code actually changed, not to the number of possible
option combinations.
Small `disc_raw` and `disc_user` patches are equal-length guarded writes and may
not cross a sector boundary:
## Derived discs
- `disc_raw` offsets use `lba * 2352 + byte_in_sector`.
- `disc_user` offsets use `lba * 2048 + byte_in_sector`.
A `derived_disc` is a data-only VCDIFF recipe whose source is the verified stock
disc from `[[target]].disc_sha256`. It is intended for mods that relocate files,
grow the ISO, replace large assets, or otherwise change disc geometry. A package
may contain a matrix of conditional recipes for its own options, but exactly one
recipe may resolve in the complete mod plan.
File-backed `[[overlay]]` operations are intended for large assets and may span
any number of sectors. Their paths must remain inside the archive. Payload
size and SHA-256 are verified while scanning, but disabled payloads are not
retained in memory. Enabled payloads are loaded and reverified during
resolution, then indexed by target and LBA before boot. A CD read performs a
direct indexed lookup rather than scanning every installed mod.
The launcher continues to display and persist the user's stock BIN/CUE. Before
boot, the runtime:
Feature disc overlays require an exact `disc_sha256` on every target entry.
`expected_sha256` can additionally guard the replaced stock range.
1. fingerprints that stock image and resolves package options;
2. verifies the package's VCDIFF payload;
3. invokes the release's trusted `xdelta3` binary (packages cannot provide an
executable);
4. verifies the derived size and SHA-256; and
5. atomically publishes and mounts
`mods/cache/<plan-fingerprint>.bin`.
## State and migration
Changing package versions or options changes the plan fingerprint and therefore
the cache key. A cached result is reused on later launches. Ordinary guarded
sector overlays may be applied on top of the derived image, so a structural
base package can support many small composable add-ons.
`mods/state.toml` format 2 stores selected package versions separately from
per-feature enabled states and values:
Release builders stage the trusted decoder with
`-DPSXRECOMP_XDELTA3_EXECUTABLE=/path/to/xdelta3`. More than one active
derived-disc recipe is rejected after option resolution; packages should use a
single owner package for structural transforms and dependencies/conflicts for
external ownership.
```toml
format_version = 2
Do not split one structural option system into many mutually exclusive
full-disc packages. For example, a game-wide Tweaks system should be one package
with launcher options and a conditional recipe matrix. Choices that are
mutually exclusive by design belong inside that package as option values, while
unrelated mods should remain separate packages and compose normally.
[[package]]
id = "example.localization"
version = "1.2.0"
## Resolution rules
[[feature]]
package_id = "example.localization"
id = "title-screen"
enabled = true
- Installed versions are side-by-side. The launcher can select an older version
to roll back.
- Enabling or disabling one package changes only that package. It does not
silently toggle other packages.
- Enabled packages are topologically ordered by dependencies, then by stable
package/patch order.
- Missing dependencies, version mismatches, declared conflicts, dependency
cycles, overlapping writes, unavailable trusted resolvers, invalid option
values, multiple derived-disc providers, and target mismatches prevent launch.
- The resolved package versions, option values, writes, and derived-disc recipe
produce a canonical SHA-256 plan fingerprint suitable for diagnostics and
multiplayer agreement.
- Package and state changes apply on the next launch. There is no mid-frame
mutation.
[feature.values]
variant = "rockman"
```
## Trusted adapters
State format 1 and package-only manifests remain readable as a migration aid.
They appear through one synthetic legacy feature. New packages should use
explicit features.
`resolver = "builtin:<id>"` selects a resolver statically registered by the game
executable. This is for legacy patch systems whose dependency and composition
rules cannot be expressed as independent declarative writes. A package cannot
load native code or choose an arbitrary symbol: unregistered IDs fail closed.
The adapter emits the same expected-byte-guarded resolved writes as a
declarative package, so validation, overlap checks, fingerprinting, and runtime
execution remain shared.
The old `derived_disc` VCDIFF mechanism is legacy conversion scaffolding only.
Feature-style manifests reject it. It is not a product mod primitive, fallback,
or image-selection workflow; patched discs may be used offline as parity
oracles while converting known mods to native operations.
## Archive safety
## Resolution and diagnostics
Before boot, the manager:
1. verifies the selected stock game and revision;
2. expands only enabled features and their selected options;
3. orders active packages deterministically by dependencies;
4. verifies enabled payloads and operation bounds;
5. collision-checks the complete byte-range plan;
6. coalesces only truly identical target/range/expected/replacement writes or
identical overlays; and
7. produces a canonical SHA-256 plan fingerprint.
Incompatible overlaps fail before launch. Structured diagnostics identify both
`(package, feature)` owners and the exact contested target range. The launcher
marks both feature rows and lets the user decide what to disable. It never
silently chooses a winner.
Package-level dependencies and conflicts are reserved for actual implementation
relationships. Mutually exclusive choices such as US versus Japanese artwork
belong inside one feature as option values.
## Trusted adapters and archive safety
`resolver = "builtin:<id>"` selects a resolver statically registered by the
game. Packages cannot load arbitrary native code or select arbitrary symbols.
The installer accepts stored or DEFLATE-compressed ZIP entries, validates CRCs,
rejects encrypted entries and unsafe/absolute paths, limits archives to 4096
rejects encrypted entries and unsafe or absolute paths, limits archives to 4096
files and 256 MiB expanded size, stages extraction, validates the manifest, and
publishes the version with an atomic rename.
publishes the version atomically.
+68
View File
@@ -21,6 +21,7 @@ struct ModChoice {
};
struct ModOption {
std::string feature_id;
std::string id;
std::string label;
std::string description;
@@ -33,6 +34,15 @@ struct ModOption {
std::vector<ModChoice> choices;
};
struct ModFeature {
std::string id;
std::string name;
std::string description;
std::string group = "General";
bool default_enabled = false;
bool legacy = false;
};
struct ModRequirement {
std::string id;
std::string version;
@@ -51,6 +61,7 @@ enum class ModPatchTarget {
};
struct ModPatch {
std::string feature_id;
ModPatchTarget target = ModPatchTarget::MainExe;
uint64_t location = 0; /* guest address or canonical disc-stream byte offset */
std::vector<uint8_t> expected;
@@ -61,6 +72,18 @@ struct ModPatch {
int64_t order = 0;
};
struct ModOverlay {
std::string feature_id;
ModPatchTarget target = ModPatchTarget::DiscRaw;
uint64_t location = 0;
std::filesystem::path file;
std::string sha256;
std::string expected_sha256;
uint64_t size = 0;
std::map<std::string, std::string> when;
int64_t order = 0;
};
struct ModDerivedDisc {
std::string kind = "vcdiff";
std::filesystem::path patch;
@@ -86,15 +109,25 @@ struct ModPackage {
std::vector<ModTarget> targets;
std::vector<ModRequirement> dependencies;
std::vector<std::string> conflicts;
std::vector<ModFeature> features;
std::vector<ModOption> options;
std::vector<ModPatch> patches;
std::vector<ModOverlay> overlays;
std::vector<ModDerivedDisc> derived_discs;
};
struct ModFeatureSelection {
bool enabled = false;
bool has_enabled = false;
std::map<std::string, std::string> values;
};
struct ModSelection {
/* v1 migration state. Feature-style manifests do not use these fields. */
bool enabled = false;
std::string version;
std::map<std::string, std::string> values;
std::map<std::string, ModFeatureSelection> features;
};
struct ModResolution {
@@ -107,8 +140,19 @@ struct ModResolution {
std::vector<uint8_t> expected;
std::vector<uint8_t> replacement;
std::string package_id;
std::string feature_id;
};
std::vector<Write> writes;
struct Overlay {
ModPatchTarget target = ModPatchTarget::DiscRaw;
uint64_t location = 0;
std::vector<uint8_t> payload;
std::string payload_sha256;
std::string expected_sha256;
std::string package_id;
std::string feature_id;
};
std::vector<Overlay> overlays;
struct DerivedDisc {
std::string kind;
std::filesystem::path patch;
@@ -118,6 +162,15 @@ struct ModResolution {
std::string package_id;
};
std::vector<DerivedDisc> derived_discs;
struct Diagnostic {
std::string message;
std::string resource;
std::string package_id;
std::string feature_id;
std::string other_package_id;
std::string other_feature_id;
};
std::vector<Diagnostic> diagnostics;
std::vector<std::string> errors;
};
@@ -150,12 +203,27 @@ public:
std::string* error = nullptr);
bool set_option(const std::string& id, const std::string& option,
const std::string& value, std::string* error = nullptr);
bool set_feature_enabled(const std::string& package_id,
const std::string& feature_id, bool enabled,
std::string* error = nullptr);
bool 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 = nullptr);
const std::map<std::string, std::map<std::string, ModPackage>>& packages() const {
return packages_;
}
const std::map<std::string, ModSelection>& selections() const { return selections_; }
const ModPackage* selected_package(const std::string& id) const;
const ModFeature* selected_feature(const std::string& package_id,
const std::string& feature_id) const;
bool feature_enabled(const std::string& package_id,
const std::string& feature_id) const;
std::string feature_option_value(const std::string& package_id,
const std::string& feature_id,
const std::string& option_id) const;
ModResolution resolve(const std::string& game_id,
const std::string& exe_sha256 = {},
+626 -93
View File
@@ -569,6 +569,7 @@ bool target_matches(const ModPackage& package, const std::string& game,
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::string& source_disc_sha256) {
std::ostringstream out;
@@ -576,16 +577,49 @@ std::string canonical_resolution(const std::vector<const ModPackage*>& ordered,
for (const ModPackage* package : ordered) {
out << package->id << '@' << package->version << '\n';
const auto sit = selections.find(package->id);
if (sit == selections.end()) continue;
for (const auto& [key, value] : sit->second.values)
out << key << '=' << value << '\n';
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) {
out << (write.target == ModPatchTarget::MainExe ? "main_exe" :
write.target == ModPatchTarget::DiscRaw ? "disc_raw" : "disc_user")
<< '@' << std::hex << write.location << std::dec << ':'
<< hex_bytes(write.expected) << '>' << hex_bytes(write.replacement)
<< ':' << write.package_id << '\n';
<< ':' << 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 << ':'
@@ -595,26 +629,81 @@ std::string canonical_resolution(const std::vector<const ModPackage*>& ordered,
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 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 auto selected = selection.values.find(id);
if (selected != selection.values.end()) return selected->second;
const auto option = std::find_if(package.options.begin(), package.options.end(),
[&](const ModOption& item) { return item.id == id; });
return option == package.options.end() ? std::string() : option->default_value;
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();
}
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, id) != value)
if (effective_option_value(package, selection, feature_id, id) != value)
return false;
}
return true;
}
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 =
@@ -638,7 +727,9 @@ void read_conditions(const toml::value& value, const std::vector<ModOption>& opt
(void)condition_value;
const auto option = std::find_if(
options.begin(), options.end(),
[&](const ModOption& item) { return item.id == id; });
[&](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");
}
@@ -651,6 +742,50 @@ bool writes_overlap(const ModResolution::Write& a, const ModResolution::Write& b
return a.location < b_end && b.location < a_end;
}
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) {
return a.target == b.target && a.location == b.location &&
a.expected == b.expected && a.replacement == b.replacement;
}
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();
}
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; });
try {
size_t used = 0;
const int64_t parsed = std::stoll(value, &used);
return used == value.size() && parsed >= option.min_value &&
parsed <= option.max_value &&
((parsed - option.min_value) % option.step) == 0;
} catch (...) {
return false;
}
}
std::string fingerprint_text(const std::string& text) {
uint8_t digest[32];
psx_sha256_compute((const uint8_t*)text.data(), text.size(), digest);
@@ -735,17 +870,53 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out,
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.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);
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.description = out.description;
feature.legacy = true;
out.features.push_back(std::move(feature));
}
if (cfg.contains("option")) {
std::set<std::string> option_ids;
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";
const std::string type = toml::find<std::string>(v, "type");
if (!valid_id(option.id) || !option_ids.insert(option.id).second)
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;
@@ -785,6 +956,10 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out,
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;
@@ -822,7 +997,7 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out,
"disc patch may not cross a sector boundary");
patch.order = toml::find_or<int64_t>(
v, "order", (int64_t)declaration_index);
read_conditions(v, out.options, patch.when, "patch");
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;
@@ -831,7 +1006,77 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out,
++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;
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");
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();
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");
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("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");
@@ -850,7 +1095,8 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out,
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, derived.when, "derived_disc");
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;
@@ -911,25 +1157,62 @@ bool ModPackageManager::load_state(std::string* error) {
try {
const toml::value cfg = toml::parse(path.string());
const int64_t version = toml::find<int64_t>(cfg, "format_version");
if (version != 1) throw std::runtime_error("unsupported state format_version");
if (!cfg.contains("package")) return true;
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");
if (version != 1 && version != 2)
throw std::runtime_error("unsupported state format_version");
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");
}
}
selections_[package_id].features[feature_id] =
std::move(feature);
}
selections_[id] = std::move(selection);
}
return true;
} catch (const std::exception& ex) {
@@ -952,19 +1235,46 @@ bool ModPackageManager::save_state(std::string* error) const {
set_error(error, "cannot write " + temp.string());
return false;
}
out << "format_version = 1\n";
out << "format_version = 2\n";
for (const auto& [id, selection] : selections_) {
out << "\n[[package]]\n";
out << "id = " << quote_toml(id) << "\n";
out << "enabled = " << (selection.enabled ? "true" : "false") << "\n";
if (!selection.version.empty())
out << "version = " << quote_toml(selection.version) << "\n";
if (!selection.values.empty()) {
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";
}
}
}
out.close();
if (!out) {
set_error(error, "cannot finish " + temp.string());
@@ -1048,15 +1358,25 @@ bool ModPackageManager::install_archive(const fs::path& archive,
bool ModPackageManager::remove_version(const std::string& id, const std::string& version,
std::string* error) {
const auto sit = selections_.find(id);
if (sit != selections_.end() && sit->second.enabled &&
(sit->second.version.empty() || sit->second.version == version)) {
const ModSelection blank;
const ModSelection& current =
sit == selections_.end() ? blank : sit->second;
const ModPackage* selected = find_selected(packages_, id, current);
if (selected && selected->version == version &&
has_enabled_feature(*selected, current)) {
set_error(error, "cannot remove an active package version");
return false;
}
for (const auto& [other_id, selection] : selections_) {
if (!selection.enabled || other_id == id) continue;
const ModPackage* package = find_selected(packages_, other_id, selection);
if (!package) continue;
for (const auto& [other_id, versions] : packages_) {
(void)versions;
const auto other_selection = selections_.find(other_id);
const ModSelection& selection =
other_selection == selections_.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);
@@ -1087,6 +1407,16 @@ bool ModPackageManager::set_enabled(const std::string& id, bool enabled, std::st
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;
}
@@ -1112,30 +1442,20 @@ bool ModPackageManager::set_option(const std::string& id, const std::string& opt
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.id == option_id; });
[&](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;
}
bool valid = false;
if (oit->type == ModOptionType::Boolean) {
valid = value == "true" || value == "false";
} else if (oit->type == ModOptionType::Choice) {
valid = std::any_of(oit->choices.begin(), oit->choices.end(),
[&](const ModChoice& choice) { return choice.value == value; });
} else {
try {
size_t used = 0;
const int64_t parsed = std::stoll(value, &used);
valid = used == value.size() && parsed >= oit->min_value &&
parsed <= oit->max_value &&
((parsed - oit->min_value) % oit->step) == 0;
} catch (...) {
valid = false;
}
}
if (!valid) {
if (!valid_option_value(*oit, value)) {
set_error(error, "invalid option value");
return false;
}
@@ -1143,6 +1463,46 @@ bool ModPackageManager::set_option(const std::string& id, const std::string& opt
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;
}
ModFeatureSelection& selection =
selections_[package_id].features[feature_id];
selection.enabled = enabled;
selection.has_enabled = true;
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;
}
selections_[package_id].features[feature_id].values[option_id] = value;
return true;
}
const ModPackage* ModPackageManager::selected_package(const std::string& id) const {
const auto selection = selections_.find(id);
const ModSelection blank;
@@ -1150,18 +1510,55 @@ const ModPackage* ModPackageManager::selected_package(const std::string& id) con
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);
}
ModResolution ModPackageManager::resolve(const std::string& game_id,
const std::string& exe_sha256,
const std::string& disc_sha256) const {
ModResolution result;
std::map<std::string, const ModPackage*> active;
for (const auto& [id, selection] : selections_) {
if (!selection.enabled) continue;
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) {
result.errors.push_back("selected package/version is not installed: " + id);
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;
@@ -1184,26 +1581,27 @@ ModResolution ModPackageManager::resolve(const std::string& game_id,
const auto selection = selections_.find(id);
if (selection != selections_.end()) {
for (const ModOption& option : package->options) {
const auto value = selection->second.values.find(option.id);
if (value == selection->second.values.end()) continue;
/* Reuse the public validation path without mutating by checking
* the same domain directly. Defaults require no state entry. */
bool valid = false;
if (option.type == ModOptionType::Boolean)
valid = value->second == "true" || value->second == "false";
else if (option.type == ModOptionType::Choice)
valid = std::any_of(option.choices.begin(), option.choices.end(),
[&](const ModChoice& c) { return c.value == value->second; });
else {
try {
size_t used = 0;
const int64_t n = std::stoll(value->second, &used);
valid = used == value->second.size() && n >= option.min_value &&
n <= option.max_value &&
((n - option.min_value) % option.step) == 0;
} catch (...) {}
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 (!valid) result.errors.push_back(id + ": invalid value for " + option.id);
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) {
@@ -1246,7 +1644,10 @@ ModResolution ModPackageManager::resolve(const std::string& game_id,
selected_it == selections_.end() ? blank : selected_it->second;
if (package->resolver == "declarative") {
for (const ModDerivedDisc& derived : package->derived_discs) {
if (!conditions_match(*package, selected, derived.when))
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;
@@ -1260,7 +1661,12 @@ ModResolution ModPackageManager::resolve(const std::string& game_id,
std::vector<const ModPatch*> patches;
patches.reserve(package->patches.size());
for (const ModPatch& patch : package->patches) {
if (!conditions_match(*package, selected, patch.when))
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))
continue;
patches.push_back(&patch);
}
@@ -1273,8 +1679,53 @@ ModResolution ModPackageManager::resolve(const std::string& game_id,
write.expected = patch->expected;
write.replacement = patch->replacement;
write.package_id = package->id;
write.feature_id = patch->feature_id;
result.writes.push_back(std::move(write));
}
std::vector<const ModOverlay*> overlays;
overlays.reserve(package->overlays.size());
for (const ModOverlay& overlay : package->overlays) {
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))
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;
if (!read_file(overlay->file, payload, &payload_error)) {
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));
}
} else {
const std::string resolver_id = package->resolver.substr(8);
const auto resolver = builtin_resolvers().find(resolver_id);
@@ -1293,31 +1744,113 @@ ModResolution ModPackageManager::resolve(const std::string& game_id,
result.errors.push_back(
"more than one derived-disc provider is active: " + providers);
}
for (size_t i = 0; i < result.writes.size(); ++i) {
const ModResolution::Write& write = result.writes[i];
std::vector<ModResolution::Write> coalesced;
coalesced.reserve(result.writes.size());
for (const ModResolution::Write& write : result.writes) {
if (write.expected.empty() ||
write.expected.size() != write.replacement.size()) {
result.errors.push_back(write.package_id + ": resolver emitted invalid write");
result.errors.push_back(
write.package_id + "/" + write.feature_id +
": resolver emitted invalid write");
continue;
}
for (size_t j = 0; j < i; ++j) {
if (writes_overlap(result.writes[j], write)) {
result.errors.push_back(
write.package_id + ": patch overlaps a write from " +
result.writes[j].package_id);
bool duplicate = false;
for (const ModResolution::Write& previous : coalesced) {
if (identical_write(previous, write)) {
duplicate = true;
break;
}
if (!writes_overlap(previous, write)) continue;
ModResolution::Diagnostic diagnostic;
diagnostic.resource = overlap_resource(
write.target, write.location, write.replacement.size(),
previous.location, previous.replacement.size());
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 (!ranges_overlap(
overlay.target, overlay.location, overlay.payload.size(),
write.target, write.location, write.replacement.size()))
continue;
ModResolution::Diagnostic diagnostic;
diagnostic.resource = overlap_resource(
overlay.target, overlay.location, overlay.payload.size(),
write.location, write.replacement.size());
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;
}
ModResolution::Diagnostic diagnostic;
diagnostic.resource = overlap_resource(
overlay.target, overlay.location, overlay.payload.size(),
previous.location, previous.payload.size());
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();
return result;
}
result.fingerprint = fingerprint_text(
canonical_resolution(
result.ordered, selections_, result.writes, result.derived_discs,
result.ordered, selections_, result.writes, result.overlays,
result.derived_discs,
disc_sha256));
result.ok = true;
return result;
+387 -26
View File
@@ -41,6 +41,11 @@ namespace {
struct RuntimeMods {
ModPackageManager manager;
ModResolution plan;
ModResolution validation;
std::map<uint32_t, std::vector<size_t>> raw_disc_index;
std::map<uint32_t, std::vector<size_t>> user_disc_index;
std::map<uint32_t, std::vector<size_t>> raw_overlay_index;
std::map<uint32_t, std::vector<size_t>> user_overlay_index;
std::string game_id;
std::string error;
std::string exe_sha256;
@@ -63,6 +68,14 @@ const ModPackage* selected_package(const std::string& id) {
return state().manager.selected_package(id);
}
bool package_has_enabled_feature(const ModPackage& package) {
return std::any_of(
package.features.begin(), package.features.end(),
[&](const ModFeature& feature) {
return state().manager.feature_enabled(package.id, feature.id);
});
}
std::string selected_value(const ModPackage& package, const ModOption& option) {
const auto selection = state().manager.selections().find(package.id);
if (selection != state().manager.selections().end()) {
@@ -72,6 +85,32 @@ std::string selected_value(const ModPackage& package, const ModOption& option) {
return option.default_value;
}
void build_disc_index(RuntimeMods& s) {
s.raw_disc_index.clear();
s.user_disc_index.clear();
s.raw_overlay_index.clear();
s.user_overlay_index.clear();
for (size_t i = 0; i < s.plan.writes.size(); ++i) {
const ModResolution::Write& write = s.plan.writes[i];
if (write.target == ModPatchTarget::DiscRaw)
s.raw_disc_index[(uint32_t)(write.location / 2352)].push_back(i);
else if (write.target == ModPatchTarget::DiscUser)
s.user_disc_index[(uint32_t)(write.location / 2048)].push_back(i);
}
for (size_t i = 0; i < s.plan.overlays.size(); ++i) {
const ModResolution::Overlay& overlay = s.plan.overlays[i];
const uint64_t sector_size =
overlay.target == ModPatchTarget::DiscRaw ? 2352 : 2048;
auto& index = overlay.target == ModPatchTarget::DiscRaw
? s.raw_overlay_index : s.user_overlay_index;
const uint64_t first = overlay.location / sector_size;
const uint64_t last =
(overlay.location + overlay.payload.size() - 1) / sector_size;
for (uint64_t lba = first; lba <= last; ++lba)
index[(uint32_t)lba].push_back(i);
}
}
void set_error(const std::string& error) {
state().error = error;
}
@@ -158,7 +197,7 @@ std::filesystem::path raw_image_path(const std::filesystem::path& path,
if (extension != ".cue") return path;
std::ifstream cue(path);
if (!cue) {
if (error) *error = "cannot open CUE for derived disc: " + path.string();
if (error) *error = "cannot open disc CUE: " + path.string();
return {};
}
std::string line;
@@ -182,10 +221,67 @@ std::filesystem::path raw_image_path(const std::filesystem::path& path,
}
return (path.parent_path() / name).lexically_normal();
}
if (error) *error = "CUE has no source file for derived disc: " + path.string();
if (error) *error = "disc CUE has no source file: " + path.string();
return {};
}
bool sha256_disc_range(const std::filesystem::path& image,
ModPatchTarget target, uint64_t location, size_t size,
std::string& out, std::string* error) {
const std::filesystem::path source = raw_image_path(image, error);
if (source.empty()) return false;
std::ifstream file(source, std::ios::binary);
if (!file) {
if (error) *error = "cannot open stock image range: " + source.string();
return false;
}
file.seekg(0, std::ios::end);
const std::streamoff file_size = file.tellg();
if (file_size < 0) {
if (error) *error = "cannot size stock image: " + source.string();
return false;
}
psx_sha256_ctx hash;
psx_sha256_init(&hash);
std::array<uint8_t, 2048> bytes{};
size_t remaining = size;
uint64_t at = location;
const bool raw_source = file_size > 0 &&
((uint64_t)file_size % 2352u) == 0;
while (remaining != 0) {
uint64_t physical = at;
size_t chunk = remaining;
if (target == ModPatchTarget::DiscUser && raw_source) {
const uint64_t lba = at / 2048u;
const size_t within = (size_t)(at % 2048u);
physical = lba * 2352u + 24u + within;
chunk = std::min(chunk, 2048u - within);
}
chunk = std::min(chunk, bytes.size());
if (physical > (uint64_t)file_size ||
chunk > (uint64_t)file_size - physical) {
if (error) *error = "overlay expected range exceeds stock image";
return false;
}
file.clear();
file.seekg((std::streamoff)physical);
if (!file.read((char*)bytes.data(), (std::streamsize)chunk)) {
if (error) *error = "cannot read stock image overlay range";
return false;
}
psx_sha256_update(&hash, bytes.data(), chunk);
at += chunk;
remaining -= chunk;
}
uint8_t digest[32];
psx_sha256_final(&hash, digest);
std::ostringstream text;
for (uint8_t byte : digest)
text << std::hex << std::setw(2) << std::setfill('0') << (unsigned)byte;
out = text.str();
return true;
}
#if defined(_WIN32)
std::wstring quote_windows_argument(const std::wstring& value) {
if (value.find_first_of(L" \t\n\v\"") == std::wstring::npos) return value;
@@ -382,9 +478,7 @@ int provider_package_get(void*, int index, RecompLauncherCModPackage* out) {
copy_text(out->author, sizeof(out->author), package->author);
copy_text(out->description, sizeof(out->description), package->description);
copy_text(out->license, sizeof(out->license), package->license);
const auto selection = state().manager.selections().find(package->id);
out->enabled = selection != state().manager.selections().end() &&
selection->second.enabled;
out->enabled = package_has_enabled_feature(*package);
out->option_count = (int)package->options.size();
out->removable = !out->enabled;
return 1;
@@ -427,6 +521,201 @@ int provider_choice_get(void*, const char* package_id, const char* option_id,
return 1;
}
template <typename Callback>
int mutate(Callback callback);
bool provider_feature_at(int index, const ModPackage*& package,
const ModFeature*& feature) {
if (index < 0) return false;
for (const auto& [package_id, versions] : state().manager.packages()) {
(void)versions;
const ModPackage* selected = selected_package(package_id);
if (!selected) continue;
for (const ModFeature& candidate : selected->features) {
if (index-- == 0) {
package = selected;
feature = &candidate;
return true;
}
}
}
return false;
}
std::vector<const ModOption*> provider_feature_options(
const ModPackage& package, const std::string& feature_id) {
std::vector<const ModOption*> out;
for (const ModOption& option : package.options)
if (option.feature_id == feature_id) out.push_back(&option);
return out;
}
bool diagnostic_matches(const ModResolution::Diagnostic& diagnostic,
const std::string& package_id,
const std::string& feature_id) {
return (diagnostic.package_id == package_id &&
diagnostic.feature_id == feature_id) ||
(diagnostic.other_package_id == package_id &&
diagnostic.other_feature_id == feature_id);
}
int provider_feature_count(void*) {
int count = 0;
for (const auto& [package_id, versions] : state().manager.packages()) {
(void)versions;
const ModPackage* package = selected_package(package_id);
if (package) count += (int)package->features.size();
}
return count;
}
int provider_feature_get(void*, int index, RecompLauncherCModFeature* out) {
if (!out) return 0;
const ModPackage* package = nullptr;
const ModFeature* feature = nullptr;
if (!provider_feature_at(index, package, feature)) return 0;
std::memset(out, 0, sizeof(*out));
copy_text(out->id, sizeof(out->id), feature->id);
copy_text(out->package_id, sizeof(out->package_id), package->id);
copy_text(out->package_version, sizeof(out->package_version), package->version);
copy_text(out->package_name, sizeof(out->package_name), package->name);
copy_text(out->name, sizeof(out->name), feature->name);
copy_text(out->author, sizeof(out->author), package->author);
copy_text(out->description, sizeof(out->description), feature->description);
copy_text(out->group, sizeof(out->group), feature->group);
out->enabled =
state().manager.feature_enabled(package->id, feature->id) ? 1 : 0;
out->option_count =
(int)provider_feature_options(*package, feature->id).size();
for (const ModResolution::Diagnostic& diagnostic :
state().validation.diagnostics) {
if (!diagnostic_matches(diagnostic, package->id, feature->id)) continue;
out->has_error = 1;
copy_text(out->status, sizeof(out->status), diagnostic.message);
break;
}
return 1;
}
int provider_feature_option_get(void*, const char* package_id,
const char* feature_id, int index,
RecompLauncherCModOption* out) {
if (!package_id || !feature_id || !out || index < 0) return 0;
const ModPackage* package = selected_package(package_id);
if (!package) return 0;
const auto options = provider_feature_options(*package, feature_id);
if ((size_t)index >= options.size()) return 0;
const ModOption& option = *options[(size_t)index];
std::memset(out, 0, sizeof(*out));
copy_text(out->id, sizeof(out->id), option.id);
copy_text(out->label, sizeof(out->label), option.label);
copy_text(out->description, sizeof(out->description), option.description);
copy_text(out->group, sizeof(out->group), option.group);
copy_text(out->value, sizeof(out->value),
state().manager.feature_option_value(
package_id, feature_id, option.id));
copy_text(out->default_value, sizeof(out->default_value),
option.default_value);
out->type = option.type == ModOptionType::Boolean
? RECOMP_MOD_OPTION_BOOLEAN
: option.type == ModOptionType::Choice
? RECOMP_MOD_OPTION_CHOICE : RECOMP_MOD_OPTION_INTEGER;
out->min_value = option.min_value;
out->max_value = option.max_value;
out->step = option.step;
out->choice_count = (int)option.choices.size();
return 1;
}
int provider_feature_choice_get(void*, const char* package_id,
const char* feature_id,
const char* option_id, int index,
RecompLauncherCModChoice* out) {
if (!package_id || !feature_id || !option_id || !out || index < 0)
return 0;
const ModPackage* package = selected_package(package_id);
if (!package) return 0;
const auto option = std::find_if(
package->options.begin(), package->options.end(),
[&](const ModOption& value) {
return value.feature_id == feature_id && value.id == option_id;
});
if (option == package->options.end() ||
(size_t)index >= option->choices.size()) return 0;
std::memset(out, 0, sizeof(*out));
copy_text(out->value, sizeof(out->value),
option->choices[(size_t)index].value);
copy_text(out->label, sizeof(out->label),
option->choices[(size_t)index].label);
return 1;
}
int provider_feature_enable(void*, const char* package_id,
const char* feature_id, int enabled) {
if (!package_id || !feature_id) return 0;
return mutate([&](std::string& error) {
const ModFeature* feature =
state().manager.selected_feature(package_id, feature_id);
if (feature && feature->legacy)
return state().manager.set_enabled(
package_id, enabled != 0, &error);
return state().manager.set_feature_enabled(
package_id, feature_id, enabled != 0, &error);
});
}
int provider_feature_set_option(void*, const char* package_id,
const char* feature_id,
const char* option_id,
const char* value) {
if (!package_id || !feature_id || !option_id || !value) return 0;
return mutate([&](std::string& error) {
const ModFeature* feature =
state().manager.selected_feature(package_id, feature_id);
if (feature && feature->legacy)
return state().manager.set_option(
package_id, option_id, value, &error);
return state().manager.set_feature_option(
package_id, feature_id, option_id, value, &error);
});
}
int provider_diagnostic_count(void*, const char* package_id,
const char* feature_id) {
if (!package_id || !feature_id) return 0;
return (int)std::count_if(
state().validation.diagnostics.begin(),
state().validation.diagnostics.end(),
[&](const ModResolution::Diagnostic& diagnostic) {
return diagnostic_matches(diagnostic, package_id, feature_id);
});
}
int provider_diagnostic_get(void*, const char* package_id,
const char* feature_id, int index,
RecompLauncherCModDiagnostic* out) {
if (!package_id || !feature_id || !out || index < 0) return 0;
for (const ModResolution::Diagnostic& diagnostic :
state().validation.diagnostics) {
if (!diagnostic_matches(diagnostic, package_id, feature_id)) continue;
if (index-- != 0) continue;
std::memset(out, 0, sizeof(*out));
out->severity = 2;
copy_text(out->resource, sizeof(out->resource), diagnostic.resource);
copy_text(out->message, sizeof(out->message), diagnostic.message);
const bool primary = diagnostic.package_id == package_id &&
diagnostic.feature_id == feature_id;
copy_text(out->related_package_id, sizeof(out->related_package_id),
primary ? diagnostic.other_package_id :
diagnostic.package_id);
copy_text(out->related_feature_id, sizeof(out->related_feature_id),
primary ? diagnostic.other_feature_id :
diagnostic.feature_id);
return 1;
}
return 0;
}
int provider_version_count(void*, const char* package_id) {
if (!package_id) return 0;
const auto package = state().manager.packages().find(package_id);
@@ -445,9 +734,8 @@ int provider_version_get(void*, const char* package_id, int index,
copy_text(out->version, sizeof(out->version), version->first);
const ModPackage* selected = selected_package(package_id);
out->selected = selected && selected->version == version->first;
const auto selection = state().manager.selections().find(package_id);
out->removable = selection == state().manager.selections().end() ||
!selection->second.enabled || !out->selected;
out->removable = !out->selected ||
!selected || !package_has_enabled_feature(*selected);
return 1;
}
@@ -458,6 +746,11 @@ int mutate(Callback callback) {
set_error(error);
return 0;
}
if (!state().disc_path.empty())
state().validation = state().manager.resolve(
state().game_id, state().exe_sha256, state().disc_sha256);
else
state().validation = {};
state().error.clear();
return 1;
}
@@ -530,6 +823,14 @@ RecompLauncherCModProvider provider = {
provider_set_option,
provider_commit,
provider_error,
provider_feature_count,
provider_feature_get,
provider_feature_option_get,
provider_feature_choice_get,
provider_feature_enable,
provider_feature_set_option,
provider_diagnostic_count,
provider_diagnostic_get,
};
#endif
@@ -543,6 +844,11 @@ bool mod_runtime_initialize(const std::filesystem::path& root,
RuntimeMods& s = state();
s.manager.set_root({});
s.plan = {};
s.validation = {};
s.raw_disc_index.clear();
s.user_disc_index.clear();
s.raw_overlay_index.clear();
s.user_overlay_index.clear();
s.game_id.clear();
s.error.clear();
s.exe_sha256.clear();
@@ -583,6 +889,7 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err
}
ModResolution plan =
s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256);
s.validation = plan;
if (!plan.ok) {
s.error.clear();
for (const std::string& item : plan.errors) {
@@ -592,6 +899,20 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err
if (error) *error = s.error;
return false;
}
for (const ModResolution::Overlay& overlay : plan.overlays) {
if (overlay.expected_sha256.empty()) continue;
std::string actual;
if (!sha256_disc_range(
s.disc_path, overlay.target, overlay.location,
overlay.payload.size(), actual, &s.error) ||
actual != overlay.expected_sha256) {
if (s.error.empty())
s.error = overlay.package_id + "/" + overlay.feature_id +
": stock overlay range checksum failed";
if (error) *error = s.error;
return false;
}
}
std::filesystem::path effective_disc;
if (!materialize_derived_disc(s, plan, effective_disc, &s.error)) {
if (error) *error = s.error;
@@ -602,6 +923,7 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err
return false;
}
s.plan = std::move(plan);
build_disc_index(s);
s.effective_disc_path = std::move(effective_disc);
s.main_applied = false;
s.error.clear();
@@ -670,29 +992,68 @@ extern "C" void mod_runtime_patch_disc_sector(uint32_t lba, int raw_sector,
RuntimeMods& s = state();
if (!s.initialized || !s.disc_enabled || s.disc_guard_failed ||
!bytes || size == 0) return;
/* Raw Mode2 Form1 reads are also the source of the 2048-byte logical
* stream consumed by the emulated CD controller. Apply raw claims to the
* complete sector, then user-data claims to its payload window. Form2/XA
* and CDDA sectors deliberately do not receive disc_user overlays. */
const bool has_mode2_form1_user_data =
raw_sector && size >= 2072 && bytes[15] == 2 &&
(bytes[18] & 0x20u) == 0;
const ModPatchTarget target =
raw_sector ? ModPatchTarget::DiscRaw : ModPatchTarget::DiscUser;
const uint64_t base = (uint64_t)lba * size;
const uint64_t end = base + size;
for (const ModResolution::Write& write : s.plan.writes) {
if (write.target != target || write.location < base ||
write.location + write.replacement.size() > end) continue;
const size_t offset = (size_t)(write.location - base);
if (std::memcmp(bytes + offset, write.expected.data(),
write.expected.size()) != 0) {
std::fprintf(stderr,
"psxrecomp: disc mod plan %s rejected at LBA %u+%zu "
"(expected-byte guard failed; disc overlay disabled)\n",
s.plan.fingerprint.c_str(), lba, offset);
s.disc_guard_failed = true;
return;
const auto& index = raw_sector ? s.raw_disc_index : s.user_disc_index;
const auto sector = index.find(lba);
const auto& overlay_index =
raw_sector ? s.raw_overlay_index : s.user_overlay_index;
const auto overlay_sector = overlay_index.find(lba);
if (sector == index.end() && overlay_sector == overlay_index.end()) {
if (has_mode2_form1_user_data)
mod_runtime_patch_disc_sector(lba, 0, bytes + 24, 2048);
return;
}
if (sector != index.end()) {
for (size_t write_index : sector->second) {
const ModResolution::Write& write = s.plan.writes[write_index];
if (write.target != target || write.location < base ||
write.location + write.replacement.size() > end) continue;
const size_t offset = (size_t)(write.location - base);
if (std::memcmp(bytes + offset, write.expected.data(),
write.expected.size()) != 0) {
std::fprintf(stderr,
"psxrecomp: disc mod plan %s rejected at LBA %u+%zu "
"(expected-byte guard failed; disc overlay disabled)\n",
s.plan.fingerprint.c_str(), lba, offset);
s.disc_guard_failed = true;
return;
}
}
for (size_t write_index : sector->second) {
const ModResolution::Write& write = s.plan.writes[write_index];
if (write.target != target || write.location < base ||
write.location + write.replacement.size() > end) continue;
const size_t offset = (size_t)(write.location - base);
std::memcpy(bytes + offset, write.replacement.data(),
write.replacement.size());
}
}
for (const ModResolution::Write& write : s.plan.writes) {
if (write.target != target || write.location < base ||
write.location + write.replacement.size() > end) continue;
const size_t offset = (size_t)(write.location - base);
std::memcpy(bytes + offset, write.replacement.data(),
write.replacement.size());
if (overlay_sector != overlay_index.end()) {
for (size_t overlay_index_value : overlay_sector->second) {
const ModResolution::Overlay& overlay =
s.plan.overlays[overlay_index_value];
const uint64_t overlay_end =
overlay.location + overlay.payload.size();
const uint64_t copy_begin = std::max(base, overlay.location);
const uint64_t copy_end = std::min(end, overlay_end);
if (copy_begin >= copy_end) continue;
const size_t destination = (size_t)(copy_begin - base);
const size_t source = (size_t)(copy_begin - overlay.location);
const size_t count = (size_t)(copy_end - copy_begin);
std::memcpy(bytes + destination,
overlay.payload.data() + source, count);
}
}
if (has_mode2_form1_user_data && !s.disc_guard_failed)
mod_runtime_patch_disc_sector(lba, 0, bytes + 24, 2048);
}
+192
View File
@@ -26,6 +26,24 @@ static void write_text(const fs::path& path, const std::string& text) {
out << text;
}
static void write_bytes(const fs::path& path, const std::vector<uint8_t>& bytes) {
fs::create_directories(path.parent_path());
std::ofstream out(path, std::ios::binary);
out.write((const char*)bytes.data(), (std::streamsize)bytes.size());
}
static std::string sha256_hex(const std::vector<uint8_t>& bytes) {
uint8_t digest[32];
psx_sha256_compute(bytes.data(), bytes.size(), digest);
static const char hex[] = "0123456789abcdef";
std::string out(64, '0');
for (size_t i = 0; i < 32; ++i) {
out[i * 2] = hex[digest[i] >> 4];
out[i * 2 + 1] = hex[digest[i] & 15];
}
return out;
}
static void write_deflated_package(const fs::path& path) {
static const char* compressed_hex =
"4bcb2fca4d2c892f4b2d2acecccf53b05530e4ca4c01524a5599057ab9f929"
@@ -240,6 +258,180 @@ int main() {
matrix.derived_discs[0].output_size == 222222,
"multi-option derived-disc condition must match selected values");
write_text(root / "packages/features.mod/1.0.0/manifest.toml",
manifest("features.mod", "1.0.0",
"\n[[feature]]\n"
"id = \"title-screen\"\n"
"name = \"Title Screen\"\n"
"group = \"Localization\"\n"
"\n[[feature]]\n"
"id = \"retranslation\"\n"
"name = \"Retranslation\"\n"
"group = \"Localization\"\n"
"\n[[feature]]\n"
"id = \"title-collision\"\n"
"name = \"Title Collision\"\n"
"\n[[feature]]\n"
"id = \"title-identical\"\n"
"name = \"Title Identical\"\n"
"\n[[option]]\n"
"feature = \"title-screen\"\n"
"id = \"variant\"\n"
"label = \"Variant\"\n"
"type = \"choice\"\n"
"default = \"usa\"\n"
"[[option.choice]]\n"
"value = \"usa\"\n"
"label = \"Mega Man X6\"\n"
"[[option.choice]]\n"
"value = \"japan\"\n"
"label = \"Rockman X6\"\n"
"\n[[option]]\n"
"feature = \"retranslation\"\n"
"id = \"variant\"\n"
"label = \"Variant\"\n"
"type = \"boolean\"\n"
"default = \"true\"\n"
"\n[[patch]]\n"
"feature = \"title-screen\"\n"
"target = \"main_exe\"\n"
"address = 2147495936\n"
"expected = \"0102\"\n"
"replace = \"a1a2\"\n"
"when = { variant = \"japan\" }\n"
"\n[[patch]]\n"
"feature = \"retranslation\"\n"
"target = \"disc_raw\"\n"
"offset = 23520\n"
"expected = \"03\"\n"
"replace = \"b3\"\n"
"when = { variant = \"true\" }\n"
"\n[[patch]]\n"
"feature = \"title-collision\"\n"
"target = \"main_exe\"\n"
"address = 2147495937\n"
"expected = \"02\"\n"
"replace = \"ff\"\n"
"\n[[patch]]\n"
"feature = \"title-identical\"\n"
"target = \"main_exe\"\n"
"address = 2147495936\n"
"expected = \"0102\"\n"
"replace = \"a1a2\"\n"));
check(reload.scan(&error), error.c_str());
check(!reload.set_enabled("features.mod", true, &error),
"feature-style package must not expose package enablement");
check(reload.set_feature_option(
"features.mod", "title-screen", "variant", "japan", &error),
error.c_str());
check(reload.set_feature_enabled(
"features.mod", "title-screen", true, &error), error.c_str());
check(reload.set_feature_enabled(
"features.mod", "retranslation", true, &error), error.c_str());
ModResolution features = reload.resolve("SLUS-TEST");
check(features.ok && features.writes.size() == 2,
"independently enabled features must compose their operations");
check(features.ok && features.writes[0].feature_id == "title-screen" &&
features.writes[1].feature_id == "retranslation",
"resolved writes must retain feature ownership");
check(reload.set_feature_enabled(
"features.mod", "title-collision", true, &error), error.c_str());
ModResolution collision = reload.resolve("SLUS-TEST");
check(!collision.ok && collision.diagnostics.size() == 1,
"overlapping feature writes must produce a structured diagnostic");
check(!collision.diagnostics.empty() &&
collision.diagnostics[0].feature_id == "title-collision" &&
collision.diagnostics[0].other_feature_id == "title-screen" &&
!collision.diagnostics[0].resource.empty(),
"collision diagnostic must identify both features and the resource");
check(reload.set_feature_enabled(
"features.mod", "title-collision", false, &error), error.c_str());
check(reload.set_feature_enabled(
"features.mod", "title-identical", true, &error), error.c_str());
ModResolution identical = reload.resolve("SLUS-TEST");
check(identical.ok && identical.writes.size() == 2,
"truly identical writes must coalesce deterministically");
check(reload.save_state(&error), error.c_str());
ModPackageManager feature_reload(root);
check(feature_reload.scan(&error), error.c_str());
check(feature_reload.load_state(&error), error.c_str());
check(feature_reload.feature_enabled("features.mod", "title-screen") &&
feature_reload.feature_enabled("features.mod", "retranslation") &&
!feature_reload.feature_enabled("features.mod", "title-collision"),
"per-feature enabled state must survive save/reload");
check(feature_reload.feature_option_value(
"features.mod", "title-screen", "variant") == "japan",
"feature-scoped option values must survive save/reload");
check(feature_reload.resolve("SLUS-TEST").fingerprint ==
identical.fingerprint,
"feature state must resolve deterministically after reload");
const std::vector<uint8_t> overlay_a = {1, 2, 3, 4};
const std::vector<uint8_t> overlay_b = {8, 9};
const std::string overlay_disc_hash(64, '4');
write_bytes(root / "packages/overlay.mod/1.0.0/assets/a.bin", overlay_a);
write_bytes(root / "packages/overlay.mod/1.0.0/assets/b.bin", overlay_b);
write_text(root / "packages/overlay.mod/1.0.0/manifest.toml",
manifest("overlay.mod", "1.0.0",
"disc_sha256 = \"" + overlay_disc_hash + "\"\n"
"[[feature]]\n"
"id = \"asset-a\"\n"
"name = \"Asset A\"\n"
"[[feature]]\n"
"id = \"asset-b\"\n"
"name = \"Asset B\"\n"
"[[overlay]]\n"
"feature = \"asset-a\"\n"
"target = \"disc_raw\"\n"
"offset = 100\n"
"file = \"assets/a.bin\"\n"
"sha256 = \"" + sha256_hex(overlay_a) + "\"\n"
"[[overlay]]\n"
"feature = \"asset-b\"\n"
"target = \"disc_raw\"\n"
"offset = 102\n"
"file = \"assets/b.bin\"\n"
"sha256 = \"" + sha256_hex(overlay_b) + "\"\n"));
check(feature_reload.scan(&error), error.c_str());
const ModPackage* overlay_package =
feature_reload.selected_package("overlay.mod");
check(overlay_package && overlay_package->overlays.size() == 2 &&
overlay_package->overlays[0].size == overlay_a.size(),
"manifest scan must verify and retain overlay metadata");
check(feature_reload.set_feature_enabled(
"overlay.mod", "asset-a", true, &error), error.c_str());
check(feature_reload.set_feature_enabled(
"overlay.mod", "asset-b", true, &error), error.c_str());
ModResolution overlay_collision =
feature_reload.resolve("SLUS-TEST", {}, overlay_disc_hash);
check(!overlay_collision.ok &&
overlay_collision.diagnostics.size() == 1 &&
overlay_collision.diagnostics[0].feature_id == "asset-b" &&
overlay_collision.diagnostics[0].other_feature_id == "asset-a",
"overlapping file overlays must identify both owning features");
check(feature_reload.set_feature_enabled(
"overlay.mod", "asset-b", false, &error), error.c_str());
ModResolution one_overlay =
feature_reload.resolve("SLUS-TEST", {}, overlay_disc_hash);
check(one_overlay.ok && one_overlay.overlays.size() == 1 &&
one_overlay.overlays[0].payload == overlay_a,
"only enabled overlay payloads must enter the resolved plan");
write_text(root / "feature-derived.toml",
manifest("bad.derived", "1.0.0",
"\n[[feature]]\n"
"id = \"bad\"\n"
"name = \"Bad\"\n"
"[[derived_disc]]\n"
"patch = \"bad.xdelta3\"\n"
"patch_sha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n"
"output_size = 1\n"
"output_sha256 = \"1111111111111111111111111111111111111111111111111111111111111111\"\n"));
ModPackage feature_derived;
check(!ModPackageManager::read_manifest(
root / "feature-derived.toml", feature_derived, &error),
"feature-style packages must reject derived-disc operations");
ModPackage invalid;
write_text(root / "bad.toml",
"format_version=1\nid=\"../bad\"\nversion=\"1.0.0\"\nname=\"Bad\"\n"
+112 -4
View File
@@ -1,4 +1,5 @@
#include "mod_runtime.h"
#include "psx_sha256.h"
#include <array>
#include <cstdint>
@@ -6,6 +7,7 @@
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
namespace fs = std::filesystem;
@@ -35,10 +37,36 @@ static void write_text(const fs::path& path, const std::string& text) {
out << text;
}
static void write_bytes(const fs::path& path, const std::vector<uint8_t>& bytes) {
fs::create_directories(path.parent_path());
std::ofstream out(path, std::ios::binary);
out.write((const char*)bytes.data(), (std::streamsize)bytes.size());
}
static std::string sha256_hex(const std::vector<uint8_t>& bytes) {
uint8_t digest[32];
psx_sha256_compute(bytes.data(), bytes.size(), digest);
static const char hex[] = "0123456789abcdef";
std::string out(64, '0');
for (size_t i = 0; i < 32; ++i) {
out[i * 2] = hex[digest[i] >> 4];
out[i * 2 + 1] = hex[digest[i] & 15];
}
return out;
}
int main() {
const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-test";
std::error_code ec;
fs::remove_all(root, ec);
const std::vector<uint8_t> stock(8 * 2352, 0);
std::vector<uint8_t> overlay(3000);
for (size_t i = 0; i < overlay.size(); ++i)
overlay[i] = (uint8_t)(i * 17u + 3u);
const fs::path stock_path = root / "stock.bin";
write_bytes(stock_path, stock);
write_bytes(root / "packages/runtime.test/1.0.0/assets/overlay.bin",
overlay);
write_text(root / "packages/runtime.test/1.0.0/manifest.toml",
"format_version = 1\n"
"id = \"runtime.test\"\n"
@@ -46,28 +74,72 @@ int main() {
"name = \"Runtime Test\"\n"
"[[target]]\n"
"game_id = \"SLUS-RUNTIME\"\n"
"disc_sha256 = \"" + sha256_hex(stock) + "\"\n"
"[[feature]]\n"
"id = \"main-code\"\n"
"name = \"Main Code\"\n"
"[[feature]]\n"
"id = \"disc-byte\"\n"
"name = \"Disc Byte\"\n"
"[[feature]]\n"
"id = \"asset-overlay\"\n"
"name = \"Asset Overlay\"\n"
"[[feature]]\n"
"id = \"user-byte\"\n"
"name = \"User Byte\"\n"
"[[patch]]\n"
"feature = \"main-code\"\n"
"target = \"main_exe\"\n"
"address = 2147487744\n"
"expected = \"01020304\"\n"
"replace = \"a1a2a3a4\"\n"
"[[patch]]\n"
"feature = \"disc-byte\"\n"
"target = \"disc_raw\"\n"
"offset = 4714\n"
"expected = \"aa\"\n"
"replace = \"bb\"\n");
"replace = \"bb\"\n"
"[[patch]]\n"
"feature = \"user-byte\"\n"
"target = \"disc_user\"\n"
"offset = 6154\n"
"expected = \"cc\"\n"
"replace = \"dd\"\n"
"[[overlay]]\n"
"feature = \"asset-overlay\"\n"
"target = \"disc_raw\"\n"
"offset = 11408\n"
"file = \"assets/overlay.bin\"\n"
"sha256 = \"" + sha256_hex(overlay) + "\"\n"
"expected_sha256 = \"" +
sha256_hex(std::vector<uint8_t>(overlay.size(), 0)) + "\"\n");
write_text(root / "state.toml",
"format_version = 1\n"
"format_version = 2\n"
"[[package]]\n"
"id = \"runtime.test\"\n"
"version = \"1.0.0\"\n"
"[[feature]]\n"
"package_id = \"runtime.test\"\n"
"id = \"main-code\"\n"
"enabled = true\n"
"version = \"1.0.0\"\n");
"[[feature]]\n"
"package_id = \"runtime.test\"\n"
"id = \"disc-byte\"\n"
"enabled = true\n"
"[[feature]]\n"
"package_id = \"runtime.test\"\n"
"id = \"asset-overlay\"\n"
"enabled = true\n"
"[[feature]]\n"
"package_id = \"runtime.test\"\n"
"id = \"user-byte\"\n"
"enabled = true\n");
std::string error;
check(PSXRecompV4::mod_runtime_initialize(
root, "SLUS-RUNTIME", 0x80002000, {}, &error),
error.c_str());
check(PSXRecompV4::mod_runtime_commit({}, &error), error.c_str());
check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str());
ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4;
mod_runtime_on_dispatch(0x80001000);
@@ -84,6 +156,42 @@ int main() {
mod_runtime_patch_disc_sector(2, 1, sector.data(), (uint32_t)sector.size());
check(sector[10] == 0xbb, "raw disc overlay must patch matching sectors");
std::array<uint8_t, 2352> overlay_sector{};
mod_runtime_patch_disc_sector(
4, 1, overlay_sector.data(), (uint32_t)overlay_sector.size());
check(overlay_sector[1999] == 0 &&
overlay_sector[2000] == overlay[0] &&
overlay_sector[2351] == overlay[351],
"file overlay must patch the tail of its first sector");
overlay_sector.fill(0);
mod_runtime_patch_disc_sector(
5, 1, overlay_sector.data(), (uint32_t)overlay_sector.size());
check(overlay_sector.front() == overlay[352] &&
overlay_sector.back() == overlay[2703],
"file overlay must patch complete middle sectors");
overlay_sector.fill(0);
mod_runtime_patch_disc_sector(
6, 1, overlay_sector.data(), (uint32_t)overlay_sector.size());
check(overlay_sector[0] == overlay[2704] &&
overlay_sector[295] == overlay[2999] &&
overlay_sector[296] == 0,
"file overlay must patch the head of its final sector");
std::array<uint8_t, 2352> mode2_sector{};
mode2_sector[15] = 2;
mode2_sector[18] = 0;
mode2_sector[24 + 10] = 0xcc;
mod_runtime_patch_disc_sector(
3, 1, mode2_sector.data(), (uint32_t)mode2_sector.size());
check(mode2_sector[24 + 10] == 0xdd,
"disc_user operations must apply to raw Mode2 Form1 user data");
std::array<uint8_t, 2352> audio_sector{};
audio_sector[24 + 10] = 0xcc;
mod_runtime_patch_disc_sector(
3, 1, audio_sector.data(), (uint32_t)audio_sector.size());
check(audio_sector[24 + 10] == 0xcc,
"disc_user operations must not modify CDDA/non-data sectors");
fs::remove_all(root, ec);
if (failures) return 1;
std::cout << "mod runtime tests passed\n";