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