From 76079b6294e1f912c628908ecf877cba16ed1424 Mon Sep 17 00:00:00 2001 From: TakaRikka <38417346+TakaRikka@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:58:09 -0700 Subject: [PATCH] mods: save service (#2256) * initial save service updated from encounter's impl * Review cleanup --------- Co-authored-by: Luke Street --- docs/modding.md | 33 +++ extern/aurora | 2 +- files.cmake | 3 + sdk/include/mods/svc/save.h | 63 +++++ src/d/d_file_select.cpp | 17 ++ src/d/d_menu_save.cpp | 5 + src/d/d_s_logo.cpp | 16 +- src/dusk/autosave.cpp | 6 +- src/dusk/mods/svc/registry.cpp | 1 + src/dusk/mods/svc/registry.hpp | 1 + src/dusk/mods/svc/save.cpp | 434 +++++++++++++++++++++++++++++++++ src/dusk/mods/svc/save.hpp | 14 ++ src/dusk/stubs.cpp | 2 +- src/dusk/utilities.cpp | 95 ++++++++ src/dusk/utilities.hpp | 17 ++ 15 files changed, 701 insertions(+), 8 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/docs/modding.md b/docs/modding.md index 86af7ce34c..93c0ac4f14 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -340,6 +340,39 @@ Change callbacks fire on the game thread whenever the value changes at runtime ( Writes that store the same value are silent. Values applied from `config.json` or `--cvar` at registration do **not** fire callbacks; read the value after `register_var` for the starting state. +### SaveService (`mods/svc/save.h`) + +Stores named binary blobs for each save slot. Blob names are scoped to the calling mod, and each mod may store up to +`SAVE_BLOB_BUDGET_BYTES` per slot. The service copies data passed to `set_blob`. + +```cpp +IMPORT_SERVICE(SaveService, svc_save); + +struct MySaveData { + uint32_t version; + uint32_t counter; +}; + +MySaveData state{1, 42}; +svc_save->set_blob(mod_ctx, "state", &state, sizeof(state)); + +MySaveData loaded{}; +size_t loadedSize = sizeof(loaded); +if (svc_save->get_blob(mod_ctx, "state", &loaded, &loadedSize) == MOD_OK && + loadedSize == sizeof(loaded)) { + apply_state(loaded); +} +``` + +`set_blob`, `get_blob`, and `delete_blob` operate on the current slot, which is available after creating or loading a +save and unavailable at file select. Blob changes are written with the next game save. File-select copy and erase +operations update the blob data as well. Use `peek_blob` to read the calling mod's data from any slot; it uses the same +buffer contract as `get_blob`. Pass a `NULL` buffer to either read function to query the blob size. + +`observe_saves` registers callbacks for new, loaded, and written saves. New-save callbacks run after the slot's blobs +are cleared. Observers are removed automatically when the mod is detached, so the output handle is only needed for +manual unregistration. Save callbacks run on the game thread. + ### UiService (`mods/svc/ui.h`) Integrate seamlessly with Dusklight's UI system: add controls and buttons to your mod's detail pane in the Mods window, 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..0d6943f96a --- /dev/null +++ b/sdk/include/mods/svc/save.h @@ -0,0 +1,63 @@ +#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 + +/* 0 is never a valid handle. */ +typedef uint64_t SaveObserverHandle; + +/* Maximum combined blob size per mod and save slot. */ +#define SAVE_BLOB_BUDGET_BYTES 65536u + +/* + * Per-slot mod storage. + * + * Blobs are scoped to the calling mod and saved alongside each slot. Current-slot calls return + * MOD_UNAVAILABLE when no slot is active. + * + * Callbacks run on the game thread. Observer registrations are removed when the calling mod is + * detached. + */ + +/* slot is the save-file index (0..2). */ +typedef void (*SaveEventFn)(ModContext* ctx, uint32_t slot, void* user_data); + +typedef struct SaveService { + ServiceHeader header; + + /* Store a copy in the current slot. Returns MOD_UNAVAILABLE if the limit would be exceeded. */ + ModResult (*set_blob)(ModContext* ctx, const char* name, const void* data, size_t size); + + /* + * Read a blob from the current slot. Pass NULL for buf to query its size. Otherwise, + * inout_size is the buffer capacity on input and the blob size on success. Returns + * MOD_UNAVAILABLE if the blob does not exist. + */ + ModResult (*get_blob)(ModContext* ctx, const char* name, void* buf, size_t* inout_size); + + ModResult (*delete_blob)(ModContext* ctx, const char* name); + + /* + * Register save lifecycle callbacks. At least one callback is required. on_new_save runs + * after clearing the slot's blobs, on_save_loaded after activating the slot, and + * on_save_written after a successful game save. out_handle may be NULL. + */ + ModResult (*observe_saves)(ModContext* ctx, SaveEventFn on_new_save, SaveEventFn on_save_loaded, + SaveEventFn on_save_written, void* user_data, SaveObserverHandle* out_handle); + + ModResult (*unobserve_saves)(ModContext* ctx, SaveObserverHandle handle); + + /* Read the calling mod's blob from any slot. Uses the get_blob buffer contract. */ + 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/d/d_s_logo.cpp b/src/d/d_s_logo.cpp index 69087a78c2..366d87c02b 100644 --- a/src/d/d_s_logo.cpp +++ b/src/d/d_s_logo.cpp @@ -25,8 +25,9 @@ #ifdef TARGET_PC #include "dusk/logging.h" -#include "dusk/version.hpp" #include "dusk/main.h" +#include "dusk/mods/svc/save.hpp" +#include "dusk/version.hpp" #include "m_Do/m_Do_MemCard.h" #endif @@ -771,15 +772,20 @@ void dScnLogo_c::nextSceneChange() { status = mDoMemCd_LoadSync(buf, sizeof(buf), 0); // Wait until the card is loaded } while (status == 0); - + + const uint32_t saveSlot = dusk::SaveRequested - 1; if (status == 1) { - dComIfGs_setCardToMemory(buf, dusk::SaveRequested - 1); + dComIfGs_setCardToMemory(buf, saveSlot); } else { dComIfGs_init(); } - + dComIfGs_setNoFile(dusk::SaveRequested); - dComIfGs_setDataNum(dusk::SaveRequested-1); + dComIfGs_setDataNum(saveSlot); + if (status == 1) { + dusk::mods::svc::save_slot_loaded( + saveSlot, buf + saveSlot * SAVEDATA_SIZE); + } dComIfGs_gameStart(); diff --git a/src/dusk/autosave.cpp b/src/dusk/autosave.cpp index 57488a27a1..bae2812a1f 100644 --- a/src/dusk/autosave.cpp +++ b/src/dusk/autosave.cpp @@ -1,6 +1,7 @@ #include "dusk/autosave.h" #include "dusk/ui/ui.hpp" #include "imgui/ImGuiConsole.hpp" +#include "mods/svc/save.hpp" bool shouldAutoSave = false; u8 mSaveBuffer[QUEST_LOG_SIZE * 3]; @@ -105,6 +106,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), @@ -114,4 +118,4 @@ void endAutoSave() { void toggleAutoSave(bool enabled) { shouldAutoSave = enabled; -} \ No newline at end of file +} 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..9930129be5 --- /dev/null +++ b/src/dusk/mods/svc/save.cpp @@ -0,0 +1,434 @@ +#include "save.hpp" + +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "d/d_save.h" +#include "dusk/main.h" +#include "dusk/mods/loader/loader.hpp" +#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_assert(kQuestLogSize == QUEST_LOG_SIZE); +constexpr int kSidecarVersion = 1; +constexpr const char* kSidecarName = "mod_saves.json"; +constexpr size_t kMaxBlobNameLength = 256; + +using BlobMap = std::map>; + +struct SlotStore { + bool snapshotValid = false; + uint32_t snapshotCrc = 0; + std::map mods; +}; + +struct SaveObserverRecord { + uint64_t handle = 0; + LoadedMod* mod = nullptr; + SaveEventFn onNewSave = nullptr; + SaveEventFn onLoaded = nullptr; + SaveEventFn onWritten = nullptr; + void* userData = nullptr; +}; + +std::array s_slots; +int32_t s_currentSlot = -1; +bool s_sidecarLoaded = false; +std::vector s_observers; +uint64_t s_nextHandle = 1; + +std::filesystem::path sidecar_path() { + return dusk::ConfigPath / kSidecarName; +} + +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 (!utils::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] = utils::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); + } +} + +void notify(uint32_t slot, SaveEventFn SaveObserverRecord::* which, const char* what) { + // Callbacks may unregister 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 + +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; +} + +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; }); + // Blob data persists across mod reloads. +} + +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_, +}; + +} // namespace + +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..89460af146 --- /dev/null +++ b/src/dusk/mods/svc/save.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace dusk::mods::svc { + +void save_slot_new(uint32_t slot); +void save_slot_loaded(uint32_t slot, const void* slotData); +void save_slot_written(uint32_t slot, const void* slotData); +void save_slot_copied(uint32_t fromSlot, uint32_t toSlot); +void save_slot_erased(uint32_t slot); +void save_no_slot(); + +} // 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..92fe96c086 --- /dev/null +++ b/src/dusk/utilities.cpp @@ -0,0 +1,95 @@ +#include "utilities.hpp" + +#include + +namespace dusk::utils { +namespace { + +constexpr char kBase64Chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +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(); + +} // namespace + +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(kBase64Chars[chunk >> 18 & 0x3F]); + out.push_back(kBase64Chars[chunk >> 12 & 0x3F]); + out.push_back(rest > 1 ? kBase64Chars[chunk >> 6 & 0x3F] : '='); + out.push_back(rest > 2 ? kBase64Chars[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(kBase64Chars[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; +} + +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; +} +} // namespace dusk::utils diff --git a/src/dusk/utilities.hpp b/src/dusk/utilities.hpp new file mode 100644 index 0000000000..ab3d9c2198 --- /dev/null +++ b/src/dusk/utilities.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include +#include + +#ifdef crc32 +// miniz defines crc32 as an alias. +#undef crc32 +#endif + +namespace dusk::utils { +std::string base64_encode(const std::vector& data); +bool base64_decode(const std::string& text, std::vector& out); +uint32_t crc32(const void* data, size_t size); +} // namespace dusk::utils