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
+1
View File
@@ -619,6 +619,7 @@ set(SOURCE_FILES ${SOURCE_FILES}
patcher/config.cpp patcher/config.cpp
patcher/lifecycle.cpp patcher/lifecycle.cpp
patcher/loader.cpp patcher/loader.cpp
patcher/loader_group.cpp
patcher/runtime.cpp patcher/runtime.cpp
# external # external
+504 -47
View File
@@ -3,6 +3,8 @@
#include <algorithm> #include <algorithm>
#include <shellapi.h> #include <shellapi.h>
#include <thread> #include <thread>
#include <unordered_map>
#include <unordered_set>
#include "avs/game.h" #include "avs/game.h"
#include "cfg/configurator.h" #include "cfg/configurator.h"
#include "games/io.h" #include "games/io.h"
@@ -15,6 +17,363 @@
namespace overlay::windows { namespace overlay::windows {
namespace {
enum class PatchGroupStatus {
Error,
Disabled,
Enabled,
Mixed,
};
struct PatchGroupState {
PatchGroupStatus status = PatchGroupStatus::Disabled;
size_t first_error_index = static_cast<size_t>(-1);
size_t configured_count = 0;
size_t unverified_count = 0;
};
using PatchGroupMembers = std::unordered_map<const patcher::PatchGroup*, std::vector<size_t>>;
struct PatchGroupSearchMatch {
bool group_matches = false;
bool child_matches = false;
bool visible() const {
return group_matches || child_matches;
}
};
// aggregate child runtime and configuration state for the group UI
PatchGroupState get_patch_group_state(const std::vector<size_t>& members) {
PatchGroupState state;
bool any_enabled = false;
bool any_disabled = false;
for (const auto patch_index : members) {
const auto& patch = patcher::patches[patch_index];
if (patch.enabled) {
state.configured_count++;
}
if (patch.unverified) {
state.unverified_count++;
}
switch (patch.last_status) {
case patcher::PatchStatus::Error:
if (state.first_error_index == static_cast<size_t>(-1)) {
state.first_error_index = patch_index;
}
break;
case patcher::PatchStatus::Enabled:
any_enabled = true;
break;
case patcher::PatchStatus::Disabled:
any_disabled = true;
break;
default:
break;
}
}
if (state.first_error_index != static_cast<size_t>(-1)) {
state.status = PatchGroupStatus::Error;
} else if (any_enabled && any_disabled) {
state.status = PatchGroupStatus::Mixed;
} else if (any_enabled) {
state.status = PatchGroupStatus::Enabled;
}
return state;
}
// index sorted patch members by their resolved group
PatchGroupMembers collect_patch_group_members() {
PatchGroupMembers group_members;
for (const auto patch_index : patcher::patches_sorted) {
const auto *group = patcher::find_patch_group(patcher::patches[patch_index]);
if (group) {
group_members[group].push_back(patch_index);
}
}
return group_members;
}
// determine whether the group or any child matches the current filter
PatchGroupSearchMatch get_patch_group_search_match(
const patcher::PatchGroup& group,
const std::vector<size_t>& members,
const std::string& search_str_in_lower) {
PatchGroupSearchMatch match;
match.group_matches = search_str_in_lower.empty()
|| group.name_in_lower_case.find(search_str_in_lower) != std::string::npos;
if (match.group_matches) {
return match;
}
for (const auto member_index : members) {
if (patcher::patches[member_index].name_in_lower_case.find(search_str_in_lower)
!= std::string::npos) {
match.child_matches = true;
break;
}
}
return match;
}
// select the stripe color for a logical patch or group row
ImU32 get_patch_row_background_color(size_t logical_row_index) {
const auto color = logical_row_index % 2 == 0
? ImGuiCol_TableRowBg
: ImGuiCol_TableRowBgAlt;
return ImGui::GetColorU32(color);
}
// begin a physical table row using its logical row's stripe color
void begin_patch_row(ImU32 row_background_color) {
ImGui::TableNextRow();
ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, row_background_color);
}
// draw the tree branch connecting a group parent to a child row
void draw_patch_group_child_guide(
const ImVec2& cursor,
float branch_x,
float branch_end_x,
bool last_group_child) {
const auto& style = ImGui::GetStyle();
const float row_midpoint = cursor.y + ImGui::GetFrameHeight() * 0.5f;
const float row_top = cursor.y - style.CellPadding.y;
const float row_bottom = last_group_child
? row_midpoint
: cursor.y + ImGui::GetFrameHeight() + style.CellPadding.y;
const auto guide_color = ImGui::GetColorU32(ImGuiCol_Separator, 0.8f);
const float guide_thickness = overlay::apply_scaling(1.0f);
auto *draw_list = ImGui::GetWindowDrawList();
draw_list->AddLine(
ImVec2(branch_x, row_top),
ImVec2(branch_x, row_bottom),
guide_color,
guide_thickness);
draw_list->AddLine(
ImVec2(branch_x, row_midpoint),
ImVec2(branch_end_x, row_midpoint),
guide_color,
guide_thickness);
}
// reserve and render the group-child gutter in the current column
void render_patch_group_child_gutter(bool last_group_child) {
const auto& style = ImGui::GetStyle();
const auto cursor = ImGui::GetCursorScreenPos();
const float gutter_width = style.IndentSpacing * 1.5f;
const float branch_x = cursor.x + style.IndentSpacing * 0.5f;
draw_patch_group_child_guide(
cursor,
branch_x,
cursor.x + gutter_width,
last_group_child);
ImGui::Dummy(ImVec2(gutter_width, ImGui::GetFrameHeight()));
}
// constrain the next patch option control to the available column width
void set_patch_option_width() {
const float available_width = ImGui::GetContentRegionAvail().x;
ImGui::SetNextItemWidth(available_width < 200.0f ? available_width : 200.0f);
}
// show the group's help or caution marker
void show_patch_group_tooltip(const patcher::PatchGroup& group) {
if (!group.caution.empty()) {
ImGui::WarnTooltip(group.description.c_str(), group.caution.c_str());
} else if (!group.description.empty()) {
ImGui::HelpTooltip(group.description.c_str());
}
}
// append aggregate state details to the group's displayed name
std::string get_patch_group_display_name(
const patcher::PatchGroup& group,
const PatchGroupState& state,
size_t member_count) {
std::string name = group.name;
if (state.status == PatchGroupStatus::Error) {
name += " (Error)";
}
if (state.configured_count == member_count) {
name += patcher::setting_auto_apply ? " (Auto apply)" : " (Saved)";
} else if (state.configured_count > 0) {
name += patcher::setting_auto_apply
? " (Partial auto apply)"
: " (Partially saved)";
}
if (state.unverified_count == member_count) {
name += " (Unverified patches)";
} else if (state.unverified_count > 0) {
name += " (Contains unverified patch)";
}
return name;
}
// draw a square checkmark for mixed-state groups (neither checked or unchecked)
void render_patch_group_mixed_checkbox_mark() {
const auto check_min = ImGui::GetItemRectMin();
const float check_size = ImGui::GetFrameHeight();
const float calculated_padding = check_size / 3.6f;
const float padding = calculated_padding < 1.0f ? 1.0f : calculated_padding;
ImGui::GetWindowDrawList()->AddRectFilled(
ImVec2(check_min.x + padding, check_min.y + padding),
ImVec2(
check_min.x + check_size - padding,
check_min.y + check_size - padding),
ImGui::GetColorU32(ImGuiCol_CheckMark),
ImGui::GetStyle().FrameRounding);
}
// render a tri-state checkbox for groups
// user can interact with this to enable or disable all child patches
bool render_patch_group_checkbox(
const PatchGroupState& state,
const std::vector<size_t>& members,
bool& checked) {
const bool mixed = state.status == PatchGroupStatus::Mixed;
ImGui::BeginDisabled(state.status == PatchGroupStatus::Error);
if (mixed) {
ImGui::PushStyleColor(ImGuiCol_CheckMark, ImVec4(0.f, 0.f, 0.f, 0.f));
}
const bool changed = ImGui::Checkbox("##group_checked_checkbox", &checked);
if (mixed) {
ImGui::PopStyleColor();
}
ImGui::EndDisabled();
if (mixed && !changed) {
render_patch_group_mixed_checkbox_mark();
}
if (!changed) {
return false;
}
if (checked) {
patcher::setting_auto_apply = true;
}
for (const auto member_index : members) {
auto& member = patcher::patches[member_index];
member.enabled = checked;
patcher::apply_patch(member, checked);
member.last_status = patcher::is_patch_active(member);
}
patcher::config_dirty = true;
return true;
}
// render aggregate status or error text beside the group checkbox
void render_patch_group_status(const PatchGroupState& state, bool checked) {
ImGui::SameLine();
ImGui::AlignTextToFramePadding();
if (state.status == PatchGroupStatus::Error) {
const auto& error_patch = patcher::patches[state.first_error_index];
const auto error_reason = error_patch.error_reason.empty()
? "Unknown error"
: error_patch.error_reason;
const auto error_text = error_patch.name + ": " + error_reason;
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.f, 0.f, 0.f, 1.f));
ImGui::TextUnformatted(error_text.c_str());
ImGui::PopStyleColor();
} else if (state.status == PatchGroupStatus::Mixed) {
ImGui::TextUnformatted("mixed");
} else {
ImGui::BeginDisabled(!checked);
ImGui::TextUnformatted(checked ? "ON" : "off");
ImGui::EndDisabled();
}
}
// render a group parent row and report whether its children are expanded
bool render_patch_group_parent(
const patcher::PatchGroup& group,
const std::vector<size_t>& members,
bool child_matches_search,
ImU32 row_background_color) {
auto group_state = get_patch_group_state(members);
begin_patch_row(row_background_color);
// render status first so its checkbox can set the tree's next open state
ImGui::TableSetColumnIndex(1);
bool group_checked = group_state.status == PatchGroupStatus::Enabled
|| group_state.status == PatchGroupStatus::Mixed;
const bool group_checkbox_changed = render_patch_group_checkbox(
group_state,
members,
group_checked);
if (group_checkbox_changed) {
group_state = get_patch_group_state(members);
}
if (ImGui::IsItemHovered(ImGui::TOOLTIP_FLAGS)) {
show_patch_group_tooltip(group);
}
render_patch_group_status(group_state, group_checked);
ImGui::TableSetColumnIndex(0);
const auto group_name = get_patch_group_display_name(
group,
group_state,
members.size());
bool group_text_color_set = false;
ImVec4 group_text_color;
switch (group_state.status) {
case PatchGroupStatus::Error:
group_text_color = ImVec4(1.f, 0.f, 0.f, 1.f);
group_text_color_set = true;
break;
case PatchGroupStatus::Enabled:
if (patcher::setting_auto_apply
&& group_state.configured_count == members.size()) {
group_text_color = ImVec4(0.f, 1.f, 0.f, 1.f);
group_text_color_set = true;
}
break;
case PatchGroupStatus::Mixed:
group_text_color = ImVec4(0.f, 1.f, 0.f, 1.f);
group_text_color_set = true;
break;
case PatchGroupStatus::Disabled:
default:
break;
}
if (!group.caution.empty()) {
ImGui::AlignTextToFramePadding();
ImGui::WarnMarker(group.description.c_str(), group.caution.c_str());
} else {
ImGui::DummyMarker();
}
ImGui::SameLine(0.0f, 0.0f);
ImGui::AlignTextToFramePadding();
if (child_matches_search) {
ImGui::SetNextItemOpen(true, ImGuiCond_Always);
} else if (group_checkbox_changed) {
ImGui::SetNextItemOpen(group_checked, ImGuiCond_Always);
}
if (group_text_color_set) {
ImGui::PushStyleColor(ImGuiCol_Text, group_text_color);
}
bool group_open = ImGui::TreeNodeEx(
"##group_tree",
ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_NoTreePushOnOpen,
"%s",
group_name.c_str());
if (group_text_color_set) {
ImGui::PopStyleColor();
}
if (ImGui::IsItemHovered(ImGui::TOOLTIP_FLAGS)) {
show_patch_group_tooltip(group);
}
ImGui::HighlightTableRowOnHover();
return group_open;
}
}
// user-assigned IDs for the patches table columns, used by the sort logic // user-assigned IDs for the patches table columns, used by the sort logic
enum PatchColumnId { enum PatchColumnId {
PATCH_COLUMN_NAME = 0, PATCH_COLUMN_NAME = 0,
@@ -406,49 +765,42 @@ namespace overlay::windows {
240, PATCH_COLUMN_STATUS); 240, PATCH_COLUMN_STATUS);
ImGui::TableHeadersRow(); ImGui::TableHeadersRow();
// maintain a sorted view of patches so the underlying vector // refresh every child before sorting and aggregating group state
// order (used by config save and hashing) is left untouched for (auto& patch : patcher::patches) {
update_sorted_patches(); patch.last_status = patcher::is_patch_active(patch);
const auto search_str_in_lower = strtolower(patch_name_filter);
size_t patches_shown = 0;
for (auto patch_index : patcher::patches_sorted) {
auto &patch = patcher::patches[patch_index];
// get patch status
patcher::PatchStatus patch_status = patcher::is_patch_active(patch);
patch.last_status = patch_status;
// user requested to disable all
if (disable_all_patches && patch.enabled) { if (disable_all_patches && patch.enabled) {
patch.enabled = false; patch.enabled = false;
patcher::config_dirty = true; patcher::config_dirty = true;
switch (patch_status) { switch (patch.last_status) {
case patcher::PatchStatus::Enabled: case patcher::PatchStatus::Enabled:
case patcher::PatchStatus::Disabled: case patcher::PatchStatus::Disabled:
patcher::apply_patch(patch, false); patcher::apply_patch(patch, false);
patch.last_status = patcher::is_patch_active(patch);
break; break;
case patcher::PatchStatus::Error: case patcher::PatchStatus::Error:
if (cfg::CONFIGURATOR_STANDALONE) {
patch.enabled = false;
}
break;
default: default:
break; break;
} }
} }
}
// search function // maintain a sorted view of patches so the underlying vector
if (!patch_name_filter.empty()) { // order (used by config save and hashing) is left untouched
if (patch.name_in_lower_case.find(search_str_in_lower) == std::string::npos) { update_sorted_patches();
continue;
} // function for rendering a patch row, used for both grouped and ungrouped patches
} const auto search_str_in_lower = strtolower(patch_name_filter);
size_t items_shown = 0;
auto render_patch = [&](size_t patch_index, ImU32 row_background_color,
bool group_child = false,
bool last_group_child = false) {
auto &patch = patcher::patches[patch_index];
const patcher::PatchStatus patch_status = patch.last_status;
// start drawing a row for this patch // start drawing a row for this patch
ImGui::TableNextRow(); begin_patch_row(row_background_color);
ImGui::PushID(&patch); ImGui::PushID(&patch);
patches_shown += 1;
// first column, part 1: help / caution marker // first column, part 1: help / caution marker
ImGui::TableNextColumn(); ImGui::TableNextColumn();
@@ -458,6 +810,10 @@ namespace overlay::windows {
} else { } else {
ImGui::DummyMarker(); ImGui::DummyMarker();
} }
if (group_child) {
ImGui::SameLine();
render_patch_group_child_gutter(last_group_child);
}
// get current state // get current state
bool patch_checked = patch_status == patcher::PatchStatus::Enabled; bool patch_checked = patch_status == patcher::PatchStatus::Enabled;
@@ -514,6 +870,10 @@ namespace overlay::windows {
// second column, part 1: enable checkbox (applies to all) // second column, part 1: enable checkbox (applies to all)
ImGui::TableNextColumn(); ImGui::TableNextColumn();
if (group_child) {
render_patch_group_child_gutter(last_group_child);
ImGui::SameLine();
}
ImGui::BeginDisabled(patch_status == patcher::PatchStatus::Error); ImGui::BeginDisabled(patch_status == patcher::PatchStatus::Error);
if (ImGui::Checkbox("##patch_checked_checkbox", &patch_checked)) { if (ImGui::Checkbox("##patch_checked_checkbox", &patch_checked)) {
patcher::config_dirty = true; patcher::config_dirty = true;
@@ -559,7 +919,7 @@ namespace overlay::windows {
} else if (patch.type == patcher::PatchType::Union || patch.type == patcher::PatchType::Integer) { } else if (patch.type == patcher::PatchType::Union || patch.type == patcher::PatchType::Integer) {
if (patch_status == patcher::PatchStatus::Enabled) { if (patch_status == patcher::PatchStatus::Enabled) {
if (patch.type == patcher::PatchType::Union) { if (patch.type == patcher::PatchType::Union) {
ImGui::SetNextItemWidth(200.0f); set_patch_option_width();
if (ImGui::BeginCombo("##union_patch_dropdown", patch.selected_union_name.c_str())) { if (ImGui::BeginCombo("##union_patch_dropdown", patch.selected_union_name.c_str())) {
for (const auto& union_patch : patch.patches_union) { for (const auto& union_patch : patch.patches_union) {
if (ImGui::Selectable(union_patch.name.c_str())) { if (ImGui::Selectable(union_patch.name.c_str())) {
@@ -574,7 +934,7 @@ namespace overlay::windows {
show_patch_tooltip(patch); show_patch_tooltip(patch);
} }
} else if (patch.type == patcher::PatchType::Integer) { } else if (patch.type == patcher::PatchType::Integer) {
ImGui::SetNextItemWidth(200.0f); set_patch_option_width();
auto& numpatch = patch.patch_number; auto& numpatch = patch.patch_number;
ImGui::InputInt("##int_input", &numpatch.value, 1, 10); ImGui::InputInt("##int_input", &numpatch.value, 1, 10);
if (ImGui::IsItemDeactivatedAfterEdit()) { if (ImGui::IsItemDeactivatedAfterEdit()) {
@@ -591,7 +951,7 @@ namespace overlay::windows {
} }
} }
} else if (patch_status == patcher::PatchStatus::Disabled) { } else if (patch_status == patcher::PatchStatus::Disabled) {
ImGui::SetNextItemWidth(200.0f); set_patch_option_width();
ImGui::BeginDisabled(); ImGui::BeginDisabled();
if (patch.type == patcher::PatchType::Union) { if (patch.type == patcher::PatchType::Union) {
if (ImGui::BeginCombo( if (ImGui::BeginCombo(
@@ -616,10 +976,65 @@ namespace overlay::windows {
ImGui::HighlightTableRowOnHover(); ImGui::HighlightTableRowOnHover();
ImGui::PopID();
};
const auto group_members = collect_patch_group_members();
std::unordered_set<const patcher::PatchGroup*> rendered_groups;
// time to render each row using render_patch
for (const auto patch_index : patcher::patches_sorted) {
auto& patch = patcher::patches[patch_index];
const auto *group = patcher::find_patch_group(patch);
if (!group) {
if (!patch_name_filter.empty()
&& patch.name_in_lower_case.find(search_str_in_lower) == std::string::npos) {
continue;
}
const auto row_background_color =
get_patch_row_background_color(items_shown++);
render_patch(patch_index, row_background_color);
continue;
}
if (!rendered_groups.insert(group).second) {
continue;
}
const auto& members = group_members.at(group);
const auto search_match = get_patch_group_search_match(
*group,
members,
search_str_in_lower);
if (!search_match.visible()) {
continue;
}
ImGui::PushID(group);
const auto row_background_color =
get_patch_row_background_color(items_shown++);
const bool group_open = render_patch_group_parent(
*group,
members,
search_match.child_matches,
row_background_color);
if (group_open) {
for (size_t member_position = 0;
member_position < members.size();
member_position++) {
render_patch(
members[member_position],
row_background_color,
true,
member_position + 1 == members.size());
}
}
ImGui::PopID(); ImGui::PopID();
} }
if (patches_shown == 0) { // no patches, show empty table
if (items_shown == 0) {
ImGui::TableNextRow(); ImGui::TableNextRow();
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::DummyMarker(); ImGui::DummyMarker();
@@ -644,44 +1059,86 @@ namespace overlay::windows {
// clears the cache to avoid dangling pointers when `patches` is rebuilt. // clears the cache to avoid dangling pointers when `patches` is rebuilt.
auto *sort_specs = ImGui::TableGetSortSpecs(); auto *sort_specs = ImGui::TableGetSortSpecs();
const bool patches_changed = patcher::patches_sorted.size() != patcher::patches.size(); const bool patches_changed = patcher::patches_sorted.size() != patcher::patches.size();
if (!patches_changed && !(sort_specs && sort_specs->SpecsDirty)) { const bool status_sort_active = sort_specs
&& sort_specs->SpecsCount > 0
&& sort_specs->Specs[0].ColumnUserID == PATCH_COLUMN_STATUS;
if (!patches_changed && !status_sort_active && !(sort_specs && sort_specs->SpecsDirty)) {
return; return;
} }
// rebuild the view in the underlying vector order (this is also the struct SortItem {
// default order shown when the sort is cleared / tristate "unsorted") std::vector<size_t> members;
patcher::patches_sorted.clear(); std::string name_in_lower_case;
patcher::patches_sorted.reserve(patcher::patches.size()); bool enabled;
};
PatchGroupMembers group_members;
std::vector<const patcher::PatchGroup*> groups_by_patch(patcher::patches.size());
for (size_t i = 0; i < patcher::patches.size(); i++) { for (size_t i = 0; i < patcher::patches.size(); i++) {
patcher::patches_sorted.push_back(i); const auto *group = patcher::find_patch_group(patcher::patches[i]);
groups_by_patch[i] = group;
if (group) {
group_members[group].push_back(i);
}
} }
// SpecsCount == 0 means no active sort: keep the default order // build one sortable item per ungrouped patch or group, placing each
// group at its first occurrence in the underlying file order
std::vector<SortItem> sort_items;
sort_items.reserve(patcher::patches.size());
std::unordered_set<const patcher::PatchGroup*> emitted_groups;
for (size_t i = 0; i < patcher::patches.size(); i++) {
const auto& patch = patcher::patches[i];
const auto *group = groups_by_patch[i];
if (!group) {
sort_items.push_back({
.members = {i},
.name_in_lower_case = patch.name_in_lower_case,
.enabled = patch.last_status == patcher::PatchStatus::Enabled,
});
} else if (emitted_groups.insert(group).second) {
auto members = std::move(group_members.at(group));
const bool group_enabled = get_patch_group_state(members).status
== PatchGroupStatus::Enabled;
sort_items.push_back({
.members = std::move(members),
.name_in_lower_case = group->name_in_lower_case,
.enabled = group_enabled,
});
}
}
// no active sort specs means the default item order is retained
if (sort_specs && sort_specs->SpecsCount > 0) { if (sort_specs && sort_specs->SpecsCount > 0) {
const auto &spec = sort_specs->Specs[0]; const auto &spec = sort_specs->Specs[0];
const bool ascending = spec.SortDirection != ImGuiSortDirection_Descending; const bool ascending = spec.SortDirection != ImGuiSortDirection_Descending;
std::stable_sort(patcher::patches_sorted.begin(), patcher::patches_sorted.end(), std::stable_sort(sort_items.begin(), sort_items.end(),
[&](size_t ia, size_t ib) { [&](const SortItem& a, const SortItem& b) {
const patcher::PatchData *a = &patcher::patches[ia];
const patcher::PatchData *b = &patcher::patches[ib];
int cmp; int cmp;
if (spec.ColumnUserID == PATCH_COLUMN_STATUS) { if (spec.ColumnUserID == PATCH_COLUMN_STATUS) {
// sort by displayed status (matches the checkbox), then // sort by displayed status (matches the checkbox), then
// by name as a tiebreaker // by name as a tiebreaker
const bool a_on = a->last_status == patcher::PatchStatus::Enabled; if (a.enabled != b.enabled) {
const bool b_on = b->last_status == patcher::PatchStatus::Enabled; cmp = a.enabled ? -1 : 1;
if (a_on != b_on) {
cmp = a_on ? -1 : 1;
} else { } else {
cmp = a->name_in_lower_case.compare(b->name_in_lower_case); cmp = a.name_in_lower_case.compare(b.name_in_lower_case);
} }
} else { } else {
cmp = a->name_in_lower_case.compare(b->name_in_lower_case); cmp = a.name_in_lower_case.compare(b.name_in_lower_case);
} }
return ascending ? (cmp < 0) : (cmp > 0); return ascending ? (cmp < 0) : (cmp > 0);
}); });
} }
patcher::patches_sorted.clear();
patcher::patches_sorted.reserve(patcher::patches.size());
for (const auto& item : sort_items) {
patcher::patches_sorted.insert(
patcher::patches_sorted.end(),
item.members.begin(),
item.members.end());
}
if (sort_specs) { if (sort_specs) {
sort_specs->SpecsDirty = false; sort_specs->SpecsDirty = false;
} }
+15
View File
@@ -1,5 +1,8 @@
#pragma once #pragma once
#include <map>
#include <utility>
#include "patch_manager.h" #include "patch_manager.h"
#include "external/rapidjson/document.h" #include "external/rapidjson/document.h"
#include "util/nt_loader.h" #include "util/nt_loader.h"
@@ -11,6 +14,7 @@ namespace patcher {
extern std::vector<std::string> setting_patches_enabled; extern std::vector<std::string> setting_patches_enabled;
extern std::map<std::string, std::string> setting_union_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::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::filesystem::path LOCAL_PATCHES_PATH;
extern std::map<std::string, std::vector<std::string>> EXTRA_DLLS; extern std::map<std::string, std::vector<std::string>> EXTRA_DLLS;
extern bool ldr_registered; extern bool ldr_registered;
@@ -28,6 +32,17 @@ namespace patcher {
bool load_from_patches_json(bool apply_patches); bool load_from_patches_json(bool apply_patches);
void load_embedded_patches(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 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); VOID CALLBACK loader_notification(ULONG reason, PCLDR_DLL_NOTIFICATION_DATA data, PVOID context);
std::string patch_hash(PatchData& patch); std::string patch_hash(PatchData& patch);
+1
View File
@@ -31,6 +31,7 @@ namespace patcher {
std::vector<PatchData> patches; std::vector<PatchData> patches;
bool local_patches_initialized = false; bool local_patches_initialized = false;
std::vector<size_t> patches_sorted; 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 = { std::map<std::string, std::vector<std::string>> EXTRA_DLLS = {
{"jubeat.dll", {"music_db.dll", "coin.dll"}}, {"jubeat.dll", {"music_db.dll", "coin.dll"}},
{"arkmdxp3.dll", {"gamemdx.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)); 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 // iterate patches
for (auto &patch : doc.GetArray()) { for (auto &patch : doc.GetArray()) {
if (is_patch_group_definition(patch)) {
continue;
}
// verfiy patch data // verfiy patch data
auto name_it = patch.FindMember("name"); auto name_it = patch.FindMember("name");
@@ -214,6 +219,9 @@ namespace patcher {
name_it->value.GetString()); name_it->value.GetString());
continue; continue;
} }
const std::string game_code(
game_code_it->value.GetString(),
game_code_it->value.GetStringLength());
auto description_it = patch.FindMember("description"); auto description_it = patch.FindMember("description");
if (description_it == patch.MemberEnd() || !description_it->value.IsString()) { if (description_it == patch.MemberEnd() || !description_it->value.IsString()) {
log_warning("patchmanager", "failed to parse description for {}", log_warning("patchmanager", "failed to parse description for {}",
@@ -235,7 +243,7 @@ namespace patcher {
// build patch data // build patch data
PatchData patch_data { PatchData patch_data {
.enabled = false, .enabled = false,
.game_code = game_code_it->value.GetString(), .game_code = game_code,
.datecode_min = 0, .datecode_min = 0,
.datecode_max = 0, .datecode_max = 0,
.name = name_it->value.GetString(), .name = name_it->value.GetString(),
@@ -247,6 +255,11 @@ namespace patcher {
.patches_memory = std::vector<MemoryPatch>(), .patches_memory = std::vector<MemoryPatch>(),
.patches_union = std::vector<UnionPatch>(), .patches_union = std::vector<UnionPatch>(),
.patch_number = NumberPatch(), .patch_number = NumberPatch(),
.group_id = resolve_patch_group_id(
patch,
group_definitions,
game_code,
name_it->value.GetString()),
.last_status = PatchStatus::Disabled, .last_status = PatchStatus::Disabled,
.hash = "", .hash = "",
.unverified = false, .unverified = false,
@@ -533,6 +546,7 @@ namespace patcher {
} }
// auto apply // auto apply
register_patch_group(patch_data, group_definitions);
if (apply_patches && setting_auto_apply && patch_data.enabled) { if (apply_patches && setting_auto_apply && patch_data.enabled) {
print_auto_apply_status(patch_data); print_auto_apply_status(patch_data);
apply_patch(patch_data, true); apply_patch(patch_data, true);
@@ -663,6 +677,7 @@ namespace patcher {
// clear old patches // clear old patches
patches.clear(); patches.clear();
patch_groups.clear();
// drop the cached sorted view so the table rebuilds it (in default order) // drop the cached sorted view so the table rebuilds it (in default order)
// on the next frame // on the next frame
patches_sorted.clear(); patches_sorted.clear();
@@ -719,6 +734,7 @@ namespace patcher {
bool imported = false; bool imported = false;
// clear old patches // clear old patches
patches.clear(); patches.clear();
patch_groups.clear();
patches_sorted.clear(); patches_sorted.clear();
url_fetch_errors.clear(); url_fetch_errors.clear();
@@ -755,8 +771,13 @@ namespace patcher {
rapidjson::GetParseError_En(error)); rapidjson::GetParseError_En(error));
} }
const auto group_definitions = parse_patch_group_definitions(doc);
// iterate patches // iterate patches
for (auto &patch : doc.GetArray()) { for (auto &patch : doc.GetArray()) {
if (is_patch_group_definition(patch)) {
continue;
}
// verfiy patch data // verfiy patch data
auto name_it = patch.FindMember("name"); auto name_it = patch.FindMember("name");
@@ -778,6 +799,9 @@ namespace patcher {
name_it->value.GetString()); name_it->value.GetString());
continue; continue;
} }
const std::string game_code(
game_code_it->value.GetString(),
game_code_it->value.GetStringLength());
auto description_it = patch.FindMember("description"); auto description_it = patch.FindMember("description");
if (description_it == patch.MemberEnd() || !description_it->value.IsString()) { if (description_it == patch.MemberEnd() || !description_it->value.IsString()) {
log_warning("patchmanager", "failed to parse description for {}", log_warning("patchmanager", "failed to parse description for {}",
@@ -809,7 +833,7 @@ namespace patcher {
// build patch data // build patch data
PatchData patch_data { PatchData patch_data {
.enabled = false, .enabled = false,
.game_code = game_code_it->value.GetString(), .game_code = game_code,
.datecode_min = 0, .datecode_min = 0,
.datecode_max = 0, .datecode_max = 0,
.name = name_it->value.GetString(), .name = name_it->value.GetString(),
@@ -821,6 +845,11 @@ namespace patcher {
.patches_memory = std::vector<MemoryPatch>(), .patches_memory = std::vector<MemoryPatch>(),
.patches_union = std::vector<UnionPatch>(), .patches_union = std::vector<UnionPatch>(),
.patch_number = NumberPatch(), .patch_number = NumberPatch(),
.group_id = resolve_patch_group_id(
patch,
group_definitions,
game_code,
name_it->value.GetString()),
.last_status = PatchStatus::Disabled, .last_status = PatchStatus::Disabled,
.hash = "", .hash = "",
.unverified = false, .unverified = false,
@@ -1264,6 +1293,7 @@ namespace patcher {
} }
// auto apply // auto apply
register_patch_group(patch_data, group_definitions);
if (apply_patches && setting_auto_apply && patch_data.enabled) { if (apply_patches && setting_auto_apply && patch_data.enabled) {
print_auto_apply_status(patch_data); print_auto_apply_status(patch_data);
apply_patch(patch_data, true); 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 <filesystem>
#include <functional> #include <functional>
#include <map>
#include <memory> #include <memory>
#include <optional> #include <optional>
#include <string> #include <string>
@@ -74,6 +73,13 @@ namespace patcher {
bool fatal_error = false; bool fatal_error = false;
}; };
struct PatchGroup {
std::string name;
std::string description;
std::string caution;
std::string name_in_lower_case;
};
struct PatchData { struct PatchData {
bool enabled; bool enabled;
std::string game_code; std::string game_code;
@@ -86,6 +92,7 @@ namespace patcher {
std::vector<MemoryPatch> patches_memory; std::vector<MemoryPatch> patches_memory;
std::vector<UnionPatch> patches_union; std::vector<UnionPatch> patches_union;
NumberPatch patch_number; NumberPatch patch_number;
std::string group_id;
PatchStatus last_status; PatchStatus last_status;
std::string hash; std::string hash;
bool unverified = false; bool unverified = false;
@@ -129,6 +136,7 @@ namespace patcher {
PatchStatus is_patch_active(PatchData& patch); PatchStatus is_patch_active(PatchData& patch);
bool apply_patch(PatchData& patch, bool active); bool apply_patch(PatchData& patch, bool active);
const PatchGroup* find_patch_group(const PatchData& patch);
std::string displayPath(const std::filesystem::path& path); std::string displayPath(const std::filesystem::path& path);
} }