Author SHA1 Message Date
YourWishesandClaude Sonnet 5 efd760fb2e Store chunk tile data as a dense minified grid instead of per-tile objects
Cuts the six overworld chunk JSON files from 146KB to 10KB by replacing
{"pos":[x,y,z],"type":t,"tile":u} entries with a fixed 256-slot array
(index x + y*16, value null or [type, z]) and dropping pretty-printing.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 15:28:22 -05:00
YourWishesandClaude Sonnet 5 f303d24f4a Add a perf timing/stack module and start using it to diagnose PSP hitching
New src/dusk/perf module: a simple push/pop tick-stack (perfPush/perfPop/
perfReport) for measuring how long a labeled span of code takes, wired
into engine.c's init/dispose. perfGetTick() is a placeholder - real time
on DUSK_LINUX (clock_gettime) and DUSK_PSP (sceKernelGetSystemTimeWide),
0 elsewhere until a proper cross-platform answer is settled on.

Used it this session to chase a reported PSP hitch when crossing a chunk
boundary. Confirmed mapPositionSet itself (unload/load sweeps, chunk
order rebuild, entity chunkIndex recompute) is consistently under ~3ms -
not the cause. Along the way, found and fixed a real, unrelated bug:
logDebug()/logError() on PSP did a fopen+write+fclose to the Memory
Stick on every single call, which was massively inflating any perf
measurement that logged from inside another measurement (the actual
source of the ~29-128ms numbers first seen). That's now gated behind a
new DUSK_PSP_LOG_FILE CMake option (default off).

Also, as a real test based on this session's findings:
- MAP_CHUNK_LOAD_CONCURRENCY dropped from 2 to 1 (8 and 4 both crashed/
  were untested further on real PSP hardware; 1 is confirmed safe).
- MAP_CHUNK_LOAD_DELAY_TEST: an intentionally exaggerated (now 50ms)
  artificial delay between starting successive chunk loads, gated in
  mapChunkLoadNext/mapUpdate, to observe the effect on framerate.

All perf instrumentation call sites added during this investigation
(assetChunkLoaderSync, assetMeshLoaderSync, mapPositionSet, the map area
callback invocation, engine.c's frame boundary) were removed again once
they'd served their purpose - only the reusable perf module itself and
the two real fixes above remain.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 15:16:48 -05:00
YourWishesandClaude Sonnet 5 9688e88a7c Load raw chunk JSON at runtime instead of baked DCF/DMF
Experimental: skip the offline tools/asset/chunk bake step entirely.
The chunk asset loader now parses the same JSON schema tools/asset/chunk
already consumed (tiles/meshes/entities/areas) directly at load time,
generating the terrain mesh in C instead of reading pre-baked .dcf/.dmf
bytes. The raw assetsraw/maps/*/chunks/*.json files are now the shipped
source directly under assets/, replacing the old baked chunk/mesh/model
output.

Also drops MAP_CHUNK_LOAD_CONCURRENCY to 1 for now, per PSP hardware
testing (8 crashed, 4 untested further - 1 is the known-safe value).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 14:05:19 -05:00
YourWishesandClaude Sonnet 5 c8a6325615 Collapse dusk.dsk from two zip archives into one, all uncompressed
Bulk assets (mp3, png) are already compressed at the file level, so a
DEFLATE pass on top bought negligible space for real CPU cost. Packs
everything into a single ZIP_STORED archive instead, so every entry
(not just locale/*) gets the reliable repeated seeking/re-opening that
libzip only supports for uncompressed entries. Header shrinks from 32
to 16 bytes; version bumped to 2 so a stale pre-change dusk.dsk is
rejected and rebuilt rather than misread.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 10:17:34 -05:00
YourWishes 798db14bb5 Recover from a corrupt or version-mismatched save file instead of crashing
saveLoadSettings()/saveLoadSlot() previously let a read failure (e.g. a
version mismatch from the new schema check) propagate all the way up
through saveUpdate()/engineUpdate() to main(), which just prints the
error and exits - taking down the whole game over one bad file. Both
now catch the failure, log it, and fall back to init defaults instead,
matching the existing "no file yet" convention.
2026-09-08 12:49:20 -05:00
YourWishes 178d78ef72 Persist the current map name in the save slot
mapSetMap() now keeps SAVE.slot.cachedData.mapName in sync with the
active map, so whichever map is loaded gets written out next time the
slot is actually saved.
2026-09-08 12:49:13 -05:00
YourWishes 8139a0ea54 Add versioned save slot/settings schemas with strict version checks
Splits saveslot_t/savesettings_t into a stable, version-independent
in-memory struct (save/slot/, save/settings/) and a separate frozen
per-schema-version struct (saveslotver1_t/savesettingsver1_t) used only
for JSON serialization, converted between manually. Every write now
stamps a "version" field (SAVE_SLOT_CURRENT/SAVE_SETTINGS_CURRENT), and
every read requires it to match exactly, rejecting anything else -
laying the groundwork for a real migration path instead of silently
misreading old/incompatible save data.
2026-09-08 12:49:06 -05:00
YourWishes 327c2bec70 Show map name in the debug player position overlay
Useful now that multiple maps can be loaded, to tell which map the
printed coordinates belong to.
2026-09-07 22:34:02 -05:00
YourWishes fc0195d9f7 Bump ASSET_FILE_NAME_MAX to 128
Per-map chunk model paths (maps/<map>/models/chunks/chunk_X_Y_Z_0.json)
were hitting the old 48-char limit, crashing assetEntryInit's length
assert during initial chunk/cutscene loading.
2026-09-07 22:33:57 -05:00
YourWishes db42bca44b Reintroduce multiple maps: per-map chunk directories + map_t name/mapSetMap
Move chunk assets from a single flat assetsraw/chunks (and assets/chunks)
into per-map assetsraw/maps/<map>/chunks (assets/maps/<map>/chunks), so
terrain mesh/model output no longer collides across maps sharing chunk
coordinates. map_t gains a name[MAP_NAME_MAX] field plus mapSetMap()/
mapIsLoaded() (checks name[0] == '\0') in place of the old bool loaded
flag; mapInit() now just resets state and mapSetMap() does the chunk
grid load. Updated the chunk asset tool and the map editor's dev
server/client to match.
2026-09-07 21:28:07 -05:00
93 changed files with 1600 additions and 13528 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
{"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]]}
@@ -0,0 +1 @@
{"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":"item","itemId":1,"quantity":1,"pos":[12,2,0]}],"areas":[{"min":[11,3,0],"max":[16,9,10],"callbackId":1,"notify":3,"trigger":6}]}
@@ -0,0 +1 @@
{"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]]}
@@ -0,0 +1 @@
{"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]]}
@@ -0,0 +1 @@
{"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]]}
@@ -0,0 +1 @@
{"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
@@ -1,10 +0,0 @@
{
"mesh": "meshes/chunks/chunk_-1_0_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
-10
View File
@@ -1,10 +0,0 @@
{
"mesh": "meshes/chunks/chunk_0_0_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
-10
View File
@@ -1,10 +0,0 @@
{
"mesh": "meshes/chunks/chunk_0_1_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
-10
View File
@@ -1,10 +0,0 @@
{
"mesh": "meshes/chunks/chunk_1_0_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
-10
View File
@@ -1,10 +0,0 @@
{
"mesh": "meshes/chunks/chunk_1_1_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
-10
View File
@@ -1,10 +0,0 @@
{
"mesh": "meshes/chunks/chunk_2_0_0_0.dmf",
"color": [
255,
255,
255,
255
],
"texture": "tiles.png"
}
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
+13
View File
@@ -60,6 +60,19 @@ 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
+7 -5
View File
@@ -345,12 +345,14 @@
return promise;
}
const MAP_NAME = "overworld";
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`,
raw: `assetsraw/maps/${MAP_NAME}/chunks/chunk_${x}_${y}_${z}.json`,
dcf: `assets/maps/${MAP_NAME}/chunks/${x}_${y}_${z}.dcf`,
terrainDmf: `assets/maps/${MAP_NAME}/meshes/chunks/chunk_${x}_${y}_${z}_0.dmf`,
terrainModel: `assets/maps/${MAP_NAME}/models/chunks/chunk_${x}_${y}_${z}_0.json`,
};
}
@@ -767,7 +769,7 @@
async function buildChunkBrowseList() {
const list = document.getElementById("chunkBrowseList");
list.innerHTML = "";
const res = await apiLs("assetsraw/chunks");
const res = await apiLs(`assetsraw/maps/${MAP_NAME}/chunks`);
for(const entry of res.entries || []) {
const m = entry.name.match(/^chunk_(-?\d+)_(-?\d+)_(-?\d+)\.json$/);
if(!m) continue;
+4 -1
View File
@@ -209,7 +209,10 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"error": "invalid chunk coordinates"}, 400)
return
json_path = PROJECT_ROOT / "assetsraw" / "chunks" / f"chunk_{x}_{y}_{z}.json"
json_path = (
PROJECT_ROOT / "assetsraw" / "maps" / "overworld" / "chunks"
/ f"chunk_{x}_{y}_{z}.json"
)
if not json_path.is_file():
self.send_json({"error": "chunk json not found"}, 404)
return
+1
View File
@@ -74,6 +74,7 @@ add_subdirectory(engine)
add_subdirectory(error)
add_subdirectory(input)
add_subdirectory(locale)
add_subdirectory(perf)
add_subdirectory(rpg)
add_subdirectory(scene)
add_subdirectory(system)
+4 -14
View File
@@ -25,11 +25,9 @@ errorret_t assetInit(void) {
threadMutexInit(&ASSET.loading[i].mutex);
}
// assetInitPlatform must either define both ASSET.zip/ASSET.zipStored or
// throw an error.
// assetInitPlatform must either define ASSET.zip or throw an error.
errorChain(assetInitPlatform());
assertNotNull(ASSET.zip, "Asset zip null without error.");
assertNotNull(ASSET.zipStored, "Asset stored zip null without error.");
threadInit(&ASSET.loadThread, assetUpdateAsync);
threadStart(&ASSET.loadThread);
@@ -40,9 +38,7 @@ 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 ||
zip_name_locate(ASSET.zipStored, filename, 0) >= 0;
bool_t found = zip_name_locate(ASSET.zip, filename, 0) >= 0;
threadMutexUnlock(&ASSET.zipLock);
return found;
}
@@ -445,19 +441,13 @@ errorret_t assetDispose(void) {
errorChain(assetReapUnused());
// Cleanup zip files.
// Cleanup zip file.
if(ASSET.zip != NULL) {
if(zip_close(ASSET.zip) != 0) {
errorThrow("Failed to close compressed asset zip archive.");
errorThrow("Failed to close 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);
+5 -9
View File
@@ -29,16 +29,12 @@
#define ASSET_BASE_DIRECTORY_MAX 256
typedef struct asset_s {
// Compressed (DEFLATE) archive - expected to hold the bulk of a game's
// binary assets. Looked up first by assetFileInit().
// 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.).
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
@@ -52,7 +48,7 @@ typedef struct asset_s {
assetplatform_t platform;
// Guards every libzip call against ASSET.zip/ASSET.zipStored (zip_fopen,
// Guards every libzip call against ASSET.zip (zip_fopen,
// zip_fread, zip_fclose, zip_fseek, zip_stat, zip_name_locate - see
// assetfile.c/assetFileExists). libzip is documented as not thread-safe,
// and this asset system genuinely calls it from multiple real threads at
+39 -163
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[7];
uint32_t fields[3];
memoryCopy(fields, bytes + ASSET_DSK_MAGIC_SIZE, sizeof(fields));
for(uint8_t i = 0; i < 7; i++) {
for(uint8_t i = 0; i < 3; i++) {
fields[i] = endianLittleToHost32(fields[i]);
}
@@ -41,24 +41,17 @@ errorret_t assetDskParseHeader(
errorThrow("dusk.dsk has an unsupported version: %u", version);
}
outHeader->compressedOffset = fields[1];
outHeader->compressedSize = fields[2];
outHeader->compressedChecksum = fields[3];
outHeader->storedOffset = fields[4];
outHeader->storedSize = fields[5];
outHeader->storedChecksum = fields[6];
outHeader->size = fields[1];
outHeader->checksum = fields[2];
errorOk();
}
errorret_t assetDskOpenFromPath(
const char_t *path,
zip_t **outCompressed,
zip_t **outStored
zip_t **outZip
) {
errorChain(assetDskOpenFromPathRange(
path, 0, SIZE_MAX, outCompressed, outStored
));
errorChain(assetDskOpenFromPathRange(path, 0, SIZE_MAX, outZip));
errorOk();
}
@@ -66,15 +59,12 @@ errorret_t assetDskOpenFromPathRange(
const char_t *path,
const size_t baseOffset,
const size_t baseSize,
zip_t **outCompressed,
zip_t **outStored
zip_t **outZip
) {
assertNotNull(path, "Path cannot be NULL.");
assertNotNull(outCompressed, "Out compressed cannot be NULL.");
assertNotNull(outStored, "Out stored cannot be NULL.");
assertNotNull(outZip, "Out zip cannot be NULL.");
*outCompressed = NULL;
*outStored = NULL;
*outZip = NULL;
FILE *headerFile = fopen(path, "rb");
if(headerFile == NULL) {
@@ -96,104 +86,30 @@ errorret_t assetDskOpenFromPathRange(
assetdskheader_t header;
errorChain(assetDskParseHeader(headerBytes, sizeof(headerBytes), &header));
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.");
if((size_t) ASSET_DSK_HEADER_SIZE + header.size > baseSize) {
errorThrow("dusk.dsk header describes a range beyond its containing file.");
}
zip_error_t zipError;
zip_error_init(&zipError);
zip_source_t *compressedSource = zip_source_file_create(
zip_source_t *source = zip_source_file_create(
path,
(zip_uint64_t) (baseOffset + header.compressedOffset),
(zip_int64_t) header.compressedSize,
(zip_uint64_t) (baseOffset + ASSET_DSK_HEADER_SIZE),
(zip_int64_t) header.size,
&zipError
);
if(compressedSource == NULL) {
if(source == NULL) {
errorThrow(
"Failed to create compressed dusk.dsk source: %s",
zip_error_strerror(&zipError)
"Failed to create dusk.dsk source: %s", zip_error_strerror(&zipError)
);
}
*outCompressed =
zip_open_from_source(compressedSource, ZIP_RDONLY, &zipError);
if(*outCompressed == NULL) {
zip_source_free(compressedSource);
*outZip = zip_open_from_source(source, ZIP_RDONLY, &zipError);
if(*outZip == NULL) {
zip_source_free(source);
errorThrow(
"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
"Failed to open dusk.dsk archive: %s", zip_error_strerror(&zipError)
);
}
@@ -203,85 +119,45 @@ errorret_t assetDskOpenFromPathRange(
errorret_t assetDskOpenFromBuffer(
uint8_t *buffer,
const size_t bufferSize,
zip_t **outCompressed,
zip_t **outStored
zip_t **outZip
) {
assertNotNull(buffer, "Buffer cannot be NULL.");
assertNotNull(outCompressed, "Out compressed cannot be NULL.");
assertNotNull(outStored, "Out stored cannot be NULL.");
assertNotNull(outZip, "Out zip cannot be NULL.");
*outCompressed = NULL;
*outStored = NULL;
*outZip = NULL;
assetdskheader_t header;
errorChain(assetDskParseHeader(buffer, bufferSize, &header));
if(
(size_t) header.compressedOffset + header.compressedSize > bufferSize ||
(size_t) header.storedOffset + header.storedSize > bufferSize
) {
errorThrow("dusk.dsk header describes ranges beyond the buffer.");
if((size_t) ASSET_DSK_HEADER_SIZE + header.size > bufferSize) {
errorThrow("dusk.dsk header describes a range beyond the buffer.");
}
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.");
uint8_t *data = buffer + ASSET_DSK_HEADER_SIZE;
uint32_t checksum = (uint32_t) crc32(0L, data, (uInt) header.size);
if(checksum != header.checksum) {
errorThrow("dusk.dsk archive failed checksum verification.");
}
zip_error_t zipError;
zip_error_init(&zipError);
// freep=0 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
// freep=0 - this is a non-owning window into the same caller-owned
// buffer, not an independent allocation libzip should free.
zip_source_t *source = zip_source_buffer_create(
data, header.size, 0, &zipError
);
if(compressedSource == NULL) {
if(source == NULL) {
errorThrow(
"Failed to create compressed dusk.dsk source: %s",
zip_error_strerror(&zipError)
"Failed to create dusk.dsk source: %s", zip_error_strerror(&zipError)
);
}
*outCompressed =
zip_open_from_source(compressedSource, ZIP_RDONLY, &zipError);
if(*outCompressed == NULL) {
zip_source_free(compressedSource);
*outZip = zip_open_from_source(source, ZIP_RDONLY, &zipError);
if(*outZip == NULL) {
zip_source_free(source);
errorThrow(
"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)
"Failed to open dusk.dsk archive: %s", zip_error_strerror(&zipError)
);
}
+36 -47
View File
@@ -13,30 +13,26 @@
// never gets misread as a valid dusk.dsk.
#define ASSET_DSK_MAGIC_SIZE 4
#define ASSET_DSK_MAGIC "DSK2"
#define ASSET_DSK_VERSION 1
#define ASSET_DSK_VERSION 2
// 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
// magic(4) + version(4) + size(4) + checksum(4), all little-endian
// regardless of host - see assetDskParseHeader. The zip archive itself
// starts immediately after, at ASSET_DSK_HEADER_SIZE.
#define ASSET_DSK_HEADER_SIZE 16
/**
* 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).
* Parsed dusk.dsk (DSK2 format) header. dusk.dsk is a single uncompressed
* (ZIP_STORED) zip archive (see tools/asset/pack) rather than DEFLATEd -
* the bulk of what's packed (audio, images) is already compressed at the
* file level, so DEFLATE-ing the whole archive on top buys negligible
* space for real CPU cost, and every entry gets the reliable repeated
* seeking/re-opening that libzip only supports for uncompressed entries
* (locale line-by-line reads, MP3 loop restarts, etc.) instead of just a
* hand-picked subset of files.
*/
typedef struct {
uint32_t compressedOffset;
uint32_t compressedSize;
uint32_t compressedChecksum;
uint32_t storedOffset;
uint32_t storedSize;
uint32_t storedChecksum;
uint32_t size;
uint32_t checksum;
} assetdskheader_t;
/**
@@ -56,27 +52,24 @@ errorret_t assetDskParseHeader(
);
/**
* 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
* Opens the dusk.dsk archive given a filesystem path, using a lazy/
* windowed file-backed zip source - no more memory used than the
* existing per-platform small read buffers, matching the memory
* characteristics of a plain zip_open() on the whole file. 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.
* characteristics of a plain zip_open() on the whole file. The checksum
* is intentionally not verified here since doing so would require
* reading the whole (potentially large) archive just to compute it.
*
* See assetDskOpenFromPathRange() for the case where the DSK2 blob isn't
* the whole file (e.g. embedded inside another container).
*
* @param path Filesystem path to the dusk.dsk file.
* @param outCompressed Set to the opened compressed archive on success.
* @param outStored Set to the opened stored archive on success.
* @param outZip Set to the opened archive on success.
* @return OK on success, error if the file is missing, too short, has a
* bad header, or either archive fails to open/verify.
* bad header, or the archive fails to open.
*/
errorret_t assetDskOpenFromPath(
const char_t *path,
zip_t **outCompressed,
zip_t **outStored
zip_t **outZip
);
/**
@@ -90,39 +83,35 @@ 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 outCompressed Set to the opened compressed archive on success.
* @param outStored Set to the opened stored archive on success.
* @param outZip Set to the opened archive on success.
* @return OK on success, error if the file is missing, too short, has a
* bad header, or either archive fails to open/verify.
* bad header, or the archive fails to open.
*/
errorret_t assetDskOpenFromPathRange(
const char_t *path,
const size_t baseOffset,
const size_t baseSize,
zip_t **outCompressed,
zip_t **outStored
zip_t **outZip
);
/**
* 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
* Opens the dusk.dsk archive already fully resident in memory (e.g. a
* PSAR embedded in an EBOOT.PBP, or an ISO-embedded file already read via
* DVD_ReadAbs - platforms that already buffer the whole file for reasons
* unrelated to this format). The returned archive doesn't take ownership
* of `buffer` (it's opened as a non-owning sub-range of it) - the caller
* remains responsible for freeing it, and must keep it alive for as long
* as either archive stays open. Verifies both archives' checksums, since
* the bytes are already resident.
* as the archive stays open. Verifies the checksum, since the bytes are
* already resident.
*
* @param buffer The whole dusk.dsk file's bytes.
* @param bufferSize Number of bytes at `buffer`.
* @param outCompressed Set to the opened compressed archive on success.
* @param outStored Set to the opened stored archive on success.
* @param outZip Set to the opened archive on success.
* @return OK on success, error if too short, has a bad header/checksum, or
* either archive fails to open.
* the archive fails to open.
*/
errorret_t assetDskOpenFromBuffer(
uint8_t *buffer,
const size_t bufferSize,
zip_t **outCompressed,
zip_t **outStored
zip_t **outZip
);
+3 -13
View File
@@ -24,20 +24,11 @@ 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);
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;
}
int statResult = zip_stat(ASSET.zip, filename, 0, &file->stat);
threadMutexUnlock(&ASSET.zipLock);
if(file->sourceZip == NULL) {
if(statResult != 0) {
errorThrow("Failed to stat asset file: %s", filename);
}
@@ -85,11 +76,10 @@ 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(file->sourceZip, file->filename, 0);
file->zipFile = zip_fopen(ASSET.zip, file->filename, 0);
threadMutexUnlock(&ASSET.zipLock);
if(file->zipFile == NULL) {
errorThrow("Failed to open asset file: %s", file->filename);
+1 -6
View File
@@ -9,7 +9,7 @@
#include "error/error.h"
#include <zip.h>
#define ASSET_FILE_NAME_MAX 48
#define ASSET_FILE_NAME_MAX 128
// Max bytes requested per zip_fread() call in assetFileRead(). Some
// zip_fread() implementations (seen on PSP) reject a single call asking
@@ -32,11 +32,6 @@ 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;
/**
+371 -125
View File
@@ -8,41 +8,284 @@
#include "assetchunkloader.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/endian.h"
#include "util/string.h"
#include "asset/asset.h"
#include "asset/loader/assetloading.h"
#include "asset/loader/assetentry.h"
#include "asset/loader/assetloader.h"
#include "asset/asset.h"
#include "asset/loader/json/assetjsonloader.h"
#include "display/color.h"
#include "display/texture/texture.h"
#include "rpg/overworld/worldpos.h"
#include "yyjson.h"
#define ASSET_CHUNK_TERRAIN_TEXTURE "tiles.png"
// Per-shape corner Z offsets (sw, se, ne, nw) as offsets from a tile's own
// base Z, ported 1:1 from tools/asset/chunk/__main__.py's _RAMP_CORNERS.
// TILE_SHAPE_NULL's row is never read - assetChunkTilesHaveTerrain/
// assetChunkBuildTerrain skip those tiles outright.
static const float_t CHUNK_RAMP_CORNERS[TILE_SHAPE_COUNT][4] = {
[TILE_SHAPE_GROUND] = {0.0f, 0.0f, 0.0f, 0.0f},
[TILE_SHAPE_RAMP_NORTH] = {0.0f, 0.0f, 1.0f, 1.0f},
[TILE_SHAPE_RAMP_SOUTH] = {1.0f, 1.0f, 0.0f, 0.0f},
[TILE_SHAPE_RAMP_EAST] = {0.0f, 1.0f, 1.0f, 0.0f},
[TILE_SHAPE_RAMP_WEST] = {1.0f, 0.0f, 0.0f, 1.0f},
[TILE_SHAPE_RAMP_NORTHEAST] = {0.0f, 0.0f, 1.0f, 0.0f},
[TILE_SHAPE_RAMP_NORTHWEST] = {0.0f, 0.0f, 0.0f, 1.0f},
[TILE_SHAPE_RAMP_SOUTHEAST] = {0.0f, 1.0f, 0.0f, 0.0f},
[TILE_SHAPE_RAMP_SOUTHWEST] = {1.0f, 0.0f, 0.0f, 0.0f},
[TILE_SHAPE_RAMP_NORTHEAST_INNER] = {0.0f, 1.0f, 1.0f, 1.0f},
[TILE_SHAPE_RAMP_NORTHWEST_INNER] = {1.0f, 0.0f, 1.0f, 1.0f},
[TILE_SHAPE_RAMP_SOUTHEAST_INNER] = {1.0f, 1.0f, 1.0f, 0.0f},
[TILE_SHAPE_RAMP_SOUTHWEST_INNER] = {1.0f, 1.0f, 0.0f, 1.0f},
};
errorret_t assetChunkLoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Should be called from an async thread.");
errorOk();
}
if(loading->loading.chunk.state != ASSET_CHUNK_LOADING_STATE_READ_FILE) {
errorOk();
static bool_t assetChunkTilesHaveTerrain(const tile_t *tiles) {
for(uint32_t i = 0; i < CHUNK_TILE_COUNT; i++) {
if(tiles[i].shape != TILE_SHAPE_NULL) return true;
}
return false;
}
// Reads the "tiles" array - a dense, fixed-size CHUNK_TILE_COUNT grid,
// one slot per (x, y) column at index x + y * CHUNK_WIDTH (position is
// implicit in the index, not stored). Each slot is either `null` (empty
// column, already zeroed = TILE_SHAPE_NULL) or a 2-element [type, z]
// array.
static void assetChunkParseTiles(yyjson_val *root, tile_t *tiles) {
memoryZero(tiles, CHUNK_TILE_COUNT * sizeof(tile_t));
yyjson_val *tilesArr = yyjson_obj_get(root, "tiles");
if(!yyjson_is_arr(tilesArr)) return;
size_t idx, len;
yyjson_val *tileVal;
yyjson_arr_foreach(tilesArr, idx, len, tileVal) {
if(idx >= CHUNK_TILE_COUNT) break;
if(!yyjson_is_arr(tileVal) || yyjson_arr_size(tileVal) < 2) continue;
int32_t type = (int32_t)yyjson_get_int(yyjson_arr_get(tileVal, 0));
int32_t z = (int32_t)yyjson_get_int(yyjson_arr_get(tileVal, 1));
tiles[idx] = (tile_t){ .shape = (tileshape_t)type, .z = (uint8_t)z };
}
}
// Resolves "meshes" (external building references, by bare filename) into
// out->modelNames/meshOffsets starting at *ioIndex, advancing it as
// entries are written. Filenames are resolved as
// "models/buildings/<basename>.json" - a hardcoded stand-in for
// tools/asset/chunk/__main__.py's find_model() recursive search, valid
// as long as every building model lives flat under that one directory
// (true today - see the raw-json-maps plan notes for the tradeoff).
static void assetChunkParseMeshes(
yyjson_val *root, assetchunkoutput_t *out, uint8_t *ioIndex
) {
yyjson_val *meshesArr = yyjson_obj_get(root, "meshes");
if(!yyjson_is_arr(meshesArr)) return;
size_t idx, len;
yyjson_val *meshVal;
yyjson_arr_foreach(meshesArr, idx, len, meshVal) {
if(*ioIndex >= CHUNK_MESH_COUNT_MAX) break;
yyjson_val *fileVal = yyjson_obj_get(meshVal, "file");
if(!fileVal || !yyjson_is_str(fileVal)) continue;
char_t baseName[CHUNK_MESH_NAME_MAX];
stringCopy(baseName, yyjson_get_str(fileVal), sizeof(baseName));
char_t *dot = stringFindLastChar(baseName, '.');
if(dot != NULL) *dot = '\0';
uint8_t m = *ioIndex;
stringFormat(
out->modelNames[m], CHUNK_MESH_NAME_MAX,
"models/buildings/%s.json", baseName
);
vec3 offset = {0.0f, 0.0f, 0.0f};
yyjson_val *posArr = yyjson_obj_get(meshVal, "pos");
if(yyjson_is_arr(posArr) && yyjson_arr_size(posArr) >= 3) {
offset[0] = (float_t)yyjson_get_num(yyjson_arr_get(posArr, 0));
offset[1] = (float_t)yyjson_get_num(yyjson_arr_get(posArr, 1));
offset[2] = (float_t)yyjson_get_num(yyjson_arr_get(posArr, 2));
}
memoryCopy(out->meshOffsets[m], offset, sizeof(vec3));
(*ioIndex)++;
}
}
static void assetChunkParseEntities(yyjson_val *root, assetchunkoutput_t *out) {
out->entitySpawnCount = 0;
yyjson_val *arr = yyjson_obj_get(root, "entities");
if(!yyjson_is_arr(arr)) return;
size_t idx, len;
yyjson_val *val;
yyjson_arr_foreach(arr, idx, len, val) {
if(out->entitySpawnCount >= CHUNK_ENTITY_SPAWN_COUNT_MAX) break;
const char_t *type = yyjson_get_str(yyjson_obj_get(val, "type"));
yyjson_val *posArr = yyjson_obj_get(val, "pos");
if(type == NULL || !yyjson_is_arr(posArr) || yyjson_arr_size(posArr) < 3) {
continue;
}
worldpos_t pos = {
.x = (worldunit_t)yyjson_get_int(yyjson_arr_get(posArr, 0)),
.y = (worldunit_t)yyjson_get_int(yyjson_arr_get(posArr, 1)),
.z = (worldunit_t)yyjson_get_int(yyjson_arr_get(posArr, 2)),
};
chunkentityspawn_t *spawn = &out->entitySpawns[out->entitySpawnCount];
if(stringEquals(type, "global")) {
spawn->kind = CHUNK_ENTITY_SPAWN_KIND_GLOBAL;
spawn->globalId =
(uint16_t)yyjson_get_int(yyjson_obj_get(val, "globalId"));
spawn->itemId = 0;
spawn->itemQuantity = 0;
} else if(stringEquals(type, "item")) {
spawn->kind = CHUNK_ENTITY_SPAWN_KIND_ITEM;
spawn->globalId = 0;
spawn->itemId = (uint16_t)yyjson_get_int(yyjson_obj_get(val, "itemId"));
spawn->itemQuantity =
(uint8_t)yyjson_get_int(yyjson_obj_get(val, "quantity"));
} else {
continue;
}
spawn->position = pos;
out->entitySpawnCount++;
}
}
static void assetChunkParseAreas(yyjson_val *root, assetchunkoutput_t *out) {
out->areaSpawnCount = 0;
yyjson_val *arr = yyjson_obj_get(root, "areas");
if(!yyjson_is_arr(arr)) return;
size_t idx, len;
yyjson_val *val;
yyjson_arr_foreach(arr, idx, len, val) {
if(out->areaSpawnCount >= CHUNK_AREA_COUNT_MAX) break;
yyjson_val *minArr = yyjson_obj_get(val, "min");
yyjson_val *maxArr = yyjson_obj_get(val, "max");
if(
!yyjson_is_arr(minArr) || yyjson_arr_size(minArr) < 3 ||
!yyjson_is_arr(maxArr) || yyjson_arr_size(maxArr) < 3
) continue;
chunkareaspawn_t *area = &out->areaSpawns[out->areaSpawnCount];
area->min = (worldpos_t){
.x = (worldunit_t)yyjson_get_int(yyjson_arr_get(minArr, 0)),
.y = (worldunit_t)yyjson_get_int(yyjson_arr_get(minArr, 1)),
.z = (worldunit_t)yyjson_get_int(yyjson_arr_get(minArr, 2)),
};
area->max = (worldpos_t){
.x = (worldunit_t)yyjson_get_int(yyjson_arr_get(maxArr, 0)),
.y = (worldunit_t)yyjson_get_int(yyjson_arr_get(maxArr, 1)),
.z = (worldunit_t)yyjson_get_int(yyjson_arr_get(maxArr, 2)),
};
area->callbackId =
(uint16_t)yyjson_get_int(yyjson_obj_get(val, "callbackId"));
area->notify = (uint8_t)yyjson_get_int(yyjson_obj_get(val, "notify"));
area->trigger = (uint8_t)yyjson_get_int(yyjson_obj_get(val, "trigger"));
out->areaSpawnCount++;
}
}
// Six vertices (2 CCW triangles) for one tile's quad, matching
// tools/asset/chunk/__main__.py's _tile_quad. Corner order: SW=(x,y),
// SE=(x+1,y), NE=(x+1,y+1), NW=(x,y+1). NORTH=+Y, EAST=+X.
static void assetChunkAppendTileQuad(
meshvertex_t *verts, uint32_t *vertCount,
const uint32_t x, const uint32_t y,
const float_t sw, const float_t se, const float_t ne, const float_t nw
) {
const float_t u0 = (float_t)x / (float_t)CHUNK_WIDTH;
const float_t u1 = (float_t)(x + 1) / (float_t)CHUNK_WIDTH;
const float_t v0 = (float_t)y / (float_t)CHUNK_HEIGHT;
const float_t v1 = (float_t)(y + 1) / (float_t)CHUNK_HEIGHT;
const float_t fx = (float_t)x;
const float_t fy = (float_t)y;
meshvertex_t *v = verts + *vertCount;
v[0] = (meshvertex_t){ .uv = {u0, v0}, .pos = {fx, fy, sw} };
v[1] = (meshvertex_t){ .uv = {u1, v0}, .pos = {fx + 1, fy, se} };
v[2] = (meshvertex_t){ .uv = {u1, v1}, .pos = {fx + 1, fy + 1, ne} };
v[3] = (meshvertex_t){ .uv = {u0, v0}, .pos = {fx, fy, sw} };
v[4] = (meshvertex_t){ .uv = {u1, v1}, .pos = {fx + 1, fy + 1, ne} };
v[5] = (meshvertex_t){ .uv = {u0, v1}, .pos = {fx, fy + 1, nw} };
*vertCount += 6;
}
// Builds a mesh_t straight from the tile grid - one quad per non-null
// tile column, shaped by CHUNK_RAMP_CORNERS - the runtime equivalent of
// tools/asset/chunk/__main__.py's build_terrain_verts. Only called once
// assetChunkTilesHaveTerrain has confirmed at least one real tile exists.
// Mirrors assetMeshLoaderSync's meshInit/meshFlush/free-after-upload
// sequence (assetmeshloader.c) since this produces the same
// assetmeshoutput_t shape, just from generated verts instead of a file.
static errorret_t assetChunkBuildTerrain(
const tile_t *tiles, assetmeshoutput_t *out
) {
meshvertex_t *verts = memoryAllocate(
CHUNK_TILE_COUNT * 6 * sizeof(meshvertex_t)
);
uint32_t vertCount = 0;
for(uint32_t y = 0; y < CHUNK_HEIGHT; y++) {
for(uint32_t x = 0; x < CHUNK_WIDTH; x++) {
const tile_t *tile = &tiles[x + y * CHUNK_WIDTH];
if(tile->shape == TILE_SHAPE_NULL) continue;
const float_t *corners = CHUNK_RAMP_CORNERS[tile->shape];
const float_t fz = (float_t)tile->z;
assetChunkAppendTileQuad(
verts, &vertCount, x, y,
(fz + corners[0]) * WORLD_LAYER_HEIGHT,
(fz + corners[1]) * WORLD_LAYER_HEIGHT,
(fz + corners[2]) * WORLD_LAYER_HEIGHT,
(fz + corners[3]) * WORLD_LAYER_HEIGHT
);
}
}
assertNull(loading->loading.chunk.data, "Data already defined?");
assertTrue(vertCount > 0, "Terrain build called with no terrain tiles.");
assetfile_t *file = &loading->loading.chunk.file;
assetLoaderErrorChain(loading,
assetFileInit(file, loading->entry->name, NULL, NULL)
out->vertices = verts;
errorret_t ret = meshInit(
&out->mesh, MESH_PRIMITIVE_TYPE_TRIANGLES, (int32_t)vertCount, verts
);
if(errorIsNotOk(ret)) {
memoryFree(out->vertices);
out->vertices = NULL;
errorChain(ret);
}
out->meshInitialized = true;
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."
);
ret = meshFlush(&out->mesh, 0, (int32_t)vertCount);
if(errorIsNotOk(ret)) {
meshDispose(&out->mesh);
out->meshInitialized = false;
memoryFree(out->vertices);
out->vertices = NULL;
errorChain(ret);
}
#if defined(DUSK_OPENGL) && !defined(DUSK_OPENGL_LEGACY)
memoryFree(out->vertices);
out->vertices = NULL;
#endif
loading->loading.chunk.data = data;
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_PARSE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
@@ -55,8 +298,8 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
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;
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_PARSE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
case ASSET_CHUNK_LOADING_STATE_PARSE:
@@ -69,9 +312,7 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
out->modelEntries[m] = assetLock(
out->modelNames[m], ASSET_LOADER_TYPE_MODEL, NULL
);
assertNotNull(
out->modelEntries[m], "Failed to lock model."
);
assertNotNull(out->modelEntries[m], "Failed to lock model.");
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
@@ -93,126 +334,103 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
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);
// --- PARSE: lock+require the chunk's own JSON as a sub-asset, exactly
// like assetmodelloader.c's ASSET_MODEL_LOADING_STATE_LOCK_ASSETS does
// for its model descriptor, then read everything out of it before
// unlocking (the yyjson_doc/vals only stay valid while jsonEntry does).
char_t jsonKey[ASSET_FILE_NAME_MAX];
stringFormat(jsonKey, sizeof(jsonKey), "json:%s", loading->entry->name);
assetloaderinput_t jsonInput;
memoryZero(&jsonInput, sizeof(jsonInput));
stringCopy(
jsonInput.json.path, loading->entry->name, ASSET_FILE_NAME_MAX
);
assetentry_t *jsonEntry =
assetLock(jsonKey, ASSET_LOADER_TYPE_JSON, &jsonInput);
errorret_t jsonRet = assetRequireLoaded(jsonEntry);
if(errorIsNotOk(jsonRet)) {
assetUnlockEntry(jsonEntry);
assetLoaderErrorThrow(
loading, "Unsupported chunk file version %u", version
loading, "Failed to load chunk JSON: %s", loading->entry->name
);
}
size_t offset = 8;
yyjson_val *root = yyjson_doc_get_root(jsonEntry->data.json);
size_t tileSize = CHUNK_TILE_COUNT * sizeof(tile_t);
out->tiles = memoryAllocate(tileSize);
memoryCopy(out->tiles, data + offset, tileSize);
offset += tileSize;
tile_t tiles[CHUNK_TILE_COUNT];
assetChunkParseTiles(root, tiles);
out->hasTerrain = assetChunkTilesHaveTerrain(tiles);
for(size_t t = 0; t < CHUNK_TILE_COUNT; t++) {
uint32_t *shape = (uint32_t *)&out->tiles[t].shape;
*shape = endianLittleToHost32(*shape);
}
uint8_t meshIndex = out->hasTerrain ? 1 : 0;
assetChunkParseMeshes(root, out, &meshIndex);
out->meshCount = meshIndex;
out->meshCount = data[offset];
offset += sizeof(uint8_t);
assertTrue(
out->meshCount <= CHUNK_MESH_COUNT_MAX,
"Chunk mesh count exceeds maximum."
);
assetChunkParseEntities(root, out);
assetChunkParseAreas(root, out);
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;
assetUnlockEntry(jsonEntry);
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->tiles = memoryAllocate(CHUNK_TILE_COUNT * sizeof(tile_t));
memoryCopy(out->tiles, tiles, CHUNK_TILE_COUNT * sizeof(tile_t));
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;
// Terrain: build the mesh, then wrap it in synthetic (non-cache,
// non-refcounted) mesh/model entries so modelEntries[0] behaves exactly
// like a real file-backed model entry for every downstream consumer -
// see the header's doc comment on hasTerrain/terrainMeshEntry.
if(out->hasTerrain) {
assetmeshoutput_t terrainMesh;
memoryZero(&terrainMesh, sizeof(terrainMesh));
errorret_t terrainRet = assetChunkBuildTerrain(out->tiles, &terrainMesh);
if(errorIsNotOk(terrainRet)) {
memoryFree(out->tiles);
out->tiles = NULL;
assetLoaderErrorThrow(
loading, "Failed to build chunk terrain mesh: %s",
loading->entry->name
);
}
spawn->position = worldPosReadLE(data, &offset);
assetloaderinput_t texInput = { .texture = TEXTURE_FORMAT_RGBA };
assetentry_t *texEntry = assetLock(
ASSET_CHUNK_TERRAIN_TEXTURE, ASSET_LOADER_TYPE_TEXTURE, &texInput
);
errorret_t texRet = assetRequireLoaded(texEntry);
if(errorIsNotOk(texRet)) {
assetUnlockEntry(texEntry);
meshDispose(&terrainMesh.mesh);
memoryFree(out->tiles);
out->tiles = NULL;
assetLoaderErrorThrow(
loading, "Failed to load chunk terrain texture: %s",
loading->entry->name
);
}
out->terrainTexEntry = texEntry;
out->terrainMeshEntry = memoryAllocate(sizeof(assetentry_t));
memoryZero(out->terrainMeshEntry, sizeof(assetentry_t));
out->terrainMeshEntry->type = ASSET_LOADER_TYPE_MESH;
out->terrainMeshEntry->state = ASSET_ENTRY_STATE_LOADED;
out->terrainMeshEntry->data.mesh = terrainMesh;
out->terrainModelEntry = memoryAllocate(sizeof(assetentry_t));
memoryZero(out->terrainModelEntry, sizeof(assetentry_t));
out->terrainModelEntry->type = ASSET_LOADER_TYPE_MODEL;
out->terrainModelEntry->state = ASSET_ENTRY_STATE_LOADED;
out->terrainModelEntry->data.model.meshEntry = out->terrainMeshEntry;
out->terrainModelEntry->data.model.texEntry = out->terrainTexEntry;
out->terrainModelEntry->data.model.color = COLOR_WHITE;
out->modelEntries[0] = out->terrainModelEntry;
}
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->loading.chunk.modelIndex = out->hasTerrain ? 1 : 0;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
@@ -231,8 +449,36 @@ errorret_t assetChunkDispose(assetentry_t *entry) {
for(uint8_t m = 0; m < out->meshCount; m++) {
if(out->modelEntries[m] == NULL) continue;
if(out->hasTerrain && m == 0) continue; // synthetic - freed below.
assetUnlockEntry(out->modelEntries[m]);
out->modelEntries[m] = NULL;
}
if(out->hasTerrain) {
if(out->terrainMeshEntry != NULL) {
assetmeshoutput_t *meshOut = &out->terrainMeshEntry->data.mesh;
if(meshOut->meshInitialized) {
meshDispose(&meshOut->mesh);
meshOut->meshInitialized = false;
}
if(meshOut->vertices != NULL) {
memoryFree(meshOut->vertices);
meshOut->vertices = NULL;
}
memoryFree(out->terrainMeshEntry);
out->terrainMeshEntry = NULL;
}
if(out->terrainModelEntry != NULL) {
memoryFree(out->terrainModelEntry);
out->terrainModelEntry = NULL;
}
if(out->terrainTexEntry != NULL) {
assetUnlockEntry(out->terrainTexEntry);
out->terrainTexEntry = NULL;
}
out->modelEntries[0] = NULL;
out->hasTerrain = false;
}
errorOk();
}
+34 -11
View File
@@ -9,8 +9,6 @@
#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;
@@ -20,16 +18,17 @@ typedef struct {
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;
// Index into assetchunkoutput_t.modelNames/modelEntries the external
// (non-terrain) model polling loop has reached - see
// ASSET_CHUNK_LOADING_STATE_LOAD_MODELS.
uint8_t modelIndex;
} assetchunkloaderloading_t;
@@ -61,6 +60,27 @@ typedef struct {
vec3 meshOffsets[CHUNK_MESH_COUNT_MAX];
assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX];
// Terrain mesh generated at load time from the tile grid (no baked
// .dmf/.json - see assetChunkBuildTerrain in assetchunkloader.c). NULL
// (with hasTerrain false) for a chunk with no non-null tiles, in which
// case modelEntries[0] (if meshCount > 0) is the first external mesh
// instead - matches the old baked pipeline's "only write a terrain
// model if it has vertices" behavior.
//
// terrainMeshEntry/terrainModelEntry are heap-allocated, plain
// assetentry_t values that are never passed to assetLock/assetGetEntry/
// assetUnlockEntry (only a real, name-keyed, ref-counted entry may be)
// - they exist purely so modelEntries[0] can point at something with
// the same ->state/->data.model/->data.mesh.mesh shape every other
// model/mesh entry has, so chunk.c/map.c/sceneoverworld.c don't need to
// know terrain isn't file-backed. Freed directly (not assetUnlockEntry)
// in assetChunkDispose. terrainTexEntry is a real, ref-counted texture
// entry (the shared "tiles.png") and must be assetUnlockEntry'd there.
bool_t hasTerrain;
assetentry_t *terrainMeshEntry;
assetentry_t *terrainModelEntry;
assetentry_t *terrainTexEntry;
uint8_t entitySpawnCount;
chunkentityspawn_t entitySpawns[CHUNK_ENTITY_SPAWN_COUNT_MAX];
@@ -69,9 +89,10 @@ typedef struct {
} 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.
* Async loader stub for chunk assets. Chunk loading is performed entirely
* on the main thread (it delegates to the JSON asset loader and to
* meshInit/meshFlush, both main-thread-only); this callback is never
* reached.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
@@ -79,9 +100,11 @@ typedef struct {
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.
* Synchronous loader for chunk assets. Locks the chunk's JSON descriptor
* as a sub-asset, parses tiles/external mesh references/entity spawns/
* area spawns, generates the terrain mesh directly from the tile grid,
* and locks/waits for every referenced model (and the terrain's shared
* tileset texture) to finish loading.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
+4 -4
View File
@@ -87,10 +87,10 @@ errorret_t audioStreamPcmSeek(audiostream_t *stream, const size_t frame) {
const size_t currentByte = (size_t) stream->pcm.file.position;
if(targetByte < currentByte) {
// Only a full rewind can move a read cursor earlier once it's already
// advanced past a point - a compressed archive entry can't be decoded
// backward. See this function's own doc comment for the performance
// implications of a deep loopTo.
// A rewind is needed to move the read cursor earlier once it's
// already advanced past a point - assetFileRewind() takes the cheap
// in-place seek path since every dusk.dsk entry is stored
// uncompressed.
errorChain(assetFileRewind(&stream->pcm.file));
errorChain(assetFileRead(&stream->pcm.file, NULL, targetByte));
} else if(targetByte > currentByte) {
+2 -5
View File
@@ -63,11 +63,8 @@ size_t audioStreamPcmGetTotalFrames(const audiostream_t *stream);
* (relative to the start of the PCM data - the same units as
* audiostream_t's startFrame/loopStart/loopTo, once converted from
* seconds). Seeking backward re-reads from the start of the underlying
* asset file (see assetFileRewind()) since a compressed archive entry can
* only be decoded forward - this makes a loop with a deep loopTo more
* expensive to restart than one near the start, which is a real
* performance caveat, not just a theoretical one, for anything backed by
* a compressed (not stored) asset archive entry.
* asset file (see assetFileRewind()), which is a cheap in-place seek
* since every dusk.dsk entry is stored uncompressed.
*
* @param stream The audio stream to seek. Must be AUDIO_STREAM_TYPE_PCM.
* @param frame Frame offset to seek to, relative to the start of the PCM
+3
View File
@@ -7,6 +7,7 @@
#include "engine.h"
#include "util/memory.h"
#include "perf/perf.h"
#include "time/time.h"
#include "input/input.h"
#include "locale/localemanager.h"
@@ -36,6 +37,7 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
ENGINE.version = DUSK_VERSION;
// Init systems. Order is important.
perfInit();
timeInit();
consoleInit();
errorChain(systemInit());
@@ -104,6 +106,7 @@ errorret_t engineDispose(void) {
errorChain(displayDispose());
errorChain(saveDispose());
errorChain(assetDispose());
perfDispose();
errorOk();
}
+10
View File
@@ -0,0 +1,10 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
perf.c
)
+88
View File
@@ -0,0 +1,88 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "perf.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "console/console.h"
#if defined(DUSK_LINUX)
#include <time.h>
#elif defined(DUSK_PSP)
#include <pspthreadman.h>
#endif
perf_t PERF;
void perfInit(void) {
assertIsMainThread("Must be called from the main thread.");
memoryZero(&PERF, sizeof(perf_t));
}
void perfDispose(void) {
assertIsMainThread("Must be called from the main thread.");
}
uint64_t perfGetTick(void) {
assertIsMainThread("Must be called from the main thread.");
#if defined(DUSK_LINUX)
// TEST - CLOCK_MONOTONIC via clock_gettime(), Linux desktop only for
// now. Not a cross-platform answer yet (see perfGetTick's doc
// comment in perf.h) - just something real to look at while that
// gets settled. Returns nanoseconds since some unspecified starting
// point, so only differences between two calls are meaningful.
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
#elif defined(DUSK_PSP)
// TEST - sceKernelGetSystemTimeWide(), microseconds since boot at
// ~1us resolution (the standard PSP homebrew idiom for elapsed-time
// measurement - psprtc.h's sceRtcGetCurrentTick is for wall-clock
// date/time instead, already used by timepsp.c for that purpose).
// Scaled to nanoseconds to match every other platform's tick unit.
return (uint64_t)sceKernelGetSystemTimeWide() * 1000ull;
#else
return 0;
#endif
}
void perfPushItem(
const char_t *label, const char_t *file, const int32_t line
) {
assertIsMainThread("Must be called from the main thread.");
assertNotNull(label, "Perf item label cannot be NULL.");
assertNotNull(file, "Perf item file cannot be NULL.");
assertTrue(PERF.stackSize < PERF_STACK_SIZE, "Perf stack overflow.");
PERF.stack[PERF.stackSize++] = (perfitem_t){
.label = label,
.file = file,
.line = line,
.tick = perfGetTick()
};
}
void perfPopItem(void) {
assertIsMainThread("Must be called from the main thread.");
assertTrue(PERF.stackSize > 0, "Perf stack underflow.");
PERF.stackSize--;
}
void perfReport(void) {
assertIsMainThread("Must be called from the main thread.");
if(PERF.stackSize == 0) return;
const perfitem_t *item = &PERF.stack[PERF.stackSize - 1];
uint64_t elapsedNs = perfGetTick() - item->tick;
double_t elapsedMs = (double_t)elapsedNs / 1000000.0;
consolePrint(
"perf: %s (%s:%d, %.4fms)",
item->label, item->file, item->line, elapsedMs
);
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "perfitem.h"
#define PERF_STACK_SIZE 256
typedef struct {
perfitem_t stack[PERF_STACK_SIZE];
uint32_t stackSize;
} perf_t;
extern perf_t PERF;
/**
* Initializes the perf system.
*/
void perfInit(void);
/**
* Disposes/cleans up the perf system. Currently a no-op - here so
* engine.c has a symmetric call to make once this needs real cleanup.
*/
void perfDispose(void);
/**
* Returns a tick value representing "now", for measuring the duration
* between a perfPush/perfPushItem and its matching perfPopItem. Always
* nanoseconds since some unspecified starting point, on every platform
* that implements it, so perfReport's ms conversion stays correct
* everywhere without per-platform special-casing.
*
* PLACEHOLDER - currently only reads a real value on DUSK_LINUX desktop
* builds (via clock_gettime(CLOCK_MONOTONIC)) and DUSK_PSP (via
* sceKernelGetSystemTimeWide(), scaled from microseconds), as a test;
* every other platform still gets a meaningless 0. A common,
* cross-platform way of reading CPU cycles/time hasn't been settled on
* yet - don't treat results as comparable across platforms until this is
* replaced with a real per-platform implementation. Only the difference
* between two calls is meaningful, never the raw value itself.
*
* @return A tick value, in nanoseconds. 0 on unimplemented platforms.
*/
uint64_t perfGetTick(void);
/**
* Pushes a new item onto the perf stack, capturing the current tick (see
* perfGetTick) as its start time. Prefer the perfPush() macro, which
* supplies file/line automatically.
*
* @param label Short caller-supplied memo of what this item represents
* (e.g. "frame start") - stored by pointer, not copied, so pass a
* string literal or something that otherwise outlives the push.
* @param file Source file the push originated from (e.g. __FILE__).
* @param line Source line the push originated from (e.g. __LINE__).
*/
void perfPushItem(
const char_t *label, const char_t *file, const int32_t line
);
/**
* Pops the most recently pushed perf item off the stack.
*/
void perfPopItem(void);
/**
* Reports on the item currently at the top of the perf stack (the most
* recently pushed one still awaiting its perfPopItem), if any.
*/
void perfReport(void);
/**
* Pushes a new perf item onto the stack, tagged with the call site this
* macro is expanded at.
*
* @param label Short memo of what this item represents (e.g. "frame
* start") - see perfPushItem.
*/
#define perfPush(label) perfPushItem(label, __FILE__, __LINE__)
/**
* Pops the most recently pushed perf item off the stack.
*/
#define perfPop() perfPopItem()
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
typedef struct {
const char_t *label;
int32_t line;
const char_t *file;
uint64_t tick;
} perfitem_t;
+47 -9
View File
@@ -15,6 +15,8 @@
#include "rpg/entity/global/entityglobal.h"
#include "rpg/entity/item/entityitem.h"
#include "rpg/overworld/maparea.h"
#include "save/save.h"
#include "time/time.h"
map_t MAP;
@@ -28,7 +30,29 @@ void mapChunkLoadingSlotClear(chunk_t *chunk) {
errorret_t mapInit() {
memoryZero(&MAP, sizeof(map_t));
MAP.loaded = true;
errorOk();
}
bool_t mapIsLoaded() {
return MAP.name[0] != '\0';
}
errorret_t mapSetMap(const char_t *map) {
assertNotNull(map, "mapSetMap: map name cannot be NULL");
assertStrLenMax(map, MAP_NAME_MAX, "Map name too long");
if(mapIsLoaded()) {
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
mapChunkUnload(&MAP.chunks[i]);
}
}
memoryZero(&MAP, sizeof(map_t));
stringCopy(MAP.name, map, MAP_NAME_MAX);
// Keep the current save slot's cached map name in sync so whichever map
// is active gets persisted next time the slot is actually saved.
stringCopy(SAVE.slot.cachedData.mapName, MAP.name, MAP_NAME_MAX);
chunkindex_t i = 0;
for(chunkunit_t z = 0; z < MAP_CHUNK_DEPTH; z++) {
@@ -47,10 +71,6 @@ errorret_t mapInit() {
errorOk();
}
bool_t mapIsLoaded() {
return MAP.loaded;
}
errorret_t mapPositionSet(const chunkpos_t newPos) {
if(!mapIsLoaded()) errorThrow("No map loaded");
if(chunkPositionIsEqual(newPos, MAP.chunkPosition)) errorOk();
@@ -125,6 +145,15 @@ errorret_t mapPositionSet(const chunkpos_t newPos) {
}
errorret_t mapUpdate() {
// TEST ONLY - see MAP_CHUNK_LOAD_DELAY_TEST.
if(MAP.chunkLoadCooldown > 0.0f) {
MAP.chunkLoadCooldown -= TIME.delta;
if(MAP.chunkLoadCooldown <= 0.0f) {
MAP.chunkLoadCooldown = 0.0f;
mapChunkLoadNext();
}
}
errorOk();
}
@@ -132,6 +161,7 @@ errorret_t mapDispose() {
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
mapChunkUnload(&MAP.chunks[i]);
}
MAP.name[0] = '\0';
errorOk();
}
@@ -203,10 +233,11 @@ errorret_t mapChunkLoad(chunk_t *chunk) {
chunk->meshCount = 0;
char_t name[64];
char_t name[MAP_FILE_PATH_MAX];
stringFormat(
name, sizeof(name),
"chunks/%d_%d_%d.dcf",
"maps/%s/chunks/chunk_%d_%d_%d.json",
MAP.name,
(int32_t)chunk->position.x,
(int32_t)chunk->position.y,
(int32_t)chunk->position.z
@@ -230,6 +261,11 @@ errorret_t mapChunkLoad(chunk_t *chunk) {
}
void mapChunkLoadNext() {
// TEST ONLY - see MAP_CHUNK_LOAD_DELAY_TEST. Skip starting anything new
// until the cooldown set by the last load we started has expired;
// mapUpdate() ticks it down and retries this once it hits zero.
if(MAP.chunkLoadCooldown > 0.0f) return;
for(uint32_t slot = 0; slot < MAP_CHUNK_LOAD_CONCURRENCY; slot++) {
if(MAP.loadingChunks[slot] != NULL) continue;
if(MAP.loadQueueCount == 0) return;
@@ -240,11 +276,13 @@ void mapChunkLoadNext() {
}
MAP.loadQueueCount--;
MAP.loadingChunks[slot] = chunk;
MAP.chunkLoadCooldown = MAP_CHUNK_LOAD_DELAY_TEST;
char_t name[64];
char_t name[MAP_FILE_PATH_MAX];
stringFormat(
name, sizeof(name),
"chunks/%d_%d_%d.dcf",
"maps/%s/chunks/chunk_%d_%d_%d.json",
MAP.name,
(int32_t)chunk->position.x,
(int32_t)chunk->position.y,
(int32_t)chunk->position.z
+29 -3
View File
@@ -12,12 +12,23 @@
#define MAP_FILE_PATH_MAX 128
// Buffer size for a map's name, including the null terminator - so map
// names are at most 9 characters.
#define MAP_NAME_MAX 10
// Number of chunks that may be mid-load (asset locked & awaiting onLoaded/
// onError) at the same time - everything past this waits in loadQueue.
#define MAP_CHUNK_LOAD_CONCURRENCY 2
#define MAP_CHUNK_LOAD_CONCURRENCY 1
// TEST ONLY - deliberately exaggerated delay (seconds) enforced between
// starting one chunk load and starting the next, to observe its effect on
// framerate. See MAP.chunkLoadCooldown / mapChunkLoadNext.
#define MAP_CHUNK_LOAD_DELAY_TEST 0.05f
typedef struct map_s {
bool_t loaded;
// Name of the currently loaded map, or an empty string if none is
// loaded (see mapIsLoaded).
char_t name[MAP_NAME_MAX];
chunk_t chunks[MAP_CHUNK_COUNT];
chunk_t *chunkOrder[MAP_CHUNK_COUNT];
@@ -26,12 +37,17 @@ typedef struct map_s {
chunk_t *loadQueue[MAP_CHUNK_COUNT];
uint32_t loadQueueCount;
chunk_t *loadingChunks[MAP_CHUNK_LOAD_CONCURRENCY];
// TEST ONLY - seconds remaining before mapChunkLoadNext is allowed to
// start another chunk load. See MAP_CHUNK_LOAD_DELAY_TEST.
float_t chunkLoadCooldown;
} map_t;
extern map_t MAP;
/**
* Initializes the map.
* Initializes the map subsystem. No map is loaded until mapSetMap is
* called.
*
* @return An error code.
*/
@@ -44,6 +60,16 @@ errorret_t mapInit();
*/
bool_t mapIsLoaded();
/**
* Sets the active map by name, unloading the previously loaded map (if
* any) first, then starts loading the new map's chunks around chunk
* position (0, 0, 0).
*
* @param map The map's name, at most MAP_NAME_MAX - 1 characters.
* @return An error code.
*/
errorret_t mapSetMap(const char_t *map);
/**
* Updates the map.
*
+1 -2
View File
@@ -32,9 +32,8 @@ errorret_t rpgInit(void) {
errorChain(mapInit());
rpgCameraInit();
// Init test world
errorChain(mapPositionSet((chunkpos_t){ 0, 0, 0 }));
errorChain(mapSetMap("overworld"));
// The player is the one entity that isn't sourced from map/chunk data -
// every other entity (NPCs, items) and map area comes from the loaded
+4 -2
View File
@@ -8,6 +8,8 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
save.c
savedevice.c
saveslot.c
savesettings.c
)
# Subdirs
add_subdirectory(slot)
add_subdirectory(settings)
+25 -4
View File
@@ -9,6 +9,7 @@
#include "util/memory.h"
#include "assert/assert.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "console/console.h"
save_t SAVE;
@@ -186,10 +187,18 @@ errorret_t saveSaveSettings() {
errorret_t saveLoadSettings() {
assertTrue(SAVE.deviceCurrent != 0xFF, "No current device");
errorChain(saveDeviceSettingsRead(
errorret_t ret = saveDeviceSettingsRead(
&SAVE.devices[SAVE.deviceCurrent],
&SAVE.settings
));
);
if(errorIsNotOk(ret)) {
// Corrupt or incompatible (e.g. wrong/missing schema version) settings
// file - don't take the whole game down over it, just reset to
// defaults and carry on.
errorCatch(errorPrint(ret));
consolePrint("Settings file could not be loaded, resetting to defaults.\n");
saveSettingsInit(&SAVE.settings);
}
SAVE.settingsDirty = false;
errorOk();
@@ -222,11 +231,23 @@ errorret_t saveLoadSlot() {
errorOk();
}
errorChain(saveDeviceSlotRead(
errorret_t ret = saveDeviceSlotRead(
&SAVE.devices[SAVE.deviceCurrent],
&SAVE.slot,
SAVE.slotCurrent
));
);
if(errorIsNotOk(ret)) {
// Corrupt or incompatible (e.g. wrong/missing schema version) slot
// file - don't take the whole game down over it, treat it the same as
// an empty/unused slot.
errorCatch(errorPrint(ret));
consolePrint(
"Save slot %u could not be loaded, treating as empty.\n",
(uint32_t)SAVE.slotCurrent
);
saveSlotInit(&SAVE.slot);
}
// Update cache
SAVE.caches[SAVE.slotCurrent] = SAVE.slot.cachedData;
+2 -2
View File
@@ -7,8 +7,8 @@
#pragma once
#include "savedevice.h"
#include "saveslot.h"
#include "savesettings.h"
#include "save/slot/saveslot.h"
#include "save/settings/savesettings.h"
typedef struct {
// File state
+2 -2
View File
@@ -12,8 +12,8 @@
#if defined(SAVE_DEVICE_DATA_RAW)
#include "yyjson.h"
#include "save/savejson.h"
#include "save/saveslot.h"
#include "save/savesettings.h"
#include "save/slot/saveslot.h"
#include "save/settings/savesettings.h"
#include "util/crypt.h"
#include "util/endian.h"
#include <zlib.h>
+4 -4
View File
@@ -164,10 +164,10 @@ errorret_t saveDeviceDispose(savedevice_t *device);
#if defined(SAVE_DEVICE_DATA_RAW)
// Mirrors the same guarded default in saveslot.h - can't #include that
// header here to get it directly, since saveslot.h itself includes this
// header (savedevice.h), and relying on include order to resolve the cycle
// correctly would be fragile.
// Mirrors the same guarded default in save/slot/saveslot.h - can't
// #include that header here to get it directly, since it itself includes
// this header (savedevice.h), and relying on include order to resolve the
// cycle correctly would be fragile.
#ifndef SAVE_SLOT_COUNT
#define SAVE_SLOT_COUNT 3
#endif
+27
View File
@@ -76,6 +76,33 @@
(dest) = hasInt32(key) ? \
(int32_t)yyjson_get_int(yyjson_obj_get(object, key)) : (int32_t)(def)
/**
* Writes a schema version into the current JSON object's "version" key.
* Pair with requireVersion() on the read side.
*
* @param version The version constant to write (e.g. SAVE_SLOT_CURRENT).
*/
#define writeVersion(version) writeInt32("version", (version))
/**
* Requires the current JSON object's "version" key to exist and exactly
* match the given version constant, throwing otherwise. Used to reject a
* save file outright rather than trying to migrate an old schema.
*
* @param version The expected version constant (e.g. SAVE_SLOT_CURRENT).
*/
#define requireVersion(version) { \
requireInt32("version"); \
int32_t saveJsonVersion; \
readInt32("version", saveJsonVersion, 0); \
if(saveJsonVersion != (version)) { \
errorThrow( \
"Save JSON version mismatch (expected %d, got %d)", \
(int)(version), (int)saveJsonVersion \
); \
} \
}
/**
* Checks if the given key exists on the current JSON object.
*
+12
View File
@@ -0,0 +1,12 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
savesettings.c
savesettingscurrent.c
savesettingsver1.c
)
+38
View File
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "savesettings.h"
#include "save/settings/savesettingscurrent.h"
#include "assert/assert.h"
#include "util/memory.h"
void saveSettingsInit(savesettings_t *settings) {
assertNotNull(settings, "Settings cannot be null");
memorySet(settings, 0, sizeof(savesettings_t));
settings->audioMasterVolume = 1.0f;
audiomixerchannel_t channel = 0;
while(channel < AUDIO_MIXER_CHANNEL_COUNT) {
settings->audioChannelVolume[channel] = 1.0f;
channel++;
}
}
errorret_t saveSettingsWriteJSON(
savesettings_t *settings,
yyjson_mut_doc *doc,
yyjson_mut_val *object
) {
errorChain(saveSettingsCurrentWriteJSON(settings, doc, object));
errorOk();
}
errorret_t saveSettingsReadJSON(savesettings_t *settings, yyjson_val *object) {
errorChain(saveSettingsCurrentReadJSON(settings, object));
errorOk();
}
@@ -6,14 +6,19 @@
*/
#pragma once
#include "time/timeepoch.h"
#include "savedevice.h"
#include "yyjson.h"
#include "save/savedevice.h"
#include "audio/mixer/audiomixerchannel.h"
#include "yyjson.h"
// The stable, version-independent in-memory shape of save settings - what
// the rest of the engine actually reads/writes, using live engine constants
// (AUDIO_MIXER_CHANNEL_COUNT) freely. Every save schema version
// (savesettingsver1_t, ...) redefines its own frozen copy of these fields
// for its JSON wire format; saveSettingsCurrentWriteJSON()/ReadJSON()
// (savesettingscurrent.c) manually copy field-by-field between this and
// whichever version is current, so a version's on-disk shape can never
// drift just because a live engine constant changes.
typedef struct savesettings_s {
int32_t someSetting;
// Persisted mixer volume preferences - see audiomixerchannelstate_t's own
// comment on why these live here rather than on the mixer itself.
float_t audioMasterVolume;
@@ -0,0 +1,66 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "savesettingscurrent.h"
#include "assert/assert.h"
errorret_t saveSettingsCurrentWriteJSON(
savesettings_t *settings,
yyjson_mut_doc *doc,
yyjson_mut_val *object
) {
assertNotNull(settings, "Settings cannot be null");
assertNotNull(doc, "Doc cannot be null");
assertNotNull(object, "Object cannot be null");
savesettingsver1_t ver1;
ver1.audioMasterVolume = settings->audioMasterVolume;
// Bounded by the smaller of the two counts, in case a future live channel
// count ever diverges from this frozen v1 schema's count.
uint8_t channelCount = AUDIO_MIXER_CHANNEL_COUNT;
if(SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT < channelCount) {
channelCount = SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT;
}
for(uint8_t i = 0; i < channelCount; i++) {
ver1.audioChannelVolume[i] = settings->audioChannelVolume[i];
}
for(
uint8_t i = channelCount; i < SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT; i++
) {
ver1.audioChannelVolume[i] = 1.0f;
}
errorChain(saveSettingsVer1WriteJSON(&ver1, doc, object));
errorOk();
}
errorret_t saveSettingsCurrentReadJSON(
savesettings_t *settings,
yyjson_val *object
) {
assertNotNull(settings, "Settings cannot be null");
assertNotNull(object, "Object cannot be null");
savesettingsver1_t ver1;
errorChain(saveSettingsVer1ReadJSON(&ver1, object));
settings->audioMasterVolume = ver1.audioMasterVolume;
uint8_t channelCount = AUDIO_MIXER_CHANNEL_COUNT;
if(SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT < channelCount) {
channelCount = SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT;
}
for(uint8_t i = 0; i < channelCount; i++) {
settings->audioChannelVolume[i] = ver1.audioChannelVolume[i];
}
for(uint8_t i = channelCount; i < AUDIO_MIXER_CHANNEL_COUNT; i++) {
settings->audioChannelVolume[i] = 1.0f;
}
errorOk();
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "save/settings/savesettings.h"
#include "save/settings/savesettingsver1.h"
/**
* Converts the given save settings to whichever schema SAVE_SETTINGS_CURRENT
* (savesettingsver.h) selects and writes it out to the given JSON object.
* Update this (only) when bumping to a new SAVE_SETTINGS_VER_N.
*
* @param settings The save settings to write.
* @param doc The mutable JSON document that owns the object.
* @param object The mutable JSON object to write into.
* @return Error state if any.
*/
errorret_t saveSettingsCurrentWriteJSON(
savesettings_t *settings,
yyjson_mut_doc *doc,
yyjson_mut_val *object
);
/**
* Reads whichever schema SAVE_SETTINGS_CURRENT (savesettingsver.h) selects
* from the given JSON object and converts it into the given save settings.
* Update this (only) when bumping to a new SAVE_SETTINGS_VER_N.
*
* @param settings The save settings to read into.
* @param object The JSON object to read from.
* @return Error state if any.
*/
errorret_t saveSettingsCurrentReadJSON(
savesettings_t *settings,
yyjson_val *object
);
+15
View File
@@ -0,0 +1,15 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "time/timeepoch.h"
#include "save/savedevice.h"
#include "yyjson.h"
#define SAVE_SETTINGS_VER_1 1
#define SAVE_SETTINGS_CURRENT SAVE_SETTINGS_VER_1
@@ -5,26 +5,12 @@
* https://opensource.org/licenses/MIT
*/
#include "savesettings.h"
#include "savesettingsver1.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "savejson.h"
#include "save/savejson.h"
void saveSettingsInit(savesettings_t *settings) {
assertNotNull(settings, "Settings cannot be null");
memorySet(settings, 0, sizeof(savesettings_t));
settings->audioMasterVolume = 1.0f;
audiomixerchannel_t channel = 0;
while(channel < AUDIO_MIXER_CHANNEL_COUNT) {
settings->audioChannelVolume[channel] = 1.0f;
channel++;
}
}
errorret_t saveSettingsWriteJSON(
savesettings_t *settings,
errorret_t saveSettingsVer1WriteJSON(
savesettingsver1_t *settings,
yyjson_mut_doc *doc,
yyjson_mut_val *object
) {
@@ -32,25 +18,29 @@ errorret_t saveSettingsWriteJSON(
assertNotNull(doc, "Doc cannot be null");
assertNotNull(object, "Object cannot be null");
writeInt32("someSetting", settings->someSetting);
writeVersion(SAVE_SETTINGS_CURRENT);
writeFloat("audioMasterVolume", settings->audioMasterVolume);
writeFloatArray(
"audioChannelVolume", settings->audioChannelVolume,
AUDIO_MIXER_CHANNEL_COUNT
SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT
);
errorOk();
}
errorret_t saveSettingsReadJSON(savesettings_t *settings, yyjson_val *object) {
errorret_t saveSettingsVer1ReadJSON(
savesettingsver1_t *settings,
yyjson_val *object
) {
assertNotNull(settings, "Settings cannot be null");
assertNotNull(object, "Object cannot be null");
readInt32("someSetting", settings->someSetting, 0);
requireVersion(SAVE_SETTINGS_CURRENT);
readFloat("audioMasterVolume", settings->audioMasterVolume, 1.0f);
readFloatArray(
"audioChannelVolume", settings->audioChannelVolume,
AUDIO_MIXER_CHANNEL_COUNT, 1.0f
SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT, 1.0f
);
errorOk();
+55
View File
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "save/settings/savesettingsver.h"
#include "yyjson.h"
// This schema's own frozen channel count - never reference
// AUDIO_MIXER_CHANNEL_COUNT (or any other live constant) here. Once
// shipped, this file's on-disk shape must never change underneath old save
// files, even if the live engine constant does.
#define SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT 7
// Independent from savesettings_t (save/settings/savesettings.h) by design
// - this is what version 1 actually put on disk, frozen forever.
// saveSettingsCurrentWriteJSON()/ReadJSON() (savesettingscurrent.c) manually
// copy field-by-field between the two, since they're separate types that
// merely happen to currently look alike.
typedef struct savesettingsver1_s {
float_t audioMasterVolume;
float_t audioChannelVolume[SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT];
} savesettingsver1_t;
/**
* Writes the given v1 save settings data out to the given JSON object,
* stamping SAVE_SETTINGS_CURRENT as the "version" field.
*
* @param settings The v1 save settings data to write.
* @param doc The mutable JSON document that owns the object.
* @param object The mutable JSON object to write into.
* @return Error state if any.
*/
errorret_t saveSettingsVer1WriteJSON(
savesettingsver1_t *settings,
yyjson_mut_doc *doc,
yyjson_mut_val *object
);
/**
* Reads v1 save settings data in from the given JSON object. Throws if the
* JSON's "version" is missing or doesn't match SAVE_SETTINGS_CURRENT.
*
* @param settings The v1 save settings data to read into.
* @param object The JSON object to read from.
* @return Error state if any.
*/
errorret_t saveSettingsVer1ReadJSON(
savesettingsver1_t *settings,
yyjson_val *object
);
+12
View File
@@ -0,0 +1,12 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
saveslot.c
saveslotcurrent.c
saveslotver1.c
)
@@ -6,10 +6,9 @@
*/
#include "saveslot.h"
#include "save/slot/saveslotcurrent.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "savejson.h"
#include "time/time.h"
void saveSlotInit(saveslot_t *slot) {
assertNotNull(slot, "Slot cannot be null");
@@ -33,29 +32,11 @@ errorret_t saveSlotWriteJSON(
yyjson_mut_doc *doc,
yyjson_mut_val *object
) {
assertNotNull(slot, "Slot cannot be null");
assertNotNull(doc, "Doc cannot be null");
assertNotNull(object, "Object cannot be null");
// Update time
slot->cachedData.time = timeGetEpoch();
writeString("name", slot->cachedData.name);
writeTime("time", slot->cachedData.time);
writeInt32("playerLevel", slot->cachedData.playerLevel);
errorChain(saveSlotCurrentWriteJSON(slot, doc, object));
errorOk();
}
errorret_t saveSlotReadJSON(saveslot_t *slot, yyjson_val *object) {
assertNotNull(slot, "Slot cannot be null");
assertNotNull(object, "Object cannot be null");
char_t saveJsonStringBuffer[SAVE_JSON_STRING_BUFFER_SIZE];
readString("name", slot->cachedData.name, "", SAVE_SLOT_NAME_LENGTH);
readTime("time", slot->cachedData.time);
readInt32("playerLevel", slot->cachedData.playerLevel, 1);
errorChain(saveSlotCurrentReadJSON(slot, object));
errorOk();
}
@@ -6,8 +6,9 @@
*/
#pragma once
#include "save/savedevice.h"
#include "rpg/overworld/map.h"
#include "time/timeepoch.h"
#include "savedevice.h"
#include "yyjson.h"
#define SAVE_SLOT_NAME_LENGTH 8
@@ -15,10 +16,18 @@
#define SAVE_SLOT_COUNT 3
#endif
// The stable, version-independent in-memory shape of a save slot - what the
// rest of the engine actually reads/writes. Every save schema version
// (saveslotver1_t, ...) redefines its own copy of these same fields for its
// JSON wire format; saveSlotCurrentWriteJSON()/ReadJSON() (saveslotcurrent.c)
// manually copy field-by-field between this and whichever version is
// current, so a version's frozen on-disk shape can never drift just because
// this stable struct (or a live engine constant like MAP_NAME_MAX) changes.
typedef struct {
char_t name[SAVE_SLOT_NAME_LENGTH + 1];// 8 characters + null terminator
dusktimeepoch_t time;
int32_t playerLevel;
char_t mapName[MAP_NAME_MAX];
} saveslotcache_t;
typedef struct saveslot_s {
+51
View File
@@ -0,0 +1,51 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "saveslotcurrent.h"
#include "assert/assert.h"
#include "util/string.h"
#include "time/time.h"
errorret_t saveSlotCurrentWriteJSON(
saveslot_t *slot,
yyjson_mut_doc *doc,
yyjson_mut_val *object
) {
assertNotNull(slot, "Slot cannot be null");
assertNotNull(doc, "Doc cannot be null");
assertNotNull(object, "Object cannot be null");
// Stamped on the stable struct (not the throwaway ver1 copy below) so the
// fresh timestamp survives the write - saveSlotHasSaved() depends on it.
slot->cachedData.time = timeGetEpoch();
saveslotver1_t ver1;
stringCopy(ver1.name, slot->cachedData.name, sizeof(ver1.name));
ver1.time = slot->cachedData.time;
ver1.playerLevel = slot->cachedData.playerLevel;
stringCopy(ver1.mapName, slot->cachedData.mapName, sizeof(ver1.mapName));
errorChain(saveSlotVer1WriteJSON(&ver1, doc, object));
errorOk();
}
errorret_t saveSlotCurrentReadJSON(saveslot_t *slot, yyjson_val *object) {
assertNotNull(slot, "Slot cannot be null");
assertNotNull(object, "Object cannot be null");
saveslotver1_t ver1;
errorChain(saveSlotVer1ReadJSON(&ver1, object));
stringCopy(slot->cachedData.name, ver1.name, sizeof(slot->cachedData.name));
slot->cachedData.time = ver1.time;
slot->cachedData.playerLevel = ver1.playerLevel;
stringCopy(
slot->cachedData.mapName, ver1.mapName, sizeof(slot->cachedData.mapName)
);
errorOk();
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "save/slot/saveslot.h"
#include "save/slot/saveslotver1.h"
/**
* Converts the given save slot to whichever schema SAVE_SLOT_CURRENT
* (saveslotver.h) selects and writes it out to the given JSON object.
* Update this (only) when bumping to a new SAVE_SLOT_VER_N.
*
* @param slot The save slot to write.
* @param doc The mutable JSON document that owns the object.
* @param object The mutable JSON object to write into.
* @return Error state if any.
*/
errorret_t saveSlotCurrentWriteJSON(
saveslot_t *slot,
yyjson_mut_doc *doc,
yyjson_mut_val *object
);
/**
* Reads whichever schema SAVE_SLOT_CURRENT (saveslotver.h) selects from the
* given JSON object and converts it into the given save slot. Update this
* (only) when bumping to a new SAVE_SLOT_VER_N.
*
* @param slot The save slot to read into.
* @param object The JSON object to read from.
* @return Error state if any.
*/
errorret_t saveSlotCurrentReadJSON(saveslot_t *slot, yyjson_val *object);
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
// One constant per save slot JSON schema this codebase has ever shipped.
// Never remove or renumber an existing entry - old save files on disk may
// still declare it.
#define SAVE_SLOT_VER_1 1
// The schema saveSlotCurrentWriteJSON()/saveSlotCurrentReadJSON() use today.
// Bump this to a new SAVE_SLOT_VER_N when the slot JSON shape changes.
#define SAVE_SLOT_CURRENT SAVE_SLOT_VER_1
+46
View File
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "saveslotver1.h"
#include "assert/assert.h"
#include "save/savejson.h"
errorret_t saveSlotVer1WriteJSON(
saveslotver1_t *slot,
yyjson_mut_doc *doc,
yyjson_mut_val *object
) {
assertNotNull(slot, "Slot cannot be null");
assertNotNull(doc, "Doc cannot be null");
assertNotNull(object, "Object cannot be null");
writeVersion(SAVE_SLOT_CURRENT);
writeString("name", slot->name);
writeTime("time", slot->time);
writeInt32("playerLevel", slot->playerLevel);
writeString("mapName", slot->mapName);
errorOk();
}
errorret_t saveSlotVer1ReadJSON(saveslotver1_t *slot, yyjson_val *object) {
assertNotNull(slot, "Slot cannot be null");
assertNotNull(object, "Object cannot be null");
char_t saveJsonStringBuffer[SAVE_JSON_STRING_BUFFER_SIZE];
requireVersion(SAVE_SLOT_CURRENT);
readString("name", slot->name, "", SAVE_SLOT_VER1_NAME_LENGTH);
readTime("time", slot->time);
readInt32("playerLevel", slot->playerLevel, 1);
readString(
"mapName", slot->mapName, "", SAVE_SLOT_VER1_MAP_NAME_MAX - 1
);
errorOk();
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "time/timeepoch.h"
#include "save/slot/saveslotver.h"
#include "yyjson.h"
// This schema's own frozen copies of save/slot/saveslot.h's field sizes -
// never reference SAVE_SLOT_NAME_LENGTH/MAP_NAME_MAX (or any other live
// constant) here. Once shipped, this file's on-disk shape must never change
// underneath old save files, even if the live engine constants do.
#define SAVE_SLOT_VER1_NAME_LENGTH 8
#define SAVE_SLOT_VER1_MAP_NAME_MAX 10
// Independent from saveslotcache_t (save/slot/saveslot.h) by design - this
// is what version 1 actually put on disk, frozen forever. saveSlotCurrent
// WriteJSON()/ReadJSON() (saveslotcurrent.c) manually copy field-by-field
// between the two, since they're separate types that merely happen to
// currently look alike.
typedef struct saveslotver1_s {
char_t name[SAVE_SLOT_VER1_NAME_LENGTH + 1];
dusktimeepoch_t time;
int32_t playerLevel;
char_t mapName[SAVE_SLOT_VER1_MAP_NAME_MAX];
} saveslotver1_t;
/**
* Writes the given v1 save slot data out to the given JSON object, stamping
* SAVE_SLOT_CURRENT as the "version" field.
*
* @param slot The v1 save slot data to write.
* @param doc The mutable JSON document that owns the object.
* @param object The mutable JSON object to write into.
* @return Error state if any.
*/
errorret_t saveSlotVer1WriteJSON(
saveslotver1_t *slot,
yyjson_mut_doc *doc,
yyjson_mut_val *object
);
/**
* Reads v1 save slot data in from the given JSON object. Throws if the
* JSON's "version" is missing or doesn't match SAVE_SLOT_CURRENT.
*
* @param slot The v1 save slot data to read into.
* @param object The JSON object to read from.
* @return Error state if any.
*/
errorret_t saveSlotVer1ReadJSON(saveslotver1_t *slot, yyjson_val *object);
+3 -1
View File
@@ -13,6 +13,7 @@
#include "rpg/entity/entity.h"
#include "rpg/entity/entitytype.h"
#include "rpg/overworld/worldpos.h"
#include "rpg/overworld/map.h"
uiplayerpos_t UIPLAYERPOS;
@@ -46,7 +47,8 @@ errorret_t uiPlayerPosDraw() {
stringFormat(
UIPLAYERPOS.text,
UI_PLAYERPOS_TEXT_MAX - 1,
"%d,%d,%d[%d,%d,%d]#%d",
"%s %d,%d,%d[%d,%d,%d]#%d",
MAP.name,
(int_t)player->position.x,
(int_t)player->position.y,
(int_t)player->position.z,
+3 -3
View File
@@ -28,9 +28,9 @@ extern uiplayerpos_t UIPLAYERPOS;
errorret_t uiPlayerPosInit();
/**
* Draws the player world position, chunk position, and chunk index on
* screen. Searches for the first ENTITY_TYPE_PLAYER and renders:
* WORLDX,WORLDY,WORLDZ[chunkx,chunky,chunkz]#chunkIndex
* Draws the current map name, player world position, chunk position, and
* chunk index on screen. Searches for the first ENTITY_TYPE_PLAYER and
* renders: MAPNAME WORLDX,WORLDY,WORLDZ[chunkx,chunky,chunkz]#chunkIndex
*
* @return Any error that occurs.
*/
+1 -1
View File
@@ -11,7 +11,7 @@
#include "ui/widget/uimenu.h"
#include "ui/widget/uiscrolling.h"
#include "ui/dialog/save/uisaveslot.h"
#include "save/saveslot.h"
#include "save/slot/saveslot.h"
#define UI_SELECT_SAVE_TITLE_TEXT_MAX 64
#define UI_SELECT_SAVE_TITLE_SPRITES_MAX UI_SELECT_SAVE_TITLE_TEXT_MAX
+1 -3
View File
@@ -91,9 +91,7 @@ errorret_t assetInitDolphinDVD(void) {
);
if(!data) errorThrow("Failed to read asset file from ISO.");
errorret_t ret = assetDskOpenFromBuffer(
data, fileSize, &ASSET.zip, &ASSET.zipStored
);
errorret_t ret = assetDskOpenFromBuffer(data, fileSize, &ASSET.zip);
if(errorIsNotOk(ret)) {
memoryFree(data);
errorChain(ret);
+1 -1
View File
@@ -25,7 +25,7 @@
typedef struct {
// Whole dusk.dsk blob read from the ISO, kept alive for as long as
// ASSET.zip/ASSET.zipStored are open since they're non-owning windows
// ASSET.zip is open since it's a non-owning window
// into it (see assetDskOpenFromBuffer). Freed in assetDisposeDolphinDVD.
uint8_t *dskData;
} assetdolphindvd_t;
+1 -1
View File
@@ -56,7 +56,7 @@ errorret_t assetInitDolphinFAT(void) {
if(foundPath[0] == '\0')
errorThrow("Failed to find asset file on FAT filesystem.");
errorChain(assetDskOpenFromPath(foundPath, &ASSET.zip, &ASSET.zipStored));
errorChain(assetDskOpenFromPath(foundPath, &ASSET.zip));
errorOk();
}
+1 -3
View File
@@ -76,9 +76,7 @@ errorret_t assetInitLinux(void) {
printf("Try open asset file: %s\n", searchPath);
// Try open
if(errorIsNotOk(
assetDskOpenFromPath(searchPath, &ASSET.zip, &ASSET.zipStored)
)) {
if(errorIsNotOk(assetDskOpenFromPath(searchPath, &ASSET.zip))) {
printf("Failed to open asset file: %s\n", searchPath);
continue;
}
+2 -2
View File
@@ -7,8 +7,8 @@
#include "save/savedevice.h"
#include "save/savedevicelinux.h"
#include "save/saveslot.h"
#include "save/savesettings.h"
#include "save/slot/saveslot.h"
#include "save/settings/savesettings.h"
#include "save/savejson.h"
#include "asset/asset.h"
#include "assert/assert.h"
+1 -2
View File
@@ -80,8 +80,7 @@ errorret_t assetInitPBP(const char_t *pbpPath) {
pbpPath,
ASSET.platform.pbpHeader.psarOffset,
psarSize,
&ASSET.zip,
&ASSET.zipStored
&ASSET.zip
));
errorOk();
+22 -16
View File
@@ -17,14 +17,17 @@ void logDebug(const char_t *message, ...) {
vprintf(message, copy);
va_end(copy);
// print to file
FILE *file = fopen("ms0:/PSP/GAME/Dusk/debug.log", "a");
if(file) {
va_copy(copy, args);
vfprintf(file, message, copy);
va_end(copy);
fclose(file);
}
#ifdef DUSK_PSP_LOG_FILE
// print to file - fopen+fclose every call is real, measured Memory
// Stick I/O cost (this is why the flag exists - see psp.cmake).
FILE *file = fopen("ms0:/PSP/GAME/Dusk/debug.log", "a");
if(file) {
va_copy(copy, args);
vfprintf(file, message, copy);
va_end(copy);
fclose(file);
}
#endif
va_end(args);
}
@@ -39,14 +42,17 @@ void logError(const char_t *message, ...) {
vfprintf(stderr, message, copy);
va_end(copy);
// print to file
FILE *file = fopen("ms0:/PSP/GAME/Dusk/error.log", "a");
if(file) {
va_copy(copy, args);
vfprintf(file, message, copy);
va_end(copy);
fclose(file);
}
#ifdef DUSK_PSP_LOG_FILE
// print to file - fopen+fclose every call is real, measured Memory
// Stick I/O cost (this is why the flag exists - see psp.cmake).
FILE *file = fopen("ms0:/PSP/GAME/Dusk/error.log", "a");
if(file) {
va_copy(copy, args);
vfprintf(file, message, copy);
va_end(copy);
fclose(file);
}
#endif
va_end(args);
}
+1 -3
View File
@@ -10,9 +10,7 @@
#include "assert/assert.h"
errorret_t assetInitVita(void) {
errorChain(
assetDskOpenFromPath(ASSET_VITA_DSK_PATH, &ASSET.zip, &ASSET.zipStored)
);
errorChain(assetDskOpenFromPath(ASSET_VITA_DSK_PATH, &ASSET.zip));
errorOk();
}
+2 -1
View File
@@ -9,12 +9,13 @@
#include "rpg/overworld/map.h"
#include "rpg/overworld/tile.h"
#include "util/memory.h"
#include "util/string.h"
void entityTestFixtureReset(void) {
memoryZero(ENTITIES, sizeof(ENTITIES));
memoryZero(&MAP, sizeof(map_t));
MAP.loaded = true;
stringCopy(MAP.name, "test", MAP_NAME_MAX);
MAP.chunkPosition = (chunkpos_t){ 0, 0, 0 };
chunk_t *chunk = &MAP.chunks[0];
+10 -10
View File
@@ -57,7 +57,7 @@ static void test_saveInit_setsDefaults(void **state) {
assert_int_equal(SAVE.slotCurrent, 0xFF);
assert_false(SAVE.findingAvailableDevice);
assert_false(SAVE.noAvailableDeviceFound);
assert_int_equal(SAVE.settings.someSetting, 0);
assert_true(SAVE.settings.audioMasterVolume == 1.0f);
assert_int_equal(SAVE.devices[0].state, SAVE_DEVICE_STATE_UNKNOWN);
for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
@@ -229,7 +229,7 @@ static void test_saveSaveSettings_notDirty_isNoop(void **state) {
// if someone changes the no-op condition without meaning to.
makeDeviceAvailable();
assert_false(SAVE.settingsDirty);
SAVE.settings.someSetting = 123;
SAVE.settings.audioMasterVolume = 12.0f;
errorret_t ret = saveSaveSettings();
assert_true(errorIsOk(ret));
@@ -238,7 +238,7 @@ static void test_saveSaveSettings_notDirty_isNoop(void **state) {
saveSettingsInit(&onDisk);
errorret_t readRet = saveDeviceSettingsRead(&SAVE.devices[0], &onDisk);
assert_true(errorIsOk(readRet));
assert_int_equal(onDisk.someSetting, 0);// never actually written
assert_true(onDisk.audioMasterVolume == 1.0f);// never actually written
}
static void test_saveSaveSettings_dirtyForced_writesAndClearsFlag(
@@ -246,7 +246,7 @@ static void test_saveSaveSettings_dirtyForced_writesAndClearsFlag(
) {
makeDeviceAvailable();
SAVE.settingsDirty = true;// nothing in real code sets this - forced here
SAVE.settings.someSetting = 55;
SAVE.settings.audioMasterVolume = 5.5f;
errorret_t ret = saveSaveSettings();
assert_true(errorIsOk(ret));
@@ -256,7 +256,7 @@ static void test_saveSaveSettings_dirtyForced_writesAndClearsFlag(
saveSettingsInit(&onDisk);
errorret_t readRet = saveDeviceSettingsRead(&SAVE.devices[0], &onDisk);
assert_true(errorIsOk(readRet));
assert_int_equal(onDisk.someSetting, 55);
assert_true(onDisk.audioMasterVolume == 5.5f);
}
static void test_saveLoadSettings_noCurrentDeviceAsserts(void **state) {
@@ -268,16 +268,16 @@ static void test_saveLoadSettings_readsAndClearsDirtyFlag(void **state) {
savesettings_t onDisk;
saveSettingsInit(&onDisk);
onDisk.someSetting = 99;
onDisk.audioMasterVolume = 9.25f;
errorret_t writeRet = saveDeviceSettingsWrite(&SAVE.devices[0], &onDisk);
assert_true(errorIsOk(writeRet));
SAVE.settingsDirty = true;
SAVE.settings.someSetting = 0;
SAVE.settings.audioMasterVolume = 0.0f;
errorret_t ret = saveLoadSettings();
assert_true(errorIsOk(ret));
assert_int_equal(SAVE.settings.someSetting, 99);
assert_true(SAVE.settings.audioMasterVolume == 9.25f);
assert_false(SAVE.settingsDirty);
}
@@ -470,7 +470,7 @@ static void test_saveUpdate_foundDevice_rewritesSettingsUnconditionally(
// it, regardless of settingsDirty - independent of (and unguarded by) the
// same dirty-flag check saveSaveSettings() itself honors.
resetFindCallbackState();
SAVE.settings.someSetting = 321;
SAVE.settings.audioMasterVolume = 3.25f;
assert_false(SAVE.settingsDirty);
saveFindAvailableDevice(findAvailableCallback, NULL);
@@ -482,7 +482,7 @@ static void test_saveUpdate_foundDevice_rewritesSettingsUnconditionally(
saveSettingsInit(&onDisk);
errorret_t readRet = saveDeviceSettingsRead(&SAVE.devices[0], &onDisk);
assert_true(errorIsOk(readRet));
assert_int_equal(onDisk.someSetting, 321);
assert_true(onDisk.audioMasterVolume == 3.25f);
}
int main(void) {
+4 -4
View File
@@ -8,8 +8,8 @@
#include "dusktest.h"
#include "savetestfixture.h"
#include "save/savedevice.h"
#include "save/saveslot.h"
#include "save/savesettings.h"
#include "save/slot/saveslot.h"
#include "save/settings/savesettings.h"
#include "util/memory.h"
#include "util/string.h"
@@ -224,7 +224,7 @@ static void test_saveDeviceSettings_dispatchesToPlatform(void **state) {
savesettings_t written;
saveSettingsInit(&written);
written.someSetting = 88;
written.audioMasterVolume = 0.25f;// exactly representable, safe to compare
errorret_t writeRet = saveDeviceSettingsWrite(&device, &written);
assert_true(errorIsOk(writeRet));
@@ -234,7 +234,7 @@ static void test_saveDeviceSettings_dispatchesToPlatform(void **state) {
errorret_t readRet = saveDeviceSettingsRead(&device, &read);
assert_true(errorIsOk(readRet));
assert_int_equal(read.someSetting, 88);
assert_true(read.audioMasterVolume == 0.25f);
}
// ============================================================
+6 -6
View File
@@ -9,8 +9,8 @@
#include "savetestfixture.h"
#include "save/savedevice.h"
#include "save/savedevicelinux.h"
#include "save/saveslot.h"
#include "save/savesettings.h"
#include "save/slot/saveslot.h"
#include "save/settings/savesettings.h"
#include "util/memory.h"
#include "util/string.h"
#include <stdio.h>
@@ -298,7 +298,7 @@ static void test_settingsWriteRead_roundTrip(void **state) {
savesettings_t written;
saveSettingsInit(&written);
written.someSetting = 314;
written.audioMasterVolume = 3.25f;// exactly representable, safe to compare
errorret_t writeRet = saveDeviceLinuxSettingsWrite(&device, &written);
assert_true(errorIsOk(writeRet));
@@ -308,7 +308,7 @@ static void test_settingsWriteRead_roundTrip(void **state) {
errorret_t readRet = saveDeviceLinuxSettingsRead(&device, &read);
assert_true(errorIsOk(readRet));
assert_int_equal(read.someSetting, 314);
assert_true(read.audioMasterVolume == 3.25f);
}
static void test_settingsRead_noFileYet_leavesUntouched(void **state) {
@@ -317,11 +317,11 @@ static void test_settingsRead_noFileYet_leavesUntouched(void **state) {
savesettings_t settings;
saveSettingsInit(&settings);
settings.someSetting = 777;
settings.audioMasterVolume = 7.75f;
errorret_t ret = saveDeviceLinuxSettingsRead(&device, &settings);
assert_true(errorIsOk(ret));
assert_int_equal(settings.someSetting, 777);
assert_true(settings.audioMasterVolume == 7.75f);
}
static void test_settingsRead_corruptFile_errors(void **state) {
+37 -14
View File
@@ -6,7 +6,7 @@
*/
#include "dusktest.h"
#include "save/savesettings.h"
#include "save/settings/savesettings.h"
#include "save/savejson.h"
#include "util/memory.h"
#include <stdlib.h>
@@ -51,14 +51,14 @@ static errorret_t settingsFromJSON(
// saveSettingsInit
// ============================================================
static void test_saveSettingsInit_zeroesSomeSetting(void **state) {
static void test_saveSettingsInit_resetsAudioMasterVolume(void **state) {
savesettings_t settings;
memoryZero(&settings, sizeof(settings));
settings.someSetting = 42;
settings.audioMasterVolume = 42.0f;
saveSettingsInit(&settings);
assert_int_equal(settings.someSetting, 0);
assert_true(settings.audioMasterVolume == 1.0f);
}
static void test_saveSettingsInit_nullAsserts(void **state) {
@@ -85,7 +85,6 @@ static void test_saveSettingsReadJSON_nullAsserts(void **state) {
savesettings_t settings;
saveSettingsInit(&settings);
writeInit();
yyjson_mut_obj_add_int(doc, object, "someSetting", 1);
size_t len;
char_t *json = yyjson_mut_write(doc, 0, &len);
@@ -105,7 +104,7 @@ static void test_saveSettingsReadJSON_nullAsserts(void **state) {
static void test_saveSettingsWriteReadJSON_roundTrip(void **state) {
savesettings_t written;
saveSettingsInit(&written);
written.someSetting = 12345;
written.audioMasterVolume = 2.5f;
char_t *json;
size_t len;
@@ -114,35 +113,59 @@ static void test_saveSettingsWriteReadJSON_roundTrip(void **state) {
savesettings_t read;
saveSettingsInit(&read);
read.someSetting = -1;// sentinel, should be overwritten
read.audioMasterVolume = -1.0f;// sentinel, should be overwritten
errorret_t readRet = settingsFromJSON(&read, json);
assert_true(errorIsOk(readRet));
assert_int_equal(read.someSetting, 12345);
assert_true(read.audioMasterVolume == 2.5f);
free(json);
}
static void test_saveSettingsReadJSON_missingKeyDefaultsToZero(void **state) {
static void test_saveSettingsReadJSON_missingKeyUsesDefault(void **state) {
savesettings_t settings;
saveSettingsInit(&settings);
settings.audioMasterVolume = 999.0f;// sentinel
errorret_t ret = settingsFromJSON(&settings, "{\"version\":1}");
assert_true(errorIsOk(ret));
assert_true(settings.audioMasterVolume == 1.0f);
}
// ============================================================
// saveSettingsReadJSON - version enforcement
// ============================================================
static void test_saveSettingsReadJSON_versionMissing_errors(void **state) {
savesettings_t settings;
saveSettingsInit(&settings);
settings.someSetting = 999;// sentinel
errorret_t ret = settingsFromJSON(&settings, "{}");
assert_true(errorIsOk(ret));
assert_int_equal(settings.someSetting, 0);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
static void test_saveSettingsReadJSON_versionMismatch_errors(void **state) {
savesettings_t settings;
saveSettingsInit(&settings);
errorret_t ret = settingsFromJSON(&settings, "{\"version\":999}");
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
int main(void) {
assertInit();
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_saveSettingsInit_zeroesSomeSetting),
cmocka_unit_test(test_saveSettingsInit_resetsAudioMasterVolume),
cmocka_unit_test(test_saveSettingsInit_nullAsserts),
cmocka_unit_test(test_saveSettingsWriteJSON_nullAsserts),
cmocka_unit_test(test_saveSettingsReadJSON_nullAsserts),
cmocka_unit_test(test_saveSettingsWriteReadJSON_roundTrip),
cmocka_unit_test(test_saveSettingsReadJSON_missingKeyDefaultsToZero),
cmocka_unit_test(test_saveSettingsReadJSON_missingKeyUsesDefault),
cmocka_unit_test(test_saveSettingsReadJSON_versionMissing_errors),
cmocka_unit_test(test_saveSettingsReadJSON_versionMismatch_errors),
};
return cmocka_run_group_tests(tests, NULL, NULL);
+42 -5
View File
@@ -6,7 +6,7 @@
*/
#include "dusktest.h"
#include "save/saveslot.h"
#include "save/slot/saveslot.h"
#include "save/savejson.h"
#include "util/memory.h"
#include "util/string.h"
@@ -174,7 +174,9 @@ static void test_saveSlotReadJSON_missingFieldsUseDefaults(void **state) {
slot.cachedData.playerLevel = 999;
errorret_t ret = slotFromJSON(
&slot, "{\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
&slot,
"{\"version\":1,"
"\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
);
assert_true(errorIsOk(ret));
@@ -186,7 +188,9 @@ static void test_saveSlotReadJSON_missingTimeKey_errors(void **state) {
saveslot_t slot;
saveSlotInit(&slot);
errorret_t ret = slotFromJSON(&slot, "{\"name\":\"Hero\",\"playerLevel\":5}");
errorret_t ret = slotFromJSON(
&slot, "{\"version\":1,\"name\":\"Hero\",\"playerLevel\":5}"
);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
@@ -197,7 +201,7 @@ static void test_saveSlotReadJSON_nameTooLong_errors(void **state) {
errorret_t ret = slotFromJSON(
&slot,
"{\"name\":\"WayTooLongAName\","
"{\"version\":1,\"name\":\"WayTooLongAName\","
"\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
);
assert_true(errorIsNotOk(ret));
@@ -209,12 +213,43 @@ static void test_saveSlotReadJSON_nonObjectRoot_errors(void **state) {
saveSlotInit(&slot);
// A non-object root has no keys, so every field falls back to its
// default - except "time", which is not optional and errors instead.
// default - except "version" and "time", which are required and error
// instead.
errorret_t ret = slotFromJSON(&slot, "[]");
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
// ============================================================
// saveSlotReadJSON - version enforcement
// ============================================================
static void test_saveSlotReadJSON_versionMissing_errors(void **state) {
saveslot_t slot;
saveSlotInit(&slot);
errorret_t ret = slotFromJSON(
&slot,
"{\"name\":\"Hero\","
"\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
static void test_saveSlotReadJSON_versionMismatch_errors(void **state) {
saveslot_t slot;
saveSlotInit(&slot);
errorret_t ret = slotFromJSON(
&slot,
"{\"version\":999,\"name\":\"Hero\","
"\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
int main(void) {
assertInit();
const struct CMUnitTest tests[] = {
@@ -234,6 +269,8 @@ int main(void) {
cmocka_unit_test(test_saveSlotReadJSON_missingTimeKey_errors),
cmocka_unit_test(test_saveSlotReadJSON_nameTooLong_errors),
cmocka_unit_test(test_saveSlotReadJSON_nonObjectRoot_errors),
cmocka_unit_test(test_saveSlotReadJSON_versionMissing_errors),
cmocka_unit_test(test_saveSlotReadJSON_versionMismatch_errors),
};
return cmocka_run_group_tests(tests, NULL, NULL);
+57 -19
View File
@@ -7,7 +7,7 @@
Generates DCF + companion DMF files from raw chunk JSON files, or upgrades
legacy DCF files (version 1 or 2) to the current version.
JSON input (assetsraw/chunks/chunk_X_Y_Z.json):
JSON input (assetsraw/maps/<map>/chunks/chunk_X_Y_Z.json):
{
"tiles": [
{ "pos": [x, y, z], "type": <tile_shape_int>, "tile": <uv_tile_int> }
@@ -35,6 +35,9 @@ JSON input (assetsraw/chunks/chunk_X_Y_Z.json):
Mesh files are located by searching under assets/meshes/ and referenced by
path from the assets root in the DCF.
<map> is the map's name, up to 9 characters (MAP_NAME_MAX in
src/dusk/rpg/overworld/map.h reserves 1 byte for the null terminator).
"entities" spawns things into the world when this chunk loads. A "global"
entity is spawned via mapSpawnEntity() - globalId indexes
ENTITY_GLOBAL_LIST (src/dusk/rpg/entity/global/entitygloballist.h) and is
@@ -53,7 +56,8 @@ JSON input (assetsraw/chunks/chunk_X_Y_Z.json):
trigger is bitwise MAP_TRIGGER_STEP(1)|ENTER(2)|EXIT(4).
Output DCF is derived automatically:
assetsraw/chunks/chunk_X_Y_Z.json -> assets/chunks/X_Y_Z.dcf
assetsraw/maps/<map>/chunks/chunk_X_Y_Z.json
-> assets/maps/<map>/chunks/X_Y_Z.dcf
Version 5 DCF format (after 8-byte header):
tile_t tiles[CHUNK_WIDTH * CHUNK_HEIGHT] (one per x/y column)
@@ -85,7 +89,7 @@ DMF format:
Each vertex: uv[2] + pos[3] = 5 x float32 LE = 20 bytes
Usage:
python3 -m tools.asset.chunk # process all assetsraw/chunks/*.json
python3 -m tools.asset.chunk # process all assetsraw/maps/*/chunks/*.json
python3 -m tools.asset.chunk <file.json> # process one JSON file
python3 -m tools.asset.chunk <file.dcf> # upgrade legacy (v1/v2) DCF in-place
"""
@@ -118,6 +122,10 @@ CHUNK_MESH_NAME_MAX = 64
CHUNK_ENTITY_SPAWN_COUNT_MAX = 8
CHUNK_AREA_COUNT_MAX = 4
# Matches MAP_NAME_MAX in src/dusk/rpg/overworld/map.h (buffer size,
# including the null terminator - so map names are at most 9 characters).
MAP_NAME_MAX = 10
ENTITY_SPAWN_KIND_GLOBAL = 0
ENTITY_SPAWN_KIND_ITEM = 1
@@ -188,8 +196,29 @@ def write_model_json(path, mesh_rel, texture_rel, color):
print(f' Wrote model JSON {path}')
def chunk_map_name(json_path):
"""assetsraw/maps/<map>/chunks/chunk_X_Y_Z.json -> <map>
Raises ValueError if json_path isn't under that structure, or if <map>
is longer than MAP_NAME_MAX - 1 characters."""
rel = os.path.relpath(os.path.abspath(json_path), ASSETSRAW_DIR)
parts = rel.split(os.sep)
if len(parts) != 4 or parts[0] != 'maps' or parts[2] != 'chunks':
raise ValueError(
f"Chunk JSON must live at assetsraw/maps/<map>/chunks/*.json, got: "
f"{json_path}"
)
map_name = parts[1]
if len(map_name) > MAP_NAME_MAX - 1:
raise ValueError(
f"Map name too long (> {MAP_NAME_MAX - 1} characters): {map_name}"
)
return map_name
def derive_dcf_path(json_path):
"""assetsraw/chunks/chunk_X_Y_Z.json -> assets/chunks/X_Y_Z.dcf"""
"""assetsraw/maps/<map>/chunks/chunk_X_Y_Z.json
-> assets/maps/<map>/chunks/X_Y_Z.dcf"""
base = os.path.splitext(os.path.basename(json_path))[0]
if base.startswith('chunk_'):
base = base[len('chunk_'):]
@@ -351,6 +380,8 @@ def build_terrain_verts(tiles_bytes):
def from_json(json_path, dcf_path):
map_name = chunk_map_name(json_path)
with open(json_path) as f:
data = json.load(f)
@@ -377,15 +408,17 @@ def from_json(json_path, dcf_path):
dcf_base = os.path.splitext(os.path.basename(dcf_path))[0]
# Terrain mesh + model (only written if non-empty)
terrain_dir = os.path.join(ASSETS_DIR, 'meshes', 'chunks')
models_dir = os.path.join(ASSETS_DIR, 'models', 'chunks')
# Terrain mesh + model (only written if non-empty). Namespaced under the
# owning map so two maps can each have a chunk at the same (x, y, z)
# without their generated terrain assets colliding.
terrain_dir = os.path.join(ASSETS_DIR, 'maps', map_name, 'meshes', 'chunks')
models_dir = os.path.join(ASSETS_DIR, 'maps', map_name, 'models', 'chunks')
os.makedirs(terrain_dir, exist_ok=True)
os.makedirs(models_dir, exist_ok=True)
if terrain_verts:
dmf_name = f'chunk_{dcf_base}_0.dmf'
write_dmf(os.path.join(terrain_dir, dmf_name), terrain_verts)
mesh_rel = f'meshes/chunks/{dmf_name}'
mesh_rel = f'maps/{map_name}/meshes/chunks/{dmf_name}'
json_name = f'chunk_{dcf_base}_0.json'
write_model_json(
os.path.join(models_dir, json_name),
@@ -393,7 +426,7 @@ def from_json(json_path, dcf_path):
'tiles.png',
[255, 255, 255, 255]
)
model_names.append(f'models/chunks/{json_name}')
model_names.append(f'maps/{map_name}/models/chunks/{json_name}')
mesh_offsets.append((0.0, 0.0, 0.0))
# External mesh references -> look up matching model JSON
@@ -555,22 +588,27 @@ def main():
parser.add_argument(
'path', nargs='?',
help='Chunk JSON file to process, or legacy DCF file to upgrade. '
'If omitted, processes all assetsraw/chunks/*.json'
'If omitted, processes all assetsraw/maps/*/chunks/*.json'
)
args = parser.parse_args()
if args.path is None:
chunks_dir = os.path.join(ASSETSRAW_DIR, 'chunks')
if not os.path.isdir(chunks_dir):
print(f"No directory found: {chunks_dir}")
maps_dir = os.path.join(ASSETSRAW_DIR, 'maps')
if not os.path.isdir(maps_dir):
print(f"No directory found: {maps_dir}")
sys.exit(1)
json_files = sorted(
os.path.join(chunks_dir, f)
for f in os.listdir(chunks_dir)
if f.endswith('.json')
)
json_files = []
for map_name in sorted(os.listdir(maps_dir)):
chunks_dir = os.path.join(maps_dir, map_name, 'chunks')
if not os.path.isdir(chunks_dir):
continue
json_files.extend(sorted(
os.path.join(chunks_dir, f)
for f in os.listdir(chunks_dir)
if f.endswith('.json')
))
if not json_files:
print(f"No JSON files found in {chunks_dir}")
print(f"No JSON files found under {maps_dir}")
sys.exit(0)
for p in json_files:
process_json(p)
+32 -72
View File
@@ -6,35 +6,26 @@
"""
Packs the assets directory into a dusk.dsk (DSK2) archive.
Replaces a single plain zip with two independent zip archives back to
back, so specific files can be stored uncompressed instead of DEFLATEd -
useful for small files (locale strings, config) accessed at runtime via
repeated seeks/re-opens, which is only reliably supported by libzip for
uncompressed (STORED) entries. Reading a whole compressed archive into
memory just to get reliable access isn't viable once that archive holds
the bulk of a game's binary assets, so the two archives are kept
separate and only the (small, by convention) stored one is expected to
ever need to be buffered whole.
A small fixed header (magic/version/size/checksum) followed by a single
uncompressed (ZIP_STORED) zip archive of every asset. Uncompressed rather
than DEFLATEd because the bulk of what's packed (audio, images) is
already compressed at the file level, so a second, zip-level DEFLATE
pass buys negligible space back for real CPU cost, and it lets every
entry - not just a hand-picked subset - support the reliable repeated
seeking/re-opening (e.g. locale line-by-line reads, MP3 loop restarts)
that libzip only supports for uncompressed entries.
DSK2 format:
Bytes 0-3: "DSK2" (magic)
Bytes 4-7: uint32_t version = 1 (little-endian)
Bytes 8-11: uint32_t compressedOffset
Bytes 12-15: uint32_t compressedSize
Bytes 16-19: uint32_t compressedChecksum (CRC32 of the compressed blob)
Bytes 20-23: uint32_t storedOffset
Bytes 24-27: uint32_t storedSize
Bytes 28-31: uint32_t storedChecksum (CRC32 of the stored blob)
Bytes 32+: compressed zip blob, then stored zip blob (each a
complete, independently valid zip archive)
Bytes 0-3: "DSK2" (magic)
Bytes 4-7: uint32_t version = 2 (little-endian)
Bytes 8-11: uint32_t size (byte length of the zip archive that follows)
Bytes 12-15: uint32_t checksum (CRC32 of the zip archive that follows)
Bytes 16+: the zip archive itself
Usage:
python3 -m tools.asset.pack --input <assets_dir> --output <dusk.dsk>
[--stored <glob-pattern>]...
Every file under <assets_dir> is added, using its path relative to
<assets_dir> as the zip entry name, to the stored archive if its
relative path matches any --stored pattern (fnmatch, default:
"locale/*"), otherwise to the compressed archive.
<assets_dir> as the zip entry name.
Called unconditionally on every CMake build (see the DUSK_ASSETS_BUILT
target) rather than gated behind file-level build-system dependencies -
@@ -44,7 +35,10 @@ existing files or lag a build behind on adds/removes. Instead this script
itself decides whether anything actually needs repacking: it writes a
manifest of every input file's (mtime, size) next to the output on each
successful pack, and skips the real work if a fresh manifest compares
equal to it and the output still exists.
equal to it and the output still exists. The manifest also carries the
format version, so a manifest written by an older version of this script
(a stale single/dual-archive dusk.dsk sitting from before a format
change) never compares equal and always triggers a fresh pack.
"""
import argparse
@@ -53,19 +47,17 @@ import os
import struct
import zipfile
import zlib
import fnmatch
import io
MAGIC = b'DSK2'
VERSION = 1
HEADER_FORMAT = '<4sIIIIIII'
VERSION = 2
HEADER_FORMAT = '<4sIII'
HEADER_SIZE = struct.calcsize(HEADER_FORMAT)
DEFAULT_STORED_PATTERNS = ['locale/*']
def build_zip_blob(root, relative_paths, compression):
def build_zip_blob(root, relative_paths):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', compression=compression) as zf:
with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_STORED) as zf:
for relative_path in sorted(relative_paths):
zf.write(os.path.join(root, relative_path), arcname=relative_path)
return buf.getvalue()
@@ -75,12 +67,12 @@ def manifest_path(output_path):
return output_path + '.manifest.json'
def compute_manifest(input_dir, relative_paths, stored_patterns):
def compute_manifest(input_dir, relative_paths):
entries = {}
for relative_path in relative_paths:
st = os.stat(os.path.join(input_dir, relative_path))
entries[relative_path] = [st.st_mtime_ns, st.st_size]
return {'stored_patterns': sorted(stored_patterns), 'files': entries}
return {'version': VERSION, 'files': entries}
def load_manifest(path):
@@ -91,7 +83,7 @@ def load_manifest(path):
return None
def pack(input_dir, output_path, stored_patterns):
def pack(input_dir, output_path):
relative_paths = []
for dirpath, _dirnames, filenames in os.walk(input_dir):
for filename in filenames:
@@ -100,52 +92,28 @@ def pack(input_dir, output_path, stored_patterns):
os.path.relpath(full_path, input_dir).replace(os.sep, '/')
)
manifest = compute_manifest(input_dir, relative_paths, stored_patterns)
manifest = compute_manifest(input_dir, relative_paths)
manifest_file = manifest_path(output_path)
if os.path.isfile(output_path) and load_manifest(manifest_file) == manifest:
print(f'{output_path} is up to date, skipping pack')
return
stored_paths = [
path for path in relative_paths
if any(fnmatch.fnmatch(path, pattern) for pattern in stored_patterns)
]
compressed_paths = [
path for path in relative_paths if path not in stored_paths
]
compressed_blob = build_zip_blob(
input_dir, compressed_paths, zipfile.ZIP_DEFLATED
)
stored_blob = build_zip_blob(input_dir, stored_paths, zipfile.ZIP_STORED)
compressed_offset = HEADER_SIZE
stored_offset = compressed_offset + len(compressed_blob)
blob = build_zip_blob(input_dir, relative_paths)
header = struct.pack(
HEADER_FORMAT,
MAGIC,
VERSION,
compressed_offset,
len(compressed_blob),
zlib.crc32(compressed_blob) & 0xFFFFFFFF,
stored_offset,
len(stored_blob),
zlib.crc32(stored_blob) & 0xFFFFFFFF,
HEADER_FORMAT, MAGIC, VERSION, len(blob), zlib.crc32(blob) & 0xFFFFFFFF
)
with open(output_path, 'wb') as f:
f.write(header)
f.write(compressed_blob)
f.write(stored_blob)
f.write(blob)
with open(manifest_file, 'w', encoding='utf-8') as f:
json.dump(manifest, f)
print(
f'Wrote {output_path}: {len(compressed_paths)} compressed file(s) '
f'({len(compressed_blob)} bytes), {len(stored_paths)} stored file(s) '
f'({len(stored_blob)} bytes), {len(header) + len(compressed_blob) + len(stored_blob)} bytes total'
f'Wrote {output_path}: {len(relative_paths)} file(s) '
f'({len(blob)} bytes), {len(header) + len(blob)} bytes total'
)
@@ -155,17 +123,9 @@ def main():
)
parser.add_argument('--input', required=True, help='Assets directory to pack')
parser.add_argument('--output', required=True, help='Path to write dusk.dsk to')
parser.add_argument(
'--stored',
action='append',
dest='stored_patterns',
help='fnmatch pattern (relative to --input) of files to store '
'uncompressed instead of compressing. May be given multiple '
'times. Defaults to "locale/*" if never given.'
)
args = parser.parse_args()
pack(args.input, args.output, args.stored_patterns or DEFAULT_STORED_PATTERNS)
pack(args.input, args.output)
if __name__ == '__main__':