From e630827b343c5df2dc58bcfb27cbfdc0ad48c7df Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Tue, 25 Aug 2026 09:51:26 -0500 Subject: [PATCH] Add runtime-loaded cutscene files, convert initial/main menu to use them New ASSET_LOADER_TYPE_CUTSCENE (assetcutsceneloader.c) reads a versioned binary .cts format decoded into a heap-allocated cutsceneitem_t array + a string/data pool, both sized to the file's actual content rather than a fixed capacity, so the shared assetloaderoutput_t union doesn't bloat for every asset slot regardless of type. Authoring pipeline mirrors the chunk asset pattern: assetsraw/cutscenes/*.jsonc (JSON plus // and /* */ comments) -> tools/asset/cutscene -> assets/cutscenes/*.cts. cutsceneSystemSetOnComplete() lets the caller arm a native callback that fires when a cutscene finishes normally, so a file (which can't store a function pointer) can end plainly and still hand off to native code - cutsceneRestart() preserves it across a retry loop rather than clearing it, since a restart is the same logical run trying again. The initial and main-menu start-game cutscenes are now loaded from files instead of compiled in via the CUTSCENE(...) macro. Fixed a real bug found while converting these: the sync loader read the file's total size from assetfile_t.size to locate the trailing pool region, but assetFileDispose() (called at the end of the async phase) zeroes that whole struct first, so the size was always 0 and the pool offset computation underflowed into an out-of-bounds read - intermittent depending on heap layout. Fixed by saving the size before disposal; also fixed the read-completeness assert being checked after that same zeroing (a no-op 0 == 0 check). Co-Authored-By: Claude Sonnet 5 --- assets/cutscenes/initial.cts | Bin 0 -> 282 bytes assets/cutscenes/main_menu_start_game.cts | Bin 0 -> 461 bytes assetsraw/cutscenes/initial.jsonc | 69 +++ .../cutscenes/main_menu_start_game.jsonc | 92 ++++ src/dusk/asset/loader/CMakeLists.txt | 3 +- src/dusk/asset/loader/assetloader.c | 6 + src/dusk/asset/loader/assetloader.h | 5 + src/dusk/asset/loader/cutscene/CMakeLists.txt | 9 + .../loader/cutscene/assetcutsceneloader.c | 449 +++++++++++++++++ .../loader/cutscene/assetcutsceneloader.h | 71 +++ src/dusk/rpg/cutscene/cutscenesystem.c | 28 +- src/dusk/rpg/cutscene/cutscenesystem.h | 22 + src/dusk/scene/initial/sceneinitial.c | 49 +- src/dusk/scene/mainmenu/scenemainmenu.c | 76 ++- tools/asset/cutscene/__main__.py | 455 ++++++++++++++++++ 15 files changed, 1261 insertions(+), 73 deletions(-) create mode 100644 assets/cutscenes/initial.cts create mode 100644 assets/cutscenes/main_menu_start_game.cts create mode 100644 assetsraw/cutscenes/initial.jsonc create mode 100644 assetsraw/cutscenes/main_menu_start_game.jsonc create mode 100644 src/dusk/asset/loader/cutscene/CMakeLists.txt create mode 100644 src/dusk/asset/loader/cutscene/assetcutsceneloader.c create mode 100644 src/dusk/asset/loader/cutscene/assetcutsceneloader.h create mode 100644 tools/asset/cutscene/__main__.py diff --git a/assets/cutscenes/initial.cts b/assets/cutscenes/initial.cts new file mode 100644 index 0000000000000000000000000000000000000000..9c7ba46799709d0ad14e1e4664161938d68f72bc GIT binary patch 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 literal 0 HcmV?d00001 diff --git a/assets/cutscenes/main_menu_start_game.cts b/assets/cutscenes/main_menu_start_game.cts new file mode 100644 index 0000000000000000000000000000000000000000..29cbbd591a5a836d0b3141ac03e7620699eb5094 GIT binary patch literal 461 zcmZ>94hd#tU|?Y8zs?{jo12)K7oVG&SE`qsk(!*HnU@}4oLH8sSCUzhlPZr(HaE4n zI59nyf%WVeA3FsG1|9}k2?l8?Nrd{m{P>jAvdmumM}=m z$<{DPD|RqUV33`|Acf=rkgMWz@)J|yQ;Ul7iy#hEL>P-M4>g=|1;Ysj*$WKL{(d2z zexa^Fk2CoB$Gf-mK0S2)djhR1VsXAY-*D8^GY)FN>hQ-DE7eg jAlvQZ@95&{!oUEc<6VP-{DV;02=mcB1aS$>OsF{kVP=lk literal 0 HcmV?d00001 diff --git a/assetsraw/cutscenes/initial.jsonc b/assetsraw/cutscenes/initial.jsonc new file mode 100644 index 00000000..35ff17ac --- /dev/null +++ b/assetsraw/cutscenes/initial.jsonc @@ -0,0 +1,69 @@ +{ + "items": [ + // Boot check: make sure a save device is available before handing off + // to the main menu. + { + "type": "MODAL", + "title": "initial.checking_save.title", + "message": "initial.checking_save.message" + }, + { + "type": "WAIT", + "seconds": 0.2 + }, + { + "type": "SAVE_DEVICE_CHECK", + "successMarker": "CONTINUE", + "failureMarker": "NO_DEVICE" + }, + + // No save device found - offer to retry or continue without saving. + { + "type": "MARKER", + "name": "NO_DEVICE" + }, + { + "type": "MODAL_CLOSE" + }, + { + "type": "MODAL_OPTIONS_MARKERS", + "title": "initial.no_device.title", + "message": "initial.no_device.message", + "options": [ + { + "text": "initial.no_device.retry", + "marker": "RETRY" + }, + { + "text": "initial.no_device.continue", + "marker": "CONTINUE" + } + ] + }, + + { + "type": "MARKER", + "name": "RETRY" + }, + { + "type": "MODAL_CLOSE" + }, + { + "type": "RESTART" + }, + + // Save device found (or continuing without one) - hand off to the + // main menu scene. + { + "type": "MARKER", + "name": "CONTINUE" + }, + { + "type": "MODAL_CLOSE" + }, + { + "type": "SCENE", + "sceneType": "MAIN_MENU" + } + ] +} diff --git a/assetsraw/cutscenes/main_menu_start_game.jsonc b/assetsraw/cutscenes/main_menu_start_game.jsonc new file mode 100644 index 00000000..ac1a880c --- /dev/null +++ b/assetsraw/cutscenes/main_menu_start_game.jsonc @@ -0,0 +1,92 @@ +{ + "items": [ + { + "type": "MODAL", + "title": "main_menu.checking_save.title", + "message": "main_menu.checking_save.message" + }, + { + "type": "WAIT", + "seconds": 0.2 + }, + { + "type": "SAVE_DEVICE_CHECK", + "successMarker": "CONTINUE", + "failureMarker": "NO_DEVICE" + }, + + // No save device found - offer to retry or continue without saving. + { + "type": "MARKER", + "name": "NO_DEVICE" + }, + { + "type": "MODAL_CLOSE" + }, + { + "type": "MODAL_OPTIONS_MARKERS", + "title": "main_menu.no_device.title", + "message": "main_menu.no_device.message", + "options": [ + { + "text": "main_menu.no_device.retry", + "marker": "RETRY" + }, + { + "text": "main_menu.no_device.continue", + "marker": "CONTINUE" + } + ] + }, + + // Retry + { + "type": "MARKER", + "name": "RETRY" + }, + { + "type": "MODAL_CLOSE" + }, + { + "type": "RESTART" + }, + + // Save device found - attempt to load all save slots. + { + "type": "MARKER", + "name": "CONTINUE" + }, + { + "type": "MODAL_CLOSE" + }, + { + "type": "SAVE_LOAD_ALL_SLOTS", + "successMarker": "LOADED", + "failureMarker": "LOAD_ERROR" + }, + + // Save data failed to load (e.g. corrupt/unreadable) - only option is + // to retry, no "continue without saving" here since we already know a + // device is present. + { + "type": "MARKER", + "name": "LOAD_ERROR" + }, + { + "type": "MODAL_OPTIONS_MARKERS", + "title": "main_menu.save_load_error.title", + "message": "main_menu.save_load_error.message", + "options": [ + { + "text": "main_menu.save_load_error.retry", + "marker": "RETRY" + } + ] + }, + + { + "type": "MARKER", + "name": "LOADED" + } + ] +} diff --git a/src/dusk/asset/loader/CMakeLists.txt b/src/dusk/asset/loader/CMakeLists.txt index 30f97bc1..5edd469c 100644 --- a/src/dusk/asset/loader/CMakeLists.txt +++ b/src/dusk/asset/loader/CMakeLists.txt @@ -16,4 +16,5 @@ add_subdirectory(display) add_subdirectory(locale) add_subdirectory(json) add_subdirectory(chunk) -add_subdirectory(dmf) \ No newline at end of file +add_subdirectory(dmf) +add_subdirectory(cutscene) \ No newline at end of file diff --git a/src/dusk/asset/loader/assetloader.c b/src/dusk/asset/loader/assetloader.c index ab1ba41b..1bfa92b5 100644 --- a/src/dusk/asset/loader/assetloader.c +++ b/src/dusk/asset/loader/assetloader.c @@ -51,4 +51,10 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = { .loadAsync = assetChunkLoaderAsync, .dispose = assetChunkDispose }, + + [ASSET_LOADER_TYPE_CUTSCENE] = { + .loadSync = assetCutsceneLoaderSync, + .loadAsync = assetCutsceneLoaderAsync, + .dispose = assetCutsceneDispose + }, }; diff --git a/src/dusk/asset/loader/assetloader.h b/src/dusk/asset/loader/assetloader.h index a9edd1b1..fdfd3ecb 100644 --- a/src/dusk/asset/loader/assetloader.h +++ b/src/dusk/asset/loader/assetloader.h @@ -13,6 +13,7 @@ #include "asset/loader/locale/assetlocaleloader.h" #include "asset/loader/json/assetjsonloader.h" #include "asset/loader/chunk/assetchunkloader.h" +#include "asset/loader/cutscene/assetcutsceneloader.h" typedef enum { ASSET_LOADER_TYPE_NULL, @@ -24,6 +25,7 @@ typedef enum { ASSET_LOADER_TYPE_LOCALE, ASSET_LOADER_TYPE_JSON, ASSET_LOADER_TYPE_CHUNK, + ASSET_LOADER_TYPE_CUTSCENE, ASSET_LOADER_TYPE_COUNT } assetloadertype_t; @@ -36,6 +38,7 @@ typedef union { assetlocaleloaderloading_t locale; assetjsonloaderloading_t json; assetchunkloaderloading_t chunk; + assetcutsceneloaderloading_t cutscene; } assetloaderloading_t; typedef union { @@ -46,6 +49,7 @@ typedef union { assetlocaleoutput_t locale; assetjsonoutput_t json; assetchunkoutput_t chunk; + assetcutsceneoutput_t cutscene; } assetloaderoutput_t; typedef union { @@ -54,6 +58,7 @@ typedef union { assetlocaleloaderinput_t locale; assetjsonloaderinput_t json; assetchunkloaderinput_t chunk; + assetcutsceneloaderinput_t cutscene; } assetloaderinput_t; typedef struct assetloading_s assetloading_t; diff --git a/src/dusk/asset/loader/cutscene/CMakeLists.txt b/src/dusk/asset/loader/cutscene/CMakeLists.txt new file mode 100644 index 00000000..04279174 --- /dev/null +++ b/src/dusk/asset/loader/cutscene/CMakeLists.txt @@ -0,0 +1,9 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + assetcutsceneloader.c +) diff --git a/src/dusk/asset/loader/cutscene/assetcutsceneloader.c b/src/dusk/asset/loader/cutscene/assetcutsceneloader.c new file mode 100644 index 00000000..23aea0aa --- /dev/null +++ b/src/dusk/asset/loader/cutscene/assetcutsceneloader.c @@ -0,0 +1,449 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "assetcutsceneloader.h" +#include "assert/assert.h" +#include "util/memory.h" +#include "util/endian.h" +#include "asset/loader/assetloading.h" +#include "asset/loader/assetentry.h" +#include "asset/loader/assetloader.h" +#include "asset/asset.h" + +// DCTS header: magic "DCTS" (4), version u32 LE (4), pauseType u8 (1), +// itemCount u8 (1), poolSize u16 LE (2) = 12 bytes. +#define ASSET_CUTSCENE_HEADER_SIZE 12 + +static uint8_t assetCutsceneReadU8(const uint8_t *data, size_t *offset) { + uint8_t value = data[*offset]; + *offset += sizeof(uint8_t); + return value; +} + +static uint16_t assetCutsceneReadU16(const uint8_t *data, size_t *offset) { + uint16_t value; + memoryCopy(&value, data + *offset, sizeof(uint16_t)); + *offset += sizeof(uint16_t); + return endianLittleToHost16(value); +} + +static uint32_t assetCutsceneReadU32(const uint8_t *data, size_t *offset) { + uint32_t value; + memoryCopy(&value, data + *offset, sizeof(uint32_t)); + *offset += sizeof(uint32_t); + return endianLittleToHost32(value); +} + +static float_t assetCutsceneReadFloat(const uint8_t *data, size_t *offset) { + float_t value; + memoryCopy(&value, data + *offset, sizeof(float_t)); + *offset += sizeof(float_t); + return endianLittleToHostFloat(value); +} + +static worldunit_t assetCutsceneReadWorldUnit( + const uint8_t *data, + size_t *offset +) { + uint16_t value = assetCutsceneReadU16(data, offset); + return (worldunit_t)value; +} + +static worldpos_t assetCutsceneReadWorldPos( + const uint8_t *data, + size_t *offset +) { + worldpos_t pos; + pos.x = assetCutsceneReadWorldUnit(data, offset); + pos.y = assetCutsceneReadWorldUnit(data, offset); + pos.z = assetCutsceneReadWorldUnit(data, offset); + return pos; +} + +// Copies a length-prefixed string directly into an item's own embedded +// char_t[destCapacity] field (CUTSCENE_TEXT_MAX_CHARS and friends) - these +// are never pool references, see the item-field inventory in the runtime +// cutscene file design. +static void assetCutsceneReadEmbeddedString( + const uint8_t *data, + size_t *offset, + char_t *dest, + const size_t destCapacity +) { + uint8_t len = assetCutsceneReadU8(data, offset); + assertTrue(len < destCapacity, "Cutscene string exceeds field capacity"); + memoryCopy(dest, data + *offset, len); + dest[len] = '\0'; + *offset += len; +} + +// Resolves a u16 pool offset (read from the item stream) to a real pointer +// into the entry's own persistent pool allocation. +static const char_t * assetCutsceneReadPoolString( + const uint8_t *data, + size_t *offset, + const char_t *pool +) { + uint16_t poolOffset = assetCutsceneReadU16(data, offset); + return pool + poolOffset; +} + +errorret_t assetCutsceneLoaderAsync(assetloading_t *loading) { + assertNotNull(loading, "Loading cannot be NULL"); + assertNotMainThread("Should be called from an async thread."); + + if(loading->loading.cutscene.state != ASSET_CUTSCENE_LOADING_STATE_READ_FILE) { + errorOk(); + } + + assertNull(loading->loading.cutscene.data, "Data already defined?"); + + assetfile_t *file = &loading->loading.cutscene.file; + assetLoaderErrorChain(loading, + assetFileInit(file, loading->entry->name, NULL, NULL) + ); + + uint8_t *data = memoryAllocate(file->size); + assetLoaderErrorChain(loading, assetFileOpen(file)); + assetLoaderErrorChain(loading, assetFileRead(file, data, file->size)); + assertTrue( + file->lastRead == file->size, + "Failed to read entire cutscene file." + ); + // Saved before assetFileDispose zeroes the whole assetfile_t struct + // (including .size) - the sync phase needs the file's total length to + // locate the pool region, which starts poolSize bytes before the end. + loading->loading.cutscene.dataSize = (size_t)file->size; + + assetLoaderErrorChain(loading, assetFileClose(file)); + assetLoaderErrorChain(loading, assetFileDispose(file)); + + loading->loading.cutscene.data = data; + loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_PARSE; + loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC; + errorOk(); +} + +errorret_t assetCutsceneLoaderSync(assetloading_t *loading) { + assertNotNull(loading, "Loading cannot be NULL"); + assertTrue(loading->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type."); + assertIsMainThread("Must be called from the main thread."); + + if(loading->loading.cutscene.state == ASSET_CUTSCENE_LOADING_STATE_INITIAL) { + loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_READ_FILE; + loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC; + errorOk(); + } + + assetcutsceneoutput_t *out = &loading->entry->data.cutscene; + uint8_t *data = loading->loading.cutscene.data; + assertNotNull(data, "Cutscene data should have been loaded by now."); + + size_t fileSize = loading->loading.cutscene.dataSize; + + if(data[0] != 'D' || data[1] != 'C' || data[2] != 'T' || data[3] != 'S') { + memoryFree(data); + assetLoaderErrorThrow(loading, "Invalid cutscene file header"); + } + + size_t offset = 4; + uint32_t version = assetCutsceneReadU32(data, &offset); + if(version != ASSET_CUTSCENE_FILE_VERSION) { + memoryFree(data); + assetLoaderErrorThrow( + loading, "Unsupported cutscene file version %u", version + ); + } + + cutscenepause_t pauseType = (cutscenepause_t)assetCutsceneReadU8(data, &offset); + uint8_t itemCount = assetCutsceneReadU8(data, &offset); + uint16_t poolSize = assetCutsceneReadU16(data, &offset); + assertTrue(offset == ASSET_CUTSCENE_HEADER_SIZE, "Cutscene header size mismatch"); + + out->pool = poolSize > 0 ? memoryAllocate(poolSize) : NULL; + if(poolSize > 0) { + size_t poolStart = fileSize - (size_t)poolSize; + memoryCopy(out->pool, data + poolStart, poolSize); + } + const char_t *pool = out->pool; + + out->items = memoryAllocate(itemCount * sizeof(cutsceneitem_t)); + memoryZero(out->items, itemCount * sizeof(cutsceneitem_t)); + + for(uint8_t i = 0; i < itemCount; i++) { + cutsceneitem_t *item = &out->items[i]; + item->type = (cutsceneitemtype_t)assetCutsceneReadU8(data, &offset); + + switch(item->type) { + case CUTSCENE_ITEM_TYPE_TEXT: + assetCutsceneReadEmbeddedString( + data, &offset, item->text.text, CUTSCENE_TEXT_MAX_CHARS + ); + break; + + case CUTSCENE_ITEM_TYPE_TEXT_MINI: + assetCutsceneReadEmbeddedString( + data, &offset, item->textMini.text, CUTSCENE_TEXT_MINI_MAX_CHARS + ); + item->textMini.position[0] = assetCutsceneReadFloat(data, &offset); + item->textMini.position[1] = assetCutsceneReadFloat(data, &offset); + item->textMini.position[2] = assetCutsceneReadFloat(data, &offset); + item->textMini.duration = assetCutsceneReadFloat(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_TEXT_MINI_HIDE: + item->textMiniHide.index = assetCutsceneReadU8(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_WAIT: + item->wait = assetCutsceneReadFloat(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_ENTITY_TELEPORT: + item->entityTeleport.entityIndex = assetCutsceneReadU8(data, &offset); + item->entityTeleport.target = assetCutsceneReadWorldPos(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO: { + item->entityWalkTo.entityIndex = assetCutsceneReadU8(data, &offset); + item->entityWalkTo.walkAround = assetCutsceneReadU8(data, &offset) != 0; + uint8_t count = assetCutsceneReadU8(data, &offset); + uint16_t poolOffset = assetCutsceneReadU16(data, &offset); + item->entityWalkTo.count = count; + item->entityWalkTo.positions = (const worldpos_t *)(pool + poolOffset); + break; + } + + case CUTSCENE_ITEM_TYPE_FADE: + item->fade.from.r = assetCutsceneReadU8(data, &offset); + item->fade.from.g = assetCutsceneReadU8(data, &offset); + item->fade.from.b = assetCutsceneReadU8(data, &offset); + item->fade.from.a = assetCutsceneReadU8(data, &offset); + item->fade.to.r = assetCutsceneReadU8(data, &offset); + item->fade.to.g = assetCutsceneReadU8(data, &offset); + item->fade.to.b = assetCutsceneReadU8(data, &offset); + item->fade.to.a = assetCutsceneReadU8(data, &offset); + item->fade.duration = assetCutsceneReadFloat(data, &offset); + item->fade.easing = (easingtype_t)assetCutsceneReadU8(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_SET_PAUSE: + item->setPause = (cutscenepause_t)assetCutsceneReadU8(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_ITEM_GIVE: + item->itemGive.item = (itemid_t)assetCutsceneReadU16(data, &offset); + item->itemGive.quantity = assetCutsceneReadU8(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_ENTITY_REMOVE: + item->entityRemove.entityIndex = assetCutsceneReadU8(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_ENTITY_ADD: + item->entityAdd.entityType = assetCutsceneReadU8(data, &offset); + item->entityAdd.position = assetCutsceneReadWorldPos(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_ENTITY_TURN: + item->entityTurn.entityIndex = assetCutsceneReadU8(data, &offset); + item->entityTurn.direction = + (entitydir_t)assetCutsceneReadU8(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO_ENTITY: + item->entityWalkToEntity.entityIndex = + assetCutsceneReadU8(data, &offset); + item->entityWalkToEntity.targetEntityIndex = + assetCutsceneReadU8(data, &offset); + item->entityWalkToEntity.offsetX = + assetCutsceneReadWorldUnit(data, &offset); + item->entityWalkToEntity.offsetY = + assetCutsceneReadWorldUnit(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_MAP_AREA_REMOVE: + item->mapAreaRemove.areaId = assetCutsceneReadU8(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_MAP_AREA_WAIT: { + uint8_t count = assetCutsceneReadU8(data, &offset); + uint16_t poolOffset = assetCutsceneReadU16(data, &offset); + assertTrue( + count <= CUTSCENE_MAP_AREA_WAIT_MAX, + "Cutscene map area wait count exceeds maximum" + ); + item->mapAreaWait.count = count; + item->mapAreaWait.areaIds = (const uint8_t *)(pool + poolOffset); + break; + } + + case CUTSCENE_ITEM_TYPE_START_BATTLE: { + item->startBattle.encounterType = + (battleencountertype_t)assetCutsceneReadU8(data, &offset); + item->startBattle.fleeAvailable = + assetCutsceneReadU8(data, &offset) != 0; + uint8_t enemyCount = assetCutsceneReadU8(data, &offset); + assertTrue( + enemyCount <= CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX, + "Cutscene battle enemy count exceeds maximum" + ); + item->startBattle.enemyCount = enemyCount; + for(uint8_t e = 0; e < enemyCount; e++) { + cutscenestartbattleenemy_t *enemy = &item->startBattle.enemies[e]; + enemy->stats.attack = assetCutsceneReadU16(data, &offset); + enemy->stats.defense = assetCutsceneReadU16(data, &offset); + enemy->stats.magic = assetCutsceneReadU16(data, &offset); + enemy->stats.speed = assetCutsceneReadU16(data, &offset); + enemy->stats.luck = assetCutsceneReadU16(data, &offset); + enemy->healthMax = assetCutsceneReadU16(data, &offset); + enemy->mpMax = assetCutsceneReadU16(data, &offset); + } + break; + } + + case CUTSCENE_ITEM_TYPE_EMOJI: + item->emoji.entityIndex = assetCutsceneReadU8(data, &offset); + item->emoji.duration = assetCutsceneReadFloat(data, &offset); + item->emoji.emojiType = (uiemojitype_t)assetCutsceneReadU8(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_SHAKE: + item->shake.amount = assetCutsceneReadU8(data, &offset); + item->shake.duration = assetCutsceneReadFloat(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE: + item->battleWaitState.state = + (battlestate_t)assetCutsceneReadU8(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION: + item->battleForceAction.fighterIndex = assetCutsceneReadU8(data, &offset); + item->battleForceAction.targetIndex = assetCutsceneReadU8(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_MODAL: + assetCutsceneReadEmbeddedString( + data, &offset, item->modal.title, CUTSCENE_MODAL_TITLE_MAX_CHARS + ); + assetCutsceneReadEmbeddedString( + data, &offset, item->modal.message, CUTSCENE_MODAL_MESSAGE_MAX_CHARS + ); + // v1 only supports the message-only form: MODAL and MODAL_OPTIONS + // share this same tag with no separate discriminator, and there is + // no native-callback registry yet to resolve an options callback. + assertTrue( + assetCutsceneReadU8(data, &offset) == 0, + "Cutscene MODAL item with options is not supported in file-based " + "cutscenes yet - use MODAL_OPTIONS_MARKERS instead" + ); + break; + + case CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS: { + assetCutsceneReadEmbeddedString( + data, &offset, item->modalOptionsMarkers.title, + CUTSCENE_MODAL_TITLE_MAX_CHARS + ); + assetCutsceneReadEmbeddedString( + data, &offset, item->modalOptionsMarkers.message, + CUTSCENE_MODAL_MESSAGE_MAX_CHARS + ); + uint8_t optionCount = assetCutsceneReadU8(data, &offset); + assertTrue( + optionCount <= CUTSCENE_MODAL_OPTIONS_MARKERS_MAX, + "Cutscene modal option count exceeds maximum" + ); + item->modalOptionsMarkers.optionCount = optionCount; + for(uint8_t o = 0; o < optionCount; o++) { + item->modalOptionsMarkers.options[o] = + assetCutsceneReadPoolString(data, &offset, pool); + item->modalOptionsMarkers.markers[o] = + assetCutsceneReadPoolString(data, &offset, pool); + } + break; + } + + case CUTSCENE_ITEM_TYPE_MODAL_CLOSE: + case CUTSCENE_ITEM_TYPE_RESTART: + break; + + case CUTSCENE_ITEM_TYPE_PRINT: + assetCutsceneReadEmbeddedString( + data, &offset, item->print.text, CUTSCENE_PRINT_MAX_CHARS + ); + break; + + case CUTSCENE_ITEM_TYPE_MARKER: + item->marker.name = assetCutsceneReadPoolString(data, &offset, pool); + break; + + case CUTSCENE_ITEM_TYPE_SCENE: + item->sceneChange.type = (scenetype_t)assetCutsceneReadU8(data, &offset); + break; + + case CUTSCENE_ITEM_TYPE_SAVE_DEVICE_CHECK: + item->saveDeviceCheck.successMarker = + assetCutsceneReadPoolString(data, &offset, pool); + item->saveDeviceCheck.failureMarker = + assetCutsceneReadPoolString(data, &offset, pool); + break; + + case CUTSCENE_ITEM_TYPE_SAVE_LOAD_ALL_SLOTS: + item->saveLoadAllSlots.successMarker = + assetCutsceneReadPoolString(data, &offset, pool); + item->saveLoadAllSlots.failureMarker = + assetCutsceneReadPoolString(data, &offset, pool); + break; + + default: + memoryFree(data); + memoryFree(out->items); + out->items = NULL; + if(out->pool != NULL) { + memoryFree(out->pool); + out->pool = NULL; + } + assetLoaderErrorThrow( + loading, + "Cutscene item type %u is not supported in file-based cutscenes", + (uint32_t)item->type + ); + } + } + + memoryFree(data); + loading->loading.cutscene.data = NULL; + + out->cutscene.items = out->items; + out->cutscene.itemCount = itemCount; + out->cutscene.pause = pauseType; + out->cutscene.dataSize = 0; + + loading->entry->state = ASSET_ENTRY_STATE_LOADED; + errorOk(); +} + +errorret_t assetCutsceneDispose(assetentry_t *entry) { + assertNotNull(entry, "Entry cannot be NULL"); + assertTrue(entry->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type."); + assertIsMainThread("Must be called from the main thread."); + + assetcutsceneoutput_t *out = &entry->data.cutscene; + + if(out->items != NULL) { + memoryFree(out->items); + out->items = NULL; + } + + if(out->pool != NULL) { + memoryFree(out->pool); + out->pool = NULL; + } + + errorOk(); +} diff --git a/src/dusk/asset/loader/cutscene/assetcutsceneloader.h b/src/dusk/asset/loader/cutscene/assetcutsceneloader.h new file mode 100644 index 00000000..403f4e5a --- /dev/null +++ b/src/dusk/asset/loader/cutscene/assetcutsceneloader.h @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "asset/assetfile.h" +#include "rpg/cutscene/cutscene.h" +#include "rpg/cutscene/item/cutsceneitem.h" + +#define ASSET_CUTSCENE_FILE_VERSION 1 + +typedef struct assetloading_s assetloading_t; +typedef struct assetentry_s assetentry_t; + +typedef struct { + void *nothing; +} assetcutsceneloaderinput_t; + +typedef enum { + ASSET_CUTSCENE_LOADING_STATE_INITIAL, + ASSET_CUTSCENE_LOADING_STATE_READ_FILE, + ASSET_CUTSCENE_LOADING_STATE_PARSE +} assetcutsceneloadingstate_t; + +typedef struct { + assetfile_t file; + assetcutsceneloadingstate_t state; + uint8_t *data; + size_t dataSize;// Saved before assetFileDispose zeroes file.size. +} assetcutsceneloaderloading_t; + +// Runtime-loaded cutscene: items/pool are heap-allocated to the file's +// actual declared sizes (not fixed-capacity), so an entry that never holds +// a cutscene costs nothing extra in the shared assetloaderoutput_t union - +// see assetchunkoutput_t.tiles for the same pattern. +typedef struct { + cutscene_t cutscene; // .items points at the items array below + cutsceneitem_t *items; + char_t *pool; +} assetcutsceneoutput_t; + +/** + * Asynchronous loader for cutscene assets. Reads the raw DCTS file bytes + * into the loading buffer so the sync phase can parse without blocking the + * main thread on I/O. + * + * @param loading Loading information for the asset being loaded. + * @return Error code indicating success or failure of the load operation. + */ +errorret_t assetCutsceneLoaderAsync(assetloading_t *loading); + +/** + * Synchronous loader for cutscene assets. Validates the DCTS binary + * previously read by the async phase and decodes it into a heap-allocated + * cutsceneitem_t array + string/data pool. + * + * @param loading Loading information for the asset being loaded. + * @return Error code indicating success or failure of the load operation. + */ +errorret_t assetCutsceneLoaderSync(assetloading_t *loading); + +/** + * Disposer for cutscene assets. + * + * @param entry Asset entry containing the cutscene data to dispose. + * @return Error code indicating success or failure of the dispose operation. + */ +errorret_t assetCutsceneDispose(assetentry_t *entry); diff --git a/src/dusk/rpg/cutscene/cutscenesystem.c b/src/dusk/rpg/cutscene/cutscenesystem.c index 800cce4a..7ccd101b 100644 --- a/src/dusk/rpg/cutscene/cutscenesystem.c +++ b/src/dusk/rpg/cutscene/cutscenesystem.c @@ -36,6 +36,7 @@ void cutsceneSystemPrepare( CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED; CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED; CUTSCENE_SYSTEM.currentItem = 0xFF;// Set to 0xFF so Next wraps to 0. + CUTSCENE_SYSTEM.onComplete = NULL; } void cutsceneSystemStartCutscene(const cutscene_t *cutscene) { @@ -63,11 +64,17 @@ void cutsceneRestart(void) { assertNotNull( CUTSCENE_SYSTEM.scene, "cutsceneRestart called with no cutscene running" ); + + // A restart is the same logical run trying again (e.g. retrying a failed + // save-device check), not a fresh unrelated start, so it should not + // silently drop a completion callback the caller already armed. + cutscenecallback_t onComplete = CUTSCENE_SYSTEM.onComplete; cutsceneSystemStartCutsceneWith( CUTSCENE_SYSTEM.scene, CUTSCENE_SYSTEM.entityInteract, CUTSCENE_SYSTEM.entityInteracted ); + CUTSCENE_SYSTEM.onComplete = onComplete; } void cutsceneSystemUpdate() { @@ -86,6 +93,13 @@ void cutsceneSystemNext() { if( CUTSCENE_SYSTEM.currentItem >= CUTSCENE_SYSTEM.scene->itemCount ) { + // Saved and cleared before firing so a callback that immediately + // starts another cutscene (or sets its own onComplete) isn't clobbered + // by this function's own cleanup running after it - same reentrancy + // hazard as uiFocusPop, see src/dusk/ui/focus/uifocus.c. + cutscenecallback_t onComplete = CUTSCENE_SYSTEM.onComplete; + void *userData = CUTSCENE_SYSTEM.userData; + CUTSCENE_SYSTEM.scene = NULL; CUTSCENE_SYSTEM.currentItem = 0xFF; CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE; @@ -94,7 +108,10 @@ void cutsceneSystemNext() { CUTSCENE_SYSTEM.entityLastCreated = NULL; CUTSCENE_SYSTEM.entityLastRef = NULL; CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED; - CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED; + CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED; + CUTSCENE_SYSTEM.onComplete = NULL; + + if(onComplete != NULL) onComplete(userData); return; } @@ -104,6 +121,14 @@ void cutsceneSystemNext() { cutsceneItemStart(item, &CUTSCENE_SYSTEM.data); } +void cutsceneSystemSetOnComplete(cutscenecallback_t onComplete) { + assertNotNull( + CUTSCENE_SYSTEM.scene, + "cutsceneSystemSetOnComplete called with no cutscene running" + ); + CUTSCENE_SYSTEM.onComplete = onComplete; +} + void cutsceneGoTo(const char_t *name) { assertNotNull( CUTSCENE_SYSTEM.scene, "cutsceneGoTo called with no cutscene running" @@ -203,4 +228,5 @@ void cutsceneSystemDispose() { CUTSCENE_SYSTEM.entityLastRef = NULL; CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED; CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED; + CUTSCENE_SYSTEM.onComplete = NULL; } diff --git a/src/dusk/rpg/cutscene/cutscenesystem.h b/src/dusk/rpg/cutscene/cutscenesystem.h index 2e04c1e9..8c9ed702 100644 --- a/src/dusk/rpg/cutscene/cutscenesystem.h +++ b/src/dusk/rpg/cutscene/cutscenesystem.h @@ -38,6 +38,9 @@ typedef struct { // Custom user data for the running cutscene, sized per-scene by // cutscene_t.dataSize. uint8_t userData[CUTSCENE_SYSTEM_SIZE_MAX]; + + // See cutsceneSystemSetOnComplete. + cutscenecallback_t onComplete; } cutscenesystem_t; extern cutscenesystem_t CUTSCENE_SYSTEM; @@ -89,6 +92,25 @@ void cutsceneSystemStartCutsceneAndGoToMarker( */ void cutsceneRestart(void); +/** + * Sets a native callback to fire once when the currently running cutscene + * finishes by running off the end of its item list. A fresh + * cutsceneSystemStartCutscene* call clears any previously set callback, so + * call this again after starting a new cutscene to arm it - but + * cutsceneRestart() preserves whatever was armed, since a restart (e.g. + * retrying a failed check) is the same logical run trying again, not a new + * one. Invoked with CUTSCENE_SYSTEM.userData, same as CUTSCENE_CALLBACK. + * + * Exists so a runtime-loaded cutscene file (which can't store a native + * function pointer) can still hand off to native code once it's done, + * without needing a whole name->function registry: the file just ends + * normally, and whoever started it supplies what happens next. + * + * @param onComplete Callback to fire on natural completion. May be NULL + * to clear a previously set one. + */ +void cutsceneSystemSetOnComplete(cutscenecallback_t onComplete); + /** * Resolves a raw entity index (or sentinel) to an entity pointer. * Handles CUTSCENE_ENTITY_INTERACT, CUTSCENE_ENTITY_INTERACTED, diff --git a/src/dusk/scene/initial/sceneinitial.c b/src/dusk/scene/initial/sceneinitial.c index 3b619615..0471a81a 100644 --- a/src/dusk/scene/initial/sceneinitial.c +++ b/src/dusk/scene/initial/sceneinitial.c @@ -11,34 +11,13 @@ #include "error/error.h" #include "display/screen/screen.h" #include "console/console.h" -#include "ui/widget/uimodal.h" -#include "rpg/cutscene/cutscene.h" #include "rpg/cutscene/cutscenesystem.h" +#include "asset/asset.h" -CUTSCENE(INITIAL, 0, DEFAULT, - CUTSCENE_MODAL( - "initial.checking_save.title", "initial.checking_save.message" - ), - CUTSCENE_WAIT(0.2f), - CUTSCENE_SAVE_DEVICE_CHECK("CONTINUE", "NO_DEVICE"), - - CUTSCENE_MARKER("NO_DEVICE"), - CUTSCENE_MODAL_CLOSE(), - CUTSCENE_MODAL_OPTIONS_TWO( - "initial.no_device.title", - "initial.no_device.message", - "initial.no_device.retry", "RETRY", - "initial.no_device.continue", "CONTINUE" - ), - - CUTSCENE_MARKER("RETRY"), - CUTSCENE_MODAL_CLOSE(), - CUTSCENE_RESTART(), - - CUTSCENE_MARKER("CONTINUE"), - CUTSCENE_MODAL_CLOSE(), - CUTSCENE_SCENE(SCENE_TYPE_MAIN_MENU) -); +// Loaded lazily and kept resident for the rest of the process - this scene +// only ever runs once at boot, but there's no reason to unlock it (same +// lifetime convention as e.g. LOCALE.entry). +static assetentry_t *INITIAL_CUTSCENE_ENTRY = NULL; errorret_t sceneInitialInit(scenedata_t *sceneData) { assertNotNull(sceneData, "Scene data cannot be null"); @@ -47,7 +26,23 @@ errorret_t sceneInitialInit(scenedata_t *sceneData) { // Set background color to black for the initial scene SCREEN.background = COLOR_BLACK; - cutsceneSystemStartCutscene(&CUTSCENE_INITIAL); + // Runtime-loaded from assets/cutscenes/initial.cts (authored at + // assetsraw/cutscenes/initial.jsonc via `python3 -m tools.asset.cutscene`) + // - checks for a save device, retrying on failure, then hands off to the + // main menu scene via a plain CUTSCENE_SCENE item (no native callback + // needed here, unlike the main menu's own start-game cutscene). + if(INITIAL_CUTSCENE_ENTRY == NULL) { + INITIAL_CUTSCENE_ENTRY = assetLock( + "cutscenes/initial.cts", ASSET_LOADER_TYPE_CUTSCENE, NULL + ); + } + errorret_t result = assetRequireLoaded(INITIAL_CUTSCENE_ENTRY); + if(errorIsNotOk(result)) { + errorCatch(errorPrint(result)); + assertTrue(false, "Failed to load initial scene cutscene asset"); + } + + cutsceneSystemStartCutscene(&INITIAL_CUTSCENE_ENTRY->data.cutscene.cutscene); errorOk(); } diff --git a/src/dusk/scene/mainmenu/scenemainmenu.c b/src/dusk/scene/mainmenu/scenemainmenu.c index 2acb030d..835cb961 100644 --- a/src/dusk/scene/mainmenu/scenemainmenu.c +++ b/src/dusk/scene/mainmenu/scenemainmenu.c @@ -8,52 +8,16 @@ #include "scenemainmenu.h" #include "ui/screen/mainmenu/uimainmenu.h" #include "ui/dialog/save/uiselectsave.h" -#include "rpg/cutscene/cutscene.h" #include "rpg/cutscene/cutscenesystem.h" #include "scene/scene.h" +#include "asset/asset.h" +#include "assert/assert.h" -void sceneMainMenuOpenSelectSave(void *userData); - -// Loads every save slot before opening the load-game picker, same -// checking/retry-on-error shape as the initial scene's device lookup -// (see scene/initial/sceneinitial.c) - the error branch loops back via -// CUTSCENE_RESTART. -CUTSCENE(MAIN_MENU_START_GAME, 0, DEFAULT, - CUTSCENE_MODAL( - "main_menu.checking_save.title", - "main_menu.checking_save.message" - ), - CUTSCENE_WAIT(0.2f), - CUTSCENE_SAVE_DEVICE_CHECK("CONTINUE", "NO_DEVICE"), - - CUTSCENE_MARKER("NO_DEVICE"), - CUTSCENE_MODAL_CLOSE(), - CUTSCENE_MODAL_OPTIONS_TWO( - "main_menu.no_device.title", - "main_menu.no_device.message", - "main_menu.no_device.retry", "RETRY", - "main_menu.no_device.continue", "CONTINUE" - ), - - CUTSCENE_MARKER("RETRY"), - CUTSCENE_MODAL_CLOSE(), - CUTSCENE_RESTART(), - - CUTSCENE_MARKER("CONTINUE"), - CUTSCENE_MODAL_CLOSE(), - CUTSCENE_SAVE_LOAD_ALL_SLOTS("LOADED", "LOAD_ERROR"), - - CUTSCENE_MARKER("LOAD_ERROR"), - CUTSCENE_MODAL_OPTIONS_ONE( - "main_menu.save_load_error.title", - "main_menu.save_load_error.message", - "main_menu.save_load_error.retry", - "RETRY" - ), - - CUTSCENE_MARKER("LOADED"), - CUTSCENE_CALLBACK(sceneMainMenuOpenSelectSave) -); +// Loaded lazily on first Start Game click and kept resident for the rest +// of the process - it's tiny and reused every time, so there's no benefit +// to unlocking/reloading it between attempts (same lifetime convention as +// e.g. LOCALE.entry). +static assetentry_t *MAIN_MENU_START_GAME_CUTSCENE_ENTRY = NULL; void sceneMainMenuSelectSaveResult(const uint8_t slotIndex, void *user) { if(slotIndex == UI_SELECT_SAVE_RESULT_NONE) { @@ -71,8 +35,32 @@ void sceneMainMenuOpenSelectSave(void *userData) { ); } +// Loads every save slot before opening the load-game picker (checking/ +// retry-on-error shape mirroring the initial scene's device lookup, see +// scene/initial/sceneinitial.c) - runtime-loaded from +// assets/cutscenes/main_menu_start_game.cts (authored at +// assetsraw/cutscenes/main_menu_start_game.json via +// `python3 -m tools.asset.cutscene`) rather than compiled in, since it's +// player-facing flow rather than core engine wiring. The file ends at the +// LOADED marker with no further action - sceneMainMenuOpenSelectSave is +// armed as the completion callback below instead of being baked into the +// cutscene itself, since a file can't store a native function pointer. void sceneMainMenuStartGame(void) { - cutsceneSystemStartCutscene(&CUTSCENE_MAIN_MENU_START_GAME); + if(MAIN_MENU_START_GAME_CUTSCENE_ENTRY == NULL) { + MAIN_MENU_START_GAME_CUTSCENE_ENTRY = assetLock( + "cutscenes/main_menu_start_game.cts", ASSET_LOADER_TYPE_CUTSCENE, NULL + ); + } + errorret_t result = assetRequireLoaded(MAIN_MENU_START_GAME_CUTSCENE_ENTRY); + if(errorIsNotOk(result)) { + errorCatch(errorPrint(result)); + assertTrue(false, "Failed to load main menu start-game cutscene asset"); + } + + cutsceneSystemStartCutscene( + &MAIN_MENU_START_GAME_CUTSCENE_ENTRY->data.cutscene.cutscene + ); + cutsceneSystemSetOnComplete(sceneMainMenuOpenSelectSave); } errorret_t sceneMainMenuInit(scenedata_t *sceneData) { diff --git a/tools/asset/cutscene/__main__.py b/tools/asset/cutscene/__main__.py new file mode 100644 index 00000000..db4545aa --- /dev/null +++ b/tools/asset/cutscene/__main__.py @@ -0,0 +1,455 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +""" +Generates DCTS binary cutscene files from raw JSONC cutscene definitions. + +JSONC input (assetsraw/cutscenes/.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 json +import os +import struct +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +PROJECT_ROOT = os.path.normpath(os.path.join(_HERE, '..', '..', '..')) +ASSETSRAW_DIR = os.path.join(PROJECT_ROOT, 'assetsraw') +ASSETS_DIR = os.path.join(PROJECT_ROOT, 'assets') + +FILE_MAGIC = b'DCTS' +VERSION_OUT = 1 + +# Must match cutsceneitemtype_t's declared order in +# src/dusk/rpg/cutscene/item/cutsceneitem.h exactly. Types with no entry +# here are v2 (native callback / recursive item data) and unsupported. +ITEM_TYPE = { + 'TEXT': 1, + 'TEXT_MINI': 2, + 'TEXT_MINI_HIDE': 3, + 'WAIT': 5, + 'ENTITY_TELEPORT': 7, + 'ENTITY_WALK_TO': 8, + 'FADE': 9, + 'SET_PAUSE': 10, + 'ITEM_GIVE': 12, + 'ENTITY_REMOVE': 13, + 'ENTITY_ADD': 14, + 'ENTITY_TURN': 15, + 'ENTITY_WALK_TO_ENTITY': 16, + 'MAP_AREA_REMOVE': 18, + 'MAP_AREA_WAIT': 19, + 'START_BATTLE': 20, + 'EMOJI': 21, + 'SHAKE': 22, + 'BATTLE_WAIT_STATE': 23, + 'BATTLE_FORCE_ACTION': 24, + 'MODAL': 25, + 'MODAL_OPTIONS_MARKERS': 26, + 'MODAL_CLOSE': 27, + 'PRINT': 28, + 'MARKER': 29, + 'RESTART': 30, + 'SCENE': 31, + 'SAVE_DEVICE_CHECK': 32, + 'SAVE_LOAD_ALL_SLOTS': 33, +} + +PAUSE_FLAG = {'NPC': 1 << 0, 'PLAYER': 1 << 1, 'WORLD': 1 << 2, 'BATTLE': 1 << 3} +PAUSE_DEFAULT = PAUSE_FLAG['NPC'] | PAUSE_FLAG['PLAYER'] + +ENTITY_SENTINEL = {'INTERACT': 0xFE, 'INTERACTED': 0xFD} +AREA_SENTINEL = {'LAST_CREATED': 0xFF} + +ENTITY_TYPE = {'NULL': 0, 'PLAYER': 1, 'NPC': 2, 'ITEM': 3} +ENTITY_DIR = { + 'NORTH': 0, 'EAST': 1, 'SOUTH': 2, 'WEST': 3, + 'UP': 0, 'RIGHT': 1, 'DOWN': 2, 'LEFT': 3, +} +EASING = { + name: i for i, name in enumerate([ + 'LINEAR', 'IN_SINE', 'OUT_SINE', 'IN_OUT_SINE', + 'IN_QUAD', 'OUT_QUAD', 'IN_OUT_QUAD', + 'IN_CUBIC', 'OUT_CUBIC', 'IN_OUT_CUBIC', + 'IN_QUART', 'OUT_QUART', 'IN_OUT_QUART', + 'IN_BACK', 'OUT_BACK', 'IN_OUT_BACK', + ]) +} +UI_EMOJI = {'NULL': 0, 'QUESTION_MARK': 1, 'EXCLAMATION_MARK': 2} +BATTLE_ENCOUNTER = {'REGULAR': 0, 'PLAYER_ADVANTAGE': 1, 'BACK_ATTACK': 2} +BATTLE_STATE = { + 'NONE': 0, 'OPENING': 1, 'PRE_ROUND': 2, 'PLAYER_SELECTION': 3, + 'AI_SELECTION': 4, 'MOVES_EXECUTING': 5, 'POST_ROUND': 6, 'ENDED': 7, +} +SCENE_TYPE = {'NULL': 0, 'INITIAL': 1, 'MAIN_MENU': 2, 'OVERWORLD': 3, 'BATTLE': 4} + +CUTSCENE_TEXT_MAX_CHARS = 256 +CUTSCENE_TEXT_MINI_MAX_CHARS = 128 +CUTSCENE_PRINT_MAX_CHARS = 128 +CUTSCENE_MODAL_TITLE_MAX_CHARS = 64 +CUTSCENE_MODAL_MESSAGE_MAX_CHARS = 256 +CUTSCENE_MODAL_OPTIONS_MARKERS_MAX = 2 +CUTSCENE_MAP_AREA_WAIT_MAX = 4 +CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX = 4 + + +class Pool: + def __init__(self): + self.data = bytearray() + + def _align(self, n): + while len(self.data) % n != 0: + self.data += b'\x00' + + def add_string(self, s): + self._align(4) + offset = len(self.data) + self.data += s.encode('utf-8') + b'\x00' + return offset + + def add_bytes(self, b): + self._align(4) + offset = len(self.data) + self.data += b + return offset + + +def resolve_entity_index(v): + if isinstance(v, str): + return ENTITY_SENTINEL[v.upper()] + return int(v) + + +def resolve_area_id(v): + if isinstance(v, str): + return AREA_SENTINEL[v.upper()] + return int(v) + + +def write_string_field(buf, s, max_len): + encoded = s.encode('utf-8') + if len(encoded) >= max_len: + raise ValueError(f"String exceeds max length {max_len}: {s!r}") + buf += struct.pack(' 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(): + args = sys.argv[1:] + + if not args: + cutscenes_dir = os.path.join(ASSETSRAW_DIR, 'cutscenes') + if not os.path.isdir(cutscenes_dir): + print(f"No directory found: {cutscenes_dir}") + sys.exit(1) + json_files = sorted( + os.path.join(cutscenes_dir, f) + for f in os.listdir(cutscenes_dir) + if f.endswith('.jsonc') + ) + if not json_files: + print(f"No JSONC files found in {cutscenes_dir}") + sys.exit(0) + for p in json_files: + process_json(p) + return + + for p in args: + process_json(p) + + +if __name__ == '__main__': + main()