diff --git a/CMakeLists.txt b/CMakeLists.txt index 43180b2..cb0c71c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,7 +82,7 @@ install(DIRECTORY "${VGUI2EXTENSION_GAMEDATA_DIR}/" DESTINATION svencoop/metahoo option(VGUI2EXTENSION_BUILD_TESTS "Build existing language and IME regression tests" OFF) if(VGUI2EXTENSION_BUILD_TESTS) enable_testing() - foreach(test language_registry_tests ime_window_message_test combobox_unicode_tests career_player_name_tests teammenu_hook_tests default_server_name_tests career_popup_order_tests career_profile_storage_tests) + foreach(test language_registry_tests ime_window_message_test combobox_unicode_tests career_player_name_tests teammenu_hook_tests default_server_name_tests career_popup_order_tests career_profile_storage_tests buildmode_layout_tests) add_executable(${test} "tests/${test}.cpp") target_compile_features(${test} PRIVATE cxx_std_17) target_compile_options(${test} PRIVATE /W4 /WX /UNDEBUG) diff --git a/cmake/Sources.cmake b/cmake/Sources.cmake index 3c1df61..7bc7e3e 100644 --- a/cmake/Sources.cmake +++ b/cmake/Sources.cmake @@ -8,6 +8,7 @@ set(VGUI2EXTENSION_SOURCES "${METAHOOK_SOURCE_PATH}/include/vgui_controls/MemoryBitmap.cpp" "${METAHOOK_SOURCE_PATH}/include/SourceSDK/tier0/vcrmode.cpp" "src/BaseUI.cpp" + "src/BuildModeDialogHooks.cpp" "src/ClientVGUI.cpp" "src/CounterStrikeViewport.cpp" "src/CareerProfileStorage.cpp" diff --git a/docs/en/gamedata.md b/docs/en/gamedata.md index 5eb7666..645976a 100644 --- a/docs/en/gamedata.md +++ b/docs/en/gamedata.md @@ -104,20 +104,30 @@ retain their native handlers. This fourth record is published in `v20261023b`; deploy the final DLL with that catalog or newer. `v20261023a` does not satisfy the extended manifest. +The native build editor DPI hooks require `v20261025b` or newer. They hook the +GameUI constructor and the editor/list vtables, retaining native editing, saving +and ownership. Only editor lists receive the row/column and scrollbar correction. +The native editor inherits Frame's scheme handler and already uses proportional +child fonts, so no shared Frame function is patched. Persisted window dimensions +remain pixels; only missing defaults and design constants are scaled. List item +IDs come from the two individually published `m_SortedItems` fields, without +casting native containers or the dialog to the SDK's private classes. + The declaration is [scripts/manifests/vgui2extension.json](../../scripts/manifests/vgui2extension.json). The list below follows its exact module, name and kind and has been checked against the -source calls. It contains **226 explicit records** across 5 modules, including the -initial `_0` records of **19 numbered patch sets**. Each numbered set can consume -additional records; 226 is not the total size of every installed catalog. +source calls. It contains **256 distinct explicit records** across 5 modules, +including the initial `_0` records of the conditional numbered patch sets +(23 declarations). Each numbered set can consume additional records; 256 is not +the total size of every installed catalog. | Module | Explicit entries | | --- | ---: | | `engine` | 14 | | `client` | 37 | -| `gameui` | 165 | +| `gameui` | 195 | | `serverbrowser` | 9 | | `vgui2` | 1 | -| Total | 226 | +| Total | 256 | A GameSymbol is identified by its module and name. Identical names in `engine`, `client`, `gameui` and `serverbrowser` refer to separate records. Conditions below describe the @@ -143,7 +153,7 @@ API 115's `mh_gamesymbol_t::vfuncIndex`. ### Manifest conditions -`All` is the manifest's top-level `gameVersions` list. `G1` through `G26` identify the +`All` is the manifest's top-level `gameVersions` list. `G1` through `G28` identify the current `conditionalGroups` entries in order. The `cl_time` exemption is explained under [Runtime conditions and optional records](#runtime-conditions-and-optional-records). @@ -175,7 +185,9 @@ current `conditionalGroups` entries in order. The `cl_time` exemption is explain | G23 | `hl-10210` | | G24 | `czero-10210`, `czero-8684` | | G25 | `hl-10210` | -| G26 | `hl-10210` | +| G26 | `hl-10210`, `hl-8684`, `cstrikegameui-8684` | +| G27 | `cstrikegameui-8684` | +| G28 | `cof-5936`, `cstrikegameui-8684`, `hl-10210`, `hl-3248`, `hl-3266`, `hl-3329`, `hl-3647`, `hl-4554`, `hl-6153`, `hl-8684`, `svencoop-10257`, `svencoop-8948` | ## Symbols by module @@ -249,7 +261,8 @@ Sources: [ClientVGUI.cpp](../../src/ClientVGUI.cpp) and [privatefuncs.cpp](../.. ### gameui -Source: [GameUI.cpp](../../src/GameUI.cpp). +Sources: [GameUI.cpp](../../src/GameUI.cpp) and +[BuildModeDialogHooks.cpp](../../src/BuildModeDialogHooks.cpp). | GameSymbol | Kind | Manifest condition | | --- | --- | --- | @@ -327,6 +340,22 @@ Source: [GameUI.cpp](../../src/GameUI.cpp). | `vgui2_Panel_SetMinimumSize_Const_callsite_0` | `patch` | G16 | | `vgui2_Panel_SetBounds_Const_callsite_0` | `patch` | G16 | | `vgui2_Panel_SetBounds_Const_callsite_0` | `patch` | G18 | +| `vgui2::BuildModeDialog` | `vtable` | G28 | +| `vgui2::BuildModeDialog::BuildModeDialog(vgui2::BuildGroup*)` | `function` | G28 | +| `vgui2::BuildModeDialog::PerformLayout()` | `virtualFunction` | G28 | +| `vgui2::BuildModeDialog::SetActiveControl(vgui2::Panel*)` | `virtualFunction` | G28 | +| `vgui2::BuildModeDialog.m_pAddNewControlCombo` | `structMember` | G28 | +| `vgui2::Frame::GetClientArea(int&, int&, int&, int&)` | `virtualFunction` | G28 | +| `vgui2::Panel::GetVPanel()` | `virtualFunction` | G28 | +| `vgui2::PanelListPanel` | `vtable` | G28 | +| `vgui2::PanelListPanel::PerformLayout()` | `virtualFunction` | G28 | +| `vgui2::PanelListPanel::GetItemCount()` | `virtualFunction` | G28 | +| `vgui2::PanelListPanel::GetItemLabel(int)` | `virtualFunction` | G28 | +| `vgui2::PanelListPanel::GetItemPanel(int)` | `virtualFunction` | G28 | +| `vgui2::PanelListPanel.m_SortedItems_m_pMemory` | `structMember` | G28 | +| `vgui2::PanelListPanel.m_SortedItems_m_Size` | `structMember` | G28 | +| `vgui2::PanelListPanel.m_iFirstColumnWidth` | `structMember` | G28 | +| `vgui2::PanelListPanel.m_iPanelBuffer` | `structMember` | G28 | ### serverbrowser diff --git a/docs/zh-CN/gamedata.md b/docs/zh-CN/gamedata.md index d26fa66..77ffd9a 100644 --- a/docs/zh-CN/gamedata.md +++ b/docs/zh-CN/gamedata.md @@ -84,19 +84,25 @@ CRT locale,也不经过原有的 31 字节截断。无效 UTF-8 或缺失本 保留待确认的 Career 选择;业务命令继续由原生处理。这第四项记录已在 `v20261023b` 发布;最终 DLL 应搭配该版本或更新 catalog,`v20261023a` 不满足扩展后的 manifest。 +原生构建编辑器 DPI hook 需要 `v20261025b` 或更新版本。它们 hook GameUI 构造入口和 +编辑器/列表虚表,保留原生编辑、保存和所有权行为;行列布局及滚动条修正只作用于编辑器列表。 +原生编辑器继承 Frame 的 scheme 处理,子控件已使用比例字体,因此不修改共享 Frame 函数。 +已保存的窗口尺寸保持像素单位,仅缩放缺失的默认尺寸和设计常量。列表 item ID 通过独立发布的 +两个 `m_SortedItems` 字段读取,不将原生容器或对话框强转为 SDK 私有类。 + 声明来源为 [scripts/manifests/vgui2extension.json](../../scripts/manifests/vgui2extension.json)。 以下清单保留 manifest 中的完整 module、名称与 kind,并已对照源码调用核对。 -共 5 个 module、**226 条显式记录**,其中包含 **19 组连续编号补丁**的首条 `_0` 记录。 -每组编号补丁还可能消费后续记录,因此 226 不是每份安装 catalog 的记录总数。 +共 5 个 module、**256 条不同的显式记录**,其中包含条件编号补丁组的首条 `_0` 记录 +(共 23 处声明)。每组编号补丁还可能消费后续记录,因此 256 不是每份安装 catalog 的记录总数。 | Module | 显式记录数 | | --- | ---: | | `engine` | 14 | | `client` | 37 | -| `gameui` | 165 | +| `gameui` | 195 | | `serverbrowser` | 9 | | `vgui2` | 1 | -| 合计 | 226 | +| 合计 | 256 | GameSymbol 由 module 与名称共同定位。`engine`、`client`、`gameui`、`serverbrowser` 中的同名符号是独立记录。下列条件表示 manifest 的 catalog 裁剪与校验范围; @@ -120,7 +126,7 @@ GameSymbol 由 module 与名称共同定位。`engine`、`client`、`gameui`、` ### Manifest 条件 -`All` 表示 manifest 顶层的 `gameVersions` 列表;`G1` 至 `G26` 按顺序对应当前 +`All` 表示 manifest 顶层的 `gameVersions` 列表;`G1` 至 `G28` 按顺序对应当前 `conditionalGroups` 的条目。`cl_time` 的豁免见[运行条件与可选记录](#运行条件与可选记录)。 | 条件 | Manifest 中的 gameVersions | @@ -151,7 +157,9 @@ GameSymbol 由 module 与名称共同定位。`engine`、`client`、`gameui`、` | G23 | `hl-10210` | | G24 | `czero-10210`, `czero-8684` | | G25 | `hl-10210` | -| G26 | `hl-10210` | +| G26 | `hl-10210`, `hl-8684`, `cstrikegameui-8684` | +| G27 | `cstrikegameui-8684` | +| G28 | `cof-5936`, `cstrikegameui-8684`, `hl-10210`, `hl-3248`, `hl-3266`, `hl-3329`, `hl-3647`, `hl-4554`, `hl-6153`, `hl-8684`, `svencoop-10257`, `svencoop-8948` | ## 按 module 列出的符号 @@ -225,7 +233,8 @@ GameSymbol 由 module 与名称共同定位。`engine`、`client`、`gameui`、` ### gameui -源码:[GameUI.cpp](../../src/GameUI.cpp)。 +源码:[GameUI.cpp](../../src/GameUI.cpp) 和 +[BuildModeDialogHooks.cpp](../../src/BuildModeDialogHooks.cpp)。 | GameSymbol | Kind | Manifest 条件 | | --- | --- | --- | @@ -303,6 +312,22 @@ GameSymbol 由 module 与名称共同定位。`engine`、`client`、`gameui`、` | `vgui2_Panel_SetMinimumSize_Const_callsite_0` | `patch` | G16 | | `vgui2_Panel_SetBounds_Const_callsite_0` | `patch` | G16 | | `vgui2_Panel_SetBounds_Const_callsite_0` | `patch` | G18 | +| `vgui2::BuildModeDialog` | `vtable` | G28 | +| `vgui2::BuildModeDialog::BuildModeDialog(vgui2::BuildGroup*)` | `function` | G28 | +| `vgui2::BuildModeDialog::PerformLayout()` | `virtualFunction` | G28 | +| `vgui2::BuildModeDialog::SetActiveControl(vgui2::Panel*)` | `virtualFunction` | G28 | +| `vgui2::BuildModeDialog.m_pAddNewControlCombo` | `structMember` | G28 | +| `vgui2::Frame::GetClientArea(int&, int&, int&, int&)` | `virtualFunction` | G28 | +| `vgui2::Panel::GetVPanel()` | `virtualFunction` | G28 | +| `vgui2::PanelListPanel` | `vtable` | G28 | +| `vgui2::PanelListPanel::PerformLayout()` | `virtualFunction` | G28 | +| `vgui2::PanelListPanel::GetItemCount()` | `virtualFunction` | G28 | +| `vgui2::PanelListPanel::GetItemLabel(int)` | `virtualFunction` | G28 | +| `vgui2::PanelListPanel::GetItemPanel(int)` | `virtualFunction` | G28 | +| `vgui2::PanelListPanel.m_SortedItems_m_pMemory` | `structMember` | G28 | +| `vgui2::PanelListPanel.m_SortedItems_m_Size` | `structMember` | G28 | +| `vgui2::PanelListPanel.m_iFirstColumnWidth` | `structMember` | G28 | +| `vgui2::PanelListPanel.m_iPanelBuffer` | `structMember` | G28 | ### serverbrowser diff --git a/memory/build_and_verification.md b/memory/build_and_verification.md index 4f77c37..32fe6fd 100644 --- a/memory/build_and_verification.md +++ b/memory/build_and_verification.md @@ -177,3 +177,13 @@ Chinese numeric-format follow-up: GetCurrentGameLanguage() values schinese/tchin - Verification: Windows x86 Release build, 66-file format-check, all 8 existing CTests and staged install passed. Live czero build 10210 at 1280x720 used a task-only empty-Maps mission pack to exercise the same no-triplet branch without removing stock data: schinese title/body rendered correctly (HalfLife44.tga), English title/body rendered correctly (HalfLife46.tga), and a fresh schinese process with both plugin language files absent retained literal English without displaying keys (HalfLife48.tga). OK dismissed the modal back to profile selection. This validates the native error branch, not a direct deleted-Normal.vdf reproduction; checksum failure and other engine builds were not separately exercised. - Cleanup: all task-owned game processes exited cleanly. Temporary fixture, profile and screenshots were moved into ignored `build/issue952-runtime/`; 21 preexisting game files were restored and SHA256-verified. Evidence screenshots remain under `build/issue952-runtime/screenshots/`. - Scope: localization only; mission-data validation, fallback selection, modal behavior and storage are unchanged. Existing gamedata sync was disabled in the local cache; no claim is made about a newly downloaded online catalog. + +## Issue 954: native BuildModeDialog high-DPI layout (2026-10-25) + +- Trigger: czero engine10210 at2560x1440; native editor stayed300x400 while caption/scrollbar and scheme fonts scaled. The Options editor comes from GameUI, with an empty panel name and `vgui2` RTTI, rather than the plugin's compiled SDK editor. +- Root cause/constraints: native constructor and outer PerformLayout retain literal pixel dimensions/margins; its PanelListPanel additionally uses fixed horizontal insets, overflows its embedded content width and disables its scrollbar. The native editor inherits Frame::ApplySchemeSettings; SDK-only navigation/localization controls and the newer SDK scheme override are absent. Replacing SDK objects would cross incompatible private class/BuildGroup layouts. +- Implementation: BuildModeDialogHooks.{h,cpp} integrates with GameUI fill/install/uninstall. Resolve constructor, editor/list tables, slots and individually published fields through gamedata. Hook constructor for missing default dimensions/minimum size while preserving native pixel user config; hook only editor PerformLayout/SetActiveControl table slots and list PerformLayout filtered by exact parent editor table. Original layout runs first; row sizing runs before native list range calculation, then editor-specific row/scrollbar geometry accounts for actual insets. VPANEL/IPanel access avoids SDK private object/container casts, child-index guesses and TLS. Native proportional fonts remain native; measure font heights once as actual pixels. Failed hook installation rolls back all four hooks; shutdown removes them. +- Catalog: public GoldSrc_VibeSignatures v20261025b,16 consumed gameui records in G28 across12 Windows GameUI identities; two m_SortedItems field offsets provide row IDs, with native GetItemLabel/GetItemPanel. Frame::ApplySchemeSettings is not consumed because no shared base hook is needed. Manifest/bilingual inventory synchronized; current distinct explicit inventory256 (gameui195). Actual online sync/prune and validate-only/standalone validation passed for23 snapshots. +- Verification: MSVC x86 Release build,9 CTests (new geometry bounds/font/resize/repeated-calculation tests),11 Python tests, pinned70-file format-check and git diff --check passed. Live final layout:720p300x400/row24,1080p450x600/row36,1440p600x800/row48, no horizontal row overflow or clipped scrollbar. Native mouse resized600x800 to780x940, moved to[780,220], and Exit/reopen retained that exact pixel geometry. Selecting Options ApplyButton rebuilt rows at48px; native button drag/save wrote xpos614/ypos391/wide72/tall24 for a144x48px button, and subsequent launches loaded the saved resource. Scroll drag reached the last rows. Final source cleanup only named the same design constants and removed a duplicate idempotent row-sizing pass; compilation/tests reran afterward, the full live matrix preceded that cleanup. +- Verification limits: activation used the existing Options BuildGroup SetEnabled on the main RunFrame thread, then debugger detachment; physical Shift+Ctrl+Alt+B routing remains unverified because CLI injection did not trigger creation. Requested3840x2160 was clamped to3440x1440, so4K is not runtime verified. Existing DpiManagerInternal::InitClient forces support true for HL25 after command-line flags; -no_high_dpi therefore did not test an actual disabled-DPI path. Other builds/custom schemes have catalog coverage only. +- Cleanup/evidence: ten task-owned processes exited normally via RCON code0; no debugger sessions remain.72 original DLL/PDB/catalog/config/resource/appid files restored with SHA256 verification;11 task screenshots moved into ignored build/issue954-fix-runtime/screenshots. Evidence includes installed-hooks.txt,matrix.json,result.md,saved-options-dialog.res and restoration.json. Do not publish config backups or screenshots automatically. Original game deployment is restored. diff --git a/scripts/manifests/vgui2extension.json b/scripts/manifests/vgui2extension.json index 9b341b7..546e50d 100644 --- a/scripts/manifests/vgui2extension.json +++ b/scripts/manifests/vgui2extension.json @@ -836,6 +836,42 @@ "prefix": "vgui2_Panel_SetBounds_Const_callsite" } ] + }, + { + "when": [ + "cof-5936", + "cstrikegameui-8684", + "hl-10210", + "hl-3248", + "hl-3266", + "hl-3329", + "hl-3647", + "hl-4554", + "hl-6153", + "hl-8684", + "svencoop-10257", + "svencoop-8948" + ], + "symbols": { + "gameui": { + "vgui2::BuildModeDialog": "vtable", + "vgui2::BuildModeDialog::BuildModeDialog(vgui2::BuildGroup*)": "function", + "vgui2::BuildModeDialog::PerformLayout()": "virtualFunction", + "vgui2::BuildModeDialog::SetActiveControl(vgui2::Panel*)": "virtualFunction", + "vgui2::BuildModeDialog.m_pAddNewControlCombo": "structMember", + "vgui2::Frame::GetClientArea(int&, int&, int&, int&)": "virtualFunction", + "vgui2::Panel::GetVPanel()": "virtualFunction", + "vgui2::PanelListPanel": "vtable", + "vgui2::PanelListPanel::PerformLayout()": "virtualFunction", + "vgui2::PanelListPanel::GetItemCount()": "virtualFunction", + "vgui2::PanelListPanel::GetItemLabel(int)": "virtualFunction", + "vgui2::PanelListPanel::GetItemPanel(int)": "virtualFunction", + "vgui2::PanelListPanel.m_SortedItems_m_pMemory": "structMember", + "vgui2::PanelListPanel.m_SortedItems_m_Size": "structMember", + "vgui2::PanelListPanel.m_iFirstColumnWidth": "structMember", + "vgui2::PanelListPanel.m_iPanelBuffer": "structMember" + } + } } ] } diff --git a/src/BuildModeDialogHooks.cpp b/src/BuildModeDialogHooks.cpp new file mode 100644 index 0000000..86579a7 --- /dev/null +++ b/src/BuildModeDialogHooks.cpp @@ -0,0 +1,380 @@ +#include +#include +#include +#include +#include + +#include "plugins.h" +#include "privatefuncs.h" +#include "DpiManagerInternal.h" +#include "BuildModeDialogHooks.h" +#include "BuildModeDialogLayout.h" + +namespace +{ +using NativePanel = void*; +using LayoutFn = void(__fastcall*)(NativePanel, int); +using ConstructorFn = NativePanel(__fastcall*)(NativePanel, int, void*); +using ActiveControlFn = void(__fastcall*)(NativePanel, int, NativePanel); + +struct Symbols +{ + void** editorTable = nullptr; + void** listTable = nullptr; + DWORD layoutSlot = 0, activeSlot = 0, listLayoutSlot = 0; + DWORD getVPanelSlot = 0, clientAreaSlot = 0; + DWORD itemCountSlot = 0, itemLabelSlot = 0, itemPanelSlot = 0; + DWORD addComboOffset = 0, sortedMemoryOffset = 0, sortedSizeOffset = 0; + DWORD columnOffset = 0, bufferOffset = 0; + ConstructorFn construct = nullptr; +} g_symbols; + +hook_t* g_constructorHook = nullptr; +hook_t* g_layoutHook = nullptr; +hook_t* g_activeHook = nullptr; +hook_t* g_listLayoutHook = nullptr; +LayoutFn g_layout = nullptr; +LayoutFn g_listLayout = nullptr; +ActiveControlFn g_setActive = nullptr; + +template +T& Member(NativePanel panel, DWORD offset) +{ + return *reinterpret_cast(static_cast(panel) + offset); +} + +template +Fn Method(NativePanel panel, DWORD slot) +{ + return reinterpret_cast((*static_cast(panel))[slot]); +} + +vgui::VPANEL GetVPanel(NativePanel panel) +{ + return panel ? Method(panel, g_symbols.getVPanelSlot)(panel, 0) : 0; +} + +NativePanel Client(vgui::VPANEL panel) +{ + return panel ? vgui::ipanel()->Client(panel) : nullptr; +} + +bool IsEditor(NativePanel panel) +{ + return panel && *static_cast(panel) == g_symbols.editorTable; +} + +bool ShouldScale(vgui::VPANEL panel) +{ + return panel && DpiManagerInternal()->IsHighDpiSupportEnabled() && vgui::ipanel()->IsProportional(panel); +} + +vgui::VPANEL FindPanel(vgui::VPANEL parent, const char* name) +{ + //Only direct editor/list children are needed. Do not accidentally find a + //property's nested dropdown or a similarly named panel in the edited UI. + auto* panels = vgui::ipanel(); + for (int i = 0; i < panels->GetChildCount(parent); ++i) + { + const auto child = panels->GetChild(parent, i); + if (!strcmp(panels->GetName(child), name)) + return child; + } + return 0; +} + +auto Metrics(vgui::VPANEL editor) +{ + const auto scheme = vgui::ipanel()->GetScheme(editor); + const auto scale = [=](int value) { + return g_pVGuiSchemeManager2->GetProportionalScaledValueEx(scheme, value); + }; + int fontTall = 0; + if (auto* settings = g_pVGuiSchemeManager2->GetIScheme(scheme)) + { + //The native editor inherits Frame's scheme handler. Its children already + //use proportional scheme fonts; measure them without changing shared fonts. + for (const auto* name : {"Default", "DefaultSmall", "DefaultVerySmall"}) + { + const auto font = settings->GetFont(name, true); + if (font) + fontTall = (std::max)(fontTall, vgui::surface()->GetFontTall(font)); + } + } + return BuildModeLayout::Measure(scale, fontTall); +} + +void SetBounds(vgui::VPANEL panel, BuildModeLayout::Rect bounds) +{ + auto* panels = vgui::ipanel(); + int x, y, wide, tall; + panels->GetPos(panel, x, y); + panels->GetSize(panel, wide, tall); + if (x != bounds.x || y != bounds.y) + panels->SetPos(panel, bounds.x, bounds.y); + if (wide != bounds.wide || tall != bounds.tall) + panels->SetSize(panel, bounds.wide, bounds.tall); +} + +void SetRowHeight(vgui::VPANEL panel, int rowTall) +{ + if (!panel) + return; + int wide, tall; + vgui::ipanel()->GetSize(panel, wide, tall); + if (tall != rowTall) + vgui::ipanel()->SetSize(panel, wide, rowTall); +} + +bool GetRows(NativePanel list, int& count, const int*& ids) +{ + count = Method(list, g_symbols.itemCountSlot)(list, 0); + const int sortedCount = Member(list, g_symbols.sortedSizeOffset); + ids = Member(list, g_symbols.sortedMemoryOffset); + //Only read the two published vector fields. Neither the SDK CUtlVector nor + //the native linked-list node layout is part of this consumer's ABI. + return count >= 0 && sortedCount == count && (count == 0 || ids); +} + +void GetRow(NativePanel list, int id, vgui::VPANEL& label, vgui::VPANEL& edit) +{ + using GetItemFn = NativePanel(__fastcall*)(NativePanel, int, int); + label = GetVPanel(Method(list, g_symbols.itemLabelSlot)(list, 0, id)); + edit = GetVPanel(Method(list, g_symbols.itemPanelSlot)(list, 0, id)); +} + +void SizeRows(NativePanel list, const BuildModeLayout::Metrics& m) +{ + int count; + const int* ids; + if (!GetRows(list, count, ids)) + return; + Member(list, g_symbols.columnOffset) = m.columnWide; + Member(list, g_symbols.bufferOffset) = m.cellGap; + for (int row = 0; row < count; ++row) + { + vgui::VPANEL label, edit; + GetRow(list, ids[row], label, edit); + SetRowHeight(label, m.rowTall); + SetRowHeight(edit, m.rowTall); + } +} + +void LayoutEditor(NativePanel self) +{ + const auto editor = GetVPanel(self); + if (!ShouldScale(editor)) + return; + + struct Control + { + const char* name; + BuildModeLayout::Rect BuildModeLayout::Layout::* bounds; + vgui::VPANEL panel = 0; + } controls[] = { + {"FileSelectionCombo", &BuildModeLayout::Layout::file}, + {"StatusLabel", &BuildModeLayout::Layout::status}, + {"BuildModeControls", &BuildModeLayout::Layout::list}, + {"Divider", &BuildModeLayout::Layout::divider}, + {"SaveButton", &BuildModeLayout::Layout::save}, + {"ExitButton", &BuildModeLayout::Layout::exit}, + {"ApplyButton", &BuildModeLayout::Layout::apply}, + {"VarsButton", &BuildModeLayout::Layout::variables}, + {"DeletePanelButton", &BuildModeLayout::Layout::remove}, + }; + for (auto& control : controls) + { + control.panel = FindPanel(editor, control.name); + if (!control.panel) + return; + } + const auto add = GetVPanel(Member(self, g_symbols.addComboOffset)); + if (!add || vgui::ipanel()->GetParent(add) != editor) + return; + + BuildModeLayout::Rect client{}; + using ClientAreaFn = void(__fastcall*)(NativePanel, int, int&, int&, int&, int&); + Method(self, g_symbols.clientAreaSlot)(self, 0, client.x, client.y, client.wide, client.tall); + BuildModeLayout::Layout layout{}; + if (!BuildModeLayout::Calculate(client, Metrics(editor), layout)) + return; + //Resolve the whole plan before applying it. All coordinates are local pixels. + for (const auto& control : controls) + SetBounds(control.panel, layout.*(control.bounds)); + SetBounds(add, layout.add); +} + +void __fastcall PerformLayout(NativePanel self, int dummy) +{ + g_layout(self, dummy); + LayoutEditor(self); +} + +void __fastcall ListPerformLayout(NativePanel self, int dummy) +{ + const auto list = GetVPanel(self); + const auto editor = vgui::ipanel()->GetParent(list); + const bool fix = IsEditor(Client(editor)) && ShouldScale(editor); + if (fix) + SizeRows(self, Metrics(editor)); + //The native list owns scroll range, slider state and the embedded panel. + g_listLayout(self, dummy); + if (!fix) + return; + + const auto embedded = FindPanel(list, "PanelListEmbedded"); + int count; + const int* ids; + if (!embedded || !GetRows(self, count, ids)) + return; + const auto m = Metrics(editor); + int wide, tall; + vgui::ipanel()->GetSize(embedded, wide, tall); + int listWide, listTall; + vgui::ipanel()->GetSize(list, listWide, listTall); + int left, top, right, bottom; + vgui::ipanel()->GetInset(list, left, top, right, bottom); + const int clientWide = listWide - left - right; + const int clientTall = listTall - top - bottom; + //The native list disables its scrollbar even when the content overflows. + //Keep the native range/slider calculations, but allow normal dragging. + if (const auto scrollbar = FindPanel(list, "PanelListPanelVScroll")) + { + int barWide, barTall; + vgui::ipanel()->GetSize(scrollbar, barWide, barTall); + if (clientWide <= barWide || clientTall <= 0) + return; + SetBounds(scrollbar, {clientWide - barWide, 0, barWide, clientTall}); + vgui::ipanel()->SetEnabled(scrollbar, tall > clientTall); + int x, y; + vgui::ipanel()->GetPos(embedded, x, y); + wide = clientWide - barWide; + SetBounds(embedded, {0, y, wide, tall}); + } + const int editX = m.margin + m.columnWide + m.cellGap; + const int editWide = wide - editX - m.margin; + if (editWide <= 0) + return; + //Native layout still uses fixed horizontal insets and can extend an editor + //past its embedded panel. Reposition every row, including rows off screen, + //in content coordinates; retain the native embedded panel's scroll offset. + int y = m.cellGap; + for (int row = 0; row < count; ++row) + { + vgui::VPANEL label, edit; + GetRow(self, ids[row], label, edit); + if (label) + SetBounds(label, {m.margin, y, m.columnWide, m.rowTall}); + if (edit) + SetBounds(edit, {editX, y, editWide, m.rowTall}); + y += m.rowTall + m.cellGap; + } +} + +void __fastcall SetActiveControl(NativePanel self, int dummy, NativePanel selected) +{ + g_setActive(self, dummy, selected); + const auto editor = GetVPanel(self); + if (!ShouldScale(editor)) + return; + const auto list = FindPanel(editor, "BuildModeControls"); + if (auto* nativeList = Client(list)) + { + //Do not wait for the outer dialog to invalidate: native SetActiveControl + //can rebuild the property rows without changing the frame's dimensions. + ListPerformLayout(nativeList, 0); + } +} + +NativePanel __fastcall ConstructEditor(NativePanel self, int dummy, void* group) +{ + const auto result = g_symbols.construct(self, dummy, group); + const auto editor = GetVPanel(result); + if (!ShouldScale(editor)) + return result; + + const auto scheme = vgui::ipanel()->GetScheme(editor); + const auto scale = [=](int value) { + return g_pVGuiSchemeManager2->GetProportionalScaledValueEx(scheme, value); + }; + //Native LoadUserConfig has already run. Persisted dimensions are pixels; + //scale only missing defaults, not an existing size on each editor reopening. + auto* config = vgui::system()->GetUserConfigFileData("BuildModeDialog", 0); + const bool savedWide = config && config->GetInt("wide", 0) > 0; + const bool savedTall = config && config->GetInt("tall", 0) > 0; + const bool savedPosition = config && config->FindKey("xpos", false) && config->FindKey("ypos", false); + int workX, workY, workWide, workTall; + vgui::surface()->GetWorkspaceBounds(workX, workY, workWide, workTall); + if (workWide <= 0 || workTall <= 0) + return result; + const int minWide = (std::min)(scale(BuildModeLayout::DefaultWide), workWide); + const int minTall = (std::min)(scale(BuildModeLayout::MinimumTall), workTall); + vgui::ipanel()->SetMinimumSize(editor, minWide, minTall); + int x, y, wide, tall; + vgui::ipanel()->GetPos(editor, x, y); + vgui::ipanel()->GetSize(editor, wide, tall); + wide = std::clamp(savedWide ? wide : scale(BuildModeLayout::DefaultWide), minWide, workWide); + tall = std::clamp(savedTall ? tall : scale(BuildModeLayout::DefaultTall), minTall, workTall); + if (!savedPosition) + { + x = workX + (workWide - wide) / 2; + y = workY + (workTall - tall) / 2; + } + SetBounds(editor, {std::clamp(x, workX, workX + workWide - wide), std::clamp(y, workY, workY + workTall - tall), wide, tall}); + return result; +} +} // namespace + +void BuildModeDialog_FillAddress() +{ + const auto base = g_GameUIDllInfo.ImageBase; + g_symbols.editorTable = static_cast(GamedataResolvePtrIfAvailable(base, "gameui", "vgui2::BuildModeDialog", MH_GAMESYMBOL_KIND_VTABLE)); + if (!g_symbols.editorTable) + return; + g_symbols.listTable = static_cast(GamedataResolvePtr(base, "gameui", "vgui2::PanelListPanel", MH_GAMESYMBOL_KIND_VTABLE)); + g_symbols.construct = reinterpret_cast(GamedataResolvePtr(base, "gameui", "vgui2::BuildModeDialog::BuildModeDialog(vgui2::BuildGroup*)", MH_GAMESYMBOL_KIND_FUNCTION)); + g_symbols.layoutSlot = GamedataResolveVFuncIndex(base, "gameui", "vgui2::BuildModeDialog::PerformLayout()"); + g_symbols.activeSlot = GamedataResolveVFuncIndex(base, "gameui", "vgui2::BuildModeDialog::SetActiveControl(vgui2::Panel*)"); + g_symbols.listLayoutSlot = GamedataResolveVFuncIndex(base, "gameui", "vgui2::PanelListPanel::PerformLayout()"); + g_symbols.getVPanelSlot = GamedataResolveVFuncIndex(base, "gameui", "vgui2::Panel::GetVPanel()"); + g_symbols.clientAreaSlot = GamedataResolveVFuncIndex(base, "gameui", "vgui2::Frame::GetClientArea(int&, int&, int&, int&)"); + g_symbols.itemCountSlot = GamedataResolveVFuncIndex(base, "gameui", "vgui2::PanelListPanel::GetItemCount()"); + g_symbols.itemLabelSlot = GamedataResolveVFuncIndex(base, "gameui", "vgui2::PanelListPanel::GetItemLabel(int)"); + g_symbols.itemPanelSlot = GamedataResolveVFuncIndex(base, "gameui", "vgui2::PanelListPanel::GetItemPanel(int)"); + g_symbols.addComboOffset = GamedataResolveStructMember(base, "gameui", "vgui2::BuildModeDialog.m_pAddNewControlCombo"); + g_symbols.sortedMemoryOffset = GamedataResolveStructMember(base, "gameui", "vgui2::PanelListPanel.m_SortedItems_m_pMemory"); + g_symbols.sortedSizeOffset = GamedataResolveStructMember(base, "gameui", "vgui2::PanelListPanel.m_SortedItems_m_Size"); + g_symbols.columnOffset = GamedataResolveStructMember(base, "gameui", "vgui2::PanelListPanel.m_iFirstColumnWidth"); + g_symbols.bufferOffset = GamedataResolveStructMember(base, "gameui", "vgui2::PanelListPanel.m_iPanelBuffer"); +} + +void BuildModeDialog_InstallHooks() +{ + if (!g_symbols.editorTable || g_constructorHook) + return; + g_constructorHook = g_pMetaHookAPI->InlineHook(reinterpret_cast(g_symbols.construct), ConstructEditor, reinterpret_cast(&g_symbols.construct)); + g_layoutHook = g_pMetaHookAPI->VFTHookEx(g_symbols.editorTable, g_symbols.layoutSlot, PerformLayout, reinterpret_cast(&g_layout)); + g_activeHook = g_pMetaHookAPI->VFTHookEx(g_symbols.editorTable, g_symbols.activeSlot, SetActiveControl, reinterpret_cast(&g_setActive)); + g_listLayoutHook = g_pMetaHookAPI->VFTHookEx(g_symbols.listTable, g_symbols.listLayoutSlot, ListPerformLayout, reinterpret_cast(&g_listLayout)); + if (!g_constructorHook || !g_layoutHook || !g_activeHook || !g_listLayoutHook) + { + BuildModeDialog_UninstallHooks(); + Sys_Error("Could not install GameUI build editor DPI hooks."); + } +} + +void BuildModeDialog_UninstallHooks() +{ + for (auto** hook : {&g_listLayoutHook, &g_activeHook, &g_layoutHook, &g_constructorHook}) + { + if (*hook) + { + g_pMetaHookAPI->UnHook(*hook); + *hook = nullptr; + } + } + g_layout = nullptr; + g_listLayout = nullptr; + g_setActive = nullptr; + g_symbols = {}; +} diff --git a/src/BuildModeDialogHooks.h b/src/BuildModeDialogHooks.h new file mode 100644 index 0000000..d464904 --- /dev/null +++ b/src/BuildModeDialogHooks.h @@ -0,0 +1,5 @@ +#pragma once + +void BuildModeDialog_FillAddress(); +void BuildModeDialog_InstallHooks(); +void BuildModeDialog_UninstallHooks(); diff --git a/src/BuildModeDialogLayout.h b/src/BuildModeDialogLayout.h new file mode 100644 index 0000000..cddd6b7 --- /dev/null +++ b/src/BuildModeDialogLayout.h @@ -0,0 +1,69 @@ +#pragma once + +#include + +namespace BuildModeLayout +{ +//Defaults of the native GameUI editor, rather than the newer SDK dialog. +constexpr int DefaultWide = 300; +constexpr int DefaultTall = 400; +constexpr int MinimumTall = 256; + +struct Rect +{ + int x, y, wide, tall; +}; + +struct Metrics +{ + int margin, gap, groupGap, rowTall, dividerTall; + int buttonWide, variablesWide, addWide, columnWide, cellGap; +}; + +template +Metrics Measure(Scale scale, int fontTall) +{ + constexpr Metrics design{8, 6, 8, 24, 2, 64, 72, 116, 100, 10}; + constexpr int fontPadding = 6; + //Font measurements and client bounds are already in pixels. + return {scale(design.margin), scale(design.gap), scale(design.groupGap), + (std::max)(scale(design.rowTall), fontTall + scale(fontPadding)), + (std::max)(1, scale(design.dividerTall)), scale(design.buttonWide), + scale(design.variablesWide), scale(design.addWide), scale(design.columnWide), scale(design.cellGap)}; +} + +struct Layout +{ + Rect file, status, list, divider, save, exit, apply, variables, remove, add; +}; + +inline bool Calculate(Rect client, const Metrics& m, Layout& out) +{ + const int left = client.x + m.margin; + const int wide = client.wide - 2 * m.margin; + const int bottom = client.y + client.tall - m.margin; + const int toolsWide = m.variablesWide + m.buttonWide + m.addWide + 2 * m.gap; + const int actionsWide = 3 * m.buttonWide + 2 * m.gap; + if (m.rowTall <= 0 || wide < (std::max)(toolsWide, actionsWide)) + return false; + + Layout result{}; + result.file = {left, client.y + m.margin, wide, m.rowTall}; + result.status = {left, result.file.y + m.rowTall + m.gap, wide, m.rowTall}; + result.apply = {left + wide - m.buttonWide, bottom - m.rowTall, m.buttonWide, m.rowTall}; + result.exit = {result.apply.x - m.gap - m.buttonWide, result.apply.y, m.buttonWide, m.rowTall}; + result.save = {result.exit.x - m.gap - m.buttonWide, result.apply.y, m.buttonWide, m.rowTall}; + const int toolsY = result.apply.y - m.groupGap - m.rowTall; + result.variables = {left, toolsY, m.variablesWide, m.rowTall}; + result.remove = {left + m.variablesWide + m.gap, toolsY, m.buttonWide, m.rowTall}; + result.add = {result.remove.x + m.buttonWide + m.gap, toolsY, m.addWide, m.rowTall}; + result.divider = {left, toolsY - m.groupGap - m.dividerTall, wide, m.dividerTall}; + const int listY = result.status.y + m.rowTall + m.gap; + result.list = {left, listY, wide, result.divider.y - m.groupGap - listY}; + if (result.list.tall < m.rowTall) + return false; + + out = result; + return true; +} +} // namespace BuildModeLayout diff --git a/src/GameUI.cpp b/src/GameUI.cpp index 3469abf..8827636 100644 --- a/src/GameUI.cpp +++ b/src/GameUI.cpp @@ -24,6 +24,7 @@ #include "CareerProfileStorageHooks.h" #include "DefaultServerName.h" #include "ServerBrowserHistory.h" +#include "BuildModeDialogHooks.h" static hook_t* g_phook_ServerBrowser_Panel_Init = NULL; static hook_t* g_phook_ServerBrowser_KeyValues_LoadFromFile = NULL; @@ -2841,6 +2842,7 @@ void GameUI_FillAddress(void) GameUI_FillAddress_CareerPlayerName(g_GameUIDllInfo); GameUI_FillAddress_DefaultServerName(g_GameUIDllInfo); CareerStorage_FillAddress(); + BuildModeDialog_FillAddress(); } bool GameUI_HasExclusiveInput() @@ -3061,10 +3063,12 @@ void GameUI_InstallHooks(void) } GameUI_PatchPanelSize(); + BuildModeDialog_InstallHooks(); } void GameUI_UninstallHooks(void) { + BuildModeDialog_UninstallHooks(); CareerStorage_UninstallHooks(); VGUI2ExtensionInternal()->UnregisterKeyValuesCallbacks(&s_ContentControlKeyValuesCallbacks); diff --git a/tests/buildmode_layout_tests.cpp b/tests/buildmode_layout_tests.cpp new file mode 100644 index 0000000..23a2706 --- /dev/null +++ b/tests/buildmode_layout_tests.cpp @@ -0,0 +1,61 @@ +#include "../src/BuildModeDialogLayout.h" +#include + +using namespace BuildModeLayout; + +static void CheckBounds(Rect client, const Layout& l) +{ + for (auto r : {l.file, l.status, l.list, l.divider, l.save, l.exit, l.apply, l.variables, l.remove, l.add}) + { + assert(client.x <= r.x && client.y <= r.y); + assert(r.wide > 0 && r.tall > 0); + assert(r.x + r.wide <= client.x + client.wide); + assert(r.y + r.tall <= client.y + client.tall); + } + assert(l.file.y + l.file.tall <= l.status.y); + assert(l.status.y + l.status.tall <= l.list.y); + assert(l.list.y + l.list.tall <= l.divider.y); + assert(l.divider.y + l.divider.tall <= l.variables.y); + assert(l.variables.y + l.variables.tall <= l.save.y); + assert(l.variables.x + l.variables.wide <= l.remove.x); + assert(l.remove.x + l.remove.wide <= l.add.x); + assert(l.save.x + l.save.wide <= l.exit.x); + assert(l.exit.x + l.exit.wide <= l.apply.x); +} + +int main() +{ + //Use actual pixel client rectangles with an independently scaled caption. + //The same design must fit the default and minimum editor sizes at each scale. + for (int scale : {1, 2, 3}) + { + const auto m = Measure([=](int value) { return value * scale; }, 14 * scale); + for (int tall : {256, 400, 600}) + { + Rect client{5 * scale, 28 * scale, 290 * scale, (tall - 33) * scale}; + Layout layout{}; + assert(Calculate(client, m, layout)); + CheckBounds(client, layout); + //Repeated native invalidations must not accumulate scaling. + Layout repeated{}; + assert(Calculate(client, m, repeated)); + assert(layout.list.x == repeated.list.x && layout.list.tall == repeated.list.tall); + assert(layout.apply.x == repeated.apply.x && layout.apply.y == repeated.apply.y); + } + } + + //A measured 60px font is not multiplied by the proportional scale again. + const auto largeFont = Measure([](int value) { return value * 2; }, 60); + assert(72 == largeFont.rowTall); + Layout l{}; + assert(Calculate({10, 56, 580, 734}, largeFont, l)); + CheckBounds({10, 56, 580, 734}, l); + + //Reject an incomplete plan before mutating any controls in a tiny client area. + const auto normal = Measure([](int value) { return value; }, 14); + Layout sentinel{}; + sentinel.list = {1, 2, 3, 4}; + assert(!Calculate({0, 0, 100, 400}, normal, sentinel)); + assert(!Calculate({0, 0, 300, 100}, normal, sentinel)); + assert(4 == sentinel.list.tall); +}