mirror of
https://github.com/TwilitRealm/dusklight
synced 2026-07-31 15:47:17 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21bdabe1d6 |
Vendored
+1
-1
Submodule extern/aurora updated: 0bddb86249...6c4c27f9e8
@@ -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
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
|
||||
#include <mods/api.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
#include <mods/service.hpp>
|
||||
#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);
|
||||
@@ -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<JUtility::TColor&>(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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -211,6 +211,7 @@ void ModLoader::init_services() {
|
||||
&svc::g_cameraModule,
|
||||
&svc::g_windowModule,
|
||||
&svc::g_gfxModule,
|
||||
&svc::g_saveModule,
|
||||
})
|
||||
{
|
||||
svc::register_module(*module);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <string_view>
|
||||
#include <fstream>
|
||||
|
||||
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<std::string, std::vector<uint8_t>>;
|
||||
|
||||
struct SlotStore {
|
||||
bool snapshotValid = false;
|
||||
uint32_t snapshotCrc = 0;
|
||||
std::map<std::string, BlobMap> 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<SlotStore, kSlotCount> s_slots;
|
||||
int32_t s_currentSlot = -1;
|
||||
bool s_sidecarLoaded = false;
|
||||
std::vector<SaveObserverRecord> 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<uint8_t>& 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<uint8_t>& out) {
|
||||
if (text.size() % 4 != 0) {
|
||||
return false;
|
||||
}
|
||||
static const auto lookup = [] {
|
||||
std::array<int8_t, 256> table;
|
||||
table.fill(-1);
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
table[static_cast<uint8_t>(kB64Chars[i])] = static_cast<int8_t>(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<uint8_t>(c)];
|
||||
if (value < 0 || pads != 0) {
|
||||
return false;
|
||||
}
|
||||
chunk = chunk << 6 | static_cast<uint32_t>(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<uint32_t>();
|
||||
}
|
||||
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<uint8_t> bytes;
|
||||
if (!base64_decode(encoded.get<std::string>(), 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<int32_t>(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<int32_t>(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<const uint8_t*>(data);
|
||||
(*blobs)[name] = std::vector<uint8_t>{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
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
// 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
|
||||
@@ -22,4 +22,8 @@ struct ModMenuTabEntry {
|
||||
|
||||
std::vector<ModMenuTabEntry> ui_mod_menu_tabs();
|
||||
|
||||
namespace ui_impl {
|
||||
bool ui_any_document_visible();
|
||||
}
|
||||
|
||||
} // namespace dusk::mods::svc
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#include "utilities.hpp"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace dusk::utils {
|
||||
constexpr std::array<uint32_t, 256> generate_crc32_table() {
|
||||
std::array<uint32_t, 256> 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<uint32_t, 256> kCrc32Table = generate_crc32_table();
|
||||
|
||||
// CRC-32 (IEEE, reflected)
|
||||
uint32_t CRC32(const void* data, size_t size) {
|
||||
const auto* bytes = static_cast<const uint8_t*>(data);
|
||||
uint32_t crc = ~0u;
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
crc = crc >> 8 ^ kCrc32Table[static_cast<uint8_t>(crc ^ bytes[i])];
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dusk::utils {
|
||||
uint32_t CRC32(const void* data, size_t size);
|
||||
}
|
||||
Reference in New Issue
Block a user