3 Commits
Author SHA1 Message Date
YourWishesandClaude Sonnet 5.5 cad41e456b Add asset, resource system, mesh formats, color, SDL3 display and example game
Asset directory with CMake add_asset (extension dispatched), plain file asset
access, resource/resource manager with async load thread and text loader,
URI utilities, mesh format helpers, generated color header, and example game
hooks wired through the engine.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 20:12:40 -06:00
YourWishesandClaude Sonnet 5.5 65c4a9c051 Add fresh CMake/platform scaffolding, core engine, and SDL3 window
Platform folders, cmake target/option/platform detection, Linux and PSP
scripts and Docker setup, assert/util/thread/error/log/perf/time/console/
engine/display modules, and an SDL3 window cleared to red.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 17:02:14 -06:00
YourWishesandClaude Sonnet 5.5 3710924503 Remove all existing code for fresh rewrite on new branch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 16:33:26 -06:00
1098 changed files with 2550 additions and 69602 deletions

No files matched your search

@@ -1,49 +0,0 @@
name: Setup devkitPro
description: Install devkitPro with GameCube/Wii packages on an Ubuntu runner
runs:
using: composite
steps:
- name: Install apt dependencies
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y \
cmake \
python3 \
python3-pip \
python3-polib \
python3-pil \
python3-dotenv \
python3-pyqt5 \
python3-opengl \
xorriso
- name: Install devkitPro pacman
shell: bash
run: |
sudo ln -sf /proc/self/mounts /etc/mtab
wget https://apt.devkitpro.org/install-devkitpro-pacman \
-O /tmp/install-devkitpro-pacman
echo "=== installer script contents ==="
cat /tmp/install-devkitpro-pacman
echo "================================="
chmod +x /tmp/install-devkitpro-pacman
sudo /tmp/install-devkitpro-pacman
echo "DEVKITPRO=/opt/devkitpro" >> $GITHUB_ENV
echo "DEVKITPPC=/opt/devkitpro/devkitPPC" >> $GITHUB_ENV
echo "/opt/devkitpro/tools/bin" >> $GITHUB_PATH
echo "/opt/devkitpro/devkitPPC/bin" >> $GITHUB_PATH
- name: Install devkitPro GameCube/Wii packages
shell: bash
run: |
sudo dkp-pacman -S --needed --noconfirm \
gamecube-dev \
gamecube-sdl2 \
ppc-liblzma \
ppc-libzip \
libogc2 \
gamecube-tools \
ppc-libmad \
ppc-zlib-ng \
ppc-bzip2 \
ppc-zstd
-20
View File
@@ -1,20 +0,0 @@
name: Setup pspdev
description: Install the pspdev PSP toolchain on an Ubuntu runner
runs:
using: composite
steps:
- name: Install dependencies
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y cmake python3 python3-pip python3-dotenv
- name: Install pspdev toolchain
shell: bash
run: |
wget -q \
https://github.com/pspdev/pspdev/releases/latest/download/pspdev-ubuntu-latest-x86_64.tar.gz \
-O /tmp/pspdev.tar.gz
sudo tar -xzf /tmp/pspdev.tar.gz -C /usr/local
echo "PSPDEV=/usr/local/pspdev" >> $GITHUB_ENV
echo "/usr/local/pspdev/bin" >> $GITHUB_PATH
-247
View File
@@ -1,247 +0,0 @@
name: Build Dusk
on:
push:
tags:
- '*'
jobs:
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
cmake \
python3 \
python3-pip \
python3-polib \
python3-pil \
libsdl2-dev \
libgl1-mesa-dev \
libzip-dev \
python3-dotenv \
python3-pyqt5 \
python3-opengl \
xz-utils \
liblzma-dev \
libbz2-dev \
zlib1g-dev \
git \
libssl-dev
- name: Build Linux
run: ./scripts/build-linux.sh
- name: Upload Linux binary
uses: actions/upload-artifact@v3
with:
name: dusk-linux
path: build-linux/Dusk
if-no-files-found: error
build-psp:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pspdev
uses: ./.github/actions/setup-pspdev
- name: Build PSP
run: ./scripts/build-psp.sh
- name: Move EBOOT.PBP to Dusk subfolder
run: |
mkdir -p ./git-artifcats/Dusk/PSP/GAME/Dusk
cp build-psp/EBOOT.PBP ./git-artifcats/Dusk/PSP/GAME/Dusk/EBOOT.PBP
- name: Upload PSP binary
uses: actions/upload-artifact@v3
with:
name: dusk-psp
path: ./git-artifcats/Dusk
if-no-files-found: error
build-knulli:
runs-on: ubuntu-latest
container:
image: debian:trixie
steps:
- name: Install Node.js
run: apt-get update && apt-get install -y nodejs
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: |
dpkg --add-architecture arm64
apt-get update
apt-get install -y --no-install-recommends \
crossbuild-essential-arm64 \
ca-certificates \
pkg-config \
cmake \
make \
ninja-build \
git \
file \
python3 \
python3-pip \
python3-polib \
python3-pil \
python3-dotenv \
python3-pyqt5 \
python3-opengl \
liblua5.4-dev:arm64 \
xz-utils:arm64 \
libbz2-dev:arm64 \
zlib1g-dev:arm64 \
libzip-dev:arm64 \
libssl-dev:arm64 \
libsdl2-dev:arm64 \
liblzma-dev:arm64 \
libopengl0:arm64 \
libgl1:arm64 \
libegl1:arm64 \
libgles2:arm64 \
libgl1-mesa-dev:arm64
- name: Build Knulli
run: ./scripts/build-knulli.sh
- name: Move output to Dusk subfolder
run: |
mkdir -p ./git-artifcats/Dusk
cp -r build-knulli/dusk ./git-artifcats/Dusk
- name: Upload Knulli binary
uses: actions/upload-artifact@v3
with:
name: dusk-knulli
path: ./git-artifcats/Dusk
if-no-files-found: error
build-gamecube:
runs-on: ubuntu-latest
container:
image: ghcr.io/extremscorner/libogc2:latest
steps:
- name: Install Node.js
run: apt-get update && apt-get install -y nodejs
- name: Checkout repository
uses: actions/checkout@v4
- name: Install additional dependencies
run: |
apt-get install -y \
python3-pip python3-polib python3-pil \
python3-dotenv python3-pyqt5 python3-opengl
dkp-pacman -Syu --noconfirm
dkp-pacman -S --needed --noconfirm \
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build GameCube
run: ./scripts/build-gamecube.sh
- name: Copy output files
run: |
mkdir -p ./git-artifcats/Dusk
cp build-gamecube/Dusk.dol ./git-artifcats/Dusk/Dusk.dol
cp build-gamecube/dusk.dsk ./git-artifcats/Dusk/dusk.dsk
- name: Upload GameCube binary
uses: actions/upload-artifact@v3
with:
name: dusk-gamecube
path: ./git-artifcats/Dusk
if-no-files-found: error
build-gamecube-iso:
runs-on: ubuntu-latest
container:
image: ghcr.io/extremscorner/libogc2:latest
steps:
- name: Install Node.js
run: apt-get update && apt-get install -y nodejs
- name: Checkout repository
uses: actions/checkout@v4
- name: Install additional dependencies
run: |
apt-get install -y \
python3-pip python3-polib python3-pil \
python3-dotenv python3-pyqt5 python3-opengl xorriso
dkp-pacman -Syu --noconfirm
dkp-pacman -S --needed --noconfirm \
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build GameCube ISO
run: ./scripts/build-gamecube-iso.sh
- name: Copy output files
run: |
mkdir -p ./git-artifcats/Dusk
cp build-gamecube-iso/Dusk-NTSC-J.iso ./git-artifcats/Dusk/Dusk-NTSC-J.iso
cp build-gamecube-iso/Dusk-NTSC-U.iso ./git-artifcats/Dusk/Dusk-NTSC-U.iso
cp build-gamecube-iso/Dusk-PAL.iso ./git-artifcats/Dusk/Dusk-PAL.iso
- name: Upload GameCube ISO
uses: actions/upload-artifact@v3
with:
name: dusk-gamecube-iso
path: ./git-artifcats/Dusk
if-no-files-found: error
build-wii:
runs-on: ubuntu-latest
container:
image: ghcr.io/extremscorner/libogc2:latest
steps:
- name: Install Node.js
run: apt-get update && apt-get install -y nodejs
- name: Checkout repository
uses: actions/checkout@v4
- name: Install additional dependencies
run: |
apt-get install -y \
python3-pip python3-polib python3-pil \
python3-dotenv python3-pyqt5 python3-opengl
dkp-pacman -Syu --noconfirm
dkp-pacman -S --needed --noconfirm \
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build Wii
run: ./scripts/build-wii.sh
- name: Copy output files
run: |
mkdir -p ./git-artifcats/Dusk/apps/Dusk
cp build-wii/boot.dol ./git-artifcats/Dusk/apps/Dusk/boot.dol
cp build-wii/dusk.dsk ./git-artifcats/Dusk/apps/Dusk/dusk.dsk
cp build-wii/meta.xml ./git-artifcats/Dusk/apps/Dusk/meta.xml
- name: Upload Wii binary
uses: actions/upload-artifact@v3
with:
name: dusk-wii
path: ./git-artifcats/Dusk
if-no-files-found: error
build-wii-iso:
runs-on: ubuntu-latest
container:
image: ghcr.io/extremscorner/libogc2:latest
steps:
- name: Install Node.js
run: apt-get update && apt-get install -y nodejs
- name: Checkout repository
uses: actions/checkout@v4
- name: Install additional dependencies
run: |
apt-get install -y \
python3-pip python3-polib python3-pil \
python3-dotenv python3-pyqt5 python3-opengl xorriso
dkp-pacman -Syu --noconfirm
dkp-pacman -S --needed --noconfirm \
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build Wii ISO
run: ./scripts/build-wii-iso.sh
- name: Copy output files
run: |
mkdir -p ./git-artifcats/Dusk
cp build-wii-iso/Dusk-NTSC-J.iso ./git-artifcats/Dusk/Dusk-NTSC-J.iso
cp build-wii-iso/Dusk-NTSC-U.iso ./git-artifcats/Dusk/Dusk-NTSC-U.iso
cp build-wii-iso/Dusk-PAL.iso ./git-artifcats/Dusk/Dusk-PAL.iso
- name: Upload Wii ISO
uses: actions/upload-artifact@v3
with:
name: dusk-wii-iso
path: ./git-artifcats/Dusk
if-no-files-found: error
-35
View File
@@ -1,35 +0,0 @@
name: Test Dusk
on:
pull_request:
branches:
- main
jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
cmake \
python3 \
python3-pip \
python3-polib \
python3-pil \
libsdl2-dev \
libgl1-mesa-dev \
libzip-dev \
python3-dotenv \
python3-pyqt5 \
python3-opengl \
xz-utils \
liblzma-dev \
libbz2-dev \
zlib1g-dev \
git \
libssl-dev
- name: Run tests
run: ./scripts/test-linux.sh
+1 -1
View File
@@ -91,7 +91,7 @@ assets/borrowed
/archive0 /archive0
/archive1 /archive1
/archive # /archive
__pycache__ __pycache__
+55 -129
View File
@@ -1,172 +1,98 @@
# Copyright (c) 2025 Dominic Masters # Copyright (c) 2026 Dominic Masters
# #
# This software is released under the MIT License. # This software is released under the MIT License.
# https://opensource.org/licenses/MIT # https://opensource.org/licenses/MIT
# Setup
cmake_minimum_required(VERSION 3.13) cmake_minimum_required(VERSION 3.13)
set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_MODULE_PATH
${CMAKE_MODULE_PATH}
${CMAKE_CURRENT_SOURCE_DIR}/cmake/modules
)
cmake_policy(SET CMP0079 NEW)
# set(FETCHCONTENT_UPDATES_DISCONNECTED ON)
option(DUSK_BUILD_TESTS "Enable tests" OFF) # Directories
option(DUSK_NETWORK "Enable network support" ON)
set(DUSK_GAME_NAME "Dusk" CACHE STRING "Game display name")
set(DUSK_GAME_AUTHOR "YourWishes" CACHE STRING "Game author / coder")
set(DUSK_GAME_SHORT_DESCRIPTION "Dusk game" CACHE STRING "One-line description")
set(DUSK_GAME_LONG_DESCRIPTION
"No description yet." CACHE STRING "Full description"
)
# Prep cache
set(DUSK_CACHE_TARGET "dusk-target")
# Build variables
set(DUSK_ROOT_DIR "${CMAKE_SOURCE_DIR}") set(DUSK_ROOT_DIR "${CMAKE_SOURCE_DIR}")
set(DUSK_BUILD_DIR "${CMAKE_BINARY_DIR}")
set(DUSK_SOURCES_DIR "${DUSK_ROOT_DIR}/src") set(DUSK_SOURCES_DIR "${DUSK_ROOT_DIR}/src")
set(DUSK_TEST_DIR "${DUSK_ROOT_DIR}/test")
set(DUSK_TEMP_DIR "${DUSK_BUILD_DIR}/temp")
set(DUSK_TOOLS_DIR "${DUSK_ROOT_DIR}/tools")
set(DUSK_DATA_DIR "${DUSK_ROOT_DIR}/data")
set(DUSK_ASSETS_DIR "${DUSK_ROOT_DIR}/assets") set(DUSK_ASSETS_DIR "${DUSK_ROOT_DIR}/assets")
set(DUSK_BUILT_ASSETS_DIR "${DUSK_BUILD_DIR}/built_assets" set(DUSK_BUILT_ASSETS_DIR "${CMAKE_BINARY_DIR}/assets")
CACHE INTERNAL ${DUSK_CACHE_TARGET} set(DUSK_GENERATED_HEADERS_DIR "${CMAKE_BINARY_DIR}/generated")
)
set(DUSK_GENERATED_HEADERS_DIR "${DUSK_BUILD_DIR}/generated")
set(DUSK_BUILD_BINARY ${DUSK_BUILD_DIR}/Dusk
CACHE INTERNAL ${DUSK_CACHE_TARGET}
)
set(DUSK_ASSETS "" CACHE INTERNAL ${DUSK_CACHE_TARGET})
set(DUSK_LIBRARY_TARGET_NAME "DuskCore" CACHE INTERNAL ${DUSK_CACHE_TARGET})
set(DUSK_BINARY_TARGET_NAME "Dusk" CACHE INTERNAL ${DUSK_CACHE_TARGET})
set(DUSK_ASSETS_ZIP "${DUSK_BUILD_DIR}/dusk.dsk"
CACHE INTERNAL ${DUSK_CACHE_TARGET}
)
if(NOT DEFINED DUSK_TARGET_SYSTEM)
set(DUSK_TARGET_SYSTEM "linux")
endif()
# Create directories
file(MAKE_DIRECTORY ${DUSK_GENERATED_HEADERS_DIR}) file(MAKE_DIRECTORY ${DUSK_GENERATED_HEADERS_DIR})
file(MAKE_DIRECTORY ${DUSK_TEMP_DIR})
file(MAKE_DIRECTORY ${DUSK_BUILT_ASSETS_DIR})
# Required build packages project(DuskCore VERSION 1.0.0 LANGUAGES C)
find_package(Python3 COMPONENTS Interpreter REQUIRED)
# Init Project. # Work out the target platform unless one was given explicitly.
project(${DUSK_LIBRARY_TARGET_NAME} include(${DUSK_ROOT_DIR}/cmake/determineplatform.cmake)
VERSION 1.0.0
LANGUAGES C # Platform defaults and definitions (sets DUSK_DEFAULT_* and
# DUSK_PLATFORM_DIRS / DUSK_PLATFORM_DEFINITIONS).
set(DUSK_TARGET_FILE
"${DUSK_ROOT_DIR}/cmake/${DUSK_TARGET_SYSTEM}/target.cmake"
) )
if(NOT EXISTS "${DUSK_TARGET_FILE}")
message(FATAL_ERROR "Unknown DUSK_TARGET_SYSTEM: ${DUSK_TARGET_SYSTEM}")
endif()
include("${DUSK_TARGET_FILE}")
# Either, create library and binary separately (used for tests), or make them # All options and their defaults.
# one in the same so all code is in the binary only. include(${DUSK_ROOT_DIR}/cmake/option.cmake)
# Binary Executable
add_executable(${DUSK_BINARY_TARGET_NAME} ${DUSK_SOURCES_DIR}/dusk/null.c) # Targets. Tests split the code into a library plus the binary, otherwise all
# code is compiled straight into the binary.
set(DUSK_BINARY_TARGET_NAME "Dusk")
add_executable(${DUSK_BINARY_TARGET_NAME})
if(DUSK_BUILD_TESTS) if(DUSK_BUILD_TESTS)
# MainLibrary set(DUSK_LIBRARY_TARGET_NAME "DuskCore")
add_library(${DUSK_LIBRARY_TARGET_NAME} STATIC) add_library(${DUSK_LIBRARY_TARGET_NAME} STATIC)
target_link_libraries(${DUSK_BINARY_TARGET_NAME} PUBLIC
# Link library to binary
target_link_libraries(${DUSK_BINARY_TARGET_NAME}
PUBLIC
${DUSK_LIBRARY_TARGET_NAME} ${DUSK_LIBRARY_TARGET_NAME}
) )
else() else()
set(DUSK_LIBRARY_TARGET_NAME "${DUSK_BINARY_TARGET_NAME}" set(DUSK_LIBRARY_TARGET_NAME "${DUSK_BINARY_TARGET_NAME}")
CACHE INTERNAL ${DUSK_CACHE_TARGET}
)
endif() endif()
if(NOT DEFINED DUSK_VERSION) target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
string(TIMESTAMP DUSK_VERSION "debug-%y%m%d%H%M%S")
endif()
# Definitions
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
DUSK_TARGET_SYSTEM="${DUSK_TARGET_SYSTEM}" DUSK_TARGET_SYSTEM="${DUSK_TARGET_SYSTEM}"
DUSK_GAME_NAME="${DUSK_GAME_NAME}" DUSK_GAME_NAME="${DUSK_GAME_NAME}"
DUSK_GAME_AUTHOR="${DUSK_GAME_AUTHOR}" DUSK_GAME_AUTHOR="${DUSK_GAME_AUTHOR}"
DUSK_GAME_SHORT_DESCRIPTION="${DUSK_GAME_SHORT_DESCRIPTION}"
DUSK_GAME_LONG_DESCRIPTION="${DUSK_GAME_LONG_DESCRIPTION}"
DUSK_VERSION="${DUSK_VERSION}" DUSK_VERSION="${DUSK_VERSION}"
) )
if(DUSK_NETWORK) target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC ${DUSK_PLATFORM_DEFINITIONS}
DUSK_NETWORK )
) foreach(OPT ASSERTIONS_FAKED BACKTRACE TRACK_MEMORY NETWORK)
if(DUSK_${OPT})
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC DUSK_${OPT})
endif()
endforeach()
if(DUSK_BACKTRACE)
target_link_options(${DUSK_LIBRARY_TARGET_NAME} PUBLIC -rdynamic)
endif() endif()
# Toolchains # Tools (run during the build to generate headers)
include(cmake/targets/${DUSK_TARGET_SYSTEM}.cmake)
# Add tools
add_subdirectory(tools) add_subdirectory(tools)
# Include generated headers from tools.
target_include_directories(${DUSK_LIBRARY_TARGET_NAME} PUBLIC target_include_directories(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
${DUSK_GENERATED_HEADERS_DIR} ${DUSK_GENERATED_HEADERS_DIR}
) )
# Add main code # Core engine code
add_subdirectory(${DUSK_SOURCES_DIR}) add_subdirectory(${DUSK_SOURCES_DIR}/dusk)
# Include generated headers # Assets, copied into <build>/assets
target_include_directories(${DUSK_LIBRARY_TARGET_NAME} PUBLIC include(${DUSK_ROOT_DIR}/cmake/asset/asset.cmake)
${DUSK_GENERATED_HEADERS_DIR} # Separate binary dir, so <build>/assets only contains built assets.
) add_subdirectory(${DUSK_ROOT_DIR}/assets ${CMAKE_BINARY_DIR}/assets_cmake)
dusk_assets_finalize(DuskAssets)
add_dependencies(${DUSK_LIBRARY_TARGET_NAME} DuskAssets)
# Game code
add_subdirectory(${DUSK_SOURCES_DIR}/${DUSK_GAME})
# Platform code
foreach(DIR ${DUSK_PLATFORM_DIRS})
add_subdirectory(${DUSK_SOURCES_DIR}/${DIR})
endforeach()
# Handle tests
if(DUSK_BUILD_TESTS) if(DUSK_BUILD_TESTS)
enable_testing() enable_testing()
add_subdirectory(test)
endif() endif()
# Build assets. Runs unconditionally on every build (rather than being
# gated behind a custom_command's file-level DEPENDS list) because
# CONFIGURE_DEPENDS globs only pick up added/removed files on the next
# CMake configure, so that approach could miss/lag a rebuild behind an
# asset edit. tools.asset.pack itself decides whether anything actually
# changed (see its manifest check) and no-ops quickly when nothing did,
# so this stays cheap on a build where assets are untouched.
#
# A .ds script under assets/ is compiled to .dsb by duskscriptcc as part of
# packing, so dusk.dsk only ever carries bytecode. duskscriptcc is a host
# tool, only built when target_system is the build host (linux/knulli, see
# src/CMakeLists.txt) - a cross-compiled console target has no duskscriptcc
# of its own to depend on or pass down, so it can only pack assets with no
# .ds in them until something sets up a host-built duskscriptcc for it too.
if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli")
set(DUSK_ASSET_PACK_DUSKSCRIPTCC_ARGS --duskscriptcc "$<TARGET_FILE:duskscriptcc>")
set(DUSK_ASSET_PACK_DEPENDS duskscriptcc)
else()
set(DUSK_ASSET_PACK_DUSKSCRIPTCC_ARGS "")
set(DUSK_ASSET_PACK_DEPENDS "")
endif()
add_custom_target(DUSK_ASSETS_BUILT ALL
COMMAND ${CMAKE_COMMAND} -E make_directory "${DUSK_ASSETS_DIR}"
COMMAND ${Python3_EXECUTABLE} -m tools.asset.pack
--input "${DUSK_ASSETS_DIR}"
--output "${DUSK_ASSETS_ZIP}"
${DUSK_ASSET_PACK_DUSKSCRIPTCC_ARGS}
WORKING_DIRECTORY "${DUSK_ROOT_DIR}"
BYPRODUCTS "${DUSK_ASSETS_ZIP}"
DEPENDS ${DUSK_ASSET_PACK_DEPENDS}
COMMENT "Packing assets into dusk.dsk"
VERBATIM
)
add_dependencies(${DUSK_LIBRARY_TARGET_NAME} DUSK_ASSETS_BUILT)
-26
View File
@@ -1,26 +0,0 @@
# Dusk
RPG Game Project, small and able to run on a PSP.
# Building
Each build target has different requirements. You can take a look at the git
workflow to see how the builds are done for each target. In addition, for
accessing the editor and building the game on your host system, install the
following packages, depending on your system;
Fedora;
```
sudo dnf install git make gcc cmake python python-polib python3-pillow python3-dotenv python3-numpy python-qt5 python3-pyopengl SDL2-devel zlib-devel libzip-devel bzip2-devel openssl-devel lzma-sdk-devel xz xz-devel lua-devel libmad-devel
```
`libmad-devel` (used for MP3 decoding) is GPL-licensed and ships in the
[RPM Fusion](https://rpmfusion.org/) free repository rather than Fedora's
base repos - make sure RPM Fusion is enabled first.
Ubuntu;
```
sudo apt-get install git build-essential gcc python python-polib python3-pillow python3-dotenv python3-numpy python3-pyqt5 python3-opengl libmad0-dev
```
Arch Linux;
```
sudo pacman -S git base-devel gcc python python-polib python-pillow python-dotenv python-numpy python-pyqt5 python-opengl libmad
```
@@ -2,3 +2,5 @@
# #
# This software is released under the MIT License. # This software is released under the MIT License.
# https://opensource.org/licenses/MIT # https://opensource.org/licenses/MIT
add_asset(cube.obj)
+42
View File
@@ -0,0 +1,42 @@
# Unit cube, centered on the origin, with per face normals and UVs.
o Cube
v -0.5 -0.5 -0.5
v 0.5 -0.5 -0.5
v 0.5 0.5 -0.5
v -0.5 0.5 -0.5
v -0.5 -0.5 0.5
v 0.5 -0.5 0.5
v 0.5 0.5 0.5
v -0.5 0.5 0.5
vt 0.0 0.0
vt 1.0 0.0
vt 1.0 1.0
vt 0.0 1.0
vn 0.0 0.0 -1.0
vn 0.0 0.0 1.0
vn -1.0 0.0 0.0
vn 1.0 0.0 0.0
vn 0.0 -1.0 0.0
vn 0.0 1.0 0.0
# Back (-Z)
f 2/1/1 1/2/1 4/3/1
f 2/1/1 4/3/1 3/4/1
# Front (+Z)
f 5/1/2 6/2/2 7/3/2
f 5/1/2 7/3/2 8/4/2
# Left (-X)
f 1/1/3 5/2/3 8/3/3
f 1/1/3 8/3/3 4/4/3
# Right (+X)
f 6/1/4 2/2/4 3/3/4
f 6/1/4 3/3/4 7/4/4
# Bottom (-Y)
f 1/1/5 2/2/5 6/3/5
f 1/1/5 6/3/5 5/4/5
# Top (+Y)
f 8/1/6 7/2/6 3/3/6
f 8/1/6 3/3/6 4/4/6
-20
View File
@@ -1,20 +0,0 @@
// Entry point, run once by the engine after every other system is ready.
var camera = entityAdd();
var cameraPosition = entityAddComponent(camera, COMPONENT_TYPE_POSITION);
entityAddComponent(camera, COMPONENT_TYPE_CAMERA);
entityPositionLookAt(camera, cameraPosition, 3, 3, 3, 0, 0, 0);
var cube = entityAdd();
var cubePosition = entityAddComponent(cube, COMPONENT_TYPE_POSITION);
var cubeRenderable = entityAddComponent(cube, COMPONENT_TYPE_RENDERABLE);
entityRenderableSetMesh(cube, cubeRenderable, "cube");
entityPositionSetLocalPosition(cube, cubePosition, 0, 0, 0);
var plane = entityAdd();
var planePosition = entityAddComponent(plane, COMPONENT_TYPE_POSITION);
var planeRenderable = entityAddComponent(plane, COMPONENT_TYPE_RENDERABLE);
entityRenderableSetMesh(plane, planeRenderable, "plane");
entityPositionSetLocalPosition(plane, planePosition, 0, 0, 0);
consolePrint("Camera, cube and plane created");
-289
View File
@@ -1,289 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: ExampleApp 1.0\n"
"Language: en\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : n==2 ? 1 : (n<7 ? 2 : 3));\n"
# Initial Scene
msgid "initial.checking_save.title"
msgstr "Checking for save data"
msgid "initial.checking_save.message"
msgstr "Please wait..."
msgid "initial.no_device.title"
msgstr "No Save Device Found"
msgid "initial.no_device.message"
msgstr "Could not find a save device, ensure it is connected and try again. You can continue, but progress will not be saved."
msgid "initial.no_device.retry"
msgstr "Try again"
msgid "initial.no_device.continue"
msgstr "Continue without saving"
# Main Menu Scene
msgid "main_menu.start_game"
msgstr "Start Game"
msgid "main_menu.options"
msgstr "Options"
msgid "main_menu.quit"
msgstr "Quit Game"
msgid "main_menu.quit_confirm"
msgstr "Are you sure you want to quit?"
msgid "main_menu.checking_save.title"
msgstr "Checking for save data"
msgid "main_menu.checking_save.message"
msgstr "Please wait..."
msgid "main_menu.no_device.title"
msgstr "No Save Device Found"
msgid "main_menu.no_device.message"
msgstr "Could not find a save device, ensure it is connected and try again. You can continue, but progress will not be saved."
msgid "main_menu.no_device.retry"
msgstr "Try again"
msgid "main_menu.no_device.continue"
msgstr "Continue without saving"
msgid "main_menu.save_load_error.title"
msgstr "Error"
msgid "main_menu.save_load_error.message"
msgstr "Failed to load save data. Please try again."
msgid "main_menu.save_load_error.retry"
msgstr "Try Again"
# Select Save Screen
msgid "ui.select_save.title"
msgstr "Select Save"
msgid "ui.select_save.empty"
msgstr "Empty Slot"
msgid "ui.select_save.corrupt"
msgstr "Corrupted Save"
msgid "ui.select_save.corrupt_warning"
msgstr "This save file is corrupted and could not be loaded. Please contact support if this issue persists."
msgid "ui.select_save.slot_format"
msgstr "%s Lv.%d %s"
msgid "ui.select_save.delete_mode"
msgstr "Delete a Save"
msgid "ui.select_save.delete_confirm"
msgstr "Are you sure you want to delete this save?"
msgid "ui.select_save.name_title"
msgstr "Enter game save file name"
msgid "ui.save_slot.number_format"
msgstr "Slot %d"
#: ui/menu.c:10
msgid "ui.title"
msgstr ""
"Welcome"
msgid "save.linux.mkdirp_failed"
msgstr "Failed to create save directory, check the disk is not full or write-protected."
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.general"
msgstr "General"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.input"
msgstr "Input"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.display"
msgstr "Display"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.audio"
msgstr "Audio"
msgid "ui.settings.input.deadzone"
msgstr "Deadzone"
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "ui.settings.general.language"
msgstr "Language"
msgid "ui.settings.general.language_detail"
msgstr "Takes effect after restarting the application."
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.apply"
msgstr "Apply"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.discard_changes"
msgstr "Discard unsaved changes?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.characters"
msgstr "Characters"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.items"
msgstr "Items"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.settings"
msgstr "Settings"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save"
msgstr "Save"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_success"
msgstr "Game saved."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_cancelled"
msgstr "Save cancelled."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_unavailable"
msgstr "Can't save - no save device found."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_temporary"
msgstr "This session is temporary - no save device was found, so saving is disabled."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_create_confirm"
msgstr "No save data found. Create a new save?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_failed_format"
msgstr "Save failed: %s"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_check_failed_format"
msgstr "Can't save: %s"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.message"
msgstr "No save device found. You can continue, but\nprogress will not be saved."
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.retry"
msgstr "Retry"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.continue"
msgstr "Continue Anyway"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.message"
msgstr "No save data found. Create a new save?"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.yes"
msgstr "Yes"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.no"
msgstr "No"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.confirm"
msgstr "Confirm"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.cancel"
msgstr "Cancel"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.attack"
msgstr "Attack"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.flee"
msgstr "Flee"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.target_format"
msgstr "Enemy %u (%u/%u HP)"
#: src/dusk/ui/frame/battle/uibattlehud.c
msgid "ui.battle.hud.hp_format"
msgstr "HP %u/%u"
#: src/dusk/ui/frame/battle/uibattlehud.c
msgid "ui.battle.hud.mp_format"
msgstr "MP %u/%u"
#: src/dusk/ui/frame/settings/uisettingsaudio.c
msgid "ui.settings.audio.placeholder"
msgstr "No audio settings yet"
#: src/dusk/ui/frame/settings/uisettingsdisplay.c
msgid "ui.settings.display.placeholder"
msgstr "No display settings yet"
#: src/dusk/ui/frame/settings/uisettingsinput.c
msgid "ui.settings.input.placeholder"
msgstr "No input settings yet"
#: src/dusk/ui/frame/backpack/uibackpack.c
msgid "ui.backpack.category_format"
msgstr "Category %u"
#: src/dusk/ui/overlay/uiloading.c
msgid "ui.loading.text"
msgstr "loading"
#: src/dusk/ui/overlay/uiautosave.c
msgid "ui.autosave.saving"
msgstr "SAVING"
#
# Items
#
msgid "item.potion.name"
msgstr "Potion"
msgid "item.potato.name"
msgstr "Potato"
msgid "item.apple.name"
msgstr "Apple"
#
# Moves
#
msgid "move.fire.name"
msgstr "Fire"
msgid "move.slash.name"
msgstr "Slash"
+65
View File
@@ -0,0 +1,65 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Asset building. Include this from the root CMakeLists.txt.
#
# Assets are registered with add_asset() and built by dusk_assets_finalize().
# Each asset is dispatched on its file extension to a handler function named
# dusk_asset_<extension> (e.g. dusk_asset_obj), defined in its own file in this
# directory. Extensions without a handler fall back to a plain copy.
include(${CMAKE_CURRENT_LIST_DIR}/copy.cmake)
include(${CMAKE_CURRENT_LIST_DIR}/obj.cmake)
# Registers an asset to be built into the project. The path is relative to the
# calling CMakeLists.txt, and is kept relative to the root assets directory in
# the output, so assets/models/a.obj ends up at <build>/assets/models/a.obj.
#
# This only records the asset. The build rules are created by
# dusk_assets_finalize() from the root CMakeLists.txt, because a custom command
# output can only be used by targets in the directory it was defined in.
function(add_asset ASSET_PATH)
set(ASSET_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/${ASSET_PATH}")
if(NOT EXISTS "${ASSET_SOURCE}")
message(FATAL_ERROR "add_asset: ${ASSET_SOURCE} does not exist")
endif()
set_property(GLOBAL APPEND PROPERTY DUSK_ASSET_SOURCES "${ASSET_SOURCE}")
endfunction()
# Determines the lowercase extension of an asset, without the leading dot.
function(dusk_asset_extension ASSET_SOURCE OUT_VAR)
get_filename_component(ASSET_EXT "${ASSET_SOURCE}" LAST_EXT)
string(TOLOWER "${ASSET_EXT}" ASSET_EXT)
string(REGEX REPLACE "^\\." "" ASSET_EXT "${ASSET_EXT}")
set(${OUT_VAR} "${ASSET_EXT}" PARENT_SCOPE)
endfunction()
# Creates the TARGET_NAME target, which builds every asset registered with
# add_asset(). Call once from the root CMakeLists.txt after adding the assets.
function(dusk_assets_finalize TARGET_NAME)
get_property(ASSET_SOURCES GLOBAL PROPERTY DUSK_ASSET_SOURCES)
set(ASSET_OUTPUTS "")
foreach(ASSET_SOURCE IN LISTS ASSET_SOURCES)
file(RELATIVE_PATH ASSET_RELATIVE "${DUSK_ASSETS_DIR}" "${ASSET_SOURCE}")
dusk_asset_extension("${ASSET_SOURCE}" ASSET_EXT)
# Dispatch on the extension, falling back to a plain copy.
set(ASSET_OUTPUT "")
if(COMMAND dusk_asset_${ASSET_EXT})
cmake_language(CALL dusk_asset_${ASSET_EXT}
"${ASSET_SOURCE}" "${ASSET_RELATIVE}" ASSET_OUTPUT
)
else()
message(STATUS "Asset ${ASSET_RELATIVE}: no handler for '.${ASSET_EXT}', copying")
dusk_asset_copy("${ASSET_SOURCE}" "${ASSET_RELATIVE}" ASSET_OUTPUT)
endif()
list(APPEND ASSET_OUTPUTS "${ASSET_OUTPUT}")
endforeach()
add_custom_target(${TARGET_NAME} ALL DEPENDS ${ASSET_OUTPUTS})
endfunction()
+22
View File
@@ -0,0 +1,22 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Handler that copies an asset into the built assets directory as is. Every
# handler has the signature (SOURCE RELATIVE OUT_VAR) and must set OUT_VAR, in
# the caller's scope, to the file it produces.
function(dusk_asset_copy ASSET_SOURCE ASSET_RELATIVE OUT_VAR)
set(ASSET_OUTPUT "${DUSK_BUILT_ASSETS_DIR}/${ASSET_RELATIVE}")
add_custom_command(
OUTPUT "${ASSET_OUTPUT}"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${ASSET_SOURCE}" "${ASSET_OUTPUT}"
DEPENDS "${ASSET_SOURCE}"
COMMENT "Copying asset ${ASSET_RELATIVE}"
VERBATIM
)
set(${OUT_VAR} "${ASSET_OUTPUT}" PARENT_SCOPE)
endfunction()
+10
View File
@@ -0,0 +1,10 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Handler for .obj (Wavefront mesh) assets. Currently just copies the file.
function(dusk_asset_obj ASSET_SOURCE ASSET_RELATIVE OUT_VAR)
dusk_asset_copy("${ASSET_SOURCE}" "${ASSET_RELATIVE}" ASSET_OUTPUT)
set(${OUT_VAR} "${ASSET_OUTPUT}" PARENT_SCOPE)
endfunction()
+45
View File
@@ -0,0 +1,45 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Determines DUSK_TARGET_SYSTEM when it was not explicitly provided with
# -DDUSK_TARGET_SYSTEM=<target>. Must be included after project() so that the
# compiler and CMAKE_SYSTEM_NAME are known.
#
# Console toolchains all cross compile as "Generic", so they are recognised by
# the compiler they configure.
if(DUSK_TARGET_SYSTEM)
message(STATUS "Dusk target: ${DUSK_TARGET_SYSTEM} (explicit)")
return()
endif()
if(CMAKE_C_COMPILER MATCHES "psp-")
set(DUSK_DETECTED_SYSTEM "psp")
elseif(CMAKE_C_COMPILER MATCHES "arm-vita-eabi")
set(DUSK_DETECTED_SYSTEM "vita")
elseif(CMAKE_C_COMPILER MATCHES "powerpc-eabi")
set(DUSK_DETECTED_SYSTEM "dolphin")
elseif(CMAKE_C_COMPILER MATCHES "ps2")
set(DUSK_DETECTED_SYSTEM "ps2")
elseif(CMAKE_C_COMPILER MATCHES "sh-elf")
set(DUSK_DETECTED_SYSTEM "dreamcast")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
set(DUSK_DETECTED_SYSTEM "windows")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
set(DUSK_DETECTED_SYSTEM "linux")
else()
message(FATAL_ERROR
"Could not determine the target platform (system: ${CMAKE_SYSTEM_NAME}, "
"compiler: ${CMAKE_C_COMPILER}). Pass -DDUSK_TARGET_SYSTEM=<target>."
)
endif()
set(DUSK_TARGET_SYSTEM "${DUSK_DETECTED_SYSTEM}" CACHE STRING
"Target platform" FORCE
)
set_property(CACHE DUSK_TARGET_SYSTEM PROPERTY STRINGS
linux windows psp dolphin vita ps2 dreamcast
)
message(STATUS "Dusk target: ${DUSK_TARGET_SYSTEM} (detected)")
+27
View File
@@ -0,0 +1,27 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Platform setup for the dolphin target, included by the root CMakeLists.txt.
# Only sets defaults and definitions; the root applies them.
# Source directories to build for this platform.
set(DUSK_PLATFORM_DIRS duskdolphin)
# Default values for the shared options declared in the root CMakeLists.txt.
# Assertions are faked (compiled out) unless this is a Debug build.
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
set(DUSK_DEFAULT_ASSERTIONS_FAKED OFF)
else()
set(DUSK_DEFAULT_ASSERTIONS_FAKED ON)
endif()
set(DUSK_DEFAULT_BACKTRACE OFF)
set(DUSK_DEFAULT_TRACK_MEMORY OFF)
# Platform definitions.
set(DUSK_PLATFORM_DEFINITIONS
DUSK_PLATFORM_ENDIAN_BIG
DUSK_DOLPHIN
DUSK_THREAD_PTHREAD
)
+26
View File
@@ -0,0 +1,26 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Platform setup for the dreamcast target, included by the root CMakeLists.txt.
# Only sets defaults and definitions; the root applies them.
# Source directories to build for this platform.
set(DUSK_PLATFORM_DIRS duskdreamcast)
# Default values for the shared options declared in the root CMakeLists.txt.
# Assertions are faked (compiled out) unless this is a Debug build.
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
set(DUSK_DEFAULT_ASSERTIONS_FAKED OFF)
else()
set(DUSK_DEFAULT_ASSERTIONS_FAKED ON)
endif()
set(DUSK_DEFAULT_BACKTRACE OFF)
set(DUSK_DEFAULT_TRACK_MEMORY OFF)
# Platform definitions.
set(DUSK_PLATFORM_DEFINITIONS
DUSK_PLATFORM_ENDIAN_LITTLE
DUSK_DREAMCAST
)
+32
View File
@@ -0,0 +1,32 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Platform setup for the linux target, included by the root CMakeLists.txt.
# Only sets defaults and definitions; the root applies them.
# Source directories to build for this platform.
set(DUSK_PLATFORM_DIRS dusklinux dusksdl3 duskvulkan)
# Default values for the shared options declared in the root CMakeLists.txt.
set(DUSK_DEFAULT_ASSERTIONS_FAKED OFF)
set(DUSK_DEFAULT_BACKTRACE ON)
set(DUSK_DEFAULT_TRACK_MEMORY ON)
# Linux runs on both endiannesses, so detect it from the target compiler.
include(TestBigEndian)
test_big_endian(DUSK_IS_BIG_ENDIAN)
if(DUSK_IS_BIG_ENDIAN)
set(DUSK_ENDIAN_DEFINITION DUSK_PLATFORM_ENDIAN_BIG)
else()
set(DUSK_ENDIAN_DEFINITION DUSK_PLATFORM_ENDIAN_LITTLE)
endif()
# Platform definitions.
set(DUSK_PLATFORM_DEFINITIONS
${DUSK_ENDIAN_DEFINITION}
DUSK_LINUX
DUSK_THREAD_PTHREAD
DUSK_TIME_DYNAMIC
)
-15
View File
@@ -1,15 +0,0 @@
# Copyright (c) 2025 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
include(FetchContent)
FetchContent_Declare(
cglm
# GIT_REPOSITORY https://git.wish.moe/YourWishes/cglm.git
GIT_REPOSITORY https://github.com/recp/cglm.git
GIT_TAG v0.9.6
)
FetchContent_MakeAvailable(cglm)
set(cglm_FOUND ON)
-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
include(FetchContent)
FetchContent_Declare(
cmocka
GIT_REPOSITORY https://gitlab.com/cmocka/cmocka.git
GIT_TAG cmocka-2.0.1
)
FetchContent_MakeAvailable(cmocka)
set(cmocka_FOUND ON)
-32
View File
@@ -1,32 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# NOTE: libmad itself is GPL-licensed (see https://www.underbit.com/products/mad/),
# not MIT like the rest of this project. Any binary linking it must comply
# with the GPL - a deliberate tradeoff accepted for MP3 decode correctness
# (see audiostreammp3decodersw.c's own comment on why minimp3 wasn't enough).
find_path(LIBMAD_INCLUDE_DIR NAMES mad.h)
mark_as_advanced(LIBMAD_INCLUDE_DIR)
find_library(LIBMAD_LIBRARY NAMES mad)
mark_as_advanced(LIBMAD_LIBRARY)
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(
libmad
REQUIRED_VARS
LIBMAD_LIBRARY
LIBMAD_INCLUDE_DIR
)
if(libmad_FOUND AND NOT TARGET libmad::mad)
add_library(libmad::mad UNKNOWN IMPORTED)
set_target_properties(libmad::mad
PROPERTIES
INTERFACE_INCLUDE_DIRECTORIES ${LIBMAD_INCLUDE_DIR}
IMPORTED_LOCATION ${LIBMAD_LIBRARY}
)
endif()
-61
View File
@@ -1,61 +0,0 @@
find_package(ZLIB REQUIRED)
find_path(LIBZIP_INCLUDE_DIR NAMES zip.h)
mark_as_advanced(LIBZIP_INCLUDE_DIR)
find_library(LIBZIP_LIBRARY NAMES zip)
mark_as_advanced(LIBZIP_LIBRARY)
get_filename_component(_libzip_libdir ${LIBZIP_LIBRARY} DIRECTORY)
find_file(_libzip_pkgcfg libzip.pc
HINTS ${_libzip_libdir} ${LIBZIP_INCLUDE_DIR}/..
PATH_SUFFIXES pkgconfig lib/pkgconfig libdata/pkgconfig
NO_DEFAULT_PATH
)
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(
LIBZIP
REQUIRED_VARS
LIBZIP_LIBRARY
LIBZIP_INCLUDE_DIR
_libzip_pkgcfg
)
if(LIBZIP_FOUND)
if(NOT TARGET libzip::zip)
add_library(libzip::zip UNKNOWN IMPORTED)
set_target_properties(libzip::zip
PROPERTIES
INTERFACE_INCLUDE_DIRECTORIES ${LIBZIP_INCLUDE_DIR}
INTERFACE_LINK_LIBRARIES ZLIB::ZLIB
IMPORTED_LOCATION ${LIBZIP_LIBRARY}
)
# (Ab)use the (always) installed pkgconfig file to check if BZip2 is required
file(STRINGS ${_libzip_pkgcfg} _have_extra_libs REGEX Libs)
if(_have_extra_libs MATCHES "-lbz2")
find_package(BZip2 REQUIRED)
set_property(TARGET libzip::zip APPEND PROPERTY INTERFACE_LINK_LIBRARIES BZip2::BZip2)
endif()
if(_have_extra_libs MATCHES "-lcrypto")
find_package(OpenSSL REQUIRED)
set_property(TARGET libzip::zip APPEND PROPERTY INTERFACE_LINK_LIBRARIES OpenSSL::Crypto)
endif()
if(_have_extra_libs MATCHES "-lgnutls")
find_package(GnuTLS REQUIRED)
set_property(TARGET libzip::zip APPEND PROPERTY INTERFACE_LINK_LIBRARIES GnuTLS::GnuTLS)
endif()
if(_have_extra_libs MATCHES "-lnettle")
find_package(Nettle REQUIRED)
set_property(TARGET libzip::zip APPEND PROPERTY INTERFACE_LINK_LIBRARIES Nettle::Nettle)
endif()
if(_have_extra_libs MATCHES "-llzma")
find_package(LibLZMA REQUIRED)
set_property(TARGET libzip::zip APPEND PROPERTY INTERFACE_LINK_LIBRARIES LibLZMA::LibLZMA)
endif()
if(_have_extra_libs MATCHES "-lz")
find_package(ZLIB REQUIRED)
set_property(TARGET libzip::zip APPEND PROPERTY INTERFACE_LINK_LIBRARIES ZLIB::ZLIB)
endif()
endif()
endif()
-31
View File
@@ -1,31 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
include(FetchContent)
FetchContent_Declare(
stb
GIT_REPOSITORY https://github.com/nothings/stb.git
)
# Fetch stb if not already done
FetchContent_MakeAvailable(stb)
# Find the stb_image.h header
set(STB_INCLUDE_DIR "${stb_SOURCE_DIR}")
set(STB_IMAGE_HEADER "${stb_SOURCE_DIR}/stb_image.h")
if(EXISTS "${STB_IMAGE_HEADER}")
add_library(stb_image INTERFACE)
target_include_directories(stb_image INTERFACE "${STB_INCLUDE_DIR}")
set(STB_IMAGE_FOUND TRUE)
else()
set(STB_IMAGE_FOUND FALSE)
endif()
# Standard CMake variables
set(STB_IMAGE_INCLUDE_DIRS "${STB_INCLUDE_DIR}")
set(STB_IMAGE_LIBRARIES stb_image)
mark_as_advanced(STB_IMAGE_INCLUDE_DIRS STB_IMAGE_LIBRARIES STB_IMAGE_FOUND)
-18
View File
@@ -1,18 +0,0 @@
include(FetchContent)
if(NOT TARGET yyjson)
FetchContent_Declare(
yyjson
GIT_REPOSITORY https://github.com/ibireme/yyjson.git
GIT_TAG 0.12.0
)
FetchContent_MakeAvailable(yyjson)
endif()
# Provide an imported target if not already available
if(NOT TARGET yyjson::yyjson)
add_library(yyjson::yyjson ALIAS yyjson)
endif()
# Mark yyjson as found for find_package compatibility
set(yyjson_FOUND TRUE)
-32
View File
@@ -1,32 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
find_package(cmocka REQUIRED)
# Function to create a test executable from a single .c file
function(dusktest TEST_C_FILE)
# Create target name from file name
get_filename_component(TEST_NAME ${TEST_C_FILE} NAME_WE)
# Create the executable target
add_executable(${TEST_NAME})
# Setup some compiler definitions.
target_compile_definitions(${TEST_NAME} PRIVATE
DUSK_TEST_ASSERT=1
)
# Add the test file
target_sources(${TEST_NAME} PRIVATE ${TEST_C_FILE})
# Add sources, both common and test-specific
target_include_directories(${TEST_NAME} PUBLIC ${CMAKE_CURRENT_LIST_DIR} ${DUSK_TEST_DIR})
# Link against DuskCore and cmocka
target_link_libraries(${TEST_NAME} PUBLIC ${DUSK_LIBRARY_TARGET_NAME} cmocka)
# Add as a CTest
add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME})
endfunction()
+43
View File
@@ -0,0 +1,43 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# All user facing CMake options and their defaults. Included by the root
# CMakeLists.txt after the platform's target.cmake, which supplies the
# DUSK_DEFAULT_* values used below. DUSK_TARGET_SYSTEM itself is declared by
# determineplatform.cmake.
# General
option(DUSK_BUILD_TESTS "Enable tests." OFF)
option(DUSK_NETWORK "Enable network support." ON)
# Game metadata
set(DUSK_GAME "duskexample" CACHE STRING
"Directory under src/ containing the game to build (provides game/game.c)."
)
set(DUSK_GAME_NAME "Dusk" CACHE STRING "Game display name.")
set(DUSK_GAME_AUTHOR "YourWishes" CACHE STRING "Game author / coder.")
if(NOT DEFINED DUSK_VERSION)
string(TIMESTAMP DUSK_VERSION "debug-%y%m%d%H%M%S")
endif()
# Platform dependent defaults
option(DUSK_ASSERTIONS_FAKED
"Compile assertions out (they do nothing)."
${DUSK_DEFAULT_ASSERTIONS_FAKED}
)
option(DUSK_BACKTRACE
"Log a stack trace on assertion failure."
${DUSK_DEFAULT_BACKTRACE}
)
option(DUSK_TRACK_MEMORY
"Track memoryAllocate() call sites to help find memory leaks."
${DUSK_DEFAULT_TRACK_MEMORY}
)
# Tests mock assertions, so they can never be faked.
if(DUSK_BUILD_TESTS AND DUSK_ASSERTIONS_FAKED)
message(STATUS "DUSK_BUILD_TESTS: forcing DUSK_ASSERTIONS_FAKED off")
set(DUSK_ASSERTIONS_FAKED OFF CACHE BOOL "" FORCE)
endif()
+26
View File
@@ -0,0 +1,26 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Platform setup for the ps2 target, included by the root CMakeLists.txt.
# Only sets defaults and definitions; the root applies them.
# Source directories to build for this platform.
set(DUSK_PLATFORM_DIRS duskps2)
# Default values for the shared options declared in the root CMakeLists.txt.
# Assertions are faked (compiled out) unless this is a Debug build.
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
set(DUSK_DEFAULT_ASSERTIONS_FAKED OFF)
else()
set(DUSK_DEFAULT_ASSERTIONS_FAKED ON)
endif()
set(DUSK_DEFAULT_BACKTRACE OFF)
set(DUSK_DEFAULT_TRACK_MEMORY OFF)
# Platform definitions.
set(DUSK_PLATFORM_DEFINITIONS
DUSK_PLATFORM_ENDIAN_LITTLE
DUSK_PS2
)
+27
View File
@@ -0,0 +1,27 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Platform setup for the psp target, included by the root CMakeLists.txt.
# Only sets defaults and definitions; the root applies them.
# Source directories to build for this platform.
set(DUSK_PLATFORM_DIRS duskpsp)
# Default values for the shared options declared in the root CMakeLists.txt.
# Assertions are faked (compiled out) unless this is a Debug build.
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
set(DUSK_DEFAULT_ASSERTIONS_FAKED OFF)
else()
set(DUSK_DEFAULT_ASSERTIONS_FAKED ON)
endif()
set(DUSK_DEFAULT_BACKTRACE OFF)
set(DUSK_DEFAULT_TRACK_MEMORY OFF)
# Platform definitions.
set(DUSK_PLATFORM_DEFINITIONS
DUSK_PLATFORM_ENDIAN_LITTLE
DUSK_PSP
DUSK_THREAD_PTHREAD
)
-81
View File
@@ -1,81 +0,0 @@
# Build type: DOL (SD/USB via libfat) or ISO (DVD disc via libogc DVD driver)
set(DUSK_DOLPHIN_BUILD_TYPE "DOL" CACHE STRING "Dolphin asset source: DOL (SD/USB) or ISO (DVD disc)")
set_property(CACHE DUSK_DOLPHIN_BUILD_TYPE PROPERTY STRINGS "DOL" "ISO")
# Numeric tokens so #if DUSK_DOLPHIN_BUILD_TYPE == DOL works in C.
# DUSK_DOLPHIN_BUILD_TYPE is passed without quotes so it expands to the identifier.
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_DOLPHIN
DUSK_INPUT_GAMEPAD
# DUSK_DISPLAY_WIDTH=640
# DUSK_DISPLAY_HEIGHT=480
DUSK_DISPLAY_WIDTH=320
DUSK_DISPLAY_HEIGHT=240
DUSK_DISPLAY_OVERSCAN=16
DUSK_THREAD_PTHREAD
DOL=1
ISO=2
DUSK_DOLPHIN_BUILD_TYPE=${DUSK_DOLPHIN_BUILD_TYPE}
# GameCube/Wii PowerPC is always big-endian; declare it at compile time
# like every other target instead of relying on endian.h's runtime probe.
DUSK_PLATFORM_ENDIAN_BIG
)
# Custom compiler flags
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-rtti -fno-exceptions")
# Disable all warnings
target_compile_options(${DUSK_LIBRARY_TARGET_NAME} PRIVATE -w)
# cglm: fetched at source level via Findcglm.cmake (FetchContent, headers only)
find_package(cglm REQUIRED)
# Pre-create ZLIB::ZLIB so any downstream cmake module that resolves it
# (FindZLIB, pkg-config IMPORTED_TARGET Requires processing) gets plain -lz
# rather than an unresolvable IMPORTED target that the PPC linker rejects.
if(NOT TARGET ZLIB::ZLIB)
add_library(ZLIB::ZLIB INTERFACE IMPORTED GLOBAL)
set_target_properties(ZLIB::ZLIB PROPERTIES INTERFACE_LINK_LIBRARIES "z")
endif()
# Mark libzip as found so src/dusk/CMakeLists.txt skips Findlibzip.cmake.
# Findlibzip.cmake calls find_package(ZLIB) which can recreate a broken
# ZLIB::ZLIB IMPORTED target, bypassing the shim above.
set(libzip_FOUND TRUE CACHE BOOL "libzip found (devkitpro portlibs)" FORCE)
# Locate zip.h in the devkitpro sysroot (respects CMAKE_FIND_ROOT_PATH).
find_path(_dusk_zip_inc NAMES zip.h)
if(_dusk_zip_inc)
target_include_directories(${DUSK_LIBRARY_TARGET_NAME} PRIVATE "${_dusk_zip_inc}")
endif()
# Link libraries.
# zip/z/lzma use target_link_options (raw flags) to bypass cmake target
# resolution - pkg-config-generated targets for these carry ZLIB::ZLIB in
# INTERFACE_LINK_LIBRARIES which breaks the PPC link step.
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
cglm
m
zip
bz2
zstd
z
lzma
asnd
mad
)
if(DUSK_DOLPHIN_BUILD_TYPE STREQUAL "ISO")
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC DUSK_DOLPHIN_BUILD_ISO)
else()
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE fat)
endif()
# Postbuild: ELF -> DOL
set(DUSK_BINARY_TARGET_NAME_DOL "${DUSK_BUILD_DIR}/Dusk.dol")
add_custom_command(TARGET ${DUSK_BINARY_TARGET_NAME} POST_BUILD
COMMAND elf2dol
"$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>"
"${DUSK_BINARY_TARGET_NAME_DOL}"
COMMENT "Generating ${DUSK_BINARY_TARGET_NAME_DOL} from ${DUSK_BINARY_TARGET_NAME}"
)
-24
View File
@@ -1,24 +0,0 @@
include(cmake/targets/dolphin.cmake)
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_GAMECUBE
)
# Link libraries
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
# bba
)
# ISO post-build: produce NTSC-J, NTSC-U and PAL disc images
if(DUSK_DOLPHIN_BUILD_TYPE STREQUAL "ISO")
add_custom_command(TARGET ${DUSK_BINARY_TARGET_NAME} POST_BUILD
COMMAND ${Python3_EXECUTABLE}
"${CMAKE_SOURCE_DIR}/tools/makedolphiniso.py"
"GCN"
"${DUSK_BINARY_TARGET_NAME_DOL}"
"${DUSK_ASSETS_ZIP}"
"${DUSK_GAME_NAME}"
"${DUSK_BUILD_DIR}"
COMMENT "Building GameCube ISO images (NTSC-J, NTSC-U, PAL)"
)
endif()
-46
View File
@@ -1,46 +0,0 @@
# Find link platform-specific libraries
set(OpenGL_GL_PREFERENCE LEGACY)
find_package(SDL2 REQUIRED)
find_library(EGL_LIB EGL REQUIRED)
find_library(GL_LIB GL REQUIRED)
find_package(OpenGL REQUIRED)
# Setup endianess at compile time to optimize.
include(TestBigEndian)
test_big_endian(IS_BIG_ENDIAN)
if(IS_BIG_ENDIAN)
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_PLATFORM_ENDIAN_BIG
)
else()
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_PLATFORM_ENDIAN_LITTLE
)
endif()
# Link required libraries.
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
SDL2
pthread
OpenGL::GLES2
${GL_LIB}
${EGL_LIB}
m
)
# Define platform-specific macros.
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_SDL2
DUSK_OPENGL
DUSK_OPENGL_ES
DUSK_LINUX
DUSK_KNULLI
DUSK_DISPLAY_SIZE_DYNAMIC
DUSK_DISPLAY_WIDTH_DEFAULT=640
DUSK_DISPLAY_HEIGHT_DEFAULT=480
DUSK_DISPLAY_SCREEN_HEIGHT=240
DUSK_INPUT_KEYBOARD
DUSK_INPUT_POINTER
DUSK_INPUT_GAMEPAD
DUSK_TIME_DYNAMIC
)
-49
View File
@@ -1,49 +0,0 @@
# Find link platform-specific libraries
find_package(SDL2 REQUIRED)
find_package(OpenGL REQUIRED)
# find_package(CURL REQUIRED)
# Setup endianess at compile time to optimize.
include(TestBigEndian)
test_big_endian(IS_BIG_ENDIAN)
if(IS_BIG_ENDIAN)
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_PLATFORM_ENDIAN_BIG
)
else()
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_PLATFORM_ENDIAN_LITTLE
)
endif()
# Link required libraries.
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
SDL2
pthread
OpenGL::GL
GL
m
# CURL::libcurl
)
set(DUSK_BACKTRACE ON CACHE BOOL "Enable backtrace support for assert failures.")
set(DUSK_TRACK_MEMORY ON CACHE BOOL "Track memoryAllocate() call sites to help find memory leaks.")
# Define platform-specific macros.
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_SDL2
DUSK_OPENGL
DUSK_CONSOLE_POSIX
# DUSK_OPENGL_LEGACY
DUSK_LINUX
DUSK_DISPLAY_SIZE_DYNAMIC
DUSK_DISPLAY_WIDTH_DEFAULT=854
DUSK_DISPLAY_HEIGHT_DEFAULT=480
DUSK_DISPLAY_SCREEN_HEIGHT=240
DUSK_INPUT_KEYBOARD
DUSK_INPUT_POINTER
DUSK_INPUT_GAMEPAD
DUSK_TIME_DYNAMIC
DUSK_NETWORK_IPV6
DUSK_THREAD_PTHREAD
)
-137
View File
@@ -1,137 +0,0 @@
set(CMAKE_AR "$ENV{PSPDEV}/bin/psp-ar" CACHE FILEPATH "" FORCE)
set(CMAKE_RANLIB "$ENV{PSPDEV}/bin/psp-ranlib" CACHE FILEPATH "" FORCE)
set(CMAKE_C_COMPILER_AR "$ENV{PSPDEV}/bin/psp-ar" CACHE FILEPATH "" FORCE)
set(CMAKE_C_COMPILER_RANLIB "$ENV{PSPDEV}/bin/psp-ranlib" CACHE FILEPATH "" FORCE)
set(CMAKE_C_ARCHIVE_CREATE "$ENV{PSPDEV}/bin/psp-ar qc <TARGET> <LINK_FLAGS> <OBJECTS>")
set(CMAKE_C_ARCHIVE_APPEND "$ENV{PSPDEV}/bin/psp-ar q <TARGET> <LINK_FLAGS> <OBJECTS>")
set(CMAKE_C_ARCHIVE_FINISH "$ENV{PSPDEV}/bin/psp-ranlib <TARGET>")
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION OFF CACHE BOOL "" FORCE)
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_C OFF CACHE BOOL "" FORCE)
find_package(SDL2 REQUIRED)
find_package(OpenGL REQUIRED)
target_link_libraries(${DUSK_BINARY_TARGET_NAME} PUBLIC
${SDL2_LIBRARIES}
SDL2
pthread
OpenGL::GL
zip
bz2
z
mbedtls
mbedcrypto
lzma
m
pspdebug
pspdisplay
pspge
pspctrl
pspgu
pspaudio
pspaudiolib
pspmp3
psputility
pspvfpu
pspvram
psphprm
pspnet
pspnet_inet
pspnet_apctl
psphttp
pspssl
)
target_include_directories(${DUSK_BINARY_TARGET_NAME} PRIVATE
${SDL2_INCLUDE_DIRS}
)
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
DUSK_SDL2
DUSK_OPENGL
DUSK_PSP
DUSK_INPUT_GAMEPAD
DUSK_PLATFORM_ENDIAN_LITTLE
DUSK_OPENGL_LEGACY
DUSK_DISPLAY_WIDTH=480
DUSK_DISPLAY_HEIGHT=272
DUSK_THREAD_PTHREAD
DUSK_TIME_DYNAMIC
DUSK_DISPLAY_OVERSCAN=6
)
# logDebug()/logError() additionally write every call to a file on the
# Memory Stick (ms0:/PSP/GAME/Dusk/*.log) - a real, measured source of
# per-call hitching (fopen+write+fclose every log line), not just a
# theoretical cost. Default off so a normal build doesn't pay it; opt in
# with -DDUSK_PSP_LOG_FILE=ON when the log file itself is actually needed
# (e.g. no way to see stdout on real hardware).
set(DUSK_PSP_LOG_FILE OFF CACHE BOOL "Also write PSP debug/error logs to ms0:/PSP/GAME/Dusk/*.log (slow - Memory Stick I/O per call).")
if(DUSK_PSP_LOG_FILE)
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
DUSK_PSP_LOG_FILE
)
endif()
if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
DUSK_ASSERTIONS_FAKED
)
endif()
# Generate PARAM.SFO as a normal tracked build output (instead of letting
# create_pbp_file() auto-generate + delete it) so it can be reused below by
# a properly dependency-tracked EBOOT.PBP repack step.
set(DUSK_PSP_PARAM_SFO "${DUSK_BUILD_DIR}/PARAM.SFO")
add_custom_command(
OUTPUT "${DUSK_PSP_PARAM_SFO}"
COMMAND "$ENV{PSPDEV}/bin/mksfoex" "-d" "MEMSIZE=1" "-s" "APP_VER=01.00"
"${DUSK_BINARY_TARGET_NAME}" "${DUSK_PSP_PARAM_SFO}"
COMMENT "Generating PARAM.SFO for ${DUSK_BINARY_TARGET_NAME}"
VERBATIM
)
add_custom_target(DuskPspParamSfo DEPENDS "${DUSK_PSP_PARAM_SFO}")
# create_pbp_file()'s own POST_BUILD chain (below) also consumes
# DUSK_PSP_PARAM_SFO, so make sure it exists before that chain runs.
add_dependencies(${DUSK_BINARY_TARGET_NAME} DuskPspParamSfo)
# Postbuild, create .pbp file for PSP.
create_pbp_file(
TARGET "${DUSK_BINARY_TARGET_NAME}"
ICON_PATH NULL
BACKGROUND_PATH NULL
PREVIEW_PATH NULL
TITLE "${DUSK_BINARY_TARGET_NAME}"
PSAR_PATH ${DUSK_ASSETS_ZIP}
VERSION 01.00
SFO_PATH "${DUSK_PSP_PARAM_SFO}"
OUTPUT_DIR "${DUSK_BUILD_DIR}"
)
# CreatePBP.cmake's pack-pbp step is a POST_BUILD command tied to the
# executable target, so it only reruns when the ELF itself relinks. That
# means regenerating dusk.dsk (assets) alone, without touching any C
# source, silently leaves EBOOT.PBP embedding a stale asset pak. Repack it
# here as a normal file-tracked custom command depending on both the
# executable and the asset zip, so EBOOT.PBP always reflects the current
# assets even when nothing else about the build changed.
set(DUSK_PSP_EBOOT "${DUSK_BUILD_DIR}/EBOOT.PBP")
if(BUILD_PRX)
set(DUSK_PSP_EXECUTABLE "$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>.prx")
else()
set(DUSK_PSP_EXECUTABLE "$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>")
endif()
add_custom_command(
OUTPUT "${DUSK_PSP_EBOOT}"
COMMAND "$ENV{PSPDEV}/bin/pack-pbp" "${DUSK_PSP_EBOOT}" "${DUSK_PSP_PARAM_SFO}"
"NULL" "NULL" "NULL" "NULL" "NULL"
"${DUSK_PSP_EXECUTABLE}" "${DUSK_ASSETS_ZIP}"
DEPENDS
"$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>"
"${DUSK_PSP_PARAM_SFO}"
"${DUSK_ASSETS_ZIP}"
COMMENT "Repacking EBOOT.PBP (tracks executable + asset pak freshness)"
VERBATIM
)
add_custom_target(DuskPspEbootRepack ALL DEPENDS "${DUSK_PSP_EBOOT}")
-88
View File
@@ -1,88 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
if(NOT DEFINED ENV{VITASDK})
message(FATAL_ERROR "VITASDK environment variable is not set.")
endif()
include("$ENV{VITASDK}/share/vita.cmake" REQUIRED)
set(VITA_APP_NAME "Dusk")
set(VITA_TITLEID "DUSK00001")
set(VITA_VERSION "01.00")
find_package(SDL2 REQUIRED)
# Custom flags for cglm
set(CGLM_SHARED OFF CACHE BOOL "Build cglm shared" FORCE)
set(CGLM_STATIC ON CACHE BOOL "Build cglm static" FORCE)
find_package(cglm REQUIRED)
# Link libraries
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
${SDL2_LIBRARIES}
cglm
SDL2
SDL2main
zip
bz2
z
zstd
crypto
lzma
m
pthread
stdc++
vitaGL
mathneon
vitashark
kubridge_stub
SceAppMgr_stub
SceAudio_stub
SceCtrl_stub
SceCommonDialog_stub
SceDisplay_stub
SceKernelDmacMgr_stub
SceGxm_stub
SceShaccCg_stub
SceSysmodule_stub
ScePower_stub
SceTouch_stub
SceVshBridge_stub
SceIofilemgr_stub
SceShaccCgExt
libtaihen_stub.a
# SceKernel_stub
SceAppUtil_stub
SceHid_stub
SceRtc_stub
)
target_include_directories(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
${SDL2_INCLUDE_DIRS}
)
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_SDL2
DUSK_OPENGL
DUSK_VITA
DUSK_INPUT_GAMEPAD
DUSK_PLATFORM_ENDIAN_LITTLE
DUSK_OPENGL_LEGACY
DUSK_DISPLAY_WIDTH=960
DUSK_DISPLAY_HEIGHT=544
)
# Post-build: create SELF from the ELF binary (UNSAFE = homebrew, no signing)
vita_create_self(${DUSK_BINARY_TARGET_NAME}.self ${DUSK_BINARY_TARGET_NAME} UNSAFE)
# Post-build: package SELF + assets into a .vpk installable on the Vita
vita_create_vpk(${DUSK_BINARY_TARGET_NAME}.vpk ${VITA_TITLEID} ${DUSK_BINARY_TARGET_NAME}.self
VERSION ${VITA_VERSION}
NAME ${VITA_APP_NAME}
FILE ${DUSK_ASSETS_ZIP} dusk.dsk
)
-38
View File
@@ -1,38 +0,0 @@
include(cmake/targets/dolphin.cmake)
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_WII
)
# Wii save storage method - see src/duskdolphin/save/savedeviceplatform.h.
set(DUSK_SAVE_WII_METHOD "SD" CACHE STRING
"Wii save storage: NAND (internal storage via ISFS), CARD (GameCube-\
compatible memory card emulation), or SD (SD card via libfat)"
)
set_property(CACHE DUSK_SAVE_WII_METHOD PROPERTY STRINGS "NAND" "CARD" "SD")
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_SAVE_WII_METHOD_${DUSK_SAVE_WII_METHOD}
)
# Generate Homebrew Channel meta.xml from project identity variables
string(TIMESTAMP DUSK_BUILD_DATE "%Y%m%d000000" UTC)
configure_file(
"${CMAKE_SOURCE_DIR}/docker/dolphin/meta.xml.in"
"${DUSK_BUILD_DIR}/meta.xml"
@ONLY
)
# ISO post-build: produce NTSC-J, NTSC-U and PAL disc images
if(DUSK_DOLPHIN_BUILD_TYPE STREQUAL "ISO")
add_custom_command(TARGET ${DUSK_BINARY_TARGET_NAME} POST_BUILD
COMMAND ${Python3_EXECUTABLE}
"${CMAKE_SOURCE_DIR}/tools/makedolphiniso.py"
"WII"
"${DUSK_BINARY_TARGET_NAME_DOL}"
"${DUSK_ASSETS_ZIP}"
"${DUSK_GAME_NAME}"
"${DUSK_BUILD_DIR}"
COMMENT "Building Wii ISO images (NTSC-J, NTSC-U, PAL)"
)
endif()
-29
View File
@@ -1,29 +0,0 @@
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)
set(CMAKE_ASM_COMPILER aarch64-linux-gnu-gcc)
set(CMAKE_SYSROOT /)
set(CMAKE_C_COMPILER_TARGET aarch64-linux-gnu)
set(CMAKE_CXX_COMPILER_TARGET aarch64-linux-gnu)
set(CMAKE_FIND_ROOT_PATH
/usr/aarch64-linux-gnu
/usr/lib/aarch64-linux-gnu
/usr/include/aarch64-linux-gnu
)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(ENV{PKG_CONFIG_LIBDIR} "/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig")
set(ENV{PKG_CONFIG_PATH} "/usr/lib/aarch64-linux-gnu/pkgconfig")
set(CMAKE_PREFIX_PATH "/usr/aarch64-linux-gnu;/usr/lib/aarch64-linux-gnu")
# Optional: helps some Find modules
set(SDL2_DIR "/usr/lib/aarch64-linux-gnu/cmake/SDL2" CACHE PATH "")
+27
View File
@@ -0,0 +1,27 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Platform setup for the vita target, included by the root CMakeLists.txt.
# Only sets defaults and definitions; the root applies them.
# Source directories to build for this platform.
set(DUSK_PLATFORM_DIRS duskvita)
# Default values for the shared options declared in the root CMakeLists.txt.
# Assertions are faked (compiled out) unless this is a Debug build.
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
set(DUSK_DEFAULT_ASSERTIONS_FAKED OFF)
else()
set(DUSK_DEFAULT_ASSERTIONS_FAKED ON)
endif()
set(DUSK_DEFAULT_BACKTRACE OFF)
set(DUSK_DEFAULT_TRACK_MEMORY OFF)
# Platform definitions.
set(DUSK_PLATFORM_DEFINITIONS
DUSK_PLATFORM_ENDIAN_LITTLE
DUSK_VITA
DUSK_THREAD_PTHREAD
)
+21
View File
@@ -0,0 +1,21 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Platform setup for the windows target, included by the root CMakeLists.txt.
# Only sets defaults and definitions; the root applies them.
# Source directories to build for this platform.
set(DUSK_PLATFORM_DIRS duskwindows dusksdl3 duskvulkan)
# Default values for the shared options declared in the root CMakeLists.txt.
set(DUSK_DEFAULT_ASSERTIONS_FAKED OFF)
set(DUSK_DEFAULT_BACKTRACE OFF)
set(DUSK_DEFAULT_TRACK_MEMORY ON)
# Platform definitions.
set(DUSK_PLATFORM_DEFINITIONS
DUSK_PLATFORM_ENDIAN_LITTLE
DUSK_WINDOWS
)
-7
View File
@@ -1,7 +0,0 @@
FROM ghcr.io/extremscorner/libogc2
WORKDIR /workdir
RUN apt update && \
dkp-pacman -Syu --noconfirm && \
apt install -y python3 python3-pip python3-polib python3-pil python3-dotenv python3-pyqt5 python3-opengl xorriso && \
dkp-pacman -S --needed --noconfirm gamecube-sdl2 ppc-liblzma ppc-libzip libogc2 gamecube-tools ppc-libmad ppc-zlib-ng ppc-liblzma ppc-bzip2 ppc-zstd
VOLUME ["/workdir"]
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<app version="1">
<name>@DUSK_GAME_NAME@</name>
<version>@PROJECT_VERSION@</version>
<release_date>@DUSK_BUILD_DATE@</release_date>
<coder>@DUSK_GAME_AUTHOR@</coder>
<short_description>@DUSK_GAME_SHORT_DESCRIPTION@</short_description>
<long_description>@DUSK_GAME_LONG_DESCRIPTION@</long_description>
<ahb_access/>
</app>
-35
View File
@@ -1,35 +0,0 @@
FROM debian:trixie
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /workdir
RUN dpkg --add-architecture arm64 && \
apt-get update && \
apt-get install -y --no-install-recommends \
crossbuild-essential-arm64 \
ca-certificates \
pkg-config \
cmake \
make \
ninja-build \
git \
file \
python3 \
python3-pip \
python3-polib \
python3-pil \
python3-dotenv \
python3-pyqt5 \
python3-opengl \
liblua5.4-dev:arm64 \
xz-utils:arm64 \
libbz2-dev:arm64 \
zlib1g-dev:arm64 \
libzip-dev:arm64 \
libssl-dev:arm64 \
libsdl2-dev:arm64 \
liblzma-dev:arm64 \
libopengl0:arm64 \
libgl1:arm64 \
libegl1:arm64 \
libgles2:arm64 \
libgl1-mesa-dev:arm64 && \
rm -rf /var/lib/apt/lists/*
+3 -22
View File
@@ -1,24 +1,5 @@
FROM ubuntu:latest FROM ubuntu:latest
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /workdir WORKDIR /workdir
RUN apt-get update COPY scripts/linux/install.sh /tmp/install.sh
RUN apt-get install -y \ RUN chmod +x /tmp/install.sh && /tmp/install.sh && rm /tmp/install.sh
build-essential \
cmake \
python3 \
python3-pip \
python3-polib \
python3-pil \
libsdl2-dev \
libgl1-mesa-dev \
libzip-dev \
python3-dotenv \
python3-pyqt5 \
python3-opengl \
xz-utils \
liblzma-dev \
libbz2-dev \
zlib1g-dev \
libzip-dev \
libbz2-dev \
git \
libssl-dev
+2 -4
View File
@@ -1,7 +1,5 @@
FROM pspdev/pspdev:latest FROM pspdev/pspdev:latest
WORKDIR /workdir WORKDIR /workdir
RUN apk add --no-cache \ COPY scripts/psp/install.sh /tmp/install.sh
python3 \ RUN chmod +x /tmp/install.sh && /tmp/install.sh && rm /tmp/install.sh
py3-pip \
py3-dotenv
VOLUME ["/workdir"] VOLUME ["/workdir"]
-13
View File
@@ -1,13 +0,0 @@
FROM vitasdk/vitasdk:latest
WORKDIR /workdir
# Install vitaGL and its dependencies (vitashark, SceShaccCg) via vdpm
RUN which vdpm
# Install Python (needed for Dusk code generation tools)
RUN apk add --no-cache \
python3 \
py3-pip \
py3-dotenv
VOLUME ["/workdir"]
-159
View File
@@ -1,159 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
"use strict";
// ChunkTerrain - JS port of the terrain-quad generation performed by
// tools/asset/chunk/__main__.py (build_terrain_verts / _tile_quad /
// _RAMP_CORNERS) so the editor's 3D preview can show the same geometry the
// Python compiler will bake into the chunk's terrain mesh, without waiting
// for a compile pass. Vertex layout matches DMF: interleaved [u, v, x, y, z].
const ChunkTerrain = (() => {
const CHUNK_WIDTH = 16;
const CHUNK_HEIGHT = 16;
const CHUNK_DEPTH = 4;
const TILE_COUNT = CHUNK_WIDTH * CHUNK_HEIGHT * CHUNK_DEPTH;
// 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.
const WORLD_LAYER_HEIGHT = Math.SQRT1_2;
const TILE_SHAPE_NULL = 0;
const TILE_SHAPE_GROUND = 1;
const TILE_SHAPE_RAMP_NORTH = 2;
const TILE_SHAPE_RAMP_EAST = 3;
const TILE_SHAPE_RAMP_SOUTH = 4;
const TILE_SHAPE_RAMP_WEST = 5;
const TILE_SHAPE_RAMP_NORTHEAST = 6;
const TILE_SHAPE_RAMP_NORTHWEST = 7;
const TILE_SHAPE_RAMP_SOUTHEAST = 8;
const TILE_SHAPE_RAMP_SOUTHWEST = 9;
const TILE_SHAPE_RAMP_NORTHEAST_INNER = 10;
const TILE_SHAPE_RAMP_NORTHWEST_INNER = 11;
const TILE_SHAPE_RAMP_SOUTHEAST_INNER = 12;
const TILE_SHAPE_RAMP_SOUTHWEST_INNER = 13;
// 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.
const 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],
[TILE_SHAPE_RAMP_SOUTH]: [1.0, 1.0, 0.0, 0.0],
[TILE_SHAPE_RAMP_EAST]: [0.0, 1.0, 1.0, 0.0],
[TILE_SHAPE_RAMP_WEST]: [1.0, 0.0, 0.0, 1.0],
[TILE_SHAPE_RAMP_NORTHEAST]: [0.0, 0.0, 1.0, 0.0],
[TILE_SHAPE_RAMP_NORTHWEST]: [0.0, 0.0, 0.0, 1.0],
[TILE_SHAPE_RAMP_SOUTHEAST]: [0.0, 1.0, 0.0, 0.0],
[TILE_SHAPE_RAMP_SOUTHWEST]: [1.0, 0.0, 0.0, 0.0],
[TILE_SHAPE_RAMP_NORTHEAST_INNER]: [0.0, 1.0, 1.0, 1.0],
[TILE_SHAPE_RAMP_NORTHWEST_INNER]: [1.0, 0.0, 1.0, 1.0],
[TILE_SHAPE_RAMP_SOUTHEAST_INNER]: [1.0, 1.0, 1.0, 0.0],
[TILE_SHAPE_RAMP_SOUTHWEST_INNER]: [1.0, 1.0, 0.0, 1.0],
};
function tileIndex(x, y, z) {
return x + y * CHUNK_WIDTH + z * CHUNK_WIDTH * CHUNK_HEIGHT;
}
// 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).
function pushTileQuad(out, u0, u1, v0, v1, fx, fy, swZ, seZ, neZ, nwZ) {
out.push(
u0, v0, fx, fy, swZ,
u1, v0, fx + 1, fy, seZ,
u1, v1, fx + 1, fy + 1, neZ,
u0, v0, fx, fy, swZ,
u1, v1, fx + 1, fy + 1, neZ,
u0, v1, fx, fy + 1, nwZ,
);
}
// Generate terrain quads for every non-null tile in a flat tiles array
// (length TILE_COUNT, indexed via tileIndex). Returns a Float32Array of
// interleaved [u, v, x, y, z] vertices, matching DMF.VERTEX_FLOATS.
function buildTerrainVerts(tiles) {
const out = [];
for(let z = 0; z < CHUNK_DEPTH; z++) {
for(let y = 0; y < CHUNK_HEIGHT; y++) {
for(let x = 0; x < CHUNK_WIDTH; x++) {
const type = tiles[tileIndex(x, y, z)] || TILE_SHAPE_NULL;
const corners = RAMP_CORNERS[type];
if(!corners) continue;
const u0 = x / CHUNK_WIDTH;
const u1 = (x + 1) / CHUNK_WIDTH;
const v0 = y / CHUNK_HEIGHT;
const v1 = (y + 1) / CHUNK_HEIGHT;
const [sw, se, ne, nw] = corners;
pushTileQuad(
out, u0, u1, v0, v1, x, y,
(z + sw) * WORLD_LAYER_HEIGHT,
(z + se) * WORLD_LAYER_HEIGHT,
(z + ne) * WORLD_LAYER_HEIGHT,
(z + nw) * WORLD_LAYER_HEIGHT
);
}
}
}
return new Float32Array(out);
}
// Four edges (SW-SE, SE-NE, NE-NW, NW-SW) of a tile's quad as line
// segments, lifted slightly above the terrain surface to avoid
// z-fighting - same corner-height convention as pushTileQuad().
function pushTileGridLines(out, fx, fy, swZ, seZ, neZ, nwZ) {
const eps = 0.01;
const sw = [fx, fy, swZ + eps];
const se = [fx + 1, fy, seZ + eps];
const ne = [fx + 1, fy + 1, neZ + eps];
const nw = [fx, fy + 1, nwZ + eps];
const edges = [sw, se, se, ne, ne, nw, nw, sw];
for(const [x, y, z] of edges) out.push(0, 0, x, y, z);
}
// Generate grid-outline line segments for every non-null tile, following
// the same per-corner heights as buildTerrainVerts() so lines hug ramps
// instead of cutting through them. Returns a Float32Array of interleaved
// [u, v, x, y, z] vertices meant to be drawn with gl.LINES (pairs).
function buildGridLines(tiles) {
const out = [];
for(let z = 0; z < CHUNK_DEPTH; z++) {
for(let y = 0; y < CHUNK_HEIGHT; y++) {
for(let x = 0; x < CHUNK_WIDTH; x++) {
const type = tiles[tileIndex(x, y, z)] || TILE_SHAPE_NULL;
const corners = RAMP_CORNERS[type];
if(!corners) continue;
const [sw, se, ne, nw] = corners;
pushTileGridLines(
out, x, y,
(z + sw) * WORLD_LAYER_HEIGHT,
(z + se) * WORLD_LAYER_HEIGHT,
(z + ne) * WORLD_LAYER_HEIGHT,
(z + nw) * WORLD_LAYER_HEIGHT
);
}
}
}
return new Float32Array(out);
}
return Object.freeze({
CHUNK_WIDTH, CHUNK_HEIGHT, CHUNK_DEPTH, TILE_COUNT, WORLD_LAYER_HEIGHT,
TILE_SHAPE_NULL, TILE_SHAPE_GROUND,
TILE_SHAPE_RAMP_NORTH, TILE_SHAPE_RAMP_EAST,
TILE_SHAPE_RAMP_SOUTH, TILE_SHAPE_RAMP_WEST,
TILE_SHAPE_RAMP_NORTHEAST, TILE_SHAPE_RAMP_NORTHWEST,
TILE_SHAPE_RAMP_SOUTHEAST, TILE_SHAPE_RAMP_SOUTHWEST,
TILE_SHAPE_RAMP_NORTHEAST_INNER, TILE_SHAPE_RAMP_NORTHWEST_INNER,
TILE_SHAPE_RAMP_SOUTHEAST_INNER, TILE_SHAPE_RAMP_SOUTHWEST_INNER,
RAMP_CORNERS, tileIndex, buildTerrainVerts, buildGridLines,
});
})();
-64
View File
@@ -1,64 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
"use strict";
// DMF - Dusk Mesh Format
//
// Header (12 bytes):
// [0-3] "DMF\0" magic
// [4-7] uint32 version (little-endian)
// [8-11] uint32 vertCount (little-endian)
//
// Followed by vertCount vertices, each 20 bytes:
// [0-7] float32[2] uv (little-endian)
// [8-19] float32[3] pos (little-endian)
const DMF = (() => {
const MAGIC = [0x44, 0x4d, 0x46, 0x00]; // "DMF\0"
const VERSION = 1;
const HEADER_SIZE = 12;
const VERTEX_FLOATS = 5; // uv[2] + pos[3]
const VERTEX_SIZE = VERTEX_FLOATS * 4;
// Decode a DMF ArrayBuffer into { version, vertexCount, floats }, where
// floats is a Float32Array of tightly interleaved [u, v, x, y, z] per
// vertex - ready to hand straight to gl.bufferData().
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 DMF");
if(
bytes[0] !== MAGIC[0] || bytes[1] !== MAGIC[1] ||
bytes[2] !== MAGIC[2] || bytes[3] !== MAGIC[3]
) {
throw new Error("Invalid DMF magic bytes - not a DMF file");
}
const version = view.getUint32(4, true);
if(version !== VERSION) {
throw new Error(`Unsupported DMF version: ${version}`);
}
const vertexCount = view.getUint32(8, true);
const expected = HEADER_SIZE + vertexCount * VERTEX_SIZE;
if(bytes.length < expected) {
throw new Error(`DMF vertex data truncated (expected ${expected} bytes, got ${bytes.length})`);
}
const floats = new Float32Array(vertexCount * VERTEX_FLOATS);
for(let i = 0; i < floats.length; i++) {
floats[i] = view.getFloat32(HEADER_SIZE + i * 4, true);
}
return { version, vertexCount, floats };
}
return Object.freeze({
decode,
VERSION, HEADER_SIZE, VERTEX_FLOATS, VERTEX_SIZE,
});
})();
-6
View File
@@ -1,6 +0,0 @@
<div class="row">
<div class="col-12">
<h1>Dusk Map Editor</h1>
<p class="text-muted">Browse and edit project assets.</p>
</div>
</div>
-92
View File
@@ -1,92 +0,0 @@
<link rel="stylesheet" href="/map/map.css">
<div class="row g-3">
<div class="col-12">
<div class="d-flex flex-wrap align-items-end gap-2 mb-2">
<div>
<label class="form-label mb-0">X</label>
<input type="number" id="coordX" class="form-control form-control-sm" style="width:5rem" value="0">
</div>
<div>
<label class="form-label mb-0">Y</label>
<input type="number" id="coordY" class="form-control form-control-sm" style="width:5rem" value="0">
</div>
<div>
<label class="form-label mb-0">Z</label>
<input type="number" id="coordZ" class="form-control form-control-sm" style="width:5rem" value="0">
</div>
<button id="btnLoad" type="button" class="btn btn-sm btn-secondary">Load</button>
<div class="dropdown">
<button
class="btn btn-sm btn-outline-secondary dropdown-toggle"
type="button" data-bs-toggle="dropdown"
>Browse</button>
<ul class="dropdown-menu" id="chunkBrowseList"></ul>
</div>
<div class="dpad" title="Navigate to the neighboring chunk">
<button type="button" class="btn btn-sm btn-outline-secondary dpad-n" id="navN" title="North (+Y)">N</button>
<button type="button" class="btn btn-sm btn-outline-secondary dpad-w" id="navW" title="West (-X)">W</button>
<button type="button" class="btn btn-sm btn-outline-secondary dpad-e" id="navE" title="East (+X)">E</button>
<button type="button" class="btn btn-sm btn-outline-secondary dpad-s" id="navS" title="South (-Y)">S</button>
</div>
<div class="d-flex flex-column gap-1">
<button type="button" class="btn btn-sm btn-outline-secondary" id="navUp" title="Up (+Z)">Up</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="navDown" title="Down (-Z)">Down</button>
</div>
<button id="btnSave" type="button" class="btn btn-sm btn-primary ms-auto">
Save &amp; Compile
</button>
</div>
<div id="statusLine" class="small text-muted mb-2">&nbsp;</div>
</div>
<div class="col-12 col-lg-6">
<div class="d-flex align-items-center gap-2 mb-2">
<label class="form-label mb-0">Z-Level</label>
<button type="button" class="btn btn-sm btn-outline-secondary" id="zLevelDown">-</button>
<input type="range" id="zLevel" class="form-range" style="width:150px" min="0" max="3" value="0">
<button type="button" class="btn btn-sm btn-outline-secondary" id="zLevelUp">+</button>
<span id="zLevelLabel">0</span>
</div>
<div class="d-flex gap-3">
<div id="toolPalette" class="tile-palette"></div>
<div id="tilePalette" class="tile-palette"></div>
<canvas id="gridCanvas" class="border tile-grid" width="512" height="512"></canvas>
</div>
<h5 class="mt-3">Meshes</h5>
<ul id="meshList" class="list-group mb-2"></ul>
<div class="d-flex gap-2 align-items-end flex-wrap">
<div style="min-width:220px">
<label class="form-label mb-0">Model</label>
<select id="meshPicker" class="form-select form-select-sm"></select>
</div>
<div>
<label class="form-label mb-0">X</label>
<input id="meshX" type="number" class="form-control form-control-sm" style="width:5rem" value="0">
</div>
<div>
<label class="form-label mb-0">Y</label>
<input id="meshY" type="number" class="form-control form-control-sm" style="width:5rem" value="0">
</div>
<div>
<label class="form-label mb-0">Z</label>
<input id="meshZ" type="number" class="form-control form-control-sm" style="width:5rem" value="0">
</div>
<button id="btnAddMesh" type="button" class="btn btn-sm btn-outline-primary">Add</button>
</div>
</div>
<div class="col-12 col-lg-6">
<p class="small text-muted mb-2">Preview (scroll to zoom)</p>
<canvas id="previewCanvas" class="border" width="512" height="512"></canvas>
</div>
</div>
<script src="/common/dmf.js"></script>
<script src="/common/chunkterrain.js"></script>
<script src="/map/renderer.js"></script>
<script src="/map/map.js"></script>
-65
View File
@@ -1,65 +0,0 @@
/* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
.tile-palette {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 6px;
max-height: 600px;
}
.tile-swatch {
position: relative;
width: 32px;
height: 32px;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
padding: 0;
}
.tile-swatch.active {
border-color: #fff;
}
.tile-swatch-icon {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
.dpad {
display: grid;
grid-template-columns: repeat(3, 2rem);
grid-template-rows: repeat(3, 2rem);
grid-template-areas:
". n ."
"w . e"
". s .";
gap: 2px;
}
.dpad-n { grid-area: n; }
.dpad-w { grid-area: w; }
.dpad-e { grid-area: e; }
.dpad-s { grid-area: s; }
.dpad button { padding: 0; }
.tile-grid {
image-rendering: pixelated;
cursor: crosshair;
touch-action: none;
max-width: 100%;
height: auto;
}
#previewCanvas {
width: 100%;
aspect-ratio: 1 / 1;
height: auto;
}
-920
View File
@@ -1,920 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
"use strict";
(() => {
const SHAPES = [
{ type: ChunkTerrain.TILE_SHAPE_NULL, name: "Erase", color: "#20232a" },
{ type: ChunkTerrain.TILE_SHAPE_GROUND, name: "Ground", color: "#4caf50" },
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTH, name: "Ramp North", color: "#2196f3" },
{ type: ChunkTerrain.TILE_SHAPE_RAMP_EAST, name: "Ramp East", color: "#03a9f4" },
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTH, name: "Ramp South", color: "#00bcd4" },
{ type: ChunkTerrain.TILE_SHAPE_RAMP_WEST, name: "Ramp West", color: "#009688" },
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST, name: "Ramp NE", color: "#ff9800" },
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST, name: "Ramp NW", color: "#ff5722" },
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST, name: "Ramp SE", color: "#9c27b0" },
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST, name: "Ramp SW", color: "#e91e63" },
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST_INNER, name: "Ramp NE Inner", color: "#795548" },
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST_INNER, name: "Ramp NW Inner", color: "#607d8b" },
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST_INNER, name: "Ramp SE Inner", color: "#8bc34a" },
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST_INNER, name: "Ramp SW Inner", color: "#ffc107" },
];
const SHAPE_COLORS = Object.fromEntries(SHAPES.map((s) => [s.type, s.color]));
// Arrow direction per ramp, in canvas/screen space: north is up (dy < 0),
// east is right (dx > 0) - matches renderGrid()'s north-is-up convention.
const SHAPE_DIRECTIONS = {
[ChunkTerrain.TILE_SHAPE_RAMP_NORTH]: { dx: 0, dy: -1 },
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTH]: { dx: 0, dy: 1 },
[ChunkTerrain.TILE_SHAPE_RAMP_EAST]: { dx: 1, dy: 0 },
[ChunkTerrain.TILE_SHAPE_RAMP_WEST]: { dx: -1, dy: 0 },
[ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST]: { dx: 1, dy: -1 },
[ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST]: { dx: -1, dy: -1 },
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST]: { dx: 1, dy: 1 },
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST]: { dx: -1, dy: 1 },
[ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST_INNER]: { dx: 1, dy: -1 },
[ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST_INNER]: { dx: -1, dy: -1 },
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST_INNER]: { dx: 1, dy: 1 },
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST_INNER]: { dx: -1, dy: 1 },
};
const INNER_CORNER_SHAPES = new Set([
ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST_INNER,
ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST_INNER,
ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST_INNER,
ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST_INNER,
]);
// Draws a single-headed arrow from (tailX, tailY) to (tipX, tipY).
function drawArrowSegment(ctx, tailX, tailY, tipX, tipY, size) {
ctx.strokeStyle = "#ffffff";
ctx.fillStyle = "#ffffff";
ctx.lineWidth = Math.max(1, size * 0.08);
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(tailX, tailY);
ctx.lineTo(tipX, tipY);
ctx.stroke();
const headLen = size * 0.18;
const angle = Math.atan2(tipY - tailY, tipX - tailX);
const leftAngle = angle + Math.PI * 0.8;
const rightAngle = angle - Math.PI * 0.8;
ctx.beginPath();
ctx.moveTo(tipX, tipY);
ctx.lineTo(tipX + Math.cos(leftAngle) * headLen, tipY + Math.sin(leftAngle) * headLen);
ctx.lineTo(tipX + Math.cos(rightAngle) * headLen, tipY + Math.sin(rightAngle) * headLen);
ctx.closePath();
ctx.fill();
}
// Draws an arrow centered at (cx, cy) pointing toward the ramp's high
// side, so ramp direction is visible at a glance on both the palette
// swatches and the tile grid. Used for cardinal ramps and outer-corner
// ramps (a single raised corner).
function drawArrow(ctx, cx, cy, size, dx, dy) {
const mag = Math.hypot(dx, dy) || 1;
const ux = dx / mag, uy = dy / mag;
const len = size * 0.32;
drawArrowSegment(ctx, cx - ux * len, cy - uy * len, cx + ux * len, cy + uy * len, size);
}
// Draws a plain right-angle bracket - two line segments meeting at 90
// degrees at the tile's corner, one running along each of the two edges
// adjacent to it - so inner-corner ramps (three corners raised, one
// dropped) read as "the adjacent sides meeting in a 90-degree corner",
// distinct from the single diagonal arrow used for outer-corner ramps.
function drawCornerBracket(ctx, cx, cy, size, dx, dy) {
const cornerDist = size * 0.42;
const armLen = size * 0.34;
const cornerX = cx + dx * cornerDist;
const cornerY = cy + dy * cornerDist;
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = Math.max(1, size * 0.08);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(cornerX - dx * armLen, cornerY);
ctx.lineTo(cornerX, cornerY);
ctx.lineTo(cornerX, cornerY - dy * armLen);
ctx.stroke();
}
// Draws the direction icon for a ramp tile `type` in a `size`x`size` area
// centered at (cx, cy): a diagonal arrow for cardinal/outer-corner ramps,
// or a right-angle bracket for inner-corner ramps. No-op for shapes with
// no direction (ground, erase).
function drawShapeIcon(ctx, cx, cy, size, type) {
const dir = SHAPE_DIRECTIONS[type];
if(!dir) return;
if(INNER_CORNER_SHAPES.has(type)) {
drawCornerBracket(ctx, cx, cy, size, dir.dx, dir.dy);
} else {
drawArrow(ctx, cx, cy, size, dir.dx, dir.dy);
}
}
// Draws a small pencil icon centered in a `size`x`size` canvas.
function drawPencilIcon(ctx, size) {
ctx.save();
ctx.translate(size / 2, size / 2);
ctx.rotate(-Math.PI / 4);
const shaftW = size * 0.18;
const shaftH = size * 0.62;
ctx.fillStyle = "#e0e0e0";
ctx.fillRect(-shaftW / 2, shaftH * 0.28, shaftW, shaftH * 0.22);
ctx.fillStyle = "#f0c419";
ctx.fillRect(-shaftW / 2, -shaftH * 0.5, shaftW, shaftH * 0.78);
ctx.fillStyle = "#8d6e63";
ctx.beginPath();
ctx.moveTo(-shaftW / 2, -shaftH * 0.5);
ctx.lineTo(shaftW / 2, -shaftH * 0.5);
ctx.lineTo(0, -shaftH * 0.5 - shaftW);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// Draws a small paint bucket icon centered in a `size`x`size` canvas.
function drawBucketIcon(ctx, size) {
ctx.save();
ctx.translate(size / 2, size / 2);
const w = size * 0.5, h = size * 0.34;
ctx.strokeStyle = "#e0e0e0";
ctx.lineWidth = Math.max(1, size * 0.06);
ctx.beginPath();
ctx.arc(0, -h * 0.4, w * 0.4, Math.PI, 0);
ctx.stroke();
ctx.fillStyle = "#03a9f4";
ctx.beginPath();
ctx.moveTo(-w / 2, -h * 0.2);
ctx.lineTo(w / 2, -h * 0.2);
ctx.lineTo(w * 0.32, h * 0.6);
ctx.lineTo(-w * 0.32, h * 0.6);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.arc(-w * 0.55, h * 0.75, size * 0.06, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
const TOOLS = [
{ id: "pencil", name: "Pencil", draw: drawPencilIcon },
{ id: "bucket", name: "Paint Bucket", draw: drawBucketIcon },
];
let coord = { x: 0, y: 0, z: 0 };
let tiles = new Int32Array(ChunkTerrain.TILE_COUNT);
let meshes = [];
let chunkExists = false;
let currentZLevel = 0;
let selectedShape = ChunkTerrain.TILE_SHAPE_GROUND;
let zoomWorldH = 20;
let dirty = false;
let hoverTile = null;
let activeTool = "pencil";
let mapRenderer = null;
let terrainMesh = null;
let gridLinesMesh = null;
let terrainTexturePromise = null;
let modelIndex = null;
let neighborChunks = [];
const modelCache = new Map();
const UNDO_LIMIT = 100;
let undoStack = [];
function markDirty() {
dirty = true;
}
function snapshotState() {
return {
tiles: tiles.slice(),
meshes: meshes.map((m) => ({ file: m.file, pos: m.pos.slice() })),
};
}
function pushUndo() {
undoStack.push(snapshotState());
if(undoStack.length > UNDO_LIMIT) undoStack.shift();
}
function undo() {
if(!undoStack.length) return;
const snap = undoStack.pop();
tiles = snap.tiles;
meshes = snap.meshes;
dirty = true;
renderGrid();
renderMeshList();
renderPreview();
updateStatus("Undid last change.");
}
// Returns true if it's OK to proceed (no unsaved changes, or the user
// confirmed discarding them).
function confirmDiscardIfDirty() {
if(!dirty) return true;
return window.confirm("You have unsaved changes to this chunk. Discard them?");
}
function statusLine() {
return document.getElementById("statusLine");
}
function updateStatus(msg, isError) {
const el = statusLine();
el.textContent = msg;
el.classList.toggle("text-danger", !!isError);
el.classList.toggle("text-muted", !isError);
}
async function apiReadJson(path) {
const res = await fetch(`/api/read?path=${encodeURIComponent(path)}`);
const data = await res.json();
return { ok: res.ok, data };
}
async function apiWrite(path, content) {
return fetch(`/api/write?path=${encodeURIComponent(path)}`, {
method: "POST",
body: JSON.stringify({ content }),
});
}
async function apiDelete(path) {
return fetch(`/api/delete?path=${encodeURIComponent(path)}`, { method: "POST" });
}
async function apiCompileChunk(x, y, z) {
const res = await fetch(`/api/compile-chunk?x=${x}&y=${y}&z=${z}`, { method: "POST" });
return res.json();
}
async function apiLs(path) {
const res = await fetch(`/api/ls?path=${encodeURIComponent(path)}`);
return res.json();
}
async function apiFind(path, ext) {
const res = await fetch(`/api/find?path=${encodeURIComponent(path)}&ext=${encodeURIComponent(ext)}`);
return res.json();
}
function base64ToBuffer(base64) {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return bytes.buffer;
}
async function loadImageTexture(assetPath) {
const { ok, data } = await apiReadJson(assetPath);
if(!ok || data.error) return null;
const blob = new Blob([base64ToBuffer(data.content)], { type: "image/png" });
const bitmap = await createImageBitmap(blob);
return mapRenderer.createTextureFromImage(bitmap);
}
async function readDmfVertices(assetPath) {
const { ok, data } = await apiReadJson(assetPath);
if(!ok || data.error) throw new Error(`Failed to read ${assetPath}`);
return DMF.decode(base64ToBuffer(data.content)).floats;
}
async function ensureModelIndex() {
if(modelIndex) return modelIndex;
const res = await apiFind("assets/models", ".json");
modelIndex = new Map();
for(const file of res.files || []) {
const base = file.split("/").pop().replace(/\.json$/, "");
modelIndex.set(base, file);
}
return modelIndex;
}
// Scales a mesh's stored [x, y, z] offset for preview, matching the
// Z scaling mapChunkLoaded() applies at runtime (chunk.meshOffsets[m][2]
// * WORLD_LAYER_HEIGHT) - x/y stay 1:1 since only Z is height-scaled.
function scaledMeshOffset(pos) {
return [pos[0], pos[1], pos[2] * ChunkTerrain.WORLD_LAYER_HEIGHT];
}
// Resolves a chunk mesh entry's bare filename (e.g. "house_5_3.dmf") to
// its model JSON by basename, mirroring find_model() in
// tools/asset/chunk/__main__.py - the chunk JSON only ever stores a bare
// filename, not a path.
async function resolveModelForMeshFile(file) {
await ensureModelIndex();
const base = file.replace(/\.[^.]+$/, "");
const rel = modelIndex.get(base);
if(!rel) return null;
const modelPath = `assets/models/${rel}`;
if(modelCache.has(modelPath)) return modelCache.get(modelPath);
const promise = (async () => {
const { ok, data } = await apiReadJson(modelPath);
if(!ok || data.error) throw new Error(`Failed to read model ${modelPath}`);
const modelDef = JSON.parse(data.content);
const meshFloats = await readDmfVertices(`assets/${modelDef.mesh}`);
const mesh = mapRenderer.createMesh(meshFloats);
const texture = modelDef.texture ? await loadImageTexture(`assets/${modelDef.texture}`) : null;
const colorBytes = modelDef.color || [255, 255, 255, 255];
const color = colorBytes.map((c) => c / 255);
return { mesh, texture, color };
})();
modelCache.set(modelPath, promise);
return promise;
}
const MAP_NAME = "overworld";
function chunkPaths(x, y, z) {
return {
raw: `assetsraw/maps/${MAP_NAME}/chunks/chunk_${x}_${y}_${z}.json`,
dcf: `assets/maps/${MAP_NAME}/chunks/${x}_${y}_${z}.dcf`,
terrainDmf: `assets/maps/${MAP_NAME}/meshes/chunks/chunk_${x}_${y}_${z}_0.dmf`,
terrainModel: `assets/maps/${MAP_NAME}/models/chunks/chunk_${x}_${y}_${z}_0.json`,
};
}
// Loads the 8 same-Z neighbors around `coord` (read-only, for context in
// the 3D preview) and pre-builds their render data so renderPreview()
// doesn't have to rebuild GL buffers on every paint stroke.
async function loadNeighborChunks() {
const offsets = [];
for(let dy = -1; dy <= 1; dy++) {
for(let dx = -1; dx <= 1; dx++) {
if(dx || dy) offsets.push({ dx, dy });
}
}
const results = await Promise.all(offsets.map(async ({ dx, dy }) => {
const nx = coord.x + dx, ny = coord.y + dy, nz = coord.z;
const { ok, data } = await apiReadJson(chunkPaths(nx, ny, nz).raw);
if(!ok || data.error) return null;
const json = JSON.parse(data.content);
const neighborTiles = new Int32Array(ChunkTerrain.TILE_COUNT);
for(const t of json.tiles || []) {
const [tx, ty, tz] = t.pos;
neighborTiles[ChunkTerrain.tileIndex(tx, ty, tz)] = t.type;
}
const neighborMeshes = (json.meshes || []).map((m) => ({ file: m.file, pos: m.pos || [0, 0, 0] }));
const floats = ChunkTerrain.buildTerrainVerts(neighborTiles);
const terrain = floats.length ? { mesh: mapRenderer.createMesh(floats) } : null;
const models = [];
for(const m of neighborMeshes) {
try {
const resolved = await resolveModelForMeshFile(m.file);
if(resolved) models.push({ ...resolved, offset: scaledMeshOffset(m.pos) });
} catch(e) {
console.warn("Failed to load neighbor mesh preview", m.file, e);
}
}
return {
offset: [dx * ChunkTerrain.CHUNK_WIDTH, dy * ChunkTerrain.CHUNK_HEIGHT, 0],
terrain,
models,
};
}));
neighborChunks = results.filter(Boolean);
}
async function loadChunk(x, y, z) {
coord = { x, y, z };
tiles = new Int32Array(ChunkTerrain.TILE_COUNT);
meshes = [];
chunkExists = false;
const { ok, data } = await apiReadJson(chunkPaths(x, y, z).raw);
if(ok && !data.error) {
chunkExists = true;
const json = JSON.parse(data.content);
for(const t of json.tiles || []) {
const [tx, ty, tz] = t.pos;
tiles[ChunkTerrain.tileIndex(tx, ty, tz)] = t.type;
}
meshes = (json.meshes || []).map((m) => ({ file: m.file, pos: m.pos || [0, 0, 0] }));
}
await loadNeighborChunks();
dirty = false;
undoStack = [];
renderGrid();
renderMeshList();
renderPreview();
updateStatus(chunkExists
? `Loaded chunk_${x}_${y}_${z}.json`
: `New (empty) chunk at ${x}, ${y}, ${z} - paint a tile and save to create it.`);
}
async function saveChunk() {
const tilesArr = [];
for(let z = 0; z < ChunkTerrain.CHUNK_DEPTH; z++) {
for(let y = 0; y < ChunkTerrain.CHUNK_HEIGHT; y++) {
for(let x = 0; x < ChunkTerrain.CHUNK_WIDTH; x++) {
const type = tiles[ChunkTerrain.tileIndex(x, y, z)];
if(type && type !== ChunkTerrain.TILE_SHAPE_NULL) {
tilesArr.push({ pos: [x, y, z], type, tile: 0 });
}
}
}
}
const paths = chunkPaths(coord.x, coord.y, coord.z);
if(tilesArr.length === 0 && meshes.length === 0) {
await Promise.all(Object.values(paths).map((p) => apiDelete(p)));
chunkExists = false;
dirty = false;
updateStatus(`Chunk ${coord.x}, ${coord.y}, ${coord.z} is empty - deleted.`);
return;
}
const json = { tiles: tilesArr, meshes: meshes.map((m) => ({ file: m.file, pos: m.pos })) };
await apiWrite(paths.raw, JSON.stringify(json, null, 2));
chunkExists = true;
dirty = false;
updateStatus("Saved. Compiling...");
const result = await apiCompileChunk(coord.x, coord.y, coord.z);
if(result.ok) {
updateStatus(`Compiled OK.\n${result.stdout}`);
} else {
updateStatus(`Compile FAILED (code ${result.returncode}):\n${result.stderr}`, true);
}
}
function buildPalette() {
const el = document.getElementById("tilePalette");
el.innerHTML = "";
for(const s of SHAPES) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "tile-swatch" + (s.type === selectedShape ? " active" : "");
btn.style.background = s.color;
btn.title = s.name;
btn.addEventListener("click", () => {
selectedShape = s.type;
buildPalette();
});
if(SHAPE_DIRECTIONS[s.type]) {
const icon = document.createElement("canvas");
icon.width = 32;
icon.height = 32;
icon.className = "tile-swatch-icon";
drawShapeIcon(icon.getContext("2d"), 16, 16, 32, s.type);
btn.appendChild(icon);
}
el.appendChild(btn);
}
}
function buildToolPalette() {
const el = document.getElementById("toolPalette");
el.innerHTML = "";
for(const tool of TOOLS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "tile-swatch" + (tool.id === activeTool ? " active" : "");
btn.style.background = "#2a2d33";
btn.title = tool.name;
btn.addEventListener("click", () => {
activeTool = tool.id;
buildToolPalette();
});
const icon = document.createElement("canvas");
icon.width = 32;
icon.height = 32;
icon.className = "tile-swatch-icon";
tool.draw(icon.getContext("2d"), 32);
btn.appendChild(icon);
el.appendChild(btn);
}
}
function canvasToTile(e, canvas) {
const rect = canvas.getBoundingClientRect();
const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH;
const px = (e.clientX - rect.left) * (canvas.width / rect.width);
const py = (e.clientY - rect.top) * (canvas.height / rect.height);
const x = Math.floor(px / cell);
const yFromTop = Math.floor(py / cell);
const y = ChunkTerrain.CHUNK_HEIGHT - 1 - yFromTop;
if(x < 0 || x >= ChunkTerrain.CHUNK_WIDTH || y < 0 || y >= ChunkTerrain.CHUNK_HEIGHT) return null;
return { x, y };
}
function renderGrid() {
const canvas = document.getElementById("gridCanvas");
const ctx = canvas.getContext("2d");
const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for(let y = 0; y < ChunkTerrain.CHUNK_HEIGHT; y++) {
for(let x = 0; x < ChunkTerrain.CHUNK_WIDTH; x++) {
const type = tiles[ChunkTerrain.tileIndex(x, y, currentZLevel)];
const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - y) * cell;
ctx.fillStyle = SHAPE_COLORS[type] || "#15171b";
ctx.fillRect(x * cell, sy, cell, cell);
ctx.strokeStyle = "rgba(255, 255, 255, 0.25)";
ctx.strokeRect(x * cell, sy, cell, cell);
drawShapeIcon(ctx, x * cell + cell / 2, sy + cell / 2, cell, type);
}
}
ctx.fillStyle = "#ffffff";
for(const m of meshes) {
const [mx, my, mz] = m.pos;
if(Math.round(mz) !== currentZLevel) continue;
const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - my) * cell;
ctx.beginPath();
ctx.arc(mx * cell + cell / 2, sy + cell / 2, cell * 0.3, 0, Math.PI * 2);
ctx.fill();
}
if(hoverTile) {
const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - hoverTile.y) * cell;
ctx.strokeStyle = "#ffeb3b";
ctx.lineWidth = 2;
ctx.strokeRect(hoverTile.x * cell + 1, sy + 1, cell - 2, cell - 2);
}
}
function hitTestMesh(e, canvas) {
const rect = canvas.getBoundingClientRect();
const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH;
const px = (e.clientX - rect.left) * (canvas.width / rect.width);
const py = (e.clientY - rect.top) * (canvas.height / rect.height);
let closest = -1;
let closestDist = cell * 0.5;
meshes.forEach((m, i) => {
const [mx, my, mz] = m.pos;
if(Math.round(mz) !== currentZLevel) return;
const cx = mx * cell + cell / 2;
const cy = (ChunkTerrain.CHUNK_HEIGHT - 1 - my) * cell + cell / 2;
const dist = Math.hypot(px - cx, py - cy);
if(dist < closestDist) { closestDist = dist; closest = i; }
});
return closest;
}
function moveMeshTo(e, index) {
const canvas = document.getElementById("gridCanvas");
const t = canvasToTile(e, canvas);
if(!t) return;
meshes[index].pos[0] = t.x;
meshes[index].pos[1] = t.y;
markDirty();
renderGrid();
renderMeshList();
renderPreview();
}
function paintAt(e) {
const canvas = document.getElementById("gridCanvas");
const t = canvasToTile(e, canvas);
if(!t) return;
tiles[ChunkTerrain.tileIndex(t.x, t.y, currentZLevel)] = selectedShape;
markDirty();
renderGrid();
renderPreview();
}
// 4-connected flood fill of same-type tiles at the current Z-level,
// starting from (startX, startY), replacing them with selectedShape.
function floodFill(startX, startY) {
const targetType = tiles[ChunkTerrain.tileIndex(startX, startY, currentZLevel)];
if(targetType === selectedShape) return;
const stack = [[startX, startY]];
const visited = new Set();
while(stack.length) {
const [x, y] = stack.pop();
const key = `${x},${y}`;
if(visited.has(key)) continue;
if(x < 0 || x >= ChunkTerrain.CHUNK_WIDTH || y < 0 || y >= ChunkTerrain.CHUNK_HEIGHT) continue;
visited.add(key);
const idx = ChunkTerrain.tileIndex(x, y, currentZLevel);
if(tiles[idx] !== targetType) continue;
tiles[idx] = selectedShape;
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
}
}
function fillAt(e) {
const canvas = document.getElementById("gridCanvas");
const t = canvasToTile(e, canvas);
if(!t) return;
pushUndo();
floodFill(t.x, t.y);
markDirty();
renderGrid();
renderPreview();
}
function rebuildTerrainMesh() {
const floats = ChunkTerrain.buildTerrainVerts(tiles);
terrainMesh = floats.length ? mapRenderer.createMesh(floats) : null;
const lineFloats = ChunkTerrain.buildGridLines(tiles);
gridLinesMesh = lineFloats.length ? mapRenderer.createMesh(lineFloats) : null;
}
// Keeps the preview canvas's backing-store resolution matched to its
// actual displayed CSS size (times devicePixelRatio) - the canvas
// otherwise stays at its fixed width/height attributes (512x512) while
// CSS stretches it to fill the column, which upscales and blurs the
// WebGL output on wider or high-DPI screens.
function resizePreviewCanvas() {
const canvas = document.getElementById("previewCanvas");
const dpr = window.devicePixelRatio || 1;
const displayWidth = Math.max(1, Math.round(canvas.clientWidth * dpr));
const displayHeight = Math.max(1, Math.round(canvas.clientHeight * dpr));
if(canvas.width !== displayWidth || canvas.height !== displayHeight) {
canvas.width = displayWidth;
canvas.height = displayHeight;
}
}
async function renderPreview() {
if(!mapRenderer) return;
resizePreviewCanvas();
rebuildTerrainMesh();
const models = [];
for(const m of meshes) {
try {
const resolved = await resolveModelForMeshFile(m.file);
if(resolved) models.push({ ...resolved, offset: scaledMeshOffset(m.pos) });
} catch(e) {
console.warn("Failed to load mesh preview", m.file, e);
}
}
let terrainTexture = null;
if(terrainMesh || neighborChunks.some((n) => n.terrain)) {
if(!terrainTexturePromise) terrainTexturePromise = loadImageTexture("assets/tiles.png");
terrainTexture = await terrainTexturePromise;
}
const neighbors = neighborChunks.map((n) => ({
offset: n.offset,
terrain: n.terrain ? { mesh: n.terrain.mesh, texture: terrainTexture } : null,
models: n.models,
}));
const zLevelHeight = currentZLevel * ChunkTerrain.WORLD_LAYER_HEIGHT;
mapRenderer.render({
target: [ChunkTerrain.CHUNK_WIDTH / 2, ChunkTerrain.CHUNK_HEIGHT / 2, zLevelHeight],
worldH: zoomWorldH,
terrain: terrainMesh ? { mesh: terrainMesh, texture: terrainTexture } : null,
gridLines: gridLinesMesh ? { mesh: gridLinesMesh } : null,
models,
neighbors,
highlight: hoverTile ? { x: hoverTile.x, y: hoverTile.y, z: zLevelHeight } : null,
});
}
function renderMeshList() {
const ul = document.getElementById("meshList");
ul.innerHTML = "";
meshes.forEach((m, i) => {
const li = document.createElement("li");
li.className = "list-group-item d-flex align-items-center gap-2 flex-wrap";
const label = document.createElement("span");
label.className = "me-auto";
label.textContent = m.file;
li.appendChild(label);
["X", "Y", "Z"].forEach((axis, axisIndex) => {
const input = document.createElement("input");
input.type = "number";
input.className = "form-control form-control-sm";
input.style.width = "5rem";
input.title = axis;
input.value = m.pos[axisIndex];
input.addEventListener("change", () => {
pushUndo();
m.pos[axisIndex] = parseFloat(input.value) || 0;
markDirty();
renderGrid();
renderPreview();
});
li.appendChild(input);
});
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-sm btn-outline-danger";
btn.textContent = "Remove";
btn.addEventListener("click", () => {
pushUndo();
meshes.splice(i, 1);
markDirty();
renderMeshList();
renderGrid();
renderPreview();
});
li.appendChild(btn);
ul.appendChild(li);
});
}
async function buildMeshPicker() {
await ensureModelIndex();
const select = document.getElementById("meshPicker");
select.innerHTML = "";
for(const [base, rel] of [...modelIndex.entries()].sort()) {
const opt = document.createElement("option");
opt.value = base;
opt.textContent = rel;
select.appendChild(opt);
}
}
async function buildChunkBrowseList() {
const list = document.getElementById("chunkBrowseList");
list.innerHTML = "";
const res = await apiLs(`assetsraw/maps/${MAP_NAME}/chunks`);
for(const entry of res.entries || []) {
const m = entry.name.match(/^chunk_(-?\d+)_(-?\d+)_(-?\d+)\.json$/);
if(!m) continue;
const [, x, y, z] = m;
const li = document.createElement("li");
const a = document.createElement("a");
a.className = "dropdown-item";
a.href = "#";
a.textContent = `${x}, ${y}, ${z}`;
a.addEventListener("click", (e) => {
e.preventDefault();
goToChunk(parseInt(x, 10), parseInt(y, 10), parseInt(z, 10));
});
li.appendChild(a);
list.appendChild(li);
}
}
function setCoordInputs(x, y, z) {
document.getElementById("coordX").value = x;
document.getElementById("coordY").value = y;
document.getElementById("coordZ").value = z;
}
function goToChunk(x, y, z) {
if(!confirmDiscardIfDirty()) return;
setCoordInputs(x, y, z);
loadChunk(x, y, z);
}
function bindEvents() {
const gridCanvas = document.getElementById("gridCanvas");
let painting = false;
let draggingMeshIndex = -1;
gridCanvas.addEventListener("mousedown", (e) => {
if(activeTool === "bucket") {
fillAt(e);
return;
}
const hit = hitTestMesh(e, gridCanvas);
pushUndo();
if(hit !== -1) {
draggingMeshIndex = hit;
return;
}
painting = true;
paintAt(e);
});
window.addEventListener("mouseup", () => { painting = false; draggingMeshIndex = -1; });
gridCanvas.addEventListener("mousemove", (e) => {
hoverTile = canvasToTile(e, gridCanvas);
if(draggingMeshIndex !== -1) { moveMeshTo(e, draggingMeshIndex); return; }
if(painting) { paintAt(e); return; }
renderGrid();
renderPreview();
});
gridCanvas.addEventListener("mouseleave", () => {
hoverTile = null;
renderGrid();
renderPreview();
});
function setZLevel(z) {
currentZLevel = Math.max(0, Math.min(ChunkTerrain.CHUNK_DEPTH - 1, z));
document.getElementById("zLevel").value = currentZLevel;
document.getElementById("zLevelLabel").textContent = currentZLevel;
renderGrid();
renderPreview();
}
document.getElementById("zLevel").addEventListener("input", (e) => {
setZLevel(parseInt(e.target.value, 10));
});
document.getElementById("zLevelDown").addEventListener("click", () => setZLevel(currentZLevel - 1));
document.getElementById("zLevelUp").addEventListener("click", () => setZLevel(currentZLevel + 1));
document.getElementById("btnLoad").addEventListener("click", () => {
const x = parseInt(document.getElementById("coordX").value, 10) || 0;
const y = parseInt(document.getElementById("coordY").value, 10) || 0;
const z = parseInt(document.getElementById("coordZ").value, 10) || 0;
goToChunk(x, y, z);
});
// NORTH=+Y, EAST=+X (matches tile-shape convention used by the tile
// grid and tools/asset/chunk/__main__.py).
document.getElementById("navN").addEventListener("click", () => goToChunk(coord.x, coord.y + 1, coord.z));
document.getElementById("navS").addEventListener("click", () => goToChunk(coord.x, coord.y - 1, coord.z));
document.getElementById("navE").addEventListener("click", () => goToChunk(coord.x + 1, coord.y, coord.z));
document.getElementById("navW").addEventListener("click", () => goToChunk(coord.x - 1, coord.y, coord.z));
document.getElementById("navUp").addEventListener("click", () => goToChunk(coord.x, coord.y, coord.z + 1));
document.getElementById("navDown").addEventListener("click", () => goToChunk(coord.x, coord.y, coord.z - 1));
function runSave() {
saveChunk().catch((e) => updateStatus(`Save failed: ${e.message}`, true));
}
document.getElementById("btnSave").addEventListener("click", runSave);
document.getElementById("btnAddMesh").addEventListener("click", () => {
const base = document.getElementById("meshPicker").value;
if(!base) return;
const pos = [
parseFloat(document.getElementById("meshX").value) || 0,
parseFloat(document.getElementById("meshY").value) || 0,
parseFloat(document.getElementById("meshZ").value) || 0,
];
pushUndo();
meshes.push({ file: `${base}.dmf`, pos });
markDirty();
renderMeshList();
renderGrid();
renderPreview();
});
document.getElementById("previewCanvas").addEventListener("wheel", (e) => {
e.preventDefault();
zoomWorldH = Math.min(60, Math.max(4, zoomWorldH + Math.sign(e.deltaY)));
renderPreview();
}, { passive: false });
window.addEventListener("resize", () => renderPreview());
window.addEventListener("keydown", (e) => {
if(!(e.ctrlKey || e.metaKey)) return;
const key = e.key.toLowerCase();
if(key === "z") {
e.preventDefault();
undo();
} else if(key === "s") {
e.preventDefault();
runSave();
}
});
}
document.addEventListener("DOMContentLoaded", async () => {
buildPalette();
buildToolPalette();
mapRenderer = MapRenderer.create(document.getElementById("previewCanvas"));
bindEvents();
await Promise.all([buildChunkBrowseList(), buildMeshPicker()]);
await loadChunk(coord.x, coord.y, coord.z);
});
})();
-298
View File
@@ -1,298 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
"use strict";
// MapRenderer - a minimal, dependency-free WebGL renderer for the chunk
// preview pane. The camera formula is ported 1:1 from
// src/dusk/scene/overworld/sceneoverworld.c (lines 62-81) so the preview
// approximates the game's own fixed-angle 3rd-person camera: the eye sits
// worldH units "behind" the target along Y and a proportional distance
// "above" along Z, always looking at the target, up = (0, 1, 0).
//
// The material model mirrors SHADER_UNLIT: a texture sampler multiplied by
// a flat RGBA tint, with a 1x1 white fallback texture standing in for
// untextured models.
const MapRenderer = (() => {
const VERT_SRC = `
attribute vec2 aUV;
attribute vec3 aPos;
uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;
varying vec2 vUV;
void main() {
vUV = aUV;
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
}
`;
const FRAG_SRC = `
precision mediump float;
varying vec2 vUV;
uniform sampler2D uTexture;
uniform vec4 uColor;
void main() {
gl_FragColor = texture2D(uTexture, vUV) * uColor;
}
`;
function mat4Identity(out) {
out.fill(0);
out[0] = out[5] = out[10] = out[15] = 1;
return out;
}
function mat4Translation(out, v) {
mat4Identity(out);
out[12] = v[0];
out[13] = v[1];
out[14] = v[2];
return out;
}
function mat4Perspective(out, fovy, aspect, near, far) {
const f = 1.0 / Math.tan(fovy / 2);
out.fill(0);
out[0] = f / aspect;
out[5] = f;
out[10] = (far + near) / (near - far);
out[11] = -1;
out[14] = (2 * far * near) / (near - far);
return out;
}
function vec3Sub(a, b) {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
function vec3Cross(a, b) {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
}
function vec3Normalize(a) {
const len = Math.hypot(a[0], a[1], a[2]) || 1;
return [a[0] / len, a[1] / len, a[2] / len];
}
function mat4LookAt(out, eye, center, up) {
const zAxis = vec3Normalize(vec3Sub(eye, center));
const xAxis = vec3Normalize(vec3Cross(up, zAxis));
const yAxis = vec3Cross(zAxis, xAxis);
out[0] = xAxis[0]; out[1] = yAxis[0]; out[2] = zAxis[0]; out[3] = 0;
out[4] = xAxis[1]; out[5] = yAxis[1]; out[6] = zAxis[1]; out[7] = 0;
out[8] = xAxis[2]; out[9] = yAxis[2]; out[10] = zAxis[2]; out[11] = 0;
out[12] = -(xAxis[0] * eye[0] + xAxis[1] * eye[1] + xAxis[2] * eye[2]);
out[13] = -(yAxis[0] * eye[0] + yAxis[1] * eye[1] + yAxis[2] * eye[2]);
out[14] = -(zAxis[0] * eye[0] + zAxis[1] * eye[1] + zAxis[2] * eye[2]);
out[15] = 1;
return out;
}
// Ported from sceneoverworld.c:62-81. Returns { eye, view }.
function computeCamera(target, worldH) {
const fov = Math.PI / 4; // glm_rad(45.0f)
const zDist = (worldH * 0.5) / Math.tan(fov * 0.5);
const offset = -worldH;
const eye = [target[0], target[1] + offset, target[2] + zDist];
const view = mat4LookAt(new Float32Array(16), eye, target, [0, 1, 0]);
return { eye, view, fov };
}
function compileShader(gl, type, src) {
const shader = gl.createShader(type);
gl.shaderSource(shader, src);
gl.compileShader(shader);
if(!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`Shader compile failed: ${info}`);
}
return shader;
}
function create(canvas) {
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
if(!gl) throw new Error("WebGL is not available in this browser");
const program = gl.createProgram();
gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERT_SRC));
gl.attachShader(program, compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC));
gl.linkProgram(program);
if(!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(`Program link failed: ${gl.getProgramInfoLog(program)}`);
}
const attribs = {
aUV: gl.getAttribLocation(program, "aUV"),
aPos: gl.getAttribLocation(program, "aPos"),
};
const uniforms = {
uModel: gl.getUniformLocation(program, "uModel"),
uView: gl.getUniformLocation(program, "uView"),
uProjection: gl.getUniformLocation(program, "uProjection"),
uTexture: gl.getUniformLocation(program, "uTexture"),
uColor: gl.getUniformLocation(program, "uColor"),
};
const whiteTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, whiteTexture);
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
new Uint8Array([255, 255, 255, 255]),
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// Terrain/mesh winding isn't guaranteed consistent relative to this
// preview's fixed camera angle, so backface culling stays off - this
// is an authoring preview, not a performance-sensitive renderer, and
// an invisible mesh is a worse failure mode than a visible backface.
// Uploads interleaved [u, v, x, y, z] float data (DMF/terrain layout)
// into a GL buffer. Returns { buffer, vertexCount }.
function createMesh(floats) {
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, floats, gl.STATIC_DRAW);
return { buffer, vertexCount: floats.length / 5 };
}
function createTextureFromImage(image) {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return texture;
}
function drawMesh(mesh, model, texture, color, depthWrite, mode) {
gl.depthMask(depthWrite !== false);
gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer);
gl.enableVertexAttribArray(attribs.aUV);
gl.vertexAttribPointer(attribs.aUV, 2, gl.FLOAT, false, 20, 0);
gl.enableVertexAttribArray(attribs.aPos);
gl.vertexAttribPointer(attribs.aPos, 3, gl.FLOAT, false, 20, 8);
gl.uniformMatrix4fv(uniforms.uModel, false, model);
gl.uniform4f(uniforms.uColor, color[0], color[1], color[2], color[3]);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture || whiteTexture);
gl.uniform1i(uniforms.uTexture, 0);
gl.drawArrays(mode || gl.TRIANGLES, 0, mesh.vertexCount);
}
// Builds a 4-vertex outline of the tile at (x, y, z), lifted slightly
// to avoid z-fighting with the terrain surface beneath it.
function buildHighlightMesh(x, y, z) {
const eps = 0.02;
return createMesh(new Float32Array([
0, 0, x, y, z + eps,
0, 0, x + 1, y, z + eps,
0, 0, x + 1, y + 1, z + eps,
0, 0, x, y + 1, z + eps,
]));
}
// Neighboring chunks are drawn dimmer and semi-transparent, so they
// read as context rather than part of the chunk being edited.
const NEIGHBOR_ALPHA = 0.35;
const NEIGHBOR_DARKEN = 0.5;
// scene = {
// target: [x, y, z], worldH: number,
// terrain: { mesh, texture } | null,
// gridLines: { mesh } | null,
// models: [{ mesh, texture, color: [r,g,b,a in 0..1], offset: [x,y,z] }],
// neighbors: [{
// offset: [x,y,z],
// terrain: { mesh, texture } | null,
// models: [{ mesh, texture, color, offset }],
// }],
// highlight: { x, y, z } | null,
// }
function render(scene) {
const width = canvas.width, height = canvas.height;
gl.viewport(0, 0, width, height);
gl.clearColor(0.16, 0.18, 0.22, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.useProgram(program);
gl.depthMask(true);
const { view, fov } = computeCamera(scene.target, scene.worldH);
const projection = mat4Perspective(new Float32Array(16), fov, width / height, 0.1, 1000);
gl.uniformMatrix4fv(uniforms.uProjection, false, projection);
gl.uniformMatrix4fv(uniforms.uView, false, view);
const identity = mat4Identity(new Float32Array(16));
if(scene.terrain) {
drawMesh(scene.terrain.mesh, identity, scene.terrain.texture, [1, 1, 1, 1], true);
}
if(scene.gridLines) {
drawMesh(scene.gridLines.mesh, identity, null, [0, 0, 0, 0.35], false, gl.LINES);
}
for(const model of scene.models) {
const modelMatrix = mat4Translation(new Float32Array(16), model.offset);
drawMesh(model.mesh, modelMatrix, model.texture, model.color, true);
}
for(const neighbor of scene.neighbors || []) {
if(neighbor.terrain) {
const modelMatrix = mat4Translation(new Float32Array(16), neighbor.offset);
drawMesh(
neighbor.terrain.mesh, modelMatrix, neighbor.terrain.texture,
[NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_ALPHA], false,
);
}
for(const model of neighbor.models) {
const worldOffset = [
neighbor.offset[0] + model.offset[0],
neighbor.offset[1] + model.offset[1],
neighbor.offset[2] + model.offset[2],
];
const modelMatrix = mat4Translation(new Float32Array(16), worldOffset);
const color = [
model.color[0] * NEIGHBOR_DARKEN,
model.color[1] * NEIGHBOR_DARKEN,
model.color[2] * NEIGHBOR_DARKEN,
model.color[3] * NEIGHBOR_ALPHA,
];
drawMesh(model.mesh, modelMatrix, model.texture, color, false);
}
}
if(scene.highlight) {
const { x, y, z } = scene.highlight;
const highlightMesh = buildHighlightMesh(x, y, z);
drawMesh(highlightMesh, identity, null, [1, 0.92, 0.23, 1], false, gl.LINE_LOOP);
}
}
return { gl, createMesh, createTextureFromImage, render };
}
return Object.freeze({ create, computeCamera });
})();
-23
View File
@@ -1,23 +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 Map Editor</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css">
</head>
<body>
<nav class="navbar navbar-expand navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">Dusk Map Editor</a>
<ul class="navbar-nav flex-row">
<li class="nav-item">
<a class="nav-link" href="/map">Map Editor</a>
</li>
</ul>
</div>
</nav>
<div id="editor" class="container-fluid py-3"></div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
-298
View File
@@ -1,298 +0,0 @@
"""
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
GET /api/find?path=<dir>&ext=<.json> recursive file listing by extension
POST /api/write?path=<file> write file (JSON body: {"content":"..."})
POST /api/mkdir?path=<dir> create directory
POST /api/delete?path=<file> delete a file
POST /api/compile-chunk?x=&y=&z= recompile a chunk JSON to .dcf
"""
import os
import re
import sys
import json
import mimetypes
import subprocess
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs, unquote
from pathlib import Path
CHUNK_COORD_RE = re.compile(r"^-?\d+$")
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)
elif path == "/api/find":
self.api_find(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)
elif parsed.path == "/api/delete":
self.api_delete(params)
elif parsed.path == "/api/compile-chunk":
self.api_compile_chunk(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))})
def api_delete(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 target.is_file():
target.unlink()
self.send_json({"ok": True, "path": str(target.relative_to(PROJECT_ROOT))})
def api_find(self, params):
rel = params.get("path", [""])[0]
ext = params.get("ext", [""])[0]
target = self.resolve_project_path(rel)
if target is None:
self.send_json({"error": "invalid path"}, 400)
return
if not target.is_dir():
self.send_json({"error": "not a directory"}, 400)
return
pattern = f"*{ext}" if ext else "*"
paths = sorted(
str(p.relative_to(target)).replace(os.sep, "/")
for p in target.rglob(pattern)
if p.is_file()
)
self.send_json({"path": str(target.relative_to(PROJECT_ROOT)), "files": paths})
def api_compile_chunk(self, params):
x = params.get("x", [""])[0]
y = params.get("y", [""])[0]
z = params.get("z", [""])[0]
if not (CHUNK_COORD_RE.match(x) and CHUNK_COORD_RE.match(y) and CHUNK_COORD_RE.match(z)):
self.send_json({"error": "invalid chunk coordinates"}, 400)
return
json_path = (
PROJECT_ROOT / "assetsraw" / "maps" / "overworld" / "chunks"
/ f"chunk_{x}_{y}_{z}.json"
)
if not json_path.is_file():
self.send_json({"error": "chunk json not found"}, 404)
return
result = subprocess.run(
[sys.executable, "-m", "tools.asset.chunk", str(json_path)],
cwd=str(PROJECT_ROOT),
capture_output=True,
text=True,
)
self.send_json({
"ok": result.returncode == 0,
"returncode": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
})
# ------------------------------------------------------------------
# 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")
marker = shell.find('id="editor"')
if marker == -1:
content = shell.encode("utf-8")
else:
tag_end = shell.index(">", marker) + 1
close_idx = shell.index("</div>", tag_end)
content = (shell[:tag_end] + fragment + shell[close_idx:]).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
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")"
python3 server/server.py
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/build-gamecube.sh"
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/build-gamecube-iso.sh"
-13
View File
@@ -1,13 +0,0 @@
#!/bin/bash
if [ -z "$DEVKITPRO" ]; then
echo "DEVKITPRO environment variable is not set. Please set it to the path of your DEVKITPRO installation."
exit 1
fi
mkdir -p build-gamecube-iso
cmake -S. -Bbuild-gamecube-iso \
-DDUSK_TARGET_SYSTEM=gamecube \
-DDUSK_DOLPHIN_BUILD_TYPE=ISO \
-DCMAKE_TOOLCHAIN_FILE="$DEVKITPRO/cmake/GameCube.cmake"
cd build-gamecube-iso
make -j$(nproc) VERBOSE=1
-14
View File
@@ -1,14 +0,0 @@
#!/bin/bash
if [ -z "$DEVKITPRO" ]; then
echo "DEVKITPRO environment variable is not set. Please set it to the path of your DEVKITPRO installation."
exit 1
fi
mkdir -p build-gamecube
cmake -S. -Bbuild-gamecube \
-DDUSK_TARGET_SYSTEM=gamecube \
-DDUSK_DOLPHIN_BUILD_TYPE=DOL \
-DCMAKE_TOOLCHAIN_FILE="$DEVKITPRO/cmake/GameCube.cmake" \
-DDKP_OGC_PLATFORM_LIBRARY=libogc2
cd build-gamecube
make -j$(nproc) VERBOSE=1
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
docker build -t dusk-knulli -f docker/knulli/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-knulli /bin/bash -c "./scripts/build-knulli.sh"
-24
View File
@@ -1,24 +0,0 @@
#!/bin/bash
cmake -S . -B build-knulli -G Ninja \
-DDUSK_BUILD_TESTS=ON \
-DDUSK_TARGET_SYSTEM=knulli \
-DCMAKE_TOOLCHAIN_FILE=./cmake/toolchains/aarch64-linux-gnu.cmake \
-DCMAKE_BUILD_TYPE=Release
cmake --build build-knulli -- -j$(nproc)
# Copy necessary libs out
mkdir -p ./build-knulli/dusk
cp ./build-knulli/Dusk ./build-knulli/dusk/Dusk
cp ./build-knulli/dusk.dsk ./build-knulli/dusk/dusk.dsk
echo '#!/bin/bash' > build-knulli/dusk/Dusk.sh
echo 'cd "$(dirname "$(readlink -f "$0")")"' >> build-knulli/dusk/Dusk.sh
echo 'export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$(dirname "$(readlink -f "$0")")' >> build-knulli/dusk/Dusk.sh
echo 'export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/lib/gl4es' >> build-knulli/dusk/Dusk.sh
echo 'export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/lib' >> build-knulli/dusk/Dusk.sh
echo '$(dirname "$(readlink -f "$0")")/Dusk' >> build-knulli/dusk/Dusk.sh
chmod +x build-knulli/dusk/Dusk.sh
cp /usr/lib/aarch64-linux-gnu/liblua5.4.so.0 build-knulli/dusk/
# cp /usr/lib/aarch64-linux-gnu/libSDL2-2.0.so.0 build-knulli/dusk/
# cp /usr/lib/aarch64-linux-gnu/libGL.so.1 build-knulli/dusk/
# cp /usr/lib/aarch64-linux-gnu/libEGL.so.1 build-knulli/dusk/
# cp /usr/lib/aarch64-linux-gnu/libGLESv2.so.2 build-knulli/dusk/
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
cmake -S . -B build-linux -DDUSK_BUILD_TESTS=ON -DDUSK_TARGET_SYSTEM=linux
cmake --build build-linux -- -j$(nproc)
-12
View File
@@ -1,12 +0,0 @@
#!/bin/bash
if [ -z "$PSPDEV" ]; then
echo "PSPDEV environment variable is not set. Please set it to the path of your PSP development environment."
exit 1
fi
mkdir -p build-psp
cd build-psp
psp-cmake -DDUSK_TARGET_SYSTEM=psp -DCMAKE_TOOLCHAIN_FILE=$PSPDEV/psp/share/pspdev.cmake -DBUILD_PRX=1 .. CMAKE_BUILD_TYPE=Release
make -j$(nproc)
# psp-cmake -DDUSK_TARGET_SYSTEM=psp -DCMAKE_TOOLCHAIN_FILE=$PSPDEV/psp/share/pspdev.cmake -DBUILD_PRX=1 -DCMAKE_BUILD_TYPE=Debug ..
# make
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
docker build -t dusk-vita -f docker/vita/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-vita /bin/bash -c "./scripts/build-vita.sh"
-13
View File
@@ -1,13 +0,0 @@
#!/bin/bash
if [ -z "$VITASDK" ]; then
echo "VITASDK environment variable is not set. Please set it to the path of your VitaSDK installation."
exit 1
fi
mkdir -p build-vita
cd build-vita
cmake \
-DCMAKE_TOOLCHAIN_FILE=$VITASDK/share/vita.toolchain.cmake \
-DDUSK_TARGET_SYSTEM=vita \
..
make -j$(nproc)
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/build-wii.sh"
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/build-wii-iso.sh"
-13
View File
@@ -1,13 +0,0 @@
#!/bin/bash
if [ -z "$DEVKITPRO" ]; then
echo "DEVKITPRO environment variable is not set. Please set it to the path of your DEVKITPRO installation."
exit 1
fi
mkdir -p build-wii-iso
cmake -S. -Bbuild-wii-iso \
-DDUSK_TARGET_SYSTEM=wii \
-DDUSK_DOLPHIN_BUILD_TYPE=ISO \
-DCMAKE_TOOLCHAIN_FILE="$DEVKITPRO/cmake/Wii.cmake"
cd build-wii-iso
make -j$(nproc) VERBOSE=1
-14
View File
@@ -1,14 +0,0 @@
#!/bin/bash
if [ -z "$DEVKITPRO" ]; then
echo "DEVKITPRO environment variable is not set. Please set it to the path of your DEVKITPRO installation."
exit 1
fi
mkdir -p build-wii
cmake -S. -Bbuild-wii \
-DDUSK_TARGET_SYSTEM=wii \
-DCMAKE_TOOLCHAIN_FILE="$DEVKITPRO/cmake/Wii.cmake" \
-DDUSK_DOLPHIN_BUILD_TYPE=DOL
cd build-wii
make -j$(nproc) VERBOSE=1
mv Dusk.dol boot.dol
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -e
cd "$(dirname "$0")/../.."
docker build -t dusk-linux -f docker/linux/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-linux /bin/bash -c "./scripts/linux/build.sh"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -e
cd "$(dirname "$0")/../.."
cmake -S . -B build-linux -DDUSK_TARGET_SYSTEM=linux
cmake --build build-linux -- -j"$(nproc)"
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# Installs the packages required to build and test Dusk on Linux.
set -e
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y \
build-essential \
cmake \
git \
python3 \
python3-pip \
python3-polib \
python3-pil \
python3-dotenv \
python3-numpy \
libsdl3-dev \
libvulkan-dev \
libzip-dev \
libbz2-dev \
liblzma-dev \
zlib1g-dev \
libssl-dev \
xz-utils
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y \
git make gcc cmake python python-polib python3-pillow python3-dotenv \
python3-numpy SDL2-devel mesa-libGL-devel zlib-devel libzip-devel \
bzip2-devel openssl-devel xz xz-devel
elif command -v pacman >/dev/null 2>&1; then
$SUDO pacman -Sy --noconfirm --needed \
base-devel git cmake python python-polib python-pillow python-dotenv \
python-numpy sdl2 mesa libzip bzip2 openssl xz zlib
else
echo "Unsupported package manager; install dependencies manually." >&2
exit 1
fi
@@ -1,3 +1,5 @@
#!/bin/bash #!/bin/bash
set -e
cd "$(dirname "$0")/../.."
docker build -t dusk-linux -f docker/linux/Dockerfile . docker build -t dusk-linux -f docker/linux/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-linux /bin/bash -c "./scripts/build-linux.sh" docker run --rm -v "$(pwd):/workdir" dusk-linux /bin/bash -c "./scripts/linux/test.sh"
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
set -e
cd "$(dirname "$0")/../.."
rm -rf build-tests
cmake -S . -B build-tests -DDUSK_BUILD_TESTS=ON -DDUSK_TARGET_SYSTEM=linux
cmake --build build-tests -- -j"$(nproc)"
ctest --output-on-failure --test-dir build-tests
@@ -1,3 +1,5 @@
#!/bin/bash #!/bin/bash
set -e
cd "$(dirname "$0")/../.."
docker build -t dusk-psp -f docker/psp/Dockerfile . docker build -t dusk-psp -f docker/psp/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-psp /bin/bash -c "./scripts/build-psp.sh" docker run --rm -v "$(pwd):/workdir" dusk-psp /bin/bash -c "./scripts/psp/build.sh"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -e
cd "$(dirname "$0")/../.."
if [ -z "$PSPDEV" ]; then
echo "PSPDEV environment variable is not set." >&2
exit 1
fi
psp-cmake -S . -B build-psp \
-DDUSK_TARGET_SYSTEM=psp \
-DBUILD_PRX=1 \
-DCMAKE_BUILD_TYPE=Release
cmake --build build-psp -- -j"$(nproc)"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Installs the packages required to build Dusk for the PSP. The PSP toolchain
# itself (pspdev) must already be installed; the pspdev/pspdev docker image
# provides it.
set -e
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
fi
if command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache git cmake make python3 py3-pip py3-dotenv
elif command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y git cmake make python3 python3-pip python3-dotenv
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y git cmake make python python3-dotenv
elif command -v pacman >/dev/null 2>&1; then
$SUDO pacman -Sy --noconfirm --needed git cmake make python python-dotenv
else
echo "Unsupported package manager; install dependencies manually." >&2
exit 1
fi
if [ -z "$PSPDEV" ] || ! command -v psp-cmake >/dev/null 2>&1; then
echo "pspdev toolchain not found. Install it from https://pspdev.github.io/" >&2
echo "and set PSPDEV, or use scripts/psp/build-docker.sh." >&2
exit 1
fi
-7
View File
@@ -1,7 +0,0 @@
#!/bin/bash
docker build -t dusk-linux -f docker/linux/Dockerfile .
docker run \
--rm \
-v "${GITHUB_WORKSPACE}:/workdir" \
dusk-linux \
/bin/bash -c "./scripts/test-linux.sh"
-6
View File
@@ -1,6 +0,0 @@
#!/bin/bash
set -e
rm -rf build-tests
cmake -S . -B build-tests -DDUSK_BUILD_TESTS=ON -DDUSK_TARGET_SYSTEM=linux
cmake --build build-tests -- -j$(nproc)
ctest --output-on-failure --test-dir build-tests
-42
View File
@@ -1,42 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
add_subdirectory(dusk)
if(DUSK_NETWORK)
add_subdirectory(dusknetwork)
endif()
if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli")
# The duskscript runtime itself lives in src/dusk/script, added
# unconditionally of platform there since it is plain, portable C; this is
# just the duskscriptcc compiler, a host tool only built on the dev machine.
add_subdirectory(duskscriptcc)
endif()
if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli")
add_subdirectory(dusklinux)
add_subdirectory(dusksdl2)
add_subdirectory(duskgl)
add_subdirectory(duskmad)
elseif(DUSK_TARGET_SYSTEM STREQUAL "psp")
add_subdirectory(duskpsp)
add_subdirectory(dusksdl2)
add_subdirectory(duskgl)
elseif(DUSK_TARGET_SYSTEM STREQUAL "vita")
add_subdirectory(duskvita)
add_subdirectory(dusksdl2)
add_subdirectory(duskgl)
elseif(
DUSK_TARGET_SYSTEM STREQUAL "wii" OR
DUSK_TARGET_SYSTEM STREQUAL "gamecube"
)
add_subdirectory(duskdolphin)
add_subdirectory(duskmad)
endif()
+8 -77
View File
@@ -1,91 +1,22 @@
# Copyright (c) 2025 Dominic Masters # Copyright (c) 2026 Dominic Masters
# #
# This software is released under the MIT License. # This software is released under the MIT License.
# https://opensource.org/licenses/MIT # https://opensource.org/licenses/MIT
# Required Libraries # Headers are included relative to this directory (e.g. "util/string.h").
if(NOT cglm_FOUND) target_include_directories(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
find_package(cglm REQUIRED) ${CMAKE_CURRENT_SOURCE_DIR}
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC cglm)
endif()
if(NOT libzip_FOUND)
find_package(libzip REQUIRED)
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC zip)
endif()
# assetdsk.c calls crc32() directly (to verify dusk.dsk archive checksums).
# find_package(libzip) above already resolves ZLIB as a side effect, so
# ZLIB_FOUND may already be true without ZLIB::ZLIB having been linked to
# our target - link it unconditionally rather than guarding on ZLIB_FOUND.
find_package(ZLIB REQUIRED)
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC ZLIB::ZLIB)
if(NOT stb_image_FOUND)
find_package(stb REQUIRED)
if(STB_IMAGE_FOUND)
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC stb_image)
else()
message(FATAL_ERROR
"stb_image not found. Please ensure stb is correctly fetched."
)
endif()
endif()
if(NOT yyjson_FOUND)
find_package(yyjson REQUIRED)
if(yyjson_FOUND)
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC yyjson::yyjson)
else()
message(FATAL_ERROR
"yyjson not found. Please ensure yyjson is correctly fetched."
)
endif()
endif()
if(DUSK_BACKTRACE)
target_link_options(${DUSK_LIBRARY_TARGET_NAME} PUBLIC -rdynamic)
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
DUSK_BACKTRACE
)
endif()
if(DUSK_TRACK_MEMORY)
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_TRACK_MEMORY
)
endif()
# Includes
target_include_directories(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
${CMAKE_CURRENT_LIST_DIR}
) )
# Main Binary Source # Modules
target_sources(${DUSK_BINARY_TARGET_NAME}
PUBLIC
main.c
)
# Subdirs
add_subdirectory(animation)
add_subdirectory(assert)
add_subdirectory(asset) add_subdirectory(asset)
add_subdirectory(audio) add_subdirectory(assert)
add_subdirectory(console) add_subdirectory(console)
add_subdirectory(display) add_subdirectory(display)
add_subdirectory(log)
add_subdirectory(engine) add_subdirectory(engine)
add_subdirectory(entity)
add_subdirectory(error) add_subdirectory(error)
add_subdirectory(input)
add_subdirectory(locale)
add_subdirectory(perf) add_subdirectory(perf)
add_subdirectory(scene) add_subdirectory(resource)
add_subdirectory(script)
add_subdirectory(system)
add_subdirectory(time) add_subdirectory(time)
add_subdirectory(save)
add_subdirectory(util)
add_subdirectory(thread) add_subdirectory(thread)
add_subdirectory(util)
-11
View File
@@ -1,11 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
easing.c
animation.c
keyframe.c
)
-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
#include "animation.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/math.h"
void animationInit(
animation_t *anim,
keyframe_t *keyframes,
uint16_t *keyframeCounts,
const uint16_t layerCount
) {
assertNotNull(anim, "Animation pointer cannot be null.");
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
assertNotNull(keyframeCounts, "Keyframe counts pointer cannot be null.");
assertTrue(layerCount > 0, "Layer count must be greater than zero.");
memoryZero(anim, sizeof(animation_t));
anim->keyframes = keyframes;
anim->keyframeCounts = keyframeCounts;
anim->layerCount = layerCount;
// Determine duration
float_t duration = 0.0f;
for(uint16_t layer = 0; layer < layerCount; layer++) {
uint16_t keyframeCount = keyframeCounts[layer];
assertTrue(keyframeCount > 0, "Keyframe count invalid.");
keyframe_t *layerKeyframes = keyframes + layer * keyframeCount;
#ifdef DUSK_ASSERTIONS
// Check that the keyframes are sorted by time.
for(uint16_t i = 1; i < keyframeCount; i++) {
assertTrue(
layerKeyframes[i].time >= layerKeyframes[i - 1].time,
"Keyframes must be sorted by time."
);
}
#endif
keyframe_t *lastKeyframe = layerKeyframes + keyframeCount - 1;
duration = mathMax(duration, lastKeyframe->time);
}
assertTrue(duration > 0, "Animation duration must be greater than 0.");
anim->duration = duration;
}
float_t animationGetLayerValue(const animation_t *anim, const uint16_t layer) {
assertNotNull(anim, "Animation pointer cannot be null.");
assertTrue(layer < anim->layerCount, "Layer index out of bounds.");
uint16_t keyframeCount = anim->keyframeCounts[layer];
keyframe_t *layerKeyframes = anim->keyframes + layer * keyframeCount;
return keyframeGetValue(layerKeyframes, keyframeCount, anim->time);
}
void animationUpdate(
animation_t *anim,
const float_t deltaTime
) {
assertNotNull(anim, "Animation pointer cannot be null.");
assertTrue(deltaTime >= 0, "Delta time must be non-negative.");
bool_t justCompleted = false;
if(!(anim->flags & ANIMATION_FLAG_INTERNAL_COMPLETED)) {
bool_t loop = (anim->flags & ANIMATION_FLAG_LOOP) != 0;
bool_t pingpong = (anim->flags & ANIMATION_FLAG_PINGPONG) != 0;
assertFalse(
loop && pingpong,
"Cannot set both ANIMATION_FLAG_LOOP and ANIMATION_FLAG_PINGPONG."
);
bool_t backward;
if(pingpong) {
backward = (anim->flags & ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD) != 0;
} else {
backward = (anim->flags & ANIMATION_FLAG_REVERSE) != 0;
}
// Resolve boundary crossings one at a time, so a single large deltaTime
// can correctly loop/pingpong across multiple boundaries in one call.
float_t remaining = deltaTime;
while(remaining > 0.0f) {
float_t toBoundary = (
backward ? anim->time : (anim->duration - anim->time)
);
if(remaining < toBoundary) {
anim->time += backward ? -remaining : remaining;
break;
}
remaining -= toBoundary;
anim->time = backward ? 0.0f : anim->duration;
bool_t stopHere;
if(backward) {
stopHere = (anim->flags & ANIMATION_FLAG_STOP_BEGINNING) != 0;
} else {
stopHere = (anim->flags & ANIMATION_FLAG_STOP_END) != 0;
}
if(stopHere) {
justCompleted = true;
break;
} else if(pingpong) {
backward = !backward;
if(backward) anim->flags |= ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD;
else anim->flags &= ~ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD;
} else if(loop) {
anim->time = backward ? anim->duration : 0.0f;
if(anim->onLoop) anim->onLoop(anim->user);
} else {
justCompleted = true;
break;
}
}
if(justCompleted) anim->flags |= ANIMATION_FLAG_INTERNAL_COMPLETED;
}
// Call onUpdate for each layer.
for(uint16_t layer = 0; layer < anim->layerCount; layer++) {
float_t value = animationGetLayerValue(anim, layer);
if(anim->onUpdate) anim->onUpdate(layer, value, anim->user);
}
if(justCompleted && anim->onComplete) anim->onComplete(anim->user);
}
-94
View File
@@ -1,94 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
#pragma once
#include "keyframe.h"
#define ANIMATION_FLAG_LOOP (1 << 0)
#define ANIMATION_FLAG_REVERSE (1 << 1)
#define ANIMATION_FLAG_PINGPONG (1 << 2)
#define ANIMATION_FLAG_STOP_BEGINNING (1 << 3)
#define ANIMATION_FLAG_STOP_END (1 << 4)
// Internal - tracks which direction a pingponging animation is currently
// travelling. Do not set this manually, it is managed by animationUpdate().
#define ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD (1 << 7)
// Internal - set once the animation has stopped advancing (see
// animationUpdate()). Do not set this manually. There is currently no way to
// restart a completed animation short of clearing this bit and resetting
// anim->time by hand.
#define ANIMATION_FLAG_INTERNAL_COMPLETED (1 << 6)
typedef struct {
keyframe_t *keyframes;
uint16_t *keyframeCounts;
uint16_t layerCount;
float_t time;
float_t duration;
uint8_t flags;
void *user;
void (*onUpdate)(const uint16_t layer, const float_t value, void *user);
void (*onComplete)(void *user);
void (*onLoop)(void *user);
} animation_t;
/**
* Initializes an animation with the given keyframes and layer count.
*
* @param anim Pointer to the animation to initialize.
* @param keyframes Pointer to the array of keyframes for each layer.
* @param keyframeCount Number of keyframes in each layer.
* @param layerCount Number of layers in the animation.
*/
void animationInit(
animation_t *anim,
keyframe_t *keyframes,
uint16_t *keyframeCounts,
const uint16_t layerCount
);
/**
* Sets the current time of the animation, clamping it to the valid range.
* This will call the onUpdate callback but none of the other callbacks.
*
* @param anim Pointer to the animation to set the time for.
* @param time The new time to set for the animation.
*/
void animationSetTime(animation_t *anim, const float_t time);
/**
* Gets the current value of a specific layer in the animation based on the
* current animation time.
*/
float_t animationGetLayerValue(const animation_t *anim, const uint16_t layer);
/**
* Updates the animation state based on the elapsed time. Advances anim->time
* by deltaTime (or against it, if ANIMATION_FLAG_REVERSE is set), then
* resolves whatever happens when it reaches the 0 or duration boundary:
*
* - ANIMATION_FLAG_PINGPONG: reflects off the boundary and continues playing
* in the opposite direction, forever, unless stopped (see below).
* - ANIMATION_FLAG_LOOP: wraps back around to the other boundary and keeps
* playing in the same direction, forever, unless stopped (see below).
* - ANIMATION_FLAG_STOP_BEGINNING / ANIMATION_FLAG_STOP_END: when the
* animation reaches that specific boundary, it clamps there and stops
* (firing onComplete) instead of looping/pingponging past it.
* - If none of the above apply at a boundary, the animation clamps there and
* stops, firing onComplete.
*
* onUpdate is called for every layer on every call. onLoop is called each
* time a loop wraps around. onComplete is called at most once, the moment
* the animation stops advancing.
*
* @param anim Pointer to the animation to update.
* @param deltaTime Time elapsed since the last update (in seconds).
*/
void animationUpdate(
animation_t *anim,
const float_t deltaTime
);
-111
View File
@@ -1,111 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
#include "easing.h"
#include "assert/assert.h"
#include "util/math.h"
const easingfn_t EASING_FUNCTIONS[EASING_COUNT] = {
easingLinear,
easingInSine,
easingOutSine,
easingInOutSine,
easingInQuad,
easingOutQuad,
easingInOutQuad,
easingInCubic,
easingOutCubic,
easingInOutCubic,
easingInQuart,
easingOutQuart,
easingInOutQuart,
easingInBack,
easingOutBack,
easingInOutBack,
};
float_t easingApply(const easingtype_t type, const float_t t) {
assertTrue(type < EASING_COUNT, "Invalid easing type");
return EASING_FUNCTIONS[type](t);
}
float_t easingLinear(const float_t t) {
return t;
}
float_t easingInSine(const float_t t) {
return 1.0f - cosf(t * MATH_PI * 0.5f);
}
float_t easingOutSine(const float_t t) {
return sinf(t * MATH_PI * 0.5f);
}
float_t easingInOutSine(const float_t t) {
return -(cosf(MATH_PI * t) - 1.0f) * 0.5f;
}
float_t easingInQuad(const float_t t) {
return t * t;
}
float_t easingOutQuad(const float_t t) {
float_t u = 1.0f - t;
return 1.0f - u * u;
}
float_t easingInOutQuad(const float_t t) {
if(t < 0.5f) return 2.0f * t * t;
float_t u = -2.0f * t + 2.0f;
return 1.0f - u * u * 0.5f;
}
float_t easingInCubic(const float_t t) {
return t * t * t;
}
float_t easingOutCubic(const float_t t) {
float_t u = 1.0f - t;
return 1.0f - u * u * u;
}
float_t easingInOutCubic(const float_t t) {
if(t < 0.5f) return 4.0f * t * t * t;
float_t u = -2.0f * t + 2.0f;
return 1.0f - u * u * u * 0.5f;
}
float_t easingInQuart(const float_t t) {
return t * t * t * t;
}
float_t easingOutQuart(const float_t t) {
float_t u = 1.0f - t;
return 1.0f - u * u * u * u;
}
float_t easingInOutQuart(const float_t t) {
if(t < 0.5f) return 8.0f * t * t * t * t;
float_t u = -2.0f * t + 2.0f;
return 1.0f - u * u * u * u * 0.5f;
}
float_t easingInBack(const float_t t) {
return EASING_C3 * t * t * t - EASING_C1 * t * t;
}
float_t easingOutBack(const float_t t) {
float_t u = t - 1.0f;
return 1.0f + EASING_C3 * u * u * u + EASING_C1 * u * u;
}
float_t easingInOutBack(const float_t t) {
if(t < 0.5f) {
float_t u = 2.0f * t;
return u * u * ((EASING_C2 + 1.0f) * u - EASING_C2) * 0.5f;
}
float_t u = 2.0f * t - 2.0f;
return (u * u * ((EASING_C2 + 1.0f) * u + EASING_C2) + 2.0f) * 0.5f;
}
-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
#pragma once
#include "dusk.h"
#define EASING_PI 3.14159265358979323846f
#define EASING_C1 1.70158f
#define EASING_C2 (EASING_C1 * 1.525f)
#define EASING_C3 (EASING_C1 + 1.0f)
typedef enum {
EASING_LINEAR,
EASING_IN_SINE,
EASING_OUT_SINE,
EASING_IN_OUT_SINE,
EASING_IN_QUAD,
EASING_OUT_QUAD,
EASING_IN_OUT_QUAD,
EASING_IN_CUBIC,
EASING_OUT_CUBIC,
EASING_IN_OUT_CUBIC,
EASING_IN_QUART,
EASING_OUT_QUART,
EASING_IN_OUT_QUART,
EASING_IN_BACK,
EASING_OUT_BACK,
EASING_IN_OUT_BACK,
EASING_COUNT
} easingtype_t;
typedef float_t (*easingfn_t)(const float_t t);
extern const easingfn_t EASING_FUNCTIONS[EASING_COUNT];
/**
* Applies the specified easing function to t.
*
* @param type The easing type to apply.
* @param t The input time, in the range [0, 1].
* @return The eased value, in the range [0, 1].
*/
float_t easingApply(const easingtype_t type, const float_t t);
float_t easingLinear(const float_t t);
float_t easingInSine(const float_t t);
float_t easingOutSine(const float_t t);
float_t easingInOutSine(const float_t t);
float_t easingInQuad(const float_t t);
float_t easingOutQuad(const float_t t);
float_t easingInOutQuad(const float_t t);
float_t easingInCubic(const float_t t);
float_t easingOutCubic(const float_t t);
float_t easingInOutCubic(const float_t t);
float_t easingInQuart(const float_t t);
float_t easingOutQuart(const float_t t);
float_t easingInOutQuart(const float_t t);
float_t easingInBack(const float_t t);
float_t easingOutBack(const float_t t);
float_t easingInOutBack(const float_t t);
-45
View File
@@ -1,45 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "keyframe.h"
#include "assert/assert.h"
#include "util/math.h"
float_t keyframeGetValue(
const keyframe_t *keyframes,
const uint32_t keyframeCount,
const float_t time
) {
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
assertTrue(keyframeCount > 0, "Keyframe count must be more than 0.");
assertTrue(time >= 0, "Time must be non-negative.");
#ifdef DUSK_ASSERTIONS
// Checks that the keyframes are sorted by time.
for(uint32_t i = 1; i < keyframeCount; i++) {
assertTrue(
keyframes[i].time >= keyframes[i - 1].time,
"Keyframes must be sorted by time."
);
}
#endif
keyframe_t *last = (keyframe_t *)(keyframes + keyframeCount - 1);
if(time >= last->time) return last->value;
// Since time < last->time (checked above), current is guaranteed to stop
// at or before reaching last, so no separate end-of-array check is needed.
keyframe_t *current = (keyframe_t *)keyframes;
keyframe_t *start = current;
while(current->time <= time) {
start = current;
current++;
}
keyframe_t *end = current;
float_t t = (time - start->time) / (end->time - start->time);
return mathLerp(start->value, end->value, easingApply(start->easing, t));
}
-27
View File
@@ -1,27 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
#pragma once
#include "easing.h"
typedef struct {
float_t time;
float_t value;
easingtype_t easing;
} keyframe_t;
/**
* Gets the value of a keyframe at a given time.
*
* @param keyframes The keyframes to get the value from.
* @param keyframeCount The number of keyframes in the array.
* @param time The time at which to get the value, in seconds.
* @return The value of the keyframe at the given time.
*/
float_t keyframeGetValue(
const keyframe_t *keyframes,
const uint32_t keyframeCount,
const float_t time
);
+1 -1
View File
@@ -40,7 +40,7 @@
#include <execinfo.h> #include <execinfo.h>
#include <stdlib.h> #include <stdlib.h>
static void assertLogBacktrace(void) { void assertLogBacktrace(void) {
void *frames[64]; void *frames[64];
int count = backtrace(frames, 64); int count = backtrace(frames, 64);
char **symbols = backtrace_symbols(frames, count); char **symbols = backtrace_symbols(frames, count);
+26 -2
View File
@@ -30,6 +30,13 @@
*/ */
void assertInit(void); void assertInit(void);
#ifdef DUSK_BACKTRACE
/**
* Logs a stack trace of the current call stack to the error log.
*/
void assertLogBacktrace(void);
#endif
/** /**
* Assert a given value to be true. * Assert a given value to be true.
* *
@@ -276,8 +283,25 @@
#endif #endif
// Static Assertions // Static Assertions. These are compile time only, so they are never faked.
/**
* Asserts a constant expression at compile time. Never faked, even when
* DUSK_ASSERTIONS_FAKED is set.
*
* @param x Constant expression that must be true.
* @param message Message to show on failure, must be a string literal.
*/
#define assertStatic(x, message) \
_Static_assert(x, message)
/**
* Asserts the size of a struct at compile time.
*
* @param struct The struct type to check.
* @param size The size in bytes the struct must be.
*/
#define assertStructSize(struct, size) \ #define assertStructSize(struct, size) \
_Static_assert(sizeof(struct) == size, "Size of " #struct " must be " #size) assertStatic(sizeof(struct) == size, "Size of " #struct " must be " #size)
// EOF // EOF
+1 -6
View File
@@ -1,15 +1,10 @@
# Copyright (c) 2025 Dominic Masters # Copyright (c) 2026 Dominic Masters
# #
# This software is released under the MIT License. # This software is released under the MIT License.
# https://opensource.org/licenses/MIT # https://opensource.org/licenses/MIT
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
asset.c asset.c
assetdsk.c
assetfile.c assetfile.c
) )
# Subdirs
add_subdirectory(loader)
+34 -423
View File
@@ -5,451 +5,62 @@
* https://opensource.org/licenses/MIT * https://opensource.org/licenses/MIT
*/ */
#include "asset.h" #include "asset/asset.h"
#include "assert/assert.h"
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h" #include "util/string.h"
#include "assert/assert.h"
#include "engine/engine.h"
#include "util/string.h"
#include "console/console.h"
#include <unistd.h>
asset_t ASSET; asset_t ASSET;
errorret_t assetInit(void) { errorret_t assetInit(void) {
memoryZero(&ASSET, sizeof(asset_t)); memoryZero(&ASSET, sizeof(asset_t));
threadMutexInit(&ASSET.zipLock); // The platform must set ASSET.baseDirectory or throw an error.
errorChain(assetInitPlatform());
assertStrLenMin(ASSET.baseDirectory, 1, "Asset directory empty without error.");
for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { errorOk();
threadMutexInit(&ASSET.loading[i].mutex); }
errorret_t assetGetPath(
const char_t *filename,
char_t *out,
const size_t outSize
) {
assertNotNull(filename, "Filename cannot be NULL.");
assertNotNull(out, "Output cannot be NULL.");
if(filename[0] == '\0') {
errorThrow("Asset filename cannot be empty.");
}
if(filename[0] == '/' || stringIncludesString(filename, "..")) {
errorThrow("Invalid asset filename: %s", filename);
} }
// assetInitPlatform must either define ASSET.zip or throw an error. int32_t len = stringFormat(out, outSize, "%s/%s", ASSET.baseDirectory, filename);
errorChain(assetInitPlatform()); if(len < 0 || (size_t)len >= outSize) {
assertNotNull(ASSET.zip, "Asset zip null without error."); errorThrow("Asset path too long: %s", filename);
threadInit(&ASSET.loadThread, assetUpdateAsync); }
threadStart(&ASSET.loadThread);
errorOk(); errorOk();
} }
bool_t assetFileExists(const char_t *filename) { bool_t assetFileExists(const char_t *filename) {
assertStrLenMax(filename, ASSET_FILE_NAME_MAX, "Filename too long."); char_t path[ASSET_PATH_MAX];
errorret_t ret = assetGetPath(filename, path, sizeof(path));
threadMutexLock(&ASSET.zipLock);
bool_t found = zip_name_locate(ASSET.zip, filename, 0) >= 0;
threadMutexUnlock(&ASSET.zipLock);
return found;
}
assetentry_t * assetGetEntry(
const char_t *name,
const assetloadertype_t type,
assetloaderinput_t *input
) {
// Is there an existing asset?
assetentry_t *entry = ASSET.entries;
do {
if(entry->type == ASSET_LOADER_TYPE_NULL) {
entry++;
continue;
}
if(stringEquals(entry->name, name)) {
assertTrue(entry->type == type, "Asset entry type mismatch.");
return entry;
}
entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
// We did not find one existing. Find first available slot, reaping
// zero-ref entries to make room if none are immediately available.
bool_t reaped = false;
for(;;) {
entry = ASSET.entries;
do {
if(entry->type != ASSET_LOADER_TYPE_NULL) {
entry++;
continue;
}
if(entry->state == ASSET_ENTRY_STATE_NOT_STARTED) {
assetEntryInit(entry, name, type, input);
return entry;
}
entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
if(reaped) break;
reaped = true;
errorCatch(assetReapUnused());
}
assertUnreachable("No available asset entry slots.");
return NULL;
}
uint32_t assetGetEntriesOfType(
assetentry_t **outEntries,
const assetloadertype_t type
) {
assertNotNull(outEntries, "Output entries cannot be NULL.");
uint32_t count = 0;
assetentry_t *entry = ASSET.entries;
do {
if(entry->type == type) {
outEntries[count++] = entry;
}
entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
return count;
}
errorret_t assetRequireLoaded(assetentry_t *entry) {
assertNotNull(entry, "Entry cannot be NULL.");
assertTrue(entry->type != ASSET_LOADER_TYPE_NULL, "Invalid loader type.");
assertIsMainThread("Currently only works on main thread.");
if(entry->state == ASSET_ENTRY_STATE_LOADED) {
errorOk();
}
// Lock to prevent the reaper from collecting the entry mid-spin.
assetEntryLock(entry);
while(entry->state != ASSET_ENTRY_STATE_LOADED) {
// A failed load transitions to ERROR, not LOADED - without this check
// this loop spins forever on any load failure (assetUpdate() itself
// still returns OK, since a single asset failing isn't meant to halt
// the whole update loop - see its ASSET_ENTRY_STATE_ERROR handling).
if(entry->state == ASSET_ENTRY_STATE_ERROR) {
assetEntryUnlock(entry);
errorThrow("Failed to load asset: %s", entry->name);
}
usleep(1000);
errorret_t ret = assetUpdate();
if(errorIsNotOk(ret)) { if(errorIsNotOk(ret)) {
assetEntryUnlock(entry); errorCatch(ret);
errorChain(ret); return false;
}
} }
assetEntryUnlock(entry); FILE *file = fopen(path, "rb");
errorOk(); if(file == NULL) return false;
} fclose(file);
return true;
errorret_t assetRequireDisposed(assetentry_t *entry) {
assertNotNull(entry, "Entry cannot be NULL.");
assertTrue(entry->type != ASSET_LOADER_TYPE_NULL, "Invalid loader type.");
assertIsMainThread("Currently only works on main thread.");
if(entry->type == ASSET_LOADER_TYPE_NULL) {
errorOk();
}
if(
entry->state == ASSET_ENTRY_STATE_NOT_STARTED ||
entry->state == ASSET_ENTRY_STATE_ERROR
) {
errorOk();
}
assertTrue(
entry->refs.count == 0,
"Cannot require disposal of an entry with active references."
);
// Lock to prevent the reaper from collecting the entry mid-spin.
assetEntryLock(entry);
while(entry->type != ASSET_LOADER_TYPE_NULL) {
usleep(1000);
errorret_t ret = assetUpdate();
if(errorIsNotOk(ret)) {
assetEntryUnlock(entry);
errorChain(ret);
}
}
assetEntryUnlock(entry);
errorOk();
}
assetentry_t * assetLock(
const char_t *name,
const assetloadertype_t type,
assetloaderinput_t *input
) {
assetentry_t *entry = assetGetEntry(name, type, input);
assetEntryLock(entry);
return entry;
}
void assetUnlock(const char_t *name) {
assertNotNull(name, "Name cannot be NULL.");
assetentry_t *entry = ASSET.entries;
do {
if(
entry->type != ASSET_LOADER_TYPE_NULL &&
stringEquals(entry->name, name)
) {
assetEntryUnlock(entry);
return;
}
entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
assertUnreachable("Asset entry not found for unlock.");
}
void assetUnlockEntry(assetentry_t *entry) {
assertNotNull(entry, "Entry cannot be NULL.");
assetEntryUnlock(entry);
}
errorret_t assetReapUnused(void) {
assertIsMainThread("assetReapUnused must be called from the main thread.");
// 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 {
any = false;
assetentry_t *entry = ASSET.entries;
do {
if(entry->type == ASSET_LOADER_TYPE_NULL) { entry++; continue; }
if(entry->state != ASSET_ENTRY_STATE_LOADED) { entry++; continue; }
if(entry->refs.count > 0) { entry++; continue; }
errorChain(assetEntryDispose(entry));
any = true;
entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
} while(any);
errorOk();
}
errorret_t assetUpdate(void) {
assertIsMainThread("assetUpdate must be called from the main thread.");
// Determine how many available loading slots we have.
assetloading_t *availableLoading[ASSET_LOADING_COUNT_MAX];
uint8_t availableLoadingCount = 0;
assetloading_t *loading = ASSET.loading;
assetentry_t *entry;
do {
// We only care about NULL entry references. Nothing async touches this so
// it's fine to use raw here.
if(loading->entry != NULL) {
loading++;
continue;
}
availableLoading[availableLoadingCount++] = loading;
loading++;
} while(loading < ASSET.loading + ASSET_LOADING_COUNT_MAX);
// Now we can check for pending asset entries, we can't do anything if there
// is no available slots though.
if(availableLoadingCount > 0) {
entry = ASSET.entries;
do {
// Is this asset "ready to start loading" ?
if(entry->type == ASSET_LOADER_TYPE_NULL) {
entry++;
continue;
}
// We only care about assets not started.
if(entry->state != ASSET_ENTRY_STATE_NOT_STARTED) {
entry++;
continue;
}
// Pop a loading slot for this asset entry.
loading = availableLoading[--availableLoadingCount];
// Start loading this asset.
assetEntryStartLoading(entry, loading);
entry++;
// Did we run out of loading slots?
if(availableLoadingCount == 0) {
break;
}
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
}
// Now walk over all the loading slots and see what needs to be done.
loading = ASSET.loading;
do {
// Is the loading slot in use? Entry can only be modified synchronously.
if(loading->entry == NULL) {
loading++;
continue;
}
// Lock the loading slot. This will prevent any async modifications.
threadMutexLock(&loading->mutex);
// Check the state of the entry.
switch(loading->entry->state) {
// This thing is pending synchronous loading.
case ASSET_ENTRY_STATE_PENDING_SYNC:
loading->entry->state = ASSET_ENTRY_STATE_LOADING_SYNC;
// Unlock before calling loadSync. The sync loader may re-enter
// assetUpdate (e.g. a script loading another asset), and the async
// thread never touches LOADING_SYNC entries, so this is safe.
threadMutexUnlock(&loading->mutex);
errorret_t ret = (
ASSET_LOADER_CALLBACKS[loading->type].loadSync(loading)
);
// After a sync load, these are the only valid states.
assertTrue(
loading->entry->state == ASSET_ENTRY_STATE_LOADED ||
loading->entry->state == ASSET_ENTRY_STATE_ERROR ||
loading->entry->state == ASSET_ENTRY_STATE_PENDING_SYNC ||
loading->entry->state == ASSET_ENTRY_STATE_PENDING_ASYNC,
"Loader did not set entry state to loaded or error on finished load."
);
if(errorIsNotOk(ret)) {
errorCatch(errorPrint(ret));
assertTrue(
loading->entry->state == ASSET_ENTRY_STATE_ERROR,
"Loader did not set entry state to error on failed load."
);
} else if(loading->entry->state == ASSET_ENTRY_STATE_LOADED) {
assetentry_t *loadedEntry = loading->entry;
loading->entry = NULL;
if(loadedEntry->onLoaded) {
loadedEntry->onLoaded(loadedEntry, loadedEntry->onLoadedUser);
}
}
loading++;
break;
case ASSET_ENTRY_STATE_LOADING_SYNC:
// A re-entrant assetUpdate call (e.g. from a script loading another
// asset) will see this entry mid-sync-load. Skip it.
threadMutexUnlock(&loading->mutex);
loading++;
continue;
// Done loading, we can just free it up.
case ASSET_ENTRY_STATE_LOADED:
loading->entry = NULL;
threadMutexUnlock(&loading->mutex);
loading++;
break;
case ASSET_ENTRY_STATE_ERROR: {
assetentry_t *errEntry = loading->entry;
loading->entry = NULL;
threadMutexUnlock(&loading->mutex);
if(errEntry->onError) {
errEntry->onError(errEntry, errEntry->onErrorUser);
}
loading++;
break;
}
default:
threadMutexUnlock(&loading->mutex);
loading++;
continue;
}
} while(loading < ASSET.loading + ASSET_LOADING_COUNT_MAX);
errorOk();
}
void assetUpdateAsync(thread_t *thread) {
assertNotMainThread("assetUpdateAsync must not run on the main thread.");
while(!threadShouldStop(thread)) {
// Walk over each asset
assetloading_t *loading;
loading = ASSET.loading;
do {
threadMutexLock(&loading->mutex);
if(loading->entry == NULL) {
threadMutexUnlock(&loading->mutex);
loading++;
continue;
}
switch(loading->entry->state) {
case ASSET_ENTRY_STATE_PENDING_ASYNC:
loading->entry->state = ASSET_ENTRY_STATE_LOADING_ASYNC;
assertNotNull(
ASSET_LOADER_CALLBACKS[loading->type].loadAsync,
"Loader does not support async loading."
);
errorret_t ret = (
ASSET_LOADER_CALLBACKS[loading->type].loadAsync(loading)
);
if(errorIsNotOk(ret)) {
errorCatch(errorPrint(ret));
assertTrue(
loading->entry->state == ASSET_ENTRY_STATE_ERROR,
"Loader did not set entry state to error on failed load."
);
}
threadMutexUnlock(&loading->mutex);
loading++;
break;
case ASSET_ENTRY_STATE_LOADING_ASYNC:
assertUnreachable(
"Entry is in a pending async state still?"
);
break;
default:
threadMutexUnlock(&loading->mutex);
loading++;
continue;
}
} while(loading < ASSET.loading + ASSET_LOADING_COUNT_MAX);
if(threadShouldStop(thread)) break;
usleep(1000);
}
} }
errorret_t assetDispose(void) { errorret_t assetDispose(void) {
assertIsMainThread("Must be called from the main thread.");
threadStop(&ASSET.loadThread);
errorChain(assetReapUnused());
// Cleanup zip file.
if(ASSET.zip != NULL) {
if(zip_close(ASSET.zip) != 0) {
errorThrow("Failed to close asset zip archive.");
}
ASSET.zip = NULL;
}
errorChain(assetDisposePlatform()); errorChain(assetDisposePlatform());
threadMutexDispose(&ASSET.zipLock); memoryZero(&ASSET, sizeof(asset_t));
errorOk(); errorOk();
} }
+26 -154
View File
@@ -8,10 +8,7 @@
#pragma once #pragma once
#include "error/error.h" #include "error/error.h"
#include "asset/assetplatform.h" #include "asset/assetplatform.h"
#include "assetfile.h" #include "asset/assetfile.h"
#include "thread/thread.h"
#include "asset/loader/assetentry.h"
#include "asset/loader/assetloading.h"
#ifndef assetInitPlatform #ifndef assetInitPlatform
#error "Platform must define assetInitPlatform function." #error "Platform must define assetInitPlatform function."
@@ -20,174 +17,49 @@
#error "Platform must define assetDisposePlatform function." #error "Platform must define assetDisposePlatform function."
#endif #endif
#define ASSET_FILE_NAME "dusk.dsk"
#define ASSET_HEADER_SIZE 3
#define ASSET_LOADING_COUNT_MAX 10
#define ASSET_ENTRY_COUNT_MAX 64
#define ASSET_BASE_DIRECTORY_MAX 256 #define ASSET_BASE_DIRECTORY_MAX 256
typedef struct asset_s { typedef struct {
// The dusk.dsk archive - a single uncompressed (ZIP_STORED) zip, so // Directory that every asset filename is resolved relative to. Set by the
// every entry gets the reliable repeated seeking/re-opening that // platform in assetInitPlatform.
// libzip only supports reliably for uncompressed entries (locale
// strings, MP3 loop restarts, etc.).
zip_t *zip;
// Directory dusk.dsk was actually found/opened in (on PSP, the directory
// EBOOT.PBP lives in, since dusk.dsk is embedded inside it rather than a
// loose file) - populated by each platform's own assetInitXxx() once it
// locates the file. Save devices that persist to a real filesystem path
// (Linux, PSP, Wii SD) use this as their base directory instead of a
// separate hardcoded location, so saves live next to wherever the game
// itself was actually launched from rather than some unrelated fixed
// path. Not meaningful for devices with no filesystem-path concept at
// all (GameCube/Wii memory card, Wii NAND's ISFS namespace).
char_t baseDirectory[ASSET_BASE_DIRECTORY_MAX]; char_t baseDirectory[ASSET_BASE_DIRECTORY_MAX];
assetplatform_t platform;
// Guards every libzip call against ASSET.zip (zip_fopen,
// zip_fread, zip_fclose, zip_fseek, zip_stat, zip_name_locate - see
// assetfile.c/assetFileExists). libzip is documented as not thread-safe,
// and this asset system genuinely calls it from multiple real threads at
// once (main thread, the background load thread below, and - on
// platforms that stream PCM from an asset on their own thread, like PSP
// - an audio feeder thread too). Confirmed necessary on real PSP
// hardware: without this lock, concurrent zip_fopen/zip_fread calls from
// different threads corrupted reads (EINVAL, then zlib data errors) once
// dusk.dsk stopped being read entirely into memory up front.
threadmutex_t zipLock;
// Background loading thread.
thread_t loadThread;
// Assets that are mid loading.
assetloading_t loading[ASSET_LOADING_COUNT_MAX];
assetentry_t entries[ASSET_ENTRY_COUNT_MAX];
} asset_t; } asset_t;
extern asset_t ASSET; extern asset_t ASSET;
/** /**
* Initializes the asset system. * Initializes the asset system. The platform locates the assets and sets
* ASSET.baseDirectory, or throws an error if it cannot.
* *
* @return An error code if the asset system could not be initialized properly. * @return An errorret_t indicating success or failure.
*/ */
errorret_t assetInit(void); errorret_t assetInit(void);
/** /**
* Checks if an asset file exists. * Builds the full path of an asset on disk.
* *
* @param filename The filename of the asset to check. * @param filename Asset filename, relative to the asset directory.
* @return true if the asset file exists, false otherwise. * @param out Buffer to write the null terminated path into.
* @param outSize Size of the buffer in bytes.
* @return An error if the filename is invalid or the path does not fit.
*/
errorret_t assetGetPath(
const char_t *filename,
char_t *out,
const size_t outSize
);
/**
* Checks whether an asset exists.
*
* @param filename Asset filename, relative to the asset directory.
* @return True if the asset exists and can be opened for reading.
*/ */
bool_t assetFileExists(const char_t *filename); bool_t assetFileExists(const char_t *filename);
/** /**
* Gets, or creates, a new asset entry. Internal - prefer assetLock. * Disposes of the asset system.
* *
* @param name Filename of the asset. * @return An errorret_t indicating success or failure.
* @param type Type of the asset.
* @param input Loader-specific parameters.
*/
assetentry_t * assetGetEntry(
const char_t *name,
const assetloadertype_t type,
assetloaderinput_t *input
);
/**
* Gets all asset entries of a given type.
*
* @param outEntries Output array to write the entries to.
* @param type Type of the asset entries to get.
* @return The number of entries written to outEntries.
*/
uint32_t assetGetEntriesOfType(
assetentry_t **outEntries,
const assetloadertype_t type
);
/**
* Gets, creates, and locks an asset entry. The asset will begin loading on
* the next assetUpdate. Call assetUnlock when done to allow the entry to be
* reclaimed.
*
* @param name Filename of the asset.
* @param type Type of the asset.
* @param input Loader-specific parameters.
* @return The locked asset entry.
*/
assetentry_t * assetLock(
const char_t *name,
const assetloadertype_t type,
assetloaderinput_t *input
);
/**
* Releases a lock on an asset entry by name. When all locks are released the
* entry will be reclaimed at the start of the next assetUpdate.
*
* @param name Filename of the asset to unlock.
*/
void assetUnlock(const char_t *name);
/**
* Releases a lock on an asset entry by pointer. When all locks are released
* the entry will be reclaimed at the start of the next assetUpdate.
*
* @param entry The asset entry to unlock.
*/
void assetUnlockEntry(assetentry_t *entry);
/**
* Frees every currently unreferenced (zero-ref) loaded asset entry. Repeats
* until a full pass frees nothing further, since disposing a parent entry
* (e.g. a model) may drop a child entry's (e.g. a mesh) ref count to zero,
* making it eligible for reaping too.
*
* @return An error code if any entry could not be disposed properly.
*/
errorret_t assetReapUnused(void);
/**
* Requires an asset entry to be loaded. This will block until the asset entry
* is fully loaded.
*
* @param entry The asset entry to require.
* @return An error code if the asset entry could not be loaded properly.
*/
errorret_t assetRequireLoaded(assetentry_t *entry);
/**
* Requires an asset entry to be disposed. This will block until the asset entry
* is fully disposed.
*
* @param entry The asset entry to require disposal of.
* @return An error code if the asset entry could not be disposed properly.
*/
errorret_t assetRequireDisposed(assetentry_t *entry);
/**
* Updates the asset system.
*
* @return An error code if the asset system could not be updated properly.
*/
errorret_t assetUpdate(void);
/**
* Starts the background asset loading thread. The thread runs assetUpdate
* in a loop with a short sleep until stopped.
*
* @param thread The thread runner.
*/
void assetUpdateAsync(thread_t *thread);
/**
* Disposes/cleans up the asset system.
*
* @return An error code if the asset system could not be disposed properly.
*/ */
errorret_t assetDispose(void); errorret_t assetDispose(void);
-165
View File
@@ -1,165 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetdsk.h"
#include "util/memory.h"
#include "util/endian.h"
#include "assert/assert.h"
#include <zlib.h>
errorret_t assetDskParseHeader(
const uint8_t *bytes,
const size_t bytesSize,
assetdskheader_t *outHeader
) {
assertNotNull(bytes, "Bytes cannot be NULL.");
assertNotNull(outHeader, "Out header cannot be NULL.");
if(bytesSize < ASSET_DSK_HEADER_SIZE) {
errorThrow("dusk.dsk header is truncated.");
}
if(memoryCompare(bytes, ASSET_DSK_MAGIC, ASSET_DSK_MAGIC_SIZE) != 0) {
errorThrow("dusk.dsk has an invalid magic header.");
}
// Every field is a little-endian uint32_t regardless of host - convert
// to host order (a no-op on little-endian hosts, a real byteswap on
// Dolphin's big-endian PowerPC).
uint32_t fields[3];
memoryCopy(fields, bytes + ASSET_DSK_MAGIC_SIZE, sizeof(fields));
for(uint8_t i = 0; i < 3; i++) {
fields[i] = endianLittleToHost32(fields[i]);
}
const uint32_t version = fields[0];
if(version != ASSET_DSK_VERSION) {
errorThrow("dusk.dsk has an unsupported version: %u", version);
}
outHeader->size = fields[1];
outHeader->checksum = fields[2];
errorOk();
}
errorret_t assetDskOpenFromPath(
const char_t *path,
zip_t **outZip
) {
errorChain(assetDskOpenFromPathRange(path, 0, SIZE_MAX, outZip));
errorOk();
}
errorret_t assetDskOpenFromPathRange(
const char_t *path,
const size_t baseOffset,
const size_t baseSize,
zip_t **outZip
) {
assertNotNull(path, "Path cannot be NULL.");
assertNotNull(outZip, "Out zip cannot be NULL.");
*outZip = NULL;
FILE *headerFile = fopen(path, "rb");
if(headerFile == NULL) {
errorThrow("Failed to open dusk.dsk: %s", path);
}
if(fseek(headerFile, (long) baseOffset, SEEK_SET) != 0) {
fclose(headerFile);
errorThrow("Failed to seek to dusk.dsk range in file: %s", path);
}
uint8_t headerBytes[ASSET_DSK_HEADER_SIZE];
size_t headerRead = fread(headerBytes, 1, sizeof(headerBytes), headerFile);
fclose(headerFile);
if(headerRead != sizeof(headerBytes)) {
errorThrow("Failed to read dusk.dsk header: %s", path);
}
assetdskheader_t header;
errorChain(assetDskParseHeader(headerBytes, sizeof(headerBytes), &header));
if((size_t) ASSET_DSK_HEADER_SIZE + header.size > baseSize) {
errorThrow("dusk.dsk header describes a range beyond its containing file.");
}
zip_error_t zipError;
zip_error_init(&zipError);
zip_source_t *source = zip_source_file_create(
path,
(zip_uint64_t) (baseOffset + ASSET_DSK_HEADER_SIZE),
(zip_int64_t) header.size,
&zipError
);
if(source == NULL) {
errorThrow(
"Failed to create dusk.dsk source: %s", zip_error_strerror(&zipError)
);
}
*outZip = zip_open_from_source(source, ZIP_RDONLY, &zipError);
if(*outZip == NULL) {
zip_source_free(source);
errorThrow(
"Failed to open dusk.dsk archive: %s", zip_error_strerror(&zipError)
);
}
errorOk();
}
errorret_t assetDskOpenFromBuffer(
uint8_t *buffer,
const size_t bufferSize,
zip_t **outZip
) {
assertNotNull(buffer, "Buffer cannot be NULL.");
assertNotNull(outZip, "Out zip cannot be NULL.");
*outZip = NULL;
assetdskheader_t header;
errorChain(assetDskParseHeader(buffer, bufferSize, &header));
if((size_t) ASSET_DSK_HEADER_SIZE + header.size > bufferSize) {
errorThrow("dusk.dsk header describes a range beyond the buffer.");
}
uint8_t *data = buffer + ASSET_DSK_HEADER_SIZE;
uint32_t checksum = (uint32_t) crc32(0L, data, (uInt) header.size);
if(checksum != header.checksum) {
errorThrow("dusk.dsk archive failed checksum verification.");
}
zip_error_t zipError;
zip_error_init(&zipError);
// freep=0 - this is a non-owning window into the same caller-owned
// buffer, not an independent allocation libzip should free.
zip_source_t *source = zip_source_buffer_create(
data, header.size, 0, &zipError
);
if(source == NULL) {
errorThrow(
"Failed to create dusk.dsk source: %s", zip_error_strerror(&zipError)
);
}
*outZip = zip_open_from_source(source, ZIP_RDONLY, &zipError);
if(*outZip == NULL) {
zip_source_free(source);
errorThrow(
"Failed to open dusk.dsk archive: %s", zip_error_strerror(&zipError)
);
}
errorOk();
}
-120
View File
@@ -1,120 +0,0 @@
/**
* 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 <zip.h>
// "DSK2" - distinct from a plain zip's "PK\x03\x04" so a stray plain zip
// never gets misread as a valid dusk.dsk.
#define ASSET_DSK_MAGIC_SIZE 4
#define ASSET_DSK_MAGIC "DSK"
#define ASSET_DSK_VERSION 2
// Magic + version + size + checksum
#define ASSET_DSK_HEADER_SIZE ( \
ASSET_DSK_MAGIC_SIZE + \
sizeof(uint32_t) + \
sizeof(uint32_t) + \
sizeof(uint32_t) \
)
/**
* Parsed dusk.dsk (DSK2 format) header. dusk.dsk is a single uncompressed
* (ZIP_STORED) zip archive (see tools/asset/pack) rather than DEFLATEd -
* the bulk of what's packed (audio, images) is already compressed at the
* file level, so DEFLATE-ing the whole archive on top buys negligible
* space for real CPU cost, and every entry gets the reliable repeated
* seeking/re-opening that libzip only supports for uncompressed entries
* (locale line-by-line reads, MP3 loop restarts, etc.) instead of just a
* hand-picked subset of files.
*/
typedef struct {
uint32_t size;
uint32_t checksum;
} assetdskheader_t;
/**
* Parses a DSK2 header from a raw byte buffer (at least
* ASSET_DSK_HEADER_SIZE bytes), validating the magic/version and
* byte-swapping the little-endian fields to host order.
*
* @param bytes Buffer containing the header (and beyond).
* @param bytesSize Number of bytes available at `bytes`.
* @param outHeader Filled with the parsed header on success.
* @return OK on success, error if too short, bad magic, or unsupported version.
*/
errorret_t assetDskParseHeader(
const uint8_t *bytes,
const size_t bytesSize,
assetdskheader_t *outHeader
);
/**
* Opens the dusk.dsk archive given a filesystem path, using a lazy/
* windowed file-backed zip source - no more memory used than the
* existing per-platform small read buffers, matching the memory
* characteristics of a plain zip_open() on the whole file. The checksum
* is intentionally not verified here since doing so would require
* reading the whole (potentially large) archive just to compute it.
*
* See assetDskOpenFromPathRange() for the case where the DSK2 blob isn't
* the whole file (e.g. embedded inside another container).
*
* @param path Filesystem path to the dusk.dsk file.
* @param outZip Set to the opened archive on success.
* @return OK on success, error if the file is missing, too short, has a
* bad header, or the archive fails to open.
*/
errorret_t assetDskOpenFromPath(
const char_t *path,
zip_t **outZip
);
/**
* Same as assetDskOpenFromPath(), but the DSK2 blob doesn't start at the
* beginning of `path` - it's a byte range embedded inside a larger
* container file (e.g. the PSAR region of an EBOOT.PBP). Every offset the
* header describes is relative to `baseOffset`; `baseSize` bounds them
* (the size of the embedded blob, not the whole container file) - pass
* SIZE_MAX to skip that check when the caller doesn't know/care.
*
* @param path Filesystem path to the container file.
* @param baseOffset Byte offset within `path` where the DSK2 blob starts.
* @param baseSize Number of bytes available at `baseOffset`.
* @param outZip Set to the opened archive on success.
* @return OK on success, error if the file is missing, too short, has a
* bad header, or the archive fails to open.
*/
errorret_t assetDskOpenFromPathRange(
const char_t *path,
const size_t baseOffset,
const size_t baseSize,
zip_t **outZip
);
/**
* Opens the dusk.dsk archive already fully resident in memory (e.g. a
* PSAR embedded in an EBOOT.PBP, or an ISO-embedded file already read via
* DVD_ReadAbs - platforms that already buffer the whole file for reasons
* unrelated to this format). The returned archive doesn't take ownership
* of `buffer` (it's opened as a non-owning sub-range of it) - the caller
* remains responsible for freeing it, and must keep it alive for as long
* as the archive stays open. Verifies the checksum, since the bytes are
* already resident.
*
* @param buffer The whole dusk.dsk file's bytes.
* @param bufferSize Number of bytes at `buffer`.
* @param outZip Set to the opened archive on success.
* @return OK on success, error if too short, has a bad header/checksum, or
* the archive fails to open.
*/
errorret_t assetDskOpenFromBuffer(
uint8_t *buffer,
const size_t bufferSize,
zip_t **outZip
);
+78 -356
View File
@@ -8,165 +8,113 @@
#include "asset/asset.h" #include "asset/asset.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h"
#include "util/math.h" #include "util/math.h"
errorret_t assetFileInit( errorret_t assetFileInit(assetfile_t *file, const char_t *filename) {
assetfile_t *file,
const char_t *filename,
void *params,
void *output
) {
assertNotNull(file, "Asset file cannot be NULL."); assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(filename, "Filename cannot be NULL.");
assertStrLenMax(filename, ASSET_FILE_NAME_MAX, "Filename too long."); assertStrLenMax(filename, ASSET_FILE_NAME_MAX, "Filename too long.");
memoryZero(file, sizeof(assetfile_t)); memoryZero(file, sizeof(assetfile_t));
memoryCopy(file->filename, filename, ASSET_FILE_NAME_MAX); stringCopy(file->filename, filename, ASSET_FILE_NAME_MAX);
file->params = params;
file->output = output;
zip_stat_init(&file->stat); // Open temporarily to find the size.
threadMutexLock(&ASSET.zipLock); char_t path[ASSET_PATH_MAX];
int statResult = zip_stat(ASSET.zip, filename, 0, &file->stat); errorChain(assetGetPath(filename, path, sizeof(path)));
threadMutexUnlock(&ASSET.zipLock);
if(statResult != 0) { FILE *handle = fopen(path, "rb");
errorThrow("Failed to stat asset file: %s", filename); if(handle == NULL) {
errorThrow("Failed to open asset file: %s", filename);
}
if(fseek(handle, 0, SEEK_END) != 0) {
fclose(handle);
errorThrow("Failed to seek asset file: %s", filename);
}
long size = ftell(handle);
fclose(handle);
if(size < 0) {
errorThrow("Failed to get size of asset file: %s", filename);
} }
// Minimum file size. file->size = (size_t)size;
file->size = (zip_int64_t)file->stat.size;
if(file->size <= 0) {
errorThrow("Invalid asset file size: %s", filename);
}
errorOk();
}
errorret_t assetFileRewind(assetfile_t *file) {
assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(file->zipFile, "Asset file must be opened before rewinding.");
if(file->position == 0) {
errorOk();
}
// Prefer seeking within the still-open handle over closing and
// re-opening it. Repeatedly closing/re-opening the same compressed zip
// entry (once per rewind - e.g. once per locale string lookup) was
// confirmed unreliable on at least one platform's zip backend, silently
// skipping ranges of the decompressed content on some re-opens after
// the first. A real seek avoids that failure mode entirely, with no
// extra memory cost over the close+reopen fallback.
threadMutexLock(&ASSET.zipLock);
bool_t seekable = zip_file_is_seekable(file->zipFile);
int seekResult = seekable ? zip_fseek(file->zipFile, 0, SEEK_SET) : -1;
threadMutexUnlock(&ASSET.zipLock);
if(seekable) {
if(seekResult != 0) {
errorThrow("Failed to seek asset file: %s", file->filename);
}
file->position = 0;
errorOk();
}
errorChain(assetFileClose(file));
errorChain(assetFileOpen(file));
errorOk(); errorOk();
} }
errorret_t assetFileOpen(assetfile_t *file) { errorret_t assetFileOpen(assetfile_t *file) {
assertNotNull(file, "Asset file cannot be NULL."); assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(file->filename, "Asset file filename cannot be NULL."); assertNull(file->file, "Asset file already open.");
assertNull(file->zipFile, "Asset file already open.");
threadMutexLock(&ASSET.zipLock); char_t path[ASSET_PATH_MAX];
file->zipFile = zip_fopen(ASSET.zip, file->filename, 0); errorChain(assetGetPath(file->filename, path, sizeof(path)));
threadMutexUnlock(&ASSET.zipLock);
if(file->zipFile == NULL) { file->file = fopen(path, "rb");
if(file->file == NULL) {
errorThrow("Failed to open asset file: %s", file->filename); errorThrow("Failed to open asset file: %s", file->filename);
} }
file->position = 0; file->position = 0;
errorOk(); errorOk();
} }
errorret_t assetFileRewind(assetfile_t *file) {
assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(file->file, "Asset file must be opened before rewinding.");
if(fseek(file->file, 0, SEEK_SET) != 0) {
errorThrow("Failed to rewind asset file: %s", file->filename);
}
file->position = 0;
errorOk();
}
errorret_t assetFileRead( errorret_t assetFileRead(
assetfile_t *file, assetfile_t *file,
void *buffer, void *buffer,
const size_t bufferSize const size_t bufferSize
) { ) {
assertNotNull(file, "Asset file cannot be NULL."); assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(file->zipFile, "Asset file must be opened before reading."); assertNotNull(file->file, "Asset file must be opened before reading.");
// No buffer, skip the bytes.
if(buffer == NULL) { if(buffer == NULL) {
size_t bytesRemaining = bufferSize; size_t skipped = 0;
uint8_t tempBuffer[256]; uint8_t temp[256];
while(bytesRemaining > 0) { while(skipped < bufferSize) {
size_t chunkSize = mathMin(bytesRemaining, sizeof(tempBuffer)); size_t chunk = mathMin(bufferSize - skipped, sizeof(temp));
// The recursive call below already advances file->position by size_t got = fread(temp, 1, chunk, file->file);
// chunkSize (the non-NULL branch does this itself) - do not also skipped += got;
// advance it here, or every skip ends up double-counted. if(got < chunk) break;
errorChain(assetFileRead(file, tempBuffer, chunkSize));
bytesRemaining -= chunkSize;
} }
file->lastRead = bufferSize; file->position += skipped;
file->lastRead = skipped;
errorOk(); errorOk();
} }
// Some zip_fread() implementations (seen on PSP) reject a single call size_t got = fread(buffer, 1, bufferSize, file->file);
// asking for the entire (potentially large) file at once with EINVAL; if(got < bufferSize && ferror(file->file)) {
// the line reader above only ever asks for up to 1024 bytes per call and errorThrow("Failed to read from asset file: %s", file->filename);
// works fine, so read in bounded chunks here too.
size_t totalRead = 0;
uint8_t *dest = (uint8_t *)buffer;
while(totalRead < bufferSize) {
size_t chunkSize = mathMin(
bufferSize - totalRead, ASSET_FILE_READ_CHUNK_MAX
);
threadMutexLock(&ASSET.zipLock);
zip_int64_t bytesRead = zip_fread(
file->zipFile, dest + totalRead, chunkSize
);
errorret_t readError = errorOkImpl();
if(bytesRead < 0) {
// Built (not thrown) while still holding the lock, so
// zip_file_strerror() reads the just-failed zipFile's error state
// before another thread can touch it - errorThrow() itself isn't
// used here since it returns immediately, which would leave
// ASSET.zipLock held forever.
readError = errorThrowImpl(
&ERROR_STATE, ERROR_NOT_OK, __FILE__, __func__, __LINE__,
"Failed to read from asset file: %s (%s)",
file->filename, zip_file_strerror(file->zipFile)
);
} }
threadMutexUnlock(&ASSET.zipLock); file->position += got;
if(errorIsNotOk(readError)) { file->lastRead = got;
errorChain(readError);
}
if(bytesRead == 0) break;
totalRead += (size_t)bytesRead;
}
file->position += totalRead;
file->lastRead = totalRead;
errorOk(); errorOk();
} }
errorret_t assetFileClose(assetfile_t *file) { errorret_t assetFileClose(assetfile_t *file) {
assertNotNull(file, "Asset file cannot be NULL."); assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(file->zipFile, "Asset file must be opened before closing."); assertNotNull(file->file, "Asset file must be opened before closing.");
threadMutexLock(&ASSET.zipLock); int result = fclose(file->file);
int closeResult = zip_fclose(file->zipFile); file->file = NULL;
threadMutexUnlock(&ASSET.zipLock); file->position = 0;
if(closeResult != 0) { if(result != 0) {
errorThrow("Failed to close asset file: %s", file->filename); errorThrow("Failed to close asset file: %s", file->filename);
} }
file->zipFile = NULL;
file->position = 0;
errorOk(); errorOk();
} }
errorret_t assetFileDispose(assetfile_t *file) { errorret_t assetFileDispose(assetfile_t *file) {
if(file->zipFile != NULL) { assertNotNull(file, "Asset file cannot be NULL.");
if(file->file != NULL) {
errorChain(assetFileClose(file)); errorChain(assetFileClose(file));
} }
memoryZero(file, sizeof(assetfile_t)); memoryZero(file, sizeof(assetfile_t));
@@ -181,256 +129,30 @@ errorret_t assetFileReadEntire(
assertNotNull(file, "Asset file cannot be NULL."); assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(outBuffer, "outBuffer cannot be NULL."); assertNotNull(outBuffer, "outBuffer cannot be NULL.");
assertNotNull(outSize, "outSize cannot be NULL."); assertNotNull(outSize, "outSize cannot be NULL.");
assertTrue( assertNull(file->file, "Asset file must be closed before reading entire.");
file->size > 0,
"Asset file has no size; call assetFileInit first." if(file->size == 0) {
errorThrow("Asset file is empty: %s", file->filename);
}
errorChain(assetFileOpen(file));
uint8_t *buffer = (uint8_t *)memoryAllocate(file->size);
errorret_t ret = assetFileRead(file, buffer, file->size);
if(errorIsOk(ret) && file->lastRead != file->size) {
ret = errorThrowImpl(
&ERROR_STATE, ERROR_NOT_OK, __FILE__, __func__, __LINE__,
"Failed to read entire asset file: %s", file->filename
); );
// File should be closed currently.
assertNull(file->zipFile, "Asset file must be closed before reading entire.");
// Open file
errorret_t ret = assetFileOpen(file);
if(errorIsNotOk(ret)) {
errorChain(ret);
} }
// Set output.
size_t size = (size_t)file->size;
uint8_t *buffer = (uint8_t *)memoryAllocate(size);
// Read entire file.
ret = assetFileRead(file, buffer, size);
if(errorIsNotOk(ret)) {
memoryFree(buffer);
errorChain(ret);
}
// Close the file.
ret = assetFileClose(file);
if(errorIsNotOk(ret)) { if(errorIsNotOk(ret)) {
memoryFree(buffer); memoryFree(buffer);
assetFileClose(file);
errorChain(ret); errorChain(ret);
} }
errorChain(assetFileClose(file));
*outBuffer = buffer; *outBuffer = buffer;
*outSize = size; *outSize = file->size;
errorOk(); errorOk();
} }
// Line Reader;
void assetFileLineReaderInit(
assetfilelinereader_t *reader,
assetfile_t *file,
uint8_t *readBuffer,
const size_t readBufferSize,
uint8_t *outBuffer,
const size_t outBufferSize
) {
assertNotNull(reader, "Line reader cannot be NULL.");
assertNotNull(file, " File cannot be NULL.");
assertNotNull(readBuffer, "Read buffer cannot be NULL.");
assertNotNull(outBuffer, "Output buffer cannot be NULL.");
assertTrue(readBufferSize > 0, "Read buffer size must be greater than 0.");
assertTrue(outBufferSize > 0, "Output buffer size must be greater than 0.");
memoryZero(reader, sizeof(assetfilelinereader_t));
reader->file = file;
reader->readBuffer = readBuffer;
reader->readBufferSize = readBufferSize;
reader->outBuffer = outBuffer;
reader->outBufferSize = outBufferSize;
}
size_t assetFileLineReaderUnreadBytes(const assetfilelinereader_t *reader) {
assertNotNull(reader, "Reader cannot be NULL.");
assertTrue(reader->bufferEnd >= reader->bufferStart, "Invalid buffer state.");
return reader->bufferEnd - reader->bufferStart;
}
const uint8_t *assetFileLineReaderUnreadPtr(
const assetfilelinereader_t *reader
) {
assertNotNull(reader, "Reader cannot be NULL.");
assertNotNull(reader->readBuffer, "Read buffer cannot be NULL.");
return reader->readBuffer + reader->bufferStart;
}
errorret_t assetFileLineReaderAppend(
assetfilelinereader_t *reader,
const uint8_t *src,
size_t srcLength
) {
assertNotNull(reader, "Reader cannot be NULL.");
assertNotNull(reader->outBuffer, "Out buffer cannot be NULL.");
if(srcLength == 0) {
errorOk();
}
/* reserve room for optional NUL terminator */
if(reader->lineLength + srcLength >= reader->outBufferSize) {
errorThrow("Line length exceeds output buffer size.");
}
memoryCopy(reader->outBuffer + reader->lineLength, src, srcLength);
reader->lineLength += srcLength;
errorOk();
}
void assetFileLineReaderTerminate(assetfilelinereader_t *reader) {
assertNotNull(reader, "Reader cannot be NULL.");
assertNotNull(reader->outBuffer, "Out buffer cannot be NULL.");
assertTrue(
reader->lineLength < reader->outBufferSize,
"Line length exceeds out buffer."
);
reader->outBuffer[reader->lineLength] = '\0';
}
ssize_t assetFileLineReaderFindNewline(
const assetfilelinereader_t *reader
) {
size_t i;
assertNotNull(reader, "Reader cannot be NULL.");
assertNotNull(reader->readBuffer, "Read buffer cannot be NULL.");
for(i = reader->bufferStart; i < reader->bufferEnd; ++i) {
if(reader->readBuffer[i] == '\n') {
return (ssize_t)i;
}
}
return -1;
}
errorret_t assetFileLineReaderFill(assetfilelinereader_t *reader) {
assertNotNull(reader, "Reader cannot be NULL.");
assertNotNull(reader->file, "File cannot be NULL.");
assertNotNull(reader->readBuffer, "Read buffer cannot be NULL.");
if(reader->eof) errorOk();
errorret_t ret;
size_t unreadBytes = assetFileLineReaderUnreadBytes(reader);
/* If buffer is fully consumed, refill from start. */
if(unreadBytes == 0) {
reader->bufferStart = 0;
reader->bufferEnd = 0;
errorChain(assetFileRead(
reader->file,
reader->readBuffer,
reader->readBufferSize
));
if(reader->file->lastRead == 0) {
reader->eof = true;
errorOk();
}
reader->bufferStart = 0;
reader->bufferEnd = reader->file->lastRead;
errorOk();
}
/*
* There are unread bytes left but no newline in them.
* If bufferStart > 0, slide unread bytes to front so we can read more.
* This only happens when necessary to make space.
*/
if(reader->bufferEnd == reader->readBufferSize) {
if(reader->bufferStart == 0) {
/*
* Entire read buffer is unread and contains no newline.
* Caller must have a large enough outBuffer to accumulate across fills,
* so we consume these bytes into outBuffer before refilling.
*/
errorOk();
}
memoryMove(
reader->readBuffer,
reader->readBuffer + reader->bufferStart,
unreadBytes
);
reader->bufferStart = 0;
reader->bufferEnd = unreadBytes;
}
errorChain(assetFileRead(
reader->file,
reader->readBuffer + reader->bufferEnd,
reader->readBufferSize - reader->bufferEnd
));
if(reader->file->lastRead == 0) {
reader->eof = true;
errorOk();
}
reader->bufferEnd += reader->file->lastRead;
errorOk();
}
errorret_t assetFileLineReaderNext(assetfilelinereader_t *reader) {
assertNotNull(reader, "Reader cannot be NULL.");
assertNotNull(reader->file, "File cannot be NULL.");
assertNotNull(reader->readBuffer, "Read buffer cannot be NULL.");
assertNotNull(reader->outBuffer, "Out buffer cannot be NULL.");
reader->lineLength = 0;
for(;;) {
ssize_t newlineIndex = assetFileLineReaderFindNewline(reader);
if(newlineIndex >= 0) {
size_t chunkLength = (size_t)newlineIndex - reader->bufferStart;
errorret_t ret;
/* strip CR in CRLF */
if(
chunkLength > 0 &&
reader->readBuffer[(size_t)newlineIndex - 1] == '\r'
) {
chunkLength--;
}
errorChain(assetFileLineReaderAppend(
reader,
reader->readBuffer + reader->bufferStart,
chunkLength
));
reader->bufferStart = (size_t)newlineIndex + 1;
assetFileLineReaderTerminate(reader);
errorOk();
}
if(assetFileLineReaderUnreadBytes(reader) > 0) {
errorChain(assetFileLineReaderAppend(
reader,
assetFileLineReaderUnreadPtr(reader),
assetFileLineReaderUnreadBytes(reader)
));
reader->bufferStart = reader->bufferEnd;
}
if(reader->eof) {
if(reader->lineLength > 0) {
assetFileLineReaderTerminate(reader);
errorOk();
}
errorThrow("End of file reached.");
}
errorChain(assetFileLineReaderFill(reader));
}
}
+38 -161
View File
@@ -7,73 +7,55 @@
#pragma once #pragma once
#include "error/error.h" #include "error/error.h"
#include <zip.h>
#define ASSET_FILE_NAME_MAX 128 #define ASSET_FILE_NAME_MAX 128
// Max bytes requested per zip_fread() call in assetFileRead(). Some // Full on disk path, base directory plus filename.
// zip_fread() implementations (seen on PSP) reject a single call asking #define ASSET_PATH_MAX 512
// for very large amounts of data at once; the locale line reader has
// always used 1024-byte reads successfully, so that's the proven-safe cap.
#define ASSET_FILE_READ_CHUNK_MAX 1024
typedef struct assetfile_s assetfile_t; typedef struct {
typedef errorret_t (*assetfileloader_t)(assetfile_t *file);
// Describes a file not yet loaded.
typedef struct assetfile_s {
char_t filename[ASSET_FILE_NAME_MAX]; char_t filename[ASSET_FILE_NAME_MAX];
void *params; FILE *file;
void *output; size_t size;
size_t position;
zip_stat_t stat; size_t lastRead;
zip_int64_t size;
zip_int64_t position;
zip_int64_t lastRead;
zip_file_t *zipFile;
} assetfile_t; } assetfile_t;
/** /**
* Initializes the asset file structure in preparation for loading. This will * Initializes an asset file, checking it exists and reading its size. Does not
* stat the file but not open the handle. * open it.
* *
* @param file The asset file structure to initialize. * @param file The asset file to initialize.
* @param filename The name of the asset file to load. * @param filename Asset filename, relative to the asset directory.
* @param params Optional loader params. * @return An error if the file does not exist or cannot be read.
* @param output Output pointer for the loader.
* @return Error indicating success or failure.
*/ */
errorret_t assetFileInit( errorret_t assetFileInit(assetfile_t *file, const char_t *filename);
assetfile_t *file,
const char_t *filename,
void *params,
void *output
);
/** /**
* Opens the asset file for reading. After opening the loader is responsible * Opens an asset file for reading, positioned at the start.
* for closing the file.
* *
* @param file The asset file to open. * @param file The asset file to open. Must not already be open.
* @return An error code if the file could not be opened. * @return An error if the file cannot be opened.
*/ */
errorret_t assetFileOpen(assetfile_t *file); errorret_t assetFileOpen(assetfile_t *file);
/** /**
* Rewind the file to the initial position. * Moves an open asset file back to its start.
* *
* @param file The asset file to rewind. * @param file The asset file to rewind. Must be open.
* @return An error if the file cannot be rewound.
*/ */
errorret_t assetFileRewind(assetfile_t *file); errorret_t assetFileRewind(assetfile_t *file);
/** /**
* Read bytes from the asset file. Assumes the file has already been opened * Reads bytes from an open asset file. The number of bytes actually read is
* prior to trying to read anything. * stored in file->lastRead, which is less than bufferSize at the end of the
* file.
* *
* @param file File to read from. * @param file The asset file to read from. Must be open.
* @param buffer Buffer to read the file data into., or NULL to skip bytes. * @param buffer Buffer to read into. If NULL the bytes are skipped.
* @param size Size of the buffer to read into. * @param bufferSize Number of bytes to read.
* @return An error if reading fails.
*/ */
errorret_t assetFileRead( errorret_t assetFileRead(
assetfile_t *file, assetfile_t *file,
@@ -82,138 +64,33 @@ errorret_t assetFileRead(
); );
/** /**
* Closes the asset file and releases any resources associated with it. * Closes an open asset file.
* *
* @param file The asset file to close. * @param file The asset file to close. Must be open.
* @return An error code if the file could not be closed properly. * @return An error if the file cannot be closed.
*/ */
errorret_t assetFileClose(assetfile_t *file); errorret_t assetFileClose(assetfile_t *file);
/** /**
* Disposes the asset file structure, closing any open handles and zeroing * Disposes an asset file, closing it first if it is still open.
* out the structure.
* *
* @param file The asset file to dispose. * @param file The asset file to dispose.
* @return An error code if the file could not be disposed properly. * @return An error if the file cannot be closed.
*/ */
errorret_t assetFileDispose(assetfile_t *file); errorret_t assetFileDispose(assetfile_t *file);
/** /**
* Reads the entire contents of the asset file into a newly allocated buffer. * Reads a whole asset file into a newly allocated buffer. The file is opened
* The caller is responsible for freeing the buffer with memoryFree. * and closed by this call, and must not already be open.
* *
* @param file The asset file to read. Must be initialized but not open. * @param file The asset file to read, already initialized.
* @param outBuffer Receives a pointer to the allocated buffer. * @param outBuffer Output buffer, allocated with memoryAllocate. The caller
* @param outSize Receives the number of bytes written to the buffer. * must memoryFree it.
* @return An error code if the file could not be read. * @param outSize Output size of the buffer in bytes.
* @return An error if the file cannot be read in full.
*/ */
errorret_t assetFileReadEntire( errorret_t assetFileReadEntire(
assetfile_t *file, assetfile_t *file,
uint8_t **outBuffer, uint8_t **outBuffer,
size_t *outSize size_t *outSize
); );
typedef struct {
assetfile_t *file;
uint8_t *readBuffer;
size_t readBufferSize;
uint8_t *outBuffer;
size_t outBufferSize;
// A
size_t bufferStart;
size_t bufferEnd;
bool_t eof;//?
// Updated each reach:
size_t lineLength;
} assetfilelinereader_t;
/**
* Initializes a line reader for the given asset file. The line reader will read
* lines from the file into the provided line buffer, using the provided buffer
* for reading chunks of the file.
*
* @param file The asset file to read from. Must already be opened.
* @param readBuffer Buffer to use for reading chunks of the file.
* @param readBufferSize Size of the read buffer.
* @param outBuffer Buffer to read lines into. Lines will be null-terminated.
* @param outBufferSize Size of the output buffer.
* @return An initialized line reader structure.
*/
void assetFileLineReaderInit(
assetfilelinereader_t *reader,
assetfile_t *file,
uint8_t *readBuffer,
const size_t readBufferSize,
uint8_t *outBuffer,
const size_t outBufferSize
);
/**
* Returns the number of bytes still unread in the line reader's read buffer.
*
* @param reader The line reader to check.
* @return Number of unread bytes remaining in the read buffer.
*/
size_t assetFileLineReaderUnreadBytes(const assetfilelinereader_t *reader);
/**
* Returns a pointer to the first unread byte in the line reader's read
* buffer.
*
* @param reader The line reader to check.
* @return Pointer to the first unread byte.
*/
const uint8_t *assetFileLineReaderUnreadPtr(
const assetfilelinereader_t *reader
);
/**
* Appends src to the line reader's current line buffer, growing lineLength.
*
* @param reader The line reader whose out buffer to append to.
* @param src Bytes to append.
* @param srcLength Number of bytes in src.
* @return Error state if any (the line would exceed the output buffer).
*/
errorret_t assetFileLineReaderAppend(
assetfilelinereader_t *reader,
const uint8_t *src,
size_t srcLength
);
/**
* Null-terminates the line reader's current line buffer at lineLength.
*
* @param reader The line reader whose out buffer to terminate.
*/
void assetFileLineReaderTerminate(assetfilelinereader_t *reader);
/**
* Searches the line reader's unread buffered bytes for a newline character.
*
* @param reader The line reader to search.
* @return The index of the newline within readBuffer, or -1 if not found.
*/
ssize_t assetFileLineReaderFindNewline(const assetfilelinereader_t *reader);
/**
* Refills the line reader's read buffer from the underlying file once its
* buffered bytes are fully consumed. A no-op once the file is at EOF.
*
* @param reader The line reader to refill.
* @return Error state if any.
*/
errorret_t assetFileLineReaderFill(assetfilelinereader_t *reader);
/**
* Reads the next line from the asset file into the line buffer. The line
* buffer is null-terminated and does not include the newline character.
*
* @param reader The line reader to read from.
* @return An error code if a failure occurs, or errorOk() if a line was read
* successfully. If the end of the file is reached, errorEndOfFile() is
* returned.
*/
errorret_t assetFileLineReaderNext(assetfilelinereader_t *reader);
-20
View File
@@ -1,20 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
assetentry.c
assetloader.c
)
# Subdirs
add_subdirectory(display)
add_subdirectory(locale)
add_subdirectory(json)
add_subdirectory(dmf)
add_subdirectory(wav)
add_subdirectory(mp3)
-88
View File
@@ -1,88 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetentry.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
void assetEntryInit(
assetentry_t *entry,
const char_t *name,
const assetloadertype_t type,
assetloaderinput_t *input
) {
assertNotNull(entry, "Entry cannot be NULL");
assertStrLenMin(name, 1, "Name cannot be empty");
assertStrLenMax(name, ASSET_FILE_NAME_MAX - 1, "Name too long");
assertTrue(type != ASSET_LOADER_TYPE_NULL, "Invalid loader type.");
assertTrue(type < ASSET_LOADER_TYPE_COUNT, "Invalid loader type.");
assertIsMainThread("Must be called from the main thread.");
memoryZero(entry, sizeof(assetentry_t));
stringCopy(entry->name, name, ASSET_FILE_NAME_MAX);
entry->type = type;
entry->state = ASSET_ENTRY_STATE_NOT_STARTED;
if(input) {
entry->inputData = *input;
entry->input = &entry->inputData;
} else {
memoryZero(&entry->inputData, sizeof(assetloaderinput_t));
entry->input = NULL;
}
refInit(&entry->refs, entry, NULL, NULL, NULL);
}
void assetEntryLock(assetentry_t *entry) {
assertNotNull(entry, "Entry cannot be NULL");
assertTrue(entry->type != ASSET_LOADER_TYPE_NULL, "Invalid loader type.");
refLock(&entry->refs);
}
void assetEntryUnlock(assetentry_t *entry) {
assertNotNull(entry, "Entry cannot be NULL");
assertTrue(entry->type != ASSET_LOADER_TYPE_NULL, "Invalid loader type.");
refUnlock(&entry->refs);
}
void assetEntryStartLoading(
assetentry_t *entry,
assetloading_t *loading
) {
assertNotNull(entry, "Entry cannot be NULL");
assertNotNull(loading, "Loading cannot be NULL");
assertTrue(entry->type != ASSET_LOADER_TYPE_NULL, "Invalid loader type.");
assertTrue(
entry->state == ASSET_ENTRY_STATE_NOT_STARTED,
"Can only start loading from NOT_STARTED state."
);
assertIsMainThread("Must be called from the main thread.");
entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
memoryZero(&loading->loading, sizeof(assetloaderloading_t));
loading->type = entry->type;
loading->entry = entry;
// At this point the asset manager will manage this thing's loading
}
errorret_t assetEntryDispose(assetentry_t *entry) {
assertNotNull(entry, "Entry cannot be NULL");
assertTrue(entry->type != ASSET_LOADER_TYPE_NULL, "Invalid loader type.");
assertTrue(entry->type < ASSET_LOADER_TYPE_COUNT, "Invalid loader type.");
assertIsMainThread("Must be called from the main thread.");
assertTrue(
entry->refs.count == 0,
"Asset entry still refed at dispose time."
);
if(entry->onUnloaded) entry->onUnloaded(entry, entry->onUnloadedUser);
errorChain(ASSET_LOADER_CALLBACKS[entry->type].dispose(entry));
memoryZero(entry, sizeof(assetentry_t));
errorOk();
}
-114
View File
@@ -1,114 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "asset/loader/assetloading.h"
#include "util/ref.h"
typedef enum {
ASSET_ENTRY_STATE_NOT_STARTED,
ASSET_ENTRY_STATE_PENDING_ASYNC,
ASSET_ENTRY_STATE_LOADING_ASYNC,
ASSET_ENTRY_STATE_PENDING_SYNC,
ASSET_ENTRY_STATE_LOADING_SYNC,
ASSET_ENTRY_STATE_LOADED,
ASSET_ENTRY_STATE_ERROR
} assetentrystate_t;
typedef struct assetentry_s assetentry_t;
/**
* A single asset entry callback. Each entry supports at most one subscriber
* per event - a second assignment without clearing the first is a bug.
*
* @param entry The assetentry_t the event fired on.
* @param user The user pointer passed alongside the callback.
*/
typedef void (*assetentrycallback_t)(assetentry_t *entry, void *user);
struct assetentry_s {
char_t name[ASSET_FILE_NAME_MAX];
assetloadertype_t type;
assetloaderoutput_t data;
assetentrystate_t state;
ref_t refs;
assetloaderinput_t *input;
assetloaderinput_t inputData;
/**
* Fired once when loading completes successfully.
* Always invoked on the main thread.
*/
assetentrycallback_t onLoaded;
void *onLoadedUser;
/**
* Fired once when the entry is disposed/reaped. The asset data is still
* accessible when the callback runs. Always invoked on the main thread.
*/
assetentrycallback_t onUnloaded;
void *onUnloadedUser;
/**
* Fired once when loading fails.
* Always invoked on the main thread.
*/
assetentrycallback_t onError;
void *onErrorUser;
};
/**
* Initializes an asset entry with the given name and type. This does not load
* the asset.
*
* @param entry The asset entry to initialize.
* @param name The name of the asset, used as a key for loading and caching.
* @param type The type of asset this entry represents.
* @param input Data that will be passed to the loader about how it should load.
*/
void assetEntryInit(
assetentry_t *entry,
const char_t *name,
const assetloadertype_t type,
assetloaderinput_t *input
);
/**
* Locks an asset entry, preventing it from being freed until it is unlocked.
*
* @param entry The asset entry to lock.
*/
void assetEntryLock(assetentry_t *entry);
/**
* Unlocks an asset entry, allowing it to be freed if there are no more locks.
*
* @param entry The asset entry to unlock.
*/
void assetEntryUnlock(assetentry_t *entry);
/**
* Starts loading the given asset entry using an assetloading slot. This will
* be called by the asset manager when it deems it's a good time to begin the
* loading of this asset entry.
*
* Currently we return the error but in future this will not be returned.
*
* @param entry The asset entry to start loading.
* @param loading The assetloading slot to use for loading this asset entry.
* @return Any error that occurs during loading.
*/
void assetEntryStartLoading(assetentry_t *entry, assetloading_t *loading);
/**
* Disposes an asset entry, freeing any resources it holds.
* Fires the onUnloaded event before releasing asset data.
*
* @param entry The asset entry to dispose.
* @return Any error that occurs during disposal.
*/
errorret_t assetEntryDispose(assetentry_t *entry);
-60
View File
@@ -1,60 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetloader.h"
assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = {
[ASSET_LOADER_TYPE_NULL] = { 0 },
[ASSET_LOADER_TYPE_MESH] = {
.loadSync = assetMeshLoaderSync,
.loadAsync = assetMeshLoaderAsync,
.dispose = assetMeshDispose
},
[ASSET_LOADER_TYPE_MODEL] = {
.loadSync = assetModelLoaderSync,
.loadAsync = assetModelLoaderAsync,
.dispose = assetModelDispose
},
[ASSET_LOADER_TYPE_TEXTURE] = {
.loadSync = assetTextureLoaderSync,
.loadAsync = assetTextureLoaderAsync,
.dispose = assetTextureDispose
},
[ASSET_LOADER_TYPE_TILESET] = {
.loadSync = assetTilesetLoaderSync,
.loadAsync = assetTilesetLoaderAsync,
.dispose = assetTilesetDispose
},
[ASSET_LOADER_TYPE_LOCALE] = {
.loadSync = assetLocaleLoaderSync,
.loadAsync = assetLocaleLoaderAsync,
.dispose = assetLocaleDispose
},
[ASSET_LOADER_TYPE_JSON] = {
.loadSync = assetJsonLoaderSync,
.loadAsync = assetJsonLoaderAsync,
.dispose = assetJsonDispose
},
[ASSET_LOADER_TYPE_WAV] = {
.loadSync = assetWavLoaderSync,
.loadAsync = assetWavLoaderAsync,
.dispose = assetWavDispose
},
[ASSET_LOADER_TYPE_MP3] = {
.loadSync = assetMp3LoaderSync,
.loadAsync = assetMp3LoaderAsync,
.dispose = assetMp3Dispose
},
};
Loaded 100 of 1098 files, more files were not shown because too many files have changed in this diff. Show more