Load an image or existing .dtf file, then export as Dusk Texture Format (.dtf) or PNG.
+
+
+
+
+
+
+
+
+
+
Load an image or .dtf file to get started
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/editor/texture/texture.js b/tools/editor/texture/texture.js
new file mode 100644
index 00000000..93011fde
--- /dev/null
+++ b/tools/editor/texture/texture.js
@@ -0,0 +1,278 @@
+"use strict";
+
+// ─── State ───────────────────────────────────────────────────────────────────
+
+const state = {
+ pixels: null, // Uint8ClampedArray RGBA at original resolution
+ width: 0,
+ height: 0,
+ scale: 1,
+ bg: "grid",
+ format: DTF.FORMAT_RGBA, // output DTF format
+ redAsAlpha: false, // Alpha format: use red channel instead of alpha
+ filename: "texture", // basename without extension
+};
+
+// ─── DOM refs ────────────────────────────────────────────────────────────────
+
+const canvas = document.getElementById("canvas");
+const ctx = canvas.getContext("2d");
+const fileInput = document.getElementById("file-input");
+const loadLabel = document.getElementById("load-label");
+const scaleInput = document.getElementById("scale-input");
+const bgSwatches = document.getElementById("bg-swatches");
+const previewArea = document.getElementById("preview-area");
+const previewEmpty = document.getElementById("preview-empty");
+const previewScroll = document.getElementById("preview-scroll");
+const infoSection = document.getElementById("info-section");
+const fileNameEl = document.getElementById("file-name");
+const infoFilename = document.getElementById("info-filename");
+const infoSize = document.getElementById("info-size");
+const infoFormat = document.getElementById("info-format");
+const btnDtf = document.getElementById("btn-dtf");
+const btnPng = document.getElementById("btn-png");
+const formatSelect = document.getElementById("format-select");
+const infoDtfSize = document.getElementById("info-dtf-size");
+const warningsSection = document.getElementById("warnings-section");
+const warningList = document.getElementById("warning-list");
+const redAsAlphaRow = document.getElementById("red-as-alpha-row");
+const redAsAlphaCheck = document.getElementById("red-as-alpha");
+
+// ─── Rendering ───────────────────────────────────────────────────────────────
+
+const CHECKER_CELL = 8;
+
+function drawCheckerboard(w, h) {
+ for (let y = 0; y < h; y += CHECKER_CELL) {
+ for (let x = 0; x < w; x += CHECKER_CELL) {
+ ctx.fillStyle = ((x / CHECKER_CELL + y / CHECKER_CELL) % 2 === 0) ? "#c8c8c8" : "#ffffff";
+ ctx.fillRect(x, y, Math.min(CHECKER_CELL, w - x), Math.min(CHECKER_CELL, h - y));
+ }
+ }
+}
+
+function render() {
+ if (!state.pixels) return;
+
+ const { pixels, width, height, scale, bg, format } = state;
+ const cw = width * scale;
+ const ch = height * scale;
+
+ canvas.width = cw;
+ canvas.height = ch;
+
+ // 1. Background
+ if (bg === "grid") {
+ drawCheckerboard(cw, ch);
+ } else {
+ ctx.fillStyle = bg;
+ ctx.fillRect(0, 0, cw, ch);
+ }
+
+ // 2. Composite image on top at scaled size (pixelated)
+ const src = Object.assign(document.createElement("canvas"), { width, height });
+ src.getContext("2d").putImageData(DTF.toImageData(width, height, pixels, format, state.redAsAlpha), 0, 0);
+
+ ctx.imageSmoothingEnabled = false;
+ ctx.drawImage(src, 0, 0, cw, ch);
+}
+
+// ─── Loading ─────────────────────────────────────────────────────────────────
+
+function loadStandardImage(file) {
+ const url = URL.createObjectURL(file);
+ const img = new Image();
+
+ img.onload = () => {
+ const offscreen = Object.assign(document.createElement("canvas"), {
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ });
+ offscreen.getContext("2d").drawImage(img, 0, 0);
+ const imageData = offscreen.getContext("2d").getImageData(0, 0, img.naturalWidth, img.naturalHeight);
+
+ applyImageData(
+ img.naturalWidth,
+ img.naturalHeight,
+ imageData.data,
+ file.name,
+ file.type || "image",
+ );
+ URL.revokeObjectURL(url);
+ };
+
+ img.onerror = () => {
+ showError("Failed to load image. The file may be corrupt or unsupported.");
+ URL.revokeObjectURL(url);
+ };
+
+ img.src = url;
+}
+
+function loadDTF(file) {
+ const reader = new FileReader();
+ reader.onload = e => {
+ try {
+ const { width, height, format, data } = DTF.decode(e.target.result);
+ const label = format === DTF.FORMAT_ALPHA ? "DTF (Alpha)"
+ : format === DTF.FORMAT_RGB ? "DTF (RGB)"
+ : "DTF (RGBA)";
+ applyImageData(width, height, data, file.name, label, format);
+ } catch (err) {
+ showError(`Failed to load DTF: ${err.message}`);
+ }
+ };
+ reader.readAsArrayBuffer(file);
+}
+
+function updateWarnings() {
+ const warnings = [];
+
+ if (state.pixels) {
+ const { width, height } = state;
+ const isPow2 = n => n > 0 && (n & (n - 1)) === 0;
+
+ if (width < 4) warnings.push(`Width is below 4 px (${width})`);
+ if (height < 4) warnings.push(`Height is below 4 px (${height})`);
+ if (!isPow2(width)) warnings.push(`Width is not a power of two (${width})`);
+ if (!isPow2(height)) warnings.push(`Height is not a power of two (${height})`);
+
+ const bytes = DTF.HEADER_SIZE + width * height * DTF.BPP[state.format];
+ if (bytes > 256 * 1024) {
+ warnings.push(`Output exceeds 256 KB (${(bytes / 1024).toFixed(1)} KB)`);
+ }
+ }
+
+ warningList.replaceChildren(
+ ...warnings.map(msg => Object.assign(document.createElement("li"), { textContent: msg })),
+ );
+ warningsSection.hidden = warnings.length === 0;
+}
+
+function updateDtfSize() {
+ if (!state.pixels) return;
+ const bytes = DTF.HEADER_SIZE + state.width * state.height * DTF.BPP[state.format];
+ infoDtfSize.textContent = `${(bytes / 1024).toFixed(1)} KB`;
+ updateWarnings();
+}
+
+function applyImageData(width, height, data, filename, formatLabel, format) {
+ state.pixels = new Uint8ClampedArray(data); // defensive copy
+ state.width = width;
+ state.height = height;
+ state.filename = filename.replace(/\.[^/.]+$/, "");
+
+ // Sync format selector when loading an existing DTF
+ if (format !== undefined) {
+ state.format = format;
+ formatSelect.value = format;
+ }
+
+ // Sidebar info
+ fileNameEl.textContent = filename;
+ infoFilename.textContent = filename;
+ infoSize.textContent = `${width} × ${height}`;
+ infoFormat.textContent = formatLabel;
+ infoSection.hidden = false;
+ updateDtfSize();
+
+ // Show canvas
+ previewEmpty.hidden = true;
+ previewScroll.hidden = false;
+
+ // Enable export
+ btnDtf.disabled = false;
+ btnPng.disabled = false;
+
+ render();
+}
+
+function handleFile(file) {
+ if (!file) return;
+ if (file.name.toLowerCase().endsWith(".dtf")) {
+ loadDTF(file);
+ } else {
+ loadStandardImage(file);
+ }
+}
+
+function showError(msg) {
+ alert(msg);
+}
+
+// ─── Export ───────────────────────────────────────────────────────────────────
+
+function exportDTF() {
+ if (!state.pixels) return;
+ const buf = DTF.encode(state.width, state.height, state.pixels, state.format, state.redAsAlpha);
+ const blob = new Blob([buf], { type: "application/octet-stream" });
+ const url = URL.createObjectURL(blob);
+ const a = Object.assign(document.createElement("a"), {
+ href: url,
+ download: `${state.filename}.dtf`,
+ });
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+function exportPNG() {
+ if (!state.pixels) return;
+ DuskPNG.download(`${state.filename}.png`, state.width, state.height, state.pixels);
+}
+
+// ─── Event listeners ─────────────────────────────────────────────────────────
+
+fileInput.addEventListener("change", e => handleFile(e.target.files[0]));
+
+scaleInput.addEventListener("input", () => {
+ const v = Math.max(1, Math.min(10, parseInt(scaleInput.value, 10) || 1));
+ scaleInput.value = v;
+ state.scale = v;
+ render();
+});
+
+bgSwatches.addEventListener("click", e => {
+ const btn = e.target.closest(".bg-swatch");
+ if (!btn) return;
+ bgSwatches.querySelectorAll(".bg-swatch").forEach(b => b.classList.remove("active"));
+ btn.classList.add("active");
+ state.bg = btn.dataset.bg;
+ render();
+});
+
+btnDtf.addEventListener("click", exportDTF);
+btnPng.addEventListener("click", exportPNG);
+
+formatSelect.addEventListener("change", () => {
+ state.format = parseInt(formatSelect.value, 10);
+ redAsAlphaRow.hidden = state.format !== DTF.FORMAT_ALPHA;
+ updateDtfSize();
+ render();
+});
+
+redAsAlphaCheck.addEventListener("change", () => {
+ state.redAsAlpha = redAsAlphaCheck.checked;
+ render();
+});
+
+// Drag-and-drop on the load label
+function setupDrop(target) {
+ target.addEventListener("dragover", e => {
+ e.preventDefault();
+ loadLabel.classList.add("drag-over");
+ previewArea.classList.add("drag-over");
+ });
+ target.addEventListener("dragleave", () => {
+ loadLabel.classList.remove("drag-over");
+ previewArea.classList.remove("drag-over");
+ });
+ target.addEventListener("drop", e => {
+ e.preventDefault();
+ loadLabel.classList.remove("drag-over");
+ previewArea.classList.remove("drag-over");
+ handleFile(e.dataTransfer.files[0]);
+ });
+}
+
+setupDrop(loadLabel);
+setupDrop(previewArea);
diff --git a/tools/env_to_h/CMakeLists.txt b/tools/env_to_h/CMakeLists.txt
deleted file mode 100644
index b97b1a2a..00000000
--- a/tools/env_to_h/CMakeLists.txt
+++ /dev/null
@@ -1,15 +0,0 @@
-# Copyright (c) 2026 Dominic Masters
-#
-# This software is released under the MIT License.
-# https://opensource.org/licenses/MIT
-
-function(dusk_env_to_h INPUT_PATH OUTPUT_NAME_RELATIVE)
- set(DUSK_DEFS_TARGET_NAME "DUSK_DEFS_${OUTPUT_NAME_RELATIVE}")
- dusk_run_python(
- ${DUSK_DEFS_TARGET_NAME}
- tools.env_to_h
- --env "${CMAKE_CURRENT_LIST_DIR}/${INPUT_PATH}"
- --output ${DUSK_GENERATED_HEADERS_DIR}/${OUTPUT_NAME_RELATIVE}
- )
- add_dependencies(${DUSK_LIBRARY_TARGET_NAME} ${DUSK_DEFS_TARGET_NAME})
-endfunction()
\ No newline at end of file
diff --git a/tools/env_to_h/__main__.py b/tools/env_to_h/__main__.py
deleted file mode 100644
index 88ab6a26..00000000
--- a/tools/env_to_h/__main__.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import argparse
-import os
-from dotenv import load_dotenv, dotenv_values
-
-parser = argparse.ArgumentParser(description="Convert .env to .h defines")
-parser.add_argument("--env", required=True, help="Path to .env file")
-parser.add_argument("--output", required=True, help="Path to output .h file")
-args = parser.parse_args()
-
-# Load .env file
-load_dotenv(dotenv_path=args.env)
-fileDefs = dotenv_values(dotenv_path=args.env)
-
-outHeader = ""
-outHeader += "#include \"dusk.h\"\n\n"
-for key, value in fileDefs.items():
- # Determine type and print out appropriate C type define.
-
- # Integer
- try:
- asInt = int(value)
- outHeader += f"#define {key} {asInt}\n"
- continue
- except:
- pass
-
- # Float
- try:
- asFloat = float(value)
- outHeader += f"#define {key} {asFloat}f\n"
- continue
- except:
- pass
-
- # Boolean
- if value.lower() in ['true', 'false']:
- asBool = '1' if value.lower() == 'true' else '0'
- outHeader += f"#define {key} {asBool}\n"
- continue
-
- # String
- outHeader += f'#define {key} "{value}"\n'
-
-# Write to output file
-with open(args.output, 'w') as outFile:
- outFile.write(outHeader)
\ No newline at end of file
diff --git a/tools/input/csv/__main__.py b/tools/input/csv/__main__.py
index f10f9d69..6055de08 100644
--- a/tools/input/csv/__main__.py
+++ b/tools/input/csv/__main__.py
@@ -1,60 +1,58 @@
import argparse
-import os
import csv
+import os
parser = argparse.ArgumentParser(description="Input CSV to .h defines")
parser.add_argument("--csv", required=True, help="Path to Input CSV file")
parser.add_argument("--output", required=True, help="Path to output .h file")
args = parser.parse_args()
-def csvIdToEnumName(inputId):
- return "INPUT_ACTION_" + inputId.upper()
+def id_enum(name):
+ return "INPUT_ACTION_" + name.upper()
-# Load up CSV file.
-outHeader = "#pragma once\n"
-outHeader += '#include "dusk.h"\n\n'
-
-with open(args.csv, newline="", encoding="utf-8") as csvfile:
- reader = csv.DictReader(csvfile)
-
- # CSV must have id column.
+# Load CSV
+input_ids = []
+with open(args.csv, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
if "id" not in reader.fieldnames:
- raise Exception("CSV file must have 'id' column")
-
- # For each ID
- inputIds = []
- inputIdValues = {}
+ raise ValueError("CSV must have an 'id' column")
for row in reader:
- inputId = row["id"]
- if inputId not in inputIds:
- inputIds.append(inputId)
+ input_id = row["id"]
+ if input_id not in input_ids:
+ input_ids.append(input_id)
- # For each ID, create enum entry.
- count = 0
- outHeader += "typedef enum {\n"
- outHeader += f" INPUT_ACTION_NULL = 0x{count:x},\n\n"
- count += 1
- for inputId in inputIds:
- inputIdValues[inputId] = count
- outHeader += f" {csvIdToEnumName(inputId)} = 0x{count:x},\n"
- count += 1
- outHeader += f"\n INPUT_ACTION_COUNT = 0x{count:x}\n"
- outHeader += "} inputaction_t;\n\n"
+# Assign enum values
+id_values = {input_id: i + 1 for i, input_id in enumerate(input_ids)}
- # Write IDs to char array.
- outHeader += f"static const char_t* INPUT_ACTION_IDS[] = {{\n"
- for inputId in inputIds:
- outHeader += f" [{csvIdToEnumName(inputId)}] = \"{inputId}\",\n"
- outHeader += f"}};\n\n"
+# Build output
+out = [
+ "#pragma once",
+ '#include "dusk.h"',
+ "",
+ "typedef enum {",
+ " INPUT_ACTION_NULL = 0x0,",
+ "",
+]
+for input_id in input_ids:
+ out.append(f" {id_enum(input_id)} = 0x{id_values[input_id]:x},")
+out += [
+ "",
+ f" INPUT_ACTION_COUNT = 0x{len(input_ids) + 1:x}",
+ "} inputaction_t;",
+ "",
+ "static const char_t *INPUT_ACTION_IDS[] = {",
+]
+for input_id in input_ids:
+ out.append(f" [{id_enum(input_id)}] = \"{input_id}\",")
+out += [
+ "};",
+ "",
+ "static const char_t *INPUT_ACTION_SCRIPT =",
+]
+for input_id in input_ids:
+ out.append(f" \"{id_enum(input_id)} = {id_values[input_id]}\\n\"")
+out += [";", ""]
- # Lua Script
- outHeader += f"static const char_t *INPUT_ACTION_SCRIPT = \n"
- for inputId in inputIds:
- # Reference the enum
- outHeader += f" \"{csvIdToEnumName(inputId)} = {inputIdValues[inputId]}\\n\"\n"
- outHeader += f";\n\n"
-
-# Write to output file.
os.makedirs(os.path.dirname(args.output), exist_ok=True)
-with open(args.output, "w", encoding="utf-8") as outFile:
- outFile.write(outHeader)
\ No newline at end of file
+with open(args.output, "w", encoding="utf-8") as f:
+ f.write("\n".join(out))
diff --git a/tools/item/csv/__main__.py b/tools/item/csv/__main__.py
index f065aec9..c3c4d5fc 100644
--- a/tools/item/csv/__main__.py
+++ b/tools/item/csv/__main__.py
@@ -1,94 +1,104 @@
import argparse
-import os
import csv
+import os
parser = argparse.ArgumentParser(description="Item CSV to .h defines")
parser.add_argument("--csv", required=True, help="Path to item CSV file")
parser.add_argument("--output", required=True, help="Path to output .h file")
args = parser.parse_args()
-def csvIdToEnumName(itemId):
- return "ITEM_ID_" + itemId.upper()
+def type_enum(name):
+ return "ITEM_TYPE_" + name.upper()
-itemIds = []
-itemTypes = []
-itemRowById = {}
+def id_enum(name):
+ return "ITEM_ID_" + name.upper()
-with open(args.csv, newline="", encoding="utf-8") as csvfile:
- reader = csv.DictReader(csvfile)
+# Load CSV
+item_ids = []
+item_types = []
+rows = {}
- # CSV must have id and type columns.
+with open(args.csv, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
if "id" not in reader.fieldnames or "type" not in reader.fieldnames:
- raise Exception("CSV file must have 'id' and 'type' columns")
-
+ raise ValueError("CSV must have 'id' and 'type' columns")
for row in reader:
- itemId = row["id"]
- itemType = row["type"]
-
- if itemId not in itemIds:
- itemIds.append(itemId)
+ item_id, item_type = row["id"], row["type"]
+ if item_id not in item_ids:
+ item_ids.append(item_id)
+ if item_type not in item_types:
+ item_types.append(item_type)
+ rows[item_id] = row
- if itemType not in itemTypes:
- itemTypes.append(itemType)
-
- itemRowById[itemId] = row
-
-# Now Prep output
-outHeader = "#pragma once\n"
-outHeader += '#include "dusk.h"\n\n'
-
-itemTypeValues = {}
-itemIdValues = {}
+# Assign enum values — types and IDs share a single counter so values never collide
count = 0
+type_values = {}
+id_values = {}
-# Create enum for types and ids, include null and count.
-outHeader += "typedef enum {\n"
-outHeader += f" ITEM_TYPE_NULL = {count},\n"
-count += 1
-for itemType in itemTypes:
- itemTypeValues[itemType] = count
- outHeader += f" {csvIdToEnumName(itemType)} = {count},\n"
+count += 1 # 0 = NULL
+for t in item_types:
+ type_values[t] = count
count += 1
-outHeader += f" ITEM_TYPE_COUNT = {count}\n"
-outHeader += "} itemtype_t;\n\n"
+# ITEM_TYPE_COUNT = count; item IDs continue from here
+type_count = count
-outHeader += "typedef enum {\n"
-outHeader += f" ITEM_ID_NULL = {count},\n"
-for itemId in itemIds:
- itemIdValues[itemId] = count
- outHeader += f" {csvIdToEnumName(itemId)} = {count},\n"
+count += 1 # type_count = ITEM_ID_NULL
+for i in item_ids:
+ id_values[i] = count
count += 1
-outHeader += f" ITEM_ID_COUNT = {count}\n"
-outHeader += "} itemid_t;\n\n"
+id_count = count
-# Create struct for item data.
-outHeader += "typedef struct {\n"
-outHeader += " itemid_t id;\n"
-outHeader += " itemtype_t type;\n"
-outHeader += " const char_t *name;\n"
-outHeader += "} item_t;\n\n"
+# Build output
+out = [
+ "#pragma once",
+ '#include "dusk.h"',
+ "",
+ "typedef enum {",
+ " ITEM_TYPE_NULL = 0,",
+]
+for t in item_types:
+ out.append(f" {type_enum(t)} = {type_values[t]},")
+out += [
+ f" ITEM_TYPE_COUNT = {type_count}",
+ "} itemtype_t;",
+ "",
+ "typedef enum {",
+ f" ITEM_ID_NULL = {type_count},",
+]
+for i in item_ids:
+ out.append(f" {id_enum(i)} = {id_values[i]},")
+out += [
+ f" ITEM_ID_COUNT = {id_count}",
+ "} itemid_t;",
+ "",
+ "typedef struct {",
+ " itemid_t id;",
+ " itemtype_t type;",
+ " const char_t *name;",
+ "} item_t;",
+ "",
+ "static const item_t ITEMS[] = {",
+]
+for i in item_ids:
+ row = rows[i]
+ out += [
+ f" [{id_enum(i)}] = {{",
+ f" .id = {id_enum(i)},",
+ f" .type = {type_enum(row['type'])},",
+ f" .name = \"{i}\",",
+ " },",
+ ]
+out += [
+ "};",
+ "",
+ "static const char_t *ITEM_SCRIPT =",
+]
+for i in item_ids:
+ out.append(f" \"{id_enum(i)} = {id_values[i]}\\n\"")
+for t in item_types:
+ out.append(f" \"{type_enum(t)} = {type_values[t]}\\n\"")
+out += [";", ""]
-# Create array of item data.
-outHeader += f"static const item_t ITEMS[] = {{\n"
-for itemId in itemIds:
- outHeader += f" [{csvIdToEnumName(itemId)}] = {{\n"
- outHeader += f" .id = {csvIdToEnumName(itemId)},\n"
- itemType = itemRowById[itemId]["type"]
- outHeader += f" .type = {csvIdToEnumName(itemType)},\n"
- itemName = itemRowById[itemId]["id"]
- outHeader += f" .name = \"{itemName}\",\n"
- outHeader += f" }},\n"
-outHeader += f"}};\n\n"
-
-# Create lua script defining items.
-outHeader += f"static const char_t *ITEM_SCRIPT = \n"
-for itemId in itemIds:
- outHeader += f" \"{csvIdToEnumName(itemId)} = {itemIdValues[itemId]}\\n\"\n"
-for itemType in itemTypes:
- outHeader += f" \"{csvIdToEnumName(itemType)} = {itemTypeValues[itemType]}\\n\"\n"
-outHeader += f";\n\n"
-
-# Write to output file.
os.makedirs(os.path.dirname(args.output), exist_ok=True)
-with open(args.output, "w", encoding="utf-8") as outFile:
- outFile.write(outHeader)
\ No newline at end of file
+with open(args.output, "w", encoding="utf-8") as f:
+ f.write("\n".join(out))
diff --git a/tools/palette-indexer.html b/tools/palette-indexer.html
deleted file mode 100644
index 9ae97268..00000000
--- a/tools/palette-indexer.html
+++ /dev/null
@@ -1,687 +0,0 @@
-
-
-
-
-
- Dusk Tools / Palette Indexer
-
-
-
-
-
-
Dusk Palette Indexer
-
- This tool takes an input image and creates a palettized version of it.
- You will get two files, a .dpf (Dusk Palette File) and a .dpt
- (Dusk Palettized Texture). The first being the palette, which I recommend
- reusing across multiple images, and the second being the indexed image,
- which uses the colors from the palette.
-
-
- Also, dusk previously supported Alpha textures, but due to so many little
- platform differences I realized it is simpler and about as much work to
- get palettized textures working instead. As a result I suggest creating a
- single palette for your alpha textures, and reusing that globally.
-
-
-
-
Input Image
-
-
-
-
-
-
-
-
-
Palette
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Palette Preview
-
-
Palette
-
-
-
-
-
-
-
Indexed Image
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/tools/run_python/CMakeLists.txt b/tools/run_python/CMakeLists.txt
index efad0c44..abd8808a 100644
--- a/tools/run_python/CMakeLists.txt
+++ b/tools/run_python/CMakeLists.txt
@@ -1,10 +1,5 @@
# Copyright (c) 2026 Dominic Masters
-#
-# This software is released under the MIT License.
-# https://opensource.org/licenses/MIT
-
-# Copyright (c) 2026 Dominic Masters
-#
+#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
@@ -16,4 +11,4 @@ function(dusk_run_python CMAKE_TARGET_NAME PYTHON_MODULE)
COMMAND
${Python3_EXECUTABLE} -m ${PYTHON_MODULE} ${ARGN}
)
-endfunction()
\ No newline at end of file
+endfunction()
diff --git a/tools/server.py b/tools/server.py
new file mode 100755
index 00000000..c55f5b8f
--- /dev/null
+++ b/tools/server.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+# Copyright (c) 2026 Dominic Masters
+#
+# This software is released under the MIT License.
+# https://opensource.org/licenses/MIT
+
+import argparse
+import functools
+import http.server
+import os
+
+EDITOR_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor")
+
+parser = argparse.ArgumentParser(description="Dusk editor tools server")
+parser.add_argument("--host", default="localhost", help="Host to bind to")
+parser.add_argument("--port", type=int, default=3000, help="Port to listen on")
+args = parser.parse_args()
+
+handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=EDITOR_DIR)
+
+with http.server.HTTPServer((args.host, args.port), handler) as server:
+ print(f"Dusk editor tools: http://{args.host}:{args.port}/")
+ server.serve_forever()
diff --git a/tools/story/csv/__main__.py b/tools/story/csv/__main__.py
index e99e3b4e..f7223836 100644
--- a/tools/story/csv/__main__.py
+++ b/tools/story/csv/__main__.py
@@ -1,48 +1,49 @@
import argparse
-import os
import csv
+import os
parser = argparse.ArgumentParser(description="Story CSV to .h defines")
parser.add_argument("--csv", required=True, help="Path to story CSV file")
-parser.add_argument("--header-file", required=True, help="Path to output .h file")
+parser.add_argument("--output", required=True, help="Path to output .h file")
args = parser.parse_args()
-def idToEnum(id):
- return "STORY_FLAG_" + id.upper().replace(" ", "_")
+def flag_enum(name):
+ return "STORY_FLAG_" + name.upper().replace(" ", "_")
-# Load up CSV file.
-outHeader = "#pragma once\n"
-outHeader += '#include "story/storyflagdefs.h"\n\n'
-
-with open(args.csv, newline="", encoding="utf-8") as csvfile:
- reader = csv.DictReader(csvfile)
-
- # CSV must have id column
+# Load flags
+flags = []
+with open(args.csv, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
if "id" not in reader.fieldnames:
- raise Exception("CSV file must have 'id' column")
-
- # Generate enum
- outHeader += "typedef enum {\n"
- outHeader += " STORY_FLAG_NULL,\n\n"
+ raise ValueError("CSV must have an 'id' column")
for row in reader:
- id = idToEnum(row["id"].strip())
- outHeader += f" {id},\n"
- outHeader += "\n STORY_FLAG_COUNT\n"
- outHeader += "} storyflag_t;\n\n"
+ flags.append({
+ "id": row["id"].strip(),
+ "initial": (row.get("initial") or "0").strip(),
+ })
- # Generate flag values
- csvfile.seek(0)
- reader = csv.DictReader(csvfile)
+# Build output
+out = [
+ "#pragma once",
+ '#include "story/storyflagdefs.h"',
+ "",
+ "typedef enum {",
+ " STORY_FLAG_NULL,",
+ "",
+]
+for flag in flags:
+ out.append(f" {flag_enum(flag['id'])},")
+out += [
+ "",
+ " STORY_FLAG_COUNT",
+ "} storyflag_t;",
+ "",
+ "static storyflagvalue_t STORY_FLAG_VALUES[STORY_FLAG_COUNT] = {",
+]
+for flag in flags:
+ out.append(f" [{flag_enum(flag['id'])}] = {flag['initial']},")
+out += ["};", ""]
- outHeader += "static storyflagvalue_t STORY_FLAG_VALUES[STORY_FLAG_COUNT] = {\n"
- for row in reader:
- id = idToEnum(row["id"].strip())
- initial = row.get("initial", "0").strip() or "0"
- outHeader += f" [{id}] = {initial},\n"
- outHeader += "};\n"
-
-os.makedirs(os.path.dirname(args.header_file), exist_ok=True)
-
-# Write header
-with open(args.header_file, "w", encoding="utf-8") as headerFile:
- headerFile.write(outHeader)
\ No newline at end of file
+os.makedirs(os.path.dirname(args.output), exist_ok=True)
+with open(args.output, "w", encoding="utf-8") as f:
+ f.write("\n".join(out))
diff --git a/tools/texture-creator.html b/tools/texture-creator.html
deleted file mode 100644
index 152f3b6f..00000000
--- a/tools/texture-creator.html
+++ /dev/null
@@ -1,417 +0,0 @@
-
-
-
-
-
- Dusk Tools / Texture Creator
-
-
-
-
-
-
Dusk Texture Creator
-
- Creates texture files. This will not create palletized textures, use the
- palette-indexer.html tool for that. This will instead work for all other
- kinds of textures.
-
-
-
-
-
-
-
-
-
-
Settings
-
-
- Texture Format:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Preview
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/tools/tile-joiner.html b/tools/tile-joiner.html
deleted file mode 100644
index b1f8a120..00000000
--- a/tools/tile-joiner.html
+++ /dev/null
@@ -1,235 +0,0 @@
-
-
-
-
-
- Dusk Tools / Tile Joiner
-
-
-
-
-
-
Dusk Tile Joiner
-
- Joins multiple tiles together into a single tileset image. Optimizing and
- allowing it to work on multiple platforms. Output width and height will
- always be a power of two. This tool will take a while to process images,
- since all images need to be loaded into memory.
-
-
-
-
-
-
-
-
-
-
-
-
Settings
-
-
-
-
-
-
-
-
Joined Preview
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/tools/tile-slicer.html b/tools/tile-slicer.html
deleted file mode 100644
index dcc62471..00000000
--- a/tools/tile-slicer.html
+++ /dev/null
@@ -1,391 +0,0 @@
-
-
-
-
-
- Dusk Tools / Tile Slicer
-
-
-
-
-
-
Dusk Tile Slicer
-
- Tool allows you to take a single image and slice it into individual tiles.
- This should be done as the first step of optimizing tilesets for your
- dusk game.
-
-
-
-
-
-
-
-
-
Input Preview
-
-
-
-
-
Settings
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Sliced Preview
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/tools/tileset-creator.html b/tools/tileset-creator.html
deleted file mode 100644
index f6715411..00000000
--- a/tools/tileset-creator.html
+++ /dev/null
@@ -1,546 +0,0 @@
-
-
-
-
-
- Dusk Tools / Tileset Creator
-
-
-
-
-
-
Dusk Tileset Creator
-
- Tool to create tilesets for textures. Currently only supports well sliced
- tilesets (those with fixed dimensions essentially). In the future, may
- support more freeform tilesets.
-