diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 0d605e92..466e5b7f 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -109,22 +109,26 @@ default = 2 feature = "starting-lives" target = "main_exe" address = 0x8001DE64 -expected = "02 00" -replace_from = { option = "count", encoding = "u16le" } +expected = "02 00 02 24" +replace_from = { option = "count", encoding = "u16le", offset = 0 } [[patch]] feature = "starting-lives" target = "main_exe" address = 0x8001DE70 -expected = "03 00" -replace_from = { option = "count", encoding = "u16le", addend = 1 } +expected = "03 00 02 24" +replace_from = { option = "count", encoding = "u16le", offset = 0, 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. +encodings are `u8`, `u16le`, and `u32le`. `offset` selects a byte field inside +the expected guard and defaults to zero. Generated replacement bytes begin as +an exact copy of the expected bytes, then the encoded value replaces only that +field. This lets a MIPS immediate, for example, retain a guard and collision +claim over its complete instruction. `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 diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index f129e2ce..1a63cdee 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -74,6 +74,7 @@ struct ModPatch { std::vector replacement; std::string replace_from_option; ModValueEncoding replace_encoding = ModValueEncoding::U8; + uint64_t replace_offset = 0; int64_t replace_addend = 0; std::string when_option; std::string when_value; diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 1f275db2..c6e88f5e 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -1155,7 +1155,7 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, for (const auto& [key, unused] : table) { (void)unused; if (key != "option" && key != "encoding" && - key != "addend") + key != "offset" && key != "addend") throw std::runtime_error( "replace_from has unknown field: " + key); } @@ -1163,6 +1163,13 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, toml::find(replacement, "option"); const std::string encoding = toml::find(replacement, "encoding"); + const int64_t replace_offset = + toml::find_or(replacement, "offset", 0); + if (replace_offset < 0) + throw std::runtime_error( + "replace_from offset must not be negative"); + patch.replace_offset = + static_cast(replace_offset); patch.replace_addend = toml::find_or(replacement, "addend", 0); if (!parse_value_encoding( @@ -1174,10 +1181,13 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, 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)) + const uint64_t encoded_size = + value_encoding_size(patch.replace_encoding); + if (patch.replace_offset > patch.expected.size() || + encoded_size > + patch.expected.size() - patch.replace_offset) throw std::runtime_error( - "replace_from encoding width must match expected bytes"); + "replace_from value exceeds expected byte range"); if (!option_range_fits_encoding( *option, patch.replace_encoding, patch.replace_addend)) @@ -1893,18 +1903,24 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, patch->replace_from_option); int64_t parsed = 0; int64_t adjusted = 0; + std::vector encoded; if (!parse_canonical_int64(selected_value, parsed) || !checked_add_int64( parsed, patch->replace_addend, adjusted) || !encode_unsigned_value( patch->replace_encoding, adjusted, - write.replacement)) { + encoded)) { result.errors.push_back( package->id + "/" + patch->feature_id + ": could not encode replace_from option " + patch->replace_from_option); continue; } + write.replacement = write.expected; + std::copy( + encoded.begin(), encoded.end(), + write.replacement.begin() + + static_cast(patch->replace_offset)); if (write.replacement == write.expected) continue; } diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index a2169dd4..1d3982b9 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -478,6 +478,12 @@ int main() { "expected = \"00000000\"\n" "replace_from = { option = \"dword\", encoding = \"u32le\" }\n" "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500040\n" + "expected = \"0000aabb\"\n" + "replace_from = { option = \"word\", encoding = \"u16le\", offset = 0 }\n" + "[[patch]]\n" "feature = \"numeric-collision\"\n" "target = \"main_exe\"\n" "address = 2147500032\n" @@ -507,6 +513,8 @@ int main() { const ModResolution::Write* noop_write = numeric_write(0x80004001ull); const ModResolution::Write* word_write = numeric_write(0x80004002ull); const ModResolution::Write* dword_write = numeric_write(0x80004004ull); + const ModResolution::Write* guarded_word_write = + numeric_write(0x80004008ull); check(parametric.ok && byte_write && byte_write->replacement == std::vector({7}), "u8 replace_from must encode the selected value"); @@ -519,6 +527,10 @@ int main() { dword_write->replacement == std::vector({0x78, 0x56, 0x34, 0x12}), "u32le replace_from must encode little-endian"); + check(guarded_word_write && + guarded_word_write->replacement == + std::vector({0x34, 0x12, 0xaa, 0xbb}), + "replace_from must preserve guarded bytes outside its value field"); const std::string parametric_fingerprint = parametric.fingerprint; check(feature_reload.set_feature_option( "parametric.mod", "numeric", "byte", "9", &error), @@ -577,8 +589,8 @@ int main() { "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"); + "replace_from={option=\"value\",encoding=\"u8\",offset=2}\n"), + "replace_from value must stay inside the expected guard"); check(reject_parametric_manifest( "dynamic-overflow", "format_version=2\n" + dynamic_prelude +