#pragma once #include "mod_plugins.h" #include #include #include #include #include #include #include namespace PSXRecompV4 { /* How finished a feature is, and therefore who is allowed to see it. * * Stable ships and is offered to everyone. Experimental ships, badged, and is * default-off: it works, but has not been validated on this title. Developer * does not ship at all -- contributors reach those features by cloning the * repo and building locally. * * This lives on the FEATURE, not the package. A package is the trust and * installation boundary; how finished one of its features is has nothing to do * with that, and tying the two forced a whole catalog down to the maturity of * its least finished feature. A package may still declare a channel, which its * features inherit unless they state their own. */ enum class ModChannel { Stable = 0, Experimental = 1, Developer = 2, }; /* Whether this build may see developer-channel features. A local build may; a * CI/release build may not, which is what "developer does not ship" means in * practice. runtime.cmake defines PSX_MOD_DEVELOPER_CHANNEL for local builds. */ #if defined(PSX_MOD_DEVELOPER_CHANNEL) && PSX_MOD_DEVELOPER_CHANNEL inline constexpr bool kDeveloperChannelDefault = true; #else inline constexpr bool kDeveloperChannelDefault = false; #endif const char* mod_channel_name(ModChannel channel); bool mod_channel_from_name(const std::string& name, ModChannel& out); enum class ModOptionType { Boolean, Choice, Integer, }; struct ModChoice { std::string value; std::string label; }; struct ModOption { std::string feature_id; std::string id; std::string label; std::string description; std::string group; ModOptionType type = ModOptionType::Boolean; std::string default_value; int64_t min_value = 0; int64_t max_value = 0; int64_t step = 1; std::vector choices; /* Optional id of a BOOLEAN option in the same feature that overrides this * one. While that option is true this control is inert: the launcher greys * it out and plugins ignore its value. Models "tick Instant and the speed * box stops mattering" without inventing a second widget type. */ std::string disabled_by; }; struct ModFeature { std::string id; std::string name; std::string author; std::string description; std::string group = "General"; ModChannel channel = ModChannel::Stable; bool default_enabled = false; bool hidden = false; bool legacy = false; }; enum class ModConstraintKind { OrderedInteger, RequiresFeature, }; enum class ModConstraintDirection { Nondecreasing, Nonincreasing, }; struct ModConstraint { std::string feature_id; ModConstraintKind kind = ModConstraintKind::OrderedInteger; ModConstraintDirection direction = ModConstraintDirection::Nondecreasing; std::vector options; std::string required_feature_id; std::string required_option_id; std::string required_value; }; struct ModRequirement { std::string id; std::string version; }; /* A feature of this package that needs a feature of ANOTHER package while a * condition on its own options holds (manifest [[requirement]], format 7). * * Package-level [[dependency]] is unconditional, and an in-package * requires_feature constraint cannot name another package. This is the * missing shape: "Extras = Full needs psx.enhancement.8mb-ram/8mb-ram". The * required feature is DERIVED for the session -- resolve() activates it even * when it is hidden or disabled in state.toml -- and is never written to * state.toml, because the player did not choose it. A requirement that cannot * be met (package absent, excluded, wrong version, feature missing) fails the * plan loudly; it never runs the requiring selection without it. */ struct ModFeatureRequirement { std::string feature_id; /* the requiring feature (owner) */ std::string package_id; /* the package that provides it */ std::string version = "*"; /* same range syntax as [[dependency]] */ std::string required_feature_id; /* feature of package_id to activate */ std::map when; /* owner's option conditions */ }; struct ModTarget { std::string game_id; std::string exe_sha256; std::string disc_sha256; }; enum class ModPatchTarget { MainExe, DiscRaw, DiscUser, }; enum class ModValueEncoding { U8, U16LE, U32LE, MipsLuiOriU32, }; enum class ModIntegerPredicateOp { Equal, NotEqual, Less, LessEqual, Greater, GreaterEqual, }; struct ModIntegerPredicate { bool present = false; std::string option; ModIntegerPredicateOp op = ModIntegerPredicateOp::Equal; int64_t value = 0; }; struct ModPatchField { uint64_t offset = 0; std::vector replacement; std::string replace_from_option; ModValueEncoding replace_encoding = ModValueEncoding::U8; int64_t replace_addend = 0; }; struct ModPatch { std::string feature_id; ModPatchTarget target = ModPatchTarget::MainExe; uint64_t location = 0; /* guest address or canonical disc-stream byte offset */ std::vector expected; std::vector replacement; std::string replace_from_option; ModValueEncoding replace_encoding = ModValueEncoding::U8; uint64_t replace_offset = 0; int64_t replace_addend = 0; bool replace_omit_when_default = false; std::string when_option; std::string when_value; std::map when; ModIntegerPredicate when_integer; std::vector fields; 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 when; struct FeaturePredicate { bool present = false; std::string package_id; std::string feature_id; bool enabled = true; } when_feature; int64_t order = 0; }; struct ModPlugin { std::string feature_id; std::string id; std::map when; int64_t order = 0; }; struct ModResource { std::string feature_id; std::string id; std::string label; std::string description; std::string file_patterns; std::string file_description; std::string format = "file"; bool required = false; uint64_t size = 0; std::string sha256; // Source inputs are launcher configuration, not guest resources. A shared // key stores one path across packages; derived resources never get pickers. std::string shared_source; bool input_only = false; bool hidden = false; }; struct ModDerivedDisc { std::string kind = "vcdiff"; std::filesystem::path patch; std::string patch_sha256; uint64_t output_size = 0; std::string output_sha256; std::string when_option; std::string when_value; std::map when; }; struct ModAuthorLink { std::string name; std::string url; }; /* Where a package's files came from, and therefore who owns them on disk. * * Bundled packages are build output: the framework and the title stage them * into /mods/bundled, which every build wipes and re-stages. Nothing the * player owns may live there. Installed packages are written by the launcher * into /mods/installed and are never touched by a build. * * Before this split there was one /mods/packages tree serving both roles, * so a rebuild deleted every mod the player had installed. */ enum class ModPackageOrigin { Bundled, Installed, }; struct ModPackage { uint32_t format_version = 0; std::string id; std::string version; std::string name; std::string author; std::vector author_links; std::string description; std::string license; std::string source_name; std::string source_url; std::string resolver = "declarative"; std::string prepare; std::string save_compatibility = "shared"; /* Default channel for features that do not declare their own. */ ModChannel channel = ModChannel::Stable; ModPackageOrigin origin = ModPackageOrigin::Installed; /* Set when an installed package of the same id shadows a bundled one. */ bool shadows_bundled = false; std::filesystem::path root; std::vector targets; std::vector dependencies; std::vector conflicts; std::vector features; std::vector options; std::vector constraints; std::vector requirements; std::vector patches; std::vector overlays; std::vector plugins; std::vector resources; std::vector derived_discs; }; struct ModPrepareContext { const ModPackage& package; std::string feature_id; std::filesystem::path disc_path; std::filesystem::path cache_root; std::filesystem::path application_dir; std::map inputs; }; using ModMediaPreparer = std::function&, std::string&)>; // Implementations are linked trusted code. A manifest cannot execute a command. bool mod_register_media_preparer(const std::string& id, ModMediaPreparer callback); struct ModFeatureSelection { bool enabled = false; bool has_enabled = false; std::map values; std::map resources; }; struct ModSelection { /* v1 migration state. Feature-style manifests do not use these fields. */ bool enabled = false; std::string version; std::map values; std::map features; }; struct ModResolution { bool ok = false; std::string fingerprint; std::vector ordered; struct Write { struct Field { uint64_t offset = 0; std::vector replacement; }; ModPatchTarget target = ModPatchTarget::MainExe; uint64_t location = 0; std::vector expected; std::vector replacement; std::vector fields; std::string package_id; std::string feature_id; }; std::vector writes; struct Overlay { ModPatchTarget target = ModPatchTarget::DiscRaw; uint64_t location = 0; std::vector payload; std::string payload_sha256; std::string expected_sha256; std::string package_id; std::string feature_id; }; std::vector overlays; struct DerivedDisc { std::string kind; std::filesystem::path patch; std::string patch_sha256; uint64_t output_size = 0; std::string output_sha256; std::string package_id; }; std::vector derived_discs; struct Plugin { std::string id; std::string package_id; std::string feature_id; }; std::vector plugins; struct Resource { std::string package_id; std::string feature_id; std::string id; std::filesystem::path path; std::string format; std::string sha256; std::shared_ptr> bytes; }; std::vector resources; 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 diagnostics; /* Features the plan activates because an active [[requirement]] needs * them, not because state.toml enables them. One entry per derived * feature (the first requirement that activated it). */ struct ImplicitFeature { std::string package_id; std::string feature_id; std::string required_by_package_id; std::string required_by_feature_id; }; std::vector implicit_features; /* The selection the plan was built from: state.toml's selection plus the * implicit activations above. Plugins read option values from this, so a * committed plan and the values its plugins observe cannot disagree. */ std::map selections; std::vector errors; }; struct ModBuiltinResolverContext { const std::map* active_packages = nullptr; const std::map* selections = nullptr; }; using ModBuiltinResolver = std::function& writes, std::vector& errors)>; class ModPackageManager { public: explicit ModPackageManager(std::filesystem::path mods_root = {}); void set_root(std::filesystem::path mods_root); const std::filesystem::path& root() const { return root_; } /* Developer-channel features are stripped at scan and install time unless * this is set. Defaults to kDeveloperChannelDefault; tests set it * explicitly so they exercise both a local and a release build. */ void set_developer_channel_visible(bool visible) { developer_channel_ = visible; } bool developer_channel_visible() const { return developer_channel_; } /* Build-owned catalog: wiped and re-staged by every build. */ std::filesystem::path bundled_root() const { return root_ / "bundled"; } /* Launcher-owned catalog: a build never touches this tree. */ std::filesystem::path installed_root() const { return root_ / "installed"; } bool scan(std::string* error = nullptr); bool load_state(std::string* error = nullptr); bool save_state(std::string* error = nullptr) const; bool prepare_resources(const std::string& game_id, const std::filesystem::path& disc_path, const std::filesystem::path& cache_root, std::string* error = nullptr); bool install_archive(const std::filesystem::path& archive, std::string* installed_id = nullptr, std::string* installed_version = nullptr, std::string* error = nullptr); bool remove_version(const std::string& id, const std::string& version, std::string* error = nullptr); bool set_enabled(const std::string& id, bool enabled, std::string* error = nullptr); bool select_version(const std::string& id, const std::string& version, 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); bool set_feature_resource_path(const std::string& package_id, const std::string& feature_id, const std::string& resource_id, const std::filesystem::path& path, std::string* error = nullptr); const std::map>& packages() const { return packages_; } const std::map& 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; std::filesystem::path feature_resource_path( const std::string& package_id, const std::string& feature_id, const std::string& resource_id) const; /* True when an active [[requirement]] activates this feature for the * session although state.toml does not enable it. feature_enabled() keeps * answering for the player's own choice, which is what the launcher shows * and what save_state() persists. */ bool feature_implicitly_enabled(const std::string& package_id, const std::string& feature_id) const; /* An option value as a resolved plan sees it (plan.selections), for * plugins running under that plan. */ std::string feature_option_value(const ModResolution& plan, const std::string& package_id, const std::string& feature_id, const std::string& option_id) const; // Defer prepared resources only for launcher diagnostics, never execution // or fingerprints. A commit prepares first, then resolves without deferral. ModResolution resolve(const std::string& game_id, const std::string& exe_sha256 = {}, const std::string& disc_sha256 = {}, bool defer_prepared_resources = false) const; static bool read_manifest(const std::filesystem::path& path, ModPackage& out, std::string* error = nullptr); /* Manifests that failed to parse during the last scan(), as * ": ". A package that cannot be read must never vanish * silently -- the launcher surfaces these so a mod author sees why their * package is absent from the list. */ const std::vector& scan_errors() const { return scan_errors_; } /* Package ids that mods/state.toml carries a selection for but that no * catalog root holds -- a package the player removed, a developer-only * package a release build stripped, or a framework builtin the title * excludes (EXCLUDE_BUILTIN_MODS). Such a selection is DORMANT: resolve() * never visits it, so it contributes nothing to the plan, and save_state() * keeps it verbatim so the choice returns if the package ever does. The * runtime names these once at startup instead of dropping them silently. */ std::vector dormant_selections() const; private: /* One-time move of a pre-split /mods/packages tree into the two * owned roots. Anything the build also staged into bundled/ is dropped; * everything else is the player's and moves to installed/. */ void migrate_legacy_root(); bool scan_root(const std::filesystem::path& packages_root, ModPackageOrigin origin, std::string* error); /* selections_ plus every feature an active [[requirement]] derives, * iterated to a fixed point (a derived feature may carry requirements of * its own). Unmet requirements are reported through errors/diagnostics. * Never touches selections_: derived activations are not player choices. */ std::map effective_selections( std::vector* implicit, std::vector* diagnostics, std::vector* errors) const; std::filesystem::path root_; bool developer_channel_ = kDeveloperChannelDefault; std::vector scan_errors_; std::map> packages_; std::map selections_; std::map sources_; }; bool mod_register_builtin_resolver(const std::string& id, ModBuiltinResolver resolver); void mod_clear_builtin_resolvers_for_tests(); bool mod_register_activation_plugin(const std::string& id, void (*callback)(void)); bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)); bool mod_register_savestate_plugin(const std::string& id, void (*callback)(void)); void mod_invoke_savestate_plugin(const std::string& id); /* A function-entry hook is a trusted implementation like the others: manifests * select it by id, and it runs only while a resolved plan activates that id. */ bool mod_register_function_entry_plugin(const std::string& id, uint32_t address, PSXModFunctionEntryCallback callback); bool mod_register_function_filter_plugin(const std::string& id, uint32_t address, PSXModFunctionFilterCallback callback); bool mod_register_guest_function_plugin(const std::string& id, uint32_t address, PSXModFunctionEntryCallback callback); bool mod_plugin_registered(const std::string& id); void mod_invoke_activation_plugin(const std::string& id); void mod_invoke_vblank_plugin(const std::string& id); struct ModFunctionEntryHook { uint32_t address = 0; PSXModFunctionEntryCallback callback = nullptr; PSXModFunctionFilterCallback filter = nullptr; }; /* Hooks one implementation registered, in registration order. mod_runtime * flattens these into an address table when the plan's plugins activate. */ std::vector mod_function_entry_hooks(const std::string& id); std::vector mod_guest_functions(const std::string& id); struct ModInstructionHook { uint32_t address, expected; PSXModFunctionEntryCallback callback; }; bool mod_register_instruction_plugin(const std::string& id, uint32_t address, uint32_t expected, PSXModFunctionEntryCallback callback); std::vector mod_instruction_hooks(const std::string& id); void mod_clear_plugins_for_tests(); /* Every id a trusted implementation registered (activation, vblank or * function-entry), sorted. */ std::vector mod_registered_plugin_ids(); /* Registered implementations that no manifest under `manifest_roots` declares * as a [[plugin]] id. The resolved plan activates plugins by manifest id only, * so such an implementation can never run: a hook registered under a sub-id * ("x.widescreen.hud") is silently dead while its package ("x.widescreen") * resolves cleanly. Roots are searched recursively for manifest.toml; pass the * AUTHORED trees (title mods/preloaded, framework mods/builtin) so developer * packages a release catalog strips still count as declarations. Manifests * that fail to parse are reported through `errors`. */ struct ModPluginAudit { std::vector undeclared; /* registered, never declared */ std::vector declared; /* every manifest [[plugin]] id */ std::vector errors; size_t manifests = 0; }; ModPluginAudit mod_audit_registered_plugins( const std::vector& manifest_roots); } // namespace PSXRecompV4