# Copyright (c) 2026 Dominic Masters # # This software is released under the MIT License. # https://opensource.org/licenses/MIT """ Generates DCTS binary cutscene files from raw JSONC cutscene definitions. JSONC input (assetsraw/cutscenes/.jsonc) - plain JSON plus // and /* */ comments, stripped before parsing: { // optional flag names, default matches CUTSCENE_PAUSE_DEFAULT "pause": ["NPC", "PLAYER"], "items": [ { "type": "TEXT", "text": "Hello!" }, { "type": "WAIT", "seconds": 1.0 }, { "type": "MARKER", "name": "GREET" }, ... ] } Output: assetsraw/cutscenes/.jsonc -> assets/cutscenes/.cts Only a subset of cutscene item types is supported (v1) - anything needing a native callback (CALLBACK, MAP_AREA_ADD, the multi-option form of MODAL) or recursive item data (CUTSCENE, CONCURRENT) isn't representable in a file yet; those still have to be authored as compiled-in CUTSCENE(...) macros. See src/dusk/asset/loader/cutscene/assetcutsceneloader.c for the C reader this must match byte-for-byte, and src/dusk/rpg/cutscene/item/cutsceneitem.h for the authoritative enum - ITEM_TYPE below MUST match its declared order. DCTS format (little-endian throughout): Header (12 bytes): magic b"DCTS" 4 bytes version u32 ASSET_CUTSCENE_FILE_VERSION pauseType u8 cutscenepause_t bitmask itemCount u8 poolSize u16 bytes in the pool blob following the item stream Item stream (itemCount records back to back): type u8 cutsceneitemtype_t ...type-specific payload; embedded strings are length-prefixed (u8 len + bytes, no null terminator); references into the pool are a u16 byte offset... Pool (poolSize bytes): null-terminated strings and/or raw fixed-width array data (e.g. worldpos_t triples), referenced by offset from the item stream. Every pool entry starts 4-byte aligned. """ import json import os import struct import sys _HERE = os.path.dirname(os.path.abspath(__file__)) PROJECT_ROOT = os.path.normpath(os.path.join(_HERE, '..', '..', '..')) ASSETSRAW_DIR = os.path.join(PROJECT_ROOT, 'assetsraw') ASSETS_DIR = os.path.join(PROJECT_ROOT, 'assets') FILE_MAGIC = b'DCTS' VERSION_OUT = 1 # Must match cutsceneitemtype_t's declared order in # src/dusk/rpg/cutscene/item/cutsceneitem.h exactly. Types with no entry # here are v2 (native callback / recursive item data) and unsupported. ITEM_TYPE = { 'TEXT': 1, 'TEXT_MINI': 2, 'TEXT_MINI_HIDE': 3, 'WAIT': 5, 'ENTITY_TELEPORT': 7, 'ENTITY_WALK_TO': 8, 'FADE': 9, 'SET_PAUSE': 10, 'ITEM_GIVE': 12, 'ENTITY_REMOVE': 13, 'ENTITY_ADD': 14, 'ENTITY_TURN': 15, 'ENTITY_WALK_TO_ENTITY': 16, 'MAP_AREA_REMOVE': 18, 'MAP_AREA_WAIT': 19, 'START_BATTLE': 20, 'EMOJI': 21, 'SHAKE': 22, 'BATTLE_WAIT_STATE': 23, 'BATTLE_FORCE_ACTION': 24, 'MODAL': 25, 'MODAL_OPTIONS_MARKERS': 26, 'MODAL_CLOSE': 27, 'PRINT': 28, 'MARKER': 29, 'RESTART': 30, 'SCENE': 31, 'SAVE_DEVICE_CHECK': 32, 'SAVE_LOAD_ALL_SLOTS': 33, } PAUSE_FLAG = {'NPC': 1 << 0, 'PLAYER': 1 << 1, 'WORLD': 1 << 2, 'BATTLE': 1 << 3} PAUSE_DEFAULT = PAUSE_FLAG['NPC'] | PAUSE_FLAG['PLAYER'] ENTITY_SENTINEL = {'INTERACT': 0xFE, 'INTERACTED': 0xFD} AREA_SENTINEL = {'LAST_CREATED': 0xFF} ENTITY_TYPE = {'NULL': 0, 'PLAYER': 1, 'NPC': 2, 'ITEM': 3} ENTITY_DIR = { 'NORTH': 0, 'EAST': 1, 'SOUTH': 2, 'WEST': 3, 'UP': 0, 'RIGHT': 1, 'DOWN': 2, 'LEFT': 3, } EASING = { name: i for i, name in enumerate([ 'LINEAR', 'IN_SINE', 'OUT_SINE', 'IN_OUT_SINE', 'IN_QUAD', 'OUT_QUAD', 'IN_OUT_QUAD', 'IN_CUBIC', 'OUT_CUBIC', 'IN_OUT_CUBIC', 'IN_QUART', 'OUT_QUART', 'IN_OUT_QUART', 'IN_BACK', 'OUT_BACK', 'IN_OUT_BACK', ]) } UI_EMOJI = {'NULL': 0, 'QUESTION_MARK': 1, 'EXCLAMATION_MARK': 2} BATTLE_ENCOUNTER = {'REGULAR': 0, 'PLAYER_ADVANTAGE': 1, 'BACK_ATTACK': 2} BATTLE_STATE = { 'NONE': 0, 'OPENING': 1, 'PRE_ROUND': 2, 'PLAYER_SELECTION': 3, 'AI_SELECTION': 4, 'MOVES_EXECUTING': 5, 'POST_ROUND': 6, 'ENDED': 7, } SCENE_TYPE = {'NULL': 0, 'INITIAL': 1, 'MAIN_MENU': 2, 'OVERWORLD': 3, 'BATTLE': 4} CUTSCENE_TEXT_MAX_CHARS = 256 CUTSCENE_TEXT_MINI_MAX_CHARS = 128 CUTSCENE_PRINT_MAX_CHARS = 128 CUTSCENE_MODAL_TITLE_MAX_CHARS = 64 CUTSCENE_MODAL_MESSAGE_MAX_CHARS = 256 CUTSCENE_MODAL_OPTIONS_MARKERS_MAX = 2 CUTSCENE_MAP_AREA_WAIT_MAX = 4 CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX = 4 class Pool: def __init__(self): self.data = bytearray() def _align(self, n): while len(self.data) % n != 0: self.data += b'\x00' def add_string(self, s): self._align(4) offset = len(self.data) self.data += s.encode('utf-8') + b'\x00' return offset def add_bytes(self, b): self._align(4) offset = len(self.data) self.data += b return offset def resolve_entity_index(v): if isinstance(v, str): return ENTITY_SENTINEL[v.upper()] return int(v) def resolve_area_id(v): if isinstance(v, str): return AREA_SENTINEL[v.upper()] return int(v) def write_string_field(buf, s, max_len): encoded = s.encode('utf-8') if len(encoded) >= max_len: raise ValueError(f"String exceeds max length {max_len}: {s!r}") buf += struct.pack(' CUTSCENE_MAP_AREA_WAIT_MAX: raise ValueError("MAP_AREA_WAIT areaIds exceeds maximum of 4") buf += struct.pack(' CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX: raise ValueError("START_BATTLE enemies exceeds maximum of 4") buf += struct.pack(' 255: raise ValueError("Cutscene has more than 255 items") pool = Pool() item_stream = bytearray() for item in items: item_stream += encode_item(item, pool) header = bytearray() header += FILE_MAGIC header += struct.pack(' {out_path} ({len(data)} bytes)") def main(): args = sys.argv[1:] if not args: cutscenes_dir = os.path.join(ASSETSRAW_DIR, 'cutscenes') if not os.path.isdir(cutscenes_dir): print(f"No directory found: {cutscenes_dir}") sys.exit(1) json_files = sorted( os.path.join(cutscenes_dir, f) for f in os.listdir(cutscenes_dir) if f.endswith('.jsonc') ) if not json_files: print(f"No JSONC files found in {cutscenes_dir}") sys.exit(0) for p in json_files: process_json(p) return for p in args: process_json(p) if __name__ == '__main__': main()