patcher: patch groups (#812)

## Link to GitHub Issue or related Pull Request, if one exists
Fixes #245

## Description of change

Introduce patch groups. JSON schema now allows for a `"type": "group"`:

```json
    {
        "type": "group",
        "gameCode": "LDJ",
        "id": "timer-freeze",
        "name": "Timer Freeze Patches",
        "description": "Freezes various timers in the game."
    },
    {
        "name": "Standard/Menu Timer Freeze",
        "description": "Freezes all non-premium area timers.",
        "caution": "",
        "gameCode": "LDJ",
        "type": "memory",
        "group": "timer-freeze",
        "patches": [
            {
                "offset": 9962743,
                "dllName": "bm2dx.dll",
                "dataDisabled": "0F84",
                "dataEnabled": "90E9"
            }
        ]
    },
    {
        "name": "Premium Free Timer Freeze",
        "description": "Freezes all premium area timers.",
        "caution": "",
        "gameCode": "LDJ",
        "type": "memory",
        "group": "timer-freeze",
        "patches": [
            {
                "offset": 9111965,
                "dllName": "bm2dx.dll",
                "dataDisabled": "7E",
                "dataEnabled": "EB"
            }
        ]
    },
```

In the UI, these will show up as tree nodes. The parent is not actually
treated as a patch; e.g., its state is not saved to the patch manager
config file; only the child patches states are managed, the parent's
state only bubble up only in the UI.

In downlevel versions of spice, group parents will show up as a patch of
invalid type. Child patches will still show up as individual patches.

## Testing
*how was the code tested?*
This commit is contained in:
bicarus
2026-07-19 02:10:13 -07:00
committed by GitHub
parent a8cc85531c
commit be4bb00390
7 changed files with 741 additions and 50 deletions
+15
View File
@@ -1,5 +1,8 @@
#pragma once
#include <map>
#include <utility>
#include "patch_manager.h"
#include "external/rapidjson/document.h"
#include "util/nt_loader.h"
@@ -11,6 +14,7 @@ namespace patcher {
extern std::vector<std::string> setting_patches_enabled;
extern std::map<std::string, std::string> setting_union_patches_enabled;
extern std::map<std::string, int64_t> setting_int_patches_enabled;
extern std::map<std::pair<std::string, std::string>, PatchGroup> patch_groups;
extern std::filesystem::path LOCAL_PATCHES_PATH;
extern std::map<std::string, std::vector<std::string>> EXTRA_DLLS;
extern bool ldr_registered;
@@ -28,6 +32,17 @@ namespace patcher {
bool load_from_patches_json(bool apply_patches);
void load_embedded_patches(bool apply_patches);
bool import_remote_patches_for_dll(const std::string& url, const std::string& dll_name);
bool is_patch_group_definition(const rapidjson::Value& patch);
std::map<std::pair<std::string, std::string>, PatchGroup> parse_patch_group_definitions(
const rapidjson::Document& doc);
std::string resolve_patch_group_id(
const rapidjson::Value& patch,
const std::map<std::pair<std::string, std::string>, PatchGroup>& groups,
const std::string& game_code,
const char *patch_name);
void register_patch_group(
PatchData& patch,
const std::map<std::pair<std::string, std::string>, PatchGroup>& definitions);
VOID CALLBACK loader_notification(ULONG reason, PCLDR_DLL_NOTIFICATION_DATA data, PVOID context);
std::string patch_hash(PatchData& patch);
+1
View File
@@ -31,6 +31,7 @@ namespace patcher {
std::vector<PatchData> patches;
bool local_patches_initialized = false;
std::vector<size_t> patches_sorted;
std::map<std::pair<std::string, std::string>, PatchGroup> patch_groups;
std::map<std::string, std::vector<std::string>> EXTRA_DLLS = {
{"jubeat.dll", {"music_db.dll", "coin.dll"}},
{"arkmdxp3.dll", {"gamemdx.dll"}},
+32 -2
View File
@@ -199,8 +199,13 @@ namespace patcher {
log_warning("patchmanager", "embedded patches json file parse error: {}", static_cast<uint32_t>(error));
}
const auto group_definitions = parse_patch_group_definitions(doc);
// iterate patches
for (auto &patch : doc.GetArray()) {
if (is_patch_group_definition(patch)) {
continue;
}
// verfiy patch data
auto name_it = patch.FindMember("name");
@@ -214,6 +219,9 @@ namespace patcher {
name_it->value.GetString());
continue;
}
const std::string game_code(
game_code_it->value.GetString(),
game_code_it->value.GetStringLength());
auto description_it = patch.FindMember("description");
if (description_it == patch.MemberEnd() || !description_it->value.IsString()) {
log_warning("patchmanager", "failed to parse description for {}",
@@ -235,7 +243,7 @@ namespace patcher {
// build patch data
PatchData patch_data {
.enabled = false,
.game_code = game_code_it->value.GetString(),
.game_code = game_code,
.datecode_min = 0,
.datecode_max = 0,
.name = name_it->value.GetString(),
@@ -247,6 +255,11 @@ namespace patcher {
.patches_memory = std::vector<MemoryPatch>(),
.patches_union = std::vector<UnionPatch>(),
.patch_number = NumberPatch(),
.group_id = resolve_patch_group_id(
patch,
group_definitions,
game_code,
name_it->value.GetString()),
.last_status = PatchStatus::Disabled,
.hash = "",
.unverified = false,
@@ -533,6 +546,7 @@ namespace patcher {
}
// auto apply
register_patch_group(patch_data, group_definitions);
if (apply_patches && setting_auto_apply && patch_data.enabled) {
print_auto_apply_status(patch_data);
apply_patch(patch_data, true);
@@ -663,6 +677,7 @@ namespace patcher {
// clear old patches
patches.clear();
patch_groups.clear();
// drop the cached sorted view so the table rebuilds it (in default order)
// on the next frame
patches_sorted.clear();
@@ -719,6 +734,7 @@ namespace patcher {
bool imported = false;
// clear old patches
patches.clear();
patch_groups.clear();
patches_sorted.clear();
url_fetch_errors.clear();
@@ -755,8 +771,13 @@ namespace patcher {
rapidjson::GetParseError_En(error));
}
const auto group_definitions = parse_patch_group_definitions(doc);
// iterate patches
for (auto &patch : doc.GetArray()) {
if (is_patch_group_definition(patch)) {
continue;
}
// verfiy patch data
auto name_it = patch.FindMember("name");
@@ -778,6 +799,9 @@ namespace patcher {
name_it->value.GetString());
continue;
}
const std::string game_code(
game_code_it->value.GetString(),
game_code_it->value.GetStringLength());
auto description_it = patch.FindMember("description");
if (description_it == patch.MemberEnd() || !description_it->value.IsString()) {
log_warning("patchmanager", "failed to parse description for {}",
@@ -809,7 +833,7 @@ namespace patcher {
// build patch data
PatchData patch_data {
.enabled = false,
.game_code = game_code_it->value.GetString(),
.game_code = game_code,
.datecode_min = 0,
.datecode_max = 0,
.name = name_it->value.GetString(),
@@ -821,6 +845,11 @@ namespace patcher {
.patches_memory = std::vector<MemoryPatch>(),
.patches_union = std::vector<UnionPatch>(),
.patch_number = NumberPatch(),
.group_id = resolve_patch_group_id(
patch,
group_definitions,
game_code,
name_it->value.GetString()),
.last_status = PatchStatus::Disabled,
.hash = "",
.unverified = false,
@@ -1264,6 +1293,7 @@ namespace patcher {
}
// auto apply
register_patch_group(patch_data, group_definitions);
if (apply_patches && setting_auto_apply && patch_data.enabled) {
print_auto_apply_status(patch_data);
apply_patch(patch_data, true);
+179
View File
@@ -0,0 +1,179 @@
#include "internal.h"
#include <cstring>
#include "util/logging.h"
#include "util/utils.h"
namespace patcher {
static std::pair<std::string, std::string> make_patch_group_key(
const std::string& game_code,
const std::string& group_id) {
return {strtolower(game_code), group_id};
}
static bool has_embedded_null(const rapidjson::Value& value) {
return strlen(value.GetString()) != value.GetStringLength();
}
bool is_patch_group_definition(const rapidjson::Value& patch) {
if (!patch.IsObject()) {
return false;
}
const auto type_it = patch.FindMember("type");
return type_it != patch.MemberEnd()
&& type_it->value.IsString()
&& type_it->value.GetStringLength() == strlen("group")
&& !_stricmp(type_it->value.GetString(), "group");
}
std::map<std::pair<std::string, std::string>, PatchGroup> parse_patch_group_definitions(
const rapidjson::Document& doc) {
std::map<std::pair<std::string, std::string>, PatchGroup> groups;
for (const auto& patch : doc.GetArray()) {
if (!is_patch_group_definition(patch)) {
continue;
}
const auto id_it = patch.FindMember("id");
const auto game_code_it = patch.FindMember("gameCode");
const auto name_it = patch.FindMember("name");
if (id_it == patch.MemberEnd() || !id_it->value.IsString()
|| id_it->value.GetStringLength() == 0
|| has_embedded_null(id_it->value)
|| game_code_it == patch.MemberEnd() || !game_code_it->value.IsString()
|| game_code_it->value.GetStringLength() == 0
|| has_embedded_null(game_code_it->value)
|| name_it == patch.MemberEnd() || !name_it->value.IsString()
|| name_it->value.GetStringLength() == 0
|| has_embedded_null(name_it->value)) {
log_warning("patchmanager", "invalid patch group definition");
continue;
}
PatchGroup group;
group.name.assign(name_it->value.GetString(), name_it->value.GetStringLength());
group.name_in_lower_case = strtolower(group.name);
const std::string group_id(
id_it->value.GetString(),
id_it->value.GetStringLength());
const auto description_it = patch.FindMember("description");
if (description_it != patch.MemberEnd()) {
if (!description_it->value.IsString()
|| has_embedded_null(description_it->value)) {
log_warning("patchmanager", "invalid description for patch group {}", group_id);
continue;
}
group.description.assign(
description_it->value.GetString(),
description_it->value.GetStringLength());
}
const auto caution_it = patch.FindMember("caution");
if (caution_it != patch.MemberEnd()) {
if (!caution_it->value.IsString() || has_embedded_null(caution_it->value)) {
log_warning("patchmanager", "invalid caution for patch group {}", group_id);
continue;
}
group.caution.assign(
caution_it->value.GetString(),
caution_it->value.GetStringLength());
}
const std::string game_code(
game_code_it->value.GetString(),
game_code_it->value.GetStringLength());
if (!groups.emplace(
make_patch_group_key(game_code, group_id),
std::move(group)).second) {
log_warning(
"patchmanager",
"duplicate patch group definition for {}/{}, ignoring duplicate",
game_code,
group_id);
}
}
return groups;
}
static const PatchGroup* find_patch_group(
const std::map<std::pair<std::string, std::string>, PatchGroup>& groups,
const std::string& game_code,
const std::string& group_id) {
const auto group = groups.find(make_patch_group_key(game_code, group_id));
return group == groups.end() ? nullptr : &group->second;
}
const PatchGroup* find_patch_group(const PatchData& patch) {
return find_patch_group(patch_groups, patch.game_code, patch.group_id);
}
std::string resolve_patch_group_id(
const rapidjson::Value& patch,
const std::map<std::pair<std::string, std::string>, PatchGroup>& groups,
const std::string& game_code,
const char *patch_name) {
const auto group_it = patch.FindMember("group");
if (group_it == patch.MemberEnd()) {
return "";
}
if (!group_it->value.IsString()
|| group_it->value.GetStringLength() == 0
|| has_embedded_null(group_it->value)) {
log_warning("patchmanager", "invalid group reference for {}", patch_name);
return "";
}
const std::string group_id(
group_it->value.GetString(),
group_it->value.GetStringLength());
if (!find_patch_group(groups, game_code, group_id)) {
log_warning(
"patchmanager",
"unknown patch group {}/{} referenced by {}",
game_code,
group_id,
patch_name);
return "";
}
return group_id;
}
void register_patch_group(
PatchData& patch,
const std::map<std::pair<std::string, std::string>, PatchGroup>& definitions) {
if (patch.group_id.empty()) {
return;
}
const auto *definition = find_patch_group(
definitions,
patch.game_code,
patch.group_id);
if (!definition) {
patch.group_id.clear();
return;
}
const auto key = make_patch_group_key(patch.game_code, patch.group_id);
const auto [existing, inserted] = patch_groups.emplace(key, *definition);
if (!inserted
&& (existing->second.name != definition->name
|| existing->second.description != definition->description
|| existing->second.caution != definition->caution)) {
log_warning(
"patchmanager",
"conflicting group metadata for {}/{}, ignoring group on {}",
patch.game_code,
patch.group_id,
patch.name);
patch.group_id.clear();
}
}
}
+9 -1
View File
@@ -2,7 +2,6 @@
#include <filesystem>
#include <functional>
#include <map>
#include <memory>
#include <optional>
#include <string>
@@ -74,6 +73,13 @@ namespace patcher {
bool fatal_error = false;
};
struct PatchGroup {
std::string name;
std::string description;
std::string caution;
std::string name_in_lower_case;
};
struct PatchData {
bool enabled;
std::string game_code;
@@ -86,6 +92,7 @@ namespace patcher {
std::vector<MemoryPatch> patches_memory;
std::vector<UnionPatch> patches_union;
NumberPatch patch_number;
std::string group_id;
PatchStatus last_status;
std::string hash;
bool unverified = false;
@@ -129,6 +136,7 @@ namespace patcher {
PatchStatus is_patch_active(PatchData& patch);
bool apply_patch(PatchData& patch, bool active);
const PatchGroup* find_patch_group(const PatchData& patch);
std::string displayPath(const std::filesystem::path& path);
}