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>
This commit is contained in:
2026-08-25 09:51:26 -05:00
parent 106d9b0fc0
commit e630827b34
15 changed files with 1261 additions and 73 deletions
+2 -1
View File
@@ -16,4 +16,5 @@ add_subdirectory(display)
add_subdirectory(locale)
add_subdirectory(json)
add_subdirectory(chunk)
add_subdirectory(dmf)
add_subdirectory(dmf)
add_subdirectory(cutscene)
+6
View File
@@ -51,4 +51,10 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = {
.loadAsync = assetChunkLoaderAsync,
.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/json/assetjsonloader.h"
#include "asset/loader/chunk/assetchunkloader.h"
#include "asset/loader/cutscene/assetcutsceneloader.h"
typedef enum {
ASSET_LOADER_TYPE_NULL,
@@ -24,6 +25,7 @@ typedef enum {
ASSET_LOADER_TYPE_LOCALE,
ASSET_LOADER_TYPE_JSON,
ASSET_LOADER_TYPE_CHUNK,
ASSET_LOADER_TYPE_CUTSCENE,
ASSET_LOADER_TYPE_COUNT
} assetloadertype_t;
@@ -36,6 +38,7 @@ typedef union {
assetlocaleloaderloading_t locale;
assetjsonloaderloading_t json;
assetchunkloaderloading_t chunk;
assetcutsceneloaderloading_t cutscene;
} assetloaderloading_t;
typedef union {
@@ -46,6 +49,7 @@ typedef union {
assetlocaleoutput_t locale;
assetjsonoutput_t json;
assetchunkoutput_t chunk;
assetcutsceneoutput_t cutscene;
} assetloaderoutput_t;
typedef union {
@@ -54,6 +58,7 @@ typedef union {
assetlocaleloaderinput_t locale;
assetjsonloaderinput_t json;
assetchunkloaderinput_t chunk;
assetcutsceneloaderinput_t cutscene;
} assetloaderinput_t;
typedef struct assetloading_s assetloading_t;
@@ -0,0 +1,9 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
assetcutsceneloader.c
)
@@ -0,0 +1,449 @@
/**
* 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.
assertTrue(
assetCutsceneReadU8(data, &offset) == 0,
"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",
(uint32_t)item->type
);
}
}
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);
+27 -1
View File
@@ -36,6 +36,7 @@ void cutsceneSystemPrepare(
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
CUTSCENE_SYSTEM.currentItem = 0xFF;// Set to 0xFF so Next wraps to 0.
CUTSCENE_SYSTEM.onComplete = NULL;
}
void cutsceneSystemStartCutscene(const cutscene_t *cutscene) {
@@ -63,11 +64,17 @@ 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() {
@@ -86,6 +93,13 @@ void cutsceneSystemNext() {
if(
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.currentItem = 0xFF;
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE;
@@ -94,7 +108,10 @@ void cutsceneSystemNext() {
CUTSCENE_SYSTEM.entityLastCreated = NULL;
CUTSCENE_SYSTEM.entityLastRef = NULL;
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.onComplete = NULL;
if(onComplete != NULL) onComplete(userData);
return;
}
@@ -104,6 +121,14 @@ void cutsceneSystemNext() {
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"
@@ -203,4 +228,5 @@ void cutsceneSystemDispose() {
CUTSCENE_SYSTEM.entityLastRef = NULL;
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
CUTSCENE_SYSTEM.onComplete = NULL;
}
+22
View File
@@ -38,6 +38,9 @@ typedef struct {
// Custom user data for the running cutscene, sized per-scene by
// cutscene_t.dataSize.
uint8_t userData[CUTSCENE_SYSTEM_SIZE_MAX];
// See cutsceneSystemSetOnComplete.
cutscenecallback_t onComplete;
} cutscenesystem_t;
extern cutscenesystem_t CUTSCENE_SYSTEM;
@@ -89,6 +92,25 @@ void cutsceneSystemStartCutsceneAndGoToMarker(
*/
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.
* Handles CUTSCENE_ENTITY_INTERACT, CUTSCENE_ENTITY_INTERACTED,
+22 -27
View File
@@ -11,34 +11,13 @@
#include "error/error.h"
#include "display/screen/screen.h"
#include "console/console.h"
#include "ui/widget/uimodal.h"
#include "rpg/cutscene/cutscene.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "asset/asset.h"
CUTSCENE(INITIAL, 0, DEFAULT,
CUTSCENE_MODAL(
"initial.checking_save.title", "initial.checking_save.message"
),
CUTSCENE_WAIT(0.2f),
CUTSCENE_SAVE_DEVICE_CHECK("CONTINUE", "NO_DEVICE"),
CUTSCENE_MARKER("NO_DEVICE"),
CUTSCENE_MODAL_CLOSE(),
CUTSCENE_MODAL_OPTIONS_TWO(
"initial.no_device.title",
"initial.no_device.message",
"initial.no_device.retry", "RETRY",
"initial.no_device.continue", "CONTINUE"
),
CUTSCENE_MARKER("RETRY"),
CUTSCENE_MODAL_CLOSE(),
CUTSCENE_RESTART(),
CUTSCENE_MARKER("CONTINUE"),
CUTSCENE_MODAL_CLOSE(),
CUTSCENE_SCENE(SCENE_TYPE_MAIN_MENU)
);
// 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) {
assertNotNull(sceneData, "Scene data cannot be null");
@@ -47,7 +26,23 @@ errorret_t sceneInitialInit(scenedata_t *sceneData) {
// Set background color to black for the initial scene
SCREEN.background = COLOR_BLACK;
cutsceneSystemStartCutscene(&CUTSCENE_INITIAL);
// 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();
}
+32 -44
View File
@@ -8,52 +8,16 @@
#include "scenemainmenu.h"
#include "ui/screen/mainmenu/uimainmenu.h"
#include "ui/dialog/save/uiselectsave.h"
#include "rpg/cutscene/cutscene.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "scene/scene.h"
#include "asset/asset.h"
#include "assert/assert.h"
void sceneMainMenuOpenSelectSave(void *userData);
// Loads every save slot before opening the load-game picker, same
// checking/retry-on-error shape as the initial scene's device lookup
// (see scene/initial/sceneinitial.c) - the error branch loops back via
// CUTSCENE_RESTART.
CUTSCENE(MAIN_MENU_START_GAME, 0, DEFAULT,
CUTSCENE_MODAL(
"main_menu.checking_save.title",
"main_menu.checking_save.message"
),
CUTSCENE_WAIT(0.2f),
CUTSCENE_SAVE_DEVICE_CHECK("CONTINUE", "NO_DEVICE"),
CUTSCENE_MARKER("NO_DEVICE"),
CUTSCENE_MODAL_CLOSE(),
CUTSCENE_MODAL_OPTIONS_TWO(
"main_menu.no_device.title",
"main_menu.no_device.message",
"main_menu.no_device.retry", "RETRY",
"main_menu.no_device.continue", "CONTINUE"
),
CUTSCENE_MARKER("RETRY"),
CUTSCENE_MODAL_CLOSE(),
CUTSCENE_RESTART(),
CUTSCENE_MARKER("CONTINUE"),
CUTSCENE_MODAL_CLOSE(),
CUTSCENE_SAVE_LOAD_ALL_SLOTS("LOADED", "LOAD_ERROR"),
CUTSCENE_MARKER("LOAD_ERROR"),
CUTSCENE_MODAL_OPTIONS_ONE(
"main_menu.save_load_error.title",
"main_menu.save_load_error.message",
"main_menu.save_load_error.retry",
"RETRY"
),
CUTSCENE_MARKER("LOADED"),
CUTSCENE_CALLBACK(sceneMainMenuOpenSelectSave)
);
// 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) {
@@ -71,8 +35,32 @@ void sceneMainMenuOpenSelectSave(void *userData) {
);
}
// 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) {
cutsceneSystemStartCutscene(&CUTSCENE_MAIN_MENU_START_GAME);
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) {