#include "mods/charfile.h" #include #include #include #include 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 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 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 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(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(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(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 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 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(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 = 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 ReadFile(const std::string& path) { std::ifstream file(path, std::ios::binary); if (!file) { return std::nullopt; } return Bytes(std::istreambuf_iterator(file), std::istreambuf_iterator()); } 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(bytes.data()), static_cast(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" 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(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(entry), sizeof(entry))) { return false; } Section section; const auto* name = reinterpret_cast(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 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 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(bytes.data()), static_cast(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(bytes.size()); section.data = std::move(bytes); return; } } Section section; section.name = name; section.size = static_cast(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 contents; for (const Section& section : sections_) { std::optional 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(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(out.size())); PutU32(out, entry + kSectionNameSize + 4, static_cast(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