/** * @file tests/unit/codegen/config_test.cpp * @brief Unit tests for RecompilerConfig include-based config layering * * @copyright Copyright (c) 2026 Tom Clay * All rights reserved. * * @license BSD 3-Clause License * See LICENSE file in the project root for full license text. */ #include #include #include #include #include namespace fs = std::filesystem; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- static fs::path WriteTempToml(const fs::path& dir, const std::string& name, const std::string& content) { auto path = dir / name; fs::create_directories(path.parent_path()); std::ofstream(path) << content; return path; } // Produce a chain of N TOML files where each file includes the next. // Returns the path to the first file in the chain (depth 0). static fs::path WriteIncludeChain(const fs::path& dir, uint32_t length) { // Write the leaf (no includes), then work backwards. for (uint32_t i = length - 1; i > 0; --i) { std::string next = "chain_" + std::to_string(i) + ".toml"; std::string content = "includes = [\"" + next + "\"]\n" "file_path = \"dummy.xex\"\n"; WriteTempToml(dir, "chain_" + std::to_string(i - 1) + ".toml", content); } // Leaf file WriteTempToml(dir, "chain_" + std::to_string(length - 1) + ".toml", "file_path = \"dummy.xex\"\n"); return dir / "chain_0.toml"; } // --------------------------------------------------------------------------- // Test 1: Scalar override -- last (top-level) wins // --------------------------------------------------------------------------- TEST_CASE("Config scalar override - last wins", "[codegen][config]") { auto tmp = fs::temp_directory_path() / "rex_cfg_scalar"; fs::remove_all(tmp); fs::create_directories(tmp); // base.toml sets project_name and file_path WriteTempToml(tmp, "base.toml", "project_name = \"base_project\"\n" "file_path = \"base.xex\"\n" "skip_lr = false\n"); // top.toml includes base, then overrides project_name and skip_lr WriteTempToml(tmp, "top.toml", "includes = [\"base.toml\"]\n" "project_name = \"top_project\"\n" "file_path = \"base.xex\"\n" "skip_lr = true\n"); rex::codegen::RecompilerConfig cfg; REQUIRE(cfg.Load((tmp / "top.toml").string())); // Top-level values must win CHECK(cfg.projectName == "top_project"); CHECK(cfg.skipLr == true); // file_path from base is not re-overridden by top (top also sets it) CHECK(cfg.filePath == "base.xex"); fs::remove_all(tmp); } // --------------------------------------------------------------------------- // Test 2: Collection merge -- additive // --------------------------------------------------------------------------- TEST_CASE("Config collection merge - additive", "[codegen][config]") { auto tmp = fs::temp_directory_path() / "rex_cfg_additive"; fs::remove_all(tmp); fs::create_directories(tmp); // base.toml defines one function WriteTempToml(tmp, "base.toml", "file_path = \"game.xex\"\n" "\n" "[functions.\"82200000\"]\n" "size = 0x100\n" "name = \"BaseFunc\"\n"); // top.toml includes base and adds a second distinct function WriteTempToml(tmp, "top.toml", "includes = [\"base.toml\"]\n" "file_path = \"game.xex\"\n" "\n" "[functions.\"82200200\"]\n" "size = 0x80\n" "name = \"TopFunc\"\n"); rex::codegen::RecompilerConfig cfg; REQUIRE(cfg.Load((tmp / "top.toml").string())); // Both functions must be present REQUIRE(cfg.functions.count(0x82200000u) == 1); REQUIRE(cfg.functions.count(0x82200200u) == 1); CHECK(cfg.functions.at(0x82200000u).name == "BaseFunc"); CHECK(cfg.functions.at(0x82200200u).name == "TopFunc"); fs::remove_all(tmp); } // --------------------------------------------------------------------------- // Test 3: Keyed conflict -- last wins // --------------------------------------------------------------------------- TEST_CASE("Config keyed conflict - last wins", "[codegen][config]") { auto tmp = fs::temp_directory_path() / "rex_cfg_conflict"; fs::remove_all(tmp); fs::create_directories(tmp); // included.toml defines a function at address 0x82210000 WriteTempToml(tmp, "included.toml", "file_path = \"game.xex\"\n" "\n" "[functions.\"82210000\"]\n" "size = 0x200\n" "name = \"OldName\"\n"); // top.toml includes the above, then redefines the SAME address WriteTempToml(tmp, "top.toml", "includes = [\"included.toml\"]\n" "file_path = \"game.xex\"\n" "\n" "[functions.\"82210000\"]\n" "size = 0x400\n" "name = \"NewName\"\n"); rex::codegen::RecompilerConfig cfg; REQUIRE(cfg.Load((tmp / "top.toml").string())); // Only one entry for the address; top-level definition wins REQUIRE(cfg.functions.count(0x82210000u) == 1); CHECK(cfg.functions.at(0x82210000u).name == "NewName"); CHECK(cfg.functions.at(0x82210000u).size == 0x400u); fs::remove_all(tmp); } // --------------------------------------------------------------------------- // Test 4: Array-of-table dedup -- same address, last wins // --------------------------------------------------------------------------- TEST_CASE("Config switch_tables dedup - last wins", "[codegen][config]") { auto tmp = fs::temp_directory_path() / "rex_cfg_switchtable"; fs::remove_all(tmp); fs::create_directories(tmp); // first.toml defines a switch_table at bctrAddress 0x82220000 with 2 labels WriteTempToml(tmp, "first.toml", "file_path = \"game.xex\"\n" "\n" "[[switch_tables]]\n" "address = 0x82220000\n" "register = 3\n" "labels = [0x82220100, 0x82220200]\n"); // top.toml includes first, then redefines the SAME bctrAddress with 3 labels WriteTempToml(tmp, "top.toml", "includes = [\"first.toml\"]\n" "file_path = \"game.xex\"\n" "\n" "[[switch_tables]]\n" "address = 0x82220000\n" "register = 4\n" "labels = [0x82220100, 0x82220200, 0x82220300]\n"); rex::codegen::RecompilerConfig cfg; REQUIRE(cfg.Load((tmp / "top.toml").string())); // One entry at the address; top-level definition wins REQUIRE(cfg.switchTables.count(0x82220000u) == 1); const auto& jt = cfg.switchTables.at(0x82220000u); CHECK(jt.indexRegister == 4); REQUIRE(jt.targets.size() == 3); CHECK(jt.targets[2] == 0x82220300u); fs::remove_all(tmp); } // --------------------------------------------------------------------------- // Test 5: Circular include detection -> Load() returns false // --------------------------------------------------------------------------- TEST_CASE("Config circular include detection", "[codegen][config]") { auto tmp = fs::temp_directory_path() / "rex_cfg_circular"; fs::remove_all(tmp); fs::create_directories(tmp); // a.toml includes b.toml WriteTempToml(tmp, "a.toml", "file_path = \"game.xex\"\n" "includes = [\"b.toml\"]\n"); // b.toml includes a.toml (cycle) WriteTempToml(tmp, "b.toml", "file_path = \"game.xex\"\n" "includes = [\"a.toml\"]\n"); rex::codegen::RecompilerConfig cfg; CHECK_FALSE(cfg.Load((tmp / "a.toml").string())); fs::remove_all(tmp); } // --------------------------------------------------------------------------- // Test 6: Max depth enforcement (chain of 33 non-cyclic files -> false) // --------------------------------------------------------------------------- TEST_CASE("Config max include depth enforcement", "[codegen][config]") { // kMaxIncludeDepth == 32; a chain of 34 files has depth 33 at the leaf. constexpr uint32_t kChainLength = 34; auto tmp = fs::temp_directory_path() / "rex_cfg_maxdepth"; fs::remove_all(tmp); fs::create_directories(tmp); fs::path root = WriteIncludeChain(tmp, kChainLength); rex::codegen::RecompilerConfig cfg; CHECK_FALSE(cfg.Load(root.string())); fs::remove_all(tmp); } // --------------------------------------------------------------------------- // Test 7: Recursive includes happy path // --------------------------------------------------------------------------- TEST_CASE("Config recursive includes happy path", "[codegen][config]") { auto tmp = fs::temp_directory_path() / "rex_cfg_recursive"; fs::remove_all(tmp); fs::create_directories(tmp); // leaf.toml: lowest-level config WriteTempToml(tmp, "leaf.toml", "file_path = \"game.xex\"\n" "project_name = \"leaf_project\"\n" "\n" "[functions.\"82230000\"]\n" "size = 0x80\n" "name = \"LeafFunc\"\n"); // mid.toml: includes leaf, adds another function WriteTempToml(tmp, "mid.toml", "includes = [\"leaf.toml\"]\n" "file_path = \"game.xex\"\n" "\n" "[functions.\"82230100\"]\n" "size = 0x80\n" "name = \"MidFunc\"\n"); // top.toml: includes mid, adds yet another function and overrides project_name WriteTempToml(tmp, "top.toml", "includes = [\"mid.toml\"]\n" "file_path = \"game.xex\"\n" "project_name = \"top_project\"\n" "\n" "[functions.\"82230200\"]\n" "size = 0x80\n" "name = \"TopFunc\"\n"); rex::codegen::RecompilerConfig cfg; REQUIRE(cfg.Load((tmp / "top.toml").string())); // All three functions must be present CHECK(cfg.functions.count(0x82230000u) == 1); CHECK(cfg.functions.count(0x82230100u) == 1); CHECK(cfg.functions.count(0x82230200u) == 1); // Top-level project_name overrides leaf's value CHECK(cfg.projectName == "top_project"); fs::remove_all(tmp); } // --------------------------------------------------------------------------- // Test 8: Relative path resolution // --------------------------------------------------------------------------- TEST_CASE("Config relative path resolution from subdirectory", "[codegen][config]") { auto tmp = fs::temp_directory_path() / "rex_cfg_relpath"; fs::remove_all(tmp); fs::create_directories(tmp / "sub"); // sub/leaf.toml: lives in a subdirectory WriteTempToml(tmp, "sub/leaf.toml", "file_path = \"game.xex\"\n" "\n" "[functions.\"82240000\"]\n" "size = 0xC0\n" "name = \"SubFunc\"\n"); // top.toml: includes sub/leaf.toml using a relative path WriteTempToml(tmp, "top.toml", "includes = [\"sub/leaf.toml\"]\n" "file_path = \"game.xex\"\n"); rex::codegen::RecompilerConfig cfg; REQUIRE(cfg.Load((tmp / "top.toml").string())); REQUIRE(cfg.functions.count(0x82240000u) == 1); CHECK(cfg.functions.at(0x82240000u).name == "SubFunc"); // Now test that an included file resolves its OWN includes relative to itself. // Create: top2.toml -> subdir/mid.toml -> ../leaf2.toml (back up to tmp root) WriteTempToml(tmp, "leaf2.toml", "file_path = \"game.xex\"\n" "\n" "[functions.\"82240100\"]\n" "size = 0xC0\n" "name = \"Leaf2Func\"\n"); // sub/mid.toml includes the parent directory's leaf2.toml WriteTempToml(tmp, "sub/mid.toml", "includes = [\"../leaf2.toml\"]\n" "file_path = \"game.xex\"\n"); WriteTempToml(tmp, "top2.toml", "includes = [\"sub/mid.toml\"]\n" "file_path = \"game.xex\"\n"); rex::codegen::RecompilerConfig cfg2; REQUIRE(cfg2.Load((tmp / "top2.toml").string())); REQUIRE(cfg2.functions.count(0x82240100u) == 1); CHECK(cfg2.functions.at(0x82240100u).name == "Leaf2Func"); fs::remove_all(tmp); }