Author SHA1 Message Date
YourWishesandClaude Opus 5 5cf81aa238 Normalize code style to match established conventions
An assistant-written stretch of code had drifted from the conventions the
older hand-written files establish. Sweeps the whole of src/ back into line.

Systematic:
- @returns -> @return (163 occurrences, 84 files). Concentrated in ui/ and
  rpg/cutscene/; the rest of the tree already used @return 646 times.
- Lowercase "null" -> "NULL" in assert/error message strings (170
  occurrences, 28 files), matching the dominant 441-use spelling. Covers
  "cannot be null", "must not be null" and adjectival uses.

Localized:
- sort.c: drop a stray #include <stdlib.h> that sat *above* the copyright
  header, leaving it the only file in the repo without a leading header
  block. Also drops the same redundant include from random.c and npcturn.c
  (dusk.h already pulls in stdlib.h).
- Convert 7 files' license headers from // lines to the /** */ block form.
- cutscenesystem.c: memset -> memoryZero, matching the identical call ~20
  lines further down and the rest of the codebase.
- Struct tags suffixed _t -> _s: threadlock_t -> threadmutex_s (which also
  makes the tag match its typedef) and chunkpos_t -> chunkpos_s.
- Convert 20 inline /* */ block comments to // across the mesh builders,
  assetfile.c and assetlocaleloader.c.
- Pointer truthiness if(!ptr) -> if(ptr == NULL) in 16 places, matching the
  267 existing explicit comparisons. Boolean !x checks are left alone.
- entityanim.h: drop a JSDoc-style type annotation from an @return.
- Document easing.h's 16 undeclared easing functions and
  assetjsonloader.h's 3 loader callbacks.

No behavioral change. Builds clean with no new warnings; ctest shows the
same 6 pre-existing failures as HEAD, verified against a pristine worktree.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-07 17:10:32 -05:00
541 changed files with 43812 additions and 1940 deletions

No files matched your search

+7 -7
View File
@@ -32,7 +32,7 @@ jobs:
git \
libssl-dev
- name: Build Linux
run: ./scripts/linux/build-linux.sh
run: ./scripts/build-linux.sh
- name: Upload Linux binary
uses: actions/upload-artifact@v3
with:
@@ -48,7 +48,7 @@ jobs:
- name: Setup pspdev
uses: ./.github/actions/setup-pspdev
- name: Build PSP
run: ./scripts/psp/build-psp.sh
run: ./scripts/build-psp.sh
- name: Move EBOOT.PBP to Dusk subfolder
run: |
mkdir -p ./git-artifcats/Dusk/PSP/GAME/Dusk
@@ -103,7 +103,7 @@ jobs:
libgles2:arm64 \
libgl1-mesa-dev:arm64
- name: Build Knulli
run: ./scripts/knulli/build-knulli.sh
run: ./scripts/build-knulli.sh
- name: Move output to Dusk subfolder
run: |
mkdir -p ./git-artifcats/Dusk
@@ -134,7 +134,7 @@ jobs:
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build GameCube
run: ./scripts/gamecube/build-gamecube.sh
run: ./scripts/build-gamecube.sh
- name: Copy output files
run: |
mkdir -p ./git-artifcats/Dusk
@@ -166,7 +166,7 @@ jobs:
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build GameCube ISO
run: ./scripts/gamecube/build-gamecube-iso.sh
run: ./scripts/build-gamecube-iso.sh
- name: Copy output files
run: |
mkdir -p ./git-artifcats/Dusk
@@ -199,7 +199,7 @@ jobs:
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build Wii
run: ./scripts/wii/build-wii.sh
run: ./scripts/build-wii.sh
- name: Copy output files
run: |
mkdir -p ./git-artifcats/Dusk/apps/Dusk
@@ -232,7 +232,7 @@ jobs:
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build Wii ISO
run: ./scripts/wii/build-wii-iso.sh
run: ./scripts/build-wii-iso.sh
- name: Copy output files
run: |
mkdir -p ./git-artifcats/Dusk
+1 -1
View File
@@ -32,4 +32,4 @@ jobs:
git \
libssl-dev
- name: Run tests
run: ./scripts/linux/test-linux.sh
run: ./scripts/test-linux.sh
-21
View File
@@ -1,21 +0,0 @@
{
"moves": {
"fire": {
// Target flags
"enemies": true,
"allies": true,
"multiple": false,
"self": true,
"default": "enemy",
"target": "SINGLE_ENEMY"
},
"slash": {
"enemies": true,
"allies": true,
"self": false,
"default": "enemy",
"target": "SINGLE_ENEMY"
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-21
View File
@@ -1,21 +0,0 @@
{
"items": [
{
"type": "MODAL",
"title": "Fire",
"message": "A burst of flame scorches the target."
},
{
"type": "WAIT",
"seconds": 2
},
{
"type": "MODAL_CLOSE"
},
{
"type": "BATTLE_FIGHTER_DAMAGE",
"fighter": "target",
"amount": 12
}
]
}
-21
View File
@@ -1,21 +0,0 @@
{
"items": [
{
"type": "MODAL",
"title": "Slash",
"message": "A quick blade strike cuts across the target."
},
{
"type": "WAIT",
"seconds": 2
},
{
"type": "MODAL_CLOSE"
},
{
"type": "BATTLE_FIGHTER_DAMAGE",
"fighter": "target",
"amount": 8
}
]
}
-18
View File
@@ -1,18 +0,0 @@
{
"items": [
{
"type": "BATTLE_START",
"enemies": [
{
"attack": 8, "defense": 4, "magic": 0, "speed": 5, "luck": 2,
"healthMax": 30, "mpMax": 0,
"abilities": ["fire"]
},
{
"attack": 6, "defense": 3, "magic": 0, "speed": 7, "luck": 2,
"healthMax": 20, "mpMax": 0
}
]
}
]
}
+1 -1
View File
@@ -62,7 +62,7 @@
},
{
"type": "CUTSCENE",
"name": "cutscenes/main_menu.jsonc"
"name": "main_menu"
}
]
}
-11
View File
@@ -1,11 +0,0 @@
{
// Placeholder for the new-game intro flow - just shows a modal for now.
"items": [
{
"type": "MODAL",
"title": "New Game",
"message": "This is a placeholder for the new game intro.",
"blocking": true
}
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"items": {
"potion": { "type": "MEDICINE" },
"potato": { "type": "FOOD" },
"apple": { "type": "FOOD" }
}
}
-19
View File
@@ -73,12 +73,6 @@ 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"
@@ -266,10 +260,6 @@ msgstr "loading"
msgid "ui.autosave.saving"
msgstr "SAVING"
#
# Items
#
msgid "item.potion.name"
msgstr "Potion"
@@ -278,12 +268,3 @@ msgstr "Potato"
msgid "item.apple.name"
msgstr "Apple"
#
# Moves
#
msgid "move.fire.name"
msgstr "Fire"
msgid "move.slash.name"
msgstr "Slash"
@@ -1 +0,0 @@
{"meshes":[{"file":"house_4_4.dmf","pos":[2,1,0]},{"file":"house_6_3.dmf","pos":[1,9,0]},{"file":"house_2_2.dmf","pos":[12,6,0]}],"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]]}
@@ -1 +0,0 @@
{"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[6,0],[2,0],[2,0],[2,0],[2,0],[2,0],[2,0],[7,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[4,0],[12,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[9,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]],"meshes":[{"file":"house_5_3.dmf","pos":[1,1,0]}],"entities":[{"type":"global","globalId":3,"pos":[8,8,1]},{"type":"global","globalId":4,"pos":[12,2,0]}],"areas":[{"min":[11,3,0],"max":[16,9,10],"callbackId":1,"notify":3,"trigger":6}]}
@@ -1 +0,0 @@
{"meshes":[{"file":"house_8_4.dmf","pos":[1,1,0]},{"file":"house_3_3.dmf","pos":[11,2,0]},{"file":"house_2_2.dmf","pos":[3,11,0]}],"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,null,null,null,[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],null,null,null,null,null,null,null,null,[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],null,null,null,null,null,null,null,null,[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]]}
@@ -1 +0,0 @@
{"meshes":[{"file":"house_3_2.dmf","pos":[1,1,0]},{"file":"house_2_3.dmf","pos":[7,2,0]},{"file":"house_4_2.dmf","pos":[1,8,0]},{"file":"house_1_1.dmf","pos":[11,10,0]},{"file":"house_5_2.dmf","pos":[13,6,0]}],"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,[1,0],null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]]}
@@ -1 +0,0 @@
{"meshes":[{"file":"house_5_5.dmf","pos":[3,2,0]},{"file":"house_2_3.dmf","pos":[10,3,0]},{"file":"house_3_1.dmf","pos":[1,12,0]}],"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]]}
@@ -1 +0,0 @@
{"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,3],[1,3],[1,3],[1,3],[1,3],[1,3],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,3],[1,3],[1,3],[1,3],[1,3],[1,3],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,3],[1,3],[1,3],[1,3],[1,3],[1,3],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[4,1],[4,1],[4,1],[4,1],[4,1],[4,1],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[4,0],[4,0],[4,0],[4,0],[4,0],[4,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]],"meshes":[]}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
{
"mesh": "meshes/chunks/chunk_-1_0_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
+10
View File
@@ -0,0 +1,10 @@
{
"mesh": "meshes/chunks/chunk_0_0_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
+10
View File
@@ -0,0 +1,10 @@
{
"mesh": "meshes/chunks/chunk_0_1_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
+10
View File
@@ -0,0 +1,10 @@
{
"mesh": "meshes/chunks/chunk_1_0_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
+10
View File
@@ -0,0 +1,10 @@
{
"mesh": "meshes/chunks/chunk_1_1_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
+10
View File
@@ -0,0 +1,10 @@
{
"mesh": "meshes/chunks/chunk_2_0_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
-22
View File
@@ -1,22 +0,0 @@
{
"members": {
"hero": {
"attack": 10,
"defense": 5,
"magic": 0,
"speed": 10,
"luck": 0,
"healthMax": 30,
"mpMax": 10
},
"companion": {
"attack": 8,
"defense": 6,
"magic": 4,
"speed": 8,
"luck": 1,
"healthMax": 25,
"mpMax": 15
}
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"flags": {
"test": {
"initial": true
},
"test2": {
"initial": 1
}
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-1
View File
@@ -27,7 +27,6 @@ target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
)
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
-13
View File
@@ -60,19 +60,6 @@ target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
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
+159
View File
@@ -0,0 +1,159 @@
// 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
@@ -0,0 +1,64 @@
// 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
@@ -0,0 +1,6 @@
<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
@@ -0,0 +1,92 @@
<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
@@ -0,0 +1,65 @@
/* 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;
}
+918
View File
@@ -0,0 +1,918 @@
// 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;
}
function chunkPaths(x, y, z) {
return {
raw: `assetsraw/chunks/chunk_${x}_${y}_${z}.json`,
dcf: `assets/chunks/${x}_${y}_${z}.dcf`,
terrainDmf: `assets/meshes/chunks/chunk_${x}_${y}_${z}_0.dmf`,
terrainModel: `assets/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/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
@@ -0,0 +1,298 @@
// 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
@@ -0,0 +1,23 @@
<!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>
+295
View File
@@ -0,0 +1,295 @@
"""
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" / "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
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")"
python3 server/server.py
@@ -1,3 +1,3 @@
#!/bin/bash
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/gamecube/build-gamecube.sh"
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/build-gamecube.sh"
@@ -1,3 +1,3 @@
#!/bin/bash
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/gamecube/build-gamecube-iso.sh"
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/build-gamecube-iso.sh"
File renamed without changes.
File renamed without changes.
@@ -1,3 +1,3 @@
#!/bin/bash
docker build -t dusk-knulli -f docker/knulli/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-knulli /bin/bash -c "./scripts/knulli/build-knulli.sh"
docker run --rm -v "$(pwd):/workdir" dusk-knulli /bin/bash -c "./scripts/build-knulli.sh"
File renamed without changes.
@@ -1,3 +1,3 @@
#!/bin/bash
docker build -t dusk-linux -f docker/linux/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-linux /bin/bash -c "./scripts/linux/build-linux.sh"
docker run --rm -v "$(pwd):/workdir" dusk-linux /bin/bash -c "./scripts/build-linux.sh"
File renamed without changes.
@@ -1,3 +1,3 @@
#!/bin/bash
docker build -t dusk-psp -f docker/psp/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-psp /bin/bash -c "./scripts/psp/build-psp.sh"
docker run --rm -v "$(pwd):/workdir" dusk-psp /bin/bash -c "./scripts/build-psp.sh"
File renamed without changes.
@@ -1,3 +1,3 @@
#!/bin/bash
docker build -t dusk-vita -f docker/vita/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-vita /bin/bash -c "./scripts/vita/build-vita.sh"
docker run --rm -v "$(pwd):/workdir" dusk-vita /bin/bash -c "./scripts/build-vita.sh"
File renamed without changes.
@@ -1,3 +1,3 @@
#!/bin/bash
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/wii/build-wii.sh"
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/build-wii.sh"
@@ -1,3 +1,3 @@
#!/bin/bash
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/wii/build-wii-iso.sh"
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/build-wii-iso.sh"
File renamed without changes.
File renamed without changes.
@@ -4,4 +4,4 @@ docker run \
--rm \
-v "${GITHUB_WORKSPACE}:/workdir" \
dusk-linux \
/bin/bash -c "./scripts/linux/test-linux.sh"
/bin/bash -c "./scripts/test-linux.sh"
File renamed without changes.
+2 -7
View File
@@ -50,12 +50,6 @@ if(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
@@ -80,10 +74,11 @@ add_subdirectory(engine)
add_subdirectory(error)
add_subdirectory(input)
add_subdirectory(locale)
add_subdirectory(perf)
add_subdirectory(rpg)
add_subdirectory(scene)
add_subdirectory(system)
add_subdirectory(time)
add_subdirectory(ui)
add_subdirectory(save)
add_subdirectory(util)
add_subdirectory(thread)
+2
View File
@@ -6,4 +6,6 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
easing.c
animation.c
keyframe.c
)
+134
View File
@@ -0,0 +1,134 @@
/**
* 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);
}
+96
View File
@@ -0,0 +1,96 @@
/**
* 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
);
+6 -4
View File
@@ -1,7 +1,9 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "easing.h"
#include "assert/assert.h"
+123 -4
View File
@@ -1,7 +1,9 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
@@ -45,19 +47,136 @@ extern const easingfn_t EASING_FUNCTIONS[EASING_COUNT];
*/
float_t easingApply(const easingtype_t type, const float_t t);
/**
* No easing - returns t unchanged.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, equal to t.
*/
float_t easingLinear(const float_t t);
/**
* Sinusoidal easing in: starts slow, accelerates to the end.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, in the range [0, 1].
*/
float_t easingInSine(const float_t t);
/**
* Sinusoidal easing out: starts fast, decelerates to the end.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, in the range [0, 1].
*/
float_t easingOutSine(const float_t t);
/**
* Sinusoidal easing in and out: slow at both ends, fastest in
* the middle.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, in the range [0, 1].
*/
float_t easingInOutSine(const float_t t);
/**
* Quadratic easing in: starts slow, accelerates to the end.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, in the range [0, 1].
*/
float_t easingInQuad(const float_t t);
/**
* Quadratic easing out: starts fast, decelerates to the end.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, in the range [0, 1].
*/
float_t easingOutQuad(const float_t t);
/**
* Quadratic easing in and out: slow at both ends, fastest in the
* middle.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, in the range [0, 1].
*/
float_t easingInOutQuad(const float_t t);
/**
* Cubic easing in: starts slow, accelerates to the end.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, in the range [0, 1].
*/
float_t easingInCubic(const float_t t);
/**
* Cubic easing out: starts fast, decelerates to the end.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, in the range [0, 1].
*/
float_t easingOutCubic(const float_t t);
/**
* Cubic easing in and out: slow at both ends, fastest in the
* middle.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, in the range [0, 1].
*/
float_t easingInOutCubic(const float_t t);
/**
* Quartic easing in: starts slow, accelerates to the end.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, in the range [0, 1].
*/
float_t easingInQuart(const float_t t);
/**
* Quartic easing out: starts fast, decelerates to the end.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, in the range [0, 1].
*/
float_t easingOutQuart(const float_t t);
/**
* Quartic easing in and out: slow at both ends, fastest in the
* middle.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, in the range [0, 1].
*/
float_t easingInOutQuart(const float_t t);
/**
* Back easing in: overshoots backwards before moving to the end.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, which may fall below 0.
*/
float_t easingInBack(const float_t t);
/**
* Back easing out: overshoots past the end before settling on it.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, which may rise above 1.
*/
float_t easingOutBack(const float_t t);
/**
* Back easing in and out: overshoots backwards at the start and
* past the end before settling on it.
*
* @param t The input time, in the range [0, 1].
* @return The eased value, which may fall below 0 or rise above 1.
*/
float_t easingInOutBack(const float_t t);
+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
*/
#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));
}
+29
View File
@@ -0,0 +1,29 @@
/**
* 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
);
+15 -5
View File
@@ -25,9 +25,11 @@ errorret_t assetInit(void) {
threadMutexInit(&ASSET.loading[i].mutex);
}
// assetInitPlatform must either define ASSET.zip or throw an error.
// assetInitPlatform must either define both ASSET.zip/ASSET.zipStored or
// throw an error.
errorChain(assetInitPlatform());
assertNotNull(ASSET.zip, "Asset zip null without error.");
assertNotNull(ASSET.zip, "Asset zip NULL without error.");
assertNotNull(ASSET.zipStored, "Asset stored zip NULL without error.");
threadInit(&ASSET.loadThread, assetUpdateAsync);
threadStart(&ASSET.loadThread);
@@ -38,7 +40,9 @@ bool_t assetFileExists(const char_t *filename) {
assertStrLenMax(filename, ASSET_FILE_NAME_MAX, "Filename too long.");
threadMutexLock(&ASSET.zipLock);
bool_t found = zip_name_locate(ASSET.zip, filename, 0) >= 0;
bool_t found =
zip_name_locate(ASSET.zip, filename, 0) >= 0 ||
zip_name_locate(ASSET.zipStored, filename, 0) >= 0;
threadMutexUnlock(&ASSET.zipLock);
return found;
}
@@ -441,13 +445,19 @@ errorret_t assetDispose(void) {
errorChain(assetReapUnused());
// Cleanup zip file.
// Cleanup zip files.
if(ASSET.zip != NULL) {
if(zip_close(ASSET.zip) != 0) {
errorThrow("Failed to close asset zip archive.");
errorThrow("Failed to close compressed asset zip archive.");
}
ASSET.zip = NULL;
}
if(ASSET.zipStored != NULL) {
if(zip_close(ASSET.zipStored) != 0) {
errorThrow("Failed to close stored asset zip archive.");
}
ASSET.zipStored = NULL;
}
errorChain(assetDisposePlatform());
threadMutexDispose(&ASSET.zipLock);
+9 -5
View File
@@ -29,12 +29,16 @@
#define ASSET_BASE_DIRECTORY_MAX 256
typedef struct asset_s {
// The dusk.dsk archive - a single uncompressed (ZIP_STORED) zip, so
// every entry gets the reliable repeated seeking/re-opening that
// libzip only supports reliably for uncompressed entries (locale
// strings, MP3 loop restarts, etc.).
// Compressed (DEFLATE) archive - expected to hold the bulk of a game's
// binary assets. Looked up first by assetFileInit().
zip_t *zip;
// Stored (uncompressed) archive - expected to hold small files that need
// reliable repeated seeking/re-opening (e.g. locale strings), which
// libzip only supports reliably for uncompressed entries. Looked up by
// assetFileInit() only if the name isn't found in `zip`.
zip_t *zipStored;
// 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
@@ -48,7 +52,7 @@ typedef struct asset_s {
assetplatform_t platform;
// Guards every libzip call against ASSET.zip (zip_fopen,
// Guards every libzip call against ASSET.zip/ASSET.zipStored (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
+163 -39
View File
@@ -30,9 +30,9 @@ errorret_t assetDskParseHeader(
// 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];
uint32_t fields[7];
memoryCopy(fields, bytes + ASSET_DSK_MAGIC_SIZE, sizeof(fields));
for(uint8_t i = 0; i < 3; i++) {
for(uint8_t i = 0; i < 7; i++) {
fields[i] = endianLittleToHost32(fields[i]);
}
@@ -41,17 +41,24 @@ errorret_t assetDskParseHeader(
errorThrow("dusk.dsk has an unsupported version: %u", version);
}
outHeader->size = fields[1];
outHeader->checksum = fields[2];
outHeader->compressedOffset = fields[1];
outHeader->compressedSize = fields[2];
outHeader->compressedChecksum = fields[3];
outHeader->storedOffset = fields[4];
outHeader->storedSize = fields[5];
outHeader->storedChecksum = fields[6];
errorOk();
}
errorret_t assetDskOpenFromPath(
const char_t *path,
zip_t **outZip
zip_t **outCompressed,
zip_t **outStored
) {
errorChain(assetDskOpenFromPathRange(path, 0, SIZE_MAX, outZip));
errorChain(assetDskOpenFromPathRange(
path, 0, SIZE_MAX, outCompressed, outStored
));
errorOk();
}
@@ -59,12 +66,15 @@ errorret_t assetDskOpenFromPathRange(
const char_t *path,
const size_t baseOffset,
const size_t baseSize,
zip_t **outZip
zip_t **outCompressed,
zip_t **outStored
) {
assertNotNull(path, "Path cannot be NULL.");
assertNotNull(outZip, "Out zip cannot be NULL.");
assertNotNull(outCompressed, "Out compressed cannot be NULL.");
assertNotNull(outStored, "Out stored cannot be NULL.");
*outZip = NULL;
*outCompressed = NULL;
*outStored = NULL;
FILE *headerFile = fopen(path, "rb");
if(headerFile == NULL) {
@@ -86,30 +96,104 @@ errorret_t assetDskOpenFromPathRange(
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.");
if(
(size_t) header.compressedOffset + header.compressedSize > baseSize ||
(size_t) header.storedOffset + header.storedSize > baseSize
) {
errorThrow("dusk.dsk header describes ranges beyond its containing file.");
}
zip_error_t zipError;
zip_error_init(&zipError);
zip_source_t *source = zip_source_file_create(
zip_source_t *compressedSource = zip_source_file_create(
path,
(zip_uint64_t) (baseOffset + ASSET_DSK_HEADER_SIZE),
(zip_int64_t) header.size,
(zip_uint64_t) (baseOffset + header.compressedOffset),
(zip_int64_t) header.compressedSize,
&zipError
);
if(source == NULL) {
if(compressedSource == NULL) {
errorThrow(
"Failed to create dusk.dsk source: %s", zip_error_strerror(&zipError)
"Failed to create compressed dusk.dsk source: %s",
zip_error_strerror(&zipError)
);
}
*outZip = zip_open_from_source(source, ZIP_RDONLY, &zipError);
if(*outZip == NULL) {
zip_source_free(source);
*outCompressed =
zip_open_from_source(compressedSource, ZIP_RDONLY, &zipError);
if(*outCompressed == NULL) {
zip_source_free(compressedSource);
errorThrow(
"Failed to open dusk.dsk archive: %s", zip_error_strerror(&zipError)
"Failed to open compressed dusk.dsk archive: %s",
zip_error_strerror(&zipError)
);
}
zip_source_t *storedSource = zip_source_file_create(
path,
(zip_uint64_t) (baseOffset + header.storedOffset),
(zip_int64_t) header.storedSize,
&zipError
);
if(storedSource == NULL) {
zip_close(*outCompressed);
*outCompressed = NULL;
errorThrow(
"Failed to create stored dusk.dsk source: %s",
zip_error_strerror(&zipError)
);
}
*outStored = zip_open_from_source(storedSource, ZIP_RDONLY, &zipError);
if(*outStored == NULL) {
zip_source_free(storedSource);
zip_close(*outCompressed);
*outCompressed = NULL;
errorThrow(
"Failed to open stored dusk.dsk archive: %s",
zip_error_strerror(&zipError)
);
}
// The stored archive is small by convention, so verifying its checksum
// here (one extra small read) is cheap; the compressed archive isn't
// checked since it's meant to be read lazily/on-demand from here on.
uint8_t *storedBytes = (uint8_t *) memoryAllocate(header.storedSize);
FILE *storedFile = fopen(path, "rb");
if(storedFile == NULL) {
memoryFree(storedBytes);
zip_close(*outStored);
zip_close(*outCompressed);
*outStored = NULL;
*outCompressed = NULL;
errorThrow(
"Failed to re-open dusk.dsk to verify stored checksum: %s", path
);
}
fseek(storedFile, (long) (baseOffset + header.storedOffset), SEEK_SET);
size_t storedRead = fread(storedBytes, 1, header.storedSize, storedFile);
fclose(storedFile);
if(storedRead != header.storedSize) {
memoryFree(storedBytes);
zip_close(*outStored);
zip_close(*outCompressed);
*outStored = NULL;
*outCompressed = NULL;
errorThrow(
"Failed to read dusk.dsk stored archive to verify checksum: %s", path
);
}
uint32_t checksum =
(uint32_t) crc32(0L, storedBytes, (uInt) header.storedSize);
memoryFree(storedBytes);
if(checksum != header.storedChecksum) {
zip_close(*outStored);
zip_close(*outCompressed);
*outStored = NULL;
*outCompressed = NULL;
errorThrow(
"dusk.dsk stored archive failed checksum verification: %s", path
);
}
@@ -119,45 +203,85 @@ errorret_t assetDskOpenFromPathRange(
errorret_t assetDskOpenFromBuffer(
uint8_t *buffer,
const size_t bufferSize,
zip_t **outZip
zip_t **outCompressed,
zip_t **outStored
) {
assertNotNull(buffer, "Buffer cannot be NULL.");
assertNotNull(outZip, "Out zip cannot be NULL.");
assertNotNull(outCompressed, "Out compressed cannot be NULL.");
assertNotNull(outStored, "Out stored cannot be NULL.");
*outZip = NULL;
*outCompressed = NULL;
*outStored = 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.");
if(
(size_t) header.compressedOffset + header.compressedSize > bufferSize ||
(size_t) header.storedOffset + header.storedSize > bufferSize
) {
errorThrow("dusk.dsk header describes ranges 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.");
uint32_t compressedChecksum = (uint32_t) crc32(
0L, buffer + header.compressedOffset, (uInt) header.compressedSize
);
if(compressedChecksum != header.compressedChecksum) {
errorThrow("dusk.dsk compressed archive failed checksum verification.");
}
uint32_t storedChecksum = (uint32_t) crc32(
0L, buffer + header.storedOffset, (uInt) header.storedSize
);
if(storedChecksum != header.storedChecksum) {
errorThrow("dusk.dsk stored 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
// freep=0 for both - they're non-owning windows into the same caller-owned
// buffer, not independent allocations libzip should free.
zip_source_t *compressedSource = zip_source_buffer_create(
buffer + header.compressedOffset, header.compressedSize, 0, &zipError
);
if(source == NULL) {
if(compressedSource == NULL) {
errorThrow(
"Failed to create dusk.dsk source: %s", zip_error_strerror(&zipError)
"Failed to create compressed dusk.dsk source: %s",
zip_error_strerror(&zipError)
);
}
*outZip = zip_open_from_source(source, ZIP_RDONLY, &zipError);
if(*outZip == NULL) {
zip_source_free(source);
*outCompressed =
zip_open_from_source(compressedSource, ZIP_RDONLY, &zipError);
if(*outCompressed == NULL) {
zip_source_free(compressedSource);
errorThrow(
"Failed to open dusk.dsk archive: %s", zip_error_strerror(&zipError)
"Failed to open compressed dusk.dsk archive: %s",
zip_error_strerror(&zipError)
);
}
zip_source_t *storedSource = zip_source_buffer_create(
buffer + header.storedOffset, header.storedSize, 0, &zipError
);
if(storedSource == NULL) {
zip_close(*outCompressed);
*outCompressed = NULL;
errorThrow(
"Failed to create stored dusk.dsk source: %s",
zip_error_strerror(&zipError)
);
}
*outStored = zip_open_from_source(storedSource, ZIP_RDONLY, &zipError);
if(*outStored == NULL) {
zip_source_free(storedSource);
zip_close(*outCompressed);
*outCompressed = NULL;
errorThrow(
"Failed to open stored dusk.dsk archive: %s",
zip_error_strerror(&zipError)
);
}
+48 -40
View File
@@ -12,30 +12,31 @@
// "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
#define ASSET_DSK_MAGIC "DSK2"
#define ASSET_DSK_VERSION 1
// Magic + version + size + checksum
#define ASSET_DSK_HEADER_SIZE ( \
ASSET_DSK_MAGIC_SIZE + \
sizeof(uint32_t) + \
sizeof(uint32_t) + \
sizeof(uint32_t) \
)
// magic(4) + version(4) + compressedOffset(4) + compressedSize(4) +
// compressedChecksum(4) + storedOffset(4) + storedSize(4) +
// storedChecksum(4), all little-endian regardless of host - see
// assetDskParseHeader.
#define ASSET_DSK_HEADER_SIZE 32
/**
* 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.
* Parsed dusk.dsk (DSK2 format) header. dusk.dsk is two independent, back
* to back zip archives (see tools/asset/pack) rather than a single plain
* zip: a "compressed" one (DEFLATE, expected to hold the bulk of a game's
* binary assets, opened lazily/on-demand) and a "stored" one (uncompressed,
* expected to hold small files - locale strings, config - that need
* reliable repeated seeking/re-opening, which libzip only supports for
* uncompressed entries).
*/
typedef struct {
uint32_t size;
uint32_t checksum;
uint32_t compressedOffset;
uint32_t compressedSize;
uint32_t compressedChecksum;
uint32_t storedOffset;
uint32_t storedSize;
uint32_t storedChecksum;
} assetdskheader_t;
/**
@@ -55,24 +56,27 @@ errorret_t assetDskParseHeader(
);
/**
* Opens the dusk.dsk archive given a filesystem path, using a lazy/
* windowed file-backed zip source - no more memory used than the
* Opens both archives of a dusk.dsk file given a filesystem path, using
* lazy/windowed file-backed zip sources - 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.
* characteristics of a plain zip_open() on the whole file. Verifies the
* (small, by convention) stored archive's checksum; the compressed
* archive's checksum is intentionally not verified here since doing so
* would require reading the bulk of the game's assets 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.
* @param outCompressed Set to the opened compressed archive on success.
* @param outStored Set to the opened stored 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.
* bad header, or either archive fails to open/verify.
*/
errorret_t assetDskOpenFromPath(
const char_t *path,
zip_t **outZip
zip_t **outCompressed,
zip_t **outStored
);
/**
@@ -86,35 +90,39 @@ errorret_t assetDskOpenFromPath(
* @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.
* @param outCompressed Set to the opened compressed archive on success.
* @param outStored Set to the opened stored 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.
* bad header, or either archive fails to open/verify.
*/
errorret_t assetDskOpenFromPathRange(
const char_t *path,
const size_t baseOffset,
const size_t baseSize,
zip_t **outZip
zip_t **outCompressed,
zip_t **outStored
);
/**
* 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
* Opens both archives of a dusk.dsk file 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). Neither archive takes ownership of
* `buffer` (both are opened as non-owning sub-ranges 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.
* as either archive stays open. Verifies both archives' checksums, 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.
* @param outCompressed Set to the opened compressed archive on success.
* @param outStored Set to the opened stored archive on success.
* @return OK on success, error if too short, has a bad header/checksum, or
* the archive fails to open.
* either archive fails to open.
*/
errorret_t assetDskOpenFromBuffer(
uint8_t *buffer,
const size_t bufferSize,
zip_t **outZip
zip_t **outCompressed,
zip_t **outStored
);
+22 -16
View File
@@ -24,11 +24,20 @@ errorret_t assetFileInit(
file->params = params;
file->output = output;
// Stat the file, trying the compressed archive first and falling back to
// the stored one - remember which matched so assetFileOpen opens it from
// the right archive.
zip_stat_init(&file->stat);
threadMutexLock(&ASSET.zipLock);
int statResult = zip_stat(ASSET.zip, filename, 0, &file->stat);
if(zip_stat(ASSET.zip, filename, 0, &file->stat) == 0) {
file->sourceZip = ASSET.zip;
} else if(zip_stat(ASSET.zipStored, filename, 0, &file->stat) == 0) {
file->sourceZip = ASSET.zipStored;
} else {
file->sourceZip = NULL;
}
threadMutexUnlock(&ASSET.zipLock);
if(statResult != 0) {
if(file->sourceZip == NULL) {
errorThrow("Failed to stat asset file: %s", filename);
}
@@ -76,10 +85,11 @@ errorret_t assetFileRewind(assetfile_t *file) {
errorret_t assetFileOpen(assetfile_t *file) {
assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(file->filename, "Asset file filename cannot be NULL.");
assertNotNull(file->sourceZip, "Asset file must be inited before opening.");
assertNull(file->zipFile, "Asset file already open.");
threadMutexLock(&ASSET.zipLock);
file->zipFile = zip_fopen(ASSET.zip, file->filename, 0);
file->zipFile = zip_fopen(file->sourceZip, file->filename, 0);
threadMutexUnlock(&ASSET.zipLock);
if(file->zipFile == NULL) {
errorThrow("Failed to open asset file: %s", file->filename);
@@ -269,7 +279,7 @@ errorret_t assetFileLineReaderAppend(
errorOk();
}
/* reserve room for optional NUL terminator */
// reserve room for optional NUL terminator
if(reader->lineLength + srcLength >= reader->outBufferSize) {
errorThrow("Line length exceeds output buffer size.");
}
@@ -318,7 +328,7 @@ errorret_t assetFileLineReaderFill(assetfilelinereader_t *reader) {
size_t unreadBytes = assetFileLineReaderUnreadBytes(reader);
/* If buffer is fully consumed, refill from start. */
// If buffer is fully consumed, refill from start.
if(unreadBytes == 0) {
reader->bufferStart = 0;
reader->bufferEnd = 0;
@@ -339,18 +349,14 @@ errorret_t assetFileLineReaderFill(assetfilelinereader_t *reader) {
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.
*/
// 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.
*/
// 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();
}
@@ -393,7 +399,7 @@ errorret_t assetFileLineReaderNext(assetfilelinereader_t *reader) {
size_t chunkLength = (size_t)newlineIndex - reader->bufferStart;
errorret_t ret;
/* strip CR in CRLF */
// strip CR in CRLF
if(
chunkLength > 0 &&
reader->readBuffer[(size_t)newlineIndex - 1] == '\r'
+6 -1
View File
@@ -9,7 +9,7 @@
#include "error/error.h"
#include <zip.h>
#define ASSET_FILE_NAME_MAX 128
#define ASSET_FILE_NAME_MAX 48
// Max bytes requested per zip_fread() call in assetFileRead(). Some
// zip_fread() implementations (seen on PSP) reject a single call asking
@@ -32,6 +32,11 @@ typedef struct assetfile_s {
zip_int64_t position;
zip_int64_t lastRead;
zip_file_t *zipFile;
// The archive this file was found in (ASSET.zip or ASSET.zipStored),
// set by assetFileInit and used by assetFileOpen so lookups fall back
// correctly between the two dusk.dsk archives.
zip_t *sourceZip;
} assetfile_t;
/**
+2
View File
@@ -15,6 +15,8 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
add_subdirectory(display)
add_subdirectory(locale)
add_subdirectory(json)
add_subdirectory(chunk)
add_subdirectory(dmf)
add_subdirectory(cutscene)
add_subdirectory(wav)
add_subdirectory(mp3)
+12
View File
@@ -46,6 +46,18 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = {
.dispose = assetJsonDispose
},
[ASSET_LOADER_TYPE_CHUNK] = {
.loadSync = assetChunkLoaderSync,
.loadAsync = assetChunkLoaderAsync,
.dispose = assetChunkDispose
},
[ASSET_LOADER_TYPE_CUTSCENE] = {
.loadSync = assetCutsceneLoaderSync,
.loadAsync = assetCutsceneLoaderAsync,
.dispose = assetCutsceneDispose
},
[ASSET_LOADER_TYPE_WAV] = {
.loadSync = assetWavLoaderSync,
.loadAsync = assetWavLoaderAsync,
+10
View File
@@ -12,6 +12,8 @@
#include "asset/loader/display/assettilesetloader.h"
#include "asset/loader/locale/assetlocaleloader.h"
#include "asset/loader/json/assetjsonloader.h"
#include "asset/loader/chunk/assetchunkloader.h"
#include "asset/loader/cutscene/assetcutsceneloader.h"
#include "asset/loader/wav/assetwavloader.h"
#include "asset/loader/mp3/assetmp3loader.h"
@@ -24,6 +26,8 @@ typedef enum {
ASSET_LOADER_TYPE_TILESET,
ASSET_LOADER_TYPE_LOCALE,
ASSET_LOADER_TYPE_JSON,
ASSET_LOADER_TYPE_CHUNK,
ASSET_LOADER_TYPE_CUTSCENE,
ASSET_LOADER_TYPE_WAV,
ASSET_LOADER_TYPE_MP3,
@@ -37,6 +41,8 @@ typedef union {
assettilesetloaderloading_t tileset;
assetlocaleloaderloading_t locale;
assetjsonloaderloading_t json;
assetchunkloaderloading_t chunk;
assetcutsceneloaderloading_t cutscene;
assetwavloaderloading_t wav;
assetmp3loaderloading_t mp3;
} assetloaderloading_t;
@@ -48,6 +54,8 @@ typedef union {
assettilesetoutput_t tileset;
assetlocaleoutput_t locale;
assetjsonoutput_t json;
assetchunkoutput_t chunk;
assetcutsceneoutput_t cutscene;
assetwavoutput_t wav;
assetmp3output_t mp3;
} assetloaderoutput_t;
@@ -57,6 +65,8 @@ typedef union {
assettilesetloaderinput_t tileset;
assetlocaleloaderinput_t locale;
assetjsonloaderinput_t json;
assetchunkloaderinput_t chunk;
assetcutsceneloaderinput_t cutscene;
assetwavloaderinput_t wav;
assetmp3loaderinput_t mp3;
} assetloaderinput_t;
@@ -6,7 +6,5 @@
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
saveslot.c
saveslotcurrent.c
saveslotver1.c
assetchunkloader.c
)
@@ -0,0 +1,238 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetchunkloader.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/endian.h"
#include "asset/loader/assetloading.h"
#include "asset/loader/assetentry.h"
#include "asset/loader/assetloader.h"
#include "asset/asset.h"
#include "rpg/overworld/worldpos.h"
errorret_t assetChunkLoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Should be called from an async thread.");
if(loading->loading.chunk.state != ASSET_CHUNK_LOADING_STATE_READ_FILE) {
errorOk();
}
assertNull(loading->loading.chunk.data, "Data already defined?");
assetfile_t *file = &loading->loading.chunk.file;
assetLoaderErrorChain(loading,
assetFileInit(file, loading->entry->name, NULL, NULL)
);
uint8_t *data = memoryAllocate(file->size);
assetLoaderErrorChain(loading, assetFileOpen(file));
assetLoaderErrorChain(loading, assetFileRead(file, data, file->size));
assetLoaderErrorChain(loading, assetFileClose(file));
assetLoaderErrorChain(loading, assetFileDispose(file));
assertTrue(
file->lastRead == file->size,
"Failed to read entire chunk file."
);
loading->loading.chunk.data = data;
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_PARSE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
errorret_t assetChunkLoaderSync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertTrue(loading->type == ASSET_LOADER_TYPE_CHUNK, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
assetchunkoutput_t *out = &loading->entry->data.chunk;
switch(loading->loading.chunk.state) {
case ASSET_CHUNK_LOADING_STATE_INITIAL:
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_READ_FILE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
errorOk();
case ASSET_CHUNK_LOADING_STATE_PARSE:
break;
case ASSET_CHUNK_LOADING_STATE_LOAD_MODELS:
while(loading->loading.chunk.modelIndex < out->meshCount) {
uint8_t m = loading->loading.chunk.modelIndex;
if(out->modelEntries[m] == NULL) {
out->modelEntries[m] = assetLock(
out->modelNames[m], ASSET_LOADER_TYPE_MODEL, NULL
);
assertNotNull(
out->modelEntries[m], "Failed to lock model."
);
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
if(out->modelEntries[m]->state == ASSET_ENTRY_STATE_ERROR) {
assetLoaderErrorThrow(
loading, "Model failed to load: %s", out->modelNames[m]
);
}
if(out->modelEntries[m]->state != ASSET_ENTRY_STATE_LOADED) {
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
loading->loading.chunk.modelIndex++;
}
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk();
default:
errorOk();
}
uint8_t *data = loading->loading.chunk.data;
assertNotNull(data, "Chunk data should have been loaded by now.");
if(data[0] != 'D' || data[1] != 'C' || data[2] != 'F') {
memoryFree(data);
assetLoaderErrorThrow(loading, "Invalid chunk file header");
}
uint32_t version = endianLittleToHost32(*(uint32_t *)(data + 4));
if(version != ASSET_CHUNK_FILE_VERSION) {
memoryFree(data);
assetLoaderErrorThrow(
loading, "Unsupported chunk file version %u", version
);
}
size_t offset = 8;
size_t tileSize = CHUNK_TILE_COUNT * sizeof(tile_t);
out->tiles = memoryAllocate(tileSize);
memoryCopy(out->tiles, data + offset, tileSize);
offset += tileSize;
for(size_t t = 0; t < CHUNK_TILE_COUNT; t++) {
uint32_t *shape = (uint32_t *)&out->tiles[t].shape;
*shape = endianLittleToHost32(*shape);
}
out->meshCount = data[offset];
offset += sizeof(uint8_t);
assertTrue(
out->meshCount <= CHUNK_MESH_COUNT_MAX,
"Chunk mesh count exceeds maximum."
);
for(uint8_t m = 0; m < out->meshCount; m++) {
uint8_t nameLen = 0;
while(
data[offset + nameLen] != '\0' &&
nameLen < CHUNK_MESH_NAME_MAX - 1
) {
nameLen++;
}
memoryCopy(out->modelNames[m], data + offset, nameLen);
out->modelNames[m][nameLen] = '\0';
offset += nameLen + 1;
memoryCopy(out->meshOffsets[m], data + offset, sizeof(vec3));
offset += sizeof(vec3);
out->meshOffsets[m][0] = endianLittleToHostFloat(out->meshOffsets[m][0]);
out->meshOffsets[m][1] = endianLittleToHostFloat(out->meshOffsets[m][1]);
out->meshOffsets[m][2] = endianLittleToHostFloat(out->meshOffsets[m][2]);
}
out->entitySpawnCount = data[offset];
offset += sizeof(uint8_t);
assertTrue(
out->entitySpawnCount <= CHUNK_ENTITY_SPAWN_COUNT_MAX,
"Chunk entity spawn count exceeds maximum."
);
for(uint8_t s = 0; s < out->entitySpawnCount; s++) {
chunkentityspawn_t *spawn = &out->entitySpawns[s];
spawn->kind = (chunkentityspawnkind_t)data[offset];
offset += sizeof(uint8_t);
uint16_t a;
memoryCopy(&a, data + offset, sizeof(uint16_t));
a = endianLittleToHost16(a);
offset += sizeof(uint16_t);
uint8_t b = data[offset];
offset += sizeof(uint8_t);
if(spawn->kind == CHUNK_ENTITY_SPAWN_KIND_ITEM) {
spawn->globalId = 0;
spawn->itemId = a;
spawn->itemQuantity = b;
} else {
spawn->globalId = a;
spawn->itemId = 0;
spawn->itemQuantity = 0;
}
spawn->position = worldPosReadLE(data, &offset);
}
out->areaSpawnCount = data[offset];
offset += sizeof(uint8_t);
assertTrue(
out->areaSpawnCount <= CHUNK_AREA_COUNT_MAX,
"Chunk area spawn count exceeds maximum."
);
for(uint8_t s = 0; s < out->areaSpawnCount; s++) {
chunkareaspawn_t *area = &out->areaSpawns[s];
area->min = worldPosReadLE(data, &offset);
area->max = worldPosReadLE(data, &offset);
uint16_t callbackId;
memoryCopy(&callbackId, data + offset, sizeof(uint16_t));
area->callbackId = endianLittleToHost16(callbackId);
offset += sizeof(uint16_t);
area->notify = data[offset];
offset += sizeof(uint8_t);
area->trigger = data[offset];
offset += sizeof(uint8_t);
}
memoryFree(data);
loading->loading.chunk.data = NULL;
if(out->meshCount == 0) {
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk();
}
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_LOAD_MODELS;
loading->loading.chunk.modelIndex = 0;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
errorret_t assetChunkDispose(assetentry_t *entry) {
assertNotNull(entry, "Entry cannot be NULL");
assertTrue(entry->type == ASSET_LOADER_TYPE_CHUNK, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
assetchunkoutput_t *out = &entry->data.chunk;
if(out->tiles != NULL) {
memoryFree(out->tiles);
out->tiles = NULL;
}
for(uint8_t m = 0; m < out->meshCount; m++) {
if(out->modelEntries[m] == NULL) continue;
assetUnlockEntry(out->modelEntries[m]);
out->modelEntries[m] = NULL;
}
errorOk();
}
@@ -0,0 +1,97 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "asset/assetfile.h"
#include "rpg/overworld/chunk.h"
#define ASSET_CHUNK_FILE_VERSION 5
typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t;
typedef struct {
void *nothing;
} assetchunkloaderinput_t;
typedef enum {
ASSET_CHUNK_LOADING_STATE_INITIAL,
ASSET_CHUNK_LOADING_STATE_READ_FILE,
ASSET_CHUNK_LOADING_STATE_PARSE,
ASSET_CHUNK_LOADING_STATE_LOAD_MODELS,
ASSET_CHUNK_LOADING_STATE_DONE
} assetchunkloadingstate_t;
typedef struct {
assetfile_t file;
assetchunkloadingstate_t state;
uint8_t *data;
uint8_t modelIndex;
} assetchunkloaderloading_t;
typedef enum {
CHUNK_ENTITY_SPAWN_KIND_GLOBAL,
CHUNK_ENTITY_SPAWN_KIND_ITEM
} chunkentityspawnkind_t;
typedef struct {
chunkentityspawnkind_t kind;
uint16_t globalId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_GLOBAL.
uint16_t itemId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM.
uint8_t itemQuantity; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM.
worldpos_t position;
} chunkentityspawn_t;
typedef struct {
worldpos_t min;
worldpos_t max;
uint16_t callbackId; // Index into MAP_AREA_CALLBACK_LIST.
uint8_t notify;
uint8_t trigger;
} chunkareaspawn_t;
typedef struct {
tile_t *tiles;
uint8_t meshCount;
char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
vec3 meshOffsets[CHUNK_MESH_COUNT_MAX];
assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX];
uint8_t entitySpawnCount;
chunkentityspawn_t entitySpawns[CHUNK_ENTITY_SPAWN_COUNT_MAX];
uint8_t areaSpawnCount;
chunkareaspawn_t areaSpawns[CHUNK_AREA_COUNT_MAX];
} assetchunkoutput_t;
/**
* Asynchronous loader for chunk assets. Reads the raw DCF file bytes into
* the loading buffer so the sync phase can parse without blocking the
* main thread on I/O.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
*/
errorret_t assetChunkLoaderAsync(assetloading_t *loading);
/**
* Synchronous loader for chunk assets. Validates the DCF binary previously
* read by the async phase and populates the output assetchunkoutput_t with
* tile data and model paths.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
*/
errorret_t assetChunkLoaderSync(assetloading_t *loading);
/**
* Disposer for chunk assets.
*
* @param entry Asset entry containing the chunk data to dispose.
* @return Error code indicating success or failure of the dispose operation.
*/
errorret_t assetChunkDispose(assetentry_t *entry);
@@ -0,0 +1,9 @@
# 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
assetcutsceneloader.c
)
@@ -0,0 +1,225 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetcutsceneloader.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include "asset/loader/assetloading.h"
#include "asset/loader/assetentry.h"
#include "asset/loader/assetloader.h"
#include "asset/asset.h"
#include "rpg/cutscene/item/json/cutscenejsonpauseflags.h"
errorret_t assetCutsceneParseItem(
assetloading_t *loading,
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset,
const uint8_t index
) {
if(!yyjson_is_obj(itemObj)) {
assetLoaderErrorThrow(
loading,
"Cutscene item %u is not an object",
(uint32_t)index
);
}
const char_t *typeName = yyjson_get_str(yyjson_obj_get(itemObj, "type"));
if(!cutsceneItemLookupType(typeName, &item->type)) {
assetLoaderErrorThrow(
loading,
"Cutscene item %u: missing/unsupported item type %s",
(uint32_t)index,
typeName
);
}
cutsceneitemloadcallback_t *load = CUTSCENE_ITEM_CALLBACKS[item->type].load;
if(load == NULL) {
assetLoaderErrorThrow(
loading, "Cutscene item %u: unhandled item type %u", (uint32_t)index,
(uint32_t)item->type
);
}
errorret_t result = load(itemObj, item, pool, poolOffset);
if(errorIsNotOk(result)) {
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorChain(result);
}
errorOk();
}
size_t assetCutsceneComputePoolSize(yyjson_val *itemsArr) {
size_t size = 0;
yyjson_arr_iter iter = yyjson_arr_iter_with(itemsArr);
yyjson_val *itemVal;
while((itemVal = yyjson_arr_iter_next(&iter)) != NULL) {
const char_t *typeName = yyjson_get_str(yyjson_obj_get(itemVal, "type"));
cutsceneitemtype_t type;
if(!cutsceneItemLookupType(typeName, &type)) continue;
cutsceneitempoolsizecallback_t *poolSize =
CUTSCENE_ITEM_CALLBACKS[type].poolSize;
if(poolSize == NULL) continue;
size = cutsceneJsonPoolAlign(size) + poolSize(itemVal);
}
return size;
}
void assetCutsceneFreeParsed(assetcutsceneoutput_t *out) {
if(out->items != NULL) {
memoryFree(out->items);
out->items = NULL;
}
if(out->pool != NULL) {
memoryFree(out->pool);
out->pool = NULL;
}
if(out->doc != NULL) {
yyjson_doc_free(out->doc);
out->doc = NULL;
}
}
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Should be called from an async thread.");
if(
loading->loading.cutscene.state != ASSET_CUTSCENE_LOADING_STATE_READ_FILE
) {
errorOk();
}
assertNull(loading->loading.cutscene.buffer, "Buffer already defined?");
assetfile_t *file = &loading->loading.cutscene.file;
assetLoaderErrorChain(loading,
assetFileInit(file, loading->entry->name, NULL, NULL)
);
if(file->size > ASSET_CUTSCENE_FILE_SIZE_MAX) {
assetLoaderErrorThrow(
loading, "Cutscene JSON exceeds maximum allowed size"
);
}
size_t fileSize = (size_t)file->size;
uint8_t *buffer = memoryAllocate(fileSize);
assetLoaderErrorChain(loading, assetFileOpen(file));
assetLoaderErrorChain(loading, assetFileRead(file, buffer, fileSize));
assertTrue(
file->lastRead == file->size,
"Failed to read entire cutscene file."
);
assetLoaderErrorChain(loading, assetFileClose(file));
assetLoaderErrorChain(loading, assetFileDispose(file));
loading->loading.cutscene.buffer = buffer;
loading->loading.cutscene.size = fileSize;
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_PARSE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
errorret_t assetCutsceneLoaderSync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertTrue(loading->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
if(loading->loading.cutscene.state == ASSET_CUTSCENE_LOADING_STATE_INITIAL) {
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_READ_FILE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
errorOk();
}
assetcutsceneoutput_t *out = &loading->entry->data.cutscene;
uint8_t *buffer = loading->loading.cutscene.buffer;
assertNotNull(buffer, "Cutscene data should have been loaded by now.");
size_t bufferSize = loading->loading.cutscene.size;
yyjson_doc *doc = yyjson_read(
(char *)buffer, bufferSize,
YYJSON_READ_ALLOW_COMMENTS | YYJSON_READ_ALLOW_TRAILING_COMMAS
);
memoryFree(buffer);
loading->loading.cutscene.buffer = NULL;
if(doc == NULL) {
assetLoaderErrorThrow(loading, "Failed to parse cutscene JSON");
}
out->doc = doc;
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *itemsArr = yyjson_obj_get(root, "items");
if(!yyjson_is_arr(itemsArr)) {
assetCutsceneFreeParsed(out);
assetLoaderErrorThrow(loading, "Cutscene JSON is missing an 'items' array");
}
size_t itemCount = yyjson_arr_size(itemsArr);
if(itemCount > UINT8_MAX) {
assetCutsceneFreeParsed(out);
assetLoaderErrorThrow(loading, "Cutscene has more than 255 items");
}
cutscenepause_t pause = CUTSCENE_PAUSE_DEFAULT;
yyjson_val *pauseArr = yyjson_obj_get(root, "pause");
if(yyjson_is_arr(pauseArr)) {
if(!cutsceneJsonParsePauseFlags(pauseArr, &pause)) {
assetCutsceneFreeParsed(out);
assetLoaderErrorThrow(
loading, "Cutscene JSON has an invalid 'pause' flag"
);
}
}
out->items = memoryAllocate(itemCount * sizeof(cutsceneitem_t));
memoryZero(out->items, itemCount * sizeof(cutsceneitem_t));
size_t poolSize = assetCutsceneComputePoolSize(itemsArr);
out->pool = poolSize > 0 ? memoryAllocate(poolSize) : NULL;
size_t poolOffset = 0;
yyjson_arr_iter itemIter = yyjson_arr_iter_with(itemsArr);
yyjson_val *itemVal;
uint8_t index = 0;
while((itemVal = yyjson_arr_iter_next(&itemIter)) != NULL) {
errorret_t itemResult = assetCutsceneParseItem(
loading, itemVal, &out->items[index], out->pool, &poolOffset, index
);
if(errorIsNotOk(itemResult)) {
assetCutsceneFreeParsed(out);
errorChain(itemResult);
}
index++;
}
out->cutscene.items = out->items;
out->cutscene.itemCount = (uint8_t)itemCount;
out->cutscene.pause = pause;
out->cutscene.dataSize = 0;
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk();
}
errorret_t assetCutsceneDispose(assetentry_t *entry) {
assertNotNull(entry, "Entry cannot be NULL");
assertTrue(entry->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
assetCutsceneFreeParsed(&entry->data.cutscene);
errorOk();
}
@@ -0,0 +1,106 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "asset/assetfile.h"
#include "rpg/cutscene/cutscene.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/item/json/cutscenejsonpool.h"
#include "yyjson.h"
#define ASSET_CUTSCENE_FILE_SIZE_MAX (1024 * 64)
typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t;
typedef struct {
void *nothing;
} assetcutsceneloaderinput_t;
typedef enum {
ASSET_CUTSCENE_LOADING_STATE_INITIAL,
ASSET_CUTSCENE_LOADING_STATE_READ_FILE,
ASSET_CUTSCENE_LOADING_STATE_PARSE
} assetcutsceneloadingstate_t;
typedef struct {
assetfile_t file;
assetcutsceneloadingstate_t state;
uint8_t *buffer;
size_t size;
} assetcutsceneloaderloading_t;
typedef struct {
cutscene_t cutscene;
cutsceneitem_t *items;
uint8_t *pool;
yyjson_doc *doc;
} assetcutsceneoutput_t;
/**
* Parses one cutscene item object into item, writing any pool-backed
* array data (entityWalkTo positions, mapAreaWait areaIds) into pool at
* *poolOffset and advancing it. Every field/enum name is validated as
* real (untrusted, authored) file content - failures are real errors, not
* asserts. Dispatches to the matching type's Load callback (see
* CUTSCENE_ITEM_CALLBACKS in rpg/cutscene/item/cutsceneitem.h) - the
* per-type parsing logic itself lives there now, colocated with that
* type's runtime behavior.
*
* @param loading Loading information for the asset being loaded.
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param pool Base pointer of the entry's pool allocation (may be NULL if
* assetCutsceneComputePoolSize returned 0).
* @param poolOffset In/out cursor into pool.
* @param index Index of this item within the cutscene, for error messages.
* @return Error code indicating success or failure of the parse.
*/
errorret_t assetCutsceneParseItem(
assetloading_t *loading,
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset,
const uint8_t index
);
/**
* Frees whatever of out->items/out->pool/out->doc is currently non-NULL
* and clears them - shared by both the normal disposer and every parse
* failure path's cleanup.
*
* @param out The output to free.
*/
void assetCutsceneFreeParsed(assetcutsceneoutput_t *out);
/**
* Asynchronous loader for cutscene assets. Reads the raw JSONC file bytes
* into the loading buffer so the sync phase can parse without blocking the
* main thread on I/O.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
*/
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading);
/**
* Synchronous loader for cutscene assets. Parses the JSONC previously read
* by the async phase into a heap-allocated cutsceneitem_t array + pool.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
*/
errorret_t assetCutsceneLoaderSync(assetloading_t *loading);
/**
* Disposer for cutscene assets.
*
* @param entry Asset entry containing the cutscene data to dispose.
* @return Error code indicating success or failure of the dispose operation.
*/
errorret_t assetCutsceneDispose(assetentry_t *entry);
+1 -1
View File
@@ -62,7 +62,7 @@ errorret_t assetModelLoaderSync(assetloading_t *loading) {
// Parse required mesh path.
yyjson_val *meshVal = yyjson_obj_get(root, "mesh");
if(!meshVal || !yyjson_is_str(meshVal)) {
if(meshVal == NULL || !yyjson_is_str(meshVal)) {
assetUnlockEntry(jsonEntry);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorThrow("Model JSON missing 'mesh' string");
+3 -3
View File
@@ -32,7 +32,7 @@ typedef struct {
* on the main thread; this callback is never reached.
*
* @param loading Loading information for the asset being loaded.
* @returns Always success.
* @return Always success.
*/
errorret_t assetModelLoaderAsync(assetloading_t *loading);
@@ -44,7 +44,7 @@ errorret_t assetModelLoaderAsync(assetloading_t *loading);
* within ASSET_LOADING_COUNT_MAX slot limits.
*
* @param loading Loading information for the asset being loaded.
* @returns Error code indicating success or failure.
* @return Error code indicating success or failure.
*/
errorret_t assetModelLoaderSync(assetloading_t *loading);
@@ -53,6 +53,6 @@ errorret_t assetModelLoaderSync(assetloading_t *loading);
* sub-asset entries.
*
* @param entry Asset entry containing the model to dispose.
* @returns Error code indicating success or failure.
* @return Error code indicating success or failure.
*/
errorret_t assetModelDispose(assetentry_t *entry);
+1 -1
View File
@@ -77,7 +77,7 @@ errorret_t assetJsonLoaderSync(assetloading_t *loading) {
memoryFree(buffer);
loading->loading.json.buffer = NULL;
if(!loading->entry->data.json) {
if(loading->entry->data.json == NULL) {
assetLoaderErrorThrow(loading, "Failed to parse JSON");
}
@@ -41,6 +41,32 @@ typedef struct {
typedef yyjson_doc * assetjsonoutput_t;
/**
* Asynchronous loader callback. Opens the JSON file and reads the whole of
* it into a heap buffer, so the main thread is not blocked on I/O. The
* buffer is parsed later, on the main thread, by assetJsonLoaderSync. Sets
* the entry state to `ASSET_ENTRY_STATE_PENDING_SYNC` on success.
*
* @param loading The loading slot for this asset entry.
* @return OK on success, error otherwise.
*/
errorret_t assetJsonLoaderAsync(assetloading_t *loading);
/**
* Synchronous loader callback. Drives the load state machine: on the first
* call it queues the async read, and once that has completed it parses the
* buffer into a `yyjson_doc` (comments and trailing commas allowed), frees
* the buffer and marks the entry `ASSET_ENTRY_STATE_LOADED`.
*
* @param loading The loading slot for this asset entry.
* @return OK on success, error otherwise.
*/
errorret_t assetJsonLoaderSync(assetloading_t *loading);
/**
* Dispose callback. Frees the parsed `yyjson_doc` held by the entry.
*
* @param entry The asset entry to dispose.
* @return OK on success, error otherwise.
*/
errorret_t assetJsonDispose(assetentry_t *entry);
@@ -93,7 +93,7 @@ errorret_t assetLocaleParseHeader(
// Find "Plural-Forms: " line and parse out plural form info
char_t *pluralFormsLine = strstr(headerBuffer, "Plural-Forms:");
if(!pluralFormsLine) {
if(pluralFormsLine == NULL) {
errorOk();
}
@@ -101,7 +101,7 @@ errorret_t assetLocaleParseHeader(
// Expect nplurals
char_t *npluralsStr = strstr(pluralFormsLine, "nplurals=");
if(!npluralsStr) {
if(npluralsStr == NULL) {
errorThrow("Failed to find nplurals in Plural-Forms header.");
}
npluralsStr += strlen("nplurals=");
@@ -120,7 +120,7 @@ errorret_t assetLocaleParseHeader(
// Expect plural=
char_t *pluralStr = strstr(pluralFormsLine, "plural=");
if(!pluralStr) {
if(pluralStr == NULL) {
errorThrow("Failed to find plural in Plural-Forms header.");
}
pluralStr += strlen("plural=");
@@ -128,7 +128,7 @@ errorret_t assetLocaleParseHeader(
// Expect ( [expressions] )
char_t *openParen = strchr(pluralStr, '(');
char_t *closeParen = strrchr(pluralStr, ')');
if(!openParen || !closeParen || closeParen < openParen) {
if(openParen == NULL || closeParen == NULL || closeParen < openParen) {
errorThrow("Failed to find plural expression in Plural-Forms header.");
}
@@ -399,12 +399,12 @@ errorret_t assetLocaleLineUnbuffer(
// At the point this funciton is called, we are looking for an opening quote.
char_t *start = strchr((char_t *)lineBuffer, '"');
if(!start) {
if(start == NULL) {
errorThrow("Expected open (0) \"");
}
char *end = strchr(start + 1, '"');
if(!end) {
if(end == NULL) {
errorThrow("Expected close (0) \"");
}
*end = '\0';
@@ -690,7 +690,7 @@ errorret_t assetLocaleGetStringLookup(
// Parse until ]
char *end = strchr(ptr, ']');
if(!end) {
if(end == NULL) {
errorThrow("Expected ] for plural form, found: %s", lineBuffer);
}
*end = '\0';
@@ -854,7 +854,7 @@ errorret_t assetLocaleGetStringWithArgs(
inIndex++;
/* Escaped percent */
// Escaped percent
if(format[inIndex] == '%') {
if(outIndex + 1 >= bufferSize) {
memoryFree(format);
@@ -880,7 +880,7 @@ errorret_t assetLocaleGetStringWithArgs(
specBuffer[specLength++] = '%';
/* Allow flags / width / precision */
// Allow flags / width / precision
while(format[inIndex] != '\0') {
char_t ch = format[inIndex];
Loaded 100 of 541 files, more files were not shown because too many files have changed in this diff. Show more