Chunks, models, and more

This commit is contained in:
2026-07-01 08:35:01 -05:00
parent a34831aa02
commit a9ab8dc1d8
94 changed files with 941 additions and 130 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_1_1.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_1_2.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_1_3.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_1_4.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_1_5.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_1_6.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_1_7.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_1_8.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_2_1.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_2_2.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_2_3.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_2_4.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_2_5.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_2_6.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_2_7.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_2_8.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_3_1.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_3_2.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_3_3.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_3_4.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_3_5.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_3_6.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_3_7.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_3_8.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_4_1.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_4_2.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_4_3.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_4_4.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_4_5.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_4_6.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_4_7.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_4_8.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_5_1.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_5_2.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_5_3.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_5_4.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_5_5.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_5_6.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_5_7.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_5_8.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_6_1.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_6_2.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_6_3.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_6_4.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_6_5.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_6_6.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_6_7.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_6_8.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_7_1.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_7_2.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_7_3.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_7_4.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_7_5.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_7_6.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_7_7.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_7_8.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_8_1.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_8_2.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_8_3.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_8_4.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_8_5.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_8_6.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_8_7.dmf",
"color": [255, 0, 255, 255]
}
+4
View File
@@ -0,0 +1,4 @@
{
"mesh": "meshes/buildings/house_8_8.dmf",
"color": [255, 0, 255, 255]
}
+10
View File
@@ -0,0 +1,10 @@
{
"mesh": "meshes/chunks/chunk_-1_0_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
+10
View File
@@ -0,0 +1,10 @@
{
"mesh": "meshes/chunks/chunk_0_0_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
+10
View File
@@ -0,0 +1,10 @@
{
"mesh": "meshes/chunks/chunk_0_1_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
+10
View File
@@ -0,0 +1,10 @@
{
"mesh": "meshes/chunks/chunk_1_0_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
+10
View File
@@ -0,0 +1,10 @@
{
"mesh": "meshes/chunks/chunk_1_1_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

+1
View File
@@ -0,0 +1 @@
<p>Hello Homepage</p>
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dusk Map Editor</title>
</head>
<body>
<div id="editor"></div>
</body>
</html>
+224
View File
@@ -0,0 +1,224 @@
"""
Map Editor HTTP Server
Serves editor/client/public/ as static files with smart routing:
/ -> index.html
/page -> page.html or page/index.html
/index -> index.html
API endpoints (all paths relative to project root):
GET /api/ls?path=<dir> directory listing
GET /api/read?path=<file> file contents
POST /api/write?path=<file> write file (JSON body: {"content":"..."})
POST /api/mkdir?path=<dir> create directory
"""
import os
import json
import mimetypes
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs, unquote
from pathlib import Path
PORT = 3000
SCRIPT_DIR = Path(__file__).parent.resolve()
PUBLIC_DIR = SCRIPT_DIR.parent / "client" / "public"
PROJECT_ROOT = SCRIPT_DIR.parent.parent.resolve()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
path = unquote(parsed.path)
if path.startswith("/api/"):
params = parse_qs(parsed.query)
if path == "/api/ls":
self.api_ls(params)
elif path == "/api/read":
self.api_read(params)
else:
self.send_error(404)
else:
self.serve_static(path)
def do_POST(self):
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
if parsed.path == "/api/write":
self.api_write(params)
elif parsed.path == "/api/mkdir":
self.api_mkdir(params)
else:
self.send_error(404)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def resolve_project_path(self, rel):
rel = rel.lstrip("/")
target = (PROJECT_ROOT / rel).resolve()
if not str(target).startswith(str(PROJECT_ROOT)):
return None
return target
def send_json(self, data, code=200):
body = json.dumps(data, indent=2).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", len(body))
self.end_headers()
self.wfile.write(body)
def read_body(self):
length = int(self.headers.get("Content-Length", 0))
return self.rfile.read(length)
# ------------------------------------------------------------------
# API handlers
# ------------------------------------------------------------------
def api_ls(self, params):
rel = params.get("path", [""])[0]
target = self.resolve_project_path(rel)
if target is None:
self.send_json({"error": "invalid path"}, 400)
return
if not target.exists():
self.send_json({"error": "path not found"}, 404)
return
if not target.is_dir():
self.send_json({"error": "not a directory"}, 400)
return
entries = []
for entry in sorted(target.iterdir(), key=lambda e: (e.is_file(), e.name)):
entries.append({
"name": entry.name,
"type": "dir" if entry.is_dir() else "file",
"size": entry.stat().st_size if entry.is_file() else None,
})
self.send_json({
"path": str(target.relative_to(PROJECT_ROOT)),
"entries": entries,
})
def api_read(self, params):
rel = params.get("path", [""])[0]
target = self.resolve_project_path(rel)
if target is None:
self.send_json({"error": "invalid path"}, 400)
return
if not target.exists():
self.send_json({"error": "file not found"}, 404)
return
if not target.is_file():
self.send_json({"error": "not a file"}, 400)
return
try:
content = target.read_text(encoding="utf-8")
self.send_json({
"path": str(target.relative_to(PROJECT_ROOT)),
"content": content,
})
except UnicodeDecodeError:
import base64
content = base64.b64encode(target.read_bytes()).decode("ascii")
self.send_json({
"path": str(target.relative_to(PROJECT_ROOT)),
"content": content,
"encoding": "base64",
})
def api_write(self, params):
rel = params.get("path", [""])[0]
target = self.resolve_project_path(rel)
if target is None:
self.send_json({"error": "invalid path"}, 400)
return
body = self.read_body()
try:
data = json.loads(body)
content = data.get("content", "")
except (json.JSONDecodeError, UnicodeDecodeError):
content = body.decode("utf-8")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
self.send_json({"ok": True, "path": str(target.relative_to(PROJECT_ROOT))})
def api_mkdir(self, params):
rel = params.get("path", [""])[0]
target = self.resolve_project_path(rel)
if target is None:
self.send_json({"error": "invalid path"}, 400)
return
target.mkdir(parents=True, exist_ok=True)
self.send_json({"ok": True, "path": str(target.relative_to(PROJECT_ROOT))})
# ------------------------------------------------------------------
# Static file serving
# ------------------------------------------------------------------
def serve_static(self, path):
clean = path.lstrip("/")
candidates = []
if clean == "" or clean == "index" or clean == "index.html":
candidates = ["index.html"]
else:
candidates.append(clean)
if "." not in Path(clean).name:
candidates.append(clean + ".html")
candidates.append(clean + "/index.html")
for candidate in candidates:
file_path = PUBLIC_DIR / candidate
if file_path.is_file():
self.serve_file(file_path)
return
self.send_error(404)
def serve_file(self, file_path):
mime, _ = mimetypes.guess_type(str(file_path))
mime = mime or "application/octet-stream"
template = PUBLIC_DIR / "template.html"
if (
mime == "text/html"
and file_path.name != "template.html"
and template.is_file()
):
fragment = file_path.read_text(encoding="utf-8")
shell = template.read_text(encoding="utf-8")
content = shell.replace(
'<div id="editor"></div>',
f'<div id="editor">{fragment}</div>',
1,
).encode("utf-8")
else:
content = file_path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", mime)
self.send_header("Content-Length", len(content))
self.end_headers()
self.wfile.write(content)
def log_message(self, fmt, *args):
print(f" {self.address_string()} {fmt % args}")
if __name__ == "__main__":
print(f"Map Editor")
print(f" http://localhost:{PORT}")
print(f" public : {PUBLIC_DIR}")
print(f" project: {PROJECT_ROOT}")
server = HTTPServer(("", PORT), Handler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")"
python3 server/server.py
+19 -9
View File
@@ -411,16 +411,26 @@ errorret_t assetDispose(void) {
assertIsMainThread("Must be called from the main thread.");
threadStop(&ASSET.loadThread);
// Dispose every non-null entry so type-specific dispose callbacks
// (e.g. assetScriptDispose freeing jerry values) run before the
// scripting engine is torn down.
assetentry_t *entry = ASSET.entries;
// Drain-dispose: repeatedly find and dispose zero-ref LOADED entries
// until none remain. This handles dependency chains where an entry
// (e.g. a model) holds refs on child entries (mesh, texture): dispose
// parents first so child ref counts drop to zero, then pick up the
// children on the next pass. Without this, a forward-only scan fails
// when a shared child entry appears before a parent that still holds a
// ref to it.
bool_t any;
do {
if(entry->type != ASSET_LOADER_TYPE_NULL) {
errorChain(assetEntryDispose(entry));
}
entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
any = false;
assetentry_t *e = ASSET.entries;
do {
if(e->type == ASSET_LOADER_TYPE_NULL) { e++; continue; }
if(e->state != ASSET_ENTRY_STATE_LOADED) { e++; continue; }
if(e->refs.count > 0) { e++; continue; }
errorChain(assetEntryDispose(e));
any = true;
e++;
} while(e < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
} while(any);
// Cleanup zip file.
if(ASSET.zip != NULL) {
+6
View File
@@ -16,6 +16,12 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = {
.dispose = assetMeshDispose
},
[ASSET_LOADER_TYPE_MODEL] = {
.loadSync = assetModelLoaderSync,
.loadAsync = assetModelLoaderAsync,
.dispose = assetModelDispose
},
[ASSET_LOADER_TYPE_TEXTURE] = {
.loadSync = assetTextureLoaderSync,
.loadAsync = assetTextureLoaderAsync,
+4
View File
@@ -7,6 +7,7 @@
#pragma once
#include "asset/loader/dmf/assetmeshloader.h"
#include "asset/loader/dmf/assetmodelloader.h"
#include "asset/loader/display/assettextureloader.h"
#include "asset/loader/display/assettilesetloader.h"
#include "asset/loader/locale/assetlocaleloader.h"
@@ -17,6 +18,7 @@ typedef enum {
ASSET_LOADER_TYPE_NULL,
ASSET_LOADER_TYPE_MESH,
ASSET_LOADER_TYPE_MODEL,
ASSET_LOADER_TYPE_TEXTURE,
ASSET_LOADER_TYPE_TILESET,
ASSET_LOADER_TYPE_LOCALE,
@@ -28,6 +30,7 @@ typedef enum {
typedef union {
assetmeshloaderloading_t mesh;
assetmodelloaderloading_t model;
assettextureloaderloading_t texture;
assettilesetloaderloading_t tileset;
assetlocaleloaderloading_t locale;
@@ -37,6 +40,7 @@ typedef union {
typedef union {
assetmeshoutput_t mesh;
assetmodeloutput_t model;
assettextureoutput_t texture;
assettilesetoutput_t tileset;
assetlocaleoutput_t locale;
@@ -100,8 +100,8 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
) {
nameLen++;
}
memoryCopy(out->meshNames[m], data + offset, nameLen);
out->meshNames[m][nameLen] = '\0';
memoryCopy(out->modelNames[m], data + offset, nameLen);
out->modelNames[m][nameLen] = '\0';
offset += nameLen + 1;
memoryCopy(
@@ -34,7 +34,7 @@ typedef struct {
typedef struct {
tile_t tiles[CHUNK_TILE_COUNT];
uint8_t meshCount;
char_t meshNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
vec3 meshOffsets[CHUNK_MESH_COUNT_MAX];
} assetchunkoutput_t;
@@ -51,7 +51,7 @@ errorret_t assetChunkLoaderAsync(assetloading_t *loading);
/**
* Synchronous loader for chunk assets. Validates the DCF binary previously
* read by the async phase and populates the output assetchunkoutput_t with
* tile data and DMF mesh names.
* tile data and model paths.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
+1
View File
@@ -6,4 +6,5 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
assetmeshloader.c
assetmodelloader.c
)
@@ -0,0 +1,171 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetmodelloader.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include "asset/asset.h"
#include "asset/loader/assetloading.h"
#include "asset/loader/assetentry.h"
#include "asset/loader/json/assetjsonloader.h"
#include "display/texture/texture.h"
#include "yyjson.h"
errorret_t assetModelLoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Should be called from an async thread.");
errorOk();
}
errorret_t assetModelLoaderSync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertTrue(loading->type == ASSET_LOADER_TYPE_MODEL, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
switch(loading->loading.model.state) {
case ASSET_MODEL_LOADING_STATE_INITIAL:
loading->loading.model.state = ASSET_MODEL_LOADING_STATE_LOCK_ASSETS;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
break;
case ASSET_MODEL_LOADING_STATE_LOCK_ASSETS: {
// Lock the model's JSON descriptor as a JSON sub-asset. The entry
// key is prefixed with "json:" to avoid a type-collision with the
// model entry itself (both share the same filename). The JSON loader
// reads the real file path supplied in the input.
char_t jsonKey[ASSET_FILE_NAME_MAX];
stringFormat(
jsonKey, sizeof(jsonKey), "json:%s", loading->entry->name
);
assetloaderinput_t jsonInput;
memoryZero(&jsonInput, sizeof(jsonInput));
stringCopy(
jsonInput.json.path, loading->entry->name, ASSET_FILE_NAME_MAX
);
assetentry_t *jsonEntry = assetLock(
jsonKey, ASSET_LOADER_TYPE_JSON, &jsonInput
);
errorret_t ret = assetRequireLoaded(jsonEntry);
if(errorIsNotOk(ret)) {
assetUnlockEntry(jsonEntry);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorChain(ret);
}
yyjson_val *root = yyjson_doc_get_root(jsonEntry->data.json);
// Parse required mesh path.
yyjson_val *meshVal = yyjson_obj_get(root, "mesh");
if(!meshVal || !yyjson_is_str(meshVal)) {
assetUnlockEntry(jsonEntry);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorThrow("Model JSON missing 'mesh' string");
}
const char *meshStr = yyjson_get_str(meshVal);
size_t meshLen = yyjson_get_len(meshVal);
if(meshLen >= ASSET_FILE_NAME_MAX) {
assetUnlockEntry(jsonEntry);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorThrow("Model mesh path exceeds max length");
}
char_t meshName[ASSET_FILE_NAME_MAX];
memoryCopy(meshName, meshStr, meshLen + 1);
// Parse optional texture path (absent = color only, no texture).
char_t textureName[ASSET_FILE_NAME_MAX];
textureName[0] = '\0';
yyjson_val *texVal = yyjson_obj_get(root, "texture");
if(texVal && yyjson_is_str(texVal)) {
const char *texStr = yyjson_get_str(texVal);
size_t texLen = yyjson_get_len(texVal);
if(texLen >= ASSET_FILE_NAME_MAX) {
assetUnlockEntry(jsonEntry);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorThrow("Model texture path exceeds max length");
}
memoryCopy(textureName, texStr, texLen + 1);
}
// Parse optional color (RGBA array 0-255, default white).
color_t color = COLOR_WHITE;
yyjson_val *colorVal = yyjson_obj_get(root, "color");
if(colorVal && yyjson_is_arr(colorVal)) {
uint8_t ch[4] = {255, 255, 255, 255};
size_t colorIdx, colorLen;
yyjson_val *colorElem;
yyjson_arr_foreach(colorVal, colorIdx, colorLen, colorElem) {
if(colorIdx >= 4) break;
if(yyjson_is_int(colorElem)) {
ch[colorIdx] = (uint8_t)yyjson_get_int(colorElem);
}
}
color.r = ch[0];
color.g = ch[1];
color.b = ch[2];
color.a = ch[3];
}
// Release JSON entry; all needed fields are now in local variables.
assetUnlockEntry(jsonEntry);
// Lock and load the mesh sub-asset.
assetentry_t *meshEntry = assetLock(meshName, ASSET_LOADER_TYPE_MESH, NULL);
ret = assetRequireLoaded(meshEntry);
if(errorIsNotOk(ret)) {
assetUnlockEntry(meshEntry);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorChain(ret);
}
// Lock and load the optional texture sub-asset.
assetentry_t *texEntry = NULL;
if(textureName[0] != '\0') {
assetloaderinput_t texInput = { .texture = TEXTURE_FORMAT_RGBA };
texEntry = assetLock(textureName, ASSET_LOADER_TYPE_TEXTURE, &texInput);
ret = assetRequireLoaded(texEntry);
if(errorIsNotOk(ret)) {
assetUnlockEntry(texEntry);
assetUnlockEntry(meshEntry);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorChain(ret);
}
}
assetmodeloutput_t *out = &loading->entry->data.model;
out->meshEntry = meshEntry;
out->texEntry = texEntry;
out->color = color;
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk();
break;
}
default:
errorOk();
}
errorOk();
}
errorret_t assetModelDispose(assetentry_t *entry) {
assertNotNull(entry, "Entry cannot be NULL");
assertTrue(entry->type == ASSET_LOADER_TYPE_MODEL, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
assetmodeloutput_t *out = &entry->data.model;
if(out->meshEntry != NULL) {
assetUnlockEntry(out->meshEntry);
out->meshEntry = NULL;
}
if(out->texEntry != NULL) {
assetUnlockEntry(out->texEntry);
out->texEntry = NULL;
}
errorOk();
}
@@ -0,0 +1,58 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "display/color.h"
typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t;
typedef enum {
ASSET_MODEL_LOADING_STATE_INITIAL,
ASSET_MODEL_LOADING_STATE_LOCK_ASSETS
} assetmodelloadingstate_t;
typedef struct {
assetmodelloadingstate_t state;
} assetmodelloaderloading_t;
typedef struct {
assetentry_t *meshEntry;
assetentry_t *texEntry;
color_t color;
} assetmodeloutput_t;
/**
* Async loader stub for model assets. Model loading is performed entirely
* on the main thread; this callback is never reached.
*
* @param loading Loading information for the asset being loaded.
* @returns Always success.
*/
errorret_t assetModelLoaderAsync(assetloading_t *loading);
/**
* Synchronous loader for model assets. Locks the JSON descriptor file as
* a sub-asset, waits for it to load, parses the mesh path, optional
* texture path, and color tint, then releases the JSON entry. Locks and
* waits for mesh and optional texture sub-assets sequentially to stay
* within ASSET_LOADING_COUNT_MAX slot limits.
*
* @param loading Loading information for the asset being loaded.
* @returns Error code indicating success or failure.
*/
errorret_t assetModelLoaderSync(assetloading_t *loading);
/**
* Disposer for model assets. Releases the locks on the mesh and texture
* sub-asset entries.
*
* @param entry Asset entry containing the model to dispose.
* @returns Error code indicating success or failure.
*/
errorret_t assetModelDispose(assetentry_t *entry);
+5 -3
View File
@@ -22,9 +22,11 @@ errorret_t assetJsonLoaderAsync(assetloading_t *loading) {
assertNull(loading->loading.json.buffer, "Buffer already defined?");
assetfile_t *file = &loading->loading.json.file;
assetLoaderErrorChain(loading,
assetFileInit(file, loading->entry->name, NULL, NULL)
);
const char_t *filePath = (
loading->entry->input != NULL &&
loading->entry->input->json.path[0] != '\0'
) ? loading->entry->input->json.path : loading->entry->name;
assetLoaderErrorChain(loading, assetFileInit(file, filePath, NULL, NULL));
if(file->size > ASSET_JSON_FILE_SIZE_MAX) {
assetLoaderErrorThrow(loading, "JSON exceeds maximum allowed size");
+10 -1
View File
@@ -14,7 +14,16 @@
typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t;
typedef struct { void *nothing; } assetjsonloaderinput_t;
/**
* Optional file path override. When path[0] is non-zero the JSON loader
* reads this path from the archive instead of loading->entry->name. Use
* this to decouple the asset pool key from the actual file location (e.g.
* when a parent loader locks a JSON entry under a synthetic key to avoid
* a type-collision with an entry of the same name but different type).
*/
typedef struct {
char_t path[ASSET_FILE_NAME_MAX];
} assetjsonloaderinput_t;
typedef enum {
ASSET_JSON_LOADING_STATE_INITIAL,
+1 -1
View File
@@ -17,7 +17,7 @@ console_t CONSOLE;
void consoleInit(void) {
memoryZero(&CONSOLE, sizeof(console_t));
CONSOLE.visible = false;
CONSOLE.visible = true;
#ifdef DUSK_CONSOLE_POSIX
threadMutexInit(&CONSOLE.printMutex);
+2 -2
View File
@@ -22,10 +22,10 @@ typedef struct chunk_s {
assetentry_t *dcfEntry;
uint8_t meshCount;
char_t meshNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
vec3 meshOffsets[CHUNK_MESH_COUNT_MAX];
mat4 meshModels[CHUNK_MESH_COUNT_MAX];
assetentry_t *meshEntries[CHUNK_MESH_COUNT_MAX];
assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX];
uint8_t entities[CHUNK_ENTITY_COUNT_MAX];
} chunk_t;
+59 -64
View File
@@ -148,9 +148,9 @@ void mapChunkUnload(chunk_t *chunk) {
}
for(uint8_t m = 0; m < chunk->meshCount; m++) {
if(chunk->meshEntries[m] == NULL) continue;
assetUnlockEntry(chunk->meshEntries[m]);
chunk->meshEntries[m] = NULL;
if(chunk->modelEntries[m] == NULL) continue;
assetUnlockEntry(chunk->modelEntries[m]);
chunk->modelEntries[m] = NULL;
}
chunk->meshCount = 0;
}
@@ -183,6 +183,7 @@ errorret_t mapChunkLoad(chunk_t *chunk) {
}
errorret_t mapWaitForChunks() {
// Phase 1: Wait for all DCF entries to finish loading.
bool_t anyPending;
do {
anyPending = false;
@@ -190,76 +191,70 @@ errorret_t mapWaitForChunks() {
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
chunk_t *chunk = &MAP.chunks[i];
if(chunk->dcfEntry == NULL) continue;
if(chunk->dcfEntry != NULL) {
assetentrystate_t state = chunk->dcfEntry->state;
assetentrystate_t state = chunk->dcfEntry->state;
if(state == ASSET_ENTRY_STATE_LOADED) {
uint8_t meshCount = chunk->dcfEntry->data.chunk.meshCount;
memoryCopy(
chunk->tiles,
chunk->dcfEntry->data.chunk.tiles,
sizeof(chunk->tiles)
);
worldpos_t wp;
chunkPosToWorldPos(&chunk->position, &wp);
vec3 wpf = { (float_t)wp.x, (float_t)wp.y, (float_t)wp.z };
if(state == ASSET_ENTRY_STATE_LOADED) {
uint8_t meshCount = chunk->dcfEntry->data.chunk.meshCount;
memoryCopy(
chunk->tiles,
chunk->dcfEntry->data.chunk.tiles,
sizeof(chunk->tiles)
for(uint8_t m = 0; m < meshCount; m++) {
stringCopy(
chunk->modelNames[m],
chunk->dcfEntry->data.chunk.modelNames[m],
CHUNK_MESH_NAME_MAX
);
worldpos_t wp;
chunkPosToWorldPos(&chunk->position, &wp);
vec3 wpf = { (float_t)wp.x, (float_t)wp.y, (float_t)wp.z };
for(uint8_t m = 0; m < meshCount; m++) {
stringCopy(
chunk->meshNames[m],
chunk->dcfEntry->data.chunk.meshNames[m],
CHUNK_MESH_NAME_MAX
);
glm_vec3_copy(
chunk->dcfEntry->data.chunk.meshOffsets[m],
chunk->meshOffsets[m]
);
vec3 pos;
glm_vec3_add(wpf, chunk->meshOffsets[m], pos);
glm_translate_make(chunk->meshModels[m], pos);
}
assetUnlockEntry(chunk->dcfEntry);
chunk->dcfEntry = NULL;
for(uint8_t m = 0; m < meshCount; m++) {
chunk->meshEntries[m] = assetLock(
chunk->meshNames[m], ASSET_LOADER_TYPE_MESH, NULL
);
if(chunk->meshEntries[m] == NULL) {
for(uint8_t j = 0; j < m; j++) {
assetUnlockEntry(chunk->meshEntries[j]);
chunk->meshEntries[j] = NULL;
}
errorThrow("Failed to lock mesh: %s", chunk->meshNames[m]);
}
}
chunk->meshCount = meshCount;
// Fall through to the mesh-state check below so cached meshes
// don't force an unnecessary extra sleep iteration.
} else if(state == ASSET_ENTRY_STATE_ERROR) {
assetUnlockEntry(chunk->dcfEntry);
chunk->dcfEntry = NULL;
memorySet(chunk->tiles, TILE_SHAPE_GROUND, sizeof(chunk->tiles));
continue;
} else {
anyPending = true;
continue;
}
}
for(uint8_t m = 0; m < chunk->meshCount; m++) {
if(chunk->meshEntries[m] == NULL) continue;
if(chunk->meshEntries[m]->state != ASSET_ENTRY_STATE_LOADED) {
anyPending = true;
break;
glm_vec3_copy(
chunk->dcfEntry->data.chunk.meshOffsets[m],
chunk->meshOffsets[m]
);
vec3 pos;
glm_vec3_add(wpf, chunk->meshOffsets[m], pos);
glm_translate_make(chunk->meshModels[m], pos);
}
assetUnlockEntry(chunk->dcfEntry);
chunk->dcfEntry = NULL;
chunk->meshCount = meshCount;
} else if(state == ASSET_ENTRY_STATE_ERROR) {
assetUnlockEntry(chunk->dcfEntry);
chunk->dcfEntry = NULL;
memorySet(chunk->tiles, TILE_SHAPE_GROUND, sizeof(chunk->tiles));
} else {
anyPending = true;
}
}
if(anyPending) usleep(1000);
} while(anyPending);
// Phase 2: Load model entries one at a time.
// Models are locked and waited on sequentially so that only one model
// entry occupies a loading slot at a time. This leaves the remaining
// slots free for the model's mesh and texture sub-assets, preventing
// the slot-exhaustion deadlock that occurs when multiple models are
// assigned slots simultaneously and each blocks waiting for sub-assets
// that have nowhere to run.
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
chunk_t *chunk = &MAP.chunks[i];
for(uint8_t m = 0; m < chunk->meshCount; m++) {
if(chunk->modelEntries[m] != NULL) continue;
chunk->modelEntries[m] = assetLock(
chunk->modelNames[m], ASSET_LOADER_TYPE_MODEL, NULL
);
if(chunk->modelEntries[m] == NULL) {
errorThrow("Failed to lock model: %s", chunk->modelNames[m]);
}
errorChain(assetRequireLoaded(chunk->modelEntries[m]));
}
}
errorOk();
}
+16 -32
View File
@@ -24,27 +24,8 @@
#include "asset/loader/assetloader.h"
#include "asset/loader/assetentry.h"
#define TEXTURE_CHUNK_SIZE 16
static texture_t TEXTURE_CHUNK;
static color_t TEXTURE_CHUNK_PIXELS[TEXTURE_CHUNK_SIZE * TEXTURE_CHUNK_SIZE];
errorret_t sceneOverworldInit(scenedata_t *sceneData) {
assertNotNull(sceneData, "Scene data cannot be null");
for(uint32_t i = 0; i < TEXTURE_CHUNK_SIZE * TEXTURE_CHUNK_SIZE; i++) {
uint8_t r = (uint8_t)((i & 7) * 36);
uint8_t g = (uint8_t)(((i >> 3) & 7) * 36);
uint8_t b = (uint8_t)((i >> 6) * 85);
TEXTURE_CHUNK_PIXELS[i] = color4b(r, g, b, 255);
}
errorChain(textureInit(
&TEXTURE_CHUNK,
TEXTURE_CHUNK_SIZE, TEXTURE_CHUNK_SIZE,
TEXTURE_FORMAT_RGBA,
(texturedata_t){ .rgbaColors = TEXTURE_CHUNK_PIXELS }
));
errorOk();
}
@@ -138,25 +119,31 @@ errorret_t sceneOverworldRender(scenedata_t *sceneData) {
}
errorret_t sceneOverworldDrawChunks() {
shadermaterial_t chunkMaterial = {
.unlit = {
.color = COLOR_WHITE,
.texture = &TEXTURE_CHUNK
}
};
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
chunk_t *chunk = &MAP.chunks[i];
if(chunk->meshCount == 0) continue;
for(uint8_t m = 0; m < chunk->meshCount; m++) {
if(chunk->meshEntries[m] == NULL) continue;
if(chunk->modelEntries[m] == NULL) continue;
if(chunk->modelEntries[m]->state != ASSET_ENTRY_STATE_LOADED) continue;
assetmodeloutput_t *model = &chunk->modelEntries[m]->data.model;
if(model->meshEntry == NULL) continue;
texture_t *tex = model->texEntry != NULL
? &model->texEntry->data.texture
: NULL;
shadermaterial_t mat = {
.unlit = { .color = model->color, .texture = tex }
};
errorChain(shaderSetMatrix(
&SHADER_UNLIT, SHADER_UNLIT_MODEL, chunk->meshModels[m]
));
errorChain(shaderSetMaterial(&SHADER_UNLIT, &chunkMaterial));
errorChain(meshDraw(&chunk->meshEntries[m]->data.mesh.mesh, 0, -1));
errorChain(shaderSetMaterial(&SHADER_UNLIT, &mat));
errorChain(meshDraw(
&model->meshEntry->data.mesh.mesh, 0, -1
));
}
}
@@ -171,8 +158,5 @@ errorret_t sceneOverworldDrawChunks() {
errorret_t sceneOverworldDispose(scenedata_t *sceneData) {
assertNotNull(sceneData, "Scene data cannot be null");
errorChain(textureDispose(&TEXTURE_CHUNK));
errorOk();
}
+39 -14
View File
@@ -26,8 +26,8 @@ JSON input (assetsraw/chunks/chunk_X_Y_Z.json):
Version 3 DCF format (after 8-byte header + tiles):
uint8_t meshCount
for each mesh:
null-terminated string (relative asset path to .dmf)
for each model:
null-terminated string (relative asset path to .json model)
float32[3] (x, y, z offset)
DMF format:
@@ -89,11 +89,11 @@ 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.
def find_model(filename):
"""Search ASSETS_DIR/models/ 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):
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
@@ -102,6 +102,17 @@ def find_mesh(filename):
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 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]
@@ -228,31 +239,45 @@ def from_json(json_path, dcf_path):
)
terrain_verts = build_terrain_verts(tiles)
mesh_names = []
model_names = []
mesh_offsets = []
# Terrain mesh (only written if non-empty)
dcf_base = os.path.splitext(os.path.basename(dcf_path))[0]
# Terrain mesh + model (only written if non-empty)
terrain_dir = os.path.join(ASSETS_DIR, 'meshes', 'chunks')
models_dir = os.path.join(ASSETS_DIR, '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_names.append(f'meshes/chunks/{dmf_name}')
mesh_rel = f'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'models/chunks/{json_name}')
mesh_offsets.append((0.0, 0.0, 0.0))
# External mesh references
# External mesh references -> look up matching model JSON
for mesh in data.get('meshes', []):
filename = mesh['file']
pos = mesh.get('pos', [0, 0, 0])
rel = find_mesh(filename)
model_filename = os.path.splitext(filename)[0] + '.json'
rel = find_model(model_filename)
if rel is None:
raise ValueError(f"Mesh not found under assets/meshes/: {filename}")
mesh_names.append(rel)
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}')
write_v3(dcf_path, bytes(tiles), mesh_names, mesh_offsets)
write_v3(dcf_path, bytes(tiles), model_names, mesh_offsets)
def process_json(json_path):