From eb3b4db5996a31d53b37b22eb7c2ced371b4771b Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 23 Jul 2026 15:06:13 -0700 Subject: [PATCH] feat: add bounded integer mod patches --- docs/MOD_PACKAGES.md | 55 ++++++++ runtime/include/mod_packages.h | 9 ++ runtime/src/mod_packages.cpp | 208 +++++++++++++++++++++++++--- runtime/tests/test_mod_packages.cpp | 200 ++++++++++++++++++++++++++ runtime/tests/test_mod_runtime.cpp | 50 ++++++- 5 files changed, 503 insertions(+), 19 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 71d8d455..0d605e92 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -83,6 +83,61 @@ feature-local: `when = { option = "value", ... }` requires every listed option to match. The legacy `when_option`/`when_value` pair remains accepted for a single condition. +## Bounded integer patches + +Package format 2 can encode a bounded integer option directly into a guarded +write: + +```toml +format_version = 2 + +[[feature]] +id = "starting-lives" +name = "Starting Lives" + +[[option]] +feature = "starting-lives" +id = "count" +label = "Lives" +type = "integer" +min = 0 +max = 99 +step = 1 +default = 2 + +[[patch]] +feature = "starting-lives" +target = "main_exe" +address = 0x8001DE64 +expected = "02 00" +replace_from = { option = "count", encoding = "u16le" } + +[[patch]] +feature = "starting-lives" +target = "main_exe" +address = 0x8001DE70 +expected = "03 00" +replace_from = { option = "count", encoding = "u16le", addend = 1 } +``` + +`replace_from` and literal `replace` are mutually exclusive. The referenced +option must be a bounded integer owned by the same feature. The initial +encodings are `u8`, `u16le`, and `u32le`; the expected guard must have exactly +the selected width. `addend` is the only supported transform, and the complete +declared option range after that addend must fit the unsigned encoding. + +There is deliberately no host-endian encoding, signed inference, mask, shift, +scale, expression language, or partial-field merge. A package uses multiple +guarded `[[patch]]` entries when the same value has multiple destinations. +Generated bytes enter the ordinary pre-boot write plan, collision checks, and +fingerprint. A generated value identical to the stock guard is omitted as a +no-op, so an enabled stock-valued option does not claim or conflict on bytes it +does not change. + +Integer values use canonical decimal text. Leading plus signs, redundant +leading zeroes, values outside the bounds, and values not aligned to `step` are +rejected. + ## Native operations `main_exe` writes use PSX guest virtual addresses. Expected bytes are checked diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 5a677527..f129e2ce 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -60,12 +60,21 @@ enum class ModPatchTarget { DiscUser, }; +enum class ModValueEncoding { + U8, + U16LE, + U32LE, +}; + 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; + int64_t replace_addend = 0; std::string when_option; std::string when_value; std::map when; diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 395d1687..1f275db2 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -20,7 +20,8 @@ namespace fs = std::filesystem; namespace PSXRecompV4 { namespace { -constexpr uint32_t kFormatVersion = 1; +constexpr uint32_t kMinFormatVersion = 1; +constexpr uint32_t kMaxFormatVersion = 2; constexpr uint64_t kMaxArchiveBytes = 256ull * 1024ull * 1024ull; constexpr uint32_t kMaxArchiveFiles = 4096; @@ -73,6 +74,33 @@ bool parse_hex_bytes(const std::string& text, std::vector& out) { return true; } +bool parse_value_encoding(const std::string& text, ModValueEncoding& out) { + static const std::pair values[] = { + {"u8", ModValueEncoding::U8}, + {"u16le", ModValueEncoding::U16LE}, + {"u32le", ModValueEncoding::U32LE}, + }; + for (const auto& [name, encoding] : values) { + if (text == name) { + out = encoding; + return true; + } + } + return false; +} + +uint32_t value_encoding_size(ModValueEncoding encoding) { + switch (encoding) { + case ModValueEncoding::U8: + return 1; + case ModValueEncoding::U16LE: + return 2; + case ModValueEncoding::U32LE: + return 4; + } + return 0; +} + std::string hex_bytes(const std::vector& bytes) { std::ostringstream out; for (uint8_t byte : bytes) @@ -702,6 +730,8 @@ bool conditions_match(const ModPackage& package, const ModSelection& selection, return true; } +bool valid_option_value(const ModOption& option, const std::string& value); + void read_conditions(const toml::value& value, const std::vector& options, const std::string& feature_id, std::map& when, @@ -732,6 +762,9 @@ void read_conditions(const toml::value& value, const std::vector& opt }); if (option == options.end()) throw std::runtime_error(std::string(label) + " references unknown option"); + if (!valid_option_value(*option, condition_value)) + throw std::runtime_error( + std::string(label) + " condition has invalid option value"); } } @@ -825,21 +858,77 @@ std::string byte_resource(ModPatchTarget target, uint64_t location) { return overlap_resource(target, location, 1, location, 1); } +bool parse_canonical_int64(const std::string& value, int64_t& parsed) { + try { + size_t used = 0; + parsed = std::stoll(value, &used); + return used == value.size() && std::to_string(parsed) == value; + } catch (...) { + return false; + } +} + +bool integer_step_aligned(int64_t value, int64_t minimum, int64_t step) { + if (value < minimum || step <= 0) return false; + const uint64_t distance = + static_cast(value) - static_cast(minimum); + return distance % static_cast(step) == 0; +} + bool valid_option_value(const ModOption& option, const std::string& value) { if (option.type == ModOptionType::Boolean) return value == "true" || value == "false"; if (option.type == ModOptionType::Choice) return std::any_of(option.choices.begin(), option.choices.end(), [&](const ModChoice& choice) { return choice.value == value; }); - 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 (...) { + int64_t parsed = 0; + return parse_canonical_int64(value, parsed) && + parsed >= option.min_value && parsed <= option.max_value && + integer_step_aligned(parsed, option.min_value, option.step); +} + +bool checked_add_int64(int64_t left, int64_t right, int64_t& out) { + if ((right > 0 && left > std::numeric_limits::max() - right) || + (right < 0 && left < std::numeric_limits::min() - right)) return false; + out = left + right; + return true; +} + +uint64_t value_encoding_max(ModValueEncoding encoding) { + switch (encoding) { + case ModValueEncoding::U8: + return UINT8_MAX; + case ModValueEncoding::U16LE: + return UINT16_MAX; + case ModValueEncoding::U32LE: + return UINT32_MAX; } + return 0; +} + +bool option_range_fits_encoding(const ModOption& option, + ModValueEncoding encoding, + int64_t addend) { + int64_t low = 0; + int64_t high = 0; + return checked_add_int64(option.min_value, addend, low) && + checked_add_int64(option.max_value, addend, high) && + low >= 0 && + static_cast(high) <= value_encoding_max(encoding); +} + +bool encode_unsigned_value(ModValueEncoding encoding, int64_t value, + std::vector& out) { + if (value < 0 || + static_cast(value) > value_encoding_max(encoding)) + return false; + const uint32_t size = value_encoding_size(encoding); + out.resize(size); + const uint64_t encoded = static_cast(value); + for (uint32_t i = 0; i < size; ++i) + out[i] = static_cast(encoded >> (i * 8)); + return true; } std::string fingerprint_text(const std::string& text) { @@ -888,7 +977,8 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, out.save_compatibility = cfg.contains("save_compatibility") ? toml::find(cfg, "save_compatibility") : "shared"; out.root = path.parent_path(); - if (out.format_version != kFormatVersion) + if (out.format_version < kMinFormatVersion || + out.format_version > kMaxFormatVersion) throw std::runtime_error("unsupported format_version"); if (!valid_id(out.id)) throw std::runtime_error("invalid package id"); if (!parse_semver(out.version).valid) throw std::runtime_error("invalid semantic version"); @@ -999,7 +1089,9 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, option.step = toml::find_or(v, "step", 1); const int64_t def = toml::find(v, "default"); if (option.min_value > option.max_value || option.step <= 0 || - def < option.min_value || def > option.max_value) + def < option.min_value || def > option.max_value || + !integer_step_aligned( + def, option.min_value, option.step)) throw std::runtime_error("invalid integer bounds/default"); option.default_value = std::to_string(def); } else { @@ -1037,20 +1129,79 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, "patch target must be main_exe, disc_raw, or disc_user"); } const std::string expected = toml::find(v, "expected"); - const std::string replacement = toml::find(v, "replace"); if (!parse_hex_bytes(expected, patch.expected) || - !parse_hex_bytes(replacement, patch.replacement) || - patch.expected.empty() || - patch.expected.size() != patch.replacement.size()) + patch.expected.empty()) throw std::runtime_error( - "patch expected/replace must be equal-length non-empty hex"); + "patch expected must be non-empty hex"); + const bool has_static_replace = v.contains("replace"); + const bool has_dynamic_replace = v.contains("replace_from"); + if (has_static_replace == has_dynamic_replace) + throw std::runtime_error( + "patch requires exactly one of replace or replace_from"); + if (has_static_replace) { + const std::string replacement = + toml::find(v, "replace"); + if (!parse_hex_bytes(replacement, patch.replacement) || + patch.expected.size() != patch.replacement.size()) + throw std::runtime_error( + "patch expected/replace must be equal-length non-empty hex"); + } else { + if (out.format_version < 2) + throw std::runtime_error( + "replace_from requires format_version 2"); + const toml::value& replacement = + toml::find(v, "replace_from"); + const auto& table = replacement.as_table(); + for (const auto& [key, unused] : table) { + (void)unused; + if (key != "option" && key != "encoding" && + key != "addend") + throw std::runtime_error( + "replace_from has unknown field: " + key); + } + patch.replace_from_option = + toml::find(replacement, "option"); + const std::string encoding = + toml::find(replacement, "encoding"); + patch.replace_addend = + toml::find_or(replacement, "addend", 0); + if (!parse_value_encoding( + encoding, patch.replace_encoding)) + throw std::runtime_error( + "replace_from encoding must be u8, u16le, or u32le"); + const ModOption* option = find_option( + out, patch.feature_id, patch.replace_from_option); + if (!option || option->type != ModOptionType::Integer) + throw std::runtime_error( + "replace_from must reference a same-feature integer option"); + if (patch.expected.size() != + value_encoding_size(patch.replace_encoding)) + throw std::runtime_error( + "replace_from encoding width must match expected bytes"); + if (!option_range_fits_encoding( + *option, patch.replace_encoding, + patch.replace_addend)) + throw std::runtime_error( + "replace_from option range/addend does not fit encoding"); + } const uint64_t sector_size = patch.target == ModPatchTarget::DiscRaw ? 2352 : patch.target == ModPatchTarget::DiscUser ? 2048 : 0; if (sector_size != 0 && - patch.location % sector_size + patch.replacement.size() > sector_size) + patch.location % sector_size + patch.expected.size() > sector_size) throw std::runtime_error( "disc patch may not cross a sector boundary"); + if (patch.target == ModPatchTarget::MainExe && + (patch.location > UINT32_MAX || + patch.expected.size() > + static_cast(UINT32_MAX) + 1 - + patch.location)) + throw std::runtime_error( + "main_exe patch exceeds 32-bit guest address space"); + if (sector_size != 0 && + patch.location / sector_size > UINT32_MAX) + throw std::runtime_error( + "disc patch LBA exceeds runtime index range"); patch.order = toml::find_or( v, "order", (int64_t)declaration_index); read_conditions(v, out.options, patch.feature_id, patch.when, "patch"); @@ -1733,7 +1884,30 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, write.target = patch->target; write.location = patch->location; write.expected = patch->expected; - write.replacement = patch->replacement; + if (patch->replace_from_option.empty()) { + write.replacement = patch->replacement; + } else { + const std::string selected_value = + effective_option_value( + *package, selected, patch->feature_id, + patch->replace_from_option); + int64_t parsed = 0; + int64_t adjusted = 0; + if (!parse_canonical_int64(selected_value, parsed) || + !checked_add_int64( + parsed, patch->replace_addend, adjusted) || + !encode_unsigned_value( + patch->replace_encoding, adjusted, + write.replacement)) { + result.errors.push_back( + package->id + "/" + patch->feature_id + + ": could not encode replace_from option " + + patch->replace_from_option); + continue; + } + if (write.replacement == write.expected) + continue; + } write.package_id = package->id; write.feature_id = patch->feature_id; result.writes.push_back(std::move(write)); diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 3d947665..a2169dd4 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -1,6 +1,7 @@ #include "mod_packages.h" #include "psx_sha256.h" +#include #include #include #include @@ -403,6 +404,205 @@ int main() { partial_compatible.fingerprint, "feature state must resolve deterministically after reload"); + write_text(root / "packages/parametric.mod/1.0.0/manifest.toml", + "format_version = 2\n" + "id = \"parametric.mod\"\n" + "version = \"1.0.0\"\n" + "name = \"Parametric\"\n" + "resolver = \"declarative\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + "[[feature]]\n" + "id = \"numeric\"\n" + "name = \"Numeric\"\n" + "[[feature]]\n" + "id = \"numeric-collision\"\n" + "name = \"Numeric Collision\"\n" + "[[option]]\n" + "feature = \"numeric\"\n" + "id = \"byte\"\n" + "label = \"Byte\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 255\n" + "step = 1\n" + "default = 7\n" + "[[option]]\n" + "feature = \"numeric\"\n" + "id = \"word\"\n" + "label = \"Word\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 65534\n" + "step = 1\n" + "default = 4660\n" + "[[option]]\n" + "feature = \"numeric\"\n" + "id = \"dword\"\n" + "label = \"Dword\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 4294967295\n" + "step = 1\n" + "default = 305419896\n" + "[[option]]\n" + "feature = \"numeric-collision\"\n" + "id = \"byte\"\n" + "label = \"Byte\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 255\n" + "default = 8\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500032\n" + "expected = \"00\"\n" + "replace_from = { option = \"byte\", encoding = \"u8\" }\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500033\n" + "expected = \"07\"\n" + "replace_from = { option = \"byte\", encoding = \"u8\" }\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500034\n" + "expected = \"0000\"\n" + "replace_from = { option = \"word\", encoding = \"u16le\", addend = 1 }\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500036\n" + "expected = \"00000000\"\n" + "replace_from = { option = \"dword\", encoding = \"u32le\" }\n" + "[[patch]]\n" + "feature = \"numeric-collision\"\n" + "target = \"main_exe\"\n" + "address = 2147500032\n" + "expected = \"00\"\n" + "replace_from = { option = \"byte\", encoding = \"u8\" }\n"); + check(feature_reload.scan(&error), error.c_str()); + check(feature_reload.set_feature_enabled( + "parametric.mod", "numeric", true, &error), error.c_str()); + check(!feature_reload.set_feature_option( + "parametric.mod", "numeric", "byte", "+7", &error), + "integer options must reject a leading plus"); + check(!feature_reload.set_feature_option( + "parametric.mod", "numeric", "byte", "07", &error), + "integer options must reject noncanonical leading zeroes"); + ModResolution parametric = feature_reload.resolve("SLUS-TEST"); + const auto numeric_write = [&](uint64_t location) + -> const ModResolution::Write* { + const auto found = std::find_if( + parametric.writes.begin(), parametric.writes.end(), + [&](const ModResolution::Write& write) { + return write.package_id == "parametric.mod" && + write.location == location; + }); + return found == parametric.writes.end() ? nullptr : &*found; + }; + const ModResolution::Write* byte_write = numeric_write(0x80004000ull); + const ModResolution::Write* noop_write = numeric_write(0x80004001ull); + const ModResolution::Write* word_write = numeric_write(0x80004002ull); + const ModResolution::Write* dword_write = numeric_write(0x80004004ull); + check(parametric.ok && byte_write && + byte_write->replacement == std::vector({7}), + "u8 replace_from must encode the selected value"); + check(!noop_write, + "replace_from equal to the stock guard must elide the no-op write"); + check(word_write && + word_write->replacement == std::vector({0x35, 0x12}), + "u16le replace_from must apply addend and encode little-endian"); + check(dword_write && + dword_write->replacement == + std::vector({0x78, 0x56, 0x34, 0x12}), + "u32le replace_from must encode little-endian"); + const std::string parametric_fingerprint = parametric.fingerprint; + check(feature_reload.set_feature_option( + "parametric.mod", "numeric", "byte", "9", &error), + error.c_str()); + ModResolution changed_parametric = feature_reload.resolve("SLUS-TEST"); + check(changed_parametric.ok && + changed_parametric.fingerprint != parametric_fingerprint, + "changing a generated integer must change the plan fingerprint"); + check(feature_reload.set_feature_enabled( + "parametric.mod", "numeric-collision", true, &error), + error.c_str()); + check(!feature_reload.resolve("SLUS-TEST").ok, + "different generated values at one guarded byte must collide"); + check(feature_reload.set_feature_enabled( + "parametric.mod", "numeric-collision", false, &error), + error.c_str()); + check(feature_reload.save_state(&error), error.c_str()); + ModPackageManager parametric_reload(root); + check(parametric_reload.scan(&error), error.c_str()); + check(parametric_reload.load_state(&error), error.c_str()); + check(parametric_reload.feature_option_value( + "parametric.mod", "numeric", "byte") == "9" && + parametric_reload.resolve("SLUS-TEST").fingerprint == + changed_parametric.fingerprint, + "generated integer state and fingerprint must survive reload"); + + const auto reject_parametric_manifest = + [&](const std::string& name, const std::string& body) { + const fs::path path = root / (name + ".toml"); + write_text(path, body); + ModPackage rejected; + return !ModPackageManager::read_manifest(path, rejected, &error); + }; + const std::string dynamic_prelude = + "id=\"bad.dynamic\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" + "[[target]]\ngame_id=\"SLUS-TEST\"\n" + "[[feature]]\nid=\"bad\"\nname=\"Bad\"\n" + "[[option]]\nfeature=\"bad\"\nid=\"value\"\nlabel=\"Value\"\n" + "type=\"integer\"\nmin=0\nmax=255\ndefault=1\n"; + check(reject_parametric_manifest( + "dynamic-v1", + "format_version=1\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"00\"\n" + "replace_from={option=\"value\",encoding=\"u8\"}\n"), + "format 1 manifests must reject replace_from"); + check(reject_parametric_manifest( + "dynamic-both", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"00\"\nreplace=\"01\"\n" + "replace_from={option=\"value\",encoding=\"u8\"}\n"), + "a patch must reject simultaneous replace and replace_from"); + check(reject_parametric_manifest( + "dynamic-width", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"0000\"\n" + "replace_from={option=\"value\",encoding=\"u8\"}\n"), + "replace_from width must match the expected guard"); + check(reject_parametric_manifest( + "dynamic-overflow", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"00\"\n" + "replace_from={option=\"value\",encoding=\"u8\",addend=1}\n"), + "the full option range plus addend must fit its encoding"); + check(reject_parametric_manifest( + "dynamic-unknown", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"00\"\n" + "replace_from={option=\"value\",encoding=\"u8\",shift=1}\n"), + "replace_from must reject unknown transform fields"); + check(reject_parametric_manifest( + "dynamic-step-default", + "format_version=2\n" + "id=\"bad.dynamic\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" + "[[target]]\ngame_id=\"SLUS-TEST\"\n" + "[[feature]]\nid=\"bad\"\nname=\"Bad\"\n" + "[[option]]\nfeature=\"bad\"\nid=\"value\"\nlabel=\"Value\"\n" + "type=\"integer\"\nmin=0\nmax=10\nstep=2\ndefault=3\n"), + "integer defaults must align to their declared step"); + const std::vector overlay_a = {1, 2, 3, 4}; const std::vector overlay_b = {8, 9}; const std::vector overlay_c = {3, 4, 7}; diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 5ae841b9..daaa2938 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -68,7 +68,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 = 1\n" + "format_version = 2\n" "id = \"runtime.test\"\n" "version = \"1.0.0\"\n" "name = \"Runtime Test\"\n" @@ -87,6 +87,17 @@ int main() { "[[feature]]\n" "id = \"user-byte\"\n" "name = \"User Byte\"\n" + "[[feature]]\n" + "id = \"dynamic-main\"\n" + "name = \"Dynamic Main\"\n" + "[[option]]\n" + "feature = \"dynamic-main\"\n" + "id = \"count\"\n" + "label = \"Count\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 254\n" + "default = 42\n" "[[patch]]\n" "feature = \"main-code\"\n" "target = \"main_exe\"\n" @@ -105,6 +116,18 @@ int main() { "offset = 6154\n" "expected = \"cc\"\n" "replace = \"dd\"\n" + "[[patch]]\n" + "feature = \"dynamic-main\"\n" + "target = \"main_exe\"\n" + "address = 2147488000\n" + "expected = \"0000\"\n" + "replace_from = { option = \"count\", encoding = \"u16le\" }\n" + "[[patch]]\n" + "feature = \"dynamic-main\"\n" + "target = \"main_exe\"\n" + "address = 2147488002\n" + "expected = \"0100\"\n" + "replace_from = { option = \"count\", encoding = \"u16le\", addend = 1 }\n" "[[overlay]]\n" "feature = \"asset-overlay\"\n" "target = \"disc_raw\"\n" @@ -133,7 +156,13 @@ int main() { "[[feature]]\n" "package_id = \"runtime.test\"\n" "id = \"user-byte\"\n" - "enabled = true\n"); + "enabled = true\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"dynamic-main\"\n" + "enabled = true\n" + "[feature.values]\n" + "count = 42\n"); std::string error; check(PSXRecompV4::mod_runtime_initialize( @@ -142,11 +171,16 @@ int main() { check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str()); ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; + ram[0x1100] = 0; ram[0x1101] = 0; + ram[0x1102] = 1; ram[0x1103] = 0; mod_runtime_on_dispatch(0x80001000); check(ram[0x1000] == 1, "patch must wait for the configured entry point"); mod_runtime_on_dispatch(0x80002000); check(ram[0x1000] == 0xa1 && ram[0x1003] == 0xa4, "main-EXE patch must apply before entry execution"); + check(ram[0x1100] == 42 && ram[0x1101] == 0 && + ram[0x1102] == 43 && ram[0x1103] == 0, + "dynamic main-EXE patches must encode all sites before entry"); std::array sector{}; sector[10] = 0xaa; @@ -192,6 +226,18 @@ int main() { check(audio_sector[24 + 10] == 0xcc, "disc_user operations must not modify CDDA/non-data sectors"); + check(PSXRecompV4::mod_runtime_initialize( + root, "SLUS-RUNTIME", 0x80002000, {}, &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; + ram[0x1100] = 0; ram[0x1101] = 0; + ram[0x1102] = 2; ram[0x1103] = 0; /* second dynamic guard is wrong */ + mod_runtime_on_dispatch(0x80002000); + check(ram[0x1000] == 1 && ram[0x1003] == 4 && + ram[0x1100] == 0 && ram[0x1101] == 0, + "one failed generated guard must leave the complete main plan untouched"); + fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n";