"Zuruecksetzen" in the load window lists the selected character's worlds and asks for confirmation. The world goes (a copy in characters\deleted), with an autosave of it, and the shared hero's story completion of that difficulty is cleared. The dialog is one generic list (title + entries). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
322 lines
12 KiB
C++
322 lines
12 KiB
C++
#include "mods/charfile.h"
|
|
|
|
#include <algorithm>
|
|
#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 kHeroStoryCompleted = 0xed; // one byte per difficulty (written at 0x604442..)
|
|
|
|
constexpr size_t kWorldCampaign = 0x24;
|
|
constexpr uint32_t kSaveModeAncaria = 2;
|
|
constexpr uint32_t kSaveModeUnderworld = 3;
|
|
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));
|
|
if (hero->size >= kHeroStoryCompleted + info.storyCompleted.size()) {
|
|
std::memcpy(info.storyCompleted.data(), header + kHeroStoryCompleted, info.storyCompleted.size());
|
|
}
|
|
info.world = file[2] == 'S';
|
|
if (info.world) {
|
|
info.difficulty = static_cast<int>(U32(file, kWorldDifficulty));
|
|
info.campaign = U32(file, kWorldCampaign) == kSaveModeUnderworld ? kCampaignUnderworld : kCampaignAncaria;
|
|
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;
|
|
}
|
|
|
|
bool SetStoryCompleted(Bytes& file, int difficulty, uint8_t count) {
|
|
const std::optional<Chunk> hero = FindChunk(file, kChunkHero);
|
|
if (!hero || difficulty < 0 || difficulty >= kDifficultyCount ||
|
|
hero->size < kHeroStoryCompleted + kDifficultyCount) {
|
|
return false;
|
|
}
|
|
file[hero->offset + kHeroStoryCompleted + difficulty] = count;
|
|
return true;
|
|
}
|
|
|
|
uint32_t CampaignSaveMode(int campaign) {
|
|
return campaign == kCampaignUnderworld ? kSaveModeUnderworld : kSaveModeAncaria;
|
|
}
|
|
|
|
Bytes BuildPreview(const Bytes& world, const std::string& description, int campaign) {
|
|
Bytes preview(world.begin(), world.begin() + std::min(world.size(), kChunkData));
|
|
preview.resize(kChunkData);
|
|
PutU32(preview, kWorldCampaign, CampaignSaveMode(campaign));
|
|
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 campaign, int difficulty) {
|
|
return (campaign == kCampaignUnderworld ? "underworld" : "ancaria") + std::to_string(difficulty);
|
|
}
|
|
|
|
namespace {
|
|
|
|
// Format of 2026-10-20 (before campaigns): "world<d>" were Ancaria's worlds;
|
|
// renamed when read, written under the new name at the next save.
|
|
constexpr char kOldWorldPrefix[] = "world";
|
|
|
|
std::string CurrentSectionName(const std::string& name) {
|
|
constexpr size_t length = sizeof(kOldWorldPrefix) - 1;
|
|
if (name.size() > length && name.compare(0, length, kOldWorldPrefix) == 0 &&
|
|
std::all_of(name.begin() + length, name.end(), [](char c) { return c >= '0' && c <= '9'; })) {
|
|
return WorldSection(kCampaignAncaria, std::stoi(name.substr(length)));
|
|
}
|
|
return name;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
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 = CurrentSectionName(std::string(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
|