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:
1 parent
5df7efc198
commit
5bccb0734e
16 files changed
+2052
-64
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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(§ion.offset, entry + kSectionNameSize, 4);
|
||||
std::memcpy(§ion.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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) + "\\";
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user