From 21bdabe1d611a72ccf1a10c88e5750fd6b1de9f8 Mon Sep 17 00:00:00 2001 From: TakaRikka Date: Fri, 31 Jul 2026 00:01:13 -0700 Subject: [PATCH] initial save service updated from encounter's impl --- extern/aurora | 2 +- files.cmake | 3 + sdk/include/mods/svc/save.h | 88 ++++++ src/d/d_file_select.cpp | 17 ++ src/d/d_menu_save.cpp | 5 + src/dusk/autosave.cpp | 5 + src/dusk/mods/svc/registry.cpp | 1 + src/dusk/mods/svc/registry.hpp | 1 + src/dusk/mods/svc/save.cpp | 522 +++++++++++++++++++++++++++++++++ src/dusk/mods/svc/save.hpp | 27 ++ src/dusk/mods/svc/ui.hpp | 4 + src/dusk/stubs.cpp | 2 +- src/dusk/utilities.cpp | 29 ++ src/dusk/utilities.hpp | 8 + 14 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 sdk/include/mods/svc/save.h create mode 100644 src/dusk/mods/svc/save.cpp create mode 100644 src/dusk/mods/svc/save.hpp create mode 100644 src/dusk/utilities.cpp create mode 100644 src/dusk/utilities.hpp diff --git a/extern/aurora b/extern/aurora index 0bddb86249..6c4c27f9e8 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 0bddb8624905d74cc47202a71962f9c9834c0933 +Subproject commit 6c4c27f9e8e40f584d27726655d80ec85a5a7d2c diff --git a/files.cmake b/files.cmake index 7eb8ba6038..3fda4fefbe 100644 --- a/files.cmake +++ b/files.cmake @@ -1502,6 +1502,8 @@ set(DUSK_FILES src/dusk/mods/svc/ui.hpp src/dusk/mods/svc/window.cpp src/dusk/mods/svc/window.hpp + src/dusk/mods/svc/save.cpp + src/dusk/mods/svc/save.hpp src/dusk/mouse.cpp src/dusk/scope_guard.hpp src/dusk/settings.cpp @@ -1581,6 +1583,7 @@ set(DUSK_FILES src/dusk/update_check.cpp src/dusk/update_check.hpp src/dusk/version.cpp + src/dusk/utilities.cpp src/helpers/batch.cpp src/helpers/endian.cpp src/helpers/offset_ptr.cpp diff --git a/sdk/include/mods/svc/save.h b/sdk/include/mods/svc/save.h new file mode 100644 index 0000000000..45c1c92bc7 --- /dev/null +++ b/sdk/include/mods/svc/save.h @@ -0,0 +1,88 @@ +#pragma once + +#include + +#ifdef __cplusplus +#include +#endif + +#define SAVE_SERVICE_ID "dev.twilitrealm.dusklight.save" +#define SAVE_SERVICE_MAJOR 1u +#define SAVE_SERVICE_MINOR 0u + +/* Handle for a save-observer registration. 0 is never a valid handle. */ +typedef uint64_t SaveObserverHandle; + +/* Total blob storage per mod per save slot. */ +#define SAVE_BLOB_BUDGET_BYTES 65536u + +/* + * Per-save-slot mod data. + * + * Named binary blobs that travel with a save slot: stored in a host-owned sidecar next to the + * emulated memory card, written when the game writes the card (in-game save, autosave), and + * following file-select copy and erase. Blobs are namespaced per mod — two mods can use the + * same name without collision. Format versioning inside a blob is the mod's own concern. + * + * Blob calls target the *current* slot, which exists from save load / new-save creation until + * the player returns to file select; MOD_UNAVAILABLE otherwise. Data written between game + * saves lives in memory only — like vanilla save state, it persists when the player saves. + * A new save clears the slot's previous blob data before on_new_save fires. + * + * Staleness: the sidecar snapshots a checksum of each slot's vanilla save data at write time + * and warns at load when they diverge (e.g. the card file was replaced externally); blobs are + * still delivered — treat them with suspicion in that session. + * + * Callbacks run on the game thread. Registrations are owned by the calling mod and removed + * automatically when it is disabled, reloaded, or fails. + */ + +/* slot is the save-file index (0..2). */ +typedef void (*SaveEventFn)(ModContext* ctx, uint32_t slot, void* user_data); + +typedef struct SaveService { + ServiceHeader header; + + /* + * Write a named blob for the current slot (copied). Fails with MOD_UNAVAILABLE when no + * slot is active or the mod's SAVE_BLOB_BUDGET_BYTES for the slot would be exceeded. + */ + ModResult (*set_blob)(ModContext* ctx, const char* name, const void* data, size_t size); + + /* + * Read a named blob from the current slot. With buf NULL, writes the blob's size to + * *inout_size. Otherwise *inout_size is the buffer capacity in, bytes written out. + * MOD_UNAVAILABLE when no slot is active or the blob does not exist. + */ + ModResult (*get_blob)(ModContext* ctx, const char* name, void* buf, size_t* inout_size); + + /* Remove a named blob from the current slot. */ + ModResult (*delete_blob)(ModContext* ctx, const char* name); + + /* + * Observe save lifecycle. Any callback may be NULL: + * - on_new_save: a new file was finalized in file select (name entry complete); the + * slot's blob store is empty — write initial blobs here. Like vanilla save data, + * nothing hits disk until the first in-game save. + * - on_save_loaded: a slot was loaded into the live game (file select or quick-load); + * blobs are readable. + * - on_save_written: the game persisted the slot (menu save or autosave); blob data + * captured with it is on disk. + */ + ModResult (*observe_saves)(ModContext* ctx, SaveEventFn on_new_save, + SaveEventFn on_save_loaded, SaveEventFn on_save_written, void* user_data, + SaveObserverHandle* out_handle); + + /* Remove an observer previously registered by the calling mod. */ + ModResult (*unobserve_saves)(ModContext* ctx, SaveObserverHandle handle); + + /* + * Read a named blob from any slot (0..2), including at file select when no slot is + * active. Same buffer contract as get_blob. The calling mod's own namespace only. + */ + ModResult (*peek_blob)(ModContext* ctx, uint32_t slot, const char* name, void* buf, size_t* inout_size); + +} SaveService; + +MOD_DECLARE_SERVICE( + SaveService, svc_save, SAVE_SERVICE_ID, SAVE_SERVICE_MAJOR, SAVE_SERVICE_MINOR); diff --git a/src/d/d_file_select.cpp b/src/d/d_file_select.cpp index 98d60fa195..936a368f81 100644 --- a/src/d/d_file_select.cpp +++ b/src/d/d_file_select.cpp @@ -28,6 +28,7 @@ #if TARGET_PC #include "dusk/menu_pointer.h" #include "helpers/string.hpp" +#include "dusk/mods/svc/save.hpp" namespace { constexpr u8 pointer_target(u8 group, u8 index) noexcept { @@ -255,6 +256,10 @@ dFile_select_c::~dFile_select_c() { void dFile_select_c::_create() { int i; +#if TARGET_PC + dusk::mods::svc::save_no_slot(); +#endif + mDoGph_gInf_c::setFadeColor(static_cast(g_blackColor)); stick = JKR_NEW STControl(2, 2, 1, 1, 0.9f, 0.5f, 0, 0x2000); @@ -1390,6 +1395,9 @@ void dFile_select_c::menuSelectStart() { mIsSelectEnd = true; mDataSelProc = DATASELPROC_NEXT_MODE_WAIT; dComIfGs_setDataNum(mSelectNum); +#if TARGET_PC + dusk::mods::svc::save_slot_loaded(mSelectNum, &mSaveData[mSelectNum]); +#endif } else if (mSelectMenuNum == 0) { mSelIcon->setAlphaRate(0.0f); yesnoMenuMoveAnmInitSet(0x473, 0x47d); @@ -1740,6 +1748,9 @@ void dFile_select_c::nameInput2() { case 2: dComIfGs_setHorseName(mpName->getInputStrPtr()); mIsSelectEnd = true; +#if TARGET_PC + dusk::mods::svc::save_slot_new(mSelectNum); +#endif mDataSelProc = DATASELPROC_NEXT_MODE_WAIT; } } @@ -2666,6 +2677,9 @@ void dFile_select_c::DataEraseWait2() { mDataSelProc = DATASELPROC_ERROR_MSG_PANE_MOVE; } else if (field_0x03b4 == 1) { mDoAud_seStart(Z2SE_SY_FILE_DELETE_OK, NULL, 0, 0); +#if TARGET_PC + dusk::mods::svc::save_slot_erased(mSelectNum); +#endif field_0x03b1 = 0; mDeleteEfPane[mSelectNum]->alphaAnimeStart(0); mFileInfoNoDatBasePane[mSelectNum]->alphaAnimeStart(0); @@ -2769,6 +2783,9 @@ void dFile_select_c::DataCopyWait2() { mDataSelProc = DATASELPROC_ERROR_MSG_PANE_MOVE; } else if (field_0x03b4 == 1) { mDoAud_seStart(Z2SE_SY_FILE_COPY_OK, NULL, 0, 0); +#if TARGET_PC + dusk::mods::svc::save_slot_copied(mCpDataNum, mCpDataToNum); +#endif field_0x03b1 = 0; mCopyEfPane[mSelectNum]->alphaAnimeStart(0); mCopyEfPane[mCpDataToNum]->alphaAnimeStart(0); diff --git a/src/d/d_menu_save.cpp b/src/d/d_menu_save.cpp index e9be233f6b..48f3f953ea 100644 --- a/src/d/d_menu_save.cpp +++ b/src/d/d_menu_save.cpp @@ -26,6 +26,7 @@ #include "dusk/frame_interpolation.h" #include "dusk/menu_pointer.h" #include "dusk/settings.h" +#include "dusk/mods/svc/save.hpp" #endif static int SelStartFrameTbl[3] = { @@ -1468,6 +1469,10 @@ void dMenu_save_c::memCardDataSaveWait2() { dComIfGs_setDataNum(mSelectedFile); dComIfGs_setNoFile(0); +#if TARGET_PC + dusk::mods::svc::save_slot_written(mSelectedFile, mSaveBuffer + mSelectedFile * QUEST_LOG_SIZE); +#endif + if (mUseType == TYPE_WHITE_EVENT || mUseType == TYPE_BLACK_EVENT) { headerTxtSet(0x530); // Saved. mWarning->closeInit(); diff --git a/src/dusk/autosave.cpp b/src/dusk/autosave.cpp index 57488a27a1..67d08565fb 100644 --- a/src/dusk/autosave.cpp +++ b/src/dusk/autosave.cpp @@ -1,6 +1,8 @@ #include "dusk/autosave.h" #include "dusk/ui/ui.hpp" #include "imgui/ImGuiConsole.hpp" +#include "mods/svc/config.hpp" +#include "mods/svc/save.hpp" bool shouldAutoSave = false; u8 mSaveBuffer[QUEST_LOG_SIZE * 3]; @@ -105,6 +107,9 @@ void waitingForWrite() { } void endAutoSave() { + const int slot = dComIfGs_getDataNum(); + dusk::mods::svc::save_slot_written(slot, mSaveBuffer + slot * QUEST_LOG_SIZE); + dusk::ui::push_toast({ .type = "autosave", .duration = std::chrono::milliseconds(1500), diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 622db55da9..67e1fe01d3 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -211,6 +211,7 @@ void ModLoader::init_services() { &svc::g_cameraModule, &svc::g_windowModule, &svc::g_gfxModule, + &svc::g_saveModule, }) { svc::register_module(*module); diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 4ba4f695ab..165375d29a 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -73,5 +73,6 @@ extern const ServiceModule g_gameModule; extern const ServiceModule g_cameraModule; extern const ServiceModule g_windowModule; extern const ServiceModule g_gfxModule; +extern const ServiceModule g_saveModule; } // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/save.cpp b/src/dusk/mods/svc/save.cpp new file mode 100644 index 0000000000..6596f45d8f --- /dev/null +++ b/src/dusk/mods/svc/save.cpp @@ -0,0 +1,522 @@ +#include "save.hpp" +#include "ui.hpp" + +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "dusk/main.h" +#include "dusk/utilities.hpp" +#include "mods/svc/save.h" + +#include +#include + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log("dusk::mods::save"); + +constexpr uint32_t kSlotCount = 3; +constexpr size_t kQuestLogSize = 0xA94; // static_asserted against QUEST_LOG_SIZE +constexpr int kSidecarVersion = 1; +constexpr const char* kSidecarName = "mod_saves.json"; +constexpr size_t kMaxBlobNameLength = 256; + +// Blob names sort deterministically in the sidecar; mod ids likewise. +using BlobMap = std::map>; + +struct SlotStore { + bool snapshotValid = false; + uint32_t snapshotCrc = 0; + std::map mods; // mod id -> blobs +}; + +struct SaveObserverRecord { + uint64_t handle = 0; + LoadedMod* mod = nullptr; + SaveEventFn onNewSave = nullptr; + SaveEventFn onLoaded = nullptr; + SaveEventFn onWritten = nullptr; + void* userData = nullptr; +}; + +// Game thread only (same rule as the other loader registries). +std::array s_slots; +int32_t s_currentSlot = -1; +bool s_sidecarLoaded = false; +std::vector s_observers; +uint64_t s_nextHandle = 1; +int32_t s_nextSeq = 1; + +// Position in m_mods (dependency-sorted load order) + 1; later-loaded mods win. +int32_t compute_mod_priority(const LoadedMod& mod) { + int32_t index = 0; + for (const auto& other : ModLoader::instance().mods()) { + ++index; + if (&other == &mod) { + return index; + } + } + return index + 1; +} + +std::filesystem::path sidecar_path() { + return dusk::ConfigPath / kSidecarName; +} + +// --- base64 (RFC 4648, no wrapping) --- + +constexpr char kB64Chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +std::string base64_encode(const std::vector& data) { + std::string out; + out.reserve((data.size() + 2) / 3 * 4); + for (size_t i = 0; i < data.size(); i += 3) { + const uint32_t rest = data.size() - i; + uint32_t chunk = data[i] << 16; + if (rest > 1) { + chunk |= data[i + 1] << 8; + } + if (rest > 2) { + chunk |= data[i + 2]; + } + out.push_back(kB64Chars[chunk >> 18 & 0x3F]); + out.push_back(kB64Chars[chunk >> 12 & 0x3F]); + out.push_back(rest > 1 ? kB64Chars[chunk >> 6 & 0x3F] : '='); + out.push_back(rest > 2 ? kB64Chars[chunk & 0x3F] : '='); + } + return out; +} + +bool base64_decode(const std::string& text, std::vector& out) { + if (text.size() % 4 != 0) { + return false; + } + static const auto lookup = [] { + std::array table; + table.fill(-1); + for (int i = 0; i < 64; ++i) { + table[static_cast(kB64Chars[i])] = static_cast(i); + } + return table; + }(); + out.clear(); + out.reserve(text.size() / 4 * 3); + for (size_t i = 0; i < text.size(); i += 4) { + uint32_t chunk = 0; + int pads = 0; + for (size_t j = 0; j < 4; ++j) { + const char c = text[i + j]; + if (c == '=' && i + 4 == text.size() && j >= 2) { + ++pads; + chunk <<= 6; + continue; + } + const int8_t value = lookup[static_cast(c)]; + if (value < 0 || pads != 0) { + return false; + } + chunk = chunk << 6 | static_cast(value); + } + out.push_back(chunk >> 16 & 0xFF); + if (pads < 2) { + out.push_back(chunk >> 8 & 0xFF); + } + if (pads < 1) { + out.push_back(chunk & 0xFF); + } + } + return true; +} + +// --- sidecar I/O --- + +void load_sidecar() { + if (s_sidecarLoaded) { + return; + } + s_sidecarLoaded = true; + std::ifstream in{sidecar_path()}; + if (!in.is_open()) { + return; + } + try { + const auto json = nlohmann::json::parse(in); + if (json.value("version", 0) != kSidecarVersion) { + Log.warn("mod save sidecar has unknown version {}; ignoring it", + json.value("version", 0)); + return; + } + const auto& slots = json.at("slots"); + for (uint32_t slot = 0; slot < kSlotCount && slot < slots.size(); ++slot) { + auto& store = s_slots[slot]; + const auto& slotJson = slots[slot]; + if (slotJson.contains("snapshot_crc32")) { + store.snapshotValid = true; + store.snapshotCrc = slotJson["snapshot_crc32"].get(); + } + const auto modsJson = slotJson.value("mods", nlohmann::json::object()); + for (const auto& [modId, blobs] : modsJson.items()) { + for (const auto& [name, encoded] : blobs.items()) { + std::vector bytes; + if (!base64_decode(encoded.get(), bytes)) { + Log.warn("mod save sidecar: bad blob '{}/{}' in slot {}; dropped", + modId, name, slot); + continue; + } + s_slots[slot].mods[modId][name] = std::move(bytes); + } + } + } + } catch (const std::exception& e) { + Log.error("failed to read mod save sidecar: {}", e.what()); + } +} + +void flush_sidecar() { + nlohmann::json slots = nlohmann::json::array(); + for (const auto& store : s_slots) { + nlohmann::json slotJson = nlohmann::json::object(); + if (store.snapshotValid) { + slotJson["snapshot_crc32"] = store.snapshotCrc; + } + nlohmann::json mods = nlohmann::json::object(); + for (const auto& [modId, blobs] : store.mods) { + if (blobs.empty()) { + continue; + } + nlohmann::json blobsJson = nlohmann::json::object(); + for (const auto& [name, bytes] : blobs) { + blobsJson[name] = base64_encode(bytes); + } + mods[modId] = std::move(blobsJson); + } + slotJson["mods"] = std::move(mods); + slots.push_back(std::move(slotJson)); + } + const nlohmann::json json{{"version", kSidecarVersion}, {"slots", std::move(slots)}}; + + const auto path = sidecar_path(); + const auto tempPath = path.string() + ".tmp"; + try { + { + std::ofstream out{tempPath, std::ios::trunc}; + out << json.dump(2); + if (!out.good()) { + throw std::runtime_error("write failed"); + } + } + std::filesystem::rename(tempPath, path); + } catch (const std::exception& e) { + Log.error("failed to write mod save sidecar: {}", e.what()); + std::error_code ec; + std::filesystem::remove(tempPath, ec); + } +} + +// --- observer notification --- + +void notify(uint32_t slot, SaveEventFn SaveObserverRecord::* which, const char* what) { + // Copy before invoking: callbacks may (un)register observers. + const auto observers = s_observers; + for (const auto& observer : observers) { + if (!observer.mod->active || observer.*which == nullptr) { + continue; + } + try { + (observer.*which)(observer.mod->context.get(), slot, observer.userData); + } catch (const std::exception& e) { + fail_mod(*observer.mod, MOD_ERROR, + fmt::format("exception in {} save callback: {}", what, e.what())); + } catch (...) { + fail_mod(*observer.mod, MOD_ERROR, + fmt::format("unknown exception in {} save callback", what)); + } + } +} + +} // namespace + +// --- seam entry points --- + +void save_slot_new(uint32_t slot) { + if (slot >= kSlotCount) { + return; + } + load_sidecar(); + auto& store = s_slots[slot]; + store.mods.clear(); + store.snapshotValid = false; + s_currentSlot = static_cast(slot); + Log.info("new save in slot {}; mod blob store cleared", slot); + notify(slot, &SaveObserverRecord::onNewSave, "new-save"); +} + +void save_slot_loaded(uint32_t slot, const void* slotData) { + if (slot >= kSlotCount) { + return; + } + load_sidecar(); + auto& store = s_slots[slot]; + if (store.snapshotValid && slotData != nullptr) { + const auto crc = utils::CRC32(slotData, kQuestLogSize); + if (crc != store.snapshotCrc) { + Log.warn("slot {} save data does not match the mod sidecar snapshot; mod save " + "data may be stale (card file changed externally?)", + slot); + } + } + s_currentSlot = static_cast(slot); + notify(slot, &SaveObserverRecord::onLoaded, "save-loaded"); +} + +void save_slot_written(uint32_t slot, const void* slotData) { + if (slot >= kSlotCount) { + return; + } + load_sidecar(); + auto& store = s_slots[slot]; + if (slotData != nullptr) { + store.snapshotValid = true; + store.snapshotCrc = utils::CRC32(slotData, kQuestLogSize); + } + flush_sidecar(); + notify(slot, &SaveObserverRecord::onWritten, "save-written"); +} + +void save_slot_copied(uint32_t fromSlot, uint32_t toSlot) { + if (fromSlot >= kSlotCount || toSlot >= kSlotCount || fromSlot == toSlot) { + return; + } + load_sidecar(); + s_slots[toSlot] = s_slots[fromSlot]; + flush_sidecar(); + Log.info("mod save data copied with slot {} -> {}", fromSlot, toSlot); +} + +void save_slot_erased(uint32_t slot) { + if (slot >= kSlotCount) { + return; + } + load_sidecar(); + s_slots[slot] = SlotStore{}; + flush_sidecar(); + Log.info("mod save data erased with slot {}", slot); +} + +void save_no_slot() { + s_currentSlot = -1; +} + +// --- loader plumbing (service surface) --- + +namespace { + +BlobMap* current_blobs(const LoadedMod& mod, bool create) { + if (s_currentSlot < 0) { + return nullptr; + } + load_sidecar(); + auto& mods = s_slots[s_currentSlot].mods; + if (!create) { + const auto it = mods.find(mod.metadata.id); + return it != mods.end() ? &it->second : nullptr; + } + return &mods[mod.metadata.id]; +} + +} // namespace + +ModResult save_set_blob(LoadedMod& mod, const char* name, const void* data, size_t size) { + auto* blobs = current_blobs(mod, true); + if (blobs == nullptr) { + return MOD_UNAVAILABLE; + } + size_t total = size; + for (const auto& [blobName, bytes] : *blobs) { + if (blobName != name) { + total += bytes.size(); + } + } + if (total > SAVE_BLOB_BUDGET_BYTES) { + Log.error("[{}] save blob '{}' rejected: {} bytes would exceed the {}-byte budget", + mod.metadata.id, name, total, SAVE_BLOB_BUDGET_BYTES); + return MOD_UNAVAILABLE; + } + const auto* bytes = static_cast(data); + (*blobs)[name] = std::vector{bytes, bytes + size}; + return MOD_OK; +} + +ModResult save_get_blob(LoadedMod& mod, const char* name, void* buf, size_t& inoutSize) { + auto* blobs = current_blobs(mod, false); + if (blobs == nullptr) { + return MOD_UNAVAILABLE; + } + const auto it = blobs->find(name); + if (it == blobs->end()) { + return MOD_UNAVAILABLE; + } + if (buf == nullptr) { + inoutSize = it->second.size(); + return MOD_OK; + } + if (inoutSize < it->second.size()) { + return MOD_INVALID_ARGUMENT; + } + std::memcpy(buf, it->second.data(), it->second.size()); + inoutSize = it->second.size(); + return MOD_OK; +} + +ModResult save_delete_blob(LoadedMod& mod, const char* name) { + auto* blobs = current_blobs(mod, false); + if (blobs == nullptr) { + return MOD_UNAVAILABLE; + } + return blobs->erase(name) != 0 ? MOD_OK : MOD_INVALID_ARGUMENT; +} + +ModResult save_observe(LoadedMod& mod, SaveEventFn onNewSave, SaveEventFn onLoaded, + SaveEventFn onWritten, void* userData, uint64_t& outHandle) { + auto& observer = s_observers.emplace_back(); + observer.handle = s_nextHandle++; + observer.mod = &mod; + observer.onNewSave = onNewSave; + observer.onLoaded = onLoaded; + observer.onWritten = onWritten; + observer.userData = userData; + outHandle = observer.handle; + return MOD_OK; +} + +ModResult save_unobserve(LoadedMod& mod, uint64_t handle) { + const auto removed = std::erase_if(s_observers, [&](const auto& observer) { + return observer.handle == handle && observer.mod == &mod; + }); + return removed != 0 ? MOD_OK : MOD_INVALID_ARGUMENT; +} + +ModResult save_peek_blob( + LoadedMod& mod, uint32_t slot, const char* name, void* buf, size_t& inoutSize) { + if (slot >= kSlotCount) { + return MOD_INVALID_ARGUMENT; + } + load_sidecar(); + const auto& mods = s_slots[slot].mods; + const auto modIt = mods.find(mod.metadata.id); + if (modIt == mods.end()) { + return MOD_UNAVAILABLE; + } + const auto it = modIt->second.find(name); + if (it == modIt->second.end()) { + return MOD_UNAVAILABLE; + } + if (buf == nullptr) { + inoutSize = it->second.size(); + return MOD_OK; + } + if (inoutSize < it->second.size()) { + return MOD_INVALID_ARGUMENT; + } + std::memcpy(buf, it->second.data(), it->second.size()); + inoutSize = it->second.size(); + return MOD_OK; +} + +void save_remove_mod(LoadedMod& mod) { + std::erase_if(s_observers, [&](const auto& observer) { return observer.mod == &mod; }); + // Slot blob data is deliberately kept: it belongs to the save, not the mod session. +} + +namespace { +bool is_valid_blob_name(const char* name) { + if (name == nullptr) { + return false; + } + const std::string_view view{name}; + return !view.empty() && view.size() <= kMaxBlobNameLength; +} + +ModResult save_set_blob_(ModContext* context, const char* name, const void* data, size_t size) { + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_blob_name(name) || (data == nullptr && size != 0) || + size > SAVE_BLOB_BUDGET_BYTES) + { + return MOD_INVALID_ARGUMENT; + } + return save_set_blob(*mod, name, data, size); +} + +ModResult save_get_blob_(ModContext* context, const char* name, void* buf, size_t* inoutSize) { + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_blob_name(name) || inoutSize == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return save_get_blob(*mod, name, buf, *inoutSize); +} + +ModResult save_delete_blob_(ModContext* context, const char* name) { + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_blob_name(name)) { + return MOD_INVALID_ARGUMENT; + } + return save_delete_blob(*mod, name); +} + +ModResult save_observe_saves_(ModContext* context, SaveEventFn onNewSave, SaveEventFn onLoaded, + SaveEventFn onWritten, void* userData, SaveObserverHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || (onNewSave == nullptr && onLoaded == nullptr && onWritten == nullptr)) { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = save_observe(*mod, onNewSave, onLoaded, onWritten, userData, handle); + if (outHandle != nullptr) { + *outHandle = handle; + } + return result; +} + +ModResult save_unobserve_saves_(ModContext* context, SaveObserverHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + return save_unobserve(*mod, handle); +} + +ModResult save_peek_blob_( + ModContext* context, uint32_t slot, const char* name, void* buf, size_t* inoutSize) { + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_blob_name(name) || inoutSize == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return save_peek_blob(*mod, slot, name, buf, *inoutSize); +} + +constexpr SaveService s_saveService{ + .header = SERVICE_HEADER(SaveService, SAVE_SERVICE_MAJOR, SAVE_SERVICE_MINOR), + .set_blob = save_set_blob_, + .get_blob = save_get_blob_, + .delete_blob = save_delete_blob_, + .observe_saves = save_observe_saves_, + .unobserve_saves = save_unobserve_saves_, + .peek_blob = save_peek_blob_, +}; + +} + +constinit const ServiceModule g_saveModule{ + .id = SAVE_SERVICE_ID, + .majorVersion = SAVE_SERVICE_MAJOR, + .minorVersion = SAVE_SERVICE_MINOR, + .service = &s_saveService, + .modDetached = save_remove_mod, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/save.hpp b/src/dusk/mods/svc/save.hpp new file mode 100644 index 0000000000..ef94df5759 --- /dev/null +++ b/src/dusk/mods/svc/save.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +// Save-lifecycle seam entry points for the mod save service, called from the slot-aware save +// paths (d_file_select.cpp, d_menu_save.cpp, d_s_logo.cpp quick-load, src/dusk/autosave.cpp). +// All three save slots live in one emulated-card file, so slot identity only exists at these +// game-layer call sites. +// slotData points at the slot's serialized save (QUEST_LOG_SIZE bytes) for the staleness +// snapshot; it is read synchronously during the call. + +namespace dusk::mods::svc { + +// A new file was finalized in file select (name entry complete) for slot. +void save_slot_new(uint32_t slot); +// slot was decoded into the live game info (file select start / quick-load). +void save_slot_loaded(uint32_t slot, const void* slotData); +// slot was successfully written to the card (menu save / autosave). +void save_slot_written(uint32_t slot, const void* slotData); +// File-select copy/erase (both rewrite the card; the sidecar follows). +void save_slot_copied(uint32_t fromSlot, uint32_t toSlot); +void save_slot_erased(uint32_t slot); +// File select opened: no slot is current until load/new fires again. +void save_no_slot(); + +} // namespace dusk::mods diff --git a/src/dusk/mods/svc/ui.hpp b/src/dusk/mods/svc/ui.hpp index 0f0efc7c9d..056ca88a98 100644 --- a/src/dusk/mods/svc/ui.hpp +++ b/src/dusk/mods/svc/ui.hpp @@ -22,4 +22,8 @@ struct ModMenuTabEntry { std::vector ui_mod_menu_tabs(); +namespace ui_impl { +bool ui_any_document_visible(); +} + } // namespace dusk::mods::svc diff --git a/src/dusk/stubs.cpp b/src/dusk/stubs.cpp index b283eb43bf..ac72f8377d 100644 --- a/src/dusk/stubs.cpp +++ b/src/dusk/stubs.cpp @@ -908,7 +908,7 @@ void AIInit(u8* stack) { // In a real scenario, it would set up the audio interface and prepare it for use. } -void AIInitDMA(u32 start_addr, u32 length) { +void AIInitDMA(uintptr_t start_addr, u32 length) { STUB_LOG(); } diff --git a/src/dusk/utilities.cpp b/src/dusk/utilities.cpp new file mode 100644 index 0000000000..de48d957ef --- /dev/null +++ b/src/dusk/utilities.cpp @@ -0,0 +1,29 @@ +#include "utilities.hpp" + +#include + +namespace dusk::utils { +constexpr std::array generate_crc32_table() { + std::array table{}; + for (uint32_t i = 0; i < 256; ++i) { + uint32_t ch = i; + for (size_t j = 0; j < 8; ++j) { + ch = (ch & 1) != 0 ? 0xEDB88320 ^ ch >> 1 : ch >> 1; + } + table[i] = ch; + } + return table; +} + +constexpr std::array kCrc32Table = generate_crc32_table(); + +// CRC-32 (IEEE, reflected) +uint32_t CRC32(const void* data, size_t size) { + const auto* bytes = static_cast(data); + uint32_t crc = ~0u; + for (size_t i = 0; i < size; ++i) { + crc = crc >> 8 ^ kCrc32Table[static_cast(crc ^ bytes[i])]; + } + return ~crc; +} +} \ No newline at end of file diff --git a/src/dusk/utilities.hpp b/src/dusk/utilities.hpp new file mode 100644 index 0000000000..e3cf72cb2b --- /dev/null +++ b/src/dusk/utilities.hpp @@ -0,0 +1,8 @@ +#pragma once + +#include +#include + +namespace dusk::utils { +uint32_t CRC32(const void* data, size_t size); +} \ No newline at end of file