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
Binary file not shown.
Binary file not shown.
+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"
}
]
}
+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)
+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;
@@ -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.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED; CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
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) { void cutsceneSystemStartCutscene(const cutscene_t *cutscene) {
@@ -63,11 +64,17 @@ void cutsceneRestart(void) {
assertNotNull( assertNotNull(
CUTSCENE_SYSTEM.scene, "cutsceneRestart called with no cutscene running" 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( cutsceneSystemStartCutsceneWith(
CUTSCENE_SYSTEM.scene, CUTSCENE_SYSTEM.scene,
CUTSCENE_SYSTEM.entityInteract, CUTSCENE_SYSTEM.entityInteract,
CUTSCENE_SYSTEM.entityInteracted CUTSCENE_SYSTEM.entityInteracted
); );
CUTSCENE_SYSTEM.onComplete = onComplete;
} }
void cutsceneSystemUpdate() { void cutsceneSystemUpdate() {
@@ -86,6 +93,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;
@@ -94,7 +108,10 @@ 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.onComplete = NULL;
if(onComplete != NULL) onComplete(userData);
return; return;
} }
@@ -104,6 +121,14 @@ 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) { void cutsceneGoTo(const char_t *name) {
assertNotNull( assertNotNull(
CUTSCENE_SYSTEM.scene, "cutsceneGoTo called with no cutscene running" CUTSCENE_SYSTEM.scene, "cutsceneGoTo called with no cutscene running"
@@ -203,4 +228,5 @@ 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.onComplete = NULL;
} }
+22
View File
@@ -38,6 +38,9 @@ typedef struct {
// 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;
@@ -89,6 +92,25 @@ void cutsceneSystemStartCutsceneAndGoToMarker(
*/ */
void cutsceneRestart(void); 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,
+22 -27
View File
@@ -11,34 +11,13 @@
#include "error/error.h" #include "error/error.h"
#include "display/screen/screen.h" #include "display/screen/screen.h"
#include "console/console.h" #include "console/console.h"
#include "ui/widget/uimodal.h"
#include "rpg/cutscene/cutscene.h"
#include "rpg/cutscene/cutscenesystem.h" #include "rpg/cutscene/cutscenesystem.h"
#include "asset/asset.h"
CUTSCENE(INITIAL, 0, DEFAULT, // Loaded lazily and kept resident for the rest of the process - this scene
CUTSCENE_MODAL( // only ever runs once at boot, but there's no reason to unlock it (same
"initial.checking_save.title", "initial.checking_save.message" // lifetime convention as e.g. LOCALE.entry).
), static assetentry_t *INITIAL_CUTSCENE_ENTRY = NULL;
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)
);
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");
@@ -47,7 +26,23 @@ errorret_t sceneInitialInit(scenedata_t *sceneData) {
// Set background color to black for the initial scene // Set background color to black for the initial scene
SCREEN.background = COLOR_BLACK; 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(); errorOk();
} }
+32 -44
View File
@@ -8,52 +8,16 @@
#include "scenemainmenu.h" #include "scenemainmenu.h"
#include "ui/screen/mainmenu/uimainmenu.h" #include "ui/screen/mainmenu/uimainmenu.h"
#include "ui/dialog/save/uiselectsave.h" #include "ui/dialog/save/uiselectsave.h"
#include "rpg/cutscene/cutscene.h"
#include "rpg/cutscene/cutscenesystem.h" #include "rpg/cutscene/cutscenesystem.h"
#include "scene/scene.h" #include "scene/scene.h"
#include "asset/asset.h"
#include "assert/assert.h"
void sceneMainMenuOpenSelectSave(void *userData); // 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
// Loads every save slot before opening the load-game picker, same // to unlocking/reloading it between attempts (same lifetime convention as
// checking/retry-on-error shape as the initial scene's device lookup // e.g. LOCALE.entry).
// (see scene/initial/sceneinitial.c) - the error branch loops back via static assetentry_t *MAIN_MENU_START_GAME_CUTSCENE_ENTRY = NULL;
// 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)
);
void sceneMainMenuSelectSaveResult(const uint8_t slotIndex, void *user) { void sceneMainMenuSelectSaveResult(const uint8_t slotIndex, void *user) {
if(slotIndex == UI_SELECT_SAVE_RESULT_NONE) { 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) { 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) { errorret_t sceneMainMenuInit(scenedata_t *sceneData) {
+455
View File
@@ -0,0 +1,455 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
"""
Generates DCTS binary cutscene files from raw JSONC cutscene definitions.
JSONC input (assetsraw/cutscenes/<name>.jsonc) - plain JSON plus // and
/* */ comments, stripped before parsing:
{
// optional flag names, default matches CUTSCENE_PAUSE_DEFAULT
"pause": ["NPC", "PLAYER"],
"items": [
{ "type": "TEXT", "text": "Hello!" },
{ "type": "WAIT", "seconds": 1.0 },
{ "type": "MARKER", "name": "GREET" },
...
]
}
Output: assetsraw/cutscenes/<name>.jsonc -> assets/cutscenes/<name>.cts
Only a subset of cutscene item types is supported (v1) - anything needing a
native callback (CALLBACK, MAP_AREA_ADD, the multi-option form of MODAL) or
recursive item data (CUTSCENE, CONCURRENT) isn't representable in a file
yet; those still have to be authored as compiled-in CUTSCENE(...) macros.
See src/dusk/asset/loader/cutscene/assetcutsceneloader.c for the C reader
this must match byte-for-byte, and src/dusk/rpg/cutscene/item/cutsceneitem.h
for the authoritative enum - ITEM_TYPE below MUST match its declared order.
DCTS format (little-endian throughout):
Header (12 bytes):
magic b"DCTS" 4 bytes
version u32 ASSET_CUTSCENE_FILE_VERSION
pauseType u8 cutscenepause_t bitmask
itemCount u8
poolSize u16 bytes in the pool blob following the item stream
Item stream (itemCount records back to back):
type u8 cutsceneitemtype_t
...type-specific payload; embedded strings are length-prefixed (u8 len
+ bytes, no null terminator); references into the pool are a u16
byte offset...
Pool (poolSize bytes): null-terminated strings and/or raw fixed-width
array data (e.g. worldpos_t triples), referenced by offset from the
item stream. Every pool entry starts 4-byte aligned.
"""
import json
import os
import struct
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.normpath(os.path.join(_HERE, '..', '..', '..'))
ASSETSRAW_DIR = os.path.join(PROJECT_ROOT, 'assetsraw')
ASSETS_DIR = os.path.join(PROJECT_ROOT, 'assets')
FILE_MAGIC = b'DCTS'
VERSION_OUT = 1
# Must match cutsceneitemtype_t's declared order in
# src/dusk/rpg/cutscene/item/cutsceneitem.h exactly. Types with no entry
# here are v2 (native callback / recursive item data) and unsupported.
ITEM_TYPE = {
'TEXT': 1,
'TEXT_MINI': 2,
'TEXT_MINI_HIDE': 3,
'WAIT': 5,
'ENTITY_TELEPORT': 7,
'ENTITY_WALK_TO': 8,
'FADE': 9,
'SET_PAUSE': 10,
'ITEM_GIVE': 12,
'ENTITY_REMOVE': 13,
'ENTITY_ADD': 14,
'ENTITY_TURN': 15,
'ENTITY_WALK_TO_ENTITY': 16,
'MAP_AREA_REMOVE': 18,
'MAP_AREA_WAIT': 19,
'START_BATTLE': 20,
'EMOJI': 21,
'SHAKE': 22,
'BATTLE_WAIT_STATE': 23,
'BATTLE_FORCE_ACTION': 24,
'MODAL': 25,
'MODAL_OPTIONS_MARKERS': 26,
'MODAL_CLOSE': 27,
'PRINT': 28,
'MARKER': 29,
'RESTART': 30,
'SCENE': 31,
'SAVE_DEVICE_CHECK': 32,
'SAVE_LOAD_ALL_SLOTS': 33,
}
PAUSE_FLAG = {'NPC': 1 << 0, 'PLAYER': 1 << 1, 'WORLD': 1 << 2, 'BATTLE': 1 << 3}
PAUSE_DEFAULT = PAUSE_FLAG['NPC'] | PAUSE_FLAG['PLAYER']
ENTITY_SENTINEL = {'INTERACT': 0xFE, 'INTERACTED': 0xFD}
AREA_SENTINEL = {'LAST_CREATED': 0xFF}
ENTITY_TYPE = {'NULL': 0, 'PLAYER': 1, 'NPC': 2, 'ITEM': 3}
ENTITY_DIR = {
'NORTH': 0, 'EAST': 1, 'SOUTH': 2, 'WEST': 3,
'UP': 0, 'RIGHT': 1, 'DOWN': 2, 'LEFT': 3,
}
EASING = {
name: i for i, name in enumerate([
'LINEAR', 'IN_SINE', 'OUT_SINE', 'IN_OUT_SINE',
'IN_QUAD', 'OUT_QUAD', 'IN_OUT_QUAD',
'IN_CUBIC', 'OUT_CUBIC', 'IN_OUT_CUBIC',
'IN_QUART', 'OUT_QUART', 'IN_OUT_QUART',
'IN_BACK', 'OUT_BACK', 'IN_OUT_BACK',
])
}
UI_EMOJI = {'NULL': 0, 'QUESTION_MARK': 1, 'EXCLAMATION_MARK': 2}
BATTLE_ENCOUNTER = {'REGULAR': 0, 'PLAYER_ADVANTAGE': 1, 'BACK_ATTACK': 2}
BATTLE_STATE = {
'NONE': 0, 'OPENING': 1, 'PRE_ROUND': 2, 'PLAYER_SELECTION': 3,
'AI_SELECTION': 4, 'MOVES_EXECUTING': 5, 'POST_ROUND': 6, 'ENDED': 7,
}
SCENE_TYPE = {'NULL': 0, 'INITIAL': 1, 'MAIN_MENU': 2, 'OVERWORLD': 3, 'BATTLE': 4}
CUTSCENE_TEXT_MAX_CHARS = 256
CUTSCENE_TEXT_MINI_MAX_CHARS = 128
CUTSCENE_PRINT_MAX_CHARS = 128
CUTSCENE_MODAL_TITLE_MAX_CHARS = 64
CUTSCENE_MODAL_MESSAGE_MAX_CHARS = 256
CUTSCENE_MODAL_OPTIONS_MARKERS_MAX = 2
CUTSCENE_MAP_AREA_WAIT_MAX = 4
CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX = 4
class Pool:
def __init__(self):
self.data = bytearray()
def _align(self, n):
while len(self.data) % n != 0:
self.data += b'\x00'
def add_string(self, s):
self._align(4)
offset = len(self.data)
self.data += s.encode('utf-8') + b'\x00'
return offset
def add_bytes(self, b):
self._align(4)
offset = len(self.data)
self.data += b
return offset
def resolve_entity_index(v):
if isinstance(v, str):
return ENTITY_SENTINEL[v.upper()]
return int(v)
def resolve_area_id(v):
if isinstance(v, str):
return AREA_SENTINEL[v.upper()]
return int(v)
def write_string_field(buf, s, max_len):
encoded = s.encode('utf-8')
if len(encoded) >= max_len:
raise ValueError(f"String exceeds max length {max_len}: {s!r}")
buf += struct.pack('<B', len(encoded))
buf += encoded
def write_worldpos(buf, pos):
x, y, z = pos
buf += struct.pack('<hhh', int(x), int(y), int(z))
def encode_item(item, pool):
item_type = item['type']
type_id = ITEM_TYPE.get(item_type)
if type_id is None:
raise ValueError(
f"Unsupported cutscene item type for file-based cutscenes: {item_type}"
)
buf = bytearray()
buf += struct.pack('<B', type_id)
if item_type == 'TEXT':
write_string_field(buf, item['text'], CUTSCENE_TEXT_MAX_CHARS)
elif item_type == 'TEXT_MINI':
write_string_field(buf, item['text'], CUTSCENE_TEXT_MINI_MAX_CHARS)
x, y, z = item['position']
buf += struct.pack('<fff', float(x), float(y), float(z))
buf += struct.pack('<f', float(item['duration']))
elif item_type == 'TEXT_MINI_HIDE':
buf += struct.pack('<B', int(item['index']))
elif item_type == 'WAIT':
buf += struct.pack('<f', float(item['seconds']))
elif item_type == 'ENTITY_TELEPORT':
buf += struct.pack('<B', resolve_entity_index(item['entityIndex']))
write_worldpos(buf, item['target'])
elif item_type == 'ENTITY_WALK_TO':
buf += struct.pack('<B', resolve_entity_index(item['entityIndex']))
buf += struct.pack('<B', 1 if item.get('walkAround', True) else 0)
positions = item['positions']
buf += struct.pack('<B', len(positions))
positions_bytes = bytearray()
for pos in positions:
write_worldpos(positions_bytes, pos)
offset = pool.add_bytes(bytes(positions_bytes))
buf += struct.pack('<H', offset)
elif item_type == 'FADE':
buf += bytes(int(c) for c in item['from'])
buf += bytes(int(c) for c in item['to'])
buf += struct.pack('<f', float(item['duration']))
buf += struct.pack('<B', EASING[item.get('easing', 'LINEAR').upper()])
elif item_type == 'SET_PAUSE':
value = 0
for name in item['flags']:
value |= PAUSE_FLAG[name.upper()]
buf += struct.pack('<B', value)
elif item_type == 'ITEM_GIVE':
buf += struct.pack('<H', int(item['item']))
buf += struct.pack('<B', int(item['quantity']))
elif item_type == 'ENTITY_REMOVE':
buf += struct.pack('<B', resolve_entity_index(item['entityIndex']))
elif item_type == 'ENTITY_ADD':
buf += struct.pack('<B', ENTITY_TYPE[item['entityType'].upper()])
write_worldpos(buf, item['position'])
elif item_type == 'ENTITY_TURN':
buf += struct.pack('<B', resolve_entity_index(item['entityIndex']))
buf += struct.pack('<B', ENTITY_DIR[item['direction'].upper()])
elif item_type == 'ENTITY_WALK_TO_ENTITY':
buf += struct.pack('<B', resolve_entity_index(item['entityIndex']))
buf += struct.pack('<B', resolve_entity_index(item['targetEntityIndex']))
buf += struct.pack('<hh', int(item['offsetX']), int(item['offsetY']))
elif item_type == 'MAP_AREA_REMOVE':
buf += struct.pack('<B', resolve_area_id(item['areaId']))
elif item_type == 'MAP_AREA_WAIT':
area_ids = item['areaIds']
if len(area_ids) > CUTSCENE_MAP_AREA_WAIT_MAX:
raise ValueError("MAP_AREA_WAIT areaIds exceeds maximum of 4")
buf += struct.pack('<B', len(area_ids))
ids_bytes = bytes(resolve_area_id(a) for a in area_ids)
offset = pool.add_bytes(ids_bytes)
buf += struct.pack('<H', offset)
elif item_type == 'START_BATTLE':
buf += struct.pack('<B', BATTLE_ENCOUNTER[item['encounterType'].upper()])
buf += struct.pack('<B', 1 if item.get('fleeAvailable', True) else 0)
enemies = item['enemies']
if len(enemies) > CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX:
raise ValueError("START_BATTLE enemies exceeds maximum of 4")
buf += struct.pack('<B', len(enemies))
for enemy in enemies:
stats = enemy['stats']
buf += struct.pack(
'<HHHHHHH',
int(stats['attack']), int(stats['defense']), int(stats['magic']),
int(stats['speed']), int(stats['luck']),
int(enemy['healthMax']), int(enemy['mpMax']),
)
elif item_type == 'EMOJI':
buf += struct.pack('<B', resolve_entity_index(item['entityIndex']))
buf += struct.pack('<f', float(item['duration']))
buf += struct.pack('<B', UI_EMOJI[item['emojiType'].upper()])
elif item_type == 'SHAKE':
buf += struct.pack('<B', int(item['amount']))
buf += struct.pack('<f', float(item['duration']))
elif item_type == 'BATTLE_WAIT_STATE':
buf += struct.pack('<B', BATTLE_STATE[item['state'].upper()])
elif item_type == 'BATTLE_FORCE_ACTION':
buf += struct.pack('<B', int(item['fighterIndex']))
buf += struct.pack('<B', int(item['targetIndex']))
elif item_type == 'MODAL':
write_string_field(buf, item.get('title', ''), CUTSCENE_MODAL_TITLE_MAX_CHARS)
write_string_field(buf, item['message'], CUTSCENE_MODAL_MESSAGE_MAX_CHARS)
buf += struct.pack('<B', 0) # v1: message-only, no options/callback
elif item_type == 'MODAL_OPTIONS_MARKERS':
write_string_field(buf, item.get('title', ''), CUTSCENE_MODAL_TITLE_MAX_CHARS)
write_string_field(buf, item['message'], CUTSCENE_MODAL_MESSAGE_MAX_CHARS)
options = item['options']
if not (1 <= len(options) <= CUTSCENE_MODAL_OPTIONS_MARKERS_MAX):
raise ValueError("MODAL_OPTIONS_MARKERS options must have 1 or 2 entries")
buf += struct.pack('<B', len(options))
for option in options:
text_offset = pool.add_string(option['text'])
marker_offset = pool.add_string(option['marker'])
buf += struct.pack('<HH', text_offset, marker_offset)
elif item_type in ('MODAL_CLOSE', 'RESTART'):
pass
elif item_type == 'PRINT':
write_string_field(buf, item['text'], CUTSCENE_PRINT_MAX_CHARS)
elif item_type == 'MARKER':
offset = pool.add_string(item['name'])
buf += struct.pack('<H', offset)
elif item_type == 'SCENE':
buf += struct.pack('<B', SCENE_TYPE[item['sceneType'].upper()])
elif item_type in ('SAVE_DEVICE_CHECK', 'SAVE_LOAD_ALL_SLOTS'):
success_offset = pool.add_string(item['successMarker'])
failure_offset = pool.add_string(item['failureMarker'])
buf += struct.pack('<HH', success_offset, failure_offset)
else:
raise ValueError(f"Unhandled cutscene item type: {item_type}")
return bytes(buf)
def build_cutscene(source):
pause_value = 0
for name in source.get('pause', None) or []:
pause_value |= PAUSE_FLAG[name.upper()]
if 'pause' not in source:
pause_value = PAUSE_DEFAULT
items = source['items']
if len(items) > 255:
raise ValueError("Cutscene has more than 255 items")
pool = Pool()
item_stream = bytearray()
for item in items:
item_stream += encode_item(item, pool)
header = bytearray()
header += FILE_MAGIC
header += struct.pack('<I', VERSION_OUT)
header += struct.pack('<B', pause_value)
header += struct.pack('<B', len(items))
header += struct.pack('<H', len(pool.data))
return bytes(header) + bytes(item_stream) + bytes(pool.data)
def strip_jsonc_comments(text):
"""Strips // line comments and /* */ block comments from JSONC text,
leaving string literal contents (including any // or /* inside them)
untouched."""
result = []
i = 0
n = len(text)
in_string = False
escape = False
while i < n:
c = text[i]
if in_string:
result.append(c)
if escape:
escape = False
elif c == '\\':
escape = True
elif c == '"':
in_string = False
i += 1
continue
if c == '"':
in_string = True
result.append(c)
i += 1
continue
if c == '/' and i + 1 < n and text[i + 1] == '/':
i += 2
while i < n and text[i] != '\n':
i += 1
continue
if c == '/' and i + 1 < n and text[i + 1] == '*':
i += 2
while i + 1 < n and not (text[i] == '*' and text[i + 1] == '/'):
i += 1
i += 2
continue
result.append(c)
i += 1
return ''.join(result)
def process_json(json_path):
with open(json_path, 'r', encoding='utf-8') as f:
text = f.read()
source = json.loads(strip_jsonc_comments(text))
data = build_cutscene(source)
name = os.path.splitext(os.path.basename(json_path))[0]
out_path = os.path.join(ASSETS_DIR, 'cutscenes', f'{name}.cts')
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, 'wb') as f:
f.write(data)
print(f"{json_path} -> {out_path} ({len(data)} bytes)")
def main():
args = sys.argv[1:]
if not args:
cutscenes_dir = os.path.join(ASSETSRAW_DIR, 'cutscenes')
if not os.path.isdir(cutscenes_dir):
print(f"No directory found: {cutscenes_dir}")
sys.exit(1)
json_files = sorted(
os.path.join(cutscenes_dir, f)
for f in os.listdir(cutscenes_dir)
if f.endswith('.jsonc')
)
if not json_files:
print(f"No JSONC files found in {cutscenes_dir}")
sys.exit(0)
for p in json_files:
process_json(p)
return
for p in args:
process_json(p)
if __name__ == '__main__':
main()