34 Commits

Author SHA1 Message Date
YourWishes 7a858cc424 Add a real test suite for the save system (previously had none)
Covers save.c (device discovery/orchestration), savedevice.c (the
generic device state machine and platform dispatch), and the Linux
platform backend (path building, availability checks, JSON read/write,
corrupt/missing-file handling), plus saveslot.c/savesettings.c JSON
round-trips. 88 tests across 5 files, all run against the real Linux
filesystem backend sandboxed to a temp $HOME (there's no mockable
platform layer - the hooks are compile-time macros, not function
pointers).

Deliberately locks in two existing behaviors rather than working around
them: saveSaveSettings() is a permanent no-op because nothing anywhere
ever sets SAVE.settingsDirty = true, and saveUpdate() unconditionally
rewrites settings back out the moment a device is found regardless of
that same dirty flag. Both are pre-existing, not introduced here.

Does not cover the SAVE_DEVICE_DATA_RAW blob codec (PSP/GameCube/Wii
only) or GameCube's 2-device fallback chain - neither compiles into the
Linux host test build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 12:33:20 -05:00
YourWishes c0292842a5 Fix stale include re-enabling test/item, fix dead sort-by-type coverage
test/item/test_inventory.c was disabled via a commented-out
add_subdirectory(item) with no explanation - the actual cause was a stale
"item/inventory.h" include left over from before the rpg/ reorg (real path
is rpg/item/inventory.h). The current inventory.h/.c API it tests hasn't
drifted; only the include path had rotted.

Also fixes a copy-paste bug in test_inventorySort: the "sort by type"
assertions were calling INVENTORY_SORT_BY_ID again instead of
INVENTORY_SORT_BY_TYPE, so inventorySortByType/Reverse had zero real
coverage. Asserts on type grouping only (not the tied FOOD-vs-FOOD order),
since the underlying sort() is qsort and not stable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 12:33:00 -05:00
YourWishes 717902462b Fix fatal async asset load errors, remove unused event system
An async asset load failure crashed the whole game via errorThrow,
while the identical sync failure just logged and continued - a single
missing/corrupted asset could take down the process. assetUpdate now
handles the async error path the same way as sync (invoke onError,
keep running).

Also removes event.h/event.c and assetbatch, which existed only to
support multiple subscribers per asset event but had no real caller
that ever used more than one (assetbatch itself had zero callers
anywhere). Asset entries, uifullbox, and uiloading now use plain
single-callback + user-pointer fields instead of the generic
array-backed event_t.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 10:14:38 -05:00
YourWishes 1d73b9d224 Fix PSP asset loading: stale EBOOT.PBP packing, unreliable zip file reads
EBOOT.PBP packing was a POST_BUILD step with no dependency on the asset
pak, so an assets-only rebuild could silently leave a stale dusk.dsk
embedded. cmake/targets/psp.cmake now repacks EBOOT.PBP via a properly
tracked custom command depending on the executable, PARAM.SFO, and
dusk.dsk (and correctly embeds Dusk.prx rather than the raw ELF when
BUILD_PRX is on).

Separately, libzip's zip_source_filep_create (lazy seeked FILE* reads)
proved unreliable on real PSP hardware, corrupting reads of the embedded
PSAR (first EINVAL, then zlib data errors) even though the packaged data
was verified byte-perfect. assetInitPBP now reads the whole PSAR into
memory once and uses zip_source_buffer_create instead. Confirmed working
on real hardware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 09:31:44 -05:00
YourWishes 51f262efa0 Wire keyboard into save naming, add fatal error overlay, cutscene keyboard item
- uimainmenu: removed the temporary keyboard test entry now that the
  keyboard has a real caller.
- uiselectsave: picking an empty save slot now opens the keyboard to
  name it (localized title "Enter game save file name"), writes the
  named slot to disk (mirroring the existing delete-confirm write
  pattern), and on a write failure opens the new fatal error overlay.
- uikeyboardopen_t gains a title override (NULL keeps the default
  "ENTER YOUR TEXT").
- New ui/overlay/uifatalerror: a minimal fullscreen, non-dismissible
  overlay (message + QUIT button, back disabled) for unrecoverable
  errors - a nicer alternative to a raw assert crash. Draws above
  everything else in the UI element list.
- New CUTSCENE_ITEM_TYPE_KEYBOARD cutscene item: opens the keyboard
  with an embedded uikeyboardopen_t, blocks the cutscene until it
  closes, then caches the entered text via a new general-purpose
  cutscenesystem_t.textCache (CUTSCENE_TEXT_CACHE_MAX, with get/set
  accessors) mirroring the existing entity/area/text-mini id caching
  pattern.
2026-08-28 08:29:07 -05:00
YourWishes a6e4e3f71f Add cancel confirmation, blinking cursor, custom keyboard icon glyphs
- uikeyboardopen_t gains cancelConfirm/cancelConfirmLabel, mirroring
  confirm/confirmLabel: cancelling (CANCEL button or back-with-nothing-
  left-to-delete) can now open an "are you sure?" dialog before closing.
- Added a blinking text-entry cursor, rendered as a separate label
  positioned at the end of the current text (accounting for newlines);
  it now stops at the last character rather than past it once maxLength
  is reached, matching uiKeyboardAppendChar's override-last-char
  behavior there.
- uibutton_t gains an `active` flag, independent of the focus-driven
  `highlighted` state, for a toggled-on indicator that survives focus
  moving elsewhere (used by the keyboard's CAPS/SHIFT keys).
- Added several custom icon glyphs to the default font by repurposing
  otherwise-blank/unused glyph slots (backtick, pipe, tilde, backslash,
  and the DEL trailing tile) - up arrows for Caps Lock/Shift, a return
  arrow for newline, a spacebar symbol, a left arrow for backspace, and
  a real underscore - replacing the keyboard's old text-abbreviation key
  labels ("CL"/"SL"/"NL"/"<") with single-glyph icons.
2026-08-27 22:17:00 -05:00
YourWishes 4387d223b9 Fullscreen keyboard dialog, nearest-cell vertical navigation, overwrite-at-max-length
- Keyboard dialog now fills the whole screen (no dimming backdrop); key
  cells use a fixed font-tile-based size instead of scaling to a
  percentage of the screen.
- uiMenuFocusSkipBlanks: UP/DOWN now search every non-blank cell strictly
  further in the pressed direction and land on the smallest combined
  row+column distance (ties favor the smaller column distance), instead
  of only scanning straight down the same column. Neither axis wraps
  during that search - only once the edge row is reached with nothing
  further to search does it fall back to wrapping to the opposite edge.
- Typing a new character while the keyboard's text is already at
  maxLength now overrides the last character instead of being ignored.
2026-08-27 21:14:19 -05:00
YourWishes 00bfaf6360 Fix cutscene/focus bugs found on PSP hardware, rework keyboard layout
Cutscene loader: a MODAL item's optionCount byte was read directly
inside an assertTrue() condition. On the PSP release build
(DUSK_ASSERTIONS_FAKED), assertTrue expands to a no-op and never
evaluates its argument, so that read - and the offset advance it was
responsible for - silently never happened, desyncing every item after
it in the stream. Moved the read out to its own statement and replaced
the assert with a real error throw, since this is untrusted file
content.

uifocus: fixed a wraparound bug in uiFocusMoveDirection - moving left/up
from position 0 truncated to uint8_t before the modulo wrap (0-1 => 255,
then 255 % cols), landing on the wrong column instead of the row's last
one. Only ever visible on a grid wider than a handful of columns, which
nothing but the new keyboard has.

Keyboard: merged uikeyboardqwerty into uikeyboard (only one layout
exists, so the split no longer earned its keep) and deleted the unused
numbers/symbols placeholder files. Reshaped the grid to a tighter 11x5
layout with per-mode key tables (unshifted/caps/shift) instead of
computing case transforms, and fixed the reserved NEWLINE/CONFIRM/CANCEL
slot indices to match. Added shift-symbols for digits and -/=, and
uimenu's directional navigation now skips blank filler cells (wrapping
around a row/column as if they weren't there) instead of landing on
them - a generic fix in uimenu.c, not keyboard-specific, since nothing
else uses blank cells.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:26:07 -05:00
YourWishes 709bd7be52 Add validation options and a full physical layout to the keyboard
uikeyboardopen_t grows: maxLength (auto-focuses confirm once full),
lineCount for multi-line entry with a NEWLINE key, trimmed/allowBlank
validation on confirm, and a second "are you sure" uiConfirm step.

Reworks uikeyboardqwerty from a packed flat list into a fixed 14x5 grid
shaped like a real keyboard - number row with -, =, ~ and backspace,
caps and shift (one-shot, XORed with caps for real-keyboard behavior),
and a bottom row with cancel/space/confirm in their normal corners.
Shorter rows are padded with invisible filler cells so every key lands
in its physical position; NEWLINE/CONFIRM/CANCEL are reserved slots
uikeyboard.c patches in conditionally, otherwise left as inert blanks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 18:44:07 -05:00
YourWishes 80f4348e21 Implement on-screen keyboard dialog with QWERTY layout
Builds out uikeyboard/uikeyboardqwerty from placeholders into a working
controller-navigable text entry dialog: title/current-text labels, a flat
QWERTY key grid (letters, space, backspace), and confirm/cancel buttons in
one combined menu so d-pad navigation flows across all of it.

uikeyboardopen_t configures each dialog: onInput/onKeyPress callbacks,
optional cancel button, maxLength (auto-focuses confirm once full),
lineCount for multi-line entry (adds a NEWLINE key, reserves vertical
space up front), a second "are you sure" uiConfirm step, and
trimmed/allowBlank validation on confirm.

Back now deletes the last character first, falling through to close only
when cancel is allowed and the buffer is empty - this needed a general
cancel-intercept callback added to uifocusitem_t/uimenu_t
(uiMenuSetCancelCallback), since the focus system previously only
supported popping or fully swallowing back.

Wired a temporary "LOAD GAME (TEST)" entry into the main menu with
console-logging test callbacks for interactive testing; marked TODO for
removal once the keyboard is wired up somewhere real.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:26:57 -05:00
YourWishes e630827b34 Add runtime-loaded cutscene files, convert initial/main menu to use them
New ASSET_LOADER_TYPE_CUTSCENE (assetcutsceneloader.c) reads a versioned
binary .cts format decoded into a heap-allocated cutsceneitem_t array + a
string/data pool, both sized to the file's actual content rather than a
fixed capacity, so the shared assetloaderoutput_t union doesn't bloat for
every asset slot regardless of type. Authoring pipeline mirrors the chunk
asset pattern: assetsraw/cutscenes/*.jsonc (JSON plus // and /* */
comments) -> tools/asset/cutscene -> assets/cutscenes/*.cts.

cutsceneSystemSetOnComplete() lets the caller arm a native callback that
fires when a cutscene finishes normally, so a file (which can't store a
function pointer) can end plainly and still hand off to native code -
cutsceneRestart() preserves it across a retry loop rather than clearing it,
since a restart is the same logical run trying again.

The initial and main-menu start-game cutscenes are now loaded from files
instead of compiled in via the CUTSCENE(...) macro.

Fixed a real bug found while converting these: the sync loader read the
file's total size from assetfile_t.size to locate the trailing pool
region, but assetFileDispose() (called at the end of the async phase)
zeroes that whole struct first, so the size was always 0 and the pool
offset computation underflowed into an out-of-bounds read - intermittent
depending on heap layout. Fixed by saving the size before disposal; also
fixed the read-completeness assert being checked after that same zeroing
(a no-op 0 == 0 check).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 09:51:26 -05:00
YourWishes 106d9b0fc0 Move main menu start-game cutscene from ui panel to scene
The load-all-slots/no-device cutscene and its helper callbacks belong to
scene-level game flow, not the uimainmenu UI panel. Moved them into
scenemainmenu.c/h behind a new sceneMainMenuStartGame() entry point;
uimainmenu.c's Start Game handler now just calls that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:30:33 -05:00
YourWishes 4a26f79945 Add uimenu disableBack flag, apply to main menu and all modal options
uifocusitem_t/uimenu_t gain a disableBack flag that makes back/cancel a
no-op while that item is focused, set via uiMenuSetDisableBack. Applied
centrally in uimodal.c so every modal with option buttons (confirm
dialogs, cutscene modals) must be dismissed by picking an option rather
than backing out, and to the main menu so it no longer bounces back to
the initial scene on cancel - replacing the suppressClosedSceneChange
hack entirely. Updated docs that described the now-unreachable
back/cancel dismissal path. Also adds
cutsceneSystemStartCutsceneAndGoToMarker for starting a cutscene
straight at a given marker instead of its first item.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:11:27 -05:00
YourWishes 1a199f6ce3 Add declarative cutscene items for save checks and marker-based modals
CUTSCENE_SAVE_DEVICE_CHECK and CUTSCENE_SAVE_LOAD_ALL_SLOTS wrap the
existing device-lookup/slot-load calls and jump straight to a
success/failure marker, and CUTSCENE_MODAL_OPTIONS_ONE/TWO do the same
for one/two-option modals, removing the need for a hand-written
goTo-only callback per use. Main menu's "Start Game" now runs a
cutscene (mirroring the initial scene's save-check flow) that loads all
slots before opening the load-game picker, retrying on error; the
initial scene's own device check is migrated onto the same items.
Backing out of the main menu now returns to the initial scene.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:29:57 -05:00
YourWishes ec59e77867 Moved files around 2026-08-23 10:14:39 -05:00
YourWishes cb28d2b611 Add save slot picker (uiselectsave/uisaveslot), fix focus stack reentrancy
uiselectsave is a Save/Load/Delete slot picker built on save/save.h's
SAVE_SLOT_COUNT: a scrolling list of framed uisaveslot_t rows (slot
number + file name, or an "Empty Slot" placeholder), navigated via the
same uimenu_t/focus-stack convention as every other menu. Load and
Delete modes add a trailing action row (switch to delete / cancel)
fixed below the scrollable area. Selecting a slot in Delete mode routes
through uiConfirmOpen before actually blanking the slot via
saveSlotInit + saveDeviceSlotWrite. uiScrollingEnsureVisible in
uiscrolling.c is now a real implementation instead of the earlier
placeholder.

Main menu collapses New Game/Load Game into a single Start Game entry
that opens uiselectsave in Load mode; backing out reopens the main
menu via uiselectsave's result callback.

Also fixes a real reentrancy bug in uiFocusPop: it invoked the popped
item's closed callback before decrementing UI_FOCUS.count, so a push
triggered from within that callback (e.g. reopening the main menu once
select-save reports no result) landed one slot past the true stack top
and got silently dropped, stranding the new item outside navigation.
Count is now decremented first.

Adds a uikeyboard placeholder widget, registered in uielementlist with
no behavior yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 14:16:39 -05:00
YourWishes 27b6ddf5cb Rebuild uiconfirm on top of uimodal, add quit confirmation
uiconfirm no longer duplicates its own backdrop/frame/text/menu drawing
- it now just opens the shared uimodal_t with a Confirm/Cancel option
pair and translates the result back to a bool. uimodal itself gains an
optional title (NULL skips the title row entirely) so a title-less
confirm dialog doesn't reserve blank space.

Main menu's Quit option now opens a confirm dialog instead of exiting
immediately.

Drops es_MX.po/jp_JP.po locale files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 22:41:06 -05:00
YourWishes 475c865e33 Move uimodal to ui/widget, make main menu its own scene
uimodal.c/.h now live in ui/widget alongside the other widgets rather
than ui/frame. The main menu now has a real scene (scene/mainmenu)
whose init just calls the new uiMainMenuOpen(), replacing the
SCENE.current-polling uiMainMenuUpdate with an explicit open call like
uiGameMenuOpen/uiBackpackOpen. Also renames the ui.main_menu.* locale
keys to main_menu.* and groups them under a Main Menu Scene heading in
en_US.po, alongside the existing Initial Scene keys.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 19:18:50 -05:00
YourWishes 0b4ba062bb Translate and word-wrap cutscene modal text
uiModalOpen now resolves title/message/option strings as locale
message IDs first, falling back to the literal string if no match is
found, and word-wraps the result to stay within the screen's scan
width via the new shared textWrap helper. sceneinitial.c's no-device
modal now uses locale keys instead of hardcoded English strings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 18:53:47 -05:00
YourWishes 28f5e66662 Fix PSP memory stick availability check on real hardware
sceIoGetstat on a bare device root ("ms0:/") returns EINVAL on real
PSP hardware even when a memory stick is present - confirmed via a
diagnostic print during hardware testing. Switched to sceIoDopen/
sceIoDclose, the SDK's own documented way to probe a device root,
which real hardware handles correctly.

Also adds a temporary unconditional settings write once a save device
is selected, to confirm the write path itself works on hardware now
that availability detection is fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:22:24 -05:00
YourWishes e225a076f0 Add cutscene flow control and a scriptable modal dialog item
uimodal gains an onOpen hook and lets uiModalClose take a one-shot
callback, since options are now stored as raw (uncopied) string
pointers instead of a fixed char buffer.

New cutscene items:
- CUTSCENE_MODAL / CUTSCENE_MODAL_OPTIONS / CUTSCENE_MODAL_CLOSE: opens
  a message-only or option-driven uimodal. A message-only modal
  advances the cutscene immediately; one with options blocks
  indefinitely since only its option callback (or something it
  triggers) should decide what happens next.
- CUTSCENE_MARKER + cutsceneGoTo: a named, otherwise no-op position
  that execution can jump straight to from anywhere in the same
  cutscene (e.g. from a CUTSCENE_CALLBACK), matched by name rather
  than pointer identity.
- CUTSCENE_RESTART + cutsceneRestart: restarts the running cutscene
  from its first item, preserving its interact/interacted entities.
- CUTSCENE_SCENE: requests a scene switch via sceneSet as a cutscene
  step.
- CUTSCENE_PRINT: prints a line to the console as a cutscene step.

sceneinitial.c's boot-time save device check is rebuilt on top of
these: show a modal, kick off the async device search, then branch
via markers/goto to a retry/continue prompt depending on the result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:35:41 -05:00
YourWishes 774c8ad0f8 Add uimodal dialog; uibutton reuses uilabel
uibutton now embeds a uilabel instead of drawing its text directly,
caching glyph sprites since a button's label never changes after
init. uimenu.c's item pointer in uiMenuDraw drops its unnecessary
const so uiButtonDraw can update the button's cache.

Also fixes remaining #include "ui/frame/uiframe.h" references left
over from uiframe's move to ui/widget/.

uimodal is a new generalized dialog (title + message + up to
UI_MODAL_OPTIONS_MAX option buttons) built the same way uiconfirm is,
but driven by a caller-supplied option list instead of a fixed
confirm/cancel pair.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 12:44:01 -05:00
YourWishes 52d1e7414d Convert debug UI overlays to uilabel
uiconsole, uifps, and uiplayerpos now render through uilabel instead
of calling textDraw per character every frame. Each owns its label(s)
plus backing text/sprite buffers and marks them dirty only when their
content or position actually changes. Also drops console.h's per-line
buffer from 512 to 128 chars, since the sprite cache backing each
console line label scales with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 12:43:43 -05:00
YourWishes af4cb53e5f Simplify uilabel to caller-owned buffers
uilabel no longer owns fixed-size text/sprite arrays or copies text
internally - callers pass in their own buffers and write text
directly, then mark the label dirty. This drops SetText/GetText and
the textMax bound in favor of a single Rebuffer entry point, and lets
widgets like uibutton alias an existing (possibly immutable) label
string instead of duplicating it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 12:43:02 -05:00
YourWishes c019271e12 Add uilabel widget and chunked text buffering
textDraw now batches glyphs via a new textBuffer helper instead of
buffering one sprite at a time. Fixes spriteBatchBuffer dropping/
duplicating sprites when a single call spans multiple internal
flush batches, which surfaced as cut-off characters. Adds uilabel,
a widget that caches its glyph sprites and only rebuilds/rebuffers
them when its text or position actually changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:18:35 -05:00
YourWishes 82ae2bce9d Add compressed combined save format for PSP and Dolphin
Memory sticks/cards don't suit N+1 separate save files the way Linux's
filesystem does, so add an opt-in SAVE_DEVICE_DATA_RAW mode: settings and
all save slots get serialized to JSON, concatenated, zlib-compressed, and
framed with a magic/version/checksum/generation header, then read/written
as a single blob through one combined platform hook instead of four.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:28:56 -05:00
YourWishes 092e259a06 First pass of actual saving 2026-08-17 09:18:44 -05:00
YourWishes 08b4bbfe91 Added savestatus 2026-08-17 00:17:23 -05:00
YourWishes 560c51cf27 Drop default-skip behavior from savejson write macros
Only reads should fall back to a default when a key is missing; writes
should always emit the field so the saved JSON is a complete record.
2026-08-16 20:20:34 -05:00
YourWishes 674f86b18a Add JSON (de)serialization for save slots/settings and device write/read hooks
Introduces saveslot/savesettings structs with a shared savejson.h macro
toolkit (has/require/write/read per common type) for populating and
parsing yyjson objects, wires saveManager save/load calls through new
saveDevice slot/settings write/read entry points (still stubbed at the
device level), and drops the old binary-format scaffolding in favor of
JSON only.
2026-08-16 20:14:16 -05:00
YourWishes e3f10e0926 Fix GameCube memory card slot B detection and free-space check
CARD_Mount() alone reliably reports CARD_ERROR_NOCARD for a card physically
present in slot B - unlike slot A, which the IPL polls automatically at
boot, slot B needs an explicit CARD_Probe() first (the standard pattern in
every official devkitPro CARD sample). Also fixes
saveDeviceDolphinCardHasFreeSpace() treating CARD_GetDirectory()'s
CARD_ERROR_NOFILE (a totally empty card, not an actual error) as a hard
failure, which made any card with zero existing save files - slot B's in
this case, since slot A already had leftover test data on it - get
reported as full.

Confirmed via Dolphin with slot A disabled/slot B set to a memory card:
previously reported "no save device found", now correctly detects it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 16:49:18 -05:00
YourWishes d7223d7387 Add PSP/GameCube/Wii save-device availability checks
PSP checks memory-stick reachability via sceIoGetstat. GameCube gets two
devices (CARD_SLOTA/CARD_SLOTB), each mounted via CARD_Init/CARD_Mount and
checked for free space via CARD_GetDirectory/CARD_GetBlockCount. Wii picks
between three storage methods at compile time (DUSK_SAVE_WII_METHOD =
NAND/CARD/SD in wii.cmake, default NAND via ISFS) since real hardware
behavior for the default is unverified.

Also fixes two pre-existing bugs found while build/runtime-testing this
across PPSSPP and Dolphin: wrong libogc language macros in
systemGetLocaleDolphin, and a missing SYS_STDIO_Report(true) call that
silently swallowed all guest console output in Dolphin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 16:20:28 -05:00
YourWishes 45331c2a60 First pass of save 2026-08-14 14:19:44 -05:00
YourWishes 33f50a2c69 Strip legacy save/settings systems for a unified savemanager rewrite; split uielement list out of uielement.c
Removes the per-platform save/savestream/autosave implementations, the settings UI frames, and the autosave overlay, replacing them with a savemanager.c/h + savefile.h/savedevice.h scaffold to build the new save system on top of. Also separates the concrete UI_ELEMENTS registration into uielementlist.h/.c so uielement.c only holds the generic per-element lifecycle logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 12:56:02 -05:00
244 changed files with 11905 additions and 7207 deletions
Binary file not shown.
Binary file not shown.
+96 -16
View File
@@ -5,11 +5,107 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : n==2 ? 1 : (n<7 ? 2 : 3));\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : n==2 ? 1 : (n<7 ? 2 : 3));\n"
# Initial Scene
msgid "initial.checking_save.title"
msgstr "Checking for save data"
msgid "initial.checking_save.message"
msgstr "Please wait..."
msgid "initial.no_device.title"
msgstr "No Save Device Found"
msgid "initial.no_device.message"
msgstr "Could not find a save device, ensure it is connected and try again. You can continue, but progress will not be saved."
msgid "initial.no_device.retry"
msgstr "Try again"
msgid "initial.no_device.continue"
msgstr "Continue without saving"
# Main Menu Scene
msgid "main_menu.start_game"
msgstr "Start Game"
msgid "main_menu.options"
msgstr "Options"
msgid "main_menu.quit"
msgstr "Quit Game"
msgid "main_menu.quit_confirm"
msgstr "Are you sure you want to quit?"
msgid "main_menu.checking_save.title"
msgstr "Checking for save data"
msgid "main_menu.checking_save.message"
msgstr "Please wait..."
msgid "main_menu.no_device.title"
msgstr "No Save Device Found"
msgid "main_menu.no_device.message"
msgstr "Could not find a save device, ensure it is connected and try again. You can continue, but progress will not be saved."
msgid "main_menu.no_device.retry"
msgstr "Try again"
msgid "main_menu.no_device.continue"
msgstr "Continue without saving"
msgid "main_menu.save_load_error.title"
msgstr "Error"
msgid "main_menu.save_load_error.message"
msgstr "Failed to load save data. Please try again."
msgid "main_menu.save_load_error.retry"
msgstr "Try Again"
# Select Save Screen
msgid "ui.select_save.title"
msgstr "Select Save"
msgid "ui.select_save.empty"
msgstr "Empty Slot"
msgid "ui.select_save.slot_format"
msgstr "%s Lv.%d %s"
msgid "ui.select_save.delete_mode"
msgstr "Delete a Save"
msgid "ui.select_save.delete_confirm"
msgstr "Are you sure you want to delete this save?"
msgid "ui.select_save.name_title"
msgstr "Enter game save file name"
msgid "ui.save_slot.number_format"
msgstr "Slot %d"
#: ui/menu.c:10 #: ui/menu.c:10
msgid "ui.title" msgid "ui.title"
msgstr "" msgstr ""
"Welcome" "Welcome"
msgid "save.linux.mkdirp_failed"
msgstr "Failed to create save directory, check the disk is not full or write-protected."
#: src/dusk/ui/frame/settings/uisettings.c #: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.general" msgid "ui.settings.tabs.general"
msgstr "General" msgstr "General"
@@ -112,22 +208,6 @@ msgstr "Yes"
msgid "ui.initial.create_save.no" msgid "ui.initial.create_save.no"
msgstr "No" msgstr "No"
#: src/dusk/ui/frame/mainmenu/uimainmenu.c
msgid "ui.main_menu.new_game"
msgstr "New Game"
#: src/dusk/ui/frame/mainmenu/uimainmenu.c
msgid "ui.main_menu.load_game"
msgstr "Load Game"
#: src/dusk/ui/frame/mainmenu/uimainmenu.c
msgid "ui.main_menu.options"
msgstr "Options"
#: src/dusk/ui/frame/mainmenu/uimainmenu.c
msgid "ui.main_menu.quit"
msgstr "Quit Game"
#: src/dusk/ui/frame/uiconfirm.c #: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.confirm" msgid "ui.confirm.confirm"
msgstr "Confirm" msgstr "Confirm"
-194
View File
@@ -1,194 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: ExampleApp 1.0\n"
"Language: es\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n==1 ? 0 : 1);\n"
#: ui/menu.c:10
msgid "ui.title"
msgstr ""
"Bienvenido"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.general"
msgstr "General"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.input"
msgstr "Entrada"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.display"
msgstr "Pantalla"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.audio"
msgstr "Audio"
msgid "ui.settings.input.deadzone"
msgstr "Deadzone"
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "ui.settings.general.language"
msgstr "Idioma"
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "ui.settings.general.language_detail"
msgstr "Se aplica después de reiniciar la aplicación."
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.apply"
msgstr "Aplicar"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.discard_changes"
msgstr "¿Descartar los cambios no guardados?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.characters"
msgstr "Personajes"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.items"
msgstr "Objetos"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.settings"
msgstr "Configuración"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save"
msgstr "Guardar"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_success"
msgstr "Partida guardada."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_cancelled"
msgstr "Guardado cancelado."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_unavailable"
msgstr "No se puede guardar: no se encontró ningún dispositivo de guardado."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_temporary"
msgstr "Esta sesión es temporal - no se encontró ningún dispositivo de guardado, por lo que guardar está deshabilitado."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_create_confirm"
msgstr "No se encontraron datos guardados. ¿Crear una partida nueva?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_failed_format"
msgstr "Error al guardar: %s"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_check_failed_format"
msgstr "No se puede guardar: %s"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.message"
msgstr "No se encontró ningún dispositivo de guardado. Puedes continuar, pero\nel progreso no se guardará."
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.retry"
msgstr "Reintentar"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.continue"
msgstr "Continuar de todos modos"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.message"
msgstr "No se encontraron datos guardados. ¿Crear una partida nueva?"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.yes"
msgstr "Sí"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.no"
msgstr "No"
#: src/dusk/ui/frame/mainmenu/uimainmenu.c
msgid "ui.main_menu.new_game"
msgstr "Nueva Partida"
#: src/dusk/ui/frame/mainmenu/uimainmenu.c
msgid "ui.main_menu.load_game"
msgstr "Cargar Partida"
#: src/dusk/ui/frame/mainmenu/uimainmenu.c
msgid "ui.main_menu.options"
msgstr "Opciones"
#: src/dusk/ui/frame/mainmenu/uimainmenu.c
msgid "ui.main_menu.quit"
msgstr "Salir del Juego"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.confirm"
msgstr "Confirmar"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.cancel"
msgstr "Cancelar"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.attack"
msgstr "Atacar"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.flee"
msgstr "Huir"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.target_format"
msgstr "Enemigo %u (%u/%u PS)"
#: src/dusk/ui/frame/battle/uibattlehud.c
msgid "ui.battle.hud.hp_format"
msgstr "PS %u/%u"
#: src/dusk/ui/frame/battle/uibattlehud.c
msgid "ui.battle.hud.mp_format"
msgstr "PM %u/%u"
#: src/dusk/ui/frame/settings/uisettingsaudio.c
msgid "ui.settings.audio.placeholder"
msgstr "Aún no hay opciones de audio"
#: src/dusk/ui/frame/settings/uisettingsdisplay.c
msgid "ui.settings.display.placeholder"
msgstr "Aún no hay opciones de pantalla"
#: src/dusk/ui/frame/settings/uisettingsinput.c
msgid "ui.settings.input.placeholder"
msgstr "Aún no hay opciones de entrada"
#: src/dusk/ui/frame/backpack/uibackpack.c
msgid "ui.backpack.category_format"
msgstr "Categoría %u"
#: src/dusk/ui/overlay/uiloading.c
msgid "ui.loading.text"
msgstr "cargando"
#: src/dusk/ui/overlay/uiautosave.c
msgid "ui.autosave.saving"
msgstr "GUARDANDO"
#: src/dusk/rpg/item/item.json
msgid "item.potion.name"
msgstr "Poción"
#: src/dusk/rpg/item/item.json
msgid "item.potato.name"
msgstr "Papa"
#: src/dusk/rpg/item/item.json
msgid "item.apple.name"
msgstr "Manzana"
-194
View File
@@ -1,194 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: ExampleApp 1.0\n"
"Language: ja\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=1; plural=(0);\n"
#: ui/menu.c:10
msgid "ui.title"
msgstr ""
"歓迎"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.general"
msgstr "一般"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.input"
msgstr "入力"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.display"
msgstr "表示"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.audio"
msgstr "オーディオ"
msgid "ui.settings.input.deadzone"
msgstr "デッドゾーン"
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "ui.settings.general.language"
msgstr "言語"
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "ui.settings.general.language_detail"
msgstr "アプリケーションを再起動すると適用されます。"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.apply"
msgstr "適用"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.discard_changes"
msgstr "未保存の変更を破棄しますか?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.characters"
msgstr "キャラクター"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.items"
msgstr "アイテム"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.settings"
msgstr "設定"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save"
msgstr "セーブ"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_success"
msgstr "セーブしました。"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_cancelled"
msgstr "セーブをキャンセルしました。"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_unavailable"
msgstr "セーブできません - セーブデバイスが見つかりません。"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_temporary"
msgstr "このセッションは一時的です - セーブデバイスが見つからなかったため、セーブは無効になっています。"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_create_confirm"
msgstr "セーブデータが見つかりません。新しいセーブを作成しますか?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_failed_format"
msgstr "セーブに失敗しました: %s"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_check_failed_format"
msgstr "セーブできません: %s"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.message"
msgstr "セーブデバイスが見つかりません。続行できますが、\n進行状況は保存されません。"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.retry"
msgstr "再試行"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.continue"
msgstr "続行する"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.message"
msgstr "セーブデータが見つかりません。新しいセーブを作成しますか?"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.yes"
msgstr "はい"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.no"
msgstr "いいえ"
#: src/dusk/ui/frame/mainmenu/uimainmenu.c
msgid "ui.main_menu.new_game"
msgstr "ニューゲーム"
#: src/dusk/ui/frame/mainmenu/uimainmenu.c
msgid "ui.main_menu.load_game"
msgstr "ロードゲーム"
#: src/dusk/ui/frame/mainmenu/uimainmenu.c
msgid "ui.main_menu.options"
msgstr "オプション"
#: src/dusk/ui/frame/mainmenu/uimainmenu.c
msgid "ui.main_menu.quit"
msgstr "ゲームを終了"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.confirm"
msgstr "確認"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.cancel"
msgstr "キャンセル"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.attack"
msgstr "攻撃"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.flee"
msgstr "逃げる"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.target_format"
msgstr "敵%u (%u/%u HP)"
#: src/dusk/ui/frame/battle/uibattlehud.c
msgid "ui.battle.hud.hp_format"
msgstr "HP %u/%u"
#: src/dusk/ui/frame/battle/uibattlehud.c
msgid "ui.battle.hud.mp_format"
msgstr "MP %u/%u"
#: src/dusk/ui/frame/settings/uisettingsaudio.c
msgid "ui.settings.audio.placeholder"
msgstr "オーディオ設定はまだありません"
#: src/dusk/ui/frame/settings/uisettingsdisplay.c
msgid "ui.settings.display.placeholder"
msgstr "表示設定はまだありません"
#: src/dusk/ui/frame/settings/uisettingsinput.c
msgid "ui.settings.input.placeholder"
msgstr "入力設定はまだありません"
#: src/dusk/ui/frame/backpack/uibackpack.c
msgid "ui.backpack.category_format"
msgstr "カテゴリー%u"
#: src/dusk/ui/overlay/uiloading.c
msgid "ui.loading.text"
msgstr "読み込み中"
#: src/dusk/ui/overlay/uiautosave.c
msgid "ui.autosave.saving"
msgstr "保存中"
#: src/dusk/rpg/item/item.json
msgid "item.potion.name"
msgstr "ポーション"
#: src/dusk/rpg/item/item.json
msgid "item.potato.name"
msgstr "ジャガイモ"
#: src/dusk/rpg/item/item.json
msgid "item.apple.name"
msgstr "リンゴ"
+69
View File
@@ -0,0 +1,69 @@
{
"items": [
// Boot check: make sure a save device is available before handing off
// to the main menu.
{
"type": "MODAL",
"title": "initial.checking_save.title",
"message": "initial.checking_save.message"
},
{
"type": "WAIT",
"seconds": 0.2
},
{
"type": "SAVE_DEVICE_CHECK",
"successMarker": "CONTINUE",
"failureMarker": "NO_DEVICE"
},
// No save device found - offer to retry or continue without saving.
{
"type": "MARKER",
"name": "NO_DEVICE"
},
{
"type": "MODAL_CLOSE"
},
{
"type": "MODAL_OPTIONS_MARKERS",
"title": "initial.no_device.title",
"message": "initial.no_device.message",
"options": [
{
"text": "initial.no_device.retry",
"marker": "RETRY"
},
{
"text": "initial.no_device.continue",
"marker": "CONTINUE"
}
]
},
{
"type": "MARKER",
"name": "RETRY"
},
{
"type": "MODAL_CLOSE"
},
{
"type": "RESTART"
},
// Save device found (or continuing without one) - hand off to the
// main menu scene.
{
"type": "MARKER",
"name": "CONTINUE"
},
{
"type": "MODAL_CLOSE"
},
{
"type": "SCENE",
"sceneType": "MAIN_MENU"
}
]
}
@@ -0,0 +1,92 @@
{
"items": [
{
"type": "MODAL",
"title": "main_menu.checking_save.title",
"message": "main_menu.checking_save.message"
},
{
"type": "WAIT",
"seconds": 0.2
},
{
"type": "SAVE_DEVICE_CHECK",
"successMarker": "CONTINUE",
"failureMarker": "NO_DEVICE"
},
// No save device found - offer to retry or continue without saving.
{
"type": "MARKER",
"name": "NO_DEVICE"
},
{
"type": "MODAL_CLOSE"
},
{
"type": "MODAL_OPTIONS_MARKERS",
"title": "main_menu.no_device.title",
"message": "main_menu.no_device.message",
"options": [
{
"text": "main_menu.no_device.retry",
"marker": "RETRY"
},
{
"text": "main_menu.no_device.continue",
"marker": "CONTINUE"
}
]
},
// Retry
{
"type": "MARKER",
"name": "RETRY"
},
{
"type": "MODAL_CLOSE"
},
{
"type": "RESTART"
},
// Save device found - attempt to load all save slots.
{
"type": "MARKER",
"name": "CONTINUE"
},
{
"type": "MODAL_CLOSE"
},
{
"type": "SAVE_LOAD_ALL_SLOTS",
"successMarker": "LOADED",
"failureMarker": "LOAD_ERROR"
},
// Save data failed to load (e.g. corrupt/unreadable) - only option is
// to retry, no "continue without saving" here since we already know a
// device is present.
{
"type": "MARKER",
"name": "LOAD_ERROR"
},
{
"type": "MODAL_OPTIONS_MARKERS",
"title": "main_menu.save_load_error.title",
"message": "main_menu.save_load_error.message",
"options": [
{
"text": "main_menu.save_load_error.retry",
"marker": "RETRY"
}
]
},
{
"type": "MARKER",
"name": "LOADED"
}
]
}
+46
View File
@@ -65,6 +65,23 @@ if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
) )
endif() endif()
# Generate PARAM.SFO as a normal tracked build output (instead of letting
# create_pbp_file() auto-generate + delete it) so it can be reused below by
# a properly dependency-tracked EBOOT.PBP repack step.
set(DUSK_PSP_PARAM_SFO "${DUSK_BUILD_DIR}/PARAM.SFO")
add_custom_command(
OUTPUT "${DUSK_PSP_PARAM_SFO}"
COMMAND "$ENV{PSPDEV}/bin/mksfoex" "-d" "MEMSIZE=1" "-s" "APP_VER=01.00"
"${DUSK_BINARY_TARGET_NAME}" "${DUSK_PSP_PARAM_SFO}"
COMMENT "Generating PARAM.SFO for ${DUSK_BINARY_TARGET_NAME}"
VERBATIM
)
add_custom_target(DuskPspParamSfo DEPENDS "${DUSK_PSP_PARAM_SFO}")
# create_pbp_file()'s own POST_BUILD chain (below) also consumes
# DUSK_PSP_PARAM_SFO, so make sure it exists before that chain runs.
add_dependencies(${DUSK_BINARY_TARGET_NAME} DuskPspParamSfo)
# Postbuild, create .pbp file for PSP. # Postbuild, create .pbp file for PSP.
create_pbp_file( create_pbp_file(
TARGET "${DUSK_BINARY_TARGET_NAME}" TARGET "${DUSK_BINARY_TARGET_NAME}"
@@ -74,4 +91,33 @@ create_pbp_file(
TITLE "${DUSK_BINARY_TARGET_NAME}" TITLE "${DUSK_BINARY_TARGET_NAME}"
PSAR_PATH ${DUSK_ASSETS_ZIP} PSAR_PATH ${DUSK_ASSETS_ZIP}
VERSION 01.00 VERSION 01.00
SFO_PATH "${DUSK_PSP_PARAM_SFO}"
OUTPUT_DIR "${DUSK_BUILD_DIR}"
) )
# CreatePBP.cmake's pack-pbp step is a POST_BUILD command tied to the
# executable target, so it only reruns when the ELF itself relinks. That
# means regenerating dusk.dsk (assets) alone, without touching any C
# source, silently leaves EBOOT.PBP embedding a stale asset pak. Repack it
# here as a normal file-tracked custom command depending on both the
# executable and the asset zip, so EBOOT.PBP always reflects the current
# assets even when nothing else about the build changed.
set(DUSK_PSP_EBOOT "${DUSK_BUILD_DIR}/EBOOT.PBP")
if(BUILD_PRX)
set(DUSK_PSP_EXECUTABLE "$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>.prx")
else()
set(DUSK_PSP_EXECUTABLE "$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>")
endif()
add_custom_command(
OUTPUT "${DUSK_PSP_EBOOT}"
COMMAND "$ENV{PSPDEV}/bin/pack-pbp" "${DUSK_PSP_EBOOT}" "${DUSK_PSP_PARAM_SFO}"
"NULL" "NULL" "NULL" "NULL" "NULL"
"${DUSK_PSP_EXECUTABLE}" "${DUSK_ASSETS_ZIP}"
DEPENDS
"$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>"
"${DUSK_PSP_PARAM_SFO}"
"${DUSK_ASSETS_ZIP}"
COMMENT "Repacking EBOOT.PBP (tracks executable + asset pak freshness)"
VERBATIM
)
add_custom_target(DuskPspEbootRepack ALL DEPENDS "${DUSK_PSP_EBOOT}")
+11
View File
@@ -4,6 +4,17 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_WII DUSK_WII
) )
# Wii save storage method - see src/duskdolphin/save/savedeviceplatform.h.
set(DUSK_SAVE_WII_METHOD "NAND" CACHE STRING
"Wii save storage: NAND (internal storage via ISFS), CARD (GameCube-\
compatible memory card emulation), or SD (SD card via libfat)"
)
set_property(CACHE DUSK_SAVE_WII_METHOD PROPERTY STRINGS "NAND" "CARD" "SD")
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_SAVE_WII_METHOD_${DUSK_SAVE_WII_METHOD}
)
# Generate Homebrew Channel meta.xml from project identity variables # Generate Homebrew Channel meta.xml from project identity variables
string(TIMESTAMP DUSK_BUILD_DATE "%Y%m%d000000" UTC) string(TIMESTAMP DUSK_BUILD_DATE "%Y%m%d000000" UTC)
configure_file( configure_file(
-1
View File
@@ -53,7 +53,6 @@ target_sources(${DUSK_BINARY_TARGET_NAME}
# Subdirs # Subdirs
add_subdirectory(animation) add_subdirectory(animation)
add_subdirectory(event)
add_subdirectory(assert) add_subdirectory(assert)
add_subdirectory(asset) add_subdirectory(asset)
add_subdirectory(console) add_subdirectory(console)
-1
View File
@@ -7,7 +7,6 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
asset.c asset.c
assetbatch.c
assetfile.c assetfile.c
) )
+5 -3
View File
@@ -322,7 +322,9 @@ errorret_t assetUpdate(void) {
} else if(loading->entry->state == ASSET_ENTRY_STATE_LOADED) { } else if(loading->entry->state == ASSET_ENTRY_STATE_LOADED) {
assetentry_t *loadedEntry = loading->entry; assetentry_t *loadedEntry = loading->entry;
loading->entry = NULL; loading->entry = NULL;
eventInvoke(&loadedEntry->onLoaded, loadedEntry); if(loadedEntry->onLoaded) {
loadedEntry->onLoaded(loadedEntry, loadedEntry->onLoadedUser);
}
} }
loading++; loading++;
@@ -346,8 +348,8 @@ errorret_t assetUpdate(void) {
assetentry_t *errEntry = loading->entry; assetentry_t *errEntry = loading->entry;
loading->entry = NULL; loading->entry = NULL;
threadMutexUnlock(&loading->mutex); threadMutexUnlock(&loading->mutex);
eventInvoke(&errEntry->onError, errEntry); if(errEntry->onError) errEntry->onError(errEntry, errEntry->onErrorUser);
errorThrow("Failed to load asset asynchronously."); loading++;
break; break;
} }
-165
View File
@@ -1,165 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetbatch.h"
#include "asset.h"
#include "assert/assert.h"
#include "util/memory.h"
#include <unistd.h>
void assetBatchInit(
assetbatch_t *batch,
const uint16_t count,
const assetbatchdesc_t *descs
) {
assertNotNull(batch, "Batch cannot be NULL.");
assertNotNull(descs, "Descs cannot be NULL.");
assertTrue(count > 0, "Count must be greater than 0.");
assertTrue(
count <= ASSET_BATCH_COUNT_MAX, "Count exceeds ASSET_BATCH_COUNT_MAX."
);
memoryZero(batch, sizeof(assetbatch_t));
batch->count = count;
eventInit(
&batch->onLoaded,
batch->onLoadedCallbacks, batch->onLoadedUsers, ASSET_BATCH_EVENT_MAX
);
eventInit(
&batch->onEntryLoaded,
batch->onEntryLoadedCallbacks,
batch->onEntryLoadedUsers,
ASSET_BATCH_EVENT_MAX
);
eventInit(
&batch->onError,
batch->onErrorCallbacks, batch->onErrorUsers, ASSET_BATCH_EVENT_MAX
);
eventInit(
&batch->onEntryError,
batch->onEntryErrorCallbacks,
batch->onEntryErrorUsers,
ASSET_BATCH_EVENT_MAX
);
for(uint16_t i = 0; i < count; i++) {
batch->inputs[i] = descs[i].input;
batch->entries[i] = assetLock(
descs[i].path, descs[i].type, &batch->inputs[i]
);
if(batch->entries[i]->state == ASSET_ENTRY_STATE_LOADED) {
// Already loaded (cached) - count it now, no subscription needed.
batch->loadedCount++;
} else if(batch->entries[i]->state == ASSET_ENTRY_STATE_ERROR) {
batch->errorCount++;
} else {
eventSubscribe(
&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb, batch
);
eventSubscribe(
&batch->entries[i]->onError, assetBatchEntryOnErrorCb, batch
);
}
}
}
void assetBatchLock(assetbatch_t *batch) {
assertNotNull(batch, "Batch cannot be NULL.");
for(uint16_t i = 0; i < batch->count; i++) {
assetEntryLock(batch->entries[i]);
}
}
void assetBatchUnlock(assetbatch_t *batch) {
assertNotNull(batch, "Batch cannot be NULL.");
for(uint16_t i = 0; i < batch->count; i++) {
assetEntryUnlock(batch->entries[i]);
}
}
bool_t assetBatchIsLoaded(const assetbatch_t *batch) {
assertNotNull(batch, "Batch cannot be NULL.");
for(uint16_t i = 0; i < batch->count; i++) {
if(batch->entries[i]->state != ASSET_ENTRY_STATE_LOADED) return false;
}
return true;
}
bool_t assetBatchHasError(const assetbatch_t *batch) {
assertNotNull(batch, "Batch cannot be NULL.");
for(uint16_t i = 0; i < batch->count; i++) {
if(batch->entries[i]->state == ASSET_ENTRY_STATE_ERROR) return true;
}
return false;
}
errorret_t assetBatchRequireLoaded(assetbatch_t *batch) {
assertNotNull(batch, "Batch cannot be NULL.");
bool_t allDone;
do {
allDone = true;
for(uint16_t i = 0; i < batch->count; i++) {
const assetentrystate_t state = batch->entries[i]->state;
if(state == ASSET_ENTRY_STATE_ERROR) {
errorThrow("Asset '%s' failed to load.", batch->entries[i]->name);
}
if(state != ASSET_ENTRY_STATE_LOADED) {
allDone = false;
}
}
if(!allDone) {
usleep(1000);
errorChain(assetUpdate());
}
} while(!allDone);
errorOk();
}
void assetBatchDispose(assetbatch_t *batch) {
assertNotNull(batch, "Batch cannot be NULL.");
for(uint16_t i = 0; i < batch->count; i++) {
if(batch->entries[i]) {
// Unsubscribe while we still hold a lock so the entry is live.
eventUnsubscribe(&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb);
eventUnsubscribe(&batch->entries[i]->onError, assetBatchEntryOnErrorCb);
assetUnlockEntry(batch->entries[i]);
}
}
memoryZero(batch, sizeof(assetbatch_t));
}
void assetBatchEntryOnLoadedCb(void *params, void *user) {
assetentry_t *entry = (assetentry_t *)params;
assetbatch_t *batch = (assetbatch_t *)user;
batch->loadedCount++;
eventInvoke(&batch->onEntryLoaded, entry);
if((uint16_t)(batch->loadedCount + batch->errorCount) >= batch->count) {
if(batch->errorCount == 0) {
eventInvoke(&batch->onLoaded, batch);
} else {
eventInvoke(&batch->onError, batch);
}
}
}
void assetBatchEntryOnErrorCb(void *params, void *user) {
assetentry_t *entry = (assetentry_t *)params;
assetbatch_t *batch = (assetbatch_t *)user;
batch->errorCount++;
eventInvoke(&batch->onEntryError, entry);
if((uint16_t)(batch->loadedCount + batch->errorCount) >= batch->count) {
eventInvoke(&batch->onError, batch);
}
}
-124
View File
@@ -1,124 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "asset/loader/assetentry.h"
#include "asset/loader/assetloader.h"
#include "event/event.h"
#define ASSET_BATCH_COUNT_MAX 64
#define ASSET_BATCH_EVENT_MAX 4
typedef struct {
const char_t *path;
assetloadertype_t type;
assetloaderinput_t input;
} assetbatchdesc_t;
typedef struct {
assetentry_t *entries[ASSET_BATCH_COUNT_MAX];
assetloaderinput_t inputs[ASSET_BATCH_COUNT_MAX];
uint16_t count;
uint16_t loadedCount;
uint16_t errorCount;
/** Fires once when every entry loaded. params = assetbatch_t * */
event_t onLoaded;
eventcallback_t onLoadedCallbacks[ASSET_BATCH_EVENT_MAX];
void *onLoadedUsers[ASSET_BATCH_EVENT_MAX];
/** Fires each time a single entry loads. params = assetentry_t * */
event_t onEntryLoaded;
eventcallback_t onEntryLoadedCallbacks[ASSET_BATCH_EVENT_MAX];
void *onEntryLoadedUsers[ASSET_BATCH_EVENT_MAX];
/** Fires when all entries finish (any with errors). params: assetbatch_t * */
event_t onError;
eventcallback_t onErrorCallbacks[ASSET_BATCH_EVENT_MAX];
void *onErrorUsers[ASSET_BATCH_EVENT_MAX];
/** Fires each time a single entry errors. params = assetentry_t * */
event_t onEntryError;
eventcallback_t onEntryErrorCallbacks[ASSET_BATCH_EVENT_MAX];
void *onEntryErrorUsers[ASSET_BATCH_EVENT_MAX];
} assetbatch_t;
/**
* Initialises the batch from an array of descriptors. Each entry is locked
* and queued for loading immediately.
*
* @param batch Batch to initialise.
* @param descs Array of entry descriptors (need not outlive this call).
* @param count Number of descriptors (must be <= ASSET_BATCH_COUNT_MAX).
*/
void assetBatchInit(
assetbatch_t *batch,
uint16_t count,
const assetbatchdesc_t *descs
);
/**
* Acquires one additional lock on every entry in the batch.
*
* @param batch Batch to lock.
*/
void assetBatchLock(assetbatch_t *batch);
/**
* Releases one lock from every entry in the batch. When an entry's lock
* count reaches zero it will be reaped on the next assetUpdate.
*
* @param batch Batch to unlock.
*/
void assetBatchUnlock(assetbatch_t *batch);
/**
* Returns true if every entry in the batch has finished loading.
*
* @param batch Batch to query.
*/
bool_t assetBatchIsLoaded(const assetbatch_t *batch);
/**
* Returns true if any entry in the batch is in an error state.
*
* @param batch Batch to query.
*/
bool_t assetBatchHasError(const assetbatch_t *batch);
/**
* Blocks until every entry is loaded. Returns an error if any entry fails.
*
* @param batch Batch to wait on.
*/
errorret_t assetBatchRequireLoaded(assetbatch_t *batch);
/**
* Releases the batch's lock on every entry and clears the batch. After this
* call the batch struct may be reused with assetBatchInit.
*
* @param batch Batch to dispose.
*/
void assetBatchDispose(assetbatch_t *batch);
/**
* Event trampoline invoked when a batch entry finishes loading.
* Increments the loaded counter and fires batch-level events.
*
* @param params The loaded assetentry_t pointer.
* @param user The owning assetbatch_t pointer.
*/
void assetBatchEntryOnLoadedCb(void *params, void *user);
/**
* Event trampoline invoked when a batch entry fails to load.
* Increments the error counter and fires batch-level events.
*
* @param params The errored assetentry_t pointer.
* @param user The owning assetbatch_t pointer.
*/
void assetBatchEntryOnErrorCb(void *params, void *user);
+23 -6
View File
@@ -87,13 +87,30 @@ errorret_t assetFileRead(
errorOk(); errorOk();
} }
// I assume zip_fread takes buffer NULL for skipping? // Some zip_fread() implementations (seen on PSP) reject a single call
zip_int64_t bytesRead = zip_fread(file->zipFile, buffer, bufferSize); // asking for the entire (potentially large) file at once with EINVAL;
if(bytesRead < 0) { // the line reader above only ever asks for up to 1024 bytes per call and
errorThrow("Failed to read from asset file: %s", file->filename); // works fine, so read in bounded chunks here too.
size_t totalRead = 0;
uint8_t *dest = (uint8_t *)buffer;
while(totalRead < bufferSize) {
size_t chunkSize = mathMin(
bufferSize - totalRead, ASSET_FILE_READ_CHUNK_MAX
);
zip_int64_t bytesRead = zip_fread(
file->zipFile, dest + totalRead, chunkSize
);
if(bytesRead < 0) {
errorThrow(
"Failed to read from asset file: %s (%s)",
file->filename, zip_file_strerror(file->zipFile)
);
}
if(bytesRead == 0) break;
totalRead += (size_t)bytesRead;
} }
file->position += bytesRead; file->position += totalRead;
file->lastRead = bytesRead; file->lastRead = totalRead;
errorOk(); errorOk();
} }
+6
View File
@@ -11,6 +11,12 @@
#define ASSET_FILE_NAME_MAX 48 #define ASSET_FILE_NAME_MAX 48
// Max bytes requested per zip_fread() call in assetFileRead(). Some
// zip_fread() implementations (seen on PSP) reject a single call asking
// for very large amounts of data at once; the locale line reader has
// always used 1024-byte reads successfully, so that's the proven-safe cap.
#define ASSET_FILE_READ_CHUNK_MAX 1024
typedef struct assetfile_s assetfile_t; typedef struct assetfile_s assetfile_t;
typedef errorret_t (*assetfileloader_t)(assetfile_t *file); typedef errorret_t (*assetfileloader_t)(assetfile_t *file);
+1
View File
@@ -17,3 +17,4 @@ add_subdirectory(locale)
add_subdirectory(json) add_subdirectory(json)
add_subdirectory(chunk) add_subdirectory(chunk)
add_subdirectory(dmf) add_subdirectory(dmf)
add_subdirectory(cutscene)
+1 -17
View File
@@ -35,22 +35,6 @@ void assetEntryInit(
entry->input = NULL; entry->input = NULL;
} }
refInit(&entry->refs, entry, NULL, NULL, NULL); refInit(&entry->refs, entry, NULL, NULL, NULL);
eventInit(
&entry->onLoaded,
entry->onLoadedCallbacks, entry->onLoadedUsers,
ASSET_ENTRY_EVENT_MAX
);
eventInit(
&entry->onUnloaded,
entry->onUnloadedCallbacks, entry->onUnloadedUsers,
ASSET_ENTRY_EVENT_MAX
);
eventInit(
&entry->onError,
entry->onErrorCallbacks, entry->onErrorUsers,
ASSET_ENTRY_EVENT_MAX
);
} }
void assetEntryLock(assetentry_t *entry) { void assetEntryLock(assetentry_t *entry) {
@@ -97,7 +81,7 @@ errorret_t assetEntryDispose(assetentry_t *entry) {
"Asset entry still refed at dispose time." "Asset entry still refed at dispose time."
); );
eventInvoke(&entry->onUnloaded, entry); if(entry->onUnloaded) entry->onUnloaded(entry, entry->onUnloadedUser);
errorChain(ASSET_LOADER_CALLBACKS[entry->type].dispose(entry)); errorChain(ASSET_LOADER_CALLBACKS[entry->type].dispose(entry));
memoryZero(entry, sizeof(assetentry_t)); memoryZero(entry, sizeof(assetentry_t));
errorOk(); errorOk();
+22 -20
View File
@@ -7,7 +7,6 @@
#pragma once #pragma once
#include "asset/loader/assetloading.h" #include "asset/loader/assetloading.h"
#include "event/event.h"
#include "util/ref.h" #include "util/ref.h"
typedef enum { typedef enum {
@@ -20,11 +19,17 @@ typedef enum {
ASSET_ENTRY_STATE_ERROR ASSET_ENTRY_STATE_ERROR
} assetentrystate_t; } assetentrystate_t;
/** Maximum number of subscribers for each per-entry event. */
#define ASSET_ENTRY_EVENT_MAX 2
typedef struct assetentry_s assetentry_t; typedef struct assetentry_s assetentry_t;
/**
* A single asset entry callback. Each entry supports at most one subscriber
* per event - a second assignment without clearing the first is a bug.
*
* @param entry The assetentry_t the event fired on.
* @param user The user pointer passed alongside the callback.
*/
typedef void (*assetentrycallback_t)(assetentry_t *entry, void *user);
struct assetentry_s { struct assetentry_s {
char_t name[ASSET_FILE_NAME_MAX]; char_t name[ASSET_FILE_NAME_MAX];
assetloadertype_t type; assetloadertype_t type;
@@ -33,30 +38,27 @@ struct assetentry_s {
ref_t refs; ref_t refs;
assetloaderinput_t *input; assetloaderinput_t *input;
assetloaderinput_t inputData; assetloaderinput_t inputData;
/**
* Fired once when loading completes successfully (params = assetentry_t *).
* Always invoked on the main thread.
*/
event_t onLoaded;
eventcallback_t onLoadedCallbacks[ASSET_ENTRY_EVENT_MAX];
void *onLoadedUsers[ASSET_ENTRY_EVENT_MAX];
/** /**
* Fired once when the entry is disposed/reaped (params = assetentry_t *). * Fired once when loading completes successfully.
* The asset data is still accessible when the callback runs.
* Always invoked on the main thread. * Always invoked on the main thread.
*/ */
event_t onUnloaded; assetentrycallback_t onLoaded;
eventcallback_t onUnloadedCallbacks[ASSET_ENTRY_EVENT_MAX]; void *onLoadedUser;
void *onUnloadedUsers[ASSET_ENTRY_EVENT_MAX];
/** /**
* Fired once when loading fails (params = assetentry_t *). * Fired once when the entry is disposed/reaped. The asset data is still
* accessible when the callback runs. Always invoked on the main thread.
*/
assetentrycallback_t onUnloaded;
void *onUnloadedUser;
/**
* Fired once when loading fails.
* Always invoked on the main thread. * Always invoked on the main thread.
*/ */
event_t onError; assetentrycallback_t onError;
eventcallback_t onErrorCallbacks[ASSET_ENTRY_EVENT_MAX]; void *onErrorUser;
void *onErrorUsers[ASSET_ENTRY_EVENT_MAX];
}; };
/** /**
+6
View File
@@ -51,4 +51,10 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = {
.loadAsync = assetChunkLoaderAsync, .loadAsync = assetChunkLoaderAsync,
.dispose = assetChunkDispose .dispose = assetChunkDispose
}, },
[ASSET_LOADER_TYPE_CUTSCENE] = {
.loadSync = assetCutsceneLoaderSync,
.loadAsync = assetCutsceneLoaderAsync,
.dispose = assetCutsceneDispose
},
}; };
+5
View File
@@ -13,6 +13,7 @@
#include "asset/loader/locale/assetlocaleloader.h" #include "asset/loader/locale/assetlocaleloader.h"
#include "asset/loader/json/assetjsonloader.h" #include "asset/loader/json/assetjsonloader.h"
#include "asset/loader/chunk/assetchunkloader.h" #include "asset/loader/chunk/assetchunkloader.h"
#include "asset/loader/cutscene/assetcutsceneloader.h"
typedef enum { typedef enum {
ASSET_LOADER_TYPE_NULL, ASSET_LOADER_TYPE_NULL,
@@ -24,6 +25,7 @@ typedef enum {
ASSET_LOADER_TYPE_LOCALE, ASSET_LOADER_TYPE_LOCALE,
ASSET_LOADER_TYPE_JSON, ASSET_LOADER_TYPE_JSON,
ASSET_LOADER_TYPE_CHUNK, ASSET_LOADER_TYPE_CHUNK,
ASSET_LOADER_TYPE_CUTSCENE,
ASSET_LOADER_TYPE_COUNT ASSET_LOADER_TYPE_COUNT
} assetloadertype_t; } assetloadertype_t;
@@ -36,6 +38,7 @@ typedef union {
assetlocaleloaderloading_t locale; assetlocaleloaderloading_t locale;
assetjsonloaderloading_t json; assetjsonloaderloading_t json;
assetchunkloaderloading_t chunk; assetchunkloaderloading_t chunk;
assetcutsceneloaderloading_t cutscene;
} assetloaderloading_t; } assetloaderloading_t;
typedef union { typedef union {
@@ -46,6 +49,7 @@ typedef union {
assetlocaleoutput_t locale; assetlocaleoutput_t locale;
assetjsonoutput_t json; assetjsonoutput_t json;
assetchunkoutput_t chunk; assetchunkoutput_t chunk;
assetcutsceneoutput_t cutscene;
} assetloaderoutput_t; } assetloaderoutput_t;
typedef union { typedef union {
@@ -54,6 +58,7 @@ typedef union {
assetlocaleloaderinput_t locale; assetlocaleloaderinput_t locale;
assetjsonloaderinput_t json; assetjsonloaderinput_t json;
assetchunkloaderinput_t chunk; assetchunkloaderinput_t chunk;
assetcutsceneloaderinput_t cutscene;
} assetloaderinput_t; } assetloaderinput_t;
typedef struct assetloading_s assetloading_t; typedef struct assetloading_s assetloading_t;
@@ -3,9 +3,7 @@
# This software is released under the MIT License. # This software is released under the MIT License.
# https://opensource.org/licenses/MIT # https://opensource.org/licenses/MIT
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
savevita.c assetcutsceneloader.c
savestreamvita.c
) )
@@ -0,0 +1,467 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetcutsceneloader.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/endian.h"
#include "asset/loader/assetloading.h"
#include "asset/loader/assetentry.h"
#include "asset/loader/assetloader.h"
#include "asset/asset.h"
// DCTS header: magic "DCTS" (4), version u32 LE (4), pauseType u8 (1),
// itemCount u8 (1), poolSize u16 LE (2) = 12 bytes.
#define ASSET_CUTSCENE_HEADER_SIZE 12
static uint8_t assetCutsceneReadU8(const uint8_t *data, size_t *offset) {
uint8_t value = data[*offset];
*offset += sizeof(uint8_t);
return value;
}
static uint16_t assetCutsceneReadU16(const uint8_t *data, size_t *offset) {
uint16_t value;
memoryCopy(&value, data + *offset, sizeof(uint16_t));
*offset += sizeof(uint16_t);
return endianLittleToHost16(value);
}
static uint32_t assetCutsceneReadU32(const uint8_t *data, size_t *offset) {
uint32_t value;
memoryCopy(&value, data + *offset, sizeof(uint32_t));
*offset += sizeof(uint32_t);
return endianLittleToHost32(value);
}
static float_t assetCutsceneReadFloat(const uint8_t *data, size_t *offset) {
float_t value;
memoryCopy(&value, data + *offset, sizeof(float_t));
*offset += sizeof(float_t);
return endianLittleToHostFloat(value);
}
static worldunit_t assetCutsceneReadWorldUnit(
const uint8_t *data,
size_t *offset
) {
uint16_t value = assetCutsceneReadU16(data, offset);
return (worldunit_t)value;
}
static worldpos_t assetCutsceneReadWorldPos(
const uint8_t *data,
size_t *offset
) {
worldpos_t pos;
pos.x = assetCutsceneReadWorldUnit(data, offset);
pos.y = assetCutsceneReadWorldUnit(data, offset);
pos.z = assetCutsceneReadWorldUnit(data, offset);
return pos;
}
// Copies a length-prefixed string directly into an item's own embedded
// char_t[destCapacity] field (CUTSCENE_TEXT_MAX_CHARS and friends) - these
// are never pool references, see the item-field inventory in the runtime
// cutscene file design.
static void assetCutsceneReadEmbeddedString(
const uint8_t *data,
size_t *offset,
char_t *dest,
const size_t destCapacity
) {
uint8_t len = assetCutsceneReadU8(data, offset);
assertTrue(len < destCapacity, "Cutscene string exceeds field capacity");
memoryCopy(dest, data + *offset, len);
dest[len] = '\0';
*offset += len;
}
// Resolves a u16 pool offset (read from the item stream) to a real pointer
// into the entry's own persistent pool allocation.
static const char_t * assetCutsceneReadPoolString(
const uint8_t *data,
size_t *offset,
const char_t *pool
) {
uint16_t poolOffset = assetCutsceneReadU16(data, offset);
return pool + poolOffset;
}
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Should be called from an async thread.");
if(loading->loading.cutscene.state != ASSET_CUTSCENE_LOADING_STATE_READ_FILE) {
errorOk();
}
assertNull(loading->loading.cutscene.data, "Data already defined?");
assetfile_t *file = &loading->loading.cutscene.file;
assetLoaderErrorChain(loading,
assetFileInit(file, loading->entry->name, NULL, NULL)
);
uint8_t *data = memoryAllocate(file->size);
assetLoaderErrorChain(loading, assetFileOpen(file));
assetLoaderErrorChain(loading, assetFileRead(file, data, file->size));
assertTrue(
file->lastRead == file->size,
"Failed to read entire cutscene file."
);
// Saved before assetFileDispose zeroes the whole assetfile_t struct
// (including .size) - the sync phase needs the file's total length to
// locate the pool region, which starts poolSize bytes before the end.
loading->loading.cutscene.dataSize = (size_t)file->size;
assetLoaderErrorChain(loading, assetFileClose(file));
assetLoaderErrorChain(loading, assetFileDispose(file));
loading->loading.cutscene.data = data;
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_PARSE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
errorret_t assetCutsceneLoaderSync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertTrue(loading->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
if(loading->loading.cutscene.state == ASSET_CUTSCENE_LOADING_STATE_INITIAL) {
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_READ_FILE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
errorOk();
}
assetcutsceneoutput_t *out = &loading->entry->data.cutscene;
uint8_t *data = loading->loading.cutscene.data;
assertNotNull(data, "Cutscene data should have been loaded by now.");
size_t fileSize = loading->loading.cutscene.dataSize;
if(data[0] != 'D' || data[1] != 'C' || data[2] != 'T' || data[3] != 'S') {
memoryFree(data);
assetLoaderErrorThrow(loading, "Invalid cutscene file header");
}
size_t offset = 4;
uint32_t version = assetCutsceneReadU32(data, &offset);
if(version != ASSET_CUTSCENE_FILE_VERSION) {
memoryFree(data);
assetLoaderErrorThrow(
loading, "Unsupported cutscene file version %u", version
);
}
cutscenepause_t pauseType = (cutscenepause_t)assetCutsceneReadU8(data, &offset);
uint8_t itemCount = assetCutsceneReadU8(data, &offset);
uint16_t poolSize = assetCutsceneReadU16(data, &offset);
assertTrue(offset == ASSET_CUTSCENE_HEADER_SIZE, "Cutscene header size mismatch");
out->pool = poolSize > 0 ? memoryAllocate(poolSize) : NULL;
if(poolSize > 0) {
size_t poolStart = fileSize - (size_t)poolSize;
memoryCopy(out->pool, data + poolStart, poolSize);
}
const char_t *pool = out->pool;
out->items = memoryAllocate(itemCount * sizeof(cutsceneitem_t));
memoryZero(out->items, itemCount * sizeof(cutsceneitem_t));
for(uint8_t i = 0; i < itemCount; i++) {
cutsceneitem_t *item = &out->items[i];
item->type = (cutsceneitemtype_t)assetCutsceneReadU8(data, &offset);
switch(item->type) {
case CUTSCENE_ITEM_TYPE_TEXT:
assetCutsceneReadEmbeddedString(
data, &offset, item->text.text, CUTSCENE_TEXT_MAX_CHARS
);
break;
case CUTSCENE_ITEM_TYPE_TEXT_MINI:
assetCutsceneReadEmbeddedString(
data, &offset, item->textMini.text, CUTSCENE_TEXT_MINI_MAX_CHARS
);
item->textMini.position[0] = assetCutsceneReadFloat(data, &offset);
item->textMini.position[1] = assetCutsceneReadFloat(data, &offset);
item->textMini.position[2] = assetCutsceneReadFloat(data, &offset);
item->textMini.duration = assetCutsceneReadFloat(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_TEXT_MINI_HIDE:
item->textMiniHide.index = assetCutsceneReadU8(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_WAIT:
item->wait = assetCutsceneReadFloat(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_ENTITY_TELEPORT:
item->entityTeleport.entityIndex = assetCutsceneReadU8(data, &offset);
item->entityTeleport.target = assetCutsceneReadWorldPos(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO: {
item->entityWalkTo.entityIndex = assetCutsceneReadU8(data, &offset);
item->entityWalkTo.walkAround = assetCutsceneReadU8(data, &offset) != 0;
uint8_t count = assetCutsceneReadU8(data, &offset);
uint16_t poolOffset = assetCutsceneReadU16(data, &offset);
item->entityWalkTo.count = count;
item->entityWalkTo.positions = (const worldpos_t *)(pool + poolOffset);
break;
}
case CUTSCENE_ITEM_TYPE_FADE:
item->fade.from.r = assetCutsceneReadU8(data, &offset);
item->fade.from.g = assetCutsceneReadU8(data, &offset);
item->fade.from.b = assetCutsceneReadU8(data, &offset);
item->fade.from.a = assetCutsceneReadU8(data, &offset);
item->fade.to.r = assetCutsceneReadU8(data, &offset);
item->fade.to.g = assetCutsceneReadU8(data, &offset);
item->fade.to.b = assetCutsceneReadU8(data, &offset);
item->fade.to.a = assetCutsceneReadU8(data, &offset);
item->fade.duration = assetCutsceneReadFloat(data, &offset);
item->fade.easing = (easingtype_t)assetCutsceneReadU8(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_SET_PAUSE:
item->setPause = (cutscenepause_t)assetCutsceneReadU8(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_ITEM_GIVE:
item->itemGive.item = (itemid_t)assetCutsceneReadU16(data, &offset);
item->itemGive.quantity = assetCutsceneReadU8(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_ENTITY_REMOVE:
item->entityRemove.entityIndex = assetCutsceneReadU8(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_ENTITY_ADD:
item->entityAdd.entityType = assetCutsceneReadU8(data, &offset);
item->entityAdd.position = assetCutsceneReadWorldPos(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_ENTITY_TURN:
item->entityTurn.entityIndex = assetCutsceneReadU8(data, &offset);
item->entityTurn.direction =
(entitydir_t)assetCutsceneReadU8(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO_ENTITY:
item->entityWalkToEntity.entityIndex =
assetCutsceneReadU8(data, &offset);
item->entityWalkToEntity.targetEntityIndex =
assetCutsceneReadU8(data, &offset);
item->entityWalkToEntity.offsetX =
assetCutsceneReadWorldUnit(data, &offset);
item->entityWalkToEntity.offsetY =
assetCutsceneReadWorldUnit(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_MAP_AREA_REMOVE:
item->mapAreaRemove.areaId = assetCutsceneReadU8(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_MAP_AREA_WAIT: {
uint8_t count = assetCutsceneReadU8(data, &offset);
uint16_t poolOffset = assetCutsceneReadU16(data, &offset);
assertTrue(
count <= CUTSCENE_MAP_AREA_WAIT_MAX,
"Cutscene map area wait count exceeds maximum"
);
item->mapAreaWait.count = count;
item->mapAreaWait.areaIds = (const uint8_t *)(pool + poolOffset);
break;
}
case CUTSCENE_ITEM_TYPE_START_BATTLE: {
item->startBattle.encounterType =
(battleencountertype_t)assetCutsceneReadU8(data, &offset);
item->startBattle.fleeAvailable =
assetCutsceneReadU8(data, &offset) != 0;
uint8_t enemyCount = assetCutsceneReadU8(data, &offset);
assertTrue(
enemyCount <= CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX,
"Cutscene battle enemy count exceeds maximum"
);
item->startBattle.enemyCount = enemyCount;
for(uint8_t e = 0; e < enemyCount; e++) {
cutscenestartbattleenemy_t *enemy = &item->startBattle.enemies[e];
enemy->stats.attack = assetCutsceneReadU16(data, &offset);
enemy->stats.defense = assetCutsceneReadU16(data, &offset);
enemy->stats.magic = assetCutsceneReadU16(data, &offset);
enemy->stats.speed = assetCutsceneReadU16(data, &offset);
enemy->stats.luck = assetCutsceneReadU16(data, &offset);
enemy->healthMax = assetCutsceneReadU16(data, &offset);
enemy->mpMax = assetCutsceneReadU16(data, &offset);
}
break;
}
case CUTSCENE_ITEM_TYPE_EMOJI:
item->emoji.entityIndex = assetCutsceneReadU8(data, &offset);
item->emoji.duration = assetCutsceneReadFloat(data, &offset);
item->emoji.emojiType = (uiemojitype_t)assetCutsceneReadU8(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_SHAKE:
item->shake.amount = assetCutsceneReadU8(data, &offset);
item->shake.duration = assetCutsceneReadFloat(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE:
item->battleWaitState.state =
(battlestate_t)assetCutsceneReadU8(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION:
item->battleForceAction.fighterIndex = assetCutsceneReadU8(data, &offset);
item->battleForceAction.targetIndex = assetCutsceneReadU8(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_MODAL: {
assetCutsceneReadEmbeddedString(
data, &offset, item->modal.title, CUTSCENE_MODAL_TITLE_MAX_CHARS
);
assetCutsceneReadEmbeddedString(
data, &offset, item->modal.message, CUTSCENE_MODAL_MESSAGE_MAX_CHARS
);
// v1 only supports the message-only form: MODAL and MODAL_OPTIONS
// share this same tag with no separate discriminator, and there is
// no native-callback registry yet to resolve an options callback.
// Read into a local first (not inline in the check below) - on a
// release build with DUSK_ASSERTIONS_FAKED, an assert's condition
// is never evaluated at all, so a byte-consuming call inside one
// would silently desync every item after it. This is untrusted
// file content anyway, so it gets a real error, not an assert.
uint8_t optionCount = assetCutsceneReadU8(data, &offset);
if(optionCount != 0) {
memoryFree(data);
memoryFree(out->items);
out->items = NULL;
if(out->pool != NULL) {
memoryFree(out->pool);
out->pool = NULL;
}
assetLoaderErrorThrow(
loading,
"Cutscene MODAL item with options is not supported in "
"file-based cutscenes yet - use MODAL_OPTIONS_MARKERS instead"
);
}
break;
}
case CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS: {
assetCutsceneReadEmbeddedString(
data, &offset, item->modalOptionsMarkers.title,
CUTSCENE_MODAL_TITLE_MAX_CHARS
);
assetCutsceneReadEmbeddedString(
data, &offset, item->modalOptionsMarkers.message,
CUTSCENE_MODAL_MESSAGE_MAX_CHARS
);
uint8_t optionCount = assetCutsceneReadU8(data, &offset);
assertTrue(
optionCount <= CUTSCENE_MODAL_OPTIONS_MARKERS_MAX,
"Cutscene modal option count exceeds maximum"
);
item->modalOptionsMarkers.optionCount = optionCount;
for(uint8_t o = 0; o < optionCount; o++) {
item->modalOptionsMarkers.options[o] =
assetCutsceneReadPoolString(data, &offset, pool);
item->modalOptionsMarkers.markers[o] =
assetCutsceneReadPoolString(data, &offset, pool);
}
break;
}
case CUTSCENE_ITEM_TYPE_MODAL_CLOSE:
case CUTSCENE_ITEM_TYPE_RESTART:
break;
case CUTSCENE_ITEM_TYPE_PRINT:
assetCutsceneReadEmbeddedString(
data, &offset, item->print.text, CUTSCENE_PRINT_MAX_CHARS
);
break;
case CUTSCENE_ITEM_TYPE_MARKER:
item->marker.name = assetCutsceneReadPoolString(data, &offset, pool);
break;
case CUTSCENE_ITEM_TYPE_SCENE:
item->sceneChange.type = (scenetype_t)assetCutsceneReadU8(data, &offset);
break;
case CUTSCENE_ITEM_TYPE_SAVE_DEVICE_CHECK:
item->saveDeviceCheck.successMarker =
assetCutsceneReadPoolString(data, &offset, pool);
item->saveDeviceCheck.failureMarker =
assetCutsceneReadPoolString(data, &offset, pool);
break;
case CUTSCENE_ITEM_TYPE_SAVE_LOAD_ALL_SLOTS:
item->saveLoadAllSlots.successMarker =
assetCutsceneReadPoolString(data, &offset, pool);
item->saveLoadAllSlots.failureMarker =
assetCutsceneReadPoolString(data, &offset, pool);
break;
default:
memoryFree(data);
memoryFree(out->items);
out->items = NULL;
if(out->pool != NULL) {
memoryFree(out->pool);
out->pool = NULL;
}
assetLoaderErrorThrow(
loading,
"Cutscene item type %u is not supported in file-based cutscenes "
"(item %u/%u, offset %u/%u, poolSize %u)",
(uint32_t)item->type, (uint32_t)i, (uint32_t)itemCount,
(uint32_t)offset, (uint32_t)fileSize, (uint32_t)poolSize
);
}
}
memoryFree(data);
loading->loading.cutscene.data = NULL;
out->cutscene.items = out->items;
out->cutscene.itemCount = itemCount;
out->cutscene.pause = pauseType;
out->cutscene.dataSize = 0;
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk();
}
errorret_t assetCutsceneDispose(assetentry_t *entry) {
assertNotNull(entry, "Entry cannot be NULL");
assertTrue(entry->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
assetcutsceneoutput_t *out = &entry->data.cutscene;
if(out->items != NULL) {
memoryFree(out->items);
out->items = NULL;
}
if(out->pool != NULL) {
memoryFree(out->pool);
out->pool = NULL;
}
errorOk();
}
@@ -0,0 +1,71 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "asset/assetfile.h"
#include "rpg/cutscene/cutscene.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#define ASSET_CUTSCENE_FILE_VERSION 1
typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t;
typedef struct {
void *nothing;
} assetcutsceneloaderinput_t;
typedef enum {
ASSET_CUTSCENE_LOADING_STATE_INITIAL,
ASSET_CUTSCENE_LOADING_STATE_READ_FILE,
ASSET_CUTSCENE_LOADING_STATE_PARSE
} assetcutsceneloadingstate_t;
typedef struct {
assetfile_t file;
assetcutsceneloadingstate_t state;
uint8_t *data;
size_t dataSize;// Saved before assetFileDispose zeroes file.size.
} assetcutsceneloaderloading_t;
// Runtime-loaded cutscene: items/pool are heap-allocated to the file's
// actual declared sizes (not fixed-capacity), so an entry that never holds
// a cutscene costs nothing extra in the shared assetloaderoutput_t union -
// see assetchunkoutput_t.tiles for the same pattern.
typedef struct {
cutscene_t cutscene; // .items points at the items array below
cutsceneitem_t *items;
char_t *pool;
} assetcutsceneoutput_t;
/**
* Asynchronous loader for cutscene assets. Reads the raw DCTS file bytes
* into the loading buffer so the sync phase can parse without blocking the
* main thread on I/O.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
*/
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading);
/**
* Synchronous loader for cutscene assets. Validates the DCTS binary
* previously read by the async phase and decodes it into a heap-allocated
* cutsceneitem_t array + string/data pool.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
*/
errorret_t assetCutsceneLoaderSync(assetloading_t *loading);
/**
* Disposer for cutscene assets.
*
* @param entry Asset entry containing the cutscene data to dispose.
* @return Error code indicating success or failure of the dispose operation.
*/
errorret_t assetCutsceneDispose(assetentry_t *entry);
+2 -1
View File
@@ -17,7 +17,8 @@ console_t CONSOLE;
void consoleInit(void) { void consoleInit(void) {
memoryZero(&CONSOLE, sizeof(console_t)); memoryZero(&CONSOLE, sizeof(console_t));
CONSOLE.visible = false; // CONSOLE.visible = false;
CONSOLE.visible = true;
threadMutexInit(&CONSOLE.printMutex); threadMutexInit(&CONSOLE.printMutex);
} }
+1 -1
View File
@@ -10,7 +10,7 @@
#include "dusk.h" #include "dusk.h"
#include "thread/thread.h" #include "thread/thread.h"
#define CONSOLE_LINE_MAX 512 #define CONSOLE_LINE_MAX 128
#define CONSOLE_HISTORY_MAX 16 #define CONSOLE_HISTORY_MAX 16
#define CONSOLE_EXEC_BUFFER_MAX 32 #define CONSOLE_EXEC_BUFFER_MAX 32
+1 -1
View File
@@ -76,7 +76,7 @@ errorret_t spriteBatchBuffer(
// Buffer to the mesh vertices. // Buffer to the mesh vertices.
spriteBatchBufferToMesh( spriteBatchBufferToMesh(
sprites, batchCount, v, batchCount * QUAD_VERTEX_COUNT sprites + (count - remaining), batchCount, v, batchCount * QUAD_VERTEX_COUNT
); );
SPRITEBATCH.spriteCount += batchCount; SPRITEBATCH.spriteCount += batchCount;
remaining -= batchCount; remaining -= batchCount;
+33 -6
View File
@@ -76,11 +76,23 @@ const uint8_t FONT_DEFAULT_GLYPHS[FONT_DEFAULT_TILE_COUNT][
{ 0x00, 0x22, 0x22, 0x14, 0x08, 0x08, 0x08, 0x08, 0x00, 0x00 }, // Y { 0x00, 0x22, 0x22, 0x14, 0x08, 0x08, 0x08, 0x08, 0x00, 0x00 }, // Y
{ 0x00, 0x3E, 0x02, 0x04, 0x08, 0x10, 0x20, 0x3E, 0x00, 0x00 }, // Z { 0x00, 0x3E, 0x02, 0x04, 0x08, 0x10, 0x20, 0x3E, 0x00, 0x00 }, // Z
{ 0x00, 0x0C, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0C, 0x00, 0x00 }, // [ { 0x00, 0x0C, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0C, 0x00, 0x00 }, // [
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // backslash (not drawn in source font) // Custom icon glyph, not a real backslash - a backspace symbol: Caps
// Lock's up arrow (see FONT_ICON_CAPSLOCK) rotated 270 degrees to
// point left instead, adapted to this font's fixed 6x10 tile (each of
// Caps Lock's row widths becomes a column height here, centered
// vertically). Backslash was never drawn anyway, and isn't a key this
// virtual keyboard can type - see FONT_ICON_BACKSPACE.
{ 0x00, 0x00, 0x08, 0x1F, 0x3F, 0x3F, 0x1F, 0x08, 0x00, 0x00 }, // FONT_ICON_BACKSPACE
{ 0x00, 0x18, 0x08, 0x08, 0x08, 0x08, 0x08, 0x18, 0x00, 0x00 }, // ] { 0x00, 0x18, 0x08, 0x08, 0x08, 0x08, 0x08, 0x18, 0x00, 0x00 }, // ]
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ^ (not drawn in source font) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ^ (not drawn in source font)
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // _ (not drawn in source font) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3E }, // _
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ` (not drawn in source font) // Custom icon glyph, not a real backtick - an up arrow for Shift, same
// head as Caps Lock's (see FONT_ICON_CAPSLOCK) but with a shaft 2px
// thinner, plus one empty row near the bottom of it, so it reads as a
// lighter/broken version of Caps Lock's thicker uninterrupted one.
// Backtick was never drawn anyway, and isn't a key this virtual
// keyboard can type - see FONT_ICON_SHIFT.
{ 0x0C, 0x1E, 0x3F, 0x0C, 0x0C, 0x0C, 0x0C, 0x00, 0x0C, 0x00 }, // FONT_ICON_SHIFT
{ 0x00, 0x00, 0x00, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x00, 0x00 }, // a { 0x00, 0x00, 0x00, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x00, 0x00 }, // a
{ 0x00, 0x20, 0x20, 0x3C, 0x22, 0x22, 0x22, 0x3C, 0x00, 0x00 }, // b { 0x00, 0x20, 0x20, 0x3C, 0x22, 0x22, 0x22, 0x3C, 0x00, 0x00 }, // b
{ 0x00, 0x00, 0x00, 0x1C, 0x22, 0x20, 0x22, 0x1C, 0x00, 0x00 }, // c { 0x00, 0x00, 0x00, 0x1C, 0x22, 0x20, 0x22, 0x1C, 0x00, 0x00 }, // c
@@ -108,10 +120,25 @@ const uint8_t FONT_DEFAULT_GLYPHS[FONT_DEFAULT_TILE_COUNT][
{ 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x22, 0x1E, 0x02, 0x1C }, // y { 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x22, 0x1E, 0x02, 0x1C }, // y
{ 0x00, 0x00, 0x00, 0x3E, 0x04, 0x08, 0x10, 0x3E, 0x00, 0x00 }, // z { 0x00, 0x00, 0x00, 0x3E, 0x04, 0x08, 0x10, 0x3E, 0x00, 0x00 }, // z
{ 0x00, 0x04, 0x08, 0x08, 0x10, 0x08, 0x08, 0x04, 0x00, 0x00 }, // { { 0x00, 0x04, 0x08, 0x08, 0x10, 0x08, 0x08, 0x04, 0x00, 0x00 }, // {
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // | (not drawn in source font) // Custom icon glyph, not a real pipe - a "return" arrow for the
// keyboard's newline key: a vertical riser down the right side that
// hooks left into a leftward-pointing arrowhead, i.e. "<-|" rotated
// into an L. Pipe was never drawn anyway, and isn't a key this
// virtual keyboard can type - see FONT_ICON_NEWLINE.
{ 0x02, 0x02, 0x02, 0x02, 0x0E, 0x1E, 0x08, 0x00, 0x00, 0x00 }, // FONT_ICON_NEWLINE
{ 0x00, 0x10, 0x08, 0x08, 0x04, 0x08, 0x08, 0x10, 0x00, 0x00 }, // } { 0x00, 0x10, 0x08, 0x08, 0x04, 0x08, 0x08, 0x10, 0x00, 0x00 }, // }
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ~ (not drawn in source font) // Custom icon glyph, not a real tilde - a spacebar symbol for the
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // unused trailing tile // keyboard's space key: an underscore with a tick at each end, like
// "|___|". Tilde was never drawn anyway, and was explicitly dropped
// from this virtual keyboard's own key set - see FONT_ICON_SPACE.
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x3F }, // FONT_ICON_SPACE
// Custom icon glyph, not a real ASCII character - a thick, solid,
// uninterrupted up arrow for Caps Lock (contrast FONT_ICON_SHIFT's
// same head but a thinner, gapped shaft). Assigned to char code 127
// (DEL) since that codepoint is never legitimately typed text and
// (unlike 128+) is still a positive value regardless of whether this
// platform's plain `char` is signed - see FONT_ICON_CAPSLOCK.
{ 0x0C, 0x1E, 0x3F, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x00 }, // FONT_ICON_CAPSLOCK
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // unused trailing tile { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // unused trailing tile
}; };
+36 -1
View File
@@ -28,10 +28,45 @@ typedef struct {
/** /**
* Number of glyphs defined in FONT_DEFAULT_GLYPHS (FONT_DEFAULT_COLUMNS * * Number of glyphs defined in FONT_DEFAULT_GLYPHS (FONT_DEFAULT_COLUMNS *
* FONT_DEFAULT_ROWS), covering the printable ASCII range starting at * FONT_DEFAULT_ROWS), covering the printable ASCII range starting at
* TEXT_CHAR_START ('!') plus a couple of unused trailing tiles. * TEXT_CHAR_START ('!'), one custom icon glyph in a trailing unused tile
* (see FONT_ICON_CAPSLOCK), plus one more genuinely unused trailing tile.
* FONT_ICON_SHIFT/FONT_ICON_NEWLINE reuse existing-but-blank slots within
* the printable range instead of more trailing tiles - see their own
* comments.
*/ */
#define FONT_DEFAULT_TILE_COUNT (FONT_DEFAULT_COLUMNS * FONT_DEFAULT_ROWS) #define FONT_DEFAULT_TILE_COUNT (FONT_DEFAULT_COLUMNS * FONT_DEFAULT_ROWS)
/**
* Custom (non-ASCII-meaning) icon glyphs baked into FONT_DEFAULT_GLYPHS
* at otherwise-unused codepoints within the range this font covers -
* safe to use anywhere a char_t string is expected, e.g. a uibutton_t
* label. Kept below 128: char_t is a plain `char`, whose signedness
* varies by platform, so a codepoint of 128 or above isn't safely
* representable everywhere this engine targets.
*/
// Thick, solid, uninterrupted up arrow - Caps Lock. A trailing tile
// (char code 127/DEL) that was never a real character to begin with.
#define FONT_ICON_CAPSLOCK "\x7F"
// Same up arrow, but with a thinner and gapped shaft - Shift. Reuses
// the backtick's glyph slot: backtick was never drawn by this font
// anyway, and isn't a key this engine's virtual keyboard can type.
#define FONT_ICON_SHIFT "`"
// A "return" arrow (down then left, with a leftward arrowhead) - the
// keyboard's newline key. Reuses the pipe's glyph slot, for the same
// reason as FONT_ICON_SHIFT.
#define FONT_ICON_NEWLINE "|"
// An underscore with a tick at each end ("|___|") - the keyboard's
// space key. Reuses the tilde's glyph slot: tilde was never drawn by
// this font, and was explicitly dropped from this engine's virtual
// keyboard's own key set.
#define FONT_ICON_SPACE "~"
// Caps Lock's arrow rotated to point left - the keyboard's backspace
// key. Reuses the backslash's glyph slot, for the same reason as
// FONT_ICON_SHIFT/FONT_ICON_NEWLINE/FONT_ICON_SPACE. Last free reused
// slot below 128 - one more custom icon after this needs
// FONT_DEFAULT_COLUMNS/ROWS grown to make room.
#define FONT_ICON_BACKSPACE "\\"
extern font_t FONT_DEFAULT; extern font_t FONT_DEFAULT;
/** /**
+112 -20
View File
@@ -54,6 +54,62 @@ spritebatchsprite_t textGetSprite(
return sprite; return sprite;
} }
int32_t textBuffer(
const float_t x,
const float_t y,
const char_t *text,
font_t *font,
spritebatchsprite_t *outSprites,
const int32_t maxSprites,
int32_t *charIndex,
float_t *posX,
float_t *posY
) {
assertNotNull(text, "Text cannot be NULL");
if(outSprites == NULL) {
int32_t count = 0;
char_t c;
int32_t i = 0;
while((c = text[i++]) != '\0') {
if(c != ' ' && c != '\n') count++;
}
return count;
}
assertNotNull(font, "Font cannot be NULL");
assertTrue(maxSprites > 0, "Max sprites must be greater than zero");
assertNotNull(posX, "Output posX cannot be NULL");
assertNotNull(posY, "Output posY cannot be NULL");
assertNotNull(charIndex, "Output charIndex cannot be NULL");
int32_t spriteIndex = 0;
char_t c;
for(;;) {
c = text[*charIndex];
if(c == '\0') break;
(*charIndex)++;
if(c == '\n') {
*posX = x;
*posY += font->tileset->tileHeight;
continue;
}
if(c == ' ') {
*posX += font->tileset->tileWidth;
continue;
}
outSprites[spriteIndex++] = textGetSprite((vec2){*posX, *posY}, c, font);
*posX += font->tileset->tileWidth;
if(spriteIndex >= maxSprites) break;
}
return spriteIndex;
}
errorret_t textDraw( errorret_t textDraw(
const float_t x, const float_t x,
const float_t y, const float_t y,
@@ -62,10 +118,11 @@ errorret_t textDraw(
font_t *font font_t *font
) { ) {
assertNotNull(text, "Text cannot be NULL"); assertNotNull(text, "Text cannot be NULL");
int32_t length = strlen(text);
if(length == 0) errorOk();
if(font == NULL) font = &FONT_DEFAULT; if(font == NULL) font = &FONT_DEFAULT;
spritebatchsprite_t sprite;
shadermaterial_t material = { shadermaterial_t material = {
.unlit = { .unlit = {
.color = color, .color = color,
@@ -73,31 +130,25 @@ errorret_t textDraw(
} }
}; };
spritebatchsprite_t sprites[32];
float_t posX = x; float_t posX = x;
float_t posY = y; float_t posY = y;
int32_t buffered = 0;
int32_t charIndex = 0;
do {
buffered = textBuffer(
x, y, text, font,
sprites,
sizeof(sprites) / sizeof(spritebatchsprite_t),
&charIndex, &posX, &posY
);
errorChain(spriteBatchBuffer(sprites, buffered, &SHADER_UNLIT, material));
} while(charIndex < length);
char_t c;
int32_t i = 0;
while((c = text[i++]) != '\0') {
if(c == '\n') {
posX = x;
posY += font->tileset->tileHeight;
continue;
}
if(c == ' ') {
posX += font->tileset->tileWidth;
continue;
}
sprite = textGetSprite((vec2){posX, posY}, c, font);
errorChain(spriteBatchBuffer(&sprite, 1, &SHADER_UNLIT, material));
posX += font->tileset->tileWidth;
}
errorOk(); errorOk();
} }
void textMeasure( int32_t textMeasure(
const char_t *text, const char_t *text,
const font_t *font, const font_t *font,
int32_t *outWidth, int32_t *outWidth,
@@ -110,6 +161,7 @@ void textMeasure(
int32_t width = 0; int32_t width = 0;
int32_t height = font->tileset->tileHeight; int32_t height = font->tileset->tileHeight;
int32_t lineWidth = 0; int32_t lineWidth = 0;
int32_t spriteCount = 0;
char_t c; char_t c;
int32_t i = 0; int32_t i = 0;
@@ -122,10 +174,50 @@ void textMeasure(
} }
lineWidth += font->tileset->tileWidth; lineWidth += font->tileset->tileWidth;
if(c != ' ') {
spriteCount++;
}
} }
if(lineWidth > width) width = lineWidth; if(lineWidth > width) width = lineWidth;
*outWidth = width; *outWidth = width;
*outHeight = height; *outHeight = height;
return spriteCount;
}
void textWrap(char_t *text, const font_t *font, const float_t maxWidth) {
assertNotNull(text, "Text cannot be NULL");
assertNotNull(font, "Font cannot be NULL");
float_t fontWidth = (float_t)font->tileset->tileWidth;
if(fontWidth <= 0.0f) return;
int32_t charsPerLine = (int32_t)(maxWidth / fontWidth);
if(charsPerLine <= 0) return;
int32_t lineWidth = 0;
int32_t lastSpace = -1;
for(int32_t i = 0; text[i] != '\0'; i++) {
if(text[i] == '\n') {
lineWidth = 0;
lastSpace = -1;
continue;
}
if(text[i] == ' ') {
lastSpace = i;
}
lineWidth++;
if(lineWidth > charsPerLine && lastSpace != -1) {
text[lastSpace] = '\n';
lineWidth = i - lastSpace;
lastSpace = -1;
}
}
} }
+47 -1
View File
@@ -40,6 +40,37 @@ spritebatchsprite_t textGetSprite(
const font_t *font const font_t *font
); );
/**
* Buffers a string into sprites for rendering. If outSprites is NULL then
* the function will only return the count of sprites necessary for the buffer.
*
* posX and posY are updated whilst buffering characters, if you need to do
* buffering in sets then these will be reusable between buffer commands. Start
* by setting these to x and y initially.
*
* @param x The x-coordinate to start buffering the text at.
* @param y The y-coordinate to start buffering the text at.
* @param text The null-terminated string of text to buffer.
* @param font Font to use for rendering.
* @param outSprites Pointer to an array of spritebatchsprite_t.
* @param maxSprites The maximum number of sprites in outSprites.
* @param charIndex Pointer to an int32_t to store the character indexed.
* @param posX Pointer to a float_t to store the final x position.
* @param posY Pointer to a float_t to store the final y position.
* @return The count of sprites buffered.
*/
int32_t textBuffer(
const float_t x,
const float_t y,
const char_t *text,
font_t *font,
spritebatchsprite_t *outSprites,
const int32_t maxSprites,
int32_t *charIndex,
float_t *posX,
float_t *posY
);
/** /**
* Draws a string of text at the specified position. * Draws a string of text at the specified position.
* *
@@ -65,10 +96,25 @@ errorret_t textDraw(
* @param font Font to use for measurement. * @param font Font to use for measurement.
* @param outWidth Pointer to store the measured width in pixels. * @param outWidth Pointer to store the measured width in pixels.
* @param outHeight Pointer to store the measured height in pixels. * @param outHeight Pointer to store the measured height in pixels.
* @return The count of sprites that will be rendered for the given text.
*/ */
void textMeasure( int32_t textMeasure(
const char_t *text, const char_t *text,
const font_t *font, const font_t *font,
int32_t *outWidth, int32_t *outWidth,
int32_t *outHeight int32_t *outHeight
); );
/**
* Word-wraps text in place for display at up to maxWidth pixels wide,
* by replacing the space nearest each overflow point with a newline.
* Length is unchanged - this only ever swaps existing spaces for
* newlines, never inserts characters - so it's always safe to call on a
* fixed-size buffer. A single word wider than maxWidth on its own is
* left unbroken.
*
* @param text Null-terminated, caller-owned buffer to wrap in place.
* @param font Font to measure character width with.
* @param maxWidth Maximum line width, in pixels.
*/
void textWrap(char_t *text, const font_t *font, const float_t maxWidth);
+1 -3
View File
@@ -21,8 +21,7 @@
#endif #endif
#include "system/system.h" #include "system/system.h"
#include "console/console.h" #include "console/console.h"
#include "save/save.h" #include "save/save.h"\
#include "save/autosave.h"
engine_t ENGINE; engine_t ENGINE;
@@ -69,7 +68,6 @@ errorret_t engineUpdate(void) {
errorChain(networkUpdate()); errorChain(networkUpdate());
#endif #endif
errorChain(saveUpdate()); errorChain(saveUpdate());
autoSaveUpdate();
timeUpdate(); timeUpdate();
inputUpdate(); inputUpdate();
consoleUpdate(); consoleUpdate();
-76
View File
@@ -1,76 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "event.h"
#include "assert/assert.h"
#include "util/memory.h"
void eventInit(
event_t *event,
eventcallback_t *callbacks,
void **users,
size_t size
) {
assertNotNull(event, "event must not be NULL");
assertNotNull((void *)callbacks, "callbacks must not be NULL");
assertTrue(size > 0, "size must be greater than 0");
event->callbacks = callbacks;
event->users = users;
event->size = size;
event->count = 0;
memoryZero(callbacks, sizeof(eventcallback_t) * size);
if(users) memoryZero(users, sizeof(void *) * size);
}
void eventSubscribe(event_t *event, eventcallback_t callback, void *user) {
assertNotNull(event, "event must not be NULL");
assertNotNull(callback, "callback must not be NULL");
// Ensure callback isn't already susbcribed
for(uint32_t i = 0; i < event->count; i++) {
if(event->callbacks[i] != callback) continue;
assertUnreachable("Callback already registered, cannot subscribe twice.");
}
assertTrue(event->count < event->size, "event subscriber capacity exceeded");
event->callbacks[event->count] = callback;
if(user) {
assertNotNull(event->users, "Cannot add user pointer.");
event->users[event->count] = user;
}
event->count++;
}
void eventUnsubscribe(event_t *event, eventcallback_t callback) {
assertNotNull(event, "event must not be NULL");
assertNotNull(callback, "callback must not be NULL");
for(uint32_t i = 0; i < event->count; i++) {
if(event->callbacks[i] != callback) continue;
uint32_t last = event->count - 1;
if(i != last) {
event->callbacks[i] = event->callbacks[last];
if(event->users) event->users[i] = event->users[last];
}
event->callbacks[last] = NULL;
if(event->users) event->users[last] = NULL;
event->count--;
return;
}
}
void eventInvoke(const event_t *event, void *params) {
assertNotNull(event, "event must not be NULL");
for(uint32_t i = 0; i < event->count; i++) {
void *u = event->users ? event->users[i] : NULL;
event->callbacks[i](params, u);
}
}
-64
View File
@@ -1,64 +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"
typedef void (*eventcallback_t)(void *params, void *user);
typedef struct {
eventcallback_t *callbacks;
void **users;
size_t size;
uint32_t count;
} event_t;
/**
* Initializes an event, binding it to the provided backing arrays and clearing
* all subscribers. May also be called to reset an event (re-clears subscribers
* without changing the backing arrays or size).
*
* @param event The event to initialize.
* @param callbacks Caller-owned array of at least `size` callback slots.
* @param users Array of user pointers, matching each callback, or NULL.
* @param size Capacity of both arrays, must match.
*/
void eventInit(
event_t *event,
eventcallback_t *callbacks,
void **users,
size_t size
);
/**
* Subscribes a callback to an event. The callback is invoked with params and
* the provided user pointer each time the event fires. The same (callback,
* user) pair may only be subscribed once.
*
* @param event The event to subscribe to.
* @param callback The function to call when the event fires.
* @param user Arbitrary pointer forwarded to the callback unchanged.
*/
void eventSubscribe(event_t *event, eventcallback_t callback, void *user);
/**
* Removes a previously subscribed (callback, user) pair. Does nothing if the
* pair is not currently subscribed.
*
* @param event The event to unsubscribe from.
* @param callback The callback that was passed to eventSubscribe.
*/
void eventUnsubscribe(event_t *event, eventcallback_t callback);
/**
* Invokes all subscribed callbacks, passing params and each subscriber's user
* pointer.
*
* @param event The event to invoke.
* @param params Arbitrary pointer forwarded to every callback unchanged.
*/
void eventInvoke(const event_t *event, void *params);
-1
View File
@@ -11,7 +11,6 @@
#include "util/string.h" #include "util/string.h"
#include "util/math.h" #include "util/math.h"
#include "time/time.h" #include "time/time.h"
#include "event/event.h"
input_t INPUT; input_t INPUT;
+17 -3
View File
@@ -13,17 +13,31 @@ typedef struct {
const char_t *file; const char_t *file;
} localeinfo_t; } localeinfo_t;
static const localeinfo_t LOCALE_EN_US = { static const localeinfo_t LOCALE_INFO_EN_US = {
.name = "en-US", .name = "en-US",
.file = "locale/en_US.po", .file = "locale/en_US.po",
}; };
static const localeinfo_t LOCALE_JP_JP = { static const localeinfo_t LOCALE_INFO_JP_JP = {
.name = "ja-JP", .name = "ja-JP",
.file = "locale/jp_JP.po", .file = "locale/jp_JP.po",
}; };
static const localeinfo_t LOCALE_ES_MX = { static const localeinfo_t LOCALE_INFO_ES_MX = {
.name = "es-MX", .name = "es-MX",
.file = "locale/es_MX.po", .file = "locale/es_MX.po",
}; };
static const localeinfo_t * const LOCALE_INFO_LIST[] = {
&LOCALE_INFO_EN_US,
&LOCALE_INFO_JP_JP,
&LOCALE_INFO_ES_MX
};
#define LOCALE_INFO_LIST_COUNT ( \
sizeof(LOCALE_INFO_LIST) / sizeof(LOCALE_INFO_LIST[0]) \
)
#define LOCALE_DEFAULT LOCALE_INFO_EN_US
// EOF
+12 -26
View File
@@ -9,40 +9,23 @@
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h" #include "util/string.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "save/save.h" #include "ui/ui.h"
#include "system/system.h"
#include "console/console.h"
localemanager_t LOCALE; localemanager_t LOCALE;
const localeinfo_t * const LOCALE_LIST[LOCALE_LIST_COUNT] = {
&LOCALE_EN_US,
&LOCALE_JP_JP,
&LOCALE_ES_MX
};
errorret_t localeManagerInit() { errorret_t localeManagerInit() {
memoryZero(&LOCALE, sizeof(localemanager_t)); memoryZero(&LOCALE, sizeof(localemanager_t));
// saveInit() runs before this (see engine.c) and, on most platforms,
// has already loaded a persisted language choice into savemeta_t by // TODO: Set locale based on system locale.
// this point - see localeManagerGetByIndex(). const localeinfo_t *locale = systemGetLocale();
errorChain(localeManagerSetLocale( errorChain(localeManagerSetLocale(locale));
localeManagerGetByIndex(saveGetMeta()->language) consolePrint("Locale set to: %s", locale->name);
));
errorOk(); errorOk();
} }
uint8_t localeManagerGetIndex(const localeinfo_t *locale) {
assertNotNull(locale, "Locale cannot be NULL");
for(uint8_t i = 0; i < LOCALE_LIST_COUNT; i++) {
if(stringCompare(LOCALE_LIST[i]->file, locale->file) == 0) return i;
}
return 0;
}
const localeinfo_t * localeManagerGetByIndex(const uint8_t index) {
if(index >= LOCALE_LIST_COUNT) return LOCALE_LIST[0];
return LOCALE_LIST[index];
}
errorret_t localeManagerSetLocale(const localeinfo_t *locale) { errorret_t localeManagerSetLocale(const localeinfo_t *locale) {
assertNotNull(locale, "Locale cannot be NULL"); assertNotNull(locale, "Locale cannot be NULL");
@@ -56,6 +39,9 @@ errorret_t localeManagerSetLocale(const localeinfo_t *locale) {
assetEntryLock(LOCALE.entry); assetEntryLock(LOCALE.entry);
errorChain(assetRequireLoaded(LOCALE.entry)); errorChain(assetRequireLoaded(LOCALE.entry));
// TODO : Trigger UI update.
errorChain(uiUpdateTranslations());
errorOk(); errorOk();
} }
+1 -31
View File
@@ -7,7 +7,7 @@
#pragma once #pragma once
#include "error/error.h" #include "error/error.h"
#include "localemanager.h" #include "locale/localemanager.h"
#include "locale/localeinfo.h" #include "locale/localeinfo.h"
#include "asset/asset.h" #include "asset/asset.h"
@@ -18,15 +18,6 @@ typedef struct {
extern localemanager_t LOCALE; extern localemanager_t LOCALE;
/**
* Every locale the game supports, in a fixed, stable order - index into
* this array is what gets persisted as savemeta_t.language (see
* save/savemeta.h), so the order here must never change once shipped
* (only append new locales at the end).
*/
#define LOCALE_LIST_COUNT 3
extern const localeinfo_t * const LOCALE_LIST[LOCALE_LIST_COUNT];
/** /**
* Initialize the locale system. * Initialize the locale system.
* *
@@ -42,27 +33,6 @@ errorret_t localeManagerInit();
*/ */
errorret_t localeManagerSetLocale(const localeinfo_t *locale); errorret_t localeManagerSetLocale(const localeinfo_t *locale);
/**
* Finds the LOCALE_LIST index matching the given locale's file, by
* content rather than pointer identity (localeinfo_t instances are
* declared `static const` in a header, so the same locale gets a
* distinct pointer in every translation unit that references it).
*
* @param locale The locale to find.
* @return The matching index in LOCALE_LIST, or 0 if not found.
*/
uint8_t localeManagerGetIndex(const localeinfo_t *locale);
/**
* Gets the locale at the given LOCALE_LIST index, clamping to index 0
* (LOCALE_EN_US) if out of range - e.g. a save file's persisted language
* index from a future build with more locales than this one supports.
*
* @param index The index to look up.
* @return The locale at that index, or LOCALE_LIST[0] if out of range.
*/
const localeinfo_t * localeManagerGetByIndex(const uint8_t index);
/** /**
* Get a localized string for the given message ID. * Get a localized string for the given message ID.
* *
+137
View File
@@ -53,6 +53,25 @@ typedef struct cutscene_s {
#define CUTSCENE_WAIT(WAIT) \ #define CUTSCENE_WAIT(WAIT) \
{ .type = CUTSCENE_ITEM_TYPE_WAIT, .wait = WAIT } { .type = CUTSCENE_ITEM_TYPE_WAIT, .wait = WAIT }
// A named, otherwise no-op position in the item list that cutsceneGoTo
// can jump execution straight to. NAME is matched with stringEquals,
// not pointer identity, so it's safe to use separate string literals
// with the same contents at the marker and at each call site.
#define CUTSCENE_MARKER(NAME) \
{ .type = CUTSCENE_ITEM_TYPE_MARKER, .marker = { .name = NAME } }
// Restarts the currently running cutscene from its first item,
// preserving whatever interact/interacted entities triggered it.
#define CUTSCENE_RESTART() \
{ .type = CUTSCENE_ITEM_TYPE_RESTART }
// Requests a switch to a different SCENE_TYPE via sceneSet, then
// immediately continues on to whatever follows this item - the switch
// itself doesn't happen until the next sceneUpdate() tick, so it does
// not take effect this frame.
#define CUTSCENE_SCENE(TYPE) \
{ .type = CUTSCENE_ITEM_TYPE_SCENE, .sceneChange = { .type = TYPE } }
#define CUTSCENE_CUTSCENE(CUTSCENE) \ #define CUTSCENE_CUTSCENE(CUTSCENE) \
{ \ { \
.type = CUTSCENE_ITEM_TYPE_CUTSCENE, \ .type = CUTSCENE_ITEM_TYPE_CUTSCENE, \
@@ -62,6 +81,124 @@ typedef struct cutscene_s {
#define CUTSCENE_CALLBACK(CALLBACK) \ #define CUTSCENE_CALLBACK(CALLBACK) \
{ .type = CUTSCENE_ITEM_TYPE_CALLBACK, .callback = CALLBACK } { .type = CUTSCENE_ITEM_TYPE_CALLBACK, .callback = CALLBACK }
#define CUTSCENE_PRINT(TEXT) \
{ .type = CUTSCENE_ITEM_TYPE_PRINT, .print = { .text = TEXT } }
// Shows a message-only modal (no option buttons) and immediately
// continues on to whatever follows this item - it does not wait for
// the dialog to be dismissed. Script the rest of the interaction (e.g.
// CUTSCENE_CALLBACK to kick off work, CUTSCENE_WAIT, then
// CUTSCENE_MODAL_CLOSE) as later items in the same cutscene.
// TITLE and MESSAGE are each displayed as-is unless they match a
// locale message ID, in which case the translated string is shown
// instead - see uiModalLocalize.
#define CUTSCENE_MODAL(TITLE, MESSAGE) \
{ \
.type = CUTSCENE_ITEM_TYPE_MODAL, \
.modal = { .title = TITLE, .message = MESSAGE } \
}
// Shows a modal with option buttons and immediately continues on, same
// as CUTSCENE_MODAL - it does not block waiting for a selection.
// Back/cancel input is disabled while it's open (see
// uiMenuSetDisableBack), so it must be dismissed by picking one; CALLBACK
// then fires with the selected option index once the dialog closes.
// Option labels are passed as trailing arguments, e.g.
// CUTSCENE_MODAL_OPTIONS(title, message, callback, "Retry", "Cancel") -
// their strings are not copied by this item, so they must stay valid
// until the modal opens (string literals are fine). Like TITLE and
// MESSAGE, each option is translated if it matches a locale message ID.
#define CUTSCENE_MODAL_OPTIONS(TITLE, MESSAGE, CALLBACK, ...) \
{ \
.type = CUTSCENE_ITEM_TYPE_MODAL, \
.modal = { \
.title = TITLE, .message = MESSAGE, \
.options = (const char_t *[]){ __VA_ARGS__ }, \
.optionCount = (uint8_t)( \
sizeof((const char_t *[]){ __VA_ARGS__ }) / sizeof(const char_t *) \
), \
.callback = CALLBACK \
} \
}
// Same as CUTSCENE_MODAL_OPTIONS, but fixed to exactly one option and
// MARKER1 to jump straight to via cutsceneGoTo once it's selected - no
// callback function to write. Does not fall through to whatever follows
// this item, so MARKER1 must be scripted elsewhere in the same cutscene.
#define CUTSCENE_MODAL_OPTIONS_ONE(TITLE, MESSAGE, OPTION1, MARKER1) \
{ \
.type = CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS, \
.modalOptionsMarkers = { \
.title = TITLE, .message = MESSAGE, \
.options = { OPTION1 }, \
.markers = { MARKER1 }, \
.optionCount = 1 \
} \
}
// Same as CUTSCENE_MODAL_OPTIONS, but fixed to exactly two options,
// jumping straight to OPTION1_MARKER or OPTION2_MARKER via cutsceneGoTo
// once the corresponding option is selected - no callback function to
// write. Does not fall through to whatever follows this item, so both
// markers must be scripted elsewhere in the same cutscene.
#define CUTSCENE_MODAL_OPTIONS_TWO( \
TITLE, MESSAGE, OPTION1, OPTION1_MARKER, OPTION2, OPTION2_MARKER \
) \
{ \
.type = CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS, \
.modalOptionsMarkers = { \
.title = TITLE, .message = MESSAGE, \
.options = { OPTION1, OPTION2 }, \
.markers = { OPTION1_MARKER, OPTION2_MARKER }, \
.optionCount = 2 \
} \
}
// Closes the currently open modal (if any). Useful when a modal was
// opened outside of a blocking CUTSCENE_MODAL item (e.g. directly via
// uiModalOpen) and this cutscene just needs to dismiss it and continue
// on to whatever follows this item in the sequence.
#define CUTSCENE_MODAL_CLOSE() \
{ .type = CUTSCENE_ITEM_TYPE_MODAL_CLOSE }
// Opens the on-screen keyboard and blocks the cutscene until it closes,
// then caches whatever was typed - see cutsceneSystemGetTextCache to
// read it back afterwards. __VA_ARGS__ are uikeyboardopen_t designated
// initializers, e.g. CUTSCENE_KEYBOARD(.cancel = true, .maxLength = 8).
#define CUTSCENE_KEYBOARD(...) \
{ \
.type = CUTSCENE_ITEM_TYPE_KEYBOARD, \
.keyboard = { .open = { __VA_ARGS__ } } \
}
// (Re)requests an available save device and jumps straight to
// SUCCESS_MARKER or FAILURE_MARKER once it resolves, same as a
// CUTSCENE_MODAL_OPTIONS callback - it does not fall through to
// whatever follows this item, so both markers must be scripted
// elsewhere in the same cutscene.
#define CUTSCENE_SAVE_DEVICE_CHECK(SUCCESS_MARKER, FAILURE_MARKER) \
{ \
.type = CUTSCENE_ITEM_TYPE_SAVE_DEVICE_CHECK, \
.saveDeviceCheck = { \
.successMarker = SUCCESS_MARKER, \
.failureMarker = FAILURE_MARKER \
} \
}
// (Re)loads every save slot via saveLoadAllSlots() and jumps straight to
// SUCCESS_MARKER or FAILURE_MARKER once it resolves, same shape as
// CUTSCENE_SAVE_DEVICE_CHECK - it does not fall through to whatever
// follows this item, so both markers must be scripted elsewhere in the
// same cutscene.
#define CUTSCENE_SAVE_LOAD_ALL_SLOTS(SUCCESS_MARKER, FAILURE_MARKER) \
{ \
.type = CUTSCENE_ITEM_TYPE_SAVE_LOAD_ALL_SLOTS, \
.saveLoadAllSlots = { \
.successMarker = SUCCESS_MARKER, \
.failureMarker = FAILURE_MARKER \
} \
}
#define CUTSCENE_ENTITY_WALK_TO(ENTITY_INDEX, X, Y, Z) \ #define CUTSCENE_ENTITY_WALK_TO(ENTITY_INDEX, X, Y, Z) \
{ \ { \
.type = CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO, \ .type = CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO, \
+94 -6
View File
@@ -8,6 +8,7 @@
#include "cutscenesystem.h" #include "cutscenesystem.h"
#include "rpg/entity/entity.h" #include "rpg/entity/entity.h"
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h"
#include "assert/assert.h" #include "assert/assert.h"
cutscenesystem_t CUTSCENE_SYSTEM; cutscenesystem_t CUTSCENE_SYSTEM;
@@ -16,11 +17,7 @@ void cutsceneSystemInit() {
memoryZero(&CUTSCENE_SYSTEM, sizeof(cutscenesystem_t)); memoryZero(&CUTSCENE_SYSTEM, sizeof(cutscenesystem_t));
} }
void cutsceneSystemStartCutscene(const cutscene_t *cutscene) { void cutsceneSystemPrepare(
cutsceneSystemStartCutsceneWith(cutscene, NULL, NULL);
}
void cutsceneSystemStartCutsceneWith(
const cutscene_t *cutscene, const cutscene_t *cutscene,
entity_t *interact, entity_t *interact,
entity_t *interacted entity_t *interacted
@@ -38,10 +35,49 @@ void cutsceneSystemStartCutsceneWith(
CUTSCENE_SYSTEM.entityLastRef = NULL; CUTSCENE_SYSTEM.entityLastRef = NULL;
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED; CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED; CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
CUTSCENE_SYSTEM.textCache[0] = '\0';
CUTSCENE_SYSTEM.currentItem = 0xFF;// Set to 0xFF so Next wraps to 0. CUTSCENE_SYSTEM.currentItem = 0xFF;// Set to 0xFF so Next wraps to 0.
CUTSCENE_SYSTEM.onComplete = NULL;
}
void cutsceneSystemStartCutscene(const cutscene_t *cutscene) {
cutsceneSystemStartCutsceneWith(cutscene, NULL, NULL);
}
void cutsceneSystemStartCutsceneWith(
const cutscene_t *cutscene,
entity_t *interact,
entity_t *interacted
) {
cutsceneSystemPrepare(cutscene, interact, interacted);
cutsceneSystemNext(); cutsceneSystemNext();
} }
void cutsceneSystemStartCutsceneAndGoToMarker(
const cutscene_t *cutscene,
const char_t *marker
) {
cutsceneSystemPrepare(cutscene, NULL, NULL);
cutsceneGoTo(marker);
}
void cutsceneRestart(void) {
assertNotNull(
CUTSCENE_SYSTEM.scene, "cutsceneRestart called with no cutscene running"
);
// A restart is the same logical run trying again (e.g. retrying a failed
// save-device check), not a fresh unrelated start, so it should not
// silently drop a completion callback the caller already armed.
cutscenecallback_t onComplete = CUTSCENE_SYSTEM.onComplete;
cutsceneSystemStartCutsceneWith(
CUTSCENE_SYSTEM.scene,
CUTSCENE_SYSTEM.entityInteract,
CUTSCENE_SYSTEM.entityInteracted
);
CUTSCENE_SYSTEM.onComplete = onComplete;
}
void cutsceneSystemUpdate() { void cutsceneSystemUpdate() {
if(CUTSCENE_SYSTEM.scene == NULL) return; if(CUTSCENE_SYSTEM.scene == NULL) return;
@@ -58,6 +94,13 @@ void cutsceneSystemNext() {
if( if(
CUTSCENE_SYSTEM.currentItem >= CUTSCENE_SYSTEM.scene->itemCount CUTSCENE_SYSTEM.currentItem >= CUTSCENE_SYSTEM.scene->itemCount
) { ) {
// Saved and cleared before firing so a callback that immediately
// starts another cutscene (or sets its own onComplete) isn't clobbered
// by this function's own cleanup running after it - same reentrancy
// hazard as uiFocusPop, see src/dusk/ui/focus/uifocus.c.
cutscenecallback_t onComplete = CUTSCENE_SYSTEM.onComplete;
void *userData = CUTSCENE_SYSTEM.userData;
CUTSCENE_SYSTEM.scene = NULL; CUTSCENE_SYSTEM.scene = NULL;
CUTSCENE_SYSTEM.currentItem = 0xFF; CUTSCENE_SYSTEM.currentItem = 0xFF;
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE; CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE;
@@ -66,7 +109,11 @@ void cutsceneSystemNext() {
CUTSCENE_SYSTEM.entityLastCreated = NULL; CUTSCENE_SYSTEM.entityLastCreated = NULL;
CUTSCENE_SYSTEM.entityLastRef = NULL; CUTSCENE_SYSTEM.entityLastRef = NULL;
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED; CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED; CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
CUTSCENE_SYSTEM.textCache[0] = '\0';
CUTSCENE_SYSTEM.onComplete = NULL;
if(onComplete != NULL) onComplete(userData);
return; return;
} }
@@ -76,6 +123,35 @@ void cutsceneSystemNext() {
cutsceneItemStart(item, &CUTSCENE_SYSTEM.data); cutsceneItemStart(item, &CUTSCENE_SYSTEM.data);
} }
void cutsceneSystemSetOnComplete(cutscenecallback_t onComplete) {
assertNotNull(
CUTSCENE_SYSTEM.scene,
"cutsceneSystemSetOnComplete called with no cutscene running"
);
CUTSCENE_SYSTEM.onComplete = onComplete;
}
void cutsceneGoTo(const char_t *name) {
assertNotNull(
CUTSCENE_SYSTEM.scene, "cutsceneGoTo called with no cutscene running"
);
for(uint8_t i = 0; i < CUTSCENE_SYSTEM.scene->itemCount; i++) {
const cutsceneitem_t *item = &CUTSCENE_SYSTEM.scene->items[i];
if(
item->type == CUTSCENE_ITEM_TYPE_MARKER &&
stringEquals(item->marker.name, name)
) {
CUTSCENE_SYSTEM.currentItem = i;
memoryZero(&CUTSCENE_SYSTEM.data, sizeof(CUTSCENE_SYSTEM.data));
cutsceneItemStart(item, &CUTSCENE_SYSTEM.data);
return;
}
}
assertTrue(false, "cutsceneGoTo: no marker found with that name");
}
const cutsceneitem_t * cutsceneSystemGetCurrentItem() { const cutsceneitem_t * cutsceneSystemGetCurrentItem() {
if(CUTSCENE_SYSTEM.scene == NULL) return NULL; if(CUTSCENE_SYSTEM.scene == NULL) return NULL;
@@ -144,6 +220,16 @@ uint8_t cutsceneSystemGetTextMiniId(const uint8_t index) {
return index; return index;
} }
const char_t * cutsceneSystemGetTextCache(void) {
return CUTSCENE_SYSTEM.textCache;
}
void cutsceneSystemSetTextCache(const char_t *text) {
stringCopy(
CUTSCENE_SYSTEM.textCache, text, CUTSCENE_TEXT_CACHE_MAX - 1
);
}
void cutsceneSystemDispose() { void cutsceneSystemDispose() {
CUTSCENE_SYSTEM.scene = NULL; CUTSCENE_SYSTEM.scene = NULL;
CUTSCENE_SYSTEM.currentItem = 0xFF; CUTSCENE_SYSTEM.currentItem = 0xFF;
@@ -154,4 +240,6 @@ void cutsceneSystemDispose() {
CUTSCENE_SYSTEM.entityLastRef = NULL; CUTSCENE_SYSTEM.entityLastRef = NULL;
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED; CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED; CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
CUTSCENE_SYSTEM.textCache[0] = '\0';
CUTSCENE_SYSTEM.onComplete = NULL;
} }
+88
View File
@@ -21,6 +21,11 @@ typedef struct entity_s entity_t;
// cutscene_t.dataSize. // cutscene_t.dataSize.
#define CUTSCENE_SYSTEM_SIZE_MAX 8192 #define CUTSCENE_SYSTEM_SIZE_MAX 8192
// Size of CUTSCENE_SYSTEM.textCache - keep >= UI_KEYBOARD_TEXT_MAX (see
// ui/dialog/keyboard/uikeyboard.h) so text entered via a
// CUTSCENE_ITEM_TYPE_KEYBOARD item is never truncated caching it here.
#define CUTSCENE_TEXT_CACHE_MAX 64
typedef struct { typedef struct {
const cutscene_t *scene; const cutscene_t *scene;
uint8_t currentItem; uint8_t currentItem;
@@ -32,12 +37,21 @@ typedef struct {
uint8_t areaLastCreated; uint8_t areaLastCreated;
uint8_t textMiniLastCreated; uint8_t textMiniLastCreated;
// Free-form text cache, e.g. holding whatever was last typed via a
// CUTSCENE_ITEM_TYPE_KEYBOARD item - see cutsceneSystemGetTextCache/
// cutsceneSystemSetTextCache. Not tied to any one item type; any item
// may read or write it.
char_t textCache[CUTSCENE_TEXT_CACHE_MAX];
// Data (used by the current item). // Data (used by the current item).
cutsceneitemdata_t data; cutsceneitemdata_t data;
// Custom user data for the running cutscene, sized per-scene by // Custom user data for the running cutscene, sized per-scene by
// cutscene_t.dataSize. // cutscene_t.dataSize.
uint8_t userData[CUTSCENE_SYSTEM_SIZE_MAX]; uint8_t userData[CUTSCENE_SYSTEM_SIZE_MAX];
// See cutsceneSystemSetOnComplete.
cutscenecallback_t onComplete;
} cutscenesystem_t; } cutscenesystem_t;
extern cutscenesystem_t CUTSCENE_SYSTEM; extern cutscenesystem_t CUTSCENE_SYSTEM;
@@ -67,6 +81,47 @@ void cutsceneSystemStartCutsceneWith(
entity_t *interacted entity_t *interacted
); );
/**
* Starts a cutscene with no bound entities, jumping straight to the
* CUTSCENE_MARKER item with the given name instead of running from the
* first item - as if cutsceneGoTo(marker) had been called immediately
* after cutsceneSystemStartCutscene. Asserts if no marker with that
* name exists in the cutscene.
*
* @param cutscene Pointer to the cutscene to start.
* @param marker Marker name to jump to, matched with stringEquals.
*/
void cutsceneSystemStartCutsceneAndGoToMarker(
const cutscene_t *cutscene,
const char_t *marker
);
/**
* Restarts the currently running cutscene from its first item,
* preserving whatever interact/interacted entities triggered it.
* Asserts if no cutscene is running.
*/
void cutsceneRestart(void);
/**
* Sets a native callback to fire once when the currently running cutscene
* finishes by running off the end of its item list. A fresh
* cutsceneSystemStartCutscene* call clears any previously set callback, so
* call this again after starting a new cutscene to arm it - but
* cutsceneRestart() preserves whatever was armed, since a restart (e.g.
* retrying a failed check) is the same logical run trying again, not a new
* one. Invoked with CUTSCENE_SYSTEM.userData, same as CUTSCENE_CALLBACK.
*
* Exists so a runtime-loaded cutscene file (which can't store a native
* function pointer) can still hand off to native code once it's done,
* without needing a whole name->function registry: the file just ends
* normally, and whoever started it supplies what happens next.
*
* @param onComplete Callback to fire on natural completion. May be NULL
* to clear a previously set one.
*/
void cutsceneSystemSetOnComplete(cutscenecallback_t onComplete);
/** /**
* Resolves a raw entity index (or sentinel) to an entity pointer. * Resolves a raw entity index (or sentinel) to an entity pointer.
* Handles CUTSCENE_ENTITY_INTERACT, CUTSCENE_ENTITY_INTERACTED, * Handles CUTSCENE_ENTITY_INTERACT, CUTSCENE_ENTITY_INTERACTED,
@@ -97,11 +152,44 @@ uint8_t cutsceneSystemGetAreaId(const uint8_t areaId);
*/ */
uint8_t cutsceneSystemGetTextMiniId(const uint8_t index); uint8_t cutsceneSystemGetTextMiniId(const uint8_t index);
/**
* Returns CUTSCENE_SYSTEM.textCache - whatever was last written there via
* cutsceneSystemSetTextCache (e.g. by a CUTSCENE_ITEM_TYPE_KEYBOARD item
* once its keyboard closes). Empty ("") if nothing has been cached yet
* for the running cutscene.
*
* @returns The cached text.
*/
const char_t * cutsceneSystemGetTextCache(void);
/**
* Overwrites CUTSCENE_SYSTEM.textCache with a copy of text. Any item may
* call this - it isn't tied to any one item type - so later items can
* read back whatever the caller wants to pass along, up to
* CUTSCENE_TEXT_CACHE_MAX - 1 characters.
*
* @param text The text to cache; copied internally, safe to be
* transient. Must not exceed CUTSCENE_TEXT_CACHE_MAX - 1 characters.
*/
void cutsceneSystemSetTextCache(const char_t *text);
/** /**
* Advance to the next item in the cutscene. * Advance to the next item in the cutscene.
*/ */
void cutsceneSystemNext(); void cutsceneSystemNext();
/**
* Jumps the running cutscene directly to the CUTSCENE_MARKER item with
* the given name and starts it immediately, as if cutsceneSystemNext()
* had advanced straight to it. Intended to be called from within
* another item's start/update (e.g. a CUTSCENE_CALLBACK) to implement
* flow control. Asserts if no cutscene is running or no marker with
* that name exists in it.
*
* @param name Marker name to search for, matched with stringEquals.
*/
void cutsceneGoTo(const char_t *name);
/** /**
* Update the cutscene system for one frame. * Update the cutscene system for one frame.
*/ */
@@ -7,6 +7,7 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
cutsceneitem.c cutsceneitem.c
cutscenecallback.c cutscenecallback.c
cutsceneprint.c
) )
add_subdirectory(control) add_subdirectory(control)
@@ -15,3 +16,4 @@ add_subdirectory(item)
add_subdirectory(maparea) add_subdirectory(maparea)
add_subdirectory(ui) add_subdirectory(ui)
add_subdirectory(battle) add_subdirectory(battle)
add_subdirectory(save)
@@ -8,4 +8,7 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
cutscenewait.c cutscenewait.c
cutscenesetpause.c cutscenesetpause.c
cutsceneconcurrent.c cutsceneconcurrent.c
cutscenemarker.c
cutscenerestart.c
cutscenescene.c
) )
@@ -0,0 +1,21 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
void cutsceneMarkerStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
}
bool_t cutsceneMarkerUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
@@ -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 "dusk.h"
typedef struct cutsceneitem_s cutsceneitem_t;
typedef union cutsceneitemdata_u cutsceneitemdata_t;
typedef struct {
const char_t *name;
} cutscenemarker_t;
/**
* Starts a marker item. A marker does nothing on its own - it exists
* purely as a named position for cutsceneGoTo to jump to.
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneMarkerStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates a marker item (always completes immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneMarkerUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
void cutsceneRestartStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
cutsceneRestart();
}
bool_t cutsceneRestartUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return false;
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
typedef struct cutsceneitem_s cutsceneitem_t;
typedef union cutsceneitemdata_u cutsceneitemdata_t;
/**
* Starts a restart item (restarts the currently running cutscene from
* its first item via cutsceneRestart).
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneRestartStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates a restart item. By the time this would run, the cutscene has
* already restarted from its first item, so this always reports
* incomplete (mirrors cutsceneCutsceneUpdate).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns false always.
*/
bool_t cutsceneRestartUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "scene/scene.h"
void cutsceneSceneStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
sceneSet(item->sceneChange.type);
}
bool_t cutsceneSceneUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
#include "scene/scenetype.h"
typedef struct cutsceneitem_s cutsceneitem_t;
typedef union cutsceneitemdata_u cutsceneitemdata_t;
typedef struct {
scenetype_t type;
} cutscenescene_t;
/**
* Starts a scene item (requests a switch to the given scene via
* sceneSet). The switch itself doesn't happen until the next
* sceneUpdate() tick, so the rest of the current frame - including
* whatever else this cutscene does after this item - still runs
* against the scene that's being left.
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneSceneStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates a scene item (always completes immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneSceneUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
+50
View File
@@ -127,6 +127,56 @@ cutsceneitemcallbacks_t CUTSCENE_ITEM_CALLBACKS[CUTSCENE_ITEM_TYPE_COUNT] = {
[CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION] = { [CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION] = {
.init = cutsceneBattleForceActionStart, .init = cutsceneBattleForceActionStart,
.update = cutsceneBattleForceActionUpdate .update = cutsceneBattleForceActionUpdate
},
[CUTSCENE_ITEM_TYPE_MODAL] = {
.init = cutsceneModalStart,
.update = cutsceneModalUpdate
},
[CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS] = {
.init = cutsceneModalOptionsMarkersStart,
.update = cutsceneModalOptionsMarkersUpdate
},
[CUTSCENE_ITEM_TYPE_MODAL_CLOSE] = {
.init = cutsceneModalCloseStart,
.update = cutsceneModalCloseUpdate
},
[CUTSCENE_ITEM_TYPE_PRINT] = {
.init = cutscenePrintStart,
.update = cutscenePrintUpdate
},
[CUTSCENE_ITEM_TYPE_MARKER] = {
.init = cutsceneMarkerStart,
.update = cutsceneMarkerUpdate
},
[CUTSCENE_ITEM_TYPE_RESTART] = {
.init = cutsceneRestartStart,
.update = cutsceneRestartUpdate
},
[CUTSCENE_ITEM_TYPE_SCENE] = {
.init = cutsceneSceneStart,
.update = cutsceneSceneUpdate
},
[CUTSCENE_ITEM_TYPE_SAVE_DEVICE_CHECK] = {
.init = cutsceneSaveDeviceCheckStart,
.update = cutsceneSaveDeviceCheckUpdate
},
[CUTSCENE_ITEM_TYPE_SAVE_LOAD_ALL_SLOTS] = {
.init = cutsceneSaveLoadAllSlotsStart,
.update = cutsceneSaveLoadAllSlotsUpdate
},
[CUTSCENE_ITEM_TYPE_KEYBOARD] = {
.init = cutsceneKeyboardStart,
.update = cutsceneKeyboardUpdate
} }
}; };
+27
View File
@@ -7,9 +7,13 @@
#pragma once #pragma once
#include "cutscenecallback.h" #include "cutscenecallback.h"
#include "cutsceneprint.h"
#include "control/cutscenewait.h" #include "control/cutscenewait.h"
#include "control/cutscenesetpause.h" #include "control/cutscenesetpause.h"
#include "control/cutsceneconcurrent.h" #include "control/cutsceneconcurrent.h"
#include "control/cutscenemarker.h"
#include "control/cutscenerestart.h"
#include "control/cutscenescene.h"
#include "entity/cutsceneentityteleport.h" #include "entity/cutsceneentityteleport.h"
#include "entity/cutsceneentitywalkto.h" #include "entity/cutsceneentitywalkto.h"
#include "entity/cutsceneentityremove.h" #include "entity/cutsceneentityremove.h"
@@ -22,6 +26,9 @@
#include "ui/cutscenefade.h" #include "ui/cutscenefade.h"
#include "ui/cutsceneemoji.h" #include "ui/cutsceneemoji.h"
#include "ui/cutsceneshake.h" #include "ui/cutsceneshake.h"
#include "ui/cutscenemodal.h"
#include "ui/cutscenemodaloptionsmarkers.h"
#include "ui/cutscenekeyboard.h"
#include "item/cutsceneitemgive.h" #include "item/cutsceneitemgive.h"
#include "maparea/cutscenemapareaadd.h" #include "maparea/cutscenemapareaadd.h"
#include "maparea/cutscenemaparearemove.h" #include "maparea/cutscenemaparearemove.h"
@@ -29,6 +36,8 @@
#include "battle/cutscenestartbattle.h" #include "battle/cutscenestartbattle.h"
#include "battle/cutscenebattlewaitstate.h" #include "battle/cutscenebattlewaitstate.h"
#include "battle/cutscenebattleforceaction.h" #include "battle/cutscenebattleforceaction.h"
#include "save/cutscenesavedevicecheck.h"
#include "save/cutscenesaveloadallslots.h"
typedef struct cutscene_s cutscene_t; typedef struct cutscene_s cutscene_t;
@@ -59,6 +68,16 @@ typedef enum {
CUTSCENE_ITEM_TYPE_SHAKE, CUTSCENE_ITEM_TYPE_SHAKE,
CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE, CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE,
CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION, CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION,
CUTSCENE_ITEM_TYPE_MODAL,
CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS,
CUTSCENE_ITEM_TYPE_MODAL_CLOSE,
CUTSCENE_ITEM_TYPE_PRINT,
CUTSCENE_ITEM_TYPE_MARKER,
CUTSCENE_ITEM_TYPE_RESTART,
CUTSCENE_ITEM_TYPE_SCENE,
CUTSCENE_ITEM_TYPE_SAVE_DEVICE_CHECK,
CUTSCENE_ITEM_TYPE_SAVE_LOAD_ALL_SLOTS,
CUTSCENE_ITEM_TYPE_KEYBOARD,
CUTSCENE_ITEM_TYPE_COUNT CUTSCENE_ITEM_TYPE_COUNT
} cutsceneitemtype_t; } cutsceneitemtype_t;
@@ -91,6 +110,14 @@ struct cutsceneitem_s {
cutsceneshake_t shake; cutsceneshake_t shake;
cutscenebattlewaitstate_t battleWaitState; cutscenebattlewaitstate_t battleWaitState;
cutscenebattleforceaction_t battleForceAction; cutscenebattleforceaction_t battleForceAction;
cutscenemodal_t modal;
cutscenemodaloptionsmarkers_t modalOptionsMarkers;
cutsceneprint_t print;
cutscenemarker_t marker;
cutscenescene_t sceneChange;
cutscenesavedevicecheck_t saveDeviceCheck;
cutscenesaveloadallslots_t saveLoadAllSlots;
cutscenekeyboard_t keyboard;
}; };
}; };
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "cutsceneitem.h"
#include "console/console.h"
void cutscenePrintStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
consolePrint("%s", item->print.text);
}
bool_t cutscenePrintUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
typedef struct cutsceneitem_s cutsceneitem_t;
typedef union cutsceneitemdata_u cutsceneitemdata_t;
#define CUTSCENE_PRINT_MAX_CHARS 128
typedef struct {
char_t text[CUTSCENE_PRINT_MAX_CHARS];
} cutsceneprint_t;
/**
* Starts a print item (prints the item's text to the console).
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutscenePrintStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates a print item (always completes immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutscenePrintUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
@@ -5,9 +5,6 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
uisettings.c cutscenesavedevicecheck.c
uisettingsgeneral.c cutscenesaveloadallslots.c
uisettingsinput.c
uisettingsdisplay.c
uisettingsaudio.c
) )
@@ -0,0 +1,33 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "save/save.h"
void cutsceneSaveDeviceCheckCallback(savedevice_t *device, void *user) {
const cutsceneitem_t *item = cutsceneSystemGetCurrentItem();
cutsceneGoTo(
device != NULL ?
item->saveDeviceCheck.successMarker :
item->saveDeviceCheck.failureMarker
);
}
void cutsceneSaveDeviceCheckStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
saveFindAvailableDevice(cutsceneSaveDeviceCheckCallback, NULL);
}
bool_t cutsceneSaveDeviceCheckUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return false;
}
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
typedef struct cutsceneitem_s cutsceneitem_t;
typedef union cutsceneitemdata_u cutsceneitemdata_t;
typedef struct {
const char_t *successMarker;
const char_t *failureMarker;
} cutscenesavedevicecheck_t;
/**
* Starts a save-device-check item: (re)requests an available save
* device via saveFindAvailableDevice. Never completes on its own - see
* cutsceneSaveDeviceCheckUpdate.
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneSaveDeviceCheckStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates a save-device-check item. Like a CUTSCENE_MODAL_OPTIONS item,
* this never completes on its own - once saveFindAvailableDevice's
* callback fires, it jumps straight to the item's successMarker or
* failureMarker via cutsceneGoTo.
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns false always.
*/
bool_t cutsceneSaveDeviceCheckUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "save/save.h"
void cutsceneSaveLoadAllSlotsStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
errorret_t result = saveLoadAllSlots();
if(errorIsNotOk(result)) {
errorCatch(errorPrint(result));
cutsceneGoTo(item->saveLoadAllSlots.failureMarker);
return;
}
cutsceneGoTo(item->saveLoadAllSlots.successMarker);
}
bool_t cutsceneSaveLoadAllSlotsUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return false;
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
typedef struct cutsceneitem_s cutsceneitem_t;
typedef union cutsceneitemdata_u cutsceneitemdata_t;
typedef struct {
const char_t *successMarker;
const char_t *failureMarker;
} cutscenesaveloadallslots_t;
/**
* Starts a save-load-all-slots item: calls saveLoadAllSlots() and jumps
* straight to the item's successMarker or failureMarker via
* cutsceneGoTo, depending on the result.
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneSaveLoadAllSlotsStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates a save-load-all-slots item. Never completes on its own -
* Start already jumps to whichever marker applies before this would
* ever run.
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns false always.
*/
bool_t cutsceneSaveLoadAllSlotsUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
@@ -11,4 +11,7 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
cutscenefade.c cutscenefade.c
cutsceneemoji.c cutsceneemoji.c
cutsceneshake.c cutsceneshake.c
cutscenemodal.c
cutscenemodaloptionsmarkers.c
cutscenekeyboard.c
) )
@@ -0,0 +1,27 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "ui/dialog/keyboard/uikeyboard.h"
void cutsceneKeyboardStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
uiKeyboardOpen(&item->keyboard.open);
}
bool_t cutsceneKeyboardUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
if(uiKeyboardIsOpen()) return false;
cutsceneSystemSetTextCache(UI_KEYBOARD.text);
return true;
}
@@ -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"
#include "ui/dialog/keyboard/uikeyboard.h"
typedef struct cutsceneitem_s cutsceneitem_t;
typedef union cutsceneitemdata_u cutsceneitemdata_t;
typedef struct {
// Passed straight through to uiKeyboardOpen - see uikeyboardopen_t.
uikeyboardopen_t open;
} cutscenekeyboard_t;
/**
* Starts a keyboard item (opens the on-screen keyboard with the item's
* open parameters, unmodified - see uiKeyboardOpen).
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneKeyboardStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates a keyboard item. Blocks the cutscene while the keyboard is
* open; once it closes (confirmed or cancelled), caches its resulting
* text via cutsceneSystemSetTextCache (see cutsceneSystemGetTextCache)
* before completing, regardless of whether confirm or cancel was
* picked - check UI_KEYBOARD.confirmed from the item's own onInput
* callback if that distinction matters.
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true once the keyboard has closed; false while still open.
*/
bool_t cutsceneKeyboardUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "assert/assert.h"
#include "ui/widget/uimodal.h"
void cutsceneModalStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
const cutscenemodal_t *modal = &item->modal;
assertTrue(
modal->optionCount <= CUTSCENE_MODAL_OPTIONS_MAX,
"Too many options for cutscene modal"
);
uiModalOpen(
modal->title,
modal->message,
modal->options,
modal->optionCount,
modal->callback,
NULL,
CUTSCENE_SYSTEM.userData
);
}
bool_t cutsceneModalUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return item->modal.optionCount == 0;
}
void cutsceneModalCloseStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
uiModalClose(NULL);
}
bool_t cutsceneModalCloseUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
@@ -0,0 +1,109 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
typedef struct cutsceneitem_s cutsceneitem_t;
typedef union cutsceneitemdata_u cutsceneitemdata_t;
#define CUTSCENE_MODAL_TITLE_MAX_CHARS 64
#define CUTSCENE_MODAL_MESSAGE_MAX_CHARS 256
#define CUTSCENE_MODAL_OPTIONS_MAX 4
/**
* Callback invoked with the selected option once a cutscene modal
* item's dialog closes. Back/cancel input is disabled while it's open
* (see uiMenuSetDisableBack), so it must be dismissed by picking one.
*
* @param optionIndex Index into the options array that was selected.
* @param userData CUTSCENE_SYSTEM.userData for the running cutscene.
*/
typedef void (*cutscenemodaloptioncallback_t)(
const uint8_t optionIndex, void *userData
);
typedef struct {
// Display text, or a locale message ID to translate - see
// uiModalLocalize.
char_t title[CUTSCENE_MODAL_TITLE_MAX_CHARS];
char_t message[CUTSCENE_MODAL_MESSAGE_MAX_CHARS];
// NOT copied by this struct - these pointers are only read once,
// while uiModalOpen is opening the modal in cutsceneModalStart, so
// they only need to stay valid until then (e.g. string literals, as
// CUTSCENE_MODAL_OPTIONS produces). Each is displayed text or a
// locale message ID, same as title/message.
const char_t **options;
uint8_t optionCount;
cutscenemodaloptioncallback_t callback;
} cutscenemodal_t;
/**
* Starts a modal item (shows the modal dialog with the item's title,
* message, and options, wiring callback to fire with the selected
* option once it closes). optionCount may be 0 for a message-only
* dialog with no option buttons.
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneModalStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates a modal item. A message-only modal (optionCount == 0, e.g.
* from CUTSCENE_MODAL) always completes immediately - the cutscene
* continues on to whatever follows this item right away, it does not
* wait for the dialog to be dismissed. Script what should happen while
* it's up, and how it gets closed, as later items in the same cutscene
* (e.g. CUTSCENE_CALLBACK, CUTSCENE_WAIT, CUTSCENE_MODAL_CLOSE).
*
* A modal with options (optionCount > 0, e.g. from
* CUTSCENE_MODAL_OPTIONS) never completes on its own - the cutscene
* blocks here indefinitely. The option callback fires once the user
* picks something, and it alone is responsible for moving the cutscene
* on from there (typically via cutsceneGoTo or cutsceneRestart).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true once immediately for a message-only modal; false
* always for a modal with options.
*/
bool_t cutsceneModalUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Starts a modal-close item (closes the currently open modal, if any).
* Whatever should happen next belongs in the cutscene's own item
* sequence (e.g. a CUTSCENE_CALLBACK or CUTSCENE_PRINT placed right
* after this item), not on this item itself.
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneModalCloseStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates a modal-close item (always completes immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneModalCloseUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "ui/widget/uimodal.h"
void cutsceneModalOptionsMarkersCallback(
const uint8_t optionIndex,
void *userData
) {
const cutscenemodaloptionsmarkers_t *options =
&cutsceneSystemGetCurrentItem()->modalOptionsMarkers;
// uiModalOpen disables back/cancel for a dialog with options, so
// UI_MODAL_RESULT_NONE can't reach here via input - this fallback to
// the last option's marker only matters if something ever force-closes
// the modal directly (uiModalClose) before an option is picked.
const uint8_t index =
optionIndex < options->optionCount ? optionIndex : options->optionCount - 1;
cutsceneGoTo(options->markers[index]);
}
void cutsceneModalOptionsMarkersStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
const cutscenemodaloptionsmarkers_t *options = &item->modalOptionsMarkers;
uiModalOpen(
options->title,
options->message,
options->options,
options->optionCount,
cutsceneModalOptionsMarkersCallback,
NULL,
CUTSCENE_SYSTEM.userData
);
}
bool_t cutsceneModalOptionsMarkersUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return false;
}
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
typedef struct cutsceneitem_s cutsceneitem_t;
typedef union cutsceneitemdata_u cutsceneitemdata_t;
// Backs CUTSCENE_MODAL_OPTIONS_ONE and CUTSCENE_MODAL_OPTIONS_TWO, so two
// is the most either macro ever needs.
#define CUTSCENE_MODAL_OPTIONS_MARKERS_MAX 2
typedef struct {
// Display text, or a locale message ID to translate - see
// uiModalLocalize.
char_t title[CUTSCENE_MODAL_TITLE_MAX_CHARS];
char_t message[CUTSCENE_MODAL_MESSAGE_MAX_CHARS];
// Each option's display text/locale message ID, and the marker to
// cutsceneGoTo when that option is selected.
const char_t *options[CUTSCENE_MODAL_OPTIONS_MARKERS_MAX];
const char_t *markers[CUTSCENE_MODAL_OPTIONS_MARKERS_MAX];
uint8_t optionCount;
} cutscenemodaloptionsmarkers_t;
/**
* Starts a modal-options-markers item (shows the modal dialog with the
* item's title, message, and options).
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneModalOptionsMarkersStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates a modal-options-markers item. Never completes on its own -
* the cutscene blocks here indefinitely. Back/cancel input is disabled
* while it's open (see uiMenuSetDisableBack), so it must be dismissed
* by picking an option, which jumps straight to that option's marker
* via cutsceneGoTo.
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns false always.
*/
bool_t cutsceneModalOptionsMarkersUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
@@ -6,5 +6,4 @@
# Sources # Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
globalitemstore.c
) )
@@ -1,25 +0,0 @@
/**
* 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;
}
@@ -1,40 +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 "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
);
+1 -1
View File
@@ -11,7 +11,7 @@
#include "util/memory.h" #include "util/memory.h"
#include "time/time.h" #include "time/time.h"
#include "ui/focus/uifocus.h" #include "ui/focus/uifocus.h"
#include "ui/frame/game/uigamemenu.h" #include "ui/screen/game/uigamemenu.h"
#include "rpg/cutscene/cutscenesystem.h" #include "rpg/cutscene/cutscenesystem.h"
void playerInit(entity_t *entity) { void playerInit(entity_t *entity) {
+26 -17
View File
@@ -11,7 +11,6 @@
#include "asset/asset.h" #include "asset/asset.h"
#include "asset/loader/assetloader.h" #include "asset/loader/assetloader.h"
#include "console/console.h" #include "console/console.h"
#include "event/event.h"
#include "util/string.h" #include "util/string.h"
#include "rpg/entity/global/entityglobal.h" #include "rpg/entity/global/entityglobal.h"
#include "rpg/entity/item/entityitem.h" #include "rpg/entity/item/entityitem.h"
@@ -137,8 +136,10 @@ void mapChunkUnload(chunk_t *chunk) {
memorySet(chunk->areas, 0xFF, sizeof(chunk->areas)); memorySet(chunk->areas, 0xFF, sizeof(chunk->areas));
if(chunk->dcfEntry != NULL) { if(chunk->dcfEntry != NULL) {
eventUnsubscribe(&chunk->dcfEntry->onLoaded, mapChunkLoaded); chunk->dcfEntry->onLoaded = NULL;
eventUnsubscribe(&chunk->dcfEntry->onError, mapChunkLoadError); chunk->dcfEntry->onLoadedUser = NULL;
chunk->dcfEntry->onError = NULL;
chunk->dcfEntry->onErrorUser = NULL;
assetUnlockEntry(chunk->dcfEntry); assetUnlockEntry(chunk->dcfEntry);
chunk->dcfEntry = NULL; chunk->dcfEntry = NULL;
} }
@@ -159,8 +160,10 @@ errorret_t mapChunkLoad(chunk_t *chunk) {
mapChunkLoadingSlotClear(chunk); mapChunkLoadingSlotClear(chunk);
if(chunk->dcfEntry != NULL) { if(chunk->dcfEntry != NULL) {
eventUnsubscribe(&chunk->dcfEntry->onLoaded, mapChunkLoaded); chunk->dcfEntry->onLoaded = NULL;
eventUnsubscribe(&chunk->dcfEntry->onError, mapChunkLoadError); chunk->dcfEntry->onLoadedUser = NULL;
chunk->dcfEntry->onError = NULL;
chunk->dcfEntry->onErrorUser = NULL;
assetUnlockEntry(chunk->dcfEntry); assetUnlockEntry(chunk->dcfEntry);
chunk->dcfEntry = NULL; chunk->dcfEntry = NULL;
} }
@@ -244,8 +247,12 @@ void mapChunkLoadNext() {
continue; continue;
} }
eventSubscribe(&entry->onLoaded, mapChunkLoaded, chunk); assertNull(entry->onLoaded, "Entry already has an onLoaded subscriber.");
eventSubscribe(&entry->onError, mapChunkLoadError, chunk); assertNull(entry->onError, "Entry already has an onError subscriber.");
entry->onLoaded = mapChunkLoaded;
entry->onLoadedUser = chunk;
entry->onError = mapChunkLoadError;
entry->onErrorUser = chunk;
} }
} }
@@ -385,10 +392,9 @@ void mapRebuildChunkOrder() {
} }
} }
void mapChunkLoadError(void *params, void *user) { void mapChunkLoadError(assetentry_t *entry, void *user) {
assertNotNull(params, "mapChunkLoadError: params cannot be NULL"); assertNotNull(entry, "mapChunkLoadError: entry cannot be NULL");
assertNotNull(user, "mapChunkLoadError: user cannot be NULL"); assertNotNull(user, "mapChunkLoadError: user cannot be NULL");
assetentry_t *entry = (assetentry_t *)params;
chunk_t *chunk = (chunk_t *)user; chunk_t *chunk = (chunk_t *)user;
if(chunk->dcfEntry != entry) return; if(chunk->dcfEntry != entry) return;
consolePrint( consolePrint(
@@ -397,8 +403,10 @@ void mapChunkLoadError(void *params, void *user) {
(int32_t)chunk->position.y, (int32_t)chunk->position.y,
(int32_t)chunk->position.z (int32_t)chunk->position.z
); );
eventUnsubscribe(&entry->onLoaded, mapChunkLoaded); entry->onLoaded = NULL;
eventUnsubscribe(&entry->onError, mapChunkLoadError); entry->onLoadedUser = NULL;
entry->onError = NULL;
entry->onErrorUser = NULL;
assetUnlockEntry(chunk->dcfEntry); assetUnlockEntry(chunk->dcfEntry);
chunk->dcfEntry = NULL; chunk->dcfEntry = NULL;
memorySet(chunk->tiles, 0x00, sizeof(chunk->tiles)); memorySet(chunk->tiles, 0x00, sizeof(chunk->tiles));
@@ -407,10 +415,9 @@ void mapChunkLoadError(void *params, void *user) {
mapChunkLoadNext(); mapChunkLoadNext();
} }
void mapChunkLoaded(void *params, void *user) { void mapChunkLoaded(assetentry_t *entry, void *user) {
assertNotNull(params, "mapChunkLoaded: params cannot be NULL"); assertNotNull(entry, "mapChunkLoaded: entry cannot be NULL");
assertNotNull(user, "mapChunkLoaded: user cannot be NULL"); assertNotNull(user, "mapChunkLoaded: user cannot be NULL");
assetentry_t *entry = (assetentry_t *)params;
chunk_t *chunk = (chunk_t *)user; chunk_t *chunk = (chunk_t *)user;
if(chunk->dcfEntry != entry) return; if(chunk->dcfEntry != entry) return;
// consolePrint( // consolePrint(
@@ -457,8 +464,10 @@ void mapChunkLoaded(void *params, void *user) {
// modelEntries must still be intact for that next reuse to copy from. // modelEntries must still be intact for that next reuse to copy from.
chunk->modelEntries[m] = entry->data.chunk.modelEntries[m]; chunk->modelEntries[m] = entry->data.chunk.modelEntries[m];
} }
eventUnsubscribe(&entry->onLoaded, mapChunkLoaded); entry->onLoaded = NULL;
eventUnsubscribe(&entry->onError, mapChunkLoadError); entry->onLoadedUser = NULL;
entry->onError = NULL;
entry->onErrorUser = NULL;
// Deliberately keep chunk->dcfEntry locked and set - it is what keeps the // Deliberately keep chunk->dcfEntry locked and set - it is what keeps the
// chunk asset entry (and therefore its model locks) alive for as long as // chunk asset entry (and therefore its model locks) alive for as long as
// this chunk_t is displaying it. Released in mapChunkUnload instead. // this chunk_t is displaying it. Released in mapChunkUnload instead.
+4 -4
View File
@@ -101,19 +101,19 @@ void mapChunkLoadQueueRemove(chunk_t *chunk);
* chunk tiles with TILE_SHAPE_GROUND as a fallback. * chunk tiles with TILE_SHAPE_GROUND as a fallback.
* Always invoked on the main thread. * Always invoked on the main thread.
* *
* @param params The failed assetentry_t. * @param entry The failed assetentry_t.
* @param user The chunk_t that owns the entry. * @param user The chunk_t that owns the entry.
*/ */
void mapChunkLoadError(void *params, void *user); void mapChunkLoadError(assetentry_t *entry, void *user);
/** /**
* Callback invoked when a chunk DCF asset finishes loading. * Callback invoked when a chunk DCF asset finishes loading.
* Always invoked on the main thread. * Always invoked on the main thread.
* *
* @param params The loaded assetentry_t. * @param entry The loaded assetentry_t.
* @param user The chunk_t that owns the entry. * @param user The chunk_t that owns the entry.
*/ */
void mapChunkLoaded(void *params, void *user); void mapChunkLoaded(assetentry_t *entry, void *user);
/** /**
* Rebuilds chunkOrder from the loaded chunks that fall within the * Rebuilds chunkOrder from the loaded chunks that fall within the
+4 -25
View File
@@ -17,11 +17,8 @@
#include "rpgcamera.h" #include "rpgcamera.h"
#include "util/memory.h" #include "util/memory.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "save/save.h"
#include "save/autosave.h"
#include "error/error.h" #include "error/error.h"
#include "scene/scene.h" #include "scene/scene.h"
#include "ui/rpg/uiemoji.h" #include "ui/rpg/uiemoji.h"
#include "rpg/story/storyflag.h" #include "rpg/story/storyflag.h"
@@ -29,27 +26,14 @@ errorret_t rpgInit(void) {
memoryZero(ENTITIES, sizeof(ENTITIES)); memoryZero(ENTITIES, sizeof(ENTITIES));
memoryZero(MAP_AREAS, sizeof(MAP_AREAS)); 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(); backpackInit();
partyInit(); partyInit();
cutsceneSystemInit(); cutsceneSystemInit();
errorChain(mapInit()); errorChain(mapInit());
rpgCameraInit(); rpgCameraInit();
// Init world
// Init test world
errorChain(mapPositionSet((chunkpos_t){ 0, 0, 0 })); errorChain(mapPositionSet((chunkpos_t){ 0, 0, 0 }));
// The player is the one entity that isn't sourced from map/chunk data - // The player is the one entity that isn't sourced from map/chunk data -
@@ -68,6 +52,7 @@ errorret_t rpgInit(void) {
backpackAdd(ITEM_ID_POTATO, 3); backpackAdd(ITEM_ID_POTATO, 3);
backpackAdd(ITEM_ID_APPLE, 8); backpackAdd(ITEM_ID_APPLE, 8);
// All Good! // All Good!
errorOk(); errorOk();
} }
@@ -79,12 +64,6 @@ errorret_t rpgUpdate(void) {
} }
#endif #endif
// A failed autosave forces a no-save-device prompt (see autosave.h) -
// freeze the world entirely until the player resolves it, rather than
// letting NPCs/cutscenes/camera keep running behind a modal that's
// supposed to be blocking.
if(autoSaveIsBlocking()) errorOk();
// TODO: Do not update if the scene is not the map scene? // TODO: Do not update if the scene is not the map scene?
errorChain(mapUpdate()); errorChain(mapUpdate());
+9 -12
View File
@@ -7,20 +7,17 @@
#include "storyflag.h" #include "storyflag.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "console/console.h"
storyflagvalue_t storyFlagGet(const storyflag_t flag) {
assertTrue(flag > STORY_FLAG_NULL && flag < STORY_FLAG_COUNT, "Bad Flag");
consolePrint("Story flag get without save implementation");
return 0;
}
void storyFlagSet(const storyflag_t flag, const storyflagvalue_t value) { void storyFlagSet(const storyflag_t flag, const storyflagvalue_t value) {
assertTrue(flag > STORY_FLAG_NULL && flag < STORY_FLAG_COUNT, "Bad Flag"); assertTrue(flag > STORY_FLAG_NULL && flag < STORY_FLAG_COUNT, "Bad Flag");
saveGetSlot(SAVE_ACTIVE_SLOT)->storyFlags[flag] = value;
}
void storyFlagInitDefaults(saveslot_t *file) { // TODO: Dirty savefile
assertNotNull(file, "Save slot cannot be NULL"); consolePrint("Story flag set without save implementation");
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];
}
} }
+2 -16
View File
@@ -7,7 +7,6 @@
#pragma once #pragma once
#include "rpg/story/storyflagvalue.h" #include "rpg/story/storyflagvalue.h"
#include "save/save.h"
/** /**
* Gets the value of a story flag. Reads directly from the active save * Gets the value of a story flag. Reads directly from the active save
@@ -16,25 +15,12 @@
* @param flag The story flag to get. * @param flag The story flag to get.
* @return The value of the story flag. * @return The value of the story flag.
*/ */
#define storyFlagGet(flag) (saveGetSlot(SAVE_ACTIVE_SLOT)->storyFlags[(flag)]) storyflagvalue_t storyFlagGet(const storyflag_t flag);
/** /**
* Sets the value of a story flag, directly in the active save slot (see * Sets the value of a story flag. Will dirty the savefile.
* 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 flag The story flag to set.
* @param value The value to set the story flag to. * @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);
+3 -2
View File
@@ -7,6 +7,7 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
save.c save.c
savestream.c savedevice.c
autosave.c saveslot.c
savesettings.c
) )
-72
View File
@@ -1,72 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "autosave.h"
#include "save.h"
#include "error/error.h"
#include "ui/frame/initial/uiinitialnocard.h"
autosave_t AUTO_SAVE;
static void autoSaveNoCardResult(const bool_t retry, void *user) {
AUTO_SAVE.blocking = false;
// "Retry" just re-queues the write for the next tick - there's no way
// to force a re-probe of the hardware mid-session (see saveInit()'s doc
// comment), so this only actually helps for a transient failure.
if(retry) {
AUTO_SAVE.pending = true;
return;
}
// The player explicitly acknowledged there's no save device and chose
// to proceed anyway - sticks for the rest of the session, so future
// autoSaveUpdate() calls silently skip instead of prompting again.
saveMarkTemporary();
}
static void autoSaveWriteComplete(errorret_t result, void *user) {
AUTO_SAVE.saving = false;
if(errorIsOk(result)) return;
errorCatch(errorPrint(result));
// The write failed - most likely no save medium is present right now
// (a GameCube with no memory card inserted, for example). Pause world
// simulation and force the same prompt the initial boot scene uses so
// the player can insert a card and retry, or explicitly accept a
// temporary, unsaved session.
AUTO_SAVE.blocking = true;
uiInitialNoCardOpen(autoSaveNoCardResult, NULL);
}
void autoSaveQueue(void) {
AUTO_SAVE.pending = true;
}
void autoSaveUpdate(void) {
if(AUTO_SAVE.blocking) return;
if(!AUTO_SAVE.pending) return;
if(saveIsBusy()) return;
AUTO_SAVE.pending = false;
// Already opted out of saving for this session - nothing to do, and
// definitely don't reprompt every time an autosave is queued.
if(saveIsTemporary()) return;
AUTO_SAVE.saving = true;
saveWriteSlot(SAVE_ACTIVE_SLOT, autoSaveWriteComplete, NULL);
}
bool_t autoSaveIsSaving(void) {
return AUTO_SAVE.saving;
}
bool_t autoSaveIsBlocking(void) {
return AUTO_SAVE.blocking;
}
-57
View File
@@ -1,57 +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"
typedef struct {
/** True from autoSaveQueue() until the queued write starts. */
bool_t pending;
/** True while the queued write is actually in flight. */
bool_t saving;
/**
* True while the forced no-save-device prompt is up after a queued
* write failed - see autoSaveUpdate(). World simulation must pause
* while this is true (see rpgUpdate()).
*/
bool_t blocking;
} autosave_t;
extern autosave_t AUTO_SAVE;
/**
* Queues an autosave to run on a future engine tick. Safe to call as
* often as needed (e.g. after every map transition or story event) -
* repeated calls before the queued save starts just collapse into one.
*/
void autoSaveQueue(void);
/**
* Pumps the queued autosave: starts the write once nothing else is
* using the save system, and if that write fails (e.g. a GameCube with
* no memory card inserted), forces the same no-save-device prompt the
* initial boot scene uses and pauses world simulation until the player
* resolves it. Must be called every engine frame, after saveUpdate().
*/
void autoSaveUpdate(void);
/**
* True while a queued autosave's write is actually in progress. Intended
* for UI to show a "Saving" indicator.
*
* @return true if an autosave write is currently in flight.
*/
bool_t autoSaveIsSaving(void);
/**
* True while a failed autosave is forcing the no-save-device prompt and
* waiting on the player's decision. Intended for the world simulation to
* pause while this is true.
*
* @return true if an autosave is currently blocking on player input.
*/
bool_t autoSaveIsBlocking(void);
+201 -144
View File
@@ -5,184 +5,241 @@
* https://opensource.org/licenses/MIT * https://opensource.org/licenses/MIT
*/ */
#include "save/save.h" #include "save.h"
#include "save/savestream.h"
#include "util/memory.h" #include "util/memory.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "error/error.h"
save_t SAVE; save_t SAVE;
const saveslot_t SAVE_DEFAULT = { errorret_t saveInit() {
.header = {
SAVE_SLOT_HEADER[0], SAVE_SLOT_HEADER[1], SAVE_SLOT_HEADER[2]
},
.version = SAVE_SLOT_VERSION,
.exists = true,
.playerName = "Player"
// globalItemCollected/storyFlags are left at their zero default.
};
static void _saveEagerLoadComplete(errorret_t result, void *user) {
if(errorIsNotOk(result)) errorCatch(errorPrint(result));
}
errorret_t saveInit(void) {
memoryZero(&SAVE, sizeof(save_t)); memoryZero(&SAVE, sizeof(save_t));
SAVE.meta.deadzone = SAVE_META_DEADZONE_DEFAULT;
SAVE.meta.language = SAVE_META_LANGUAGE_DEFAULT;
#ifdef saveInitPlatform SAVE.deviceCurrent = 0xFF;// No current device.
// A missing/unreachable save medium is expected, recoverable state, SAVE.slotCurrent = 0xFF;// No current slot.
// 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 // Initialize the slots and settings
// harmless (nothing consumes loaded slot data automatically; there's no saveSettingsInit(&SAVE.settings);
// continue-game flow yet). PSP opts out entirely (see saveSlotInit(&SAVE.slot);
// saveSkipEagerLoadPlatform) since its only read path is now the native
// savedata dialog, and running that on every boot would defeat the whole // Update caches to match default data.
// point of folding meta into it instead of a separate instant-write file. for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
#ifndef saveSkipEagerLoadPlatform SAVE.caches[i] = SAVE.slot.cachedData;
if(SAVE.available) { }
saveLoadMeta(_saveEagerLoadComplete, NULL);
for(uint8_t i = 0; i < SAVE_SLOT_COUNT_MAX; i++) { // Start by initializing each of the save devices.
saveLoadSlot(i, _saveEagerLoadComplete, NULL); savedevice_t *device = &SAVE.devices[0];
do {
errorChain(saveDeviceInit(device));
} while(device++ < &SAVE.devices[SAVE_DEVICE_COUNT - 1]);
errorOk();
}
errorret_t saveUpdate() {
assertIsMainThread("Invalid thread");
// Start by updating each device
savedevice_t *device = &SAVE.devices[0];
do {
saveDeviceUpdate(device);
} while(device++ < &SAVE.devices[SAVE_DEVICE_COUNT - 1]);
// We may be waiting for a device to become available
if(SAVE.findingAvailableDevice) {
assertNotNull(
SAVE.findAvailableCallback,
"Callback cannot be null while looking for devices"
);
// Yes, we have a device available, fire the callback.
if(SAVE.deviceCurrent == 0xFF) {
// Are we still looking or did we fail to find anything?
if(SAVE.noAvailableDeviceFound) {
SAVE.findingAvailableDevice = false;
SAVE.findAvailableCallback(
NULL,
SAVE.findAvailableUser
);
} }
// Still searching
} else {
// Device was found, cache in the data.
errorChain(saveLoadSettings());
errorChain(saveLoadAllSlots());
// Reset slot, implying no slot has been selected.
SAVE.slotCurrent = 0xFF;
// TEST: write settings back out immediately once a device is
// selected, to confirm the write path actually works on hardware.
errorChain(saveDeviceSettingsWrite(
&SAVE.devices[SAVE.deviceCurrent],
&SAVE.settings
));
SAVE.findingAvailableDevice = false;
SAVE.findAvailableCallback(
&SAVE.devices[SAVE.deviceCurrent],
SAVE.findAvailableUser
);
} }
#endif }
errorOk(); errorOk();
} }
bool_t saveIsAvailable(void) { void saveFindAvailableDevice(
return SAVE.available && !SAVE.temporary; savedevicestatecallback_t callback,
void *user
) {
assertIsMainThread("Invalid thread");
assertNotNull(callback, "Callback cannot be null");
assertFalse(
SAVE.findingAvailableDevice,
"Already finding an available device"
);
SAVE.findingAvailableDevice = true;
SAVE.findAvailableCallback = callback;
SAVE.findAvailableUser = user;
SAVE.noAvailableDeviceFound = false;
// Is there an available device marked already?
if(SAVE.deviceCurrent != 0xFF) {
// Yes, fire the callback next tick.
return;
}
// Do we have an available device not yet marked as current?
savedevice_t *device = &SAVE.devices[0];
do {
if(device->state != SAVE_DEVICE_STATE_AVAILABLE) continue;
// Yes this device is available, set as current device.
SAVE.deviceCurrent = (uint8_t)(device - &SAVE.devices[0]);
return;// Next tick will fire the callback.
} while(device++ < &SAVE.devices[SAVE_DEVICE_COUNT - 1]);
// No currently available device, check them IN ORDER, starting at 0
savedevice_t *deviceToCheck = &SAVE.devices[0];
assertNotNull(deviceToCheck, "Device to check cannot be null");
saveDeviceCheckAvailability(
deviceToCheck,
saveOnDeviceAvailabilityChecked,
NULL
);
} }
void saveMarkTemporary(void) { void saveOnDeviceAvailabilityChecked(savedevice_t *device, void *user) {
SAVE.temporary = true; assertNotNull(device, "Device cannot be null");
assertTrue(
SAVE.findingAvailableDevice,
"Not currently finding an available device"
);
// Did we already find a device?
if(SAVE.deviceCurrent != 0xFF) return;
// Is this device available?
if(device->state != SAVE_DEVICE_STATE_AVAILABLE) {
// Since it's unavailable we should tell the next device to check its
// availability. If there is no next device we give up.
savedevice_t *nextDevice = device + 1;
if(nextDevice >= &SAVE.devices[SAVE_DEVICE_COUNT]) {
// No more devices to check, we give up.
SAVE.noAvailableDeviceFound = true;
return;
}
// Check the next device's availability.
saveDeviceCheckAvailability(
nextDevice,
saveOnDeviceAvailabilityChecked,
NULL
);
return;
}
// Yes this device is available, set as current device and next tick will
// invoke callback
SAVE.deviceCurrent = (uint8_t)(device - &SAVE.devices[0]);
} }
bool_t saveIsTemporary(void) { errorret_t saveSaveSettings() {
return SAVE.temporary; assertTrue(SAVE.deviceCurrent != 0xFF, "No current device");
}
errorret_t saveDispose(void) { if(!SAVE.settingsDirty) errorOk();
#ifdef saveDisposePlatform
errorChain(saveDisposePlatform()); errorChain(saveDeviceSettingsWrite(
#endif &SAVE.devices[SAVE.deviceCurrent],
&SAVE.settings
));
SAVE.settingsDirty = false;
errorOk(); errorOk();
} }
errorret_t saveUpdate(void) { errorret_t saveLoadSettings() {
#ifdef savePlatformUpdate assertTrue(SAVE.deviceCurrent != 0xFF, "No current device");
errorChain(savePlatformUpdate());
#endif errorChain(saveDeviceSettingsRead(
&SAVE.devices[SAVE.deviceCurrent],
&SAVE.settings
));
SAVE.settingsDirty = false;
errorOk(); errorOk();
} }
bool_t saveIsBusy(void) { errorret_t saveSaveSlot() {
#ifdef saveIsBusyPlatform assertTrue(SAVE.deviceCurrent != 0xFF, "No current device");
return saveIsBusyPlatform(); assertTrue(SAVE.slotCurrent < SAVE_SLOT_COUNT, "Invalid slot index");
#else
return false;
#endif
}
void saveLoadSlot(const uint8_t slot, savecallback_t onComplete, void *user) { errorChain(saveDeviceSlotWrite(
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX"); &SAVE.devices[SAVE.deviceCurrent],
assertNotNull(onComplete, "onComplete cannot be NULL"); &SAVE.slot,
SAVE.slotCurrent
));
SAVE.slots[slot].exists = false; // Update cache
SAVE.caches[SAVE.slotCurrent] = SAVE.slot.cachedData;
SAVE.slotDirty = false;
// 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 saveLoadDefault(const uint8_t slot) {
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX");
SAVE.slots[slot] = SAVE_DEFAULT;
}
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
SAVE.slots[slot].exists = false;
errorOk(); errorOk();
} }
bool_t saveSlotExists(const uint8_t slot) { errorret_t saveLoadSlot() {
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX"); assertTrue(SAVE.deviceCurrent != 0xFF, "No current device");
return SAVE.slots[slot].exists; assertTrue(SAVE.slotCurrent < SAVE_SLOT_COUNT, "Invalid slot index");
errorChain(saveDeviceSlotRead(
&SAVE.devices[SAVE.deviceCurrent],
&SAVE.slot,
SAVE.slotCurrent
));
// Update cache
SAVE.caches[SAVE.slotCurrent] = SAVE.slot.cachedData;
SAVE.slotDirty = false;
errorOk();
} }
saveslot_t * saveGetSlot(const uint8_t slot) { errorret_t saveLoadAllSlots() {
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX"); for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
return &SAVE.slots[slot]; SAVE.slotCurrent = i;
saveSlotInit(&SAVE.slot);
errorChain(saveLoadSlot());// Load slot updates the cache.
}
errorOk();
} }
void saveLoadMeta(savecallback_t onComplete, void *user) { errorret_t saveDispose() {
assertNotNull(onComplete, "onComplete cannot be NULL"); // Dispose each device.
savedevice_t *device = &SAVE.devices[0];
do {
saveDeviceDispose(device);
} while(device++ < &SAVE.devices[SAVE_DEVICE_COUNT - 1]);
SAVE.meta.exists = false; errorOk();
#ifdef saveMetaAsyncLoadPlatform
saveMetaAsyncLoadPlatform(onComplete, user);
#else
errorret_t ret = saveMetaLoadPlatform(&SAVE.meta);
SAVE.available = errorIsOk(ret);
onComplete(ret, user);
#endif
}
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
}
savemeta_t * saveGetMeta(void) {
return &SAVE.meta;
} }
+78 -176
View File
@@ -6,205 +6,107 @@
*/ */
#pragma once #pragma once
#include "error/error.h" #include "savedevice.h"
#include "saveslot.h" #include "saveslot.h"
#include "savemeta.h" #include "savesettings.h"
#include "save/saveplatform.h"
typedef struct { typedef struct {
/** Per-slot save data; indexed 0 to SAVE_SLOT_COUNT_MAX - 1. */ // File state
saveslot_t slots[SAVE_SLOT_COUNT_MAX]; savesettings_t settings;
/** Device-wide preferences - see savemeta.h. */ saveslotcache_t caches[SAVE_SLOT_COUNT];
savemeta_t meta; saveslot_t slot;
/** Platform-specific save system state (paths, card handles, etc.). */
saveplatform_t platform; uint8_t slotCurrent;
/** bool_t settingsDirty;
* True if the save medium (memory card/stick/disk) was reachable the bool_t slotDirty;
* last time it was checked - at saveInit(), and refreshed by every
* subsequent load/write attempt. Starting the game with no card/stick // Device state
* inserted, or one being removed mid-session, are both expected savedevice_t devices[SAVE_DEVICE_COUNT];
* conditions here, not fatal errors - see saveIsAvailable(). uint8_t deviceCurrent;
*/ bool_t findingAvailableDevice;
bool_t available; bool_t noAvailableDeviceFound;
/** savedevicestatecallback_t findAvailableCallback;
* True once the player has explicitly acknowledged there's no save void *findAvailableUser;
* device and chosen to continue anyway (see the initial scene's "no
* card" prompt) - sticky for the rest of the session, folded into
* saveIsAvailable() so every existing/future call site that already
* gates on that automatically refuses to save/load from here on. See
* saveMarkTemporary()/saveIsTemporary().
*/
bool_t temporary;
/**
* 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; } save_t;
extern save_t SAVE; extern save_t SAVE;
/** /**
* Initializes the save system. Never fails the way saveWriteSlot()/ * Initializes the save system, this does not do anything related to mounting
* saveWriteMeta() can - if the platform's save medium isn't reachable * files, memory cards, etc, this is entirely prepping for that capability.
* (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.
* *
* On most platforms, this also eagerly loads meta and every slot from * @return Error state if any.
* 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); errorret_t saveInit();
/** /**
* Checks whether the save medium was reachable as of the last load/write * Updates the save system.
* attempt (or saveInit(), if none has been attempted yet), AND the
* session hasn't been marked temporary (see saveMarkTemporary()).
* 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 saving/loading can be attempted right now. * @return Error state if any.
*/ */
bool_t saveIsAvailable(void); errorret_t saveUpdate();
/** /**
* Marks this session as temporary - the player explicitly acknowledged * Requests the save system to try and find an available device. This will fire
* there's no save device and chose to continue anyway. One-way: nothing * the callback whenever an avaialble device is found, or will fire with a NULL
* currently re-probes the save medium mid-session (see saveInit()'s doc * save device if no available save devices are found.
* comment), so there's no legitimate way to un-stick this once set short *
* of restarting the game. * @param callback The callback to fire when an available device is found.
* @param user The user data to pass to the callback.
*/ */
void saveMarkTemporary(void); void saveFindAvailableDevice(
savedevicestatecallback_t callback,
void *user
);
/** /**
* True once saveMarkTemporary() has been called this session. Distinct * Internal method to fire the save callback, does it at the appropriate time.
* from saveIsAvailable() so UI can give a more specific message (e.g.
* "this session is temporary" rather than the generic "no save device
* found") once the player has already made that choice.
* *
* @return true if this session has been marked temporary. * @param device The save device to fire the callback for.
* @param user Unused, present to match savedevicestatecallback_t.
*/ */
bool_t saveIsTemporary(void); void saveOnDeviceAvailabilityChecked(savedevice_t *device, void *user);
/**
* Saves the current settings to the current device.
*
* @return Error state if any.
*/
errorret_t saveSaveSettings();
/**
* Loads the current settings from the current device.
*
* @return Error state if any.
*/
errorret_t saveLoadSettings();
/**
* Saves the current slot to the current device, at the current slot index.
*
* @return Error state if any.
*/
errorret_t saveSaveSlot();
/**
* Loads the current slot from the current device, at the current slot index.
*
* @return Error state if any.
*/
errorret_t saveLoadSlot();
/**
* Resets and loads every slot from the current device, updating the caches
* along the way. Leaves SAVE.slotCurrent pointing at the last slot index
* once done.
*
* @return Error state if any.
*/
errorret_t saveLoadAllSlots();
/** /**
* Disposes of the save system. * Disposes of the save system.
* *
* @return An error code if disposal fails. * @return Error state if any.
*/ */
errorret_t saveDispose(void); errorret_t saveDispose();
/**
* 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).
*
* @return An error code indicating success or failure.
*/
errorret_t saveUpdate(void);
/**
* 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.
*
* @return True if a save/load request is currently in progress.
*/
bool_t saveIsBusy(void);
/**
* 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_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);
/**
* Resets a save slot in memory to SAVE_DEFAULT, for starting a new game
* without reading anything from persistent storage. Synchronous - no
* disk I/O is involved, so there's no callback to wait on.
*
* @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1) to reset.
*/
void saveLoadDefault(const uint8_t slot);
/**
* 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 saveDeleteSlot(const uint8_t slot);
/**
* Checks whether a save slot has data for a given index.
*
* @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1).
* @return true if the slot has data, false otherwise.
*/
bool_t saveSlotExists(const uint8_t slot);
/**
* Gets a pointer to the save slot data for a given index.
*
* @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1).
* @return A pointer to the saveslot_t for the given 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);
+571
View File
@@ -0,0 +1,571 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "save/savedevice.h"
#include "assert/assert.h"
#include "util/memory.h"
#if defined(SAVE_DEVICE_DATA_RAW)
#include "yyjson.h"
#include "save/savejson.h"
#include "save/saveslot.h"
#include "save/savesettings.h"
#include "util/crypt.h"
#include "util/endian.h"
#include <zlib.h>
#endif
errorret_t saveDeviceInit(savedevice_t *device) {
assertNotNull(device, "device cannot be null");
memoryZero(device, sizeof(savedevice_t));
device->state = 0xFF;// We set this because the platform must define it.
errorChain(saveDevicePlatformInit(device));
// The platform must put the device into a state we can work with.
assertTrue(
device->state == SAVE_DEVICE_STATE_UNKNOWN ||
device->state == SAVE_DEVICE_STATE_UNAVAILABLE ||
device->state == SAVE_DEVICE_STATE_AVAILABLE ||
device->state == SAVE_DEVICE_STATE_ERRORED,
"Save device must be in a state that allows checking availability"
);
errorOk();
}
errorret_t saveDeviceUpdate(savedevice_t *device) {
assertNotNull(device, "device cannot be null");
assertIsMainThread("Invalid thread");
// Perform a device update.
errorChain(saveDevicePlatformUpdate(device));
// Fire the callback if desired.
if(device->fireCallback) {
device->fireCallback = false;
if(device->stateCallback) device->stateCallback(device, device->user);
}
errorOk();
}
void saveDeviceFireCallback(savedevice_t *device) {
assertNotNull(device, "device cannot be null");
assertIsMainThread("Invalid thread");
assertFalse(device->fireCallback, "Device callback already fired?");
device->fireCallback = true;
}
void saveDeviceCheckAvailability(
savedevice_t *device,
savedevicestatecallback_t callback,
void *user
) {
assertNotNull(device, "device cannot be null");
assertNotNull(callback, "callback cannot be null");
assertIsMainThread("Invalid thread");
// The device must be in a state that we allow checking the availability.
assertTrue(
device->state == SAVE_DEVICE_STATE_UNKNOWN ||
device->state == SAVE_DEVICE_STATE_UNAVAILABLE ||
device->state == SAVE_DEVICE_STATE_AVAILABLE ||
device->state == SAVE_DEVICE_STATE_ERRORED,
"Save device must be in a state that allows checking availability"
);
// Set state data and callback.
device->state = SAVE_DEVICE_STATE_CHECKING_AVAILABILITY;
device->stateCallback = callback;
device->user = user;
// Handoff to the platform to do its checks, it can opt to do this either
// synchronously or asynchronously.
saveDeviceCheckAvailabilityPlatform(device);
}
#if defined(SAVE_DEVICE_DATA_RAW)
// Bumped to 2 for the addition of `generation` below.
#define SAVE_DEVICE_RAW_VERSION ((uint32_t)2)
// Sanity cap against a corrupted/hostile header driving a bogus allocation.
#define SAVE_DEVICE_RAW_MAX_SIZE ((uint32_t)(256 * 1024))
static const char_t SAVE_DEVICE_RAW_MAGIC[4] = {'D', 'S', 'A', 'V'};
#pragma pack(push, 1)
typedef struct {
char_t magic[4];
uint32_t version;
uint32_t uncompressedSize;// size of the logical blob, pre-compression
uint32_t compressedSize;// size of the payload following this header
uint32_t checksum;// cryptCRC32() over the compressed payload
// Monotonically increasing with every store. Unused by single-file
// platforms (rename already guarantees the current file is the latest),
// but load-bearing for platforms with no rename primitive and multiple
// physical copies - e.g. GameCube memory cards ping-ponging between two
// fixed files, where this is how saveDeviceRawIsValid() picks the newest
// valid copy.
uint32_t generation;
} savedevicerawheader_t;
#pragma pack(pop)
typedef struct {
const uint8_t *ptr;
uint32_t len;
} savedevicerawspan_t;
typedef struct {
savedevicerawspan_t settings;
savedevicerawspan_t slots[SAVE_SLOT_COUNT];
} savedevicerawspans_t;
// A single settings/slot item's JSON bytes, either borrowed from an existing
// decompressed blob (owned == NULL) or freshly serialized just now via
// yyjson_mut_write (owned != NULL, must be freed with plain free()).
typedef struct {
const uint8_t *ptr;
uint32_t len;
char_t *owned;
} savedevicerawitem_t;
// Parses the length-prefixed span table at the front of a decompressed
// logical blob. Spans point directly into `logical`, nothing is copied.
static void saveDeviceRawParseSpans(
const uint8_t *logical,
savedevicerawspans_t *spans
) {
size_t offset = sizeof(uint32_t) * (1 + SAVE_SLOT_COUNT);
spans->settings.len = endianLittleToHost32(*(const uint32_t *)(logical + 0));
for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
spans->slots[i].len = endianLittleToHost32(
*(const uint32_t *)(logical + sizeof(uint32_t) * (1 + i))
);
}
spans->settings.ptr = logical + offset;
offset += spans->settings.len;
for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
spans->slots[i].ptr = logical + offset;
offset += spans->slots[i].len;
}
}
// Cheaply checks whether `raw` is a well-formed, checksum-valid raw save
// blob, without decompressing it - just header + checksum validation. Used
// both by saveDeviceRawInflate below and, publicly (see savedevice.h), by
// platforms that keep more than one physical copy and need to pick the
// newest valid one (e.g. GameCube memory cards - see `generation` above).
bool_t saveDeviceRawIsValid(
const uint8_t *raw,
const size_t rawSize,
uint32_t *outGeneration
) {
if(raw == NULL || rawSize < sizeof(savedevicerawheader_t)) return false;
const savedevicerawheader_t *header = (const savedevicerawheader_t *)raw;
if(memoryCompare(header->magic, SAVE_DEVICE_RAW_MAGIC, 4) != 0) {
return false;
}
if(endianLittleToHost32(header->version) != SAVE_DEVICE_RAW_VERSION) {
return false;
}
uint32_t uncompressedSize = endianLittleToHost32(header->uncompressedSize);
uint32_t compressedSize = endianLittleToHost32(header->compressedSize);
if(
uncompressedSize > SAVE_DEVICE_RAW_MAX_SIZE ||
compressedSize > SAVE_DEVICE_RAW_MAX_SIZE ||
// Trailing bytes beyond the declared payload are tolerated as padding -
// e.g. a GameCube memory card file rounded up to a whole sector.
rawSize < sizeof(savedevicerawheader_t) + compressedSize
) {
return false;
}
uint32_t checksum = cryptCRC32(raw + sizeof(savedevicerawheader_t), compressedSize);
if(checksum != endianLittleToHost32(header->checksum)) return false;
if(outGeneration != NULL) {
*outGeneration = endianLittleToHost32(header->generation);
}
return true;
}
// Validates the header and inflates the compressed payload that follows it.
// On success, *outLogical is a memoryAllocate'd buffer the caller must free.
static errorret_t saveDeviceRawInflate(
const uint8_t *raw,
const size_t rawSize,
uint8_t **outLogical,
size_t *outLogicalSize,
uint32_t *outGeneration
) {
if(!saveDeviceRawIsValid(raw, rawSize, outGeneration)) {
errorThrow("Save data failed validation (magic/version/size/checksum)");
}
const savedevicerawheader_t *header = (const savedevicerawheader_t *)raw;
uint32_t uncompressedSize = endianLittleToHost32(header->uncompressedSize);
uint32_t compressedSize = endianLittleToHost32(header->compressedSize);
const uint8_t *compressed = raw + sizeof(savedevicerawheader_t);
uint8_t *logical = memoryAllocate(uncompressedSize);
uLongf destLen = (uLongf)uncompressedSize;
int result = uncompress(
logical, &destLen, compressed, (uLong)compressedSize
);
if(result != Z_OK || destLen != uncompressedSize) {
memoryFree(logical);
errorThrow("Failed to decompress save data (zlib error %d)", result);
}
*outLogical = logical;
*outLogicalSize = uncompressedSize;
errorOk();
}
// Serializes `settings` to a freshly malloc'd JSON string via yyjson - the
// result must be freed with plain free(), not memoryFree().
static errorret_t saveDeviceRawSerializeSettings(
savesettings_t *settings,
char_t **outJson,
size_t *outLen
) {
writeInit();
errorret_t writeResult = saveSettingsWriteJSON(settings, doc, object);
if(errorIsNotOk(writeResult)) {
yyjson_mut_doc_free(doc);
errorChain(writeResult);
}
*outJson = yyjson_mut_write(doc, 0, outLen);
yyjson_mut_doc_free(doc);
if(*outJson == NULL) errorThrow("Failed to write settings JSON");
errorOk();
}
// Serializes `slot` to a freshly malloc'd JSON string via yyjson - the
// result must be freed with plain free(), not memoryFree().
static errorret_t saveDeviceRawSerializeSlot(
saveslot_t *slot,
char_t **outJson,
size_t *outLen
) {
writeInit();
errorret_t writeResult = saveSlotWriteJSON(slot, doc, object);
if(errorIsNotOk(writeResult)) {
yyjson_mut_doc_free(doc);
errorChain(writeResult);
}
*outJson = yyjson_mut_write(doc, 0, outLen);
yyjson_mut_doc_free(doc);
if(*outJson == NULL) errorThrow("Failed to write slot JSON");
errorOk();
}
// Frees whatever's been collected so far - safe to call at any point since
// unset items are zero-initialized (owned == NULL is a no-op skip).
static void saveDeviceRawCleanupStore(
uint8_t *oldRaw,
uint8_t *oldLogical,
savedevicerawitem_t *settingsItem,
savedevicerawitem_t *slotItems
) {
if(oldRaw != NULL) memoryFree(oldRaw);
if(oldLogical != NULL) memoryFree(oldLogical);
if(settingsItem->owned != NULL) free(settingsItem->owned);
for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
if(slotItems[i].owned != NULL) free(slotItems[i].owned);
}
}
// Reads the existing combined blob (if any), replaces exactly one item -
// `settings`, or the slot at `slotIndex` when `slot` is given, exactly one
// of the two must be non-NULL - and rewrites the whole blob. Every other
// item's JSON is carried through byte-for-byte from what was already
// stored (or freshly defaulted if nothing was stored yet), so a save never
// reconstructs data it wasn't given.
static errorret_t saveDeviceRawStoreItem(
savedevice_t *device,
savesettings_t *settings,
saveslot_t *slot,
const uint8_t slotIndex
) {
uint8_t *oldRaw = NULL;
size_t oldRawSize = 0;
errorChain(saveDeviceDataReadPlatform(device, &oldRaw, &oldRawSize));
uint8_t *oldLogical = NULL;
size_t oldLogicalSize = 0;
uint32_t oldGeneration = 0;
savedevicerawspans_t oldSpans;
bool_t haveOld = oldRaw != NULL;
if(haveOld) {
errorret_t inflateResult = saveDeviceRawInflate(
oldRaw, oldRawSize, &oldLogical, &oldLogicalSize, &oldGeneration
);
if(errorIsNotOk(inflateResult)) {
memoryFree(oldRaw);
errorChain(inflateResult);
}
saveDeviceRawParseSpans(oldLogical, &oldSpans);
}
savedevicerawitem_t settingsItem = {0};
savedevicerawitem_t slotItems[SAVE_SLOT_COUNT] = {0};
if(settings != NULL) {
char_t *json = NULL;
size_t len = 0;
errorret_t result = saveDeviceRawSerializeSettings(settings, &json, &len);
if(errorIsNotOk(result)) {
saveDeviceRawCleanupStore(oldRaw, oldLogical, &settingsItem, slotItems);
errorChain(result);
}
settingsItem = (savedevicerawitem_t){
.ptr = (const uint8_t *)json, .len = (uint32_t)len, .owned = json
};
} else if(haveOld) {
settingsItem.ptr = oldSpans.settings.ptr;
settingsItem.len = oldSpans.settings.len;
} else {
savesettings_t defaultSettings;
saveSettingsInit(&defaultSettings);
char_t *json = NULL;
size_t len = 0;
errorret_t result = saveDeviceRawSerializeSettings(
&defaultSettings, &json, &len
);
if(errorIsNotOk(result)) {
saveDeviceRawCleanupStore(oldRaw, oldLogical, &settingsItem, slotItems);
errorChain(result);
}
settingsItem = (savedevicerawitem_t){
.ptr = (const uint8_t *)json, .len = (uint32_t)len, .owned = json
};
}
for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
if(slot != NULL && i == slotIndex) {
char_t *json = NULL;
size_t len = 0;
errorret_t result = saveDeviceRawSerializeSlot(slot, &json, &len);
if(errorIsNotOk(result)) {
saveDeviceRawCleanupStore(oldRaw, oldLogical, &settingsItem, slotItems);
errorChain(result);
}
slotItems[i] = (savedevicerawitem_t){
.ptr = (const uint8_t *)json, .len = (uint32_t)len, .owned = json
};
} else if(haveOld) {
slotItems[i].ptr = oldSpans.slots[i].ptr;
slotItems[i].len = oldSpans.slots[i].len;
} else {
saveslot_t defaultSlot;
saveSlotInit(&defaultSlot);
char_t *json = NULL;
size_t len = 0;
errorret_t result = saveDeviceRawSerializeSlot(&defaultSlot, &json, &len);
if(errorIsNotOk(result)) {
saveDeviceRawCleanupStore(oldRaw, oldLogical, &settingsItem, slotItems);
errorChain(result);
}
slotItems[i] = (savedevicerawitem_t){
.ptr = (const uint8_t *)json, .len = (uint32_t)len, .owned = json
};
}
}
// Assemble the new logical blob: length table, then each item's bytes.
size_t tableSize = sizeof(uint32_t) * (1 + SAVE_SLOT_COUNT);
size_t logicalSize = tableSize + settingsItem.len;
for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) logicalSize += slotItems[i].len;
uint8_t *newLogical = memoryAllocate(logicalSize);
*(uint32_t *)(newLogical + 0) = endianLittleToHost32(settingsItem.len);
for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
*(uint32_t *)(newLogical + sizeof(uint32_t) * (1 + i)) =
endianLittleToHost32(slotItems[i].len);
}
size_t offset = tableSize;
memoryCopy(newLogical + offset, settingsItem.ptr, settingsItem.len);
offset += settingsItem.len;
for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
memoryCopy(newLogical + offset, slotItems[i].ptr, slotItems[i].len);
offset += slotItems[i].len;
}
saveDeviceRawCleanupStore(oldRaw, oldLogical, &settingsItem, slotItems);
// Compress and frame with the header.
uLongf compressedCap = compressBound((uLong)logicalSize);
uint8_t *compressedBuf = memoryAllocate(compressedCap);
uLongf compressedLen = compressedCap;
int compressResult = compress2(
compressedBuf, &compressedLen, newLogical, (uLong)logicalSize,
Z_DEFAULT_COMPRESSION
);
memoryFree(newLogical);
if(compressResult != Z_OK) {
memoryFree(compressedBuf);
errorThrow("Failed to compress save data (zlib error %d)", compressResult);
}
savedevicerawheader_t header;
memoryCopy(header.magic, SAVE_DEVICE_RAW_MAGIC, 4);
header.version = endianLittleToHost32(SAVE_DEVICE_RAW_VERSION);
header.uncompressedSize = endianLittleToHost32((uint32_t)logicalSize);
header.compressedSize = endianLittleToHost32((uint32_t)compressedLen);
header.checksum = endianLittleToHost32(cryptCRC32(compressedBuf, compressedLen));
header.generation = endianLittleToHost32(haveOld ? oldGeneration + 1 : 1);
size_t finalSize = sizeof(header) + compressedLen;
uint8_t *finalBuf = memoryAllocate(finalSize);
memoryCopy(finalBuf, &header, sizeof(header));
memoryCopy(finalBuf + sizeof(header), compressedBuf, compressedLen);
memoryFree(compressedBuf);
errorret_t writeResult = saveDeviceDataWritePlatform(
device, finalBuf, finalSize
);
memoryFree(finalBuf);
errorChain(writeResult);
errorOk();
}
// Reads the combined blob and populates exactly one item - `settings`, or
// the slot at `slotIndex` when `slot` is given, exactly one of the two must
// be non-NULL. Leaves the destination untouched if nothing's been saved yet.
static errorret_t saveDeviceRawFetchItem(
savedevice_t *device,
savesettings_t *settings,
saveslot_t *slot,
const uint8_t slotIndex
) {
uint8_t *raw = NULL;
size_t rawSize = 0;
errorChain(saveDeviceDataReadPlatform(device, &raw, &rawSize));
if(raw == NULL) errorOk();
uint8_t *logical = NULL;
size_t logicalSize = 0;
errorret_t inflateResult = saveDeviceRawInflate(
raw, rawSize, &logical, &logicalSize, NULL
);
memoryFree(raw);
errorChain(inflateResult);
savedevicerawspans_t spans;
saveDeviceRawParseSpans(logical, &spans);
const savedevicerawspan_t *span = settings != NULL ?
&spans.settings : &spans.slots[slotIndex];
yyjson_doc *jsonDoc = yyjson_read((const char_t *)span->ptr, span->len, 0);
if(jsonDoc == NULL) {
memoryFree(logical);
errorThrow("Failed to parse save data item JSON");
}
yyjson_val *object = yyjson_doc_get_root(jsonDoc);
if(object == NULL) {
yyjson_doc_free(jsonDoc);
memoryFree(logical);
errorThrow("Save data item JSON missing root object");
}
errorret_t readResult = settings != NULL ?
saveSettingsReadJSON(settings, object) : saveSlotReadJSON(slot, object);
yyjson_doc_free(jsonDoc);
memoryFree(logical);
errorChain(readResult);
errorOk();
}
#endif// defined(SAVE_DEVICE_DATA_RAW)
errorret_t saveDeviceSlotWrite(
savedevice_t *device,
saveslot_t *slot,
const uint8_t slotIndex
) {
assertNotNull(device, "device cannot be null");
assertNotNull(slot, "slot cannot be null");
#if defined(SAVE_DEVICE_DATA_RAW)
errorChain(saveDeviceRawStoreItem(device, NULL, slot, slotIndex));
#elif defined(saveDeviceSlotWritePlatform)
errorChain(saveDeviceSlotWritePlatform(device, slot, slotIndex));
#endif
errorOk();
}
errorret_t saveDeviceSlotRead(
savedevice_t *device,
saveslot_t *slot,
const uint8_t slotIndex
) {
assertNotNull(device, "device cannot be null");
assertNotNull(slot, "slot cannot be null");
#if defined(SAVE_DEVICE_DATA_RAW)
errorChain(saveDeviceRawFetchItem(device, NULL, slot, slotIndex));
#elif defined(saveDeviceSlotReadPlatform)
errorChain(saveDeviceSlotReadPlatform(device, slot, slotIndex));
#endif
errorOk();
}
errorret_t saveDeviceSettingsWrite(
savedevice_t *device,
savesettings_t *settings
) {
assertNotNull(device, "device cannot be null");
assertNotNull(settings, "settings cannot be null");
#if defined(SAVE_DEVICE_DATA_RAW)
errorChain(saveDeviceRawStoreItem(device, settings, NULL, 0));
#elif defined(saveDeviceSettingsWritePlatform)
errorChain(saveDeviceSettingsWritePlatform(device, settings));
#endif
errorOk();
}
errorret_t saveDeviceSettingsRead(
savedevice_t *device,
savesettings_t *settings
) {
assertNotNull(device, "device cannot be null");
assertNotNull(settings, "settings cannot be null");
#if defined(SAVE_DEVICE_DATA_RAW)
errorChain(saveDeviceRawFetchItem(device, settings, NULL, 0));
#elif defined(saveDeviceSettingsReadPlatform)
errorChain(saveDeviceSettingsReadPlatform(device, settings));
#endif
errorOk();
}
errorret_t saveDeviceDispose(savedevice_t *device) {
assertNotNull(device, "device cannot be null");
errorChain(saveDevicePlatformDispose(device));
errorOk();
}
+182
View File
@@ -0,0 +1,182 @@
/**
* 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/savedeviceplatform.h"
// Save device count e.g. Memory Cards count.
#ifndef SAVE_DEVICE_COUNT
#error "SAVE_DEVICE_COUNT must be defined"
#endif
// Platform opt-in: define this in a platform's savedeviceplatform.h (instead
// of the four saveDevice{Slot,Settings}{Write,Read}Platform macros) for
// devices without free-form filesystem access - e.g. a memory card/stick
// that suits one combined save blob better than N+1 separate files. When
// defined, the platform must instead provide these two:
//
// errorret_t saveDeviceDataWritePlatform(
// savedevice_t *device, const uint8_t *buffer, size_t size
// );
// errorret_t saveDeviceDataReadPlatform(
// savedevice_t *device, uint8_t **outBuffer, size_t *outSize
// );
//
// savedevice.c compresses settings + all save slots' JSON into one
// magic/version/checksum-framed blob and read/writes it as a single raw
// buffer through these two hooks instead. saveDeviceDataReadPlatform must
// allocate its output with memoryAllocate (ownership passes to the caller,
// who frees it with memoryFree), and set *outBuffer = NULL/*outSize = 0
// (not an error) when nothing has been saved yet - same convention as the
// "file not found" case in the non-raw four-hook mode.
// #define SAVE_DEVICE_DATA_RAW
//
// saveDeviceDataReadPlatform must always resolve to "the one current blob"
// even if the platform keeps more than one physical copy under the hood -
// e.g. a platform with no rename primitive (GameCube memory cards) that
// ping-pongs between two fixed files instead. saveDeviceRawIsValid() below
// is what such a platform uses to tell which of its copies is newest.
typedef struct savedevice_s savedevice_t;
typedef struct savesettings_s savesettings_t;
typedef struct saveslot_s saveslot_t;
typedef void (*savedevicestatecallback_t)(savedevice_t *device, void *user);
typedef enum {
SAVE_DEVICE_STATE_UNKNOWN,
SAVE_DEVICE_STATE_CHECKING_AVAILABILITY,
SAVE_DEVICE_STATE_UNAVAILABLE,
SAVE_DEVICE_STATE_AVAILABLE,
SAVE_DEVICE_STATE_ERRORED
} savedevicestate_t;
typedef struct savedevice_s {
savedevicestate_t state;
savedeviceplatform_t platform;
const char_t *reasonKey;
bool_t fireCallback;
void *user;
savedevicestatecallback_t stateCallback;
} savedevice_t;
/**
* Initializes the save device.
*
* @param device The save device to initialize.
* @return Error state if any.
*/
errorret_t saveDeviceInit(savedevice_t *device);
/**
* Updates the save device.
*
* @param device The save device to update.
* @return Error state if any.
*/
errorret_t saveDeviceUpdate(savedevice_t *device);
/**
* Internal method to fire the save callback, does it at the appropriate time.
*
* @param device The save device to fire the callback for.
*/
void saveDeviceFireCallback(savedevice_t *device);
/**
* Requests the device to check its availability, this will call the callback
* whence completed.
*
* @param device The save device to check availability.
* @param callback The callback to call when the availability check is complete.
* @param user User data to pass to the callback.
*/
void saveDeviceCheckAvailability(
savedevice_t *device,
savedevicestatecallback_t callback,
void *user
);
/**
* Writes the given save slot to the device at the given slot index.
*
* @param device The save device to write to.
* @param slot The save slot to write.
* @param slotIndex The slot index to write to.
* @return Error state if any.
*/
errorret_t saveDeviceSlotWrite(
savedevice_t *device,
saveslot_t *slot,
const uint8_t slotIndex
);
/**
* Reads a save slot from the device at the given slot index.
*
* @param device The save device to read from.
* @param slot The save slot to read into.
* @param slotIndex The slot index to read from.
* @return Error state if any.
*/
errorret_t saveDeviceSlotRead(
savedevice_t *device,
saveslot_t *slot,
const uint8_t slotIndex
);
/**
* Writes the given save settings to the device.
*
* @param device The save device to write to.
* @param settings The save settings to write.
* @return Error state if any.
*/
errorret_t saveDeviceSettingsWrite(
savedevice_t *device,
savesettings_t *settings
);
/**
* Reads the save settings from the device.
*
* @param device The save device to read from.
* @param settings The save settings to read into.
* @return Error state if any.
*/
errorret_t saveDeviceSettingsRead(
savedevice_t *device,
savesettings_t *settings
);
/**
* Disposes of the save device.
*
* @param device The save device to dispose.
* @return Error state if any.
*/
errorret_t saveDeviceDispose(savedevice_t *device);
/**
* Only relevant to SAVE_DEVICE_DATA_RAW platforms that keep more than one
* physical copy of the save blob (see the note above) - cheaply checks
* whether `raw` is a well-formed, checksum-valid raw save blob without
* decompressing it, and if so reports its generation counter so the caller
* can tell which of several copies is newest.
*
* @param raw The raw bytes to validate.
* @param rawSize The number of bytes in raw.
* @param outGeneration Receives the blob's generation counter if valid, may
* be NULL if the caller doesn't need it.
* @return true if raw is a valid, checksum-passing save blob.
*/
bool_t saveDeviceRawIsValid(
const uint8_t *raw,
const size_t rawSize,
uint32_t *outGeneration
);
+363
View File
@@ -0,0 +1,363 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "yyjson.h"
#include "error/error.h"
#include "assert/assert.h"
#include "util/string.h"
#include "time/timeepoch.h"
#define SAVE_JSON_STRING_BUFFER_SIZE 256
/**
* Creates a new mutable JSON document with an empty root object, ready for
* the writeX macros below to populate. Declares `doc` and `object` in the
* calling scope.
*/
#define writeInit() \
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); \
assertNotNull(doc, "Failed to create JSON document"); \
yyjson_mut_val *object = yyjson_mut_obj(doc); \
yyjson_mut_doc_set_root(doc, object)
/**
* Parses the given JSON text and exposes its root object for the readX
* macros below to consume. Declares `jsonDoc` (keep it around to dispose
* with yyjson_doc_free once reading is done) and `object` in the calling
* scope. Errors if the text fails to parse or has no object root.
*
* @param jsonText The JSON text to parse.
* @param jsonLength The length of the JSON text, in bytes.
*/
#define readInit(jsonText, jsonLength) \
yyjson_doc *jsonDoc = yyjson_read((jsonText), (jsonLength), 0); \
if(jsonDoc == NULL) errorThrow("Failed to parse save JSON"); \
yyjson_val *object = yyjson_doc_get_root(jsonDoc); \
if(object == NULL) errorThrow("Save JSON missing root object")
/**
* Checks if the given key exists on the current JSON object.
*
* @param key The key to check for.
*/
#define hasInt32(key) (yyjson_obj_get(object, key) != NULL)
/**
* Errors if the given key does not exist on the current JSON object.
*
* @param key The key that must exist.
*/
#define requireInt32(key) \
if(!hasInt32(key)) errorThrow("Save JSON missing '%s' key", key)
/**
* Writes an int32_t to the current JSON object.
*
* @param key The key to write to.
* @param value The value to write.
*/
#define writeInt32(key, value) \
yyjson_mut_obj_add_int(doc, object, key, (int64_t)(value))
/**
* Reads an int32_t from the current JSON object, falling back to the given
* default if the key is missing.
*
* @param key The key to read from.
* @param dest The destination variable to assign to.
* @param def The default value to use if the key is missing.
*/
#define readInt32(key, dest, def) \
(dest) = hasInt32(key) ? \
(int32_t)yyjson_get_int(yyjson_obj_get(object, key)) : (int32_t)(def)
/**
* Checks if the given key exists on the current JSON object.
*
* @param key The key to check for.
*/
#define hasUInt32(key) (yyjson_obj_get(object, key) != NULL)
/**
* Errors if the given key does not exist on the current JSON object.
*
* @param key The key that must exist.
*/
#define requireUInt32(key) \
if(!hasUInt32(key)) errorThrow("Save JSON missing '%s' key", key)
/**
* Writes a uint32_t to the current JSON object.
*
* @param key The key to write to.
* @param value The value to write.
*/
#define writeUInt32(key, value) \
yyjson_mut_obj_add_uint(doc, object, key, (uint64_t)(value))
/**
* Reads a uint32_t from the current JSON object, falling back to the given
* default if the key is missing.
*
* @param key The key to read from.
* @param dest The destination variable to assign to.
* @param def The default value to use if the key is missing.
*/
#define readUInt32(key, dest, def) \
(dest) = hasUInt32(key) ? \
(uint32_t)yyjson_get_uint(yyjson_obj_get(object, key)) : (uint32_t)(def)
/**
* Checks if the given key exists on the current JSON object.
*
* @param key The key to check for.
*/
#define hasUInt8(key) (yyjson_obj_get(object, key) != NULL)
/**
* Errors if the given key does not exist on the current JSON object.
*
* @param key The key that must exist.
*/
#define requireUInt8(key) \
if(!hasUInt8(key)) errorThrow("Save JSON missing '%s' key", key)
/**
* Writes a uint8_t to the current JSON object.
*
* @param key The key to write to.
* @param value The value to write.
*/
#define writeUInt8(key, value) \
yyjson_mut_obj_add_uint(doc, object, key, (uint64_t)(value))
/**
* Reads a uint8_t from the current JSON object, falling back to the given
* default if the key is missing.
*
* @param key The key to read from.
* @param dest The destination variable to assign to.
* @param def The default value to use if the key is missing.
*/
#define readUInt8(key, dest, def) \
(dest) = hasUInt8(key) ? \
(uint8_t)yyjson_get_uint(yyjson_obj_get(object, key)) : (uint8_t)(def)
/**
* Checks if the given key exists on the current JSON object.
*
* @param key The key to check for.
*/
#define hasInt64(key) (yyjson_obj_get(object, key) != NULL)
/**
* Errors if the given key does not exist on the current JSON object.
*
* @param key The key that must exist.
*/
#define requireInt64(key) \
if(!hasInt64(key)) errorThrow("Save JSON missing '%s' key", key)
/**
* Writes an int64_t to the current JSON object.
*
* @param key The key to write to.
* @param value The value to write.
*/
#define writeInt64(key, value) \
yyjson_mut_obj_add_sint(doc, object, key, (int64_t)(value))
/**
* Reads an int64_t from the current JSON object, falling back to the given
* default if the key is missing.
*
* @param key The key to read from.
* @param dest The destination variable to assign to.
* @param def The default value to use if the key is missing.
*/
#define readInt64(key, dest, def) \
(dest) = hasInt64(key) ? \
yyjson_get_sint(yyjson_obj_get(object, key)) : (int64_t)(def)
/**
* Checks if the given key exists on the current JSON object.
*
* @param key The key to check for.
*/
#define hasUInt64(key) (yyjson_obj_get(object, key) != NULL)
/**
* Errors if the given key does not exist on the current JSON object.
*
* @param key The key that must exist.
*/
#define requireUInt64(key) \
if(!hasUInt64(key)) errorThrow("Save JSON missing '%s' key", key)
/**
* Writes a uint64_t to the current JSON object.
*
* @param key The key to write to.
* @param value The value to write.
*/
#define writeUInt64(key, value) \
yyjson_mut_obj_add_uint(doc, object, key, (uint64_t)(value))
/**
* Reads a uint64_t from the current JSON object, falling back to the given
* default if the key is missing.
*
* @param key The key to read from.
* @param dest The destination variable to assign to.
* @param def The default value to use if the key is missing.
*/
#define readUInt64(key, dest, def) \
(dest) = hasUInt64(key) ? \
yyjson_get_uint(yyjson_obj_get(object, key)) : (uint64_t)(def)
/**
* Checks if the given key exists on the current JSON object.
*
* @param key The key to check for.
*/
#define hasFloat(key) (yyjson_obj_get(object, key) != NULL)
/**
* Errors if the given key does not exist on the current JSON object.
*
* @param key The key that must exist.
*/
#define requireFloat(key) \
if(!hasFloat(key)) errorThrow("Save JSON missing '%s' key", key)
/**
* Writes a float_t to the current JSON object.
*
* @param key The key to write to.
* @param value The value to write.
*/
#define writeFloat(key, value) \
yyjson_mut_obj_add_real(doc, object, key, (double)(value))
/**
* Reads a float_t from the current JSON object, falling back to the given
* default if the key is missing.
*
* @param key The key to read from.
* @param dest The destination variable to assign to.
* @param def The default value to use if the key is missing.
*/
#define readFloat(key, dest, def) \
(dest) = hasFloat(key) ? \
(float_t)yyjson_get_real(yyjson_obj_get(object, key)) : (float_t)(def)
/**
* Checks if the given key exists on the current JSON object.
*
* @param key The key to check for.
*/
#define hasString(key) (yyjson_obj_get(object, key) != NULL)
/**
* Errors if the given key does not exist on the current JSON object.
*
* @param key The key that must exist.
*/
#define requireString(key) \
if(!hasString(key)) errorThrow("Save JSON missing '%s' key", key)
/**
* Writes a string to the current JSON object. The string is copied, so it
* does not need to outlive the JSON document.
*
* @param key The key to write to.
* @param value The value to write.
*/
#define writeString(key, value) \
yyjson_mut_obj_add_strcpy(doc, object, key, value)
/**
* Reads a string from the current JSON object into dest, falling back to
* the given default if the key is missing. Errors if the JSON string is
* longer than maxLength. Uses the caller's `saveJsonStringBuffer` local as
* scratch space.
*
* @param key The key to read from.
* @param dest The destination buffer to copy into.
* @param def The default value to use if the key is missing.
* @param maxLength The maximum length of dest, excluding the null terminator.
*/
#define readString(key, dest, def, maxLength) { \
yyjson_val *saveJsonStrVal = yyjson_obj_get(object, key); \
if(saveJsonStrVal != NULL) { \
if(yyjson_get_len(saveJsonStrVal) > (size_t)(maxLength)) { \
errorThrow( \
"Save JSON string '%s' exceeds max length of %d", \
key, (int)(maxLength) \
); \
} \
stringCopy(saveJsonStringBuffer, yyjson_get_str(saveJsonStrVal), (maxLength)); \
} else { \
stringCopy(saveJsonStringBuffer, (def), (maxLength)); \
} \
stringCopy((dest), saveJsonStringBuffer, (maxLength)); \
}
/**
* Checks if the given key exists on the current JSON object.
*
* @param key The key to check for.
*/
#define hasTime(key) (yyjson_obj_get(object, key) != NULL)
/**
* Errors if the given key does not exist on the current JSON object.
*
* @param key The key that must exist.
*/
#define requireTime(key) \
if(!hasTime(key)) errorThrow("Save JSON missing '%s' key", key)
/**
* Writes a dusktimeepoch_t to the current JSON object as a nested object of
* its time/timeZone/offsetTime fields.
*
* @param key The key to write to.
* @param value The dusktimeepoch_t value to write.
*/
#define writeTime(key, value) { \
yyjson_mut_val *saveJsonTimeObj = yyjson_mut_obj_add_obj(doc, object, key); \
yyjson_mut_obj_add_real(doc, saveJsonTimeObj, "time", (value).time); \
yyjson_mut_obj_add_real(doc, saveJsonTimeObj, "timeZone", (value).timeZone); \
yyjson_mut_obj_add_real(doc, saveJsonTimeObj, "offsetTime", (value).offsetTime); \
}
/**
* Reads a dusktimeepoch_t from the current JSON object's nested time/
* timeZone/offsetTime fields. Errors if the key or any sub-field is missing.
*
* @param key The key to read from.
* @param dest The destination dusktimeepoch_t to assign to.
*/
#define readTime(key, dest) { \
yyjson_val *saveJsonTimeObj = yyjson_obj_get(object, key); \
if(saveJsonTimeObj == NULL) errorThrow("Save JSON missing '%s' key", key); \
yyjson_val *saveJsonTimeTime = yyjson_obj_get(saveJsonTimeObj, "time"); \
yyjson_val *saveJsonTimeZone = yyjson_obj_get(saveJsonTimeObj, "timeZone"); \
yyjson_val *saveJsonTimeOffset = yyjson_obj_get(saveJsonTimeObj, "offsetTime"); \
if(saveJsonTimeTime == NULL) errorThrow("Save JSON missing '%s.time' key", key); \
if(saveJsonTimeZone == NULL) { \
errorThrow("Save JSON missing '%s.timeZone' key", key); \
} \
if(saveJsonTimeOffset == NULL) { \
errorThrow("Save JSON missing '%s.offsetTime' key", key); \
} \
(dest).time = yyjson_get_real(saveJsonTimeTime); \
(dest).timeZone = yyjson_get_real(saveJsonTimeZone); \
(dest).offsetTime = yyjson_get_real(saveJsonTimeOffset); \
}
-63
View File
@@ -1,63 +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 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
/**
* Default language index for meta that's never actually been loaded from
* disk yet - index 0 into LOCALE_LIST (see locale/localemanager.h), i.e.
* LOCALE_EN_US.
*/
#define SAVE_META_LANGUAGE_DEFAULT 0
/**
* 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;
/**
* Index into LOCALE_LIST (see locale/localemanager.h) for the player's
* preferred UI language - the save meta is the only place this lives;
* read it directly via saveGetMeta()->language rather than caching it
* anywhere else. Never reorder/remove entries from LOCALE_LIST, only
* append, since this index must keep meaning the same locale across
* versions.
*/
uint8_t language;
} savemeta_t;
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
// Custom error codes for the save system, these start at 2 since ERROR_OK
// and ERROR_NOT_OK occupy 0 and 1 respectively.
#define SAVE_ERROR_NO_CURRENT_DEVICE ((errorcode_t)2)
#define SAVE_ERROR_NO_CURRENT_SLOT ((errorcode_t)3)
#define SAVE_ERROR_DEVICE_NOT_AVAILABLE ((errorcode_t)4)
#define SAVE_ERROR_SLOT_NOT_IN_USE ((errorcode_t)5)
+40
View File
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "savesettings.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "savejson.h"
void saveSettingsInit(savesettings_t *settings) {
assertNotNull(settings, "Settings cannot be null");
memorySet(settings, 0, sizeof(savesettings_t));
}
errorret_t saveSettingsWriteJSON(
savesettings_t *settings,
yyjson_mut_doc *doc,
yyjson_mut_val *object
) {
assertNotNull(settings, "Settings cannot be null");
assertNotNull(doc, "Doc cannot be null");
assertNotNull(object, "Object cannot be null");
writeInt32("someSetting", settings->someSetting);
errorOk();
}
errorret_t saveSettingsReadJSON(savesettings_t *settings, yyjson_val *object) {
assertNotNull(settings, "Settings cannot be null");
assertNotNull(object, "Object cannot be null");
readInt32("someSetting", settings->someSetting, 0);
errorOk();
}
+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 "time/timeepoch.h"
#include "savedevice.h"
#include "yyjson.h"
typedef struct savesettings_s {
int32_t someSetting;
} savesettings_t;
/**
* Inits the save settings with the default state, this is functionally "new
* game" but will not set the player name, as that is what we use to determine
* if a save slot is "in use" or not.
*
* @param settings The save settings to init.
*/
void saveSettingsInit(savesettings_t *settings);
/**
* Writes the given save settings out to the given JSON object.
*
* @param settings The save settings to write.
* @param doc The mutable JSON document that owns the object.
* @param object The mutable JSON object to write into.
* @return Error state if any.
*/
errorret_t saveSettingsWriteJSON(
savesettings_t *settings,
yyjson_mut_doc *doc,
yyjson_mut_val *object
);
/**
* Reads the given save settings in from the given JSON object.
*
* @param settings The save settings to read into.
* @param object The JSON object to read from.
* @return Error state if any.
*/
errorret_t saveSettingsReadJSON(savesettings_t *settings, yyjson_val *object);
+61
View File
@@ -0,0 +1,61 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "saveslot.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "savejson.h"
#include "time/time.h"
void saveSlotInit(saveslot_t *slot) {
assertNotNull(slot, "Slot cannot be null");
memorySet(slot, 0, sizeof(saveslot_t));
slot->version = 1;
}
bool_t saveSlotInUse(saveslotcache_t *slot) {
assertNotNull(slot, "Slot cannot be null");
return slot->name[0] != '\0';
}
bool_t saveSlotHasSaved(saveslotcache_t *slot) {
assertNotNull(slot, "Slot cannot be null");
return slot->time.time != 0;
}
errorret_t saveSlotWriteJSON(
saveslot_t *slot,
yyjson_mut_doc *doc,
yyjson_mut_val *object
) {
assertNotNull(slot, "Slot cannot be null");
assertNotNull(doc, "Doc cannot be null");
assertNotNull(object, "Object cannot be null");
// Update time
slot->cachedData.time = timeGetEpoch();
writeString("name", slot->cachedData.name);
writeTime("time", slot->cachedData.time);
writeInt32("playerLevel", slot->cachedData.playerLevel);
errorOk();
}
errorret_t saveSlotReadJSON(saveslot_t *slot, yyjson_val *object) {
assertNotNull(slot, "Slot cannot be null");
assertNotNull(object, "Object cannot be null");
char_t saveJsonStringBuffer[SAVE_JSON_STRING_BUFFER_SIZE];
readString("name", slot->cachedData.name, "", SAVE_SLOT_NAME_LENGTH);
readTime("time", slot->cachedData.time);
readInt32("playerLevel", slot->cachedData.playerLevel, 1);
errorOk();
}
+58 -80
View File
@@ -6,95 +6,73 @@
*/ */
#pragma once #pragma once
#include "dusk.h" #include "time/timeepoch.h"
#include "savedevice.h"
#include "yyjson.h"
/** Save slot format version. Increment on breaking change. */ #define SAVE_SLOT_NAME_LENGTH 8
#define SAVE_SLOT_VERSION 1 #ifndef SAVE_SLOT_COUNT
#define SAVE_SLOT_COUNT 3
/** 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 #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 { typedef struct {
/** Magic header bytes read from the slot; must equal SAVE_SLOT_HEADER. */ char_t name[SAVE_SLOT_NAME_LENGTH + 1];// 8 characters + null terminator
char_t header[SAVE_SLOT_HEADER_SIZE]; dusktimeepoch_t time;
/** Format version read from the slot; used to branch on older layouts. */ int32_t playerLevel;
uint32_t version; } saveslotcache_t;
/** Runtime flag - true if this slot was successfully loaded or written. */
bool_t exists; typedef struct saveslot_s {
/** The player's saved name. */ uint8_t version;
char_t playerName[SAVE_PLAYER_NAME_MAX]; uint8_t dataType;
/** Per-global-ID "already collected" flags - see globalitemstore.h. */
bool_t globalItemCollected[SAVE_GLOBAL_ITEM_COUNT_MAX]; saveslotcache_t cachedData;
/**
* 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; } saveslot_t;
/** /**
* Callback invoked when an async saveWriteSlot()/saveLoadSlot()/ * Inits the save slot with the default state, this is functionally "new game"
* saveWriteMeta()/saveLoadMeta() request completes. Declared here (rather * but will not set the player name, as that is what we use to determine if a
* than save.h) so platform save headers - which save.h's platform * save slot is "in use" or not.
* indirection pulls in before save.h finishes defining anything else - can
* reference it without a circular include.
* *
* @param result Whether the request succeeded. * @param slot The save slot to init.
* @param user User data passed through from the original call.
*/ */
typedef void (*savecallback_t)(errorret_t result, void *user); void saveSlotInit(saveslot_t *slot);
/** /**
* The blank template used to (re)initialize a save slot for a brand new * Checks if the save slot is in use, this is determined by checking if the
* game via saveLoadDefault(), rather than reading one from persistent * player name is set or not.
* storage. Deliberately generic at this layer (empty collected-item *
* flags, no story flags set) - anything that needs CSV-defined story flag * @param slot The save slot to check.
* defaults layers that on top afterward (see storyFlagInitDefaults()), * @return True if the save slot is in use, false otherwise.
* since saveslot.h stays a leaf header with no dependency on generated
* story content (see SAVE_STORY_FLAG_COUNT_MAX's doc comment above).
*/ */
extern const saveslot_t SAVE_DEFAULT; bool_t saveSlotInUse(saveslotcache_t *slot);
/**
* Returns whether or not the given save slot has ever saved (has an epoc non 0)
*
* @param slot The save slot to check.
* @return True if the save slot has ever saved, false otherwise.
*/
bool_t saveSlotHasSaved(saveslotcache_t *slot);
/**
* Writes the given save slot out to the given JSON object.
*
* @param slot The save slot to write.
* @param doc The mutable JSON document that owns the object.
* @param object The mutable JSON object to write into.
* @return Error state if any.
*/
errorret_t saveSlotWriteJSON(
saveslot_t *slot,
yyjson_mut_doc *doc,
yyjson_mut_val *object
);
/**
* Reads the given save slot in from the given JSON object.
*
* @param slot The save slot to read into.
* @param object The JSON object to read from.
* @return Error state if any.
*/
errorret_t saveSlotReadJSON(saveslot_t *slot, yyjson_val *object);
-403
View File
@@ -1,403 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "save/savestream.h"
#include "util/crypt.h"
#include "util/endian.h"
#include "util/string.h"
#include "util/memory.h"
errorret_t saveStreamReadBytesRawImpl(
savestream_t *stream, void *buf, const size_t len
) {
#ifdef saveStreamReadBytesPlatform
errorChain(saveStreamReadBytesPlatform(stream, buf, len));
#endif
errorOk();
}
errorret_t saveStreamWriteBytesRawImpl(
savestream_t *stream, const void *buf, const size_t len
) {
#ifdef saveStreamWriteBytesPlatform
errorChain(saveStreamWriteBytesPlatform(stream, buf, len));
#endif
errorOk();
}
errorret_t saveStreamReadBytesImpl(
savestream_t *stream, void *buf, const size_t len
) {
errorChain(saveStreamReadBytesRawImpl(stream, buf, len));
cryptCRC32Update(&stream->checksum, buf, len);
errorOk();
}
errorret_t saveStreamWriteBytesImpl(
savestream_t *stream, const void *buf, const size_t len
) {
cryptCRC32Update(&stream->checksum, buf, len);
errorChain(saveStreamWriteBytesRawImpl(stream, buf, len));
errorOk();
}
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, headerPosition + headerSize));
#endif
errorChain(saveStreamWriteBytesRawImpl(
stream, &leChecksum, sizeof(uint32_t)
));
errorOk();
}
errorret_t saveStreamVerifyChecksumImpl(
savestream_t *stream, const char_t *sectionLabel
) {
uint32_t computed = cryptCRC32End(stream->checksum);
if(computed != stream->expectedChecksum) {
errorThrow("%s has invalid checksum", sectionLabel);
}
errorOk();
}
errorret_t saveStreamReadHeaderImpl(
savestream_t *stream, char_t *header, const char_t *expectedHeader,
const size_t headerSize
) {
errorChain(saveStreamReadBytesRawImpl(stream, header, headerSize));
for(size_t i = 0; i < headerSize; i++) {
if(header[i] != expectedHeader[i]) {
errorThrow("Save data has invalid header");
}
}
uint32_t leChecksum;
errorChain(saveStreamReadBytesRawImpl(stream, &leChecksum, sizeof(uint32_t)));
stream->expectedChecksum = endianLittleToHost32(leChecksum);
stream->checksum = cryptCRC32Begin();
errorOk();
}
errorret_t saveStreamWriteHeaderImpl(
savestream_t *stream, const char_t *header, const size_t headerSize
) {
errorChain(saveStreamWriteBytesRawImpl(stream, header, headerSize));
uint32_t placeholder = 0;
errorChain(saveStreamWriteBytesRawImpl(
stream, &placeholder, sizeof(uint32_t)
));
stream->checksum = cryptCRC32Begin();
errorOk();
}
errorret_t saveStreamReadVersionImpl(savestream_t *stream, uint32_t *out) {
uint32_t raw;
errorChain(saveStreamReadBytesImpl(stream, &raw, sizeof(uint32_t)));
*out = endianLittleToHost32(raw);
errorOk();
}
errorret_t saveStreamWriteVersionImpl(
savestream_t *stream, const uint32_t *input
) {
uint32_t raw = endianLittleToHost32(*input);
errorChain(saveStreamWriteBytesImpl(stream, &raw, sizeof(uint32_t)));
errorOk();
}
errorret_t saveStreamReadBoolImpl(savestream_t *stream, bool_t *out) {
uint8_t raw;
errorChain(saveStreamReadBytesImpl(stream, &raw, sizeof(uint8_t)));
*out = (bool_t)(raw != 0);
errorOk();
}
errorret_t saveStreamWriteBoolImpl(savestream_t *stream, const bool_t *input) {
uint8_t raw = *input ? 1 : 0;
errorChain(saveStreamWriteBytesImpl(stream, &raw, sizeof(uint8_t)));
errorOk();
}
errorret_t saveStreamReadInt8Impl(savestream_t *stream, int8_t *out) {
errorChain(saveStreamReadBytesImpl(stream, out, sizeof(int8_t)));
errorOk();
}
errorret_t saveStreamWriteInt8Impl(savestream_t *stream, const int8_t *input) {
errorChain(saveStreamWriteBytesImpl(stream, input, sizeof(int8_t)));
errorOk();
}
errorret_t saveStreamReadUInt8Impl(savestream_t *stream, uint8_t *out) {
errorChain(saveStreamReadBytesImpl(stream, out, sizeof(uint8_t)));
errorOk();
}
errorret_t saveStreamWriteUInt8Impl(
savestream_t *stream, const uint8_t *input
) {
errorChain(saveStreamWriteBytesImpl(stream, input, sizeof(uint8_t)));
errorOk();
}
errorret_t saveStreamReadInt16Impl(savestream_t *stream, int16_t *out) {
uint16_t raw;
errorChain(saveStreamReadBytesImpl(stream, &raw, sizeof(uint16_t)));
uint16_t host = endianLittleToHost16(raw);
memoryCopy(out, &host, sizeof(int16_t));
errorOk();
}
errorret_t saveStreamWriteInt16Impl(
savestream_t *stream, const int16_t *input
) {
uint16_t raw;
memoryCopy(&raw, input, sizeof(int16_t));
raw = endianLittleToHost16(raw);
errorChain(saveStreamWriteBytesImpl(stream, &raw, sizeof(uint16_t)));
errorOk();
}
errorret_t saveStreamReadUInt16Impl(savestream_t *stream, uint16_t *out) {
uint16_t raw;
errorChain(saveStreamReadBytesImpl(stream, &raw, sizeof(uint16_t)));
*out = endianLittleToHost16(raw);
errorOk();
}
errorret_t saveStreamWriteUInt16Impl(
savestream_t *stream, const uint16_t *input
) {
uint16_t raw = endianLittleToHost16(*input);
errorChain(saveStreamWriteBytesImpl(stream, &raw, sizeof(uint16_t)));
errorOk();
}
errorret_t saveStreamReadInt32Impl(savestream_t *stream, int32_t *out) {
uint32_t raw;
errorChain(saveStreamReadBytesImpl(stream, &raw, sizeof(uint32_t)));
uint32_t host = endianLittleToHost32(raw);
memoryCopy(out, &host, sizeof(int32_t));
errorOk();
}
errorret_t saveStreamWriteInt32Impl(
savestream_t *stream, const int32_t *input
) {
uint32_t raw;
memoryCopy(&raw, input, sizeof(int32_t));
raw = endianLittleToHost32(raw);
errorChain(saveStreamWriteBytesImpl(stream, &raw, sizeof(uint32_t)));
errorOk();
}
errorret_t saveStreamReadUInt32Impl(savestream_t *stream, uint32_t *out) {
uint32_t raw;
errorChain(saveStreamReadBytesImpl(stream, &raw, sizeof(uint32_t)));
*out = endianLittleToHost32(raw);
errorOk();
}
errorret_t saveStreamWriteUInt32Impl(
savestream_t *stream, const uint32_t *input
) {
uint32_t raw = endianLittleToHost32(*input);
errorChain(saveStreamWriteBytesImpl(stream, &raw, sizeof(uint32_t)));
errorOk();
}
errorret_t saveStreamReadInt64Impl(savestream_t *stream, int64_t *out) {
uint64_t raw;
errorChain(saveStreamReadBytesImpl(stream, &raw, sizeof(uint64_t)));
uint64_t host = endianLittleToHost64(raw);
memoryCopy(out, &host, sizeof(int64_t));
errorOk();
}
errorret_t saveStreamWriteInt64Impl(
savestream_t *stream, const int64_t *input
) {
uint64_t raw;
memoryCopy(&raw, input, sizeof(int64_t));
raw = endianLittleToHost64(raw);
errorChain(saveStreamWriteBytesImpl(stream, &raw, sizeof(uint64_t)));
errorOk();
}
errorret_t saveStreamReadUInt64Impl(savestream_t *stream, uint64_t *out) {
uint64_t raw;
errorChain(saveStreamReadBytesImpl(stream, &raw, sizeof(uint64_t)));
*out = endianLittleToHost64(raw);
errorOk();
}
errorret_t saveStreamWriteUInt64Impl(
savestream_t *stream, const uint64_t *input
) {
uint64_t raw = endianLittleToHost64(*input);
errorChain(saveStreamWriteBytesImpl(stream, &raw, sizeof(uint64_t)));
errorOk();
}
errorret_t saveStreamReadFloatImpl(savestream_t *stream, float_t *out) {
float_t raw;
errorChain(saveStreamReadBytesImpl(stream, &raw, sizeof(float_t)));
*out = endianLittleToHostFloat(raw);
errorOk();
}
errorret_t saveStreamWriteFloatImpl(
savestream_t *stream, const float_t *input
) {
float_t raw = endianLittleToHostFloat(*input);
errorChain(saveStreamWriteBytesImpl(stream, &raw, sizeof(float_t)));
errorOk();
}
errorret_t saveStreamReadStringImpl(
savestream_t *stream, char_t *out, const size_t maxLen
) {
for(size_t i = 0; i < maxLen; i++) {
errorChain(saveStreamReadBytesImpl(stream, &out[i], sizeof(char_t)));
if(out[i] == '\0') errorOk();
}
out[maxLen - 1] = '\0';
errorOk();
}
errorret_t saveStreamWriteStringImpl(
savestream_t *stream, const char_t *input, const size_t maxLen
) {
size_t len = strlen(input);
if(len >= maxLen) len = maxLen - 1;
errorChain(saveStreamWriteBytesImpl(stream, input, len + 1));
errorOk();
}
errorret_t saveStreamReadDateImpl(savestream_t *stream, dusktimeepoch_t *out) {
uint64_t raw;
errorChain(saveStreamReadBytesImpl(stream, &raw, sizeof(uint64_t)));
raw = endianLittleToHost64(raw);
memoryCopy(&out->time, &raw, sizeof(double_t));
errorChain(saveStreamReadBytesImpl(stream, &raw, sizeof(uint64_t)));
raw = endianLittleToHost64(raw);
memoryCopy(&out->timeZone, &raw, sizeof(double_t));
errorChain(saveStreamReadBytesImpl(stream, &raw, sizeof(uint64_t)));
raw = endianLittleToHost64(raw);
memoryCopy(&out->offsetTime, &raw, sizeof(double_t));
errorOk();
}
errorret_t saveStreamWriteDateImpl(
savestream_t *stream, const dusktimeepoch_t *input
) {
uint64_t raw;
memoryCopy(&raw, &input->time, sizeof(double_t));
raw = endianLittleToHost64(raw);
errorChain(saveStreamWriteBytesImpl(stream, &raw, sizeof(uint64_t)));
memoryCopy(&raw, &input->timeZone, sizeof(double_t));
raw = endianLittleToHost64(raw);
errorChain(saveStreamWriteBytesImpl(stream, &raw, sizeof(uint64_t)));
memoryCopy(&raw, &input->offsetTime, sizeof(double_t));
raw = endianLittleToHost64(raw);
errorChain(saveStreamWriteBytesImpl(stream, &raw, sizeof(uint64_t)));
errorOk();
}
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);
saveFileReadUInt8(stream, &meta->language);
errorChain(saveStreamVerifyChecksumImpl(stream, "Save meta"));
meta->exists = true;
errorOk();
}
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);
saveFileWriteUInt8(stream, &meta->language);
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();
}
-511
View File
@@ -1,511 +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 "saveslot.h"
#include "savemeta.h"
#include "save/saveplatform.h"
#include "time/timeepoch.h"
typedef struct {
bool_t found;
uint32_t checksum;
uint32_t expectedChecksum;
saveplatformstream_t platform;
} savestream_t;
/**
* Reads bytes from the platform stream without updating the CRC.
*
* @param stream Active stream.
* @param buf Destination buffer.
* @param len Number of bytes to read.
* @return An error if the read fails.
*/
errorret_t saveStreamReadBytesRawImpl(
savestream_t *stream, void *buf, const size_t len
);
/**
* Writes bytes to the platform stream without updating the CRC.
*
* @param stream Active stream.
* @param buf Source buffer.
* @param len Number of bytes to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteBytesRawImpl(
savestream_t *stream, const void *buf, const size_t len
);
/**
* Reads bytes from the platform stream and accumulates them into the CRC.
*
* @param stream Active stream.
* @param buf Destination buffer.
* @param len Number of bytes to read.
* @return An error if the read fails.
*/
errorret_t saveStreamReadBytesImpl(
savestream_t *stream, void *buf, const size_t len
);
/**
* Updates the CRC then writes bytes to the platform stream.
*
* @param stream Active stream.
* @param buf Source buffer.
* @param len Number of bytes to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteBytesImpl(
savestream_t *stream, const void *buf, const size_t len
);
/**
* 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, const size_t headerPosition, const size_t headerSize
);
/**
* Verifies that the CRC32 accumulated during loading matches the value
* stored in this section's header.
*
* @param stream Active read stream (loading must be complete).
* @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 char_t *sectionLabel
);
/**
* 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 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, const char_t *expectedHeader,
const size_t headerSize
);
/**
* 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 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, const size_t headerSize
);
/**
* Reads a little-endian uint32 version field from the stream.
*
* @param stream Active read stream.
* @param out Receives the host-order value.
* @return An error if the read fails.
*/
errorret_t saveStreamReadVersionImpl(savestream_t *stream, uint32_t *out);
/**
* Writes a uint32 version field to the stream in little-endian order.
*
* @param stream Active write stream.
* @param input Value to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteVersionImpl(
savestream_t *stream, const uint32_t *input
);
/**
* Reads a single byte as a boolean (0 = false, non-zero = true).
*
* @param stream Active read stream.
* @param out Receives the boolean value.
* @return An error if the read fails.
*/
errorret_t saveStreamReadBoolImpl(savestream_t *stream, bool_t *out);
/**
* Writes a boolean as a single byte (true = 1, false = 0).
*
* @param stream Active write stream.
* @param input Value to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteBoolImpl(savestream_t *stream, const bool_t *input);
/**
* Reads a signed 8-bit integer from the stream.
*
* @param stream Active read stream.
* @param out Receives the value.
* @return An error if the read fails.
*/
errorret_t saveStreamReadInt8Impl(savestream_t *stream, int8_t *out);
/**
* Writes a signed 8-bit integer to the stream.
*
* @param stream Active write stream.
* @param input Value to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteInt8Impl(savestream_t *stream, const int8_t *input);
/**
* Reads an unsigned 8-bit integer from the stream.
*
* @param stream Active read stream.
* @param out Receives the value.
* @return An error if the read fails.
*/
errorret_t saveStreamReadUInt8Impl(savestream_t *stream, uint8_t *out);
/**
* Writes an unsigned 8-bit integer to the stream.
*
* @param stream Active write stream.
* @param input Value to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteUInt8Impl(savestream_t *stream, const uint8_t *input);
/**
* Reads a little-endian signed 16-bit integer from the stream.
*
* @param stream Active read stream.
* @param out Receives the host-order value.
* @return An error if the read fails.
*/
errorret_t saveStreamReadInt16Impl(savestream_t *stream, int16_t *out);
/**
* Writes a signed 16-bit integer to the stream in little-endian order.
*
* @param stream Active write stream.
* @param input Value to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteInt16Impl(
savestream_t *stream, const int16_t *input
);
/**
* Reads a little-endian unsigned 16-bit integer from the stream.
*
* @param stream Active read stream.
* @param out Receives the host-order value.
* @return An error if the read fails.
*/
errorret_t saveStreamReadUInt16Impl(savestream_t *stream, uint16_t *out);
/**
* Writes an unsigned 16-bit integer to the stream in little-endian order.
*
* @param stream Active write stream.
* @param input Value to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteUInt16Impl(
savestream_t *stream, const uint16_t *input
);
/**
* Reads a little-endian signed 32-bit integer from the stream.
*
* @param stream Active read stream.
* @param out Receives the host-order value.
* @return An error if the read fails.
*/
errorret_t saveStreamReadInt32Impl(savestream_t *stream, int32_t *out);
/**
* Writes a signed 32-bit integer to the stream in little-endian order.
*
* @param stream Active write stream.
* @param input Value to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteInt32Impl(
savestream_t *stream, const int32_t *input
);
/**
* Reads a little-endian unsigned 32-bit integer from the stream.
*
* @param stream Active read stream.
* @param out Receives the host-order value.
* @return An error if the read fails.
*/
errorret_t saveStreamReadUInt32Impl(savestream_t *stream, uint32_t *out);
/**
* Writes an unsigned 32-bit integer to the stream in little-endian order.
*
* @param stream Active write stream.
* @param input Value to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteUInt32Impl(
savestream_t *stream, const uint32_t *input
);
/**
* Reads a little-endian signed 64-bit integer from the stream.
*
* @param stream Active read stream.
* @param out Receives the host-order value.
* @return An error if the read fails.
*/
errorret_t saveStreamReadInt64Impl(savestream_t *stream, int64_t *out);
/**
* Writes a signed 64-bit integer to the stream in little-endian order.
*
* @param stream Active write stream.
* @param input Value to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteInt64Impl(
savestream_t *stream, const int64_t *input
);
/**
* Reads a little-endian unsigned 64-bit integer from the stream.
*
* @param stream Active read stream.
* @param out Receives the host-order value.
* @return An error if the read fails.
*/
errorret_t saveStreamReadUInt64Impl(savestream_t *stream, uint64_t *out);
/**
* Writes an unsigned 64-bit integer to the stream in little-endian order.
*
* @param stream Active write stream.
* @param input Value to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteUInt64Impl(
savestream_t *stream, const uint64_t *input
);
/**
* Reads a little-endian float from the stream.
*
* @param stream Active read stream.
* @param out Receives the host-order value.
* @return An error if the read fails.
*/
errorret_t saveStreamReadFloatImpl(savestream_t *stream, float_t *out);
/**
* Writes a float to the stream in little-endian order.
*
* @param stream Active write stream.
* @param input Value to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteFloatImpl(
savestream_t *stream, const float_t *input
);
/**
* Reads a null-terminated string from the stream up to maxLen bytes
* (including the terminator). Always null-terminates the output buffer.
*
* @param stream Active read stream.
* @param out Destination buffer of at least maxLen bytes.
* @param maxLen Maximum bytes to read, including the null terminator.
* @return An error if the read fails.
*/
errorret_t saveStreamReadStringImpl(
savestream_t *stream, char_t *out, const size_t maxLen
);
/**
* Writes a null-terminated string to the stream, truncating to maxLen-1
* characters and always appending a null terminator.
*
* @param stream Active write stream.
* @param input Source string.
* @param maxLen Maximum bytes to write, including the null terminator.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteStringImpl(
savestream_t *stream, const char_t *input, const size_t maxLen
);
/**
* Reads a dusktimeepoch_t as three little-endian 64-bit IEEE 754 doubles
* (time, timeZone, offsetTime).
*
* @param stream Active read stream.
* @param out Receives the epoch value.
* @return An error if the read fails.
*/
errorret_t saveStreamReadDateImpl(
savestream_t *stream, dusktimeepoch_t *out
);
/**
* Writes a dusktimeepoch_t as three little-endian 64-bit IEEE 754 doubles
* (time, timeZone, offsetTime).
*
* @param stream Active write stream.
* @param input Epoch value to write.
* @return An error if the write fails.
*/
errorret_t saveStreamWriteDateImpl(
savestream_t *stream, const dusktimeepoch_t *input
);
/**
* Reads a self-contained save meta section (header, version, fields,
* checksum verification) from the stream.
*
* @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 saveMetaSerializeRead(savestream_t *stream, savemeta_t *meta);
/**
* Writes a self-contained save meta section (header, version, fields,
* checksum) to the stream.
*
* @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 saveMetaSerializeWrite(savestream_t *stream, savemeta_t *meta);
/**
* 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))
#define saveFileWriteVersion(stream, input) \
errorChain(saveStreamWriteVersionImpl(stream, input))
#define saveFileReadBool(stream, out) \
errorChain(saveStreamReadBoolImpl(stream, out))
#define saveFileWriteBool(stream, input) \
errorChain(saveStreamWriteBoolImpl(stream, input))
#define saveFileReadInt8(stream, out) \
errorChain(saveStreamReadInt8Impl(stream, out))
#define saveFileWriteInt8(stream, input) \
errorChain(saveStreamWriteInt8Impl(stream, input))
#define saveFileReadUInt8(stream, out) \
errorChain(saveStreamReadUInt8Impl(stream, out))
#define saveFileWriteUInt8(stream, input) \
errorChain(saveStreamWriteUInt8Impl(stream, input))
#define saveFileReadInt16(stream, out) \
errorChain(saveStreamReadInt16Impl(stream, out))
#define saveFileWriteInt16(stream, input) \
errorChain(saveStreamWriteInt16Impl(stream, input))
#define saveFileReadUInt16(stream, out) \
errorChain(saveStreamReadUInt16Impl(stream, out))
#define saveFileWriteUInt16(stream, input) \
errorChain(saveStreamWriteUInt16Impl(stream, input))
#define saveFileReadInt32(stream, out) \
errorChain(saveStreamReadInt32Impl(stream, out))
#define saveFileWriteInt32(stream, input) \
errorChain(saveStreamWriteInt32Impl(stream, input))
#define saveFileReadUInt32(stream, out) \
errorChain(saveStreamReadUInt32Impl(stream, out))
#define saveFileWriteUInt32(stream, input) \
errorChain(saveStreamWriteUInt32Impl(stream, input))
#define saveFileReadInt64(stream, out) \
errorChain(saveStreamReadInt64Impl(stream, out))
#define saveFileWriteInt64(stream, input) \
errorChain(saveStreamWriteInt64Impl(stream, input))
#define saveFileReadUInt64(stream, out) \
errorChain(saveStreamReadUInt64Impl(stream, out))
#define saveFileWriteUInt64(stream, input) \
errorChain(saveStreamWriteUInt64Impl(stream, input))
#define saveFileReadFloat(stream, out) \
errorChain(saveStreamReadFloatImpl(stream, out))
#define saveFileWriteFloat(stream, input) \
errorChain(saveStreamWriteFloatImpl(stream, input))
#define saveFileReadString(stream, out, maxLen) \
errorChain(saveStreamReadStringImpl(stream, out, maxLen))
#define saveFileWriteString(stream, input, maxLen) \
errorChain(saveStreamWriteStringImpl(stream, input, maxLen))
#define saveFileReadDate(stream, out) \
errorChain(saveStreamReadDateImpl(stream, out))
#define saveFileWriteDate(stream, input) \
errorChain(saveStreamWriteDateImpl(stream, input))
+28 -58
View File
@@ -9,70 +9,40 @@
#include "assert/assert.h" #include "assert/assert.h"
#include "util/memory.h" #include "util/memory.h"
#include "error/error.h" #include "error/error.h"
#include "save/save.h" #include "display/screen/screen.h"
#include "ui/frame/initial/uiinitialnocard.h" #include "console/console.h"
#include "ui/frame/initial/uiinitialcreatesave.h" #include "rpg/cutscene/cutscenesystem.h"
#include "asset/asset.h"
static void sceneInitialCheckSave(void); // Loaded lazily and kept resident for the rest of the process - this scene
// only ever runs once at boot, but there's no reason to unlock it (same
static void sceneInitialCreateSaveWriteComplete(errorret_t result, void *user) { // lifetime convention as e.g. LOCALE.entry).
if(errorIsNotOk(result)) errorCatch(errorPrint(result)); static assetentry_t *INITIAL_CUTSCENE_ENTRY = NULL;
sceneSet(SCENE_TYPE_MAIN_MENU);
}
static void sceneInitialCreateSaveResult(const bool_t create, void *user) {
if(!create) {
sceneSet(SCENE_TYPE_MAIN_MENU);
return;
}
saveWriteSlot(SAVE_ACTIVE_SLOT, sceneInitialCreateSaveWriteComplete, NULL);
}
static void sceneInitialNoCardResult(const bool_t retry, void *user) {
if(retry) {
sceneInitialCheckSave();
return;
}
// The player explicitly acknowledged there's no save device and chose
// to proceed anyway - stick with that for the rest of the session (see
// saveMarkTemporary()'s doc comment for why this can't be un-set later).
saveMarkTemporary();
sceneSet(SCENE_TYPE_MAIN_MENU);
}
static void sceneInitialLoadComplete(errorret_t result, void *user) {
if(errorIsNotOk(result) || !saveIsAvailable()) {
errorCatch(result);
uiInitialNoCardOpen(sceneInitialNoCardResult, NULL);
return;
}
if(saveSlotExists(SAVE_ACTIVE_SLOT)) {
sceneSet(SCENE_TYPE_MAIN_MENU);
return;
}
uiInitialCreateSaveOpen(sceneInitialCreateSaveResult, NULL);
}
static void sceneInitialCheckSave(void) {
if(!saveIsAvailable()) {
uiInitialNoCardOpen(sceneInitialNoCardResult, NULL);
return;
}
if(saveIsBusy()) return;
saveLoadSlot(SAVE_ACTIVE_SLOT, sceneInitialLoadComplete, NULL);
}
errorret_t sceneInitialInit(scenedata_t *sceneData) { errorret_t sceneInitialInit(scenedata_t *sceneData) {
assertNotNull(sceneData, "Scene data cannot be null"); assertNotNull(sceneData, "Scene data cannot be null");
memoryZero(&sceneData->initial, sizeof(sceneinitial_t)); memoryZero(&sceneData->initial, sizeof(sceneinitial_t));
sceneInitialCheckSave(); // Set background color to black for the initial scene
SCREEN.background = COLOR_BLACK;
// Runtime-loaded from assets/cutscenes/initial.cts (authored at
// assetsraw/cutscenes/initial.jsonc via `python3 -m tools.asset.cutscene`)
// - checks for a save device, retrying on failure, then hands off to the
// main menu scene via a plain CUTSCENE_SCENE item (no native callback
// needed here, unlike the main menu's own start-game cutscene).
if(INITIAL_CUTSCENE_ENTRY == NULL) {
INITIAL_CUTSCENE_ENTRY = assetLock(
"cutscenes/initial.cts", ASSET_LOADER_TYPE_CUTSCENE, NULL
);
}
errorret_t result = assetRequireLoaded(INITIAL_CUTSCENE_ENTRY);
if(errorIsNotOk(result)) {
errorCatch(errorPrint(result));
assertTrue(false, "Failed to load initial scene cutscene asset");
}
cutsceneSystemStartCutscene(&INITIAL_CUTSCENE_ENTRY->data.cutscene.cutscene);
errorOk(); errorOk();
} }
+2 -4
View File
@@ -8,11 +8,9 @@
#pragma once #pragma once
#include "scene/scenebase.h" #include "scene/scenebase.h"
// No per-scene state needed - the save globals and the two modal UI
// elements (see ui/frame/initial/) carry everything this scene cares
// about. A byte placeholder keeps the struct non-empty for portability.
typedef struct { typedef struct {
uint8_t reserved; // uint32_t callbackState;
void *nothing;
} sceneinitial_t; } sceneinitial_t;
/** /**
+57
View File
@@ -6,8 +6,65 @@
*/ */
#include "scenemainmenu.h" #include "scenemainmenu.h"
#include "ui/screen/mainmenu/uimainmenu.h"
#include "ui/dialog/save/uiselectsave.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "scene/scene.h"
#include "asset/asset.h"
#include "assert/assert.h"
// Loaded lazily on first Start Game click and kept resident for the rest
// of the process - it's tiny and reused every time, so there's no benefit
// to unlocking/reloading it between attempts (same lifetime convention as
// e.g. LOCALE.entry).
static assetentry_t *MAIN_MENU_START_GAME_CUTSCENE_ENTRY = NULL;
void sceneMainMenuSelectSaveResult(const uint8_t slotIndex, void *user) {
if(slotIndex == UI_SELECT_SAVE_RESULT_NONE) {
uiMainMenuOpen();
return;
}
// TODO: load/start the game using the chosen save slot.
sceneSet(SCENE_TYPE_OVERWORLD);
}
void sceneMainMenuOpenSelectSave(void *userData) {
uiSelectSaveOpen(
UI_SELECT_SAVE_TYPE_LOAD, sceneMainMenuSelectSaveResult, NULL
);
}
// Loads every save slot before opening the load-game picker (checking/
// retry-on-error shape mirroring the initial scene's device lookup, see
// scene/initial/sceneinitial.c) - runtime-loaded from
// assets/cutscenes/main_menu_start_game.cts (authored at
// assetsraw/cutscenes/main_menu_start_game.json via
// `python3 -m tools.asset.cutscene`) rather than compiled in, since it's
// player-facing flow rather than core engine wiring. The file ends at the
// LOADED marker with no further action - sceneMainMenuOpenSelectSave is
// armed as the completion callback below instead of being baked into the
// cutscene itself, since a file can't store a native function pointer.
void sceneMainMenuStartGame(void) {
if(MAIN_MENU_START_GAME_CUTSCENE_ENTRY == NULL) {
MAIN_MENU_START_GAME_CUTSCENE_ENTRY = assetLock(
"cutscenes/main_menu_start_game.cts", ASSET_LOADER_TYPE_CUTSCENE, NULL
);
}
errorret_t result = assetRequireLoaded(MAIN_MENU_START_GAME_CUTSCENE_ENTRY);
if(errorIsNotOk(result)) {
errorCatch(errorPrint(result));
assertTrue(false, "Failed to load main menu start-game cutscene asset");
}
cutsceneSystemStartCutscene(
&MAIN_MENU_START_GAME_CUTSCENE_ENTRY->data.cutscene.cutscene
);
cutsceneSystemSetOnComplete(sceneMainMenuOpenSelectSave);
}
errorret_t sceneMainMenuInit(scenedata_t *sceneData) { errorret_t sceneMainMenuInit(scenedata_t *sceneData) {
uiMainMenuOpen();
errorOk(); errorOk();
} }
+13 -7
View File
@@ -8,16 +8,14 @@
#pragma once #pragma once
#include "scene/scenebase.h" #include "scene/scenebase.h"
// Empty for now -- the menu itself lives in ui/frame/mainmenu/uimainmenu.c, // Empty - the menu itself lives in ui/screen/mainmenu/uimainmenu.c. A byte
// driven by the global UI element pipeline (see uimainmenu.c's own // placeholder keeps the struct non-empty for portability.
// SCENE.current check for when it shows itself). A byte placeholder keeps
// the struct non-empty for portability.
typedef struct { typedef struct {
uint8_t reserved; uint8_t reserved;
} scenemainmenu_t; } scenemainmenu_t;
/** /**
* Initializes the main menu scene. * Initializes the main menu scene by opening the main menu panel.
* *
* @param sceneData The scene data used for this scene. * @param sceneData The scene data used for this scene.
* @return An error if the init failed, or errorOk() if it succeeded. * @return An error if the init failed, or errorOk() if it succeeded.
@@ -25,7 +23,15 @@ typedef struct {
errorret_t sceneMainMenuInit(scenedata_t *sceneData); errorret_t sceneMainMenuInit(scenedata_t *sceneData);
/** /**
* Updates the main menu scene. Currently a no-op -- the menu drives * Starts the "Start Game" flow: runs a cutscene that loads every save
* slot (retrying on error, prompting if no save device is found) and
* opens the load-game picker once it succeeds. Called by
* ui/screen/mainmenu/uimainmenu.c when the player selects Start Game.
*/
void sceneMainMenuStartGame(void);
/**
* Updates the main menu scene. Currently a no-op - the menu drives
* itself via the global UI element pipeline. * itself via the global UI element pipeline.
* *
* @param sceneData The scene data used for this scene. * @param sceneData The scene data used for this scene.
@@ -34,7 +40,7 @@ errorret_t sceneMainMenuInit(scenedata_t *sceneData);
errorret_t sceneMainMenuUpdate(scenedata_t *sceneData); errorret_t sceneMainMenuUpdate(scenedata_t *sceneData);
/** /**
* Renders the main menu scene. Currently a no-op -- the menu draws * Renders the main menu scene. Currently a no-op - the menu draws
* itself via the global UI element pipeline. * itself via the global UI element pipeline.
* *
* @param sceneData The scene data used for this scene. * @param sceneData The scene data used for this scene.
+8
View File
@@ -16,6 +16,10 @@
#error "systemInitPlatform is not defined" #error "systemInitPlatform is not defined"
#endif #endif
#ifndef systemGetLocalePlatform
#error "systemGetLocalePlatform is not defined"
#endif
errorret_t systemInit() { errorret_t systemInit() {
return systemInitPlatform(); return systemInitPlatform();
} }
@@ -24,6 +28,10 @@ systemdialogtype_t systemGetActiveDialogType() {
return systemGetActiveDialogTypePlatform(); return systemGetActiveDialogTypePlatform();
} }
const localeinfo_t * systemGetLocale(void) {
return systemGetLocalePlatform();
}
systemplatform_t systemGetPlatform(void) { systemplatform_t systemGetPlatform(void) {
#if defined(DUSK_KNULLI) #if defined(DUSK_KNULLI)
return SYSTEM_PLATFORM_KNULLI; return SYSTEM_PLATFORM_KNULLI;
+8
View File
@@ -8,6 +8,7 @@
#pragma once #pragma once
#include "error/error.h" #include "error/error.h"
#include "system/systemplatformlist.h" #include "system/systemplatformlist.h"
#include "locale/localeinfo.h"
#define SYSTEM_PLATFORM(name, value) SYSTEM_PLATFORM_##name = value, #define SYSTEM_PLATFORM(name, value) SYSTEM_PLATFORM_##name = value,
typedef enum { SYSTEM_PLATFORM_LIST } systemplatform_t; typedef enum { SYSTEM_PLATFORM_LIST } systemplatform_t;
@@ -48,3 +49,10 @@ systemdialogtype_t systemGetActiveDialogType();
* @return The current platform. * @return The current platform.
*/ */
systemplatform_t systemGetPlatform(void); systemplatform_t systemGetPlatform(void);
/**
* Returns the current locale of the system.
*
* @return The current locale.
*/
const localeinfo_t * systemGetLocale(void);
+3 -1
View File
@@ -4,7 +4,8 @@
# https://opensource.org/licenses/MIT # https://opensource.org/licenses/MIT
add_subdirectory(debug) add_subdirectory(debug)
add_subdirectory(frame) add_subdirectory(dialog)
add_subdirectory(screen)
add_subdirectory(focus) add_subdirectory(focus)
add_subdirectory(overlay) add_subdirectory(overlay)
add_subdirectory(rpg) add_subdirectory(rpg)
@@ -16,5 +17,6 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
ui.c ui.c
uielement.c uielement.c
uielementlist.c
# uitextbox.c # uitextbox.c
) )
+20 -9
View File
@@ -6,23 +6,34 @@
*/ */
#include "uiconsole.h" #include "uiconsole.h"
#include "console/console.h"
#include "display/screen/screen.h" #include "display/screen/screen.h"
#include "display/text/text.h" #include "display/text/text.h"
#include "display/spritebatch/spritebatch.h" #include "display/spritebatch/spritebatch.h"
uiconsole_t UICONSOLE;
errorret_t uiConsoleInit(void) {
float_t lineH = (float_t)FONT_DEFAULT.tileset->tileHeight;
for(uint32_t i = 0; i < CONSOLE_HISTORY_MAX; i++) {
uiLabelInit(
&UICONSOLE.labels[i],
CONSOLE.line[i],
UICONSOLE.sprites[i], CONSOLE_LINE_MAX
);
uiLabelSetX(&UICONSOLE.labels[i], (float_t)SCREEN.scanX);
uiLabelSetY(&UICONSOLE.labels[i], (float_t)SCREEN.scanY + lineH * (float_t)i);
}
errorOk();
}
errorret_t uiConsoleDraw(void) { errorret_t uiConsoleDraw(void) {
if(!CONSOLE.visible) errorOk(); if(!CONSOLE.visible) errorOk();
float_t lineH = (float_t)FONT_DEFAULT.tileset->tileHeight;
for(uint32_t i = 0; i < CONSOLE_HISTORY_MAX; i++) { for(uint32_t i = 0; i < CONSOLE_HISTORY_MAX; i++) {
errorChain(textDraw( UICONSOLE.labels[i].dirty = true;
(float_t)SCREEN.scanX, errorChain(uiLabelRender(&UICONSOLE.labels[i], COLOR_RED));
(float_t)SCREEN.scanY + lineH * (float_t)i,
CONSOLE.line[i],
COLOR_RED,
&FONT_DEFAULT
));
} }
return spriteBatchFlush(); return spriteBatchFlush();
} }
+16
View File
@@ -7,6 +7,22 @@
#pragma once #pragma once
#include "error/error.h" #include "error/error.h"
#include "console/console.h"
#include "ui/widget/uilabel.h"
typedef struct {
uilabel_t labels[CONSOLE_HISTORY_MAX];
spritebatchsprite_t sprites[CONSOLE_HISTORY_MAX][CONSOLE_LINE_MAX];
} uiconsole_t;
extern uiconsole_t UICONSOLE;
/**
* Initializes the console's history labels.
*
* @return Any error that occurs.
*/
errorret_t uiConsoleInit(void);
/** /**
* Renders the console history into the scan-safe area. * Renders the console history into the scan-safe area.
+35 -21
View File
@@ -7,16 +7,42 @@
#include "uifps.h" #include "uifps.h"
#include "time/time.h" #include "time/time.h"
#include "util/string.h"
#include "display/spritebatch/spritebatch.h" #include "display/spritebatch/spritebatch.h"
#include "display/text/text.h" #include "display/color.h"
#include "display/screen/screen.h" #include "display/screen/screen.h"
#include "engine/engine.h" #include "engine/engine.h"
uifps_t UIFPS; uifps_t UIFPS;
errorret_t uiFPSDraw() { errorret_t uiFPSInit() {
char_t fpsText[32]; uiLabelInit(
&UIFPS.fpsLabel,
UIFPS.fpsText,
UIFPS.fpsSprites, UI_FPS_SPRITES_MAX
);
uiLabelSetX(&UIFPS.fpsLabel, (float_t)SCREEN.scanX);
uiLabelSetY(&UIFPS.fpsLabel, (float_t)SCREEN.scanY);
uiLabelInit(
&UIFPS.versionLabel,
UIFPS.versionText,
UIFPS.versionSprites, UI_FPS_VERSION_SPRITES_MAX
);
stringCopy(UIFPS.versionText, ENGINE.version, UI_FPS_VERSION_TEXT_MAX - 1);
UIFPS.versionLabel.dirty = true;
uiLabelRebuffer(&UIFPS.versionLabel);
uiLabelSetX(&UIFPS.versionLabel, (float_t)(
SCREEN.scanX + SCREEN.scanWidth - UIFPS.versionLabel.width
));
uiLabelSetY(&UIFPS.versionLabel, (float_t)(
SCREEN.scanY + SCREEN.scanHeight - UIFPS.versionLabel.height
));
errorOk();
}
errorret_t uiFPSDraw() {
// Get now. // Get now.
dusktimeepoch_t now = timeGetEpoch(); dusktimeepoch_t now = timeGetEpoch();
double_t delta = now.time - UIFPS.lastTick.time; double_t delta = now.time - UIFPS.lastTick.time;
@@ -33,13 +59,14 @@ errorret_t uiFPSDraw() {
UIFPS.fpsAverage = alpha * fps + (1.0f - alpha) * UIFPS.fpsAverage; UIFPS.fpsAverage = alpha * fps + (1.0f - alpha) * UIFPS.fpsAverage;
} }
snprintf( stringFormat(
fpsText, UIFPS.fpsText,
sizeof(fpsText), UI_FPS_TEXT_MAX - 1,
"%.1f/%.1fms", "%.1f/%.1fms",
UIFPS.fpsAverage, UIFPS.fpsAverage,
delta * 1000.0f delta * 1000.0f
); );
UIFPS.fpsLabel.dirty = true;
color_t textColor; color_t textColor;
if(fps >= 55.0f) { if(fps >= 55.0f) {
@@ -50,23 +77,10 @@ errorret_t uiFPSDraw() {
textColor = COLOR_RED; textColor = COLOR_RED;
} }
errorChain(textDraw( errorChain(uiLabelRender(&UIFPS.fpsLabel, textColor));
(float_t)SCREEN.scanX,
(float_t)SCREEN.scanY,
fpsText, textColor,
&FONT_DEFAULT
));
errorChain(spriteBatchFlush()); errorChain(spriteBatchFlush());
int32_t versionWidth, versionHeight; errorChain(uiLabelRender(&UIFPS.versionLabel, color(255, 255, 255, 128)));
textMeasure(ENGINE.version, &FONT_DEFAULT, &versionWidth, &versionHeight);
errorChain(textDraw(
(float_t)(SCREEN.scanX + SCREEN.scanWidth - versionWidth),
(float_t)(SCREEN.scanY + SCREEN.scanHeight - versionHeight),
ENGINE.version,
color(255, 255, 255, 128),
&FONT_DEFAULT
));
return spriteBatchFlush(); return spriteBatchFlush();
} }

Some files were not shown because too many files have changed in this diff Show More