Characters: one file per character with a world per difficulty

- CharacterNN.sacredK holds the shared hero and one world per difficulty,
  plus an autosave file; the game works in save\characters\_work
- Load window: one row per character, a difficulty dialog on Laden
  (worlds, newer autosaves, "(neu)" starts a new game with the shared
  hero), Umbenennen, delete moves the character to characters\deleted
- The world keeps its own portals and hero position when the shared hero
  is put on
- K and the Esc menu's "Speichern" save directly (quicksave path)
- One-time migration of the old save rows and exported heroes, after a
  backup
- Startup no longer hangs in the game's foreground loop (0x810af0)
- Text overrides apply to our own text ids too; shared cUI_Button2
  helpers in game/ui/button.h

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-20 01:41:42 +02:00
1 parent 5df7efc198
commit 5bccb0734e
16 files changed
+2052 -64

No files matched your search

+38
View File
@@ -256,3 +256,41 @@ SHORT WINAPI GetAsyncKeyStateWhenFocused(int key) {
}
return GetAsyncKeyStateWhenFocused_original(key);
}
// The game's "bring the window to the front" (0x810af0, thiscall (window,
// timeout ms), ret 8; WinMain calls it three times): ShowWindow, then
// SetForegroundWindow until the window is in front and SetFocus until it has
// the focus. WinMain passes 0xffffffff, which never times out. Windows refuses
// the foreground to a process when the user clicked or typed elsewhere while
// it started, so the game hung at startup (2026-10-20, four times; main
// thread in this loop, log ending at WM_ACTIVATEAPP(1, 0)). Now it asks for a
// limited time, with the input of the window in front attached (which Windows
// allows), and carries on in the background otherwise; a click brings it up.
constexpr DWORD kForegroundWaitMs = 2000;
bool __fastcall BringWindowToFront(void* self, void* edx, HWND window, DWORD timeout);
RE_HOOK(0x00810af0, BringWindowToFront);
bool __fastcall BringWindowToFront(void*, void*, HWND window, DWORD timeout) {
ShowWindow(window, SW_SHOW);
const DWORD wait = std::min(timeout, kForegroundWaitMs);
const DWORD start = GetTickCount();
while (GetForegroundWindow() != window && GetTickCount() - start < wait) {
const DWORD front = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
const DWORD self = GetCurrentThreadId();
const bool attached = front && front != self && AttachThreadInput(front, self, TRUE);
BringWindowToTop(window);
SetForegroundWindow(window);
if (attached) {
AttachThreadInput(front, self, FALSE);
}
Sleep(10);
}
const bool inFront = GetForegroundWindow() == window;
if (inFront) {
SetFocus(window);
} else {
re::Log("borderless: the game window did not get to the front, starting in the background");
}
UpdateWindow(window);
return inFront;
}
File diff suppressed because it is too large. Load diff
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// Characters and difficulties (characters.cpp, docs/characters.md).
namespace mods::characters {
// Saves the running game into the current character at once, without the
// save window (on the next engine tick).
void RequestSave();
} // namespace mods::characters
+282
View File
@@ -0,0 +1,282 @@
#include "mods/charfile.h"
#include <cstring>
#include <fstream>
#include <windows.h>
namespace mods::characters {
namespace {
// Save files (AMS / AMH): header, chunk table at 0x100 with 64 entries.
constexpr size_t kChunkTable = 0x100;
constexpr size_t kChunkEntrySize = 12;
constexpr size_t kChunkCount = 64;
constexpr size_t kChunkData = kChunkTable + kChunkCount * kChunkEntrySize; // 0x400
constexpr uint32_t kChunkHero = 0xc3;
constexpr uint32_t kChunkWorldTime = 0x95; // "Date[..] Time[..]", UTF-16
constexpr uint32_t kChunkDescription = 0x83; // the save's name, 0x40 bytes ANSI
constexpr uint32_t kChunkThumbnail = 0x93;
constexpr size_t kDescriptionSize = 0x40;
// The load window reads the header and 0xc00 table bytes (0x71de90).
constexpr size_t kPreviewMinimumSize = 0xd00;
constexpr size_t kHeroLevel = 0x0;
constexpr size_t kHeroClass = 0x4;
constexpr size_t kHeroName = 0x8;
constexpr size_t kHeroNameChars = 0x40;
constexpr size_t kWorldMode = 0x24;
constexpr size_t kWorldMultiplayer = 0x2c;
constexpr size_t kWorldSavedAt = 0x5c; // year, month, day, weekday, hour, minute, second, ms (dwords)
constexpr size_t kWorldDifficulty = 0x84;
// Character file: "SKCH", version, section count, then {name[16], offset, size}.
constexpr char kMagic[4] = {'S', 'K', 'C', 'H'};
constexpr uint32_t kVersion = 1;
constexpr size_t kSectionNameSize = 16;
constexpr size_t kSectionEntrySize = kSectionNameSize + 8;
constexpr size_t kFileHeaderSize = 12;
uint32_t U32(const Bytes& bytes, size_t offset) {
uint32_t value = 0;
if (offset + 4 <= bytes.size()) {
std::memcpy(&value, bytes.data() + offset, 4);
}
return value;
}
void PutU32(Bytes& bytes, size_t offset, uint32_t value) {
std::memcpy(bytes.data() + offset, &value, 4);
}
struct Chunk {
uint32_t type = 0;
uint32_t offset = 0;
uint32_t size = 0;
};
std::optional<Chunk> FindChunk(const Bytes& file, uint32_t type) {
if (file.size() < kChunkData) {
return std::nullopt;
}
for (size_t i = 0; i < kChunkCount; ++i) {
const size_t entry = kChunkTable + i * kChunkEntrySize;
Chunk chunk{U32(file, entry), U32(file, entry + 4), U32(file, entry + 8)};
if (chunk.type == type && chunk.offset + uint64_t(chunk.size) <= file.size()) {
return chunk;
}
}
return std::nullopt;
}
} // namespace
std::optional<SaveInfo> ReadSaveInfo(const Bytes& file) {
if (file.size() < kChunkData || (std::memcmp(file.data(), "AMS", 3) != 0 && std::memcmp(file.data(), "AMH", 3) != 0)) {
return std::nullopt;
}
const std::optional<Chunk> hero = FindChunk(file, kChunkHero);
if (!hero || hero->size < kHeroName + kHeroNameChars * 2) {
return std::nullopt;
}
SaveInfo info;
const uint8_t* header = file.data() + hero->offset;
std::memcpy(&info.level, header + kHeroLevel, 4);
std::memcpy(&info.heroClass, header + kHeroClass, 4);
const auto* name = reinterpret_cast<const wchar_t*>(header + kHeroName);
info.name.assign(name, wcsnlen(name, kHeroNameChars));
info.world = file[2] == 'S';
if (info.world) {
info.difficulty = static_cast<int>(U32(file, kWorldDifficulty));
info.mode = U32(file, kWorldMode);
info.multiplayer = U32(file, kWorldMultiplayer) != 0;
SYSTEMTIME time{};
WORD* fields[] = {&time.wYear, &time.wMonth, &time.wDay, &time.wDayOfWeek,
&time.wHour, &time.wMinute, &time.wSecond, &time.wMilliseconds};
for (size_t i = 0; i < std::size(fields); ++i) {
*fields[i] = static_cast<WORD>(U32(file, kWorldSavedAt + i * 4));
}
FILETIME stamp{};
if (SystemTimeToFileTime(&time, &stamp)) {
info.savedAt = (uint64_t(stamp.dwHighDateTime) << 32) | stamp.dwLowDateTime;
}
}
return info;
}
bool SetHeroName(Bytes& file, const std::wstring& name) {
const std::optional<Chunk> hero = FindChunk(file, kChunkHero);
if (!hero || hero->size < kHeroName + kHeroNameChars * 2 || name.empty() || name.size() > kHeroNameMaxChars) {
return false;
}
uint8_t* field = file.data() + hero->offset + kHeroName;
std::memset(field, 0, kHeroNameChars * 2);
std::memcpy(field, name.data(), name.size() * 2);
return true;
}
Bytes BuildPreview(const Bytes& world, const std::string& description) {
Bytes preview(world.begin(), world.begin() + std::min(world.size(), kChunkData));
preview.resize(kChunkData);
std::memset(preview.data() + kChunkTable, 0, kChunkCount * kChunkEntrySize);
size_t entry = kChunkTable;
auto append = [&](uint32_t type, const uint8_t* data, uint32_t size) {
PutU32(preview, entry, type);
PutU32(preview, entry + 4, static_cast<uint32_t>(preview.size()));
PutU32(preview, entry + 8, size);
entry += kChunkEntrySize;
preview.insert(preview.end(), data, data + size);
};
for (uint32_t type : {kChunkWorldTime, kChunkThumbnail}) {
if (const std::optional<Chunk> chunk = FindChunk(world, type)) {
append(type, world.data() + chunk->offset, chunk->size);
}
}
uint8_t text[kDescriptionSize] = {};
std::memcpy(text, description.data(), std::min(description.size(), kDescriptionSize - 1));
append(kChunkDescription, text, kDescriptionSize);
if (preview.size() < kPreviewMinimumSize) {
preview.resize(kPreviewMinimumSize);
}
return preview;
}
std::optional<Bytes> ReadFile(const std::string& path) {
std::ifstream file(path, std::ios::binary);
if (!file) {
return std::nullopt;
}
return Bytes(std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>());
}
bool WriteFileReplacing(const std::string& path, const Bytes& bytes) {
const std::string temp = path + ".tmp";
{
std::ofstream file(temp, std::ios::binary | std::ios::trunc);
if (!file.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
return false;
}
}
return MoveFileExA(temp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
}
std::string WorldSection(int difficulty) {
return "world" + std::to_string(difficulty);
}
bool CharacterFile::Open() {
sections_.clear();
std::ifstream file(path_, std::ios::binary);
uint8_t header[kFileHeaderSize];
if (!file.read(reinterpret_cast<char*>(header), sizeof(header)) || std::memcmp(header, kMagic, 4) != 0) {
return false;
}
uint32_t count = 0;
std::memcpy(&count, header + 8, 4);
for (uint32_t i = 0; i < count; ++i) {
uint8_t entry[kSectionEntrySize];
if (!file.read(reinterpret_cast<char*>(entry), sizeof(entry))) {
return false;
}
Section section;
const auto* name = reinterpret_cast<const char*>(entry);
section.name.assign(name, strnlen(name, kSectionNameSize));
std::memcpy(&section.offset, entry + kSectionNameSize, 4);
std::memcpy(&section.size, entry + kSectionNameSize + 4, 4);
sections_.push_back(std::move(section));
}
meta = nlohmann::json::object();
if (std::optional<Bytes> text = Read("meta")) {
meta = nlohmann::json::parse(text->begin(), text->end(), nullptr, false);
if (meta.is_discarded() || !meta.is_object()) {
meta = nlohmann::json::object();
}
}
return true;
}
bool CharacterFile::Has(const std::string& name) const {
for (const Section& section : sections_) {
if (section.name == name) {
return true;
}
}
return false;
}
std::optional<Bytes> CharacterFile::Read(const std::string& name) const {
for (const Section& section : sections_) {
if (section.name != name) {
continue;
}
if (section.data) {
return section.data;
}
std::ifstream file(path_, std::ios::binary);
Bytes bytes(section.size);
if (!file.seekg(section.offset) ||
!file.read(reinterpret_cast<char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
return std::nullopt;
}
return bytes;
}
return std::nullopt;
}
void CharacterFile::Set(const std::string& name, Bytes bytes) {
for (Section& section : sections_) {
if (section.name == name) {
section.size = static_cast<uint32_t>(bytes.size());
section.data = std::move(bytes);
return;
}
}
Section section;
section.name = name;
section.size = static_cast<uint32_t>(bytes.size());
section.data = std::move(bytes);
sections_.push_back(std::move(section));
}
void CharacterFile::Remove(const std::string& name) {
std::erase_if(sections_, [&](const Section& section) { return section.name == name; });
}
bool CharacterFile::Save() {
const std::string text = meta.dump(2);
Set("meta", Bytes(text.begin(), text.end()));
std::vector<Bytes> contents;
for (const Section& section : sections_) {
std::optional<Bytes> bytes = Read(section.name);
if (!bytes) {
return false;
}
contents.push_back(std::move(*bytes));
}
Bytes out(kFileHeaderSize + sections_.size() * kSectionEntrySize);
std::memcpy(out.data(), kMagic, 4);
PutU32(out, 4, kVersion);
PutU32(out, 8, static_cast<uint32_t>(sections_.size()));
for (size_t i = 0; i < sections_.size(); ++i) {
const size_t entry = kFileHeaderSize + i * kSectionEntrySize;
std::memcpy(out.data() + entry, sections_[i].name.data(), std::min(sections_[i].name.size(), kSectionNameSize - 1));
PutU32(out, entry + kSectionNameSize, static_cast<uint32_t>(out.size()));
PutU32(out, entry + kSectionNameSize + 4, static_cast<uint32_t>(contents[i].size()));
out.insert(out.end(), contents[i].begin(), contents[i].end());
}
if (!WriteFileReplacing(path_, out)) {
return false;
}
// The file now holds every section; offsets as just written.
for (size_t i = 0; i < sections_.size(); ++i) {
const size_t entry = kFileHeaderSize + i * kSectionEntrySize;
sections_[i].offset = U32(out, entry + kSectionNameSize);
sections_[i].size = U32(out, entry + kSectionNameSize + 4);
sections_[i].data.reset();
}
return true;
}
} // namespace mods::characters
+82
View File
@@ -0,0 +1,82 @@
#pragma once
// Character files (docs/characters.md): one file per character with its shared
// hero and one world per difficulty, plus reading the game's save files.
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
namespace mods::characters {
using Bytes = std::vector<uint8_t>;
// Bronze, Silber, Gold, Platin, Niob (g_gameLevel 0..4).
constexpr int kDifficultyCount = 5;
// What the game's save files (GAME.PAK "AMS", hero files .pax "AMH") say about
// their hero: header 0x100 bytes, then a table of 64 chunks {type, offset,
// size}; chunk 0xc3 is the hero header.
struct SaveInfo {
std::wstring name; // hero header +8, UTF-16
uint32_t heroClass = 0; // hero header +4: 1 Seraphim .. 9 Daemonin
uint32_t level = 0; // hero header +0
bool world = false; // a save game (AMS), not a hero file
int difficulty = 0; // AMS header +0x84 (g_gameLevel)
uint32_t mode = 0; // AMS header +0x24: 2 normal, 3 hardcore
bool multiplayer = false; // AMS header +0x2c
uint64_t savedAt = 0; // AMS header +0x5c SYSTEMTIME, as FILETIME
};
std::optional<SaveInfo> ReadSaveInfo(const Bytes& file);
// Writes `name` as the hero's name (hero header +8, 0x40 UTF-16 characters
// with the terminator), the only place the name is stored; false if the file
// has no hero header or the name is empty or too long.
constexpr size_t kHeroNameMaxChars = 63;
bool SetHeroName(Bytes& file, const std::wstring& name);
// A small save game the game's load window can list: `world`'s header with its
// date, its thumbnail and world time, and `description` (ANSI, up to 63
// characters) as the save's name.
Bytes BuildPreview(const Bytes& world, const std::string& description);
// Reading and writing whole files (paths in the game's code page).
std::optional<Bytes> ReadFile(const std::string& path);
bool WriteFileReplacing(const std::string& path, const Bytes& bytes);
// A character file: named sections ("meta" JSON, "hero" .pax, "world<d>"
// GAME.PAK). Sections are read from disk on demand; Save writes all of them
// to a temporary file and replaces the old one.
class CharacterFile {
public:
explicit CharacterFile(std::string path) : path_(std::move(path)) {}
// Reads the section table and the meta; false if the file is missing or
// not a character file.
bool Open();
const std::string& Path() const { return path_; }
bool Has(const std::string& section) const;
std::optional<Bytes> Read(const std::string& section) const;
void Set(const std::string& section, Bytes bytes);
void Remove(const std::string& section);
bool Save();
nlohmann::json meta = nlohmann::json::object();
private:
struct Section {
std::string name;
uint32_t offset = 0; // in the file; unused once `data` is set
uint32_t size = 0;
std::optional<Bytes> data; // set or changed since Open
};
std::string path_;
std::vector<Section> sections_;
};
std::string WorldSection(int difficulty);
} // namespace mods::characters
+1 -2
View File
@@ -695,8 +695,7 @@ HUD_RENDER(0x006f73f0, UiNetworkInfo_Render, Anchor::Center)
HUD_EVENT(0x0070e860, UiCharacter_HandleEvent, Anchor::Center)
HUD_RENDER3_FRAMED(0x0070f160, UiCharacter_Render) // export screen in-game
// cUI_Options (0x716b30 / 0x716640): hooked by mods/options.cpp.
HUD_EVENT(0x0071d130, UiSavegame_HandleEvent, Anchor::Center)
HUD_RENDER_FRAMED(0x0071dd80, UiSavegame_Render)
// cUI_Savegame (0x71d130 / 0x71dd80): hooked by mods/characters.cpp.
HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center)
HUD_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center)
+3 -20
View File
@@ -26,7 +26,7 @@
// AllyPotion=None ; kind 1 on the weakest ally (the game's own potion keys
// ; Space/B/Q/W/E/R are switched off)
// PickUp=Q ; what A did (pick up items nearby; Q's own action slot is given up)
// SaveGame=K ; what S did (save game window)
// SaveGame=K ; save the game at once (S opened the save window)
// Map=M ; open / close the map (the game's M is switched off)
// OverviewMap=Tab ; the overview map while held (the game's Tab is switched off)
// Labels=Alt ; names of items and creatures while held (the game's Alt is switched off)
@@ -45,6 +45,7 @@
#include "game/engine/items.h"
#include "game/engine/orders.h"
#include "game/ui/windows.h"
#include "mods/characters.h"
#include "mods/skillbar.h"
#include "mods/skillkeys.h"
#include "re/config.h"
@@ -252,24 +253,6 @@ void OnTick(uint8_t* engine) {
}
}
// The save game window, as the UI manager's S hotkey opens it (0x757775):
// only with the window built (manager slot 0x2b) and no blocking window open;
// it posts an engine event (code 0x13, 5, 1; with 0 for the kernel's second
// argument) and shows UI_WND_SAVEGAME.
constexpr int kSaveGameSlot = 0x2b;
constexpr uint32_t kEngineEventVtable = 0x0088eb38;
constexpr uint32_t kEngineEventSaveGame = 0x13;
void OpenSaveGame() {
uint32_t* ui = game::UiManager();
if (!ui || !ui[kSaveGameSlot] || !game::UiCanOpenWindow(ui)) {
return;
}
uint32_t event[9] = {kEngineEventVtable, kEngineEventSaveGame, 5, 1};
game::PostEvent(event, 0);
game::SendWindowCommand(ui, "UI_WND_SAVEGAME", game::WindowCommand::Show);
}
// The map (cUI_Megamap, manager slot 0x25), as the UI manager's M hotkey
// toggles it (cUI_Manager::ReceiveEvent 0x757120): open, M closes it; else
// nothing while one of the windows it checks first is open (slots 0x2b save
@@ -376,7 +359,7 @@ void Press(const Binding& binding, int vk) {
mods::skillbar::SelectWeaponSet(slot);
break;
case Kind::SaveGame:
OpenSaveGame();
characters::RequestSave(); // at once, no save window
break;
case Kind::PickUp:
g_pickUp = true;
+13 -38
View File
@@ -28,6 +28,7 @@
#include <windows.h>
#include "game/ui/button.h"
#include "game/ui/control.h"
#include "game/ui/draw.h"
#include "game/ui/events.h"
@@ -326,10 +327,6 @@ game::UiRect OnWindow(void* window, const game::UiRect& r) {
return {origin.x + r.x, origin.y + r.y, r.w, r.h};
}
bool Inside(const game::UiRect& r, int x, int y) {
return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h;
}
void FillBox(void* device, const game::UiRect& r, uint32_t colour) {
game::FillRect(device, static_cast<float>(r.x), static_cast<float>(r.y), static_cast<float>(r.x + r.w),
static_cast<float>(r.y + r.h), colour);
@@ -412,25 +409,13 @@ void ResetPending() {
// The binding whose box is at (x, y), -1 if none.
int BoxAt(void* window, int x, int y) {
for (const Line& line : Layout()) {
if (line.kind == LineKind::Binding && Inside(BoxRect(window, line), x, y)) {
if (line.kind == LineKind::Binding && game::Inside(BoxRect(window, line), x, y)) {
return line.binding;
}
}
return -1;
}
// cUI_Button2 (vtable 0x895308, 0xbc bytes, constructor 0x72a180, plain
// ret). 0x7184e0 sets up Cancel and OK the same way: images (+0x54..+0x87),
// sounds (0x72acf0 -> +0x90..+0x9f), caption (0x72ad60 (text id, font 1) ->
// +0xa4 / +0xa0), caption offset (0x72ad40 -> +0xb0), name (0x732090).
constexpr size_t kButtonSize = 0xbc;
constexpr size_t kButtonLook = 0x54;
constexpr size_t kButtonLookEnd = 0x88;
constexpr size_t kButtonSounds = 0x90;
constexpr size_t kButtonSoundsEnd = 0xa0;
constexpr size_t kButtonCaptionOffset = 0xb0; // x, y (int16)
constexpr uint32_t kButtonFlags = 5; // 0x731e70, as 0x7184e0 passes
void PushControl(uint8_t* window, void* control) {
using PushFn = void(__fastcall*)(void*, void*, void**);
reinterpret_cast<PushFn>(0x006bcf50)(window + kWindowControls, nullptr, &control);
@@ -439,27 +424,17 @@ void PushControl(uint8_t* window, void* control) {
// "Alle zurücksetzen": a button with Cancel's look.
uint8_t* CreateResetButton(uint8_t* window) {
static const uint32_t kCaption = game::RegisterText(L"Alle zurücksetzen");
uint8_t* cancel = Field(window, kCancelButton);
void* memory = cancel ? game::GameNew(kButtonSize) : nullptr;
if (!memory) {
const uint8_t* cancel = Field(window, kCancelButton);
if (!cancel) {
return nullptr;
}
using ConstructFn = uint8_t*(__fastcall*)(void*, void*);
uint8_t* button = reinterpret_cast<ConstructFn>(0x0072a180)(memory, nullptr);
using FlagsFn = void(__fastcall*)(void*, void*, uint32_t);
reinterpret_cast<FlagsFn>(0x00731e70)(button, nullptr, kButtonFlags);
std::copy(cancel + kButtonLook, cancel + kButtonLookEnd, button + kButtonLook);
std::copy(cancel + kButtonSounds, cancel + kButtonSoundsEnd, button + kButtonSounds);
std::copy(cancel + kButtonCaptionOffset, cancel + kButtonCaptionOffset + 4, button + kButtonCaptionOffset);
auto* rect = reinterpret_cast<game::UiRect*>(button + game::kControlRect);
*rect = *reinterpret_cast<const game::UiRect*>(cancel + game::kControlRect);
rect->x = kResetX;
rect->w = kResetWidth;
using CaptionFn = void(__fastcall*)(void*, void*, uint32_t, uint32_t);
reinterpret_cast<CaptionFn>(0x0072ad60)(button, nullptr, kCaption, 1);
using NameFn = void(__fastcall*)(void*, void*, const char*);
reinterpret_cast<NameFn>(0x00732090)(button, nullptr, "UI_BTN_KEYS_RESET");
PushControl(window, button);
game::UiRect rect = *reinterpret_cast<const game::UiRect*>(cancel + game::kControlRect);
rect.x = kResetX;
rect.w = kResetWidth;
uint8_t* button = game::NewButtonLike(cancel, rect, kCaption, "UI_BTN_KEYS_RESET");
if (button) {
PushControl(window, button);
}
return button;
}
@@ -551,7 +526,7 @@ game::UiRect LootBoxRect(void* window, int row, int mode) {
int LootBoxAt(void* window, int x, int y) {
for (int category = 0; category < mods::lootfilter::kCategoryCount; ++category) {
for (int mode = 0; mode < mods::lootfilter::kModeCount; ++mode) {
if (Inside(LootBoxRect(window, category + 1, mode), x, y)) {
if (game::Inside(LootBoxRect(window, category + 1, mode), x, y)) {
return category * mods::lootfilter::kModeCount + mode;
}
}
@@ -690,7 +665,7 @@ int EventBody(void* context) {
if (g_capturing >= 0) {
StopCapture();
}
if (left && w->resetButton && Inside(game::ControlScreenRect(w->resetButton), x, y)) {
if (left && w->resetButton && game::Inside(game::ControlScreenRect(w->resetButton), x, y)) {
ResetPending(); // the button still gets the click (pressed look, sound)
break;
}
+22 -4
View File
@@ -17,6 +17,10 @@
// folder's files are copied into it (the originals stay where they were).
// Config.exe and gameserver.exe are separate programs and still use the game
// folder.
//
// The game's own view of the save folder can be moved into a subfolder
// (SetGameSaveSubfolder): the characters mod (characters.cpp) keeps the game's
// files in a work folder and its own files beside it.
#include "mods/userfolders.h"
@@ -136,6 +140,13 @@ Folders FindFolders() {
return folders;
}
// Below the save folder, with leading separator ("\characters\_work"); empty:
// the save folder itself.
std::string& GameSaveSubfolder() {
static std::string subfolder;
return subfolder;
}
// Looked up on first use, not at startup: the hooks are installed under the
// loader lock, where the shell's folder lookup must not run.
const Folders& UserFolders() {
@@ -166,15 +177,18 @@ const char* Redirect(const char* path, std::string& buffer) {
return nullptr;
}
const Folders& folders = UserFolders();
if (folders.save.empty()) {
return nullptr;
}
if (save) {
buffer = folders.save;
if (folders.save.empty() && GameSaveSubfolder().empty()) {
return nullptr;
}
buffer = (folders.save.empty() ? GameDir() + kSaveFolder : folders.save) + GameSaveSubfolder();
for (char c : rest.substr(first.size())) {
buffer += c == '/' ? '\\' : c;
}
} else {
if (folders.config.empty()) {
return nullptr;
}
buffer = folders.config + "\\" + kSettingsFile;
}
return buffer.c_str();
@@ -188,6 +202,10 @@ const char* Mapped(const char* path, std::string& buffer) {
} // namespace
void SetGameSaveSubfolder(const std::string& subfolder) {
GameSaveSubfolder() = subfolder;
}
std::string SaveDir() {
const Folders& folders = UserFolders();
return (folders.save.empty() ? GameDir() + kSaveFolder : folders.save) + "\\";
+5
View File
@@ -8,4 +8,9 @@ namespace mods::userfolders {
// or the game's own save\ when the Documents folder is unusable.
std::string SaveDir();
// The game's save folder becomes SaveDir() + `subfolder` (leading separator,
// e.g. "\characters\_work"); SaveDir() itself stays for sacredK's files.
// Set during static initialisation, before the game touches a file.
void SetGameSaveSubfolder(const std::string& subfolder);
} // namespace mods::userfolders