DS2Revival/mods/common/settings_file.h
DS2Revival a1e2f8f9b5
Some checks failed
build / build (push) Failing after 1m35s
CI: build on the project's own runner at git.dsii.uk
2026-10-27 22:02:02 -04:00

817 lines
44 KiB
C++

// The shared settings file DS2Revival.ini, in the game folder next to DungeonSiege2.exe: created on the first start,
// completed on later starts. (Broken World started from the Steam game's "Broken World" folder uses the main game folder's
// file: one file for both games; a Broken World folder on its own keeps its own.) Versions 1.2.0 and 1.2.1 kept it in the
// scripts folder: the first start of a newer plugin moves it up (when the game folder has none; when both exist the game
// folder's wins and the scripts one is renamed DS2Revival.ini.old).
//
// The release zip does not contain DS2Revival.ini (dragging a new zip over the game folder must never overwrite
// the player's settings). Whichever plugin starts first writes it from the commented default text compiled into
// every plugin (mods/common/DS2Revival.ini is the one source; mods/build.py generates revival_ini_text.h from it).
// * created: the text is written to a temp file and moved into place, under a named mutex, so several plugins
// (or the Steam game and Broken World) starting at once write it exactly once;
// * one-time migration while creating it: values the player changed in the old per-plugin files
// (DS2Multiplayer.ini, DS2Widescreen.ini, DS2Fixes.ini, DS2BrokenWorld.ini, DS2ModLoader.ini - also in Broken
// World's scripts folder - and the very old ds2fix.ini / server.txt) are copied into it (only values that differ
// from the defaults; the GPU options of [Display] go to [Graphics]); those files are renamed to <name>.old;
// * later starts and updates: settings the file lacks are added with their comment lines under the right section.
// Existing values, the player's own comments and the order of their lines are never touched, and a file that
// already has every setting is not rewritten. (An old DS2Revival.ini that still kept the GPU options in [Display]
// has them moved to [Graphics] with their values, so that the new [Graphics] default does not hide them.)
// * changed defaults, once: [Settings] Version (absent = a 1.2.x file; the default text has kSettingsVersion) says which
// defaults the file has been brought up to. When a release changes the default of a setting (kChangedDefaults: section,
// key, the old default(s), the new one, the version), a value that is still an old default becomes the new default at the
// first start of that release, a value the player set is left alone; then the version line is raised, so it is done once.
// The same for the first-start migration: a value of an old per-plugin file that equals an old default is not carried over.
// * comments, every start (RefreshComments): the explanatory comments above the settings, the [section] banners and the
// introduction of an older file are brought up to the current default text when they are text some version shipped
// (a comment you wrote or edited stays); values, your own settings and comments, blank lines and order are never touched.
// kSettingsVersion 3 made every file from 1.2.x / 1.3 get this once; later it is a no-op until the default text changes.
// Not done for a UTF-16 file (it is left exactly as it is) or in a test instance of the ds2dev harness (DS2DEV_DOCS
// is set: developer tools write per-plugin inis there between runs; DS2_SETTINGS_AUTOCREATE=1 switches it on).
#pragma once
#include "patch.h"
#include "revival_ini_text.h" // kRevivalIniText: generated by mods/build.py from mods/common/DS2Revival.ini
#include <algorithm>
#include <iterator>
#include <map>
#include <set>
#include <string>
#include <vector>
namespace ds2 {
// The settings file all release plugins share (in the game folder, next to DungeonSiege2.exe).
constexpr char kSharedIni[] = "DS2Revival.ini";
inline bool FileExists(const char* path) { return GetFileAttributesA(path) != INVALID_FILE_ATTRIBUTES; }
namespace settings {
using Lines = std::vector<std::string>;
inline std::string Trim(const std::string& s) {
auto blank = [](char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; };
size_t a = 0, b = s.size();
while (a < b && blank(s[a])) ++a;
while (b > a && blank(s[b - 1])) --b;
return s.substr(a, b - a);
}
inline std::string Lower(std::string s) {
for (char& c : s) if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + 32);
return s;
}
// "[name]"
inline bool IsSection(const std::string& line, std::string* name = nullptr) {
std::string t = Trim(line);
if (t.size() < 2 || t.front() != '[' || t.back() != ']') return false;
if (name) *name = Trim(t.substr(1, t.size() - 2));
return true;
}
// "key=value" (not a comment)
inline bool IsKey(const std::string& line, std::string* key = nullptr, std::string* value = nullptr) {
std::string t = Trim(line);
if (t.empty() || t[0] == ';') return false;
size_t eq = t.find('=');
if (eq == std::string::npos) return false;
if (key) *key = Trim(t.substr(0, eq));
if (value) *value = Trim(t.substr(eq + 1));
return true;
}
// An ini as lines. Anything but UTF-16 can be edited: the lines are kept byte for byte (UTF-8 or the ANSI code
// page), a BOM and the line ending style (CRLF or LF) are kept.
struct Doc {
Lines lines;
bool crlf = true;
bool bom = false;
};
inline bool Parse(const std::string& bytes, Doc& d) {
d = Doc();
auto u = [&](size_t i) { return static_cast<unsigned char>(bytes[i]); };
if (bytes.size() >= 2 && ((u(0) == 0xFF && u(1) == 0xFE) || (u(0) == 0xFE && u(1) == 0xFF))) return false; // UTF-16
if (bytes.find('\0') != std::string::npos) return false; // UTF-16 without BOM
size_t pos = 0;
if (bytes.size() >= 3 && u(0) == 0xEF && u(1) == 0xBB && u(2) == 0xBF) d.bom = true, pos = 3;
d.crlf = bytes.find("\r\n") != std::string::npos || bytes.find('\n') == std::string::npos;
while (pos < bytes.size()) {
size_t nl = bytes.find('\n', pos);
size_t end = nl == std::string::npos ? bytes.size() : nl;
size_t stop = end > pos && bytes[end - 1] == '\r' ? end - 1 : end;
d.lines.push_back(bytes.substr(pos, stop - pos));
pos = nl == std::string::npos ? bytes.size() : nl + 1;
}
return true;
}
inline std::string Serialize(const Doc& d) {
std::string out = d.bom ? "\xEF\xBB\xBF" : "";
for (const std::string& l : d.lines) out += l, out += d.crlf ? "\r\n" : "\n";
return out;
}
// ---- the defaults (the compiled-in text) ----------------------------------------------------------------------------
struct Setting {
std::string section, key, value;
};
using SettingMap = std::map<std::string, Setting>; // "section\nkey" (lower case)
inline std::string MapKey(const std::string& section, const std::string& key) { return Lower(section) + '\n' + Lower(key); }
inline Doc DefaultDoc() {
Doc d;
Parse(kRevivalIniText, d);
d.crlf = true;
return d;
}
inline SettingMap Settings(const Doc& d) {
SettingMap out;
std::string section, k, v;
for (const std::string& l : d.lines) {
if (IsSection(l, &section)) continue;
if (!section.empty() && IsKey(l, &k, &v)) out.emplace(MapKey(section, k), Setting{section, k, v});
}
return out;
}
// Equal as text (any case) or as numbers (0.20 = 0.2).
inline bool SameValue(const std::string& a, const std::string& b) {
std::string x = Trim(a), y = Trim(b);
if (Lower(x) == Lower(y)) return true;
auto number = [](const std::string& s, double& n) {
if (s.empty() || s.find_first_not_of("0123456789+-.eE") != std::string::npos) return false;
char* end = nullptr;
n = std::strtod(s.c_str(), &end);
return end && !*end;
};
double p = 0, q = 0;
return number(x, p) && number(y, q) && p == q;
}
// The GPU options that moved from [Display] to [Graphics] (a graphics plugin is split off from the display one).
// The plugin reads [Graphics] first and falls back to [Display].
constexpr const char* kGpuKeys[] = {"antialiasing", "menuantialiasing", "sharpen", "ssao", "ssaoradius", "ssaointensity", "anisotropy",
"texturelodbias", "shaders", "shadowsize", "portraitfix", "finerglow", "renderer", "remixsky",
"remixskipoffscreen", "remixcursor", "remixmenulight"};
inline bool IsGpuKey(const std::string& lowerKey) {
for (const char* k : kGpuKeys) if (lowerKey == k) return true;
return false;
}
// ---- defaults that changed between releases ---------------------------------------------------------------------------
// MergeMissing only adds keys, and a file made by an earlier version has every default spelled out, so a new default would
// never reach a player who never touched the setting. Each row: the value(s) the setting had as its default before (several
// separated by '|'), the new default, and the settings version that introduced the change. A file's version is
// [Settings] Version (absent = 1, a file from 1.2.x). On the first start with a newer plugin a value equal to an old default
// becomes the new default (once; the version line is then raised), any other value is the player's and stays.
// kSettingsVersion must equal Version= in mods/common/DS2Revival.ini (tools/test_settings_ini.py checks it).
constexpr int kSettingsVersion = 3; // 3: the comments of an older file are refreshed once (RefreshComments); 2: changed defaults
struct ChangedDefault {
const char *section, *key, *oldValues, *newValue;
int version; // the settings version that introduced this default
const char* release; // for the log: the plugin release that did
};
constexpr ChangedDefault kChangedDefaults[] = {
{"Graphics", "AntiAliasing", "smaa", "msaa4+smaa", 2, "v1.3"}, // MSAA 4x + SMAA (1.2.x: SMAA only)
{"BrokenWorld", "AcceptContentCrcs", "D4EE6791,75112728|D4EE6791", "", 2, "v1.3"}, // the known-editions table has both now
};
// ---- editing --------------------------------------------------------------------------------------------------------
// The value of key in [section] (the first one), false when there is none.
inline bool GetValue(const Doc& d, const std::string& section, const std::string& key, std::string& value) {
std::string wantSection = Lower(section), wantKey = Lower(key), cur, name, k, v;
for (const std::string& l : d.lines) {
if (IsSection(l, &name)) cur = Lower(name);
else if (cur == wantSection && IsKey(l, &k, &v) && Lower(k) == wantKey) return value = v, true;
}
return false;
}
// `value` is an old default of [section] key (any row of the table, whatever its version).
inline bool IsOldDefault(const std::string& section, const std::string& key, const std::string& value, const ChangedDefault** row = nullptr) {
for (const ChangedDefault& c : kChangedDefaults) {
if (Lower(c.section) != Lower(section) || Lower(c.key) != Lower(key)) continue;
std::string olds = c.oldValues;
for (size_t a = 0; a <= olds.size();) {
size_t b = olds.find('|', a);
if (b == std::string::npos) b = olds.size();
if (SameValue(olds.substr(a, b - a), value)) {
if (row) *row = &c;
return true;
}
a = b + 1;
}
}
return false;
}
// key=value in [section], other lines and comments untouched; the key is added at the end of the section (the
// section at the end of the file) when missing.
inline void SetValue(Doc& d, const std::string& section, const std::string& key, const std::string& value) {
std::string wantSection = Lower(section), wantKey = Lower(key), cur, name, k;
long end = -1;
bool done = false;
for (size_t i = 0; i < d.lines.size(); ++i) {
std::string& l = d.lines[i];
if (IsSection(l, &name)) {
cur = Lower(name);
if (cur == wantSection) end = static_cast<long>(i);
} else if (cur == wantSection) {
if (!Trim(l).empty()) end = static_cast<long>(i);
if (IsKey(l, &k) && Lower(k) == wantKey) l = key + "=" + value, done = true;
}
}
if (done) return;
if (end < 0) {
if (!d.lines.empty()) d.lines.push_back("");
d.lines.push_back("[" + section + "]");
end = static_cast<long>(d.lines.size()) - 1;
}
d.lines.insert(d.lines.begin() + end + 1, key + "=" + value);
}
// Adds the settings of `tmpl` that `d` lacks, each with its comment lines, at the end of the matching section (a
// section that is missing is appended). Returns "section.key" of what was added; `moved` gets the legacy [Display]
// GPU settings that were carried over to [Graphics] with their values.
inline std::vector<std::string> MergeMissing(Doc& d, const Doc& tmpl, std::vector<std::string>& moved) {
struct Sec {
size_t end = 0;
std::set<std::string> keys;
};
std::map<std::string, Sec> have;
std::string section, name, k, v;
for (size_t i = 0; i < d.lines.size(); ++i) {
const std::string& l = d.lines[i];
if (IsSection(l, &name)) {
section = Lower(name);
have[section].end = i;
} else if (!section.empty() && IsKey(l, &k)) {
have[section].keys.insert(Lower(k));
have[section].end = i; // new settings go after the last setting; comments below it (the player's own notes,
} // often meant for the next section) stay where they are
}
// GPU options an older file still has in [Display] and [Graphics] lacks: their values go to [Graphics]
std::map<std::string, std::string> legacy;
const auto graphics = have.find("graphics"); // (not operator[]: that would add the section)
section.clear();
for (const std::string& l : d.lines) {
if (IsSection(l, &name)) section = Lower(name);
else if (section == "display" && IsKey(l, &k, &v) && IsGpuKey(Lower(k)) &&
(graphics == have.end() || !graphics->second.keys.count(Lower(k))))
legacy.emplace(Lower(k), v);
}
std::vector<std::string> added;
std::map<size_t, Lines> inserts; // line index -> lines to insert after it
Lines comments;
Lines header; // the template's comment/blank lines since its last setting: the banner above the next [section]
Lines sectionHeader;
section.clear();
std::string sectionName;
for (const std::string& l : tmpl.lines) {
if (IsSection(l, &sectionName)) {
section = Lower(sectionName);
comments.clear();
while (!header.empty() && Trim(header.front()).empty()) header.erase(header.begin());
sectionHeader.swap(header);
header.clear();
} else if (!Trim(l).empty() && Trim(l)[0] == ';') {
comments.push_back(l);
header.push_back(l);
} else if (!section.empty() && IsKey(l, &k, &v)) {
header.clear();
if (!have.count(section)) { // a whole new section: with the banner the template has above it
d.lines.push_back("");
d.lines.insert(d.lines.end(), sectionHeader.begin(), sectionHeader.end());
d.lines.push_back("[" + sectionName + "]");
have[section].end = d.lines.size() - 1;
}
Sec& s = have[section];
if (!s.keys.count(Lower(k))) {
std::string line = l;
auto lg = section == "graphics" ? legacy.find(Lower(k)) : legacy.end();
if (lg != legacy.end()) line = k + "=" + lg->second, moved.push_back(k);
Lines& at = inserts[s.end];
at.insert(at.end(), comments.begin(), comments.end());
at.push_back(line);
s.keys.insert(Lower(k));
added.push_back(section + "." + Lower(k));
}
comments.clear();
} else {
comments.clear();
if (Trim(l).empty()) header.push_back(l);
}
}
for (auto it = inserts.rbegin(); it != inserts.rend(); ++it)
d.lines.insert(d.lines.begin() + it->first + 1, it->second.begin(), it->second.end());
if (!moved.empty()) { // the carried-over options leave [Display]
Lines keep;
section.clear();
for (const std::string& l : d.lines) {
if (IsSection(l, &name)) section = Lower(name);
else if (section == "display" && IsKey(l, &k) && IsGpuKey(Lower(k)) && legacy.count(Lower(k))) continue;
keep.push_back(l);
}
d.lines.swap(keep);
}
return added;
}
// The settings version the file says it has (1 when there is no [Settings] Version: written by 1.2.x). A value that is not a
// number counts as the current version: such a file is left alone.
inline int FileVersion(const Doc& d) {
std::string v;
if (!GetValue(d, "Settings", "Version", v)) return 1;
char* end = nullptr;
long n = std::strtol(v.c_str(), &end, 10);
return !v.empty() && end && !*end && n >= 1 ? static_cast<int>(n) : kSettingsVersion;
}
// The one-time upgrade: for every row of kChangedDefaults newer than the file's version, a value that is still the old
// default becomes the new one (a value the player changed is left alone), then [Settings] Version is raised. Run it BEFORE
// MergeMissing: a GPU option an old file still has in [Display] (while [Graphics] lacks it) is changed there, and MergeMissing
// then carries the new value over to [Graphics]. Returns what it did, as log text.
inline std::vector<std::string> UpgradeDefaults(Doc& d) {
std::vector<std::string> done;
const int have = FileVersion(d);
if (have >= kSettingsVersion) return done;
for (const ChangedDefault& c : kChangedDefaults) {
if (c.version <= have) continue;
std::string section = c.section, value;
std::string where = std::string("[") + c.section + "] " + c.key;
if (!GetValue(d, section, c.key, value)) {
std::string legacy; // the GPU options lived in [Display] before the [Graphics] section
if (Lower(section) != "graphics" || !IsGpuKey(Lower(c.key)) || !GetValue(d, "Display", c.key, legacy)) continue;
section = "Display", value = legacy, where = std::string("[Display] ") + c.key; // (MergeMissing moves it on)
}
if (!IsOldDefault(c.section, c.key, value)) continue;
SetValue(d, section, c.key, c.newValue);
done.push_back(where + "=" + value + " was the old default, now " + (*c.newValue ? c.newValue : "(empty)") + " (the " + c.release +
" default; set it back if you prefer)");
}
// the marker: raised when it is there but older; a file without it gets it (with its comment) from MergeMissing
std::string v;
if (GetValue(d, "Settings", "Version", v)) SetValue(d, "Settings", "Version", std::to_string(kSettingsVersion));
return done;
}
// ---- the explanatory comments ---------------------------------------------------------------------------------------
// A file made by an earlier version keeps that version's comments (AntiAliasing=msaa4+smaa under a text that only lists "smaa or
// off"). RefreshComments brings them up to the default text, without touching a value, a key or a line of the player's own.
// * A "comment block" is a maximal run of lines that start with ';' (blank lines, keys and [sections] end it). A commented-out
// setting of the file (";AntiAliasing=smaa": ';', the name of a setting of that section, '=') is the player's line, not part
// of a block.
// * A block belongs to what directly follows it: a setting (the block directly above it, the section's intro included - it is
// one block with the first setting's comment), a [section] header (the banner above it, blank lines between are fine), or the
// start of the file (the introduction). The default text has a block for each of these anchors; the player's file the one it
// has. Blocks the default text has no anchor for, and anchors the player's file lacks a block for, are left alone: a player
// who deleted comments does not get them back.
// * The player's block is replaced by the default text's when it is text some version shipped (or the default text itself):
// its hash is in kShippedCommentRuns (mods/common/ini_comment_history.txt, kept by tools/ini_comment_history.py from git
// history). If only the lines at its end (or at its start) are such a block, those lines are replaced and the lines the
// player added above (below) them stay. Any other block - one that has text no version shipped - is the player's and is kept.
// Refreshing is idempotent: a block that already reads like the default text is not changed, so a file that is up to date is not
// rewritten. Blank lines and the order of the player's lines never change.
struct Run {
size_t begin, end; // lines [begin, end)
std::string anchor; // "start", "s:<section>" or "k:<section>\n<key>" (lower case)
};
inline bool IsComment(const std::string& line) {
std::string t = Trim(line);
return !t.empty() && t[0] == ';';
}
// ";Name=value" with Name a setting of `section` (`known` = MapKey of every setting of the default text)
inline bool IsDisabledKey(const std::string& line, const std::string& section, const std::set<std::string>& known) {
if (section.empty()) return false;
std::string t = Trim(line);
if (t.empty() || t[0] != ';') return false;
size_t a = 1;
while (a < t.size() && (t[a] == ' ' || t[a] == '\t')) ++a;
size_t b = a;
while (b < t.size() && ((t[b] >= 'a' && t[b] <= 'z') || (t[b] >= 'A' && t[b] <= 'Z') || (t[b] >= '0' && t[b] <= '9') || t[b] == '_')) ++b;
if (b == a) return false;
while (b < t.size() && (t[b] == ' ' || t[b] == '\t')) ++b;
return b < t.size() && t[b] == '=' && known.count(MapKey(section, t.substr(a, b - a)));
}
inline std::vector<Run> FindRuns(const Doc& d, const std::set<std::string>& known) {
const size_t n = d.lines.size();
std::vector<std::string> sec(n); // the section in force at each line (lower case; a [section] line: its own)
std::string cur, name, k;
for (size_t i = 0; i < n; ++i) {
if (IsSection(d.lines[i], &name)) cur = Lower(name);
sec[i] = cur;
}
auto inBlock = [&](size_t i) { return IsComment(d.lines[i]) && !IsDisabledKey(d.lines[i], sec[i], known); };
std::vector<Run> out;
for (size_t i = 0; i < n;) {
if (!inBlock(i)) { ++i; continue; }
size_t e = i;
while (e < n && inBlock(e)) ++e;
Run r{i, e, std::string()};
if (i == 0) r.anchor = "start";
else if (e < n && IsSection(d.lines[e])) r.anchor = "s:" + sec[e];
else if (e < n && !sec[e].empty() && IsKey(d.lines[e], &k)) r.anchor = "k:" + MapKey(sec[e], k);
else if (e < n && Trim(d.lines[e]).empty()) {
size_t c = e;
while (c < n && Trim(d.lines[c]).empty()) ++c;
if (c < n && IsSection(d.lines[c])) r.anchor = "s:" + sec[c];
}
if (!r.anchor.empty()) out.push_back(r);
i = e;
}
return out;
}
// FNV-1a (64 bit) of the trimmed lines [a, b), each followed by '\n' (mods/build.py comment_run_hashes does the same)
inline unsigned long long HashRun(const Lines& l, size_t a, size_t b) {
unsigned long long h = 1469598103934665603ULL;
for (size_t i = a; i < b; ++i) {
for (char c : Trim(l[i])) h = (h ^ static_cast<unsigned char>(c)) * 1099511628211ULL;
h = (h ^ static_cast<unsigned char>('\n')) * 1099511628211ULL;
}
return h;
}
struct CommentRefresh {
std::vector<std::string> changed; // "[Section] Key" / "[Section]" / "the top of the file": blocks brought up to the default text
std::vector<std::string> kept; // the same for blocks that hold the player's own text and were left alone
};
inline CommentRefresh RefreshComments(Doc& d, const Doc& tmpl) {
CommentRefresh res;
std::set<std::string> known;
for (const auto& kv : Settings(tmpl)) known.insert(kv.first);
std::set<unsigned long long> stock(std::begin(kShippedCommentRuns), std::end(kShippedCommentRuns));
struct Want {
Run run;
std::string label;
};
std::map<std::string, Want> want;
std::string section, name, k;
for (const Run& r : FindRuns(tmpl, known)) { // (labels with the default text's spelling)
stock.insert(HashRun(tmpl.lines, r.begin, r.end));
std::string label = "the top of the file";
if (r.anchor != "start") {
size_t at = r.begin;
for (size_t i = r.end; i < tmpl.lines.size(); ++i)
if (!Trim(tmpl.lines[i]).empty()) { at = i; break; }
label = IsSection(tmpl.lines[at], &name) ? "[" + name + "]" : std::string();
if (label.empty()) {
section.clear();
for (size_t i = 0; i <= at; ++i) IsSection(tmpl.lines[i], &section);
IsKey(tmpl.lines[at], &k);
label = "[" + section + "] " + k;
}
}
want.emplace(r.anchor, Want{r, label});
}
const std::vector<Run> have = FindRuns(d, known);
for (size_t i = have.size(); i-- > 0;) { // from the end: the line numbers of the runs before stay valid
const Run& r = have[i];
const auto w = want.find(r.anchor);
if (w == want.end()) continue;
const Lines fresh(tmpl.lines.begin() + w->second.run.begin, tmpl.lines.begin() + w->second.run.end);
size_t from = r.begin, to = r.end; // the part of the player's block that is shipped text
bool shipped = stock.count(HashRun(d.lines, from, to)) != 0;
for (size_t m = r.end - r.begin - 1; !shipped && m >= 1; --m) // shipped text at the end, the player's lines above it
if (stock.count(HashRun(d.lines, r.end - m, r.end))) shipped = true, from = r.end - m;
for (size_t m = r.end - r.begin - 1; !shipped && m >= 1; --m) // ... or at the start, the player's lines below it
if (stock.count(HashRun(d.lines, r.begin, r.begin + m))) shipped = true, to = r.begin + m;
if (!shipped) {
res.kept.push_back(w->second.label);
continue;
}
bool same = to - from == fresh.size();
for (size_t j = 0; same && j < fresh.size(); ++j) same = Trim(d.lines[from + j]) == Trim(fresh[j]);
if (same) continue;
d.lines.erase(d.lines.begin() + from, d.lines.begin() + to);
d.lines.insert(d.lines.begin() + from, fresh.begin(), fresh.end());
res.changed.push_back(w->second.label);
}
std::reverse(res.changed.begin(), res.changed.end());
std::reverse(res.kept.begin(), res.kept.end());
return res;
}
// ---- files ----------------------------------------------------------------------------------------------------------
inline bool ReadAll(const std::string& path, std::string& out) {
HANDLE h = CreateFileA(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (h == INVALID_HANDLE_VALUE) return false;
out.clear();
char buf[8192];
DWORD n = 0;
bool ok = true;
while (out.size() < (4u << 20)) {
if (!ReadFile(h, buf, sizeof(buf), &n, nullptr)) { ok = false; break; }
if (!n) break;
out.append(buf, n);
}
CloseHandle(h);
return ok;
}
// Writes `bytes` to a temp file in the same folder, then moves it over `path` (replace = false: only when `path`
// does not exist, so a file that appeared meanwhile is never overwritten).
inline bool WriteFileAtomic(const std::string& path, const std::string& bytes, bool replace) {
std::string tmp = path + "." + std::to_string(GetCurrentProcessId()) + ".tmp";
HANDLE h = CreateFileA(tmp.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (h == INVALID_HANDLE_VALUE) return false;
DWORD n = 0;
bool ok = WriteFile(h, bytes.data(), static_cast<DWORD>(bytes.size()), &n, nullptr) && n == bytes.size();
FlushFileBuffers(h);
CloseHandle(h);
if (ok) ok = MoveFileExA(tmp.c_str(), path.c_str(), replace ? MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH : 0) != 0;
if (!ok) DeleteFileA(tmp.c_str());
return ok;
}
// Every plugin and every game process that wants to write the file takes this first (named mutex per file).
class Lock {
public:
explicit Lock(const std::string& path) {
unsigned h = 2166136261u;
for (char c : Lower(path)) h = (h ^ static_cast<unsigned char>(c)) * 16777619u;
char name[64];
sprintf_s(name, "Local\\DS2RevivalSettings%08X", h);
mutex_ = CreateMutexA(nullptr, FALSE, name);
if (mutex_) held_ = WaitForSingleObject(mutex_, 5000) != WAIT_TIMEOUT;
}
~Lock() {
if (mutex_) {
if (held_) ReleaseMutex(mutex_);
CloseHandle(mutex_);
}
}
bool held() const { return mutex_ && held_; }
private:
HANDLE mutex_ = nullptr;
bool held_ = false;
};
// Every [section] key=value of an ini file in any of the encodings Windows reads (UTF-16 included), via the
// profile API; comments are skipped by it. Lower-case section and key.
inline std::vector<Setting> ReadItems(const std::string& path) {
std::vector<Setting> out;
std::vector<char> names(4096);
DWORD n;
while ((n = GetPrivateProfileSectionNamesA(names.data(), static_cast<DWORD>(names.size()), path.c_str())) + 2 >= names.size())
names.resize(names.size() * 4);
for (const char* sec = names.data(); *sec; sec += std::strlen(sec) + 1) {
std::vector<char> body(4096);
while (GetPrivateProfileSectionA(sec, body.data(), static_cast<DWORD>(body.size()), path.c_str()) + 2 >= body.size())
body.resize(body.size() * 4);
for (const char* e = body.data(); *e; e += std::strlen(e) + 1) {
std::string line = e, k, v;
if (IsKey(line, &k, &v)) out.push_back(Setting{Lower(sec), Lower(k), v});
}
}
return out;
}
// ---- the old files --------------------------------------------------------------------------------------------------
// The per-plugin files of earlier versions and the sections each plugin read from its own file (a section it never
// read does not count as its setting).
struct OldIni {
const char* file; // <file>.ini
const char* sections; // lower case, space separated
};
constexpr OldIni kOldInis[] = {
{"DS2Multiplayer", "network multiplayer"},
{"DS2Widescreen", "display graphics camera photo"},
{"DS2Graphics", "graphics"},
{"DS2Fixes", "fixes"},
{"DS2BrokenWorld", "brokenworld display"}, // [Display] Mode / ExclusiveCursor: it leaves the pointer edits to DS2Widescreen
{"DS2ModLoader", "modloader"},
};
// The very old all-in-one ds2fix.ini: its (section, key) -> the same setting in DS2Revival.ini and the default it had
// (a value equal to it is not moved).
struct OldFixKey {
const char *oldSection, *oldKey, *section, *key, *oldDefault;
};
constexpr OldFixKey kOldFixKeys[] = {
{"multiplayer", "enablemenubutton", "Multiplayer", "EnableMenuButton", "1"},
{"multiplayer", "fixstartguard", "Multiplayer", "FixStartGuard", "1"},
{"multiplayer", "skipfirewallprompt", "Multiplayer", "SkipFirewallPrompt", "1"},
{"multiplayer", "directconnecttimeout", "Multiplayer", "DirectConnectTimeout", "60"},
{"multiplayer", "lobbyserver", "Multiplayer", "LobbyServer", "1"},
{"network", "relay", "Network", "Relay", "auto"},
{"network", "singlelanaddress", "Network", "SingleLanAddress", "1"},
{"network", "virtualcdkey", "Network", "VirtualCdKey", "1"},
{"network", "cdkeyhash", "Network", "CdKeyHash", ""},
{"network", "productid", "Network", "ProductId", "00000-000-0000000-00000"},
{"network", "distroid", "Network", "DistroId", "0"},
{"display", "mode", "Display", "Mode", "borderless"},
{"display", "resolution", "Display", "Resolution", "desktop"},
{"display", "retrydevicereset", "Display", "RetryDeviceReset", "1"},
{"display", "noglobalkeyboardhook", "Fixes", "NoGlobalKeyboardHook", "1"},
{"brokenworld", "launch", "BrokenWorld", "Launch", "auto"},
{"brokenworld", "acceptsteamparties", "BrokenWorld", "AcceptSteamParties", "1"},
{"brokenworld", "acceptcontentcrcs", "BrokenWorld", "AcceptContentCrcs", "D4EE6791"},
{"brokenworld", "acceptanysavesingleplayer", "BrokenWorld", "AcceptAnySaveSinglePlayer", "1"},
{"brokenworld", "basegame", "BrokenWorld", "BaseGame", ""},
};
inline bool Allowed(const OldIni& o, const std::string& section) {
return (std::string(" ") + o.sections + " ").find(" " + section + " ") != std::string::npos;
}
struct Change {
std::string section, key, value, from; // section and key as the default text spells them
};
// What the old files say, as changes of the new file: only values that differ from the defaults. `files` = every old
// file that exists (to retire them afterwards). Folders in priority order (the first folder's value wins).
inline void CollectOld(const std::vector<std::string>& folders, const std::string& gameDir, const SettingMap& defaults,
std::vector<Change>& changes, std::vector<std::string>& files, Lines& notes) {
auto find = [&](const std::string& section, const std::string& key) -> const Setting* {
auto it = defaults.find(MapKey(section, key));
return it == defaults.end() ? nullptr : &it->second;
};
auto setdefault = [&](const Setting* d, const std::string& value, const std::string& from) {
for (const Change& c : changes)
if (Lower(c.section) == Lower(d->section) && Lower(c.key) == Lower(d->key)) return;
changes.push_back(Change{d->section, d->key, value, from});
};
// 1. the very old ds2fix.ini (lowest priority: the per-plugin files below overwrite its values)
std::vector<Change> fix;
std::string server;
const std::string fixIni = folders[0] + "\\ds2fix.ini";
if (FileExists(fixIni.c_str())) {
files.push_back(fixIni);
for (const Setting& s : ReadItems(fixIni)) {
if (s.section == "network" && s.key == "server") { server = s.value; continue; }
for (const OldFixKey& m : kOldFixKeys)
if (s.section == m.oldSection && s.key == m.oldKey && s.value != m.oldDefault)
if (const Setting* d = find(m.section, m.key)) fix.push_back(Change{d->section, d->key, s.value, "ds2fix.ini"});
}
}
const std::string txt = gameDir + "\\server.txt";
std::string txtServer;
if (FileExists(txt.c_str())) {
files.push_back(txt);
std::string text;
if (ReadAll(txt, text)) {
size_t pos = 0;
while (pos < text.size() && txtServer.empty()) {
size_t nl = text.find('\n', pos);
std::string l = Trim(text.substr(pos, nl == std::string::npos ? std::string::npos : nl - pos));
pos = nl == std::string::npos ? text.size() : nl + 1;
if (!l.empty() && l[0] != '#' && l[0] != ';') txtServer = l;
}
}
}
// 2. the per-plugin files, main game's scripts folder first
// A Broken World folder's own copy only counts when the main folder has no file of that name: the old plugins in Broken
// World read the main game's <name>.ini whenever it existed, so values in the Broken World copy were never in effect
// (moving them would quietly switch settings a player never had, e.g. a leftover Mode=off or HudScale=1).
std::vector<Change> plugin;
for (size_t fi = 0; fi < folders.size(); ++fi) {
const std::string& folder = folders[fi];
for (const OldIni& o : kOldInis) {
const std::string path = folder + "\\" + o.file + ".ini";
if (!FileExists(path.c_str())) continue;
files.push_back(path); // retired to .old either way
if (fi > 0 && FileExists((folders[0] + "\\" + o.file + ".ini").c_str())) {
notes.push_back(path + ": not used (the main game's " + o.file + ".ini was the one in effect)");
continue;
}
for (Setting s : ReadItems(path)) {
if (s.section == "display" && IsGpuKey(s.key)) s.section = "graphics";
const Setting* d = find(s.section, s.key);
if (!d || !Allowed(o, s.section)) {
notes.push_back(std::string(o.file) + ".ini: [" + s.section + "] " + s.key + " is not a setting of that plugin - it stays in the .old file");
} else if (!SameValue(s.value, d->value)) {
bool dup = false;
for (const Change& c : plugin) dup = dup || (Lower(c.section) == s.section && Lower(c.key) == s.key);
if (!dup) plugin.push_back(Change{d->section, d->key, s.value, std::string(o.file) + ".ini"});
}
}
}
}
for (const Change& c : plugin) setdefault(find(c.section, c.key), c.value, c.from);
for (const Change& c : fix) setdefault(find(c.section, c.key), c.value, c.from);
const Setting* srv = find("Network", "Server");
if (srv) {
if (!server.empty() && !SameValue(server, srv->value)) setdefault(srv, server, "ds2fix.ini");
else if (server.empty() && !txtServer.empty() && !SameValue(txtServer, srv->value)) setdefault(srv, txtServer, "server.txt");
}
}
inline void Retire(const std::string& path) {
if (MoveFileExA(path.c_str(), (path + ".old").c_str(), MOVEFILE_REPLACE_EXISTING))
log().Write("settings: kept the older settings file %s as %s.old", path.c_str(), path.c_str());
else
log().Write("settings: could not rename %s (error %lu) - it is ignored anyway while %s exists", path.c_str(), GetLastError(), kSharedIni);
}
// Makes sure <gameDir>\DS2Revival.ini exists and has every setting. `gameDir` = the folder of DungeonSiege2.exe (for Broken World
// run from the Steam game's subfolder: the main game folder); `scriptsDir` = the folder of the plugins (where versions 1.2.0 and
// 1.2.1 kept the file, and where old per-plugin files are); `otherDirs` = more folders to look for old per-plugin files in
// (Broken World's scripts). Never throws, never fails the start: problems are logged and the plugins use their built-in defaults.
inline void Ensure(const std::string& gameDir, const std::string& scriptsDir, const std::vector<std::string>& otherDirs) {
if (GetEnvironmentVariableA("DS2DEV_DOCS", nullptr, 0) && !GetEnvironmentVariableA("DS2_SETTINGS_AUTOCREATE", nullptr, 0)) {
log().Write("settings: test instance (DS2DEV_DOCS) - %s is not created or completed here", kSharedIni);
return;
}
const std::string path = gameDir + "\\" + kSharedIni;
const std::string oldPath = scriptsDir + "\\" + kSharedIni; // where 1.2.0 / 1.2.1 kept it
const bool oldElsewhere = _stricmp(path.c_str(), oldPath.c_str()) != 0;
Lock lock(path);
if (!lock.held()) {
log().Write("settings: another process holds the lock on %s - not touching it now", path.c_str());
return;
}
if (oldElsewhere && FileExists(oldPath.c_str())) {
if (!FileExists(path.c_str())) {
if (MoveFileExA(oldPath.c_str(), path.c_str(), MOVEFILE_WRITE_THROUGH))
log().Write("settings: moved %s to %s (the settings file now lives next to DungeonSiege2.exe)", oldPath.c_str(), path.c_str());
else {
log().Write("settings: could not move %s to %s (error %lu) - not touching it", oldPath.c_str(), path.c_str(), GetLastError());
return;
}
} else if (MoveFileExA(oldPath.c_str(), (oldPath + ".old").c_str(), MOVEFILE_REPLACE_EXISTING)) {
log().Write("settings: %s exists, so the older %s is not used: renamed to %s.old", path.c_str(), oldPath.c_str(), oldPath.c_str());
} else {
log().Write("settings: could not rename the older %s (error %lu) - it is ignored anyway while %s exists", oldPath.c_str(), GetLastError(), path.c_str());
}
}
const Doc tmpl = DefaultDoc();
std::string bytes;
if (!FileExists(path.c_str())) {
// ---- first start: create it from the default text, with what the old files said
Doc doc = tmpl;
std::vector<std::string> folders{scriptsDir};
folders.insert(folders.end(), otherDirs.begin(), otherDirs.end());
std::vector<Change> changes;
std::vector<std::string> oldFiles;
Lines notes;
CollectOld(folders, gameDir, Settings(tmpl), changes, oldFiles, notes);
for (const Change& c : changes) {
const ChangedDefault* row = nullptr;
if (IsOldDefault(c.section, c.key, c.value, &row)) { // an older file's untouched default: the new default applies
log().Write("settings: %s: [%s] %s=%s was the old default - the %s default %s is used", c.from.c_str(), c.section.c_str(),
c.key.c_str(), c.value.c_str(), row->release, *row->newValue ? row->newValue : "(empty)");
continue;
}
SetValue(doc, c.section, c.key, c.value);
log().Write("settings: moved [%s] %s=%s from %s", c.section.c_str(), c.key.c_str(), c.value.c_str(), c.from.c_str());
}
for (const std::string& n : notes) log().Write("settings: %s", n.c_str());
if (!WriteFileAtomic(path, Serialize(doc), false)) {
log().Write("settings: could not create %s (error %lu) - built-in defaults are used", path.c_str(), GetLastError());
return;
}
log().Write("settings: created %s with the default settings%s", path.c_str(), oldFiles.empty() ? "" : " and the values of the older settings files");
for (const std::string& f : oldFiles) Retire(f);
return;
}
// ---- later starts and updates: add what is missing
if (!ReadAll(path, bytes)) {
log().Write("settings: cannot read %s (error %lu) - not completed", path.c_str(), GetLastError());
return;
}
Doc doc;
if (!Parse(bytes, doc)) {
log().Write("settings: %s is UTF-16 - left exactly as it is (new settings are not added to it)", path.c_str());
return;
}
const int version = FileVersion(doc);
std::vector<std::string> upgraded = UpgradeDefaults(doc); // before MergeMissing (see there)
CommentRefresh comments = RefreshComments(doc, tmpl); // (before MergeMissing too: what that adds already has the new text)
std::vector<std::string> moved;
std::vector<std::string> added = MergeMissing(doc, tmpl, moved);
if (added.empty() && upgraded.empty() && comments.changed.empty() && version >= kSettingsVersion) return; // complete, up to date: not rewritten
if (!WriteFileAtomic(path, Serialize(doc), true)) {
log().Write("settings: could not update %s (error %lu)", path.c_str(), GetLastError());
return;
}
for (const std::string& u : upgraded) log().Write("settings: %s", u.c_str());
if (version < kSettingsVersion) log().Write("settings: %s is now at settings version %d", path.c_str(), kSettingsVersion);
if (!comments.changed.empty()) {
std::string list;
for (size_t i = 0; i < comments.changed.size() && i < 8; ++i) list += (list.empty() ? "" : ", ") + comments.changed[i];
if (comments.changed.size() > 8) list += ", ... (" + std::to_string(comments.changed.size() - 8) + " more)";
log().Write("settings: updated %u explanatory comment block(s) in %s to this version's text (your values are unchanged): %s",
static_cast<unsigned>(comments.changed.size()), path.c_str(), list.c_str());
}
for (const std::string& c : comments.kept)
log().Write("settings: the comment above %s has your own text - kept as it is", c.c_str());
if (added.empty()) return;
std::string list;
for (const std::string& a : added) list += (list.empty() ? "" : ", ") + a;
log().Write("settings: added %u new setting(s) to %s: %s", static_cast<unsigned>(added.size()), path.c_str(), list.c_str());
for (const std::string& m : moved) log().Write("settings: [Display] %s moved to [Graphics] with its value", m.c_str());
}
} // namespace settings
} // namespace ds2