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
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.