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

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

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-05 12:05:23 -05:00
co-authored by Claude Sonnet 5
parent 707856fcf2
commit 6ccaedd48f
18 changed files with 1483 additions and 1051 deletions
+10 -7
View File
@@ -122,18 +122,21 @@ if(DUSK_BUILD_TESTS)
add_subdirectory(test) add_subdirectory(test)
endif() endif()
# Build assets # Build assets. Runs unconditionally on every build (rather than being
file(GLOB_RECURSE DUSK_ASSET_FILES CONFIGURE_DEPENDS "${DUSK_ASSETS_DIR}/*") # gated behind a custom_command's file-level DEPENDS list) because
add_custom_command( # CONFIGURE_DEPENDS globs only pick up added/removed files on the next
OUTPUT "${DUSK_ASSETS_ZIP}" # 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 make_directory "${DUSK_ASSETS_DIR}"
COMMAND ${CMAKE_COMMAND} -E rm -f "${DUSK_ASSETS_ZIP}"
COMMAND ${Python3_EXECUTABLE} -m tools.asset.pack COMMAND ${Python3_EXECUTABLE} -m tools.asset.pack
--input "${DUSK_ASSETS_DIR}" --input "${DUSK_ASSETS_DIR}"
--output "${DUSK_ASSETS_ZIP}" --output "${DUSK_ASSETS_ZIP}"
WORKING_DIRECTORY "${DUSK_ROOT_DIR}" WORKING_DIRECTORY "${DUSK_ROOT_DIR}"
DEPENDS ${DUSK_ASSET_FILES} BYPRODUCTS "${DUSK_ASSETS_ZIP}"
COMMENT "Packing assets into dusk.dsk"
VERBATIM VERBATIM
) )
add_custom_target(DUSK_ASSETS_BUILT DEPENDS "${DUSK_ASSETS_ZIP}")
add_dependencies(${DUSK_LIBRARY_TARGET_NAME} DUSK_ASSETS_BUILT) add_dependencies(${DUSK_LIBRARY_TARGET_NAME} DUSK_ASSETS_BUILT)
Binary file not shown.
@@ -1,7 +1,6 @@
{ {
"items": [ "items": [
// Boot check: make sure a save device is available before handing off // First thing, try and find a save device, used for settings and what not.
// to the main menu.
{ {
"type": "MODAL", "type": "MODAL",
"title": "initial.checking_save.title", "title": "initial.checking_save.title",
@@ -14,13 +13,13 @@
{ {
"type": "SAVE_DEVICE_CHECK", "type": "SAVE_DEVICE_CHECK",
"successMarker": "CONTINUE", "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", "type": "MARKER",
"name": "NO_DEVICE" "name": "SAVE_DEVICE_NOT_FOUND"
}, },
{ {
"type": "MODAL_CLOSE" "type": "MODAL_CLOSE"
@@ -32,7 +31,7 @@
"options": [ "options": [
{ {
"text": "initial.no_device.retry", "text": "initial.no_device.retry",
"marker": "RETRY" "marker": "SAVE_DEVICE_RETRY"
}, },
{ {
"text": "initial.no_device.continue", "text": "initial.no_device.continue",
@@ -41,9 +40,10 @@
] ]
}, },
// Save device not found, user to retry, just close modal and loop
{ {
"type": "MARKER", "type": "MARKER",
"name": "RETRY" "name": "SAVE_DEVICE_RETRY"
}, },
{ {
"type": "MODAL_CLOSE" "type": "MODAL_CLOSE"
@@ -52,8 +52,7 @@
"type": "RESTART" "type": "RESTART"
}, },
// Save device found (or continuing without one) - hand off to the // Save device found (or continuing without one)
// main menu scene.
{ {
"type": "MARKER", "type": "MARKER",
"name": "CONTINUE" "name": "CONTINUE"
@@ -62,8 +61,8 @@
"type": "MODAL_CLOSE" "type": "MODAL_CLOSE"
}, },
{ {
"type": "SCENE", "type": "CUTSCENE",
"sceneType": "MAIN_MENU" "name": "main_menu"
} }
] ]
} }
Binary file not shown.
@@ -1,23 +1,14 @@
{ {
"items": [ "items": [
// Runs immediately as soon as the main menu scene becomes active {
// (see sceneMainMenuInit) - owns menu-wide ambience, then idles while "type": "UI_SHOW",
// the menu itself is shown and interacted with natively. "name": "main_menu"
// { },
// "type": "AUDIO_PLAY",
// "file": "audio/boa.mp3",
// "channel": "BGM_0",
// "volume": 1.0,
// "pan": 0.0,
// "looping": true
// },
{ {
"type": "IDLE" "type": "IDLE"
}, },
// New Game pressed (sceneMainMenuStartGame jumps here via // New Game button goes here.
// cutsceneGoTo) - check for a save device before loading slots.
{ {
"type": "MARKER", "type": "MARKER",
"name": "NEW_GAME" "name": "NEW_GAME"
@@ -33,16 +24,14 @@
}, },
{ {
"type": "SAVE_DEVICE_CHECK", "type": "SAVE_DEVICE_CHECK",
"successMarker": "CONTINUE", "successMarker": "SAVE_DEVICE_FOUND",
"failureMarker": "NO_DEVICE" "failureMarker": "SAVE_DEVICE_NOT_FOUND"
}, },
// No save device found - offer to retry or continue without saving. // Save device not found
// Retry jumps straight back to NEW_GAME rather than restarting the
// whole cutscene, so the BGM above isn't retriggered.
{ {
"type": "MARKER", "type": "MARKER",
"name": "NO_DEVICE" "name": "SAVE_DEVICE_NOT_FOUND"
}, },
{ {
"type": "MODAL_CLOSE" "type": "MODAL_CLOSE"
@@ -58,15 +47,15 @@
}, },
{ {
"text": "main_menu.no_device.continue", "text": "main_menu.no_device.continue",
"marker": "CONTINUE" "marker": "SAVE_DEVICE_FOUND"
} }
] ]
}, },
// Save device found - attempt to load all save slots. // Save device found
{ {
"type": "MARKER", "type": "MARKER",
"name": "CONTINUE" "name": "SAVE_DEVICE_FOUND"
}, },
{ {
"type": "MODAL_CLOSE" "type": "MODAL_CLOSE"
File diff suppressed because it is too large Load Diff
@@ -9,8 +9,13 @@
#include "asset/assetfile.h" #include "asset/assetfile.h"
#include "rpg/cutscene/cutscene.h" #include "rpg/cutscene/cutscene.h"
#include "rpg/cutscene/item/cutsceneitem.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 assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t; typedef struct assetentry_s assetentry_t;
@@ -28,94 +33,195 @@ typedef enum {
typedef struct { typedef struct {
assetfile_t file; assetfile_t file;
assetcutsceneloadingstate_t state; assetcutsceneloadingstate_t state;
uint8_t *data; uint8_t *buffer;
size_t dataSize;// Saved before assetFileDispose zeroes file.size. size_t size;
} assetcutsceneloaderloading_t; } assetcutsceneloaderloading_t;
// Runtime-loaded cutscene: items/pool are heap-allocated to the file's // Runtime-loaded cutscene, parsed directly from the cutscene's authored
// actual declared sizes (not fixed-capacity), so an entry that never holds // JSONC (no separate compiled binary format/build step - see
// a cutscene costs nothing extra in the shared assetloaderoutput_t union - // assetCutsceneLoaderSync). `doc` is kept alive for the entry's whole
// see assetchunkoutput_t.tiles for the same pattern. // 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 { typedef struct {
cutscene_t cutscene; // .items points at the items array below cutscene_t cutscene; // .items points at the items array below
cutsceneitem_t *items; cutsceneitem_t *items;
char_t *pool; uint8_t *pool;
yyjson_doc *doc;
} assetcutsceneoutput_t; } assetcutsceneoutput_t;
/** typedef struct {
* Reads a uint8_t from the current offset, advancing *offset past it. const char_t *name;
* int32_t value;
* @param data The buffer to read from. } assetcutsceneenumentry_t;
* @param offset In/out cursor into data, advanced past the value read.
* @return The decoded uint8_t. typedef struct {
*/ const char_t *name;
uint8_t assetCutsceneReadU8(const uint8_t *data, size_t *offset); } assetcutsceneitemtypeentry_t;
/** /**
* Reads a little-endian uint16_t from a potentially-unaligned offset, * Looks up name in a {name, value} table via stringEquals - exact case,
* advancing *offset past it. * 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 table Table to search.
* @param offset In/out cursor into data, advanced past the value read. * @param tableCount Number of entries in table.
* @return The decoded uint16_t. * @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, * Looks up name (exact case) against the item type table, which is indexed
* advancing *offset past it. * directly by cutsceneitemtype_t rather than searched by value.
* *
* @param data The buffer to read from. * @param name Name to search for.
* @param offset In/out cursor into data, advanced past the value read. * @param outType Set to the matching item type on success.
* @return The decoded uint32_t. * @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, * Looks up name in a {name, value} table via stringCompareInsensitive -
* advancing *offset past it. * 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 table Table to search.
* @param offset In/out cursor into data, advanced past the value read. * @param tableCount Number of entries in table.
* @return The decoded float_t. * @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 * Resolves a JSON value that's either an entity index sentinel string
* char_t[destCapacity] field (CUTSCENE_TEXT_MAX_CHARS and friends) - these * ("INTERACT"/"INTERACTED") or a plain integer entity index.
* are never pool references, see the item-field inventory in the runtime
* cutscene file design.
* *
* @param data The buffer to read from. * @param val JSON value to resolve (a string or a number).
* @param offset In/out cursor into data, advanced past the string read. * @param outIndex Set to the resolved index on success.
* @param dest Destination buffer to copy the string into. * @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. * @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( bool_t assetCutsceneCopyStringField(
const uint8_t *data, yyjson_val *obj,
size_t *offset, const char_t *key,
const char_t *defaultValue,
char_t *dest, char_t *dest,
const size_t destCapacity const size_t destCapacity
); );
/** /**
* Resolves a u16 pool offset (read from the item stream) to a real pointer * Reads a 3-element JSON array of integers into a worldpos_t.
* into the entry's own persistent pool allocation.
* *
* @param data The buffer to read the pool offset from. * @param arr JSON array value, expected to hold exactly 3 numbers.
* @param offset In/out cursor into data, advanced past the pool offset. * @param out Set to the decoded position on success.
* @param pool The base pointer of the entry's persistent pool allocation. * @return true if arr was a valid 3-element numeric array, false otherwise.
* @return Pointer to the string within pool.
*/ */
const char_t * assetCutsceneReadPoolString( bool_t assetCutsceneReadWorldPos(yyjson_val *arr, worldpos_t *out);
const uint8_t *data,
size_t *offset, /**
const char_t *pool * 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 * into the loading buffer so the sync phase can parse without blocking the
* main thread on I/O. * main thread on I/O.
* *
@@ -125,9 +231,8 @@ const char_t * assetCutsceneReadPoolString(
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading); errorret_t assetCutsceneLoaderAsync(assetloading_t *loading);
/** /**
* Synchronous loader for cutscene assets. Validates the DCTS binary * Synchronous loader for cutscene assets. Parses the JSONC previously read
* previously read by the async phase and decodes it into a heap-allocated * by the async phase into a heap-allocated cutsceneitem_t array + pool.
* cutsceneitem_t array + string/data pool.
* *
* @param loading Loading information for the asset being loaded. * @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation. * @return Error code indicating success or failure of the load operation.
+5
View File
@@ -71,6 +71,11 @@ typedef struct cutscene_s {
#define CUTSCENE_IDLE() \ #define CUTSCENE_IDLE() \
{ .type = CUTSCENE_ITEM_TYPE_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 // Requests a switch to a different SCENE_TYPE via sceneSet, then
// immediately continues on to whatever follows this item - the switch // immediately continues on to whatever follows this item - the switch
// itself doesn't happen until the next sceneUpdate() tick, so it does // itself doesn't happen until the next sceneUpdate() tick, so it does
@@ -225,6 +225,11 @@ cutsceneitemcallbacks_t CUTSCENE_ITEM_CALLBACKS[CUTSCENE_ITEM_TYPE_COUNT] = {
[CUTSCENE_ITEM_TYPE_IDLE] = { [CUTSCENE_ITEM_TYPE_IDLE] = {
.update = cutsceneIdleUpdate .update = cutsceneIdleUpdate
},
[CUTSCENE_ITEM_TYPE_UI_SHOW] = {
.init = cutsceneUIShowStart,
.update = cutsceneUIShowUpdate
} }
}; };
@@ -30,6 +30,7 @@
#include "ui/cutscenemodal.h" #include "ui/cutscenemodal.h"
#include "ui/cutscenemodaloptionsmarkers.h" #include "ui/cutscenemodaloptionsmarkers.h"
#include "ui/cutscenekeyboard.h" #include "ui/cutscenekeyboard.h"
#include "ui/cutsceneuishow.h"
#include "item/cutsceneitemgive.h" #include "item/cutsceneitemgive.h"
#include "maparea/cutscenemapareaadd.h" #include "maparea/cutscenemapareaadd.h"
#include "maparea/cutscenemaparearemove.h" #include "maparea/cutscenemaparearemove.h"
@@ -98,6 +99,7 @@ typedef enum {
CUTSCENE_ITEM_TYPE_AUDIO_SET_LOOP, CUTSCENE_ITEM_TYPE_AUDIO_SET_LOOP,
CUTSCENE_ITEM_TYPE_AUDIO_SET, CUTSCENE_ITEM_TYPE_AUDIO_SET,
CUTSCENE_ITEM_TYPE_IDLE, CUTSCENE_ITEM_TYPE_IDLE,
CUTSCENE_ITEM_TYPE_UI_SHOW,
CUTSCENE_ITEM_TYPE_COUNT CUTSCENE_ITEM_TYPE_COUNT
} cutsceneitemtype_t; } cutsceneitemtype_t;
@@ -147,6 +149,7 @@ struct cutsceneitem_s {
cutsceneaudiosetpan_t audioSetPan; cutsceneaudiosetpan_t audioSetPan;
cutsceneaudiosetloop_t audioSetLoop; cutsceneaudiosetloop_t audioSetLoop;
cutsceneaudioset_t audioSet; cutsceneaudioset_t audioSet;
cutsceneuishow_t uiShow;
}; };
}; };
@@ -14,4 +14,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
cutscenemodal.c cutscenemodal.c
cutscenemodaloptionsmarkers.c cutscenemodaloptionsmarkers.c
cutscenekeyboard.c cutscenekeyboard.c
cutsceneuishow.c
) )
@@ -0,0 +1,27 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "ui/screen/mainmenu/uimainmenu.h"
void cutsceneUIShowStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
switch(item->uiShow.screen) {
case UI_SCREEN_TYPE_MAIN_MENU:
uiMainMenuOpen();
break;
}
}
bool_t cutsceneUIShowUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
typedef struct cutsceneitem_s cutsceneitem_t;
typedef union cutsceneitemdata_u cutsceneitemdata_t;
// Full-screen UI panels a cutscene can declaratively open via UI_SHOW - not
// modals/dialogs (those already have their own dedicated item types, e.g.
// MODAL/MODAL_OPTIONS_MARKERS/KEYBOARD), just whole-screen views like the
// main menu. Add a case here (and to cutsceneUIShowStart) as more screens
// need to be openable this way.
typedef enum {
UI_SCREEN_TYPE_MAIN_MENU,
} uiscreentype_t;
typedef struct {
uiscreentype_t screen;
} cutsceneuishow_t;
/**
* Starts a UI_SHOW item, opening the requested full-screen UI panel.
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneUIShowStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates a UI_SHOW item (always completes immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneUIShowUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
+4 -6
View File
@@ -26,14 +26,12 @@ errorret_t sceneInitialInit(scenedata_t *sceneData) {
// Set background color to black for the initial scene // Set background color to black for the initial scene
SCREEN.background = COLOR_BLACK; SCREEN.background = COLOR_BLACK;
// Runtime-loaded from assets/cutscenes/initial.cts (authored at // Runtime-loaded from assets/cutscenes/initial.jsonc, parsed directly at
// assetsraw/cutscenes/initial.jsonc via `python3 -m tools.asset.cutscene`) // load time (no separate compile step) - checks for a save device,
// - checks for a save device, retrying on failure, then hands off to the // retrying on failure, then hands off to the main menu.
// 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) { if(INITIAL_CUTSCENE_ENTRY == NULL) {
INITIAL_CUTSCENE_ENTRY = assetLock( 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); errorret_t result = assetRequireLoaded(INITIAL_CUTSCENE_ENTRY);
+6 -7
View File
@@ -36,15 +36,14 @@ void sceneMainMenuOpenSelectSave(void *userData) {
} }
// Lazily locks/loads the main menu cutscene (runtime-loaded from // Lazily locks/loads the main menu cutscene (runtime-loaded from
// assets/cutscenes/main_menu.cts, authored at // assets/cutscenes/main_menu.jsonc, parsed directly at load time rather
// assetsraw/cutscenes/main_menu.jsonc via `python3 -m tools.asset.cutscene` // than compiled in, since it's player-facing flow rather than core engine
// rather than compiled in, since it's player-facing flow rather than core // wiring) and asserts it's ready to run. Shared by both the initial
// engine wiring) and asserts it's ready to run. Shared by both the initial
// scene-entry start and the New-Game fallback restart below. // scene-entry start and the New-Game fallback restart below.
cutscene_t *sceneMainMenuLoadCutscene(void) { cutscene_t *sceneMainMenuLoadCutscene(void) {
if(MAIN_MENU_CUTSCENE_ENTRY == NULL) { if(MAIN_MENU_CUTSCENE_ENTRY == NULL) {
MAIN_MENU_CUTSCENE_ENTRY = assetLock( 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); errorret_t result = assetRequireLoaded(MAIN_MENU_CUTSCENE_ENTRY);
@@ -79,8 +78,8 @@ void sceneMainMenuStartGame(void) {
} }
errorret_t sceneMainMenuInit(scenedata_t *sceneData) { 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()); cutsceneSystemStartCutscene(sceneMainMenuLoadCutscene());
cutsceneSystemSetOnComplete(sceneMainMenuOpenSelectSave); cutsceneSystemSetOnComplete(sceneMainMenuOpenSelectSave);
+3 -2
View File
@@ -17,8 +17,9 @@ typedef struct {
} scenemainmenu_t; } scenemainmenu_t;
/** /**
* Initializes the main menu scene: opens the main menu panel and starts * Initializes the main menu scene by starting the main menu cutscene,
* the main menu cutscene, which plays the menu's BGM and idles until * 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. * sceneMainMenuStartGame jumps it to its NEW_GAME marker.
* *
* @param sceneData The scene data used for this scene. * @param sceneData The scene data used for this scene.
-519
View File
@@ -1,519 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
"""
Generates DCTS binary cutscene files from raw JSONC cutscene definitions.
JSONC input (assetsraw/cutscenes/<name>.jsonc) - plain JSON plus // and
/* */ comments, stripped before parsing:
{
// optional flag names, default matches CUTSCENE_PAUSE_DEFAULT
"pause": ["NPC", "PLAYER"],
"items": [
{ "type": "TEXT", "text": "Hello!" },
{ "type": "WAIT", "seconds": 1.0 },
{ "type": "MARKER", "name": "GREET" },
...
]
}
Output: assetsraw/cutscenes/<name>.jsonc -> assets/cutscenes/<name>.cts
Only a subset of cutscene item types is supported (v1) - anything needing a
native callback (CALLBACK, MAP_AREA_ADD, the multi-option form of MODAL) or
recursive item data (CUTSCENE, CONCURRENT) isn't representable in a file
yet; those still have to be authored as compiled-in CUTSCENE(...) macros.
See src/dusk/asset/loader/cutscene/assetcutsceneloader.c for the C reader
this must match byte-for-byte, and src/dusk/rpg/cutscene/item/cutsceneitem.h
for the authoritative enum - ITEM_TYPE below MUST match its declared order.
DCTS format (little-endian throughout):
Header (12 bytes):
magic b"DCTS" 4 bytes
version u32 ASSET_CUTSCENE_FILE_VERSION
pauseType u8 cutscenepause_t bitmask
itemCount u8
poolSize u16 bytes in the pool blob following the item stream
Item stream (itemCount records back to back):
type u8 cutsceneitemtype_t
...type-specific payload; embedded strings are length-prefixed (u8 len
+ bytes, no null terminator); references into the pool are a u16
byte offset...
Pool (poolSize bytes): null-terminated strings and/or raw fixed-width
array data (e.g. worldpos_t triples), referenced by offset from the
item stream. Every pool entry starts 4-byte aligned.
"""
import argparse
import json
import os
import struct
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.normpath(os.path.join(_HERE, '..', '..', '..'))
ASSETSRAW_DIR = os.path.join(PROJECT_ROOT, 'assetsraw')
ASSETS_DIR = os.path.join(PROJECT_ROOT, 'assets')
FILE_MAGIC = b'DCTS'
VERSION_OUT = 1
# Must match cutsceneitemtype_t's declared order in
# src/dusk/rpg/cutscene/item/cutsceneitem.h exactly. Types with no entry
# here are v2 (native callback / recursive item data) and unsupported.
ITEM_TYPE = {
'TEXT': 1,
'TEXT_MINI': 2,
'TEXT_MINI_HIDE': 3,
'WAIT': 5,
'ENTITY_TELEPORT': 7,
'ENTITY_WALK_TO': 8,
'FADE': 9,
'SET_PAUSE': 10,
'ITEM_GIVE': 12,
'ENTITY_REMOVE': 13,
'ENTITY_ADD': 14,
'ENTITY_TURN': 15,
'ENTITY_WALK_TO_ENTITY': 16,
'MAP_AREA_REMOVE': 18,
'MAP_AREA_WAIT': 19,
'START_BATTLE': 20,
'EMOJI': 21,
'SHAKE': 22,
'BATTLE_WAIT_STATE': 23,
'BATTLE_FORCE_ACTION': 24,
'MODAL': 25,
'MODAL_OPTIONS_MARKERS': 26,
'MODAL_CLOSE': 27,
'PRINT': 28,
'MARKER': 29,
'RESTART': 30,
'SCENE': 31,
'SAVE_DEVICE_CHECK': 32,
'SAVE_LOAD_ALL_SLOTS': 33,
'AUDIO_PLAY': 35,
'AUDIO_STOP': 36,
'AUDIO_PAUSE': 37,
'AUDIO_RESUME': 38,
'AUDIO_FADE': 39,
'AUDIO_FADE_WAIT': 40,
'AUDIO_SET_PAN': 41,
'AUDIO_SET_LOOP': 42,
'AUDIO_SET': 43,
'IDLE': 44,
}
PAUSE_FLAG = {'NPC': 1 << 0, 'PLAYER': 1 << 1, 'WORLD': 1 << 2, 'BATTLE': 1 << 3}
PAUSE_DEFAULT = PAUSE_FLAG['NPC'] | PAUSE_FLAG['PLAYER']
ENTITY_SENTINEL = {'INTERACT': 0xFE, 'INTERACTED': 0xFD}
AREA_SENTINEL = {'LAST_CREATED': 0xFF}
ENTITY_TYPE = {'NULL': 0, 'PLAYER': 1, 'NPC': 2, 'ITEM': 3}
ENTITY_DIR = {
'NORTH': 0, 'EAST': 1, 'SOUTH': 2, 'WEST': 3,
'UP': 0, 'RIGHT': 1, 'DOWN': 2, 'LEFT': 3,
}
EASING = {
name: i for i, name in enumerate([
'LINEAR', 'IN_SINE', 'OUT_SINE', 'IN_OUT_SINE',
'IN_QUAD', 'OUT_QUAD', 'IN_OUT_QUAD',
'IN_CUBIC', 'OUT_CUBIC', 'IN_OUT_CUBIC',
'IN_QUART', 'OUT_QUART', 'IN_OUT_QUART',
'IN_BACK', 'OUT_BACK', 'IN_OUT_BACK',
])
}
UI_EMOJI = {'NULL': 0, 'QUESTION_MARK': 1, 'EXCLAMATION_MARK': 2}
BATTLE_ENCOUNTER = {'REGULAR': 0, 'PLAYER_ADVANTAGE': 1, 'BACK_ATTACK': 2}
BATTLE_STATE = {
'NONE': 0, 'OPENING': 1, 'PRE_ROUND': 2, 'PLAYER_SELECTION': 3,
'AI_SELECTION': 4, 'MOVES_EXECUTING': 5, 'POST_ROUND': 6, 'ENDED': 7,
}
SCENE_TYPE = {'NULL': 0, 'INITIAL': 1, 'MAIN_MENU': 2, 'OVERWORLD': 3, 'BATTLE': 4}
AUDIO_CHANNEL = {
'BGM_0': 0, 'VOICE_0': 1, 'VOICE_1': 2,
'SFX_0': 3, 'SFX_1': 4, 'SFX_2': 5, 'SFX_3': 6,
}
CUTSCENE_TEXT_MAX_CHARS = 256
CUTSCENE_TEXT_MINI_MAX_CHARS = 128
CUTSCENE_PRINT_MAX_CHARS = 128
CUTSCENE_MODAL_TITLE_MAX_CHARS = 64
CUTSCENE_MODAL_MESSAGE_MAX_CHARS = 256
CUTSCENE_MODAL_OPTIONS_MARKERS_MAX = 2
CUTSCENE_MAP_AREA_WAIT_MAX = 4
CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX = 4
AUDIO_PATH_MAX = 256
class Pool:
def __init__(self):
self.data = bytearray()
def _align(self, n):
while len(self.data) % n != 0:
self.data += b'\x00'
def add_string(self, s):
self._align(4)
offset = len(self.data)
self.data += s.encode('utf-8') + b'\x00'
return offset
def add_bytes(self, b):
self._align(4)
offset = len(self.data)
self.data += b
return offset
def resolve_entity_index(v):
if isinstance(v, str):
return ENTITY_SENTINEL[v.upper()]
return int(v)
def resolve_area_id(v):
if isinstance(v, str):
return AREA_SENTINEL[v.upper()]
return int(v)
def write_string_field(buf, s, max_len):
encoded = s.encode('utf-8')
if len(encoded) >= max_len:
raise ValueError(f"String exceeds max length {max_len}: {s!r}")
buf += struct.pack('<B', len(encoded))
buf += encoded
def write_worldpos(buf, pos):
x, y, z = pos
buf += struct.pack('<hhh', int(x), int(y), int(z))
def encode_item(item, pool):
item_type = item['type']
type_id = ITEM_TYPE.get(item_type)
if type_id is None:
raise ValueError(
f"Unsupported cutscene item type for file-based cutscenes: {item_type}"
)
buf = bytearray()
buf += struct.pack('<B', type_id)
if item_type == 'TEXT':
write_string_field(buf, item['text'], CUTSCENE_TEXT_MAX_CHARS)
elif item_type == 'TEXT_MINI':
write_string_field(buf, item['text'], CUTSCENE_TEXT_MINI_MAX_CHARS)
x, y, z = item['position']
buf += struct.pack('<fff', float(x), float(y), float(z))
buf += struct.pack('<f', float(item['duration']))
elif item_type == 'TEXT_MINI_HIDE':
buf += struct.pack('<B', int(item['index']))
elif item_type == 'WAIT':
buf += struct.pack('<f', float(item['seconds']))
elif item_type == 'ENTITY_TELEPORT':
buf += struct.pack('<B', resolve_entity_index(item['entityIndex']))
write_worldpos(buf, item['target'])
elif item_type == 'ENTITY_WALK_TO':
buf += struct.pack('<B', resolve_entity_index(item['entityIndex']))
buf += struct.pack('<B', 1 if item.get('walkAround', True) else 0)
positions = item['positions']
buf += struct.pack('<B', len(positions))
positions_bytes = bytearray()
for pos in positions:
write_worldpos(positions_bytes, pos)
offset = pool.add_bytes(bytes(positions_bytes))
buf += struct.pack('<H', offset)
elif item_type == 'FADE':
buf += bytes(int(c) for c in item['from'])
buf += bytes(int(c) for c in item['to'])
buf += struct.pack('<f', float(item['duration']))
buf += struct.pack('<B', EASING[item.get('easing', 'LINEAR').upper()])
elif item_type == 'SET_PAUSE':
value = 0
for name in item['flags']:
value |= PAUSE_FLAG[name.upper()]
buf += struct.pack('<B', value)
elif item_type == 'ITEM_GIVE':
buf += struct.pack('<H', int(item['item']))
buf += struct.pack('<B', int(item['quantity']))
elif item_type == 'ENTITY_REMOVE':
buf += struct.pack('<B', resolve_entity_index(item['entityIndex']))
elif item_type == 'ENTITY_ADD':
buf += struct.pack('<B', ENTITY_TYPE[item['entityType'].upper()])
write_worldpos(buf, item['position'])
elif item_type == 'ENTITY_TURN':
buf += struct.pack('<B', resolve_entity_index(item['entityIndex']))
buf += struct.pack('<B', ENTITY_DIR[item['direction'].upper()])
elif item_type == 'ENTITY_WALK_TO_ENTITY':
buf += struct.pack('<B', resolve_entity_index(item['entityIndex']))
buf += struct.pack('<B', resolve_entity_index(item['targetEntityIndex']))
buf += struct.pack('<hh', int(item['offsetX']), int(item['offsetY']))
elif item_type == 'MAP_AREA_REMOVE':
buf += struct.pack('<B', resolve_area_id(item['areaId']))
elif item_type == 'MAP_AREA_WAIT':
area_ids = item['areaIds']
if len(area_ids) > CUTSCENE_MAP_AREA_WAIT_MAX:
raise ValueError("MAP_AREA_WAIT areaIds exceeds maximum of 4")
buf += struct.pack('<B', len(area_ids))
ids_bytes = bytes(resolve_area_id(a) for a in area_ids)
offset = pool.add_bytes(ids_bytes)
buf += struct.pack('<H', offset)
elif item_type == 'START_BATTLE':
buf += struct.pack('<B', BATTLE_ENCOUNTER[item['encounterType'].upper()])
buf += struct.pack('<B', 1 if item.get('fleeAvailable', True) else 0)
enemies = item['enemies']
if len(enemies) > CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX:
raise ValueError("START_BATTLE enemies exceeds maximum of 4")
buf += struct.pack('<B', len(enemies))
for enemy in enemies:
stats = enemy['stats']
buf += struct.pack(
'<HHHHHHH',
int(stats['attack']), int(stats['defense']), int(stats['magic']),
int(stats['speed']), int(stats['luck']),
int(enemy['healthMax']), int(enemy['mpMax']),
)
elif item_type == 'EMOJI':
buf += struct.pack('<B', resolve_entity_index(item['entityIndex']))
buf += struct.pack('<f', float(item['duration']))
buf += struct.pack('<B', UI_EMOJI[item['emojiType'].upper()])
elif item_type == 'SHAKE':
buf += struct.pack('<B', int(item['amount']))
buf += struct.pack('<f', float(item['duration']))
elif item_type == 'BATTLE_WAIT_STATE':
buf += struct.pack('<B', BATTLE_STATE[item['state'].upper()])
elif item_type == 'BATTLE_FORCE_ACTION':
buf += struct.pack('<B', int(item['fighterIndex']))
buf += struct.pack('<B', int(item['targetIndex']))
elif item_type == 'MODAL':
write_string_field(buf, item.get('title', ''), CUTSCENE_MODAL_TITLE_MAX_CHARS)
write_string_field(buf, item['message'], CUTSCENE_MODAL_MESSAGE_MAX_CHARS)
buf += struct.pack('<B', 0) # v1: message-only, no options/callback
elif item_type == 'MODAL_OPTIONS_MARKERS':
write_string_field(buf, item.get('title', ''), CUTSCENE_MODAL_TITLE_MAX_CHARS)
write_string_field(buf, item['message'], CUTSCENE_MODAL_MESSAGE_MAX_CHARS)
options = item['options']
if not (1 <= len(options) <= CUTSCENE_MODAL_OPTIONS_MARKERS_MAX):
raise ValueError("MODAL_OPTIONS_MARKERS options must have 1 or 2 entries")
buf += struct.pack('<B', len(options))
for option in options:
text_offset = pool.add_string(option['text'])
marker_offset = pool.add_string(option['marker'])
buf += struct.pack('<HH', text_offset, marker_offset)
elif item_type in ('MODAL_CLOSE', 'RESTART', 'IDLE'):
pass
elif item_type == 'PRINT':
write_string_field(buf, item['text'], CUTSCENE_PRINT_MAX_CHARS)
elif item_type == 'MARKER':
offset = pool.add_string(item['name'])
buf += struct.pack('<H', offset)
elif item_type == 'SCENE':
buf += struct.pack('<B', SCENE_TYPE[item['sceneType'].upper()])
elif item_type in ('SAVE_DEVICE_CHECK', 'SAVE_LOAD_ALL_SLOTS'):
success_offset = pool.add_string(item['successMarker'])
failure_offset = pool.add_string(item['failureMarker'])
buf += struct.pack('<HH', success_offset, failure_offset)
elif item_type == 'AUDIO_PLAY':
write_string_field(buf, item['file'], AUDIO_PATH_MAX)
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<f', float(item.get('volume', 1.0)))
buf += struct.pack('<f', float(item.get('pan', 0.0)))
buf += struct.pack('<B', 1 if item.get('looping', False) else 0)
buf += struct.pack('<B', int(item.get('loopCount', 0)))
buf += struct.pack('<f', float(item.get('loopStart', -1.0)))
buf += struct.pack('<f', float(item.get('loopTo', 0.0)))
elif item_type in ('AUDIO_STOP', 'AUDIO_PAUSE', 'AUDIO_RESUME', 'AUDIO_FADE_WAIT'):
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
elif item_type == 'AUDIO_FADE':
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<f', float(item['from']))
buf += struct.pack('<f', float(item['to']))
buf += struct.pack('<f', float(item['duration']))
buf += struct.pack('<B', EASING[item.get('easing', 'LINEAR').upper()])
elif item_type == 'AUDIO_SET_PAN':
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<f', float(item['pan']))
elif item_type == 'AUDIO_SET_LOOP':
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<B', 1 if item.get('looping', True) else 0)
buf += struct.pack('<B', int(item.get('loopCount', 0)))
buf += struct.pack('<f', float(item.get('loopStart', -1.0)))
buf += struct.pack('<f', float(item.get('loopTo', 0.0)))
elif item_type == 'AUDIO_SET':
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<f', float(item['pan']))
buf += struct.pack('<B', 1 if item.get('looping', True) else 0)
buf += struct.pack('<B', int(item.get('loopCount', 0)))
buf += struct.pack('<f', float(item.get('loopStart', -1.0)))
buf += struct.pack('<f', float(item.get('loopTo', 0.0)))
else:
raise ValueError(f"Unhandled cutscene item type: {item_type}")
return bytes(buf)
def build_cutscene(source):
pause_value = 0
for name in source.get('pause', None) or []:
pause_value |= PAUSE_FLAG[name.upper()]
if 'pause' not in source:
pause_value = PAUSE_DEFAULT
items = source['items']
if len(items) > 255:
raise ValueError("Cutscene has more than 255 items")
pool = Pool()
item_stream = bytearray()
for item in items:
item_stream += encode_item(item, pool)
header = bytearray()
header += FILE_MAGIC
header += struct.pack('<I', VERSION_OUT)
header += struct.pack('<B', pause_value)
header += struct.pack('<B', len(items))
header += struct.pack('<H', len(pool.data))
return bytes(header) + bytes(item_stream) + bytes(pool.data)
def strip_jsonc_comments(text):
"""Strips // line comments and /* */ block comments from JSONC text,
leaving string literal contents (including any // or /* inside them)
untouched."""
result = []
i = 0
n = len(text)
in_string = False
escape = False
while i < n:
c = text[i]
if in_string:
result.append(c)
if escape:
escape = False
elif c == '\\':
escape = True
elif c == '"':
in_string = False
i += 1
continue
if c == '"':
in_string = True
result.append(c)
i += 1
continue
if c == '/' and i + 1 < n and text[i + 1] == '/':
i += 2
while i < n and text[i] != '\n':
i += 1
continue
if c == '/' and i + 1 < n and text[i + 1] == '*':
i += 2
while i + 1 < n and not (text[i] == '*' and text[i + 1] == '/'):
i += 1
i += 2
continue
result.append(c)
i += 1
return ''.join(result)
def process_json(json_path):
with open(json_path, 'r', encoding='utf-8') as f:
text = f.read()
source = json.loads(strip_jsonc_comments(text))
data = build_cutscene(source)
name = os.path.splitext(os.path.basename(json_path))[0]
out_path = os.path.join(ASSETS_DIR, 'cutscenes', f'{name}.cts')
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, 'wb') as f:
f.write(data)
print(f"{json_path} -> {out_path} ({len(data)} bytes)")
def main():
parser = argparse.ArgumentParser(
description="Generate DCTS binary cutscene files from raw JSONC "
"cutscene definitions"
)
parser.add_argument(
'paths', nargs='*',
help='JSONC cutscene file(s) to process. If omitted, processes all '
'assetsraw/cutscenes/*.jsonc'
)
args = parser.parse_args()
if not args.paths:
cutscenes_dir = os.path.join(ASSETSRAW_DIR, 'cutscenes')
if not os.path.isdir(cutscenes_dir):
print(f"No directory found: {cutscenes_dir}")
sys.exit(1)
json_files = sorted(
os.path.join(cutscenes_dir, f)
for f in os.listdir(cutscenes_dir)
if f.endswith('.jsonc')
)
if not json_files:
print(f"No JSONC files found in {cutscenes_dir}")
sys.exit(0)
for p in json_files:
process_json(p)
return
for p in args.paths:
process_json(p)
if __name__ == '__main__':
main()
+40
View File
@@ -35,9 +35,20 @@ Usage:
<assets_dir> as the zip entry name, to the stored archive if its <assets_dir> as the zip entry name, to the stored archive if its
relative path matches any --stored pattern (fnmatch, default: relative path matches any --stored pattern (fnmatch, default:
"locale/*"), otherwise to the compressed archive. "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 argparse
import json
import os import os
import struct import struct
import zipfile import zipfile
@@ -60,6 +71,26 @@ def build_zip_blob(root, relative_paths, compression):
return buf.getvalue() 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): def pack(input_dir, output_path, stored_patterns):
relative_paths = [] relative_paths = []
for dirpath, _dirnames, filenames in os.walk(input_dir): 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, '/') 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 = [ stored_paths = [
path for path in relative_paths path for path in relative_paths
if any(fnmatch.fnmatch(path, pattern) for pattern in stored_patterns) 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(compressed_blob)
f.write(stored_blob) f.write(stored_blob)
with open(manifest_file, 'w', encoding='utf-8') as f:
json.dump(manifest, f)
print( print(
f'Wrote {output_path}: {len(compressed_paths)} compressed file(s) ' f'Wrote {output_path}: {len(compressed_paths)} compressed file(s) '
f'({len(compressed_blob)} bytes), {len(stored_paths)} stored file(s) ' f'({len(compressed_blob)} bytes), {len(stored_paths)} stored file(s) '