358 lines
12 KiB
Python
358 lines
12 KiB
Python
# 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 version 3.
|
|
|
|
JSON input (assetsraw/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] }
|
|
]
|
|
}
|
|
|
|
Tiles absent from the array default to TILE_SHAPE_NULL.
|
|
Mesh files are located by searching under assets/meshes/ and referenced by
|
|
path from the assets root in the DCF.
|
|
|
|
Output DCF is derived automatically:
|
|
assetsraw/chunks/chunk_X_Y_Z.json -> assets/chunks/X_Y_Z.dcf
|
|
|
|
Version 3 DCF format (after 8-byte header + tiles):
|
|
uint8_t meshCount
|
|
for each mesh:
|
|
null-terminated string (relative asset path to .dmf)
|
|
float32[3] (x, y, z offset)
|
|
|
|
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/chunks/*.json
|
|
python3 -m tools.asset.chunk <file.json> # process one JSON file
|
|
python3 -m tools.asset.chunk <file.dcf> # upgrade legacy DCF in-place
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import struct
|
|
import sys
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
PROJECT_ROOT = os.path.normpath(os.path.join(_HERE, '..', '..', '..'))
|
|
ASSETSRAW_DIR = os.path.join(PROJECT_ROOT, 'assetsraw')
|
|
ASSETS_DIR = os.path.join(PROJECT_ROOT, 'assets')
|
|
|
|
CHUNK_WIDTH = 16
|
|
CHUNK_HEIGHT = 16
|
|
CHUNK_DEPTH = 4
|
|
CHUNK_TILE_COUNT = CHUNK_WIDTH * CHUNK_HEIGHT * CHUNK_DEPTH # 1024
|
|
|
|
CHUNK_MESH_COUNT_MAX = 10
|
|
CHUNK_MESH_NAME_MAX = 64
|
|
|
|
TILE_SIZE = 4
|
|
VERTEX_SIZE = 20
|
|
|
|
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
|
|
|
|
FILE_MAGIC = b'DCF'
|
|
DMF_MAGIC = b'DMF\x00'
|
|
VERSION_OUT = 3
|
|
DMF_VERSION = 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def tile_index(x, y, z):
|
|
return x + y * CHUNK_WIDTH + z * CHUNK_WIDTH * CHUNK_HEIGHT
|
|
|
|
|
|
def find_mesh(filename):
|
|
"""Search ASSETS_DIR/meshes/ recursively for filename.
|
|
Returns the path relative to ASSETS_DIR (forward slashes), or None."""
|
|
meshes_root = os.path.join(ASSETS_DIR, 'meshes')
|
|
for dirpath, _, filenames in os.walk(meshes_root):
|
|
if filename in filenames:
|
|
rel = os.path.relpath(
|
|
os.path.join(dirpath, filename), ASSETS_DIR
|
|
)
|
|
return rel.replace('\\', '/')
|
|
return None
|
|
|
|
|
|
def derive_dcf_path(json_path):
|
|
"""assetsraw/chunks/chunk_X_Y_Z.json -> assets/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_v3(dcf_path, tiles, mesh_names, mesh_offsets=None):
|
|
"""Write a v3 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
|
|
|
|
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])
|
|
with open(dcf_path, 'wb') as f:
|
|
f.write(buf)
|
|
print(
|
|
f' Wrote DCF {dcf_path}: '
|
|
f'version {VERSION_OUT}, {mesh_count} mesh(es), {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 ramps: opposite corner at 0/1, adjacent corners at 0.5.
|
|
_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
|
|
}
|
|
|
|
|
|
def build_terrain_verts(tiles_bytes):
|
|
"""Generate quads for every non-null tile, shaped to match tile type."""
|
|
buf = bytearray()
|
|
for z in range(CHUNK_DEPTH):
|
|
for y in range(CHUNK_HEIGHT):
|
|
for x in range(CHUNK_WIDTH):
|
|
idx = tile_index(x, y, z)
|
|
tile_type = struct.unpack_from(
|
|
'<I', tiles_bytes, idx * TILE_SIZE
|
|
)[0]
|
|
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, fz + se, fz + ne, fz + nw
|
|
)
|
|
return bytes(buf)
|
|
|
|
|
|
def from_json(json_path, dcf_path):
|
|
with open(json_path) as f:
|
|
data = json.load(f)
|
|
|
|
tiles = bytearray(CHUNK_TILE_COUNT * TILE_SIZE)
|
|
for tile in data.get('tiles', []):
|
|
pos = tile['pos']
|
|
x, y, z = int(pos[0]), int(pos[1]), int(pos[2])
|
|
struct.pack_into(
|
|
'<I', tiles, tile_index(x, y, z) * TILE_SIZE, int(tile['type'])
|
|
)
|
|
|
|
terrain_verts = build_terrain_verts(tiles)
|
|
mesh_names = []
|
|
mesh_offsets = []
|
|
|
|
# Terrain mesh (only written if non-empty)
|
|
dcf_base = os.path.splitext(os.path.basename(dcf_path))[0]
|
|
terrain_dir = os.path.join(ASSETS_DIR, 'meshes', 'chunks')
|
|
os.makedirs(terrain_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_names.append(f'meshes/chunks/{dmf_name}')
|
|
mesh_offsets.append((0.0, 0.0, 0.0))
|
|
|
|
# External mesh references
|
|
for mesh in data.get('meshes', []):
|
|
filename = mesh['file']
|
|
pos = mesh.get('pos', [0, 0, 0])
|
|
rel = find_mesh(filename)
|
|
if rel is None:
|
|
raise ValueError(f"Mesh not found under assets/meshes/: {filename}")
|
|
mesh_names.append(rel)
|
|
mesh_offsets.append((float(pos[0]), float(pos[1]), float(pos[2])))
|
|
print(f' Resolved {filename} -> {rel}')
|
|
|
|
write_v3(dcf_path, bytes(tiles), mesh_names, mesh_offsets)
|
|
|
|
|
|
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) -> v3
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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 = CHUNK_TILE_COUNT * TILE_SIZE
|
|
tiles = data[offset:offset + tiles_size]
|
|
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_v3(path, tiles, mesh_names)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
|
|
if not args:
|
|
chunks_dir = os.path.join(ASSETSRAW_DIR, 'chunks')
|
|
if not os.path.isdir(chunks_dir):
|
|
print(f"No directory found: {chunks_dir}")
|
|
sys.exit(1)
|
|
json_files = 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 in {chunks_dir}")
|
|
sys.exit(0)
|
|
for p in json_files:
|
|
process_json(p)
|
|
return
|
|
|
|
src = args[0]
|
|
if os.path.splitext(src)[1].lower() == '.json':
|
|
process_json(src)
|
|
else:
|
|
upgrade_dcf(src)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|