10 Commits

Author SHA1 Message Date
YourWishes aa0180571e Unify save system into one save.h/.c; diverge storage format per platform
Renames savefile_t to saveslot_t and folds last session's standalone
settings.h/.c module back in as savemeta_t, so there's one save system
(SAVE.slots[] + SAVE.meta) instead of two parallel ones - while letting
each platform pick its own physical format for the two concepts:

- Linux now writes human-editable JSON (slot0.json, settings.json, ...)
  via yyjson's mutable writer API, so players can hand-fix a bad setting.
- PSP folds meta into the same sceUtilitySavedata binary payload as its
  one save slot (SAVE_SLOT_COUNT_MAX=1 there - a future save picker will
  let players manage multiple named saves via the OS's own browser).
- GameCube consolidates the 3 per-slot memory card files and the separate
  settings file into one combined card file.

Also fixes two bugs surfaced while building this: the CRC finalize step
seeked to a hardcoded offset (only safe for one section per file, breaks
once meta+slots share a buffer), and save.c's async/sync dispatch left an
unconditional fallback call that doesn't exist on PSP-only platforms.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 18:35:06 -05:00
YourWishes 7f7be39230 Split device settings out of the per-slot save file
Deadzone (and future prefs like locale) now live in their own
settingsfile_t/settings.c, loaded eagerly at boot and saved immediately
on Apply, instead of inside savefile_t - a setting shouldn't reset or
diverge just because the player is on a different save slot, and this
also fixes settings changes not actually reaching disk until the next
full game Save.

PSP settings use a new plain sceIo path rather than sceUtilitySavedata,
since that dialog would flash its native icon on every settings tweak.
GameCube reuses the save system's existing memory card mount rather than
mounting it twice (settingsInit() now runs after saveInit()).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:54:15 -05:00
YourWishes 4d95415232 Move global item collected-state, deadzone, and story flags into save file
Adds three new pieces of save-file state, all following the same shape:
the save file is the single source of truth, not a separate live runtime
copy that gets synced in/out.

- globalitemstore.h/.c: per-global-entity-ID "collected" flags
  (savefile_t.globalItemCollected), so a global item entity's init
  callback can check whether it was already picked up in a prior session
  without needing to keep the entity itself alive to remember that.

- Gamepad deadzone: removed input_t.deadzone entirely. The setting UI and
  every platform's actual deadzone-applying code (inputGetDeadzoneDolphin/
  SDL2, previously hardcoded per-platform literals that the settings menu
  didn't actually affect) now read savefile_t.deadzone directly via
  saveGet(SAVE_ACTIVE_SLOT). Default lives in savefile.h
  (SAVE_DEADZONE_DEFAULT), stamped onto every slot in saveInit().

- Story flags: STORY_FLAG_VALUES (a live, codegen-initialized array) is
  replaced by savefile_t.storyFlags, read/written via the existing
  storyFlagGet()/storyFlagSet() call sites (now macros/functions over the
  active save file instead of a separate array). tools/story.py now
  generates STORY_FLAG_DEFAULTS (const) instead; storyFlagInitDefaults()
  stamps those onto a save the first time it's used (file->exists false),
  called from rpgInit().

Added SAVE_ACTIVE_SLOT (0) to savefile.h as the one shared "which slot is
actually being played" constant, replacing three different local/implicit
0s (uigamemenu.c, rpg.c, and now the settings/input call sites).

Verified round-trip on Linux (all three together in one save/load cycle);
both Linux and PSP build clean.
2026-08-04 11:10:44 -05:00
YourWishes 2cbd80a004 Check for existing save data before saving, confirm before creating new
uiGameMenuSave() now attempts a real load first (via the existing generic
saveLoad()/saveExists() primitives) instead of writing blind. If a save
already exists, it saves straight over it as before. If not, it prompts
via the existing uiConfirm dialog ("No save data found. Create a new
save?") before writing - this is exactly the flow GameCube needs (no
native OS save browser to lean on, unlike PSP), but implemented generically
so it also applies correctly on every other platform without any
platform-specific UI code: saveIsAvailable()/saveExists() already reflect
each platform's real state (e.g. Dolphin's memory-card presence and
existing-file checks), so the same logic just does the right thing
everywhere.

Verified the two branches directly on Linux (temporarily wiring the same
saveLoad -> check -> uiConfirmOpen sequence into rpgInit): with no save
file, saveExists() is false and the confirm dialog opens; with one already
written, it's true and the confirm dialog is correctly skipped. Not
verified via actual menu navigation (no input-injection tooling available
here) or on Dolphin (no devkitPPC toolchain in this environment).
2026-08-04 10:33:57 -05:00
YourWishes 9abf8101da PSP: save through the real sceUtilitySavedata API, not raw file I/O
Rewrote savepsp.c/savestreampsp.c to use sceUtilitySavedataInitStart/
Update/GetStatus/ShutdownStart instead of sceIoOpen/Read/Write, so PSP
saves get a proper OS-generated PARAM.SFO (title/savedataTitle/detail) and
show up correctly in the native save browser.

This dialog spans multiple frames and, per this project's prior experience
with the network config dialog, must be pumped non-blocking one step per
real engine frame rather than blocked on synchronously - a raw-sceGu
blocking loop already froze the app on real hardware for that dialog,
since pspGL owns the GU context. So save.h's saveWrite()/saveLoad() are
now callback-based (savecallback_t onComplete) instead of returning a
result directly, mirroring networkRequestConnection()'s shape, with a new
saveUpdate() (wired into engineUpdate()) pumping the active op each frame.
Linux/Dolphin behavior is unchanged - their fallback path in save.c still
completes synchronously, just via an immediate callback call instead of a
direct return.

Two real bugs found via PPSSPP testing (not just code review): SAVE/LOAD
modes show a confirm screen even for brand-new data, which blocks forever
headlessly - switched to AUTOSAVE/AUTOLOAD, which write/read silently and
generate the identical PARAM.SFO. And PPSSPP's dialog status goes straight
from QUIT to NONE without a separately observable FINISHED in between,
which the first version misread as "disappeared without a result" even on
a successful save - fixed by tracking whether QUIT was already seen.

Confirmed end-to-end in PPSSPP: write, dialog completes, PARAM.SFO +
encrypted save.bin appear on the virtual memory stick, and a subsequent
load decrypts/deserializes back to the exact original data. Not tested on
real PSP hardware.
2026-08-04 09:54:45 -05:00
YourWishes 24badd06a5 Add player name field to save file, save it out as a round-trip test
Adds savefile_t.playerName (SAVE_PLAYER_NAME_MAX) serialized via the
existing saveFileReadString/WriteString helpers, and stamps + saves it in
rpgInit() as a test that the save system now persists actual game data,
not just the header/version. Verified manually: written bytes end in
"Dusk\0" immediately after the version field, and loading it back returns
the same string.
2026-08-04 08:59:29 -05:00
YourWishes 7a03ef8eaf Re-enable save system, fix header/version stamping, handle missing media
- Fixed the actual reason saving never worked on any platform: saveWrite()
  never stamped file->header/file->version before serializing, so every
  written save file had a zeroed magic header and failed its own
  validation on the next load. Confirmed via a manual write/load round
  trip that this alone fully explains "saving doesn't work."
- Re-enabled saveInit()/saveDispose() in engine.c (previously commented out
  under "Temporarily disable save code").
- Added SAVE.available + saveIsAvailable(), refreshed by every real
  save/load/delete attempt. saveInit() no longer treats an unreachable
  save medium as fatal to booting - it logs and continues, since a missing
  memory card/stick shouldn't prevent playing.
- Hardened PSP's saveInitPSP() to actually detect a missing memory stick
  (sceIoGetstat on ms0:/) instead of assuming success, and fixed
  single-level sceIoMkdir to build the full PSP/SAVEDATA directory chain.
- Added busy-retry (CARD_ERROR_BUSY) and a not-mounted guard to Dolphin's
  live savestreamdolphin.c path, extending the same handling already
  backported into savedolphin.c.
- Added a "Save" entry to the game menu wired to saveWrite(0), showing a
  clear message on success, on failure, and when saveIsAvailable() is false.
2026-08-04 08:30:24 -05:00
YourWishes f3ea507313 Fixed save crash
Backported from branch ac2 (commit 85b61097) - CARD_Mount was being called
without CARD_Init first, leaving per-channel control blocks and the DSP
unlock sequence unset. On real Dolphin/hardware this surfaced as a hard
MMIO crash instead of a clean CARD_ERROR_* failure.

Co-Authored-By: Dominic Masters <dominic@domsplace.com>
2026-08-04 08:12:57 -05:00
YourWishes 4b0388a0e1 Chunk streaming concurrency, entity slot fix, and map-data-driven spawns
- Allow 2 chunks to be mid-load concurrently instead of 1 (MAP_CHUNK_LOAD_CONCURRENCY).
- Fix entitySetChunk silently losing track of an entity when its target chunk's
  entity slots are full - it now stays detached (and retries later) instead of
  claiming a chunk that never actually registered it.
- DCF format bumped to v5: chunks can now declare entity spawns (global/NPC via
  the existing entityglobal registry, or one-shot item pickups) and map area
  triggers, resolved via a new callback-ID registry (mapareagloballist.h)
  mirroring the entity one. rpg.c's hardcoded TEST entity/item/area spawns are
  gone - chunk_0_0_0.json now carries that data instead. The player is still
  bootstrapped in code since it isn't map-authored content.
2026-08-04 07:37:48 -05:00
YourWishes a84137b5ff del md 2026-08-04 06:58:03 -05:00
69 changed files with 2438 additions and 1291 deletions
-455
View File
@@ -1,455 +0,0 @@
# Dusk — Claude Code rules
## File headers
Every C, H, and JS file starts with:
```c
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
```
JS files use `//` comment style instead.
---
## C conventions
### Types
Always use the project-defined aliases instead of bare C primitives:
| Use | Not |
|-----------|--------------|
| `bool_t` | `bool` |
| `int_t` | `int` |
| `float_t` | `float` |
| `char_t` | `char` |
Use `uint8_t`, `uint16_t`, `int32_t`, etc. for fixed-width integers.
All struct and enum types end in `_t` (`animation_t`, `errorret_t`, …).
### Naming
- **Functions** — snake_case, prefixed with their module:
`assetLock()`, `entityPositionInit()`, `moduleAssetBatchCtor()`
- **Struct fields** — camelCase: `keyframeCount`, `localPosition`
- **Macros / constants** — UPPER_SNAKE_CASE:
`ENTITY_ID_INVALID`, `ERROR_OK`, `COMPONENT_TYPE_COUNT`
- **Files** — snake_case matching the primary type: `entityposition.c`,
`moduleassetbatch.c`
### Header files (`.h`)
- Use `#pragma once` — no include guards.
- Declare every public function, `#define`, and `extern` global.
- Write a JSDoc block (`/** … */`) above every declaration explaining
purpose, `@param`s, and `@returns`.
- Only include headers that the `.h` file itself strictly requires for
the types it exposes. Move everything else to the `.c` file.
Do not use forward declarations as a workaround — use the real
include in the `.c` file instead.
### Implementation files (`.c`)
- Contain function bodies only; no declarations.
- Pull in whatever additional includes the implementation needs.
- Do not use `static` or `inline` on **functions**. Every function,
including internal helpers, must be declared in the matching `.h` and
defined in the `.c` file. Internal helpers belong near the bottom of
the `.c` file, not at the top with a `static` qualifier.
`static` and `inline` on functions are only appropriate when the
function body is written directly inside a `.h` file.
`static` on **variables** (file-scope state) is fine and expected.
### Formatting
- Hard-wrap all lines at **80 characters**.
### Error handling
Return `errorret_t` from fallible functions. Use these macros:
```c
errorOk(); // return success
errorThrow("msg %d", val); // return failure with message
errorChain(someCall()); // propagate failure, continue on success
errorIsOk(ret) / errorIsNotOk(ret) // test a result
errorCatch(ret); // handle + free an error
```
Never return raw error codes or use `errno` for in-engine errors.
### Memory
Use the project allocator — never raw `malloc`/`free`:
```c
memoryAllocate(size) // allocate
memoryFree(ptr) // free
memoryZero(dest, size) // zero a block
memoryCopy(dest, src, size) // copy
```
### Asserts
Prefer specific assert macros over bare `assert()`:
```c
assertNotNull(ptr, "msg");
assertTrue(cond, "msg");
assertFalse(cond, "msg");
assertUnreachable("msg");
assertIsMainThread("msg");
```
---
## Build system
Each subdirectory has its own `CMakeLists.txt` that adds sources with:
```cmake
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
myfile.c
)
```
Never add source files to the root `CMakeLists.txt` directly.
---
## Platform support
### Targets
Set `DUSK_TARGET_SYSTEM` at CMake configure time to select a platform:
| `DUSK_TARGET_SYSTEM` | Macro defined | Platform |
|----------------------|-------------------|------------------|
| `linux` | `DUSK_LINUX` | Linux desktop |
| `knulli` | `DUSK_KNULLI` | Knulli (handheld)|
| `psp` | `DUSK_PSP` | Sony PSP |
| `vita` | `DUSK_VITA` | PlayStation Vita |
| `gamecube` | `DUSK_GAMECUBE` | Nintendo GameCube|
| `wii` | `DUSK_WII` | Nintendo Wii |
### Layer structure
```
src/dusk/ core, platform-agnostic game logic
src/duskgl/ OpenGL abstraction (Linux, Knulli, PSP, Vita)
src/dusksdl2/ SDL2 window + input (Linux, Knulli, PSP, Vita)
src/dusklinux/ Linux + Knulli platform impl
src/duskpsp/ PSP platform impl
src/duskvita/ Vita platform impl
src/duskdolphin/ GameCube / Wii platform impl (no SDL2/OpenGL)
```
Dolphin is the only target that bypasses SDL2 and OpenGL entirely —
it uses native GameCube/Wii rendering and input APIs.
### Platform guards
Use the compile-time macros for platform-specific code:
```c
#ifdef DUSK_PSP
// PSP-only path
#elif defined(DUSK_GAMECUBE) || defined(DUSK_WII)
// GameCube / Wii path
#else
// Generic / Linux fallback
#endif
```
Additional capability macros set per-target:
`DUSK_SDL2`, `DUSK_OPENGL`, `DUSK_OPENGL_ES`, `DUSK_OPENGL_LEGACY`,
`DUSK_INPUT_GAMEPAD`, `DUSK_INPUT_KEYBOARD`, `DUSK_INPUT_POINTER`,
`DUSK_PLATFORM_ENDIAN_BIG` / `DUSK_PLATFORM_ENDIAN_LITTLE`.
### Abstraction pattern
Platform-specific implementations are wired in via `#define` macros in
each platform's `displayplatform.h` / `inputplatform.h` etc., which
the core calls through. Functions that a platform does not support are
simply left undefined — the core guards calls with `#ifdef`.
### Adding platform-specific code
- Put it under `src/dusk<platform>/` in the matching subsystem folder.
- Gate any core call-site with the appropriate `#ifdef DUSK_<PLATFORM>`
or capability macro.
- Keep the `src/dusk/` core free of platform ifdefs — delegate through
the platform header macros instead.
---
## Adding a new asset loader type
1. Add an enum value to `assetloadertype_t` (before `_COUNT`) in
`src/dusk/asset/loader/assetloader.h`.
2. Add fields to the input/loading/output unions in `assetloader.h`.
3. Implement `assetXxxLoaderSync`, `assetXxxLoaderAsync`, and
`assetXxxDispose` in a new `src/dusk/asset/loader/xxx/` directory.
4. Register the three callbacks in `ASSET_LOADER_CALLBACKS[]` in
`src/dusk/asset/loader/assetloader.c`.
5. If user-facing, create a JS module (see below) and a `.d.ts` file.
---
## Adding a new entity component
1. Create `src/dusk/entity/component/<category>/entityMyComp.h/.c` with
struct `entityMyComp_t`, `entityMyCompInit()`, and optionally
`entityMyCompDispose()`.
2. Add the include to `src/dusk/entity/componentlist.h` header block.
3. Add a row to `src/dusk/entity/componentlist.h`:
```c
X(MYCOMP, entityMyComp_t, myComp, entityMyCompInit, NULL, NULL)
```
This auto-generates the enum, union field, and definition entry.
4. If JS-facing, create the script module and `.d.ts` (see below).
---
## Adding a new script (JS) module
1. Create `src/dusk/script/module/<category>/moduleMyMod.h/.c`.
- Declare `extern scriptproto_t MODULE_MYMOD_PROTO;` in the header.
- Use `moduleBaseFunction(name)` to define JS-callable functions.
- Register props/funcs in `moduleMyModInit()` with
`scriptProtoDefineProp` / `scriptProtoDefineFunc` /
`scriptProtoDefineStaticFunc`.
2. `#include` the header in
`src/dusk/script/module/modulelist.c` and call
`moduleMyModInit()` in `moduleListInit()` (and `Dispose` in
`moduleListDispose()`).
3. For component modules also register in
`src/dusk/script/module/entity/component/modulecomponentlist.c`
so `entity.add()` returns the typed wrapper.
4. Create `types/<category>/mymod.d.ts` and add a
`/// <reference path="..." />` line to `types/index.d.ts`.
---
## Script module type declarations
Whenever a `src/dusk/script/module/**/*.c` file is created or modified,
check whether the corresponding `types/**/*.d.ts` needs updating and
apply any changes before finishing the task.
---
## JavaScript (asset scripts)
- Use `var` for module-level state; `const` for values that never
change.
- Always use semicolons.
- Scene objects are plain objects (`var scene = {}`) with assigned
methods.
- Export via `module.exports = scene`.
- Async scene init should use `async function` and `await`.
---
## Coding style
### ASCII only
Source files (`.c`, `.h`, `.js`) must contain only ASCII characters (U+0000U+007F).
Non-ASCII characters are banned even in comments and string literals.
Use ASCII-only substitutes instead:
- `--` or `-` instead of `` (em dash)
- `->` instead of `` (arrow)
- `x` or `*` instead of `×` (multiplication)
Only non-script asset files (e.g. `.po` locale files) may contain non-ASCII text.
### Indentation
2 spaces. No tabs.
### Keyword and operator spacing
No space between a keyword or function name and its opening parenthesis:
```c
if(!ptr) return;
for(uint8_t i = 0; i < count; i++) {
while(entry->state != DONE) {
switch(type) {
sizeof(assetbatch_t)
memoryZero(ptr, size)
```
Spaces around all binary operators and after every comma:
```c
pos->flags |= ENTITY_POSITION_FLAG_WORLD_DIRTY;
(size_t)end - (size_t)start
foo(a, b, c)
```
### Braces
Opening brace on the **same line** as the statement (K&R style) for all
constructs — functions, `if`, `else`, `for`, `while`, `switch`:
```c
void assetEntryLock(assetentry_t *entry) {
...
}
if(dirty) {
...
} else {
...
}
```
### Guard returns
Short guards go on one line with no braces:
```c
if(!ptr) return;
if(!b || !b->batch) return jerry_undefined();
if(!(flags & DIRTY)) return;
```
### Blank lines
- One blank line between functions; no blank line at the start or end of
a function body.
- One blank line between logical blocks inside a function body.
- No trailing blank lines at the end of a file.
### Pointer placement
`*` is attached to the variable name, not the type:
```c
assetentry_t *entry
const char_t *name
void *ptr
uint8_t *d = (uint8_t *)dest;
```
### Casts
Space between cast and operand:
```c
(assetbatch_t *)user
(uint8_t *)dest
(textureformat_t)v
```
### Return
No parentheses around the return value:
```c
return ptr;
return MEMORY_POINTERS_IN_USE;
```
### switch / case
`case` indented 2 spaces from `switch`; body indented 2 more from `case`:
```c
switch(type) {
case ASSET_LOADER_TYPE_TEXTURE:
descs[i].input.texture = (textureformat_t)v;
break;
default:
break;
}
```
### Multi-line function signatures
When parameters don't fit on one line, put each on its own line indented
2 spaces; the closing `) {` (definition) or `);` (declaration) goes on
its own line at column 0:
```c
void assetEntryInit(
assetentry_t *entry,
const char_t *name,
const assetloadertype_t type,
assetloaderinput_t *input
) {
errorret_t memoryCompare(
const void *a,
const void *b,
const size_t size
);
```
### Structs and enums
Anonymous inner struct or enum with a `typedef`, `_t` suffix, closing
brace and name on the same line:
```c
typedef struct {
errorcode_t code;
char_t *message;
} errorstate_t;
typedef enum {
ASSET_LOADER_TYPE_NULL,
ASSET_LOADER_TYPE_COUNT
} assetloadertype_t;
```
### Designated initialisers
Spaces inside braces; `.field = value`:
```c
jsassetentry_t e = { .entry = entry };
assetbatchloadedpend_t init = { .batch = batch };
```
### Ternary operator
Spaces around `?` and `:`:
```c
const float val = psx > 0.0f ? pt[0][0] / psx : 0.0f;
```
### const placement
`const` before the type, `*` attached to the variable:
```c
const char_t *name
const void *src
const size_t size
```
### Comments in `.c` files
- Do not use section dividers (`/* ---- ... ---- */`). Just let the
functions follow one another with a single blank line between them.
- Multi-line explanatory comments inside function bodies use `//` lines:
```c
// Script modules are freed; orphaned JS wrapper objects now get GC'd
// so their finalizers fire before assetDispose() checks ref counts.
jerry_heap_gc(JERRY_GC_PRESSURE_HIGH);
```
- Do not use `/* */` for inline or inline-block comments inside `.c`
function bodies.
### Comments in `.h` files
Every public declaration gets a Javadoc block (`/** … */`) with
`@param` and `@returns` where relevant. Keep it on the lines immediately
above the declaration with no blank line in between.
---
## Color system
Colors are defined in `src/dusk/display/color.csv` and code-generated
into a `color.h` header by `tools/color/csv/__main__.py`.
Each row in the CSV has `name,r,g,b,a` with channel values in `[0.0, 1.0]`.
The script emits four `#define` variants per color plus a bare alias:
```
COLOR_<NAME>_4B color4b(r8, g8, b8, a8) // default alias target
COLOR_<NAME>_3B color3b(r8, g8, b8)
COLOR_<NAME>_3F color3f(rf, gf, bf)
COLOR_<NAME>_4F color4f(rf, gf, bf, af)
COLOR_<NAME> COLOR_<NAME>_4B
```
`color_t` is `color4b_t` (four `uint8_t` channels).
To add a new color, append a row to `color.csv` and rebuild — do not
hand-edit the generated header.
---
## Tests
- Tests live in `test/` mirroring `src/dusk/` structure.
- Use cmocka; include `dusktest.h`.
- Test functions: `static void test_something(void **state)`.
- After each test, assert `memoryGetAllocatedCount() == 0` to catch
leaks.
- Build with `-DDUSK_BUILD_TESTS=ON`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -56,6 +56,10 @@ msgstr "Items"
msgid "ui.game_menu.settings"
msgstr "Settings"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save"
msgstr "Save"
msgid "item.potion.name"
msgstr "Potion"
+4
View File
@@ -57,6 +57,10 @@ msgstr "Objetos"
msgid "ui.game_menu.settings"
msgstr "Configuración"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save"
msgstr "Guardar"
#: src/dusk/rpg/item/item.json
msgid "item.potion.name"
msgstr "Poción"
+4
View File
@@ -57,6 +57,10 @@ msgstr "アイテム"
msgid "ui.game_menu.settings"
msgstr "設定"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save"
msgstr "セーブ"
#: src/dusk/rpg/item/item.json
msgid "item.potion.name"
msgstr "ポーション"
+22
View File
@@ -2179,5 +2179,27 @@
0
]
}
],
"entities": [
{
"type": "global",
"globalId": 3,
"pos": [8, 8, 1]
},
{
"type": "item",
"itemId": 1,
"quantity": 1,
"pos": [12, 2, 0]
}
],
"areas": [
{
"min": [11, 3, 0],
"max": [16, 9, 10],
"callbackId": 1,
"notify": 3,
"trigger": 6
}
]
}
@@ -14,6 +14,26 @@
#include "asset/loader/assetloader.h"
#include "asset/asset.h"
// Reads a little-endian int16 from a potentially-unaligned offset into a
// worldunit_t, advancing *offset past it.
static worldunit_t assetChunkReadWorldUnit(
const uint8_t *data,
size_t *offset
) {
int16_t value;
memoryCopy(&value, data + *offset, sizeof(int16_t));
*offset += sizeof(int16_t);
return (worldunit_t)endianLittleToHost16((uint16_t)value);
}
static worldpos_t assetChunkReadWorldPos(const uint8_t *data, size_t *offset) {
worldpos_t pos;
pos.x = assetChunkReadWorldUnit(data, offset);
pos.y = assetChunkReadWorldUnit(data, offset);
pos.z = assetChunkReadWorldUnit(data, offset);
return pos;
}
errorret_t assetChunkLoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Should be called from an async thread.");
@@ -146,6 +166,62 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
out->meshOffsets[m][2] = endianLittleToHostFloat(out->meshOffsets[m][2]);
}
out->entitySpawnCount = data[offset];
offset += sizeof(uint8_t);
assertTrue(
out->entitySpawnCount <= CHUNK_ENTITY_SPAWN_COUNT_MAX,
"Chunk entity spawn count exceeds maximum."
);
for(uint8_t s = 0; s < out->entitySpawnCount; s++) {
chunkentityspawn_t *spawn = &out->entitySpawns[s];
spawn->kind = (chunkentityspawnkind_t)data[offset];
offset += sizeof(uint8_t);
uint16_t a;
memoryCopy(&a, data + offset, sizeof(uint16_t));
a = endianLittleToHost16(a);
offset += sizeof(uint16_t);
uint8_t b = data[offset];
offset += sizeof(uint8_t);
if(spawn->kind == CHUNK_ENTITY_SPAWN_KIND_ITEM) {
spawn->globalId = 0;
spawn->itemId = a;
spawn->itemQuantity = b;
} else {
spawn->globalId = a;
spawn->itemId = 0;
spawn->itemQuantity = 0;
}
spawn->position = assetChunkReadWorldPos(data, &offset);
}
out->areaSpawnCount = data[offset];
offset += sizeof(uint8_t);
assertTrue(
out->areaSpawnCount <= CHUNK_AREA_COUNT_MAX,
"Chunk area spawn count exceeds maximum."
);
for(uint8_t s = 0; s < out->areaSpawnCount; s++) {
chunkareaspawn_t *area = &out->areaSpawns[s];
area->min = assetChunkReadWorldPos(data, &offset);
area->max = assetChunkReadWorldPos(data, &offset);
uint16_t callbackId;
memoryCopy(&callbackId, data + offset, sizeof(uint16_t));
area->callbackId = endianLittleToHost16(callbackId);
offset += sizeof(uint16_t);
area->notify = data[offset];
offset += sizeof(uint8_t);
area->trigger = data[offset];
offset += sizeof(uint8_t);
}
memoryFree(data);
loading->loading.chunk.data = NULL;
+28 -1
View File
@@ -9,7 +9,7 @@
#include "asset/assetfile.h"
#include "rpg/overworld/chunk.h"
#define ASSET_CHUNK_FILE_VERSION 4
#define ASSET_CHUNK_FILE_VERSION 5
typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t;
@@ -33,12 +33,39 @@ typedef struct {
uint8_t modelIndex;
} assetchunkloaderloading_t;
typedef enum {
CHUNK_ENTITY_SPAWN_KIND_GLOBAL,
CHUNK_ENTITY_SPAWN_KIND_ITEM
} chunkentityspawnkind_t;
typedef struct {
chunkentityspawnkind_t kind;
uint16_t globalId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_GLOBAL.
uint16_t itemId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM.
uint8_t itemQuantity; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM.
worldpos_t position;
} chunkentityspawn_t;
typedef struct {
worldpos_t min;
worldpos_t max;
uint16_t callbackId; // Index into MAP_AREA_CALLBACK_LIST.
uint8_t notify;
uint8_t trigger;
} chunkareaspawn_t;
typedef struct {
tile_t *tiles;
uint8_t meshCount;
char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
vec3 meshOffsets[CHUNK_MESH_COUNT_MAX];
assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX];
uint8_t entitySpawnCount;
chunkentityspawn_t entitySpawns[CHUNK_ENTITY_SPAWN_COUNT_MAX];
uint8_t areaSpawnCount;
chunkareaspawn_t areaSpawns[CHUNK_AREA_COUNT_MAX];
} assetchunkoutput_t;
/**
+3 -2
View File
@@ -37,7 +37,7 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
errorChain(systemInit());
errorChain(inputInit());
errorChain(assetInit());
// errorChain(saveInit());
errorChain(saveInit());
errorChain(localeManagerInit());
errorChain(displayInit());
errorChain(uiInit());
@@ -62,6 +62,7 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
errorret_t engineUpdate(void) {
// Order here is important.
errorChain(networkUpdate());
errorChain(saveUpdate());
timeUpdate();
inputUpdate();
consoleUpdate();
@@ -88,7 +89,7 @@ errorret_t engineDispose(void) {
errorChain(uiDispose());
consoleDispose();
errorChain(displayDispose());
// errorChain(saveDispose());
errorChain(saveDispose());
errorChain(assetDispose());
errorOk();
-1
View File
@@ -17,7 +17,6 @@ input_t INPUT;
errorret_t inputInit(void) {
memoryZero(&INPUT, sizeof(input_t));
INPUT.deadzone = INPUT_DEADZONE_DEFAULT;
for(uint8_t i = 0; i < INPUT_ACTION_COUNT; i++) {
INPUT.actions[i].action = (inputaction_t)i;
-4
View File
@@ -12,15 +12,11 @@
#define INPUT_LISTENER_PRESSED_MAX 16
#define INPUT_LISTENER_RELEASED_MAX INPUT_LISTENER_PRESSED_MAX
#define INPUT_DEADZONE_DEFAULT 0.1f
typedef struct {
inputactiondata_t actions[INPUT_ACTION_COUNT];
inputplatform_t platform;
/** User-configured gamepad axis deadzone (0.0f to 1.0f). */
float_t deadzone;
} input_t;
extern input_t INPUT;
+13 -1
View File
@@ -10,6 +10,7 @@
#include "util/memory.h"
#include "time/time.h"
#include "util/math.h"
#include "console/console.h"
#include "rpg/overworld/map.h"
#include "rpg/overworld/maparea.h"
#include "rpg/overworld/chunk.h"
@@ -292,7 +293,10 @@ void entitySetChunk(entity_t *entity, const uint8_t chunkIndex) {
}
}
entity->chunkIndex = chunkIndex;
// Only claim the new chunk once actually inserted into one of its slots -
// otherwise entity->chunkIndex would point at a chunk that doesn't know
// about this entity, so it would never be torn down on unload.
entity->chunkIndex = 0xFF;
if(chunkIndex != 0xFF) {
chunk_t *next = mapGetChunk(chunkIndex);
@@ -300,8 +304,16 @@ void entitySetChunk(entity_t *entity, const uint8_t chunkIndex) {
for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) {
if(next->entities[i] != 0xFF) continue;
next->entities[i] = entity->id;
entity->chunkIndex = chunkIndex;
break;
}
if(entity->chunkIndex != chunkIndex) {
consolePrint(
"entitySetChunk: chunk %u has no free entity slots, entity %u "
"left untracked",
chunkIndex, entity->id
);
}
}
}
}
+4 -1
View File
@@ -142,7 +142,10 @@ uint8_t entityGetAvailable();
/**
* Assigns an entity to a chunk, removing it from its current chunk first.
* Pass 0xFF as chunkIndex to detach the entity from any chunk.
* Pass 0xFF as chunkIndex to detach the entity from any chunk. If the
* target chunk has no free entity slots, the entity is left detached
* (chunkIndex 0xFF) rather than assigned to a chunk that isn't actually
* tracking it - entityUpdateChunk will keep retrying on subsequent moves.
*
* @param entity Pointer to the entity.
* @param chunkIndex Index of the chunk to assign to, or 0xFF for none.
@@ -6,4 +6,5 @@
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
globalitemstore.c
)
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "globalitemstore.h"
#include "assert/assert.h"
bool_t globalItemStoreIsCollected(
const saveslot_t *file, const entityglobalid_t id
) {
assertNotNull(file, "Save slot cannot be NULL");
assertTrue(id < SAVE_GLOBAL_ITEM_COUNT_MAX, "Global item ID out of range");
return file->globalItemCollected[id];
}
void globalItemStoreSetCollected(
saveslot_t *file, const entityglobalid_t id, const bool_t collected
) {
assertNotNull(file, "Save slot cannot be NULL");
assertTrue(id < SAVE_GLOBAL_ITEM_COUNT_MAX, "Global item ID out of range");
file->globalItemCollected[id] = collected;
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "save/saveslot.h"
#include "rpg/entity/entity.h"
/**
* Checks whether the global entity with the given ID has already been
* marked collected in the given save slot's data - e.g. so a global item
* entity's init callback (see rpg/entity/global/entitygloballist.h) can
* skip spawning itself if the player already picked it up in a prior
* session, without needing to keep the entity itself alive to remember
* that (which would need render/collision special-casing - this doesn't).
*
* @param file The save slot to check.
* @param id The global entity ID to check.
* @return True if already marked collected.
*/
bool_t globalItemStoreIsCollected(
const saveslot_t *file, const entityglobalid_t id
);
/**
* Marks the global entity with the given ID as collected (or not) in the
* given save slot's data. Does not itself write the save to disk - call
* saveWriteSlot() separately once ready to persist it.
*
* @param file The save slot to write into.
* @param id The global entity ID to mark.
* @param collected The new collected state.
*/
void globalItemStoreSetCollected(
saveslot_t *file, const entityglobalid_t id, const bool_t collected
);
+2
View File
@@ -14,3 +14,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
tileshape.c
)
add_subdirectory(global)
+9
View File
@@ -12,6 +12,8 @@
#define CHUNK_MESH_COUNT_MAX 10
#define CHUNK_MESH_NAME_MAX 64
#define CHUNK_ENTITY_COUNT_MAX 10
#define CHUNK_ENTITY_SPAWN_COUNT_MAX 8
#define CHUNK_AREA_COUNT_MAX 4
typedef struct assetentry_s assetentry_t;
@@ -28,6 +30,13 @@ typedef struct chunk_s {
assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX];
uint8_t entities[CHUNK_ENTITY_COUNT_MAX];
// Map area IDs (into MAP_AREAS) spawned from this chunk's file data.
// Removed via mapAreaRemove when this chunk unloads, and re-added if it
// streams back in - unlike entities (tracked by current position via
// entities[] above), areas have no position-based ownership mechanism of
// their own, so the owning chunk must track and tear them down directly.
uint8_t areas[CHUNK_AREA_COUNT_MAX];
} chunk_t;
/**
@@ -0,0 +1,9 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
)
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "mapareaglobaldefs.h"
#include "mapareagloballist.h"
#define MAP_AREA_CALLBACK_LIST_COUNT ( \
sizeof(MAP_AREA_CALLBACK_LIST) / \
sizeof(MAP_AREA_CALLBACK_LIST[0]) \
)
//EOF
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "rpg/overworld/maparea.h"
#define MAP_AREA_CALLBACK(id) \
static void MAP_AREA_CALLBACK_##id(entity_t *entity, const uint8_t trigger)
#define MAP_AREA_CALLBACK_REF(id) \
MAP_AREA_CALLBACK_##id
//EOF
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "mapareaglobaldefs.h"
#include "console/console.h"
MAP_AREA_CALLBACK(1) {
consolePrint("mapAreaGlobalCallback 1: trigger=%u", trigger);
}
// Index 0 is reserved (not a valid callback ID) - see mapAreaAddGlobal.
static const mapareacallback_t MAP_AREA_CALLBACK_LIST[] = {
NULL,
MAP_AREA_CALLBACK_REF(1),
};
//EOF
+112 -40
View File
@@ -14,9 +14,20 @@
#include "event/event.h"
#include "util/string.h"
#include "rpg/entity/global/entityglobal.h"
#include "rpg/entity/item/entityitem.h"
#include "rpg/overworld/maparea.h"
map_t MAP;
// Clears chunk's mid-load slot, if it currently holds one.
static void mapChunkLoadingSlotClear(chunk_t *chunk) {
for(uint32_t i = 0; i < MAP_CHUNK_LOAD_CONCURRENCY; i++) {
if(MAP.loadingChunks[i] != chunk) continue;
MAP.loadingChunks[i] = NULL;
return;
}
}
errorret_t mapInit() {
memoryZero(&MAP, sizeof(map_t));
MAP.loaded = true;
@@ -105,7 +116,7 @@ errorret_t mapDispose() {
void mapChunkUnload(chunk_t *chunk) {
mapChunkLoadQueueRemove(chunk);
if(MAP.loadingChunk == chunk) MAP.loadingChunk = NULL;
mapChunkLoadingSlotClear(chunk);
for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) {
if(chunk->entities[i] == 0xFF) continue;
@@ -119,6 +130,12 @@ void mapChunkUnload(chunk_t *chunk) {
memorySet(chunk->entities, 0xFF, sizeof(chunk->entities));
for(uint8_t i = 0; i < CHUNK_AREA_COUNT_MAX; i++) {
if(chunk->areas[i] == 0xFF) continue;
mapAreaRemove(chunk->areas[i]);
}
memorySet(chunk->areas, 0xFF, sizeof(chunk->areas));
if(chunk->dcfEntry != NULL) {
eventUnsubscribe(&chunk->dcfEntry->onLoaded, mapChunkLoaded);
eventUnsubscribe(&chunk->dcfEntry->onError, mapChunkLoadError);
@@ -139,7 +156,7 @@ errorret_t mapChunkLoad(chunk_t *chunk) {
if(!mapIsLoaded()) errorThrow("No map loaded");
mapChunkLoadQueueRemove(chunk);
if(MAP.loadingChunk == chunk) MAP.loadingChunk = NULL;
mapChunkLoadingSlotClear(chunk);
if(chunk->dcfEntry != NULL) {
eventUnsubscribe(&chunk->dcfEntry->onLoaded, mapChunkLoaded);
@@ -149,6 +166,16 @@ errorret_t mapChunkLoad(chunk_t *chunk) {
}
memorySet(chunk->entities, 0xFF, sizeof(chunk->entities));
// Normally already empty (mapChunkUnload clears these before a chunk is
// handed back for reuse), but cleared defensively here too so a reload
// never leaks a MAP_AREAS slot referenced by a stale owned area ID.
for(uint8_t i = 0; i < CHUNK_AREA_COUNT_MAX; i++) {
if(chunk->areas[i] == 0xFF) continue;
mapAreaRemove(chunk->areas[i]);
}
memorySet(chunk->areas, 0xFF, sizeof(chunk->areas));
chunk->meshCount = 0;
char_t name[64];
@@ -178,44 +205,48 @@ errorret_t mapChunkLoad(chunk_t *chunk) {
}
void mapChunkLoadNext() {
if(MAP.loadingChunk != NULL) return;
if(MAP.loadQueueCount == 0) return;
for(uint32_t slot = 0; slot < MAP_CHUNK_LOAD_CONCURRENCY; slot++) {
if(MAP.loadingChunks[slot] != NULL) continue;
if(MAP.loadQueueCount == 0) return;
chunk_t *chunk = MAP.loadQueue[0];
for(uint32_t i = 1; i < MAP.loadQueueCount; i++) {
MAP.loadQueue[i - 1] = MAP.loadQueue[i];
chunk_t *chunk = MAP.loadQueue[0];
for(uint32_t i = 1; i < MAP.loadQueueCount; i++) {
MAP.loadQueue[i - 1] = MAP.loadQueue[i];
}
MAP.loadQueueCount--;
MAP.loadingChunks[slot] = chunk;
char_t name[64];
stringFormat(
name, sizeof(name),
"chunks/%d_%d_%d.dcf",
(int32_t)chunk->position.x,
(int32_t)chunk->position.y,
(int32_t)chunk->position.z
);
assetentry_t *entry = assetLock(name, ASSET_LOADER_TYPE_CHUNK, NULL);
assertNotNull(entry, "Failed to get chunk asset entry");
chunk->dcfEntry = entry;
// The entry may already be resident from an earlier load that hasn't
// been reaped yet - in that case onLoaded/onError already fired once
// and never will again, so handle the terminal state directly instead
// of waiting on a subscription that would never trigger. Both of these
// recurse back into mapChunkLoadNext once they clear this slot, so the
// outer loop just continues on to try filling the next one.
if(entry->state == ASSET_ENTRY_STATE_LOADED) {
mapChunkLoaded(entry, chunk);
continue;
}
if(entry->state == ASSET_ENTRY_STATE_ERROR) {
mapChunkLoadError(entry, chunk);
continue;
}
eventSubscribe(&entry->onLoaded, mapChunkLoaded, chunk);
eventSubscribe(&entry->onError, mapChunkLoadError, chunk);
}
MAP.loadQueueCount--;
MAP.loadingChunk = chunk;
char_t name[64];
stringFormat(
name, sizeof(name),
"chunks/%d_%d_%d.dcf",
(int32_t)chunk->position.x,
(int32_t)chunk->position.y,
(int32_t)chunk->position.z
);
assetentry_t *entry = assetLock(name, ASSET_LOADER_TYPE_CHUNK, NULL);
assertNotNull(entry, "Failed to get chunk asset entry");
chunk->dcfEntry = entry;
// The entry may already be resident from an earlier load that hasn't been
// reaped yet - in that case onLoaded/onError already fired once and never
// will again, so handle the terminal state directly instead of waiting on
// a subscription that would never trigger.
if(entry->state == ASSET_ENTRY_STATE_LOADED) {
mapChunkLoaded(entry, chunk);
return;
}
if(entry->state == ASSET_ENTRY_STATE_ERROR) {
mapChunkLoadError(entry, chunk);
return;
}
eventSubscribe(&entry->onLoaded, mapChunkLoaded, chunk);
eventSubscribe(&entry->onError, mapChunkLoadError, chunk);
}
void mapChunkLoadQueueRemove(chunk_t *chunk) {
@@ -372,7 +403,7 @@ void mapChunkLoadError(void *params, void *user) {
chunk->dcfEntry = NULL;
memorySet(chunk->tiles, 0x00, sizeof(chunk->tiles));
if(MAP.loadingChunk == chunk) MAP.loadingChunk = NULL;
mapChunkLoadingSlotClear(chunk);
mapChunkLoadNext();
}
@@ -433,6 +464,47 @@ void mapChunkLoaded(void *params, void *user) {
// this chunk_t is displaying it. Released in mapChunkUnload instead.
chunk->meshCount = meshCount;
if(MAP.loadingChunk == chunk) MAP.loadingChunk = NULL;
// Spawn entities declared by this chunk's file. Global entities are
// deduped by mapSpawnEntity itself (a persistent NPC that streams back
// in won't be duplicated); item entities have no persistent identity, so
// each reload spawns a fresh one - picking an item up and then leaving
// and re-entering its chunk will currently respawn it, since nothing
// tracks "already collected" across a chunk unload/reload yet.
for(uint8_t s = 0; s < entry->data.chunk.entitySpawnCount; s++) {
chunkentityspawn_t *spawn = &entry->data.chunk.entitySpawns[s];
if(spawn->kind == CHUNK_ENTITY_SPAWN_KIND_GLOBAL) {
mapSpawnEntity((entityglobalid_t)spawn->globalId, spawn->position);
continue;
}
uint8_t index = entityGetAvailable();
assertTrue(index != 0xFF, "No available entity slots for chunk spawn");
entity_t *itemEntity = &ENTITIES[index];
entityInit(itemEntity, ENTITY_TYPE_ITEM);
entityItemSet(
itemEntity, (itemid_t)spawn->itemId, spawn->itemQuantity
);
entityPositionSet(itemEntity, spawn->position);
}
// Spawn map areas declared by this chunk's file, tracked as owned by
// this chunk so mapChunkUnload can tear them down again.
for(uint8_t s = 0; s < entry->data.chunk.areaSpawnCount; s++) {
chunkareaspawn_t *area = &entry->data.chunk.areaSpawns[s];
uint8_t areaId = mapAreaAddGlobal(
area->min, area->max, area->callbackId, area->notify, area->trigger
);
uint8_t slot = 0xFF;
for(uint8_t i = 0; i < CHUNK_AREA_COUNT_MAX; i++) {
if(chunk->areas[i] != 0xFF) continue;
slot = i;
break;
}
assertTrue(slot != 0xFF, "Chunk has no free owned-area slots");
chunk->areas[slot] = areaId;
}
mapChunkLoadingSlotClear(chunk);
mapChunkLoadNext();
}
+8 -6
View File
@@ -12,6 +12,10 @@
#define MAP_FILE_PATH_MAX 128
// Number of chunks that may be mid-load (asset locked & awaiting onLoaded/
// onError) at the same time - everything past this waits in loadQueue.
#define MAP_CHUNK_LOAD_CONCURRENCY 2
typedef struct map_s {
bool_t loaded;
@@ -19,11 +23,9 @@ typedef struct map_s {
chunk_t *chunkOrder[MAP_CHUNK_COUNT];
chunkpos_t chunkPosition;
// Only one chunk may be mid-load (asset locked & awaiting onLoaded/
// onError) at any given time - everything else waits here in FIFO order.
chunk_t *loadQueue[MAP_CHUNK_COUNT];
uint32_t loadQueueCount;
chunk_t *loadingChunk;
chunk_t *loadingChunks[MAP_CHUNK_LOAD_CONCURRENCY];
} map_t;
extern map_t MAP;
@@ -80,9 +82,9 @@ void mapChunkUnload(chunk_t* chunk);
errorret_t mapChunkLoad(chunk_t* chunk);
/**
* Starts loading the next queued chunk, if no chunk is currently mid-load.
* Called after mapChunkLoad enqueues a chunk, and again after the
* currently-loading chunk finishes (or is unloaded) to advance the queue.
* Starts loading queued chunks until MAP_CHUNK_LOAD_CONCURRENCY chunks are
* mid-load. Called after mapChunkLoad enqueues a chunk, and again after a
* mid-load chunk finishes (or is unloaded) to advance the queue.
*/
void mapChunkLoadNext();
+18
View File
@@ -10,6 +10,7 @@
#include "util/math.h"
#include "util/memory.h"
#include "rpg/overworld/map.h"
#include "rpg/overworld/global/mapareaglobal.h"
maparea_t MAP_AREAS[MAP_AREA_COUNT_MAX];
@@ -148,3 +149,20 @@ void mapAreaCheckEntity(entity_t *entity) {
void mapAreaNoopCallback(entity_t *entity, const uint8_t trigger) {
}
uint8_t mapAreaAddGlobal(
const worldpos_t min,
const worldpos_t max,
const uint16_t callbackId,
const uint8_t notify,
const uint8_t trigger
) {
assertTrue(callbackId > 0, "Map area callback ID 0 is reserved");
assertTrue(
callbackId < MAP_AREA_CALLBACK_LIST_COUNT,
"Map area callback ID is out of range"
);
return mapAreaAdd(
min, max, MAP_AREA_CALLBACK_LIST[callbackId], notify, trigger
);
}
+26 -1
View File
@@ -158,4 +158,29 @@ void mapAreaCheckEntity(entity_t *entity);
* @param entity Pointer to the entity associated with the callback.
* @param trigger Which MAP_TRIGGER_* condition invoked the callback.
*/
void mapAreaNoopCallback(entity_t *entity, const uint8_t trigger);
void mapAreaNoopCallback(entity_t *entity, const uint8_t trigger);
/**
* Adds a map area using a compiled-in callback referenced by ID (see
* MAP_AREA_CALLBACK_LIST in rpg/overworld/global/mapareagloballist.h),
* rather than a direct function pointer. This is what lets chunk file
* data - which can only reference compiled code by a small integer ID,
* not a function pointer - declare map areas.
*
* @param min The minimum world position of the area.
* @param max The maximum world position of the area.
* @param callbackId Index into MAP_AREA_CALLBACK_LIST. Must be greater
* than 0 (0 is reserved) and within range.
* @param notify Bitwise MAP_AREA_NOTIFY_* flags for which entity types
* should trigger the callback.
* @param trigger Bitwise MAP_TRIGGER_* flags for which conditions should
* invoke the callback.
* @returns The ID of the newly added map area.
*/
uint8_t mapAreaAddGlobal(
const worldpos_t min,
const worldpos_t max,
const uint16_t callbackId,
const uint8_t notify,
const uint8_t trigger
);
+28 -27
View File
@@ -7,12 +7,9 @@
#include "rpg.h"
#include "entity/entity.h"
#include "rpg/entity/npc/npcpath.h"
#include "rpg/entity/item/entityitem.h"
#include "rpg/overworld/map.h"
#include "rpg/overworld/maparea.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "rpg/cutscene/scene/testcutscene.h"
#include "rpg/item/backpack.h"
#include "rpg/battle/party.h"
#include "ui/rpg/textbox/uitextboxminilist.h"
@@ -21,18 +18,33 @@
#include "util/memory.h"
#include "util/string.h"
#include "assert/assert.h"
#include "console/console.h"
#include "save/save.h"
#include "error/error.h"
#include "ui/rpg/uiemoji.h"
#include "rpg/story/storyflag.h"
void rpgTestAreaCallback(entity_t *entity, const uint8_t trigger) {
consolePrint("rpgTestAreaCallback: trigger=%u", trigger);
static void rpgTestSaveComplete(errorret_t result, void *user) {
if(errorIsNotOk(result)) errorCatch(errorPrint(result));
}
errorret_t rpgInit(void) {
memoryZero(ENTITIES, sizeof(ENTITIES));
memoryZero(MAP_AREAS, sizeof(MAP_AREAS));
saveslot_t *saveSlot = saveGetSlot(SAVE_ACTIVE_SLOT);
// saveInit() eagerly loads every slot from disk, but there's no
// continue-game flow yet - every boot is a fresh game regardless of
// what was found on disk, so force this false rather than let a stale
// "exists" from a real save skip storyFlagInitDefaults() below while
// everything else here still hardcodes new-game state.
saveSlot->exists = false;
// Must run before any code reads a story flag - stamps CSV-defined
// defaults onto the active save slot since it's now forced to look
// unloaded.
storyFlagInitDefaults(saveSlot);
backpackInit();
partyInit();
cutsceneSystemInit();
@@ -43,7 +55,9 @@ errorret_t rpgInit(void) {
// Init world
errorChain(mapPositionSet((chunkpos_t){ 0, 0, 0 }));
// TEST: Create some entities.
// The player is the one entity that isn't sourced from map/chunk data -
// every other entity (NPCs, items) and map area comes from the loaded
// chunks' own spawn data (see rpg/overworld/map.c mapChunkLoaded).
uint8_t entIndex = entityGetAvailable();
assertTrue(entIndex != 0xFF, "No available entity slots!.");
entity_t *ent = &ENTITIES[entIndex];
@@ -52,30 +66,17 @@ errorret_t rpgInit(void) {
RPG_CAMERA.mode = RPG_CAMERA_MODE_FOLLOW_ENTITY;
RPG_CAMERA.followEntity.followEntityId = ent->id;
mapSpawnEntity(3, (worldpos_t){ 8, 8, 1 });
// TEST: Place an item entity.
uint8_t itemEntIndex = entityGetAvailable();
assertTrue(itemEntIndex != 0xFF, "No available entity slots!.");
entity_t *itemEnt = &ENTITIES[itemEntIndex];
entityInit(itemEnt, ENTITY_TYPE_ITEM);
entityItemSet(itemEnt, ITEM_ID_POTION, 1);
entityPositionSet(itemEnt, (worldpos_t){ 12, 2, 0 });
// TEST: Give the player a starting assortment of items.
// Starting inventory.
backpackAdd(ITEM_ID_POTION, 5);
backpackAdd(ITEM_ID_POTATO, 3);
backpackAdd(ITEM_ID_APPLE, 8);
// TEST: Create a test map area.
uint8_t areaIndex = mapAreaAdd(
(worldpos_t){ 11, 3, 0 },
(worldpos_t){ 16, 9, 10 },
rpgTestAreaCallback,
MAP_AREA_NOTIFY_ALL,
MAP_TRIGGER_ENTER | MAP_TRIGGER_EXIT
);
assertTrue(areaIndex != 0xFF, "No available map area slots!.");
// TEST: Verify the save system round-trips real game data, not just the
// header/version. Remove once there's an actual name-entry flow. On PSP
// this shows the real native save dialog every boot - expected while
// testing that path, not something to ship as-is.
stringCopy(saveSlot->playerName, "Dusk", SAVE_PLAYER_NAME_MAX);
saveWriteSlot(SAVE_ACTIVE_SLOT, rpgTestSaveComplete, NULL);
// All Good!
errorOk();
+15 -3
View File
@@ -1,6 +1,6 @@
/**
* Copyright (c) 2026 Dominic Masters
*
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
@@ -10,5 +10,17 @@
void storyFlagSet(const storyflag_t flag, const storyflagvalue_t value) {
assertTrue(flag > STORY_FLAG_NULL && flag < STORY_FLAG_COUNT, "Bad Flag");
STORY_FLAG_VALUES[flag] = value;
}
saveGetSlot(SAVE_ACTIVE_SLOT)->storyFlags[flag] = value;
}
void storyFlagInitDefaults(saveslot_t *file) {
assertNotNull(file, "Save slot cannot be NULL");
if(file->exists) return;
assertTrue(
STORY_FLAG_COUNT <= SAVE_STORY_FLAG_COUNT_MAX,
"Too many story flags for the save format - bump SAVE_STORY_FLAG_COUNT_MAX"
);
for(storyflag_t i = 0; i < STORY_FLAG_COUNT; i++) {
file->storyFlags[i] = STORY_FLAG_DEFAULTS[i];
}
}
+22 -7
View File
@@ -1,25 +1,40 @@
/**
* Copyright (c) 2026 Dominic Masters
*
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "rpg/story/storyflagvalue.h"
#include "save/save.h"
/**
* Gets the value of a story flag.
*
* Gets the value of a story flag. Reads directly from the active save
* slot (see SAVE_ACTIVE_SLOT) - flag values have no separate live copy.
*
* @param flag The story flag to get.
* @return The value of the story flag.
*/
#define storyFlagGet(flag) (STORY_FLAG_VALUES[(flag)])
#define storyFlagGet(flag) (saveGetSlot(SAVE_ACTIVE_SLOT)->storyFlags[(flag)])
/**
* Sets the value of a story flag.
*
* Sets the value of a story flag, directly in the active save slot (see
* SAVE_ACTIVE_SLOT). Does not itself write the save to disk - call
* saveWriteSlot() separately once ready to persist it.
*
* @param flag The story flag to set.
* @param value The value to set the story flag to.
*/
void storyFlagSet(const storyflag_t flag, const storyflagvalue_t value);
void storyFlagSet(const storyflag_t flag, const storyflagvalue_t value);
/**
* Stamps each story flag's CSV-defined default (STORY_FLAG_DEFAULTS) onto
* the given save slot, but only if it hasn't actually been loaded from
* disk yet (file->exists is false) - otherwise leaves already-played
* progress alone. Call once, e.g. during rpgInit(), before any gameplay
* code reads a story flag.
*
* @param file The save slot to stamp defaults onto.
*/
void storyFlagInitDefaults(saveslot_t *file);
+115 -59
View File
@@ -9,19 +9,51 @@
#include "save/savestream.h"
#include "util/memory.h"
#include "assert/assert.h"
#include "error/error.h"
save_t SAVE;
static void _saveEagerLoadComplete(errorret_t result, void *user) {
if(errorIsNotOk(result)) errorCatch(errorPrint(result));
}
errorret_t saveInit(void) {
memoryZero(&SAVE, sizeof(save_t));
SAVE.meta.deadzone = SAVE_META_DEADZONE_DEFAULT;
#ifdef saveInitPlatform
errorChain(saveInitPlatform());
// A missing/unreachable save medium is expected, recoverable state,
// not a reason to fail booting the whole game - log it and carry on
// with SAVE.available false instead of chaining the error upward.
errorret_t result = saveInitPlatform();
SAVE.available = errorIsOk(result);
if(!SAVE.available) errorCatch(errorPrint(result));
#else
SAVE.available = false;
#endif
// Eagerly pull meta + every slot into memory up front - cheap and
// harmless (nothing consumes loaded slot data automatically; there's no
// continue-game flow yet). PSP opts out entirely (see
// saveSkipEagerLoadPlatform) since its only read path is now the native
// savedata dialog, and running that on every boot would defeat the whole
// point of folding meta into it instead of a separate instant-write file.
#ifndef saveSkipEagerLoadPlatform
if(SAVE.available) {
saveLoadMeta(_saveEagerLoadComplete, NULL);
for(uint8_t i = 0; i < SAVE_SLOT_COUNT_MAX; i++) {
saveLoadSlot(i, _saveEagerLoadComplete, NULL);
}
}
#endif
errorOk();
}
bool_t saveIsAvailable(void) {
return SAVE.available;
}
errorret_t saveDispose(void) {
#ifdef saveDisposePlatform
errorChain(saveDisposePlatform());
@@ -29,80 +61,104 @@ errorret_t saveDispose(void) {
errorOk();
}
errorret_t saveLoad(const uint8_t slot) {
assertTrue(slot < SAVE_FILE_COUNT_MAX, "slot exceeds SAVE_FILE_COUNT_MAX");
savefile_t *file = &SAVE.files[slot];
file->exists = false;
savestream_t stream;
memoryZero(&stream, sizeof(savestream_t));
#ifdef saveStreamOpenReadPlatform
errorChain(saveStreamOpenReadPlatform(&stream, slot));
errorret_t saveUpdate(void) {
#ifdef savePlatformUpdate
errorChain(savePlatformUpdate());
#endif
if(!stream.found) errorOk();
errorret_t ret = saveFileLoad(&stream, file);
#ifdef saveStreamClosePlatform
saveStreamClosePlatform(&stream);
#endif
if(errorIsNotOk(ret)) return ret;
errorChain(saveStreamVerifyChecksumImpl(&stream, slot));
file->exists = true;
errorOk();
}
errorret_t saveWrite(const uint8_t slot) {
assertTrue(slot < SAVE_FILE_COUNT_MAX, "slot exceeds SAVE_FILE_COUNT_MAX");
bool_t saveIsBusy(void) {
#ifdef saveIsBusyPlatform
return saveIsBusyPlatform();
#else
return false;
#endif
}
savefile_t *file = &SAVE.files[slot];
void saveLoadSlot(const uint8_t slot, savecallback_t onComplete, void *user) {
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX");
assertNotNull(onComplete, "onComplete cannot be NULL");
savestream_t stream;
memoryZero(&stream, sizeof(savestream_t));
SAVE.slots[slot].exists = false;
#ifdef saveStreamOpenWritePlatform
errorChain(saveStreamOpenWritePlatform(&stream, slot));
// Some platforms (PSP's native save dialog) can't complete within this
// call - they take over entirely and invoke onComplete later, from
// saveUpdate(), once their own multi-frame flow finishes. Those
// platforms never define the sync saveSlotLoadPlatform() at all, so the
// fallback below must live in the #else, not just after an early return.
#ifdef saveSlotAsyncLoadPlatform
saveSlotAsyncLoadPlatform(slot, onComplete, user);
#else
errorret_t ret = saveSlotLoadPlatform(slot, &SAVE.slots[slot]);
SAVE.available = errorIsOk(ret);
onComplete(ret, user);
#endif
}
void saveWriteSlot(const uint8_t slot, savecallback_t onComplete, void *user) {
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX");
assertNotNull(onComplete, "onComplete cannot be NULL");
// See saveLoadSlot() - some platforms take over and complete later.
#ifdef saveSlotAsyncWritePlatform
saveSlotAsyncWritePlatform(slot, onComplete, user);
#else
errorret_t ret = saveSlotWritePlatform(slot, &SAVE.slots[slot]);
SAVE.available = errorIsOk(ret);
onComplete(ret, user);
#endif
}
errorret_t saveDeleteSlot(const uint8_t slot) {
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX");
#ifdef saveSlotDeletePlatform
errorret_t deleteRet = saveSlotDeletePlatform(slot);
SAVE.available = errorIsOk(deleteRet);
errorChain(deleteRet);
#endif
errorret_t ret = saveFileWrite(&stream, file);
if(errorIsOk(ret)) {
ret = saveStreamFinalizeWriteImpl(&stream);
}
#ifdef saveStreamClosePlatform
saveStreamClosePlatform(&stream);
#endif
if(errorIsNotOk(ret)) return ret;
file->exists = true;
SAVE.slots[slot].exists = false;
errorOk();
}
errorret_t saveDelete(const uint8_t slot) {
assertTrue(slot < SAVE_FILE_COUNT_MAX, "slot exceeds SAVE_FILE_COUNT_MAX");
bool_t saveSlotExists(const uint8_t slot) {
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX");
return SAVE.slots[slot].exists;
}
#ifdef saveDeletePlatform
errorChain(saveDeletePlatform(slot));
saveslot_t * saveGetSlot(const uint8_t slot) {
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX");
return &SAVE.slots[slot];
}
void saveLoadMeta(savecallback_t onComplete, void *user) {
assertNotNull(onComplete, "onComplete cannot be NULL");
SAVE.meta.exists = false;
#ifdef saveMetaAsyncLoadPlatform
saveMetaAsyncLoadPlatform(onComplete, user);
#else
errorret_t ret = saveMetaLoadPlatform(&SAVE.meta);
SAVE.available = errorIsOk(ret);
onComplete(ret, user);
#endif
SAVE.files[slot].exists = false;
errorOk();
}
bool_t saveExists(const uint8_t slot) {
assertTrue(slot < SAVE_FILE_COUNT_MAX, "slot exceeds SAVE_FILE_COUNT_MAX");
return SAVE.files[slot].exists;
void saveWriteMeta(savecallback_t onComplete, void *user) {
assertNotNull(onComplete, "onComplete cannot be NULL");
#ifdef saveMetaAsyncWritePlatform
saveMetaAsyncWritePlatform(onComplete, user);
#else
errorret_t ret = saveMetaWritePlatform(&SAVE.meta);
SAVE.available = errorIsOk(ret);
onComplete(ret, user);
#endif
}
savefile_t * saveGet(const uint8_t slot) {
assertTrue(slot < SAVE_FILE_COUNT_MAX, "slot exceeds SAVE_FILE_COUNT_MAX");
return &SAVE.files[slot];
savemeta_t * saveGetMeta(void) {
return &SAVE.meta;
}
+121 -24
View File
@@ -7,25 +7,67 @@
#pragma once
#include "error/error.h"
#include "savefile.h"
#include "saveslot.h"
#include "savemeta.h"
#include "save/saveplatform.h"
typedef struct {
/** Per-slot save file data; indexed 0 to SAVE_FILE_COUNT_MAX - 1. */
savefile_t files[SAVE_FILE_COUNT_MAX];
/** Per-slot save data; indexed 0 to SAVE_SLOT_COUNT_MAX - 1. */
saveslot_t slots[SAVE_SLOT_COUNT_MAX];
/** Device-wide preferences - see savemeta.h. */
savemeta_t meta;
/** Platform-specific save system state (paths, card handles, etc.). */
saveplatform_t platform;
/**
* True if the save medium (memory card/stick/disk) was reachable the
* last time it was checked - at saveInit(), and refreshed by every
* subsequent load/write attempt. Starting the game with no card/stick
* inserted, or one being removed mid-session, are both expected
* conditions here, not fatal errors - see saveIsAvailable().
*/
bool_t available;
/**
* Scratch error state used by platforms whose save/load completes
* asynchronously (see saveIsBusy()) to construct a result to hand to a
* savecallback_t from inside saveUpdate(), rather than from a direct
* errorThrow() return - mirrors network_t.errorState for the same reason.
*/
errorstate_t errorState;
} save_t;
extern save_t SAVE;
/**
* Initializes the save system.
* Initializes the save system. Never fails the way saveWriteSlot()/
* saveWriteMeta() can - if the platform's save medium isn't reachable
* (e.g. no memory card/stick inserted), that's logged and reflected in
* saveIsAvailable() rather than treated as fatal, since the game should
* still be playable without save support.
*
* @return An error code if initialization fails.
* On most platforms, this also eagerly loads meta and every slot from
* disk immediately - cheap and harmless, since nothing consumes the
* loaded slot data automatically (there's no "continue game" flow yet;
* rpgInit() always hardcodes a fresh game regardless of what's loaded).
* PSP skips this (see saveSkipEagerLoadPlatform) - its only read path is
* the native sceUtilitySavedata dialog now that meta lives inside the
* same payload as the save slot, and running that multi-frame dialog on
* every single boot would reintroduce the exact UX problem a lightweight
* settings-only file used to avoid.
*
* @return An error code only for unexpected platform failures.
*/
errorret_t saveInit(void);
/**
* Checks whether the save medium was reachable as of the last load/write
* attempt (or saveInit(), if none has been attempted yet). Intended for UI
* to decide whether to offer saving/loading at all, or to explain why it
* isn't available right now - e.g. "No memory card inserted".
*
* @return true if the save medium was available last time it was checked.
*/
bool_t saveIsAvailable(void);
/**
* Disposes of the save system.
*
@@ -34,41 +76,96 @@ errorret_t saveInit(void);
errorret_t saveDispose(void);
/**
* Loads the save file for a given slot from persistent storage.
* Updates the save manager, pumping any in-progress async save/load and
* dispatching its callback once complete. No-op on platforms where every
* operation always completes synchronously (see saveIsBusy()). Must be
* called every engine frame for platforms that need it (PSP's native save
* dialog spans multiple frames).
*
* @param slot The save slot index (0 to SAVE_FILE_COUNT_MAX - 1).
* @return An error code if the load fails.
* @return An error code indicating success or failure.
*/
errorret_t saveLoad(const uint8_t slot);
errorret_t saveUpdate(void);
/**
* Writes the save file for a given slot to persistent storage.
* True while an async save/load is in progress (e.g. PSP's native save
* dialog is open). Calling any save/load function again while this is true
* is undefined behavior - wait for the previous call's callback first.
*
* @param slot The save slot index (0 to SAVE_FILE_COUNT_MAX - 1).
* @return An error code if the write fails.
* @return True if a save/load request is currently in progress.
*/
errorret_t saveWrite(const uint8_t slot);
bool_t saveIsBusy(void);
/**
* Deletes the save file for a given slot from persistent storage.
* Loads the save slot for a given index from persistent storage. Slow/
* async on some platforms (PSP's native save dialog spans multiple
* frames) - on others (Linux, Dolphin) onComplete is invoked before this
* call returns. See saveIsBusy().
*
* @param slot The save slot index (0 to SAVE_FILE_COUNT_MAX - 1).
* @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1).
* @param onComplete Callback invoked with the result once loading finishes.
* @param user User data passed through to onComplete.
*/
void saveLoadSlot(const uint8_t slot, savecallback_t onComplete, void *user);
/**
* Writes the save slot for a given index to persistent storage. Slow/
* async on some platforms (PSP's native save dialog spans multiple
* frames) - on others (Linux, Dolphin) onComplete is invoked before this
* call returns. See saveIsBusy().
*
* @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1).
* @param onComplete Callback invoked with the result once writing finishes.
* @param user User data passed through to onComplete.
*/
void saveWriteSlot(const uint8_t slot, savecallback_t onComplete, void *user);
/**
* Deletes the save slot for a given index from persistent storage.
*
* @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1).
* @return An error code if the delete fails.
*/
errorret_t saveDelete(const uint8_t slot);
errorret_t saveDeleteSlot(const uint8_t slot);
/**
* Checks whether a save file exists for a given slot.
* Checks whether a save slot has data for a given index.
*
* @param slot The save slot index (0 to SAVE_FILE_COUNT_MAX - 1).
* @return true if a save file exists for the slot, false otherwise.
* @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1).
* @return true if the slot has data, false otherwise.
*/
bool_t saveExists(const uint8_t slot);
bool_t saveSlotExists(const uint8_t slot);
/**
* Gets a pointer to the save file data for a given slot.
* Gets a pointer to the save slot data for a given index.
*
* @param slot The save slot index (0 to SAVE_FILE_COUNT_MAX - 1).
* @return A pointer to the savefile_t for the given slot.
* @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1).
* @return A pointer to the saveslot_t for the given slot.
*/
savefile_t * saveGet(const uint8_t slot);
saveslot_t * saveGetSlot(const uint8_t slot);
/**
* Loads device-wide meta (preferences) from persistent storage. Callback-
* based on every platform, same as saveLoadSlot() - on PSP specifically,
* loading meta means loading the same combined savedata payload as the
* active slot, which is unavoidably async there.
*
* @param onComplete Callback invoked with the result once loading finishes.
* @param user User data passed through to onComplete.
*/
void saveLoadMeta(savecallback_t onComplete, void *user);
/**
* Writes device-wide meta (preferences) to persistent storage.
*
* @param onComplete Callback invoked with the result once writing finishes.
* @param user User data passed through to onComplete.
*/
void saveWriteMeta(savecallback_t onComplete, void *user);
/**
* Gets a pointer to the live meta data. Modify fields directly, then call
* saveWriteMeta() to persist them.
*
* @return A pointer to the meta.
*/
savemeta_t * saveGetMeta(void);
-30
View File
@@ -1,30 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
/** Save file format version. Increment on breaking change. */
#define SAVE_FILE_VERSION 1
/** Magic bytes that identify a Dusk save file. */
#define SAVE_FILE_HEADER "DSK"
/** Byte length of the magic header (excludes the null terminator). */
#define SAVE_FILE_HEADER_SIZE (sizeof(SAVE_FILE_HEADER) - 1)
/** Maximum number of independent save slots supported. */
#define SAVE_FILE_COUNT_MAX 3
typedef struct {
/** Magic header bytes read from the file; must equal SAVE_FILE_HEADER. */
char_t header[SAVE_FILE_HEADER_SIZE];
/** Format version read from the file; used to branch on older layouts. */
uint32_t version;
/** Runtime flag - true if this slot was successfully loaded or written. */
bool_t exists;
} savefile_t;
+47
View File
@@ -0,0 +1,47 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
/** Save meta format version. Increment on breaking change. */
#define SAVE_META_VERSION 1
/** Magic bytes that identify a Dusk save meta blob. */
#define SAVE_META_HEADER "DSM"
/** Byte length of the magic header (excludes the null terminator). */
#define SAVE_META_HEADER_SIZE (sizeof(SAVE_META_HEADER) - 1)
/**
* Default gamepad deadzone for meta that's never actually been loaded from
* disk yet (see saveInit(), which stamps this on first boot) - the save
* meta is the single source of truth for this value (see
* savemeta_t.deadzone); nothing else stores or defaults it.
*/
#define SAVE_META_DEADZONE_DEFAULT 0.1f
/**
* Device/user-wide preferences, independent of any individual game save
* slot (see saveslot.h) - there's exactly one of these, not one per slot,
* since a setting like gamepad deadzone shouldn't reset or diverge just
* because the player started a new game in a different slot.
*/
typedef struct {
/** Magic header bytes read from the blob; must equal SAVE_META_HEADER. */
char_t header[SAVE_META_HEADER_SIZE];
/** Format version read from the blob; used to branch on older layouts. */
uint32_t version;
/** Runtime flag - true if meta was successfully loaded or written. */
bool_t exists;
/**
* User-configured gamepad deadzone (0.0f-1.0f) - the save meta is the
* only place this lives; read it directly via saveGetMeta()->deadzone
* rather than caching it anywhere else.
*/
float_t deadzone;
} savemeta_t;
+89
View File
@@ -0,0 +1,89 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
/** Save slot format version. Increment on breaking change. */
#define SAVE_SLOT_VERSION 1
/** Magic bytes that identify a Dusk save slot. */
#define SAVE_SLOT_HEADER "DSK"
/** Byte length of the magic header (excludes the null terminator). */
#define SAVE_SLOT_HEADER_SIZE (sizeof(SAVE_SLOT_HEADER) - 1)
/**
* Maximum number of independent save slots supported. Platform-overridable
* via a compiler define (not a header #ifndef alone, since this header is
* included before any platform header gets a chance to react) - see PSP's
* CMakeLists.txt, which overrides this to 1.
*/
#ifndef SAVE_SLOT_COUNT_MAX
#define SAVE_SLOT_COUNT_MAX 3
#endif
/**
* The save slot actually used for gameplay right now - there's no slot
* select/multi-save UX yet (SAVE_SLOT_COUNT_MAX > 1 exists for later), so
* every part of the game that needs "the" save slot (the game menu's Save
* button, etc.) reads/writes this one slot.
*/
#define SAVE_ACTIVE_SLOT 0
/** Maximum length of a saved player name, including the null terminator. */
#define SAVE_PLAYER_NAME_MAX 32
/**
* Maximum number of global entities whose "collected" state can be
* tracked - see rpg/entity/global/globalitemstore.h. Bounded/fixed here
* rather than tied to ENTITY_GLOBAL_LIST_COUNT, since saveslot.h is a
* leaf header with no dependency on the entity system (and no reason to
* take one just for a size constant).
*/
#define SAVE_GLOBAL_ITEM_COUNT_MAX 64
/**
* Maximum number of story flags the save format can hold - see
* rpg/story/storyflag.h. Bounded/fixed here (with real headroom over the
* current flag count) rather than tied to STORY_FLAG_COUNT, since
* saveslot.h is a leaf header with no dependency on generated story
* content, matching SAVE_GLOBAL_ITEM_COUNT_MAX's reasoning.
*/
#define SAVE_STORY_FLAG_COUNT_MAX 128
/** Per-slot game progress - the state a "save file" traditionally means. */
typedef struct {
/** Magic header bytes read from the slot; must equal SAVE_SLOT_HEADER. */
char_t header[SAVE_SLOT_HEADER_SIZE];
/** Format version read from the slot; used to branch on older layouts. */
uint32_t version;
/** Runtime flag - true if this slot was successfully loaded or written. */
bool_t exists;
/** The player's saved name. */
char_t playerName[SAVE_PLAYER_NAME_MAX];
/** Per-global-ID "already collected" flags - see globalitemstore.h. */
bool_t globalItemCollected[SAVE_GLOBAL_ITEM_COUNT_MAX];
/**
* Story flag values, indexed by storyflag_t - the save slot is the only
* place these live; read/write via storyFlagGet()/storyFlagSet() (see
* rpg/story/storyflag.h), not directly.
*/
uint8_t storyFlags[SAVE_STORY_FLAG_COUNT_MAX];
} saveslot_t;
/**
* Callback invoked when an async saveWriteSlot()/saveLoadSlot()/
* saveWriteMeta()/saveLoadMeta() request completes. Declared here (rather
* than save.h) so platform save headers - which save.h's platform
* indirection pulls in before save.h finishes defining anything else - can
* reference it without a circular include.
*
* @param result Whether the request succeeded.
* @param user User data passed through from the original call.
*/
typedef void (*savecallback_t)(errorret_t result, void *user);
+84 -23
View File
@@ -45,12 +45,23 @@ errorret_t saveStreamWriteBytesImpl(
errorOk();
}
errorret_t saveStreamFinalizeWriteImpl(savestream_t *stream) {
errorret_t saveStreamTellImpl(savestream_t *stream, size_t *out) {
#ifdef saveStreamTellPlatform
errorChain(saveStreamTellPlatform(stream, out));
#else
*out = 0;
#endif
errorOk();
}
errorret_t saveStreamFinalizeWriteImpl(
savestream_t *stream, const size_t headerPosition, const size_t headerSize
) {
uint32_t finalCRC = cryptCRC32End(stream->checksum);
uint32_t leChecksum = endianLittleToHost32(finalCRC);
#ifdef saveStreamSeekPlatform
errorChain(saveStreamSeekPlatform(stream, SAVE_FILE_HEADER_SIZE));
errorChain(saveStreamSeekPlatform(stream, headerPosition + headerSize));
#endif
errorChain(saveStreamWriteBytesRawImpl(
@@ -60,27 +71,25 @@ errorret_t saveStreamFinalizeWriteImpl(savestream_t *stream) {
}
errorret_t saveStreamVerifyChecksumImpl(
savestream_t *stream, const uint8_t slot
savestream_t *stream, const char_t *sectionLabel
) {
uint32_t computed = cryptCRC32End(stream->checksum);
if(computed != stream->expectedChecksum) {
errorThrow("Save slot %u has invalid checksum", (uint32_t)slot);
errorThrow("%s has invalid checksum", sectionLabel);
}
errorOk();
}
errorret_t saveStreamReadHeaderImpl(
savestream_t *stream, char_t header[SAVE_FILE_HEADER_SIZE]
savestream_t *stream, char_t *header, const char_t *expectedHeader,
const size_t headerSize
) {
errorChain(saveStreamReadBytesRawImpl(stream, header, SAVE_FILE_HEADER_SIZE));
errorChain(saveStreamReadBytesRawImpl(stream, header, headerSize));
if(
header[0] != SAVE_FILE_HEADER[0] ||
header[1] != SAVE_FILE_HEADER[1] ||
header[2] != SAVE_FILE_HEADER[2]
) {
errorThrow("Save file has invalid header");
for(size_t i = 0; i < headerSize; i++) {
if(header[i] != expectedHeader[i]) {
errorThrow("Save data has invalid header");
}
}
uint32_t leChecksum;
@@ -91,11 +100,9 @@ errorret_t saveStreamReadHeaderImpl(
}
errorret_t saveStreamWriteHeaderImpl(
savestream_t *stream, const char_t header[SAVE_FILE_HEADER_SIZE]
savestream_t *stream, const char_t *header, const size_t headerSize
) {
errorChain(saveStreamWriteBytesRawImpl(
stream, header, SAVE_FILE_HEADER_SIZE
));
errorChain(saveStreamWriteBytesRawImpl(stream, header, headerSize));
uint32_t placeholder = 0;
errorChain(saveStreamWriteBytesRawImpl(
@@ -327,14 +334,68 @@ errorret_t saveStreamWriteDateImpl(
errorOk();
}
errorret_t saveFileLoad(savestream_t *stream, savefile_t *file) {
saveFileReadHeader(stream, file->header);
saveFileReadVersion(stream, &file->version);
errorret_t saveMetaSerializeRead(savestream_t *stream, savemeta_t *meta) {
saveFileReadHeader(
stream, meta->header, SAVE_META_HEADER, SAVE_META_HEADER_SIZE
);
saveFileReadVersion(stream, &meta->version);
saveFileReadFloat(stream, &meta->deadzone);
errorChain(saveStreamVerifyChecksumImpl(stream, "Save meta"));
meta->exists = true;
errorOk();
}
errorret_t saveFileWrite(savestream_t *stream, savefile_t *file) {
saveFileWriteHeader(stream, file->header);
saveFileWriteVersion(stream, &file->version);
errorret_t saveMetaSerializeWrite(savestream_t *stream, savemeta_t *meta) {
memoryCopy(meta->header, SAVE_META_HEADER, SAVE_META_HEADER_SIZE);
meta->version = SAVE_META_VERSION;
size_t headerPosition;
errorChain(saveStreamTellImpl(stream, &headerPosition));
saveFileWriteHeader(stream, meta->header, SAVE_META_HEADER_SIZE);
saveFileWriteVersion(stream, &meta->version);
saveFileWriteFloat(stream, &meta->deadzone);
errorChain(saveStreamFinalizeWriteImpl(
stream, headerPosition, SAVE_META_HEADER_SIZE
));
meta->exists = true;
errorOk();
}
errorret_t saveSlotSerializeRead(savestream_t *stream, saveslot_t *slot) {
saveFileReadHeader(
stream, slot->header, SAVE_SLOT_HEADER, SAVE_SLOT_HEADER_SIZE
);
saveFileReadVersion(stream, &slot->version);
saveFileReadString(stream, slot->playerName, SAVE_PLAYER_NAME_MAX);
for(size_t i = 0; i < SAVE_GLOBAL_ITEM_COUNT_MAX; i++) {
saveFileReadBool(stream, &slot->globalItemCollected[i]);
}
for(size_t i = 0; i < SAVE_STORY_FLAG_COUNT_MAX; i++) {
saveFileReadUInt8(stream, &slot->storyFlags[i]);
}
errorChain(saveStreamVerifyChecksumImpl(stream, "Save slot"));
slot->exists = true;
errorOk();
}
errorret_t saveSlotSerializeWrite(savestream_t *stream, saveslot_t *slot) {
memoryCopy(slot->header, SAVE_SLOT_HEADER, SAVE_SLOT_HEADER_SIZE);
slot->version = SAVE_SLOT_VERSION;
size_t headerPosition;
errorChain(saveStreamTellImpl(stream, &headerPosition));
saveFileWriteHeader(stream, slot->header, SAVE_SLOT_HEADER_SIZE);
saveFileWriteVersion(stream, &slot->version);
saveFileWriteString(stream, slot->playerName, SAVE_PLAYER_NAME_MAX);
for(size_t i = 0; i < SAVE_GLOBAL_ITEM_COUNT_MAX; i++) {
saveFileWriteBool(stream, &slot->globalItemCollected[i]);
}
for(size_t i = 0; i < SAVE_STORY_FLAG_COUNT_MAX; i++) {
saveFileWriteUInt8(stream, &slot->storyFlags[i]);
}
errorChain(saveStreamFinalizeWriteImpl(
stream, headerPosition, SAVE_SLOT_HEADER_SIZE
));
slot->exists = true;
errorOk();
}
+74 -31
View File
@@ -7,7 +7,8 @@
#pragma once
#include "error/error.h"
#include "savefile.h"
#include "saveslot.h"
#include "savemeta.h"
#include "save/saveplatform.h"
#include "time/timeepoch.h"
@@ -67,49 +68,72 @@ errorret_t saveStreamWriteBytesImpl(
);
/**
* Finalizes a write stream: computes the final CRC32, seeks to the
* checksum field in the header, and writes it in little-endian order.
* Gets the current read/write position within the stream. Used to capture
* a section's start position before writing its header, so its checksum
* can be backfilled at the right offset once the section's body is known -
* required now that a single stream can hold multiple self-contained
* sections back-to-back (meta + N save slots), not just one.
*
* @param stream Active stream.
* @param out Receives the current position.
* @return An error if the platform can't report a position.
*/
errorret_t saveStreamTellImpl(savestream_t *stream, size_t *out);
/**
* Finalizes a write stream: computes the final CRC32, seeks back to the
* checksum field just after this section's header, and writes it in
* little-endian order.
*
* @param stream Active write stream.
* @param headerPosition Byte offset where this section's header started
* (see saveStreamTellImpl), captured before writing the header.
* @param headerSize Byte length of this section's magic header.
* @return An error if the seek or write fails.
*/
errorret_t saveStreamFinalizeWriteImpl(savestream_t *stream);
errorret_t saveStreamFinalizeWriteImpl(
savestream_t *stream, const size_t headerPosition, const size_t headerSize
);
/**
* Verifies that the CRC32 accumulated during loading matches the value
* stored in the file header.
* stored in this section's header.
*
* @param stream Active read stream (loading must be complete).
* @param slot Slot index used in the error message on mismatch.
* @param sectionLabel Human-readable label used in the error message on
* mismatch (e.g. "save meta", "save slot").
* @return An error if the checksum does not match.
*/
errorret_t saveStreamVerifyChecksumImpl(
savestream_t *stream, const uint8_t slot
savestream_t *stream, const char_t *sectionLabel
);
/**
* Reads and validates the magic header, then reads the stored CRC32 and
* resets the running accumulator.
* Reads and validates a section's magic header, then reads its stored
* CRC32 and resets the running accumulator.
*
* @param stream Active read stream.
* @param header Buffer of SAVE_FILE_HEADER_SIZE bytes to receive the header.
* @param header Buffer of headerSize bytes to receive the header.
* @param expectedHeader The magic bytes this section must match.
* @param headerSize Byte length of the magic header.
* @return An error if the header is missing or invalid.
*/
errorret_t saveStreamReadHeaderImpl(
savestream_t *stream, char_t header[SAVE_FILE_HEADER_SIZE]
savestream_t *stream, char_t *header, const char_t *expectedHeader,
const size_t headerSize
);
/**
* Writes the magic header and a zero CRC32 placeholder, then resets the
* running accumulator.
* Writes a section's magic header and a zero CRC32 placeholder, then
* resets the running accumulator.
*
* @param stream Active write stream.
* @param header Buffer of SAVE_FILE_HEADER_SIZE bytes to write.
* @param header Buffer of headerSize bytes to write.
* @param headerSize Byte length of the magic header.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteHeaderImpl(
savestream_t *stream,
const char_t header[SAVE_FILE_HEADER_SIZE]
savestream_t *stream, const char_t *header, const size_t headerSize
);
/**
@@ -377,29 +401,49 @@ errorret_t saveStreamWriteDateImpl(
);
/**
* Reads the contents of a save slot from the stream into the save file
* struct. Use saveFileRead* macros to deserialize fields one at a time.
* Reads a self-contained save meta section (header, version, fields,
* checksum verification) from the stream.
*
* @param stream Active read stream for this slot.
* @param file Save file struct to populate.
* @param stream Active read stream, positioned at the section's start.
* @param meta Meta struct to populate.
* @return An error code if loading fails.
*/
errorret_t saveFileLoad(savestream_t *stream, savefile_t *file);
errorret_t saveMetaSerializeRead(savestream_t *stream, savemeta_t *meta);
/**
* Writes the contents of the save file struct into the stream.
* Use saveFileWrite* macros to serialize fields one at a time.
* Writes a self-contained save meta section (header, version, fields,
* checksum) to the stream.
*
* @param stream Active write stream for this slot.
* @param file Save file struct to serialize.
* @param stream Active write stream, positioned at the section's start.
* @param meta Meta struct to serialize.
* @return An error code if writing fails.
*/
errorret_t saveFileWrite(savestream_t *stream, savefile_t *file);
errorret_t saveMetaSerializeWrite(savestream_t *stream, savemeta_t *meta);
#define saveFileReadHeader(stream, header) \
errorChain(saveStreamReadHeaderImpl(stream, header))
#define saveFileWriteHeader(stream, header) \
errorChain(saveStreamWriteHeaderImpl(stream, header))
/**
* Reads a self-contained save slot section (header, version, fields,
* checksum verification) from the stream.
*
* @param stream Active read stream, positioned at the section's start.
* @param slot Slot struct to populate.
* @return An error code if loading fails.
*/
errorret_t saveSlotSerializeRead(savestream_t *stream, saveslot_t *slot);
/**
* Writes a self-contained save slot section (header, version, fields,
* checksum) to the stream.
*
* @param stream Active write stream, positioned at the section's start.
* @param slot Slot struct to serialize.
* @return An error code if writing fails.
*/
errorret_t saveSlotSerializeWrite(savestream_t *stream, saveslot_t *slot);
#define saveFileReadHeader(stream, header, expected, size) \
errorChain(saveStreamReadHeaderImpl(stream, header, expected, size))
#define saveFileWriteHeader(stream, header, size) \
errorChain(saveStreamWriteHeaderImpl(stream, header, size))
#define saveFileReadVersion(stream, out) \
errorChain(saveStreamReadVersionImpl(stream, out))
@@ -465,4 +509,3 @@ errorret_t saveFileWrite(savestream_t *stream, savefile_t *file);
errorChain(saveStreamReadDateImpl(stream, out))
#define saveFileWriteDate(stream, input) \
errorChain(saveStreamWriteDateImpl(stream, input))
+79
View File
@@ -7,18 +7,88 @@
#include "uigamemenu.h"
#include "ui/frame/uiframe.h"
#include "ui/frame/uiconfirm.h"
#include "ui/frame/settings/uisettings.h"
#include "ui/frame/backpack/uibackpack.h"
#include "ui/rpg/textbox/uitextboxmain.h"
#include "util/memory.h"
#include "display/spritebatch/spritebatch.h"
#include "display/screen/screen.h"
#include "assert/assert.h"
#include "locale/localemanager.h"
#include "asset/loader/locale/assetlocaleloader.h"
#include "save/save.h"
#include "error/error.h"
#include "util/string.h"
#define UI_GAME_MENU_INDEX_CHARACTERS 0
#define UI_GAME_MENU_INDEX_ITEMS 1
#define UI_GAME_MENU_INDEX_SETTINGS 2
#define UI_GAME_MENU_INDEX_SAVE 3
static void uiGameMenuSaveWriteComplete(errorret_t result, void *user) {
if(errorIsNotOk(result)) {
// Generously sized - stringFormat asserts (crashes) rather than
// truncating if the message doesn't fit, so this must comfortably fit
// the longest platform save-error message plus this prefix.
char_t msg[256];
stringFormat(
msg, sizeof(msg), "Save failed: %s", result.state->message
);
errorCatch(result);
uiTextboxMainSetText(msg);
return;
}
uiTextboxMainSetText("Game saved.");
}
static void uiGameMenuSaveCreateConfirmed(const bool_t confirmed, void *user) {
if(!confirmed) {
uiTextboxMainSetText("Save cancelled.");
return;
}
saveWriteSlot(SAVE_ACTIVE_SLOT, uiGameMenuSaveWriteComplete, NULL);
}
// Determines whether there's actually save data to overwrite (not just
// whether the medium is present) by attempting a real load first - this is
// what lets a fresh memory card/stick, with no prior save on it yet, be
// told apart from one that already has our data on it. Cheap either way
// (a single sector/file read), and correct on every platform without any
// platform-specific UI code - saveSlotExists() already reflects each
// platform's own notion of "found something."
static void uiGameMenuSaveCheckComplete(errorret_t result, void *user) {
if(errorIsNotOk(result)) {
char_t msg[256];
stringFormat(msg, sizeof(msg), "Can't save: %s", result.state->message);
errorCatch(result);
uiTextboxMainSetText(msg);
return;
}
if(saveSlotExists(SAVE_ACTIVE_SLOT)) {
saveWriteSlot(SAVE_ACTIVE_SLOT, uiGameMenuSaveWriteComplete, NULL);
return;
}
uiConfirmOpen(
"No save data found. Create a new save?",
uiGameMenuSaveCreateConfirmed,
NULL
);
}
static void uiGameMenuSave(void) {
if(!saveIsAvailable()) {
uiTextboxMainSetText("Can't save - no save device found.");
return;
}
if(saveIsBusy()) return;// A save/load dialog (e.g. on PSP) is already up.
saveLoadSlot(SAVE_ACTIVE_SLOT, uiGameMenuSaveCheckComplete, NULL);
}
uigamemenu_t UI_GAME_MENU;
@@ -29,6 +99,7 @@ void uiGameMenuSelected(
) {
if(index == UI_GAME_MENU_INDEX_ITEMS) uiBackpackOpen();
if(index == UI_GAME_MENU_INDEX_SETTINGS) uiSettingsOpen();
if(index == UI_GAME_MENU_INDEX_SAVE) uiGameMenuSave();
}
errorret_t uiGameMenuInit(void) {
@@ -55,6 +126,13 @@ errorret_t uiGameMenuInit(void) {
UI_GAME_MENU.settingsLabel,
UI_GAME_MENU_LABEL_MAX
));
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.game_menu.save",
0,
UI_GAME_MENU.saveLabel,
UI_GAME_MENU_LABEL_MAX
));
MENU_BEGIN(
&UI_GAME_MENU.menu, UI_GAME_MENU.items, uiGameMenuSelected, NULL, NULL
@@ -62,6 +140,7 @@ errorret_t uiGameMenuInit(void) {
MENU_BUTTON(UI_GAME_MENU.charactersLabel);
MENU_BUTTON(UI_GAME_MENU.itemsLabel);
MENU_BUTTON(UI_GAME_MENU.settingsLabel);
MENU_BUTTON(UI_GAME_MENU.saveLabel);
MENU_END(UI_GAME_MENU.items, 1);
+2 -1
View File
@@ -9,7 +9,7 @@
#include "error/error.h"
#include "ui/widget/uimenu.h"
#define UI_GAME_MENU_ITEM_COUNT 3
#define UI_GAME_MENU_ITEM_COUNT 4
#define UI_GAME_MENU_WIDTH 150.0f
#define UI_GAME_MENU_LABEL_MAX 32
@@ -19,6 +19,7 @@ typedef struct {
char_t charactersLabel[UI_GAME_MENU_LABEL_MAX];
char_t itemsLabel[UI_GAME_MENU_LABEL_MAX];
char_t settingsLabel[UI_GAME_MENU_LABEL_MAX];
char_t saveLabel[UI_GAME_MENU_LABEL_MAX];
} uigamemenu_t;
extern uigamemenu_t UI_GAME_MENU;
+10 -5
View File
@@ -11,7 +11,11 @@
#include "util/memory.h"
#include "locale/localemanager.h"
#include "asset/loader/locale/assetlocaleloader.h"
#include "input/input.h"
#include "save/save.h"
static void uiSettingsInputSaveComplete(errorret_t result, void *user) {
if(errorIsNotOk(result)) errorCatch(errorPrint(result));
}
void uiSettingsInputSelected(
const uimenu_t *menu,
@@ -39,7 +43,7 @@ errorret_t uiSettingsInputInit(uisettingsdata_t *data) {
UI_SETTINGS_INPUT_LABEL_MAX
));
MENU_SLIDER_FLOAT(
input->deadzoneLabel, INPUT_DEADZONE_DEFAULT, 0.0f, 1.0f, 0.05f
input->deadzoneLabel, SAVE_META_DEADZONE_DEFAULT, 0.0f, 1.0f, 0.05f
);
#else
MENU_LABEL("No input settings yet");
@@ -55,16 +59,17 @@ void uiSettingsInputLoad(void) {
#ifdef DUSK_INPUT_GAMEPAD
uiSliderSetFloat(
&UI_SETTINGS.data.input.items[UI_SETTINGS_INPUT_INDEX_DEADZONE].slider,
INPUT.deadzone
saveGetMeta()->deadzone
);
#endif
}
void uiSettingsInputApply(void) {
#ifdef DUSK_INPUT_GAMEPAD
INPUT.deadzone = uiSliderGetFloat(
saveGetMeta()->deadzone = uiSliderGetFloat(
&UI_SETTINGS.data.input.items[UI_SETTINGS_INPUT_INDEX_DEADZONE].slider
);
saveWriteMeta(uiSettingsInputSaveComplete, NULL);
#endif
uiMenuClose(&UI_SETTINGS.data.input.menu);
}
@@ -73,7 +78,7 @@ bool_t uiSettingsInputHasChanges(void) {
#ifdef DUSK_INPUT_GAMEPAD
if(uiSliderGetFloat(
&UI_SETTINGS.data.input.items[UI_SETTINGS_INPUT_INDEX_DEADZONE].slider
) != INPUT.deadzone) return true;
) != saveGetMeta()->deadzone) return true;
#endif
return false;
+2 -1
View File
@@ -9,6 +9,7 @@
#include "assert/assert.h"
#include "log/log.h"
#include "util/string.h"
#include "save/save.h"
inputbuttondata_t INPUT_BUTTON_DATA[] = {
#ifdef DUSK_INPUT_GAMEPAD
@@ -187,5 +188,5 @@ float_t inputButtonGetValueDolphin(const inputbutton_t button) {
}
float_t inputGetDeadzoneDolphin(const inputbutton_t button) {
return 0.2f;
return saveGetMeta()->deadzone;
}
+122 -99
View File
@@ -6,26 +6,52 @@
*/
#include "save/save.h"
#include "save/savestream.h"
#include "util/memory.h"
#include "util/string.h"
static void _saveGetFileName(
const uint8_t slot, char_t *out, const size_t max
) {
snprintf(out, max, "%s_%u", SAVE_DOLPHIN_GAME_CODE, (uint32_t)slot);
}
errorret_t saveInitDolphin(void) {
SAVE.platform.mounted = false;
int32_t result = CARD_Mount(
SAVE_DOLPHIN_CHANNEL,
SAVE.platform.cardBuffer,
NULL
);
// Must run once before any other CARD_* call: sets up card_inited,
// the per-channel control blocks (wait queues, alarms) CARD_Mount reads,
// and initializes the DSP (needed for the card unlock sequence).
// Skipping this leaves those structures unset, so CARD_Mount ends up
// touching hardware state that was never brought up -- e.g. Dolphin's
// "Trying to read 32 bits from an invalid MMIO" error -- rather than
// failing cleanly with a CARD_ERROR_* code.
int32_t result = CARD_Init(SAVE_DOLPHIN_GAME_CODE, NULL);
if(result < 0) {
errorThrow("Failed to initialize memory card subsystem: %s (%d)",
saveCardErrorStringDolphin(result), result
);
}
do {
result = CARD_Mount(
SAVE_DOLPHIN_CHANNEL,
SAVE.platform.cardBuffer,
NULL
);
} while(result == CARD_ERROR_BUSY);
// Special-case the failures a player can actually act on; everything
// else falls through to the generic, fully-enumerated message below.
switch(result) {
case CARD_ERROR_NOCARD:
errorThrow("No memory card inserted in the slot");
case CARD_ERROR_WRONGDEVICE:
errorThrow("Unsupported device inserted in the memory card slot");
case CARD_ERROR_BROKEN:
errorThrow("Memory card is damaged or unformatted");
default:
break;
}
if(result < 0) {
errorThrow("Failed to mount memory card (error %d)", result);
errorThrow("Failed to mount memory card: %s (%d)",
saveCardErrorStringDolphin(result), result
);
}
SAVE.platform.mounted = true;
@@ -40,106 +66,103 @@ errorret_t saveDisposeDolphin(void) {
errorOk();
}
errorret_t saveLoadDolphin(const uint8_t slot, savefile_t *file) {
char_t fileName[SAVE_DOLPHIN_FILE_NAME_MAX];
_saveGetFileName(slot, fileName, SAVE_DOLPHIN_FILE_NAME_MAX);
errorret_t saveCombinedLoadDolphin(void) {
savestream_t stream;
memoryZero(&stream, sizeof(savestream_t));
int32_t result = CARD_Open(
SAVE_DOLPHIN_CHANNEL, fileName, &SAVE.platform.cardFile
);
if(result == CARD_ERROR_NOFILE) {
file->exists = false;
errorOk();
}
if(result < 0) {
file->exists = false;
errorThrow("Failed to open memory card file for slot %u (error %d)",
(uint32_t)slot, result
);
errorret_t openRet = saveStreamOpenReadPlatform(&stream);
SAVE.available = errorIsOk(openRet);
errorChain(openRet);
if(!stream.found) errorOk();
errorret_t ret = saveMetaSerializeRead(&stream, &SAVE.meta);
for(uint8_t i = 0; errorIsOk(ret) && i < SAVE_SLOT_COUNT_MAX; i++) {
ret = saveSlotSerializeRead(&stream, &SAVE.slots[i]);
}
void *buffer = memoryAlign(32, SAVE_DOLPHIN_SECTOR_SIZE);
if(!buffer) {
CARD_Close(&SAVE.platform.cardFile);
errorThrow("Failed to allocate memory card read buffer");
}
#ifdef saveStreamClosePlatform
saveStreamClosePlatform(&stream);
#endif
result = CARD_Read(
&SAVE.platform.cardFile, buffer, SAVE_DOLPHIN_SECTOR_SIZE, 0
);
CARD_Close(&SAVE.platform.cardFile);
if(result < 0) {
memoryFree(buffer);
file->exists = false;
errorThrow("Failed to read memory card data for slot %u (error %d)",
(uint32_t)slot, result
);
}
memoryCopy(file, buffer, sizeof(savefile_t));
memoryFree(buffer);
file->exists = true;
errorChain(ret);
errorOk();
}
errorret_t saveWriteDolphin(const uint8_t slot, const savefile_t *file) {
char_t fileName[SAVE_DOLPHIN_FILE_NAME_MAX];
_saveGetFileName(slot, fileName, SAVE_DOLPHIN_FILE_NAME_MAX);
errorret_t saveCombinedWriteDolphin(void) {
savestream_t stream;
memoryZero(&stream, sizeof(savestream_t));
void *buffer = memoryAlign(32, SAVE_DOLPHIN_SECTOR_SIZE);
if(!buffer) {
errorThrow("Failed to allocate memory card write buffer");
}
memoryZero(buffer, SAVE_DOLPHIN_SECTOR_SIZE);
memoryCopy(buffer, file, sizeof(savefile_t));
errorret_t openRet = saveStreamOpenWritePlatform(&stream);
SAVE.available = errorIsOk(openRet);
errorChain(openRet);
// Try open existing file first; create if absent.
int32_t result = CARD_Open(
SAVE_DOLPHIN_CHANNEL, fileName, &SAVE.platform.cardFile
);
if(result == CARD_ERROR_NOFILE) {
result = CARD_Create(
SAVE_DOLPHIN_CHANNEL,
fileName,
SAVE_DOLPHIN_SECTOR_SIZE,
&SAVE.platform.cardFile
);
errorret_t ret = saveMetaSerializeWrite(&stream, &SAVE.meta);
for(uint8_t i = 0; errorIsOk(ret) && i < SAVE_SLOT_COUNT_MAX; i++) {
ret = saveSlotSerializeWrite(&stream, &SAVE.slots[i]);
}
if(result < 0) {
memoryFree(buffer);
errorThrow("Failed to open/create memory card file for slot %u (error %d)",
(uint32_t)slot, result
);
}
result = CARD_Write(
&SAVE.platform.cardFile, buffer, SAVE_DOLPHIN_SECTOR_SIZE, 0
);
CARD_Close(&SAVE.platform.cardFile);
memoryFree(buffer);
if(result < 0) {
errorThrow("Failed to write memory card data for slot %u (error %d)",
(uint32_t)slot, result
);
}
#ifdef saveStreamClosePlatform
saveStreamClosePlatform(&stream);
#endif
errorChain(ret);
errorOk();
}
errorret_t saveDeleteDolphin(const uint8_t slot) {
char_t fileName[SAVE_DOLPHIN_FILE_NAME_MAX];
_saveGetFileName(slot, fileName, SAVE_DOLPHIN_FILE_NAME_MAX);
int32_t result = CARD_Delete(SAVE_DOLPHIN_CHANNEL, fileName);
if(result < 0 && result != CARD_ERROR_NOFILE) {
errorThrow("Failed to delete memory card file for slot %u (error %d)",
(uint32_t)slot, result
);
}
errorOk();
errorret_t saveSlotLoadDolphin(const uint8_t slot, saveslot_t *out) {
(void)slot;
(void)out;
return saveCombinedLoadDolphin();
}
errorret_t saveSlotWriteDolphin(const uint8_t slot, saveslot_t *slotData) {
(void)slot;
(void)slotData;
return saveCombinedWriteDolphin();
}
errorret_t saveSlotDeleteDolphin(const uint8_t slot) {
memoryZero(&SAVE.slots[slot], sizeof(saveslot_t));
SAVE.slots[slot].exists = false;
return saveCombinedWriteDolphin();
}
errorret_t saveMetaLoadDolphin(savemeta_t *out) {
(void)out;
return saveCombinedLoadDolphin();
}
errorret_t saveMetaWriteDolphin(savemeta_t *meta) {
(void)meta;
return saveCombinedWriteDolphin();
}
const char_t *saveCardErrorStringDolphin(const int32_t result) {
switch(result) {
case CARD_ERROR_READY: return "card is ready";
case CARD_ERROR_UNLOCKED:
return "card is being unlocked or already unlocked";
case CARD_ERROR_BUSY: return "card is busy";
case CARD_ERROR_WRONGDEVICE: return "wrong device connected in slot";
case CARD_ERROR_NOCARD: return "no memory card in slot";
case CARD_ERROR_NOFILE: return "specified file not found";
case CARD_ERROR_IOERROR: return "internal EXI I/O error";
case CARD_ERROR_BROKEN:
return "directory structure or file entry broken";
case CARD_ERROR_EXIST:
return "file already exists with the specified parameters";
case CARD_ERROR_NOENT:
return "no empty block available to create the file";
case CARD_ERROR_INSSPACE:
return "not enough space to write file to memory card";
case CARD_ERROR_NOPERM:
return "not enough permissions to operate on the file";
case CARD_ERROR_LIMIT: return "card size limit reached";
case CARD_ERROR_NAMETOOLONG: return "filename too long";
case CARD_ERROR_ENCODING: return "font encoding PAL/SJIS mismatch";
case CARD_ERROR_CANCELED: return "card operation canceled";
case CARD_ERROR_FATAL_ERROR: return "fatal error, non-recoverable";
default: return "unknown card error";
}
}
+65 -13
View File
@@ -7,10 +7,10 @@
#pragma once
#include "error/error.h"
#include "save/savefile.h"
#include "save/saveslot.h"
#include "save/savemeta.h"
#include <gccore.h>
#define SAVE_DOLPHIN_FILE_NAME_MAX 32
#define SAVE_DOLPHIN_SECTOR_SIZE 8192
#ifndef SAVE_DOLPHIN_GAME_CODE
@@ -21,6 +21,17 @@
#define SAVE_DOLPHIN_CHANNEL CARD_SLOTA
#endif
/**
* Fixed memory card file name holding meta + every save slot, back to
* back, in one file - GameCube memory cards are small enough that one
* consolidated file (rather than one per slot, plus a separate one for
* meta) meaningfully saves card space, and nothing needs true random
* access into just one section (see saveCombinedLoadDolphin()).
*/
#ifndef SAVE_DOLPHIN_FILE_NAME
#define SAVE_DOLPHIN_FILE_NAME "DUSK_SAVE"
#endif
typedef struct {
card_file cardFile;
uint8_t cardBuffer[CARD_WORKAREA] __attribute__((aligned(32)));
@@ -42,27 +53,68 @@ errorret_t saveInitDolphin(void);
errorret_t saveDisposeDolphin(void);
/**
* Loads a save file from the memory card for the given slot.
* Reads the one consolidated card file (SAVE_DOLPHIN_FILE_NAME) into
* SAVE.meta and every SAVE.slots[i], in order. Not finding the file is
* not an error - SAVE.meta/SAVE.slots simply keep their compiled
* defaults.
*
* @param slot The save slot index.
* @param file Output save file data.
* @return An error code if the load fails.
* @return An error code if the card is mounted but the read/parse fails.
*/
errorret_t saveLoadDolphin(const uint8_t slot, savefile_t *file);
errorret_t saveCombinedLoadDolphin(void);
/**
* Writes a save file to the memory card for the given slot.
* Writes SAVE.meta and every SAVE.slots[i], in order, into the one
* consolidated card file (SAVE_DOLPHIN_FILE_NAME), creating it if needed.
*
* @param slot The save slot index.
* @param file Save file data to write.
* @return An error code if the write fails.
*/
errorret_t saveWriteDolphin(const uint8_t slot, const savefile_t *file);
errorret_t saveCombinedWriteDolphin(void);
/**
* Deletes the save file for the given slot from the memory card.
* Save-slot platform entry point - always (re)reads the whole
* consolidated file (see saveCombinedLoadDolphin()); slot/out are unused
* since every slot is populated in the same pass.
*/
errorret_t saveSlotLoadDolphin(const uint8_t slot, saveslot_t *out);
/**
* Save-slot platform entry point - always (re)writes the whole
* consolidated file (see saveCombinedWriteDolphin()); slot/slotData are
* unused since every slot is written in the same pass.
*/
errorret_t saveSlotWriteDolphin(const uint8_t slot, saveslot_t *slotData);
/**
* Deletes a single save slot's data (zeroes it in memory) and re-writes
* the consolidated file - the file itself always exists as long as any
* slot or meta does, so "delete" can't remove the file wholesale.
*
* @param slot The save slot index.
* @return An error code if the delete fails.
*/
errorret_t saveDeleteDolphin(const uint8_t slot);
errorret_t saveSlotDeleteDolphin(const uint8_t slot);
/**
* Meta platform entry point - always (re)reads the whole consolidated
* file (see saveCombinedLoadDolphin()); out is unused since meta is
* populated in the same pass.
*/
errorret_t saveMetaLoadDolphin(savemeta_t *out);
/**
* Meta platform entry point - always (re)writes the whole consolidated
* file (see saveCombinedWriteDolphin()); meta is unused since it's
* written in the same pass.
*/
errorret_t saveMetaWriteDolphin(savemeta_t *meta);
/**
* Describes a libogc CARD_ERROR_* result code (see
* https://libogc.devkitpro.org/group__card__errors.html), for logging
* alongside the raw numeric code.
*
* @param result The result code returned by a CARD_* libogc call.
* @return A human-readable description of the result code, or
* "unknown card error" if result doesn't match a known CARD_ERROR_* code.
*/
const char_t *saveCardErrorStringDolphin(const int32_t result);
+12 -5
View File
@@ -14,12 +14,17 @@ typedef savestreamdolphin_t saveplatformstream_t;
#define saveInitPlatform saveInitDolphin
#define saveDisposePlatform saveDisposeDolphin
#define saveDeletePlatform saveDeleteDolphin
#define saveStreamOpenReadPlatform(stream, slot) \
saveStreamOpenReadDolphin(&(stream)->platform, &(stream)->found, slot)
#define saveStreamOpenWritePlatform(stream, slot) \
saveStreamOpenWriteDolphin(&(stream)->platform, slot)
#define saveSlotDeletePlatform saveSlotDeleteDolphin
#define saveSlotLoadPlatform saveSlotLoadDolphin
#define saveSlotWritePlatform saveSlotWriteDolphin
#define saveMetaLoadPlatform saveMetaLoadDolphin
#define saveMetaWritePlatform saveMetaWriteDolphin
#define saveStreamOpenReadPlatform(stream) \
saveStreamOpenReadDolphin(&(stream)->platform, &(stream)->found)
#define saveStreamOpenWritePlatform(stream) \
saveStreamOpenWriteDolphin(&(stream)->platform)
#define saveStreamClosePlatform(stream) \
saveStreamCloseDolphin(&(stream)->platform)
#define saveStreamReadBytesPlatform(stream, buf, len) \
@@ -28,3 +33,5 @@ typedef savestreamdolphin_t saveplatformstream_t;
saveStreamWriteBytesDolphin(&(stream)->platform, buf, len)
#define saveStreamSeekPlatform(stream, pos) \
saveStreamSeekDolphin(&(stream)->platform, pos)
#define saveStreamTellPlatform(stream, out) \
saveStreamTellDolphin(&(stream)->platform, out)
+41 -33
View File
@@ -8,23 +8,19 @@
#include "save/save.h"
#include "save/savestreamdolphin.h"
#include "util/memory.h"
#include "util/string.h"
static void _saveStreamGetFileName(
char_t *out, const size_t max, const uint8_t slot
) {
snprintf(out, max, "%s_%u", SAVE_DOLPHIN_GAME_CODE, (uint32_t)slot);
}
errorret_t saveStreamOpenReadDolphin(savestreamdolphin_t *p, bool_t *found) {
if(!SAVE.platform.mounted) {
*found = false;
errorThrow("No memory card mounted");
}
errorret_t saveStreamOpenReadDolphin(
savestreamdolphin_t *p, bool_t *found, const uint8_t slot
) {
char_t fileName[SAVE_DOLPHIN_FILE_NAME_MAX];
_saveStreamGetFileName(fileName, SAVE_DOLPHIN_FILE_NAME_MAX, slot);
int32_t result = CARD_Open(
SAVE_DOLPHIN_CHANNEL, fileName, &p->cardFile
);
int32_t result;
do {
result = CARD_Open(
SAVE_DOLPHIN_CHANNEL, SAVE_DOLPHIN_FILE_NAME, &p->cardFile
);
} while(result == CARD_ERROR_BUSY);
if(result == CARD_ERROR_NOFILE) {
*found = false;
p->position = 0;
@@ -33,51 +29,58 @@ errorret_t saveStreamOpenReadDolphin(
}
if(result < 0) {
*found = false;
errorThrow("Failed to open memory card file for slot %u (error %d)",
(uint32_t)slot, result
errorThrow("Failed to open memory card file: %s (%d)",
saveCardErrorStringDolphin(result), result
);
}
result = CARD_Read(&p->cardFile, p->buffer, SAVE_DOLPHIN_SECTOR_SIZE, 0);
do {
result = CARD_Read(&p->cardFile, p->buffer, SAVE_DOLPHIN_SECTOR_SIZE, 0);
} while(result == CARD_ERROR_BUSY);
CARD_Close(&p->cardFile);
if(result < 0) {
*found = false;
errorThrow("Failed to read memory card data for slot %u (error %d)",
(uint32_t)slot, result
errorThrow("Failed to read memory card data: %s (%d)",
saveCardErrorStringDolphin(result), result
);
}
*found = true;
p->position = 0;
p->writing = false;
p->slot = slot;
errorOk();
}
errorret_t saveStreamOpenWriteDolphin(
savestreamdolphin_t *p, const uint8_t slot
) {
errorret_t saveStreamOpenWriteDolphin(savestreamdolphin_t *p) {
if(!SAVE.platform.mounted) errorThrow("No memory card mounted");
memoryZero(p->buffer, SAVE_DOLPHIN_SECTOR_SIZE);
p->position = 0;
p->writing = true;
p->slot = slot;
errorOk();
}
void saveStreamCloseDolphin(savestreamdolphin_t *p) {
if(!p->writing) return;
char_t fileName[SAVE_DOLPHIN_FILE_NAME_MAX];
_saveStreamGetFileName(fileName, SAVE_DOLPHIN_FILE_NAME_MAX, p->slot);
int32_t result = CARD_Open(SAVE_DOLPHIN_CHANNEL, fileName, &p->cardFile);
if(result == CARD_ERROR_NOFILE) {
CARD_Create(
SAVE_DOLPHIN_CHANNEL, fileName, SAVE_DOLPHIN_SECTOR_SIZE, &p->cardFile
int32_t result;
do {
result = CARD_Open(
SAVE_DOLPHIN_CHANNEL, SAVE_DOLPHIN_FILE_NAME, &p->cardFile
);
} while(result == CARD_ERROR_BUSY);
if(result == CARD_ERROR_NOFILE) {
do {
result = CARD_Create(
SAVE_DOLPHIN_CHANNEL, SAVE_DOLPHIN_FILE_NAME,
SAVE_DOLPHIN_SECTOR_SIZE, &p->cardFile
);
} while(result == CARD_ERROR_BUSY);
}
CARD_Write(&p->cardFile, p->buffer, SAVE_DOLPHIN_SECTOR_SIZE, 0);
do {
result = CARD_Write(&p->cardFile, p->buffer, SAVE_DOLPHIN_SECTOR_SIZE, 0);
} while(result == CARD_ERROR_BUSY);
CARD_Close(&p->cardFile);
}
@@ -110,3 +113,8 @@ errorret_t saveStreamSeekDolphin(savestreamdolphin_t *p, const size_t pos) {
p->position = pos;
errorOk();
}
errorret_t saveStreamTellDolphin(savestreamdolphin_t *p, size_t *out) {
*out = p->position;
errorOk();
}
+17 -14
View File
@@ -20,34 +20,28 @@ typedef struct {
size_t position;
/** True when opened for writing; flushes buffer to card on close. */
bool_t writing;
/** Slot index stored at open time so Close can derive the filename. */
uint8_t slot;
} savestreamdolphin_t;
/**
* Opens a memory card slot for reading by loading its sector into buffer.
* Opens the consolidated memory card file for reading by loading its
* sector into buffer.
*
* @param p Stream to initialize.
* @param found Set to true if the file exists, false if it does not.
* @param slot Save slot index.
* @return An error if reading the card fails for a reason other than
* missing file.
*/
errorret_t saveStreamOpenReadDolphin(
savestreamdolphin_t *p, bool_t *found, const uint8_t slot
);
errorret_t saveStreamOpenReadDolphin(savestreamdolphin_t *p, bool_t *found);
/**
* Opens a memory card slot for writing by zeroing the sector buffer.
* The buffer is flushed to the card when savestreamCloseDolphin is called.
* Opens the consolidated memory card file for writing by zeroing the
* sector buffer. The buffer is flushed to the card when
* saveStreamCloseDolphin is called.
*
* @param p Stream to initialize.
* @param slot Save slot index.
* @param p Stream to initialize.
* @return An error if initialization fails.
*/
errorret_t saveStreamOpenWriteDolphin(
savestreamdolphin_t *p, const uint8_t slot
);
errorret_t saveStreamOpenWriteDolphin(savestreamdolphin_t *p);
/**
* Flushes the sector buffer to the memory card (write mode only) and
@@ -89,3 +83,12 @@ errorret_t saveStreamWriteBytesDolphin(
* @return An error if pos is out of range.
*/
errorret_t saveStreamSeekDolphin(savestreamdolphin_t *p, const size_t pos);
/**
* Gets the current read/write position within the sector buffer.
*
* @param p Active stream.
* @param out Receives the current position.
* @return An error - always succeeds, matches saveStreamTellImpl's shape.
*/
errorret_t saveStreamTellDolphin(savestreamdolphin_t *p, size_t *out);
+2 -1
View File
@@ -6,6 +6,7 @@
*/
#include "input/input.h"
#include "save/save.h"
inputbuttondata_t INPUT_BUTTON_DATA[] = {
#ifdef DUSK_INPUT_GAMEPAD
@@ -547,5 +548,5 @@ errorret_t inputInitLinux(void) {
}
float_t inputGetDeadzoneSDL2(const inputbutton_t button) {
return 0.17f;
return saveGetMeta()->deadzone;
}
+1 -1
View File
@@ -7,5 +7,5 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
savelinux.c
savestreamlinux.c
savejsonlinux.c
)
+161
View File
@@ -0,0 +1,161 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "save/savejsonlinux.h"
#include "util/string.h"
#include <sys/stat.h>
errorret_t saveJsonWriterInitLinux(savejsonwriterlinux_t *writer) {
writer->doc = yyjson_mut_doc_new(NULL);
if(!writer->doc) {
errorThrow("Failed to allocate JSON document");
}
writer->root = yyjson_mut_obj(writer->doc);
yyjson_mut_doc_set_root(writer->doc, writer->root);
errorOk();
}
void saveJsonWriterAddUInt32Linux(
savejsonwriterlinux_t *writer, const char_t *key, const uint32_t value
) {
yyjson_mut_obj_add_uint(writer->doc, writer->root, key, (uint64_t)value);
}
void saveJsonWriterAddFloatLinux(
savejsonwriterlinux_t *writer, const char_t *key, const float_t value
) {
yyjson_mut_obj_add_real(writer->doc, writer->root, key, (double)value);
}
void saveJsonWriterAddStringLinux(
savejsonwriterlinux_t *writer, const char_t *key, const char_t *value
) {
yyjson_mut_obj_add_strcpy(writer->doc, writer->root, key, value);
}
void saveJsonWriterAddBoolArrayLinux(
savejsonwriterlinux_t *writer, const char_t *key, const bool_t *values,
const size_t count
) {
yyjson_mut_val *arr = yyjson_mut_arr(writer->doc);
for(size_t i = 0; i < count; i++) {
yyjson_mut_arr_add_bool(writer->doc, arr, values[i]);
}
yyjson_mut_obj_add_val(writer->doc, writer->root, key, arr);
}
void saveJsonWriterAddUInt8ArrayLinux(
savejsonwriterlinux_t *writer, const char_t *key, const uint8_t *values,
const size_t count
) {
yyjson_mut_val *arr = yyjson_mut_arr(writer->doc);
for(size_t i = 0; i < count; i++) {
yyjson_mut_arr_add_uint(writer->doc, arr, (uint64_t)values[i]);
}
yyjson_mut_obj_add_val(writer->doc, writer->root, key, arr);
}
errorret_t saveJsonWriterSaveLinux(
savejsonwriterlinux_t *writer, const char_t *path
) {
yyjson_write_err err;
if(!yyjson_mut_write_file(
path, writer->doc, YYJSON_WRITE_PRETTY, NULL, &err
)) {
errorThrow("Failed to write %s: %s", path, err.msg);
}
errorOk();
}
void saveJsonWriterDisposeLinux(savejsonwriterlinux_t *writer) {
if(writer->doc) {
yyjson_mut_doc_free(writer->doc);
writer->doc = NULL;
}
}
errorret_t saveJsonReaderOpenLinux(
const char_t *path, yyjson_doc **outDoc, yyjson_val **outRoot,
bool_t *found
) {
*outDoc = NULL;
*outRoot = NULL;
struct stat st;
if(stat(path, &st) != 0) {
*found = false;
errorOk();
}
yyjson_read_err err;
*outDoc = yyjson_read_file(
path, YYJSON_READ_ALLOW_COMMENTS | YYJSON_READ_ALLOW_TRAILING_COMMAS,
NULL, &err
);
if(!*outDoc) {
*found = false;
errorThrow("Failed to parse %s: %s", path, err.msg);
}
*outRoot = yyjson_doc_get_root(*outDoc);
*found = true;
errorOk();
}
uint32_t saveJsonReadUInt32Linux(
yyjson_val *root, const char_t *key, const uint32_t defaultValue
) {
yyjson_val *val = yyjson_obj_get(root, key);
if(!val || !yyjson_is_num(val)) return defaultValue;
return (uint32_t)yyjson_get_uint(val);
}
float_t saveJsonReadFloatLinux(
yyjson_val *root, const char_t *key, const float_t defaultValue
) {
yyjson_val *val = yyjson_obj_get(root, key);
if(!val || !yyjson_is_num(val)) return defaultValue;
return (float_t)yyjson_get_num(val);
}
void saveJsonReadStringLinux(
yyjson_val *root, const char_t *key, char_t *out, const size_t maxLen,
const char_t *defaultValue
) {
yyjson_val *val = yyjson_obj_get(root, key);
const char_t *src = defaultValue;
if(val && yyjson_is_str(val)) src = yyjson_get_str(val);
stringCopy(out, src, maxLen);
}
void saveJsonReadBoolArrayLinux(
yyjson_val *root, const char_t *key, bool_t *out, const size_t count
) {
yyjson_val *arr = yyjson_obj_get(root, key);
if(!arr || !yyjson_is_arr(arr)) return;
size_t idx, len;
yyjson_val *elem;
yyjson_arr_foreach(arr, idx, len, elem) {
if(idx >= count) break;
if(yyjson_is_bool(elem)) out[idx] = yyjson_get_bool(elem);
}
}
void saveJsonReadUInt8ArrayLinux(
yyjson_val *root, const char_t *key, uint8_t *out, const size_t count
) {
yyjson_val *arr = yyjson_obj_get(root, key);
if(!arr || !yyjson_is_arr(arr)) return;
size_t idx, len;
yyjson_val *elem;
yyjson_arr_foreach(arr, idx, len, elem) {
if(idx >= count) break;
if(yyjson_is_int(elem)) out[idx] = (uint8_t)yyjson_get_int(elem);
}
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "yyjson.h"
/**
* Small helper around a yyjson mutable document, used to build up a save
* file's fields one at a time before writing it out. Schema-agnostic -
* knows nothing about saveslot_t/savemeta_t; the caller supplies field
* names and values.
*/
typedef struct {
yyjson_mut_doc *doc;
yyjson_mut_val *root;
} savejsonwriterlinux_t;
/**
* Creates a new mutable JSON document with an empty root object.
*
* @param writer Writer to initialize.
* @return An error if the document can't be allocated.
*/
errorret_t saveJsonWriterInitLinux(savejsonwriterlinux_t *writer);
/**
* Adds a "version": <uint> field to the root object.
*/
void saveJsonWriterAddUInt32Linux(
savejsonwriterlinux_t *writer, const char_t *key, const uint32_t value
);
/**
* Adds a float field to the root object (written as a JSON number).
*/
void saveJsonWriterAddFloatLinux(
savejsonwriterlinux_t *writer, const char_t *key, const float_t value
);
/**
* Adds a string field to the root object. The value is copied into the
* document, so the caller's buffer doesn't need to outlive the call.
*/
void saveJsonWriterAddStringLinux(
savejsonwriterlinux_t *writer, const char_t *key, const char_t *value
);
/**
* Adds a JSON array of booleans as a field on the root object.
*/
void saveJsonWriterAddBoolArrayLinux(
savejsonwriterlinux_t *writer, const char_t *key, const bool_t *values,
const size_t count
);
/**
* Adds a JSON array of unsigned 8-bit integers as a field on the root
* object.
*/
void saveJsonWriterAddUInt8ArrayLinux(
savejsonwriterlinux_t *writer, const char_t *key, const uint8_t *values,
const size_t count
);
/**
* Pretty-prints the document to the given file path, creating or
* truncating it.
*
* @param writer Writer holding the document to write.
* @param path Destination file path.
* @return An error if the write fails.
*/
errorret_t saveJsonWriterSaveLinux(
savejsonwriterlinux_t *writer, const char_t *path
);
/**
* Frees the document. Safe to call even if saveJsonWriterInitLinux()
* failed partway.
*
* @param writer Writer to dispose.
*/
void saveJsonWriterDisposeLinux(savejsonwriterlinux_t *writer);
/**
* Reads and parses a JSON file, returning its root object.
*
* @param path File path to read.
* @param outDoc Receives the parsed document (must be freed via
* yyjson_doc_free() once done, regardless of found/error outcome).
* @param outRoot Receives the root object, or NULL if not found.
* @param found Set to true if the file exists, false if it does not
* (not finding the file is not an error).
* @return An error if the file exists but fails to parse.
*/
errorret_t saveJsonReaderOpenLinux(
const char_t *path, yyjson_doc **outDoc, yyjson_val **outRoot,
bool_t *found
);
/**
* Reads a uint32 field, falling back to defaultValue if the key is
* missing or not a number - a hand-edited file shouldn't hard-fail the
* whole load over one bad/missing field.
*/
uint32_t saveJsonReadUInt32Linux(
yyjson_val *root, const char_t *key, const uint32_t defaultValue
);
/**
* Reads a float field, falling back to defaultValue if the key is
* missing or not a number.
*/
float_t saveJsonReadFloatLinux(
yyjson_val *root, const char_t *key, const float_t defaultValue
);
/**
* Reads a string field into out, falling back to defaultValue if the key
* is missing or not a string. Always null-terminates.
*/
void saveJsonReadStringLinux(
yyjson_val *root, const char_t *key, char_t *out, const size_t maxLen,
const char_t *defaultValue
);
/**
* Reads a JSON array of booleans into out, up to count entries. Missing
* key, non-array value, or a shorter array all leave the remaining/all
* entries untouched (caller should zero the buffer first).
*/
void saveJsonReadBoolArrayLinux(
yyjson_val *root, const char_t *key, bool_t *out, const size_t count
);
/**
* Reads a JSON array of unsigned 8-bit integers into out, up to count
* entries. Same forgiving semantics as saveJsonReadBoolArrayLinux().
*/
void saveJsonReadUInt8ArrayLinux(
yyjson_val *root, const char_t *key, uint8_t *out, const size_t count
);
+98 -36
View File
@@ -6,8 +6,9 @@
*/
#include "save/save.h"
#include "save/savejsonlinux.h"
#include "util/string.h"
#include <stdio.h>
#include "util/memory.h"
#include <sys/stat.h>
#include <errno.h>
@@ -25,60 +26,121 @@ errorret_t saveDisposeLinux(void) {
errorOk();
}
errorret_t saveLoadLinux(const uint8_t slot, savefile_t *file) {
char_t path[SAVE_LINUX_PATH_MAX];
snprintf(path, SAVE_LINUX_PATH_MAX, SAVE_LINUX_FILE_FORMAT,
SAVE.platform.savePath, (uint32_t)slot
static void _saveSlotPathLinux(
char_t *out, const size_t max, const uint8_t slot
) {
snprintf(
out, max, SAVE_LINUX_SLOT_FILE_FORMAT, SAVE.platform.savePath,
(uint32_t)slot
);
}
FILE *f = fopen(path, "rb");
if(!f) {
file->exists = false;
errorOk();
}
static void _saveMetaPathLinux(char_t *out, const size_t max) {
snprintf(out, max, SAVE_LINUX_META_FILE_FORMAT, SAVE.platform.savePath);
}
size_t read = fread(file, sizeof(savefile_t), 1, f);
fclose(f);
errorret_t saveSlotLoadLinux(const uint8_t slot, saveslot_t *out) {
char_t path[SAVE_LINUX_PATH_MAX];
_saveSlotPathLinux(path, SAVE_LINUX_PATH_MAX, slot);
if(read != 1) {
file->exists = false;
errorThrow("Failed to read save data for slot %u", (uint32_t)slot);
}
yyjson_doc *doc;
yyjson_val *root;
bool_t found;
errorret_t ret = saveJsonReaderOpenLinux(path, &doc, &root, &found);
if(errorIsNotOk(ret)) { yyjson_doc_free(doc); errorChain(ret); }
if(!found) errorOk();
file->exists = true;
memoryZero(out, sizeof(saveslot_t));
out->version = saveJsonReadUInt32Linux(root, "version", SAVE_SLOT_VERSION);
saveJsonReadStringLinux(
root, "playerName", out->playerName, SAVE_PLAYER_NAME_MAX, ""
);
saveJsonReadBoolArrayLinux(
root, "globalItemCollected", out->globalItemCollected,
SAVE_GLOBAL_ITEM_COUNT_MAX
);
saveJsonReadUInt8ArrayLinux(
root, "storyFlags", out->storyFlags, SAVE_STORY_FLAG_COUNT_MAX
);
out->exists = true;
yyjson_doc_free(doc);
errorOk();
}
errorret_t saveWriteLinux(const uint8_t slot, const savefile_t *file) {
errorret_t saveSlotWriteLinux(const uint8_t slot, saveslot_t *slotData) {
char_t path[SAVE_LINUX_PATH_MAX];
snprintf(path, SAVE_LINUX_PATH_MAX, SAVE_LINUX_FILE_FORMAT,
SAVE.platform.savePath, (uint32_t)slot
_saveSlotPathLinux(path, SAVE_LINUX_PATH_MAX, slot);
slotData->version = SAVE_SLOT_VERSION;
savejsonwriterlinux_t writer;
errorChain(saveJsonWriterInitLinux(&writer));
saveJsonWriterAddUInt32Linux(&writer, "version", slotData->version);
saveJsonWriterAddStringLinux(&writer, "playerName", slotData->playerName);
saveJsonWriterAddBoolArrayLinux(
&writer, "globalItemCollected", slotData->globalItemCollected,
SAVE_GLOBAL_ITEM_COUNT_MAX
);
saveJsonWriterAddUInt8ArrayLinux(
&writer, "storyFlags", slotData->storyFlags, SAVE_STORY_FLAG_COUNT_MAX
);
FILE *f = fopen(path, "wb");
if(!f) {
errorThrow("Failed to open save file for writing: slot %u", (uint32_t)slot);
}
size_t written = fwrite(file, sizeof(savefile_t), 1, f);
fclose(f);
if(written != 1) {
errorThrow("Failed to write save data for slot %u", (uint32_t)slot);
}
errorret_t ret = saveJsonWriterSaveLinux(&writer, path);
saveJsonWriterDisposeLinux(&writer);
errorChain(ret);
slotData->exists = true;
errorOk();
}
errorret_t saveDeleteLinux(const uint8_t slot) {
errorret_t saveDeleteSlotLinux(const uint8_t slot) {
char_t path[SAVE_LINUX_PATH_MAX];
snprintf(path, SAVE_LINUX_PATH_MAX, SAVE_LINUX_FILE_FORMAT,
SAVE.platform.savePath, (uint32_t)slot
);
_saveSlotPathLinux(path, SAVE_LINUX_PATH_MAX, slot);
if(remove(path) != 0 && errno != ENOENT) {
errorThrow("Failed to delete save file for slot %u", (uint32_t)slot);
errorThrow("Failed to delete save slot %u: %s", (uint32_t)slot, path);
}
errorOk();
}
errorret_t saveMetaLoadLinux(savemeta_t *out) {
char_t path[SAVE_LINUX_PATH_MAX];
_saveMetaPathLinux(path, SAVE_LINUX_PATH_MAX);
yyjson_doc *doc;
yyjson_val *root;
bool_t found;
errorret_t ret = saveJsonReaderOpenLinux(path, &doc, &root, &found);
if(errorIsNotOk(ret)) { yyjson_doc_free(doc); errorChain(ret); }
if(!found) errorOk();
out->version = saveJsonReadUInt32Linux(root, "version", SAVE_META_VERSION);
out->deadzone = saveJsonReadFloatLinux(
root, "deadzone", SAVE_META_DEADZONE_DEFAULT
);
out->exists = true;
yyjson_doc_free(doc);
errorOk();
}
errorret_t saveMetaWriteLinux(savemeta_t *meta) {
char_t path[SAVE_LINUX_PATH_MAX];
_saveMetaPathLinux(path, SAVE_LINUX_PATH_MAX);
meta->version = SAVE_META_VERSION;
savejsonwriterlinux_t writer;
errorChain(saveJsonWriterInitLinux(&writer));
saveJsonWriterAddUInt32Linux(&writer, "version", meta->version);
saveJsonWriterAddFloatLinux(&writer, "deadzone", meta->deadzone);
errorret_t ret = saveJsonWriterSaveLinux(&writer, path);
saveJsonWriterDisposeLinux(&writer);
errorChain(ret);
meta->exists = true;
errorOk();
}
+31 -12
View File
@@ -7,10 +7,12 @@
#pragma once
#include "error/error.h"
#include "save/savefile.h"
#include "save/saveslot.h"
#include "save/savemeta.h"
#define SAVE_LINUX_PATH_MAX FILENAME_MAX
#define SAVE_LINUX_FILE_FORMAT "%s/save_%u.dat"
#define SAVE_LINUX_SLOT_FILE_FORMAT "%s/slot%u.json"
#define SAVE_LINUX_META_FILE_FORMAT "%s/settings.json"
#ifndef SAVE_LINUX_PATH
#define SAVE_LINUX_PATH "./saves"
@@ -21,7 +23,8 @@ typedef struct {
} savelinux_t;
/**
* Initializes the save system on Linux.
* Initializes the save system on Linux - ensures the save directory
* exists (shared by both save slots and meta).
*
* @return An error code if initialization fails.
*/
@@ -35,27 +38,43 @@ errorret_t saveInitLinux(void);
errorret_t saveDisposeLinux(void);
/**
* Loads a save file from disk for the given slot.
* Loads a save slot as JSON (slotN.json) from disk, if it exists.
*
* @param slot The save slot index.
* @param file Output save file data.
* @return An error code if the load fails.
* @param out Output slot data.
* @return An error code if the slot exists but fails to parse.
*/
errorret_t saveLoadLinux(const uint8_t slot, savefile_t *file);
errorret_t saveSlotLoadLinux(const uint8_t slot, saveslot_t *out);
/**
* Writes a save file to disk for the given slot.
* Writes a save slot as JSON (slotN.json) to disk.
*
* @param slot The save slot index.
* @param file Save file data to write.
* @param slotData Slot data to write.
* @return An error code if the write fails.
*/
errorret_t saveWriteLinux(const uint8_t slot, const savefile_t *file);
errorret_t saveSlotWriteLinux(const uint8_t slot, saveslot_t *slotData);
/**
* Deletes the save file for the given slot from disk.
* Deletes the save slot JSON file for the given index.
*
* @param slot The save slot index.
* @return An error code if the delete fails.
*/
errorret_t saveDeleteLinux(const uint8_t slot);
errorret_t saveDeleteSlotLinux(const uint8_t slot);
/**
* Loads save meta as JSON (settings.json) from disk, if it exists.
*
* @param out Output meta data.
* @return An error code if the file exists but fails to parse.
*/
errorret_t saveMetaLoadLinux(savemeta_t *out);
/**
* Writes save meta as JSON (settings.json) to disk.
*
* @param meta Meta data to write.
* @return An error code if the write fails.
*/
errorret_t saveMetaWriteLinux(savemeta_t *meta);
+12 -15
View File
@@ -7,24 +7,21 @@
#pragma once
#include "save/savelinux.h"
#include "save/savestreamlinux.h"
typedef savelinux_t saveplatform_t;
typedef savestreamlinux_t saveplatformstream_t;
// Linux fully overrides every save operation with JSON I/O (see
// savelinux.c/savejsonlinux.c) - nothing generic ever opens a
// savestream_t here, so this only needs to exist for that shared type to
// compile.
typedef struct {
uint8_t reserved;
} saveplatformstream_t;
#define saveInitPlatform saveInitLinux
#define saveDisposePlatform saveDisposeLinux
#define saveDeletePlatform saveDeleteLinux
#define saveStreamOpenReadPlatform(stream, slot) \
saveStreamOpenReadLinux(&(stream)->platform, &(stream)->found, slot)
#define saveStreamOpenWritePlatform(stream, slot) \
saveStreamOpenWriteLinux(&(stream)->platform, slot)
#define saveStreamClosePlatform(stream) \
saveStreamCloseLinux(&(stream)->platform)
#define saveStreamReadBytesPlatform(stream, buf, len) \
saveStreamReadBytesLinux(&(stream)->platform, buf, len)
#define saveStreamWriteBytesPlatform(stream, buf, len) \
saveStreamWriteBytesLinux(&(stream)->platform, buf, len)
#define saveStreamSeekPlatform(stream, pos) \
saveStreamSeekLinux(&(stream)->platform, pos)
#define saveSlotDeletePlatform saveDeleteSlotLinux
#define saveSlotLoadPlatform saveSlotLoadLinux
#define saveSlotWritePlatform saveSlotWriteLinux
#define saveMetaLoadPlatform saveMetaLoadLinux
#define saveMetaWritePlatform saveMetaWriteLinux
-75
View File
@@ -1,75 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "save/save.h"
#include "save/savestreamlinux.h"
#include "util/string.h"
#include <sys/stat.h>
#include <errno.h>
static void _saveStreamGetPath(
char_t *out, const size_t max, const uint8_t slot
) {
snprintf(
out, max, SAVE_LINUX_FILE_FORMAT,
SAVE.platform.savePath, (uint32_t)slot
);
}
errorret_t saveStreamOpenReadLinux(
savestreamlinux_t *p, bool_t *found, const uint8_t slot
) {
char_t path[SAVE_LINUX_PATH_MAX];
_saveStreamGetPath(path, SAVE_LINUX_PATH_MAX, slot);
p->file = fopen(path, "rb");
*found = (p->file != NULL);
errorOk();
}
errorret_t saveStreamOpenWriteLinux(savestreamlinux_t *p, const uint8_t slot) {
char_t path[SAVE_LINUX_PATH_MAX];
_saveStreamGetPath(path, SAVE_LINUX_PATH_MAX, slot);
p->file = fopen(path, "wb");
if(!p->file) {
errorThrow("Failed to open save file for writing: slot %u", (uint32_t)slot);
}
errorOk();
}
void saveStreamCloseLinux(savestreamlinux_t *p) {
if(p->file) {
fclose(p->file);
p->file = NULL;
}
}
errorret_t saveStreamReadBytesLinux(
savestreamlinux_t *p, void *buf, const size_t len
) {
if(fread(buf, 1, len, p->file) != len) {
errorThrow("Unexpected end of save file");
}
errorOk();
}
errorret_t saveStreamWriteBytesLinux(
savestreamlinux_t *p, const void *buf, const size_t len
) {
if(fwrite(buf, 1, len, p->file) != len) {
errorThrow("Failed to write save data");
}
errorOk();
}
errorret_t saveStreamSeekLinux(savestreamlinux_t *p, const size_t pos) {
if(fseek(p->file, (long)pos, SEEK_SET) != 0) {
errorThrow("Failed to seek in save file");
}
errorOk();
}
-78
View File
@@ -1,78 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include <stdio.h>
#include <stddef.h>
typedef struct {
FILE *file;
} savestreamlinux_t;
/**
* Opens a save slot file for reading.
*
* @param p Stream to initialize.
* @param found Set to true if the file exists, false if it does not.
* @param slot Save slot index.
* @return An error if the open fails for a reason other than missing file.
*/
errorret_t saveStreamOpenReadLinux(
savestreamlinux_t *p, bool_t *found, const uint8_t slot
);
/**
* Opens a save slot file for writing, creating or truncating it.
*
* @param p Stream to initialize.
* @param slot Save slot index.
* @return An error if the file cannot be opened for writing.
*/
errorret_t saveStreamOpenWriteLinux(
savestreamlinux_t *p, const uint8_t slot
);
/**
* Closes the file handle held by the stream.
*
* @param p Stream to close.
*/
void saveStreamCloseLinux(savestreamlinux_t *p);
/**
* Reads len bytes from the stream into buf.
*
* @param p Active stream.
* @param buf Destination buffer.
* @param len Number of bytes to read.
* @return An error if fewer than len bytes are available.
*/
errorret_t saveStreamReadBytesLinux(
savestreamlinux_t *p, void *buf, const size_t len
);
/**
* Writes len bytes from buf into the stream.
*
* @param p Active stream.
* @param buf Source buffer.
* @param len Number of bytes to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteBytesLinux(
savestreamlinux_t *p, const void *buf, const size_t len
);
/**
* Seeks to an absolute byte position within the stream.
*
* @param p Active stream.
* @param pos Target byte offset from the start of the file.
* @return An error if the seek fails.
*/
errorret_t saveStreamSeekLinux(savestreamlinux_t *p, const size_t pos);
+2 -1
View File
@@ -6,6 +6,7 @@
*/
#include "input/input.h"
#include "save/save.h"
// #define INPUT_PSP_GAMEPAD_BUTTON_ACCEPT INPUT_SDL2_GAMEPAD_BUTTON_CUSTOM
// #define INPUT_PSP_GAMEPAD_BUTTON_CANCEL INPUT_SDL2_GAMEPAD_BUTTON_CUSTOM
@@ -94,5 +95,5 @@ errorret_t inputInitPSP(void) {
}
float_t inputGetDeadzoneSDL2(const inputbutton_t button) {
return 0.2f;
return saveGetMeta()->deadzone;
}
+8
View File
@@ -9,3 +9,11 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
savepsp.c
savestreampsp.c
)
# PSP only needs one Dusk-side save slot - a future main-menu save picker
# will let players manage multiple named saves through the OS's own
# sceUtilitySavedata browser instead of Dusk maintaining its own numbered
# slots (see save/saveslot.h for the default used by every other platform).
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
SAVE_SLOT_COUNT_MAX=1
)
+30 -7
View File
@@ -14,17 +14,40 @@ typedef savestreampsp_t saveplatformstream_t;
#define saveInitPlatform saveInitPSP
#define saveDisposePlatform saveDisposePSP
#define saveDeletePlatform saveDeletePSP
#define saveSlotDeletePlatform saveDeleteSlotPSP
#define saveStreamOpenReadPlatform(stream, slot) \
saveStreamOpenReadPSP(&(stream)->platform, &(stream)->found, slot)
#define saveStreamOpenWritePlatform(stream, slot) \
saveStreamOpenWritePSP(&(stream)->platform, slot)
#define saveStreamClosePlatform(stream) \
saveStreamClosePSP(&(stream)->platform)
#define saveStreamReadBytesPlatform(stream, buf, len) \
saveStreamReadBytesPSP(&(stream)->platform, buf, len)
#define saveStreamWriteBytesPlatform(stream, buf, len) \
saveStreamWriteBytesPSP(&(stream)->platform, buf, len)
#define saveStreamSeekPlatform(stream, pos) \
saveStreamSeekPSP(&(stream)->platform, pos)
#define saveStreamTellPlatform(stream, out) \
saveStreamTellPSP(&(stream)->platform, out)
// Save/load go entirely through the native sceUtilitySavedata dialog
// (savePSPBeginSave/Load), which spans multiple frames - these bypass
// save.c's normal synchronous open/write-fields/close flow above (that's
// still used internally, just against an in-memory buffer, from within
// savePSPBeginSave/Load themselves) and are what save.c's saveWriteSlot()/
// saveLoadSlot()/saveWriteMeta()/saveLoadMeta() actually call on this
// platform. Meta and the (one) slot are serialized together into the same
// buffer - there is no separate meta-only path on PSP - so the meta
// variants just drive the same dialog against SAVE_ACTIVE_SLOT.
#define saveSlotAsyncWritePlatform(slot, onComplete, user) \
savePSPBeginSave(slot, onComplete, user)
#define saveSlotAsyncLoadPlatform(slot, onComplete, user) \
savePSPBeginLoad(slot, onComplete, user)
#define saveMetaAsyncWritePlatform(onComplete, user) \
savePSPBeginSave(SAVE_ACTIVE_SLOT, onComplete, user)
#define saveMetaAsyncLoadPlatform(onComplete, user) \
savePSPBeginLoad(SAVE_ACTIVE_SLOT, onComplete, user)
#define saveIsBusyPlatform() savePSPIsBusy()
#define savePlatformUpdate() savePSPUpdate()
// Meta only reaches memory via the native dialog now (folded into the same
// payload as the save slot) - running that multi-frame dialog on every
// single boot just to eagerly populate SAVE.meta would reintroduce the
// exact UX problem a lightweight settings-only file used to avoid, so
// saveInit() skips eager loading entirely on this platform.
#define saveSkipEagerLoadPlatform
+265 -55
View File
@@ -6,8 +6,38 @@
*/
#include "save/save.h"
#include "save/savepsp.h"
#include "save/savestream.h"
#include "system/systempsp.h"
#include "util/memory.h"
#include "util/string.h"
#include "assert/assert.h"
static void savePSPParamCommonInit(SceUtilitySavedataParam *param) {
memoryZero(param, sizeof(SceUtilitySavedataParam));
param->base.size = sizeof(SceUtilitySavedataParam);
param->base.language = systemPSPGetLanguage();
param->base.buttonSwap = systemPSPGetCrossButtonSetting();
param->base.graphicsThread = 17;
param->base.accessThread = 19;
param->base.fontThread = 18;
param->base.soundThread = 16;
stringCopy(param->gameName, SAVE_PSP_GAME_NAME, sizeof(param->gameName));
stringCopy(param->fileName, SAVE_PSP_FILE_NAME, sizeof(param->fileName));
}
static void savePSPSaveNameForSlot(
char_t *out, const size_t max, const uint8_t slot
) {
stringFormat(out, max, "%02u", (uint32_t)slot);
}
errorret_t saveInitPSP(void) {
SceIoStat stat;
if(sceIoGetstat(SAVE_PSP_ROOT, &stat) < 0) {
errorThrow("No memory stick detected");
}
errorOk();
}
@@ -15,66 +45,246 @@ errorret_t saveDisposePSP(void) {
errorOk();
}
errorret_t saveLoadPSP(const uint8_t slot, savefile_t *file) {
errorret_t saveDeleteSlotPSP(const uint8_t slot) {
char_t path[SAVE_PSP_PATH_MAX];
snprintf(path, SAVE_PSP_PATH_MAX, SAVE_PSP_FILE_FORMAT,
SAVE_PSP_TITLE_ID, (uint32_t)slot
);
SceUID fd = sceIoOpen(path, PSP_O_RDONLY, 0);
if(fd < 0) {
file->exists = false;
errorOk();
}
int32_t read = sceIoRead(fd, file, sizeof(savefile_t));
sceIoClose(fd);
if(read != (int32_t)sizeof(savefile_t)) {
file->exists = false;
errorThrow("Failed to read save data for slot %u", (uint32_t)slot);
}
file->exists = true;
errorOk();
}
errorret_t saveWritePSP(const uint8_t slot, const savefile_t *file) {
char_t dir[SAVE_PSP_PATH_MAX];
snprintf(dir, SAVE_PSP_PATH_MAX, SAVE_PSP_DIR_FORMAT,
SAVE_PSP_TITLE_ID, (uint32_t)slot
);
sceIoMkdir(dir, 0777);
char_t path[SAVE_PSP_PATH_MAX];
snprintf(path, SAVE_PSP_PATH_MAX, SAVE_PSP_FILE_FORMAT,
SAVE_PSP_TITLE_ID, (uint32_t)slot
);
SceUID fd = sceIoOpen(path, PSP_O_WRONLY | PSP_O_CREAT | PSP_O_TRUNC, 0777);
if(fd < 0) {
errorThrow("Failed to open save file for writing: slot %u", (uint32_t)slot);
}
int32_t written = sceIoWrite(fd, file, sizeof(savefile_t));
sceIoClose(fd);
if(written != (int32_t)sizeof(savefile_t)) {
errorThrow("Failed to write save data for slot %u", (uint32_t)slot);
}
errorOk();
}
errorret_t saveDeletePSP(const uint8_t slot) {
char_t path[SAVE_PSP_PATH_MAX];
snprintf(path, SAVE_PSP_PATH_MAX, SAVE_PSP_FILE_FORMAT,
SAVE_PSP_TITLE_ID, (uint32_t)slot
stringFormat(
path, sizeof(path), SAVE_PSP_FILE_FORMAT, SAVE_PSP_GAME_NAME,
(uint32_t)slot
);
int32_t result = sceIoRemove(path);
if(result < 0 && result != (int32_t)0x80010002) {
errorThrow("Failed to delete save file for slot %u", (uint32_t)slot);
errorThrow("Failed to delete save data for slot %u", (uint32_t)slot);
}
char_t dir[SAVE_PSP_PATH_MAX];
stringFormat(
dir, sizeof(dir), "ms0:/PSP/SAVEDATA/%s%02u", SAVE_PSP_GAME_NAME,
(uint32_t)slot
);
char_t sfoPath[SAVE_PSP_PATH_MAX];
stringFormat(sfoPath, sizeof(sfoPath), "%s/PARAM.SFO", dir);
// Best-effort - PARAM.SFO/the directory itself may not exist (e.g. this
// slot was written by the old raw-file format, pre-dating this dialog-
// based rewrite) or the directory may still contain other entries.
sceIoRemove(sfoPath);
sceIoRmdir(dir);
errorOk();
}
void savePSPBeginSave(
const uint8_t slot, savecallback_t onComplete, void *user
) {
assertNotNull(onComplete, "onComplete cannot be NULL");
assertTrue(SAVE.platform.op == SAVE_PSP_OP_NONE, "Save already in progress");
saveslot_t *slotData = &SAVE.slots[slot];
// Serialize meta then the slot into the buffer synchronously (plain
// memory writes, same header/version/CRC framing as every other
// platform) before the dialog ever starts - only the actual commit-to-
// storage step needs to wait on the dialog.
savestream_t stream;
memoryZero(&stream, sizeof(savestream_t));
stream.platform.buffer = SAVE.platform.dataBuffer;
stream.platform.bufferSize = sizeof(SAVE.platform.dataBuffer);
errorret_t ret = saveMetaSerializeWrite(&stream, &SAVE.meta);
if(errorIsOk(ret)) ret = saveSlotSerializeWrite(&stream, slotData);
if(errorIsNotOk(ret)) {
onComplete(ret, user);
return;
}
SAVE.platform.dataLength = stream.platform.length;
SceUtilitySavedataParam *param = &SAVE.platform.param;
savePSPParamCommonInit(param);
// AUTOSAVE rather than SAVE - SAVE shows a "save to this data?" confirm
// screen even for a slot with no existing data, which isn't the UX we
// want for a menu-triggered "Save" action (that confirmation already
// happened when the player chose to save). AUTOSAVE writes silently
// (just a brief "saving" icon flash) while still generating the same
// PARAM.SFO/title/description as any other mode.
param->mode = PSP_UTILITY_SAVEDATA_AUTOSAVE;
param->overwrite = 1;
savePSPSaveNameForSlot(param->saveName, sizeof(param->saveName), slot);
param->dataBuf = SAVE.platform.dataBuffer;
param->dataBufSize = sizeof(SAVE.platform.dataBuffer);
param->dataSize = SAVE.platform.dataLength;
// No ICON0/PIC1/SND0 art exists in this project yet, so these are left
// zeroed (bufSize 0) - the utility treats that as "no icon/background/
// sound" rather than an error. title/savedataTitle/detail are still
// fully functional and are what actually populates PARAM.SFO and the
// save browser entry.
stringCopy(param->sfoParam.title, "Dusk", sizeof(param->sfoParam.title));
stringCopy(
param->sfoParam.savedataTitle, slotData->playerName,
sizeof(param->sfoParam.savedataTitle)
);
stringCopy(
param->sfoParam.detail, "Dusk save file.", sizeof(param->sfoParam.detail)
);
int32_t initRet = sceUtilitySavedataInitStart(param);
if(initRet < 0) {
onComplete(errorThrowImpl(
&SAVE.errorState, ERROR_NOT_OK, __FILE__, __func__, __LINE__,
"Failed to start save dialog: 0x%08X", initRet
), user);
return;
}
SAVE.platform.op = SAVE_PSP_OP_SAVE;
SAVE.platform.slot = slot;
SAVE.platform.onComplete = onComplete;
SAVE.platform.onCompleteUser = user;
}
void savePSPBeginLoad(
const uint8_t slot, savecallback_t onComplete, void *user
) {
assertNotNull(onComplete, "onComplete cannot be NULL");
assertTrue(SAVE.platform.op == SAVE_PSP_OP_NONE, "Save already in progress");
char_t path[SAVE_PSP_PATH_MAX];
stringFormat(
path, sizeof(path), SAVE_PSP_FILE_FORMAT, SAVE_PSP_GAME_NAME,
(uint32_t)slot
);
SceIoStat stat;
if(sceIoGetstat(path, &stat) < 0) {
// No save data yet - not an error (matches every other platform's
// "nothing to load yet" behavior), and deliberately skips showing the
// dialog at all rather than surfacing an empty "no data" native
// screen for data the player has never saved.
onComplete(errorOkImpl(), user);
return;
}
SceUtilitySavedataParam *param = &SAVE.platform.param;
savePSPParamCommonInit(param);
param->mode = PSP_UTILITY_SAVEDATA_AUTOLOAD;// See savePSPBeginSave().
savePSPSaveNameForSlot(param->saveName, sizeof(param->saveName), slot);
param->dataBuf = SAVE.platform.dataBuffer;
param->dataBufSize = sizeof(SAVE.platform.dataBuffer);
int32_t initRet = sceUtilitySavedataInitStart(param);
if(initRet < 0) {
onComplete(errorThrowImpl(
&SAVE.errorState, ERROR_NOT_OK, __FILE__, __func__, __LINE__,
"Failed to start load dialog: 0x%08X", initRet
), user);
return;
}
SAVE.platform.op = SAVE_PSP_OP_LOAD;
SAVE.platform.slot = slot;
SAVE.platform.onComplete = onComplete;
SAVE.platform.onCompleteUser = user;
}
bool_t savePSPIsBusy(void) {
return SAVE.platform.op != SAVE_PSP_OP_NONE;
}
errorret_t savePSPUpdate(void) {
if(SAVE.platform.op == SAVE_PSP_OP_NONE) errorOk();
int32_t status = sceUtilitySavedataGetStatus();
switch(status) {
case PSP_UTILITY_DIALOG_INIT:
break;
// NOTE: unlike the netconf dialog, this does not replicate Dusk's own
// GL state (blend/cull/depth + texture/color) before calling Update().
// A prior fix for exactly that class of bug was documented for the
// network dialog, but no longer exists in the current codebase to
// copy from - if the save dialog's own text/icons don't render
// correctly on real hardware (PPSSPP won't reproduce this - it doesn't
// model pspGL's deferred state application), that state-priming
// pattern is the fix to reach for. See the network dialog's git
// history / the project's PSP dialog memory notes for the exact
// technique (state flags + a forced flush via a degenerate triangle
// draw).
case PSP_UTILITY_DIALOG_VISIBLE:
// sceUtilitySavedataUpdate() is void, unlike sceUtilityNetconfUpdate()
// - nothing to check here, GetStatus() next frame reflects any
// resulting state change.
sceUtilitySavedataUpdate(1);
break;
case PSP_UTILITY_DIALOG_QUIT:
// The save/load operation itself has already finished (successfully
// or not) - this just starts tearing the dialog down. The actual
// result is read once that teardown settles, below - don't call
// ShutdownStart more than once while waiting for it to.
if(!SAVE.platform.shuttingDown) {
SAVE.platform.shuttingDown = true;
sceUtilitySavedataShutdownStart();
}
break;
// Confirmed under PPSSPP: status settles straight from QUIT to NONE,
// without FINISHED ever being separately observed in between - so
// both are treated identically here as "torn down, read the result",
// and it's shuttingDown (not which of these two codes we saw) that
// distinguishes that from a genuine disappearance.
case PSP_UTILITY_DIALOG_FINISHED:
case PSP_UTILITY_DIALOG_NONE: {
savepspop_t op = SAVE.platform.op;
uint8_t slot = SAVE.platform.slot;
savecallback_t cb = SAVE.platform.onComplete;
void *user = SAVE.platform.onCompleteUser;
bool_t reachedQuit = SAVE.platform.shuttingDown;
SAVE.platform.op = SAVE_PSP_OP_NONE;
SAVE.platform.shuttingDown = false;
if(!reachedQuit) {
cb(errorThrowImpl(
&SAVE.errorState, ERROR_NOT_OK, __FILE__, __func__, __LINE__,
"Save dialog disappeared without a result"
), user);
break;
}
int32_t result = SAVE.platform.param.base.result;
if(result != 0) {
SAVE.available = false;
cb(errorThrowImpl(
&SAVE.errorState, ERROR_NOT_OK, __FILE__, __func__, __LINE__,
"Save dialog failed: 0x%08X", result
), user);
break;
}
SAVE.available = true;
if(op == SAVE_PSP_OP_LOAD) {
savestream_t stream;
memoryZero(&stream, sizeof(savestream_t));
stream.platform.buffer = SAVE.platform.dataBuffer;
stream.platform.bufferSize = sizeof(SAVE.platform.dataBuffer);
stream.platform.length = SAVE.platform.param.dataSize;
errorret_t ret = saveMetaSerializeRead(&stream, &SAVE.meta);
if(errorIsOk(ret)) {
ret = saveSlotSerializeRead(&stream, &SAVE.slots[slot]);
}
cb(ret, user);
} else {
SAVE.meta.exists = true;
SAVE.slots[slot].exists = true;
cb(errorOkImpl(), user);
}
break;
}
default:
errorThrow("Unknown savedata dialog status: %d", status);
}
errorOk();
+99 -29
View File
@@ -7,25 +7,60 @@
#pragma once
#include "error/error.h"
#include "save/savefile.h"
#include "save/saveslot.h"
#include "save/savemeta.h"
#include <pspiofilemgr.h>
#include <psputility.h>
#define SAVE_PSP_PATH_MAX 256
#define SAVE_PSP_FILE_FORMAT "ms0:/PSP/SAVEDATA/%s%02u/save.dat"
#define SAVE_PSP_DIR_FORMAT "ms0:/PSP/SAVEDATA/%s%02u"
#define SAVE_PSP_ROOT "ms0:/"
#define SAVE_PSP_FILE_NAME "save.bin"
#define SAVE_PSP_FILE_FORMAT "ms0:/PSP/SAVEDATA/%s%02u/" SAVE_PSP_FILE_NAME
#define SAVE_PSP_DATA_BUFFER_SIZE 4096
#ifndef SAVE_PSP_TITLE_ID
#define SAVE_PSP_TITLE_ID "DUSK00001"
#ifndef SAVE_PSP_GAME_NAME
#define SAVE_PSP_GAME_NAME "DUSK00001"
#endif
typedef enum {
SAVE_PSP_OP_NONE,
SAVE_PSP_OP_SAVE,
SAVE_PSP_OP_LOAD
} savepspop_t;
typedef struct {
uint8_t unused;
SceUtilitySavedataParam param;
// Raw buffer sceUtilitySavedata reads/writes the whole save into/from -
// holds BOTH save meta and the (single) save slot back-to-back,
// populated by our own savestream_t serialization (see savestreampsp.h)
// before a save starts, and deserialized from after a load finishes.
// Meta lives in here rather than its own lightweight file specifically
// because a device-wide preference change is meant to feel like a real
// save on this platform (a brief native icon flash), not need its own
// separate storage mechanism.
uint8_t dataBuffer[SAVE_PSP_DATA_BUFFER_SIZE] __attribute__((aligned(64)));
size_t dataLength;
savepspop_t op;
// True once sceUtilitySavedataShutdownStart() has been requested (dialog
// status PSP_UTILITY_DIALOG_QUIT seen) - distinguishes a normal "torn
// down after finishing" NONE/FINISHED from a genuinely unexpected one
// seen before ever reaching QUIT. Some implementations (confirmed on
// PPSSPP) settle straight to NONE after shutdown without a separately
// observable FINISHED step in between.
bool_t shuttingDown;
uint8_t slot;
savecallback_t onComplete;
void *onCompleteUser;
} savepsp_t;
/**
* Initializes the save system on PSP.
* Initializes the save system on PSP. Confirms the memory stick is
* actually reachable (sceIoGetstat on SAVE_PSP_ROOT) rather than assuming
* so, since the savedata dialog otherwise only reports failure once a
* save/load is actually attempted.
*
* @return An error code if initialization fails.
* @return An error code if no memory stick is reachable.
*/
errorret_t saveInitPSP(void);
@@ -37,27 +72,62 @@ errorret_t saveInitPSP(void);
errorret_t saveDisposePSP(void);
/**
* Loads a save file from PSP save data for the given slot.
* Deletes the (one) save data folder from the memory stick.
*
* @param slot The save slot index.
* @param file Output save file data.
* @return An error code if the load fails.
*/
errorret_t saveLoadPSP(const uint8_t slot, savefile_t *file);
/**
* Writes a save file to PSP save data for the given slot.
*
* @param slot The save slot index.
* @param file Save file data to write.
* @return An error code if the write fails.
*/
errorret_t saveWritePSP(const uint8_t slot, const savefile_t *file);
/**
* Deletes the save file for the given slot from PSP save data.
*
* @param slot The save slot index.
* @param slot The save slot index (always 0 on PSP - see
* SAVE_SLOT_COUNT_MAX's override in this platform's CMakeLists.txt).
* @return An error code if the delete fails.
*/
errorret_t saveDeletePSP(const uint8_t slot);
errorret_t saveDeleteSlotPSP(const uint8_t slot);
/**
* Starts a save via the native sceUtilitySavedata dialog (mode AUTOSAVE -
* writes silently with just a brief icon flash, no confirm screen, since
* SAVE mode shows one even for a slot with no existing data - but
* PARAM.SFO/title/description are generated identically regardless of
* mode, and the OS handles the save browser entry either way). Serializes
* SAVE.meta then SAVE.slots[slot] into SAVE.platform.dataBuffer first,
* synchronously, then kicks off the dialog and returns - completion is
* reported later via onComplete, driven by savePSPUpdate() each frame.
* If no save data exists yet, sceUtilitySavedataInitStart() creates it.
*
* @param slot The save slot index.
* @param onComplete Callback invoked once the dialog finishes.
* @param user User data passed through to onComplete.
*/
void savePSPBeginSave(
const uint8_t slot, savecallback_t onComplete, void *user
);
/**
* Starts a load via the native sceUtilitySavedata dialog (mode AUTOLOAD -
* see savePSPBeginSave() for why not the plain LOAD mode) unless a quick
* sceIoGetstat check finds no save data yet - in which case onComplete is
* invoked immediately with SAVE.meta/SAVE.slots[slot].exists left false,
* matching the other platforms' "no file yet" semantics, and no dialog is
* shown at all.
*
* @param slot The save slot index.
* @param onComplete Callback invoked once the dialog (or immediate
* not-found short-circuit) finishes.
* @param user User data passed through to onComplete.
*/
void savePSPBeginLoad(
const uint8_t slot, savecallback_t onComplete, void *user
);
/**
* Pumps the in-progress save/load dialog one step, if any - must be called
* every engine frame (see saveUpdate()). No-op if no dialog is active.
*
* @return An error code indicating success or failure.
*/
errorret_t savePSPUpdate(void);
/**
* True while a save/load dialog is in progress (see savePSPBeginSave()/
* savePSPBeginLoad()).
*
* @return True if a save/load dialog is currently open.
*/
bool_t savePSPIsBusy(void);
+18 -49
View File
@@ -7,71 +7,40 @@
#include "save/save.h"
#include "save/savestreampsp.h"
errorret_t saveStreamOpenReadPSP(
savestreampsp_t *p, bool_t *found, const uint8_t slot
) {
char_t path[SAVE_PSP_PATH_MAX];
snprintf(path, SAVE_PSP_PATH_MAX, SAVE_PSP_FILE_FORMAT,
SAVE_PSP_TITLE_ID, (uint32_t)slot
);
p->fd = sceIoOpen(path, PSP_O_RDONLY, 0);
*found = (p->fd >= 0);
errorOk();
}
errorret_t saveStreamOpenWritePSP(savestreampsp_t *p, const uint8_t slot) {
char_t dir[SAVE_PSP_PATH_MAX];
snprintf(dir, SAVE_PSP_PATH_MAX, SAVE_PSP_DIR_FORMAT,
SAVE_PSP_TITLE_ID, (uint32_t)slot
);
sceIoMkdir(dir, 0777);
char_t path[SAVE_PSP_PATH_MAX];
snprintf(path, SAVE_PSP_PATH_MAX, SAVE_PSP_FILE_FORMAT,
SAVE_PSP_TITLE_ID, (uint32_t)slot
);
p->fd = sceIoOpen(path, PSP_O_WRONLY | PSP_O_CREAT | PSP_O_TRUNC, 0777);
if(p->fd < 0) {
errorThrow(
"Failed to open PSP save file for writing: slot %u", (uint32_t)slot
);
}
errorOk();
}
void saveStreamClosePSP(savestreampsp_t *p) {
if(p->fd >= 0) {
sceIoClose(p->fd);
p->fd = -1;
}
}
#include "util/memory.h"
errorret_t saveStreamReadBytesPSP(
savestreampsp_t *p, void *buf, const size_t len
) {
int32_t read = sceIoRead(p->fd, buf, (SceSize)len);
if(read != (int32_t)len) {
errorThrow("Unexpected end of PSP save file");
if(p->position + len > p->length) {
errorThrow("Save stream read exceeds buffer length");
}
memoryCopy(buf, p->buffer + p->position, len);
p->position += len;
errorOk();
}
errorret_t saveStreamWriteBytesPSP(
savestreampsp_t *p, const void *buf, const size_t len
) {
int32_t written = sceIoWrite(p->fd, buf, (SceSize)len);
if(written != (int32_t)len) {
errorThrow("Failed to write PSP save data");
if(p->position + len > p->bufferSize) {
errorThrow("Save stream write exceeds buffer size");
}
memoryCopy(p->buffer + p->position, buf, len);
p->position += len;
if(p->position > p->length) p->length = p->position;
errorOk();
}
errorret_t saveStreamSeekPSP(savestreampsp_t *p, const size_t pos) {
if(sceIoLseek(p->fd, (SceOff)pos, PSP_SEEK_SET) < 0) {
errorThrow("Failed to seek in PSP save file");
if(pos > p->bufferSize) {
errorThrow("Save stream seek out of range");
}
p->position = pos;
errorOk();
}
errorret_t saveStreamTellPSP(savestreampsp_t *p, size_t *out) {
*out = p->position;
errorOk();
}
+25 -38
View File
@@ -7,71 +7,58 @@
#pragma once
#include "error/error.h"
#include <pspiofilemgr.h>
#include <stddef.h>
// Backed by SAVE.platform.dataBuffer (see savepsp.h) rather than owning its
// own memory - the buffer has to outlive a single saveFileWrite()/Load()
// call, since the actual save/load dialog it's handed to only completes
// several frames later.
typedef struct {
SceUID fd;
uint8_t *buffer;
size_t bufferSize;
size_t position;
size_t length;
} savestreampsp_t;
/**
* Opens a PSP save data file for reading.
*
* @param p Stream to initialize.
* @param found Set to true if the file exists, false if it does not.
* @param slot Save slot index.
* @return An error if the open fails for a reason other than missing file.
*/
errorret_t saveStreamOpenReadPSP(
savestreampsp_t *p, bool_t *found, const uint8_t slot
);
/**
* Opens a PSP save data file for writing, creating or truncating it.
* Creates the save data directory if it does not already exist.
*
* @param p Stream to initialize.
* @param slot Save slot index.
* @return An error if the file cannot be opened for writing.
*/
errorret_t saveStreamOpenWritePSP(savestreampsp_t *p, const uint8_t slot);
/**
* Closes the file descriptor held by the stream.
*
* @param p Stream to close.
*/
void saveStreamClosePSP(savestreampsp_t *p);
/**
* Reads len bytes from the stream into buf.
* Copies len bytes from the buffer at the current position into buf.
*
* @param p Active stream.
* @param buf Destination buffer.
* @param len Number of bytes to read.
* @return An error if fewer than len bytes are available.
* @return An error if the read would exceed the populated data length.
*/
errorret_t saveStreamReadBytesPSP(
savestreampsp_t *p, void *buf, const size_t len
);
/**
* Writes len bytes from buf into the stream.
* Copies len bytes from buf into the buffer at the current position,
* growing p->length if this write extends past it.
*
* @param p Active stream.
* @param buf Source buffer.
* @param len Number of bytes to write.
* @return An error if the write fails.
* @return An error if the write would exceed bufferSize.
*/
errorret_t saveStreamWriteBytesPSP(
savestreampsp_t *p, const void *buf, const size_t len
);
/**
* Seeks to an absolute byte position within the stream.
* Sets the current read/write position within the buffer.
*
* @param p Active stream.
* @param pos Target byte offset from the start of the file.
* @return An error if the seek fails.
* @param pos Target byte offset from the start of the buffer.
* @return An error if pos is out of range.
*/
errorret_t saveStreamSeekPSP(savestreampsp_t *p, const size_t pos);
/**
* Gets the current read/write position within the buffer.
*
* @param p Active stream.
* @param out Receives the current position.
* @return An error - always succeeds, matches saveStreamTellImpl's shape.
*/
errorret_t saveStreamTellPSP(savestreampsp_t *p, size_t *out);
+2 -1
View File
@@ -6,6 +6,7 @@
*/
#include "input/input.h"
#include "save/save.h"
inputbuttondata_t INPUT_BUTTON_DATA[] = {
{ .name = "triangle", {
@@ -83,5 +84,5 @@ inputbuttondata_t INPUT_BUTTON_DATA[] = {
};
float_t inputGetDeadzoneSDL2(const inputbutton_t button) {
return 0.17f;
return saveGetMeta()->deadzone;
}
+123 -5
View File
@@ -14,6 +14,16 @@ JSON input (assetsraw/chunks/chunk_X_Y_Z.json):
],
"meshes": [
{ "file": "house_5_3.dmf", "pos": [x, y, z] }
],
"entities": [
{ "type": "global", "globalId": <int>, "pos": [x, y, z] },
{ "type": "item", "itemId": <int>, "quantity": <int>, "pos": [x, y, z] }
],
"areas": [
{
"min": [x, y, z], "max": [x, y, z],
"callbackId": <int>, "notify": <int>, "trigger": <int>
}
]
}
@@ -25,10 +35,27 @@ JSON input (assetsraw/chunks/chunk_X_Y_Z.json):
Mesh files are located by searching under assets/meshes/ and referenced by
path from the assets root in the DCF.
"entities" spawns things into the world when this chunk loads. A "global"
entity is spawned via mapSpawnEntity() - globalId indexes
ENTITY_GLOBAL_LIST (src/dusk/rpg/entity/global/entitygloballist.h) and is
deduped automatically if already spawned, so it's safe to declare on a
chunk that streams in more than once. An "item" entity has no persistent
identity - it respawns fresh every time this chunk (re)loads, including
after being picked up, since nothing tracks "already collected" yet.
itemId is a raw ITEM_ID_* value (see src/dusk/rpg/item/item.json for the
name -> id mapping, same convention as the tile "type" ints above).
"areas" declares map trigger regions (see rpg/overworld/maparea.h) owned
by this chunk - they're removed when the chunk unloads and re-added if it
streams back in. callbackId indexes MAP_AREA_CALLBACK_LIST
(src/dusk/rpg/overworld/global/mapareagloballist.h; 0 is reserved and
invalid). notify is bitwise MAP_AREA_NOTIFY_PLAYER(1)|NOTIFY_NPC(2).
trigger is bitwise MAP_TRIGGER_STEP(1)|ENTER(2)|EXIT(4).
Output DCF is derived automatically:
assetsraw/chunks/chunk_X_Y_Z.json -> assets/chunks/X_Y_Z.dcf
Version 4 DCF format (after 8-byte header):
Version 5 DCF format (after 8-byte header):
tile_t tiles[CHUNK_WIDTH * CHUNK_HEIGHT] (one per x/y column)
each tile: uint32_t shape, uint8_t z, 3 padding bytes (8 bytes total,
matching the C tile_t struct's layout: { tileshape_t shape; uint8_t z; })
@@ -36,6 +63,19 @@ Version 4 DCF format (after 8-byte header):
for each model:
null-terminated string (relative asset path to .json model)
float32[3] (x, y, z offset)
uint8_t entitySpawnCount
for each entity spawn:
uint8_t kind (0 = global entity, 1 = item entity)
uint16_t a (globalId if kind 0, itemId if kind 1)
uint8_t b (unused if kind 0, quantity if kind 1)
int16_t x, y, z (world position, 3 fields)
uint8_t areaSpawnCount
for each area spawn:
int16_t minX, minY, minZ (3 fields)
int16_t maxX, maxY, maxZ (3 fields)
uint16_t callbackId
uint8_t notify
uint8_t trigger
DMF format:
Bytes 0-3: DMF\\x00
@@ -74,6 +114,11 @@ WORLD_LAYER_HEIGHT = 1.0 / math.sqrt(2)
CHUNK_MESH_COUNT_MAX = 10
CHUNK_MESH_NAME_MAX = 64
CHUNK_ENTITY_SPAWN_COUNT_MAX = 8
CHUNK_AREA_COUNT_MAX = 4
ENTITY_SPAWN_KIND_GLOBAL = 0
ENTITY_SPAWN_KIND_ITEM = 1
# Matches sizeof(tile_t) on the C side: uint32_t shape + uint8_t z, padded
# to 8 bytes ({ tileshape_t shape; uint8_t z; } with 4-byte enum alignment).
@@ -106,7 +151,7 @@ TILE_SHAPE_RAMP_SOUTHWEST_INNER = 13
FILE_MAGIC = b'DCF'
DMF_MAGIC = b'DMF\x00'
VERSION_OUT = 4
VERSION_OUT = 5
DMF_VERSION = 1
@@ -163,11 +208,29 @@ def write_dmf(path, vertex_bytes):
print(f' Wrote DMF {path}: {vert_count} vertices, {len(buf)} bytes')
def write_dcf(dcf_path, tiles, mesh_names, mesh_offsets=None):
def write_dcf(
dcf_path, tiles, mesh_names, mesh_offsets=None,
entity_spawns=None, area_spawns=None
):
"""Write a current-version DCF referencing the given DMF asset paths."""
mesh_count = len(mesh_names)
if mesh_offsets is None:
mesh_offsets = [(0.0, 0.0, 0.0)] * mesh_count
if entity_spawns is None:
entity_spawns = []
if area_spawns is None:
area_spawns = []
if len(entity_spawns) > CHUNK_ENTITY_SPAWN_COUNT_MAX:
raise ValueError(
f"Too many entity spawns ({len(entity_spawns)}) - max "
f"{CHUNK_ENTITY_SPAWN_COUNT_MAX}"
)
if len(area_spawns) > CHUNK_AREA_COUNT_MAX:
raise ValueError(
f"Too many area spawns ({len(area_spawns)}) - max "
f"{CHUNK_AREA_COUNT_MAX}"
)
buf = bytearray()
buf += FILE_MAGIC
@@ -183,11 +246,34 @@ def write_dcf(dcf_path, tiles, mesh_names, mesh_offsets=None):
)
buf += encoded + b'\x00'
buf += struct.pack('<3f', offset[0], offset[1], offset[2])
buf += struct.pack('<B', len(entity_spawns))
for spawn in entity_spawns:
kind = spawn['kind']
x, y, z = spawn['pos']
if kind == ENTITY_SPAWN_KIND_GLOBAL:
a, b = spawn['globalId'], 0
else:
a, b = spawn['itemId'], spawn['quantity']
buf += struct.pack('<BHB3h', kind, a, b, x, y, z)
buf += struct.pack('<B', len(area_spawns))
for area in area_spawns:
minX, minY, minZ = area['min']
maxX, maxY, maxZ = area['max']
buf += struct.pack(
'<6hHBB',
minX, minY, minZ, maxX, maxY, maxZ,
area['callbackId'], area['notify'], area['trigger']
)
with open(dcf_path, 'wb') as f:
f.write(buf)
print(
f' Wrote DCF {dcf_path}: '
f'version {VERSION_OUT}, {mesh_count} mesh(es), {len(buf)} bytes'
f'version {VERSION_OUT}, {mesh_count} mesh(es), '
f'{len(entity_spawns)} entity spawn(s), {len(area_spawns)} '
f'area(s), {len(buf)} bytes'
)
@@ -323,7 +409,39 @@ def from_json(json_path, dcf_path):
mesh_offsets.append((float(pos[0]), float(pos[1]), float(pos[2])))
print(f' Resolved {filename} -> {rel}')
write_dcf(dcf_path, bytes(tiles), model_names, mesh_offsets)
entity_spawns = []
for spawn in data.get('entities', []):
pos = tuple(int(v) for v in spawn['pos'])
if spawn['type'] == 'global':
entity_spawns.append({
'kind': ENTITY_SPAWN_KIND_GLOBAL,
'globalId': int(spawn['globalId']),
'pos': pos,
})
elif spawn['type'] == 'item':
entity_spawns.append({
'kind': ENTITY_SPAWN_KIND_ITEM,
'itemId': int(spawn['itemId']),
'quantity': int(spawn['quantity']),
'pos': pos,
})
else:
raise ValueError(f"Unknown entity spawn type: {spawn['type']}")
area_spawns = []
for area in data.get('areas', []):
area_spawns.append({
'min': tuple(int(v) for v in area['min']),
'max': tuple(int(v) for v in area['max']),
'callbackId': int(area['callbackId']),
'notify': int(area['notify']),
'trigger': int(area['trigger']),
})
write_dcf(
dcf_path, bytes(tiles), model_names, mesh_offsets,
entity_spawns, area_spawns
)
def process_json(json_path):
+5 -1
View File
@@ -38,7 +38,11 @@ out += [
" STORY_FLAG_COUNT",
"} storyflag_t;",
"",
"static storyflagvalue_t STORY_FLAG_VALUES[STORY_FLAG_COUNT] = {",
"// Stamped onto a save file's storyFlags the first time it's used (see",
"// storyFlagInitDefaults()) - not a live value array. Live flag state",
"// lives entirely in the save file (savefile_t.storyFlags), read/written",
"// via storyFlagGet()/storyFlagSet() - see storyflag.h.",
"static const storyflagvalue_t STORY_FLAG_DEFAULTS[STORY_FLAG_COUNT] = {",
]
for flag in flags:
out.append(f" [{flag_enum(flag['id'])}] = {flag['initial']},")