From 6ccaedd48ffe36555e85be1bc0c51f23765cf138 Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Sat, 5 Sep 2026 12:05:23 -0500 Subject: [PATCH] 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 --- CMakeLists.txt | 17 +- assets/cutscenes/initial.cts | Bin 282 -> 0 bytes {assetsraw => assets}/cutscenes/initial.jsonc | 21 +- assets/cutscenes/main_menu.cts | Bin 472 -> 0 bytes .../cutscenes/main_menu.jsonc | 35 +- .../loader/cutscene/assetcutsceneloader.c | 1564 ++++++++++++----- .../loader/cutscene/assetcutsceneloader.h | 221 ++- src/dusk/rpg/cutscene/cutscene.h | 5 + src/dusk/rpg/cutscene/item/cutsceneitem.c | 5 + src/dusk/rpg/cutscene/item/cutsceneitem.h | 3 + src/dusk/rpg/cutscene/item/ui/CMakeLists.txt | 1 + .../rpg/cutscene/item/ui/cutsceneuishow.c | 27 + .../rpg/cutscene/item/ui/cutsceneuishow.h | 48 + src/dusk/scene/initial/sceneinitial.c | 10 +- src/dusk/scene/mainmenu/scenemainmenu.c | 13 +- src/dusk/scene/mainmenu/scenemainmenu.h | 5 +- tools/asset/cutscene/__main__.py | 519 ------ tools/asset/pack/__main__.py | 40 + 18 files changed, 1483 insertions(+), 1051 deletions(-) delete mode 100644 assets/cutscenes/initial.cts rename {assetsraw => assets}/cutscenes/initial.jsonc (67%) delete mode 100644 assets/cutscenes/main_menu.cts rename {assetsraw => assets}/cutscenes/main_menu.jsonc (62%) create mode 100644 src/dusk/rpg/cutscene/item/ui/cutsceneuishow.c create mode 100644 src/dusk/rpg/cutscene/item/ui/cutsceneuishow.h delete mode 100644 tools/asset/cutscene/__main__.py diff --git a/CMakeLists.txt b/CMakeLists.txt index ce079434..36a0fd82 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/assets/cutscenes/initial.cts b/assets/cutscenes/initial.cts deleted file mode 100644 index 9c7ba46799709d0ad14e1e4664161938d68f72bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 282 zcmZ>94hd#tU|?Y8sb!Fq&dkd!$xO`AOU_75&d$tBk1tLvOVumMEXhfg#V(hdT3no% zp31;__Kc660s{jNgRBIDw3IkZbzXjaN@`hVGSn1FOmV0QOezdE3@!`_46->4(sHsT z4ASyU&i;NOo_?XOK=(2D`NzAshIu-JILK^F1B+5iiYgg`Ttk8)fod`3lk@XRGV@AP N85oewhpC5)0RZKzQ`7(e diff --git a/assetsraw/cutscenes/initial.jsonc b/assets/cutscenes/initial.jsonc similarity index 67% rename from assetsraw/cutscenes/initial.jsonc rename to assets/cutscenes/initial.jsonc index 35ff17ac..c4be0d23 100644 --- a/assetsraw/cutscenes/initial.jsonc +++ b/assets/cutscenes/initial.jsonc @@ -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" } ] } diff --git a/assets/cutscenes/main_menu.cts b/assets/cutscenes/main_menu.cts deleted file mode 100644 index ef18b66447199232f871849e6dbaf55b76121cd4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 472 zcmZ>94hd#tU|?Y8d(5CC%fKKho12)K7oVG&SE`qsk(!*HnU@}4oLH8sSCUzhlPZr( zHaE4nI59nyf%WVeA3Fse1_=gP6$WW3Nrd{m{P>jAvdm.jsonc asset at parse time - +// see the CUTSCENE_ITEM_TYPE_CUTSCENE case below. +// Indexed directly by cutsceneitemtype_t rather than searched by value. +static const assetcutsceneitemtypeentry_t ASSET_CUTSCENE_ITEM_TYPE_TABLE[CUTSCENE_ITEM_TYPE_COUNT] = { + [CUTSCENE_ITEM_TYPE_NULL] = { "NULL" }, + [CUTSCENE_ITEM_TYPE_TEXT] = { "TEXT" }, + [CUTSCENE_ITEM_TYPE_TEXT_MINI] = { "TEXT_MINI" }, + [CUTSCENE_ITEM_TYPE_TEXT_MINI_HIDE] = { "TEXT_MINI_HIDE" }, + [CUTSCENE_ITEM_TYPE_CALLBACK] = { "CALLBACK" }, + [CUTSCENE_ITEM_TYPE_WAIT] = { "WAIT" }, + [CUTSCENE_ITEM_TYPE_CUTSCENE] = { "CUTSCENE" }, + [CUTSCENE_ITEM_TYPE_ENTITY_TELEPORT] = { "ENTITY_TELEPORT" }, + [CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO] = { "ENTITY_WALK_TO" }, + [CUTSCENE_ITEM_TYPE_FADE] = { "FADE" }, + [CUTSCENE_ITEM_TYPE_SET_PAUSE] = { "SET_PAUSE" }, + [CUTSCENE_ITEM_TYPE_CONCURRENT] = { "CONCURRENT" }, + [CUTSCENE_ITEM_TYPE_ITEM_GIVE] = { "ITEM_GIVE" }, + [CUTSCENE_ITEM_TYPE_ENTITY_REMOVE] = { "ENTITY_REMOVE" }, + [CUTSCENE_ITEM_TYPE_ENTITY_ADD] = { "ENTITY_ADD" }, + [CUTSCENE_ITEM_TYPE_ENTITY_TURN] = { "ENTITY_TURN" }, + [CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO_ENTITY] = { "ENTITY_WALK_TO_ENTITY" }, + [CUTSCENE_ITEM_TYPE_MAP_AREA_ADD] = { "MAP_AREA_ADD" }, + [CUTSCENE_ITEM_TYPE_MAP_AREA_REMOVE] = { "MAP_AREA_REMOVE" }, + [CUTSCENE_ITEM_TYPE_MAP_AREA_WAIT] = { "MAP_AREA_WAIT" }, + [CUTSCENE_ITEM_TYPE_START_BATTLE] = { "START_BATTLE" }, + [CUTSCENE_ITEM_TYPE_EMOJI] = { "EMOJI" }, + [CUTSCENE_ITEM_TYPE_SHAKE] = { "SHAKE" }, + [CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE] = { "BATTLE_WAIT_STATE" }, + [CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION] = { "BATTLE_FORCE_ACTION" }, + [CUTSCENE_ITEM_TYPE_MODAL] = { "MODAL" }, + [CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS] = { "MODAL_OPTIONS_MARKERS" }, + [CUTSCENE_ITEM_TYPE_MODAL_CLOSE] = { "MODAL_CLOSE" }, + [CUTSCENE_ITEM_TYPE_PRINT] = { "PRINT" }, + [CUTSCENE_ITEM_TYPE_MARKER] = { "MARKER" }, + [CUTSCENE_ITEM_TYPE_RESTART] = { "RESTART" }, + [CUTSCENE_ITEM_TYPE_SCENE] = { "SCENE" }, + [CUTSCENE_ITEM_TYPE_SAVE_DEVICE_CHECK] = { "SAVE_DEVICE_CHECK" }, + [CUTSCENE_ITEM_TYPE_SAVE_LOAD_ALL_SLOTS] = { "SAVE_LOAD_ALL_SLOTS" }, + [CUTSCENE_ITEM_TYPE_KEYBOARD] = { "KEYBOARD" }, + [CUTSCENE_ITEM_TYPE_AUDIO_PLAY] = { "AUDIO_PLAY" }, + [CUTSCENE_ITEM_TYPE_AUDIO_STOP] = { "AUDIO_STOP" }, + [CUTSCENE_ITEM_TYPE_AUDIO_PAUSE] = { "AUDIO_PAUSE" }, + [CUTSCENE_ITEM_TYPE_AUDIO_RESUME] = { "AUDIO_RESUME" }, + [CUTSCENE_ITEM_TYPE_AUDIO_FADE] = { "AUDIO_FADE" }, + [CUTSCENE_ITEM_TYPE_AUDIO_FADE_WAIT] = { "AUDIO_FADE_WAIT" }, + [CUTSCENE_ITEM_TYPE_AUDIO_SET_PAN] = { "AUDIO_SET_PAN" }, + [CUTSCENE_ITEM_TYPE_AUDIO_SET_LOOP] = { "AUDIO_SET_LOOP" }, + [CUTSCENE_ITEM_TYPE_AUDIO_SET] = { "AUDIO_SET" }, + [CUTSCENE_ITEM_TYPE_IDLE] = { "IDLE" }, + [CUTSCENE_ITEM_TYPE_UI_SHOW] = { "UI_SHOW" }, +}; -uint8_t assetCutsceneReadU8(const uint8_t *data, size_t *offset) { - uint8_t value = data[*offset]; - *offset += sizeof(uint8_t); - return value; +static const assetcutsceneenumentry_t ASSET_CUTSCENE_PAUSE_FLAG_TABLE[] = { + { "NPC", CUTSCENE_PAUSE_NPC }, + { "PLAYER", CUTSCENE_PAUSE_PLAYER }, + { "WORLD", CUTSCENE_PAUSE_WORLD }, + { "BATTLE", CUTSCENE_PAUSE_BATTLE }, +}; +#define ASSET_CUTSCENE_PAUSE_FLAG_TABLE_COUNT \ + (sizeof(ASSET_CUTSCENE_PAUSE_FLAG_TABLE) / sizeof(assetcutsceneenumentry_t)) + +static const assetcutsceneenumentry_t ASSET_CUTSCENE_ENTITY_TYPE_TABLE[] = { + { "NULL", ENTITY_TYPE_NULL }, + { "PLAYER", ENTITY_TYPE_PLAYER }, + { "NPC", ENTITY_TYPE_NPC }, + { "ITEM", ENTITY_TYPE_ITEM }, +}; +#define ASSET_CUTSCENE_ENTITY_TYPE_TABLE_COUNT \ + (sizeof(ASSET_CUTSCENE_ENTITY_TYPE_TABLE) / sizeof(assetcutsceneenumentry_t)) + +static const assetcutsceneenumentry_t ASSET_CUTSCENE_ENTITY_DIR_TABLE[] = { + { "NORTH", ENTITY_DIR_NORTH }, + { "EAST", ENTITY_DIR_EAST }, + { "SOUTH", ENTITY_DIR_SOUTH }, + { "WEST", ENTITY_DIR_WEST }, + { "UP", ENTITY_DIR_UP }, + { "RIGHT", ENTITY_DIR_RIGHT }, + { "DOWN", ENTITY_DIR_DOWN }, + { "LEFT", ENTITY_DIR_LEFT }, +}; +#define ASSET_CUTSCENE_ENTITY_DIR_TABLE_COUNT \ + (sizeof(ASSET_CUTSCENE_ENTITY_DIR_TABLE) / sizeof(assetcutsceneenumentry_t)) + +static const assetcutsceneenumentry_t ASSET_CUTSCENE_EASING_TABLE[] = { + { "LINEAR", EASING_LINEAR }, + { "IN_SINE", EASING_IN_SINE }, + { "OUT_SINE", EASING_OUT_SINE }, + { "IN_OUT_SINE", EASING_IN_OUT_SINE }, + { "IN_QUAD", EASING_IN_QUAD }, + { "OUT_QUAD", EASING_OUT_QUAD }, + { "IN_OUT_QUAD", EASING_IN_OUT_QUAD }, + { "IN_CUBIC", EASING_IN_CUBIC }, + { "OUT_CUBIC", EASING_OUT_CUBIC }, + { "IN_OUT_CUBIC", EASING_IN_OUT_CUBIC }, + { "IN_QUART", EASING_IN_QUART }, + { "OUT_QUART", EASING_OUT_QUART }, + { "IN_OUT_QUART", EASING_IN_OUT_QUART }, + { "IN_BACK", EASING_IN_BACK }, + { "OUT_BACK", EASING_OUT_BACK }, + { "IN_OUT_BACK", EASING_IN_OUT_BACK }, +}; +#define ASSET_CUTSCENE_EASING_TABLE_COUNT \ + (sizeof(ASSET_CUTSCENE_EASING_TABLE) / sizeof(assetcutsceneenumentry_t)) + +static const assetcutsceneenumentry_t ASSET_CUTSCENE_UI_EMOJI_TABLE[] = { + { "NULL", UI_EMOJI_NULL }, + { "QUESTION_MARK", UI_EMOJI_QUESTION_MARK }, + { "EXCLAMATION_MARK", UI_EMOJI_EXCLAMATION_MARK }, +}; +#define ASSET_CUTSCENE_UI_EMOJI_TABLE_COUNT \ + (sizeof(ASSET_CUTSCENE_UI_EMOJI_TABLE) / sizeof(assetcutsceneenumentry_t)) + +static const assetcutsceneenumentry_t ASSET_CUTSCENE_BATTLE_ENCOUNTER_TABLE[] = { + { "REGULAR", BATTLE_ENCOUNTER_REGULAR }, + { "PLAYER_ADVANTAGE", BATTLE_ENCOUNTER_PLAYER_ADVANTAGE }, + { "BACK_ATTACK", BATTLE_ENCOUNTER_BACK_ATTACK }, +}; +#define ASSET_CUTSCENE_BATTLE_ENCOUNTER_TABLE_COUNT \ + (sizeof(ASSET_CUTSCENE_BATTLE_ENCOUNTER_TABLE) / sizeof(assetcutsceneenumentry_t)) + +static const assetcutsceneenumentry_t ASSET_CUTSCENE_BATTLE_STATE_TABLE[] = { + { "NONE", BATTLE_STATE_NONE }, + { "OPENING", BATTLE_STATE_OPENING }, + { "PRE_ROUND", BATTLE_STATE_PRE_ROUND }, + { "PLAYER_SELECTION", BATTLE_STATE_PLAYER_SELECTION }, + { "AI_SELECTION", BATTLE_STATE_AI_SELECTION }, + { "MOVES_EXECUTING", BATTLE_STATE_MOVES_EXECUTING }, + { "POST_ROUND", BATTLE_STATE_POST_ROUND }, + { "ENDED", BATTLE_STATE_ENDED }, +}; +#define ASSET_CUTSCENE_BATTLE_STATE_TABLE_COUNT \ + (sizeof(ASSET_CUTSCENE_BATTLE_STATE_TABLE) / sizeof(assetcutsceneenumentry_t)) + +static const assetcutsceneenumentry_t ASSET_CUTSCENE_SCENE_TYPE_TABLE[] = { + { "NULL", SCENE_TYPE_NULL }, + { "INITIAL", SCENE_TYPE_INITIAL }, + { "MAIN_MENU", SCENE_TYPE_MAIN_MENU }, + { "OVERWORLD", SCENE_TYPE_OVERWORLD }, + { "BATTLE", SCENE_TYPE_BATTLE }, +}; +#define ASSET_CUTSCENE_SCENE_TYPE_TABLE_COUNT \ + (sizeof(ASSET_CUTSCENE_SCENE_TYPE_TABLE) / sizeof(assetcutsceneenumentry_t)) + +static const assetcutsceneenumentry_t ASSET_CUTSCENE_UI_SCREEN_TYPE_TABLE[] = { + { "MAIN_MENU", UI_SCREEN_TYPE_MAIN_MENU }, +}; +#define ASSET_CUTSCENE_UI_SCREEN_TYPE_TABLE_COUNT \ + (sizeof(ASSET_CUTSCENE_UI_SCREEN_TYPE_TABLE) / sizeof(assetcutsceneenumentry_t)) + +static const assetcutsceneenumentry_t ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE[] = { + { "BGM_0", AUDIO_MIXER_CHANNEL_BGM_0 }, + { "VOICE_0", AUDIO_MIXER_CHANNEL_VOICE_0 }, + { "VOICE_1", AUDIO_MIXER_CHANNEL_VOICE_1 }, + { "SFX_0", AUDIO_MIXER_CHANNEL_SFX_0 }, + { "SFX_1", AUDIO_MIXER_CHANNEL_SFX_1 }, + { "SFX_2", AUDIO_MIXER_CHANNEL_SFX_2 }, + { "SFX_3", AUDIO_MIXER_CHANNEL_SFX_3 }, +}; +#define ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE_COUNT \ + (sizeof(ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE) / sizeof(assetcutsceneenumentry_t)) + +// Sentinels accepted in place of a literal index/id - see +// cutscenesystem.h's CUTSCENE_ENTITY_INTERACT/INTERACTED/CUTSCENE_AREA_ +// LAST_CREATED. Kept local since they're a JSON-authoring convenience, +// not part of the item struct definitions themselves. +#define ASSET_CUTSCENE_ENTITY_SENTINEL_INTERACT ((uint8_t)0xFE) +#define ASSET_CUTSCENE_ENTITY_SENTINEL_INTERACTED ((uint8_t)0xFD) +#define ASSET_CUTSCENE_AREA_SENTINEL_LAST_CREATED ((uint8_t)0xFF) + +bool_t assetCutsceneLookupEnum( + const assetcutsceneenumentry_t *table, + const size_t tableCount, + const char_t *name, + int32_t *outValue +) { + if(name == NULL) return false; + for(size_t i = 0; i < tableCount; i++) { + if(stringEquals(table[i].name, name)) { + *outValue = table[i].value; + return true; + } + } + return false; } -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); +bool_t assetCutsceneLookupItemType( + const char_t *name, + cutsceneitemtype_t *outType +) { + if(name == NULL) return false; + for(size_t i = 0; i < CUTSCENE_ITEM_TYPE_COUNT; i++) { + const char_t *entryName = ASSET_CUTSCENE_ITEM_TYPE_TABLE[i].name; + if(entryName != NULL && stringEquals(entryName, name)) { + *outType = (cutsceneitemtype_t)i; + return true; + } + } + return false; } -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); +bool_t assetCutsceneLookupEnumInsensitive( + const assetcutsceneenumentry_t *table, + const size_t tableCount, + const char_t *name, + int32_t *outValue +) { + if(name == NULL) return false; + for(size_t i = 0; i < tableCount; i++) { + if(stringCompareInsensitive(table[i].name, name) == 0) { + *outValue = table[i].value; + return true; + } + } + return false; } -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); +bool_t assetCutsceneResolveEntityIndex(yyjson_val *val, uint8_t *outIndex) { + if(yyjson_is_str(val)) { + const char_t *name = yyjson_get_str(val); + if(stringCompareInsensitive(name, "INTERACT") == 0) { + *outIndex = ASSET_CUTSCENE_ENTITY_SENTINEL_INTERACT; + return true; + } + if(stringCompareInsensitive(name, "INTERACTED") == 0) { + *outIndex = ASSET_CUTSCENE_ENTITY_SENTINEL_INTERACTED; + return true; + } + return false; + } + if(!yyjson_is_int(val)) return false; + *outIndex = (uint8_t)yyjson_get_int(val); + return true; } -// 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. -void assetCutsceneReadEmbeddedString( - const uint8_t *data, - size_t *offset, +bool_t assetCutsceneResolveAreaId(yyjson_val *val, uint8_t *outAreaId) { + if(yyjson_is_str(val)) { + const char_t *name = yyjson_get_str(val); + if(stringCompareInsensitive(name, "LAST_CREATED") == 0) { + *outAreaId = ASSET_CUTSCENE_AREA_SENTINEL_LAST_CREATED; + return true; + } + return false; + } + if(!yyjson_is_int(val)) return false; + *outAreaId = (uint8_t)yyjson_get_int(val); + return true; +} + +bool_t assetCutsceneCopyStringField( + yyjson_val *obj, + const char_t *key, + const char_t *defaultValue, 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; + yyjson_val *val = yyjson_obj_get(obj, key); + const char_t *str = yyjson_get_str(val); + size_t len; + if(str == NULL) { + if(defaultValue == NULL) return false; + str = defaultValue; + len = (size_t)stringFormat(NULL, 0, "%s", str); + } else { + len = yyjson_get_len(val); + } + if(len >= destCapacity) return false; + stringCopy(dest, str, destCapacity); + return true; } -// Resolves a u16 pool offset (read from the item stream) to a real pointer -// into the entry's own persistent pool allocation. -const char_t * assetCutsceneReadPoolString( - const uint8_t *data, - size_t *offset, - const char_t *pool +bool_t assetCutsceneReadWorldPos(yyjson_val *arr, worldpos_t *out) { + if(!yyjson_is_arr(arr) || yyjson_arr_size(arr) != 3) return false; + yyjson_val *x = yyjson_arr_get(arr, 0); + yyjson_val *y = yyjson_arr_get(arr, 1); + yyjson_val *z = yyjson_arr_get(arr, 2); + if(!yyjson_is_int(x) || !yyjson_is_int(y) || !yyjson_is_int(z)) return false; + out->x = (worldunit_t)yyjson_get_int(x); + out->y = (worldunit_t)yyjson_get_int(y); + out->z = (worldunit_t)yyjson_get_int(z); + return true; +} + +size_t assetCutscenePoolAlign(const size_t size) { + return (size + 3) & ~((size_t)3); +} + +size_t assetCutsceneComputePoolSize(yyjson_val *itemsArr) { + size_t size = 0; + yyjson_arr_iter iter = yyjson_arr_iter_with(itemsArr); + yyjson_val *itemVal; + while((itemVal = yyjson_arr_iter_next(&iter)) != NULL) { + const char_t *typeName = yyjson_get_str(yyjson_obj_get(itemVal, "type")); + if(typeName == NULL) continue; + + if(stringEquals(typeName, "ENTITY_WALK_TO")) { + size_t count = yyjson_arr_size(yyjson_obj_get(itemVal, "positions")); + size = assetCutscenePoolAlign(size) + count * sizeof(worldpos_t); + } else if(stringEquals(typeName, "MAP_AREA_WAIT")) { + size_t count = yyjson_arr_size(yyjson_obj_get(itemVal, "areaIds")); + size = assetCutscenePoolAlign(size) + count; + } + } + return size; +} + +errorret_t assetCutsceneParseItem( + assetloading_t *loading, + yyjson_val *itemObj, + cutsceneitem_t *item, + uint8_t *pool, + size_t *poolOffset, + const uint8_t index ) { - uint16_t poolOffset = assetCutsceneReadU16(data, offset); - return pool + poolOffset; + if(!yyjson_is_obj(itemObj)) { + assetLoaderErrorThrow( + loading, + "Cutscene item %u is not an object", + (uint32_t)index + ); + } + + const char_t *typeName = yyjson_get_str(yyjson_obj_get(itemObj, "type")); + if(!assetCutsceneLookupItemType(typeName, &item->type)) { + assetLoaderErrorThrow( + loading, + "Cutscene item %u: missing/unsupported item type %s", + (uint32_t)index, + typeName + ); + } + + switch(item->type) { + case CUTSCENE_ITEM_TYPE_TEXT: + if(!assetCutsceneCopyStringField( + itemObj, "text", NULL, item->text.text, CUTSCENE_TEXT_MAX_CHARS + )) { + assetLoaderErrorThrow( + loading, + "Cutscene item %u (TEXT): missing/too-long 'text'", + (uint32_t)index + ); + } + break; + + case CUTSCENE_ITEM_TYPE_TEXT_MINI: { + if(!assetCutsceneCopyStringField( + itemObj, "text", NULL, item->textMini.text, CUTSCENE_TEXT_MINI_MAX_CHARS + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (TEXT_MINI): missing/too-long 'text'", + (uint32_t)index + ); + } + yyjson_val *position = yyjson_obj_get(itemObj, "position"); + if(!yyjson_is_arr(position) || yyjson_arr_size(position) != 3) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (TEXT_MINI): 'position' must be [x, y, z]", + (uint32_t)index + ); + } + item->textMini.position[0] = (float_t)yyjson_get_num(yyjson_arr_get(position, 0)); + item->textMini.position[1] = (float_t)yyjson_get_num(yyjson_arr_get(position, 1)); + item->textMini.position[2] = (float_t)yyjson_get_num(yyjson_arr_get(position, 2)); + item->textMini.duration = (float_t)yyjson_get_num(yyjson_obj_get(itemObj, "duration")); + break; + } + + case CUTSCENE_ITEM_TYPE_TEXT_MINI_HIDE: + item->textMiniHide.index = (uint8_t)yyjson_get_int(yyjson_obj_get(itemObj, "index")); + break; + + case CUTSCENE_ITEM_TYPE_WAIT: + item->wait = (float_t)yyjson_get_num(yyjson_obj_get(itemObj, "seconds")); + break; + + case CUTSCENE_ITEM_TYPE_CUTSCENE: { + const char_t *name = yyjson_get_str(yyjson_obj_get(itemObj, "name")); + if(name == NULL) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (CUTSCENE): missing 'name'", (uint32_t)index + ); + } + + // Referenced cutscene is a sibling asset (assets/cutscenes/.jsonc), + // resolved to a stable pointer right now rather than deferred to + // runtime - assetRequireLoaded re-enters assetUpdate() safely from + // within this sync load (see its ASSET_ENTRY_STATE_LOADING_SYNC case), + // and the entry is locked forever (never unlocked) so the pointer + // stays valid for the process lifetime, same convention as + // scene/mainmenu/scenemainmenu.c's MAIN_MENU_CUTSCENE_ENTRY. + char_t path[ASSET_FILE_NAME_MAX]; + int32_t pathLen = stringFormat(NULL, 0, "cutscenes/%s.jsonc", name); + if(pathLen < 0 || (size_t)pathLen >= ASSET_FILE_NAME_MAX) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (CUTSCENE): 'name' too long", (uint32_t)index + ); + } + stringFormat(path, sizeof(path), "cutscenes/%s.jsonc", name); + + assetentry_t *refEntry = assetLock(path, ASSET_LOADER_TYPE_CUTSCENE, NULL); + assetLoaderErrorChain(loading, assetRequireLoaded(refEntry)); + item->cutscene = &refEntry->data.cutscene.cutscene; + break; + } + + case CUTSCENE_ITEM_TYPE_ENTITY_TELEPORT: + if(!assetCutsceneResolveEntityIndex( + yyjson_obj_get(itemObj, "entityIndex"), &item->entityTeleport.entityIndex + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (ENTITY_TELEPORT): invalid 'entityIndex'", + (uint32_t)index + ); + } + if(!assetCutsceneReadWorldPos( + yyjson_obj_get(itemObj, "target"), &item->entityTeleport.target + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (ENTITY_TELEPORT): 'target' must be [x, y, z]", + (uint32_t)index + ); + } + break; + + case CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO: { + if(!assetCutsceneResolveEntityIndex( + yyjson_obj_get(itemObj, "entityIndex"), &item->entityWalkTo.entityIndex + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (ENTITY_WALK_TO): invalid 'entityIndex'", + (uint32_t)index + ); + } + yyjson_val *walkAround = yyjson_obj_get(itemObj, "walkAround"); + item->entityWalkTo.walkAround = walkAround == NULL ? true : yyjson_get_bool(walkAround); + + yyjson_val *positions = yyjson_obj_get(itemObj, "positions"); + size_t count = yyjson_arr_size(positions); + if(!yyjson_is_arr(positions) || count == 0 || count > UINT8_MAX) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (ENTITY_WALK_TO): 'positions' must be a " + "non-empty array of [x, y, z]", (uint32_t)index + ); + } + + *poolOffset = assetCutscenePoolAlign(*poolOffset); + worldpos_t *dest = (worldpos_t *)(pool + *poolOffset); + yyjson_arr_iter iter = yyjson_arr_iter_with(positions); + yyjson_val *posVal; + size_t i = 0; + while((posVal = yyjson_arr_iter_next(&iter)) != NULL) { + if(!assetCutsceneReadWorldPos(posVal, &dest[i])) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (ENTITY_WALK_TO): position %u must be " + "[x, y, z]", (uint32_t)index, (uint32_t)i + ); + } + i++; + } + item->entityWalkTo.positions = dest; + item->entityWalkTo.count = (uint8_t)count; + *poolOffset += count * sizeof(worldpos_t); + break; + } + + case CUTSCENE_ITEM_TYPE_FADE: { + yyjson_val *from = yyjson_obj_get(itemObj, "from"); + yyjson_val *to = yyjson_obj_get(itemObj, "to"); + if(!yyjson_is_arr(from) || yyjson_arr_size(from) != 4 || + !yyjson_is_arr(to) || yyjson_arr_size(to) != 4) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (FADE): 'from'/'to' must each be " + "[r, g, b, a]", (uint32_t)index + ); + } + item->fade.from.r = (uint8_t)yyjson_get_int(yyjson_arr_get(from, 0)); + item->fade.from.g = (uint8_t)yyjson_get_int(yyjson_arr_get(from, 1)); + item->fade.from.b = (uint8_t)yyjson_get_int(yyjson_arr_get(from, 2)); + item->fade.from.a = (uint8_t)yyjson_get_int(yyjson_arr_get(from, 3)); + item->fade.to.r = (uint8_t)yyjson_get_int(yyjson_arr_get(to, 0)); + item->fade.to.g = (uint8_t)yyjson_get_int(yyjson_arr_get(to, 1)); + item->fade.to.b = (uint8_t)yyjson_get_int(yyjson_arr_get(to, 2)); + item->fade.to.a = (uint8_t)yyjson_get_int(yyjson_arr_get(to, 3)); + item->fade.duration = (float_t)yyjson_get_num(yyjson_obj_get(itemObj, "duration")); + + const char_t *easingName = yyjson_get_str(yyjson_obj_get(itemObj, "easing")); + int32_t easingValue = EASING_LINEAR; + if(easingName != NULL && !assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_EASING_TABLE, ASSET_CUTSCENE_EASING_TABLE_COUNT, + easingName, &easingValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (FADE): invalid 'easing'", (uint32_t)index + ); + } + item->fade.easing = (easingtype_t)easingValue; + break; + } + + case CUTSCENE_ITEM_TYPE_SET_PAUSE: { + yyjson_val *flags = yyjson_obj_get(itemObj, "flags"); + if(!yyjson_is_arr(flags)) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (SET_PAUSE): 'flags' must be an array", + (uint32_t)index + ); + } + cutscenepause_t value = CUTSCENE_PAUSE_NONE; + yyjson_arr_iter iter = yyjson_arr_iter_with(flags); + yyjson_val *flagVal; + while((flagVal = yyjson_arr_iter_next(&iter)) != NULL) { + int32_t flagValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_PAUSE_FLAG_TABLE, ASSET_CUTSCENE_PAUSE_FLAG_TABLE_COUNT, + yyjson_get_str(flagVal), &flagValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (SET_PAUSE): invalid pause flag", + (uint32_t)index + ); + } + value |= (cutscenepause_t)flagValue; + } + item->setPause = value; + break; + } + + case CUTSCENE_ITEM_TYPE_ITEM_GIVE: + item->itemGive.item = (itemid_t)yyjson_get_int(yyjson_obj_get(itemObj, "item")); + item->itemGive.quantity = (uint8_t)yyjson_get_int(yyjson_obj_get(itemObj, "quantity")); + break; + + case CUTSCENE_ITEM_TYPE_ENTITY_REMOVE: + if(!assetCutsceneResolveEntityIndex( + yyjson_obj_get(itemObj, "entityIndex"), &item->entityRemove.entityIndex + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (ENTITY_REMOVE): invalid 'entityIndex'", + (uint32_t)index + ); + } + break; + + case CUTSCENE_ITEM_TYPE_ENTITY_ADD: { + int32_t entityTypeValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_ENTITY_TYPE_TABLE, ASSET_CUTSCENE_ENTITY_TYPE_TABLE_COUNT, + yyjson_get_str(yyjson_obj_get(itemObj, "entityType")), &entityTypeValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (ENTITY_ADD): invalid 'entityType'", + (uint32_t)index + ); + } + item->entityAdd.entityType = (entitytype_t)entityTypeValue; + if(!assetCutsceneReadWorldPos( + yyjson_obj_get(itemObj, "position"), &item->entityAdd.position + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (ENTITY_ADD): 'position' must be [x, y, z]", + (uint32_t)index + ); + } + break; + } + + case CUTSCENE_ITEM_TYPE_ENTITY_TURN: { + if(!assetCutsceneResolveEntityIndex( + yyjson_obj_get(itemObj, "entityIndex"), &item->entityTurn.entityIndex + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (ENTITY_TURN): invalid 'entityIndex'", + (uint32_t)index + ); + } + int32_t dirValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_ENTITY_DIR_TABLE, ASSET_CUTSCENE_ENTITY_DIR_TABLE_COUNT, + yyjson_get_str(yyjson_obj_get(itemObj, "direction")), &dirValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (ENTITY_TURN): invalid 'direction'", + (uint32_t)index + ); + } + item->entityTurn.direction = (entitydir_t)dirValue; + break; + } + + case CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO_ENTITY: + if(!assetCutsceneResolveEntityIndex( + yyjson_obj_get(itemObj, "entityIndex"), &item->entityWalkToEntity.entityIndex + ) || !assetCutsceneResolveEntityIndex( + yyjson_obj_get(itemObj, "targetEntityIndex"), + &item->entityWalkToEntity.targetEntityIndex + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (ENTITY_WALK_TO_ENTITY): invalid entity index", + (uint32_t)index + ); + } + item->entityWalkToEntity.offsetX = + (worldunit_t)yyjson_get_int(yyjson_obj_get(itemObj, "offsetX")); + item->entityWalkToEntity.offsetY = + (worldunit_t)yyjson_get_int(yyjson_obj_get(itemObj, "offsetY")); + break; + + case CUTSCENE_ITEM_TYPE_MAP_AREA_REMOVE: + if(!assetCutsceneResolveAreaId( + yyjson_obj_get(itemObj, "areaId"), &item->mapAreaRemove.areaId + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (MAP_AREA_REMOVE): invalid 'areaId'", + (uint32_t)index + ); + } + break; + + case CUTSCENE_ITEM_TYPE_MAP_AREA_WAIT: { + yyjson_val *areaIds = yyjson_obj_get(itemObj, "areaIds"); + size_t count = yyjson_arr_size(areaIds); + if(!yyjson_is_arr(areaIds) || count == 0 || count > CUTSCENE_MAP_AREA_WAIT_MAX) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (MAP_AREA_WAIT): 'areaIds' must have " + "1-%u entries", (uint32_t)index, (uint32_t)CUTSCENE_MAP_AREA_WAIT_MAX + ); + } + *poolOffset = assetCutscenePoolAlign(*poolOffset); + uint8_t *dest = pool + *poolOffset; + yyjson_arr_iter iter = yyjson_arr_iter_with(areaIds); + yyjson_val *idVal; + size_t i = 0; + while((idVal = yyjson_arr_iter_next(&iter)) != NULL) { + if(!assetCutsceneResolveAreaId(idVal, &dest[i])) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (MAP_AREA_WAIT): areaIds[%u] is invalid", + (uint32_t)index, (uint32_t)i + ); + } + i++; + } + item->mapAreaWait.areaIds = dest; + item->mapAreaWait.count = (uint8_t)count; + *poolOffset += count; + break; + } + + case CUTSCENE_ITEM_TYPE_START_BATTLE: { + int32_t encounterValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_BATTLE_ENCOUNTER_TABLE, ASSET_CUTSCENE_BATTLE_ENCOUNTER_TABLE_COUNT, + yyjson_get_str(yyjson_obj_get(itemObj, "encounterType")), &encounterValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (START_BATTLE): invalid 'encounterType'", + (uint32_t)index + ); + } + item->startBattle.encounterType = (battleencountertype_t)encounterValue; + + yyjson_val *fleeAvailable = yyjson_obj_get(itemObj, "fleeAvailable"); + item->startBattle.fleeAvailable = + fleeAvailable == NULL ? true : yyjson_get_bool(fleeAvailable); + + yyjson_val *enemies = yyjson_obj_get(itemObj, "enemies"); + size_t enemyCount = yyjson_arr_size(enemies); + if(!yyjson_is_arr(enemies) || enemyCount == 0 || + enemyCount > CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (START_BATTLE): 'enemies' must have 1-%u " + "entries", (uint32_t)index, (uint32_t)CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX + ); + } + item->startBattle.enemyCount = (uint8_t)enemyCount; + + yyjson_arr_iter iter = yyjson_arr_iter_with(enemies); + yyjson_val *enemyVal; + size_t e = 0; + while((enemyVal = yyjson_arr_iter_next(&iter)) != NULL) { + yyjson_val *stats = yyjson_obj_get(enemyVal, "stats"); + if(!yyjson_is_obj(stats)) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (START_BATTLE): enemies[%u] is missing " + "'stats'", (uint32_t)index, (uint32_t)e + ); + } + cutscenestartbattleenemy_t *enemy = &item->startBattle.enemies[e]; + enemy->stats.attack = (uint16_t)yyjson_get_int(yyjson_obj_get(stats, "attack")); + enemy->stats.defense = (uint16_t)yyjson_get_int(yyjson_obj_get(stats, "defense")); + enemy->stats.magic = (uint16_t)yyjson_get_int(yyjson_obj_get(stats, "magic")); + enemy->stats.speed = (uint16_t)yyjson_get_int(yyjson_obj_get(stats, "speed")); + enemy->stats.luck = (uint16_t)yyjson_get_int(yyjson_obj_get(stats, "luck")); + enemy->healthMax = (uint16_t)yyjson_get_int(yyjson_obj_get(enemyVal, "healthMax")); + enemy->mpMax = (uint16_t)yyjson_get_int(yyjson_obj_get(enemyVal, "mpMax")); + e++; + } + break; + } + + case CUTSCENE_ITEM_TYPE_EMOJI: { + if(!assetCutsceneResolveEntityIndex( + yyjson_obj_get(itemObj, "entityIndex"), &item->emoji.entityIndex + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (EMOJI): invalid 'entityIndex'", (uint32_t)index + ); + } + item->emoji.duration = (float_t)yyjson_get_num(yyjson_obj_get(itemObj, "duration")); + int32_t emojiValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_UI_EMOJI_TABLE, ASSET_CUTSCENE_UI_EMOJI_TABLE_COUNT, + yyjson_get_str(yyjson_obj_get(itemObj, "emojiType")), &emojiValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (EMOJI): invalid 'emojiType'", (uint32_t)index + ); + } + item->emoji.emojiType = (uiemojitype_t)emojiValue; + break; + } + + case CUTSCENE_ITEM_TYPE_SHAKE: + item->shake.amount = (uint8_t)yyjson_get_int(yyjson_obj_get(itemObj, "amount")); + item->shake.duration = (float_t)yyjson_get_num(yyjson_obj_get(itemObj, "duration")); + break; + + case CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE: { + int32_t stateValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_BATTLE_STATE_TABLE, ASSET_CUTSCENE_BATTLE_STATE_TABLE_COUNT, + yyjson_get_str(yyjson_obj_get(itemObj, "state")), &stateValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (BATTLE_WAIT_STATE): invalid 'state'", + (uint32_t)index + ); + } + item->battleWaitState.state = (battlestate_t)stateValue; + break; + } + + case CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION: + item->battleForceAction.fighterIndex = + (uint8_t)yyjson_get_int(yyjson_obj_get(itemObj, "fighterIndex")); + item->battleForceAction.targetIndex = + (uint8_t)yyjson_get_int(yyjson_obj_get(itemObj, "targetIndex")); + break; + + case CUTSCENE_ITEM_TYPE_MODAL: + if(yyjson_obj_get(itemObj, "options") != NULL) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (MODAL): options are not supported here - " + "use MODAL_OPTIONS_MARKERS instead", (uint32_t)index + ); + } + assetCutsceneCopyStringField( + itemObj, "title", "", item->modal.title, CUTSCENE_MODAL_TITLE_MAX_CHARS + ); + if(!assetCutsceneCopyStringField( + itemObj, "message", NULL, item->modal.message, CUTSCENE_MODAL_MESSAGE_MAX_CHARS + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (MODAL): missing/too-long 'message'", + (uint32_t)index + ); + } + break; + + case CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS: { + assetCutsceneCopyStringField( + itemObj, "title", "", item->modalOptionsMarkers.title, + CUTSCENE_MODAL_TITLE_MAX_CHARS + ); + if(!assetCutsceneCopyStringField( + itemObj, "message", NULL, item->modalOptionsMarkers.message, + CUTSCENE_MODAL_MESSAGE_MAX_CHARS + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (MODAL_OPTIONS_MARKERS): missing/too-long " + "'message'", (uint32_t)index + ); + } + + yyjson_val *options = yyjson_obj_get(itemObj, "options"); + size_t optionCount = yyjson_arr_size(options); + if(!yyjson_is_arr(options) || optionCount == 0 || + optionCount > CUTSCENE_MODAL_OPTIONS_MARKERS_MAX) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (MODAL_OPTIONS_MARKERS): 'options' must " + "have 1-%u entries", (uint32_t)index, + (uint32_t)CUTSCENE_MODAL_OPTIONS_MARKERS_MAX + ); + } + item->modalOptionsMarkers.optionCount = (uint8_t)optionCount; + + yyjson_arr_iter iter = yyjson_arr_iter_with(options); + yyjson_val *optionVal; + size_t o = 0; + while((optionVal = yyjson_arr_iter_next(&iter)) != NULL) { + const char_t *text = yyjson_get_str(yyjson_obj_get(optionVal, "text")); + const char_t *marker = yyjson_get_str(yyjson_obj_get(optionVal, "marker")); + if(text == NULL || marker == NULL) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (MODAL_OPTIONS_MARKERS): options[%u] " + "needs 'text' and 'marker'", (uint32_t)index, (uint32_t)o + ); + } + item->modalOptionsMarkers.options[o] = text; + item->modalOptionsMarkers.markers[o] = marker; + o++; + } + break; + } + + case CUTSCENE_ITEM_TYPE_MODAL_CLOSE: + case CUTSCENE_ITEM_TYPE_RESTART: + case CUTSCENE_ITEM_TYPE_IDLE: + break; + + case CUTSCENE_ITEM_TYPE_UI_SHOW: { + int32_t screenValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_UI_SCREEN_TYPE_TABLE, ASSET_CUTSCENE_UI_SCREEN_TYPE_TABLE_COUNT, + yyjson_get_str(yyjson_obj_get(itemObj, "name")), &screenValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (UI_SHOW): invalid 'name'", (uint32_t)index + ); + } + item->uiShow.screen = (uiscreentype_t)screenValue; + break; + } + + case CUTSCENE_ITEM_TYPE_PRINT: + if(!assetCutsceneCopyStringField( + itemObj, "text", NULL, item->print.text, CUTSCENE_PRINT_MAX_CHARS + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (PRINT): missing/too-long 'text'", (uint32_t)index + ); + } + break; + + case CUTSCENE_ITEM_TYPE_MARKER: { + const char_t *name = yyjson_get_str(yyjson_obj_get(itemObj, "name")); + if(name == NULL) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (MARKER): missing 'name'", (uint32_t)index + ); + } + item->marker.name = name; + break; + } + + case CUTSCENE_ITEM_TYPE_SCENE: { + int32_t sceneValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_SCENE_TYPE_TABLE, ASSET_CUTSCENE_SCENE_TYPE_TABLE_COUNT, + yyjson_get_str(yyjson_obj_get(itemObj, "sceneType")), &sceneValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (SCENE): invalid 'sceneType'", (uint32_t)index + ); + } + item->sceneChange.type = (scenetype_t)sceneValue; + break; + } + + case CUTSCENE_ITEM_TYPE_SAVE_DEVICE_CHECK: { + const char_t *successMarker = yyjson_get_str(yyjson_obj_get(itemObj, "successMarker")); + const char_t *failureMarker = yyjson_get_str(yyjson_obj_get(itemObj, "failureMarker")); + if(successMarker == NULL || failureMarker == NULL) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (SAVE_DEVICE_CHECK): needs " + "'successMarker'/'failureMarker'", (uint32_t)index + ); + } + item->saveDeviceCheck.successMarker = successMarker; + item->saveDeviceCheck.failureMarker = failureMarker; + break; + } + + case CUTSCENE_ITEM_TYPE_SAVE_LOAD_ALL_SLOTS: { + const char_t *successMarker = yyjson_get_str(yyjson_obj_get(itemObj, "successMarker")); + const char_t *failureMarker = yyjson_get_str(yyjson_obj_get(itemObj, "failureMarker")); + if(successMarker == NULL || failureMarker == NULL) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (SAVE_LOAD_ALL_SLOTS): needs " + "'successMarker'/'failureMarker'", (uint32_t)index + ); + } + item->saveLoadAllSlots.successMarker = successMarker; + item->saveLoadAllSlots.failureMarker = failureMarker; + break; + } + + case CUTSCENE_ITEM_TYPE_AUDIO_PLAY: { + if(!assetCutsceneCopyStringField( + itemObj, "file", NULL, item->audioPlay.file, AUDIO_PATH_MAX + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (AUDIO_PLAY): missing/too-long 'file'", + (uint32_t)index + ); + } + int32_t channelValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE, ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE_COUNT, + yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (AUDIO_PLAY): invalid 'channel'", (uint32_t)index + ); + } + item->audioPlay.channel = (audiomixerchannel_t)channelValue; + + yyjson_val *volume = yyjson_obj_get(itemObj, "volume"); + item->audioPlay.volume = volume == NULL ? 1.0f : (float_t)yyjson_get_num(volume); + yyjson_val *pan = yyjson_obj_get(itemObj, "pan"); + item->audioPlay.pan = pan == NULL ? 0.0f : (float_t)yyjson_get_num(pan); + yyjson_val *looping = yyjson_obj_get(itemObj, "looping"); + item->audioPlay.looping = looping == NULL ? false : yyjson_get_bool(looping); + yyjson_val *loopCount = yyjson_obj_get(itemObj, "loopCount"); + item->audioPlay.loopCount = + loopCount == NULL ? 0 : (uint8_t)yyjson_get_int(loopCount); + yyjson_val *loopStart = yyjson_obj_get(itemObj, "loopStart"); + item->audioPlay.loopStart = + loopStart == NULL ? -1.0f : (float_t)yyjson_get_num(loopStart); + yyjson_val *loopTo = yyjson_obj_get(itemObj, "loopTo"); + item->audioPlay.loopTo = loopTo == NULL ? 0.0f : (float_t)yyjson_get_num(loopTo); + break; + } + + case CUTSCENE_ITEM_TYPE_AUDIO_STOP: + case CUTSCENE_ITEM_TYPE_AUDIO_PAUSE: + case CUTSCENE_ITEM_TYPE_AUDIO_RESUME: + case CUTSCENE_ITEM_TYPE_AUDIO_FADE_WAIT: { + int32_t channelValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE, ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE_COUNT, + yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u: invalid 'channel'", (uint32_t)index + ); + } + // All four share the same single-field shape (a channel enum stored + // directly in the item union, not a sub-struct) - see cutsceneitem.h. + switch(item->type) { + case CUTSCENE_ITEM_TYPE_AUDIO_STOP: + item->audioStopChannel = (audiomixerchannel_t)channelValue; + break; + case CUTSCENE_ITEM_TYPE_AUDIO_PAUSE: + item->audioPauseChannel = (audiomixerchannel_t)channelValue; + break; + case CUTSCENE_ITEM_TYPE_AUDIO_RESUME: + item->audioResumeChannel = (audiomixerchannel_t)channelValue; + break; + default:// CUTSCENE_ITEM_TYPE_AUDIO_FADE_WAIT + item->audioFadeWaitChannel = (audiomixerchannel_t)channelValue; + break; + } + break; + } + + case CUTSCENE_ITEM_TYPE_AUDIO_FADE: { + int32_t channelValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE, ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE_COUNT, + yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (AUDIO_FADE): invalid 'channel'", (uint32_t)index + ); + } + item->audioFade.channel = (audiomixerchannel_t)channelValue; + item->audioFade.from = (float_t)yyjson_get_num(yyjson_obj_get(itemObj, "from")); + item->audioFade.to = (float_t)yyjson_get_num(yyjson_obj_get(itemObj, "to")); + item->audioFade.duration = (float_t)yyjson_get_num(yyjson_obj_get(itemObj, "duration")); + + const char_t *easingName = yyjson_get_str(yyjson_obj_get(itemObj, "easing")); + int32_t easingValue = EASING_LINEAR; + if(easingName != NULL && !assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_EASING_TABLE, ASSET_CUTSCENE_EASING_TABLE_COUNT, + easingName, &easingValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (AUDIO_FADE): invalid 'easing'", (uint32_t)index + ); + } + item->audioFade.easing = (easingtype_t)easingValue; + break; + } + + case CUTSCENE_ITEM_TYPE_AUDIO_SET_PAN: { + int32_t channelValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE, ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE_COUNT, + yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (AUDIO_SET_PAN): invalid 'channel'", + (uint32_t)index + ); + } + item->audioSetPan.channel = (audiomixerchannel_t)channelValue; + item->audioSetPan.pan = (float_t)yyjson_get_num(yyjson_obj_get(itemObj, "pan")); + break; + } + + case CUTSCENE_ITEM_TYPE_AUDIO_SET_LOOP: { + int32_t channelValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE, ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE_COUNT, + yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (AUDIO_SET_LOOP): invalid 'channel'", + (uint32_t)index + ); + } + item->audioSetLoop.channel = (audiomixerchannel_t)channelValue; + yyjson_val *looping = yyjson_obj_get(itemObj, "looping"); + item->audioSetLoop.looping = looping == NULL ? true : yyjson_get_bool(looping); + yyjson_val *loopCount = yyjson_obj_get(itemObj, "loopCount"); + item->audioSetLoop.loopCount = + loopCount == NULL ? 0 : (uint8_t)yyjson_get_int(loopCount); + yyjson_val *loopStart = yyjson_obj_get(itemObj, "loopStart"); + item->audioSetLoop.loopStart = + loopStart == NULL ? -1.0f : (float_t)yyjson_get_num(loopStart); + yyjson_val *loopTo = yyjson_obj_get(itemObj, "loopTo"); + item->audioSetLoop.loopTo = loopTo == NULL ? 0.0f : (float_t)yyjson_get_num(loopTo); + break; + } + + case CUTSCENE_ITEM_TYPE_AUDIO_SET: { + int32_t channelValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE, ASSET_CUTSCENE_AUDIO_CHANNEL_TABLE_COUNT, + yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue + )) { + assetLoaderErrorThrow( + loading, "Cutscene item %u (AUDIO_SET): invalid 'channel'", (uint32_t)index + ); + } + item->audioSet.channel = (audiomixerchannel_t)channelValue; + item->audioSet.pan = (float_t)yyjson_get_num(yyjson_obj_get(itemObj, "pan")); + yyjson_val *looping = yyjson_obj_get(itemObj, "looping"); + item->audioSet.looping = looping == NULL ? true : yyjson_get_bool(looping); + yyjson_val *loopCount = yyjson_obj_get(itemObj, "loopCount"); + item->audioSet.loopCount = + loopCount == NULL ? 0 : (uint8_t)yyjson_get_int(loopCount); + yyjson_val *loopStart = yyjson_obj_get(itemObj, "loopStart"); + item->audioSet.loopStart = + loopStart == NULL ? -1.0f : (float_t)yyjson_get_num(loopStart); + yyjson_val *loopTo = yyjson_obj_get(itemObj, "loopTo"); + item->audioSet.loopTo = loopTo == NULL ? 0.0f : (float_t)yyjson_get_num(loopTo); + break; + } + + default: + // Unreachable - ASSET_CUTSCENE_ITEM_TYPE_TABLE only maps to types + // handled above. + assetLoaderErrorThrow( + loading, "Cutscene item %u: unhandled item type %u", (uint32_t)index, + (uint32_t)item->type + ); + } + + errorOk(); +} + +void assetCutsceneFreeParsed(assetcutsceneoutput_t *out) { + if(out->items != NULL) { + memoryFree(out->items); + out->items = NULL; + } + if(out->pool != NULL) { + memoryFree(out->pool); + out->pool = NULL; + } + if(out->doc != NULL) { + yyjson_doc_free(out->doc); + out->doc = NULL; + } } errorret_t assetCutsceneLoaderAsync(assetloading_t *loading) { @@ -82,29 +1117,30 @@ errorret_t assetCutsceneLoaderAsync(assetloading_t *loading) { errorOk(); } - assertNull(loading->loading.cutscene.data, "Data already defined?"); + assertNull(loading->loading.cutscene.buffer, "Buffer already defined?"); assetfile_t *file = &loading->loading.cutscene.file; assetLoaderErrorChain(loading, assetFileInit(file, loading->entry->name, NULL, NULL) ); - uint8_t *data = memoryAllocate(file->size); + if(file->size > ASSET_CUTSCENE_FILE_SIZE_MAX) { + assetLoaderErrorThrow(loading, "Cutscene JSON exceeds maximum allowed size"); + } + + size_t fileSize = (size_t)file->size; + uint8_t *buffer = memoryAllocate(fileSize); assetLoaderErrorChain(loading, assetFileOpen(file)); - assetLoaderErrorChain(loading, assetFileRead(file, data, file->size)); + assetLoaderErrorChain(loading, assetFileRead(file, buffer, fileSize)); 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.buffer = buffer; + loading->loading.cutscene.size = fileSize; loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_PARSE; loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC; errorOk(); @@ -122,376 +1158,78 @@ errorret_t assetCutsceneLoaderSync(assetloading_t *loading) { } assetcutsceneoutput_t *out = &loading->entry->data.cutscene; - uint8_t *data = loading->loading.cutscene.data; - assertNotNull(data, "Cutscene data should have been loaded by now."); + uint8_t *buffer = loading->loading.cutscene.buffer; + assertNotNull(buffer, "Cutscene data should have been loaded by now."); + size_t bufferSize = loading->loading.cutscene.size; - size_t fileSize = loading->loading.cutscene.dataSize; + yyjson_doc *doc = yyjson_read( + (char *)buffer, bufferSize, + YYJSON_READ_ALLOW_COMMENTS | YYJSON_READ_ALLOW_TRAILING_COMMAS + ); + memoryFree(buffer); + loading->loading.cutscene.buffer = NULL; - if(data[0] != 'D' || data[1] != 'C' || data[2] != 'T' || data[3] != 'S') { - memoryFree(data); - assetLoaderErrorThrow(loading, "Invalid cutscene file header"); + if(doc == NULL) { + assetLoaderErrorThrow(loading, "Failed to parse cutscene JSON"); + } + out->doc = doc; + + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *itemsArr = yyjson_obj_get(root, "items"); + if(!yyjson_is_arr(itemsArr)) { + assetCutsceneFreeParsed(out); + assetLoaderErrorThrow(loading, "Cutscene JSON is missing an 'items' array"); } - 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 - ); + size_t itemCount = yyjson_arr_size(itemsArr); + if(itemCount > UINT8_MAX) { + assetCutsceneFreeParsed(out); + assetLoaderErrorThrow(loading, "Cutscene has more than 255 items"); } - 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); + cutscenepause_t pause = CUTSCENE_PAUSE_DEFAULT; + yyjson_val *pauseArr = yyjson_obj_get(root, "pause"); + if(yyjson_is_arr(pauseArr)) { + pause = CUTSCENE_PAUSE_NONE; + yyjson_arr_iter pauseIter = yyjson_arr_iter_with(pauseArr); + yyjson_val *flagVal; + while((flagVal = yyjson_arr_iter_next(&pauseIter)) != NULL) { + int32_t flagValue; + if(!assetCutsceneLookupEnumInsensitive( + ASSET_CUTSCENE_PAUSE_FLAG_TABLE, ASSET_CUTSCENE_PAUSE_FLAG_TABLE_COUNT, + yyjson_get_str(flagVal), &flagValue + )) { + assetCutsceneFreeParsed(out); + assetLoaderErrorThrow(loading, "Cutscene JSON has an invalid 'pause' flag"); + } + pause |= (cutscenepause_t)flagValue; + } } - 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); + size_t poolSize = assetCutsceneComputePoolSize(itemsArr); + out->pool = poolSize > 0 ? memoryAllocate(poolSize) : NULL; - 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 = worldPosReadLE(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 = worldPosReadLE(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 = - worldUnitReadLE(data, &offset); - item->entityWalkToEntity.offsetY = - worldUnitReadLE(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. - // Read into a local first (not inline in the check below) - on a - // release build with DUSK_ASSERTIONS_FAKED, an assert's condition - // is never evaluated at all, so a byte-consuming call inside one - // would silently desync every item after it. This is untrusted - // file content anyway, so it gets a real error, not an assert. - uint8_t optionCount = assetCutsceneReadU8(data, &offset); - if(optionCount != 0) { - memoryFree(data); - memoryFree(out->items); - out->items = NULL; - if(out->pool != NULL) { - memoryFree(out->pool); - out->pool = NULL; - } - assetLoaderErrorThrow( - loading, - "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: - case CUTSCENE_ITEM_TYPE_IDLE: - 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; - - case CUTSCENE_ITEM_TYPE_AUDIO_PLAY: - assetCutsceneReadEmbeddedString( - data, &offset, item->audioPlay.file, AUDIO_PATH_MAX - ); - item->audioPlay.channel = - (audiomixerchannel_t)assetCutsceneReadU8(data, &offset); - item->audioPlay.volume = assetCutsceneReadFloat(data, &offset); - item->audioPlay.pan = assetCutsceneReadFloat(data, &offset); - item->audioPlay.looping = assetCutsceneReadU8(data, &offset) != 0; - item->audioPlay.loopCount = assetCutsceneReadU8(data, &offset); - item->audioPlay.loopStart = assetCutsceneReadFloat(data, &offset); - item->audioPlay.loopTo = assetCutsceneReadFloat(data, &offset); - break; - - case CUTSCENE_ITEM_TYPE_AUDIO_STOP: - item->audioStopChannel = - (audiomixerchannel_t)assetCutsceneReadU8(data, &offset); - break; - - case CUTSCENE_ITEM_TYPE_AUDIO_PAUSE: - item->audioPauseChannel = - (audiomixerchannel_t)assetCutsceneReadU8(data, &offset); - break; - - case CUTSCENE_ITEM_TYPE_AUDIO_RESUME: - item->audioResumeChannel = - (audiomixerchannel_t)assetCutsceneReadU8(data, &offset); - break; - - case CUTSCENE_ITEM_TYPE_AUDIO_FADE: - item->audioFade.channel = - (audiomixerchannel_t)assetCutsceneReadU8(data, &offset); - item->audioFade.from = assetCutsceneReadFloat(data, &offset); - item->audioFade.to = assetCutsceneReadFloat(data, &offset); - item->audioFade.duration = assetCutsceneReadFloat(data, &offset); - item->audioFade.easing = - (easingtype_t)assetCutsceneReadU8(data, &offset); - break; - - case CUTSCENE_ITEM_TYPE_AUDIO_FADE_WAIT: - item->audioFadeWaitChannel = - (audiomixerchannel_t)assetCutsceneReadU8(data, &offset); - break; - - case CUTSCENE_ITEM_TYPE_AUDIO_SET_PAN: - item->audioSetPan.channel = - (audiomixerchannel_t)assetCutsceneReadU8(data, &offset); - item->audioSetPan.pan = assetCutsceneReadFloat(data, &offset); - break; - - case CUTSCENE_ITEM_TYPE_AUDIO_SET_LOOP: - item->audioSetLoop.channel = - (audiomixerchannel_t)assetCutsceneReadU8(data, &offset); - item->audioSetLoop.looping = assetCutsceneReadU8(data, &offset) != 0; - item->audioSetLoop.loopCount = assetCutsceneReadU8(data, &offset); - item->audioSetLoop.loopStart = assetCutsceneReadFloat(data, &offset); - item->audioSetLoop.loopTo = assetCutsceneReadFloat(data, &offset); - break; - - case CUTSCENE_ITEM_TYPE_AUDIO_SET: - item->audioSet.channel = - (audiomixerchannel_t)assetCutsceneReadU8(data, &offset); - item->audioSet.pan = assetCutsceneReadFloat(data, &offset); - item->audioSet.looping = assetCutsceneReadU8(data, &offset) != 0; - item->audioSet.loopCount = assetCutsceneReadU8(data, &offset); - item->audioSet.loopStart = assetCutsceneReadFloat(data, &offset); - item->audioSet.loopTo = assetCutsceneReadFloat(data, &offset); - 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 " - "(item %u/%u, offset %u/%u, poolSize %u)", - (uint32_t)item->type, (uint32_t)i, (uint32_t)itemCount, - (uint32_t)offset, (uint32_t)fileSize, (uint32_t)poolSize - ); + size_t poolOffset = 0; + yyjson_arr_iter itemIter = yyjson_arr_iter_with(itemsArr); + yyjson_val *itemVal; + uint8_t index = 0; + while((itemVal = yyjson_arr_iter_next(&itemIter)) != NULL) { + errorret_t itemResult = assetCutsceneParseItem( + loading, itemVal, &out->items[index], out->pool, &poolOffset, index + ); + if(errorIsNotOk(itemResult)) { + assetCutsceneFreeParsed(out); + errorChain(itemResult); } + index++; } - memoryFree(data); - loading->loading.cutscene.data = NULL; - out->cutscene.items = out->items; - out->cutscene.itemCount = itemCount; - out->cutscene.pause = pauseType; + out->cutscene.itemCount = (uint8_t)itemCount; + out->cutscene.pause = pause; out->cutscene.dataSize = 0; loading->entry->state = ASSET_ENTRY_STATE_LOADED; @@ -503,17 +1241,7 @@ errorret_t assetCutsceneDispose(assetentry_t *entry) { 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; - } + assetCutsceneFreeParsed(&entry->data.cutscene); errorOk(); } diff --git a/src/dusk/asset/loader/cutscene/assetcutsceneloader.h b/src/dusk/asset/loader/cutscene/assetcutsceneloader.h index 44b88b83..197f4f0b 100644 --- a/src/dusk/asset/loader/cutscene/assetcutsceneloader.h +++ b/src/dusk/asset/loader/cutscene/assetcutsceneloader.h @@ -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. diff --git a/src/dusk/rpg/cutscene/cutscene.h b/src/dusk/rpg/cutscene/cutscene.h index 2f2d2cbe..0a3a9f35 100644 --- a/src/dusk/rpg/cutscene/cutscene.h +++ b/src/dusk/rpg/cutscene/cutscene.h @@ -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 diff --git a/src/dusk/rpg/cutscene/item/cutsceneitem.c b/src/dusk/rpg/cutscene/item/cutsceneitem.c index 8e1e428a..8a3f8cf5 100644 --- a/src/dusk/rpg/cutscene/item/cutsceneitem.c +++ b/src/dusk/rpg/cutscene/item/cutsceneitem.c @@ -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 } }; diff --git a/src/dusk/rpg/cutscene/item/cutsceneitem.h b/src/dusk/rpg/cutscene/item/cutsceneitem.h index f25e9cb2..0b567a56 100644 --- a/src/dusk/rpg/cutscene/item/cutsceneitem.h +++ b/src/dusk/rpg/cutscene/item/cutsceneitem.h @@ -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; }; }; diff --git a/src/dusk/rpg/cutscene/item/ui/CMakeLists.txt b/src/dusk/rpg/cutscene/item/ui/CMakeLists.txt index fe4708bd..5702ce7a 100644 --- a/src/dusk/rpg/cutscene/item/ui/CMakeLists.txt +++ b/src/dusk/rpg/cutscene/item/ui/CMakeLists.txt @@ -14,4 +14,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} cutscenemodal.c cutscenemodaloptionsmarkers.c cutscenekeyboard.c + cutsceneuishow.c ) diff --git a/src/dusk/rpg/cutscene/item/ui/cutsceneuishow.c b/src/dusk/rpg/cutscene/item/ui/cutsceneuishow.c new file mode 100644 index 00000000..6184e7b0 --- /dev/null +++ b/src/dusk/rpg/cutscene/item/ui/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; +} diff --git a/src/dusk/rpg/cutscene/item/ui/cutsceneuishow.h b/src/dusk/rpg/cutscene/item/ui/cutsceneuishow.h new file mode 100644 index 00000000..40f8eb5a --- /dev/null +++ b/src/dusk/rpg/cutscene/item/ui/cutsceneuishow.h @@ -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 +); diff --git a/src/dusk/scene/initial/sceneinitial.c b/src/dusk/scene/initial/sceneinitial.c index 0471a81a..ddcb4a2a 100644 --- a/src/dusk/scene/initial/sceneinitial.c +++ b/src/dusk/scene/initial/sceneinitial.c @@ -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); diff --git a/src/dusk/scene/mainmenu/scenemainmenu.c b/src/dusk/scene/mainmenu/scenemainmenu.c index 7f8227e1..8ca8cbb3 100644 --- a/src/dusk/scene/mainmenu/scenemainmenu.c +++ b/src/dusk/scene/mainmenu/scenemainmenu.c @@ -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); diff --git a/src/dusk/scene/mainmenu/scenemainmenu.h b/src/dusk/scene/mainmenu/scenemainmenu.h index 23639b3d..2e4c5419 100644 --- a/src/dusk/scene/mainmenu/scenemainmenu.h +++ b/src/dusk/scene/mainmenu/scenemainmenu.h @@ -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. diff --git a/tools/asset/cutscene/__main__.py b/tools/asset/cutscene/__main__.py deleted file mode 100644 index 62709d7d..00000000 --- a/tools/asset/cutscene/__main__.py +++ /dev/null @@ -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/.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/.jsonc -> assets/cutscenes/.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(' CUTSCENE_MAP_AREA_WAIT_MAX: - raise ValueError("MAP_AREA_WAIT areaIds exceeds maximum of 4") - buf += struct.pack(' CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX: - raise ValueError("START_BATTLE enemies exceeds maximum of 4") - buf += struct.pack(' 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(' {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() diff --git a/tools/asset/pack/__main__.py b/tools/asset/pack/__main__.py index cf2275be..851740b1 100644 --- a/tools/asset/pack/__main__.py +++ b/tools/asset/pack/__main__.py @@ -35,9 +35,20 @@ Usage: 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) '