feat: IW3 menu dumping and loading (#909)

* feat: IW3 menu dumping

* fix: IW3 menu dumper preserve menu ownerdraw flag masks

* fix: IW3 menu dumper preserve numeric script arguments

Emit numeric tokens without quotes so the linker keeps colour arguments separate.

Before: `"0.1" "0.1" "0.12" "0.5"` becomes `"0.10.10.120.5"`
After: `0.1 0.1 0.12 0.5`

This fixes `mouseExit` colour commands failing to clear menu hover borders.

* fix: IW3 menu dumper preserve empty item text

Keep explicit empty text distinct from null text when dumping menu items.

This fixes CD-key values overflowing their input boxes.

* test: cover IW3 menu dumper material case

* test: cover IW3 menu list path precedence

* chore: clang format

* fix: reverse order of union to avoid edge case bug in zcg

* feat: IW3 menu loading

* fix: validate menu expression name table

* chore: explain numeric token quoting in comment

* refactor: simplify menu dump fallback path

* chore: add more dynamic window flags

* refactor: reuse IW3 menu window flag constants

* refactor: use explicit menu item feature lookup

* refactor: represent menu item text as optional

* refactor: clarify shared IW3 and IW4 menu constants

IW4 only extends the type enum so they can be shared

* refactor: clarify IW3 menu zone state naming

* fix: log IW3 menu conversion failures

* refactor: align IW3 menu list loader with IW4

* refactor: align IW3 menu dumpers with IW4

* refactor: align IW3 menu converter with IW4

* chore: use default enum numeration for operationEnum

* chore: small optimization on edit field capability initialization

* fix: iw3 menu optimizations for rect not considering relative position to parent

* chore: align iw3 menu converting closer to iw4

* chore: align iw3 menu dumping closer to iw4

* fix: not dumping menu flags properties correctly

* chore: adjust iw3 expression dumping to work similar to iw4

* chore: adjust iw3 expression converting to work similar to iw4

* chore: use unordered_map for MenuExpressionMatchers

---------

Co-authored-by: Jan Laupetin <[email protected]>
This commit is contained in:
mo
2026-08-31 00:04:01 +02:00
committed by GitHub
co-authored by Jan Laupetin
parent 4d4921e230
commit 51770e7ae5
42 changed files with 3218 additions and 60 deletions
@@ -0,0 +1,247 @@
#include "Game/IW3/Menu/LoaderMenuListIW3.h"
#include "Game/IW3/GameIW3.h"
#include "SearchPath/MockSearchPath.h"
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <memory>
#include <string>
using namespace IW3;
using namespace std::literals;
using namespace Catch;
using namespace Catch::Matchers;
namespace
{
class MenuLoadingTestHelper
{
public:
MenuLoadingTestHelper()
: m_zone("MockZone", 0, GameId::IW3, GamePlatform::PC),
m_creator_collection(m_zone),
m_context(m_zone, &m_creator_collection, &m_ignored_asset_lookup)
{
m_loader = menu::CreateMenuListLoaderIW3(m_zone.Memory(), m_search_path);
}
void AddFile(std::string name, std::string contents)
{
m_search_path.AddFileData(std::move(name), std::move(contents));
}
Material* AddMaterial(const std::string& name)
{
auto* material = m_zone.Memory().Alloc<Material>();
material->info.name = m_zone.Memory().Dup(name.c_str());
m_context.AddAsset<AssetMaterial>(name, material);
return material;
}
AssetCreationResult LoadMenuList(const std::string& name)
{
return m_loader->CreateAsset(name, m_context);
}
menuDef_t* GetMenu(const std::string& name)
{
const auto* asset = m_zone.m_pools.GetAsset(ASSET_TYPE_MENU, name);
REQUIRE(asset);
return static_cast<menuDef_t*>(asset->m_ptr);
}
private:
Zone m_zone;
MockSearchPath m_search_path;
std::unique_ptr<IAssetCreator> m_loader;
AssetCreatorCollection m_creator_collection;
IgnoredAssetLookup m_ignored_asset_lookup;
AssetCreationContext m_context;
};
TEST_CASE("MenuListLoaderIW3: Loads dumped menus and preserves IW3 data", "[iw3][menu][assetloader]")
{
MenuLoadingTestHelper helper;
const auto* backgroundMaterial = helper.AddMaterial("background_material");
const auto* selectMaterial = helper.AddMaterial("select_material");
helper.AddFile("ui_mp/menus.txt", R"MENU({
loadMenu { "ui_mp/main.menu" }
loadMenu { "ui_mp/popup.menu" }
}
)MENU");
helper.AddFile("ui_mp/main.menu", R"MENU({
menuDef
{
name "main"
fullscreen 1
rect 0 0 640 480 0 0
background "background_material"
forecolor 0.8 0.7 0.6 0.5
disablecolor 0.2 0.3 0.4 0.5
visible when(dvarbool("ui_show_main"));
onOpen
{
play menu_open;
setLocalVarInt ui_highlight 5;
}
itemDef
{
name "empty_text"
text ""
rect 10 20 180 24 1 2
type 1
visible 1
forecolor 0.9 0.9 1 0.07
dvar "ui_fallback_text"
action
{
setItemColor empty_text borderColor 0.1 0.1 0.12 0.5;
execondvarstringvalue ui_zfeather 0 "set cg_laserLight 0";
}
exp material (localvarstring("ui_material"));
}
itemDef
{
name "server_list"
rect 20 60 300 200 0 0
type 6
columns 2 0 120 32 0 120 180 48 1
elementwidth 300
elementheight 20
feeder 2
elementtype 1
notselectable
noscrollbars
usepaging
disablecolor 0.6 0.5 0.4 0.3
selectBorder 1 1 1 1
selectIcon "select_material"
doubleclick
{
play mouse_click;
}
}
itemDef
{
name "default_text"
text "-"
maxChars 2
}
}
}
)MENU");
helper.AddFile("ui_mp/popup.menu", R"MENU({
menuDef
{
name "popup"
rect 40 40 200 100 0 0
popup
onESC
{
close self;
}
}
}
)MENU");
const auto result = helper.LoadMenuList("ui_mp/menus.txt");
REQUIRE(result.HasBeenSuccessful());
const auto* menuList = static_cast<MenuList*>(result.GetAssetInfo()->m_ptr);
const auto* mainMenu = helper.GetMenu("main");
const auto* popupMenu = helper.GetMenu("popup");
REQUIRE(menuList->name == "ui_mp/menus.txt"s);
REQUIRE(menuList->menuCount == 2);
REQUIRE(menuList->menus[0] == mainMenu);
REQUIRE(menuList->menus[1] == popupMenu);
REQUIRE(mainMenu->window.background == backgroundMaterial);
REQUIRE(mainMenu->window.dynamicFlags[0] & 0x10000);
REQUIRE_THAT(mainMenu->disableColor[0], WithinRel(0.2f));
REQUIRE_THAT(mainMenu->disableColor[3], WithinRel(0.5f));
REQUIRE(mainMenu->visibleExp.numEntries == 3);
REQUIRE(mainMenu->visibleExp.entries[0]->type == EET_OPERATOR);
REQUIRE(mainMenu->visibleExp.entries[0]->data.op == OP_DVARBOOL);
REQUIRE(mainMenu->visibleExp.entries[1]->data.operand.dataType == VAL_STRING);
REQUIRE(mainMenu->visibleExp.entries[1]->data.operand.internals.stringVal == "ui_show_main"s);
REQUIRE(mainMenu->visibleExp.entries[2]->data.op == OP_RIGHTPAREN);
REQUIRE(mainMenu->onOpen == R"("play" "menu_open" ; "setLocalVarInt" "ui_highlight" "5" ; )"s);
REQUIRE(mainMenu->itemCount == 3);
const auto* emptyTextItem = mainMenu->items[0];
REQUIRE(emptyTextItem->parent == mainMenu);
REQUIRE(emptyTextItem->text != nullptr);
REQUIRE(emptyTextItem->text == ""s);
REQUIRE(emptyTextItem->dvar == "ui_fallback_text"s);
REQUIRE(emptyTextItem->window.dynamicFlags[0] & 0x4);
REQUIRE(emptyTextItem->window.dynamicFlags[0] & 0x10000);
REQUIRE(
emptyTextItem->action
== R"("setItemColor" "empty_text" "borderColor" "0.1" "0.1" "0.12" "0.5" ; "execondvarstringvalue" "ui_zfeather" "0" "set cg_laserLight 0" ; )"s);
REQUIRE(emptyTextItem->materialExp.numEntries == 3);
REQUIRE(emptyTextItem->materialExp.entries[0]->data.op == OP_LOCALVARSTRING);
const auto* listItem = mainMenu->items[1];
REQUIRE(listItem->typeData.listBox);
REQUIRE(listItem->typeData.listBox->numColumns == 2);
REQUIRE(listItem->typeData.listBox->columnInfo[1].pos == 120);
REQUIRE(listItem->typeData.listBox->columnInfo[1].width == 180);
REQUIRE(listItem->typeData.listBox->notselectable == 1);
REQUIRE(listItem->typeData.listBox->noScrollBars == 1);
REQUIRE(listItem->typeData.listBox->usePaging == 1);
REQUIRE_THAT(listItem->typeData.listBox->disableColor[0], WithinRel(0.6f));
REQUIRE(listItem->typeData.listBox->selectIcon == selectMaterial);
REQUIRE(listItem->typeData.listBox->onDoubleClick == R"("play" "mouse_click" ; )"s);
const auto* defaultTextItem = mainMenu->items[2];
REQUIRE(defaultTextItem->type == 0);
REQUIRE(defaultTextItem->typeData.editField);
REQUIRE(defaultTextItem->typeData.editField->maxChars == 2);
REQUIRE(popupMenu->window.staticFlags & 0x01000000);
REQUIRE(popupMenu->onESC == R"("close" "self" ; )"s);
}
TEST_CASE("MenuListLoaderIW3: Ensure optimizing item rect considers relative positions", "[iw3][menu][assetloader]")
{
MenuLoadingTestHelper helper;
helper.AddFile("ui_mp/test.menu", R"MENU({
menuDef
{
name "test"
rect 2 4 100 100
itemDef
{
rect 1 2 20 20
exp rect x (5)
exp rect y (7)
}
}
}
)MENU");
const auto result = helper.LoadMenuList("ui_mp/test.menu");
REQUIRE(result.HasBeenSuccessful());
const auto* testMenu = helper.GetMenu("test");
REQUIRE(testMenu->window.rectClient.x == Approx(2));
REQUIRE(testMenu->window.rectClient.y == Approx(4));
REQUIRE(testMenu->window.rect.x == Approx(2));
REQUIRE(testMenu->window.rect.y == Approx(4));
REQUIRE(testMenu->itemCount == 1);
const auto* item = testMenu->items[0];
REQUIRE(item->rectXExp.numEntries == 0);
REQUIRE(item->rectYExp.numEntries == 0);
REQUIRE(item->window.rectClient.x == Approx(5));
REQUIRE(item->window.rectClient.y == Approx(7));
REQUIRE(item->window.rect.x == Approx(7));
REQUIRE(item->window.rect.y == Approx(11));
}
} // namespace