From f8485094ca706629598d7e9c74538a2231c4fbcf Mon Sep 17 00:00:00 2001 From: Alexander Grund Date: Tue, 3 Feb 2026 18:51:29 +0100 Subject: [PATCH 1/7] Allow relative path for campaign image --- .../gameData/CampaignDescription.cpp | 33 ++++++++++--------- .../gameData/CampaignDescription.h | 4 +-- libs/libGamedata/lua/CampaignDataLoader.cpp | 4 +-- .../s25main/desktops/dskCampaignSelection.cpp | 4 +-- tests/libGameData/testCampaignLuaFile.cpp | 24 ++++++++++++-- 5 files changed, 44 insertions(+), 25 deletions(-) diff --git a/libs/libGamedata/gameData/CampaignDescription.cpp b/libs/libGamedata/gameData/CampaignDescription.cpp index ae44b8821f..8c003826a0 100644 --- a/libs/libGamedata/gameData/CampaignDescription.cpp +++ b/libs/libGamedata/gameData/CampaignDescription.cpp @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2024 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -11,15 +11,26 @@ CampaignDescription::CampaignDescription(const boost::filesystem::path& campaignPath, const kaguya::LuaRef& table) { + const auto resolveCampaignPath = [campaignPath](const std::string& path) { + const boost::filesystem::path tmpPath = path; + // If it is only a filename or empty use path relative to campaign folder + if(!tmpPath.has_parent_path()) + return campaignPath / tmpPath; + // Otherwise it must be a valid path inside the game files + lua::validatePath(path); + return RTTRCONFIG.ExpandPath(path); + }; + CheckedLuaTable luaData(table); luaData.getOrThrow(version, "version"); luaData.getOrThrow(author, "author"); luaData.getOrThrow(name, "name"); luaData.getOrThrow(shortDescription, "shortDescription"); luaData.getOrThrow(longDescription, "longDescription"); - image = luaData.getOptional("image"); - if(image && image->empty()) - image = std::nullopt; + const auto imageValue = luaData.getOptional("image"); + if(imageValue && !imageValue->empty()) + image = resolveCampaignPath(*imageValue); + luaData.getOrThrow(maxHumanPlayers, "maxHumanPlayers"); if(maxHumanPlayers != 1) @@ -30,20 +41,10 @@ CampaignDescription::CampaignDescription(const boost::filesystem::path& campaign if(difficulty != gettext_noop("easy") && difficulty != gettext_noop("medium") && difficulty != gettext_noop("hard")) throw std::invalid_argument(helpers::format(_("Invalid difficulty: %1%"), difficulty)); - auto resolveFolder = [campaignPath](const std::string& folder) { - const boost::filesystem::path tmpPath = folder; - // If it is only a filename or empty use path relative to campaign folder - if(!tmpPath.has_parent_path()) - return campaignPath / tmpPath; - // Otherwise it must be a valid path inside the game files - lua::validatePath(folder); - return RTTRCONFIG.ExpandPath(folder); - }; - const auto mapFolder = luaData.getOrDefault("mapFolder", std::string{}); - mapFolder_ = resolveFolder(mapFolder); + mapFolder_ = resolveCampaignPath(mapFolder); // Default lua folder to map folder, i.e. LUA files are side by side with the maps - luaFolder_ = resolveFolder(luaData.getOrDefault("luaFolder", mapFolder)); + luaFolder_ = resolveCampaignPath(luaData.getOrDefault("luaFolder", mapFolder)); mapNames_ = luaData.getOrDefault("maps", std::vector()); selectionMapData = luaData.getOptional("selectionMap"); luaData.checkUnused(); diff --git a/libs/libGamedata/gameData/CampaignDescription.h b/libs/libGamedata/gameData/CampaignDescription.h index 7de41de861..fecf6fc73b 100644 --- a/libs/libGamedata/gameData/CampaignDescription.h +++ b/libs/libGamedata/gameData/CampaignDescription.h @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2024 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -20,7 +20,7 @@ struct CampaignDescription std::string name; std::string shortDescription; std::string longDescription; - std::optional image; + std::optional image; unsigned maxHumanPlayers = 1; std::string difficulty; std::optional selectionMapData; diff --git a/libs/libGamedata/lua/CampaignDataLoader.cpp b/libs/libGamedata/lua/CampaignDataLoader.cpp index e9eab06a81..4828cb8c8d 100644 --- a/libs/libGamedata/lua/CampaignDataLoader.cpp +++ b/libs/libGamedata/lua/CampaignDataLoader.cpp @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2023 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -17,7 +17,7 @@ unsigned CampaignDataLoader::GetVersion() { - return 2; + return 3; } CampaignDataLoader::CampaignDataLoader(CampaignDescription& campaignDesc, const boost::filesystem::path& basePath) diff --git a/libs/s25main/desktops/dskCampaignSelection.cpp b/libs/s25main/desktops/dskCampaignSelection.cpp index 88c970485b..3cb56c2bee 100644 --- a/libs/s25main/desktops/dskCampaignSelection.cpp +++ b/libs/s25main/desktops/dskCampaignSelection.cpp @@ -137,7 +137,7 @@ void dskCampaignSelection::Msg_TableSelectItem(const unsigned ctrl_id, const boo mapSelection->setMissionsStatus(std::vector(campaign.getNumMaps(), {true, true})); mapSelection->setPreview(true); } else if(campaign.image) - campaignImage_ = LOADER.GetImageN(ResourceId::fromPath(*campaign.image), 0); + campaignImage_ = LOADER.GetImageN(ResourceId::make(*campaign.image), 0); else campaignImage_ = nullptr; } @@ -173,7 +173,7 @@ void dskCampaignSelection::Msg_Timer(unsigned ctrl_id) for(const auto& campaign : campaigns_) { if(campaign.image) - resourcesToLoad.insert(*campaign.image); + resourcesToLoad.insert(campaign.image->string()); } LOADER.LoadFiles({resourcesToLoad.begin(), resourcesToLoad.end()}); FillCampaignsTable(); diff --git a/tests/libGameData/testCampaignLuaFile.cpp b/tests/libGameData/testCampaignLuaFile.cpp index 51c56335bb..fd8c2df058 100644 --- a/tests/libGameData/testCampaignLuaFile.cpp +++ b/tests/libGameData/testCampaignLuaFile.cpp @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2024 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -142,7 +142,7 @@ BOOST_AUTO_TEST_CASE(LoadCampaignDescriptionWithoutTranslation) BOOST_TEST(desc.name == "My campaign"); BOOST_TEST(desc.shortDescription == "Very short description"); BOOST_TEST(desc.longDescription == "This is the long description"); - BOOST_TEST(desc.image == "/GFX/PICS/WORLD.LBM"); + BOOST_TEST(desc.image == RTTRCONFIG.ExpandPath("/GFX/PICS/WORLD.LBM")); BOOST_TEST(desc.maxHumanPlayers == 1u); BOOST_TEST(desc.difficulty == "easy"); @@ -212,7 +212,8 @@ BOOST_AUTO_TEST_CASE(HandleMapAndLuaPaths) longDescription = "long", maxHumanPlayers = 1, difficulty = "easy", - maps = { "map.WLD" } + maps = { "map.WLD" }, + image = "myimage.LBM" } function getRequiredLuaVersion() return 1 end )"; @@ -225,6 +226,8 @@ BOOST_AUTO_TEST_CASE(HandleMapAndLuaPaths) BOOST_TEST_REQUIRE(loader.Load()); BOOST_TEST(desc.getMapFilePath(0) == tmp / "map.WLD"); BOOST_TEST(desc.getLuaFilePath(0) == tmp / "map.lua"); + // Similar for image + BOOST_TEST(desc.image == tmp / "myimage.LBM"); } // Only folder name is a subdirectory to the campaign @@ -298,6 +301,21 @@ BOOST_AUTO_TEST_CASE(HandleMapAndLuaPaths) BOOST_TEST_REQUIRE(!loader.Load()); RTTR_REQUIRE_LOG_CONTAINS_SOME("Invalid path 'subdir/maps", false); } + // Similar the image must only be a name if not a Date: Tue, 3 Feb 2026 19:33:08 +0100 Subject: [PATCH 2/7] Allow relative paths for selectionMap images --- .../gameData/CampaignDescription.cpp | 20 +++++++++++++++---- .../gameData/CampaignDescription.h | 2 +- .../gameData/SelectionMapInputData.cpp | 3 +-- .../gameData/SelectionMapInputData.h | 5 ++--- libs/s25main/controls/ctrlMapSelection.cpp | 4 ++-- .../s25main/desktops/dskCampaignSelection.cpp | 2 +- tests/libGameData/testCampaignLuaFile.cpp | 2 +- 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/libs/libGamedata/gameData/CampaignDescription.cpp b/libs/libGamedata/gameData/CampaignDescription.cpp index 8c003826a0..12b4375290 100644 --- a/libs/libGamedata/gameData/CampaignDescription.cpp +++ b/libs/libGamedata/gameData/CampaignDescription.cpp @@ -15,10 +15,10 @@ CampaignDescription::CampaignDescription(const boost::filesystem::path& campaign const boost::filesystem::path tmpPath = path; // If it is only a filename or empty use path relative to campaign folder if(!tmpPath.has_parent_path()) - return campaignPath / tmpPath; + return (campaignPath / tmpPath).string(); // Otherwise it must be a valid path inside the game files lua::validatePath(path); - return RTTRCONFIG.ExpandPath(path); + return path; }; CheckedLuaTable luaData(table); @@ -42,11 +42,23 @@ CampaignDescription::CampaignDescription(const boost::filesystem::path& campaign throw std::invalid_argument(helpers::format(_("Invalid difficulty: %1%"), difficulty)); const auto mapFolder = luaData.getOrDefault("mapFolder", std::string{}); - mapFolder_ = resolveCampaignPath(mapFolder); + mapFolder_ = RTTRCONFIG.ExpandPath(resolveCampaignPath(mapFolder)); // Default lua folder to map folder, i.e. LUA files are side by side with the maps - luaFolder_ = resolveCampaignPath(luaData.getOrDefault("luaFolder", mapFolder)); + luaFolder_ = RTTRCONFIG.ExpandPath(resolveCampaignPath(luaData.getOrDefault("luaFolder", mapFolder))); mapNames_ = luaData.getOrDefault("maps", std::vector()); selectionMapData = luaData.getOptional("selectionMap"); + if(selectionMapData) + { + const auto updatePath = [resolveCampaignPath](std::string& path) { + if(!path.empty()) + path = resolveCampaignPath(path); + }; + updatePath(selectionMapData->background.filePath); + updatePath(selectionMapData->map.filePath); + updatePath(selectionMapData->missionMapMask.filePath); + updatePath(selectionMapData->marker.filePath); + updatePath(selectionMapData->conquered.filePath); + } luaData.checkUnused(); } diff --git a/libs/libGamedata/gameData/CampaignDescription.h b/libs/libGamedata/gameData/CampaignDescription.h index fecf6fc73b..4f2d63bfa0 100644 --- a/libs/libGamedata/gameData/CampaignDescription.h +++ b/libs/libGamedata/gameData/CampaignDescription.h @@ -20,7 +20,7 @@ struct CampaignDescription std::string name; std::string shortDescription; std::string longDescription; - std::optional image; + std::optional image; unsigned maxHumanPlayers = 1; std::string difficulty; std::optional selectionMapData; diff --git a/libs/libGamedata/gameData/SelectionMapInputData.cpp b/libs/libGamedata/gameData/SelectionMapInputData.cpp index 0369e6fab9..f430e89dd8 100644 --- a/libs/libGamedata/gameData/SelectionMapInputData.cpp +++ b/libs/libGamedata/gameData/SelectionMapInputData.cpp @@ -225,8 +225,7 @@ struct lua_type_traits const LuaStackRef table(l, index); if(table.type() != LUA_TTABLE || table.size() != 2) throw LuaTypeMismatch(); - std::string path = table[1]; - return get_type(boost::filesystem::path(path), table[2]); + return get_type(table[1], table[2]); } static int push(lua_State* l, push_type v) { return util::push_args(l, v.filePath, v.index); } }; diff --git a/libs/libGamedata/gameData/SelectionMapInputData.h b/libs/libGamedata/gameData/SelectionMapInputData.h index 155fc238e8..8565852fa8 100644 --- a/libs/libGamedata/gameData/SelectionMapInputData.h +++ b/libs/libGamedata/gameData/SelectionMapInputData.h @@ -15,10 +15,9 @@ class LuaRef; struct ImageResource { - boost::filesystem::path filePath; + std::string filePath; unsigned index; - ImageResource(boost::filesystem::path path = boost::filesystem::path(), unsigned index = 0) - : filePath(std::move(path)), index(index){}; + ImageResource(std::string path = "", unsigned index = 0) : filePath(std::move(path)), index(index){}; }; struct MissionSelectionInfo diff --git a/libs/s25main/controls/ctrlMapSelection.cpp b/libs/s25main/controls/ctrlMapSelection.cpp index 87f673724c..8cd5aa351f 100644 --- a/libs/s25main/controls/ctrlMapSelection.cpp +++ b/libs/s25main/controls/ctrlMapSelection.cpp @@ -21,7 +21,7 @@ ctrlMapSelection::MapImages::MapImages(const SelectionMapInputData& data) { auto getImage = [](const ImageResource& res) { - auto* img = LOADER.GetImageN(ResourceId::make(res.filePath), res.index); + auto* img = LOADER.GetImageN(ResourceId::fromPath(res.filePath), res.index); if(!img) throw std::runtime_error( helpers::format(_("Loading of images %s for map selection failed."), res.filePath)); @@ -31,7 +31,7 @@ ctrlMapSelection::MapImages::MapImages(const SelectionMapInputData& data) { std::vector pathsToLoad; for(const auto& res : {data.background, data.map, data.missionMapMask, data.marker, data.conquered}) - pathsToLoad.push_back(res.filePath.string()); + pathsToLoad.push_back(res.filePath); LOADER.LoadFiles(pathsToLoad); } diff --git a/libs/s25main/desktops/dskCampaignSelection.cpp b/libs/s25main/desktops/dskCampaignSelection.cpp index 3cb56c2bee..5b43e1b7ef 100644 --- a/libs/s25main/desktops/dskCampaignSelection.cpp +++ b/libs/s25main/desktops/dskCampaignSelection.cpp @@ -173,7 +173,7 @@ void dskCampaignSelection::Msg_Timer(unsigned ctrl_id) for(const auto& campaign : campaigns_) { if(campaign.image) - resourcesToLoad.insert(campaign.image->string()); + resourcesToLoad.insert(*campaign.image); } LOADER.LoadFiles({resourcesToLoad.begin(), resourcesToLoad.end()}); FillCampaignsTable(); diff --git a/tests/libGameData/testCampaignLuaFile.cpp b/tests/libGameData/testCampaignLuaFile.cpp index fd8c2df058..f3000ffefc 100644 --- a/tests/libGameData/testCampaignLuaFile.cpp +++ b/tests/libGameData/testCampaignLuaFile.cpp @@ -142,7 +142,7 @@ BOOST_AUTO_TEST_CASE(LoadCampaignDescriptionWithoutTranslation) BOOST_TEST(desc.name == "My campaign"); BOOST_TEST(desc.shortDescription == "Very short description"); BOOST_TEST(desc.longDescription == "This is the long description"); - BOOST_TEST(desc.image == RTTRCONFIG.ExpandPath("/GFX/PICS/WORLD.LBM")); + BOOST_TEST(desc.image == "/GFX/PICS/WORLD.LBM"); BOOST_TEST(desc.maxHumanPlayers == 1u); BOOST_TEST(desc.difficulty == "easy"); From 7d1fb7c6e75ede0805a293c494c96c236caf2725 Mon Sep 17 00:00:00 2001 From: Alexander Grund Date: Tue, 3 Feb 2026 20:02:31 +0100 Subject: [PATCH 3/7] Allow images to be in subfolder of campaign Adapt documentation --- doc/AddingCustomCampaign.md | 31 +++++++++------- .../gameData/CampaignDescription.cpp | 35 ++++++++++++++----- tests/libGameData/testCampaignLuaFile.cpp | 22 ++++++++---- tests/s25Main/UI/testMapSelection.cpp | 10 +++--- 4 files changed, 66 insertions(+), 32 deletions(-) diff --git a/doc/AddingCustomCampaign.md b/doc/AddingCustomCampaign.md index 19b8206d99..46e9d3bbd9 100644 --- a/doc/AddingCustomCampaign.md +++ b/doc/AddingCustomCampaign.md @@ -6,7 +6,7 @@ SPDX-License-Identifier: GPL-2.0-or-later # Add a custom campaign -You can have a look into the already existing original Settlers 2 campaigns in the subfolders `RTTR/campaigns/roman` and `RTTR/campaigns/continent` for an example. +You can take a look into the already existing original Settlers 2 campaigns in the subfolders `RTTR/campaigns/roman` and `RTTR/campaigns/continent` for an example. We will now create an example campaign `garden`. ## Location for adding a new campaign @@ -41,18 +41,20 @@ campaign = { name = _"name", shortDescription = _"shortDescription", longDescription = _"longDescription", - image = "/campaigns/garden/garden.bmp", + image = "/campaigns/garden/garden.bmp", -- Same as: image = "garden.bmp", maxHumanPlayers= 1, difficulty = "easy", - mapFolder = "/campaigns/garden", - luaFolder = "/campaigns/garden", + mapFolder = "/campaigns/garden", -- optional + luaFolder = "/campaigns/garden", -- optional maps = { "MISS01.WLD","MISS02.WLD"}, selectionMap = { - background = {"/campaigns/garden/mapscreen/background.bmp", 0}, - map = {"/campaigns/garden/mapscreen/map.bmp", 0}, - missionMapMask = {"/campaigns/garden/mapscreen/map_mask.bmp", 0}, - marker = {"/campaigns/garden/mapscreen/marker.bmp", 0}, - conquered = {"/campaigns/garden/mapscreen/conquered.bmp", 0}, + background = {"/campaigns/garden/mapscreen/background.bmp", 0}, + map = {"/campaigns/garden/mapscreen/map.bmp", 0}, + missionMapMask = {"/campaigns/garden/mapscreen/map_mask.bmp", 0}, + marker = {"/campaigns/garden/mapscreen/marker.bmp", 0}, + conquered = {"/campaigns/garden/mapscreen/conquered.bmp", 0}, + -- Each '/campaigns/garden/' is optional: Paths are treated as relative to campaign file. + -- E.g.: conquered = {"mapscreen/conquered.bmp", 0}, backgroundOffset = {0, 0}, disabledColor = 0x70000000, missionSelectionInfos = { @@ -75,7 +77,7 @@ The Lua campaign interface is versioned using a major version. Every time a feat Every map script must have 1 function: `getRequiredLuaVersion()` -You need to implement this and return the version your script works with. If it does not match the current version an error will be shown and the script will not be used. +You need to implement this and return the version your script works with. If it is higher than the current version an error will be shown and the script will not be used. ### Explanation of the campaign table fields @@ -95,7 +97,7 @@ If you want a field to be translated you have to add the translation as describe Hints: -- To work on case-sensitive OS (like Linux) the file name of the Lua file must have the same case as the map file name. This applies to the map names in the campaign.lua file too. +- To work on case-sensitive OS (like Linux) the file name of the Lua file must have the same case as the map file name. This applies to the map names in the `campaign.lua` file too. For example: `MISS01.WLD, MISS01.lua` is correct and `MISS01.WLD, miss01.lua` will not work on Linux - The Lua file of a map must have the same name as the map itself but with the extension `.lua` to be found. - The Lua and the map file don't need to be in the same folder because the path can be specified separately. @@ -107,7 +109,7 @@ For example: `MISS01.WLD, MISS01.lua` is correct and `MISS01.WLD, miss01.lua` wi ### Optional map selection screen {#selection-map} -This parameter is optional and can be omitted in the Lua campaign file. If this parameter is specified the selection screen for the missions of a campaign is replaced by a selection map. Like the one used in the original settlers 2 world campaign. +This parameter is optional and can be omitted in the Lua campaign file. If this parameter is specified the selection screen for the missions of a campaign is replaced by a selection map. Like the one used in the original Settlers 2 world campaign. We have the following parameters: @@ -124,6 +126,11 @@ Hint: All the images are described by the path to the image file and an index parameter. Usually the index parameter is zero. For special image formats containing multiple images in an archive this is the index of the image to use. +### Image paths + +The paths to the campaign image and selection map images can be relative to the campaign folder and at most inside a single subfolder. +E.g. `images/garden.bmp` and `mapscreen/conquered.bmp` work, but `images/mapscreen/conquered.bmp` does not. + ## Final view of the example garden campaign folder ```sh diff --git a/libs/libGamedata/gameData/CampaignDescription.cpp b/libs/libGamedata/gameData/CampaignDescription.cpp index 12b4375290..f1b6382d27 100644 --- a/libs/libGamedata/gameData/CampaignDescription.cpp +++ b/libs/libGamedata/gameData/CampaignDescription.cpp @@ -4,6 +4,7 @@ #include "CampaignDescription.h" #include "RttrConfig.h" +#include "helpers/containerUtils.h" #include "helpers/format.hpp" #include "lua/CheckedLuaTable.h" #include "lua/LuaHelpers.h" @@ -11,11 +12,29 @@ CampaignDescription::CampaignDescription(const boost::filesystem::path& campaignPath, const kaguya::LuaRef& table) { - const auto resolveCampaignPath = [campaignPath](const std::string& path) { + const auto resolveCampaignPath = [campaignPath](const std::string& path, bool isFolder) { const boost::filesystem::path tmpPath = path; - // If it is only a filename or empty use path relative to campaign folder - if(!tmpPath.has_parent_path()) - return (campaignPath / tmpPath).string(); + if(tmpPath.is_relative()) + { + // If it is only a file/folder name or empty use path relative to campaign folder + if(!tmpPath.has_parent_path()) + return (campaignPath / tmpPath).string(); + if(!isFolder) + { + // For files only allow a single sub folder + const auto parentPath = tmpPath.parent_path(); + if(!parentPath.parent_path().has_parent_path()) + { + // Only alpha-numeric folder names are allowed + const auto isNonAlNum = [](const char c) { + return !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')); + }; + lua::assertTrue(!helpers::contains_if(parentPath.string(), isNonAlNum), + helpers::format(_("Invalid path '%1%': Must be alpha-numeric"), path)); + return (campaignPath / tmpPath).string(); + } + } + } // Otherwise it must be a valid path inside the game files lua::validatePath(path); return path; @@ -29,7 +48,7 @@ CampaignDescription::CampaignDescription(const boost::filesystem::path& campaign luaData.getOrThrow(longDescription, "longDescription"); const auto imageValue = luaData.getOptional("image"); if(imageValue && !imageValue->empty()) - image = resolveCampaignPath(*imageValue); + image = resolveCampaignPath(*imageValue, false); luaData.getOrThrow(maxHumanPlayers, "maxHumanPlayers"); @@ -42,16 +61,16 @@ CampaignDescription::CampaignDescription(const boost::filesystem::path& campaign throw std::invalid_argument(helpers::format(_("Invalid difficulty: %1%"), difficulty)); const auto mapFolder = luaData.getOrDefault("mapFolder", std::string{}); - mapFolder_ = RTTRCONFIG.ExpandPath(resolveCampaignPath(mapFolder)); + mapFolder_ = RTTRCONFIG.ExpandPath(resolveCampaignPath(mapFolder, true)); // Default lua folder to map folder, i.e. LUA files are side by side with the maps - luaFolder_ = RTTRCONFIG.ExpandPath(resolveCampaignPath(luaData.getOrDefault("luaFolder", mapFolder))); + luaFolder_ = RTTRCONFIG.ExpandPath(resolveCampaignPath(luaData.getOrDefault("luaFolder", mapFolder), true)); mapNames_ = luaData.getOrDefault("maps", std::vector()); selectionMapData = luaData.getOptional("selectionMap"); if(selectionMapData) { const auto updatePath = [resolveCampaignPath](std::string& path) { if(!path.empty()) - path = resolveCampaignPath(path); + path = resolveCampaignPath(path, false); }; updatePath(selectionMapData->background.filePath); updatePath(selectionMapData->map.filePath); diff --git a/tests/libGameData/testCampaignLuaFile.cpp b/tests/libGameData/testCampaignLuaFile.cpp index f3000ffefc..98794ca49b 100644 --- a/tests/libGameData/testCampaignLuaFile.cpp +++ b/tests/libGameData/testCampaignLuaFile.cpp @@ -301,20 +301,27 @@ BOOST_AUTO_TEST_CASE(HandleMapAndLuaPaths) BOOST_TEST_REQUIRE(!loader.Load()); RTTR_REQUIRE_LOG_CONTAINS_SOME("Invalid path 'subdir/maps", false); } - // Similar the image must only be a name if not a /GFX/PICS/SETUP990.LBM", 0}, map = {"/GFX/PICS/WORLD.LBM", 0}, missionMapMask = {"/GFX/PICS/WORLDMSK.LBM", 0}, - marker = {"/DATA/IO/IO.DAT", 231}, - conquered = {"/DATA/IO/IO.DAT", 232}, + -- Can be relative to campaign folder + marker = {"marker.DAT", 231}, + conquered = {"imgs/conquered.DAT", 232}, backgroundOffset = {64, 70}, disabledColor = 0x70000000, missionSelectionInfos = { @@ -539,9 +547,9 @@ BOOST_AUTO_TEST_CASE(OptionalSelectionMapLoadTest) BOOST_TEST(selectionMap->map.index == 0u); BOOST_TEST(selectionMap->missionMapMask.filePath == "/GFX/PICS/WORLDMSK.LBM"); BOOST_TEST(selectionMap->missionMapMask.index == 0u); - BOOST_TEST(selectionMap->marker.filePath == "/DATA/IO/IO.DAT"); + BOOST_TEST(selectionMap->marker.filePath == tmp / "marker.DAT"); BOOST_TEST(selectionMap->marker.index == 231u); - BOOST_TEST(selectionMap->conquered.filePath == "/DATA/IO/IO.DAT"); + BOOST_TEST(selectionMap->conquered.filePath == tmp / "imgs/conquered.DAT"); BOOST_TEST(selectionMap->conquered.index == 232u); BOOST_TEST(selectionMap->mapOffsetInBackground == Position(64, 70)); BOOST_TEST(selectionMap->disabledColor == 0x70000000u); diff --git a/tests/s25Main/UI/testMapSelection.cpp b/tests/s25Main/UI/testMapSelection.cpp index ff6373a81c..bc9e4c4ad0 100644 --- a/tests/s25Main/UI/testMapSelection.cpp +++ b/tests/s25Main/UI/testMapSelection.cpp @@ -104,11 +104,11 @@ SelectionMapInputData createInputForSelectionMap(rttr::test::TmpFolder const& tm storeBitmap(conqueredPath, createBitmapWithOneColor(overlaySize, red)); SelectionMapInputData selectionMapInputData; - selectionMapInputData.background = {backgroundPath, 0}; - selectionMapInputData.map = {mapPath, 0}; - selectionMapInputData.missionMapMask = {missionmapmaskPath, 0}; - selectionMapInputData.marker = {markerPath, 0}; - selectionMapInputData.conquered = {conqueredPath, 0}; + selectionMapInputData.background = {backgroundPath.string(), 0}; + selectionMapInputData.map = {mapPath.string(), 0}; + selectionMapInputData.missionMapMask = {missionmapmaskPath.string(), 0}; + selectionMapInputData.marker = {markerPath.string(), 0}; + selectionMapInputData.conquered = {conqueredPath.string(), 0}; selectionMapInputData.mapOffsetInBackground = mapOffsetInBackground; selectionMapInputData.disabledColor = disabledColor; selectionMapInputData.missionSelectionInfos = {{red, {Position((mapSize.x * 3) / 4, mapSize.y / 4)}}, From 9467f10129c67ac3ddb90a381b1f6eff3b1312d7 Mon Sep 17 00:00:00 2001 From: Alexander Grund Date: Tue, 3 Feb 2026 20:25:30 +0100 Subject: [PATCH 4/7] Add note for custom campaign location --- doc/AddingCustomCampaign.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/AddingCustomCampaign.md b/doc/AddingCustomCampaign.md index 46e9d3bbd9..e2f1e4c342 100644 --- a/doc/AddingCustomCampaign.md +++ b/doc/AddingCustomCampaign.md @@ -147,3 +147,6 @@ RTTR/campaigns/garden/mapscreen/map_mask.bmp RTTR/campaigns/garden/mapscreen/marker.bmp RTTR/campaigns/garden/mapscreen/conquered.bmp ``` + +Note that for custom campaigns, placed in `/campaigns/` the top-level `RTTR` folder won't exist. +Hence, `/campaigns/` should not be used in the campaign description file to refer to campaign files. From 58e993066afd501c4b8fbb84767edc3aa87d0b00 Mon Sep 17 00:00:00 2001 From: Alexander Grund Date: Tue, 3 Feb 2026 20:37:35 +0100 Subject: [PATCH 5/7] Increase feature level --- doc/lua/functions.md | 5 +++-- doc/lua/main.md | 6 +++++- libs/s25main/lua/LuaInterfaceGameBase.cpp | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/doc/lua/functions.md b/doc/lua/functions.md index bbbb687917..c651965623 100644 --- a/doc/lua/functions.md +++ b/doc/lua/functions.md @@ -1,5 +1,5 @@ @@ -24,7 +24,8 @@ Reference: [libs/libGamedata/lua/LuaInterfaceBase.cpp](../../libs/libGamedata/lu **rttr:GetFeatureLevel()** Get the current feature level of the LUA interface. Increases here indicate new features. -The current version is **6**. +The current version is **7**. +See [list of changes](main.md#versioning). **rttr:Log(message)** Log the message to console. diff --git a/doc/lua/main.md b/doc/lua/main.md index e0837adc8d..deb29602c3 100644 --- a/doc/lua/main.md +++ b/doc/lua/main.md @@ -1,5 +1,5 @@ @@ -47,6 +47,10 @@ You need to implement this and return the major/main version your script works w If it does not match the current version an error will be shown and the script will not be used. See also `rttr:GetFeatureLevel()`. +### Feature level 7 + +- Allow relative paths for images referenced by campaign files. + ## Example ```lua diff --git a/libs/s25main/lua/LuaInterfaceGameBase.cpp b/libs/s25main/lua/LuaInterfaceGameBase.cpp index 7f6e4f49ae..f00ff7514a 100644 --- a/libs/s25main/lua/LuaInterfaceGameBase.cpp +++ b/libs/s25main/lua/LuaInterfaceGameBase.cpp @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2021 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -16,7 +16,7 @@ unsigned LuaInterfaceGameBase::GetVersion() unsigned LuaInterfaceGameBase::GetFeatureLevel() { - return 6; + return 7; } LuaInterfaceGameBase::LuaInterfaceGameBase(const ILocalGameState& localGameState) : localGameState(localGameState) From feb8eea7bb274233d2835cad011033af8eef01da Mon Sep 17 00:00:00 2001 From: Alexander Grund Date: Wed, 4 Feb 2026 09:19:43 +0100 Subject: [PATCH 6/7] Document possible file types for campaign image --- doc/AddingCustomCampaign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/AddingCustomCampaign.md b/doc/AddingCustomCampaign.md index e2f1e4c342..5d99d08fb4 100644 --- a/doc/AddingCustomCampaign.md +++ b/doc/AddingCustomCampaign.md @@ -88,7 +88,7 @@ If you want a field to be translated you have to add the translation as describe 3. `name`: The name of the campaign 4. `shortDescription`: Short description of the campaign (like a headline to get a rough imagination of the campaign) 5. `longDescription`: Extended description describing the campaign in detail. Will be shown in the campaign selection screen, when the campaign is selected. - 6. `image`: Path to an image displayed in the campaign selection screen. You can omit this if you do no want to provide an image. + 6. `image`: Path to an image displayed in the campaign selection screen. Can be any "archive" with an image, e.g. `.bmp`, `.lbm`. You can omit this if you do no want to provide an image. 7. `maxHumanPlayers`: For now this is always 1 until we support multiplayer campaigns 8. `difficulty`: Difficulty of the campaign. Should be one of the values easy, medium or hard. 9. `mapFolder` and `luaFolder`: Path to the folder containing the campaign maps and associated Lua files. Usually your campaign folder or a subfolder of it. From 94e1931db350265da7dfcc74a3accc0e8275e943 Mon Sep 17 00:00:00 2001 From: Alexander Grund Date: Wed, 4 Feb 2026 09:21:24 +0100 Subject: [PATCH 7/7] Split alpha-numeric check lambda --- libs/libGamedata/gameData/CampaignDescription.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/libGamedata/gameData/CampaignDescription.cpp b/libs/libGamedata/gameData/CampaignDescription.cpp index f1b6382d27..fc1e82eb51 100644 --- a/libs/libGamedata/gameData/CampaignDescription.cpp +++ b/libs/libGamedata/gameData/CampaignDescription.cpp @@ -26,9 +26,10 @@ CampaignDescription::CampaignDescription(const boost::filesystem::path& campaign if(!parentPath.parent_path().has_parent_path()) { // Only alpha-numeric folder names are allowed - const auto isNonAlNum = [](const char c) { - return !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')); + const auto isAlNum = [](const char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); }; + const auto isNonAlNum = [isAlNum](const char c) { return !isAlNum(c); }; lua::assertTrue(!helpers::contains_if(parentPath.string(), isNonAlNum), helpers::format(_("Invalid path '%1%': Must be alpha-numeric"), path)); return (campaignPath / tmpPath).string();