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:
@@ -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)
|
||||
@@ -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
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user