Remove all existing code for fresh rewrite on new branch

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
YourWishesandClaude Sonnet 5.5 committed 2026-11-07 16:33:26 -06:00
1 parent 3948607b1e
commit 3710924503
1107 files changed
-78347

No files matched your search

-6
View File
@@ -1,6 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
add_subdirectory(run_python)
-624
View File
@@ -1,624 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
"""
Generates DCF + companion DMF files from raw chunk JSON files, or upgrades
legacy DCF files (version 1 or 2) to the current version.
JSON input (assetsraw/maps/<map>/chunks/chunk_X_Y_Z.json):
{
"tiles": [
{ "pos": [x, y, z], "type": <tile_shape_int>, "tile": <uv_tile_int> }
],
"meshes": [
{ "file": "house_5_3.dmf", "pos": [x, y, z] }
],
"entities": [
{ "type": "global", "globalId": <int>, "pos": [x, y, z] },
{ "type": "item", "itemId": <int>, "quantity": <int>, "pos": [x, y, z] }
],
"areas": [
{
"min": [x, y, z], "max": [x, y, z],
"callbackId": <int>, "notify": <int>, "trigger": <int>
}
]
}
Tiles absent from the array default to TILE_SHAPE_NULL. Each (x, y)
column may only have ONE real tile - chunks store one tile per column,
not one per (x, y, z), and that tile records its own local z (see the
version 4 tile format below). If two entries share the same (x, y) with
different z, the later one in the array wins and a warning is printed.
Mesh files are located by searching under assets/meshes/ and referenced by
path from the assets root in the DCF.
<map> is the map's name, up to 9 characters (MAP_NAME_MAX in
src/dusk/rpg/overworld/map.h reserves 1 byte for the null terminator).
"entities" spawns things into the world when this chunk loads. A "global"
entity is spawned via mapSpawnEntity() - globalId indexes
ENTITY_GLOBAL_LIST (src/dusk/rpg/entity/global/entitygloballist.h) and is
deduped automatically if already spawned, so it's safe to declare on a
chunk that streams in more than once. An "item" entity has no persistent
identity - it respawns fresh every time this chunk (re)loads, including
after being picked up, since nothing tracks "already collected" yet.
itemId is a raw ITEM_ID_* value (see src/dusk/rpg/item/item.json for the
name -> id mapping, same convention as the tile "type" ints above).
"areas" declares map trigger regions (see rpg/overworld/maparea.h) owned
by this chunk - they're removed when the chunk unloads and re-added if it
streams back in. callbackId indexes MAP_AREA_CALLBACK_LIST
(src/dusk/rpg/overworld/global/mapareagloballist.h; 0 is reserved and
invalid). notify is bitwise MAP_AREA_NOTIFY_PLAYER(1)|NOTIFY_NPC(2).
trigger is bitwise MAP_TRIGGER_STEP(1)|ENTER(2)|EXIT(4).
Output DCF is derived automatically:
assetsraw/maps/<map>/chunks/chunk_X_Y_Z.json
-> assets/maps/<map>/chunks/X_Y_Z.dcf
Version 5 DCF format (after 8-byte header):
tile_t tiles[CHUNK_WIDTH * CHUNK_HEIGHT] (one per x/y column)
each tile: uint32_t shape, uint8_t z, 3 padding bytes (8 bytes total,
matching the C tile_t struct's layout: { tileshape_t shape; uint8_t z; })
uint8_t meshCount
for each model:
null-terminated string (relative asset path to .json model)
float32[3] (x, y, z offset)
uint8_t entitySpawnCount
for each entity spawn:
uint8_t kind (0 = global entity, 1 = item entity)
uint16_t a (globalId if kind 0, itemId if kind 1)
uint8_t b (unused if kind 0, quantity if kind 1)
int16_t x, y, z (world position, 3 fields)
uint8_t areaSpawnCount
for each area spawn:
int16_t minX, minY, minZ (3 fields)
int16_t maxX, maxY, maxZ (3 fields)
uint16_t callbackId
uint8_t notify
uint8_t trigger
DMF format:
Bytes 0-3: DMF\\x00
Bytes 4-7: uint32_t version = 1 (little-endian)
Bytes 8-11: uint32_t vertCount (little-endian)
Bytes 12+: meshvertex_t vertices[vertCount]
Each vertex: uv[2] + pos[3] = 5 x float32 LE = 20 bytes
Usage:
python3 -m tools.asset.chunk # process all assetsraw/maps/*/chunks/*.json
python3 -m tools.asset.chunk <file.json> # process one JSON file
python3 -m tools.asset.chunk <file.dcf> # upgrade legacy (v1/v2) DCF in-place
"""
import argparse
import json
import math
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')
CHUNK_WIDTH = 16
CHUNK_HEIGHT = 16
CHUNK_DEPTH = 8
# One tile per (x, y) column - the column's local Z (0..CHUNK_DEPTH-1) is
# carried on the tile itself instead of storing a tile per (x, y, z).
CHUNK_TILE_COUNT = CHUNK_WIDTH * CHUNK_HEIGHT # 256
# World-space Z distance of one Z-layer/story, in world-float space.
# Must match WORLD_LAYER_HEIGHT in src/dusk/rpg/overworld/worldpos.h.
WORLD_LAYER_HEIGHT = 1.0 / math.sqrt(2)
CHUNK_MESH_COUNT_MAX = 10
CHUNK_MESH_NAME_MAX = 64
CHUNK_ENTITY_SPAWN_COUNT_MAX = 8
CHUNK_AREA_COUNT_MAX = 4
# Matches MAP_NAME_MAX in src/dusk/rpg/overworld/map.h (buffer size,
# including the null terminator - so map names are at most 9 characters).
MAP_NAME_MAX = 10
ENTITY_SPAWN_KIND_GLOBAL = 0
ENTITY_SPAWN_KIND_ITEM = 1
# Matches sizeof(tile_t) on the C side: uint32_t shape + uint8_t z, padded
# to 8 bytes ({ tileshape_t shape; uint8_t z; } with 4-byte enum alignment).
TILE_STRUCT_FORMAT = '<IB3x'
TILE_SIZE = struct.calcsize(TILE_STRUCT_FORMAT)
VERTEX_SIZE = 20
# Legacy (v1/v2) DCFs stored one uint32 shape per (x, y, z) triple across
# the full 3D grid, at whatever CHUNK_DEPTH was in effect when they were
# written (4). Frozen here, independent of the live CHUNK_DEPTH above, so
# a future depth change can't corrupt parsing of genuinely old files.
_LEGACY_CHUNK_DEPTH = 4
_LEGACY_TILE_SIZE = 4
_LEGACY_CHUNK_TILE_COUNT = CHUNK_WIDTH * CHUNK_HEIGHT * _LEGACY_CHUNK_DEPTH
TILE_SHAPE_NULL = 0
TILE_SHAPE_GROUND = 1
TILE_SHAPE_RAMP_NORTH = 2
TILE_SHAPE_RAMP_EAST = 3
TILE_SHAPE_RAMP_SOUTH = 4
TILE_SHAPE_RAMP_WEST = 5
TILE_SHAPE_RAMP_NORTHEAST = 6
TILE_SHAPE_RAMP_NORTHWEST = 7
TILE_SHAPE_RAMP_SOUTHEAST = 8
TILE_SHAPE_RAMP_SOUTHWEST = 9
TILE_SHAPE_RAMP_NORTHEAST_INNER = 10
TILE_SHAPE_RAMP_NORTHWEST_INNER = 11
TILE_SHAPE_RAMP_SOUTHEAST_INNER = 12
TILE_SHAPE_RAMP_SOUTHWEST_INNER = 13
FILE_MAGIC = b'DCF'
DMF_MAGIC = b'DMF\x00'
VERSION_OUT = 5
DMF_VERSION = 1
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def tile_index(x, y):
return x + y * CHUNK_WIDTH
def find_model(filename):
"""Search ASSETS_DIR/models/ recursively for filename.
Returns the path relative to ASSETS_DIR (forward slashes), or None."""
models_root = os.path.join(ASSETS_DIR, 'models')
for dirpath, _, filenames in os.walk(models_root):
if filename in filenames:
rel = os.path.relpath(
os.path.join(dirpath, filename), ASSETS_DIR
)
return rel.replace('\\', '/')
return None
def write_model_json(path, mesh_rel, texture_rel, color):
"""Write a model JSON descriptor file."""
obj = {'mesh': mesh_rel, 'color': color}
if texture_rel:
obj['texture'] = texture_rel
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w') as f:
json.dump(obj, f, indent=2)
print(f' Wrote model JSON {path}')
def chunk_map_name(json_path):
"""assetsraw/maps/<map>/chunks/chunk_X_Y_Z.json -> <map>
Raises ValueError if json_path isn't under that structure, or if <map>
is longer than MAP_NAME_MAX - 1 characters."""
rel = os.path.relpath(os.path.abspath(json_path), ASSETSRAW_DIR)
parts = rel.split(os.sep)
if len(parts) != 4 or parts[0] != 'maps' or parts[2] != 'chunks':
raise ValueError(
f"Chunk JSON must live at assetsraw/maps/<map>/chunks/*.json, got: "
f"{json_path}"
)
map_name = parts[1]
if len(map_name) > MAP_NAME_MAX - 1:
raise ValueError(
f"Map name too long (> {MAP_NAME_MAX - 1} characters): {map_name}"
)
return map_name
def derive_dcf_path(json_path):
"""assetsraw/maps/<map>/chunks/chunk_X_Y_Z.json
-> assets/maps/<map>/chunks/X_Y_Z.dcf"""
base = os.path.splitext(os.path.basename(json_path))[0]
if base.startswith('chunk_'):
base = base[len('chunk_'):]
rel_dir = os.path.relpath(os.path.dirname(os.path.abspath(json_path)), ASSETSRAW_DIR)
return os.path.join(ASSETS_DIR, rel_dir, base + '.dcf')
def write_dmf(path, vertex_bytes):
vert_count = len(vertex_bytes) // VERTEX_SIZE
buf = bytearray()
buf += DMF_MAGIC
buf += struct.pack('<I', DMF_VERSION)
buf += struct.pack('<I', vert_count)
buf += vertex_bytes
with open(path, 'wb') as f:
f.write(buf)
print(f' Wrote DMF {path}: {vert_count} vertices, {len(buf)} bytes')
def write_dcf(
dcf_path, tiles, mesh_names, mesh_offsets=None,
entity_spawns=None, area_spawns=None
):
"""Write a current-version DCF referencing the given DMF asset paths."""
mesh_count = len(mesh_names)
if mesh_offsets is None:
mesh_offsets = [(0.0, 0.0, 0.0)] * mesh_count
if entity_spawns is None:
entity_spawns = []
if area_spawns is None:
area_spawns = []
if len(entity_spawns) > CHUNK_ENTITY_SPAWN_COUNT_MAX:
raise ValueError(
f"Too many entity spawns ({len(entity_spawns)}) - max "
f"{CHUNK_ENTITY_SPAWN_COUNT_MAX}"
)
if len(area_spawns) > CHUNK_AREA_COUNT_MAX:
raise ValueError(
f"Too many area spawns ({len(area_spawns)}) - max "
f"{CHUNK_AREA_COUNT_MAX}"
)
buf = bytearray()
buf += FILE_MAGIC
buf += b'\x00'
buf += struct.pack('<I', VERSION_OUT)
buf += tiles
buf += struct.pack('<B', mesh_count)
for name, offset in zip(mesh_names, mesh_offsets):
encoded = name.encode('ascii')
if len(encoded) >= CHUNK_MESH_NAME_MAX:
raise ValueError(
f"Mesh name too long (>= {CHUNK_MESH_NAME_MAX}): {name}"
)
buf += encoded + b'\x00'
buf += struct.pack('<3f', offset[0], offset[1], offset[2])
buf += struct.pack('<B', len(entity_spawns))
for spawn in entity_spawns:
kind = spawn['kind']
x, y, z = spawn['pos']
if kind == ENTITY_SPAWN_KIND_GLOBAL:
a, b = spawn['globalId'], 0
else:
a, b = spawn['itemId'], spawn['quantity']
buf += struct.pack('<BHB3h', kind, a, b, x, y, z)
buf += struct.pack('<B', len(area_spawns))
for area in area_spawns:
minX, minY, minZ = area['min']
maxX, maxY, maxZ = area['max']
buf += struct.pack(
'<6hHBB',
minX, minY, minZ, maxX, maxY, maxZ,
area['callbackId'], area['notify'], area['trigger']
)
with open(dcf_path, 'wb') as f:
f.write(buf)
print(
f' Wrote DCF {dcf_path}: '
f'version {VERSION_OUT}, {mesh_count} mesh(es), '
f'{len(entity_spawns)} entity spawn(s), {len(area_spawns)} '
f'area(s), {len(buf)} bytes'
)
# ---------------------------------------------------------------------------
# JSON -> DCF + DMF
# ---------------------------------------------------------------------------
def _tile_quad(u0, u1, v0, v1, fx, fy, sw_z, se_z, ne_z, nw_z):
"""Six vertices (2 CCW triangles) for a quad with per-corner Z heights.
Corner order: SW=(fx,fy), SE=(fx+1,fy), NE=(fx+1,fy+1), NW=(fx,fy+1).
NORTH=+Y, EAST=+X (matches entityDirGetRelative)."""
verts = [
(u0, v0, fx, fy, sw_z),
(u1, v0, fx+1, fy, se_z),
(u1, v1, fx+1, fy+1, ne_z),
(u0, v0, fx, fy, sw_z),
(u1, v1, fx+1, fy+1, ne_z),
(u0, v1, fx, fy+1, nw_z),
]
buf = bytearray()
for v in verts:
buf += struct.pack('<5f', *v)
return bytes(buf)
# Per-shape corner heights as (sw, se, ne, nw) offsets from tile base Z.
# NORTH=+Y, EAST=+X. Cardinal ramps: low face at 0, high face at 1.
# Diagonal outer-corner ramps: only the named corner raised, rest at 0.
# Diagonal inner-corner ramps: only the corner opposite the named one is
# lowered to 0, the rest (including the named corner) stay raised at 1.
_RAMP_CORNERS = {
TILE_SHAPE_GROUND: (0.0, 0.0, 0.0, 0.0),
TILE_SHAPE_RAMP_NORTH: (0.0, 0.0, 1.0, 1.0), # south=low, north=high
TILE_SHAPE_RAMP_SOUTH: (1.0, 1.0, 0.0, 0.0), # south=high, north=low
TILE_SHAPE_RAMP_EAST: (0.0, 1.0, 1.0, 0.0), # west=low, east=high
TILE_SHAPE_RAMP_WEST: (1.0, 0.0, 0.0, 1.0), # west=high, east=low
TILE_SHAPE_RAMP_NORTHEAST: (0.0, 0.0, 1.0, 0.0), # only NE raised
TILE_SHAPE_RAMP_NORTHWEST: (0.0, 0.0, 0.0, 1.0), # only NW raised
TILE_SHAPE_RAMP_SOUTHEAST: (0.0, 1.0, 0.0, 0.0), # only SE raised
TILE_SHAPE_RAMP_SOUTHWEST: (1.0, 0.0, 0.0, 0.0), # only SW raised
TILE_SHAPE_RAMP_NORTHEAST_INNER: (0.0, 1.0, 1.0, 1.0), # only SW low
TILE_SHAPE_RAMP_NORTHWEST_INNER: (1.0, 0.0, 1.0, 1.0), # only SE low
TILE_SHAPE_RAMP_SOUTHEAST_INNER: (1.0, 1.0, 1.0, 0.0), # only NW low
TILE_SHAPE_RAMP_SOUTHWEST_INNER: (1.0, 1.0, 0.0, 1.0), # only NE low
}
def build_terrain_verts(tiles_bytes):
"""Generate quads for every non-null tile, shaped to match tile type."""
buf = bytearray()
for y in range(CHUNK_HEIGHT):
for x in range(CHUNK_WIDTH):
idx = tile_index(x, y)
tile_type, z = struct.unpack_from(
'<IB', tiles_bytes, idx * TILE_SIZE
)
corners = _RAMP_CORNERS.get(tile_type)
if corners is None:
continue
fx, fy, fz = float(x), float(y), float(z)
u0 = x / CHUNK_WIDTH
u1 = (x + 1) / CHUNK_WIDTH
v0 = y / CHUNK_HEIGHT
v1 = (y + 1) / CHUNK_HEIGHT
sw, se, ne, nw = corners
buf += _tile_quad(
u0, u1, v0, v1, fx, fy,
(fz + sw) * WORLD_LAYER_HEIGHT,
(fz + se) * WORLD_LAYER_HEIGHT,
(fz + ne) * WORLD_LAYER_HEIGHT,
(fz + nw) * WORLD_LAYER_HEIGHT
)
return bytes(buf)
def from_json(json_path, dcf_path):
map_name = chunk_map_name(json_path)
with open(json_path) as f:
data = json.load(f)
tiles = bytearray(CHUNK_TILE_COUNT * TILE_SIZE)
column_z = {}
for tile in data.get('tiles', []):
pos = tile['pos']
x, y, z = int(pos[0]), int(pos[1]), int(pos[2])
if (x, y) in column_z and column_z[(x, y)] != z:
print(
f' WARNING: {json_path}: column ({x}, {y}) has tiles at '
f'z={column_z[(x, y)]} and z={z} - only one tile per '
f'column is kept, z={z} wins (last one in the array)'
)
column_z[(x, y)] = z
struct.pack_into(
TILE_STRUCT_FORMAT, tiles, tile_index(x, y) * TILE_SIZE,
int(tile['type']), z
)
terrain_verts = build_terrain_verts(tiles)
model_names = []
mesh_offsets = []
dcf_base = os.path.splitext(os.path.basename(dcf_path))[0]
# Terrain mesh + model (only written if non-empty). Namespaced under the
# owning map so two maps can each have a chunk at the same (x, y, z)
# without their generated terrain assets colliding.
terrain_dir = os.path.join(ASSETS_DIR, 'maps', map_name, 'meshes', 'chunks')
models_dir = os.path.join(ASSETS_DIR, 'maps', map_name, 'models', 'chunks')
os.makedirs(terrain_dir, exist_ok=True)
os.makedirs(models_dir, exist_ok=True)
if terrain_verts:
dmf_name = f'chunk_{dcf_base}_0.dmf'
write_dmf(os.path.join(terrain_dir, dmf_name), terrain_verts)
mesh_rel = f'maps/{map_name}/meshes/chunks/{dmf_name}'
json_name = f'chunk_{dcf_base}_0.json'
write_model_json(
os.path.join(models_dir, json_name),
mesh_rel,
'tiles.png',
[255, 255, 255, 255]
)
model_names.append(f'maps/{map_name}/models/chunks/{json_name}')
mesh_offsets.append((0.0, 0.0, 0.0))
# External mesh references -> look up matching model JSON
for mesh in data.get('meshes', []):
filename = mesh['file']
pos = mesh.get('pos', [0, 0, 0])
model_filename = os.path.splitext(filename)[0] + '.json'
rel = find_model(model_filename)
if rel is None:
raise ValueError(
f"Model not found under assets/models/: {model_filename}"
)
model_names.append(rel)
mesh_offsets.append((float(pos[0]), float(pos[1]), float(pos[2])))
print(f' Resolved {filename} -> {rel}')
entity_spawns = []
for spawn in data.get('entities', []):
pos = tuple(int(v) for v in spawn['pos'])
if spawn['type'] == 'global':
entity_spawns.append({
'kind': ENTITY_SPAWN_KIND_GLOBAL,
'globalId': int(spawn['globalId']),
'pos': pos,
})
elif spawn['type'] == 'item':
entity_spawns.append({
'kind': ENTITY_SPAWN_KIND_ITEM,
'itemId': int(spawn['itemId']),
'quantity': int(spawn['quantity']),
'pos': pos,
})
else:
raise ValueError(f"Unknown entity spawn type: {spawn['type']}")
area_spawns = []
for area in data.get('areas', []):
area_spawns.append({
'min': tuple(int(v) for v in area['min']),
'max': tuple(int(v) for v in area['max']),
'callbackId': int(area['callbackId']),
'notify': int(area['notify']),
'trigger': int(area['trigger']),
})
write_dcf(
dcf_path, bytes(tiles), model_names, mesh_offsets,
entity_spawns, area_spawns
)
def process_json(json_path):
dcf_path = derive_dcf_path(json_path)
os.makedirs(os.path.dirname(dcf_path), exist_ok=True)
print(f"{json_path} -> {dcf_path}")
from_json(json_path, dcf_path)
# ---------------------------------------------------------------------------
# Legacy DCF (v1/v2) -> current version
# ---------------------------------------------------------------------------
def collapse_legacy_tiles(legacy_tiles, path):
"""Collapse the old one-tile-per-(x,y,z) grid into the current
one-tile-per-(x,y) format. If a column has more than one non-null tile
across its Z layers, the highest Z wins and a warning is printed."""
tiles = bytearray(CHUNK_TILE_COUNT * TILE_SIZE)
for y in range(CHUNK_HEIGHT):
for x in range(CHUNK_WIDTH):
found = []
for z in range(_LEGACY_CHUNK_DEPTH):
legacy_idx = x + y * CHUNK_WIDTH + z * CHUNK_WIDTH * CHUNK_HEIGHT
shape = struct.unpack_from(
'<I', legacy_tiles, legacy_idx * _LEGACY_TILE_SIZE
)[0]
if shape != TILE_SHAPE_NULL:
found.append((z, shape))
if not found:
continue
if len(found) > 1:
print(
f' WARNING: {path}: column ({x}, {y}) has tiles at '
f'z={[z for z, _ in found]} - only one tile per column '
f'is kept, z={found[-1][0]} wins (highest Z)'
)
z, shape = found[-1]
struct.pack_into(
TILE_STRUCT_FORMAT, tiles, tile_index(x, y) * TILE_SIZE,
shape, z
)
return bytes(tiles)
def read_legacy_dcf(path):
with open(path, 'rb') as f:
data = f.read()
if data[:3] != FILE_MAGIC:
raise ValueError(f"{path}: not a DCF file")
version = struct.unpack_from('<I', data, 4)[0]
if version not in (1, 2):
raise ValueError(f"{path}: expected version 1 or 2, got {version}")
offset = 8
tiles_size = _LEGACY_CHUNK_TILE_COUNT * _LEGACY_TILE_SIZE
tiles = collapse_legacy_tiles(data[offset:offset + tiles_size], path)
offset += tiles_size
meshes = []
if version == 1:
vert_count = struct.unpack_from('<I', data, offset)[0]
offset += 4
verts = data[offset:offset + vert_count * VERTEX_SIZE]
if len(verts) != vert_count * VERTEX_SIZE:
raise ValueError(f"{path}: truncated vertex data")
if vert_count > 0:
meshes.append(verts)
else:
mesh_count = data[offset]
offset += 1
for _ in range(mesh_count):
vert_count = struct.unpack_from('<I', data, offset)[0]
offset += 4
verts = data[offset:offset + vert_count * VERTEX_SIZE]
if len(verts) != vert_count * VERTEX_SIZE:
raise ValueError(f"{path}: truncated vertex data")
offset += vert_count * VERTEX_SIZE
if vert_count > 0:
meshes.append(verts)
return tiles, meshes
def upgrade_dcf(path):
print(f"Upgrading legacy DCF {path} ...")
tiles, meshes = read_legacy_dcf(path)
print(
f" tiles={CHUNK_TILE_COUNT}, meshes={len(meshes)}, "
f"total_verts={sum(len(m) // VERTEX_SIZE for m in meshes)}"
)
base = os.path.splitext(os.path.basename(path))[0]
terrain_dir = os.path.join(ASSETS_DIR, 'meshes', 'chunks')
os.makedirs(terrain_dir, exist_ok=True)
mesh_names = []
for idx, verts in enumerate(meshes):
dmf_name = f'chunk_{base}_{idx}.dmf'
write_dmf(os.path.join(terrain_dir, dmf_name), verts)
mesh_names.append(f'meshes/chunks/{dmf_name}')
write_dcf(path, tiles, mesh_names)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Generate DCF + companion DMF files from raw chunk JSON, "
"or upgrade legacy DCF files to the current version"
)
parser.add_argument(
'path', nargs='?',
help='Chunk JSON file to process, or legacy DCF file to upgrade. '
'If omitted, processes all assetsraw/maps/*/chunks/*.json'
)
args = parser.parse_args()
if args.path is None:
maps_dir = os.path.join(ASSETSRAW_DIR, 'maps')
if not os.path.isdir(maps_dir):
print(f"No directory found: {maps_dir}")
sys.exit(1)
json_files = []
for map_name in sorted(os.listdir(maps_dir)):
chunks_dir = os.path.join(maps_dir, map_name, 'chunks')
if not os.path.isdir(chunks_dir):
continue
json_files.extend(sorted(
os.path.join(chunks_dir, f)
for f in os.listdir(chunks_dir)
if f.endswith('.json')
))
if not json_files:
print(f"No JSON files found under {maps_dir}")
sys.exit(0)
for p in json_files:
process_json(p)
return
if os.path.splitext(args.path)[1].lower() == '.json':
process_json(args.path)
else:
upgrade_dcf(args.path)
if __name__ == '__main__':
main()
-4
View File
@@ -1,4 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
-63
View File
@@ -1,63 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
"""
Writes DMF (Dusk Mesh Format) files.
DMF format:
Bytes 0-3: DMF\x00 (magic)
Bytes 4-7: uint32_t version = 1 (little-endian)
Bytes 8-11: uint32_t vertCount (little-endian)
Bytes 12+: meshvertex_t vertices[vertCount]
Each vertex is 20 bytes: uv[2] + pos[3] (5 floats, LE)
Usage:
python3 -m tools.asset.dmf <output.dmf> <vertices.bin>
Reads raw vertex bytes from vertices.bin and writes a DMF file.
"""
import argparse
import struct
import os
MAGIC = b'DMF\x00'
VERSION = 1
VERTEX_SIZE = 20
def write_dmf(path, vertex_bytes):
if len(vertex_bytes) % VERTEX_SIZE != 0:
raise ValueError(
f"Vertex data size {len(vertex_bytes)} is not a "
f"multiple of {VERTEX_SIZE}"
)
vert_count = len(vertex_bytes) // VERTEX_SIZE
buf = bytearray()
buf += MAGIC
buf += struct.pack('<I', VERSION)
buf += struct.pack('<I', vert_count)
buf += vertex_bytes
with open(path, 'wb') as f:
f.write(buf)
print(
f'Wrote {path}: {vert_count} vertices, {len(buf)} bytes'
)
return vert_count
def main():
parser = argparse.ArgumentParser(description="Write a DMF (Dusk Mesh Format) file")
parser.add_argument('output', help='Path to output .dmf file')
parser.add_argument('vertices', help='Path to raw vertex bytes file')
args = parser.parse_args()
with open(args.vertices, 'rb') as f:
vertex_bytes = f.read()
write_dmf(args.output, vertex_bytes)
if __name__ == '__main__':
main()
-132
View File
@@ -1,132 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
"""
Packs the assets directory into a dusk.dsk (DSK2) archive.
A small fixed header (magic/version/size/checksum) followed by a single
uncompressed (ZIP_STORED) zip archive of every asset. Uncompressed rather
than DEFLATEd because the bulk of what's packed (audio, images) is
already compressed at the file level, so a second, zip-level DEFLATE
pass buys negligible space back for real CPU cost, and it lets every
entry - not just a hand-picked subset - support the reliable repeated
seeking/re-opening (e.g. locale line-by-line reads, MP3 loop restarts)
that libzip only supports for uncompressed entries.
DSK2 format:
Bytes 0-3: "DSK2" (magic)
Bytes 4-7: uint32_t version = 2 (little-endian)
Bytes 8-11: uint32_t size (byte length of the zip archive that follows)
Bytes 12-15: uint32_t checksum (CRC32 of the zip archive that follows)
Bytes 16+: the zip archive itself
Usage:
python3 -m tools.asset.pack --input <assets_dir> --output <dusk.dsk>
Every file under <assets_dir> is added, using its path relative to
<assets_dir> as the zip entry name.
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. The manifest also carries the
format version, so a manifest written by an older version of this script
(a stale single/dual-archive dusk.dsk sitting from before a format
change) never compares equal and always triggers a fresh pack.
"""
import argparse
import json
import os
import struct
import zipfile
import zlib
import io
MAGIC = b'DSK'
VERSION = 2
HEADER_FORMAT = '<4sIII'
HEADER_SIZE = struct.calcsize(HEADER_FORMAT)
def build_zip_blob(root, relative_paths):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_STORED) as zf:
for relative_path in sorted(relative_paths):
zf.write(os.path.join(root, relative_path), arcname=relative_path)
return buf.getvalue()
def manifest_path(output_path):
return output_path + '.manifest.json'
def compute_manifest(input_dir, relative_paths):
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 {'version': VERSION, '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):
relative_paths = []
for dirpath, _dirnames, filenames in os.walk(input_dir):
for filename in filenames:
full_path = os.path.join(dirpath, filename)
relative_paths.append(
os.path.relpath(full_path, input_dir).replace(os.sep, '/')
)
manifest = compute_manifest(input_dir, relative_paths)
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
blob = build_zip_blob(input_dir, relative_paths)
header = struct.pack(
HEADER_FORMAT, MAGIC, VERSION, len(blob), zlib.crc32(blob) & 0xFFFFFFFF
)
with open(output_path, 'wb') as f:
f.write(header)
f.write(blob)
with open(manifest_file, 'w', encoding='utf-8') as f:
json.dump(manifest, f)
print(
f'Wrote {output_path}: {len(relative_paths)} file(s) '
f'({len(blob)} bytes), {len(header) + len(blob)} bytes total'
)
def main():
parser = argparse.ArgumentParser(
description='Pack the assets directory into a dusk.dsk (DSK2) archive.'
)
parser.add_argument('--input', required=True, help='Assets directory to pack')
parser.add_argument('--output', required=True, help='Path to write dusk.dsk to')
args = parser.parse_args()
pack(args.input, args.output)
if __name__ == '__main__':
main()
-79
View File
@@ -1,79 +0,0 @@
import argparse
import csv
import os
parser = argparse.ArgumentParser(description="Color CSV to .h defines")
parser.add_argument("--csv", required=True, help="Path to color CSV file")
parser.add_argument("--output", required=True, help="Path to output .h file")
args = parser.parse_args()
# Load colors
colors = {}
with open(args.csv, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
if "name" not in reader.fieldnames:
raise ValueError("CSV must have a 'name' column")
for row in reader:
name = row["name"]
r, g, b = row["r"], row["g"], row["b"]
a = row["a"] if "a" in row and row["a"] != "" else "1.0"
for ch in (r, g, b, a):
if not (0.0 <= float(ch) <= 1.0):
raise ValueError(f"Color channel {ch!r} for '{name}' out of range [0.0, 1.0]")
colors[name] = (r, g, b, a)
# Build output
out = [
"#pragma once",
'#include "dusk.h"',
"",
"typedef float_t colorchannelf_t;",
"typedef uint8_t colorchannel8_t;",
"",
"typedef struct { colorchannelf_t r, g, b; } color3f_t;",
"typedef struct { colorchannelf_t r, g, b, a; } color4f_t;",
"typedef struct { colorchannel8_t r, g, b; } color3b_t;",
"typedef struct { colorchannel8_t r, g, b, a; } color4b_t;",
"typedef color4b_t color_t;",
"",
"#define color3f(r, g, b) ((color3f_t){ r, g, b })",
"#define color4f(r, g, b, a) ((color4f_t){ r, g, b, a })",
"#define color3b(r, g, b) ((color3b_t){ r, g, b })",
"#define color4b(r, g, b, a) ((color4b_t){ r, g, b, a })",
"#define color(r, g, b, a) color4b(r, g, b, a)",
"#define colorHex(hex) color4b(((hex >> 24) & 0xFF), ((hex >> 16) & 0xFF), ((hex >> 8) & 0xFF), (hex & 0xFF))",
"",
]
js = []
for name, (r, g, b, a) in colors.items():
r8, g8, b8, a8 = (int(float(ch) * 255) for ch in (r, g, b, a))
macro = "COLOR_" + name.upper()
camel = "".join(p[0].upper() + p[1:].lower() for p in name.split("_"))
out += [
f"// {name}",
f"#define {macro}_4B color4b({r8}, {g8}, {b8}, {a8})",
f"#define {macro}_3B color3b({r8}, {g8}, {b8})",
f"#define {macro}_3F color3f({r}f, {g}f, {b}f)",
f"#define {macro}_4F color4f({r}f, {g}f, {b}f, {a}f)",
f"#define {macro} {macro}_4B",
"",
]
js += [
f"Color.{name.lower()} = function() {{",
f" return new Color({r8}, {g8}, {b8}, {a8});",
"};",
"",
]
out.append("// JS color functions")
out.append("#define COLOR_SCRIPT \\")
for line in "\n".join(js).rstrip().splitlines():
out.append(f' "{line}\\n" \\')
out[-1] = out[-1].rstrip(" \\")
out.append("")
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))
-175
View File
@@ -1,175 +0,0 @@
"use strict";
// DTF – Dusk Texture Format
//
// Header (13 bytes):
// [0–2] "DTF" magic
// [3] 0x01 version
// [4–7] uint32 width (little-endian)
// [8–11] uint32 height (little-endian)
// [12] uint8 format
//
// Formats:
// 0x01 Alpha – 1 byte per pixel (alpha channel only)
// 0x03 RGB – 3 bytes per pixel (no alpha)
// 0x04 RGBA – 4 bytes per pixel
//
// Followed by width × height × bpp bytes of tightly-packed pixel data.
const DTF = (() => {
const MAGIC = [0x44, 0x54, 0x46]; // "DTF"
const VERSION = 0x01;
const FORMAT_ALPHA = 0x01;
const FORMAT_RGB = 0x03;
const FORMAT_RGBA = 0x04;
const HEADER_SIZE = 13;
// Bytes per pixel for each format.
const BPP = {
[FORMAT_ALPHA]: 1,
[FORMAT_RGB]: 3,
[FORMAT_RGBA]: 4,
};
// Encode RGBA source pixels into a DTF ArrayBuffer at the given format.
// When format is FORMAT_ALPHA and redAsAlpha is true, the red channel is
// used as the alpha value instead of the actual alpha channel.
function encode(width, height, rgbaData, format, redAsAlpha) {
if(format === undefined) format = FORMAT_RGBA;
const bpp = BPP[format];
if(bpp === undefined) throw new Error(`Unknown DTF format: 0x${format.toString(16)}`);
const src = rgbaData instanceof Uint8ClampedArray ? rgbaData : new Uint8ClampedArray(rgbaData);
const buf = new ArrayBuffer(HEADER_SIZE + width * height * bpp);
const bytes = new Uint8Array(buf);
const view = new DataView(buf);
bytes[0] = MAGIC[0];
bytes[1] = MAGIC[1];
bytes[2] = MAGIC[2];
bytes[3] = VERSION;
view.setUint32(4, width, true);
view.setUint32(8, height, true);
bytes[12] = format;
let dst = HEADER_SIZE;
for(let i = 0; i < width * height; i++) {
const o = i * 4;
switch (format) {
case FORMAT_ALPHA:
bytes[dst++] = redAsAlpha ? src[o] : src[o + 3];
break;
case FORMAT_RGB:
bytes[dst++] = src[o];
bytes[dst++] = src[o + 1];
bytes[dst++] = src[o + 2];
break;
default: // FORMAT_RGBA
bytes[dst++] = src[o];
bytes[dst++] = src[o + 1];
bytes[dst++] = src[o + 2];
bytes[dst++] = src[o + 3];
}
}
return buf;
}
// Decode a DTF ArrayBuffer. Always returns RGBA pixel data for internal use.
// Alpha-format files decode as {R=0, G=0, B=0, A=alpha} so the alpha channel
// is preserved and toImageData() can display it correctly.
function decode(buffer) {
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
if(bytes.length < HEADER_SIZE) throw new Error("File too small to be a valid DTF");
if(bytes[0] !== MAGIC[0] || bytes[1] !== MAGIC[1] || bytes[2] !== MAGIC[2]) {
throw new Error("Invalid DTF magic bytes – not a DTF file");
}
const version = bytes[3];
if(version !== VERSION) {
throw new Error(`Unsupported DTF version: 0x${version.toString(16).padStart(2, "0")}`);
}
const width = view.getUint32(4, true);
const height = view.getUint32(8, true);
const format = bytes[12];
const bpp = BPP[format];
if(bpp === undefined) {
throw new Error(`Unsupported DTF format: 0x${format.toString(16).padStart(2, "0")}`);
}
const expected = HEADER_SIZE + width * height * bpp;
if(bytes.length < expected) {
throw new Error(`DTF pixel data truncated (expected ${expected} bytes, got ${bytes.length})`);
}
const rgba = new Uint8ClampedArray(width * height * 4);
let src = HEADER_SIZE;
for(let i = 0; i < width * height; i++) {
const o = i * 4;
switch (format) {
case FORMAT_ALPHA:
rgba[o] = rgba[o + 1] = rgba[o + 2] = 0;
rgba[o + 3] = bytes[src++];
break;
case FORMAT_RGB:
rgba[o] = bytes[src++];
rgba[o + 1] = bytes[src++];
rgba[o + 2] = bytes[src++];
rgba[o + 3] = 255;
break;
default: // FORMAT_RGBA
rgba[o] = bytes[src++];
rgba[o + 1] = bytes[src++];
rgba[o + 2] = bytes[src++];
rgba[o + 3] = bytes[src++];
}
}
return { width, height, format, data: rgba };
}
// Convert RGBA source pixels to a display-ready ImageData for the given format.
// Shows exactly how the texture will look after a DTF encode/decode round-trip.
// Alpha → grayscale from alpha channel (or red if redAsAlpha), out-alpha=255
// RGB → discard alpha, fully opaque
// RGBA → pass-through
function toImageData(width, height, rgbaData, format, redAsAlpha) {
const src = rgbaData instanceof Uint8ClampedArray ? rgbaData : new Uint8ClampedArray(rgbaData);
const out = new Uint8ClampedArray(width * height * 4);
for(let i = 0; i < width * height; i++) {
const o = i * 4;
switch (format) {
case FORMAT_ALPHA: {
const v = redAsAlpha ? src[o] : src[o + 3];
out[o] = out[o + 1] = out[o + 2] = v;
out[o + 3] = v; // use value as canvas alpha so background shows through transparent areas
break;
}
case FORMAT_RGB:
out[o] = src[o];
out[o + 1] = src[o + 1];
out[o + 2] = src[o + 2];
out[o + 3] = 255;
break;
default: // FORMAT_RGBA
out[o] = src[o];
out[o + 1] = src[o + 1];
out[o + 2] = src[o + 2];
out[o + 3] = src[o + 3];
}
}
return new ImageData(out, width, height);
}
return Object.freeze({
encode, decode, toImageData,
FORMAT_ALPHA, FORMAT_RGB, FORMAT_RGBA,
VERSION, HEADER_SIZE, BPP,
});
})();
-55
View File
@@ -1,55 +0,0 @@
"use strict";
// DuskPNG – PNG export using pngjs (window.PNG)
// Falls back to canvas.toBlob if pngjs is unavailable.
const DuskPNG = (() => {
function _pngAvailable() {
return typeof PNG !== "undefined" && PNG.sync && typeof PNG.sync.write === "function";
}
// Encode RGBA pixel data into a PNG Buffer via pngjs.
// Returns a Uint8Array (Buffer) if pngjs is available, otherwise null.
function encode(width, height, rgbaData) {
if(!_pngAvailable()) return null;
const png = new PNG({ width, height });
const src = rgbaData instanceof Uint8ClampedArray
? rgbaData
: new Uint8ClampedArray(rgbaData);
for(let i = 0; i < src.length; i++) {
png.data[i] = src[i];
}
return PNG.sync.write(png); // returns a Buffer (Uint8Array subclass)
}
// Trigger a browser download of the RGBA data as a PNG file.
function download(filename, width, height, rgbaData) {
const buf = encode(width, height, rgbaData);
if(buf) {
// pngjs path
const blob = new Blob([buf], { type: "image/png" });
_triggerDownload(URL.createObjectURL(blob), filename);
} else {
// Canvas fallback
const canvas = Object.assign(document.createElement("canvas"), { width, height });
const ctx = canvas.getContext("2d");
const src = rgbaData instanceof Uint8ClampedArray
? rgbaData
: new Uint8ClampedArray(rgbaData);
ctx.putImageData(new ImageData(src, width, height), 0, 0);
canvas.toBlob(blob => _triggerDownload(URL.createObjectURL(blob), filename), "image/png");
}
}
function _triggerDownload(url, filename) {
const a = Object.assign(document.createElement("a"), { href: url, download: filename });
a.click();
URL.revokeObjectURL(url);
}
return Object.freeze({ encode, download });
})();
-42
View File
@@ -1,42 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dusk Editor Tools</title>
<link rel="stylesheet" href="styles/main.css">
</head>
<body>
<header class="site-header">
<span class="logo">Dusk <span>Editor</span></span>
<nav>
<a href="/" class="active">Home</a>
</nav>
</header>
<main class="page">
<section class="hero">
<h1>Editor Tools</h1>
<p>Asset creation and data authoring tools for the Dusk game engine.</p>
</section>
<section class="section">
<div class="section-label">Tools</div>
<div class="tool-grid" id="tool-grid">
<a class="tool-card" href="/texture/">
<div class="tool-name">Texture Creator</div>
<div class="tool-desc">Load PNG, JPG, or DTF files and export to the Dusk Texture Format (.dtf) or PNG.</div>
</a>
<a class="tool-card" href="/texture-padder/">
<div class="tool-name">Texture Padder</div>
<div class="tool-desc">Pad texture dimensions to power-of-two or enforce minimum size requirements.</div>
</a>
</div>
</section>
</main>
</body>
</html>
@@ -1,28 +0,0 @@
/* Component – Background swatches */
.bg-swatches {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.bg-swatch {
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
border: 2px solid transparent;
cursor: pointer;
padding: 0;
flex-shrink: 0;
overflow: hidden;
transition: border-color var(--speed);
}
.bg-swatch:hover {
border-color: var(--text-muted);
}
.bg-swatch.active,
.bg-swatch.active:hover {
border-color: var(--accent);
}
-38
View File
@@ -1,38 +0,0 @@
/* Component – Button */
.btn {
display: block;
width: 100%;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 0.4rem 0.75rem;
font-size: 0.825rem;
cursor: pointer;
text-align: center;
font-family: inherit;
transition: background var(--speed), border-color var(--speed);
}
.btn + .btn {
margin-top: 0.375rem;
}
.btn:hover:not(:disabled) {
border-color: var(--accent-dim);
}
.btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent-dim);
border-color: var(--accent);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent);
}
@@ -1,13 +0,0 @@
/* Component – Control row */
.control-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.control-row label {
color: var(--text-muted);
min-width: 44px;
}
@@ -1,16 +0,0 @@
/* Component – Empty state */
.empty-state {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
border-style: dashed;
padding: 3rem;
text-align: center;
color: var(--text-muted);
}
.empty-state p {
margin-top: 0.5rem;
font-size: 0.875rem;
}
@@ -1,8 +0,0 @@
/* Component – File name */
.file-name {
font-size: 0.775rem;
color: var(--text-muted);
word-break: break-all;
line-height: 1.4;
}
-16
View File
@@ -1,16 +0,0 @@
/* Component – Hero section */
.hero {
padding: var(--gap) 0;
}
.hero h1 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.hero p {
color: var(--text-muted);
max-width: 540px;
}
@@ -1,19 +0,0 @@
/* Component – Info table */
.info-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.info-table td {
padding: 0.15rem 0;
vertical-align: top;
}
.info-table td:first-child {
color: var(--text-muted);
width: 50px;
font-size: 0.75rem;
padding-right: 0.75rem;
}
@@ -1,19 +0,0 @@
/* Component – Load area */
.load-area {
border: 1px dashed var(--border);
border-radius: var(--radius);
padding: 0.6rem;
text-align: center;
cursor: pointer;
font-size: 0.825rem;
color: var(--text-muted);
transition: border-color var(--speed), color var(--speed);
line-height: 1.5;
}
.load-area:hover,
.load-area.drag-over {
border-color: var(--accent-dim);
color: var(--text);
}
@@ -1,47 +0,0 @@
/* Component – Panel form inputs */
.tool-panel select {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 0.25rem 0.4rem;
font-size: 0.875rem;
font-family: inherit;
transition: border-color var(--speed);
cursor: pointer;
}
.tool-panel select:focus {
outline: none;
border-color: var(--accent-dim);
}
.tool-panel input[type="checkbox"] {
accent-color: var(--accent);
cursor: pointer;
flex-shrink: 0;
}
.tool-panel input[type="number"] {
width: 52px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 0.25rem 0.4rem;
font-size: 0.875rem;
font-family: inherit;
transition: border-color var(--speed);
}
.tool-panel input[type="number"]:focus {
outline: none;
border-color: var(--accent-dim);
}
.unit {
color: var(--text-muted);
font-size: 0.875rem;
}
@@ -1,13 +0,0 @@
/* Component – Panel section */
.panel-section {
padding: 0.875rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
border-bottom: 1px solid var(--border);
}
.panel-section:last-child {
border-bottom: none;
}
@@ -1,14 +0,0 @@
/* Component – Preview empty state */
.preview-empty {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-muted);
font-size: 0.875rem;
text-align: center;
white-space: nowrap;
user-select: none;
pointer-events: none;
}
@@ -1,16 +0,0 @@
/* Component – Preview scroll */
.preview-scroll {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.preview-scroll canvas {
image-rendering: pixelated;
image-rendering: crisp-edges;
display: block;
max-width: none;
}
@@ -1,43 +0,0 @@
/* Component – Site header */
.site-header {
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
padding: 0 var(--gap);
display: flex;
align-items: center;
gap: 1rem;
height: 52px;
}
.site-header .logo {
font-weight: 700;
font-size: 1rem;
color: var(--text);
letter-spacing: 0.03em;
}
.site-header .logo span {
color: var(--accent);
}
.site-header nav {
display: flex;
gap: 0.25rem;
margin-left: auto;
}
.site-header nav a {
padding: 0.3rem 0.75rem;
border-radius: var(--radius);
color: var(--text-muted);
font-size: 0.875rem;
transition: background var(--speed), color var(--speed);
}
.site-header nav a:hover,
.site-header nav a.active {
background: var(--bg-raised);
color: var(--text);
text-decoration: none;
}
@@ -1,30 +0,0 @@
/* Component – Tool card */
.tool-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
transition: border-color var(--speed);
text-decoration: none;
color: inherit;
}
.tool-card:hover {
border-color: var(--accent-dim);
text-decoration: none;
}
.tool-card .tool-name {
font-weight: 600;
font-size: 0.95rem;
}
.tool-card .tool-desc {
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.5;
}
@@ -1,12 +0,0 @@
/* Component – Tool panel */
.tool-panel {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
width: 210px;
flex-shrink: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
@@ -1,15 +0,0 @@
/* Component – Tool preview */
.tool-preview {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
flex: 1;
position: relative;
min-height: 300px;
transition: border-color var(--speed);
}
.tool-preview.drag-over {
border-color: var(--accent-dim);
}
@@ -1,24 +0,0 @@
/* Component – Warning list */
.warning-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.warning-list li {
font-size: 0.775rem;
color: #e8a030;
line-height: 1.4;
padding-left: 1.1rem;
position: relative;
}
.warning-list li::before {
content: "!";
position: absolute;
left: 0;
font-weight: 700;
color: #e8a030;
}
@@ -1,5 +0,0 @@
/* Component – Warnings section */
.warnings-section {
border-color: #6b4a00;
}
-19
View File
@@ -1,19 +0,0 @@
/* Elements – Bare HTML */
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
-9
View File
@@ -1,9 +0,0 @@
/* Generic – Reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
[hidden] { display: none !important; }
-32
View File
@@ -1,32 +0,0 @@
/* ITCSS entry point */
@import "settings.css";
@import "generic.css";
@import "elements.css";
/* Objects */
@import "objects/section-label.css";
@import "objects/page.css";
@import "objects/section.css";
@import "objects/tool-grid.css";
@import "objects/tool-workspace.css";
/* Components */
@import "components/site-header.css";
@import "components/hero.css";
@import "components/tool-card.css";
@import "components/empty-state.css";
@import "components/tool-panel.css";
@import "components/panel-section.css";
@import "components/control-row.css";
@import "components/panel-inputs.css";
@import "components/btn.css";
@import "components/bg-swatches.css";
@import "components/tool-preview.css";
@import "components/preview-scroll.css";
@import "components/preview-empty.css";
@import "components/load-area.css";
@import "components/file-name.css";
@import "components/info-table.css";
@import "components/warnings-section.css";
@import "components/warning-list.css";
-7
View File
@@ -1,7 +0,0 @@
/* Object – Page container */
.page {
max-width: 1400px;
margin: 0 auto;
padding: var(--gap);
}
@@ -1,13 +0,0 @@
/* Object – Section label */
.section-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.section > .section-label {
margin-bottom: 1rem;
}
-4
View File
@@ -1,4 +0,0 @@
/* Object – Page section */
.section {
margin-top: var(--gap);
}
@@ -1,7 +0,0 @@
/* Object – Tool grid */
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
}
@@ -1,7 +0,0 @@
/* Object – Tool workspace */
.tool-workspace {
margin-top: var(--gap);
display: flex;
gap: 1rem;
}
-17
View File
@@ -1,17 +0,0 @@
:root {
--bg: #121217; /* deep dark base */
--bg-surface: #1a1a22; /* subtle elevation */
--bg-raised: #23232d; /* layered surfaces */
--border: #2e2e3a; /* soft border contrast */
--text: #e6e6f0; /* high readability */
--text-muted: #9a9ab0; /* muted secondary text */
--accent: #8b5cf6; /* vibrant purple */
--accent-dim: #6d46c7; /* deeper purple for hover/active */
--radius: 6px;
--radius-sm: 4px;
--gap: 1.5rem;
--speed: 0.15s;
}
-130
View File
@@ -1,130 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Texture Padder – Dusk Editor</title>
<link rel="stylesheet" href="/styles/main.css">
</head>
<body>
<header class="site-header">
<a href="/" class="logo" style="text-decoration:none;color:inherit">Dusk <span>Editor</span></a>
<nav>
<a href="/">Home</a>
<a href="/texture-padder/" class="active">Texture Padder</a>
</nav>
</header>
<main class="page">
<div class="hero">
<h1>Texture Padder</h1>
<p>Load an image or .dtf file and pad its dimensions to meet power-of-two or minimum size requirements.</p>
</div>
<div class="tool-workspace">
<!-- ─── Sidebar ────────────────────────────────────────────────────── -->
<aside class="tool-panel">
<section class="panel-section">
<div class="section-label">Load</div>
<label class="load-area" id="load-label">
<input type="file" id="file-input" accept=".png,.jpg,.jpeg,.gif,.bmp,.webp,.dtf" style="display:none">
Click or drop a file<br>
<small>PNG · JPG · GIF · BMP · WebP · DTF</small>
</label>
<div class="file-name" id="file-name">No file loaded</div>
</section>
<section class="panel-section">
<div class="section-label">Padding</div>
<div class="control-row">
<input type="checkbox" id="chk-pad-width-pow2" checked>
<label for="chk-pad-width-pow2">Pad width to power of two</label>
</div>
<div class="control-row">
<input type="checkbox" id="chk-pad-height-pow2" checked>
<label for="chk-pad-height-pow2">Pad height to power of two</label>
</div>
<div class="control-row">
<input type="checkbox" id="chk-min-width-4" checked>
<label for="chk-min-width-4">Ensure minimum width (4 px)</label>
</div>
<div class="control-row">
<input type="checkbox" id="chk-min-height-4" checked>
<label for="chk-min-height-4">Ensure minimum height (4 px)</label>
</div>
</section>
<section class="panel-section">
<div class="section-label">Preview</div>
<div class="control-row">
<label for="scale-input">Scale</label>
<input type="number" id="scale-input" min="1" max="10" value="1">
<span class="unit">×</span>
</div>
<div class="section-label">Background</div>
<div class="bg-swatches" id="bg-swatches">
<button class="bg-swatch active" data-bg="grid" title="Transparent (grid)">
<svg width="26" height="26" viewBox="0 0 26 26" xmlns="http://www.w3.org/2000/svg">
<rect width="13" height="13" fill="#c8c8c8"/>
<rect x="13" width="13" height="13" fill="#fff"/>
<rect y="13" width="13" height="13" fill="#fff"/>
<rect x="13" y="13" width="13" height="13" fill="#c8c8c8"/>
</svg>
</button>
<button class="bg-swatch" data-bg="#ffffff" title="White" style="background:#ffffff"></button>
<button class="bg-swatch" data-bg="#000000" title="Black" style="background:#000000"></button>
<button class="bg-swatch" data-bg="#ff00ff" title="Magenta" style="background:#ff00ff"></button>
<button class="bg-swatch" data-bg="#00ff00" title="Green" style="background:#00ff00"></button>
<button class="bg-swatch" data-bg="#ff0000" title="Red" style="background:#ff0000"></button>
<button class="bg-swatch" data-bg="#0000ff" title="Blue" style="background:#0000ff"></button>
<button class="bg-swatch" data-bg="#ffff00" title="Yellow" style="background:#ffff00"></button>
</div>
</section>
<section class="panel-section" id="info-section" hidden>
<div class="section-label">Info</div>
<table class="info-table">
<tr><td>File</td><td id="info-filename">N/A</td></tr>
<tr><td>Input</td><td id="info-input-size">N/A</td></tr>
<tr><td>Output</td><td id="info-output-size">N/A</td></tr>
<tr><td>DTF</td><td id="info-dtf-size">N/A</td></tr>
</table>
</section>
<section class="panel-section">
<div class="section-label">Export</div>
<select id="format-select">
<option value="1">Alpha (0x01)</option>
<option value="3">RGB (0x03)</option>
<option value="4" selected>RGBA (0x04)</option>
</select>
<button class="btn btn-primary" id="btn-dtf" disabled>Download .dtf</button>
<button class="btn" id="btn-png" disabled>Download .png</button>
</section>
</aside>
<!-- ─── Preview ───────────────────────────────────────────────────── -->
<div class="tool-preview" id="preview-area">
<div class="preview-empty" id="preview-empty">Load an image or .dtf file to get started</div>
<div class="preview-scroll" id="preview-scroll" hidden>
<canvas id="canvas"></canvas>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/browser.js"></script>
<script src="/common/dtf.js"></script>
<script src="/common/png.js"></script>
<script src="/texture-padder/texture-padder.js"></script>
</body>
</html>
@@ -1,275 +0,0 @@
"use strict";
// ─── Helpers ─────────────────────────────────────────────────────────────────
// Returns the smallest power of two >= n.
function nextPow2(n) {
if(n <= 0) return 1;
let p = 1;
while (p < n) p <<= 1;
return p;
}
// ─── State ───────────────────────────────────────────────────────────────────
const state = {
pixels: null, // Uint8ClampedArray RGBA at original resolution
width: 0,
height: 0,
scale: 1,
bg: "grid",
padWidthPow2: true,
padHeightPow2: true,
minWidth4: true,
minHeight4: true,
format: DTF.FORMAT_RGBA,
filename: "texture",
};
// ─── 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 infoInputSize = document.getElementById("info-input-size");
const infoOutputSize = document.getElementById("info-output-size");
const formatSelect = document.getElementById("format-select");
const btnDtf = document.getElementById("btn-dtf");
const btnPng = document.getElementById("btn-png");
const infoDtfSize = document.getElementById("info-dtf-size");
const chkPadWidthPow2 = document.getElementById("chk-pad-width-pow2");
const chkPadHeightPow2 = document.getElementById("chk-pad-height-pow2");
const chkMinWidth4 = document.getElementById("chk-min-width-4");
const chkMinHeight4 = document.getElementById("chk-min-height-4");
// ─── Padding logic ───────────────────────────────────────────────────────────
function computeOutputSize() {
let outW = state.width;
let outH = state.height;
// Apply minimum-size constraints first so pow2 rounding accounts for them.
if(state.minWidth4 && outW < 4) outW = 4;
if(state.minHeight4 && outH < 4) outH = 4;
if(state.padWidthPow2) outW = nextPow2(outW);
if(state.padHeightPow2) outH = nextPow2(outH);
return { outW, outH };
}
function buildPaddedPixels() {
const { outW, outH } = computeOutputSize();
const src = state.pixels;
// Zero-filled Uint8ClampedArray = fully transparent black padding.
const out = new Uint8ClampedArray(outW * outH * 4);
for(let y = 0; y < state.height; y++) {
for(let x = 0; x < state.width; x++) {
const si = (y * state.width + x) * 4;
const di = (y * outW + x) * 4;
out[di] = src[si];
out[di + 1] = src[si + 1];
out[di + 2] = src[si + 2];
out[di + 3] = src[si + 3];
}
}
return { pixels: out, width: outW, height: outH };
}
// ─── 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 } = buildPaddedPixels();
const cw = width * state.scale;
const ch = height * state.scale;
canvas.width = cw;
canvas.height = ch;
if(state.bg === "grid") {
drawCheckerboard(cw, ch);
} else {
ctx.fillStyle = state.bg;
ctx.fillRect(0, 0, cw, ch);
}
const src = Object.assign(document.createElement("canvas"), { width, height });
src.getContext("2d").putImageData(new ImageData(pixels, width, height), 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(src, 0, 0, cw, ch);
}
// ─── Info update ─────────────────────────────────────────────────────────────
function updateInfo() {
if(!state.pixels) return;
const { outW, outH } = computeOutputSize();
const bytes = DTF.HEADER_SIZE + outW * outH * DTF.BPP[state.format];
infoInputSize.textContent = `${state.width} × ${state.height}`;
infoOutputSize.textContent = `${outW} × ${outH}`;
infoDtfSize.textContent = `${(bytes / 1024).toFixed(1)} KB`;
}
// ─── 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);
URL.revokeObjectURL(url);
};
img.onerror = () => {
alert("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, data } = DTF.decode(e.target.result);
applyImageData(width, height, data, file.name);
} catch (err) {
alert(`Failed to load DTF: ${err.message}`);
}
};
reader.readAsArrayBuffer(file);
}
function applyImageData(width, height, data, filename) {
state.pixels = new Uint8ClampedArray(data);
state.width = width;
state.height = height;
state.filename = filename.replace(/\.[^/.]+$/, "");
fileNameEl.textContent = filename;
infoFilename.textContent = filename;
infoSection.hidden = false;
updateInfo();
previewEmpty.hidden = true;
previewScroll.hidden = false;
btnDtf.disabled = false;
btnPng.disabled = false;
render();
}
function handleFile(file) {
if(!file) return;
if(file.name.toLowerCase().endsWith(".dtf")) {
loadDTF(file);
} else {
loadStandardImage(file);
}
}
// ─── Export ───────────────────────────────────────────────────────────────────
function exportDTF() {
if(!state.pixels) return;
const { pixels, width, height } = buildPaddedPixels();
const buf = DTF.encode(width, height, pixels, state.format);
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;
const { pixels, width, height } = buildPaddedPixels();
DuskPNG.download(`${state.filename}.png`, width, height, 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();
});
formatSelect.addEventListener("change", () => {
state.format = parseInt(formatSelect.value, 10);
updateInfo();
});
btnDtf.addEventListener("click", exportDTF);
btnPng.addEventListener("click", exportPNG);
chkPadWidthPow2.addEventListener("change", () => { state.padWidthPow2 = chkPadWidthPow2.checked; updateInfo(); render(); });
chkPadHeightPow2.addEventListener("change", () => { state.padHeightPow2 = chkPadHeightPow2.checked; updateInfo(); render(); });
chkMinWidth4.addEventListener("change", () => { state.minWidth4 = chkMinWidth4.checked; updateInfo(); render(); });
chkMinHeight4.addEventListener("change", () => { state.minHeight4 = chkMinHeight4.checked; updateInfo(); render(); });
// Drag-and-drop
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);
-124
View File
@@ -1,124 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Texture Creator – Dusk Editor</title>
<link rel="stylesheet" href="/styles/main.css">
</head>
<body>
<header class="site-header">
<a href="/" class="logo" style="text-decoration:none;color:inherit">Dusk <span>Editor</span></a>
<nav>
<a href="/">Home</a>
<a href="/texture/" class="active">Texture Creator</a>
</nav>
</header>
<main class="page">
<div class="hero">
<h1>Texture Creator</h1>
<p>Load an image or existing .dtf file, then export as Dusk Texture Format (.dtf) or PNG.</p>
</div>
<div class="tool-workspace">
<!-- ─── Sidebar ────────────────────────────────────────────────────── -->
<aside class="tool-panel">
<section class="panel-section">
<div class="section-label">Load</div>
<label class="load-area" id="load-label">
<input type="file" id="file-input" accept=".png,.jpg,.jpeg,.gif,.bmp,.webp,.dtf" style="display:none">
Click or drop a file<br>
<small>PNG · JPG · GIF · BMP · WebP · DTF</small>
</label>
<div class="file-name" id="file-name">No file loaded</div>
</section>
<section class="panel-section">
<div class="section-label">Format</div>
<select id="format-select">
<option value="1">Alpha (0x01)</option>
<option value="3">RGB (0x03)</option>
<option value="4" selected>RGBA (0x04)</option>
</select>
<div class="control-row" id="red-as-alpha-row" hidden>
<input type="checkbox" id="red-as-alpha">
<label for="red-as-alpha">Red channel as alpha</label>
</div>
</section>
<section class="panel-section">
<div class="section-label">Preview</div>
<div class="control-row">
<label for="scale-input">Scale</label>
<input type="number" id="scale-input" min="1" max="10" value="1">
<span class="unit">×</span>
</div>
<div class="section-label">Background</div>
<div class="bg-swatches" id="bg-swatches">
<!-- Transparent grid -->
<button class="bg-swatch active" data-bg="grid" title="Transparent (grid)">
<svg width="26" height="26" viewBox="0 0 26 26" xmlns="http://www.w3.org/2000/svg">
<rect width="13" height="13" fill="#c8c8c8"/>
<rect x="13" width="13" height="13" fill="#fff"/>
<rect y="13" width="13" height="13" fill="#fff"/>
<rect x="13" y="13" width="13" height="13" fill="#c8c8c8"/>
</svg>
</button>
<button class="bg-swatch" data-bg="#ffffff" title="White" style="background:#ffffff"></button>
<button class="bg-swatch" data-bg="#000000" title="Black" style="background:#000000"></button>
<button class="bg-swatch" data-bg="#ff00ff" title="Magenta" style="background:#ff00ff"></button>
<button class="bg-swatch" data-bg="#00ff00" title="Green" style="background:#00ff00"></button>
<button class="bg-swatch" data-bg="#ff0000" title="Red" style="background:#ff0000"></button>
<button class="bg-swatch" data-bg="#0000ff" title="Blue" style="background:#0000ff"></button>
<button class="bg-swatch" data-bg="#ffff00" title="Yellow" style="background:#ffff00"></button>
</div>
</section>
<section class="panel-section" id="info-section" hidden>
<div class="section-label">Info</div>
<table class="info-table">
<tr><td>File</td><td id="info-filename">N/A</td></tr>
<tr><td>Size</td><td id="info-size">N/A</td></tr>
<tr><td>Format</td><td id="info-format">N/A</td></tr>
<tr><td>DTF</td><td id="info-dtf-size">N/A</td></tr>
</table>
</section>
<section class="panel-section warnings-section" id="warnings-section" hidden>
<div class="section-label">Warnings</div>
<ul class="warning-list" id="warning-list"></ul>
</section>
<section class="panel-section">
<div class="section-label">Export</div>
<button class="btn btn-primary" id="btn-dtf" disabled>Download .dtf</button>
<button class="btn" id="btn-png" disabled>Download .png</button>
</section>
</aside>
<!-- ─── Preview ───────────────────────────────────────────────────── -->
<div class="tool-preview" id="preview-area">
<div class="preview-empty" id="preview-empty">Load an image or .dtf file to get started</div>
<div class="preview-scroll" id="preview-scroll" hidden>
<canvas id="canvas"></canvas>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/browser.js"></script>
<script src="/common/dtf.js"></script>
<script src="/common/png.js"></script>
<script src="/texture/texture.js"></script>
</body>
</html>
-279
View File
@@ -1,279 +0,0 @@
"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;
redAsAlphaRow.hidden = format !== DTF.FORMAT_ALPHA;
}
// 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);
-58
View File
@@ -1,58 +0,0 @@
import argparse
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 id_enum(name):
return "INPUT_ACTION_" + name.upper()
# 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 ValueError("CSV must have an 'id' column")
for row in reader:
input_id = row["id"]
if input_id not in input_ids:
input_ids.append(input_id)
# Assign enum values
id_values = {input_id: i + 1 for i, input_id in enumerate(input_ids)}
# 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" \"var {id_enum(input_id)} = {id_values[input_id]};\\n\"")
out += [";", ""]
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))
-137
View File
@@ -1,137 +0,0 @@
#!/usr/bin/env python3
"""
Builds GameCube / Wii disc images (NTSC-J, NTSC-U, PAL) using xorrisofs /
mkisofs + Swiss-GC system-area headers.
The Swiss-GC headers contain a working GCN apploader that boots any DOL via
the El Torito catalog - no custom PPC assembly needed.
Headers are cached in <script_dir>/../buildtools/iso/ and downloaded from the
Swiss-GC repository on first use.
Disc layout (produced by xorrisofs):
sectors 0-15 system area (boot.bin + bi2 + apploader, from Swiss header)
sector 16 ISO 9660 PVD
sector 17 El Torito Boot Record
... directory records, file data
Dusk.dol (El Torito boot image - loaded by apploader)
dusk.dsk (asset archive - found at runtime via ISO 9660)
"""
import argparse
import os
import shutil
import struct
import subprocess
import sys
import urllib.request
SWISS_BASE = (
"https://raw.githubusercontent.com/emukidid/swiss-gc/"
"6f78e41940d2699b69ec62636d57411ae2857f1e/buildtools/iso/"
)
REGIONS = [
('j', 'J', 'NTSC-J', 0),
('u', 'E', 'NTSC-U', 1),
('e', 'P', 'PAL', 2),
]
def _buildtools_iso_dir():
here = os.path.dirname(os.path.abspath(__file__))
return os.path.join(here, '..', 'buildtools', 'iso')
def _get_hdr(region_suffix):
"""Return the raw bytes of the Swiss system-area header for *region_suffix*
('j', 'u', or 'e'), downloading it if necessary."""
cache_dir = _buildtools_iso_dir()
os.makedirs(cache_dir, exist_ok=True)
path = os.path.join(cache_dir, f'eltorito-{region_suffix}.hdr')
if not os.path.exists(path):
url = SWISS_BASE + f'eltorito-{region_suffix}.hdr'
print(f'Downloading {url} ...', flush=True)
with urllib.request.urlopen(url) as resp:
data = resp.read()
with open(path, 'wb') as f:
f.write(data)
with open(path, 'rb') as f:
return f.read()
def _patch_hdr(hdr_data, game_id, title):
"""Patch game ID (bytes 0-5) and title (bytes 0x20-0x3FF) into the
system-area header. Everything else (magic, bi2, apploader) is unchanged."""
hdr = bytearray(hdr_data)
hdr[0:6] = game_id.encode('ascii')
tb = title.encode('ascii')[:0x3E0]
hdr[0x20:0x20 + len(tb)] = tb
# zero remaining title bytes so stale Swiss title doesn't bleed through
hdr[0x20 + len(tb):0x400] = bytes(0x3E0 - len(tb))
return bytes(hdr)
def _find_mkisofs():
for tool in ('xorrisofs', 'mkisofs'):
if shutil.which(tool):
return tool
sys.exit('Error: neither xorrisofs nor mkisofs found. '
'Install xorriso (apt install xorriso) or mkisofs.')
def _build_iso(platform, game_id, title, region_suffix, dol_path, dsk_path,
out_path):
hdr_data = _patch_hdr(_get_hdr(region_suffix), game_id, title)
# Write patched header to a temp file alongside the output so we have a
# stable path (avoids tempfile cleanup races on some systems).
hdr_tmp = out_path + '.hdr.tmp'
try:
with open(hdr_tmp, 'wb') as f:
f.write(hdr_data)
mkisofs = _find_mkisofs()
dol_name = os.path.basename(dol_path)
cmd = [
mkisofs,
'-R', '-J',
'-G', hdr_tmp,
'-no-emul-boot',
'-eltorito-platform', 'PPC',
'-b', dol_name,
'-o', out_path,
dol_path,
dsk_path,
]
subprocess.run(cmd, check=True)
finally:
if os.path.exists(hdr_tmp):
os.remove(hdr_tmp)
size = os.path.getsize(out_path)
print(f'Created {out_path} ({size:,} bytes)')
def main():
ap = argparse.ArgumentParser(
description='Build GCN/Wii disc images from a DOL and asset file.')
ap.add_argument('platform', choices=['GCN', 'WII'])
ap.add_argument('dol', help='Path to Dusk.dol')
ap.add_argument('dsk', help='Path to dusk.dsk asset archive')
ap.add_argument('title', help='Game title string')
ap.add_argument('build_dir', help='Output directory')
args = ap.parse_args()
id_prefix = 'G' if args.platform == 'GCN' else 'R'
for region_suffix, region_char, region_name, _ in REGIONS:
game_id = f'{id_prefix}DK{region_char}01'
out_path = os.path.join(args.build_dir, f'Dusk-{region_name}.iso')
_build_iso(args.platform, game_id, args.title,
region_suffix, args.dol, args.dsk, out_path)
if __name__ == '__main__':
main()
-14
View File
@@ -1,14 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
function(dusk_run_python CMAKE_TARGET_NAME PYTHON_MODULE)
find_package(Python3 COMPONENTS Interpreter REQUIRED)
add_custom_target(${CMAKE_TARGET_NAME} ALL
WORKING_DIRECTORY ${DUSK_ROOT_DIR}
COMMAND
${Python3_EXECUTABLE} -m ${PYTHON_MODULE} ${ARGN}
)
endfunction()
-23
View File
@@ -1,23 +0,0 @@
#!/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()