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
@@ -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
|
||||
Reference in new issue
Block a user