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:
+10
-7
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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) '
|
||||||
|
|||||||
Reference in New Issue
Block a user