fix: guard complete dynamic patch records

This commit is contained in:
Matthew Stanley
2026-07-23 15:10:03 -07:00
parent eb3b4db599
commit bd460f3ce1
4 changed files with 47 additions and 14 deletions
+11 -7
View File
@@ -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
+1
View File
@@ -74,6 +74,7 @@ struct ModPatch {
std::vector<uint8_t> 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;
+21 -5
View File
@@ -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<std::string>(replacement, "option");
const std::string encoding =
toml::find<std::string>(replacement, "encoding");
const int64_t replace_offset =
toml::find_or<int64_t>(replacement, "offset", 0);
if (replace_offset < 0)
throw std::runtime_error(
"replace_from offset must not be negative");
patch.replace_offset =
static_cast<uint64_t>(replace_offset);
patch.replace_addend =
toml::find_or<int64_t>(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<uint8_t> 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<size_t>(patch->replace_offset));
if (write.replacement == write.expected)
continue;
}
+14 -2
View File
@@ -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<uint8_t>({7}),
"u8 replace_from must encode the selected value");
@@ -519,6 +527,10 @@ int main() {
dword_write->replacement ==
std::vector<uint8_t>({0x78, 0x56, 0x34, 0x12}),
"u32le replace_from must encode little-endian");
check(guarded_word_write &&
guarded_word_write->replacement ==
std::vector<uint8_t>({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 +