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
+504 -47
View File
@@ -3,6 +3,8 @@
#include <algorithm>
#include <shellapi.h>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include "avs/game.h"
#include "cfg/configurator.h"
#include "games/io.h"
@@ -15,6 +17,363 @@
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
enum PatchColumnId {
PATCH_COLUMN_NAME = 0,
@@ -406,49 +765,42 @@ namespace overlay::windows {
240, PATCH_COLUMN_STATUS);
ImGui::TableHeadersRow();
// maintain a sorted view of patches so the underlying vector
// order (used by config save and hashing) is left untouched
update_sorted_patches();
// refresh every child before sorting and aggregating group state
for (auto& patch : patcher::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) {
patch.enabled = false;
patcher::config_dirty = true;
switch (patch_status) {
switch (patch.last_status) {
case patcher::PatchStatus::Enabled:
case patcher::PatchStatus::Disabled:
patcher::apply_patch(patch, false);
patch.last_status = patcher::is_patch_active(patch);
break;
case patcher::PatchStatus::Error:
if (cfg::CONFIGURATOR_STANDALONE) {
patch.enabled = false;
}
break;
default:
break;
}
}
}
// search function
if (!patch_name_filter.empty()) {
if (patch.name_in_lower_case.find(search_str_in_lower) == std::string::npos) {
continue;
}
}
// maintain a sorted view of patches so the underlying vector
// order (used by config save and hashing) is left untouched
update_sorted_patches();
// 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
ImGui::TableNextRow();
begin_patch_row(row_background_color);
ImGui::PushID(&patch);
patches_shown += 1;
// first column, part 1: help / caution marker
ImGui::TableNextColumn();
@@ -458,6 +810,10 @@ namespace overlay::windows {
} else {
ImGui::DummyMarker();
}
if (group_child) {
ImGui::SameLine();
render_patch_group_child_gutter(last_group_child);
}
// get current state
bool patch_checked = patch_status == patcher::PatchStatus::Enabled;
@@ -514,6 +870,10 @@ namespace overlay::windows {
// second column, part 1: enable checkbox (applies to all)
ImGui::TableNextColumn();
if (group_child) {
render_patch_group_child_gutter(last_group_child);
ImGui::SameLine();
}
ImGui::BeginDisabled(patch_status == patcher::PatchStatus::Error);
if (ImGui::Checkbox("##patch_checked_checkbox", &patch_checked)) {
patcher::config_dirty = true;
@@ -559,7 +919,7 @@ namespace overlay::windows {
} else if (patch.type == patcher::PatchType::Union || patch.type == patcher::PatchType::Integer) {
if (patch_status == patcher::PatchStatus::Enabled) {
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())) {
for (const auto& union_patch : patch.patches_union) {
if (ImGui::Selectable(union_patch.name.c_str())) {
@@ -574,7 +934,7 @@ namespace overlay::windows {
show_patch_tooltip(patch);
}
} else if (patch.type == patcher::PatchType::Integer) {
ImGui::SetNextItemWidth(200.0f);
set_patch_option_width();
auto& numpatch = patch.patch_number;
ImGui::InputInt("##int_input", &numpatch.value, 1, 10);
if (ImGui::IsItemDeactivatedAfterEdit()) {
@@ -591,7 +951,7 @@ namespace overlay::windows {
}
}
} else if (patch_status == patcher::PatchStatus::Disabled) {
ImGui::SetNextItemWidth(200.0f);
set_patch_option_width();
ImGui::BeginDisabled();
if (patch.type == patcher::PatchType::Union) {
if (ImGui::BeginCombo(
@@ -616,10 +976,65 @@ namespace overlay::windows {
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();
}
if (patches_shown == 0) {
// no patches, show empty table
if (items_shown == 0) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::DummyMarker();
@@ -644,44 +1059,86 @@ namespace overlay::windows {
// clears the cache to avoid dangling pointers when `patches` is rebuilt.
auto *sort_specs = ImGui::TableGetSortSpecs();
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;
}
// rebuild the view in the underlying vector order (this is also the
// default order shown when the sort is cleared / tristate "unsorted")
patcher::patches_sorted.clear();
patcher::patches_sorted.reserve(patcher::patches.size());
struct SortItem {
std::vector<size_t> members;
std::string name_in_lower_case;
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++) {
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) {
const auto &spec = sort_specs->Specs[0];
const bool ascending = spec.SortDirection != ImGuiSortDirection_Descending;
std::stable_sort(patcher::patches_sorted.begin(), patcher::patches_sorted.end(),
[&](size_t ia, size_t ib) {
const patcher::PatchData *a = &patcher::patches[ia];
const patcher::PatchData *b = &patcher::patches[ib];
std::stable_sort(sort_items.begin(), sort_items.end(),
[&](const SortItem& a, const SortItem& b) {
int cmp;
if (spec.ColumnUserID == PATCH_COLUMN_STATUS) {
// sort by displayed status (matches the checkbox), then
// by name as a tiebreaker
const bool a_on = a->last_status == patcher::PatchStatus::Enabled;
const bool b_on = b->last_status == patcher::PatchStatus::Enabled;
if (a_on != b_on) {
cmp = a_on ? -1 : 1;
if (a.enabled != b.enabled) {
cmp = a.enabled ? -1 : 1;
} 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 {
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);
});
}
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) {
sort_specs->SpecsDirty = false;
}