Load cutscenes directly from JSONC at runtime, fix asset bundling staleness

Cutscenes now parse their authored .jsonc straight into the runtime
cutsceneitem_t/pool representation via yyjson, instead of going through a
separate Python-compiled DCTS binary format - removes the whole
build/compile step and the byte-format contract between the Python
encoder and the C decoder, at the cost of a (still tiny, one-time)
parse per cutscene load.

Also fixes dusk.dsk going stale after a build: the old custom_command
depended on a CMake-configure-time file glob, which only re-detects
added/removed assets on the next configure and could miss edits
entirely. tools.asset.pack now always runs and decides for itself
(via a small manifest) whether anything actually needs repacking, so
asset changes are never missed regardless of add/edit/remove.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-05 12:05:23 -05:00
co-authored by Claude Sonnet 5
parent 707856fcf2
commit 6ccaedd48f
18 changed files with 1483 additions and 1051 deletions
+10 -7
View File
@@ -122,18 +122,21 @@ if(DUSK_BUILD_TESTS)
add_subdirectory(test)
endif()
# Build assets
file(GLOB_RECURSE DUSK_ASSET_FILES CONFIGURE_DEPENDS "${DUSK_ASSETS_DIR}/*")
add_custom_command(
OUTPUT "${DUSK_ASSETS_ZIP}"
# Build assets. Runs unconditionally on every build (rather than being
# gated behind a custom_command's file-level DEPENDS list) because
# CONFIGURE_DEPENDS globs only pick up added/removed files on the next
# CMake configure, so that approach could miss/lag a rebuild behind an
# asset edit. tools.asset.pack itself decides whether anything actually
# changed (see its manifest check) and no-ops quickly when nothing did,
# so this stays cheap on a build where assets are untouched.
add_custom_target(DUSK_ASSETS_BUILT ALL
COMMAND ${CMAKE_COMMAND} -E make_directory "${DUSK_ASSETS_DIR}"
COMMAND ${CMAKE_COMMAND} -E rm -f "${DUSK_ASSETS_ZIP}"
COMMAND ${Python3_EXECUTABLE} -m tools.asset.pack
--input "${DUSK_ASSETS_DIR}"
--output "${DUSK_ASSETS_ZIP}"
WORKING_DIRECTORY "${DUSK_ROOT_DIR}"
DEPENDS ${DUSK_ASSET_FILES}
BYPRODUCTS "${DUSK_ASSETS_ZIP}"
COMMENT "Packing assets into dusk.dsk"
VERBATIM
)
add_custom_target(DUSK_ASSETS_BUILT DEPENDS "${DUSK_ASSETS_ZIP}")
add_dependencies(${DUSK_LIBRARY_TARGET_NAME} DUSK_ASSETS_BUILT)
Binary file not shown.
@@ -1,7 +1,6 @@
{
"items": [
// Boot check: make sure a save device is available before handing off
// to the main menu.
// First thing, try and find a save device, used for settings and what not.
{
"type": "MODAL",
"title": "initial.checking_save.title",
@@ -14,13 +13,13 @@
{
"type": "SAVE_DEVICE_CHECK",
"successMarker": "CONTINUE",
"failureMarker": "NO_DEVICE"
"failureMarker": "SAVE_DEVICE_NOT_FOUND"
},
// No save device found - offer to retry or continue without saving.
// No save device found
{
"type": "MARKER",
"name": "NO_DEVICE"
"name": "SAVE_DEVICE_NOT_FOUND"
},
{
"type": "MODAL_CLOSE"
@@ -32,7 +31,7 @@
"options": [
{
"text": "initial.no_device.retry",
"marker": "RETRY"
"marker": "SAVE_DEVICE_RETRY"
},
{
"text": "initial.no_device.continue",
@@ -41,9 +40,10 @@
]
},
// Save device not found, user to retry, just close modal and loop
{
"type": "MARKER",
"name": "RETRY"
"name": "SAVE_DEVICE_RETRY"
},
{
"type": "MODAL_CLOSE"
@@ -52,8 +52,7 @@
"type": "RESTART"
},
// Save device found (or continuing without one) - hand off to the
// main menu scene.
// Save device found (or continuing without one)
{
"type": "MARKER",
"name": "CONTINUE"
@@ -62,8 +61,8 @@
"type": "MODAL_CLOSE"
},
{
"type": "SCENE",
"sceneType": "MAIN_MENU"
"type": "CUTSCENE",
"name": "main_menu"
}
]
}
Binary file not shown.
@@ -1,23 +1,14 @@
{
"items": [
// Runs immediately as soon as the main menu scene becomes active
// (see sceneMainMenuInit) - owns menu-wide ambience, then idles while
// the menu itself is shown and interacted with natively.
// {
// "type": "AUDIO_PLAY",
// "file": "audio/boa.mp3",
// "channel": "BGM_0",
// "volume": 1.0,
// "pan": 0.0,
// "looping": true
// },
{
"type": "UI_SHOW",
"name": "main_menu"
},
{
"type": "IDLE"
},
// New Game pressed (sceneMainMenuStartGame jumps here via
// cutsceneGoTo) - check for a save device before loading slots.
// New Game button goes here.
{
"type": "MARKER",
"name": "NEW_GAME"
@@ -33,16 +24,14 @@
},
{
"type": "SAVE_DEVICE_CHECK",
"successMarker": "CONTINUE",
"failureMarker": "NO_DEVICE"
"successMarker": "SAVE_DEVICE_FOUND",
"failureMarker": "SAVE_DEVICE_NOT_FOUND"
},
// No save device found - offer to retry or continue without saving.
// Retry jumps straight back to NEW_GAME rather than restarting the
// whole cutscene, so the BGM above isn't retriggered.
// Save device not found
{
"type": "MARKER",
"name": "NO_DEVICE"
"name": "SAVE_DEVICE_NOT_FOUND"
},
{
"type": "MODAL_CLOSE"
@@ -58,15 +47,15 @@
},
{
"text": "main_menu.no_device.continue",
"marker": "CONTINUE"
"marker": "SAVE_DEVICE_FOUND"
}
]
},
// Save device found - attempt to load all save slots.
// Save device found
{
"type": "MARKER",
"name": "CONTINUE"
"name": "SAVE_DEVICE_FOUND"
},
{
"type": "MODAL_CLOSE"
File diff suppressed because it is too large Load Diff
@@ -9,8 +9,13 @@
#include "asset/assetfile.h"
#include "rpg/cutscene/cutscene.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/overworld/worldpos.h"
#include "yyjson.h"
#define ASSET_CUTSCENE_FILE_VERSION 1
// Above this, something is almost certainly wrong (or malicious) rather
// than a legitimately large cutscene - matches ASSET_JSON_FILE_SIZE_MAX's
// role in assetjsonloader.h.
#define ASSET_CUTSCENE_FILE_SIZE_MAX (1024 * 64)
typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t;
@@ -28,94 +33,195 @@ typedef enum {
typedef struct {
assetfile_t file;
assetcutsceneloadingstate_t state;
uint8_t *data;
size_t dataSize;// Saved before assetFileDispose zeroes file.size.
uint8_t *buffer;
size_t 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.
// Runtime-loaded cutscene, parsed directly from the cutscene's authored
// JSONC (no separate compiled binary format/build step - see
// assetCutsceneLoaderSync). `doc` is kept alive for the entry's whole
// lifetime rather than freed after parsing: every pool-string-shaped
// field (item names, markers, modal option text, ...) points straight at
// yyjson's own internally-owned string storage instead of being copied
// out, so freeing doc early would dangle every one of those pointers.
// `pool` only exists for the handful of fields that need a packed native
// array yyjson can't hand back a pointer into directly - entityWalkTo's
// worldpos_t positions and mapAreaWait's uint8_t areaIds.
typedef struct {
cutscene_t cutscene; // .items points at the items array below
cutsceneitem_t *items;
char_t *pool;
uint8_t *pool;
yyjson_doc *doc;
} assetcutsceneoutput_t;
/**
* Reads a uint8_t from the current offset, advancing *offset past it.
*
* @param data The buffer to read from.
* @param offset In/out cursor into data, advanced past the value read.
* @return The decoded uint8_t.
*/
uint8_t assetCutsceneReadU8(const uint8_t *data, size_t *offset);
typedef struct {
const char_t *name;
int32_t value;
} assetcutsceneenumentry_t;
typedef struct {
const char_t *name;
} assetcutsceneitemtypeentry_t;
/**
* Reads a little-endian uint16_t from a potentially-unaligned offset,
* advancing *offset past it.
* Looks up name in a {name, value} table via stringEquals - exact case,
* used only for the top-level item "type" name (the one enum-ish field
* the original tool's ITEM_TYPE dict looked up without .upper()).
*
* @param data The buffer to read from.
* @param offset In/out cursor into data, advanced past the value read.
* @return The decoded uint16_t.
* @param table Table to search.
* @param tableCount Number of entries in table.
* @param name Name to search for.
* @param outValue Set to the matching entry's value on success.
* @return true if a matching entry was found, false otherwise.
*/
uint16_t assetCutsceneReadU16(const uint8_t *data, size_t *offset);
bool_t assetCutsceneLookupEnum(
const assetcutsceneenumentry_t *table,
const size_t tableCount,
const char_t *name,
int32_t *outValue
);
/**
* Reads a little-endian uint32_t from a potentially-unaligned offset,
* advancing *offset past it.
* Looks up name (exact case) against the item type table, which is indexed
* directly by cutsceneitemtype_t rather than searched by value.
*
* @param data The buffer to read from.
* @param offset In/out cursor into data, advanced past the value read.
* @return The decoded uint32_t.
* @param name Name to search for.
* @param outType Set to the matching item type on success.
* @return true if a matching entry was found, false otherwise.
*/
uint32_t assetCutsceneReadU32(const uint8_t *data, size_t *offset);
bool_t assetCutsceneLookupItemType(
const char_t *name,
cutsceneitemtype_t *outType
);
/**
* Reads a little-endian float_t from a potentially-unaligned offset,
* advancing *offset past it.
* Looks up name in a {name, value} table via stringCompareInsensitive -
* every other enum-ish field (pause flags, entity/scene/battle/audio
* enums, ...) is authored case-insensitively, matching the original
* tool's ENUM_TABLE[name.upper()] lookups.
*
* @param data The buffer to read from.
* @param offset In/out cursor into data, advanced past the value read.
* @return The decoded float_t.
* @param table Table to search.
* @param tableCount Number of entries in table.
* @param name Name to search for.
* @param outValue Set to the matching entry's value on success.
* @return true if a matching entry was found, false otherwise.
*/
float_t assetCutsceneReadFloat(const uint8_t *data, size_t *offset);
bool_t assetCutsceneLookupEnumInsensitive(
const assetcutsceneenumentry_t *table,
const size_t tableCount,
const char_t *name,
int32_t *outValue
);
/**
* 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.
* Resolves a JSON value that's either an entity index sentinel string
* ("INTERACT"/"INTERACTED") or a plain integer entity index.
*
* @param data The buffer to read from.
* @param offset In/out cursor into data, advanced past the string read.
* @param dest Destination buffer to copy the string into.
* @param val JSON value to resolve (a string or a number).
* @param outIndex Set to the resolved index on success.
* @return true if val resolved to a valid index, false otherwise.
*/
bool_t assetCutsceneResolveEntityIndex(yyjson_val *val, uint8_t *outIndex);
/**
* Resolves a JSON value that's either the "LAST_CREATED" map area sentinel
* string or a plain integer area id.
*
* @param val JSON value to resolve (a string or a number).
* @param outAreaId Set to the resolved area id on success.
* @return true if val resolved to a valid area id, false otherwise.
*/
bool_t assetCutsceneResolveAreaId(yyjson_val *val, uint8_t *outAreaId);
/**
* Reads obj[key] as a string (falling back to defaultValue if the key is
* absent and defaultValue is non-NULL) into dest, a fixed-capacity
* char_t[destCapacity] item field. Fails rather than truncating/asserting
* if the string doesn't fit - this is untrusted, authored file content,
* not an in-memory invariant.
*
* @param obj The item's JSON object.
* @param key Field name to read.
* @param defaultValue Value to use if key is absent, or NULL to require it.
* @param dest Destination buffer.
* @param destCapacity Capacity of dest, including the null terminator.
* @return true on success, false if the field is missing (with no
* default) or exceeds destCapacity.
*/
void assetCutsceneReadEmbeddedString(
const uint8_t *data,
size_t *offset,
bool_t assetCutsceneCopyStringField(
yyjson_val *obj,
const char_t *key,
const char_t *defaultValue,
char_t *dest,
const size_t destCapacity
);
/**
* Resolves a u16 pool offset (read from the item stream) to a real pointer
* into the entry's own persistent pool allocation.
* Reads a 3-element JSON array of integers into a worldpos_t.
*
* @param data The buffer to read the pool offset from.
* @param offset In/out cursor into data, advanced past the pool offset.
* @param pool The base pointer of the entry's persistent pool allocation.
* @return Pointer to the string within pool.
* @param arr JSON array value, expected to hold exactly 3 numbers.
* @param out Set to the decoded position on success.
* @return true if arr was a valid 3-element numeric array, false otherwise.
*/
const char_t * assetCutsceneReadPoolString(
const uint8_t *data,
size_t *offset,
const char_t *pool
bool_t assetCutsceneReadWorldPos(yyjson_val *arr, worldpos_t *out);
/**
* Rounds size up to the next multiple of 4 - every pool entry starts
* 4-byte aligned so worldpos_t/multi-byte reads out of it never trap on
* alignment-sensitive hardware (e.g. PSP's MIPS core).
*
* @param size Size to round up.
* @return size rounded up to the next multiple of 4.
*/
size_t assetCutscenePoolAlign(const size_t size);
/**
* First pass over the parsed "items" array: sums the exact pool bytes
* ENTITY_WALK_TO/MAP_AREA_WAIT items will need, so the real parse pass can
* allocate the pool exactly once up front rather than growing/moving it
* (which would dangle pointers already written into earlier items).
*
* @param itemsArr The cutscene's "items" JSON array.
* @return Total pool bytes required (0 if none of the items need one).
*/
size_t assetCutsceneComputePoolSize(yyjson_val *itemsArr);
/**
* Parses one cutscene item object into item, writing any pool-backed
* array data (entityWalkTo positions, mapAreaWait areaIds) into pool at
* *poolOffset and advancing it. Every field/enum name is validated as
* real (untrusted, authored) file content - failures are real errors, not
* asserts.
*
* @param loading Loading information for the asset being loaded.
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param pool Base pointer of the entry's pool allocation (may be NULL if
* assetCutsceneComputePoolSize returned 0).
* @param poolOffset In/out cursor into pool.
* @param index Index of this item within the cutscene, for error messages.
* @return Error code indicating success or failure of the parse.
*/
errorret_t assetCutsceneParseItem(
assetloading_t *loading,
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset,
const uint8_t index
);
/**
* Asynchronous loader for cutscene assets. Reads the raw DCTS file bytes
* Frees whatever of out->items/out->pool/out->doc is currently non-NULL
* and clears them - shared by both the normal disposer and every parse
* failure path's cleanup.
*
* @param out The output to free.
*/
void assetCutsceneFreeParsed(assetcutsceneoutput_t *out);
/**
* Asynchronous loader for cutscene assets. Reads the raw JSONC file bytes
* into the loading buffer so the sync phase can parse without blocking the
* main thread on I/O.
*
@@ -125,9 +231,8 @@ const char_t * assetCutsceneReadPoolString(
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.
* Synchronous loader for cutscene assets. Parses the JSONC previously read
* by the async phase into a heap-allocated cutsceneitem_t array + pool.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
+5
View File
@@ -71,6 +71,11 @@ typedef struct cutscene_s {
#define CUTSCENE_IDLE() \
{ .type = CUTSCENE_ITEM_TYPE_IDLE }
// Opens a full-screen UI panel (e.g. the main menu) declaratively, then
// immediately continues on to whatever follows this item.
#define CUTSCENE_UI_SHOW(SCREEN) \
{ .type = CUTSCENE_ITEM_TYPE_UI_SHOW, .uiShow = { .screen = SCREEN } }
// Requests a switch to a different SCENE_TYPE via sceneSet, then
// immediately continues on to whatever follows this item - the switch
// itself doesn't happen until the next sceneUpdate() tick, so it does
@@ -225,6 +225,11 @@ cutsceneitemcallbacks_t CUTSCENE_ITEM_CALLBACKS[CUTSCENE_ITEM_TYPE_COUNT] = {
[CUTSCENE_ITEM_TYPE_IDLE] = {
.update = cutsceneIdleUpdate
},
[CUTSCENE_ITEM_TYPE_UI_SHOW] = {
.init = cutsceneUIShowStart,
.update = cutsceneUIShowUpdate
}
};
@@ -30,6 +30,7 @@
#include "ui/cutscenemodal.h"
#include "ui/cutscenemodaloptionsmarkers.h"
#include "ui/cutscenekeyboard.h"
#include "ui/cutsceneuishow.h"
#include "item/cutsceneitemgive.h"
#include "maparea/cutscenemapareaadd.h"
#include "maparea/cutscenemaparearemove.h"
@@ -98,6 +99,7 @@ typedef enum {
CUTSCENE_ITEM_TYPE_AUDIO_SET_LOOP,
CUTSCENE_ITEM_TYPE_AUDIO_SET,
CUTSCENE_ITEM_TYPE_IDLE,
CUTSCENE_ITEM_TYPE_UI_SHOW,
CUTSCENE_ITEM_TYPE_COUNT
} cutsceneitemtype_t;
@@ -147,6 +149,7 @@ struct cutsceneitem_s {
cutsceneaudiosetpan_t audioSetPan;
cutsceneaudiosetloop_t audioSetLoop;
cutsceneaudioset_t audioSet;
cutsceneuishow_t uiShow;
};
};
@@ -14,4 +14,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
cutscenemodal.c
cutscenemodaloptionsmarkers.c
cutscenekeyboard.c
cutsceneuishow.c
)
@@ -0,0 +1,27 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "ui/screen/mainmenu/uimainmenu.h"
void cutsceneUIShowStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
switch(item->uiShow.screen) {
case UI_SCREEN_TYPE_MAIN_MENU:
uiMainMenuOpen();
break;
}
}
bool_t cutsceneUIShowUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
typedef struct cutsceneitem_s cutsceneitem_t;
typedef union cutsceneitemdata_u cutsceneitemdata_t;
// Full-screen UI panels a cutscene can declaratively open via UI_SHOW - not
// modals/dialogs (those already have their own dedicated item types, e.g.
// MODAL/MODAL_OPTIONS_MARKERS/KEYBOARD), just whole-screen views like the
// main menu. Add a case here (and to cutsceneUIShowStart) as more screens
// need to be openable this way.
typedef enum {
UI_SCREEN_TYPE_MAIN_MENU,
} uiscreentype_t;
typedef struct {
uiscreentype_t screen;
} cutsceneuishow_t;
/**
* Starts a UI_SHOW item, opening the requested full-screen UI panel.
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneUIShowStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates a UI_SHOW item (always completes immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneUIShowUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
+4 -6
View File
@@ -26,14 +26,12 @@ errorret_t sceneInitialInit(scenedata_t *sceneData) {
// Set background color to black for the initial scene
SCREEN.background = COLOR_BLACK;
// Runtime-loaded from assets/cutscenes/initial.cts (authored at
// assetsraw/cutscenes/initial.jsonc via `python3 -m tools.asset.cutscene`)
// - checks for a save device, retrying on failure, then hands off to the
// main menu scene via a plain CUTSCENE_SCENE item (no native callback
// needed here, unlike the main menu's own start-game cutscene).
// Runtime-loaded from assets/cutscenes/initial.jsonc, parsed directly at
// load time (no separate compile step) - checks for a save device,
// retrying on failure, then hands off to the main menu.
if(INITIAL_CUTSCENE_ENTRY == NULL) {
INITIAL_CUTSCENE_ENTRY = assetLock(
"cutscenes/initial.cts", ASSET_LOADER_TYPE_CUTSCENE, NULL
"cutscenes/initial.jsonc", ASSET_LOADER_TYPE_CUTSCENE, NULL
);
}
errorret_t result = assetRequireLoaded(INITIAL_CUTSCENE_ENTRY);
+6 -7
View File
@@ -36,15 +36,14 @@ void sceneMainMenuOpenSelectSave(void *userData) {
}
// Lazily locks/loads the main menu cutscene (runtime-loaded from
// assets/cutscenes/main_menu.cts, authored at
// assetsraw/cutscenes/main_menu.jsonc via `python3 -m tools.asset.cutscene`
// rather than compiled in, since it's player-facing flow rather than core
// engine wiring) and asserts it's ready to run. Shared by both the initial
// assets/cutscenes/main_menu.jsonc, parsed directly at load time rather
// than compiled in, since it's player-facing flow rather than core engine
// wiring) and asserts it's ready to run. Shared by both the initial
// scene-entry start and the New-Game fallback restart below.
cutscene_t *sceneMainMenuLoadCutscene(void) {
if(MAIN_MENU_CUTSCENE_ENTRY == NULL) {
MAIN_MENU_CUTSCENE_ENTRY = assetLock(
"cutscenes/main_menu.cts", ASSET_LOADER_TYPE_CUTSCENE, NULL
"cutscenes/main_menu.jsonc", ASSET_LOADER_TYPE_CUTSCENE, NULL
);
}
errorret_t result = assetRequireLoaded(MAIN_MENU_CUTSCENE_ENTRY);
@@ -79,8 +78,8 @@ void sceneMainMenuStartGame(void) {
}
errorret_t sceneMainMenuInit(scenedata_t *sceneData) {
uiMainMenuOpen();
// Opening the main menu panel itself is now the cutscene's job (its
// first item is a UI_SHOW), not this function's.
cutsceneSystemStartCutscene(sceneMainMenuLoadCutscene());
cutsceneSystemSetOnComplete(sceneMainMenuOpenSelectSave);
+3 -2
View File
@@ -17,8 +17,9 @@ typedef struct {
} scenemainmenu_t;
/**
* Initializes the main menu scene: opens the main menu panel and starts
* the main menu cutscene, which plays the menu's BGM and idles until
* Initializes the main menu scene by starting the main menu cutscene,
* whose own first item opens the main menu panel (see UI_SHOW,
* rpg/cutscene/item/ui/cutsceneuishow.h) before it idles until
* sceneMainMenuStartGame jumps it to its NEW_GAME marker.
*
* @param sceneData The scene data used for this scene.
-519
View File
@@ -1,519 +0,0 @@
# 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 argparse
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,
'AUDIO_PLAY': 35,
'AUDIO_STOP': 36,
'AUDIO_PAUSE': 37,
'AUDIO_RESUME': 38,
'AUDIO_FADE': 39,
'AUDIO_FADE_WAIT': 40,
'AUDIO_SET_PAN': 41,
'AUDIO_SET_LOOP': 42,
'AUDIO_SET': 43,
'IDLE': 44,
}
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}
AUDIO_CHANNEL = {
'BGM_0': 0, 'VOICE_0': 1, 'VOICE_1': 2,
'SFX_0': 3, 'SFX_1': 4, 'SFX_2': 5, 'SFX_3': 6,
}
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
AUDIO_PATH_MAX = 256
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', 'IDLE'):
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)
elif item_type == 'AUDIO_PLAY':
write_string_field(buf, item['file'], AUDIO_PATH_MAX)
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<f', float(item.get('volume', 1.0)))
buf += struct.pack('<f', float(item.get('pan', 0.0)))
buf += struct.pack('<B', 1 if item.get('looping', False) else 0)
buf += struct.pack('<B', int(item.get('loopCount', 0)))
buf += struct.pack('<f', float(item.get('loopStart', -1.0)))
buf += struct.pack('<f', float(item.get('loopTo', 0.0)))
elif item_type in ('AUDIO_STOP', 'AUDIO_PAUSE', 'AUDIO_RESUME', 'AUDIO_FADE_WAIT'):
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
elif item_type == 'AUDIO_FADE':
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<f', float(item['from']))
buf += struct.pack('<f', float(item['to']))
buf += struct.pack('<f', float(item['duration']))
buf += struct.pack('<B', EASING[item.get('easing', 'LINEAR').upper()])
elif item_type == 'AUDIO_SET_PAN':
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<f', float(item['pan']))
elif item_type == 'AUDIO_SET_LOOP':
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<B', 1 if item.get('looping', True) else 0)
buf += struct.pack('<B', int(item.get('loopCount', 0)))
buf += struct.pack('<f', float(item.get('loopStart', -1.0)))
buf += struct.pack('<f', float(item.get('loopTo', 0.0)))
elif item_type == 'AUDIO_SET':
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<f', float(item['pan']))
buf += struct.pack('<B', 1 if item.get('looping', True) else 0)
buf += struct.pack('<B', int(item.get('loopCount', 0)))
buf += struct.pack('<f', float(item.get('loopStart', -1.0)))
buf += struct.pack('<f', float(item.get('loopTo', 0.0)))
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():
parser = argparse.ArgumentParser(
description="Generate DCTS binary cutscene files from raw JSONC "
"cutscene definitions"
)
parser.add_argument(
'paths', nargs='*',
help='JSONC cutscene file(s) to process. If omitted, processes all '
'assetsraw/cutscenes/*.jsonc'
)
args = parser.parse_args()
if not args.paths:
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.paths:
process_json(p)
if __name__ == '__main__':
main()
+40
View File
@@ -35,9 +35,20 @@ Usage:
<assets_dir> as the zip entry name, to the stored archive if its
relative path matches any --stored pattern (fnmatch, default:
"locale/*"), otherwise to the compressed archive.
Called unconditionally on every CMake build (see the DUSK_ASSETS_BUILT
target) rather than gated behind file-level build-system dependencies -
CMake's CONFIGURE_DEPENDS glob only notices added/removed files on the
NEXT configure, so a plain custom_command DEPENDS list can miss edits to
existing files or lag a build behind on adds/removes. Instead this script
itself decides whether anything actually needs repacking: it writes a
manifest of every input file's (mtime, size) next to the output on each
successful pack, and skips the real work if a fresh manifest compares
equal to it and the output still exists.
"""
import argparse
import json
import os
import struct
import zipfile
@@ -60,6 +71,26 @@ def build_zip_blob(root, relative_paths, compression):
return buf.getvalue()
def manifest_path(output_path):
return output_path + '.manifest.json'
def compute_manifest(input_dir, relative_paths, stored_patterns):
entries = {}
for relative_path in relative_paths:
st = os.stat(os.path.join(input_dir, relative_path))
entries[relative_path] = [st.st_mtime_ns, st.st_size]
return {'stored_patterns': sorted(stored_patterns), 'files': entries}
def load_manifest(path):
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except (OSError, ValueError):
return None
def pack(input_dir, output_path, stored_patterns):
relative_paths = []
for dirpath, _dirnames, filenames in os.walk(input_dir):
@@ -69,6 +100,12 @@ def pack(input_dir, output_path, stored_patterns):
os.path.relpath(full_path, input_dir).replace(os.sep, '/')
)
manifest = compute_manifest(input_dir, relative_paths, stored_patterns)
manifest_file = manifest_path(output_path)
if os.path.isfile(output_path) and load_manifest(manifest_file) == manifest:
print(f'{output_path} is up to date, skipping pack')
return
stored_paths = [
path for path in relative_paths
if any(fnmatch.fnmatch(path, pattern) for pattern in stored_patterns)
@@ -102,6 +139,9 @@ def pack(input_dir, output_path, stored_patterns):
f.write(compressed_blob)
f.write(stored_blob)
with open(manifest_file, 'w', encoding='utf-8') as f:
json.dump(manifest, f)
print(
f'Wrote {output_path}: {len(compressed_paths)} compressed file(s) '
f'({len(compressed_blob)} bytes), {len(stored_paths)} stored file(s) '