From a4afb828ac68684b604ba08e4469f24fbb22fa51 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 26 Jul 2026 18:59:10 -0700 Subject: [PATCH] feat(mods): add trusted static vblank plugins --- docs/MOD_PACKAGES.md | 33 +++++++- runtime/include/mod_packages.h | 18 ++++ runtime/include/mod_plugins.h | 46 ++++++++++ runtime/include/mod_runtime.h | 1 + runtime/src/gpu.c | 4 + runtime/src/mod_packages.cpp | 126 +++++++++++++++++++++++++++- runtime/src/mod_runtime.cpp | 21 +++++ runtime/tests/test_mod_packages.cpp | 54 ++++++++++++ runtime/tests/test_mod_runtime.cpp | 28 ++++++- 9 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 runtime/include/mod_plugins.h diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 78428486..a50bf373 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -261,6 +261,35 @@ Sparse fields and integer predicates are still pre-boot plan construction. They do not provide a general expression evaluator, masks, arithmetic beyond the checked field addend, package code execution, or per-frame dispatch. +## Trusted static plugins + +Package format 5 can activate a game-owned plugin that is already statically +linked into the executable: + +```toml +format_version = 5 + +[[feature]] +id = "warp-debug-menu" +name = "Warp Debug Menu" + +[[plugin]] +feature = "warp-debug-menu" +id = "example.warp-debug" +``` + +The plugin id is a stable registry key, not a library path or symbol name. The +package archive supplies no native code. Resolution fails before launch when +an enabled plugin has no registered implementation or when two features claim +the same plugin id. Active plugin identities and owners participate in the +canonical plan fingerprint. + +The initial lifecycle is a deterministic guest-VBlank callback. It runs from +the emulated GPU VBlank event, independent of host presentation, pacing, turbo, +or skipped frames. Trusted callbacks receive only the narrow C services exposed +by `runtime/include/mod_plugins.h`. Games should continue to use declarative +patches and overlays when those operations are sufficient. + ## Native operations `main_exe` writes use PSX guest virtual addresses. Expected bytes are checked @@ -349,7 +378,9 @@ belong inside one feature as option values. ## Trusted adapters and archive safety `resolver = "builtin:"` selects a resolver statically registered by the -game. Packages cannot load arbitrary native code or select arbitrary symbols. +game. Format-5 plugin ids likewise select only statically registered +implementations. 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 or absolute paths, limits archives to 4096 diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 504fe645..1107b1ff 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -143,6 +143,13 @@ struct ModOverlay { int64_t order = 0; }; +struct ModPlugin { + std::string feature_id; + std::string id; + std::map when; + int64_t order = 0; +}; + struct ModDerivedDisc { std::string kind = "vcdiff"; std::filesystem::path patch; @@ -173,6 +180,7 @@ struct ModPackage { std::vector constraints; std::vector patches; std::vector overlays; + std::vector plugins; std::vector derived_discs; }; @@ -227,6 +235,12 @@ struct ModResolution { 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 Diagnostic { std::string message; std::string resource; @@ -311,5 +325,9 @@ private: bool mod_register_builtin_resolver(const std::string& id, ModBuiltinResolver resolver); void mod_clear_builtin_resolvers_for_tests(); +bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)); +bool mod_vblank_plugin_registered(const std::string& id); +void mod_invoke_vblank_plugin(const std::string& id); +void mod_clear_vblank_plugins_for_tests(); } // namespace PSXRecompV4 diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h new file mode 100644 index 00000000..65243d5d --- /dev/null +++ b/runtime/include/mod_plugins.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void (*PSXModVBlankCallback)(void); + +/* + * Register a trusted, statically linked plugin implementation. Package + * manifests select implementations by this stable id; archives never provide + * native code or symbol names. + */ +int psx_mod_register_vblank_plugin(const char* id, + PSXModVBlankCallback callback); + +/* Narrow guest services available to trusted plugin callbacks. */ +int psx_mod_game_started(void); +uint8_t psx_mod_read_byte(uint32_t address); +void psx_mod_write_byte(uint32_t address, uint8_t value); + +/* + * Register a C plugin before main() on the compilers supported by the runtime. + * The registry itself uses function-local initialization, so constructor order + * between game sources and the framework is safe. + */ +#if defined(_MSC_VER) +#pragma section(".CRT$XCU", read) +#define PSX_MOD_CONSTRUCTOR(name) \ + static void __cdecl name(void); \ + __declspec(allocate(".CRT$XCU")) \ + static void (__cdecl* name##_constructor)(void) = name; \ + static void __cdecl name(void) +#elif defined(__GNUC__) || defined(__clang__) +#define PSX_MOD_CONSTRUCTOR(name) \ + static void name(void) __attribute__((constructor)); \ + static void name(void) +#else +#error "PSX mod plugin registration needs a supported constructor mechanism" +#endif + +#ifdef __cplusplus +} +#endif diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index cd3aa6d3..107979da 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -35,6 +35,7 @@ extern "C" { /* Called before a guest dispatch. Applies the complete main-EXE plan * transactionally on the first dispatch to the configured entry point. */ void mod_runtime_on_dispatch(uint32_t target); +void mod_runtime_on_vblank(void); void mod_runtime_patch_disc_sector(uint32_t lba, int raw_sector, uint8_t* bytes, uint32_t size); void mod_runtime_enable_disc_patches(void); diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 341a048c..115771a7 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -20,6 +20,7 @@ #include "cpu_state.h" #include "event_ring.h" #include "color_lut.h" +#include "mod_runtime.h" #include "ws_cull_detect.h" #include #include @@ -2104,6 +2105,9 @@ void gpu_vblank_tick(void) { } g_doff_min_this = 0x7fffffff; g_doff_max_this = -0x7fffffff; g_doff_cnt_this = 0; gpustat_poll_count = 0; + /* Trusted package-selected plugins run on guest VBlank, independent of + * host presentation, pacing, turbo, or skipped frames. */ + mod_runtime_on_vblank(); psx_irq_raise(0, 0); /* IRQ_VBLANK (gpu_vblank_tick) */ if (vblank_callback) vblank_callback(); } diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 58754d1d..85bd9bb8 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -1,6 +1,7 @@ #include "mod_packages.h" #include "crc32.h" +#include "mod_plugins.h" #include "psx_sha256.h" #include "toml.hpp" @@ -21,7 +22,7 @@ namespace PSXRecompV4 { namespace { constexpr uint32_t kMinFormatVersion = 1; -constexpr uint32_t kMaxFormatVersion = 4; +constexpr uint32_t kMaxFormatVersion = 5; constexpr uint64_t kMaxArchiveBytes = 256ull * 1024ull * 1024ull; constexpr uint32_t kMaxArchiveFiles = 4096; @@ -30,6 +31,11 @@ std::map& builtin_resolvers() { return value; } +std::map& vblank_plugins() { + static std::map value; + return value; +} + void set_error(std::string* out, const std::string& value) { if (out) *out = value; } @@ -659,6 +665,7 @@ std::string canonical_resolution(const std::vector& ordered, const std::vector& writes, const std::vector& overlays, const std::vector& derived_discs, + const std::vector& plugins, const std::string& source_disc_sha256) { std::ostringstream out; out << "source_disc=" << source_disc_sha256 << '\n'; @@ -728,6 +735,10 @@ std::string canonical_resolution(const std::vector& ordered, << derived.patch_sha256 << ':' << derived.output_size << ':' << derived.output_sha256 << ':' << derived.package_id << '\n'; } + for (const ModResolution::Plugin& plugin : plugins) { + out << "plugin:" << plugin.id << ':' << plugin.package_id << ':' + << plugin.feature_id << '\n'; + } return out.str(); } @@ -1353,6 +1364,24 @@ void mod_clear_builtin_resolvers_for_tests() { builtin_resolvers().clear(); } +bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)) { + if (!valid_id(id) || !callback) return false; + return vblank_plugins().emplace(id, callback).second; +} + +bool mod_vblank_plugin_registered(const std::string& id) { + return vblank_plugins().find(id) != vblank_plugins().end(); +} + +void mod_invoke_vblank_plugin(const std::string& id) { + const auto found = vblank_plugins().find(id); + if (found != vblank_plugins().end()) found->second(); +} + +void mod_clear_vblank_plugins_for_tests() { + vblank_plugins().clear(); +} + ModPackageManager::ModPackageManager(fs::path mods_root) : root_(std::move(mods_root)) {} void ModPackageManager::set_root(fs::path mods_root) { @@ -2004,6 +2033,33 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, "feature disc overlays require an exact disc_sha256 " "on every [[target]]"); } + if (cfg.contains("plugin")) { + if (!feature_style) + throw std::runtime_error( + "plugins require explicit [[feature]] ownership"); + if (out.format_version < 5) + throw std::runtime_error( + "plugins require format_version 5"); + size_t declaration_index = 0; + for (const toml::value& v : + toml::find(cfg, "plugin").as_array()) { + ModPlugin plugin; + plugin.feature_id = + toml::find(v, "feature"); + if (!find_feature(out, plugin.feature_id)) + throw std::runtime_error( + "plugin references unknown feature"); + plugin.id = toml::find(v, "id"); + if (!valid_id(plugin.id)) + throw std::runtime_error("invalid plugin id"); + plugin.order = toml::find_or( + v, "order", (int64_t)declaration_index); + read_conditions(v, out.options, plugin.feature_id, + plugin.when, "plugin"); + out.plugins.push_back(std::move(plugin)); + ++declaration_index; + } + } if (cfg.contains("derived_disc")) { if (feature_style) throw std::runtime_error( @@ -2885,6 +2941,36 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, result.errors.empty()) result.errors.push_back(package->id + ": built-in resolver failed"); } + std::vector plugins; + plugins.reserve(package->plugins.size()); + for (const ModPlugin& plugin : package->plugins) { + const ModFeature* feature = + find_feature(*package, plugin.feature_id); + if (!feature || + !is_feature_enabled(*package, selected, *feature) || + !conditions_match(*package, selected, + plugin.feature_id, plugin.when)) + continue; + plugins.push_back(&plugin); + } + std::stable_sort( + plugins.begin(), plugins.end(), + [](const ModPlugin* a, const ModPlugin* b) { + return a->order < b->order; + }); + for (const ModPlugin* plugin : plugins) { + if (!mod_vblank_plugin_registered(plugin->id)) { + result.errors.push_back( + package->id + "/" + plugin->feature_id + + ": trusted plugin is unavailable: " + plugin->id); + continue; + } + ModResolution::Plugin resolved; + resolved.id = plugin->id; + resolved.package_id = package->id; + resolved.feature_id = plugin->feature_id; + result.plugins.push_back(std::move(resolved)); + } } if (result.derived_discs.size() > 1) { std::string providers; @@ -2895,6 +2981,35 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, result.errors.push_back( "more than one derived-disc provider is active: " + providers); } + std::vector coalesced_plugins; + coalesced_plugins.reserve(result.plugins.size()); + for (const ModResolution::Plugin& plugin : result.plugins) { + bool claimed = false; + for (const ModResolution::Plugin& previous : coalesced_plugins) { + if (plugin.id != previous.id) continue; + if (plugin.package_id == previous.package_id && + plugin.feature_id == previous.feature_id) { + claimed = true; + break; + } + ModResolution::Diagnostic diagnostic; + diagnostic.resource = "plugin:" + plugin.id; + diagnostic.package_id = plugin.package_id; + diagnostic.feature_id = plugin.feature_id; + diagnostic.other_package_id = previous.package_id; + diagnostic.other_feature_id = previous.feature_id; + diagnostic.message = + plugin.package_id + "/" + plugin.feature_id + + " collides at " + diagnostic.resource + " with " + + previous.package_id + "/" + previous.feature_id; + result.diagnostics.push_back(diagnostic); + result.errors.push_back(diagnostic.message); + claimed = true; + break; + } + if (!claimed) coalesced_plugins.push_back(plugin); + } + result.plugins = std::move(coalesced_plugins); std::vector coalesced; coalesced.reserve(result.writes.size()); for (const ModResolution::Write& write : result.writes) { @@ -3040,15 +3155,22 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, result.writes.clear(); result.overlays.clear(); result.derived_discs.clear(); + result.plugins.clear(); return result; } result.fingerprint = fingerprint_text( canonical_resolution( result.ordered, selections_, result.writes, result.overlays, - result.derived_discs, + result.derived_discs, result.plugins, disc_sha256)); result.ok = true; return result; } } // namespace PSXRecompV4 + +extern "C" int psx_mod_register_vblank_plugin( + const char* id, PSXModVBlankCallback callback) { + return id && + PSXRecompV4::mod_register_vblank_plugin(id, callback) ? 1 : 0; +} diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 3822bdc9..e6561f47 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -34,6 +34,7 @@ extern "C" uint8_t psx_read_byte(uint32_t addr); extern "C" void psx_write_byte(uint32_t addr, uint8_t value); extern "C" void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len); +extern "C" int fntrace_is_game_started(void); namespace PSXRecompV4 { namespace { @@ -958,6 +959,26 @@ extern "C" void mod_runtime_enable_disc_patches(void) { PSXRecompV4::state().disc_enabled = true; } +extern "C" void mod_runtime_on_vblank(void) { + using namespace PSXRecompV4; + RuntimeMods& s = state(); + if (!s.initialized || !s.plan.ok) return; + for (const ModResolution::Plugin& plugin : s.plan.plugins) + mod_invoke_vblank_plugin(plugin.id); +} + +extern "C" int psx_mod_game_started(void) { + return fntrace_is_game_started(); +} + +extern "C" uint8_t psx_mod_read_byte(uint32_t address) { + return psx_read_byte(address); +} + +extern "C" void psx_mod_write_byte(uint32_t address, uint8_t value) { + psx_write_byte(address, value); +} + extern "C" void mod_runtime_patch_disc_sector(uint32_t lba, int raw_sector, uint8_t* bytes, uint32_t size) { using namespace PSXRecompV4; diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index eaff1774..20c4ab0e 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -298,8 +298,62 @@ int main() { ModResolution context_resolution = reload.resolve("SLUS-TEST"); check(context_resolution.ok && resolver_context_seen, "built-in resolver must receive active package selection context"); + check(reload.set_enabled("context.consumer", false, &error), error.c_str()); + check(reload.set_enabled("context.provider", false, &error), error.c_str()); mod_clear_builtin_resolvers_for_tests(); + mod_clear_vblank_plugins_for_tests(); + check(mod_register_vblank_plugin( + "test.vblank", +[]() {}), + "test plugin must register"); + write_text(root / "packages/plugin.mod/1.0.0/manifest.toml", + "format_version = 5\n" + "id = \"plugin.mod\"\n" + "version = \"1.0.0\"\n" + "name = \"Plugin Mod\"\n" + "resolver = \"declarative\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + "[[feature]]\n" + "id = \"vblank\"\n" + "name = \"VBlank Plugin\"\n" + "[[plugin]]\n" + "feature = \"vblank\"\n" + "id = \"test.vblank\"\n"); + check(reload.scan(&error), error.c_str()); + check(reload.set_feature_enabled( + "plugin.mod", "vblank", true, &error), error.c_str()); + ModResolution plugin_resolution = reload.resolve("SLUS-TEST"); + check(plugin_resolution.ok && plugin_resolution.plugins.size() == 1 && + plugin_resolution.plugins[0].id == "test.vblank" && + plugin_resolution.plugins[0].package_id == "plugin.mod" && + plugin_resolution.plugins[0].feature_id == "vblank", + "enabled trusted plugin must resolve with feature ownership"); + const std::string plugin_fingerprint = plugin_resolution.fingerprint; + check(reload.set_feature_enabled( + "plugin.mod", "vblank", false, &error), error.c_str()); + ModResolution plugin_disabled = reload.resolve("SLUS-TEST"); + check(plugin_disabled.ok && plugin_disabled.plugins.empty() && + plugin_disabled.fingerprint != plugin_fingerprint, + "disabling a plugin feature must remove its activation and change " + "the fingerprint"); + check(reload.set_feature_enabled( + "plugin.mod", "vblank", true, &error), error.c_str()); + mod_clear_vblank_plugins_for_tests(); + ModResolution plugin_unavailable = reload.resolve("SLUS-TEST"); + check(!plugin_unavailable.ok && + std::any_of( + plugin_unavailable.errors.begin(), + plugin_unavailable.errors.end(), + [](const std::string& item) { + return item.find( + "trusted plugin is unavailable: test.vblank") != + std::string::npos; + }), + "enabled plugin must fail closed when its trusted implementation " + "is unavailable"); + check(reload.set_feature_enabled( + "plugin.mod", "vblank", false, &error), error.c_str()); write_text(root / "packages/features.mod/1.0.0/manifest.toml", manifest("features.mod", "1.0.0", "\n[[feature]]\n" diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 154bcf96..6d443790 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1,4 +1,5 @@ #include "mod_runtime.h" +#include "mod_packages.h" #include "psx_sha256.h" #include @@ -13,6 +14,7 @@ namespace fs = std::filesystem; static std::array ram; static int failures; +static int plugin_calls; extern "C" uint8_t psx_read_byte(uint32_t address) { return ram[address & 0x1fffffu]; @@ -23,6 +25,11 @@ extern "C" void psx_write_byte(uint32_t address, uint8_t value) { } extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} +extern "C" int fntrace_is_game_started(void) { return 1; } + +static void test_vblank_plugin(void) { + plugin_calls++; +} static void check(bool value, const char* message) { if (!value) { @@ -77,7 +84,7 @@ int main() { 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 = 4\n" + "format_version = 5\n" "id = \"runtime.test\"\n" "version = \"1.0.0\"\n" "name = \"Runtime Test\"\n" @@ -108,6 +115,9 @@ int main() { "[[feature]]\n" "id = \"sparse-disc\"\n" "name = \"Sparse Disc\"\n" + "[[feature]]\n" + "id = \"vblank-plugin\"\n" + "name = \"VBlank Plugin\"\n" "[[option]]\n" "feature = \"dynamic-main\"\n" "id = \"count\"\n" @@ -189,7 +199,10 @@ int main() { "file = \"assets/overlay.bin\"\n" "sha256 = \"" + sha256_hex(overlay) + "\"\n" "expected_sha256 = \"" + - sha256_hex(std::vector(overlay.size(), 0)) + "\"\n"); + sha256_hex(std::vector(overlay.size(), 0)) + "\"\n" + "[[plugin]]\n" + "feature = \"vblank-plugin\"\n" + "id = \"runtime.test-vblank\"\n"); write_text(root / "state.toml", "format_version = 2\n" "[[package]]\n" @@ -230,14 +243,25 @@ int main() { "[[feature]]\n" "package_id = \"runtime.test\"\n" "id = \"sparse-disc\"\n" + "enabled = true\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"vblank-plugin\"\n" "enabled = true\n"); std::string error; + PSXRecompV4::mod_clear_vblank_plugins_for_tests(); + check(PSXRecompV4::mod_register_vblank_plugin( + "runtime.test-vblank", test_vblank_plugin), + "runtime test plugin must register"); check(PSXRecompV4::mod_runtime_initialize( root, "SLUS-RUNTIME", 0x80002000, {}, &error), error.c_str()); check(PSXRecompV4::mod_runtime_commit(cue_path, &error), "CUE and its data-track BIN must have the same mod target identity"); + mod_runtime_on_vblank(); + check(plugin_calls == 1, + "resolved trusted plugin must run on guest VBlank"); ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; ram[0x1100] = 0; ram[0x1101] = 0;