Add compressed combined save format for PSP and Dolphin

Memory sticks/cards don't suit N+1 separate save files the way Linux's
filesystem does, so add an opt-in SAVE_DEVICE_DATA_RAW mode: settings and
all save slots get serialized to JSON, concatenated, zlib-compressed, and
framed with a magic/version/checksum/generation header, then read/written
as a single blob through one combined platform hook instead of four.

- PSP and Dolphin's SD/NAND backends write via a temp file + atomic rename,
  so a crash mid-write can never leave a half-written save behind.
- GameCube memory cards have no rename or resize primitive, so they
  ping-pong between two fixed files instead, picking whichever is valid and
  has the higher generation counter on load.
- Linux is untouched (still four separate JSON files); saveslot.h/
  savesettings.h drop their now-unnecessary pack(1) now that nothing
  persists them as raw bytes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 21:28:56 -05:00
parent 092e259a06
commit 82ae2bce9d
14 changed files with 1125 additions and 17 deletions
@@ -9,6 +9,7 @@
#include "save/savedevicedolphincard.h"
#include "save/save.h"
#include "assert/assert.h"
#include "util/memory.h"
errorret_t saveDeviceDolphinCardInit(savedevice_t *device) {
assertNotNull(device, "device cannot be null");
@@ -128,6 +129,159 @@ bool_t saveDeviceDolphinCardHasFreeSpace(const int32_t channel) {
return usedBlocks < (uint32_t)blockCount;
}
// One probe result for one of the two ping-pong slots.
typedef struct {
bool_t valid;
uint32_t generation;
uint8_t *buffer;// only set if keepBuffer was requested, else NULL
uint32_t len;
} savedevicedolphincardslot_t;
// Opens the given card file (if it exists) and validates it as a raw save
// blob via saveDeviceRawIsValid(). CARD_Read requires a 32-byte aligned
// buffer, so the read (and any buffer kept for the caller) uses memoryAlign
// rather than memoryAllocate. If keepBuffer is false, or the file doesn't
// exist/validate, no buffer is left allocated.
static savedevicedolphincardslot_t saveDeviceDolphinCardProbeSlot(
const int32_t channel,
const char_t *filename,
const bool_t keepBuffer
) {
savedevicedolphincardslot_t result = {0};
card_file file;
if(CARD_Open(channel, filename, &file) < 0) return result;
uint32_t len = (uint32_t)file.len;
uint8_t *buffer = memoryAlign(32, len);
int32_t readResult = CARD_Read(&file, buffer, len, 0);
CARD_Close(&file);
if(readResult < 0 || !saveDeviceRawIsValid(buffer, len, &result.generation)) {
memoryFree(buffer);
return result;
}
result.valid = true;
result.len = len;
if(keepBuffer) {
result.buffer = buffer;
} else {
memoryFree(buffer);
}
return result;
}
errorret_t saveDeviceDolphinCardDataWrite(
savedevice_t *device,
const uint8_t *buffer,
size_t size
) {
assertNotNull(device, "device cannot be null");
assertNotNull(buffer, "buffer cannot be null");
int32_t channel = device->platform.channel;
savedevicedolphincardslot_t slot0 = saveDeviceDolphinCardProbeSlot(
channel, SAVE_DEVICE_DOLPHIN_CARD_FILENAME_0, false
);
savedevicedolphincardslot_t slot1 = saveDeviceDolphinCardProbeSlot(
channel, SAVE_DEVICE_DOLPHIN_CARD_FILENAME_1, false
);
// Target whichever slot is NOT the currently valid+newest one, so a crash
// partway through this write can never touch the copy a load would use.
const char_t *targetFilename;
if(slot0.valid && slot1.valid) {
targetFilename = slot0.generation <= slot1.generation ?
SAVE_DEVICE_DOLPHIN_CARD_FILENAME_0 : SAVE_DEVICE_DOLPHIN_CARD_FILENAME_1;
} else if(slot0.valid) {
targetFilename = SAVE_DEVICE_DOLPHIN_CARD_FILENAME_1;
} else {
targetFilename = SAVE_DEVICE_DOLPHIN_CARD_FILENAME_0;
}
uint32_t sectorSize = 0;
if(CARD_GetSectorSize(channel, &sectorSize) < 0 || sectorSize == 0) {
errorThrow("Failed to query memory card sector size");
}
// CARD_Create's size must be a sector-size multiple, and CARD_Read always
// reports that full (possibly padded) size back via file.len - harmless,
// since saveDeviceRawIsValid() tolerates trailing padding beyond the
// header's declared payload length.
uint32_t paddedSize =
(uint32_t)(((size + sectorSize - 1) / sectorSize) * sectorSize);
uint8_t *paddedBuffer = memoryAlign(32, paddedSize);
memorySet(paddedBuffer, 0, paddedSize);
memoryCopy(paddedBuffer, buffer, size);
// The target file may already exist from an earlier generation at a
// different size - memory card files can't be resized, so it has to be
// deleted and recreated. This only ever touches the stale/inactive slot,
// never the one saveDeviceDolphinCardDataRead would currently pick.
CARD_Delete(channel, targetFilename);// Not an error if it doesn't exist.
card_file file;
int32_t createResult = CARD_Create(
channel, targetFilename, paddedSize, &file
);
if(createResult < 0) {
memoryFree(paddedBuffer);
errorThrow("Failed to create save data file: %s", targetFilename);
}
int32_t writeResult = CARD_Write(&file, paddedBuffer, paddedSize, 0);
CARD_Close(&file);
memoryFree(paddedBuffer);
if(writeResult < 0) errorThrow("Failed to write save data: %s", targetFilename);
errorOk();
}
errorret_t saveDeviceDolphinCardDataRead(
savedevice_t *device,
uint8_t **outBuffer,
size_t *outSize
) {
assertNotNull(device, "device cannot be null");
assertNotNull(outBuffer, "outBuffer cannot be null");
assertNotNull(outSize, "outSize cannot be null");
int32_t channel = device->platform.channel;
savedevicedolphincardslot_t slot0 = saveDeviceDolphinCardProbeSlot(
channel, SAVE_DEVICE_DOLPHIN_CARD_FILENAME_0, true
);
savedevicedolphincardslot_t slot1 = saveDeviceDolphinCardProbeSlot(
channel, SAVE_DEVICE_DOLPHIN_CARD_FILENAME_1, true
);
savedevicedolphincardslot_t *chosen = NULL;
if(slot0.valid && slot1.valid) {
chosen = slot0.generation >= slot1.generation ? &slot0 : &slot1;
} else if(slot0.valid) {
chosen = &slot0;
} else if(slot1.valid) {
chosen = &slot1;
}
if(chosen == NULL) {
// Neither slot has ever been saved to (or both failed validation) -
// not an error, leave the destination as-is.
*outBuffer = NULL;
*outSize = 0;
errorOk();
}
if(chosen != &slot0 && slot0.buffer != NULL) memoryFree(slot0.buffer);
if(chosen != &slot1 && slot1.buffer != NULL) memoryFree(slot1.buffer);
*outBuffer = chosen->buffer;
*outSize = chosen->len;
errorOk();
}
errorret_t saveDeviceDolphinCardDispose(savedevice_t *device) {
assertNotNull(device, "device cannot be null");
if(device->platform.mounted) {
@@ -17,6 +17,20 @@
#define SAVE_DEVICE_DOLPHIN_GAME_CODE "DUSK"
#endif
// Memory cards have no rename primitive and files can't be resized once
// created, so a single "current" file can't be replaced atomically the way
// the other backends do. Instead saveDeviceDolphinCardDataWrite() ping-pongs
// between these two fixed files - every store targets whichever one is NOT
// the currently valid+newest copy (by saveDeviceRawIsValid()'s generation
// counter), so a crash mid-write can only ever corrupt the copy nobody
// would load from; the other one stays intact as the fallback.
#ifndef SAVE_DEVICE_DOLPHIN_CARD_FILENAME_0
#define SAVE_DEVICE_DOLPHIN_CARD_FILENAME_0 "DUSKSAVE0"
#endif
#ifndef SAVE_DEVICE_DOLPHIN_CARD_FILENAME_1
#define SAVE_DEVICE_DOLPHIN_CARD_FILENAME_1 "DUSKSAVE1"
#endif
typedef struct {
// Set from this device's index into SAVE.devices - device 0 is
// CARD_SLOTA, device 1 is CARD_SLOTB.
@@ -82,3 +96,38 @@ bool_t saveDeviceDolphinCardHasFreeSpace(const int32_t channel);
* @return Error state if any.
*/
errorret_t saveDeviceDolphinCardDispose(savedevice_t *device);
/**
* Writes the combined save data blob out to whichever of the two fixed
* card files (see SAVE_DEVICE_DOLPHIN_CARD_FILENAME_0/1 above) is not
* currently the valid+newest copy - ping-pong in place of the atomic
* rename the other backends use, since the CARD API has neither a rename
* nor a way to resize an existing file.
*
* @param device The save device to write to.
* @param buffer The raw bytes to write.
* @param size The number of bytes to write.
* @return Error state if any.
*/
errorret_t saveDeviceDolphinCardDataWrite(
savedevice_t *device,
const uint8_t *buffer,
size_t size
);
/**
* Reads whichever of the two fixed card files is valid and has the higher
* generation counter. If neither file exists or validates, this is not an
* error - *outBuffer is set to NULL and *outSize to 0.
*
* @param device The save device to read from.
* @param outBuffer Receives a memoryAlign'd (32-byte) buffer the caller
* must free with memoryFree, or NULL if nothing has been saved yet.
* @param outSize Receives the number of bytes in *outBuffer.
* @return Error state if any.
*/
errorret_t saveDeviceDolphinCardDataRead(
savedevice_t *device,
uint8_t **outBuffer,
size_t *outSize
);
@@ -8,6 +8,10 @@
#include "save/savedevice.h"
#include "save/savedevicedolphinnand.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#define SAVE_DEVICE_DOLPHIN_NAND_PATH_MAX 64
errorret_t saveDeviceDolphinNandInit(savedevice_t *device) {
assertNotNull(device, "device cannot be null");
@@ -62,6 +66,107 @@ void saveDeviceDolphinNandCheckAvailability(savedevice_t *device) {
saveDeviceFireCallback(device);
}
static errorret_t saveDeviceDolphinNandGetDataPath(
char_t *dest,
const size_t destSize
) {
assertNotNull(dest, "dest cannot be null");
stringFormat(
dest, destSize, "%s/%s",
SAVE_DEVICE_DOLPHIN_NAND_DIR, SAVE_DEVICE_DOLPHIN_NAND_DATA_FILENAME
);
errorOk();
}
errorret_t saveDeviceDolphinNandDataWrite(
savedevice_t *device,
const uint8_t *buffer,
size_t size
) {
assertNotNull(device, "device cannot be null");
assertNotNull(buffer, "buffer cannot be null");
char_t finalPath[SAVE_DEVICE_DOLPHIN_NAND_PATH_MAX];
errorChain(saveDeviceDolphinNandGetDataPath(finalPath, sizeof(finalPath)));
char_t tempPath[SAVE_DEVICE_DOLPHIN_NAND_PATH_MAX];
stringFormat(tempPath, sizeof(tempPath), "%s.tmp", finalPath);
// Best-effort create - unlike POSIX's O_CREAT, ISFS_Open can't create a
// file that doesn't exist yet, so ISFS_CreateFile has to run first. Best
// effort since a leftover temp file from a previous crash is fine to
// clobber.
ISFS_Delete(tempPath);
int32_t createResult = ISFS_CreateFile(
tempPath, 0, ISFS_OPEN_RW, ISFS_OPEN_RW, ISFS_OPEN_RW
);
if(createResult != ISFS_OK) {
errorThrow("Failed to create save data file: %s", tempPath);
}
int32_t fd = ISFS_Open(tempPath, ISFS_OPEN_WRITE);
if(fd < 0) errorThrow("Failed to open save data for writing: %s", tempPath);
int32_t written = ISFS_Write(fd, buffer, (u32)size);
ISFS_Close(fd);
if(written < 0 || (size_t)written != size) {
errorThrow("Failed to write save data: %s", tempPath);
}
// Atomic replace: the temp file is fully written before it ever takes the
// final name, so a crash/power-loss here can never leave a half-written
// save behind - at worst the previous save survives untouched.
ISFS_Delete(finalPath);// Not an error if it doesn't exist yet.
if(ISFS_Rename(tempPath, finalPath) != ISFS_OK) {
errorThrow("Failed to finalize save data: %s", finalPath);
}
errorOk();
}
errorret_t saveDeviceDolphinNandDataRead(
savedevice_t *device,
uint8_t **outBuffer,
size_t *outSize
) {
assertNotNull(device, "device cannot be null");
assertNotNull(outBuffer, "outBuffer cannot be null");
assertNotNull(outSize, "outSize cannot be null");
char_t path[SAVE_DEVICE_DOLPHIN_NAND_PATH_MAX];
errorChain(saveDeviceDolphinNandGetDataPath(path, sizeof(path)));
int32_t fd = ISFS_Open(path, ISFS_OPEN_READ);
if(fd < 0) {
// No save yet - not an error.
*outBuffer = NULL;
*outSize = 0;
errorOk();
}
fstats stats;
if(ISFS_GetFileStats(fd, &stats) != ISFS_OK) {
ISFS_Close(fd);
errorThrow("Failed to stat save data: %s", path);
}
size_t size = (size_t)stats.file_length;
uint8_t *buffer = memoryAllocate(size);
int32_t readBytes = ISFS_Read(fd, buffer, (u32)size);
ISFS_Close(fd);
if(readBytes < 0 || (size_t)readBytes != size) {
memoryFree(buffer);
errorThrow("Failed to read save data: %s", path);
}
*outBuffer = buffer;
*outSize = size;
errorOk();
}
errorret_t saveDeviceDolphinNandDispose(savedevice_t *device) {
assertNotNull(device, "device cannot be null");
if(device->platform.initialized) {
@@ -19,6 +19,9 @@
#ifndef SAVE_DEVICE_DOLPHIN_NAND_DIR
#define SAVE_DEVICE_DOLPHIN_NAND_DIR "/title/00010001/44555348/data"
#endif
#ifndef SAVE_DEVICE_DOLPHIN_NAND_DATA_FILENAME
#define SAVE_DEVICE_DOLPHIN_NAND_DATA_FILENAME "save.dat"
#endif
typedef struct {
bool_t initialized;
@@ -58,3 +61,36 @@ void saveDeviceDolphinNandCheckAvailability(savedevice_t *device);
* @return Error state if any.
*/
errorret_t saveDeviceDolphinNandDispose(savedevice_t *device);
/**
* Writes the combined save data blob out to the NAND, replacing it
* atomically (write to a temp file, then swap it in via ISFS_Rename) so a
* crash mid-write can never leave a half-written file behind.
*
* @param device The save device to write to.
* @param buffer The raw bytes to write.
* @param size The number of bytes to write.
* @return Error state if any.
*/
errorret_t saveDeviceDolphinNandDataWrite(
savedevice_t *device,
const uint8_t *buffer,
size_t size
);
/**
* Reads the combined save data blob in from the NAND. If no save data
* exists yet, this is not an error - *outBuffer is set to NULL and
* *outSize to 0.
*
* @param device The save device to read from.
* @param outBuffer Receives a memoryAllocate'd buffer the caller must free
* with memoryFree, or NULL if nothing has been saved yet.
* @param outSize Receives the number of bytes in *outBuffer.
* @return Error state if any.
*/
errorret_t saveDeviceDolphinNandDataRead(
savedevice_t *device,
uint8_t **outBuffer,
size_t *outSize
);
@@ -8,9 +8,14 @@
#include "save/savedevice.h"
#include "save/savedevicedolphinsd.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include <fat.h>
#include <sys/stat.h>
#include <errno.h>
#include <stdio.h>
#define SAVE_DEVICE_DOLPHIN_SD_PATH_MAX 128
errorret_t saveDeviceDolphinSDInit(savedevice_t *device) {
assertNotNull(device, "device cannot be null");
@@ -62,6 +67,93 @@ void saveDeviceDolphinSDCheckAvailability(savedevice_t *device) {
saveDeviceFireCallback(device);
}
static errorret_t saveDeviceDolphinSDGetDataPath(
char_t *dest,
const size_t destSize
) {
assertNotNull(dest, "dest cannot be null");
stringFormat(
dest, destSize, "%s/%s",
SAVE_DEVICE_DOLPHIN_SD_DIRECTORY, SAVE_DEVICE_DOLPHIN_SD_DATA_FILENAME
);
errorOk();
}
errorret_t saveDeviceDolphinSDDataWrite(
savedevice_t *device,
const uint8_t *buffer,
size_t size
) {
assertNotNull(device, "device cannot be null");
assertNotNull(buffer, "buffer cannot be null");
char_t finalPath[SAVE_DEVICE_DOLPHIN_SD_PATH_MAX];
errorChain(saveDeviceDolphinSDGetDataPath(finalPath, sizeof(finalPath)));
char_t tempPath[SAVE_DEVICE_DOLPHIN_SD_PATH_MAX];
stringFormat(tempPath, sizeof(tempPath), "%s.tmp", finalPath);
FILE *file = fopen(tempPath, "wb");
if(file == NULL) errorThrow("Failed to open save data for writing: %s", tempPath);
size_t written = fwrite(buffer, 1, size, file);
fclose(file);
if(written != size) errorThrow("Failed to write save data: %s", tempPath);
// Atomic replace: the temp file is fully written before it ever takes the
// final name, so a crash/power-loss here can never leave a half-written
// save behind - at worst the previous save survives untouched.
remove(finalPath);// Not an error if it doesn't exist yet.
if(rename(tempPath, finalPath) != 0) {
errorThrow("Failed to finalize save data: %s", finalPath);
}
errorOk();
}
errorret_t saveDeviceDolphinSDDataRead(
savedevice_t *device,
uint8_t **outBuffer,
size_t *outSize
) {
assertNotNull(device, "device cannot be null");
assertNotNull(outBuffer, "outBuffer cannot be null");
assertNotNull(outSize, "outSize cannot be null");
char_t path[SAVE_DEVICE_DOLPHIN_SD_PATH_MAX];
errorChain(saveDeviceDolphinSDGetDataPath(path, sizeof(path)));
struct stat st;
if(stat(path, &st) != 0) {
// No save yet - not an error.
*outBuffer = NULL;
*outSize = 0;
errorOk();
}
size_t size = (size_t)st.st_size;
uint8_t *buffer = memoryAllocate(size);
FILE *file = fopen(path, "rb");
if(file == NULL) {
memoryFree(buffer);
errorThrow("Failed to open save data for reading: %s", path);
}
size_t readBytes = fread(buffer, 1, size, file);
fclose(file);
if(readBytes != size) {
memoryFree(buffer);
errorThrow("Failed to read save data: %s", path);
}
*outBuffer = buffer;
*outSize = size;
errorOk();
}
errorret_t saveDeviceDolphinSDDispose(savedevice_t *device) {
errorOk();
}
@@ -14,6 +14,9 @@
#ifndef SAVE_DEVICE_DOLPHIN_SD_DIRECTORY
#define SAVE_DEVICE_DOLPHIN_SD_DIRECTORY "/dusk/save"
#endif
#ifndef SAVE_DEVICE_DOLPHIN_SD_DATA_FILENAME
#define SAVE_DEVICE_DOLPHIN_SD_DATA_FILENAME "save.dat"
#endif
typedef struct {
void *nothing;
@@ -53,3 +56,36 @@ void saveDeviceDolphinSDCheckAvailability(savedevice_t *device);
* @return Error state if any.
*/
errorret_t saveDeviceDolphinSDDispose(savedevice_t *device);
/**
* Writes the combined save data blob out to the SD card, replacing it
* atomically (write to a temp file, then swap it in) so a crash mid-write
* can never leave a half-written file behind.
*
* @param device The save device to write to.
* @param buffer The raw bytes to write.
* @param size The number of bytes to write.
* @return Error state if any.
*/
errorret_t saveDeviceDolphinSDDataWrite(
savedevice_t *device,
const uint8_t *buffer,
size_t size
);
/**
* Reads the combined save data blob in from the SD card. If no save data
* exists yet, this is not an error - *outBuffer is set to NULL and
* *outSize to 0.
*
* @param device The save device to read from.
* @param outBuffer Receives a memoryAllocate'd buffer the caller must free
* with memoryFree, or NULL if nothing has been saved yet.
* @param outSize Receives the number of bytes in *outBuffer.
* @return Error state if any.
*/
errorret_t saveDeviceDolphinSDDataRead(
savedevice_t *device,
uint8_t **outBuffer,
size_t *outSize
);
+20
View File
@@ -25,6 +25,12 @@
#define saveDeviceCheckAvailabilityPlatform \
saveDeviceDolphinNandCheckAvailability
#define saveDevicePlatformDispose saveDeviceDolphinNandDispose
// ISFS_Rename exists, so NAND uses the same write-temp-then-rename
// pattern as PSP/Linux - see SAVE_DEVICE_DATA_RAW in save/savedevice.h.
#define SAVE_DEVICE_DATA_RAW
#define saveDeviceDataWritePlatform saveDeviceDolphinNandDataWrite
#define saveDeviceDataReadPlatform saveDeviceDolphinNandDataRead
#elif defined(DUSK_WII) && defined(DUSK_SAVE_WII_METHOD_SD)
#include "savedevicedolphinsd.h"
#define SAVE_DEVICE_COUNT 1
@@ -33,6 +39,13 @@
#define saveDeviceCheckAvailabilityPlatform \
saveDeviceDolphinSDCheckAvailability
#define saveDevicePlatformDispose saveDeviceDolphinSDDispose
// libfat gives standard POSIX rename(), so SD uses the same
// write-temp-then-rename pattern as PSP/Linux - see SAVE_DEVICE_DATA_RAW
// in save/savedevice.h.
#define SAVE_DEVICE_DATA_RAW
#define saveDeviceDataWritePlatform saveDeviceDolphinSDDataWrite
#define saveDeviceDataReadPlatform saveDeviceDolphinSDDataRead
#else
#include "savedevicedolphincard.h"
#define SAVE_DEVICE_COUNT 2
@@ -41,4 +54,11 @@
#define saveDeviceCheckAvailabilityPlatform \
saveDeviceDolphinCardCheckAvailability
#define saveDevicePlatformDispose saveDeviceDolphinCardDispose
// No rename or resize primitive on the CARD API, so this ping-pongs
// between two fixed files instead - see SAVE_DEVICE_DOLPHIN_CARD_FILENAME_0
// in savedevicedolphincard.h and SAVE_DEVICE_DATA_RAW in save/savedevice.h.
#define SAVE_DEVICE_DATA_RAW
#define saveDeviceDataWritePlatform saveDeviceDolphinCardDataWrite
#define saveDeviceDataReadPlatform saveDeviceDolphinCardDataRead
#endif