From be4bb0039073a8d5dc95e0cda1da95ec8838d7be Mon Sep 17 00:00:00 2001 From: bicarus <202771338+bicarus-dev@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:10:13 -0700 Subject: [PATCH] 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?* --- src/spice2x/CMakeLists.txt | 1 + src/spice2x/overlay/windows/patch_manager.cpp | 551 ++++++++++++++++-- src/spice2x/patcher/internal.h | 15 + src/spice2x/patcher/lifecycle.cpp | 1 + src/spice2x/patcher/loader.cpp | 34 +- src/spice2x/patcher/loader_group.cpp | 179 ++++++ src/spice2x/patcher/patch_manager.h | 10 +- 7 files changed, 741 insertions(+), 50 deletions(-) create mode 100644 src/spice2x/patcher/loader_group.cpp diff --git a/src/spice2x/CMakeLists.txt b/src/spice2x/CMakeLists.txt index 9659fe1..64e329d 100644 --- a/src/spice2x/CMakeLists.txt +++ b/src/spice2x/CMakeLists.txt @@ -619,6 +619,7 @@ set(SOURCE_FILES ${SOURCE_FILES} patcher/config.cpp patcher/lifecycle.cpp patcher/loader.cpp + patcher/loader_group.cpp patcher/runtime.cpp # external diff --git a/src/spice2x/overlay/windows/patch_manager.cpp b/src/spice2x/overlay/windows/patch_manager.cpp index 42c3acc..ea505d5 100644 --- a/src/spice2x/overlay/windows/patch_manager.cpp +++ b/src/spice2x/overlay/windows/patch_manager.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #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(-1); + size_t configured_count = 0; + size_t unverified_count = 0; + }; + + using PatchGroupMembers = std::unordered_map>; + + 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& 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(-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(-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& 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& 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& 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 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 members; + std::string name_in_lower_case; + bool enabled; + }; + + PatchGroupMembers group_members; + std::vector 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 sort_items; + sort_items.reserve(patcher::patches.size()); + std::unordered_set 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; } diff --git a/src/spice2x/patcher/internal.h b/src/spice2x/patcher/internal.h index 67215a5..2b0b365 100644 --- a/src/spice2x/patcher/internal.h +++ b/src/spice2x/patcher/internal.h @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include "patch_manager.h" #include "external/rapidjson/document.h" #include "util/nt_loader.h" @@ -11,6 +14,7 @@ namespace patcher { extern std::vector setting_patches_enabled; extern std::map setting_union_patches_enabled; extern std::map setting_int_patches_enabled; + extern std::map, PatchGroup> patch_groups; extern std::filesystem::path LOCAL_PATCHES_PATH; extern std::map> 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, PatchGroup> parse_patch_group_definitions( + const rapidjson::Document& doc); + std::string resolve_patch_group_id( + const rapidjson::Value& patch, + const std::map, PatchGroup>& groups, + const std::string& game_code, + const char *patch_name); + void register_patch_group( + PatchData& patch, + const std::map, PatchGroup>& definitions); VOID CALLBACK loader_notification(ULONG reason, PCLDR_DLL_NOTIFICATION_DATA data, PVOID context); std::string patch_hash(PatchData& patch); diff --git a/src/spice2x/patcher/lifecycle.cpp b/src/spice2x/patcher/lifecycle.cpp index 6b298ad..277d0f2 100644 --- a/src/spice2x/patcher/lifecycle.cpp +++ b/src/spice2x/patcher/lifecycle.cpp @@ -31,6 +31,7 @@ namespace patcher { std::vector patches; bool local_patches_initialized = false; std::vector patches_sorted; + std::map, PatchGroup> patch_groups; std::map> EXTRA_DLLS = { {"jubeat.dll", {"music_db.dll", "coin.dll"}}, {"arkmdxp3.dll", {"gamemdx.dll"}}, diff --git a/src/spice2x/patcher/loader.cpp b/src/spice2x/patcher/loader.cpp index 5882b9f..954beaa 100644 --- a/src/spice2x/patcher/loader.cpp +++ b/src/spice2x/patcher/loader.cpp @@ -199,8 +199,13 @@ namespace patcher { log_warning("patchmanager", "embedded patches json file parse error: {}", static_cast(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(), .patches_union = std::vector(), .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(), .patches_union = std::vector(), .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); diff --git a/src/spice2x/patcher/loader_group.cpp b/src/spice2x/patcher/loader_group.cpp new file mode 100644 index 0000000..d78ddf4 --- /dev/null +++ b/src/spice2x/patcher/loader_group.cpp @@ -0,0 +1,179 @@ +#include "internal.h" + +#include +#include "util/logging.h" +#include "util/utils.h" + +namespace patcher { + + static std::pair 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, PatchGroup> parse_patch_group_definitions( + const rapidjson::Document& doc) { + std::map, 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, 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, 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, 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(); + } + } +} diff --git a/src/spice2x/patcher/patch_manager.h b/src/spice2x/patcher/patch_manager.h index 38df489..e666bd6 100644 --- a/src/spice2x/patcher/patch_manager.h +++ b/src/spice2x/patcher/patch_manager.h @@ -2,7 +2,6 @@ #include #include -#include #include #include #include @@ -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 patches_memory; std::vector 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); }