mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
feat: add feature-oriented mod resolution
This commit is contained in:
+121
-127
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user