817 lines
44 KiB
C++
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, §ion)) 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, §ionName)) {
|
|
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], §ion);
|
|
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
|