Add cutsceneSystemLoad, a debug cutscene overlay, and NULL-safe fatal error

cutsceneSystemLoad(file) centralizes the lock/assetRequireLoaded/start
sequence previously duplicated in sceneInitialInit, and falls back to
the fatal error overlay instead of asserting when the asset fails to
load. uiFatalErrorOpen now accepts a NULL message, showing a generic
"contact support" message for callers with no specific error text.
Also adds a ui/debug/uicutscene overlay showing the running cutscene's
item count and current item type, and wires main_menu's post-save-picker
flow through cutsceneSystemLoad.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-06 11:18:48 -05:00
co-authored by Claude Sonnet 5
parent 03efbe0681
commit 5b523c68a7
13 changed files with 165 additions and 68 deletions
@@ -58,13 +58,6 @@ errorret_t assetCutsceneParseItem(
errorOk(); errorOk();
} }
// First pass over the parsed "items" array: sums the exact pool bytes
// ENTITY_WALK_TO/MAP_AREA_WAIT items will need, so the real parse pass can
// allocate the pool exactly once up front rather than growing/moving it
// (which would dangle pointers already written into earlier items). Any
// item with an invalid/missing type is silently skipped here - the real
// error for that gets thrown later during the actual per-item parse pass
// in assetCutsceneParseItem, this first pass is pool-sizing only.
size_t assetCutsceneComputePoolSize(yyjson_val *itemsArr) { size_t assetCutsceneComputePoolSize(yyjson_val *itemsArr) {
size_t size = 0; size_t size = 0;
yyjson_arr_iter iter = yyjson_arr_iter_with(itemsArr); yyjson_arr_iter iter = yyjson_arr_iter_with(itemsArr);
@@ -12,9 +12,6 @@
#include "rpg/cutscene/item/json/cutscenejsonpool.h" #include "rpg/cutscene/item/json/cutscenejsonpool.h"
#include "yyjson.h" #include "yyjson.h"
// Above this, something is almost certainly wrong (or malicious) rather
// than a legitimately large cutscene - matches ASSET_JSON_FILE_SIZE_MAX's
// role in assetjsonloader.h.
#define ASSET_CUTSCENE_FILE_SIZE_MAX (1024 * 64) #define ASSET_CUTSCENE_FILE_SIZE_MAX (1024 * 64)
typedef struct assetloading_s assetloading_t; typedef struct assetloading_s assetloading_t;
@@ -37,18 +34,8 @@ typedef struct {
size_t size; size_t size;
} assetcutsceneloaderloading_t; } assetcutsceneloaderloading_t;
// Runtime-loaded cutscene, parsed directly from the cutscene's authored
// JSONC (no separate compiled binary format/build step - see
// assetCutsceneLoaderSync). `doc` is kept alive for the entry's whole
// lifetime rather than freed after parsing: every pool-string-shaped
// field (item names, markers, modal option text, ...) points straight at
// yyjson's own internally-owned string storage instead of being copied
// out, so freeing doc early would dangle every one of those pointers.
// `pool` only exists for the handful of fields that need a packed native
// array yyjson can't hand back a pointer into directly - entityWalkTo's
// worldpos_t positions and mapAreaWait's uint8_t areaIds.
typedef struct { typedef struct {
cutscene_t cutscene; // .items points at the items array below cutscene_t cutscene;
cutsceneitem_t *items; cutsceneitem_t *items;
uint8_t *pool; uint8_t *pool;
yyjson_doc *doc; yyjson_doc *doc;
+2 -2
View File
@@ -17,8 +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; // CONSOLE.visible = true;
threadMutexInit(&CONSOLE.printMutex); threadMutexInit(&CONSOLE.printMutex);
} }
+17
View File
@@ -10,6 +10,8 @@
#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 "asset/asset.h"
#include "ui/overlay/uifatalerror.h"
cutscenesystem_t CUTSCENE_SYSTEM; cutscenesystem_t CUTSCENE_SYSTEM;
@@ -61,6 +63,21 @@ void cutsceneSystemStartCutsceneAndGoToMarker(
cutsceneGoTo(marker); cutsceneGoTo(marker);
} }
void cutsceneSystemLoad(const char_t *file) {
char_t path[ASSET_FILE_NAME_MAX];
stringFormat(path, sizeof(path), "cutscenes/%s", file);
assetentry_t *entry = assetLock(path, ASSET_LOADER_TYPE_CUTSCENE, NULL);
errorret_t result = assetRequireLoaded(entry);
if(errorIsNotOk(result)) {
errorCatch(errorPrint(result));
uiFatalErrorOpen(NULL);
return;
}
cutsceneSystemStartCutscene(&entry->data.cutscene.cutscene);
}
void cutsceneRestart(void) { void cutsceneRestart(void) {
assertNotNull( assertNotNull(
CUTSCENE_SYSTEM.scene, "cutsceneRestart called with no cutscene running" CUTSCENE_SYSTEM.scene, "cutsceneRestart called with no cutscene running"
+16
View File
@@ -96,6 +96,22 @@ void cutsceneSystemStartCutsceneAndGoToMarker(
const char_t *marker const char_t *marker
); );
/**
* Locks, loads and immediately starts a cutscene asset by file name,
* e.g. cutsceneSystemLoad("main_menu.jsonc") loads and starts
* assets/cutscenes/main_menu.jsonc - same lock/assetRequireLoaded/start
* sequence as sceneInitialInit's INITIAL_CUTSCENE_ENTRY. The entry is
* locked forever (never unlocked), same lifetime convention as
* LOCALE.entry - it's fine to call this again later for the same file,
* it'll just resolve to the same already-loaded entry. Opens the fatal
* error overlay (see uiFatalErrorOpen) instead of starting anything if
* the asset fails to load.
*
* @param file Cutscene file name (with .jsonc extension), relative to
* assets/cutscenes/.
*/
void cutsceneSystemLoad(const char_t *file);
/** /**
* Restarts the currently running cutscene from its first item, * Restarts the currently running cutscene from its first item,
* preserving whatever interact/interacted entities triggered it. * preserving whatever interact/interacted entities triggered it.
+1 -18
View File
@@ -12,12 +12,6 @@
#include "display/screen/screen.h" #include "display/screen/screen.h"
#include "console/console.h" #include "console/console.h"
#include "rpg/cutscene/cutscenesystem.h" #include "rpg/cutscene/cutscenesystem.h"
#include "asset/asset.h"
// 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
// lifetime convention as e.g. LOCALE.entry).
static assetentry_t *INITIAL_CUTSCENE_ENTRY = 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");
@@ -29,18 +23,7 @@ errorret_t sceneInitialInit(scenedata_t *sceneData) {
// Runtime-loaded from assets/cutscenes/initial.jsonc, parsed directly at // Runtime-loaded from assets/cutscenes/initial.jsonc, parsed directly at
// load time (no separate compile step) - checks for a save device, // load time (no separate compile step) - checks for a save device,
// retrying on failure, then hands off to the main menu. // retrying on failure, then hands off to the main menu.
if(INITIAL_CUTSCENE_ENTRY == NULL) { cutsceneSystemLoad("initial.jsonc");
INITIAL_CUTSCENE_ENTRY = assetLock(
"cutscenes/initial.jsonc", 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();
} }
+1
View File
@@ -6,6 +6,7 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
uiconsole.c uiconsole.c
uicutscene.c
uifps.c uifps.c
uiplayerpos.c uiplayerpos.c
) )
+73
View File
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uicutscene.h"
#include "util/string.h"
#include "display/screen/screen.h"
#include "display/text/text.h"
#include "display/spritebatch/spritebatch.h"
#include "display/color.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "rpg/cutscene/item/cutsceneitem.h"
uicutscene_t UICUTSCENE;
errorret_t uiCutsceneInit() {
uiLabelInit(
&UICUTSCENE.countLabel,
UICUTSCENE.countText,
UICUTSCENE.countSprites, UI_CUTSCENE_SPRITES_MAX
);
uiLabelSetX(&UICUTSCENE.countLabel, (float_t)SCREEN.scanX);
uiLabelSetY(&UICUTSCENE.countLabel, (float_t)SCREEN.scanY +
(float_t)FONT_DEFAULT.tileset->tileHeight * 2
);
uiLabelInit(
&UICUTSCENE.itemLabel,
UICUTSCENE.itemText,
UICUTSCENE.itemSprites, UI_CUTSCENE_SPRITES_MAX
);
uiLabelSetX(&UICUTSCENE.itemLabel, (float_t)SCREEN.scanX);
uiLabelSetY(&UICUTSCENE.itemLabel, (float_t)SCREEN.scanY +
(float_t)FONT_DEFAULT.tileset->tileHeight * 3
);
errorOk();
}
errorret_t uiCutsceneDraw() {
const cutsceneitem_t *item = cutsceneSystemGetCurrentItem();
if(!item || !CUTSCENE_SYSTEM.scene) {
stringCopy(
UICUTSCENE.countText, "Cutscene Inactive", UI_CUTSCENE_TEXT_MAX - 1
);
UICUTSCENE.itemText[0] = '\0';
} else {
const char_t *name = CUTSCENE_ITEM_CALLBACKS[item->type].jsonName;
if(!name) name = "???";
stringFormat(
UICUTSCENE.countText,
UI_CUTSCENE_TEXT_MAX - 1,
"Cutscene Item Count: %d",
(int_t)CUTSCENE_SYSTEM.scene->itemCount
);
stringFormat(
UICUTSCENE.itemText,
UI_CUTSCENE_TEXT_MAX - 1,
"Current Item: %s",
name
);
}
UICUTSCENE.countLabel.dirty = true;
UICUTSCENE.itemLabel.dirty = true;
errorChain(uiLabelRender(&UICUTSCENE.countLabel, COLOR_GREEN));
errorChain(uiLabelRender(&UICUTSCENE.itemLabel, COLOR_GREEN));
return spriteBatchFlush();
}
+42
View File
@@ -0,0 +1,42 @@
/**
* 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 "ui/widget/uilabel.h"
#define UI_CUTSCENE_TEXT_MAX 64
#define UI_CUTSCENE_SPRITES_MAX UI_CUTSCENE_TEXT_MAX
typedef struct {
uilabel_t countLabel;
char_t countText[UI_CUTSCENE_TEXT_MAX];
spritebatchsprite_t countSprites[UI_CUTSCENE_SPRITES_MAX];
uilabel_t itemLabel;
char_t itemText[UI_CUTSCENE_TEXT_MAX];
spritebatchsprite_t itemSprites[UI_CUTSCENE_SPRITES_MAX];
} uicutscene_t;
extern uicutscene_t UICUTSCENE;
/**
* Initializes the cutscene debug labels.
*
* @return Any error that occurs.
*/
errorret_t uiCutsceneInit();
/**
* Draws the currently running cutscene's item count and current item
* type as two lines ("Item Count: %d" / "Current Item: %s"), or a
* single "Cutscene Inactive" line if no cutscene is running. Renders
* below UIPLAYERPOS (see ui/debug/uiplayerpos.h).
*
* @return Any error that occurs.
*/
errorret_t uiCutsceneDraw();
+1 -2
View File
@@ -9,7 +9,6 @@
#include "ui/widget/uiframe.h" #include "ui/widget/uiframe.h"
#include "ui/widget/uibutton.h" #include "ui/widget/uibutton.h"
#include "engine/engine.h" #include "engine/engine.h"
#include "assert/assert.h"
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h" #include "util/string.h"
#include "util/math.h" #include "util/math.h"
@@ -40,11 +39,11 @@ errorret_t uiFatalErrorInit(void) {
} }
void uiFatalErrorOpen(const char_t *message) { void uiFatalErrorOpen(const char_t *message) {
assertNotNull(message, "Message cannot be NULL");
// This is the last line of defense - it must never itself fail, so a // This is the last line of defense - it must never itself fail, so a
// second fatal error while one is already showing is silently dropped // second fatal error while one is already showing is silently dropped
// rather than asserting. // rather than asserting.
if(UI_FATAL_ERROR.open) return; if(UI_FATAL_ERROR.open) return;
if(message == NULL) message = UI_FATAL_ERROR_GENERIC_MESSAGE;
stringCopy( stringCopy(
UI_FATAL_ERROR.messageText, message, UI_FATAL_ERROR_MESSAGE_MAX - 1 UI_FATAL_ERROR.messageText, message, UI_FATAL_ERROR_MESSAGE_MAX - 1
+7
View File
@@ -14,6 +14,9 @@
#define UI_FATAL_ERROR_MESSAGE_SPRITES_MAX UI_FATAL_ERROR_MESSAGE_MAX #define UI_FATAL_ERROR_MESSAGE_SPRITES_MAX UI_FATAL_ERROR_MESSAGE_MAX
#define UI_FATAL_ERROR_ITEM_COUNT 1 #define UI_FATAL_ERROR_ITEM_COUNT 1
#define UI_FATAL_ERROR_QUIT_LABEL "QUIT" #define UI_FATAL_ERROR_QUIT_LABEL "QUIT"
#define UI_FATAL_ERROR_GENERIC_MESSAGE \
"An unexpected error occurred and the game must exit. " \
"Please contact support if this issue persists."
typedef struct { typedef struct {
bool_t open; bool_t open;
@@ -46,6 +49,10 @@ errorret_t uiFatalErrorInit(void);
* *
* @param message The error text to display; copied internally, safe to * @param message The error text to display; copied internally, safe to
* be transient. Truncated to UI_FATAL_ERROR_MESSAGE_MAX - 1 characters. * be transient. Truncated to UI_FATAL_ERROR_MESSAGE_MAX - 1 characters.
* May be NULL, in which case UI_FATAL_ERROR_GENERIC_MESSAGE is shown
* instead - useful for callers with no specific error text to report
* (or that don't want to risk formatting/locale-lookup failures of their
* own right before calling this, the last line of defense).
*/ */
void uiFatalErrorOpen(const char_t *message); void uiFatalErrorOpen(const char_t *message);
+2 -25
View File
@@ -26,17 +26,7 @@
#define UI_MAIN_MENU_INDEX_QUIT 2 #define UI_MAIN_MENU_INDEX_QUIT 2
uimainmenu_t UI_MAIN_MENU; uimainmenu_t UI_MAIN_MENU;
assetentry_t *MAIN_MENU_CUTSCENE_ENTRY = NULL;
// Loaded lazily on first activation 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). The persistent cutscene is normally already running
// by the time this screen is reachable at all (chained straight from the
// initial cutscene's own CUTSCENE item, see assets/cutscenes/initial.jsonc
// and CUTSCENE_ITEM_TYPE_CUTSCENE in asset/loader/cutscene/
// assetcutsceneloader.c) - this lock just gives uiMainMenuStartGame a
// stable pointer to restart it with if that run already completed.
static assetentry_t *MAIN_MENU_CUTSCENE_ENTRY = NULL;
void uiMainMenuQuitConfirmed(const bool_t result, void *user) { void uiMainMenuQuitConfirmed(const bool_t result, void *user) {
if(result) ENGINE.running = false; if(result) ENGINE.running = false;
@@ -59,7 +49,7 @@ cutscene_t *uiMainMenuLoadCutscene(void) {
void uiMainMenuSelectSaveResult(const uint8_t slotIndex, void *user) { void uiMainMenuSelectSaveResult(const uint8_t slotIndex, void *user) {
if(slotIndex == UI_SELECT_SAVE_RESULT_NONE) { if(slotIndex == UI_SELECT_SAVE_RESULT_NONE) {
uiMainMenuOpen(); cutsceneSystemLoad("main_menu.jsonc");
return; return;
} }
@@ -75,10 +65,6 @@ void uiMainMenuOpenSelectSave(void *userData) {
void uiMainMenuStartGame(void) { void uiMainMenuStartGame(void) {
if(CUTSCENE_SYSTEM.scene == NULL) { if(CUTSCENE_SYSTEM.scene == NULL) {
// The menu cutscene already ran to completion once (e.g. the player
// backed out of the save picker back to this menu) - restart it
// landing straight on NEW_GAME rather than replaying the idle/BGM
// setup from the top.
cutsceneSystemStartCutsceneAndGoToMarker( cutsceneSystemStartCutsceneAndGoToMarker(
uiMainMenuLoadCutscene(), "NEW_GAME" uiMainMenuLoadCutscene(), "NEW_GAME"
); );
@@ -86,11 +72,6 @@ void uiMainMenuStartGame(void) {
cutsceneGoTo("NEW_GAME"); cutsceneGoTo("NEW_GAME");
} }
// Armed unconditionally, whether this just (re)started the cutscene or
// jumped one already running since boot (chained straight from the
// initial cutscene - see assets/cutscenes/initial.jsonc) - that first
// run is never otherwise armed by anything, so setting this only in the
// restart branch above would silently drop it on a fresh boot.
cutsceneSystemSetOnComplete(uiMainMenuOpenSelectSave); cutsceneSystemSetOnComplete(uiMainMenuOpenSelectSave);
} }
@@ -107,7 +88,6 @@ void uiMainMenuSelected(
break; break;
case UI_MAIN_MENU_INDEX_OPTIONS: case UI_MAIN_MENU_INDEX_OPTIONS:
// TODO: options.
break; break;
case UI_MAIN_MENU_INDEX_QUIT: case UI_MAIN_MENU_INDEX_QUIT:
@@ -151,9 +131,6 @@ errorret_t uiMainMenuInit(void) {
MENU_BUTTON(UI_MAIN_MENU.optionsLabel); MENU_BUTTON(UI_MAIN_MENU.optionsLabel);
MENU_BUTTON(UI_MAIN_MENU.quitLabel); MENU_BUTTON(UI_MAIN_MENU.quitLabel);
MENU_END(UI_MAIN_MENU.items, 1); MENU_END(UI_MAIN_MENU.items, 1);
// The main menu is the root screen once past the initial boot check -
// there's nothing to back out to, so back/cancel does nothing here.
uiMenuSetDisableBack(&UI_MAIN_MENU.menu, true); uiMenuSetDisableBack(&UI_MAIN_MENU.menu, true);
errorOk(); errorOk();
+2
View File
@@ -12,6 +12,7 @@
#include "ui/overlay/uiloading.h" #include "ui/overlay/uiloading.h"
#include "ui/overlay/uifatalerror.h" #include "ui/overlay/uifatalerror.h"
#include "ui/debug/uiplayerpos.h" #include "ui/debug/uiplayerpos.h"
#include "ui/debug/uicutscene.h"
#include "ui/overlay/uicrop.h" #include "ui/overlay/uicrop.h"
#include "ui/transition/uitransition.h" #include "ui/transition/uitransition.h"
#include "ui/debug/uiconsole.h" #include "ui/debug/uiconsole.h"
@@ -156,6 +157,7 @@ uielement_t UI_ELEMENTS[] = {
{ .init = uiConsoleInit, .draw = uiConsoleDraw }, { .init = uiConsoleInit, .draw = uiConsoleDraw },
{ .init = uiFPSInit, .draw = uiFPSDraw }, { .init = uiFPSInit, .draw = uiFPSDraw },
{ .init = uiPlayerPosInit, .draw = uiPlayerPosDraw }, { .init = uiPlayerPosInit, .draw = uiPlayerPosDraw },
{ .init = uiCutsceneInit, .draw = uiCutsceneDraw },
// =========================================================================== // ===========================================================================
// Null terminator // Null terminator