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,3 @@
>game,IW3
menulist,ui_mp/menus.txt
@@ -0,0 +1,31 @@
{
menuDef
{
name "main"
fullscreen 1
rect 0 0 640 480 0 0
disablecolor 0.2 0.3 0.4 0.5
forecolor 0.8 0.7 0.6 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;
}
exp material localvarstring("ui_material");
}
}
}
@@ -0,0 +1,4 @@
{
loadMenu { "ui_mp/main.menu" }
loadMenu { "ui_mp/popup.menu" }
}
@@ -0,0 +1,12 @@
{
menuDef
{
name "popup"
rect 40 40 200 100 0 0
popup
onESC
{
close self;
}
}
}
@@ -0,0 +1,90 @@
#include "Game/IW3/IW3.h"
#include "Game/IW3/Menu/MenuDumperIW3.h"
#include "Game/IW3/Menu/MenuListDumperIW3.h"
#include "Linker.h"
#include "OatTestPaths.h"
#include "SearchPath/MockOutputPath.h"
#include "SearchPath/MockSearchPath.h"
#include "SystemTestsPaths.h"
#include "ZoneLoading.h"
#include <catch2/catch_test_macros.hpp>
#include <filesystem>
#include <memory>
#include <string>
namespace fs = std::filesystem;
using namespace IW3;
using namespace std::literals;
namespace
{
TEST_CASE("Menu loading and dumping (IW3)", "[iw3][menu][system]")
{
const auto testDataPath = oat::paths::GetSystemTestsDirectory() / "Game/IW3/MenuRoundTrip";
const auto testDataPathString = testDataPath.string();
const auto outputPath = oat::paths::GetTempDirectory("MenuRoundTripIW3");
const auto outputPathString = outputPath.string();
const char* arguments[]{
"SystemTests",
"--verbose",
"--base-folder",
outputPathString.c_str(),
"--asset-search-path",
testDataPathString.c_str(),
"--source-search-path",
testDataPathString.c_str(),
"--output-folder",
outputPathString.c_str(),
"MenuRoundTripIW3",
};
LinkerArgs linkerArgs;
auto shouldContinue = true;
REQUIRE(linkerArgs.ParseArgs(std::size(arguments), arguments, shouldContinue));
REQUIRE(shouldContinue);
const auto linker = Linker::Create(std::move(linkerArgs));
REQUIRE(linker->Start());
const auto zonePath = outputPath / "MenuRoundTripIW3.ff";
auto maybeZone = ZoneLoading::LoadZone(zonePath.string(), std::nullopt);
REQUIRE(maybeZone);
auto zone = std::move(*maybeZone);
const auto* menuList = zone->m_pools.GetAsset<AssetMenuList>("ui_mp/menus.txt");
const auto* mainMenu = zone->m_pools.GetAsset<AssetMenu>("main");
const auto* popupMenu = zone->m_pools.GetAsset<AssetMenu>("popup");
REQUIRE(menuList);
REQUIRE(mainMenu);
REQUIRE(popupMenu);
REQUIRE(menuList->Asset()->menuCount == 2);
REQUIRE(mainMenu->Asset()->visibleExp.numEntries == 3);
REQUIRE(mainMenu->Asset()->window.dynamicFlags[0] & 0x10000);
REQUIRE(mainMenu->Asset()->items[0]->text != nullptr);
REQUIRE(mainMenu->Asset()->items[0]->text == ""s);
REQUIRE(mainMenu->Asset()->items[0]->window.dynamicFlags[0] & 0x10000);
REQUIRE(mainMenu->Asset()->items[0]->action == R"("setItemColor" "empty_text" "borderColor" "0.1" "0.1" "0.12" "0.5" ; )"s);
MockSearchPath objPath;
MockOutputPath dumpOutput;
AssetDumpingContext dumpingContext(*zone, "", dumpOutput, objPath, std::nullopt);
menu::MenuListDumperIW3 menuListDumper;
menuListDumper.Dump(dumpingContext);
menu::MenuDumperIW3 menuDumper;
menuDumper.Dump(dumpingContext);
const auto* dumpedMenuList = dumpOutput.GetMockedFile("ui_mp/menus.txt");
const auto* dumpedMainMenu = dumpOutput.GetMockedFile("ui_mp/main.menu");
const auto* dumpedPopupMenu = dumpOutput.GetMockedFile("ui_mp/popup.menu");
REQUIRE(dumpedMenuList);
REQUIRE(dumpedMainMenu);
REQUIRE(dumpedPopupMenu);
REQUIRE(dumpedMenuList->AsString().find("loadMenu { \"ui_mp/main.menu\" }") != std::string::npos);
REQUIRE(dumpedMenuList->AsString().find("loadMenu { \"ui_mp/popup.menu\" }") != std::string::npos);
REQUIRE(dumpedMainMenu->AsString().find("text \"\"") != std::string::npos);
REQUIRE(dumpedMainMenu->AsString().find("visible when(dvarbool(\"ui_show_main\"));") != std::string::npos);
REQUIRE(dumpedMainMenu->AsString().find("setItemColor empty_text borderColor 0.1 0.1 0.12 0.5;") != std::string::npos);
}
} // namespace