Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 23 additions & 13 deletions doc/AddingCustomCampaign.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,18 +41,20 @@ campaign = {
name = _"name",
shortDescription = _"shortDescription",
longDescription = _"longDescription",
image = "<RTTR_RTTR>/campaigns/garden/garden.bmp",
image = "<RTTR_RTTR>/campaigns/garden/garden.bmp", -- Same as: image = "garden.bmp",
Copy link
Member

@Spikeone Spikeone Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what formats are supported? LBM, BMP?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty much anything that has a single image. So yes, LBM & BMP

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now documented.
I'm wondering if this should support indexed archives similar to the selection map. But probably overkill

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, I was about to ask and hope if we could support loading custom objects as well, as I'm still hoping for that addition

Copy link
Member Author

@Flamefire Flamefire Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by "loading custom objects"?

Do you have any concrete use case for that as image?

Copy link
Member

@Spikeone Spikeone Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do, some examples do include but are not limited:

  • I want to add 4 gates, only the last one reached will activate, all others I'd like to replace with a broken gate (as if they crumble after activation)
  • I want to add some custom objects for a campaign where you search for armor pieces
  • I would like to add some kind of either treasure chest/dig site where you find something
  • I would like to add custom barrier objects
  • I would possibly like to add some ruin variations of different buildings
  • Also (but that would require loading it into IO and not as a missbob) would like to have custom graphics for the messageboxEx

And now that I'm wiring this, I realize, that his would not work in multiplayer at all, or rather would require further implementations. Maybe if we put it into a .lst file we'd distribute then.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, so that is unrelated to the campaign description and could be done later.

As for placing objects: AddStaticObject can use many graphics already. We could make it accept a resource ID instead of a file number to refer to other archives.

As for custom graphics we would indeed be limited to campaigns as we don't transfer archives to clients. Even then we'd need to somehow pass the campaign folder/subfolder to the loader but it supports such overwriting already and we use it for e.g. addons already

maxHumanPlayers= 1,
difficulty = "easy",
mapFolder = "<RTTR_RTTR>/campaigns/garden",
luaFolder = "<RTTR_RTTR>/campaigns/garden",
mapFolder = "<RTTR_RTTR>/campaigns/garden", -- optional
luaFolder = "<RTTR_RTTR>/campaigns/garden", -- optional
maps = { "MISS01.WLD","MISS02.WLD"},
selectionMap = {
background = {"<RTTR_GAME>/campaigns/garden/mapscreen/background.bmp", 0},
map = {"<RTTR_GAME>/campaigns/garden/mapscreen/map.bmp", 0},
missionMapMask = {"<RTTR_GAME>/campaigns/garden/mapscreen/map_mask.bmp", 0},
marker = {"<RTTR_GAME>/campaigns/garden/mapscreen/marker.bmp", 0},
conquered = {"<RTTR_GAME>/campaigns/garden/mapscreen/conquered.bmp", 0},
background = {"<RTTR_RTTR>/campaigns/garden/mapscreen/background.bmp", 0},
map = {"<RTTR_RTTR>/campaigns/garden/mapscreen/map.bmp", 0},
missionMapMask = {"<RTTR_RTTR>/campaigns/garden/mapscreen/map_mask.bmp", 0},
marker = {"<RTTR_RTTR>/campaigns/garden/mapscreen/marker.bmp", 0},
conquered = {"<RTTR_RTTR>/campaigns/garden/mapscreen/conquered.bmp", 0},
-- Each '<RTTR_RTTR>/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 = {
Expand All @@ -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

Expand All @@ -86,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.
Expand All @@ -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.
Expand All @@ -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:

Expand All @@ -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
Expand All @@ -140,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 `<RTTR_USERDATA>/campaigns/` the top-level `RTTR` folder won't exist.
Hence, `<RTTR_RTTR>/campaigns/` should not be used in the campaign description file to refer to campaign files.
5 changes: 3 additions & 2 deletions doc/lua/functions.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!--
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
-->
Expand All @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion doc/lua/main.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!--
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
-->
Expand Down Expand Up @@ -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
Expand Down
65 changes: 49 additions & 16 deletions libs/libGamedata/gameData/CampaignDescription.cpp
Original file line number Diff line number Diff line change
@@ -1,25 +1,56 @@
// 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

#include "CampaignDescription.h"
#include "RttrConfig.h"
#include "helpers/containerUtils.h"
#include "helpers/format.hpp"
#include "lua/CheckedLuaTable.h"
#include "lua/LuaHelpers.h"
#include "mygettext/mygettext.h"

CampaignDescription::CampaignDescription(const boost::filesystem::path& campaignPath, const kaguya::LuaRef& table)
{
const auto resolveCampaignPath = [campaignPath](const std::string& path, bool isFolder) {
const boost::filesystem::path tmpPath = path;
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so my-file.bmp is forbidden? And why is that at all?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we can allow - and _ ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, only the folder name is checked. I don't see a need for campaign-images or similar inside a campaign folder

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();
}
}
}
// Otherwise it must be a valid path inside the game files
lua::validatePath(path);
return 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<std::string>("image");
if(image && image->empty())
image = std::nullopt;
const auto imageValue = luaData.getOptional<std::string>("image");
if(imageValue && !imageValue->empty())
image = resolveCampaignPath(*imageValue, false);

luaData.getOrThrow(maxHumanPlayers, "maxHumanPlayers");

if(maxHumanPlayers != 1)
Expand All @@ -30,22 +61,24 @@ 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_ = RTTRCONFIG.ExpandPath(resolveCampaignPath(mapFolder, true));
// Default lua folder to map folder, i.e. LUA files are side by side with the maps
luaFolder_ = resolveFolder(luaData.getOrDefault("luaFolder", mapFolder));
luaFolder_ = RTTRCONFIG.ExpandPath(resolveCampaignPath(luaData.getOrDefault("luaFolder", mapFolder), true));
mapNames_ = luaData.getOrDefault("maps", std::vector<std::string>());
selectionMapData = luaData.getOptional<SelectionMapInputData>("selectionMap");
if(selectionMapData)
{
const auto updatePath = [resolveCampaignPath](std::string& path) {
if(!path.empty())
path = resolveCampaignPath(path, false);
};
updatePath(selectionMapData->background.filePath);
updatePath(selectionMapData->map.filePath);
updatePath(selectionMapData->missionMapMask.filePath);
updatePath(selectionMapData->marker.filePath);
updatePath(selectionMapData->conquered.filePath);
}
luaData.checkUnused();
}

Expand Down
2 changes: 1 addition & 1 deletion libs/libGamedata/gameData/CampaignDescription.h
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 1 addition & 2 deletions libs/libGamedata/gameData/SelectionMapInputData.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,7 @@ struct lua_type_traits<ImageResource>
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); }
};
Expand Down
5 changes: 2 additions & 3 deletions libs/libGamedata/gameData/SelectionMapInputData.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions libs/libGamedata/lua/CampaignDataLoader.cpp
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -17,7 +17,7 @@

unsigned CampaignDataLoader::GetVersion()
{
return 2;
return 3;
}

CampaignDataLoader::CampaignDataLoader(CampaignDescription& campaignDesc, const boost::filesystem::path& basePath)
Expand Down
4 changes: 2 additions & 2 deletions libs/s25main/controls/ctrlMapSelection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -31,7 +31,7 @@ ctrlMapSelection::MapImages::MapImages(const SelectionMapInputData& data)
{
std::vector<std::string> 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);
}

Expand Down
2 changes: 1 addition & 1 deletion libs/s25main/desktops/dskCampaignSelection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ void dskCampaignSelection::Msg_TableSelectItem(const unsigned ctrl_id, const boo
mapSelection->setMissionsStatus(std::vector<MissionStatus>(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;
}
Expand Down
4 changes: 2 additions & 2 deletions libs/s25main/lua/LuaInterfaceGameBase.cpp
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -16,7 +16,7 @@ unsigned LuaInterfaceGameBase::GetVersion()

unsigned LuaInterfaceGameBase::GetFeatureLevel()
{
return 6;
return 7;
}

LuaInterfaceGameBase::LuaInterfaceGameBase(const ILocalGameState& localGameState) : localGameState(localGameState)
Expand Down
38 changes: 32 additions & 6 deletions tests/libGameData/testCampaignLuaFile.cpp
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
)";
Expand All @@ -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
Expand Down Expand Up @@ -298,6 +301,28 @@ BOOST_AUTO_TEST_CASE(HandleMapAndLuaPaths)
BOOST_TEST_REQUIRE(!loader.Load());
RTTR_REQUIRE_LOG_CONTAINS_SOME("Invalid path 'subdir/maps", false);
}

{
bnw::ofstream file(tmp / "campaign.lua", std::ios_base::app);
file << R"(
campaign["mapFolder"] = ""
campaign["luaFolder"] = ""
)";
}
// Relative image paths are only allowed to be a single sub folder with alpha-numeric name
for(const auto& invValue : {"sub/subsub/img", "../sub/img", "sub/../img", "../img", "Th!s/img", "/abs", "/abs/img"})
{
BOOST_TEST_INFO_SCOPE("Value: " << invValue);
// Similar the image must only be a name if not a <RTTR template
{
bnw::ofstream file(tmp / "campaign.lua", std::ios_base::app);
file << "campaign[\"image\"] = \"" << invValue << '"';
}
CampaignDescription desc;
CampaignDataLoader loader(desc, tmp);
BOOST_TEST_REQUIRE(!loader.Load());
RTTR_REQUIRE_LOG_CONTAINS_SOME("Invalid path '" + std::string(invValue), false);
}
}

BOOST_AUTO_TEST_CASE(LoadCampaignDescriptionFailsDueToMissingCampaignVariable)
Expand Down Expand Up @@ -474,8 +499,9 @@ BOOST_AUTO_TEST_CASE(OptionalSelectionMapLoadTest)
background = {"<RTTR_GAME>/GFX/PICS/SETUP990.LBM", 0},
map = {"<RTTR_GAME>/GFX/PICS/WORLD.LBM", 0},
missionMapMask = {"<RTTR_GAME>/GFX/PICS/WORLDMSK.LBM", 0},
marker = {"<RTTR_GAME>/DATA/IO/IO.DAT", 231},
conquered = {"<RTTR_GAME>/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 = {
Expand Down Expand Up @@ -521,9 +547,9 @@ BOOST_AUTO_TEST_CASE(OptionalSelectionMapLoadTest)
BOOST_TEST(selectionMap->map.index == 0u);
BOOST_TEST(selectionMap->missionMapMask.filePath == "<RTTR_GAME>/GFX/PICS/WORLDMSK.LBM");
BOOST_TEST(selectionMap->missionMapMask.index == 0u);
BOOST_TEST(selectionMap->marker.filePath == "<RTTR_GAME>/DATA/IO/IO.DAT");
BOOST_TEST(selectionMap->marker.filePath == tmp / "marker.DAT");
BOOST_TEST(selectionMap->marker.index == 231u);
BOOST_TEST(selectionMap->conquered.filePath == "<RTTR_GAME>/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);
Expand Down
Loading
Loading