diff --git a/CMakeLists.txt b/CMakeLists.txt index 35b8051..4719a57 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -81,13 +81,15 @@ 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 buildmode_layout_tests career_end_menu_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 career_end_menu_tests border_abi_tests) add_executable(${test} "tests/${test}.cpp") target_compile_features(${test} PRIVATE cxx_std_17) target_compile_options(${test} PRIVATE /W4 /WX /UNDEBUG) add_test(NAME ${test} COMMAND ${test}) endforeach() target_sources(career_profile_storage_tests PRIVATE src/CareerProfileStorage.cpp) + target_include_directories(border_abi_tests PRIVATE + "${METAHOOK_SOURCE_PATH}/include/Interface" "${METAHOOK_SOURCE_PATH}/include/VGUI") endif() # The aggregate solution owns its single LaunchGame target. diff --git a/memory/build_and_verification.md b/memory/build_and_verification.md index 4ce1314..e67982d 100644 --- a/memory/build_and_verification.md +++ b/memory/build_and_verification.md @@ -6,6 +6,16 @@ permalink: vgui2extension/build-and-verification # Build and verification +## Zombie Panic missing images and border ABI (2026-11-07) + +- Trigger: zp 10210 constructs its scoreboard using `GetImage("ui/icons/scoreboard/icon_dono0", false)->SetSize(32, 32)`, although that image is absent from the installed resources. +- Root cause: native VGUI returns a Bitmap for the missing image, but our scheme manager deleted invalid Bitmaps and returned NULL. The client's unchecked call then dereferenced zero. Restoring the native getter in a diagnostic process bypassed this failure and exposed a separate `IBorder` virtual-slot mismatch. +- Fix: cache and return invalid Bitmaps, preserving native missing-image behavior; their existing `Paint` implementation safely skips invalid textures. Remove `ParseSideSettings` from the MetaHook public `IBorder` interface so native slot 6/7 dispatch reaches `GetName`/`SetName`; keep parsing as a private nonvirtual `Border` helper. +- Regression: `border_abi_tests` calls slots 6/7 with the native calling convention, fails on the old header, and passes on the fixed header. Aggregate Debug/Win32 build, all 11 plugin CTests, and pinned formatting checks for both components passed. +- Runtime verification: deployed the rebuilt aggregate to Zombie Panic Mod and used CDB without runtime patches. The missing image returned a VGUI2Extension Bitmap and the original client `Start()` returned successfully. Both prior crash paths were traversed without access violations or runtime stack-check failures. +- Limit: halflife-mcp startup then stopped at a different Renderer error: `GameStudioRenderer_StudioDrawModel` is missing from the matched client gamedata snapshot. No successful main-menu/map/visual regression is claimed. Test processes exited. +- Evidence: aggregate `build/x86/Debug/deployment/zp-fixed-start-verification.log` and the game's `fatalerror-20261107-192620-718-16304.log`. Applies to scheme replacements serving native GoldSrc clients, not only zp. + ## Build structure The root CMake uses MSVC x86, C++20, Debug/Release, a static CRT. diff --git a/src/Scheme2.cpp b/src/Scheme2.cpp index a8f4a00..b7da1e7 100644 --- a/src/Scheme2.cpp +++ b/src/Scheme2.cpp @@ -1114,12 +1114,8 @@ IImage* CSchemeManager::GetImage(const char* imageName, bool hardwareFiltered) return NULL; } - if (imageName[0] && !pBitmap->IsValid()) - { - delete pBitmap; - return NULL; - } - + // Native VGUI returns a Bitmap even when the texture is missing. Clients may + // use it without a null check; invalid Bitmaps already skip painting safely. searchBitmap.bitmap = pBitmap; m_Bitmaps.Insert(searchBitmap); return pBitmap; diff --git a/tests/border_abi_tests.cpp b/tests/border_abi_tests.cpp new file mode 100644 index 0000000..efb47bb --- /dev/null +++ b/tests/border_abi_tests.cpp @@ -0,0 +1,66 @@ +#include + +#include +#include +#include +#include + +class BorderProbe : public vgui::IBorder +{ +public: + void Paint(vgui::VPANEL) override {} + void Paint(int, int, int, int) override {} + void Paint(int, int, int, int, int, int, int) override {} + void SetInset(int, int, int, int) override {} + void GetInset(int&, int&, int&, int&) override {} + void ApplySchemeSettings(vgui::IScheme*, KeyValues*) override {} + + // If this helper becomes virtual again, native GetName dispatch must fail. + void ParseSideSettings(int, KeyValues*, vgui::IScheme*) + { + std::fputs("Native IBorder dispatch reached ParseSideSettings\n", stderr); + std::exit(EXIT_FAILURE); + } + + const char* GetName() override + { + ++getNameCalls; + return name; + } + + void SetName(const char* value) override + { + ++setNameCalls; + name = value; + } + + backgroundtype_e GetBackgroundType() override { return BACKGROUND_FILLED; } + bool PaintFirst() override { return false; } + + const char* name = nullptr; + int getNameCalls = 0; + int setNameCalls = 0; +}; + +int main() +{ + // GoldSrc clients call these slots using the native IBorder ABI. + constexpr std::size_t NativeGetNameSlot = 6; + constexpr std::size_t NativeSetNameSlot = 7; + using GetNameFn = const char*(__fastcall*)(void*, int); + using SetNameFn = void(__fastcall*)(void*, int, const char*); + + BorderProbe border; + auto vtable = *reinterpret_cast(&border); + auto getName = reinterpret_cast(vtable[NativeGetNameSlot]); + auto setName = reinterpret_cast(vtable[NativeSetNameSlot]); + + assert(nullptr == getName(&border, 0)); + assert(1 == border.getNameCalls); + + const char name[] = "ScoreboardBorder"; + setName(&border, 0, name); + assert(1 == border.setNameCalls); + assert(name == getName(&border, 0)); + assert(2 == border.getNameCalls); +}