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

+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