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
1 change: 1 addition & 0 deletions libs/rttrConfig/src/files.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ namespace folders {
constexpr auto mbob = "<RTTR_GAME>/DATA/MBOB"; // nation graphics
constexpr auto music = "<RTTR_RTTR>/MUSIC";
constexpr auto playlists = "<RTTR_USERDATA>/playlists";
constexpr auto addonPresets = "<RTTR_USERDATA>/PRESETS";
constexpr auto replays = "<RTTR_USERDATA>/REPLAYS";
constexpr auto save = "<RTTR_USERDATA>/SAVE";
constexpr auto screenshots = "<RTTR_USERDATA>/screenshots";
Expand Down
1 change: 1 addition & 0 deletions libs/s25main/gameData/const_gui_ids.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ enum GUI_ID : unsigned
{
CGI_ACTION,
CGI_ADDONS,
CGI_ADDON_PRESETS,
CGI_AI_DEBUG,
CGI_BUILDINGS,
CGI_BUILDINGSPRODUCTIVITY,
Expand Down
302 changes: 302 additions & 0 deletions libs/s25main/ingameWindows/iwAddonPresets.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org)
//
// SPDX-License-Identifier: GPL-2.0-or-later

#include "iwAddonPresets.h"
#include "ListDir.h"
#include "Loader.h"
#include "RttrConfig.h"
#include "WindowManager.h"
#include "controls/ctrlEdit.h"
#include "controls/ctrlTable.h"
#include "controls/ctrlText.h"
#include "files.h"
#include "helpers/containerUtils.h"
#include "iwMsgbox.h"
#include "gameData/const_gui_ids.h"
#include "libsiedler2/ArchivItem_Ini.h"
#include "libsiedler2/ArchivItem_Text.h"
#include "libsiedler2/libsiedler2.h"
#include "s25util/Log.h"
#include "s25util/StringConversion.h"
#include "s25util/strAlgos.h"
#include <boost/filesystem.hpp>
#include <optional>

namespace bfs = boost::filesystem;

namespace {
enum
{
ID_tblPresets,
ID_edtName,
ID_btAction,
ID_btDelete,
ID_txtFolder,
};
constexpr unsigned ID_msgboxDelete = 0;
constexpr unsigned ID_msgboxOverwrite = 1;
} // namespace

static bfs::path GetPresetsDir()
{
return RTTRCONFIG.ExpandPath(s25::folders::addonPresets);
}

static std::optional<std::map<unsigned, unsigned>> LoadPresetsFromFile(const bfs::path& filePath)
{
libsiedler2::Archiv archive;
if(libsiedler2::Load(filePath, archive) != 0)
{
LOG.write("Failed to load addon preset from %1%\n") % filePath;
return std::nullopt;
}

const auto* ini = dynamic_cast<const libsiedler2::ArchivItem_Ini*>(archive.find("addons"));
if(!ini)
return std::nullopt;

std::map<unsigned, unsigned> states;
for(unsigned i = 0; i < ini->size(); ++i)
{
const auto* item = dynamic_cast<const libsiedler2::ArchivItem_Text*>(ini->get(i));
if(!item)
return std::nullopt;
unsigned id, status;
if(!s25util::tryFromStringClassic(item->getName(), id)
|| !s25util::tryFromStringClassic(item->getText(), status))
return std::nullopt;
states[id] = status;
}
return states;
}

// iwAddonPresetsBase
iwAddonPresetsBase::iwAddonPresetsBase(const std::string& title, const std::string& actionLabel)
: IngameWindow(CGI_ADDON_PRESETS, IngameWindow::posLastOrCenter, Extent(440, 330), title,
LOADER.GetImageN("resource", 41))
{
using SRT = ctrlTable::SortType;
AddTable(ID_tblPresets, DrawPoint(20, 30), Extent(400, 200), TextureColor::Green2, NormalFont,
ctrlTable::Columns{{_("Preset Name"), 400, SRT::String}, {}});

const bfs::path presetsDir = GetPresetsDir();
AddText(ID_txtFolder, DrawPoint(20, 236), presetsDir.string(), COLOR_YELLOW, FontStyle::TOP, SmallFont)
->setMaxWidth(400);

AddEdit(ID_edtName, DrawPoint(20, 254), Extent(400, 22), TextureColor::Green2, NormalFont);

AddTextButton(ID_btAction, DrawPoint(20, 284), Extent(185, 22), TextureColor::Green2, actionLabel, NormalFont);
AddTextButton(ID_btDelete, DrawPoint(235, 284), Extent(185, 22), TextureColor::Red1, _("Delete"), NormalFont);

bfs::create_directories(presetsDir);
RefreshTable();
}

void iwAddonPresetsBase::RefreshTable()
{
auto* table = GetCtrl<ctrlTable>(ID_tblPresets);
table->DeleteAllItems();

for(const auto& file : ListDir(GetPresetsDir(), "ini"))
table->AddRow({file.stem().string(), file.string()});

table->SortRows(0, TableSortDir::Ascending);
}

bfs::path iwAddonPresetsBase::GetSelectedFilePath() const
{
const auto* table = GetCtrl<ctrlTable>(ID_tblPresets);
if(!table->GetSelection())
return {};
return table->GetItemText(*table->GetSelection(), 1);
}

void iwAddonPresetsBase::Msg_EditEnter(const unsigned /*ctrl_id*/)
{
DoAction();
}

void iwAddonPresetsBase::Msg_ButtonClick(const unsigned ctrl_id)
{
switch(ctrl_id)
{
case ID_btAction: DoAction(); break;
case ID_btDelete: ConfirmDelete(); break;
default: break;
}
}

void iwAddonPresetsBase::Msg_TableSelectItem(const unsigned /*ctrl_id*/, const boost::optional<unsigned>& selection)
{
const auto* table = GetCtrl<ctrlTable>(ID_tblPresets);
GetCtrl<ctrlEdit>(ID_edtName)->SetText(selection ? table->GetItemText(*selection, 0) : "");
}

void iwAddonPresetsBase::ConfirmDelete()
{
if(GetSelectedFilePath().empty())
return;
WINDOWMANAGER.Show(std::make_unique<iwMsgbox>(_("Delete Preset"),
_("Are you sure you want to delete the selected preset?"), this,
MsgboxButton::YesNo, MsgboxIcon::QuestionRed, ID_msgboxDelete));
}

void iwAddonPresetsBase::Msg_MsgBoxResult(const unsigned msgbox_id, const MsgboxResult mbr)
{
if(msgbox_id != ID_msgboxDelete || mbr != MsgboxResult::Yes)
return;

const bfs::path filePath = GetSelectedFilePath();
if(filePath.empty())
return;

boost::system::error_code ec;
bfs::remove(filePath, ec);
if(ec)
{
LOG.write("Failed to delete addon preset %1%: %2%\n") % filePath % ec.message();
WINDOWMANAGER.Show(std::make_unique<iwMsgbox>(_("Delete Failed"), _("Failed to delete the selected preset."),
this, MsgboxButton::Ok, MsgboxIcon::ExclamationRed));
// Refresh so the list reflects the actual filesystem state (e.g. file became a directory)
RefreshTable();
GetCtrl<ctrlEdit>(ID_edtName)->SetText("");
return;
}

RefreshTable();
GetCtrl<ctrlEdit>(ID_edtName)->SetText("");
}

// iwSaveAddonPreset
iwSaveAddonPreset::iwSaveAddonPreset(std::map<unsigned, unsigned> states)
: iwAddonPresetsBase(_("Save Addon Preset"), _("Save")), states_(std::move(states))
{}

bfs::path iwSaveAddonPreset::GetSaveFilePath() const
{
std::string name = GetCtrl<ctrlEdit>(ID_edtName)->GetText();

// Trim leading/trailing whitespace
const auto first = name.find_first_not_of(" \t\r\n");
if(first == std::string::npos)
return {};
name = name.substr(first, name.find_last_not_of(" \t\r\n") - first + 1);

if(name.empty() || name == "." || name == "..")
return {};
if(s25util::toLower(bfs::path(name).extension().string()) != ".ini")
name += ".ini";
return GetPresetsDir() / name;
}

void iwSaveAddonPreset::DoAction()
{
const std::string rawName = GetCtrl<ctrlEdit>(ID_edtName)->GetText();

@Flamefire Flamefire Jun 23, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is this check not in GetSaveFilePath? Looks incomplete to get the name twice from (seemingly) different sources.

if(rawName.find_first_of("/\\") != std::string::npos)
{
WINDOWMANAGER.Show(std::make_unique<iwMsgbox>(
_("Invalid Name"), _("The preset name must not contain path separators. Please choose a different name."),
this, MsgboxButton::Ok, MsgboxIcon::ExclamationRed));
return;
}

const bfs::path filePath = GetSaveFilePath();
if(filePath.empty())
return;

// Reject Windows reserved device names (NUL, CON, COM1, etc.) — these cause failures on Windows
// even with an extension, and presets should be portable across platforms.
static constexpr std::array reservedNames{"con", "prn", "aux", "nul", "com1", "com2", "com3", "com4",
"com5", "com6", "com7", "com8", "com9", "lpt1", "lpt2", "lpt3",
"lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9"};
if(helpers::contains(reservedNames, s25util::toLower(filePath.stem().string())))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would makePortableName work?

@MichalLabuda MichalLabuda Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The idea for handling file names was different from what makePortableName (makePortableFileName) does.
However, I could change this to use makePortableFileName and adjust the rest of the code accordingly - let me know.

We could (or more precisely you could ;-) ) also decide if makePortableName would benefit from adding those restrictions which I could implement.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yes, move it there, I'd say any of those names is not a portable name so should be handled there.

{
WINDOWMANAGER.Show(std::make_unique<iwMsgbox>(
_("Invalid Name"), _("This filename is reserved and cannot be used. Please choose a different name."), this,
MsgboxButton::Ok, MsgboxIcon::ExclamationRed));
return;
}

if(bfs::exists(filePath))
{
WINDOWMANAGER.Show(std::make_unique<iwMsgbox>(
_("Overwrite Preset"), _("A preset with this name already exists. Do you want to overwrite it?"), this,
MsgboxButton::YesNo, MsgboxIcon::QuestionRed, ID_msgboxOverwrite));
return;
}

SaveToPath(filePath);
}

void iwSaveAddonPreset::SaveToPath(const bfs::path& filePath)
{
auto iniItem = std::make_unique<libsiedler2::ArchivItem_Ini>("addons");
for(const auto& [id, status] : states_)
iniItem->setValue(s25util::toStringClassic(id), s25util::toStringClassic(status));

libsiedler2::Archiv archive;
archive.push(std::move(iniItem));

if(libsiedler2::Write(filePath, archive) != 0)
{
LOG.write("Failed to save addon preset to %1%\n") % filePath;
WINDOWMANAGER.Show(std::make_unique<iwMsgbox>(
_("Save Failed"), _("Failed to save the preset. Please check the filename and try again."), this,
MsgboxButton::Ok, MsgboxIcon::ExclamationRed));
RefreshTable();
return;
}

Close();
}

void iwSaveAddonPreset::Msg_TableChooseItem(const unsigned /*ctrl_id*/, const unsigned /*selection*/)
{
DoAction();
}

void iwSaveAddonPreset::Msg_MsgBoxResult(const unsigned msgbox_id, const MsgboxResult mbr)
{
if(msgbox_id == ID_msgboxOverwrite)
{
if(mbr == MsgboxResult::Yes)
{
const bfs::path filePath = GetSaveFilePath();
if(!filePath.empty())
SaveToPath(filePath);
}
return;
}
iwAddonPresetsBase::Msg_MsgBoxResult(msgbox_id, mbr);
}

// iwLoadAddonPreset
iwLoadAddonPreset::iwLoadAddonPreset(std::function<void(const std::map<unsigned, unsigned>&)> onLoad)
: iwAddonPresetsBase(_("Load Addon Preset"), _("Load")), onLoad_(std::move(onLoad))
{}

void iwLoadAddonPreset::DoAction()
{
const bfs::path filePath = GetSelectedFilePath();
if(filePath.empty())
return;

const auto states = LoadPresetsFromFile(filePath);
if(!states)
{
WINDOWMANAGER.Show(std::make_unique<iwMsgbox>(
_("Load Failed"),
_("The selected preset could not be loaded. The file may be corrupted or have an invalid format."), this,
MsgboxButton::Ok, MsgboxIcon::ExclamationRed));
return;
}

onLoad_(*states);
Close();
}

void iwLoadAddonPreset::Msg_TableChooseItem(const unsigned /*ctrl_id*/, const unsigned /*selection*/)
{
DoAction();
}
57 changes: 57 additions & 0 deletions libs/s25main/ingameWindows/iwAddonPresets.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org)
//
// SPDX-License-Identifier: GPL-2.0-or-later

#pragma once

#include "IngameWindow.h"
#include <boost/filesystem/path.hpp>
#include <boost/optional.hpp>
#include <functional>
#include <map>
#include <string>

/// Base class for the save/load addon preset windows
class iwAddonPresetsBase : public IngameWindow
{
public:
explicit iwAddonPresetsBase(const std::string& title, const std::string& actionLabel);

protected:
void RefreshTable();
boost::filesystem::path GetSelectedFilePath() const;

void Msg_EditEnter(unsigned ctrl_id) override;
void Msg_ButtonClick(unsigned ctrl_id) override;
void Msg_TableSelectItem(unsigned ctrl_id, const boost::optional<unsigned>& selection) override;
void Msg_MsgBoxResult(unsigned msgbox_id, MsgboxResult mbr) override;

private:
virtual void DoAction() = 0;
void ConfirmDelete();
};

class iwSaveAddonPreset : public iwAddonPresetsBase
{
public:
explicit iwSaveAddonPreset(std::map<unsigned, unsigned> states);

private:
const std::map<unsigned, unsigned> states_;
boost::filesystem::path GetSaveFilePath() const;
void SaveToPath(const boost::filesystem::path& filePath);
void DoAction() override;
void Msg_MsgBoxResult(unsigned msgbox_id, MsgboxResult mbr) override;
void Msg_TableChooseItem(unsigned ctrl_id, unsigned selection) override;
};

class iwLoadAddonPreset : public iwAddonPresetsBase
{
public:
explicit iwLoadAddonPreset(std::function<void(const std::map<unsigned, unsigned>&)> onLoad);

private:
std::function<void(const std::map<unsigned, unsigned>&)> onLoad_;
void DoAction() override;
void Msg_TableChooseItem(unsigned ctrl_id, unsigned selection) override;
};
Loading