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
YourWishesandClaude Sonnet 5 b3623c84a5 Fix entity chunk-tracking leaks, add saveCanSave, use chunkindex_t
Entities weren't being detached from their chunk's entities[] slot in
two despawn paths (CUTSCENE_ENTITY_REMOVE, item pickup collection) - the
slot leaked forever until the whole chunk unloaded. Both now call
entitySetChunk(entity, CHUNK_INDEX_INVALID) before nulling the type.

entity_t.chunkIndex and entitySetChunk/mapGetChunk now use chunkindex_t
instead of uint8_t, matching mapGetChunkIndexAt's own -1-is-invalid
convention - added CHUNK_INDEX_INVALID next to the typedef in
worldpos.h rather than reusing the old 0xFF/uint8_t sentinel, which
would silently mean +255 on a signed 16-bit field instead of -1.

mapPositionSet now also recomputes every live entity's cached
chunkIndex after mapRebuildChunkOrder() reshuffles which chunk_t sits
at each chunkOrder slot - done as a direct field recompute rather than
through entitySetChunk, which would've looked up each entity's now-stale
old index, failed to find/clear its real registration, and inserted a
duplicate entry into the correct chunk on every map position change.

Also adds saveCanSave() (false while a cutscene is running) and shows
the player's live chunkIndex in the ui/debug/uiplayerpos overlay.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 10:12:37 -05:00
YourWishesandClaude Sonnet 5 45c1f9004b Load the chosen save slot on selection, fix transition finished re-firing
Picking a save slot now sets SAVE.slotCurrent and calls saveLoadSlot()
before entering the overworld, same slotCurrent/saveSlotInit/saveLoadSlot
sequence saveLoadAllSlots uses per-slot; a load failure shows the fatal
error overlay instead of proceeding with stale/garbage slot data.

Also fixes uiTransitionUpdate firing its "finished" callback every frame
after a transition completes instead of once - latent since the generic
transition system had no real caller yet.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 12:13:51 -05:00
YourWishesandClaude Sonnet 5 845a395ff1 Fix New Game falling through into the Quit confirm dialog
The SAVE_DEVICE_FOUND/SAVE_LOAD_ALL_SLOTS block had been moved to the
end of the file (after QUIT/QUIT_GAME) without moving the LOADED
marker with it. MARKER items always auto-advance to whatever follows
them in the array, so reaching LOADED after a successful save-load fell
straight through into the QUIT block's confirm dialog instead of
completing the cutscene. LOADED now sits after SAVE_LOAD_ALL_SLOTS as
the true last item, so it falls off the end and fires
uiMainMenuOpenSelectSave again as intended.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 11:52:48 -05:00
YourWishesandClaude Sonnet 5 f63653c307 Wire main menu Options/Quit through the cutscene, fix locale crashes
Options and Quit now route through main_menu.jsonc markers the same
way New Game does, with a new QUIT_GAME cutscene item type ending the
process directly once quit is confirmed via MODAL_OPTIONS_MARKERS
(whose "message" field is now optional, defaulting to "" like "title").

Fixes two crashes surfaced along the way:
- uiModalLocalize asserted on an empty locale key instead of treating
  it as "nothing to show" - hit as soon as a modal omitted a title or
  message.
- assetLocaleGetString's new empty-message-ID guard broke the PO
  format's own header-read convention (messageId "") used internally
  by assetLocaleLoaderAsync, crashing on every boot. Extracted the
  shared lookup into assetLocaleGetStringLookup and added a dedicated
  assetLocaleGetHeader for that internal read, leaving
  assetLocaleGetString's validation untouched for every real caller.

Also replaced every raw assetLocaleGetString/WithVA call across the UI
with the locale/localemanager.h helpers (adding localeManagerGetString
for the plain, non-formatting case), so app code no longer bypasses
them.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 11:47:34 -05:00
YourWishesandClaude Sonnet 5 5b523c68a7 Add cutsceneSystemLoad, a debug cutscene overlay, and NULL-safe fatal error
cutsceneSystemLoad(file) centralizes the lock/assetRequireLoaded/start
sequence previously duplicated in sceneInitialInit, and falls back to
the fatal error overlay instead of asserting when the asset fails to
load. uiFatalErrorOpen now accepts a NULL message, showing a generic
"contact support" message for callers with no specific error text.
Also adds a ui/debug/uicutscene overlay showing the running cutscene's
item count and current item type, and wires main_menu's post-save-picker
flow through cutsceneSystemLoad.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 11:18:48 -05:00
YourWishesandClaude Sonnet 5 03efbe0681 Move cutscene item JSON parsing into per-item Load callbacks
Each cutscene item type now owns its own JSON parsing (a Load callback
alongside its existing runtime callbacks), instead of one monolithic
switch in assetcutsceneloader.c. Shared JSON field/enum decoding helpers
move into a new rpg/cutscene/item/json/ module so per-type parsers stay
small and colocated with the behavior they configure.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 09:32:10 -05:00
YourWishesandClaude Sonnet 5 cebd3d81e7 Wrap long lines to fit within 80 columns across the codebase
Pure whitespace/line-break reformatting (braces, newlines, and line
continuations matching this codebase's existing wrap conventions) - no
logic, string content, or identifiers changed anywhere. Confirmed via
diff against the pre-change tree and by rebuilding + re-running the
affected test suites, which produce identical pass/fail results.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 13:13:10 -05:00
YourWishesandClaude Sonnet 5 de53bebeb8 Remove the main menu scene now that it's just a persistent cutscene
The main menu no longer needs its own scene lifecycle - it's started
directly by the initial cutscene's CUTSCENE item chaining into
main_menu.jsonc. Fold scenemainmenu.c's logic into uimainmenu.c (its
only real caller) and drop SCENE_TYPE_MAIN_MENU entirely. Also arm the
cutscene's onComplete callback unconditionally in uiMainMenuStartGame
so the save-picker still opens on a fresh boot, not just on restart.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 12:18:02 -05:00
YourWishesandClaude Sonnet 5 6ccaedd48f Load cutscenes directly from JSONC at runtime, fix asset bundling staleness
Cutscenes now parse their authored .jsonc straight into the runtime
cutsceneitem_t/pool representation via yyjson, instead of going through a
separate Python-compiled DCTS binary format - removes the whole
build/compile step and the byte-format contract between the Python
encoder and the C decoder, at the cost of a (still tiny, one-time)
parse per cutscene load.

Also fixes dusk.dsk going stale after a build: the old custom_command
depended on a CMake-configure-time file glob, which only re-detects
added/removed assets on the next configure and could miss edits
entirely. tools.asset.pack now always runs and decides for itself
(via a small manifest) whether anything actually needs repacking, so
asset changes are never missed regardless of add/edit/remove.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 12:05:23 -05:00
YourWishesandClaude Sonnet 5 707856fcf2 Run one persistent cutscene for the whole main menu, not just start-game
Previously the main menu UI opened directly with no cutscene, and only
pressing "Start Game" spun up a brand new cutscene
(main_menu_start_game.jsonc) for the save-device check/load-slots flow.
Now a single cutscene (main_menu.jsonc) starts the moment the main menu
scene becomes active, sitting idle (new CUTSCENE_ITEM_TYPE_IDLE item -
no existing item type blocked forever with no side effect) while the
menu is shown, so it can own menu-wide state going forward.

Pressing "Start Game" now jumps the already-running cutscene to a
NEW_GAME marker via cutsceneGoTo instead of starting a second cutscene -
this already-existing mechanism needed no engine changes. Both retry
options (no device / load error) also now jump straight back to
NEW_GAME instead of RESTARTing the whole cutscene, since RESTART would
otherwise strand the player on the idle block. sceneMainMenuStartGame
also handles the case where the cutscene already ran to completion once
(e.g. backing out of the save picker and pressing Start Game again) by
restarting it landing straight on NEW_GAME, avoiding a crash from
jumping a marker with no cutscene running.

Built and verified on Linux (boots/idles without crashing, cutscene
tests pass), PSP, GameCube and Wii (Docker).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 18:18:49 -05:00
YourWishesandClaude Sonnet 5 3c3dc35e1d Default Wii saves to SD card, store saves next to dusk.dsk/EBOOT.PBP
Wii NAND save storage is blocked by Dolphin's IOS ticket-rights check for
homebrew (documented earlier this session as a known, non-code limitation),
so Wii now defaults DUSK_SAVE_WII_METHOD to SD instead of NAND.

Also: asset_t gains ASSET.baseDirectory, populated by each platform's asset
loader with the real directory dusk.dsk was opened from (or, on PSP, the
directory EBOOT.PBP itself lives in, since dusk.dsk is embedded inside it).
Wii SD and PSP now write their single combined save file directly into
that directory instead of a separate hardcoded path, and no longer need to
create it (it's already known to exist). Linux now uses a `saves/`
subdirectory of that same directory instead of ~/.dusk/saves, since it
keeps multiple files (settings + one per slot). Wii NAND and GameCube/Wii
memory-card storage are unaffected - neither has a real filesystem-path
concept this applies to.

Built and verified on Linux, PSP (Docker), GameCube (Docker) and Wii
(Docker) - confirmed via the compiler invocation that Wii now compiles
with DUSK_SAVE_WII_METHOD_SD.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 09:43:00 -05:00
YourWishesandClaude Sonnet 5 f9820bd226 Fix crash when continuing without a save device
saveLoadSlot() and uiSelectSaveNameEntered() both asserted
SAVE.deviceCurrent != 0xFF unconditionally, crashing as soon as a player
dismissed the "no save device found" prompt and tried to start a game or
name a new save. Both now fall back to an in-memory-only slot (no device
to persist to) instead of asserting, matching the "continue without a
save device" flow the main menu cutscene already offers.

Verified fixed on Dolphin/GameCube; built clean on Linux and GameCube
(Docker).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 08:46:43 -05:00
YourWishes fe2ef92ef3 Testing audio cutscene controls 2026-09-02 18:49:12 -05:00
YourWishesandClaude Sonnet 5 9364768397 Add cutscene controls for the audio mixer, fix PSP pause latency
- Fixed PSP pause taking up to ~1.1s to actually go silent: its output
  thread ran independently of stream->state, draining its whole software
  ring regardless. audiostream_t.state is now volatile and the output
  thread checks AUDIO_STREAM_STATE_PLAYING before each hardware chunk,
  skipping output (without consuming the ring) while paused - pause now
  goes silent within about one chunk (~23ms) and resume has no gap.
- Added audioMixerPause/Resume/SetPan/SetLoop/FadeTo/IsFading to the
  mixer - immediate, channel-indexed primitives for cutscenes to drive.
  Fade transitions (fadeFrom/To/Duration/Time/Easing) live on
  audiomixerchannelstate_t and advance every frame in
  audioMixerChannelApplyVolume(), reusing the same easingApply()
  interpolation uifullbox_t already uses for screen fades.
- New src/dusk/rpg/cutscene/item/audio/ with 9 cutscene item types:
  AUDIO_PLAY (+ AUDIO_PLAY_SIMPLE/AUDIO_PLAY_LOOPED shorthands),
  AUDIO_STOP, AUDIO_PAUSE, AUDIO_RESUME, AUDIO_FADE (+ FADE_OUT/FADE_IN
  shorthands), AUDIO_FADE_WAIT, AUDIO_SET_PAN, AUDIO_SET_LOOP, and the
  combined AUDIO_SET - registered through the same enum/union/callback
  table/macro mechanism every other item type uses.
- Wired the same 9 types into the JSON-based (offline JSONC -> binary
  .cts) cutscene asset pipeline: tools/asset/cutscene/__main__.py's
  encoder and assetcutsceneloader.c's decoder. Verified round-trip by
  hand-encoding/decoding a test file covering all 9 types, and confirmed
  the two existing real cutscene files re-encode byte-identical.

Built and verified on Linux, PSP (Docker), GameCube (Docker) and Wii
(Docker).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 17:44:06 -05:00
YourWishesandClaude Sonnet 5 9c28d5a3b7 Split mixer channel logic into its own module, persist volume settings, add channel callbacks
- Extracted src/dusk/audio/mixer/audiomixerchannel.{h,c}: the channel enum,
  command/state structs, and all per-channel logic (StopStream,
  ApplyVolume, ChannelUpdateEarly/Late) now live there; audiomixer.{h,c}
  just holds the channel array and the public Play/PlayLooped/Stop API.
- Removed audioMixerSetChannelVolume()/audioMixerSetMasterVolume() - volume
  is written directly (SAVE.settings.audioChannelVolume[channel] = 0.3f,
  SAVE.settings.audioMasterVolume = 0.3f) and only ever validated where
  it's read, in audioMixerChannelApplyVolume().
- Moved master/channel volume into savesettings_t (new writeFloatArray/
  readFloatArray save-JSON macros) so they persist as user preferences;
  `fade` moved from an unused mixer-wide array to a real per-channel
  multiplier that's now actually folded into the volume calculation.
- audioMixerUpdateEarly()/Late() now catch and log a single channel's
  error instead of letting it abort every other channel's update for that
  frame (and cascade into skipping the rest of that frame's engine
  update), matching the existing per-stream error-isolation pattern.
- Added persistent per-channel onLoop/onEnd callbacks + a user pointer
  (audiomixerchannelstate_t), wired onto every stream a channel starts via
  a trampoline (audioMixerChannelOnStreamLoop/OnStreamEnd) so they survive
  across multiple plays on the same channel, unlike the underlying stream
  instance itself.
- General cleanup: for loops -> while loops, if/else cascades -> guard
  clauses, dropped unnecessary const on locals, wrapped to 80 columns.

Built and verified on Linux, PSP (Docker), GameCube (Docker) and Wii
(Docker).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 16:07:42 -05:00
YourWishesandClaude Sonnet 5 d5078b978c Cache PSP volume/pan computation, mix channel + master volume into mixer playback, wire boa.mp3 as looping BGM
- PSP output thread now caches the last computed leftVolume/rightVolume and
  only recomputes them when stream->volume/directionality actually changed
  since the previous chunk, instead of recomputing every ~23ms chunk
  unconditionally.
- Mixer channels and the mixer itself now carry their own volume
  (audioMixerSetChannelVolume()/audioMixerSetMasterVolume()), multiplied
  with each sound's own volume every audioMixerUpdateEarly() and applied via
  audioStreamSetVolume() - so changing a channel's or the master volume
  affects whatever's already playing, not just future sounds. Also fixed
  audioMixerUpdateEarly() to tolerate a PLAY command queued outside the
  normal per-frame cycle (no loadingAsset yet from a preceding
  audioMixerUpdateLate(), e.g. during engine startup) by leaving it queued
  for the following frame instead of asserting.
- engine.c now plays boa.mp3 on AUDIO_MIXER_CHANNEL_BGM_0 on loop through
  the mixer instead of driving a raw audiostream_t directly, exercising the
  new mixer pipeline end to end.

Built and verified on Linux, PSP (Docker) and GameCube (Docker).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 15:10:09 -05:00
YourWishesandClaude Sonnet 5 d49594d3fb Redesign audio mixer around a two-phase command queue instead of synchronous main-thread calls
audioMixerPlay()/audioMixerPlayLooped()/audioMixerStop() now just queue a
command per channel instead of taking effect immediately:
- audioMixerUpdateLate() (end of frame) locks and begins loading any newly
  queued PLAY command's asset, giving it until the start of the next frame
  to finish.
- audioMixerUpdateEarly() (start of the next frame) applies the queued
  command - blocking (assetRequireLoaded()) if that load hasn't finished
  yet - actually starting/stopping playback and setting volume,
  directionality and looping (reusing this session's new
  audioStreamSetLoopLimit()) on a real audiostream_t acquired from the
  shared stream pool.

Previously this was an unwired stub (never initialized/updated/disposed
from audio.c, and audioMixerUpdateLate() only printf'd). Built and verified
on Linux, PSP (Docker) and GameCube (Docker).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 14:48:14 -05:00
YourWishesandClaude Sonnet 5 619ce932f9 Split audio update into early/late phases, add asset<->stream type map, switch volume/pan to float, add stream loop limit
- audioUpdate()/audioMixerUpdate() split into Early (before rendering, per-stream buffering) and Late (end of frame, starts newly-queued mixer sounds) phases, wired into engineUpdate() around displayUpdate(); mixer is now actually initialized/disposed/updated from audio.c.
- New src/dusk/audio/stream/audiostreamtype.{h,c}: asset-loader-type <-> audio-stream-type map (with file extension), plus audioStreamAssetTypeForPath()/audioStreamTypeForAssetType()/audioStreamAssetTypeForStreamType() lookups.
- audiostream_t/audiomixerchanneldata_t volume and directionality/pan switched from integer (0-0xFF / -128..127) to float (0.0-1.0 / -1.0..1.0), removing the manual scaling each platform backend had to do.
- audiostream_t gains loopLimit/loopRestartCount so a stream can be told to stop (fire onEnd) after a fixed number of loop restarts instead of looping forever; wired into the shared restart path (Linux/Dolphin) and PSP's own read-ahead loop decision.

Built and verified on Linux, PSP (Docker) and GameCube (Docker).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 14:39:27 -05:00
YourWishesandClaude Sonnet 5 b564d0b1c1 Add Dolphin MP3 decode-thread ring, share it with Linux, dedupe common code
Fixes the main-thread hitch reported on Dolphin (and, less severely,
elsewhere) whenever the MP3 decode buffer needed refilling:
audioStreamMp3Read() -> audioStreamMp3DecoderDecodeFrame() does real work
synchronously (asset I/O + libmad decode), previously called directly from
each platform's once-per-frame Feed(), blocking rendering for a frame on
every refill.

Adds a background decode-ahead ring (src/duskmad/audiostreammp3ring.c):
a one-shot-per-pass thread reads via the existing audioStreamRead() into a
ring buffer ahead of need; Feed() becomes a cheap, lock-protected drain
instead of a decode call. Reuses this project's own thread_t/threadmutex_t
(mutex+condvar) primitives, mirroring PSP's own background-thread-plus-ring
precedent. Fixes a real (if narrow) race in thread.c along the way:
threadHandler() reset thread->threadId outside the mutex it also used to
signal STOPPED, which a stop-then-immediately-restart pattern (needed once
per pass: play/seek/loop-restart) could hit as a stale-threadId assertion.

Initially built Dolphin-only, then generalized: the ring doesn't touch
ASND at all, so it was straightforward to share with Linux too, moving
both it and the libmad decoder into a new top-level src/duskmad/ - a
shared-capability directory in the same vein as src/duskgl or
src/dusknetwork, pulled in by whichever DUSK_TARGET_SYSTEM values need it
(linux/knulli, wii/gamecube) rather than PSP, which keeps its own
hardware sceMp3 decoder and ring untouched.

With both platforms now sharing real code, deduped what was left:
audioStreamComputeEndFrame() (loop-segment math, byte-identical between
Linux/Dolphin) moved to the generic audio/stream/audiostream.c, and
audioStreamMp3Ring*IfNeeded()/audioStreamReadForPlayback() wrappers (in
duskmad) collapse each platform's own `if(stream->type ==
AUDIO_STREAM_TYPE_MP3)` branches at every Init/Dispose/Buffer/Feed call
site into one check each, living in the ring module itself.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 14:10:36 -05:00
YourWishesandClaude Sonnet 5 fc1fc9f170 Rewrite Dolphin audio to use libogc's standard ASND API
Replaces the ansnd-based backend (a third-party library with a custom
DSP ucode Dolphin's HLE audio emulation doesn't recognize, requiring
-a LLE as a workaround) with libogc's own asndlib - the standard,
HLE-supported audio API most GC/Wii homebrew already uses.

Also fixes three real bugs found while getting a large (41MB) WAV
asset actually playing on Dolphin:

- audiostreamdolphin.c previously buffered a stream's entire loop
  segment into one allocation up front (inherited from the ansnd
  design) - fine for a short test tone, but an out-of-memory crash for
  a real multi-minute track on GameCube's 24MB (or even Wii's 88MB)
  total RAM. Rewritten to stream bounded windows via ASND's real
  double-buffer primitives (ASND_SetVoice + ASND_AddVoice, polled from
  the main thread - never from an interrupt callback), matching how
  Linux/PSP already work.

- ASND_TestVoiceBufferReady() returns SND_OK (0) when ready and
  SND_BUSY (1) when not, in the actual toolchain fork
  (extremscorner/libogc2) this project builds against - opposite of a
  plain boolean. Treating the raw result as one meant a genuinely
  ready voice's SND_OK read as false, silently skipping every
  ASND_AddVoice() call forever after a stream's first buffered window.

- audioStreamPcmRead()'s 16-bit fast path read WAV's little-endian
  sample bytes straight into the output buffer with no byte-swap -
  correct by coincidence on every little-endian platform this engine
  targets (Linux, PSP), but reversed every sample's bytes on
  Dolphin/GameCube/Wii's big-endian PowerPC.

libmad is GPL-licensed (see audiostreammp3decodersw.h's own note from
the prior MP3 change); libogc's asndlib is under a permissive
BSD-style license, so this doesn't add another such dependency.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 12:49:53 -05:00
YourWishesandClaude Sonnet 5 fd5bcfae90 Switch Linux/Dolphin MP3 decode from minimp3 to libmad
minimp3's sliding-window frame confirmation could spuriously discard real
frames near the tail of a not-yet-full window, losing ~5.5% of a real
VBR file's content even at a tuned 256KB window (~0.3% residual) -
audible as the whole track finishing early with stutters at each drop.

libmad's mad_stream/mad_frame/mad_synth API reports "need more data"
(MAD_ERROR_BUFLEN) and "genuinely bad data" separately rather than
overloading one return value, which was the actual ambiguity minimp3
couldn't resolve. Verified against the same real file with a 32KB window
(vs minimp3's 256KB): frame count and duration match the Xing header
exactly. Confirmed building for Linux, GameCube, and Wii (the latter two
via the project's real devkitPPC/libogc Docker toolchain) - libmad ships
inside libogc itself on Dolphin, no separate fetch needed there.

libmad is GPL-licensed, unlike the rest of this MIT project - a
deliberate tradeoff, noted at each site that pulls it in.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 09:47:15 -05:00
YourWishesandClaude Sonnet 5 3ee0a53688 Split audio into stream/ and mixer/ subdirs, fix resulting build breaks
Reorganizes src/dusk/audio/: individual stream implementations
(audiostream, audiostreampcm, audiostreammp3, audiostreammp3decodersw)
move into audio/stream/, and a new audio/mixer/ holds a channel-based
playback queue (audiomixer.c/.h) for future use - not yet wired into
the engine.

Fixes needed to keep the tree buildable after the move:
 - A stray duplicate of audiostreammp3decodersw.c/.h was left at the old
   flat path; removed in favor of the canonical copy in stream/.
 - Updated every #include "audio/audiostream*.h" and the two platform
   CMakeLists.txt (dusklinux, duskdolphin) that still pointed at the old
   flat location.
 - audiomixer.c/.h didn't compile: audiomixerqueue_t was referenced but
   never defined (audiomixerchanneldata_t has the matching fields), a
   trailing comma in audioMixerPlayLooped's parameter list is illegal in
   C, and `file` was declared as an array of pointers instead of a char
   buffer.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 09:32:41 -05:00
YourWishesandClaude Sonnet 5 62c081ecf2 Fix minimp3 sliding-window decoder silently dropping frames near tail
audioStreamMp3DecoderDecodeFrame was treating minimp3's "can't confirm
a frame here" verdict as genuine garbage even when the window simply
hadn't been topped up yet - discarding real frames whenever one landed
near the tail of a not-yet-full window, since minimp3 needs to also
validate the *next* frame's header to confirm a decode. On a real ~236s
VBR file this silently dropped ~5.5% of frames, heard as the stream
finishing early ("racing") with a stutter at each drop.

Now refills before ever trusting a "not found" verdict as confirmed
garbage, and grows the window from 16KB to 256KB (past the point of
diminishing returns, ~0.3% residual loss). PSP is unaffected - it uses
the hardware sceMp3 decoder, not this file.

Also removes now-unneeded debug instrumentation from the Linux feed
path.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-01 09:43:19 -05:00
YourWishesandClaude Sonnet 5 d9beb647c1 Fix PSP MP3 TopUp trusting requested frame count over actual on short reads
audioStreamPSPTopUp() advanced readPosition/ringFilled/framesEnqueued by
the requested framesToRead regardless of how many frames a short read
actually produced - a leftover assumption from the WAV/PCM design, where
a short read only ever means "truly corrupt file, at the real end."
That doesn't hold for MP3: a hardware decoder backend can plausibly
report "nothing ready this instant" without that meaning no more content
exists. Every such short read silently inflated readPosition ahead of
real decode progress, triggering the loop-segment-end check far too
early - restarting the pass again and again well short of the real
runtime, heard as the clip racing through its own content.

Now only ever advances by the actual frames produced (matching
dusklinux's audioStreamLinuxFeed(), which already did this correctly),
and bases the loop/end decision on real position instead of the
originally-requested read size.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-01 09:16:42 -05:00
YourWishesandClaude Sonnet 5 f8f8a80a21 Add MP3 audio stream support with hardware/software decoder backends
New ASSET_LOADER_TYPE_MP3 (hand-rolled MPEG-1/2/2.5 Layer III header
parser - no third-party dependency needed just for metadata, since PSP's
hardware path doesn't need one at all) plus a shared audiostreammp3.c
stream layer mirroring audiostreampcm.c's shape. Generalized the stream
dispatch (hoisted sampleRate/channels onto audiostream_t, added
audioStreamGetTotalFrames()/Seek()/Read()) so all three platform audio
backends keep working unchanged, just calling the generic names instead
of PCM-specific ones.

Two decoder backends behind one interface: PSP uses the real sceMp3
hardware decoder (firmware-offloaded, lazily initialized on first use);
Linux and Dolphin share one minimp3-based software decoder (public
domain, vendored via CMake FetchContent) - libogc's own MP3Player wraps
libmad (GPL) and drives its own output pipeline, not a fit for the
ansnd-based architecture already in place, so skipped in favor of the
shared minimp3 path.

Fixed three real bugs found via hardware/runtime testing along the way:
- LAME's Xing header counts its own placeholder frame in the declared
  total, which made playback stall permanently one frame short of the
  declared end (looked like "never loops") - fixed by subtracting it.
- sceMp3Decode() can return more PCM than one MPEG frame's worth in a
  single call (PSP's pcmBuf is provisioned for 2x), overflowing the
  shared per-frame decode buffer with no bound check - very intermittent
  corruption/clicking on real hardware. Widened the buffer to the real
  worst case and added an assertion.
- sceMp3ResetPlayPosition()'s exact internal reset semantics aren't
  documented precisely enough to trust for looping - occasionally
  disagreed with the fresh stream position fed right after, clicking at
  the loop boundary about 1 in 3-4 loops. Rewind now fully tears down and
  recreates the decoder instead, the same path already proven correct at
  first Init. Also widened the PSP ring buffer to absorb that now-heavier
  operation, capping each top-up call's own work so the bigger buffer
  doesn't turn into one long blocking decode burst instead.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-01 08:21:30 -05:00
YourWishesandClaude Sonnet 5 ac8023d50f Rework PSP audio into a single output thread + main-thread top-up
The reader+player two-thread design (previous commit) still crackled -
both threads shared the same elevated real-time priority and could
contend for the PSP's single core right at the moment the player thread
needed to resume after its blocking output call returned, worse the
bigger the hardware chunk. Confirmed fixed on real hardware (Memory Stick
and pspsh) by removing the second real-time thread entirely.

PCM data now lives in a ring buffer topped up from the MAIN thread once
per engine Update(), mirroring dusklinux's own already-working
audioStreamLinuxFeed()/IsFinished() pattern (same lead/window sizing)
instead of a bespoke second thread. The sole remaining PSP-specific
thread only drains the ring and calls sceAudioOutputPannedBlocking(),
never touching the asset/PCM layer. Loop wraps are now just a transparent
seek-and-continue while filling the ring (it holds one seamless sample
stream, no per-chunk splicing needed) - a small FIFO of loop markers is
the only thing still needed to fire onLoop at the correct audible moment.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-31 20:09:15 -05:00
YourWishesandClaude Sonnet 5 769f2f5702 Split PSP audio feeder into reader + player threads to fix crackle
sceAudioOutputPannedBlocking() occupies the calling thread for the full
duration of the chunk it just submitted. That was fine while PCM chunks
came out of a fully-resident buffer (a near-instant memcpy), but now that
they're read from the asset on demand, that same read happens in between
output calls on the same thread - any read slower than a memcpy opens a
real gap in the hardware channel, heard as crackle.

Split the single feeder thread in two: a reader thread that does all PCM
I/O (seek/read, loop-wrap, fade prep) ahead of playback into a small
3-slot queue, and a player thread that only pulls ready chunks off the
queue and outputs them. This overlaps I/O with hardware playback instead
of serializing them.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-31 19:38:50 -05:00
YourWishesandClaude Sonnet 5 2f6839bcc4 Serialize all libzip access behind one mutex - fixes PSP read corruption
Reverting the whole-PSAR buffer (previous commit) reintroduced real read
corruption on hardware (EINVAL, then zlib data errors) - but this time it
hit an unrelated asset (chunks/1_0_0.dcf) at the same moment as the WAV
load, which pointed at concurrency rather than the seek pattern itself:
the asset system genuinely calls libzip from three real threads at once
(main, the background asset load thread, and PSP's own audio feeder
thread), and libzip is documented as not thread-safe. Buffering the whole
PSAR "worked" only by accident, since it stopped touching sceIo after
init entirely. Adding ASSET.zipLock around every zip_fopen/zip_fread/
zip_fclose/zip_fseek/zip_stat/zip_name_locate call serializes hardware
I/O properly without needing the whole archive resident in memory.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-31 19:17:16 -05:00
YourWishes d37109f5f7 Fixed PSP trying to load entirely into memory 2026-08-31 19:07:51 -05:00
YourWishes 15bd9fc43c Clean up audio subsystem duplication, implement seeking and loop regions
- Extract audioStreamGetPanFactors() into the shared layer, replacing
  identical pan-to-LR math duplicated in the PSP and Dolphin backends
  (and dropping a dead clamp - directionality's int8_t range already
  guarantees pan stays in [-1, 1]).
- Implement audioStreamSetPosition() for real (was previously a no-op
  that computed a value and threw it away) and add
  audioStreamSetLoopPoints() to actually drive loopStart/loopTo, which
  were previously dead fields with no setter at all. Both are threaded
  through all three platform backends:
  - PSP: the feeder thread now bounds each pass by the loop segment
    and wraps to loopTo instead of always frame 0, while still filling
    hardware chunks gaplessly.
  - Dolphin: loopTo/loopStart map directly onto ansnd's existing
    loop_start_offset/loop_end_offset, and startFrame onto start_offset.
  - Linux: Buffer() now queues only the current segment, clearing the
    SDL queue on an explicit seek but preserving the existing
    overlap-based gapless loop restart otherwise.
- Linux: skip the mix scratch-buffer entirely at full volume, queuing
  stream->data directly instead of allocating/zeroing/copying into one
  every buffer call for no reason.

Verified no regression in the default (no seek, no loop points) case on
Linux/PPSSPP/Dolphin (-a LLE), and verified seek + loop-region behavior
manually via a temporary engine.c smoke-test tweak (reverted) showing
the expected faster loop cadence and seek-then-loop sequencing.
2026-08-31 17:18:49 -05:00
YourWishes 8049e90853 Fix Dolphin ansnd audio failing to configure on GameCube/Wii
ansnd_configure_pcm_voice requires frame_data_ptr to be a physical
address - it rejects cached virtual pointers (0x8xxxxxxx) as
ANSND_ERROR_INVALID_MEMORY. Convert via MEM_VIRTUAL_TO_PHYSICAL and flush
the CPU cache first so the DSP's DMA read sees what was actually written.

Root-caused by disassembling libansnd's ansnd_configure_pcm_voice (no
source available, only the static lib) and confirming via a temporary
debug print that frame_data_ptr's top bit being set was what tripped the
check. Confirmed fixed by running the Wii DOL build in Dolphin under -a
LLE - voice configure/start now succeed with no errors.
2026-08-31 15:43:29 -05:00
YourWishes e47a2c3e5c Redesign dusk.dsk into a dual-archive DSK2 format
Splits the asset archive into a compressed (DEFLATE) zip and an
uncompressed (STORED) zip back to back behind a small header, instead of
one plain zip. DEFLATE-compressed zip entries aren't reliably seekable in
libzip, which caused locale string lookups (repeated rewind/reopen of the
same entry) to silently skip content on Dolphin specifically. Uncompressed
entries don't have that problem, so locale files now go in the stored
archive without needing to buffer the whole thing in memory.

Adds tools/asset/pack as the new packer (replacing the plain
`tar --format=zip`), a shared assetdsk.h/.c opener used by all platforms,
and a second ASSET.zipStored handle with fallback lookup in assetfile.c.

Confirmed working: Linux, PSP (PPSSPP), and Dolphin-FAT (Wii DOL under
-a LLE) - the original Dolphin locale lookup failure no longer reproduces.
2026-08-31 15:33:45 -05:00
YourWishes 9512c22e1f Fix loud PSP loop crackle + make Dolphin loop natively in hardware
Root cause of the crackle (confirmed on real PSP hardware): the
persistent feeder thread called stream->onLoop directly in the middle
of its tight chunk-feeding loop. onLoop can do arbitrary work (the
test callback does consolePrint, which locks a mutex, moves the
console history buffer, and fflushes stdout) - if that takes anywhere
close to one chunk's playback time (~23ms), the next chunk isn't
ready and the hardware channel starves. Audio callbacks must never be
invoked directly from a real-time audio thread.

Fixed generically: audiostream_t gains loopCount (incremented by
platform code from whatever context it runs in) and lastLoopCount
(main-thread-only bookkeeping). audioStreamUpdate() detects the
change and fires onLoop safely from the main thread, regardless of
which thread/interrupt actually noticed the loop. PSP's feeder thread
now increments the counter instead of calling onLoop inline.

Also eliminated the ~21.7ms of real silence padding baked into every
PSP loop pass (found while chasing the timing gap that preceded the
crackle fix): the final chunk's padding is now filled with the start
of the next loop instead of zero, avoiding sceAudioSetChannelDataLen
(the likely real cause of an earlier, separate click) while keeping
every output call the same constant size. Loop period measured via
PPSSPP is now ~1.000-1.002s for a 1.000s tone, down from a consistent
~1.02-1.03s before.

The PSP feeder thread is also now persistent for the stream's whole
lifetime (created once in Init, idles between plays) rather than
respawned via threadStartRequest on every single loop restart - real,
avoidable OS thread creation overhead that was contributing to the
gap before the padding was identified as the dominant cause.

Brought Dolphin in line architecturally rather than mirroring PSP/
Linux's restart-and-detect approach: ansnd_pcm_voice_config_t has
native loop_start_offset/loop_end_offset fields, so a looping Dolphin
voice loops entirely in DSP hardware with zero host involvement at
the loop boundary - no restart latency to create a gap in the first
place. Trade-off, clearly documented in code: onLoop never fires for
Dolphin this way (no ANSND_VOICE_STATE for "wrapped") and it requires
cleanly-authored loop content (no per-wrap fade like PSP's, matching
the same assumption). Compiles cleanly for both gamecube and wii;
not yet verified on real hardware.

Confirmed on real PSP hardware: no more gap, crackle fix pending
final hardware confirmation.
2026-08-31 13:02:51 -05:00
YourWishes 8a77001016 Fix audio loop gaps: same-tick re-buffer + Linux lead-margin refill
Two independent gap sources, both confirmed fixed on Linux:

1. audiostream.c's Update() used if/else-if, so a loop restart
   (clearing BUFFERED) couldn't re-trigger Buffer() until the *next*
   frame's Update() noticed - up to one frame of dead air on every
   loop, on every platform. Restructured to two sequential ifs so a
   loop falls straight through into re-buffering in the same call.

2. audioStreamLinuxIsFinished only reported true once SDL's queue was
   completely empty - meaning silence had already started by the time
   "empty" could be observed, guaranteeing a gap by construction.
   Changed it to report ready-to-refill once the queue drops to a
   4096-frame lead margin (matching the SDL device's own internal
   buffer size) instead of waiting for zero. SDL_QueueAudio only ever
   appends to a FIFO, so refilling that early never causes overlap -
   it just means the device's callback never runs dry.

Confirmed looping gaplessly on Linux.
2026-08-31 12:33:33 -05:00
YourWishes 9b0214ee55 Add audio stream looping (restart-from-start only)
audioStreamSetLooping() sets AUDIO_STREAM_STATE_LOOPING; when a
looping stream finishes, audioStreamUpdate() clears BUFFERED (not
PLAYING) and fires onLoop instead of onEnd, causing the next Update()
to naturally re-trigger the platform Buffer() call on the same
unmodified stream->data/dataSize - full restart from position 0,
with no platform-specific code needed. loopTo (looping to a point
other than the start) isn't honored yet - would need slicing the
buffer, flagged as a follow-up.

Also fixed the shared test tone's frequency (440Hz -> 441Hz): 44100Hz
doesn't divide evenly by 440Hz (100.23 samples/cycle), so the buffer's
last sample didn't exactly match its first - a small discontinuity at
every loop boundary independent of PSP's own click fixes. 441Hz
divides evenly into exactly 100 samples/cycle, closing that gap for
every platform, not just the ones with their own tail-handling.

Verified end-to-end on Linux (5 onLoop firings over ~9s for a 1s
tone, no errors) and confirmed compiling for PSP.
2026-08-31 12:20:50 -05:00
YourWishes 7ad735552a Fix PSP end-of-playback click: constant chunk size + trailing silence
The previous fix (shrinking the final chunk's declared length via
sceAudioSetChannelDataLen) still clicked on real hardware. Reverted
that in favor of two changes that don't rely on that call's
undocumented mid-stream behavior: every call now sends a full,
constant AUDIO_PSP_CHUNK_FRAMES buffer with the tail simply
zero-padded (channel length is never changed after the initial
reserve), and a couple of extra all-silence chunks are fed after the
real audio + fade so the channel keeps being actively driven at zero
for a moment rather than stopping outright, in case some of the click
was the channel/DAC settling rather than a pure sample-domain
discontinuity.

Confirmed fixed on real PSP hardware.
2026-08-31 12:16:03 -05:00
YourWishes ea35472ef8 Fix PSP end-of-playback click via tail fade + exact final chunk length
The feeder's final chunk zero-padded up to the full 1024-frame
reserved size (e.g. 956 padding samples for a 68-sample remainder),
jumping straight from whatever amplitude the waveform ended at down
to silence - an audible click. Fixed two ways: fade the last 32 real
frames linearly to zero before any padding begins (also covers the
case where total length is an exact multiple of the chunk size, where
the waveform would otherwise just stop abruptly with no padding at
all), and declare only the real sample count via
sceAudioSetChannelDataLen (64-aligned) for the final chunk instead of
padding out to the full reserved length, shrinking the leftover
padding to under 64 samples. Channel length gets reset to the full
chunk size at the start of each feed pass in case a previous
playback left it shortened.

Verified via PPSSPP: sceAudioSetChannelDataLen(7, 1024) then (7, 128)
for the tone's 68-sample remainder, onEnd still fires once.
2026-08-31 12:12:32 -05:00
YourWishes 50c5621d8a Fix PSP audio jitter: raise feeder thread priority above main thread
Root cause of the residual jitter (correlating with framerate, per
real-hardware testing): this project never overrides
PSP_MAIN_THREAD_PRIORITY, so the main/render thread runs at PSPSDK's
default of 32. A pthread created with default attributes (as
thread_t/threadStartRequest does) runs at priority 60 - numerically
higher, meaning LOWER scheduling priority on PSP's inverted scale.
Under load the feeder thread was structurally guaranteed to lose
scheduling contention to the main thread, starving the hardware
channel's double buffer and producing audible jitter that worsens
exactly when frames get slower - confirmed via disassembling
PSPSDK's precompiled pthread glue (pte_osThreadGetDefaultPriority
returns 60) rather than guessing.

audioStreamPSPThreadFeed now self-prioritizes via
sceKernelChangeThreadPriority(sceKernelGetThreadId(), 18) as the
first thing it does, matching the elevated-priority pattern PSP SDK
samples use for their own timing-sensitive auxiliary threads.
2026-08-31 11:14:20 -05:00
YourWishes da275cfd52 Fix crackling PSP audio: feed hardware in small chunks from a thread
The original implementation reserved a channel sized to the whole
buffer (~44160 samples) and sent it in one sceAudioOutputPannedBlocking
call. Confirmed against the PSP SDK's own samples (pspaudiolib,
the mp3 sample) that this deviates from how PSP audio hardware is
actually meant to be driven: small fixed-size chunks (1024 frames,
matching pspaudiolib's own convention) fed continuously, decoupled
from the render loop. Reproduced as crackling on real hardware.

Now reserves a 1024-frame channel and runs a dedicated thread (the
project's existing thread_t abstraction, already used by asset.c and
already working on PSP via DUSK_THREAD_PTHREAD) that streams the
buffer chunk-by-chunk until exhausted, re-reading volume/pan every
chunk so live changes take effect mid-playback. Finish detection
switched from polling sceAudioGetChannelRestLength (which can't tell
"between chunks" from "actually done" once chunked) to a flag the
feeder thread sets on completion, same shape as the Dolphin voice
callback.

Verified via the dusk-psp toolchain and PPSSPP headless (channel now
reserved at 1024 frames instead of 44160, onEnd still fires once).
Real-hardware crackle-free confirmation pending.
2026-08-31 11:03:30 -05:00
YourWishes 0f4ee5d965 Implement GameCube/Wii audio playback via libansnd
Allocates an ansnd voice per stream and configures/starts it in
single-buffer mode (no continuous re-feed needed yet, matching the
PSP/Linux single-shot approach). Real linear panning from
directionality via left/right voice volume. ansnd has no polling API
for voice state, so finish detection uses a voice_callback (fired
from ansnd's own audio DMA interrupt, not the main thread) that sets
a flag audioStreamDolphinIsFinished() reads.

Links libansnd (new for this project) and wires duskdolphin/audio
into the CMake build for the first time.

Verified via the dusk-dolphin toolchain: both GameCube and Wii
targets compile and link cleanly. Runtime verification in headless
Dolphin was inconclusive - confirmed via an A/B test against a
pre-audio baseline build that a pre-existing MMIO flood (unrelated
to this change, reproduces identically with zero audio code present)
keeps the harness from reaching the game's own steady state before
timing out.
2026-08-31 10:53:22 -05:00
YourWishes af8c0f5ccd Implement PSP audio playback via native sceAudio
Reserves a hardware channel per stream (64-aligned sample count,
mono/stereo format) and outputs through sceAudioOutputPannedBlocking,
giving PSP real stereo panning from directionality. Enforces the
44100Hz hardware-channel constraint with a clear error instead of
silently mispitching, and drops the shared test tone to 44100Hz to
match. Verified via the dusk-psp toolchain and a headless PPSSPP run
(correct channel reservation, onEnd firing once, no errors).
2026-08-31 10:36:51 -05:00
YourWishes 8ea370a1d1 Add audio subsystem skeleton with a working Linux PCM playback path
Cross-platform audiostream/audio API with per-platform hooks for
PSP/Dolphin/Linux. Linux is fully wired end-to-end through SDL2
(device open, volume-mixed queueing, finish detection via
SDL_GetQueuedAudioSize) and verified playing a generated test tone
in engine.c. PSP and Dolphin hooks are stubbed for now.
2026-08-31 10:24:02 -05:00
YourWishes fcf0de72af Fixed a bunch of code inconsistencies 2026-08-31 07:02:19 -05:00
YourWishesandClaude Sonnet 5 320c5e6ce5 Cache locale string lookups instead of re-scanning the PO file every call
assetLocaleGetString rewinds and linearly scans/re-decompresses the
whole locale file from byte 0 on every single call, with no caching -
a text-heavy screen can easily make 10+ of these in a row (e.g. opening
the game menu), and on PSP the containing archive is already fully
resident in RAM, so the repeated cost is pure CPU (decompression +
scanning), not I/O.

Adds a fixed 128-entry move-to-front LRU cache keyed by
(messageId, pluralCount), capped at 64/256 bytes per key/value (~40KB
total) so the cost stays bounded no matter how large the game's script
ends up being, rather than caching the whole locale file's text.

The cache is a lazily-allocated pointer on assetlocalefile_t, not
embedded inline - that struct lives inside the assetloaderoutput_t
union shared by every asset type, and all ASSET_ENTRY_COUNT_MAX asset
slots carry that union directly, so embedding it would have sized every
slot up by ~40KB regardless of what asset type occupies it.

Also fixes a bug this surfaced in test_assetlocale.c's own fixture:
locale_teardown zeroed the locale struct directly instead of going
through assetLocaleDispose, which would have leaked the new cache
allocation across tests.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-30 16:16:52 -05:00
YourWishesandClaude Sonnet 5 7a858cc424 Add a real test suite for the save system (previously had none)
Covers save.c (device discovery/orchestration), savedevice.c (the
generic device state machine and platform dispatch), and the Linux
platform backend (path building, availability checks, JSON read/write,
corrupt/missing-file handling), plus saveslot.c/savesettings.c JSON
round-trips. 88 tests across 5 files, all run against the real Linux
filesystem backend sandboxed to a temp $HOME (there's no mockable
platform layer - the hooks are compile-time macros, not function
pointers).

Deliberately locks in two existing behaviors rather than working around
them: saveSaveSettings() is a permanent no-op because nothing anywhere
ever sets SAVE.settingsDirty = true, and saveUpdate() unconditionally
rewrites settings back out the moment a device is found regardless of
that same dirty flag. Both are pre-existing, not introduced here.

Does not cover the SAVE_DEVICE_DATA_RAW blob codec (PSP/GameCube/Wii
only) or GameCube's 2-device fallback chain - neither compiles into the
Linux host test build.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-30 12:33:20 -05:00
YourWishesandClaude Sonnet 5 c0292842a5 Fix stale include re-enabling test/item, fix dead sort-by-type coverage
test/item/test_inventory.c was disabled via a commented-out
add_subdirectory(item) with no explanation - the actual cause was a stale
"item/inventory.h" include left over from before the rpg/ reorg (real path
is rpg/item/inventory.h). The current inventory.h/.c API it tests hasn't
drifted; only the include path had rotted.

Also fixes a copy-paste bug in test_inventorySort: the "sort by type"
assertions were calling INVENTORY_SORT_BY_ID again instead of
INVENTORY_SORT_BY_TYPE, so inventorySortByType/Reverse had zero real
coverage. Asserts on type grouping only (not the tied FOOD-vs-FOOD order),
since the underlying sort() is qsort and not stable.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-30 12:33:00 -05:00
YourWishesandClaude Sonnet 5 717902462b Fix fatal async asset load errors, remove unused event system
An async asset load failure crashed the whole game via errorThrow,
while the identical sync failure just logged and continued - a single
missing/corrupted asset could take down the process. assetUpdate now
handles the async error path the same way as sync (invoke onError,
keep running).

Also removes event.h/event.c and assetbatch, which existed only to
support multiple subscribers per asset event but had no real caller
that ever used more than one (assetbatch itself had zero callers
anywhere). Asset entries, uifullbox, and uiloading now use plain
single-callback + user-pointer fields instead of the generic
array-backed event_t.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-30 10:14:38 -05:00
YourWishesandClaude Sonnet 5 1d73b9d224 Fix PSP asset loading: stale EBOOT.PBP packing, unreliable zip file reads
EBOOT.PBP packing was a POST_BUILD step with no dependency on the asset
pak, so an assets-only rebuild could silently leave a stale dusk.dsk
embedded. cmake/targets/psp.cmake now repacks EBOOT.PBP via a properly
tracked custom command depending on the executable, PARAM.SFO, and
dusk.dsk (and correctly embeds Dusk.prx rather than the raw ELF when
BUILD_PRX is on).

Separately, libzip's zip_source_filep_create (lazy seeked FILE* reads)
proved unreliable on real PSP hardware, corrupting reads of the embedded
PSAR (first EINVAL, then zlib data errors) even though the packaged data
was verified byte-perfect. assetInitPBP now reads the whole PSAR into
memory once and uses zip_source_buffer_create instead. Confirmed working
on real hardware.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-28 09:31:44 -05:00
YourWishes 51f262efa0 Wire keyboard into save naming, add fatal error overlay, cutscene keyboard item
- uimainmenu: removed the temporary keyboard test entry now that the
  keyboard has a real caller.
- uiselectsave: picking an empty save slot now opens the keyboard to
  name it (localized title "Enter game save file name"), writes the
  named slot to disk (mirroring the existing delete-confirm write
  pattern), and on a write failure opens the new fatal error overlay.
- uikeyboardopen_t gains a title override (NULL keeps the default
  "ENTER YOUR TEXT").
- New ui/overlay/uifatalerror: a minimal fullscreen, non-dismissible
  overlay (message + QUIT button, back disabled) for unrecoverable
  errors - a nicer alternative to a raw assert crash. Draws above
  everything else in the UI element list.
- New CUTSCENE_ITEM_TYPE_KEYBOARD cutscene item: opens the keyboard
  with an embedded uikeyboardopen_t, blocks the cutscene until it
  closes, then caches the entered text via a new general-purpose
  cutscenesystem_t.textCache (CUTSCENE_TEXT_CACHE_MAX, with get/set
  accessors) mirroring the existing entity/area/text-mini id caching
  pattern.
2026-08-28 08:29:07 -05:00
YourWishes a6e4e3f71f Add cancel confirmation, blinking cursor, custom keyboard icon glyphs
- uikeyboardopen_t gains cancelConfirm/cancelConfirmLabel, mirroring
  confirm/confirmLabel: cancelling (CANCEL button or back-with-nothing-
  left-to-delete) can now open an "are you sure?" dialog before closing.
- Added a blinking text-entry cursor, rendered as a separate label
  positioned at the end of the current text (accounting for newlines);
  it now stops at the last character rather than past it once maxLength
  is reached, matching uiKeyboardAppendChar's override-last-char
  behavior there.
- uibutton_t gains an `active` flag, independent of the focus-driven
  `highlighted` state, for a toggled-on indicator that survives focus
  moving elsewhere (used by the keyboard's CAPS/SHIFT keys).
- Added several custom icon glyphs to the default font by repurposing
  otherwise-blank/unused glyph slots (backtick, pipe, tilde, backslash,
  and the DEL trailing tile) - up arrows for Caps Lock/Shift, a return
  arrow for newline, a spacebar symbol, a left arrow for backspace, and
  a real underscore - replacing the keyboard's old text-abbreviation key
  labels ("CL"/"SL"/"NL"/"<") with single-glyph icons.
2026-08-27 22:17:00 -05:00
YourWishes 4387d223b9 Fullscreen keyboard dialog, nearest-cell vertical navigation, overwrite-at-max-length
- Keyboard dialog now fills the whole screen (no dimming backdrop); key
  cells use a fixed font-tile-based size instead of scaling to a
  percentage of the screen.
- uiMenuFocusSkipBlanks: UP/DOWN now search every non-blank cell strictly
  further in the pressed direction and land on the smallest combined
  row+column distance (ties favor the smaller column distance), instead
  of only scanning straight down the same column. Neither axis wraps
  during that search - only once the edge row is reached with nothing
  further to search does it fall back to wrapping to the opposite edge.
- Typing a new character while the keyboard's text is already at
  maxLength now overrides the last character instead of being ignored.
2026-08-27 21:14:19 -05:00
YourWishesandClaude Sonnet 5 00bfaf6360 Fix cutscene/focus bugs found on PSP hardware, rework keyboard layout
Cutscene loader: a MODAL item's optionCount byte was read directly
inside an assertTrue() condition. On the PSP release build
(DUSK_ASSERTIONS_FAKED), assertTrue expands to a no-op and never
evaluates its argument, so that read - and the offset advance it was
responsible for - silently never happened, desyncing every item after
it in the stream. Moved the read out to its own statement and replaced
the assert with a real error throw, since this is untrusted file
content.

uifocus: fixed a wraparound bug in uiFocusMoveDirection - moving left/up
from position 0 truncated to uint8_t before the modulo wrap (0-1 => 255,
then 255 % cols), landing on the wrong column instead of the row's last
one. Only ever visible on a grid wider than a handful of columns, which
nothing but the new keyboard has.

Keyboard: merged uikeyboardqwerty into uikeyboard (only one layout
exists, so the split no longer earned its keep) and deleted the unused
numbers/symbols placeholder files. Reshaped the grid to a tighter 11x5
layout with per-mode key tables (unshifted/caps/shift) instead of
computing case transforms, and fixed the reserved NEWLINE/CONFIRM/CANCEL
slot indices to match. Added shift-symbols for digits and -/=, and
uimenu's directional navigation now skips blank filler cells (wrapping
around a row/column as if they weren't there) instead of landing on
them - a generic fix in uimenu.c, not keyboard-specific, since nothing
else uses blank cells.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-27 19:26:07 -05:00
YourWishesandClaude Sonnet 5 709bd7be52 Add validation options and a full physical layout to the keyboard
uikeyboardopen_t grows: maxLength (auto-focuses confirm once full),
lineCount for multi-line entry with a NEWLINE key, trimmed/allowBlank
validation on confirm, and a second "are you sure" uiConfirm step.

Reworks uikeyboardqwerty from a packed flat list into a fixed 14x5 grid
shaped like a real keyboard - number row with -, =, ~ and backspace,
caps and shift (one-shot, XORed with caps for real-keyboard behavior),
and a bottom row with cancel/space/confirm in their normal corners.
Shorter rows are padded with invisible filler cells so every key lands
in its physical position; NEWLINE/CONFIRM/CANCEL are reserved slots
uikeyboard.c patches in conditionally, otherwise left as inert blanks.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 18:44:07 -05:00
YourWishesandClaude Sonnet 5 80f4348e21 Implement on-screen keyboard dialog with QWERTY layout
Builds out uikeyboard/uikeyboardqwerty from placeholders into a working
controller-navigable text entry dialog: title/current-text labels, a flat
QWERTY key grid (letters, space, backspace), and confirm/cancel buttons in
one combined menu so d-pad navigation flows across all of it.

uikeyboardopen_t configures each dialog: onInput/onKeyPress callbacks,
optional cancel button, maxLength (auto-focuses confirm once full),
lineCount for multi-line entry (adds a NEWLINE key, reserves vertical
space up front), a second "are you sure" uiConfirm step, and
trimmed/allowBlank validation on confirm.

Back now deletes the last character first, falling through to close only
when cancel is allowed and the buffer is empty - this needed a general
cancel-intercept callback added to uifocusitem_t/uimenu_t
(uiMenuSetCancelCallback), since the focus system previously only
supported popping or fully swallowing back.

Wired a temporary "LOAD GAME (TEST)" entry into the main menu with
console-logging test callbacks for interactive testing; marked TODO for
removal once the keyboard is wired up somewhere real.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 15:26:57 -05:00
YourWishesandClaude Sonnet 5 e630827b34 Add runtime-loaded cutscene files, convert initial/main menu to use them
New ASSET_LOADER_TYPE_CUTSCENE (assetcutsceneloader.c) reads a versioned
binary .cts format decoded into a heap-allocated cutsceneitem_t array + a
string/data pool, both sized to the file's actual content rather than a
fixed capacity, so the shared assetloaderoutput_t union doesn't bloat for
every asset slot regardless of type. Authoring pipeline mirrors the chunk
asset pattern: assetsraw/cutscenes/*.jsonc (JSON plus // and /* */
comments) -> tools/asset/cutscene -> assets/cutscenes/*.cts.

cutsceneSystemSetOnComplete() lets the caller arm a native callback that
fires when a cutscene finishes normally, so a file (which can't store a
function pointer) can end plainly and still hand off to native code -
cutsceneRestart() preserves it across a retry loop rather than clearing it,
since a restart is the same logical run trying again.

The initial and main-menu start-game cutscenes are now loaded from files
instead of compiled in via the CUTSCENE(...) macro.

Fixed a real bug found while converting these: the sync loader read the
file's total size from assetfile_t.size to locate the trailing pool
region, but assetFileDispose() (called at the end of the async phase)
zeroes that whole struct first, so the size was always 0 and the pool
offset computation underflowed into an out-of-bounds read - intermittent
depending on heap layout. Fixed by saving the size before disposal; also
fixed the read-completeness assert being checked after that same zeroing
(a no-op 0 == 0 check).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-25 09:51:26 -05:00
YourWishesandClaude Sonnet 5 106d9b0fc0 Move main menu start-game cutscene from ui panel to scene
The load-all-slots/no-device cutscene and its helper callbacks belong to
scene-level game flow, not the uimainmenu UI panel. Moved them into
scenemainmenu.c/h behind a new sceneMainMenuStartGame() entry point;
uimainmenu.c's Start Game handler now just calls that.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-24 20:30:33 -05:00
YourWishesandClaude Sonnet 5 4a26f79945 Add uimenu disableBack flag, apply to main menu and all modal options
uifocusitem_t/uimenu_t gain a disableBack flag that makes back/cancel a
no-op while that item is focused, set via uiMenuSetDisableBack. Applied
centrally in uimodal.c so every modal with option buttons (confirm
dialogs, cutscene modals) must be dismissed by picking an option rather
than backing out, and to the main menu so it no longer bounces back to
the initial scene on cancel - replacing the suppressClosedSceneChange
hack entirely. Updated docs that described the now-unreachable
back/cancel dismissal path. Also adds
cutsceneSystemStartCutsceneAndGoToMarker for starting a cutscene
straight at a given marker instead of its first item.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-23 22:11:27 -05:00
YourWishesandClaude Sonnet 5 1a199f6ce3 Add declarative cutscene items for save checks and marker-based modals
CUTSCENE_SAVE_DEVICE_CHECK and CUTSCENE_SAVE_LOAD_ALL_SLOTS wrap the
existing device-lookup/slot-load calls and jump straight to a
success/failure marker, and CUTSCENE_MODAL_OPTIONS_ONE/TWO do the same
for one/two-option modals, removing the need for a hand-written
goTo-only callback per use. Main menu's "Start Game" now runs a
cutscene (mirroring the initial scene's save-check flow) that loads all
slots before opening the load-game picker, retrying on error; the
initial scene's own device check is migrated onto the same items.
Backing out of the main menu now returns to the initial scene.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-23 20:29:57 -05:00
YourWishes ec59e77867 Moved files around 2026-08-23 10:14:39 -05:00
YourWishesandClaude Sonnet 5 cb28d2b611 Add save slot picker (uiselectsave/uisaveslot), fix focus stack reentrancy
uiselectsave is a Save/Load/Delete slot picker built on save/save.h's
SAVE_SLOT_COUNT: a scrolling list of framed uisaveslot_t rows (slot
number + file name, or an "Empty Slot" placeholder), navigated via the
same uimenu_t/focus-stack convention as every other menu. Load and
Delete modes add a trailing action row (switch to delete / cancel)
fixed below the scrollable area. Selecting a slot in Delete mode routes
through uiConfirmOpen before actually blanking the slot via
saveSlotInit + saveDeviceSlotWrite. uiScrollingEnsureVisible in
uiscrolling.c is now a real implementation instead of the earlier
placeholder.

Main menu collapses New Game/Load Game into a single Start Game entry
that opens uiselectsave in Load mode; backing out reopens the main
menu via uiselectsave's result callback.

Also fixes a real reentrancy bug in uiFocusPop: it invoked the popped
item's closed callback before decrementing UI_FOCUS.count, so a push
triggered from within that callback (e.g. reopening the main menu once
select-save reports no result) landed one slot past the true stack top
and got silently dropped, stranding the new item outside navigation.
Count is now decremented first.

Adds a uikeyboard placeholder widget, registered in uielementlist with
no behavior yet.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-21 14:16:39 -05:00
YourWishesandClaude Sonnet 5 27b6ddf5cb Rebuild uiconfirm on top of uimodal, add quit confirmation
uiconfirm no longer duplicates its own backdrop/frame/text/menu drawing
- it now just opens the shared uimodal_t with a Confirm/Cancel option
pair and translates the result back to a bool. uimodal itself gains an
optional title (NULL skips the title row entirely) so a title-less
confirm dialog doesn't reserve blank space.

Main menu's Quit option now opens a confirm dialog instead of exiting
immediately.

Drops es_MX.po/jp_JP.po locale files.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-20 22:41:06 -05:00
YourWishesandClaude Sonnet 5 475c865e33 Move uimodal to ui/widget, make main menu its own scene
uimodal.c/.h now live in ui/widget alongside the other widgets rather
than ui/frame. The main menu now has a real scene (scene/mainmenu)
whose init just calls the new uiMainMenuOpen(), replacing the
SCENE.current-polling uiMainMenuUpdate with an explicit open call like
uiGameMenuOpen/uiBackpackOpen. Also renames the ui.main_menu.* locale
keys to main_menu.* and groups them under a Main Menu Scene heading in
en_US.po, alongside the existing Initial Scene keys.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-20 19:18:50 -05:00
YourWishesandClaude Sonnet 5 0b4ba062bb Translate and word-wrap cutscene modal text
uiModalOpen now resolves title/message/option strings as locale
message IDs first, falling back to the literal string if no match is
found, and word-wraps the result to stay within the screen's scan
width via the new shared textWrap helper. sceneinitial.c's no-device
modal now uses locale keys instead of hardcoded English strings.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-20 18:53:47 -05:00
YourWishesandClaude Sonnet 5 28f5e66662 Fix PSP memory stick availability check on real hardware
sceIoGetstat on a bare device root ("ms0:/") returns EINVAL on real
PSP hardware even when a memory stick is present - confirmed via a
diagnostic print during hardware testing. Switched to sceIoDopen/
sceIoDclose, the SDK's own documented way to probe a device root,
which real hardware handles correctly.

Also adds a temporary unconditional settings write once a save device
is selected, to confirm the write path itself works on hardware now
that availability detection is fixed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-20 17:22:24 -05:00
YourWishesandClaude Sonnet 5 e225a076f0 Add cutscene flow control and a scriptable modal dialog item
uimodal gains an onOpen hook and lets uiModalClose take a one-shot
callback, since options are now stored as raw (uncopied) string
pointers instead of a fixed char buffer.

New cutscene items:
- CUTSCENE_MODAL / CUTSCENE_MODAL_OPTIONS / CUTSCENE_MODAL_CLOSE: opens
  a message-only or option-driven uimodal. A message-only modal
  advances the cutscene immediately; one with options blocks
  indefinitely since only its option callback (or something it
  triggers) should decide what happens next.
- CUTSCENE_MARKER + cutsceneGoTo: a named, otherwise no-op position
  that execution can jump straight to from anywhere in the same
  cutscene (e.g. from a CUTSCENE_CALLBACK), matched by name rather
  than pointer identity.
- CUTSCENE_RESTART + cutsceneRestart: restarts the running cutscene
  from its first item, preserving its interact/interacted entities.
- CUTSCENE_SCENE: requests a scene switch via sceneSet as a cutscene
  step.
- CUTSCENE_PRINT: prints a line to the console as a cutscene step.

sceneinitial.c's boot-time save device check is rebuilt on top of
these: show a modal, kick off the async device search, then branch
via markers/goto to a retry/continue prompt depending on the result.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-20 14:35:41 -05:00
YourWishesandClaude Sonnet 5 774c8ad0f8 Add uimodal dialog; uibutton reuses uilabel
uibutton now embeds a uilabel instead of drawing its text directly,
caching glyph sprites since a button's label never changes after
init. uimenu.c's item pointer in uiMenuDraw drops its unnecessary
const so uiButtonDraw can update the button's cache.

Also fixes remaining #include "ui/frame/uiframe.h" references left
over from uiframe's move to ui/widget/.

uimodal is a new generalized dialog (title + message + up to
UI_MODAL_OPTIONS_MAX option buttons) built the same way uiconfirm is,
but driven by a caller-supplied option list instead of a fixed
confirm/cancel pair.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-20 12:44:01 -05:00
YourWishesandClaude Sonnet 5 52d1e7414d Convert debug UI overlays to uilabel
uiconsole, uifps, and uiplayerpos now render through uilabel instead
of calling textDraw per character every frame. Each owns its label(s)
plus backing text/sprite buffers and marks them dirty only when their
content or position actually changes. Also drops console.h's per-line
buffer from 512 to 128 chars, since the sprite cache backing each
console line label scales with it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-20 12:43:43 -05:00
YourWishesandClaude Sonnet 5 af4cb53e5f Simplify uilabel to caller-owned buffers
uilabel no longer owns fixed-size text/sprite arrays or copies text
internally - callers pass in their own buffers and write text
directly, then mark the label dirty. This drops SetText/GetText and
the textMax bound in favor of a single Rebuffer entry point, and lets
widgets like uibutton alias an existing (possibly immutable) label
string instead of duplicating it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-20 12:43:02 -05:00
YourWishesandClaude Sonnet 5 c019271e12 Add uilabel widget and chunked text buffering
textDraw now batches glyphs via a new textBuffer helper instead of
buffering one sprite at a time. Fixes spriteBatchBuffer dropping/
duplicating sprites when a single call spans multiple internal
flush batches, which surfaced as cut-off characters. Adds uilabel,
a widget that caches its glyph sprites and only rebuilds/rebuffers
them when its text or position actually changes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-20 09:18:35 -05:00
YourWishesandClaude Sonnet 5 82ae2bce9d Add compressed combined save format for PSP and Dolphin
Memory sticks/cards don't suit N+1 separate save files the way Linux's
filesystem does, so add an opt-in SAVE_DEVICE_DATA_RAW mode: settings and
all save slots get serialized to JSON, concatenated, zlib-compressed, and
framed with a magic/version/checksum/generation header, then read/written
as a single blob through one combined platform hook instead of four.

- PSP and Dolphin's SD/NAND backends write via a temp file + atomic rename,
  so a crash mid-write can never leave a half-written save behind.
- GameCube memory cards have no rename or resize primitive, so they
  ping-pong between two fixed files instead, picking whichever is valid and
  has the higher generation counter on load.
- Linux is untouched (still four separate JSON files); saveslot.h/
  savesettings.h drop their now-unnecessary pack(1) now that nothing
  persists them as raw bytes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-17 21:28:56 -05:00
YourWishes 092e259a06 First pass of actual saving 2026-08-17 09:18:44 -05:00
YourWishes 08b4bbfe91 Added savestatus 2026-08-17 00:17:23 -05:00
YourWishes 560c51cf27 Drop default-skip behavior from savejson write macros
Only reads should fall back to a default when a key is missing; writes
should always emit the field so the saved JSON is a complete record.
2026-08-16 20:20:34 -05:00
YourWishes 674f86b18a Add JSON (de)serialization for save slots/settings and device write/read hooks
Introduces saveslot/savesettings structs with a shared savejson.h macro
toolkit (has/require/write/read per common type) for populating and
parsing yyjson objects, wires saveManager save/load calls through new
saveDevice slot/settings write/read entry points (still stubbed at the
device level), and drops the old binary-format scaffolding in favor of
JSON only.
2026-08-16 20:14:16 -05:00
YourWishesandClaude Sonnet 5 e3f10e0926 Fix GameCube memory card slot B detection and free-space check
CARD_Mount() alone reliably reports CARD_ERROR_NOCARD for a card physically
present in slot B - unlike slot A, which the IPL polls automatically at
boot, slot B needs an explicit CARD_Probe() first (the standard pattern in
every official devkitPro CARD sample). Also fixes
saveDeviceDolphinCardHasFreeSpace() treating CARD_GetDirectory()'s
CARD_ERROR_NOFILE (a totally empty card, not an actual error) as a hard
failure, which made any card with zero existing save files - slot B's in
this case, since slot A already had leftover test data on it - get
reported as full.

Confirmed via Dolphin with slot A disabled/slot B set to a memory card:
previously reported "no save device found", now correctly detects it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-14 16:49:18 -05:00
YourWishesandClaude Sonnet 5 d7223d7387 Add PSP/GameCube/Wii save-device availability checks
PSP checks memory-stick reachability via sceIoGetstat. GameCube gets two
devices (CARD_SLOTA/CARD_SLOTB), each mounted via CARD_Init/CARD_Mount and
checked for free space via CARD_GetDirectory/CARD_GetBlockCount. Wii picks
between three storage methods at compile time (DUSK_SAVE_WII_METHOD =
NAND/CARD/SD in wii.cmake, default NAND via ISFS) since real hardware
behavior for the default is unverified.

Also fixes two pre-existing bugs found while build/runtime-testing this
across PPSSPP and Dolphin: wrong libogc language macros in
systemGetLocaleDolphin, and a missing SYS_STDIO_Report(true) call that
silently swallowed all guest console output in Dolphin.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-14 16:20:28 -05:00
YourWishes 45331c2a60 First pass of save 2026-08-14 14:19:44 -05:00
YourWishesandClaude Sonnet 5 33f50a2c69 Strip legacy save/settings systems for a unified savemanager rewrite; split uielement list out of uielement.c
Removes the per-platform save/savestream/autosave implementations, the settings UI frames, and the autosave overlay, replacing them with a savemanager.c/h + savefile.h/savedevice.h scaffold to build the new save system on top of. Also separates the concrete UI_ELEMENTS registration into uielementlist.h/.c so uielement.c only holds the generic per-element lifecycle logic.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-14 12:56:02 -05:00
YourWishesandClaude Sonnet 5 6c8e4d5cbd Route remaining hardcoded UI strings through the locale system
Main menu buttons, the confirm dialog, battle menu/HUD text, backpack
tab labels, the loading/autosave indicators, and the settings tab
placeholders were all raw English literals bypassing translation.
Loads each through assetLocaleGetString like the rest of the UI, adding
new Init hooks for uibattlehud/uiautosave/uiloading where none existed,
and adds the corresponding message ids to en_US/es_MX/jp_JP.po.

Also widens the settings placeholder buffers to 64 bytes - the es_MX
and jp_JP translations overflowed the previous 32-byte size.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-10 09:12:57 -05:00
YourWishesandClaude Sonnet 5 3b7215876a Move test battle setup out of uimainmenu into rpg/battle/testbattle
Keeps the temporary hardcoded-battle hook alongside the rest of the
battle module instead of the main menu UI, so the UI frame only
triggers it rather than owning battle setup code.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 16:28:00 -05:00
YourWishes c74f5890bd Remove event for input 2026-08-08 16:25:36 -05:00
YourWishesandClaude Sonnet 5 fbd3c71ba7 Move network code into its own dusknetwork module, gate behind DUSK_NETWORK
Splits the platform-agnostic network core out of dusk into a top-level
dusknetwork module, mirroring the duskgl/dusksdl2 pattern. Adds a
DUSK_NETWORK cmake option (default ON) so builds can exclude all
networking code, including the per-platform implementations.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 16:13:09 -05:00
YourWishes 128f9ab9d4 some random fixes 2026-08-08 01:18:07 -05:00
YourWishes 36fb359aa2 Finish animation update loop with loop/pingpong/reverse/stop flags, clean up keyframe sampling, add coverage
- animationUpdate now advances and resolves boundary crossings for
  ANIMATION_FLAG_LOOP, ANIMATION_FLAG_PINGPONG, ANIMATION_FLAG_REVERSE, and
  the STOP_BEGINNING/STOP_END flags, firing onLoop/onComplete appropriately;
  guards against LOOP+PINGPONG being set together and moves the
  duration-must-be-positive check into animationInit
- keyframeGetValue clamps to the last keyframe's value instead of dividing
  by zero once time reaches it, and its keyframe walk drops a branch that's
  unreachable after that clamp
- Adds test/animation/test_animation.c covering init, per-layer sampling,
  and the full animationUpdate flag matrix
2026-08-06 15:29:14 -05:00
YourWishes 1bd73d69fe Clamp keyframe interpolation to last value, add main menu scene/UI, battle HUD, and expanded test coverage
- keyframeGetValue now returns the last keyframe's value for times at or
  beyond it, fixes a missing util/math.h include, and asserts keyframes are
  sorted by time; adds test/animation/test_keyframe.c
- Adds mainmenu scene/UI and a battle HUD UI frame
- Adds save autosave-related fields and battle scene tweaks
- Adds headless test coverage for cutscenes, entities, and map areas
2026-08-06 12:58:07 -05:00
YourWishesandClaude Sonnet 5 fb48285143 Rebuild battle flow as an explicit state machine with cutscene hooks
Replace the one-fighter-at-a-time turn model with an OPENING/PRE_ROUND/
PLAYER_SELECTION/AI_SELECTION/MOVES_EXECUTING/POST_ROUND/ENDED state
machine and a per-fighter action queue, so actions are decided before any
of them execute (needed for speed-ordered resolution) and so a cutscene
can pause the battle, wait for a specific state, and force a fighter's
action -- enabling automated, fully-scripted, and partially-scripted
battles. Adds CUTSCENE_PAUSE_BATTLE plus CUTSCENE_BATTLE_WAIT_STATE and
CUTSCENE_BATTLE_FORCE_ACTION cutscene items, and generic onStateChanged/
onActionDecided callbacks on battle_t. Re-enables the long-dormant
test/rpg suite and adds test/rpg/battle covering the state machine and
the new cutscene hooks end-to-end.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-06 08:49:42 -05:00
YourWishesandClaude Sonnet 5 3de50b8370 Lock in Chapter 1's literal opening scene
The village square at the mountain's foot, party roles established
(tank/paladin leader, ranged ranger, support mage, melee rogue), the
funds bicker, and the cowgirl teleporter's too-good-to-question,
one-way-only offer up the mountain - the innocent-sounding line that
seeds the whole chapter's central complication. Adds character stubs
for the three other party members and the teleporter, and confirms
the bubbly ranger as the first to abandon the mission.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-05 08:11:54 -05:00
YourWishesandClaude Sonnet 5 e61cbe25b7 Add three of the mentor's other former squadron members
Introduces Strider (a merc who protects those who can't afford real
protection, and has found genuine peace in it), Downes (retired to
farming and family, forced back into one last fight), and Egor (Royal
Guard, honor curdled into blind devotion, dies still certain of
himself). Together with the party leader, they give the same
mentor/same night four distinct answers to what honor means. Updates
themes and open questions accordingly.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-05 06:54:49 -05:00
YourWishesandClaude Sonnet 5 8395830be6 Introduce the Detective, kept deliberately thin by design
Reveals him as the presence following the party since Chapter 1 - a
king's agent who frees the protagonist from execution in early
Chapter 2, drops a small, cagey lead to the newly-dismissed party
leader, and then vanishes to chase other threads. Documents these
early Chapter 2 beats, notes culinary magic is now branded "the
forbidden magic" per the king's cover story, and updates open
questions/themes accordingly. His entry is intentionally sparse -
the character is meant to stay a mystery for as long as possible.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 23:47:50 -05:00
YourWishesandClaude Sonnet 5 6e4ec2b9d8 Add the party leader's backstory and his mentor
Ties the party leader's rigid, orders-above-all sense of honor back
to a mentor he idolized as a young soldier - one who broke orders on
the night of the Great Explosion to keep his squadron out of the
massacre, then died in it. The party leader doesn't know why at the
time; his eventual realization that his mentor's disobedience was
itself the honorable act becomes his arc's throughline. Also notes
the rest of that squadron, now scattered, as a future plot thread.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 23:37:18 -05:00
YourWishesandClaude Sonnet 5 f501bb8e28 Detail the true events of the Great Explosion
Replaces the vague placeholder with the full account: the king's
personal, in-person purge of the culinary mages' hometown, the failed
hostage surrender, the town's collective final spell (built on a
shared folk song) that becomes the Great Explosion, and the
protagonist's survival via her mother's protective spell. Also adds
the king's official cover story (framing the mages as insurgents),
new characters (the Great Mage and the protagonist's mother), and
updates themes/open questions accordingly.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 23:29:39 -05:00
YourWishesandClaude Sonnet 5 d07cd3397d Start documenting the story: premise, structure, and Chapter 1
Adds story.md covering the overarching premise (a prophecy caused by
the attempt to prevent it), the eight-movement story structure, the
Great Explosion backstory, core characters, and a detailed breakdown
of Chapter 1's opening - the bounty party's retrieval of the
protagonist, the mountain teleportation-logistics trap that splits
the party, and the confrontation with the king.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 23:16:46 -05:00
YourWishesandClaude Sonnet 5 e008fb108a Embed default font as a hard-coded bitmap instead of loading it as an asset
Moves FONT_DEFAULT and its init/dispose into their own font.h/font.c,
and rebuilds it from a static glyph bit array + a runtime-generated
texture/tileset rather than loading ui/minogram.png/.dtf through the
asset system. The engine now always has a usable font to render with,
even if asset loading fails. The now-unused minogram assets are removed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 22:37:22 -05:00
YourWishesandClaude Sonnet 5 9aaffff7a8 Add autosave system with forced no-save-device prompt on failure
autoSaveQueue() is a single callable entry point for requesting an
autosave; autoSaveUpdate() pumps the queue each frame and, if the write
fails (e.g. no memory card), forces the existing no-card modal back
open and pauses world simulation until the player retries or accepts a
temporary session. A small overlay shows "SAVING" while a write is in
flight.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 22:05:43 -05:00
YourWishesandClaude Sonnet 5 7357b4a5df Localize remaining hardcoded UI strings and persist language preference
Replaces the last hardcoded English strings (initial-scene modals, game
menu save status messages) with locale-loaded text, adds a shared
LOCALE_LIST so the settings dropdown and locale manager stay in sync,
and persists the chosen language into savemeta_t across all platforms.
Also fixes two message buffers that were too small for their longest
translations.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 21:39:24 -05:00
YourWishesandClaude Sonnet 5 1ddc298a74 Only update overworld entities while the overworld scene is active
Entities (and the player's pause-to-open-game-menu handling with them)
previously kept updating every frame regardless of the active scene,
matching an existing TODO in rpgUpdate(). Gating the entity loop itself
means the game menu (and any other entity-driven input) naturally can't
trigger mid-battle or before the initial scene hands off to the
overworld, without needing a scene check at each individual call site.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 20:54:58 -05:00
YourWishesandClaude Sonnet 5 e2a9442aa6 Add initial boot scene to check/prompt for save data before overworld
A new SCENE_TYPE_INITIAL now runs before the overworld: it checks
save-device availability and existing save data, then shows one of two
new dedicated modals - "no save device found" (Retry / Continue Anyway)
or "no save data found, create one?" (Yes / No) - before handing off to
the overworld. Both modals are self-contained UI elements mirroring
uiconfirm.h's shape, registered like any other global UI element.

Choosing "Continue Anyway" marks the session temporary (SAVE.temporary,
folded into saveIsAvailable()) so saving stays disabled for the rest of
the session instead of silently retrying, and the game menu's Save
action now reports that distinctly instead of the generic "no device"
message.

Also removes rpg.c's leftover TEST block (unconditional player-name
stamp + save write on every boot) now that this real flow owns save
creation.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 20:53:01 -05:00
YourWishesandClaude Sonnet 5 aa0180571e Unify save system into one save.h/.c; diverge storage format per platform
Renames savefile_t to saveslot_t and folds last session's standalone
settings.h/.c module back in as savemeta_t, so there's one save system
(SAVE.slots[] + SAVE.meta) instead of two parallel ones - while letting
each platform pick its own physical format for the two concepts:

- Linux now writes human-editable JSON (slot0.json, settings.json, ...)
  via yyjson's mutable writer API, so players can hand-fix a bad setting.
- PSP folds meta into the same sceUtilitySavedata binary payload as its
  one save slot (SAVE_SLOT_COUNT_MAX=1 there - a future save picker will
  let players manage multiple named saves via the OS's own browser).
- GameCube consolidates the 3 per-slot memory card files and the separate
  settings file into one combined card file.

Also fixes two bugs surfaced while building this: the CRC finalize step
seeked to a hardcoded offset (only safe for one section per file, breaks
once meta+slots share a buffer), and save.c's async/sync dispatch left an
unconditional fallback call that doesn't exist on PSP-only platforms.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 18:35:06 -05:00
YourWishesandClaude Sonnet 5 7f7be39230 Split device settings out of the per-slot save file
Deadzone (and future prefs like locale) now live in their own
settingsfile_t/settings.c, loaded eagerly at boot and saved immediately
on Apply, instead of inside savefile_t - a setting shouldn't reset or
diverge just because the player is on a different save slot, and this
also fixes settings changes not actually reaching disk until the next
full game Save.

PSP settings use a new plain sceIo path rather than sceUtilitySavedata,
since that dialog would flash its native icon on every settings tweak.
GameCube reuses the save system's existing memory card mount rather than
mounting it twice (settingsInit() now runs after saveInit()).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 13:54:15 -05:00
YourWishes 4d95415232 Move global item collected-state, deadzone, and story flags into save file
Adds three new pieces of save-file state, all following the same shape:
the save file is the single source of truth, not a separate live runtime
copy that gets synced in/out.

- globalitemstore.h/.c: per-global-entity-ID "collected" flags
  (savefile_t.globalItemCollected), so a global item entity's init
  callback can check whether it was already picked up in a prior session
  without needing to keep the entity itself alive to remember that.

- Gamepad deadzone: removed input_t.deadzone entirely. The setting UI and
  every platform's actual deadzone-applying code (inputGetDeadzoneDolphin/
  SDL2, previously hardcoded per-platform literals that the settings menu
  didn't actually affect) now read savefile_t.deadzone directly via
  saveGet(SAVE_ACTIVE_SLOT). Default lives in savefile.h
  (SAVE_DEADZONE_DEFAULT), stamped onto every slot in saveInit().

- Story flags: STORY_FLAG_VALUES (a live, codegen-initialized array) is
  replaced by savefile_t.storyFlags, read/written via the existing
  storyFlagGet()/storyFlagSet() call sites (now macros/functions over the
  active save file instead of a separate array). tools/story.py now
  generates STORY_FLAG_DEFAULTS (const) instead; storyFlagInitDefaults()
  stamps those onto a save the first time it's used (file->exists false),
  called from rpgInit().

Added SAVE_ACTIVE_SLOT (0) to savefile.h as the one shared "which slot is
actually being played" constant, replacing three different local/implicit
0s (uigamemenu.c, rpg.c, and now the settings/input call sites).

Verified round-trip on Linux (all three together in one save/load cycle);
both Linux and PSP build clean.
2026-08-04 11:10:44 -05:00
YourWishes 2cbd80a004 Check for existing save data before saving, confirm before creating new
uiGameMenuSave() now attempts a real load first (via the existing generic
saveLoad()/saveExists() primitives) instead of writing blind. If a save
already exists, it saves straight over it as before. If not, it prompts
via the existing uiConfirm dialog ("No save data found. Create a new
save?") before writing - this is exactly the flow GameCube needs (no
native OS save browser to lean on, unlike PSP), but implemented generically
so it also applies correctly on every other platform without any
platform-specific UI code: saveIsAvailable()/saveExists() already reflect
each platform's real state (e.g. Dolphin's memory-card presence and
existing-file checks), so the same logic just does the right thing
everywhere.

Verified the two branches directly on Linux (temporarily wiring the same
saveLoad -> check -> uiConfirmOpen sequence into rpgInit): with no save
file, saveExists() is false and the confirm dialog opens; with one already
written, it's true and the confirm dialog is correctly skipped. Not
verified via actual menu navigation (no input-injection tooling available
here) or on Dolphin (no devkitPPC toolchain in this environment).
2026-08-04 10:33:57 -05:00
YourWishes 9abf8101da PSP: save through the real sceUtilitySavedata API, not raw file I/O
Rewrote savepsp.c/savestreampsp.c to use sceUtilitySavedataInitStart/
Update/GetStatus/ShutdownStart instead of sceIoOpen/Read/Write, so PSP
saves get a proper OS-generated PARAM.SFO (title/savedataTitle/detail) and
show up correctly in the native save browser.

This dialog spans multiple frames and, per this project's prior experience
with the network config dialog, must be pumped non-blocking one step per
real engine frame rather than blocked on synchronously - a raw-sceGu
blocking loop already froze the app on real hardware for that dialog,
since pspGL owns the GU context. So save.h's saveWrite()/saveLoad() are
now callback-based (savecallback_t onComplete) instead of returning a
result directly, mirroring networkRequestConnection()'s shape, with a new
saveUpdate() (wired into engineUpdate()) pumping the active op each frame.
Linux/Dolphin behavior is unchanged - their fallback path in save.c still
completes synchronously, just via an immediate callback call instead of a
direct return.

Two real bugs found via PPSSPP testing (not just code review): SAVE/LOAD
modes show a confirm screen even for brand-new data, which blocks forever
headlessly - switched to AUTOSAVE/AUTOLOAD, which write/read silently and
generate the identical PARAM.SFO. And PPSSPP's dialog status goes straight
from QUIT to NONE without a separately observable FINISHED in between,
which the first version misread as "disappeared without a result" even on
a successful save - fixed by tracking whether QUIT was already seen.

Confirmed end-to-end in PPSSPP: write, dialog completes, PARAM.SFO +
encrypted save.bin appear on the virtual memory stick, and a subsequent
load decrypts/deserializes back to the exact original data. Not tested on
real PSP hardware.
2026-08-04 09:54:45 -05:00
YourWishes 24badd06a5 Add player name field to save file, save it out as a round-trip test
Adds savefile_t.playerName (SAVE_PLAYER_NAME_MAX) serialized via the
existing saveFileReadString/WriteString helpers, and stamps + saves it in
rpgInit() as a test that the save system now persists actual game data,
not just the header/version. Verified manually: written bytes end in
"Dusk\0" immediately after the version field, and loading it back returns
the same string.
2026-08-04 08:59:29 -05:00
YourWishes 7a03ef8eaf Re-enable save system, fix header/version stamping, handle missing media
- Fixed the actual reason saving never worked on any platform: saveWrite()
  never stamped file->header/file->version before serializing, so every
  written save file had a zeroed magic header and failed its own
  validation on the next load. Confirmed via a manual write/load round
  trip that this alone fully explains "saving doesn't work."
- Re-enabled saveInit()/saveDispose() in engine.c (previously commented out
  under "Temporarily disable save code").
- Added SAVE.available + saveIsAvailable(), refreshed by every real
  save/load/delete attempt. saveInit() no longer treats an unreachable
  save medium as fatal to booting - it logs and continues, since a missing
  memory card/stick shouldn't prevent playing.
- Hardened PSP's saveInitPSP() to actually detect a missing memory stick
  (sceIoGetstat on ms0:/) instead of assuming success, and fixed
  single-level sceIoMkdir to build the full PSP/SAVEDATA directory chain.
- Added busy-retry (CARD_ERROR_BUSY) and a not-mounted guard to Dolphin's
  live savestreamdolphin.c path, extending the same handling already
  backported into savedolphin.c.
- Added a "Save" entry to the game menu wired to saveWrite(0), showing a
  clear message on success, on failure, and when saveIsAvailable() is false.
2026-08-04 08:30:24 -05:00
YourWishes f3ea507313 Fixed save crash
Backported from branch ac2 (commit 85b61097) - CARD_Mount was being called
without CARD_Init first, leaving per-channel control blocks and the DSP
unlock sequence unset. On real Dolphin/hardware this surfaced as a hard
MMIO crash instead of a clean CARD_ERROR_* failure.

Co-Authored-By: Dominic Masters <[email protected]>
2026-08-04 08:12:57 -05:00
YourWishes 4b0388a0e1 Chunk streaming concurrency, entity slot fix, and map-data-driven spawns
- Allow 2 chunks to be mid-load concurrently instead of 1 (MAP_CHUNK_LOAD_CONCURRENCY).
- Fix entitySetChunk silently losing track of an entity when its target chunk's
  entity slots are full - it now stays detached (and retries later) instead of
  claiming a chunk that never actually registered it.
- DCF format bumped to v5: chunks can now declare entity spawns (global/NPC via
  the existing entityglobal registry, or one-shot item pickups) and map area
  triggers, resolved via a new callback-ID registry (mapareagloballist.h)
  mirroring the entity one. rpg.c's hardcoded TEST entity/item/area spawns are
  gone - chunk_0_0_0.json now carries that data instead. The player is still
  bootstrapped in code since it isn't map-authored content.
2026-08-04 07:37:48 -05:00
YourWishes a84137b5ff del md 2026-08-04 06:58:03 -05:00
YourWishes ca02ee0352 Add camera shake 2026-07-11 11:02:09 -05:00
YourWishes fbaa54145e Fixed dolphin rendering. 2026-07-10 20:15:26 -05:00
YourWishes 28754ffbf2 Removed old scripted types 2026-07-10 13:24:21 -05:00
YourWishes 470c0eba7a Whatever, some minor map chunking improvements 2026-07-10 12:57:31 -05:00
YourWishes 7098dcec43 Cleaned some log 2026-07-09 23:25:43 -05:00
YourWishes 07137f57af Assets are slightly optimized 2026-07-09 23:25:26 -05:00
YourWishes 8b7491a3d3 Emoji support to characters 2026-07-09 13:18:48 -05:00
YourWishes 8cfa8ddfeb Weaather baseline 2026-07-08 12:57:13 -05:00
YourWishes ef284a15a1 pre-cache shader matrices 2026-07-08 12:36:40 -05:00
YourWishes 3723921573 Render culling on sceneoverworld.h 2026-07-08 12:02:41 -05:00
YourWishes 195399635e Updating mini textbox 2026-07-08 11:45:10 -05:00
YourWishes 46e2a924d3 Mini textboxes 2026-07-08 10:53:53 -05:00
YourWishes b693ea4102 Starting item and battle stuff 2026-07-08 10:05:21 -05:00
YourWishes a73f55beb0 Battle stuff
Build Dusk / build-linux (push) Successful in 7m5s
Build Dusk / build-psp (push) Successful in 1m33s
Build Dusk / build-knulli (push) Successful in 4m0s
Build Dusk / build-gamecube (push) Successful in 3m31s
Build Dusk / build-gamecube-iso (push) Successful in 3m39s
Build Dusk / build-wii (push) Successful in 3m5s
Build Dusk / build-wii-iso (push) Successful in 3m25s
2026-07-08 07:55:15 -05:00
YourWishes 0bd2491ab7 Dropdown test. 2026-07-07 15:18:33 -05:00
YourWishes 860c797c9c Last round of cleanup for settings for now. 2026-07-07 14:56:08 -05:00
YourWishes 38c5080f9f Collapsing more ui settings code 2026-07-07 14:36:19 -05:00
YourWishes fae191d8fe Settings improvements 2026-07-07 14:00:37 -05:00
YourWishes d83a953e2d First pass of language 2026-07-07 11:14:02 -05:00
YourWishes 2dcf0d0f0d UI Confirm 2026-07-07 10:12:22 -05:00
YourWishes 6dee37d8c1 Slider widget and some cleanup 2026-07-07 09:58:10 -05:00
YourWishes 3bad03afb3 Item entity 2026-07-07 07:52:22 -05:00
YourWishes 6a6d8448f7 Area cutscene controls 2026-07-07 07:28:12 -05:00
YourWishes 189babd2cf Trigger types on areas 2026-07-07 07:07:32 -05:00
YourWishes 988a0f2294 Area basics 2026-07-06 23:19:05 -05:00
YourWishes 85bf455731 entity pos sets chunk now 2026-07-04 18:43:09 -05:00
YourWishes 589e4224f3 Example global ent 2026-07-04 13:03:24 -05:00
YourWishes bd7fa154b4 Add user data to callbacks 2026-07-04 09:13:37 -05:00
YourWishes a292f1992b Change to use the Z tile system 2026-07-04 08:40:53 -05:00
YourWishes 88ddf429b7 add some entity management in prep for global entities. 2026-07-03 12:25:24 -05:00
YourWishes 7a1f6662df More cutscene tools 2026-07-03 09:17:49 -05:00
YourWishes afc68bccc6 Update test runner
Build Dusk / build-linux (push) Successful in 5m36s
Build Dusk / build-psp (push) Successful in 1m15s
Build Dusk / build-knulli (push) Successful in 4m53s
Build Dusk / build-gamecube (push) Successful in 3m51s
Build Dusk / build-gamecube-iso (push) Successful in 3m52s
Build Dusk / build-wii (push) Successful in 3m17s
Build Dusk / build-wii-iso (push) Successful in 4m54s
2026-07-02 19:20:17 -05:00
646 changed files with 38151 additions and 19719 deletions
-30
View File
@@ -4,36 +4,6 @@ on:
tags: tags:
- '*' - '*'
jobs: jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
cmake \
python3 \
python3-pip \
python3-polib \
python3-pil \
libsdl2-dev \
libgl1-mesa-dev \
libzip-dev \
python3-dotenv \
python3-pyqt5 \
python3-opengl \
xz-utils \
liblzma-dev \
libbz2-dev \
zlib1g-dev \
git \
libssl-dev
- name: Run tests
run: ./scripts/test-linux.sh
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+35
View File
@@ -0,0 +1,35 @@
name: Test Dusk
on:
pull_request:
branches:
- main
jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
cmake \
python3 \
python3-pip \
python3-polib \
python3-pil \
libsdl2-dev \
libgl1-mesa-dev \
libzip-dev \
python3-dotenv \
python3-pyqt5 \
python3-opengl \
xz-utils \
liblzma-dev \
libbz2-dev \
zlib1g-dev \
git \
libssl-dev
- name: Run tests
run: ./scripts/test-linux.sh
+1
View File
@@ -107,3 +107,4 @@ yarn.lock
/assets/test /assets/test
/tools_old /tools_old
/assets/test.png /assets/test.png
/assets/audio/boa.mp3
-455
View File
@@ -1,455 +0,0 @@
# Dusk — Claude Code rules
## File headers
Every C, H, and JS file starts with:
```c
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
```
JS files use `//` comment style instead.
---
## C conventions
### Types
Always use the project-defined aliases instead of bare C primitives:
| Use | Not |
|-----------|--------------|
| `bool_t` | `bool` |
| `int_t` | `int` |
| `float_t` | `float` |
| `char_t` | `char` |
Use `uint8_t`, `uint16_t`, `int32_t`, etc. for fixed-width integers.
All struct and enum types end in `_t` (`animation_t`, `errorret_t`, …).
### Naming
- **Functions** — snake_case, prefixed with their module:
`assetLock()`, `entityPositionInit()`, `moduleAssetBatchCtor()`
- **Struct fields** — camelCase: `keyframeCount`, `localPosition`
- **Macros / constants** — UPPER_SNAKE_CASE:
`ENTITY_ID_INVALID`, `ERROR_OK`, `COMPONENT_TYPE_COUNT`
- **Files** — snake_case matching the primary type: `entityposition.c`,
`moduleassetbatch.c`
### Header files (`.h`)
- Use `#pragma once` — no include guards.
- Declare every public function, `#define`, and `extern` global.
- Write a JSDoc block (`/** … */`) above every declaration explaining
purpose, `@param`s, and `@returns`.
- Only include headers that the `.h` file itself strictly requires for
the types it exposes. Move everything else to the `.c` file.
Do not use forward declarations as a workaround — use the real
include in the `.c` file instead.
### Implementation files (`.c`)
- Contain function bodies only; no declarations.
- Pull in whatever additional includes the implementation needs.
- Do not use `static` or `inline` on **functions**. Every function,
including internal helpers, must be declared in the matching `.h` and
defined in the `.c` file. Internal helpers belong near the bottom of
the `.c` file, not at the top with a `static` qualifier.
`static` and `inline` on functions are only appropriate when the
function body is written directly inside a `.h` file.
`static` on **variables** (file-scope state) is fine and expected.
### Formatting
- Hard-wrap all lines at **80 characters**.
### Error handling
Return `errorret_t` from fallible functions. Use these macros:
```c
errorOk(); // return success
errorThrow("msg %d", val); // return failure with message
errorChain(someCall()); // propagate failure, continue on success
errorIsOk(ret) / errorIsNotOk(ret) // test a result
errorCatch(ret); // handle + free an error
```
Never return raw error codes or use `errno` for in-engine errors.
### Memory
Use the project allocator — never raw `malloc`/`free`:
```c
memoryAllocate(size) // allocate
memoryFree(ptr) // free
memoryZero(dest, size) // zero a block
memoryCopy(dest, src, size) // copy
```
### Asserts
Prefer specific assert macros over bare `assert()`:
```c
assertNotNull(ptr, "msg");
assertTrue(cond, "msg");
assertFalse(cond, "msg");
assertUnreachable("msg");
assertIsMainThread("msg");
```
---
## Build system
Each subdirectory has its own `CMakeLists.txt` that adds sources with:
```cmake
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
myfile.c
)
```
Never add source files to the root `CMakeLists.txt` directly.
---
## Platform support
### Targets
Set `DUSK_TARGET_SYSTEM` at CMake configure time to select a platform:
| `DUSK_TARGET_SYSTEM` | Macro defined | Platform |
|----------------------|-------------------|------------------|
| `linux` | `DUSK_LINUX` | Linux desktop |
| `knulli` | `DUSK_KNULLI` | Knulli (handheld)|
| `psp` | `DUSK_PSP` | Sony PSP |
| `vita` | `DUSK_VITA` | PlayStation Vita |
| `gamecube` | `DUSK_GAMECUBE` | Nintendo GameCube|
| `wii` | `DUSK_WII` | Nintendo Wii |
### Layer structure
```
src/dusk/ core, platform-agnostic game logic
src/duskgl/ OpenGL abstraction (Linux, Knulli, PSP, Vita)
src/dusksdl2/ SDL2 window + input (Linux, Knulli, PSP, Vita)
src/dusklinux/ Linux + Knulli platform impl
src/duskpsp/ PSP platform impl
src/duskvita/ Vita platform impl
src/duskdolphin/ GameCube / Wii platform impl (no SDL2/OpenGL)
```
Dolphin is the only target that bypasses SDL2 and OpenGL entirely —
it uses native GameCube/Wii rendering and input APIs.
### Platform guards
Use the compile-time macros for platform-specific code:
```c
#ifdef DUSK_PSP
// PSP-only path
#elif defined(DUSK_GAMECUBE) || defined(DUSK_WII)
// GameCube / Wii path
#else
// Generic / Linux fallback
#endif
```
Additional capability macros set per-target:
`DUSK_SDL2`, `DUSK_OPENGL`, `DUSK_OPENGL_ES`, `DUSK_OPENGL_LEGACY`,
`DUSK_INPUT_GAMEPAD`, `DUSK_INPUT_KEYBOARD`, `DUSK_INPUT_POINTER`,
`DUSK_PLATFORM_ENDIAN_BIG` / `DUSK_PLATFORM_ENDIAN_LITTLE`.
### Abstraction pattern
Platform-specific implementations are wired in via `#define` macros in
each platform's `displayplatform.h` / `inputplatform.h` etc., which
the core calls through. Functions that a platform does not support are
simply left undefined — the core guards calls with `#ifdef`.
### Adding platform-specific code
- Put it under `src/dusk<platform>/` in the matching subsystem folder.
- Gate any core call-site with the appropriate `#ifdef DUSK_<PLATFORM>`
or capability macro.
- Keep the `src/dusk/` core free of platform ifdefs — delegate through
the platform header macros instead.
---
## Adding a new asset loader type
1. Add an enum value to `assetloadertype_t` (before `_COUNT`) in
`src/dusk/asset/loader/assetloader.h`.
2. Add fields to the input/loading/output unions in `assetloader.h`.
3. Implement `assetXxxLoaderSync`, `assetXxxLoaderAsync`, and
`assetXxxDispose` in a new `src/dusk/asset/loader/xxx/` directory.
4. Register the three callbacks in `ASSET_LOADER_CALLBACKS[]` in
`src/dusk/asset/loader/assetloader.c`.
5. If user-facing, create a JS module (see below) and a `.d.ts` file.
---
## Adding a new entity component
1. Create `src/dusk/entity/component/<category>/entityMyComp.h/.c` with
struct `entityMyComp_t`, `entityMyCompInit()`, and optionally
`entityMyCompDispose()`.
2. Add the include to `src/dusk/entity/componentlist.h` header block.
3. Add a row to `src/dusk/entity/componentlist.h`:
```c
X(MYCOMP, entityMyComp_t, myComp, entityMyCompInit, NULL, NULL)
```
This auto-generates the enum, union field, and definition entry.
4. If JS-facing, create the script module and `.d.ts` (see below).
---
## Adding a new script (JS) module
1. Create `src/dusk/script/module/<category>/moduleMyMod.h/.c`.
- Declare `extern scriptproto_t MODULE_MYMOD_PROTO;` in the header.
- Use `moduleBaseFunction(name)` to define JS-callable functions.
- Register props/funcs in `moduleMyModInit()` with
`scriptProtoDefineProp` / `scriptProtoDefineFunc` /
`scriptProtoDefineStaticFunc`.
2. `#include` the header in
`src/dusk/script/module/modulelist.c` and call
`moduleMyModInit()` in `moduleListInit()` (and `Dispose` in
`moduleListDispose()`).
3. For component modules also register in
`src/dusk/script/module/entity/component/modulecomponentlist.c`
so `entity.add()` returns the typed wrapper.
4. Create `types/<category>/mymod.d.ts` and add a
`/// <reference path="..." />` line to `types/index.d.ts`.
---
## Script module type declarations
Whenever a `src/dusk/script/module/**/*.c` file is created or modified,
check whether the corresponding `types/**/*.d.ts` needs updating and
apply any changes before finishing the task.
---
## JavaScript (asset scripts)
- Use `var` for module-level state; `const` for values that never
change.
- Always use semicolons.
- Scene objects are plain objects (`var scene = {}`) with assigned
methods.
- Export via `module.exports = scene`.
- Async scene init should use `async function` and `await`.
---
## Coding style
### ASCII only
Source files (`.c`, `.h`, `.js`) must contain only ASCII characters (U+0000–U+007F).
Non-ASCII characters are banned even in comments and string literals.
Use ASCII-only substitutes instead:
- `--` or `-` instead of `—` (em dash)
- `->` instead of `→` (arrow)
- `x` or `*` instead of `×` (multiplication)
Only non-script asset files (e.g. `.po` locale files) may contain non-ASCII text.
### Indentation
2 spaces. No tabs.
### Keyword and operator spacing
No space between a keyword or function name and its opening parenthesis:
```c
if(!ptr) return;
for(uint8_t i = 0; i < count; i++) {
while(entry->state != DONE) {
switch(type) {
sizeof(assetbatch_t)
memoryZero(ptr, size)
```
Spaces around all binary operators and after every comma:
```c
pos->flags |= ENTITY_POSITION_FLAG_WORLD_DIRTY;
(size_t)end - (size_t)start
foo(a, b, c)
```
### Braces
Opening brace on the **same line** as the statement (K&R style) for all
constructs — functions, `if`, `else`, `for`, `while`, `switch`:
```c
void assetEntryLock(assetentry_t *entry) {
...
}
if(dirty) {
...
} else {
...
}
```
### Guard returns
Short guards go on one line with no braces:
```c
if(!ptr) return;
if(!b || !b->batch) return jerry_undefined();
if(!(flags & DIRTY)) return;
```
### Blank lines
- One blank line between functions; no blank line at the start or end of
a function body.
- One blank line between logical blocks inside a function body.
- No trailing blank lines at the end of a file.
### Pointer placement
`*` is attached to the variable name, not the type:
```c
assetentry_t *entry
const char_t *name
void *ptr
uint8_t *d = (uint8_t *)dest;
```
### Casts
Space between cast and operand:
```c
(assetbatch_t *)user
(uint8_t *)dest
(textureformat_t)v
```
### Return
No parentheses around the return value:
```c
return ptr;
return MEMORY_POINTERS_IN_USE;
```
### switch / case
`case` indented 2 spaces from `switch`; body indented 2 more from `case`:
```c
switch(type) {
case ASSET_LOADER_TYPE_TEXTURE:
descs[i].input.texture = (textureformat_t)v;
break;
default:
break;
}
```
### Multi-line function signatures
When parameters don't fit on one line, put each on its own line indented
2 spaces; the closing `) {` (definition) or `);` (declaration) goes on
its own line at column 0:
```c
void assetEntryInit(
assetentry_t *entry,
const char_t *name,
const assetloadertype_t type,
assetloaderinput_t *input
) {
errorret_t memoryCompare(
const void *a,
const void *b,
const size_t size
);
```
### Structs and enums
Anonymous inner struct or enum with a `typedef`, `_t` suffix, closing
brace and name on the same line:
```c
typedef struct {
errorcode_t code;
char_t *message;
} errorstate_t;
typedef enum {
ASSET_LOADER_TYPE_NULL,
ASSET_LOADER_TYPE_COUNT
} assetloadertype_t;
```
### Designated initialisers
Spaces inside braces; `.field = value`:
```c
jsassetentry_t e = { .entry = entry };
assetbatchloadedpend_t init = { .batch = batch };
```
### Ternary operator
Spaces around `?` and `:`:
```c
const float val = psx > 0.0f ? pt[0][0] / psx : 0.0f;
```
### const placement
`const` before the type, `*` attached to the variable:
```c
const char_t *name
const void *src
const size_t size
```
### Comments in `.c` files
- Do not use section dividers (`/* ---- ... ---- */`). Just let the
functions follow one another with a single blank line between them.
- Multi-line explanatory comments inside function bodies use `//` lines:
```c
// Script modules are freed; orphaned JS wrapper objects now get GC'd
// so their finalizers fire before assetDispose() checks ref counts.
jerry_heap_gc(JERRY_GC_PRESSURE_HIGH);
```
- Do not use `/* */` for inline or inline-block comments inside `.c`
function bodies.
### Comments in `.h` files
Every public declaration gets a Javadoc block (`/** … */`) with
`@param` and `@returns` where relevant. Keep it on the lines immediately
above the declaration with no blank line in between.
---
## Color system
Colors are defined in `src/dusk/display/color.csv` and code-generated
into a `color.h` header by `tools/color/csv/__main__.py`.
Each row in the CSV has `name,r,g,b,a` with channel values in `[0.0, 1.0]`.
The script emits four `#define` variants per color plus a bare alias:
```
COLOR_<NAME>_4B color4b(r8, g8, b8, a8) // default alias target
COLOR_<NAME>_3B color3b(r8, g8, b8)
COLOR_<NAME>_3F color3f(rf, gf, bf)
COLOR_<NAME>_4F color4f(rf, gf, bf, af)
COLOR_<NAME> COLOR_<NAME>_4B
```
`color_t` is `color4b_t` (four `uint8_t` channels).
To add a new color, append a row to `color.csv` and rebuild — do not
hand-edit the generated header.
---
## Tests
- Tests live in `test/` mirroring `src/dusk/` structure.
- Use cmocka; include `dusktest.h`.
- Test functions: `static void test_something(void **state)`.
- After each test, assert `memoryGetAllocatedCount() == 0` to catch
leaks.
- Build with `-DDUSK_BUILD_TESTS=ON`.
+40 -15
View File
@@ -8,16 +8,22 @@ cmake_minimum_required(VERSION 3.13)
set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_CURRENT_SOURCE_DIR}/cmake/modules) set(CMAKE_MODULE_PATH
${CMAKE_MODULE_PATH}
${CMAKE_CURRENT_SOURCE_DIR}/cmake/modules
)
cmake_policy(SET CMP0079 NEW) cmake_policy(SET CMP0079 NEW)
# set(FETCHCONTENT_UPDATES_DISCONNECTED ON) # set(FETCHCONTENT_UPDATES_DISCONNECTED ON)
option(DUSK_BUILD_TESTS "Enable tests" OFF) option(DUSK_BUILD_TESTS "Enable tests" OFF)
option(DUSK_NETWORK "Enable network support" ON)
set(DUSK_GAME_NAME "Dusk" CACHE STRING "Game display name") set(DUSK_GAME_NAME "Dusk" CACHE STRING "Game display name")
set(DUSK_GAME_AUTHOR "YourWishes" CACHE STRING "Game author / coder") set(DUSK_GAME_AUTHOR "YourWishes" CACHE STRING "Game author / coder")
set(DUSK_GAME_SHORT_DESCRIPTION "Dusk game" CACHE STRING "One-line description") set(DUSK_GAME_SHORT_DESCRIPTION "Dusk game" CACHE STRING "One-line description")
set(DUSK_GAME_LONG_DESCRIPTION "No description yet." CACHE STRING "Full description") set(DUSK_GAME_LONG_DESCRIPTION
"No description yet." CACHE STRING "Full description"
)
# Prep cache # Prep cache
set(DUSK_CACHE_TARGET "dusk-target") set(DUSK_CACHE_TARGET "dusk-target")
@@ -31,13 +37,19 @@ set(DUSK_TEMP_DIR "${DUSK_BUILD_DIR}/temp")
set(DUSK_TOOLS_DIR "${DUSK_ROOT_DIR}/tools") set(DUSK_TOOLS_DIR "${DUSK_ROOT_DIR}/tools")
set(DUSK_DATA_DIR "${DUSK_ROOT_DIR}/data") set(DUSK_DATA_DIR "${DUSK_ROOT_DIR}/data")
set(DUSK_ASSETS_DIR "${DUSK_ROOT_DIR}/assets") set(DUSK_ASSETS_DIR "${DUSK_ROOT_DIR}/assets")
set(DUSK_BUILT_ASSETS_DIR "${DUSK_BUILD_DIR}/built_assets" CACHE INTERNAL ${DUSK_CACHE_TARGET}) set(DUSK_BUILT_ASSETS_DIR "${DUSK_BUILD_DIR}/built_assets"
CACHE INTERNAL ${DUSK_CACHE_TARGET}
)
set(DUSK_GENERATED_HEADERS_DIR "${DUSK_BUILD_DIR}/generated") set(DUSK_GENERATED_HEADERS_DIR "${DUSK_BUILD_DIR}/generated")
set(DUSK_BUILD_BINARY ${DUSK_BUILD_DIR}/Dusk CACHE INTERNAL ${DUSK_CACHE_TARGET}) set(DUSK_BUILD_BINARY ${DUSK_BUILD_DIR}/Dusk
CACHE INTERNAL ${DUSK_CACHE_TARGET}
)
set(DUSK_ASSETS "" CACHE INTERNAL ${DUSK_CACHE_TARGET}) set(DUSK_ASSETS "" CACHE INTERNAL ${DUSK_CACHE_TARGET})
set(DUSK_LIBRARY_TARGET_NAME "DuskCore" CACHE INTERNAL ${DUSK_CACHE_TARGET}) set(DUSK_LIBRARY_TARGET_NAME "DuskCore" CACHE INTERNAL ${DUSK_CACHE_TARGET})
set(DUSK_BINARY_TARGET_NAME "Dusk" CACHE INTERNAL ${DUSK_CACHE_TARGET}) set(DUSK_BINARY_TARGET_NAME "Dusk" CACHE INTERNAL ${DUSK_CACHE_TARGET})
set(DUSK_ASSETS_ZIP "${DUSK_BUILD_DIR}/dusk.dsk" CACHE INTERNAL ${DUSK_CACHE_TARGET}) set(DUSK_ASSETS_ZIP "${DUSK_BUILD_DIR}/dusk.dsk"
CACHE INTERNAL ${DUSK_CACHE_TARGET}
)
if(NOT DEFINED DUSK_TARGET_SYSTEM) if(NOT DEFINED DUSK_TARGET_SYSTEM)
set(DUSK_TARGET_SYSTEM "linux") set(DUSK_TARGET_SYSTEM "linux")
@@ -72,7 +84,9 @@ if(DUSK_BUILD_TESTS)
${DUSK_LIBRARY_TARGET_NAME} ${DUSK_LIBRARY_TARGET_NAME}
) )
else() else()
set(DUSK_LIBRARY_TARGET_NAME "${DUSK_BINARY_TARGET_NAME}" CACHE INTERNAL ${DUSK_CACHE_TARGET}) set(DUSK_LIBRARY_TARGET_NAME "${DUSK_BINARY_TARGET_NAME}"
CACHE INTERNAL ${DUSK_CACHE_TARGET}
)
endif() endif()
if(NOT DEFINED DUSK_VERSION) if(NOT DEFINED DUSK_VERSION)
@@ -90,6 +104,12 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME}
DUSK_VERSION="${DUSK_VERSION}" DUSK_VERSION="${DUSK_VERSION}"
) )
if(DUSK_NETWORK)
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_NETWORK
)
endif()
# Toolchains # Toolchains
include(cmake/targets/${DUSK_TARGET_SYSTEM}.cmake) include(cmake/targets/${DUSK_TARGET_SYSTEM}.cmake)
@@ -115,16 +135,21 @@ if(DUSK_BUILD_TESTS)
add_subdirectory(test) add_subdirectory(test)
endif() endif()
# Build assets # Build assets. Runs unconditionally on every build (rather than being
file(GLOB_RECURSE DUSK_ASSET_FILES CONFIGURE_DEPENDS "${DUSK_ASSETS_DIR}/*") # gated behind a custom_command's file-level DEPENDS list) because
add_custom_command( # CONFIGURE_DEPENDS globs only pick up added/removed files on the next
OUTPUT "${DUSK_ASSETS_ZIP}" # CMake configure, so that approach could miss/lag a rebuild behind an
# asset edit. tools.asset.pack itself decides whether anything actually
# changed (see its manifest check) and no-ops quickly when nothing did,
# so this stays cheap on a build where assets are untouched.
add_custom_target(DUSK_ASSETS_BUILT ALL
COMMAND ${CMAKE_COMMAND} -E make_directory "${DUSK_ASSETS_DIR}" COMMAND ${CMAKE_COMMAND} -E make_directory "${DUSK_ASSETS_DIR}"
COMMAND ${CMAKE_COMMAND} -E rm -f "${DUSK_ASSETS_ZIP}" COMMAND ${Python3_EXECUTABLE} -m tools.asset.pack
COMMAND ${CMAKE_COMMAND} -E tar "cf" "${DUSK_ASSETS_ZIP}" --format=zip -- . --input "${DUSK_ASSETS_DIR}"
WORKING_DIRECTORY "${DUSK_ASSETS_DIR}" --output "${DUSK_ASSETS_ZIP}"
DEPENDS ${DUSK_ASSET_FILES} WORKING_DIRECTORY "${DUSK_ROOT_DIR}"
BYPRODUCTS "${DUSK_ASSETS_ZIP}"
COMMENT "Packing assets into dusk.dsk"
VERBATIM VERBATIM
) )
add_custom_target(DUSK_ASSETS_BUILT DEPENDS "${DUSK_ASSETS_ZIP}")
add_dependencies(${DUSK_LIBRARY_TARGET_NAME} DUSK_ASSETS_BUILT) add_dependencies(${DUSK_LIBRARY_TARGET_NAME} DUSK_ASSETS_BUILT)
+6 -3
View File
@@ -9,15 +9,18 @@ following packages, depending on your system;
Fedora; Fedora;
``` ```
sudo dnf install git make gcc cmake python python-polib python3-pillow python3-dotenv python3-numpy python-qt5 python3-pyopengl SDL2-devel zlib-devel libzip-devel bzip2-devel openssl-devel lzma-sdk-devel xz xz-devel lua-devel sudo dnf install git make gcc cmake python python-polib python3-pillow python3-dotenv python3-numpy python-qt5 python3-pyopengl SDL2-devel zlib-devel libzip-devel bzip2-devel openssl-devel lzma-sdk-devel xz xz-devel lua-devel libmad-devel
``` ```
`libmad-devel` (used for MP3 decoding) is GPL-licensed and ships in the
[RPM Fusion](https://rpmfusion.org/) free repository rather than Fedora's
base repos - make sure RPM Fusion is enabled first.
Ubuntu; Ubuntu;
``` ```
sudo apt-get install git build-essential gcc python python-polib python3-pillow python3-dotenv python3-numpy python3-pyqt5 python3-opengl sudo apt-get install git build-essential gcc python python-polib python3-pillow python3-dotenv python3-numpy python3-pyqt5 python3-opengl libmad0-dev
``` ```
Arch Linux; Arch Linux;
``` ```
sudo pacman -S git base-devel gcc python python-polib python-pillow python-dotenv python-numpy python-pyqt5 python-opengl sudo pacman -S git base-devel gcc python python-polib python-pillow python-dotenv python-numpy python-pyqt5 python-opengl libmad
``` ```
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+68
View File
@@ -0,0 +1,68 @@
{
"items": [
// First thing, try and find a save device, used for settings and what not.
{
"type": "MODAL",
"title": "initial.checking_save.title",
"message": "initial.checking_save.message"
},
{
"type": "WAIT",
"seconds": 0.2
},
{
"type": "SAVE_DEVICE_CHECK",
"successMarker": "CONTINUE",
"failureMarker": "SAVE_DEVICE_NOT_FOUND"
},
// No save device found
{
"type": "MARKER",
"name": "SAVE_DEVICE_NOT_FOUND"
},
{
"type": "MODAL_CLOSE"
},
{
"type": "MODAL_OPTIONS_MARKERS",
"title": "initial.no_device.title",
"message": "initial.no_device.message",
"options": [
{
"text": "initial.no_device.retry",
"marker": "SAVE_DEVICE_RETRY"
},
{
"text": "initial.no_device.continue",
"marker": "CONTINUE"
}
]
},
// Save device not found, user to retry, just close modal and loop
{
"type": "MARKER",
"name": "SAVE_DEVICE_RETRY"
},
{
"type": "MODAL_CLOSE"
},
{
"type": "RESTART"
},
// Save device found (or continuing without one)
{
"type": "MARKER",
"name": "CONTINUE"
},
{
"type": "MODAL_CLOSE"
},
{
"type": "CUTSCENE",
"name": "main_menu"
}
]
}
+140
View File
@@ -0,0 +1,140 @@
{
"items": [
{
"type": "MARKER",
"name": "MAIN_MENU_IDLE"
},
{
"type": "UI_SHOW",
"name": "main_menu"
},
// Options button goes here - no options screen yet, so this is just
// an alias landing right back on the idle loop below.
{
"type": "MARKER",
"name": "OPTIONS"
},
{
"type": "IDLE"
},
// New Game button goes here.
{
"type": "MARKER",
"name": "NEW_GAME"
},
{
"type": "MODAL",
"title": "main_menu.checking_save.title",
"message": "main_menu.checking_save.message"
},
{
"type": "WAIT",
"seconds": 0.2
},
{
"type": "SAVE_DEVICE_CHECK",
"successMarker": "SAVE_DEVICE_FOUND",
"failureMarker": "SAVE_DEVICE_NOT_FOUND"
},
// Save device not found
{
"type": "MARKER",
"name": "SAVE_DEVICE_NOT_FOUND"
},
{
"type": "MODAL_CLOSE"
},
{
"type": "MODAL_OPTIONS_MARKERS",
"title": "main_menu.no_device.title",
"message": "main_menu.no_device.message",
"options": [
{
"text": "main_menu.no_device.retry",
"marker": "NEW_GAME"
},
{
"text": "main_menu.no_device.continue",
"marker": "SAVE_DEVICE_FOUND"
}
]
},
// Save data failed to load (e.g. corrupt/unreadable) - only option is
// to retry, no "continue without saving" here since we already know a
// device is present.
{
"type": "MARKER",
"name": "LOAD_ERROR"
},
{
"type": "MODAL_OPTIONS_MARKERS",
"title": "main_menu.save_load_error.title",
"message": "main_menu.save_load_error.message",
"options": [
{
"text": "main_menu.save_load_error.retry",
"marker": "NEW_GAME"
}
]
},
// Quit Game button goes here. Cancelling jumps back to
// MAIN_MENU_IDLE; confirming falls through to QUIT_GAME, which
// ends the process directly.
{
"type": "MARKER",
"name": "QUIT"
},
{
"type": "MODAL_OPTIONS_MARKERS",
"title": "main_menu.quit_confirm",
"options": [
{
"text": "ui.confirm.confirm",
"marker": "QUIT_CONFIRMED"
},
{
"text": "ui.confirm.cancel",
"marker": "MAIN_MENU_IDLE"
}
]
},
{
"type": "MARKER",
"name": "QUIT_CONFIRMED"
},
{
"type": "QUIT_GAME"
},
// main menu just listens for cutscene ending to show the select save modal
// so we have that here.
{
"type": "MARKER",
"name": "SAVE_DEVICE_FOUND"
},
{
"type": "MODAL_CLOSE"
},
{
"type": "SAVE_LOAD_ALL_SLOTS",
"successMarker": "LOADED",
"failureMarker": "LOAD_ERROR"
},
// Must be the very last item - falling off the end here is what
// completes the cutscene and fires uiMainMenuOpenSelectSave (see
// cutsceneSystemSetOnComplete in ui/screen/mainmenu/uimainmenu.c).
// Placing this anywhere earlier would fall through into whatever
// comes next instead (e.g. the QUIT block, if placed just before it).
{
"type": "MARKER",
"name": "LOADED"
}
]
}
+253 -43
View File
@@ -5,56 +5,266 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : n==2 ? 1 : (n<7 ? 2 : 3));\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : n==2 ? 1 : (n<7 ? 2 : 3));\n"
# Initial Scene
msgid "initial.checking_save.title"
msgstr "Checking for save data"
msgid "initial.checking_save.message"
msgstr "Please wait..."
msgid "initial.no_device.title"
msgstr "No Save Device Found"
msgid "initial.no_device.message"
msgstr "Could not find a save device, ensure it is connected and try again. You can continue, but progress will not be saved."
msgid "initial.no_device.retry"
msgstr "Try again"
msgid "initial.no_device.continue"
msgstr "Continue without saving"
# Main Menu Scene
msgid "main_menu.start_game"
msgstr "Start Game"
msgid "main_menu.options"
msgstr "Options"
msgid "main_menu.quit"
msgstr "Quit Game"
msgid "main_menu.quit_confirm"
msgstr "Are you sure you want to quit?"
msgid "main_menu.checking_save.title"
msgstr "Checking for save data"
msgid "main_menu.checking_save.message"
msgstr "Please wait..."
msgid "main_menu.no_device.title"
msgstr "No Save Device Found"
msgid "main_menu.no_device.message"
msgstr "Could not find a save device, ensure it is connected and try again. You can continue, but progress will not be saved."
msgid "main_menu.no_device.retry"
msgstr "Try again"
msgid "main_menu.no_device.continue"
msgstr "Continue without saving"
msgid "main_menu.save_load_error.title"
msgstr "Error"
msgid "main_menu.save_load_error.message"
msgstr "Failed to load save data. Please try again."
msgid "main_menu.save_load_error.retry"
msgstr "Try Again"
# Select Save Screen
msgid "ui.select_save.title"
msgstr "Select Save"
msgid "ui.select_save.empty"
msgstr "Empty Slot"
msgid "ui.select_save.slot_format"
msgstr "%s Lv.%d %s"
msgid "ui.select_save.delete_mode"
msgstr "Delete a Save"
msgid "ui.select_save.delete_confirm"
msgstr "Are you sure you want to delete this save?"
msgid "ui.select_save.name_title"
msgstr "Enter game save file name"
msgid "ui.save_slot.number_format"
msgstr "Slot %d"
#: ui/menu.c:10 #: ui/menu.c:10
msgid "ui.title" msgid "ui.title"
msgstr "" msgstr ""
"Welcome" "Welcome"
#: ui/user.c:22 msgid "save.linux.mkdirp_failed"
msgid "ui.greeting" msgstr "Failed to create save directory, check the disk is not full or write-protected."
msgstr "Hello, %s!"
#: ui/files.c:40 #: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.file_status" msgid "ui.settings.tabs.general"
msgstr "%s has %d files." msgstr "General"
#: ui/cart.c:55 #: src/dusk/ui/frame/settings/uisettings.c
msgid "cart.item_count" msgid "ui.settings.tabs.input"
msgid_plural "cart.item_count" msgstr "Input"
msgstr[0] "%d item"
msgstr[1] "%d items (dual)"
msgstr[2] "%d items (few)"
msgstr[3] "%d items (many)"
#: ui/notifications.c:71 #: src/dusk/ui/frame/settings/uisettings.c
msgid "" msgid "ui.settings.tabs.display"
"ui.multiline_help" msgstr "Display"
msgstr ""
"Line one of the help text.\n"
"Line two continues here.\n"
"Line three ends here."
#: ui/errors.c:90 #: src/dusk/ui/frame/settings/uisettings.c
msgid "" msgid "ui.settings.tabs.audio"
"error.upload_failed.long" msgstr "Audio"
msgstr ""
"Upload failed for file \"%s\".\n"
"Please try again later or contact support."
#: ui/messages.c:110 msgid "ui.settings.input.deadzone"
msgid "" msgstr "Deadzone"
"user.invite_status"
msgid_plural "" #: src/dusk/ui/frame/settings/uisettingsgeneral.c
"user.invite_status" msgid "ui.settings.general.language"
msgstr[0] "" msgstr "Language"
"%s invited %d user.\n"
"Please review the request." msgid "ui.settings.general.language_detail"
msgstr[1] "" msgstr "Takes effect after restarting the application."
"%s invited %d users (dual).\n"
"Please review the requests." #: src/dusk/ui/frame/settings/uisettings.c
msgstr[2] "" msgid "ui.settings.apply"
"%s invited %d users (few).\n" msgstr "Apply"
"Please review the requests."
msgstr[3] "" #: src/dusk/ui/frame/uiconfirm.c
"%s invited %d users (many).\n" msgid "ui.confirm.discard_changes"
"Please review the requests." msgstr "Discard unsaved changes?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.characters"
msgstr "Characters"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.items"
msgstr "Items"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.settings"
msgstr "Settings"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save"
msgstr "Save"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_success"
msgstr "Game saved."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_cancelled"
msgstr "Save cancelled."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_unavailable"
msgstr "Can't save - no save device found."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_temporary"
msgstr "This session is temporary - no save device was found, so saving is disabled."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_create_confirm"
msgstr "No save data found. Create a new save?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_failed_format"
msgstr "Save failed: %s"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_check_failed_format"
msgstr "Can't save: %s"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.message"
msgstr "No save device found. You can continue, but\nprogress will not be saved."
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.retry"
msgstr "Retry"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.continue"
msgstr "Continue Anyway"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.message"
msgstr "No save data found. Create a new save?"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.yes"
msgstr "Yes"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.no"
msgstr "No"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.confirm"
msgstr "Confirm"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.cancel"
msgstr "Cancel"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.attack"
msgstr "Attack"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.flee"
msgstr "Flee"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.target_format"
msgstr "Enemy %u (%u/%u HP)"
#: src/dusk/ui/frame/battle/uibattlehud.c
msgid "ui.battle.hud.hp_format"
msgstr "HP %u/%u"
#: src/dusk/ui/frame/battle/uibattlehud.c
msgid "ui.battle.hud.mp_format"
msgstr "MP %u/%u"
#: src/dusk/ui/frame/settings/uisettingsaudio.c
msgid "ui.settings.audio.placeholder"
msgstr "No audio settings yet"
#: src/dusk/ui/frame/settings/uisettingsdisplay.c
msgid "ui.settings.display.placeholder"
msgstr "No display settings yet"
#: src/dusk/ui/frame/settings/uisettingsinput.c
msgid "ui.settings.input.placeholder"
msgstr "No input settings yet"
#: src/dusk/ui/frame/backpack/uibackpack.c
msgid "ui.backpack.category_format"
msgstr "Category %u"
#: src/dusk/ui/overlay/uiloading.c
msgid "ui.loading.text"
msgstr "loading"
#: src/dusk/ui/overlay/uiautosave.c
msgid "ui.autosave.saving"
msgstr "SAVING"
msgid "item.potion.name"
msgstr "Potion"
msgid "item.potato.name"
msgstr "Potato"
msgid "item.apple.name"
msgstr "Apple"
@@ -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"
}
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

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
+32
View File
@@ -0,0 +1,32 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# NOTE: libmad itself is GPL-licensed (see https://www.underbit.com/products/mad/),
# not MIT like the rest of this project. Any binary linking it must comply
# with the GPL - a deliberate tradeoff accepted for MP3 decode correctness
# (see audiostreammp3decodersw.c's own comment on why minimp3 wasn't enough).
find_path(LIBMAD_INCLUDE_DIR NAMES mad.h)
mark_as_advanced(LIBMAD_INCLUDE_DIR)
find_library(LIBMAD_LIBRARY NAMES mad)
mark_as_advanced(LIBMAD_LIBRARY)
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(
libmad
REQUIRED_VARS
LIBMAD_LIBRARY
LIBMAD_INCLUDE_DIR
)
if(libmad_FOUND AND NOT TARGET libmad::mad)
add_library(libmad::mad UNKNOWN IMPORTED)
set_target_properties(libmad::mad
PROPERTIES
INTERFACE_INCLUDE_DIRECTORIES ${LIBMAD_INCLUDE_DIR}
IMPORTED_LOCATION ${LIBMAD_LIBRARY}
)
endif()
+5
View File
@@ -16,6 +16,9 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DOL=1 DOL=1
ISO=2 ISO=2
DUSK_DOLPHIN_BUILD_TYPE=${DUSK_DOLPHIN_BUILD_TYPE} DUSK_DOLPHIN_BUILD_TYPE=${DUSK_DOLPHIN_BUILD_TYPE}
# GameCube/Wii PowerPC is always big-endian; declare it at compile time
# like every other target instead of relying on endian.h's runtime probe.
DUSK_PLATFORM_ENDIAN_BIG
) )
# Custom compiler flags # Custom compiler flags
@@ -58,6 +61,8 @@ target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
zstd zstd
z z
lzma lzma
asnd
mad
) )
if(DUSK_DOLPHIN_BUILD_TYPE STREQUAL "ISO") if(DUSK_DOLPHIN_BUILD_TYPE STREQUAL "ISO")
+1 -1
View File
@@ -36,7 +36,7 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
# DUSK_OPENGL_LEGACY # DUSK_OPENGL_LEGACY
DUSK_LINUX DUSK_LINUX
DUSK_DISPLAY_SIZE_DYNAMIC DUSK_DISPLAY_SIZE_DYNAMIC
DUSK_DISPLAY_WIDTH_DEFAULT=640 DUSK_DISPLAY_WIDTH_DEFAULT=854
DUSK_DISPLAY_HEIGHT_DEFAULT=480 DUSK_DISPLAY_HEIGHT_DEFAULT=480
DUSK_DISPLAY_SCREEN_HEIGHT=240 DUSK_DISPLAY_SCREEN_HEIGHT=240
DUSK_INPUT_KEYBOARD DUSK_INPUT_KEYBOARD
+61
View File
@@ -30,6 +30,7 @@ target_link_libraries(${DUSK_BINARY_TARGET_NAME} PUBLIC
pspgu pspgu
pspaudio pspaudio
pspaudiolib pspaudiolib
pspmp3
psputility psputility
pspvfpu pspvfpu
pspvram pspvram
@@ -56,14 +57,45 @@ target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
DUSK_DISPLAY_HEIGHT=272 DUSK_DISPLAY_HEIGHT=272
DUSK_THREAD_PTHREAD DUSK_THREAD_PTHREAD
DUSK_TIME_DYNAMIC DUSK_TIME_DYNAMIC
DUSK_DISPLAY_OVERSCAN=6
) )
# logDebug()/logError() additionally write every call to a file on the
# Memory Stick (ms0:/PSP/GAME/Dusk/*.log) - a real, measured source of
# per-call hitching (fopen+write+fclose every log line), not just a
# theoretical cost. Default off so a normal build doesn't pay it; opt in
# with -DDUSK_PSP_LOG_FILE=ON when the log file itself is actually needed
# (e.g. no way to see stdout on real hardware).
set(DUSK_PSP_LOG_FILE OFF CACHE BOOL "Also write PSP debug/error logs to ms0:/PSP/GAME/Dusk/*.log (slow - Memory Stick I/O per call).")
if(DUSK_PSP_LOG_FILE)
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
DUSK_PSP_LOG_FILE
)
endif()
if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug") if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
DUSK_ASSERTIONS_FAKED DUSK_ASSERTIONS_FAKED
) )
endif() endif()
# Generate PARAM.SFO as a normal tracked build output (instead of letting
# create_pbp_file() auto-generate + delete it) so it can be reused below by
# a properly dependency-tracked EBOOT.PBP repack step.
set(DUSK_PSP_PARAM_SFO "${DUSK_BUILD_DIR}/PARAM.SFO")
add_custom_command(
OUTPUT "${DUSK_PSP_PARAM_SFO}"
COMMAND "$ENV{PSPDEV}/bin/mksfoex" "-d" "MEMSIZE=1" "-s" "APP_VER=01.00"
"${DUSK_BINARY_TARGET_NAME}" "${DUSK_PSP_PARAM_SFO}"
COMMENT "Generating PARAM.SFO for ${DUSK_BINARY_TARGET_NAME}"
VERBATIM
)
add_custom_target(DuskPspParamSfo DEPENDS "${DUSK_PSP_PARAM_SFO}")
# create_pbp_file()'s own POST_BUILD chain (below) also consumes
# DUSK_PSP_PARAM_SFO, so make sure it exists before that chain runs.
add_dependencies(${DUSK_BINARY_TARGET_NAME} DuskPspParamSfo)
# Postbuild, create .pbp file for PSP. # Postbuild, create .pbp file for PSP.
create_pbp_file( create_pbp_file(
TARGET "${DUSK_BINARY_TARGET_NAME}" TARGET "${DUSK_BINARY_TARGET_NAME}"
@@ -73,4 +105,33 @@ create_pbp_file(
TITLE "${DUSK_BINARY_TARGET_NAME}" TITLE "${DUSK_BINARY_TARGET_NAME}"
PSAR_PATH ${DUSK_ASSETS_ZIP} PSAR_PATH ${DUSK_ASSETS_ZIP}
VERSION 01.00 VERSION 01.00
SFO_PATH "${DUSK_PSP_PARAM_SFO}"
OUTPUT_DIR "${DUSK_BUILD_DIR}"
) )
# CreatePBP.cmake's pack-pbp step is a POST_BUILD command tied to the
# executable target, so it only reruns when the ELF itself relinks. That
# means regenerating dusk.dsk (assets) alone, without touching any C
# source, silently leaves EBOOT.PBP embedding a stale asset pak. Repack it
# here as a normal file-tracked custom command depending on both the
# executable and the asset zip, so EBOOT.PBP always reflects the current
# assets even when nothing else about the build changed.
set(DUSK_PSP_EBOOT "${DUSK_BUILD_DIR}/EBOOT.PBP")
if(BUILD_PRX)
set(DUSK_PSP_EXECUTABLE "$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>.prx")
else()
set(DUSK_PSP_EXECUTABLE "$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>")
endif()
add_custom_command(
OUTPUT "${DUSK_PSP_EBOOT}"
COMMAND "$ENV{PSPDEV}/bin/pack-pbp" "${DUSK_PSP_EBOOT}" "${DUSK_PSP_PARAM_SFO}"
"NULL" "NULL" "NULL" "NULL" "NULL"
"${DUSK_PSP_EXECUTABLE}" "${DUSK_ASSETS_ZIP}"
DEPENDS
"$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>"
"${DUSK_PSP_PARAM_SFO}"
"${DUSK_ASSETS_ZIP}"
COMMENT "Repacking EBOOT.PBP (tracks executable + asset pak freshness)"
VERBATIM
)
add_custom_target(DuskPspEbootRepack ALL DEPENDS "${DUSK_PSP_EBOOT}")
+11
View File
@@ -4,6 +4,17 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_WII DUSK_WII
) )
# Wii save storage method - see src/duskdolphin/save/savedeviceplatform.h.
set(DUSK_SAVE_WII_METHOD "SD" CACHE STRING
"Wii save storage: NAND (internal storage via ISFS), CARD (GameCube-\
compatible memory card emulation), or SD (SD card via libfat)"
)
set_property(CACHE DUSK_SAVE_WII_METHOD PROPERTY STRINGS "NAND" "CARD" "SD")
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_SAVE_WII_METHOD_${DUSK_SAVE_WII_METHOD}
)
# Generate Homebrew Channel meta.xml from project identity variables # Generate Homebrew Channel meta.xml from project identity variables
string(TIMESTAMP DUSK_BUILD_DATE "%Y%m%d000000" UTC) string(TIMESTAMP DUSK_BUILD_DATE "%Y%m%d000000" UTC)
configure_file( configure_file(
+7 -5
View File
@@ -345,12 +345,14 @@
return promise; return promise;
} }
const MAP_NAME = "overworld";
function chunkPaths(x, y, z) { function chunkPaths(x, y, z) {
return { return {
raw: `assetsraw/chunks/chunk_${x}_${y}_${z}.json`, raw: `assetsraw/maps/${MAP_NAME}/chunks/chunk_${x}_${y}_${z}.json`,
dcf: `assets/chunks/${x}_${y}_${z}.dcf`, dcf: `assets/maps/${MAP_NAME}/chunks/${x}_${y}_${z}.dcf`,
terrainDmf: `assets/meshes/chunks/chunk_${x}_${y}_${z}_0.dmf`, terrainDmf: `assets/maps/${MAP_NAME}/meshes/chunks/chunk_${x}_${y}_${z}_0.dmf`,
terrainModel: `assets/models/chunks/chunk_${x}_${y}_${z}_0.json`, terrainModel: `assets/maps/${MAP_NAME}/models/chunks/chunk_${x}_${y}_${z}_0.json`,
}; };
} }
@@ -767,7 +769,7 @@
async function buildChunkBrowseList() { async function buildChunkBrowseList() {
const list = document.getElementById("chunkBrowseList"); const list = document.getElementById("chunkBrowseList");
list.innerHTML = ""; list.innerHTML = "";
const res = await apiLs("assetsraw/chunks"); const res = await apiLs(`assetsraw/maps/${MAP_NAME}/chunks`);
for(const entry of res.entries || []) { for(const entry of res.entries || []) {
const m = entry.name.match(/^chunk_(-?\d+)_(-?\d+)_(-?\d+)\.json$/); const m = entry.name.match(/^chunk_(-?\d+)_(-?\d+)_(-?\d+)\.json$/);
if(!m) continue; if(!m) continue;
+4 -1
View File
@@ -209,7 +209,10 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"error": "invalid chunk coordinates"}, 400) self.send_json({"error": "invalid chunk coordinates"}, 400)
return 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(): if not json_path.is_file():
self.send_json({"error": "chunk json not found"}, 404) self.send_json({"error": "chunk json not found"}, 404)
return return
+10 -1
View File
@@ -5,10 +5,15 @@
add_subdirectory(dusk) add_subdirectory(dusk)
if(DUSK_NETWORK)
add_subdirectory(dusknetwork)
endif()
if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli") if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli")
add_subdirectory(dusklinux) add_subdirectory(dusklinux)
add_subdirectory(dusksdl2) add_subdirectory(dusksdl2)
add_subdirectory(duskgl) add_subdirectory(duskgl)
add_subdirectory(duskmad)
elseif(DUSK_TARGET_SYSTEM STREQUAL "psp") elseif(DUSK_TARGET_SYSTEM STREQUAL "psp")
add_subdirectory(duskpsp) add_subdirectory(duskpsp)
@@ -20,7 +25,11 @@ elseif(DUSK_TARGET_SYSTEM STREQUAL "vita")
add_subdirectory(dusksdl2) add_subdirectory(dusksdl2)
add_subdirectory(duskgl) add_subdirectory(duskgl)
elseif(DUSK_TARGET_SYSTEM STREQUAL "wii" OR DUSK_TARGET_SYSTEM STREQUAL "gamecube") elseif(
DUSK_TARGET_SYSTEM STREQUAL "wii" OR
DUSK_TARGET_SYSTEM STREQUAL "gamecube"
)
add_subdirectory(duskdolphin) add_subdirectory(duskdolphin)
add_subdirectory(duskmad)
endif() endif()
+15 -4
View File
@@ -14,12 +14,21 @@ if(NOT libzip_FOUND)
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC zip) target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC zip)
endif() endif()
# assetdsk.c calls crc32() directly (to verify dusk.dsk archive checksums).
# find_package(libzip) above already resolves ZLIB as a side effect, so
# ZLIB_FOUND may already be true without ZLIB::ZLIB having been linked to
# our target - link it unconditionally rather than guarding on ZLIB_FOUND.
find_package(ZLIB REQUIRED)
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC ZLIB::ZLIB)
if(NOT stb_image_FOUND) if(NOT stb_image_FOUND)
find_package(stb REQUIRED) find_package(stb REQUIRED)
if(STB_IMAGE_FOUND) if(STB_IMAGE_FOUND)
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC stb_image) target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC stb_image)
else() else()
message(FATAL_ERROR "stb_image not found. Please ensure stb is correctly fetched.") message(FATAL_ERROR
"stb_image not found. Please ensure stb is correctly fetched."
)
endif() endif()
endif() endif()
@@ -28,7 +37,9 @@ if(NOT yyjson_FOUND)
if(yyjson_FOUND) if(yyjson_FOUND)
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC yyjson::yyjson) target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC yyjson::yyjson)
else() else()
message(FATAL_ERROR "yyjson not found. Please ensure yyjson is correctly fetched.") message(FATAL_ERROR
"yyjson not found. Please ensure yyjson is correctly fetched."
)
endif() endif()
endif() endif()
@@ -53,9 +64,9 @@ target_sources(${DUSK_BINARY_TARGET_NAME}
# Subdirs # Subdirs
add_subdirectory(animation) add_subdirectory(animation)
add_subdirectory(event)
add_subdirectory(assert) add_subdirectory(assert)
add_subdirectory(asset) add_subdirectory(asset)
add_subdirectory(audio)
add_subdirectory(console) add_subdirectory(console)
add_subdirectory(display) add_subdirectory(display)
add_subdirectory(log) add_subdirectory(log)
@@ -63,12 +74,12 @@ add_subdirectory(engine)
add_subdirectory(error) add_subdirectory(error)
add_subdirectory(input) add_subdirectory(input)
add_subdirectory(locale) add_subdirectory(locale)
add_subdirectory(perf)
add_subdirectory(rpg) add_subdirectory(rpg)
add_subdirectory(scene) add_subdirectory(scene)
add_subdirectory(system) add_subdirectory(system)
add_subdirectory(time) add_subdirectory(time)
add_subdirectory(ui) add_subdirectory(ui)
add_subdirectory(network)
add_subdirectory(save) add_subdirectory(save)
add_subdirectory(util) add_subdirectory(util)
add_subdirectory(thread) add_subdirectory(thread)
+1
View File
@@ -7,4 +7,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
easing.c easing.c
animation.c animation.c
keyframe.c
) )
+103 -23
View File
@@ -11,42 +11,122 @@
void animationInit( void animationInit(
animation_t *anim, animation_t *anim,
keyframe_t *keyframes, keyframe_t *keyframes,
uint16_t keyframeCount uint16_t *keyframeCounts,
const uint16_t layerCount
) { ) {
assertNotNull(anim, "Animation pointer cannot be null."); assertNotNull(anim, "Animation pointer cannot be null.");
assertNotNull(keyframes, "Keyframes pointer cannot be null."); assertNotNull(keyframes, "Keyframes pointer cannot be null.");
assertTrue(keyframeCount > 0, "Keyframe count must be more than 0."); assertNotNull(keyframeCounts, "Keyframe counts pointer cannot be null.");
assertTrue(layerCount > 0, "Layer count must be greater than zero.");
memoryZero(anim, sizeof(animation_t));
anim->keyframes = keyframes; anim->keyframes = keyframes;
anim->keyframeCount = keyframeCount; anim->keyframeCounts = keyframeCounts;
anim->layerCount = layerCount;
// Determine duration
float_t duration = 0.0f;
for(uint16_t layer = 0; layer < layerCount; layer++) {
uint16_t keyframeCount = keyframeCounts[layer];
assertTrue(keyframeCount > 0, "Keyframe count invalid.");
keyframe_t *layerKeyframes = keyframes + layer * keyframeCount;
#ifdef DUSK_ASSERTIONS
// Check that the keyframes are sorted by time.
for(uint16_t i = 1; i < keyframeCount; i++) {
assertTrue(
layerKeyframes[i].time >= layerKeyframes[i - 1].time,
"Keyframes must be sorted by time."
);
}
#endif
keyframe_t *lastKeyframe = layerKeyframes + keyframeCount - 1;
duration = mathMax(duration, lastKeyframe->time);
}
assertTrue(duration > 0, "Animation duration must be greater than 0.");
anim->duration = duration;
} }
float_t animationGetValue(animation_t *anim, const float_t time) { float_t animationGetLayerValue(const animation_t *anim, const uint16_t layer) {
assertNotNull(anim, "Animation pointer cannot be null."); assertNotNull(anim, "Animation pointer cannot be null.");
assertNotNull(anim->keyframes, "Keyframes pointer cannot be null."); assertTrue(layer < anim->layerCount, "Layer index out of bounds.");
assertTrue(anim->keyframeCount > 0, "Keyframe count invalid.");
assertTrue(time >= 0, "Time must be non-negative.");
keyframe_t *start; uint16_t keyframeCount = anim->keyframeCounts[layer];
keyframe_t *end; keyframe_t *layerKeyframes = anim->keyframes + layer * keyframeCount;
keyframe_t *last = anim->keyframes + anim->keyframeCount - 1; return keyframeGetValue(layerKeyframes, keyframeCount, anim->time);
keyframe_t *current = anim->keyframes; }
start = current;
void animationUpdate(
do { animation_t *anim,
if(current->time > time) { const float_t deltaTime
end = current; ) {
assertNotNull(anim, "Animation pointer cannot be null.");
assertTrue(deltaTime >= 0, "Delta time must be non-negative.");
bool_t justCompleted = false;
if(!(anim->flags & ANIMATION_FLAG_INTERNAL_COMPLETED)) {
bool_t loop = (anim->flags & ANIMATION_FLAG_LOOP) != 0;
bool_t pingpong = (anim->flags & ANIMATION_FLAG_PINGPONG) != 0;
assertFalse(
loop && pingpong,
"Cannot set both ANIMATION_FLAG_LOOP and ANIMATION_FLAG_PINGPONG."
);
bool_t backward;
if(pingpong) {
backward = (anim->flags & ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD) != 0;
} else {
backward = (anim->flags & ANIMATION_FLAG_REVERSE) != 0;
}
// Resolve boundary crossings one at a time, so a single large deltaTime
// can correctly loop/pingpong across multiple boundaries in one call.
float_t remaining = deltaTime;
while(remaining > 0.0f) {
float_t toBoundary = (
backward ? anim->time : (anim->duration - anim->time)
);
if(remaining < toBoundary) {
anim->time += backward ? -remaining : remaining;
break; break;
} }
start = current;
current++;
if(current > last) { remaining -= toBoundary;
end = start; anim->time = backward ? 0.0f : anim->duration;
bool_t stopHere;
if(backward) {
stopHere = (anim->flags & ANIMATION_FLAG_STOP_BEGINNING) != 0;
} else {
stopHere = (anim->flags & ANIMATION_FLAG_STOP_END) != 0;
}
if(stopHere) {
justCompleted = true;
break;
} else if(pingpong) {
backward = !backward;
if(backward) anim->flags |= ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD;
else anim->flags &= ~ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD;
} else if(loop) {
anim->time = backward ? anim->duration : 0.0f;
if(anim->onLoop) anim->onLoop(anim->user);
} else {
justCompleted = true;
break; break;
}
}
if(justCompleted) anim->flags |= ANIMATION_FLAG_INTERNAL_COMPLETED;
}
// Call onUpdate for each layer.
for(uint16_t layer = 0; layer < anim->layerCount; layer++) {
float_t value = animationGetLayerValue(anim, layer);
if(anim->onUpdate) anim->onUpdate(layer, value, anim->user);
} }
} while(true);
float_t t = (time - start->time) / (end->time - start->time); if(justCompleted && anim->onComplete) anim->onComplete(anim->user);
return mathLerp(start->value, end->value, easingApply(start->easing, t));
} }
+71 -11
View File
@@ -6,29 +6,89 @@
#pragma once #pragma once
#include "keyframe.h" #include "keyframe.h"
#define ANIMATION_FLAG_LOOP (1 << 0)
#define ANIMATION_FLAG_REVERSE (1 << 1)
#define ANIMATION_FLAG_PINGPONG (1 << 2)
#define ANIMATION_FLAG_STOP_BEGINNING (1 << 3)
#define ANIMATION_FLAG_STOP_END (1 << 4)
// Internal - tracks which direction a pingponging animation is currently
// travelling. Do not set this manually, it is managed by animationUpdate().
#define ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD (1 << 7)
// Internal - set once the animation has stopped advancing (see
// animationUpdate()). Do not set this manually. There is currently no way to
// restart a completed animation short of clearing this bit and resetting
// anim->time by hand.
#define ANIMATION_FLAG_INTERNAL_COMPLETED (1 << 6)
typedef struct { typedef struct {
keyframe_t *keyframes; keyframe_t *keyframes;
uint16_t keyframeCount; uint16_t *keyframeCounts;
uint16_t layerCount;
float_t time;
float_t duration;
uint8_t flags;
void *user;
void (*onUpdate)(const uint16_t layer, const float_t value, void *user);
void (*onComplete)(void *user);
void (*onLoop)(void *user);
} animation_t; } animation_t;
/** /**
* Initializes an animation. * Initializes an animation with the given keyframes and layer count.
* *
* @param anim The animation to initialize. * @param anim Pointer to the animation to initialize.
* @param keyframes The keyframes to use for the animation. * @param keyframes Pointer to the array of keyframes for each layer.
* @param keyframeCount The number of keyframes in the animation. * @param keyframeCount Number of keyframes in each layer.
* @param layerCount Number of layers in the animation.
*/ */
void animationInit( void animationInit(
animation_t *anim, animation_t *anim,
keyframe_t *keyframes, keyframe_t *keyframes,
uint16_t keyframeCount uint16_t *keyframeCounts,
const uint16_t layerCount
); );
/** /**
* Gets the value of the animation at a given time. * Sets the current time of the animation, clamping it to the valid range.
* This will call the onUpdate callback but none of the other callbacks.
* *
* @param anim The animation to get the value from. * @param anim Pointer to the animation to set the time for.
* @param time The time at which to get the value, in seconds. * @param time The new time to set for the animation.
* @return The value of the animation at the given time.
*/ */
float_t animationGetValue(animation_t *anim, const float_t time); void animationSetTime(animation_t *anim, const float_t time);
/**
* Gets the current value of a specific layer in the animation based on the
* current animation time.
*/
float_t animationGetLayerValue(const animation_t *anim, const uint16_t layer);
/**
* Updates the animation state based on the elapsed time. Advances anim->time
* by deltaTime (or against it, if ANIMATION_FLAG_REVERSE is set), then
* resolves whatever happens when it reaches the 0 or duration boundary:
*
* - ANIMATION_FLAG_PINGPONG: reflects off the boundary and continues playing
* in the opposite direction, forever, unless stopped (see below).
* - ANIMATION_FLAG_LOOP: wraps back around to the other boundary and keeps
* playing in the same direction, forever, unless stopped (see below).
* - ANIMATION_FLAG_STOP_BEGINNING / ANIMATION_FLAG_STOP_END: when the
* animation reaches that specific boundary, it clamps there and stops
* (firing onComplete) instead of looping/pingponging past it.
* - If none of the above apply at a boundary, the animation clamps there and
* stops, firing onComplete.
*
* onUpdate is called for every layer on every call. onLoop is called each
* time a loop wraps around. onComplete is called at most once, the moment
* the animation stops advancing.
*
* @param anim Pointer to the animation to update.
* @param deltaTime Time elapsed since the last update (in seconds).
*/
void animationUpdate(
animation_t *anim,
const float_t deltaTime
);
+45
View File
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "keyframe.h"
#include "assert/assert.h"
#include "util/math.h"
float_t keyframeGetValue(
const keyframe_t *keyframes,
const uint32_t keyframeCount,
const float_t time
) {
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
assertTrue(keyframeCount > 0, "Keyframe count must be more than 0.");
assertTrue(time >= 0, "Time must be non-negative.");
#ifdef DUSK_ASSERTIONS
// Checks that the keyframes are sorted by time.
for(uint32_t i = 1; i < keyframeCount; i++) {
assertTrue(
keyframes[i].time >= keyframes[i - 1].time,
"Keyframes must be sorted by time."
);
}
#endif
keyframe_t *last = (keyframe_t *)(keyframes + keyframeCount - 1);
if(time >= last->time) return last->value;
// Since time < last->time (checked above), current is guaranteed to stop
// at or before reaching last, so no separate end-of-array check is needed.
keyframe_t *current = (keyframe_t *)keyframes;
keyframe_t *start = current;
while(current->time <= time) {
start = current;
current++;
}
keyframe_t *end = current;
float_t t = (time - start->time) / (end->time - start->time);
return mathLerp(start->value, end->value, easingApply(start->easing, t));
}
+14
View File
@@ -11,3 +11,17 @@ typedef struct {
float_t value; float_t value;
easingtype_t easing; easingtype_t easing;
} keyframe_t; } keyframe_t;
/**
* Gets the value of a keyframe at a given time.
*
* @param keyframes The keyframes to get the value from.
* @param keyframeCount The number of keyframes in the array.
* @param time The time at which to get the value, in seconds.
* @return The value of the keyframe at the given time.
*/
float_t keyframeGetValue(
const keyframe_t *keyframes,
const uint32_t keyframeCount,
const float_t time
);
+2
View File
@@ -22,6 +22,8 @@
#endif #endif
#ifndef DUSK_ASSERTIONS_FAKED #ifndef DUSK_ASSERTIONS_FAKED
#define DUSK_ASSERTIONS 1
/** /**
* Initializes the assert system. Must be the very first call in engine * Initializes the assert system. Must be the very first call in engine
* startup. * startup.
+1 -1
View File
@@ -7,7 +7,7 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
asset.c asset.c
assetbatch.c assetdsk.c
assetfile.c assetfile.c
) )
+59 -51
View File
@@ -19,6 +19,8 @@ asset_t ASSET;
errorret_t assetInit(void) { errorret_t assetInit(void) {
memoryZero(&ASSET, sizeof(asset_t)); memoryZero(&ASSET, sizeof(asset_t));
threadMutexInit(&ASSET.zipLock);
for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) {
threadMutexInit(&ASSET.loading[i].mutex); threadMutexInit(&ASSET.loading[i].mutex);
} }
@@ -35,9 +37,10 @@ errorret_t assetInit(void) {
bool_t assetFileExists(const char_t *filename) { bool_t assetFileExists(const char_t *filename) {
assertStrLenMax(filename, ASSET_FILE_NAME_MAX, "Filename too long."); assertStrLenMax(filename, ASSET_FILE_NAME_MAX, "Filename too long.");
zip_int64_t idx = zip_name_locate(ASSET.zip, filename, 0); threadMutexLock(&ASSET.zipLock);
if(idx < 0) return false; bool_t found = zip_name_locate(ASSET.zip, filename, 0) >= 0;
return true; threadMutexUnlock(&ASSET.zipLock);
return found;
} }
assetentry_t * assetGetEntry( assetentry_t * assetGetEntry(
@@ -59,7 +62,10 @@ assetentry_t * assetGetEntry(
entry++; entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX); } while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
// We did not find one existing, Find first available slot. // We did not find one existing. Find first available slot, reaping
// zero-ref entries to make room if none are immediately available.
bool_t reaped = false;
for(;;) {
entry = ASSET.entries; entry = ASSET.entries;
do { do {
if(entry->type != ASSET_LOADER_TYPE_NULL) { if(entry->type != ASSET_LOADER_TYPE_NULL) {
@@ -74,6 +80,11 @@ assetentry_t * assetGetEntry(
entry++; entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX); } while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
if(reaped) break;
reaped = true;
errorCatch(assetReapUnused());
}
assertUnreachable("No available asset entry slots."); assertUnreachable("No available asset entry slots.");
return NULL; return NULL;
} }
@@ -109,6 +120,15 @@ errorret_t assetRequireLoaded(assetentry_t *entry) {
assetEntryLock(entry); assetEntryLock(entry);
while(entry->state != ASSET_ENTRY_STATE_LOADED) { while(entry->state != ASSET_ENTRY_STATE_LOADED) {
// A failed load transitions to ERROR, not LOADED - without this check
// this loop spins forever on any load failure (assetUpdate() itself
// still returns OK, since a single asset failing isn't meant to halt
// the whole update loop - see its ASSET_ENTRY_STATE_ERROR handling).
if(entry->state == ASSET_ENTRY_STATE_ERROR) {
assetEntryUnlock(entry);
errorThrow("Failed to load asset: %s", entry->name);
}
usleep(1000); usleep(1000);
errorret_t ret = assetUpdate(); errorret_t ret = assetUpdate();
if(errorIsNotOk(ret)) { if(errorIsNotOk(ret)) {
@@ -191,6 +211,32 @@ void assetUnlockEntry(assetentry_t *entry) {
assetEntryUnlock(entry); assetEntryUnlock(entry);
} }
errorret_t assetReapUnused(void) {
assertIsMainThread("assetReapUnused must be called from the main thread.");
// Repeatedly find and dispose zero-ref LOADED entries until none remain.
// This handles dependency chains where an entry (e.g. a model) holds refs
// on child entries (mesh, texture): dispose parents first so child ref
// counts drop to zero, then pick up the children on the next pass. Without
// this, a forward-only scan fails when a shared child entry appears before
// a parent that still holds a ref to it.
bool_t any;
do {
any = false;
assetentry_t *entry = ASSET.entries;
do {
if(entry->type == ASSET_LOADER_TYPE_NULL) { entry++; continue; }
if(entry->state != ASSET_ENTRY_STATE_LOADED) { entry++; continue; }
if(entry->refs.count > 0) { entry++; continue; }
errorChain(assetEntryDispose(entry));
any = true;
entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
} while(any);
errorOk();
}
errorret_t assetUpdate(void) { errorret_t assetUpdate(void) {
assertIsMainThread("assetUpdate must be called from the main thread."); assertIsMainThread("assetUpdate must be called from the main thread.");
@@ -288,7 +334,9 @@ errorret_t assetUpdate(void) {
} else if(loading->entry->state == ASSET_ENTRY_STATE_LOADED) { } else if(loading->entry->state == ASSET_ENTRY_STATE_LOADED) {
assetentry_t *loadedEntry = loading->entry; assetentry_t *loadedEntry = loading->entry;
loading->entry = NULL; loading->entry = NULL;
eventInvoke(&loadedEntry->onLoaded, loadedEntry); if(loadedEntry->onLoaded) {
loadedEntry->onLoaded(loadedEntry, loadedEntry->onLoadedUser);
}
} }
loading++; loading++;
@@ -312,8 +360,10 @@ errorret_t assetUpdate(void) {
assetentry_t *errEntry = loading->entry; assetentry_t *errEntry = loading->entry;
loading->entry = NULL; loading->entry = NULL;
threadMutexUnlock(&loading->mutex); threadMutexUnlock(&loading->mutex);
eventInvoke(&errEntry->onError, errEntry); if(errEntry->onError) {
errorThrow("Failed to load asset asynchronously."); errEntry->onError(errEntry, errEntry->onErrorUser);
}
loading++;
break; break;
} }
@@ -324,30 +374,6 @@ errorret_t assetUpdate(void) {
} }
} while(loading < ASSET.loading + ASSET_LOADING_COUNT_MAX); } while(loading < ASSET.loading + ASSET_LOADING_COUNT_MAX);
// Reap unused entries.
entry = ASSET.entries;
do {
if(entry->state != ASSET_ENTRY_STATE_LOADED) {
entry++;
continue;
}
if(entry->type == ASSET_LOADER_TYPE_NULL) {
entry++;
continue;
}
if(entry->refs.count > 0) {
entry++;
continue;
}
// consolePrint("Reaping asset %s", entry->name);
errorChain(assetEntryDispose(entry));
entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
errorOk(); errorOk();
} }
@@ -413,26 +439,7 @@ errorret_t assetDispose(void) {
assertIsMainThread("Must be called from the main thread."); assertIsMainThread("Must be called from the main thread.");
threadStop(&ASSET.loadThread); threadStop(&ASSET.loadThread);
// Drain-dispose: repeatedly find and dispose zero-ref LOADED entries errorChain(assetReapUnused());
// until none remain. This handles dependency chains where an entry
// (e.g. a model) holds refs on child entries (mesh, texture): dispose
// parents first so child ref counts drop to zero, then pick up the
// children on the next pass. Without this, a forward-only scan fails
// when a shared child entry appears before a parent that still holds a
// ref to it.
bool_t any;
do {
any = false;
assetentry_t *e = ASSET.entries;
do {
if(e->type == ASSET_LOADER_TYPE_NULL) { e++; continue; }
if(e->state != ASSET_ENTRY_STATE_LOADED) { e++; continue; }
if(e->refs.count > 0) { e++; continue; }
errorChain(assetEntryDispose(e));
any = true;
e++;
} while(e < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
} while(any);
// Cleanup zip file. // Cleanup zip file.
if(ASSET.zip != NULL) { if(ASSET.zip != NULL) {
@@ -443,5 +450,6 @@ errorret_t assetDispose(void) {
} }
errorChain(assetDisposePlatform()); errorChain(assetDisposePlatform());
threadMutexDispose(&ASSET.zipLock);
errorOk(); errorOk();
} }
+42 -2
View File
@@ -23,13 +23,43 @@
#define ASSET_FILE_NAME "dusk.dsk" #define ASSET_FILE_NAME "dusk.dsk"
#define ASSET_HEADER_SIZE 3 #define ASSET_HEADER_SIZE 3
#define ASSET_LOADING_COUNT_MAX 20 #define ASSET_LOADING_COUNT_MAX 10
#define ASSET_ENTRY_COUNT_MAX 128 #define ASSET_ENTRY_COUNT_MAX 64
#define ASSET_BASE_DIRECTORY_MAX 256
typedef struct asset_s { typedef struct asset_s {
// The dusk.dsk archive - a single uncompressed (ZIP_STORED) zip, so
// every entry gets the reliable repeated seeking/re-opening that
// libzip only supports reliably for uncompressed entries (locale
// strings, MP3 loop restarts, etc.).
zip_t *zip; zip_t *zip;
// Directory dusk.dsk was actually found/opened in (on PSP, the directory
// EBOOT.PBP lives in, since dusk.dsk is embedded inside it rather than a
// loose file) - populated by each platform's own assetInitXxx() once it
// locates the file. Save devices that persist to a real filesystem path
// (Linux, PSP, Wii SD) use this as their base directory instead of a
// separate hardcoded location, so saves live next to wherever the game
// itself was actually launched from rather than some unrelated fixed
// path. Not meaningful for devices with no filesystem-path concept at
// all (GameCube/Wii memory card, Wii NAND's ISFS namespace).
char_t baseDirectory[ASSET_BASE_DIRECTORY_MAX];
assetplatform_t platform; assetplatform_t platform;
// Guards every libzip call against ASSET.zip (zip_fopen,
// zip_fread, zip_fclose, zip_fseek, zip_stat, zip_name_locate - see
// assetfile.c/assetFileExists). libzip is documented as not thread-safe,
// and this asset system genuinely calls it from multiple real threads at
// once (main thread, the background load thread below, and - on
// platforms that stream PCM from an asset on their own thread, like PSP
// - an audio feeder thread too). Confirmed necessary on real PSP
// hardware: without this lock, concurrent zip_fopen/zip_fread calls from
// different threads corrupted reads (EINVAL, then zlib data errors) once
// dusk.dsk stopped being read entirely into memory up front.
threadmutex_t zipLock;
// Background loading thread. // Background loading thread.
thread_t loadThread; thread_t loadThread;
@@ -112,6 +142,16 @@ void assetUnlock(const char_t *name);
*/ */
void assetUnlockEntry(assetentry_t *entry); void assetUnlockEntry(assetentry_t *entry);
/**
* Frees every currently unreferenced (zero-ref) loaded asset entry. Repeats
* until a full pass frees nothing further, since disposing a parent entry
* (e.g. a model) may drop a child entry's (e.g. a mesh) ref count to zero,
* making it eligible for reaping too.
*
* @return An error code if any entry could not be disposed properly.
*/
errorret_t assetReapUnused(void);
/** /**
* Requires an asset entry to be loaded. This will block until the asset entry * Requires an asset entry to be loaded. This will block until the asset entry
* is fully loaded. * is fully loaded.
-165
View File
@@ -1,165 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetbatch.h"
#include "asset.h"
#include "assert/assert.h"
#include "util/memory.h"
#include <unistd.h>
void assetBatchInit(
assetbatch_t *batch,
const uint16_t count,
const assetbatchdesc_t *descs
) {
assertNotNull(batch, "Batch cannot be NULL.");
assertNotNull(descs, "Descs cannot be NULL.");
assertTrue(count > 0, "Count must be greater than 0.");
assertTrue(
count <= ASSET_BATCH_COUNT_MAX, "Count exceeds ASSET_BATCH_COUNT_MAX."
);
memoryZero(batch, sizeof(assetbatch_t));
batch->count = count;
eventInit(
&batch->onLoaded,
batch->onLoadedCallbacks, batch->onLoadedUsers, ASSET_BATCH_EVENT_MAX
);
eventInit(
&batch->onEntryLoaded,
batch->onEntryLoadedCallbacks,
batch->onEntryLoadedUsers,
ASSET_BATCH_EVENT_MAX
);
eventInit(
&batch->onError,
batch->onErrorCallbacks, batch->onErrorUsers, ASSET_BATCH_EVENT_MAX
);
eventInit(
&batch->onEntryError,
batch->onEntryErrorCallbacks,
batch->onEntryErrorUsers,
ASSET_BATCH_EVENT_MAX
);
for(uint16_t i = 0; i < count; i++) {
batch->inputs[i] = descs[i].input;
batch->entries[i] = assetLock(
descs[i].path, descs[i].type, &batch->inputs[i]
);
if(batch->entries[i]->state == ASSET_ENTRY_STATE_LOADED) {
// Already loaded (cached) - count it now, no subscription needed.
batch->loadedCount++;
} else if(batch->entries[i]->state == ASSET_ENTRY_STATE_ERROR) {
batch->errorCount++;
} else {
eventSubscribe(
&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb, batch
);
eventSubscribe(
&batch->entries[i]->onError, assetBatchEntryOnErrorCb, batch
);
}
}
}
void assetBatchLock(assetbatch_t *batch) {
assertNotNull(batch, "Batch cannot be NULL.");
for(uint16_t i = 0; i < batch->count; i++) {
assetEntryLock(batch->entries[i]);
}
}
void assetBatchUnlock(assetbatch_t *batch) {
assertNotNull(batch, "Batch cannot be NULL.");
for(uint16_t i = 0; i < batch->count; i++) {
assetEntryUnlock(batch->entries[i]);
}
}
bool_t assetBatchIsLoaded(const assetbatch_t *batch) {
assertNotNull(batch, "Batch cannot be NULL.");
for(uint16_t i = 0; i < batch->count; i++) {
if(batch->entries[i]->state != ASSET_ENTRY_STATE_LOADED) return false;
}
return true;
}
bool_t assetBatchHasError(const assetbatch_t *batch) {
assertNotNull(batch, "Batch cannot be NULL.");
for(uint16_t i = 0; i < batch->count; i++) {
if(batch->entries[i]->state == ASSET_ENTRY_STATE_ERROR) return true;
}
return false;
}
errorret_t assetBatchRequireLoaded(assetbatch_t *batch) {
assertNotNull(batch, "Batch cannot be NULL.");
bool_t allDone;
do {
allDone = true;
for(uint16_t i = 0; i < batch->count; i++) {
const assetentrystate_t state = batch->entries[i]->state;
if(state == ASSET_ENTRY_STATE_ERROR) {
errorThrow("Asset '%s' failed to load.", batch->entries[i]->name);
}
if(state != ASSET_ENTRY_STATE_LOADED) {
allDone = false;
}
}
if(!allDone) {
usleep(1000);
errorChain(assetUpdate());
}
} while(!allDone);
errorOk();
}
void assetBatchDispose(assetbatch_t *batch) {
assertNotNull(batch, "Batch cannot be NULL.");
for(uint16_t i = 0; i < batch->count; i++) {
if(batch->entries[i]) {
// Unsubscribe while we still hold a lock so the entry is live.
eventUnsubscribe(&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb);
eventUnsubscribe(&batch->entries[i]->onError, assetBatchEntryOnErrorCb);
assetUnlockEntry(batch->entries[i]);
}
}
memoryZero(batch, sizeof(assetbatch_t));
}
void assetBatchEntryOnLoadedCb(void *params, void *user) {
assetentry_t *entry = (assetentry_t *)params;
assetbatch_t *batch = (assetbatch_t *)user;
batch->loadedCount++;
eventInvoke(&batch->onEntryLoaded, entry);
if((uint16_t)(batch->loadedCount + batch->errorCount) >= batch->count) {
if(batch->errorCount == 0) {
eventInvoke(&batch->onLoaded, batch);
} else {
eventInvoke(&batch->onError, batch);
}
}
}
void assetBatchEntryOnErrorCb(void *params, void *user) {
assetentry_t *entry = (assetentry_t *)params;
assetbatch_t *batch = (assetbatch_t *)user;
batch->errorCount++;
eventInvoke(&batch->onEntryError, entry);
if((uint16_t)(batch->loadedCount + batch->errorCount) >= batch->count) {
eventInvoke(&batch->onError, batch);
}
}
-124
View File
@@ -1,124 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "asset/loader/assetentry.h"
#include "asset/loader/assetloader.h"
#include "event/event.h"
#define ASSET_BATCH_COUNT_MAX 64
#define ASSET_BATCH_EVENT_MAX 4
typedef struct {
const char_t *path;
assetloadertype_t type;
assetloaderinput_t input;
} assetbatchdesc_t;
typedef struct {
assetentry_t *entries[ASSET_BATCH_COUNT_MAX];
assetloaderinput_t inputs[ASSET_BATCH_COUNT_MAX];
uint16_t count;
uint16_t loadedCount;
uint16_t errorCount;
/** Fires once when every entry loaded. params = assetbatch_t * */
event_t onLoaded;
eventcallback_t onLoadedCallbacks[ASSET_BATCH_EVENT_MAX];
void *onLoadedUsers[ASSET_BATCH_EVENT_MAX];
/** Fires each time a single entry loads. params = assetentry_t * */
event_t onEntryLoaded;
eventcallback_t onEntryLoadedCallbacks[ASSET_BATCH_EVENT_MAX];
void *onEntryLoadedUsers[ASSET_BATCH_EVENT_MAX];
/** Fires when all entries finish (any with errors). params: assetbatch_t * */
event_t onError;
eventcallback_t onErrorCallbacks[ASSET_BATCH_EVENT_MAX];
void *onErrorUsers[ASSET_BATCH_EVENT_MAX];
/** Fires each time a single entry errors. params = assetentry_t * */
event_t onEntryError;
eventcallback_t onEntryErrorCallbacks[ASSET_BATCH_EVENT_MAX];
void *onEntryErrorUsers[ASSET_BATCH_EVENT_MAX];
} assetbatch_t;
/**
* Initialises the batch from an array of descriptors. Each entry is locked
* and queued for loading immediately.
*
* @param batch Batch to initialise.
* @param descs Array of entry descriptors (need not outlive this call).
* @param count Number of descriptors (must be <= ASSET_BATCH_COUNT_MAX).
*/
void assetBatchInit(
assetbatch_t *batch,
uint16_t count,
const assetbatchdesc_t *descs
);
/**
* Acquires one additional lock on every entry in the batch.
*
* @param batch Batch to lock.
*/
void assetBatchLock(assetbatch_t *batch);
/**
* Releases one lock from every entry in the batch. When an entry's lock
* count reaches zero it will be reaped on the next assetUpdate.
*
* @param batch Batch to unlock.
*/
void assetBatchUnlock(assetbatch_t *batch);
/**
* Returns true if every entry in the batch has finished loading.
*
* @param batch Batch to query.
*/
bool_t assetBatchIsLoaded(const assetbatch_t *batch);
/**
* Returns true if any entry in the batch is in an error state.
*
* @param batch Batch to query.
*/
bool_t assetBatchHasError(const assetbatch_t *batch);
/**
* Blocks until every entry is loaded. Returns an error if any entry fails.
*
* @param batch Batch to wait on.
*/
errorret_t assetBatchRequireLoaded(assetbatch_t *batch);
/**
* Releases the batch's lock on every entry and clears the batch. After this
* call the batch struct may be reused with assetBatchInit.
*
* @param batch Batch to dispose.
*/
void assetBatchDispose(assetbatch_t *batch);
/**
* Event trampoline invoked when a batch entry finishes loading.
* Increments the loaded counter and fires batch-level events.
*
* @param params The loaded assetentry_t pointer.
* @param user The owning assetbatch_t pointer.
*/
void assetBatchEntryOnLoadedCb(void *params, void *user);
/**
* Event trampoline invoked when a batch entry fails to load.
* Increments the error counter and fires batch-level events.
*
* @param params The errored assetentry_t pointer.
* @param user The owning assetbatch_t pointer.
*/
void assetBatchEntryOnErrorCb(void *params, void *user);
+165
View File
@@ -0,0 +1,165 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetdsk.h"
#include "util/memory.h"
#include "util/endian.h"
#include "assert/assert.h"
#include <zlib.h>
errorret_t assetDskParseHeader(
const uint8_t *bytes,
const size_t bytesSize,
assetdskheader_t *outHeader
) {
assertNotNull(bytes, "Bytes cannot be NULL.");
assertNotNull(outHeader, "Out header cannot be NULL.");
if(bytesSize < ASSET_DSK_HEADER_SIZE) {
errorThrow("dusk.dsk header is truncated.");
}
if(memoryCompare(bytes, ASSET_DSK_MAGIC, ASSET_DSK_MAGIC_SIZE) != 0) {
errorThrow("dusk.dsk has an invalid magic header.");
}
// Every field is a little-endian uint32_t regardless of host - convert
// to host order (a no-op on little-endian hosts, a real byteswap on
// Dolphin's big-endian PowerPC).
uint32_t fields[3];
memoryCopy(fields, bytes + ASSET_DSK_MAGIC_SIZE, sizeof(fields));
for(uint8_t i = 0; i < 3; i++) {
fields[i] = endianLittleToHost32(fields[i]);
}
const uint32_t version = fields[0];
if(version != ASSET_DSK_VERSION) {
errorThrow("dusk.dsk has an unsupported version: %u", version);
}
outHeader->size = fields[1];
outHeader->checksum = fields[2];
errorOk();
}
errorret_t assetDskOpenFromPath(
const char_t *path,
zip_t **outZip
) {
errorChain(assetDskOpenFromPathRange(path, 0, SIZE_MAX, outZip));
errorOk();
}
errorret_t assetDskOpenFromPathRange(
const char_t *path,
const size_t baseOffset,
const size_t baseSize,
zip_t **outZip
) {
assertNotNull(path, "Path cannot be NULL.");
assertNotNull(outZip, "Out zip cannot be NULL.");
*outZip = NULL;
FILE *headerFile = fopen(path, "rb");
if(headerFile == NULL) {
errorThrow("Failed to open dusk.dsk: %s", path);
}
if(fseek(headerFile, (long) baseOffset, SEEK_SET) != 0) {
fclose(headerFile);
errorThrow("Failed to seek to dusk.dsk range in file: %s", path);
}
uint8_t headerBytes[ASSET_DSK_HEADER_SIZE];
size_t headerRead = fread(headerBytes, 1, sizeof(headerBytes), headerFile);
fclose(headerFile);
if(headerRead != sizeof(headerBytes)) {
errorThrow("Failed to read dusk.dsk header: %s", path);
}
assetdskheader_t header;
errorChain(assetDskParseHeader(headerBytes, sizeof(headerBytes), &header));
if((size_t) ASSET_DSK_HEADER_SIZE + header.size > baseSize) {
errorThrow("dusk.dsk header describes a range beyond its containing file.");
}
zip_error_t zipError;
zip_error_init(&zipError);
zip_source_t *source = zip_source_file_create(
path,
(zip_uint64_t) (baseOffset + ASSET_DSK_HEADER_SIZE),
(zip_int64_t) header.size,
&zipError
);
if(source == NULL) {
errorThrow(
"Failed to create dusk.dsk source: %s", zip_error_strerror(&zipError)
);
}
*outZip = zip_open_from_source(source, ZIP_RDONLY, &zipError);
if(*outZip == NULL) {
zip_source_free(source);
errorThrow(
"Failed to open dusk.dsk archive: %s", zip_error_strerror(&zipError)
);
}
errorOk();
}
errorret_t assetDskOpenFromBuffer(
uint8_t *buffer,
const size_t bufferSize,
zip_t **outZip
) {
assertNotNull(buffer, "Buffer cannot be NULL.");
assertNotNull(outZip, "Out zip cannot be NULL.");
*outZip = NULL;
assetdskheader_t header;
errorChain(assetDskParseHeader(buffer, bufferSize, &header));
if((size_t) ASSET_DSK_HEADER_SIZE + header.size > bufferSize) {
errorThrow("dusk.dsk header describes a range beyond the buffer.");
}
uint8_t *data = buffer + ASSET_DSK_HEADER_SIZE;
uint32_t checksum = (uint32_t) crc32(0L, data, (uInt) header.size);
if(checksum != header.checksum) {
errorThrow("dusk.dsk archive failed checksum verification.");
}
zip_error_t zipError;
zip_error_init(&zipError);
// freep=0 - this is a non-owning window into the same caller-owned
// buffer, not an independent allocation libzip should free.
zip_source_t *source = zip_source_buffer_create(
data, header.size, 0, &zipError
);
if(source == NULL) {
errorThrow(
"Failed to create dusk.dsk source: %s", zip_error_strerror(&zipError)
);
}
*outZip = zip_open_from_source(source, ZIP_RDONLY, &zipError);
if(*outZip == NULL) {
zip_source_free(source);
errorThrow(
"Failed to open dusk.dsk archive: %s", zip_error_strerror(&zipError)
);
}
errorOk();
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include <zip.h>
// "DSK2" - distinct from a plain zip's "PK\x03\x04" so a stray plain zip
// never gets misread as a valid dusk.dsk.
#define ASSET_DSK_MAGIC_SIZE 4
#define ASSET_DSK_MAGIC "DSK2"
#define ASSET_DSK_VERSION 2
// 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 a single uncompressed
* (ZIP_STORED) zip archive (see tools/asset/pack) rather than DEFLATEd -
* the bulk of what's packed (audio, images) is already compressed at the
* file level, so DEFLATE-ing the whole archive on top buys negligible
* space for real CPU cost, and every entry gets the reliable repeated
* seeking/re-opening that libzip only supports for uncompressed entries
* (locale line-by-line reads, MP3 loop restarts, etc.) instead of just a
* hand-picked subset of files.
*/
typedef struct {
uint32_t size;
uint32_t checksum;
} assetdskheader_t;
/**
* Parses a DSK2 header from a raw byte buffer (at least
* ASSET_DSK_HEADER_SIZE bytes), validating the magic/version and
* byte-swapping the little-endian fields to host order.
*
* @param bytes Buffer containing the header (and beyond).
* @param bytesSize Number of bytes available at `bytes`.
* @param outHeader Filled with the parsed header on success.
* @return OK on success, error if too short, bad magic, or unsupported version.
*/
errorret_t assetDskParseHeader(
const uint8_t *bytes,
const size_t bytesSize,
assetdskheader_t *outHeader
);
/**
* Opens the dusk.dsk archive given a filesystem path, using a lazy/
* windowed file-backed zip source - no more memory used than the
* existing per-platform small read buffers, matching the memory
* characteristics of a plain zip_open() on the whole file. The checksum
* is intentionally not verified here since doing so would require
* reading the whole (potentially large) archive just to compute it.
*
* See assetDskOpenFromPathRange() for the case where the DSK2 blob isn't
* the whole file (e.g. embedded inside another container).
*
* @param path Filesystem path to the dusk.dsk file.
* @param outZip Set to the opened archive on success.
* @return OK on success, error if the file is missing, too short, has a
* bad header, or the archive fails to open.
*/
errorret_t assetDskOpenFromPath(
const char_t *path,
zip_t **outZip
);
/**
* Same as assetDskOpenFromPath(), but the DSK2 blob doesn't start at the
* beginning of `path` - it's a byte range embedded inside a larger
* container file (e.g. the PSAR region of an EBOOT.PBP). Every offset the
* header describes is relative to `baseOffset`; `baseSize` bounds them
* (the size of the embedded blob, not the whole container file) - pass
* SIZE_MAX to skip that check when the caller doesn't know/care.
*
* @param path Filesystem path to the container file.
* @param baseOffset Byte offset within `path` where the DSK2 blob starts.
* @param baseSize Number of bytes available at `baseOffset`.
* @param outZip Set to the opened archive on success.
* @return OK on success, error if the file is missing, too short, has a
* bad header, or the archive fails to open.
*/
errorret_t assetDskOpenFromPathRange(
const char_t *path,
const size_t baseOffset,
const size_t baseSize,
zip_t **outZip
);
/**
* Opens the dusk.dsk archive already fully resident in memory (e.g. a
* PSAR embedded in an EBOOT.PBP, or an ISO-embedded file already read via
* DVD_ReadAbs - platforms that already buffer the whole file for reasons
* unrelated to this format). The returned archive doesn't take ownership
* of `buffer` (it's opened as a non-owning sub-range of it) - the caller
* remains responsible for freeing it, and must keep it alive for as long
* as the archive stays open. Verifies the checksum, since the bytes are
* already resident.
*
* @param buffer The whole dusk.dsk file's bytes.
* @param bufferSize Number of bytes at `buffer`.
* @param outZip Set to the opened archive on success.
* @return OK on success, error if too short, has a bad header/checksum, or
* the archive fails to open.
*/
errorret_t assetDskOpenFromBuffer(
uint8_t *buffer,
const size_t bufferSize,
zip_t **outZip
);
+69 -13
View File
@@ -24,9 +24,11 @@ errorret_t assetFileInit(
file->params = params; file->params = params;
file->output = output; file->output = output;
// Stat the file
zip_stat_init(&file->stat); zip_stat_init(&file->stat);
if(!zip_stat(ASSET.zip, filename, 0, &file->stat) == 0) { threadMutexLock(&ASSET.zipLock);
int statResult = zip_stat(ASSET.zip, filename, 0, &file->stat);
threadMutexUnlock(&ASSET.zipLock);
if(statResult != 0) {
errorThrow("Failed to stat asset file: %s", filename); errorThrow("Failed to stat asset file: %s", filename);
} }
@@ -47,6 +49,25 @@ errorret_t assetFileRewind(assetfile_t *file) {
errorOk(); errorOk();
} }
// Prefer seeking within the still-open handle over closing and
// re-opening it. Repeatedly closing/re-opening the same compressed zip
// entry (once per rewind - e.g. once per locale string lookup) was
// confirmed unreliable on at least one platform's zip backend, silently
// skipping ranges of the decompressed content on some re-opens after
// the first. A real seek avoids that failure mode entirely, with no
// extra memory cost over the close+reopen fallback.
threadMutexLock(&ASSET.zipLock);
bool_t seekable = zip_file_is_seekable(file->zipFile);
int seekResult = seekable ? zip_fseek(file->zipFile, 0, SEEK_SET) : -1;
threadMutexUnlock(&ASSET.zipLock);
if(seekable) {
if(seekResult != 0) {
errorThrow("Failed to seek asset file: %s", file->filename);
}
file->position = 0;
errorOk();
}
errorChain(assetFileClose(file)); errorChain(assetFileClose(file));
errorChain(assetFileOpen(file)); errorChain(assetFileOpen(file));
errorOk(); errorOk();
@@ -55,10 +76,11 @@ errorret_t assetFileRewind(assetfile_t *file) {
errorret_t assetFileOpen(assetfile_t *file) { errorret_t assetFileOpen(assetfile_t *file) {
assertNotNull(file, "Asset file cannot be NULL."); assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(file->filename, "Asset file filename cannot be NULL."); assertNotNull(file->filename, "Asset file filename cannot be NULL.");
assertNotNull(ASSET.zip, "Asset zip cannot be NULL.");
assertNull(file->zipFile, "Asset file already open."); assertNull(file->zipFile, "Asset file already open.");
threadMutexLock(&ASSET.zipLock);
file->zipFile = zip_fopen(ASSET.zip, file->filename, 0); file->zipFile = zip_fopen(ASSET.zip, file->filename, 0);
threadMutexUnlock(&ASSET.zipLock);
if(file->zipFile == NULL) { if(file->zipFile == NULL) {
errorThrow("Failed to open asset file: %s", file->filename); errorThrow("Failed to open asset file: %s", file->filename);
} }
@@ -79,21 +101,52 @@ errorret_t assetFileRead(
uint8_t tempBuffer[256]; uint8_t tempBuffer[256];
while(bytesRemaining > 0) { while(bytesRemaining > 0) {
size_t chunkSize = mathMin(bytesRemaining, sizeof(tempBuffer)); size_t chunkSize = mathMin(bytesRemaining, sizeof(tempBuffer));
// The recursive call below already advances file->position by
// chunkSize (the non-NULL branch does this itself) - do not also
// advance it here, or every skip ends up double-counted.
errorChain(assetFileRead(file, tempBuffer, chunkSize)); errorChain(assetFileRead(file, tempBuffer, chunkSize));
file->position += chunkSize;
bytesRemaining -= chunkSize; bytesRemaining -= chunkSize;
} }
file->lastRead = bufferSize; file->lastRead = bufferSize;
errorOk(); errorOk();
} }
// I assume zip_fread takes buffer NULL for skipping? // Some zip_fread() implementations (seen on PSP) reject a single call
zip_int64_t bytesRead = zip_fread(file->zipFile, buffer, bufferSize); // asking for the entire (potentially large) file at once with EINVAL;
// the line reader above only ever asks for up to 1024 bytes per call and
// works fine, so read in bounded chunks here too.
size_t totalRead = 0;
uint8_t *dest = (uint8_t *)buffer;
while(totalRead < bufferSize) {
size_t chunkSize = mathMin(
bufferSize - totalRead, ASSET_FILE_READ_CHUNK_MAX
);
threadMutexLock(&ASSET.zipLock);
zip_int64_t bytesRead = zip_fread(
file->zipFile, dest + totalRead, chunkSize
);
errorret_t readError = errorOkImpl();
if(bytesRead < 0) { if(bytesRead < 0) {
errorThrow("Failed to read from asset file: %s", file->filename); // Built (not thrown) while still holding the lock, so
// zip_file_strerror() reads the just-failed zipFile's error state
// before another thread can touch it - errorThrow() itself isn't
// used here since it returns immediately, which would leave
// ASSET.zipLock held forever.
readError = errorThrowImpl(
&ERROR_STATE, ERROR_NOT_OK, __FILE__, __func__, __LINE__,
"Failed to read from asset file: %s (%s)",
file->filename, zip_file_strerror(file->zipFile)
);
} }
file->position += bytesRead; threadMutexUnlock(&ASSET.zipLock);
file->lastRead = bytesRead; if(errorIsNotOk(readError)) {
errorChain(readError);
}
if(bytesRead == 0) break;
totalRead += (size_t)bytesRead;
}
file->position += totalRead;
file->lastRead = totalRead;
errorOk(); errorOk();
} }
@@ -101,7 +154,10 @@ errorret_t assetFileClose(assetfile_t *file) {
assertNotNull(file, "Asset file cannot be NULL."); assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(file->zipFile, "Asset file must be opened before closing."); assertNotNull(file->zipFile, "Asset file must be opened before closing.");
if(zip_fclose(file->zipFile) != 0) { threadMutexLock(&ASSET.zipLock);
int closeResult = zip_fclose(file->zipFile);
threadMutexUnlock(&ASSET.zipLock);
if(closeResult != 0) {
errorThrow("Failed to close asset file: %s", file->filename); errorThrow("Failed to close asset file: %s", file->filename);
} }
file->zipFile = NULL; file->zipFile = NULL;
@@ -201,7 +257,7 @@ const uint8_t *assetFileLineReaderUnreadPtr(
return reader->readBuffer + reader->bufferStart; return reader->readBuffer + reader->bufferStart;
} }
static errorret_t assetFileLineReaderAppend( errorret_t assetFileLineReaderAppend(
assetfilelinereader_t *reader, assetfilelinereader_t *reader,
const uint8_t *src, const uint8_t *src,
size_t srcLength size_t srcLength
@@ -223,7 +279,7 @@ static errorret_t assetFileLineReaderAppend(
errorOk(); errorOk();
} }
static void assetFileLineReaderTerminate(assetfilelinereader_t *reader) { void assetFileLineReaderTerminate(assetfilelinereader_t *reader) {
assertNotNull(reader, "Reader cannot be NULL."); assertNotNull(reader, "Reader cannot be NULL.");
assertNotNull(reader->outBuffer, "Out buffer cannot be NULL."); assertNotNull(reader->outBuffer, "Out buffer cannot be NULL.");
assertTrue( assertTrue(
@@ -233,7 +289,7 @@ static void assetFileLineReaderTerminate(assetfilelinereader_t *reader) {
reader->outBuffer[reader->lineLength] = '\0'; reader->outBuffer[reader->lineLength] = '\0';
} }
static ssize_t assetFileLineReaderFindNewline( ssize_t assetFileLineReaderFindNewline(
const assetfilelinereader_t *reader const assetfilelinereader_t *reader
) { ) {
size_t i; size_t i;
+64 -1
View File
@@ -9,7 +9,13 @@
#include "error/error.h" #include "error/error.h"
#include <zip.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
// for very large amounts of data at once; the locale line reader has
// always used 1024-byte reads successfully, so that's the proven-safe cap.
#define ASSET_FILE_READ_CHUNK_MAX 1024
typedef struct assetfile_s assetfile_t; typedef struct assetfile_s assetfile_t;
@@ -144,6 +150,63 @@ void assetFileLineReaderInit(
const size_t outBufferSize const size_t outBufferSize
); );
/**
* Returns the number of bytes still unread in the line reader's read buffer.
*
* @param reader The line reader to check.
* @return Number of unread bytes remaining in the read buffer.
*/
size_t assetFileLineReaderUnreadBytes(const assetfilelinereader_t *reader);
/**
* Returns a pointer to the first unread byte in the line reader's read
* buffer.
*
* @param reader The line reader to check.
* @return Pointer to the first unread byte.
*/
const uint8_t *assetFileLineReaderUnreadPtr(
const assetfilelinereader_t *reader
);
/**
* Appends src to the line reader's current line buffer, growing lineLength.
*
* @param reader The line reader whose out buffer to append to.
* @param src Bytes to append.
* @param srcLength Number of bytes in src.
* @return Error state if any (the line would exceed the output buffer).
*/
errorret_t assetFileLineReaderAppend(
assetfilelinereader_t *reader,
const uint8_t *src,
size_t srcLength
);
/**
* Null-terminates the line reader's current line buffer at lineLength.
*
* @param reader The line reader whose out buffer to terminate.
*/
void assetFileLineReaderTerminate(assetfilelinereader_t *reader);
/**
* Searches the line reader's unread buffered bytes for a newline character.
*
* @param reader The line reader to search.
* @return The index of the newline within readBuffer, or -1 if not found.
*/
ssize_t assetFileLineReaderFindNewline(const assetfilelinereader_t *reader);
/**
* Refills the line reader's read buffer from the underlying file once its
* buffered bytes are fully consumed. A no-op once the file is at EOF.
*
* @param reader The line reader to refill.
* @return Error state if any.
*/
errorret_t assetFileLineReaderFill(assetfilelinereader_t *reader);
/** /**
* Reads the next line from the asset file into the line buffer. The line * Reads the next line from the asset file into the line buffer. The line
* buffer is null-terminated and does not include the newline character. * buffer is null-terminated and does not include the newline character.
+3
View File
@@ -17,3 +17,6 @@ add_subdirectory(locale)
add_subdirectory(json) add_subdirectory(json)
add_subdirectory(chunk) add_subdirectory(chunk)
add_subdirectory(dmf) add_subdirectory(dmf)
add_subdirectory(cutscene)
add_subdirectory(wav)
add_subdirectory(mp3)
+1 -17
View File
@@ -35,22 +35,6 @@ void assetEntryInit(
entry->input = NULL; entry->input = NULL;
} }
refInit(&entry->refs, entry, NULL, NULL, NULL); refInit(&entry->refs, entry, NULL, NULL, NULL);
eventInit(
&entry->onLoaded,
entry->onLoadedCallbacks, entry->onLoadedUsers,
ASSET_ENTRY_EVENT_MAX
);
eventInit(
&entry->onUnloaded,
entry->onUnloadedCallbacks, entry->onUnloadedUsers,
ASSET_ENTRY_EVENT_MAX
);
eventInit(
&entry->onError,
entry->onErrorCallbacks, entry->onErrorUsers,
ASSET_ENTRY_EVENT_MAX
);
} }
void assetEntryLock(assetentry_t *entry) { void assetEntryLock(assetentry_t *entry) {
@@ -97,7 +81,7 @@ errorret_t assetEntryDispose(assetentry_t *entry) {
"Asset entry still refed at dispose time." "Asset entry still refed at dispose time."
); );
eventInvoke(&entry->onUnloaded, entry); if(entry->onUnloaded) entry->onUnloaded(entry, entry->onUnloadedUser);
errorChain(ASSET_LOADER_CALLBACKS[entry->type].dispose(entry)); errorChain(ASSET_LOADER_CALLBACKS[entry->type].dispose(entry));
memoryZero(entry, sizeof(assetentry_t)); memoryZero(entry, sizeof(assetentry_t));
errorOk(); errorOk();
+22 -20
View File
@@ -7,7 +7,6 @@
#pragma once #pragma once
#include "asset/loader/assetloading.h" #include "asset/loader/assetloading.h"
#include "event/event.h"
#include "util/ref.h" #include "util/ref.h"
typedef enum { typedef enum {
@@ -20,11 +19,17 @@ typedef enum {
ASSET_ENTRY_STATE_ERROR ASSET_ENTRY_STATE_ERROR
} assetentrystate_t; } assetentrystate_t;
/** Maximum number of subscribers for each per-entry event. */
#define ASSET_ENTRY_EVENT_MAX 2
typedef struct assetentry_s assetentry_t; typedef struct assetentry_s assetentry_t;
/**
* A single asset entry callback. Each entry supports at most one subscriber
* per event - a second assignment without clearing the first is a bug.
*
* @param entry The assetentry_t the event fired on.
* @param user The user pointer passed alongside the callback.
*/
typedef void (*assetentrycallback_t)(assetentry_t *entry, void *user);
struct assetentry_s { struct assetentry_s {
char_t name[ASSET_FILE_NAME_MAX]; char_t name[ASSET_FILE_NAME_MAX];
assetloadertype_t type; assetloadertype_t type;
@@ -33,30 +38,27 @@ struct assetentry_s {
ref_t refs; ref_t refs;
assetloaderinput_t *input; assetloaderinput_t *input;
assetloaderinput_t inputData; assetloaderinput_t inputData;
/**
* Fired once when loading completes successfully (params = assetentry_t *).
* Always invoked on the main thread.
*/
event_t onLoaded;
eventcallback_t onLoadedCallbacks[ASSET_ENTRY_EVENT_MAX];
void *onLoadedUsers[ASSET_ENTRY_EVENT_MAX];
/** /**
* Fired once when the entry is disposed/reaped (params = assetentry_t *). * Fired once when loading completes successfully.
* The asset data is still accessible when the callback runs.
* Always invoked on the main thread. * Always invoked on the main thread.
*/ */
event_t onUnloaded; assetentrycallback_t onLoaded;
eventcallback_t onUnloadedCallbacks[ASSET_ENTRY_EVENT_MAX]; void *onLoadedUser;
void *onUnloadedUsers[ASSET_ENTRY_EVENT_MAX];
/** /**
* Fired once when loading fails (params = assetentry_t *). * Fired once when the entry is disposed/reaped. The asset data is still
* accessible when the callback runs. Always invoked on the main thread.
*/
assetentrycallback_t onUnloaded;
void *onUnloadedUser;
/**
* Fired once when loading fails.
* Always invoked on the main thread. * Always invoked on the main thread.
*/ */
event_t onError; assetentrycallback_t onError;
eventcallback_t onErrorCallbacks[ASSET_ENTRY_EVENT_MAX]; void *onErrorUser;
void *onErrorUsers[ASSET_ENTRY_EVENT_MAX];
}; };
/** /**
+18
View File
@@ -51,4 +51,22 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = {
.loadAsync = assetChunkLoaderAsync, .loadAsync = assetChunkLoaderAsync,
.dispose = assetChunkDispose .dispose = assetChunkDispose
}, },
[ASSET_LOADER_TYPE_CUTSCENE] = {
.loadSync = assetCutsceneLoaderSync,
.loadAsync = assetCutsceneLoaderAsync,
.dispose = assetCutsceneDispose
},
[ASSET_LOADER_TYPE_WAV] = {
.loadSync = assetWavLoaderSync,
.loadAsync = assetWavLoaderAsync,
.dispose = assetWavDispose
},
[ASSET_LOADER_TYPE_MP3] = {
.loadSync = assetMp3LoaderSync,
.loadAsync = assetMp3LoaderAsync,
.dispose = assetMp3Dispose
},
}; };
+15
View File
@@ -13,6 +13,9 @@
#include "asset/loader/locale/assetlocaleloader.h" #include "asset/loader/locale/assetlocaleloader.h"
#include "asset/loader/json/assetjsonloader.h" #include "asset/loader/json/assetjsonloader.h"
#include "asset/loader/chunk/assetchunkloader.h" #include "asset/loader/chunk/assetchunkloader.h"
#include "asset/loader/cutscene/assetcutsceneloader.h"
#include "asset/loader/wav/assetwavloader.h"
#include "asset/loader/mp3/assetmp3loader.h"
typedef enum { typedef enum {
ASSET_LOADER_TYPE_NULL, ASSET_LOADER_TYPE_NULL,
@@ -24,6 +27,9 @@ typedef enum {
ASSET_LOADER_TYPE_LOCALE, ASSET_LOADER_TYPE_LOCALE,
ASSET_LOADER_TYPE_JSON, ASSET_LOADER_TYPE_JSON,
ASSET_LOADER_TYPE_CHUNK, ASSET_LOADER_TYPE_CHUNK,
ASSET_LOADER_TYPE_CUTSCENE,
ASSET_LOADER_TYPE_WAV,
ASSET_LOADER_TYPE_MP3,
ASSET_LOADER_TYPE_COUNT ASSET_LOADER_TYPE_COUNT
} assetloadertype_t; } assetloadertype_t;
@@ -36,6 +42,9 @@ typedef union {
assetlocaleloaderloading_t locale; assetlocaleloaderloading_t locale;
assetjsonloaderloading_t json; assetjsonloaderloading_t json;
assetchunkloaderloading_t chunk; assetchunkloaderloading_t chunk;
assetcutsceneloaderloading_t cutscene;
assetwavloaderloading_t wav;
assetmp3loaderloading_t mp3;
} assetloaderloading_t; } assetloaderloading_t;
typedef union { typedef union {
@@ -46,6 +55,9 @@ typedef union {
assetlocaleoutput_t locale; assetlocaleoutput_t locale;
assetjsonoutput_t json; assetjsonoutput_t json;
assetchunkoutput_t chunk; assetchunkoutput_t chunk;
assetcutsceneoutput_t cutscene;
assetwavoutput_t wav;
assetmp3output_t mp3;
} assetloaderoutput_t; } assetloaderoutput_t;
typedef union { typedef union {
@@ -54,6 +66,9 @@ typedef union {
assetlocaleloaderinput_t locale; assetlocaleloaderinput_t locale;
assetjsonloaderinput_t json; assetjsonloaderinput_t json;
assetchunkloaderinput_t chunk; assetchunkloaderinput_t chunk;
assetcutsceneloaderinput_t cutscene;
assetwavloaderinput_t wav;
assetmp3loaderinput_t mp3;
} assetloaderinput_t; } assetloaderinput_t;
typedef struct assetloading_s assetloading_t; typedef struct assetloading_s assetloading_t;
+382 -64
View File
@@ -8,40 +8,284 @@
#include "assetchunkloader.h" #include "assetchunkloader.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "util/memory.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/assetloading.h"
#include "asset/loader/assetentry.h" #include "asset/loader/assetentry.h"
#include "asset/loader/assetloader.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) { errorret_t assetChunkLoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL"); assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Should be called from an async thread."); assertNotMainThread("Should be called from an async thread.");
if(loading->loading.chunk.state != ASSET_CHUNK_LOADING_STATE_READ_FILE) {
errorOk(); 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; out->vertices = verts;
assetLoaderErrorChain(loading, errorret_t ret = meshInit(
assetFileInit(file, loading->entry->name, NULL, NULL) &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); ret = meshFlush(&out->mesh, 0, (int32_t)vertCount);
assetLoaderErrorChain(loading, assetFileOpen(file)); if(errorIsNotOk(ret)) {
assetLoaderErrorChain(loading, assetFileRead(file, data, file->size)); meshDispose(&out->mesh);
assetLoaderErrorChain(loading, assetFileClose(file)); out->meshInitialized = false;
assetLoaderErrorChain(loading, assetFileDispose(file)); memoryFree(out->vertices);
assertTrue( out->vertices = NULL;
file->lastRead == file->size, errorChain(ret);
"Failed to read entire chunk file." }
);
#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(); errorOk();
} }
@@ -54,8 +298,8 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
switch(loading->loading.chunk.state) { switch(loading->loading.chunk.state) {
case ASSET_CHUNK_LOADING_STATE_INITIAL: case ASSET_CHUNK_LOADING_STATE_INITIAL:
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_READ_FILE; loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_PARSE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC; loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk(); errorOk();
case ASSET_CHUNK_LOADING_STATE_PARSE: case ASSET_CHUNK_LOADING_STATE_PARSE:
@@ -68,9 +312,7 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
out->modelEntries[m] = assetLock( out->modelEntries[m] = assetLock(
out->modelNames[m], ASSET_LOADER_TYPE_MODEL, NULL out->modelNames[m], ASSET_LOADER_TYPE_MODEL, NULL
); );
assertNotNull( assertNotNull(out->modelEntries[m], "Failed to lock model.");
out->modelEntries[m], "Failed to lock model."
);
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC; loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk(); errorOk();
} }
@@ -92,53 +334,95 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
errorOk(); errorOk();
} }
uint8_t *data = loading->loading.chunk.data; // --- PARSE: lock+require the chunk's own JSON as a sub-asset, exactly
assertNotNull(data, "Chunk data should have been loaded by now."); // like assetmodelloader.c's ASSET_MODEL_LOADING_STATE_LOCK_ASSETS does
// for its model descriptor, then read everything out of it before
if(data[0] != 'D' || data[1] != 'C' || data[2] != 'F') { // unlocking (the yyjson_doc/vals only stay valid while jsonEntry does).
memoryFree(data); char_t jsonKey[ASSET_FILE_NAME_MAX];
assetLoaderErrorThrow(loading, "Invalid chunk file header"); stringFormat(jsonKey, sizeof(jsonKey), "json:%s", loading->entry->name);
} assetloaderinput_t jsonInput;
memoryZero(&jsonInput, sizeof(jsonInput));
uint32_t version = endianLittleToHost32(*(uint32_t *)(data + 4)); stringCopy(
if(version != ASSET_CHUNK_FILE_VERSION) { jsonInput.json.path, loading->entry->name, ASSET_FILE_NAME_MAX
memoryFree(data); );
assetentry_t *jsonEntry =
assetLock(jsonKey, ASSET_LOADER_TYPE_JSON, &jsonInput);
errorret_t jsonRet = assetRequireLoaded(jsonEntry);
if(errorIsNotOk(jsonRet)) {
assetUnlockEntry(jsonEntry);
assetLoaderErrorThrow( 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); tile_t tiles[CHUNK_TILE_COUNT];
memoryCopy(out->tiles, data + offset, tileSize); assetChunkParseTiles(root, tiles);
offset += tileSize; out->hasTerrain = assetChunkTilesHaveTerrain(tiles);
out->meshCount = data[offset]; uint8_t meshIndex = out->hasTerrain ? 1 : 0;
offset += sizeof(uint8_t); assetChunkParseMeshes(root, out, &meshIndex);
assertTrue( out->meshCount = meshIndex;
out->meshCount <= CHUNK_MESH_COUNT_MAX,
"Chunk mesh count exceeds maximum." assetChunkParseEntities(root, out);
assetChunkParseAreas(root, out);
assetUnlockEntry(jsonEntry);
out->tiles = memoryAllocate(CHUNK_TILE_COUNT * sizeof(tile_t));
memoryCopy(out->tiles, tiles, CHUNK_TILE_COUNT * sizeof(tile_t));
// 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
); );
for(uint8_t m = 0; m < out->meshCount; m++) {
uint8_t nameLen = 0;
while(
data[offset + nameLen] != '\0' &&
nameLen < CHUNK_MESH_NAME_MAX - 1
) {
nameLen++;
}
memoryCopy(out->modelNames[m], data + offset, nameLen);
out->modelNames[m][nameLen] = '\0';
offset += nameLen + 1;
memoryCopy(out->meshOffsets[m], data + offset, sizeof(vec3));
offset += sizeof(vec3);
} }
memoryFree(data); assetloaderinput_t texInput = { .texture = TEXTURE_FORMAT_RGBA };
loading->loading.chunk.data = NULL; 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;
}
if(out->meshCount == 0) { if(out->meshCount == 0) {
loading->entry->state = ASSET_ENTRY_STATE_LOADED; loading->entry->state = ASSET_ENTRY_STATE_LOADED;
@@ -146,7 +430,7 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
} }
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_LOAD_MODELS; 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; loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk(); errorOk();
} }
@@ -157,10 +441,44 @@ errorret_t assetChunkDispose(assetentry_t *entry) {
assertIsMainThread("Must be called from the main thread."); assertIsMainThread("Must be called from the main thread.");
assetchunkoutput_t *out = &entry->data.chunk; assetchunkoutput_t *out = &entry->data.chunk;
if(out->tiles != NULL) {
memoryFree(out->tiles);
out->tiles = NULL;
}
for(uint8_t m = 0; m < out->meshCount; m++) { for(uint8_t m = 0; m < out->meshCount; m++) {
if(out->modelEntries[m] == NULL) continue; if(out->modelEntries[m] == NULL) continue;
if(out->hasTerrain && m == 0) continue; // synthetic - freed below.
assetUnlockEntry(out->modelEntries[m]); assetUnlockEntry(out->modelEntries[m]);
out->modelEntries[m] = NULL; 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(); errorOk();
} }
+62 -12
View File
@@ -9,8 +9,6 @@
#include "asset/assetfile.h" #include "asset/assetfile.h"
#include "rpg/overworld/chunk.h" #include "rpg/overworld/chunk.h"
#define ASSET_CHUNK_FILE_VERSION 3
typedef struct assetloading_s assetloading_t; typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t; typedef struct assetentry_s assetentry_t;
@@ -20,31 +18,81 @@ typedef struct {
typedef enum { typedef enum {
ASSET_CHUNK_LOADING_STATE_INITIAL, ASSET_CHUNK_LOADING_STATE_INITIAL,
ASSET_CHUNK_LOADING_STATE_READ_FILE,
ASSET_CHUNK_LOADING_STATE_PARSE, ASSET_CHUNK_LOADING_STATE_PARSE,
ASSET_CHUNK_LOADING_STATE_LOAD_MODELS, ASSET_CHUNK_LOADING_STATE_LOAD_MODELS,
ASSET_CHUNK_LOADING_STATE_DONE ASSET_CHUNK_LOADING_STATE_DONE
} assetchunkloadingstate_t; } assetchunkloadingstate_t;
typedef struct { typedef struct {
assetfile_t file;
assetchunkloadingstate_t state; 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; uint8_t modelIndex;
} assetchunkloaderloading_t; } assetchunkloaderloading_t;
typedef enum {
CHUNK_ENTITY_SPAWN_KIND_GLOBAL,
CHUNK_ENTITY_SPAWN_KIND_ITEM
} chunkentityspawnkind_t;
typedef struct { typedef struct {
tile_t tiles[CHUNK_TILE_COUNT]; chunkentityspawnkind_t kind;
uint16_t globalId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_GLOBAL.
uint16_t itemId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM.
uint8_t itemQuantity; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM.
worldpos_t position;
} chunkentityspawn_t;
typedef struct {
worldpos_t min;
worldpos_t max;
uint16_t callbackId; // Index into MAP_AREA_CALLBACK_LIST.
uint8_t notify;
uint8_t trigger;
} chunkareaspawn_t;
typedef struct {
tile_t *tiles;
uint8_t meshCount; uint8_t meshCount;
char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX]; char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
vec3 meshOffsets[CHUNK_MESH_COUNT_MAX]; vec3 meshOffsets[CHUNK_MESH_COUNT_MAX];
assetentry_t *modelEntries[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];
uint8_t areaSpawnCount;
chunkareaspawn_t areaSpawns[CHUNK_AREA_COUNT_MAX];
} assetchunkoutput_t; } assetchunkoutput_t;
/** /**
* Asynchronous loader for chunk assets. Reads the raw DCF file bytes into * Async loader stub for chunk assets. Chunk loading is performed entirely
* the loading buffer so the sync phase can parse without blocking the * on the main thread (it delegates to the JSON asset loader and to
* main thread on I/O. * meshInit/meshFlush, both main-thread-only); this callback is never
* reached.
* *
* @param loading Loading information for the asset being loaded. * @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation. * @return Error code indicating success or failure of the load operation.
@@ -52,9 +100,11 @@ typedef struct {
errorret_t assetChunkLoaderAsync(assetloading_t *loading); errorret_t assetChunkLoaderAsync(assetloading_t *loading);
/** /**
* Synchronous loader for chunk assets. Validates the DCF binary previously * Synchronous loader for chunk assets. Locks the chunk's JSON descriptor
* read by the async phase and populates the output assetchunkoutput_t with * as a sub-asset, parses tiles/external mesh references/entity spawns/
* tile data and model paths. * 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. * @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation. * @return Error code indicating success or failure of the load operation.
@@ -5,6 +5,5 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
uiframe.c assetcutsceneloader.c
uisettings.c
) )
@@ -0,0 +1,225 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetcutsceneloader.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include "asset/loader/assetloading.h"
#include "asset/loader/assetentry.h"
#include "asset/loader/assetloader.h"
#include "asset/asset.h"
#include "rpg/cutscene/item/json/cutscenejsonpauseflags.h"
errorret_t assetCutsceneParseItem(
assetloading_t *loading,
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset,
const uint8_t index
) {
if(!yyjson_is_obj(itemObj)) {
assetLoaderErrorThrow(
loading,
"Cutscene item %u is not an object",
(uint32_t)index
);
}
const char_t *typeName = yyjson_get_str(yyjson_obj_get(itemObj, "type"));
if(!cutsceneItemLookupType(typeName, &item->type)) {
assetLoaderErrorThrow(
loading,
"Cutscene item %u: missing/unsupported item type %s",
(uint32_t)index,
typeName
);
}
cutsceneitemloadcallback_t *load = CUTSCENE_ITEM_CALLBACKS[item->type].load;
if(load == NULL) {
assetLoaderErrorThrow(
loading, "Cutscene item %u: unhandled item type %u", (uint32_t)index,
(uint32_t)item->type
);
}
errorret_t result = load(itemObj, item, pool, poolOffset);
if(errorIsNotOk(result)) {
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorChain(result);
}
errorOk();
}
size_t assetCutsceneComputePoolSize(yyjson_val *itemsArr) {
size_t size = 0;
yyjson_arr_iter iter = yyjson_arr_iter_with(itemsArr);
yyjson_val *itemVal;
while((itemVal = yyjson_arr_iter_next(&iter)) != NULL) {
const char_t *typeName = yyjson_get_str(yyjson_obj_get(itemVal, "type"));
cutsceneitemtype_t type;
if(!cutsceneItemLookupType(typeName, &type)) continue;
cutsceneitempoolsizecallback_t *poolSize =
CUTSCENE_ITEM_CALLBACKS[type].poolSize;
if(poolSize == NULL) continue;
size = cutsceneJsonPoolAlign(size) + poolSize(itemVal);
}
return size;
}
void assetCutsceneFreeParsed(assetcutsceneoutput_t *out) {
if(out->items != NULL) {
memoryFree(out->items);
out->items = NULL;
}
if(out->pool != NULL) {
memoryFree(out->pool);
out->pool = NULL;
}
if(out->doc != NULL) {
yyjson_doc_free(out->doc);
out->doc = NULL;
}
}
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Should be called from an async thread.");
if(
loading->loading.cutscene.state != ASSET_CUTSCENE_LOADING_STATE_READ_FILE
) {
errorOk();
}
assertNull(loading->loading.cutscene.buffer, "Buffer already defined?");
assetfile_t *file = &loading->loading.cutscene.file;
assetLoaderErrorChain(loading,
assetFileInit(file, loading->entry->name, NULL, NULL)
);
if(file->size > ASSET_CUTSCENE_FILE_SIZE_MAX) {
assetLoaderErrorThrow(
loading, "Cutscene JSON exceeds maximum allowed size"
);
}
size_t fileSize = (size_t)file->size;
uint8_t *buffer = memoryAllocate(fileSize);
assetLoaderErrorChain(loading, assetFileOpen(file));
assetLoaderErrorChain(loading, assetFileRead(file, buffer, fileSize));
assertTrue(
file->lastRead == file->size,
"Failed to read entire cutscene file."
);
assetLoaderErrorChain(loading, assetFileClose(file));
assetLoaderErrorChain(loading, assetFileDispose(file));
loading->loading.cutscene.buffer = buffer;
loading->loading.cutscene.size = fileSize;
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_PARSE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
errorret_t assetCutsceneLoaderSync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertTrue(loading->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
if(loading->loading.cutscene.state == ASSET_CUTSCENE_LOADING_STATE_INITIAL) {
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_READ_FILE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
errorOk();
}
assetcutsceneoutput_t *out = &loading->entry->data.cutscene;
uint8_t *buffer = loading->loading.cutscene.buffer;
assertNotNull(buffer, "Cutscene data should have been loaded by now.");
size_t bufferSize = loading->loading.cutscene.size;
yyjson_doc *doc = yyjson_read(
(char *)buffer, bufferSize,
YYJSON_READ_ALLOW_COMMENTS | YYJSON_READ_ALLOW_TRAILING_COMMAS
);
memoryFree(buffer);
loading->loading.cutscene.buffer = NULL;
if(doc == NULL) {
assetLoaderErrorThrow(loading, "Failed to parse cutscene JSON");
}
out->doc = doc;
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *itemsArr = yyjson_obj_get(root, "items");
if(!yyjson_is_arr(itemsArr)) {
assetCutsceneFreeParsed(out);
assetLoaderErrorThrow(loading, "Cutscene JSON is missing an 'items' array");
}
size_t itemCount = yyjson_arr_size(itemsArr);
if(itemCount > UINT8_MAX) {
assetCutsceneFreeParsed(out);
assetLoaderErrorThrow(loading, "Cutscene has more than 255 items");
}
cutscenepause_t pause = CUTSCENE_PAUSE_DEFAULT;
yyjson_val *pauseArr = yyjson_obj_get(root, "pause");
if(yyjson_is_arr(pauseArr)) {
if(!cutsceneJsonParsePauseFlags(pauseArr, &pause)) {
assetCutsceneFreeParsed(out);
assetLoaderErrorThrow(
loading, "Cutscene JSON has an invalid 'pause' flag"
);
}
}
out->items = memoryAllocate(itemCount * sizeof(cutsceneitem_t));
memoryZero(out->items, itemCount * sizeof(cutsceneitem_t));
size_t poolSize = assetCutsceneComputePoolSize(itemsArr);
out->pool = poolSize > 0 ? memoryAllocate(poolSize) : NULL;
size_t poolOffset = 0;
yyjson_arr_iter itemIter = yyjson_arr_iter_with(itemsArr);
yyjson_val *itemVal;
uint8_t index = 0;
while((itemVal = yyjson_arr_iter_next(&itemIter)) != NULL) {
errorret_t itemResult = assetCutsceneParseItem(
loading, itemVal, &out->items[index], out->pool, &poolOffset, index
);
if(errorIsNotOk(itemResult)) {
assetCutsceneFreeParsed(out);
errorChain(itemResult);
}
index++;
}
out->cutscene.items = out->items;
out->cutscene.itemCount = (uint8_t)itemCount;
out->cutscene.pause = pause;
out->cutscene.dataSize = 0;
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk();
}
errorret_t assetCutsceneDispose(assetentry_t *entry) {
assertNotNull(entry, "Entry cannot be NULL");
assertTrue(entry->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
assetCutsceneFreeParsed(&entry->data.cutscene);
errorOk();
}
@@ -0,0 +1,106 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "asset/assetfile.h"
#include "rpg/cutscene/cutscene.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/item/json/cutscenejsonpool.h"
#include "yyjson.h"
#define ASSET_CUTSCENE_FILE_SIZE_MAX (1024 * 64)
typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t;
typedef struct {
void *nothing;
} assetcutsceneloaderinput_t;
typedef enum {
ASSET_CUTSCENE_LOADING_STATE_INITIAL,
ASSET_CUTSCENE_LOADING_STATE_READ_FILE,
ASSET_CUTSCENE_LOADING_STATE_PARSE
} assetcutsceneloadingstate_t;
typedef struct {
assetfile_t file;
assetcutsceneloadingstate_t state;
uint8_t *buffer;
size_t size;
} assetcutsceneloaderloading_t;
typedef struct {
cutscene_t cutscene;
cutsceneitem_t *items;
uint8_t *pool;
yyjson_doc *doc;
} assetcutsceneoutput_t;
/**
* Parses one cutscene item object into item, writing any pool-backed
* array data (entityWalkTo positions, mapAreaWait areaIds) into pool at
* *poolOffset and advancing it. Every field/enum name is validated as
* real (untrusted, authored) file content - failures are real errors, not
* asserts. Dispatches to the matching type's Load callback (see
* CUTSCENE_ITEM_CALLBACKS in rpg/cutscene/item/cutsceneitem.h) - the
* per-type parsing logic itself lives there now, colocated with that
* type's runtime behavior.
*
* @param loading Loading information for the asset being loaded.
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param pool Base pointer of the entry's pool allocation (may be NULL if
* assetCutsceneComputePoolSize returned 0).
* @param poolOffset In/out cursor into pool.
* @param index Index of this item within the cutscene, for error messages.
* @return Error code indicating success or failure of the parse.
*/
errorret_t assetCutsceneParseItem(
assetloading_t *loading,
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset,
const uint8_t index
);
/**
* Frees whatever of out->items/out->pool/out->doc is currently non-NULL
* and clears them - shared by both the normal disposer and every parse
* failure path's cleanup.
*
* @param out The output to free.
*/
void assetCutsceneFreeParsed(assetcutsceneoutput_t *out);
/**
* Asynchronous loader for cutscene assets. Reads the raw JSONC file bytes
* into the loading buffer so the sync phase can parse without blocking the
* main thread on I/O.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
*/
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading);
/**
* Synchronous loader for cutscene assets. Parses the JSONC previously read
* by the async phase into a heap-allocated cutsceneitem_t array + pool.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
*/
errorret_t assetCutsceneLoaderSync(assetloading_t *loading);
/**
* Disposer for cutscene assets.
*
* @param entry Asset entry containing the cutscene data to dispose.
* @return Error code indicating success or failure of the dispose operation.
*/
errorret_t assetCutsceneDispose(assetentry_t *entry);
+23 -4
View File
@@ -48,8 +48,20 @@ errorret_t assetMeshLoaderAsync(assetloading_t *loading) {
uint32_t vertCount = endianLittleToHost32(*(uint32_t *)(raw + 8)); uint32_t vertCount = endianLittleToHost32(*(uint32_t *)(raw + 8));
meshvertex_t *vertices = NULL; meshvertex_t *vertices = NULL;
if(vertCount > 0) { if(vertCount > 0) {
vertices = memoryAllocate(vertCount * sizeof(meshvertex_t)); // 32-byte (cache-line) aligned: GX_SetArray + DCFlushRange on Dolphin
// require this for the DMA'd vertex data to actually reach the GPU
// coherently. Static compiled-in vertex arrays happen to get this from
// the linker; a plain memoryAllocate here would not.
vertices = memoryAlign(32, vertCount * sizeof(meshvertex_t));
memoryCopy(vertices, raw + 12, vertCount * sizeof(meshvertex_t)); memoryCopy(vertices, raw + 12, vertCount * sizeof(meshvertex_t));
for(uint32_t v = 0; v < vertCount; v++) {
vertices[v].uv[0] = endianLittleToHostFloat(vertices[v].uv[0]);
vertices[v].uv[1] = endianLittleToHostFloat(vertices[v].uv[1]);
vertices[v].pos[0] = endianLittleToHostFloat(vertices[v].pos[0]);
vertices[v].pos[1] = endianLittleToHostFloat(vertices[v].pos[1]);
vertices[v].pos[2] = endianLittleToHostFloat(vertices[v].pos[2]);
}
} }
memoryFree(raw); memoryFree(raw);
@@ -103,18 +115,22 @@ errorret_t assetMeshLoaderSync(assetloading_t *loading) {
out->vertices = NULL; out->vertices = NULL;
errorChain(ret); errorChain(ret);
} }
out->meshInitialized = true;
ret = meshFlush(&out->mesh, 0, (int32_t)vertCount); ret = meshFlush(&out->mesh, 0, (int32_t)vertCount);
if(errorIsNotOk(ret)) { if(errorIsNotOk(ret)) {
loading->entry->state = ASSET_ENTRY_STATE_ERROR; loading->entry->state = ASSET_ENTRY_STATE_ERROR;
meshDispose(&out->mesh); meshDispose(&out->mesh);
out->meshInitialized = false;
memoryFree(out->vertices); memoryFree(out->vertices);
out->vertices = NULL; out->vertices = NULL;
errorChain(ret); errorChain(ret);
} }
#ifndef DUSK_OPENGL_LEGACY #if defined(DUSK_OPENGL) && !defined(DUSK_OPENGL_LEGACY)
// VBO owns the data now; CPU copy is no longer needed. // VBO owns the data now; CPU copy is no longer needed. The platform
// mesh object itself still needs meshDispose later - tracked via
// out->meshInitialized, independent of the CPU buffer's lifetime.
memoryFree(out->vertices); memoryFree(out->vertices);
out->vertices = NULL; out->vertices = NULL;
#endif #endif
@@ -129,8 +145,11 @@ errorret_t assetMeshDispose(assetentry_t *entry) {
assertIsMainThread("Must be called from the main thread."); assertIsMainThread("Must be called from the main thread.");
assetmeshoutput_t *out = &entry->data.mesh; assetmeshoutput_t *out = &entry->data.mesh;
if(out->vertices != NULL) { if(out->meshInitialized) {
errorChain(meshDispose(&out->mesh)); errorChain(meshDispose(&out->mesh));
out->meshInitialized = false;
}
if(out->vertices != NULL) {
memoryFree(out->vertices); memoryFree(out->vertices);
out->vertices = NULL; out->vertices = NULL;
} }
@@ -30,6 +30,7 @@ typedef struct {
typedef struct { typedef struct {
mesh_t mesh; mesh_t mesh;
bool_t meshInitialized;
meshvertex_t *vertices; meshvertex_t *vertices;
} assetmeshoutput_t; } assetmeshoutput_t;
+2 -1
View File
@@ -115,7 +115,8 @@ errorret_t assetModelLoaderSync(assetloading_t *loading) {
assetUnlockEntry(jsonEntry); assetUnlockEntry(jsonEntry);
// Lock and load the mesh sub-asset. // Lock and load the mesh sub-asset.
assetentry_t *meshEntry = assetLock(meshName, ASSET_LOADER_TYPE_MESH, NULL); assetentry_t *meshEntry =
assetLock(meshName, ASSET_LOADER_TYPE_MESH, NULL);
ret = assetRequireLoaded(meshEntry); ret = assetRequireLoaded(meshEntry);
if(errorIsNotOk(ret)) { if(errorIsNotOk(ret)) {
assetUnlockEntry(meshEntry); assetUnlockEntry(meshEntry);
@@ -30,8 +30,8 @@ errorret_t assetLocaleLoaderAsync(assetloading_t *loading) {
assetLoaderErrorChain(loading, assetFileOpen(&localeFile->file)); assetLoaderErrorChain(loading, assetFileOpen(&localeFile->file));
char_t buffer[1024]; char_t buffer[1024];
assetLoaderErrorChain(loading, assetLocaleGetString( assetLoaderErrorChain(loading, assetLocaleGetHeader(
localeFile, "", 0, buffer, sizeof(buffer) localeFile, buffer, sizeof(buffer)
)); ));
assetLoaderErrorChain(loading, assetLocaleParseHeader( assetLoaderErrorChain(loading, assetLocaleParseHeader(
localeFile, buffer, sizeof(buffer) localeFile, buffer, sizeof(buffer)
@@ -71,6 +71,12 @@ errorret_t assetLocaleDispose(assetentry_t *entry) {
assertIsMainThread("Must be called from the main thread."); assertIsMainThread("Must be called from the main thread.");
assetlocalefile_t *localeFile = &entry->data.locale; assetlocalefile_t *localeFile = &entry->data.locale;
if(localeFile->cache != NULL) {
memoryFree(localeFile->cache);
localeFile->cache = NULL;
}
errorChain(assetFileClose(&localeFile->file)); errorChain(assetFileClose(&localeFile->file));
return assetFileDispose(&localeFile->file); return assetFileDispose(&localeFile->file);
} }
@@ -470,7 +476,94 @@ errorret_t assetLocaleLineUnbuffer(
errorOk(); errorOk();
} }
errorret_t assetLocaleGetString( errorret_t assetLocaleCacheFind(
assetlocalefile_t *file,
const char_t *messageId,
const int32_t pluralCount,
char_t *stringBuffer,
const size_t stringBufferSize,
bool_t *outHit
) {
assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(messageId, "Message ID cannot be NULL.");
assertNotNull(outHit, "outHit cannot be NULL.");
assertStrLenMin(messageId, 1, "Message ID cannot be empty.");
*outHit = false;
if(file->cache == NULL) errorOk();
assetlocalecache_t *cache = file->cache;
for(uint8_t i = 0; i < ASSET_LOCALE_CACHE_COUNT; i++) {
assetlocalecacheentry_t *entry = &cache->entries[i];
if(entry->messageId[0] == '\0') break;// unused tail - nothing further
if(entry->pluralCount != pluralCount) continue;
if(!stringEquals(entry->messageId, messageId)) continue;
// Move to the front of the LRU order (a no-op when already there).
if(i > 0) {
assetlocalecacheentry_t hit = *entry;
memoryMove(
&cache->entries[1], &cache->entries[0],
i * sizeof(assetlocalecacheentry_t)
);
cache->entries[0] = hit;
}
size_t len = strlen(cache->entries[0].value);
if(len >= stringBufferSize) errorThrow("String buffer overflow");
memoryCopy(stringBuffer, cache->entries[0].value, len + 1);
*outHit = true;
errorOk();
}
errorOk();
}
void assetLocaleCacheInsert(
assetlocalefile_t *file,
const char_t *messageId,
const int32_t pluralCount,
const char_t *value
) {
assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(messageId, "Message ID cannot be NULL.");
assertNotNull(value, "Value cannot be NULL.");
assertStrLenMin(messageId, 1, "Message ID cannot be empty.");
// Caching is a pure optimization - a message ID or value too long for a
// fixed cache slot is just never cached, not an error.
if(strlen(messageId) >= ASSET_LOCALE_CACHE_KEY_MAX) return;
if(strlen(value) >= ASSET_LOCALE_CACHE_VALUE_MAX) return;
if(file->cache == NULL) {
file->cache =
(assetlocalecache_t *)memoryAllocate(sizeof(assetlocalecache_t));
memoryZero(file->cache, sizeof(assetlocalecache_t));
}
// Shift every entry down one slot, dropping the least-recently-used one
// off the end, to make room for the new entry at the front. This assumes
// messageId is never already present elsewhere in the cache, which holds
// as long as callers only insert after a confirmed assetLocaleCacheFind
// miss (a hit would have returned before reaching this point).
memoryMove(
&file->cache->entries[1], &file->cache->entries[0],
(ASSET_LOCALE_CACHE_COUNT - 1) * sizeof(assetlocalecacheentry_t)
);
assetlocalecacheentry_t *entry = &file->cache->entries[0];
stringCopy(entry->messageId, messageId, ASSET_LOCALE_CACHE_KEY_MAX);
entry->pluralCount = pluralCount;
stringCopy(entry->value, value, ASSET_LOCALE_CACHE_VALUE_MAX);
}
// Shared by assetLocaleGetString (external callers, messageId required
// non-empty) and assetLocaleGetHeader (messageId "", the PO format's own
// sentinel for its header entry - see assetLocaleGetHeader). Caching is
// skipped entirely for an empty messageId: assetLocaleCacheFind/Insert
// both reject one outright (same guard as assetLocaleGetString's own),
// and it's a one-shot boot-time read anyway, not worth caching.
errorret_t assetLocaleGetStringLookup(
assetlocalefile_t *file, assetlocalefile_t *file,
const char_t *messageId, const char_t *messageId,
const int32_t pluralCount, const int32_t pluralCount,
@@ -482,6 +575,15 @@ errorret_t assetLocaleGetString(
assertTrue(pluralCount >= 0, "Plural index cannot be negative."); assertTrue(pluralCount >= 0, "Plural index cannot be negative.");
assertNotNull(stringBuffer, "String buffer cannot be NULL."); assertNotNull(stringBuffer, "String buffer cannot be NULL.");
assertTrue(stringBufferSize > 0, "String buffer size must be > 0"); assertTrue(stringBufferSize > 0, "String buffer size must be > 0");
bool_t cacheHit = false;
if(messageId[0] != '\0') {
errorChain(assetLocaleCacheFind(
file, messageId, pluralCount, stringBuffer, stringBufferSize, &cacheHit
));
if(cacheHit) errorOk();
}
assetfilelinereader_t reader; assetfilelinereader_t reader;
bool_t msgidFound = false, msgidPluralFound = false, msgstrFound = false; bool_t msgidFound = false, msgidPluralFound = false, msgstrFound = false;
@@ -506,12 +608,17 @@ errorret_t assetLocaleGetString(
sizeof(lineBuffer) sizeof(lineBuffer)
); );
// Prime the reader with the first line before scanning; outBuffer holds
// uninitialized memory until the first Next() call fills it.
errorChain(assetFileLineReaderNext(&reader));
// Skip blanks, comments, etc and start looking for msgid's // Skip blanks, comments, etc and start looking for msgid's
errorChain(assetLocaleLineSkipBlanks(&reader, lineBuffer)); errorChain(assetLocaleLineSkipBlanks(&reader, lineBuffer));
while(!reader.eof) { while(true) {
// Is this msgid? // Is this msgid?
if(memoryCompare(lineBuffer, "msgid", 5) != 0) { if(memoryCompare(lineBuffer, "msgid", 5) != 0) {
if(reader.eof) break;
errorChain(assetFileLineReaderNext(&reader)); errorChain(assetFileLineReaderNext(&reader));
msgidBuffer[0] = '\0'; msgidBuffer[0] = '\0';
continue; continue;
@@ -535,7 +642,7 @@ errorret_t assetLocaleGetString(
} }
// We are either going to see a msgstr or a msgid_plural // We are either going to see a msgstr or a msgid_plural
while(!reader.eof) { while(true) {
errorChain(assetLocaleLineSkipBlanks(&reader, lineBuffer)); errorChain(assetLocaleLineSkipBlanks(&reader, lineBuffer));
// Is msgid_plural? // Is msgid_plural?
@@ -620,9 +727,38 @@ errorret_t assetLocaleGetString(
errorThrow("Failed to find msgstr for message ID: %s", messageId); errorThrow("Failed to find msgstr for message ID: %s", messageId);
} }
if(messageId[0] != '\0') {
assetLocaleCacheInsert(file, messageId, pluralCount, stringBuffer);
}
errorOk(); errorOk();
} }
errorret_t assetLocaleGetString(
assetlocalefile_t *file,
const char_t *messageId,
const int32_t pluralCount,
char_t *stringBuffer,
const size_t stringBufferSize
) {
assertNotNull(messageId, "Message ID cannot be NULL.");
assertStrLenMin(messageId, 1, "Message ID cannot be empty.");
return assetLocaleGetStringLookup(
file, messageId, pluralCount, stringBuffer, stringBufferSize
);
}
errorret_t assetLocaleGetHeader(
assetlocalefile_t *file,
char_t *stringBuffer,
const size_t stringBufferSize
) {
return assetLocaleGetStringLookup(
file, "", 0, stringBuffer, stringBufferSize
);
}
errorret_t assetLocaleGetStringWithVA( errorret_t assetLocaleGetStringWithVA(
assetlocalefile_t *file, assetlocalefile_t *file,
const char_t *messageId, const char_t *messageId,
@@ -70,6 +70,41 @@ typedef struct {
}; };
} assetlocalearg_t; } assetlocalearg_t;
/** Number of recently resolved strings @ref assetlocalecache_t remembers. */
#define ASSET_LOCALE_CACHE_COUNT 128
/** Max length (excluding null terminator) of a cacheable message ID. */
#define ASSET_LOCALE_CACHE_KEY_MAX 64
/** Max length (excluding null terminator) of a cacheable resolved string. */
#define ASSET_LOCALE_CACHE_VALUE_MAX 256
/** One (messageId, pluralCount) -> resolved string cache slot. */
typedef struct {
char_t messageId[ASSET_LOCALE_CACHE_KEY_MAX];
int32_t pluralCount;
char_t value[ASSET_LOCALE_CACHE_VALUE_MAX];
} assetlocalecacheentry_t;
/**
* Fixed-size move-to-front LRU cache of recently resolved locale strings.
* assetLocaleGetString() re-scans and re-decompresses the whole PO file on
* every miss, which is expensive to repeat for strings a screen fetches
* every time it opens (menu labels, etc) - this cache lets identical
* (messageId, pluralCount) lookups skip that entirely.
*
* Lazily allocated on the first cache insert (see assetlocalefile_t.cache)
* so locale entries that are never queried, or a file that's disposed
* before anything is cached, never pay for it.
*/
typedef struct {
/**
* Entries ordered most-recently-used first. An entry with
* messageId[0] == '\0' (and every entry after it) is unused.
*/
assetlocalecacheentry_t entries[ASSET_LOCALE_CACHE_COUNT];
} assetlocalecache_t;
/** /**
* Runtime state for an open locale file. * Runtime state for an open locale file.
* *
@@ -98,6 +133,16 @@ typedef struct {
/** Form index used when no conditional clause matches. */ /** Form index used when no conditional clause matches. */
uint8_t pluralDefaultIndex; uint8_t pluralDefaultIndex;
/**
* Recently resolved string cache, or NULL if nothing has been cached yet.
* Heap-allocated rather than embedded because assetlocalefile_t lives
* inside the shared assetloaderoutput_t union alongside every other
* asset type - embedding a 128-entry cache there would size that union
* (and therefore every one of the ASSET_ENTRY_COUNT_MAX asset slots,
* regardless of what type of asset occupies it) up by the same amount.
*/
assetlocalecache_t *cache;
} assetlocalefile_t; } assetlocalefile_t;
/** Convenience alias - the loaded output type of a locale asset entry. */ /** Convenience alias - the loaded output type of a locale asset entry. */
@@ -204,14 +249,92 @@ errorret_t assetLocaleLineUnbuffer(
); );
/** /**
* Looks up a translated string by message ID from the open locale file. * Searches a locale file's cache for a previously resolved
* (messageId, pluralCount) pair.
* *
* Rewinds the file and scans from the beginning on every call. For plural * On a hit, copies the cached value into `stringBuffer` (erroring if it
* entries (`msgid_plural`) the `pluralCount` is evaluated against the loaded * doesn't fit, same contract as @ref assetLocaleGetString) and moves the
* plural rules to select the correct `msgstr[N]` form. * entry to the front of the cache's move-to-front LRU order. On a miss,
* `stringBuffer` is left untouched.
*
* @param file Locale file whose cache to search. Its cache may be NULL
* (nothing cached yet), which is treated as a miss.
* @param messageId Message ID to look up.
* @param pluralCount Plural count the original lookup used.
* @param stringBuffer Destination buffer, filled only on a hit.
* @param stringBufferSize Capacity of `stringBuffer` in bytes.
* @param outHit Set to true on a cache hit, false on a miss.
* @return OK on success (hit or miss), error if a hit's cached value does
* not fit `stringBuffer`.
*/
errorret_t assetLocaleCacheFind(
assetlocalefile_t *file,
const char_t *messageId,
const int32_t pluralCount,
char_t *stringBuffer,
const size_t stringBufferSize,
bool_t *outHit
);
/**
* Records a resolved (messageId, pluralCount) -> value string at the front
* of the cache's move-to-front LRU order, evicting the least-recently-used
* entry if the cache is already full. Lazily allocates the cache (see
* assetlocalefile_t.cache) on the first call.
*
* Caching is a pure optimization: if `messageId` or `value` is too long to
* fit the cache's fixed-size slots, this silently does nothing rather than
* erroring or growing the cache.
*
* @param file Locale file whose cache to insert into.
* @param messageId Message ID that was looked up.
* @param pluralCount Plural count the lookup was made with.
* @param value Resolved string to cache.
*/
void assetLocaleCacheInsert(
assetlocalefile_t *file,
const char_t *messageId,
const int32_t pluralCount,
const char_t *value
);
/**
* Shared lookup core behind assetLocaleGetString and assetLocaleGetHeader:
* checks the file's cache first (see @ref assetLocaleCacheFind; skipped
* entirely for an empty messageId, since the cache rejects one), on a
* miss rewinds the file and scans from the beginning, then caches the
* result (see @ref assetLocaleCacheInsert, same empty-messageId skip)
* before returning. For plural entries (`msgid_plural`) the
* `pluralCount` is evaluated against the loaded plural rules to select
* the correct `msgstr[N]` form.
*
* Unlike assetLocaleGetString, messageId may be `""` here (see
* assetLocaleGetHeader) - this is the shared implementation both of
* them delegate to, not a public entry point of its own in practice.
* *
* @param file Locale file to search. Must be open. * @param file Locale file to search. Must be open.
* @param messageId PO message ID to find (`""` retrieves the header entry). * @param messageId PO message ID to find, or `""` for the header entry.
* @param pluralCount Count used to select the plural form (ignored for
* singular entries).
* @param stringBuffer Buffer to receive the translated string.
* @param stringBufferSize Capacity of `stringBuffer` in bytes.
* @return OK on success, error if the message ID is not found or I/O fails.
*/
errorret_t assetLocaleGetStringLookup(
assetlocalefile_t *file,
const char_t *messageId,
const int32_t pluralCount,
char_t *stringBuffer,
const size_t stringBufferSize
);
/**
* Looks up a translated string by message ID from the open locale file.
* See @ref assetLocaleGetStringLookup for the lookup/caching mechanics.
*
* @param file Locale file to search. Must be open.
* @param messageId PO message ID to find. Must be non-empty - use
* @ref assetLocaleGetHeader to read the header entry.
* @param pluralCount Count used to select the plural form (ignored for * @param pluralCount Count used to select the plural form (ignored for
* singular entries). * singular entries).
* @param stringBuffer Buffer to receive the translated string. * @param stringBuffer Buffer to receive the translated string.
@@ -226,6 +349,27 @@ errorret_t assetLocaleGetString(
const size_t stringBufferSize const size_t stringBufferSize
); );
/**
* Reads the PO file's header entry (the `msgid ""` block every PO file
* starts with, holding metadata like the plural-forms rule) - see @ref
* assetLocaleParseHeader, its caller. A dedicated function rather than
* calling assetLocaleGetString(file, "", ...) since messageId "" is a
* PO-format-internal sentinel, not a real (untrusted) caller-supplied
* message ID, so it doesn't go through assetLocaleGetString's normal
* non-empty validation.
*
* @param file Locale file to search. Must be open.
* @param stringBuffer Buffer to receive the raw header block.
* @param stringBufferSize Capacity of `stringBuffer` in bytes.
* @return OK on success, error if the header entry is missing or I/O
* fails.
*/
errorret_t assetLocaleGetHeader(
assetlocalefile_t *file,
char_t *stringBuffer,
const size_t stringBufferSize
);
/** /**
* Looks up a translated string and formats it with a `printf`-style variadic * Looks up a translated string and formats it with a `printf`-style variadic
* argument list. * argument list.
+9
View File
@@ -0,0 +1,9 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
assetmp3loader.c
)
+255
View File
@@ -0,0 +1,255 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetmp3loader.h"
#include "util/memory.h"
#include "util/math.h"
#include "assert/assert.h"
#include "asset/loader/assetloading.h"
#include "asset/loader/assetentry.h"
// How far into the file (after any ID3v2 tag) assetMp3ParseHeader() will
// search for the first valid MPEG frame sync. Real encoders write it
// within the first handful of bytes; this is a generous sane upper bound
// (matching assetwavloader.c's own approach to bounding a stack buffer)
// rather than a real expectation of needing that much.
#define ASSET_MP3_HEADER_SEARCH_MAX 8192
// MPEG-1 Layer III bitrates (kbps), indexed by the header's 4-bit bitrate
// index. Index 0 ("free format") and 15 (reserved) aren't supported.
static const uint16_t ASSET_MP3_BITRATE_MPEG1_L3[16] = {
0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0
};
// MPEG-2/2.5 Layer III bitrates (kbps) - a different table from MPEG-1's.
static const uint16_t ASSET_MP3_BITRATE_MPEG2_L3[16] = {
0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0
};
static const uint32_t ASSET_MP3_SAMPLE_RATE_MPEG1[4] = {
44100, 48000, 32000, 0
};
static const uint32_t ASSET_MP3_SAMPLE_RATE_MPEG2[4] = {
22050, 24000, 16000, 0
};
static const uint32_t ASSET_MP3_SAMPLE_RATE_MPEG25[4] = {
11025, 12000, 8000, 0
};
errorret_t assetMp3LoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Async loader should not be on main thread.");
if(loading->loading.mp3.state != ASSET_MP3_LOADER_STATE_READ_HEADER) {
errorOk();
}
assetmp3file_t *mp3File = &loading->entry->data.mp3;
memoryZero(mp3File, sizeof(assetmp3file_t));
assetfile_t file;
assetLoaderErrorChain(loading, assetFileInit(
&file, loading->entry->name, NULL, NULL
));
assetLoaderErrorChain(loading, assetFileOpen(&file));
assetLoaderErrorChain(loading, assetMp3ParseHeader(&file, mp3File));
assetLoaderErrorChain(loading, assetFileClose(&file));
assetLoaderErrorChain(loading, assetFileDispose(&file));
loading->loading.mp3.state = ASSET_MP3_LOADER_STATE_DONE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
errorret_t assetMp3LoaderSync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertTrue(loading->type == ASSET_LOADER_TYPE_MP3, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
switch(loading->loading.mp3.state) {
case ASSET_MP3_LOADER_STATE_INITIAL:
loading->loading.mp3.state = ASSET_MP3_LOADER_STATE_READ_HEADER;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
errorOk();
break;
case ASSET_MP3_LOADER_STATE_DONE:
break;
default:
errorOk();
}
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk();
}
errorret_t assetMp3Dispose(assetentry_t *entry) {
assertNotNull(entry, "Asset entry cannot be NULL");
assertTrue(entry->type == ASSET_LOADER_TYPE_MP3, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
errorOk();
}
errorret_t assetMp3ParseHeader(assetfile_t *file, assetmp3file_t *mp3File) {
assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(mp3File, "Mp3 file cannot be NULL.");
memoryZero(mp3File, sizeof(assetmp3file_t));
// Skip a leading ID3v2 tag if present: "ID3" + version(2) + flags(1) + a
// 4-byte syncsafe (7 usable bits/byte) size covering everything after
// this 10-byte header.
uint8_t id3Header[10];
errorChain(assetFileRead(file, id3Header, sizeof(id3Header)));
size_t searchBase;
if(memoryCompare(id3Header, "ID3", 3) == 0) {
const uint32_t tagSize = (
((uint32_t) (id3Header[6] & 0x7F) << 21) |
((uint32_t) (id3Header[7] & 0x7F) << 14) |
((uint32_t) (id3Header[8] & 0x7F) << 7) |
((uint32_t) (id3Header[9] & 0x7F))
);
errorChain(assetFileRead(file, NULL, tagSize));
searchBase = sizeof(id3Header) + tagSize;
} else {
// No ID3v2 tag - rewind the probe bytes just consumed so the frame
// sync search below starts from the true beginning of the file.
errorChain(assetFileRewind(file));
searchBase = 0;
}
const size_t searchWindowSize = mathMin(
(size_t) ASSET_MP3_HEADER_SEARCH_MAX,
(size_t) file->size > searchBase ? (size_t) file->size - searchBase : 0
);
if(searchWindowSize < 4) {
errorThrow(
"MP3 file is too short to contain a frame header: %s", file->filename
);
}
uint8_t *window = (uint8_t *) memoryAllocate(searchWindowSize);
errorret_t readRet = assetFileRead(file, window, searchWindowSize);
if(errorIsNotOk(readRet)) {
memoryFree(window);
errorChain(readRet);
}
bool_t found = false;
size_t frameIndex = 0;
for(size_t i = 0; i + 4 <= searchWindowSize; i++) {
if(window[i] != 0xFF || (window[i + 1] & 0xE0) != 0xE0) continue;
const uint8_t versionBits = (window[i + 1] >> 3) & 0x3;
const uint8_t layerBits = (window[i + 1] >> 1) & 0x3;
// reserved version, or not Layer III
if(versionBits == 1 || layerBits != 1) continue;
const uint8_t bitrateIndex = (window[i + 2] >> 4) & 0xF;
const uint8_t sampleRateIndex = (window[i + 2] >> 2) & 0x3;
if(
bitrateIndex == 0 || bitrateIndex == 15 || sampleRateIndex == 3
) continue;
const uint8_t channelMode = (window[i + 3] >> 6) & 0x3;
const bool_t isMpeg1 = versionBits == 3;
mp3File->bitrateKbps = isMpeg1
? ASSET_MP3_BITRATE_MPEG1_L3[bitrateIndex]
: ASSET_MP3_BITRATE_MPEG2_L3[bitrateIndex];
mp3File->sampleRate = isMpeg1
? ASSET_MP3_SAMPLE_RATE_MPEG1[sampleRateIndex]
: (versionBits == 2
? ASSET_MP3_SAMPLE_RATE_MPEG2[sampleRateIndex]
: ASSET_MP3_SAMPLE_RATE_MPEG25[sampleRateIndex]);
mp3File->samplesPerFrame = isMpeg1 ? 1152 : 576;
mp3File->channels = channelMode == 3 ? 1 : 2;
// A Xing/Info header, if present, sits right after the side info -
// whose size depends on MPEG version and channel mode - immediately
// following the 4-byte header (plus a 2-byte CRC, if the protection
// bit says one follows).
const bool_t hasCrc = (window[i + 1] & 0x1) == 0;
const size_t sideInfoSize = isMpeg1
? (mp3File->channels == 1 ? 17 : 32)
: (mp3File->channels == 1 ? 9 : 17);
const size_t xingOffset = i + 4 + (hasCrc ? 2 : 0) + sideInfoSize;
if(
xingOffset + 8 <= searchWindowSize &&
(
memoryCompare(window + xingOffset, "Xing", 4) == 0 ||
memoryCompare(window + xingOffset, "Info", 4) == 0
)
) {
const uint8_t *flagsBytes = window + xingOffset + 4;
const uint32_t flags = (
((uint32_t) flagsBytes[0] << 24) | ((uint32_t) flagsBytes[1] << 16) |
((uint32_t) flagsBytes[2] << 8) | (uint32_t) flagsBytes[3]
);
if((flags & 0x1) != 0 && xingOffset + 12 <= searchWindowSize) {
const uint8_t *framesBytes = window + xingOffset + 8;
const uint32_t xingFrameCount = (
((uint32_t) framesBytes[0] << 24) |
((uint32_t) framesBytes[1] << 16) |
((uint32_t) framesBytes[2] << 8) |
(uint32_t) framesBytes[3]
);
// The Xing/Info header's frame count includes the header's own
// frame (this one, at `i`) - which libmad (and sceMp3) will
// attempt to decode like any other, but which doesn't contribute a
// real decodable frame of audio the way every other one does.
// Confirmed against a real LAME-encoded file: its Xing header
// declared 40 frames, but decoding actually produced exactly 39
// frames' worth of real PCM - without this -1, playback would
// permanently stall one frame short of the declared total (audible
// as "never loops"), since real decode output can never catch up
// to an overstated total.
if(xingFrameCount > 0) {
mp3File->totalFrames =
(size_t) (xingFrameCount - 1) * mp3File->samplesPerFrame;
}
}
}
frameIndex = i;
found = true;
break;
}
memoryFree(window);
if(!found) {
errorThrow(
"MP3 file has no valid MPEG-1/2/2.5 Layer III frame within the first "
"%u bytes: %s",
(uint32_t) searchWindowSize, file->filename
);
}
mp3File->dataOffset = searchBase + frameIndex;
mp3File->dataSize = (size_t) file->size - mp3File->dataOffset;
// No Xing/Info header found - estimate assuming constant bitrate (the
// common case when one's genuinely absent; a real VBR file without one
// is rare and will just get an approximate duration/loop point instead
// of an exact one).
if(mp3File->totalFrames == 0) {
const float_t durationSeconds = (
((float_t) mp3File->dataSize * 8.0f) /
((float_t) mp3File->bitrateKbps * 1000.0f)
);
mp3File->totalFrames =
(size_t) (durationSeconds * (float_t) mp3File->sampleRate);
}
errorOk();
}
+113
View File
@@ -0,0 +1,113 @@
/**
* 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 "asset/assetfile.h"
typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t;
typedef struct { void *nothing; } assetmp3loaderinput_t;
typedef enum {
ASSET_MP3_LOADER_STATE_INITIAL,
ASSET_MP3_LOADER_STATE_READ_HEADER,
ASSET_MP3_LOADER_STATE_DONE
} assetmp3loaderstate_t;
typedef struct {
assetmp3loaderstate_t state;
} assetmp3loaderloading_t;
/**
* Parsed metadata for an MP3 asset - deliberately just enough to configure
* a decoder and know the clip's total length, not anything about its
* actual compressed content (which is read/decoded on demand at playback
* time - see audiostreammp3.c). Only MPEG-1/2/2.5 Layer III ("MP3" in the
* everyday sense) is supported; Layers I/II are rejected.
*/
typedef struct {
uint32_t sampleRate;
uint8_t channels;
// 1152 for MPEG-1, 576 for MPEG-2/2.5 - how many PCM samples (per
// channel) one compressed MPEG frame decodes to.
uint16_t samplesPerFrame;
// The first frame's bitrate, in kbps. Used only as a fallback duration
// estimate (assuming CBR) when no Xing/Info header is found - see
// assetMp3ParseHeader()'s own comment.
uint32_t bitrateKbps;
// Byte offset (from the start of the file) of the first real MPEG
// frame, i.e. right after any leading ID3v2 tag.
size_t dataOffset;
// Bytes of compressed MPEG data, from dataOffset to the end of the file.
size_t dataSize;
// Decoded PCM frame count for the whole clip - exact if a Xing/Info
// header was found (the common case for anything encoded with a modern
// tool), otherwise estimated from bitrateKbps/dataSize assuming CBR (see
// assetMp3ParseHeader()'s own comment) - unlike assetwavfile_t.dataSize,
// MP3 has no header field that gives this directly.
size_t totalFrames;
} assetmp3file_t;
typedef assetmp3file_t assetmp3output_t;
/**
* Async (background-thread) half of the MP3 loader - opens the asset file,
* parses its header (see assetMp3ParseHeader()), and closes it again. Never
* buffers the compressed MPEG data itself; that's read on demand at
* playback time by audiostreammp3.c via its own independent file handle.
*
* @param loading The asset loading slot.
* @return Error indicating success or failure.
*/
errorret_t assetMp3LoaderAsync(assetloading_t *loading);
/**
* Sync (main-thread) half of the MP3 loader - a simple state machine that
* schedules the async header parse and marks the entry loaded once it's
* done, mirroring assetWavLoaderSync()'s shape exactly.
*
* @param loading The asset loading slot.
* @return Error indicating success or failure.
*/
errorret_t assetMp3LoaderSync(assetloading_t *loading);
/**
* Disposes an MP3 asset entry. A no-op - an assetmp3file_t owns no heap
* allocations, and playback streams open their own independent file
* handles (see audiostreammp3.c).
*
* @param entry The asset entry to dispose.
* @return Error indicating success or failure.
*/
errorret_t assetMp3Dispose(assetentry_t *entry);
/**
* Parses an MP3 file's metadata from an already-opened asset file
* positioned at its very start: skips a leading ID3v2 tag if present,
* scans forward for the first valid MPEG-1/2/2.5 Layer III frame header
* (rejecting anything else - other layers, or no valid frame found within
* a sane search window), and checks that frame for a Xing/Info header
* (found in the vast majority of real-world MP3s, VBR or CBR) to get an
* exact total-frame count; falls back to a CBR estimate from the first
* frame's bitrate and the file's remaining size if absent.
*
* Never reads more than a bounded lookahead window into memory - the bulk
* of the file (the actual compressed audio, following the parsed frame's
* position) is left untouched, exactly like assetWavParseHeader().
*
* @param file Asset file to parse, positioned at offset 0.
* @param mp3File Filled with the parsed metadata on success.
* @return Error indicating success or failure.
*/
errorret_t assetMp3ParseHeader(assetfile_t *file, assetmp3file_t *mp3File);
@@ -6,6 +6,5 @@
# Sources # Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
savevita.c assetwavloader.c
savestreamvita.c
) )
+181
View File
@@ -0,0 +1,181 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetwavloader.h"
#include "util/memory.h"
#include "util/endian.h"
#include "assert/assert.h"
#include "asset/loader/assetloading.h"
#include "asset/loader/assetentry.h"
// "RIFF" + chunkSize + "WAVE", before any sub-chunks begin.
#define ASSET_WAV_RIFF_HEADER_SIZE 12
// A sub-chunk header is a 4-byte id followed by a 4-byte little-endian size.
#define ASSET_WAV_CHUNK_HEADER_SIZE 8
#define ASSET_WAV_FMT_CHUNK_SIZE_MIN 16
// Sane upper bound for a stack buffer - real fmt chunks (PCM or otherwise)
// never come close to this; anything bigger is treated as malformed.
#define ASSET_WAV_FMT_CHUNK_SIZE_MAX 64
errorret_t assetWavLoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Async loader should not be on main thread.");
if(loading->loading.wav.state != ASSET_WAV_LOADER_STATE_READ_HEADER) {
errorOk();
}
assetwavfile_t *wavFile = &loading->entry->data.wav;
memoryZero(wavFile, sizeof(assetwavfile_t));
assetfile_t file;
assetLoaderErrorChain(loading, assetFileInit(
&file, loading->entry->name, NULL, NULL
));
assetLoaderErrorChain(loading, assetFileOpen(&file));
assetLoaderErrorChain(loading, assetWavParseHeader(&file, wavFile));
assetLoaderErrorChain(loading, assetFileClose(&file));
assetLoaderErrorChain(loading, assetFileDispose(&file));
loading->loading.wav.state = ASSET_WAV_LOADER_STATE_DONE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
errorret_t assetWavLoaderSync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertTrue(loading->type == ASSET_LOADER_TYPE_WAV, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
switch(loading->loading.wav.state) {
case ASSET_WAV_LOADER_STATE_INITIAL:
loading->loading.wav.state = ASSET_WAV_LOADER_STATE_READ_HEADER;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
errorOk();
break;
case ASSET_WAV_LOADER_STATE_DONE:
break;
default:
errorOk();
}
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk();
}
errorret_t assetWavDispose(assetentry_t *entry) {
assertNotNull(entry, "Asset entry cannot be NULL");
assertTrue(entry->type == ASSET_LOADER_TYPE_WAV, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
errorOk();
}
errorret_t assetWavParseHeader(
assetfile_t *file,
assetwavfile_t *wavFile
) {
assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(wavFile, "Wav file cannot be NULL.");
uint8_t riffHeader[ASSET_WAV_RIFF_HEADER_SIZE];
errorChain(assetFileRead(file, riffHeader, sizeof(riffHeader)));
if(memoryCompare(riffHeader, "RIFF", 4) != 0) {
errorThrow("WAV file has an invalid RIFF header: %s", file->filename);
}
if(memoryCompare(riffHeader + 8, "WAVE", 4) != 0) {
errorThrow("WAV file has an invalid WAVE header: %s", file->filename);
}
bool_t foundFormat = false;
bool_t foundData = false;
uint16_t audioFormat = 0;
// Walk the chunk list, reading only chunk headers (and the small "fmt "
// body) - every other chunk, "data"'s sample bytes included, is skipped
// via a NULL-buffer read rather than buffered into memory. Stops as soon
// as "data" is found; nothing after it matters here.
while(!foundData && (size_t) file->position < (size_t) file->size) {
uint8_t chunkHeader[ASSET_WAV_CHUNK_HEADER_SIZE];
errorChain(assetFileRead(file, chunkHeader, sizeof(chunkHeader)));
uint32_t chunkSizeLE;
memoryCopy(&chunkSizeLE, chunkHeader + 4, sizeof(chunkSizeLE));
const uint32_t chunkSize = endianLittleToHost32(chunkSizeLE);
// Chunks are padded to an even total size - the pad byte (if any)
// isn't included in chunkSize but still needs to be skipped over.
const uint32_t chunkSizePadded = chunkSize + (chunkSize % 2);
if(memoryCompare(chunkHeader, "fmt ", 4) == 0) {
if(
chunkSize < ASSET_WAV_FMT_CHUNK_SIZE_MIN ||
chunkSize > ASSET_WAV_FMT_CHUNK_SIZE_MAX
) {
errorThrow("WAV 'fmt ' chunk has an unsupported size: %u", chunkSize);
}
uint8_t fmtBuffer[ASSET_WAV_FMT_CHUNK_SIZE_MAX];
errorChain(assetFileRead(file, fmtBuffer, chunkSize));
if(chunkSize % 2 != 0) {
errorChain(assetFileRead(file, NULL, 1));
}
uint16_t u16;
uint32_t u32;
memoryCopy(&u16, fmtBuffer + 0, sizeof(u16));
audioFormat = endianLittleToHost16(u16);
memoryCopy(&u16, fmtBuffer + 2, sizeof(u16));
wavFile->channels = (uint8_t) endianLittleToHost16(u16);
memoryCopy(&u32, fmtBuffer + 4, sizeof(u32));
wavFile->sampleRate = endianLittleToHost32(u32);
memoryCopy(&u16, fmtBuffer + 14, sizeof(u16));
wavFile->bitsPerSample = (uint8_t) endianLittleToHost16(u16);
foundFormat = true;
} else if(memoryCompare(chunkHeader, "data", 4) == 0) {
wavFile->dataOffset = (size_t) file->position;
wavFile->dataSize = chunkSize;
foundData = true;
} else {
errorChain(assetFileRead(file, NULL, chunkSizePadded));
}
}
if(!foundFormat) {
errorThrow("WAV file is missing its 'fmt ' chunk: %s", file->filename);
}
if(!foundData) {
errorThrow("WAV file is missing its 'data' chunk: %s", file->filename);
}
if(audioFormat != 1) {
errorThrow(
"Unsupported WAV audio format: %u (only PCM is supported): %s",
audioFormat, file->filename
);
}
if(wavFile->bitsPerSample != 16 && wavFile->bitsPerSample != 24) {
errorThrow(
"Unsupported WAV bits per sample: %u (only 16/24-bit is supported): %s",
wavFile->bitsPerSample, file->filename
);
}
if(wavFile->channels == 0) {
errorThrow("WAV file declares 0 channels: %s", file->filename);
}
errorOk();
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "asset/assetfile.h"
typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t;
/** Input passed to the wav loader - currently unused. */
typedef struct { void *nothing; } assetwavloaderinput_t;
typedef enum {
ASSET_WAV_LOADER_STATE_INITIAL,
ASSET_WAV_LOADER_STATE_READ_HEADER,
ASSET_WAV_LOADER_STATE_DONE
} assetwavloaderstate_t;
/** Per-slot scratch data used while the wav file is loading. */
typedef struct {
assetwavloaderstate_t state;
} assetwavloaderloading_t;
/**
* Parsed metadata for a WAV asset - only the RIFF/WAVE/fmt/data chunk
* headers are ever read; the PCM sample data itself is never loaded here
* (see @ref assetWavParseHeader). Playback reads sample data directly
* from the archive on demand instead - see audiostreampcm.h.
*/
typedef struct {
/** Sample rate of the PCM data, in Hz. */
uint32_t sampleRate;
/** Number of interleaved channels in the PCM data. */
uint8_t channels;
/**
* Bits per sample of the *source* file data - 16 or 24 (see
* assetWavParseHeader). Playback always reads 16-bit samples out via
* audioStreamPcmRead() regardless of this - a 24-bit source is
* truncated to 16-bit there, since none of this project's audio
* backends (PSP/Dolphin hardware, SDL2 on Linux) accept anything wider.
*/
uint8_t bitsPerSample;
/** Byte offset from the start of the file to the first PCM sample. */
size_t dataOffset;
/** Size of the PCM data, in bytes. */
size_t dataSize;
} assetwavfile_t;
/** Convenience alias - the loaded output type of a wav asset entry. */
typedef assetwavfile_t assetwavoutput_t;
/**
* Asynchronous loader callback. Opens the WAV file and reads just enough of
* it to locate and parse the `fmt ` chunk and locate (not read) the `data`
* chunk - see @ref assetWavParseHeader. All I/O happens here so the main
* thread is not blocked. Sets entry state to `ASSET_ENTRY_STATE_PENDING_SYNC`
* on success or `ASSET_ENTRY_STATE_ERROR` on failure.
*
* @param loading The loading slot for this asset entry.
* @return OK on success, error otherwise.
*/
errorret_t assetWavLoaderAsync(assetloading_t *loading);
/**
* Synchronous loader callback. Confirms the async phase completed and marks
* the entry as `ASSET_ENTRY_STATE_LOADED`.
*
* @param loading The loading slot for this asset entry.
* @return OK on success, error otherwise.
*/
errorret_t assetWavLoaderSync(assetloading_t *loading);
/**
* Dispose callback. The wav asset owns no allocations of its own (its data
* chunk is read directly from the archive by whichever streams are playing
* it, each through their own handle - see audiostreampcm.h), so this is
* currently a no-op beyond the standard asserts.
*
* @param entry The asset entry to dispose.
* @return OK on success, error otherwise.
*/
errorret_t assetWavDispose(assetentry_t *entry);
/**
* Parses a RIFF/WAVE file's chunk structure from an already-open asset
* file, reading only chunk headers (and the small `fmt ` chunk body) -
* every other chunk, including `data`'s actual sample bytes, is skipped
* over via `assetFileRead(file, NULL, size)` rather than read into memory,
* so this never buffers the (potentially large) PCM payload.
*
* Stops as soon as the `data` chunk header is found, recording its file
* offset and declared size in `wavFile` without reading any of its bytes -
* the file is left positioned at the start of the PCM data.
*
* Only PCM (audio format 1), 16- or 24-bit-per-sample WAV data is
* supported - see audiostreampcm.h's audioStreamPcmRead() for how a
* 24-bit source gets truncated to the 16-bit output every platform
* backend expects. Requires the `fmt ` chunk to appear before `data`, per
* the WAV spec's recommended ordering.
*
* @param file An open asset file, positioned at the start of the WAV data.
* @param wavFile Struct whose fields will be filled in.
* @return OK on success, error if the file is malformed or an unsupported
* format.
*/
errorret_t assetWavParseHeader(
assetfile_t *file,
assetwavfile_t *wavFile
);
+14
View File
@@ -0,0 +1,14 @@
# 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
audio.c
)
# Subdirs
add_subdirectory(mixer)
add_subdirectory(stream)
+73
View File
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "audio.h"
#include "audio/mixer/audiomixer.h"
#include "util/memory.h"
#include "assert/assert.h"
audio_t AUDIO;
errorret_t audioInit() {
memoryZero(&AUDIO, sizeof(audio_t));
errorChain(audioPlatformInit());
errorChain(audioMixerInit());
errorOk();
}
audiostream_t * audioAquireStream(assetentry_t *asset) {
assertNotNull(asset, "Asset cannot be NULL.");
// Same check audioStreamInit() makes - see its own comment on why this
// is an assert (a programmer error, not untrusted data). Checking here
// too catches the mistake as early as possible, before any stream slot
// is handed out for it.
assertTrue(
audioStreamTypeForAssetType(asset->type) != AUDIO_STREAM_TYPE_NULL,
"Unsupported asset type for an audio stream."
);
for(uint8_t i = 0; i < AUDIO_STREAMS_MAX; i++) {
audiostream_t *stream = &AUDIO.streams[i];
if(stream->type == AUDIO_STREAM_TYPE_NULL) {
return stream;
}
}
return NULL;
}
errorret_t audioUpdateEarly() {
errorChain(audioPlatformUpdate());
errorChain(audioMixerUpdateEarly());
for(uint8_t i = 0; i < AUDIO_STREAMS_MAX; i++) {
audiostream_t *stream = &AUDIO.streams[i];
errorChain(audioStreamUpdate(stream));
}
errorOk();
}
errorret_t audioUpdateLate() {
errorChain(audioMixerUpdateLate());
errorOk();
}
errorret_t audioDispose() {
for(uint8_t i = 0; i < AUDIO_STREAMS_MAX; i++) {
audiostream_t *stream = &AUDIO.streams[i];
errorChain(audioStreamDispose(stream));
}
errorChain(audioMixerDispose());
errorChain(audioPlatformDispose());
errorOk();
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
#include "audio/stream/audiostream.h"
#include "audio/audioplatform.h"
#ifndef audioPlatformInit
#error "audioPlatformInit is not defined"
#endif
#ifndef audioPlatformUpdate
#error "audioPlatformUpdate is not defined"
#endif
#ifndef audioPlatformDispose
#error "audioPlatformDispose is not defined"
#endif
typedef struct {
audiostream_t streams[AUDIO_STREAMS_MAX];
} audio_t;
extern audio_t AUDIO;
/**
* Initializes the audio subsystem.
*
* @return Error indicating success or failure of the operation.
*/
errorret_t audioInit();
/**
* Aquires an available audio stream for playing the given asset. Can return
* NULL if there is no available stream slot.
*
* The returned stream is not yet configured to play `asset` - call
* audioStreamInit(stream, asset) next.
*
* @param asset The asset the caller intends to play - validated eagerly so
* an unsupported asset type is caught here rather than only
* once audioStreamInit() is called.
* @return Pointer to an available audio stream, or NULL if none are free.
*/
audiostream_t * audioAquireStream(assetentry_t *asset);
/**
* Updates the audio subsystem, updating every active stream. Should be
* called once per frame, before rendering - keeps stream buffering/decode
* work off the render thread's critical path (see duskmad's MP3 decode
* ring for why this matters).
*
* @return Error indicating success or failure of the operation.
*/
errorret_t audioUpdateEarly();
/**
* Updates the audio subsystem's end-of-frame work - currently just starting
* whatever the audio mixer queued this frame (see
* audioMixerUpdateLate()'s own comment). Should be called once per frame,
* after rendering.
*
* @return Error indicating success or failure of the operation.
*/
errorret_t audioUpdateLate();
/**
* Disposes the audio subsystem, stopping and disposing every active stream.
*
* @return Error indicating success or failure of the operation.
*/
errorret_t audioDispose();
+11
View File
@@ -0,0 +1,11 @@
# 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
audiomixer.c
audiomixerchannel.c
)
+199
View File
@@ -0,0 +1,199 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "audiomixer.h"
#include "audio/stream/audiostream.h"
#include "util/memory.h"
#include "util/string.h"
#include "assert/assert.h"
audiomixer_t AUDIO_MIXER;
errorret_t audioMixerInit() {
memoryZero(&AUDIO_MIXER, sizeof(audiomixer_t));
audiomixerchannel_t channel = 0;
while(channel < AUDIO_MIXER_CHANNEL_COUNT) {
AUDIO_MIXER.channels[channel].fade = 1.0f;
// -1 means "no fade in progress" - see its own comment; zero-init
// would otherwise look like an active fade at time 0.
AUDIO_MIXER.channels[channel].fadeTime = -1.0f;
channel++;
}
errorOk();
}
errorret_t audioMixerUpdateEarly() {
audiomixerchannel_t channel = 0;
while(channel < AUDIO_MIXER_CHANNEL_COUNT) {
// One channel's error (e.g. a failed asset load) must not stop every
// other channel from being updated this frame - caught and logged
// rather than propagated, same as audioStream*IsFinished()'s own
// per-stream error handling.
errorret_t ret = audioMixerChannelUpdateEarly(
channel, &AUDIO_MIXER.channels[channel]
);
if(errorIsNotOk(ret)) errorCatch(errorPrint(ret));
channel++;
}
errorOk();
}
errorret_t audioMixerUpdateLate() {
audiomixerchannel_t channel = 0;
while(channel < AUDIO_MIXER_CHANNEL_COUNT) {
errorret_t ret =
audioMixerChannelUpdateLate(&AUDIO_MIXER.channels[channel]);
if(errorIsNotOk(ret)) errorCatch(errorPrint(ret));
channel++;
}
errorOk();
}
void audioMixerPlay(
const char_t *file,
const audiomixerchannel_t channel,
const float_t volume,
const float_t pan
) {
audioMixerQueuePlay(file, channel, volume, pan, false, 0, -1.0f, 0.0f);
}
void audioMixerPlayLooped(
const char_t *file,
const audiomixerchannel_t channel,
const float_t volume,
const float_t pan,
const uint8_t loopCount,
const float_t loopStart,
const float_t loopTo
) {
audioMixerQueuePlay(
file, channel, volume, pan, true, loopCount, loopStart, loopTo
);
}
void audioMixerQueuePlay(
const char_t *file,
const audiomixerchannel_t channel,
const float_t volume,
const float_t pan,
const bool_t looping,
const uint8_t loopCount,
const float_t loopStart,
const float_t loopTo
) {
assertStrLenMin(file, 1, "File is required, to stop use audioMixerStop");
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
assertTrue(volume > 0.0f && volume <= 1.0f, "Volume is out of range");
assertTrue(
pan >= AUDIO_STREAM_LEFT && pan <= AUDIO_STREAM_RIGHT,
"Pan is out of range"
);
assertTrue(
!looping || loopStart < 0.0f || loopTo < loopStart,
"Loop points incorrect"
);
audiomixercommand_t *command = &AUDIO_MIXER.channels[channel].pending;
command->command = AUDIO_MIXER_COMMAND_PLAY;
stringCopy(command->file, file, AUDIO_PATH_MAX);
command->volume = volume;
command->pan = pan;
command->looping = looping;
command->loopCount = loopCount;
command->loopStart = loopStart;
command->loopTo = loopTo;
}
void audioMixerStop(const audiomixerchannel_t channel) {
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
AUDIO_MIXER.channels[channel].pending.command = AUDIO_MIXER_COMMAND_STOP;
}
void audioMixerPause(const audiomixerchannel_t channel) {
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
audiostream_t *stream = AUDIO_MIXER.channels[channel].stream;
if(stream != NULL) audioStreamPause(stream);
}
void audioMixerResume(const audiomixerchannel_t channel) {
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
audiostream_t *stream = AUDIO_MIXER.channels[channel].stream;
if(stream != NULL) audioStreamPlay(stream);
}
void audioMixerSetPan(const audiomixerchannel_t channel, const float_t pan) {
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
assertTrue(
pan >= AUDIO_STREAM_LEFT && pan <= AUDIO_STREAM_RIGHT,
"Pan is out of range"
);
audiostream_t *stream = AUDIO_MIXER.channels[channel].stream;
if(stream != NULL) audioStreamSetDirectionality(stream, pan);
}
void audioMixerSetLoop(
const audiomixerchannel_t channel,
const bool_t looping,
const uint8_t loopCount,
const float_t loopStart,
const float_t loopTo
) {
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
assertTrue(
!looping || loopStart < 0.0f || loopTo < loopStart,
"Loop points incorrect"
);
audiostream_t *stream = AUDIO_MIXER.channels[channel].stream;
if(stream == NULL) return;
audioStreamSetLooping(stream, looping);
if(looping) {
audioStreamSetLoopPoints(stream, loopStart, loopTo);
audioStreamSetLoopLimit(stream, loopCount);
}
}
void audioMixerFadeTo(
const audiomixerchannel_t channel,
const float_t from,
const float_t to,
const float_t duration,
const easingtype_t easing
) {
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
audioMixerChannelFadeTo(
&AUDIO_MIXER.channels[channel], from, to, duration, easing
);
}
bool_t audioMixerIsFading(const audiomixerchannel_t channel) {
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
return audioMixerChannelIsFading(&AUDIO_MIXER.channels[channel]);
}
errorret_t audioMixerDispose() {
audiomixerchannel_t channel = 0;
while(channel < AUDIO_MIXER_CHANNEL_COUNT) {
errorChain(audioMixerChannelStopStream(&AUDIO_MIXER.channels[channel]));
channel++;
}
errorOk();
}
+204
View File
@@ -0,0 +1,204 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "audio/mixer/audiomixerchannel.h"
typedef struct {
audiomixerchannelstate_t channels[AUDIO_MIXER_CHANNEL_COUNT];
} audiomixer_t;
extern audiomixer_t AUDIO_MIXER;
/**
* Initializes the audio mixer.
*/
errorret_t audioMixerInit();
/**
* Updates the audio mixer, called before rendering (the start of the
* frame) - see audioMixerChannelUpdateEarly()'s own comment, run for every
* channel.
*/
errorret_t audioMixerUpdateEarly();
/**
* Updates the audio mixer, called at the end of each frame - see
* audioMixerChannelUpdateLate()'s own comment, run for every channel.
*/
errorret_t audioMixerUpdateLate();
/**
* Same as audioMixerPlayLooped() but with no looping.
*
* @param file File to be played.
* @param channel Channel you want to play this sound on.
* @param volume Volume of the sound, from 0.0 (silent) to 1.0 (loudest).
* @param pan Stereo panning of the sound, from AUDIO_STREAM_LEFT to
* AUDIO_STREAM_RIGHT.
*/
void audioMixerPlay(
const char_t *file,
const audiomixerchannel_t channel,
const float_t volume,
const float_t pan
);
/**
* Queues a sound to be played on the next audio mixer update, which occurs at
* the end of each frame.
*
* Mixer channels can only hold one voice (audio stream) at a time. If you try
* to queue multiple sounds on the same channel only the last one will be played
* and will stop any actively playing on that channel currently.
*
* Volume is also mixed based on the channel, so supplying volume 1.0 is then
* multiplied by the channel's volume, e.g. 0.5*1.0 = 0.5.
*
* @param file File to be played.
* @param channel Channel you want to play this sound on.
* @param volume Volume of the sound, from 0.0 (silent) to 1.0 (loudest).
* @param pan Stereo panning of the sound, from AUDIO_STREAM_LEFT to
* AUDIO_STREAM_RIGHT.
* @param loopCount How many times to loop the sound, 0 for infinite looping.
* @param loopStart Where the loop segment ends, in seconds, or -1 to loop
* the whole stream (the default).
* @param loopTo Where the loop segment starts, in seconds.
*/
void audioMixerPlayLooped(
const char_t *file,
const audiomixerchannel_t channel,
const float_t volume,
const float_t pan,
const uint8_t loopCount,
const float_t loopStart,
const float_t loopTo
);
/**
* Shared implementation behind audioMixerPlay()/audioMixerPlayLooped() -
* queues an AUDIO_MIXER_COMMAND_PLAY for the given channel, to be loaded at
* the end of this frame and started at the start of the next (see
* audioMixerUpdateLate()/audioMixerUpdateEarly()).
*
* @param file File to be played.
* @param channel Channel you want to play this sound on.
* @param volume Volume of the sound, from 0.0 (silent) to 1.0 (loudest).
* @param pan Stereo panning of the sound, from AUDIO_STREAM_LEFT to
* AUDIO_STREAM_RIGHT.
* @param looping Whether the stream should loop once it finishes.
* @param loopCount How many times to loop the sound, 0 for infinite looping.
* Ignored unless looping is true.
* @param loopStart Where the loop segment ends, in seconds, or -1 to loop
* the whole stream. Ignored unless looping is true.
* @param loopTo Where the loop segment starts, in seconds. Ignored unless
* looping is true.
*/
void audioMixerQueuePlay(
const char_t *file,
const audiomixerchannel_t channel,
const float_t volume,
const float_t pan,
const bool_t looping,
const uint8_t loopCount,
const float_t loopStart,
const float_t loopTo
);
/**
* Stops playing sound on the given channel.
*
* @param channel Channel to stop.
*/
void audioMixerStop(const audiomixerchannel_t channel);
/**
* Pauses the given channel's currently playing stream, if any - a safe
* no-op if the channel isn't playing anything. Playback position is
* retained, so audioMixerResume() continues from the same point.
*
* @param channel Channel to pause.
*/
void audioMixerPause(const audiomixerchannel_t channel);
/**
* Resumes the given channel's currently paused stream, if any - a safe
* no-op if the channel isn't playing anything.
*
* @param channel Channel to resume.
*/
void audioMixerResume(const audiomixerchannel_t channel);
/**
* Sets the stereo panning of the given channel's currently playing stream,
* if any - a safe no-op if the channel isn't playing anything. Only
* affects what's playing right now; a later audioMixerPlay()/
* audioMixerPlayLooped() call on this channel uses its own `pan` argument
* instead.
*
* @param channel Channel to update.
* @param pan Stereo panning, from AUDIO_STREAM_LEFT to AUDIO_STREAM_RIGHT.
*/
void audioMixerSetPan(const audiomixerchannel_t channel, const float_t pan);
/**
* Sets the looping behaviour of the given channel's currently playing
* stream, if any - a safe no-op if the channel isn't playing anything.
* Only affects what's playing right now; a later audioMixerPlay()/
* audioMixerPlayLooped() call on this channel uses its own loop arguments
* instead.
*
* @param channel Channel to update.
* @param looping Whether the stream should loop.
* @param loopCount How many times to loop, 0 for infinite. Ignored unless
* looping is true.
* @param loopStart Where the loop segment ends, in seconds, or -1 to loop
* the whole stream. Ignored unless looping is true.
* @param loopTo Where the loop segment starts, in seconds. Ignored unless
* looping is true.
*/
void audioMixerSetLoop(
const audiomixerchannel_t channel,
const bool_t looping,
const uint8_t loopCount,
const float_t loopStart,
const float_t loopTo
);
/**
* Starts fading the given channel from `from` to `to` over `duration`
* seconds, eased by `easing` - see audioMixerChannelFadeTo()'s own
* comment. Continues running (advanced every audioMixerUpdateEarly()) even
* if the channel isn't currently playing anything.
*
* @param channel Channel to fade.
* @param from Starting fade value, from 0.0 (silent) to 1.0 (loudest).
* @param to Ending fade value, from 0.0 (silent) to 1.0 (loudest).
* @param duration How long the fade takes, in seconds.
* @param easing Easing curve to apply to the fade's progress.
*/
void audioMixerFadeTo(
const audiomixerchannel_t channel,
const float_t from,
const float_t to,
const float_t duration,
const easingtype_t easing
);
/**
* Whether the given channel currently has a fade in progress (started via
* audioMixerFadeTo() and not yet finished).
*
* @param channel Channel to check.
* @return true if a fade is still in progress.
*/
bool_t audioMixerIsFading(const audiomixerchannel_t channel);
/**
* Disposes the audio mixer.
*/
errorret_t audioMixerDispose();
+223
View File
@@ -0,0 +1,223 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "audiomixerchannel.h"
#include "asset/asset.h"
#include "audio/audio.h"
#include "audio/stream/audiostream.h"
#include "audio/stream/audiostreamtype.h"
#include "save/save.h"
#include "time/time.h"
#include "util/math.h"
#include "assert/assert.h"
errorret_t audioMixerChannelStopStream(audiomixerchannelstate_t *state) {
assertNotNull(state, "State cannot be NULL.");
if(state->stream == NULL) errorOk();
errorChain(audioStreamDispose(state->stream));
state->stream = NULL;
errorOk();
}
void audioMixerChannelFadeTo(
audiomixerchannelstate_t *state,
const float_t from,
const float_t to,
const float_t duration,
const easingtype_t easing
) {
assertNotNull(state, "State cannot be NULL.");
state->fade = from;
state->fadeFrom = from;
state->fadeTo = to;
state->fadeDuration = duration;
state->fadeEasing = easing;
// A non-positive duration finishes instantly - marked inactive right
// away (fadeTime < 0) rather than left at 0 to progress next frame,
// since there's nothing left to progress.
if(duration <= 0.0f) {
state->fade = to;
state->fadeTime = -1.0f;
} else {
state->fadeTime = 0.0f;
}
}
bool_t audioMixerChannelIsFading(const audiomixerchannelstate_t *state) {
assertNotNull(state, "State cannot be NULL.");
return state->fadeTime >= 0.0f;
}
void audioMixerChannelApplyVolume(
const audiomixerchannel_t channel,
audiomixerchannelstate_t *state
) {
assertNotNull(state, "State cannot be NULL.");
if(state->stream == NULL) return;
// Advances any in-progress fade before it's read below - see
// audioMixerChannelFadeTo()'s own comment on fadeTime's meaning.
if(state->fadeTime >= 0.0f) {
state->fadeTime += TIME.delta;
const float_t t = mathClamp(
state->fadeTime / state->fadeDuration, 0.0f, 1.0f
);
state->fade = state->fadeFrom +
(state->fadeTo - state->fadeFrom) * easingApply(state->fadeEasing, t);
if(state->fadeTime >= state->fadeDuration) {
state->fade = state->fadeTo;
state->fadeTime = -1.0f;
}
}
// state->fade and both SAVE.settings volumes have no setter - written
// directly by callers - so this is the only place any of them ever gets
// validated, rather than at the point they're set.
assertTrue(
state->fade >= 0.0f && state->fade <= 1.0f,
"Channel fade is out of range."
);
assertTrue(
SAVE.settings.audioChannelVolume[channel] >= 0.0f &&
SAVE.settings.audioChannelVolume[channel] <= 1.0f,
"Channel volume is out of range."
);
assertTrue(
SAVE.settings.audioMasterVolume >= 0.0f &&
SAVE.settings.audioMasterVolume <= 1.0f,
"Master volume is out of range."
);
// Final computed volume.
float_t volume = (
state->baseVolume *
state->fade *
SAVE.settings.audioChannelVolume[channel] *
SAVE.settings.audioMasterVolume
);
audioStreamSetVolume(state->stream, volume);
}
errorret_t audioMixerChannelUpdateEarly(
const audiomixerchannel_t channel,
audiomixerchannelstate_t *state
) {
assertNotNull(state, "State cannot be NULL.");
audiomixercommand_t *command = &state->pending;
if(command->command == AUDIO_MIXER_COMMAND_STOP) {
errorChain(audioMixerChannelStopStream(state));
command->command = AUDIO_MIXER_COMMAND_NONE;
audioMixerChannelApplyVolume(channel, state);
errorOk();
}
if(
command->command != AUDIO_MIXER_COMMAND_PLAY ||
command->loadingAsset == NULL
) {
// A PLAY command with no loadingAsset yet (command->loadingAsset ==
// NULL) was queued too late for the audioMixerChannelUpdateLate() that
// just ran immediately before this call to have locked it - the only
// way that happens is a command queued outside the normal per-frame
// cycle, e.g. during engine startup before any
// audioMixerChannelUpdateLate() has run yet. Left alone (still
// AUDIO_MIXER_COMMAND_PLAY) rather than treated as an error: the very
// next audioMixerChannelUpdateLate() will lock it, and the frame after
// that will reach here and apply it normally.
audioMixerChannelApplyVolume(channel, state);
errorOk();
}
// Blocks here if audioMixerChannelUpdateLate()'s load (kicked off at the
// end of the previous frame) hasn't finished yet - see this function's
// own doc comment on why that's accepted for now.
errorret_t loadRet = assetRequireLoaded(command->loadingAsset);
if(errorIsNotOk(loadRet)) {
assetUnlockEntry(command->loadingAsset);
command->loadingAsset = NULL;
command->command = AUDIO_MIXER_COMMAND_NONE;
errorChain(loadRet);
}
errorChain(audioMixerChannelStopStream(state));
audiostream_t *stream = audioAquireStream(command->loadingAsset);
assertNotNull(stream, "No free audio stream slots available.");
errorret_t initRet = audioStreamInit(stream, command->loadingAsset);
// The stream holds its own lock on the asset once initialized (or, on
// failure, audioStreamInit() has already released it itself) - either
// way this call's own lock is no longer needed.
assetUnlockEntry(command->loadingAsset);
command->loadingAsset = NULL;
command->command = AUDIO_MIXER_COMMAND_NONE;
errorChain(initRet);
// Forwards to this channel's own onLoop/onEnd (persistent across every
// play on this channel) rather than this one stream instance's own -
// see audiomixerchannelstate_t.onLoop's own comment.
stream->user = state;
stream->onLoop = audioMixerChannelOnStreamLoop;
stream->onEnd = audioMixerChannelOnStreamEnd;
audioStreamSetDirectionality(stream, command->pan);
if(command->looping) {
audioStreamSetLooping(stream, true);
audioStreamSetLoopPoints(stream, command->loopStart, command->loopTo);
audioStreamSetLoopLimit(stream, command->loopCount);
}
audioStreamPlay(stream);
state->stream = stream;
state->baseVolume = command->volume;
audioMixerChannelApplyVolume(channel, state);
errorOk();
}
errorret_t audioMixerChannelUpdateLate(audiomixerchannelstate_t *state) {
assertNotNull(state, "State cannot be NULL.");
audiomixercommand_t *command = &state->pending;
if(command->command != AUDIO_MIXER_COMMAND_PLAY) errorOk();
assetloadertype_t assetType = audioStreamAssetTypeForPath(command->file);
assertTrue(
assetType != ASSET_LOADER_TYPE_NULL, "Unsupported audio file extension."
);
// Begins loading now - kicked off at the end of this frame so it has
// until the start of the next (audioMixerChannelUpdateEarly()) to finish
// before that has to block on it.
command->loadingAsset = assetLock(command->file, assetType, NULL);
errorOk();
}
void audioMixerChannelOnStreamLoop(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
audiomixerchannelstate_t *state = (audiomixerchannelstate_t *) stream->user;
if(state->onLoop != NULL) state->onLoop(state);
}
void audioMixerChannelOnStreamEnd(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
audiomixerchannelstate_t *state = (audiomixerchannelstate_t *) stream->user;
if(state->onEnd != NULL) state->onEnd(state);
}
+242
View File
@@ -0,0 +1,242 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
#include "error/error.h"
#include "animation/easing.h"
// Forward-declared rather than including asset/asset.h - this header only
// ever needs a pointer to it, and pulling in the full asset module here
// creates a real include cycle for anything that reaches this header via
// asset/loader/cutscene/assetcutsceneloader.h (asset.h -> ... ->
// assetloader.h -> assetcutsceneloader.h -> cutscene.h -> cutsceneitem.h
// -> the audio cutscene items -> this header -> asset/asset.h again,
// mid-parse). audiomixerchannel.c itself still includes the full header.
typedef struct assetentry_s assetentry_t;
typedef struct audiostream_s audiostream_t;
#define AUDIO_PATH_MAX 256
typedef enum {
AUDIO_MIXER_CHANNEL_BGM_0,
AUDIO_MIXER_CHANNEL_VOICE_0,
AUDIO_MIXER_CHANNEL_VOICE_1,
AUDIO_MIXER_CHANNEL_SFX_0,
AUDIO_MIXER_CHANNEL_SFX_1,
AUDIO_MIXER_CHANNEL_SFX_2,
AUDIO_MIXER_CHANNEL_SFX_3,
AUDIO_MIXER_CHANNEL_COUNT
} audiomixerchannel_t;
typedef enum {
AUDIO_MIXER_COMMAND_NONE,
AUDIO_MIXER_COMMAND_PLAY,
AUDIO_MIXER_COMMAND_STOP
} audiomixercommandtype_t;
typedef struct {
// What audioMixerChannelUpdateEarly() should do to this channel at the
// start of the next frame - AUDIO_MIXER_COMMAND_NONE if nothing's
// queued. Set by audioMixerPlay()/audioMixerPlayLooped()/
// audioMixerStop(); queuing a new command on a channel replaces, rather
// than stacks on top of, whatever was already queued for it this frame -
// only the most recent survives.
audiomixercommandtype_t command;
// AUDIO_MIXER_COMMAND_PLAY only, below.
char_t file[AUDIO_PATH_MAX];
float_t volume;
float_t pan;
bool_t looping;
uint8_t loopCount;
float_t loopStart;
float_t loopTo;
// Set by audioMixerChannelUpdateLate() (the end of the frame this PLAY
// command was queued on), once it locks the requested file and kicks off
// its load. audioMixerChannelUpdateEarly() (the start of the next frame)
// blocks on this being fully loaded before actually starting playback -
// see its own comment. NULL except in that in-between window.
assetentry_t *loadingAsset;
} audiomixercommand_t;
typedef struct audiomixerchannelstate_s audiomixerchannelstate_t;
typedef struct audiomixerchannelstate_s {
// Queued action for the next audioMixerChannelUpdateEarly() to apply.
audiomixercommand_t pending;
// This channel's currently playing stream, or NULL if nothing is -
// acquired from AUDIO.streams by audioMixerChannelUpdateEarly() when it
// applies an AUDIO_MIXER_COMMAND_PLAY, disposed the same way on
// AUDIO_MIXER_COMMAND_STOP or before a new AUDIO_MIXER_COMMAND_PLAY
// replaces it.
audiostream_t *stream;
// The per-sound volume `stream` was last started with (the `volume`
// argument to whichever audioMixerPlay()/audioMixerPlayLooped() call
// started it) - combined with this channel's fade, its own volume
// (SAVE.settings.audioChannelVolume) and the mixer's master volume
// (SAVE.settings.audioMasterVolume), every frame, to get the volume
// actually passed to audioStreamSetVolume(). Meaningless while `stream`
// is NULL.
float_t baseVolume;
// This channel's own fade multiplier, from 0.0 (fully faded out) to 1.0
// (no fade applied, the default) - transient runtime state, unlike the
// channel/master volumes below (which are persisted user preferences).
// No setter - write it directly; audioMixerChannelApplyVolume() asserts
// it's in range the next time it reads it, rather than validating at the
// point it's set.
float_t fade;
// Fade transition state, set by audioMixerChannelFadeTo() and advanced
// every frame by audioMixerChannelApplyVolume(), before it reads `fade`
// to compute the mixed volume. fadeTime < 0 means no fade is in progress
// (audioMixerChannelIsFading() is just `fadeTime >= 0`) - audioMixerInit()
// explicitly sets this to -1.0f, since zero-initializing it would
// otherwise look like an active fade at time 0.
float_t fadeFrom;
float_t fadeTo;
float_t fadeDuration;
float_t fadeTime;
easingtype_t fadeEasing;
// Arbitrary caller data, handed back as-is to onLoop/onEnd below - never
// read or written by the mixer itself. NULL by default.
void *user;
// Fired (from the main thread - safe to do arbitrary work, same
// guarantee as audiostream_t's own onLoop/onEnd) whenever this channel's
// currently playing stream loops, or reaches its natural end (including
// hitting its loop limit - see audioStreamSetLoopLimit()) and stops.
// Wired onto every stream this channel starts via
// audioMixerChannelOnStreamLoop()/OnStreamEnd() - not called directly by
// anything else. No setter - write these fields directly; NULL (the
// default) means not notified. Unlike `stream`/`pending`, these persist
// across every play on this channel, not just one.
void (*onLoop)(audiomixerchannelstate_t *state);
void (*onEnd)(audiomixerchannelstate_t *state);
} audiomixerchannelstate_t;
/**
* Stops and disposes the given channel's currently playing stream, if any -
* a safe no-op if the channel is already idle. Shared by
* audioMixerChannelUpdateEarly() (AUDIO_MIXER_COMMAND_STOP, and replacing a
* channel's stream before a new AUDIO_MIXER_COMMAND_PLAY starts) and
* audioMixerDispose().
*
* @param state The channel state to stop.
*/
errorret_t audioMixerChannelStopStream(audiomixerchannelstate_t *state);
/**
* Computes the given channel's actual playback volume - its currently
* playing sound's own volume (baseVolume), this channel's fade, this
* channel's own volume (SAVE.settings.audioChannelVolume[channel]) and the
* mixer's master volume (SAVE.settings.audioMasterVolume) - and applies it
* via audioStreamSetVolume(). Asserts fade and both save-settings volumes
* are each within [0.0, 1.0] - the only validation any of them ever gets,
* since none of them have a setter. A safe no-op if the channel isn't
* playing anything. Called every audioMixerChannelUpdateEarly(), not just
* when a PLAY command starts a new stream - see its own comment.
*
* @param channel Which channel `state` is, to index
* SAVE.settings.audioChannelVolume with.
* @param state The channel state to update.
*/
void audioMixerChannelApplyVolume(
const audiomixerchannel_t channel,
audiomixerchannelstate_t *state
);
/**
* Starts fading the given channel's `fade` multiplier from `from` to `to`
* over `duration` seconds, eased by `easing` - snaps `fade` to `from`
* immediately, then audioMixerChannelApplyVolume() advances it every frame
* from here on. A `duration` of 0 or less snaps straight to `to` and marks
* the fade as already finished (audioMixerChannelIsFading() returns
* false on the very next call).
*
* @param state The channel state to fade.
* @param from Starting fade value, from 0.0 (silent) to 1.0 (loudest).
* @param to Ending fade value, from 0.0 (silent) to 1.0 (loudest).
* @param duration How long the fade takes, in seconds.
* @param easing Easing curve to apply to the fade's progress.
*/
void audioMixerChannelFadeTo(
audiomixerchannelstate_t *state,
const float_t from,
const float_t to,
const float_t duration,
const easingtype_t easing
);
/**
* Whether the given channel currently has a fade in progress (started via
* audioMixerChannelFadeTo() and not yet finished).
*
* @param state The channel state to check.
* @return true if a fade is still in progress.
*/
bool_t audioMixerChannelIsFading(const audiomixerchannelstate_t *state);
/**
* Updates a single mixer channel, called before rendering (the start of
* the frame): applies whatever command (play, stop - including everything
* a play carries, pan/looping) audioMixerChannelUpdateLate() queued for
* loading at the end of the previous frame. If that load hasn't finished
* yet, this blocks (via assetRequireLoaded()) until it has - a deliberate
* stall for now rather than skipping a frame, since there's currently no
* way to only partially apply a channel's command. Also recomputes and
* reapplies this channel's mixed volume (see
* audioMixerChannelApplyVolume()) every time, regardless of whether a
* command was queued this frame, so a live change to this channel's fade,
* its own volume, or the master volume takes effect on whatever's already
* playing too.
*
* @param channel Which channel `state` is - see
* audioMixerChannelApplyVolume()'s own comment.
* @param state The channel state to update.
*/
errorret_t audioMixerChannelUpdateEarly(
const audiomixerchannel_t channel,
audiomixerchannelstate_t *state
);
/**
* Updates a single mixer channel, called at the end of each frame, once
* every other system has had a chance to queue a command via
* audioMixerPlay()/audioMixerPlayLooped()/audioMixerStop(). For a queued
* AUDIO_MIXER_COMMAND_PLAY, locks its file and begins loading it (see
* assetLock()) - giving it from now until the next
* audioMixerChannelUpdateEarly() to finish before that has to block on it.
* Actually starting/stopping playback happens there, not here.
*
* @param state The channel state to update.
*/
errorret_t audioMixerChannelUpdateLate(audiomixerchannelstate_t *state);
/**
* Trampoline wired onto every stream a channel starts (as that stream's
* onLoop) - forwards to the channel's own onLoop (see
* audiomixerchannelstate_t.onLoop's own comment) via stream->user, which
* audioMixerChannelUpdateEarly() points at the channel's own state.
*
* @param stream The stream that looped.
*/
void audioMixerChannelOnStreamLoop(audiostream_t *stream);
/**
* Same as audioMixerChannelOnStreamLoop() but wired onto onEnd, forwarding
* to the channel's own onEnd.
*
* @param stream The stream that ended.
*/
void audioMixerChannelOnStreamEnd(audiostream_t *stream);
+13
View File
@@ -0,0 +1,13 @@
# 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
audiostream.c
audiostreampcm.c
audiostreammp3.c
audiostreamtype.c
)
+312
View File
@@ -0,0 +1,312 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "audiostream.h"
#include "assert/assert.h"
#include "util/math.h"
errorret_t audioStreamInit(audiostream_t *stream, assetentry_t *asset) {
assertNotNull(stream, "Stream cannot be NULL.");
assertNotNull(asset, "Asset cannot be NULL.");
assertTrue(
asset->state == ASSET_ENTRY_STATE_LOADED,
"Asset must be loaded before it can back an audio stream."
);
// A caller passing an asset type that can't back an audio stream is a
// programmer error, not something that can happen from untrusted data, so
// this is an assert rather than an errorThrow (see
// feedback_assert_vs_error convention).
assertTrue(
audioStreamTypeForAssetType(asset->type) != AUDIO_STREAM_TYPE_NULL,
"Unsupported asset type for an audio stream."
);
stream->state = 0;
stream->volume = 1.0f;
stream->directionality = AUDIO_STREAM_CENTER;
stream->loopStart = -1;
stream->loopTo = 0;
stream->duration = 0;
stream->startFrame = 0;
stream->seeking = false;
stream->user = NULL;
stream->onLoop = NULL;
stream->onEnd = NULL;
stream->loopCount = 0;
stream->lastLoopCount = 0;
stream->loopLimit = 0;
stream->loopRestartCount = 0;
// Locked for as long as the stream is in use (see audiostream_t.asset's
// own comment) - released in audioStreamDispose().
stream->asset = asset;
assetEntryLock(asset);
// Type-specific setup (audioStreamPcmInit() / audioStreamMp3Init())
// determines stream->type, sets stream->sampleRate/channels, and asks
// the platform implementation to set up its state.
errorret_t ret = asset->type == ASSET_LOADER_TYPE_MP3
? audioStreamMp3Init(stream)
: audioStreamPcmInit(stream);
if(errorIsNotOk(ret)) {
assetEntryUnlock(asset);
stream->asset = NULL;
errorChain(ret);
}
errorOk();
}
size_t audioStreamGetTotalFrames(const audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
return stream->type == AUDIO_STREAM_TYPE_MP3
? audioStreamMp3GetTotalFrames(stream)
: audioStreamPcmGetTotalFrames(stream);
}
errorret_t audioStreamSeek(audiostream_t *stream, const size_t frame) {
assertNotNull(stream, "Stream cannot be NULL.");
errorChain(
stream->type == AUDIO_STREAM_TYPE_MP3
? audioStreamMp3Seek(stream, frame)
: audioStreamPcmSeek(stream, frame)
);
errorOk();
}
errorret_t audioStreamRead(
audiostream_t *stream,
int16_t *buffer,
const size_t frameCount,
size_t *outFramesRead
) {
assertNotNull(stream, "Stream cannot be NULL.");
errorChain(
stream->type == AUDIO_STREAM_TYPE_MP3
? audioStreamMp3Read(stream, buffer, frameCount, outFramesRead)
: audioStreamPcmRead(stream, buffer, frameCount, outFramesRead)
);
errorOk();
}
void audioStreamPlay(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
stream->state |= AUDIO_STREAM_STATE_PLAYING;
}
void audioStreamPause(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
stream->state &= ~AUDIO_STREAM_STATE_PLAYING;
}
void audioStreamSetPosition(audiostream_t *stream, const float_t position) {
assertNotNull(stream, "Stream cannot be NULL.");
// Wrap into [0, duration).
float_t t = position;
while(t < 0) t += stream->duration;
while(t >= stream->duration) t -= stream->duration;
stream->startFrame = (size_t) (t * stream->sampleRate);
stream->seeking = true;
// Force the next audioStreamUpdate() to re-buffer from startFrame instead
// of continuing whatever was already buffered - see the platform Buffer()
// implementations for how each one applies startFrame; PSP is the one
// exception (see audioStreamSetPosition's own doc comment).
stream->state &= ~AUDIO_STREAM_STATE_BUFFERED;
}
void audioStreamSetLoopPoints(
audiostream_t *stream,
const float_t loopStart,
const float_t loopTo
) {
assertNotNull(stream, "Stream cannot be NULL.");
assertTrue(
loopStart < 0 || loopTo < loopStart,
"loopTo must be before loopStart."
);
stream->loopStart = loopStart;
stream->loopTo = loopTo;
}
void audioStreamGetPanFactors(
const audiostream_t *stream,
float_t *outLeft,
float_t *outRight
) {
assertNotNull(stream, "Stream cannot be NULL.");
assertNotNull(outLeft, "outLeft cannot be NULL.");
assertNotNull(outRight, "outRight cannot be NULL.");
// directionality is already a pan factor in AUDIO_STREAM_LEFT..RIGHT
// (-1.0..1.0) - assertTrue'd here rather than clamped, since a caller
// passing something outside that range is a programmer error (see
// audioStreamSetDirectionality()'s own assert).
const float_t pan = stream->directionality;
*outLeft = pan > 0 ? (1.0f - pan) : 1.0f;
*outRight = pan < 0 ? (1.0f + pan) : 1.0f;
}
size_t audioStreamComputeEndFrame(
const audiostream_t *stream,
const size_t startFrame,
const size_t totalFrames
) {
assertNotNull(stream, "Stream cannot be NULL.");
size_t endFrame = totalFrames;
if((stream->state & AUDIO_STREAM_STATE_LOOPING) && stream->loopStart >= 0) {
endFrame = mathMin(
(size_t) (stream->loopStart * stream->sampleRate), totalFrames
);
}
if(startFrame >= endFrame) endFrame = totalFrames;
return endFrame;
}
void audioStreamSetVolume(audiostream_t *stream, const float_t volume) {
assertNotNull(stream, "Stream cannot be NULL.");
assertTrue(volume >= 0.0f && volume <= 1.0f, "Volume is out of range.");
stream->volume = volume;
// TODO: Do I need to update the device output? PSP may require this
}
void audioStreamSetDirectionality(
audiostream_t *stream,
const float_t directionality
) {
assertNotNull(stream, "Stream cannot be NULL.");
assertTrue(
directionality >= AUDIO_STREAM_LEFT && directionality <= AUDIO_STREAM_RIGHT,
"Directionality is out of range."
);
stream->directionality = directionality;
// TODO: Need to update internal decoder?
}
void audioStreamSetLooping(audiostream_t *stream, const bool_t looping) {
assertNotNull(stream, "Stream cannot be NULL.");
if(looping) {
stream->state |= AUDIO_STREAM_STATE_LOOPING;
} else {
stream->state &= ~AUDIO_STREAM_STATE_LOOPING;
}
}
void audioStreamSetLoopLimit(audiostream_t *stream, const uint32_t loopLimit) {
assertNotNull(stream, "Stream cannot be NULL.");
stream->loopLimit = loopLimit;
stream->loopRestartCount = 0;
}
errorret_t audioStreamUpdate(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
if(stream->type == AUDIO_STREAM_TYPE_NULL) {
errorOk();
}
// Some platforms (PSP) loop entirely on their own background thread for
// gaplessness and can only safely notify onLoop by incrementing this
// counter from there, rather than calling onLoop directly off the main
// thread - see loopCount's own comment. Firing it here, unconditionally,
// catches up to the latest count in one call even if multiple loops
// happened between two Update() calls.
if(stream->loopCount != stream->lastLoopCount) {
stream->lastLoopCount = stream->loopCount;
if(stream->onLoop != NULL) {
stream->onLoop(stream);
}
}
// TODO: Once streamed (rather than fully in-memory) sources exist, this is
// where new data would be decoded/read in and re-buffered as playback
// consumes it.
// Checked in this order (finished-check before needs-buffering) so that a
// loop restart falls straight through into re-buffering within this same
// call, instead of leaving BUFFERED cleared for the caller to notice and
// act on next frame - that extra frame of latency was audible as a gap
// at every loop boundary.
if(
(stream->state & AUDIO_STREAM_STATE_PLAYING) &&
(stream->state & AUDIO_STREAM_STATE_BUFFERED) &&
audioStreamPlatformIsFinished(stream)
) {
// A limit of 0 means unlimited; otherwise this pass's restart only goes
// ahead while fewer than loopLimit restarts have already happened -
// once it's reached, fall through to the same stop/onEnd path as a
// non-looping stream instead of restarting again.
const bool_t loopLimitReached = stream->loopLimit > 0 &&
stream->loopRestartCount >= stream->loopLimit;
if((stream->state & AUDIO_STREAM_STATE_LOOPING) && !loopLimitReached) {
stream->state &= ~AUDIO_STREAM_STATE_BUFFERED;
stream->loopRestartCount++;
// Resume from loopTo rather than the very start of the buffer - only
// matters for platforms that reach this generic restart path at all
// (Linux and Dolphin, both of which re-buffer per pass and report
// "finished" at every loop boundary); PSP loops entirely on its own
// read-ahead ring instead (see loopRestartCount's own comment).
stream->startFrame = (size_t) (stream->loopTo * stream->sampleRate);
if(stream->onLoop != NULL) {
stream->onLoop(stream);
}
} else {
stream->state &=
~(AUDIO_STREAM_STATE_PLAYING | AUDIO_STREAM_STATE_BUFFERED);
if(stream->onEnd != NULL) {
stream->onEnd(stream);
}
}
}
if(
(stream->state & AUDIO_STREAM_STATE_PLAYING) &&
!(stream->state & AUDIO_STREAM_STATE_BUFFERED)
) {
errorChain(audioStreamPlatformBuffer(stream));
stream->state |= AUDIO_STREAM_STATE_BUFFERED;
}
errorOk();
}
errorret_t audioStreamDispose(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
if(stream->type != AUDIO_STREAM_TYPE_NULL) {
errorChain(audioStreamPlatformDispose(stream));
}
if(stream->type == AUDIO_STREAM_TYPE_PCM) {
errorChain(audioStreamPcmDispose(stream));
} else if(stream->type == AUDIO_STREAM_TYPE_MP3) {
errorChain(audioStreamMp3Dispose(stream));
}
if(stream->asset != NULL) {
assetEntryUnlock(stream->asset);
stream->asset = NULL;
}
stream->type = AUDIO_STREAM_TYPE_NULL;
errorOk();
}
+360
View File
@@ -0,0 +1,360 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "audio/stream/audiostreamtype.h"
#include "audio/stream/audiostreampcm.h"
#include "audio/stream/audiostreammp3.h"
#include "audio/audiostreamplatform.h"
#include "asset/loader/assetentry.h"
#ifndef audioStreamPlatformInit
#error "audioStreamPlatformInit is not defined"
#endif
#ifndef audioStreamPlatformDispose
#error "audioStreamPlatformDispose is not defined"
#endif
#ifndef audioStreamPlatformBuffer
#error "audioStreamPlatformBuffer is not defined"
#endif
#ifndef audioStreamPlatformIsFinished
#error "audioStreamPlatformIsFinished is not defined"
#endif
#define AUDIO_STREAMS_MAX 8
#define AUDIO_STREAM_STATE_PLAYING (1 << 0)
#define AUDIO_STREAM_STATE_LOOPING (1 << 1)
#define AUDIO_STREAM_STATE_BUFFERED (1 << 2)
#define AUDIO_STREAM_CENTER 0.0f
#define AUDIO_STREAM_LEFT -1.0f
#define AUDIO_STREAM_RIGHT 1.0f
typedef struct audiostream_s audiostream_t;
typedef struct audiostream_s {
// Used for aquiring new data.
audistreamtype_t type;
// What state the stream is in. volatile: PSP's output thread reads
// AUDIO_STREAM_STATE_PLAYING directly to honor a pause promptly instead
// of only reacting once the main thread's own per-frame gate notices -
// see audioStreamPSPThreadFeed()'s own comment.
volatile uint8_t state;
// The asset backing this stream's data (e.g. a WAV file) - determines
// `type` (see audioStreamInit()) and is read from on demand by the
// type-specific module (e.g. audiostreampcm.h) rather than ever being
// fully decoded into memory up front. The stream holds its own lock on
// this entry (assetEntryLock()/assetEntryUnlock()) for as long as it's
// in use, independent of whatever lock(s) the caller that requested
// playback may also be holding.
assetentry_t *asset;
// Loudness, from 0.0 (silent) to 1.0 (loudest).
float_t volume;
// In stereo space, where do we send the audio - from AUDIO_STREAM_LEFT
// (-1.0) to AUDIO_STREAM_RIGHT (1.0), AUDIO_STREAM_CENTER (0.0) by
// default.
// TODO: Can we use a 3D vector + Pro Logic II?
float_t directionality;
// In seconds, where the loop segment ends and playback jumps back to
// loopTo - -1 (the default) means "at the end of the stream", i.e. loop
// the whole buffer. Set via audioStreamSetLoopPoints().
float_t loopStart;
// In seconds, where a loop jumps back to once it reaches loopStart.
// Defaults to 0 (start of stream). Set via audioStreamSetLoopPoints().
float_t loopTo;
// Cached duration of the stream in seconds.
float_t duration;
// Frame offset the next platform Buffer() call should start playback
// from - set by audioStreamSetPosition() (an explicit seek) and by
// audioStreamUpdate() itself (to loopTo's frame offset, for platforms
// that re-enter Buffer() on every loop restart rather than looping
// natively). Each platform's Buffer()-invoking entry point must read and
// reset this to 0 synchronously, in the same call that decided to
// (re)buffer - not later/asynchronously (e.g. from a feeder thread),
// since audioStreamUpdate() may already have moved on to something else
// that touches this field by the time an async reader gets to it.
size_t startFrame;
// True when startFrame came from an explicit audioStreamSetPosition()
// seek rather than a natural loop restart. Platforms whose Buffer() call
// can leave previously-queued audio still playing underneath the new
// pass (currently just Linux's SDL queue, kept deliberately overlapping
// across a loop restart to avoid a gap) need this to tell "jump now,
// discarding whatever's still playing" (seek) apart from "let the old
// tail keep playing while the new pass queues underneath it" (loop
// restart) - both look identical as just "a pending startFrame"
// otherwise. Consumed (reset to false) the same way as startFrame.
bool_t seeking;
// Callbacks
void *user;
void (*onLoop)(audiostream_t *stream);
void (*onEnd)(audiostream_t *stream);
// Incremented by platform code (from whatever thread/context it runs in)
// each time a loop happens, instead of calling onLoop directly - onLoop
// may do arbitrary, possibly-slow work (console printing, game logic),
// which is only safe to run from the main thread inside audioStreamUpdate().
// Calling it straight from a real-time audio thread risks starving the
// hardware buffer if it takes too long - confirmed as the real cause of
// a loud crackle on PSP once its feeder thread called onLoop inline.
volatile uint32_t loopCount;
// audioStreamUpdate()'s own record of the last loopCount it fired
// onLoop for - only ever touched from the main thread.
uint32_t lastLoopCount;
// Maximum number of times this stream is allowed to loop before stopping
// (firing onEnd) instead of restarting again - 0 (the default) means loop
// forever. Has no effect unless looping is also enabled via
// audioStreamSetLooping(). Set via audioStreamSetLoopLimit().
uint32_t loopLimit;
// How many loop restarts this stream has already committed to - compared
// against loopLimit each time a restart is considered, then incremented
// if it goes ahead; reset to 0 by audioStreamSetLoopLimit(). Distinct
// from loopCount (which tracks when a loop becomes audible, purely for
// onLoop notification, and is PSP-only) - this counts decided restarts,
// main-thread-only on every platform (PSP decides whether to loop from
// its own read-ahead ring, itself driven from the main thread via
// audioStreamPSPIsFinished() - see its own comment).
uint32_t loopRestartCount;
// PCM format of the stream's decoded output - read identically by every
// platform backend regardless of stream type (PCM decodes straight
// through; MP3 decodes to this same format), so it lives here rather
// than duplicated in both audiostreampcm_t and audiostreammp3_t. Set by
// whichever type-specific Init function runs (audioStreamPcmInit() /
// audioStreamMp3Init()).
uint32_t sampleRate;
uint8_t channels;
// Stream type specific data.
union {
audiostreampcm_t pcm;
audiostreammp3_t mp3;
};
// Platform-specific playback state (e.g. SDL2 device, PSP channel, ASND
// voice). Defined by each platform's audiostreamplatform.h.
audiostreamplatform_t platform;
} audiostream_t;
/**
* Initializes an audio stream to play the given asset, determining the
* stream's type from the asset's loader type (e.g. a WAV asset becomes an
* AUDIO_STREAM_TYPE_PCM stream) and dispatching to that type's own setup
* (e.g. audioStreamPcmInit()). Does not begin playback; call
* audioStreamPlay() once this returns.
*
* The stream takes its own lock on `asset` (see audiostream_t.asset's own
* comment), released by audioStreamDispose() - the asset must already be
* loaded (ASSET_ENTRY_STATE_LOADED) when this is called.
*
* @param stream The audio stream to initialize.
* @param asset The loaded asset to play - its type must be one this
* function supports (ASSET_LOADER_TYPE_WAV or
* ASSET_LOADER_TYPE_MP3).
* @return Error indicating success or failure.
*/
errorret_t audioStreamInit(audiostream_t *stream, assetentry_t *asset);
/**
* Returns the total number of decoded PCM frames (one sample per channel)
* available in the stream's underlying data, regardless of its type -
* dispatches to audioStreamPcmGetTotalFrames() or
* audioStreamMp3GetTotalFrames(). Platform backends should use this rather
* than a type-specific function directly, so they don't need to know or
* care which type they're driving.
*
* @param stream The audio stream to query.
* @return The total number of frames available.
*/
size_t audioStreamGetTotalFrames(const audiostream_t *stream);
/**
* Seeks the stream's decode position to the given frame offset, regardless
* of its type - dispatches to audioStreamPcmSeek() or audioStreamMp3Seek().
*
* @param stream The audio stream to seek.
* @param frame Frame offset to seek to, relative to the start of the
* stream's decoded data.
* @return Error indicating success or failure.
*/
errorret_t audioStreamSeek(audiostream_t *stream, const size_t frame);
/**
* Reads up to frameCount frames of decoded PCM sample data from the
* stream's current position, regardless of its type - dispatches to
* audioStreamPcmRead() or audioStreamMp3Read(). See either for the exact
* short-read-at-end-of-stream contract, which is identical for both.
*
* @param stream The audio stream to read from.
* @param buffer Destination buffer, sized for at least frameCount frames.
* @param frameCount Maximum number of frames to read.
* @param outFramesRead Set to the number of frames actually read.
* @return Error indicating success or failure.
*/
errorret_t audioStreamRead(
audiostream_t *stream,
int16_t *buffer,
const size_t frameCount,
size_t *outFramesRead
);
/**
* Begins or resumes playback of the given audio stream.
*
* @param stream The audio stream to play.
*/
void audioStreamPlay(audiostream_t *stream);
/**
* Pauses playback of the given audio stream. Playback position is retained,
* so audioStreamPlay() resumes from the same point.
*
* @param stream The audio stream to pause.
*/
void audioStreamPause(audiostream_t *stream);
/**
* Seeks the given audio stream to the given position, in seconds, wrapping
* into range if the position is outside [0, duration). Takes effect on the
* next audioStreamUpdate() call for platforms that re-buffer per frame
* (e.g. Linux); on PSP, which manages an entire playback pass on its own
* feeder thread once started, a seek only takes effect the next time the
* stream begins playing from a stopped state, not instantaneously mid-pass.
*
* @param stream The audio stream to seek.
* @param position The new playback position, in seconds.
*/
void audioStreamSetPosition(audiostream_t *stream, const float_t position);
/**
* Sets the loop region for the given audio stream: once playback reaches
* loopStart, it jumps back to loopTo instead of continuing (or stopping,
* if not looping). Has no effect unless looping is also enabled via
* audioStreamSetLooping().
*
* @param stream The audio stream to update.
* @param loopStart Where the loop segment ends, in seconds, or -1 to loop
* the whole stream (the default).
* @param loopTo Where the loop segment starts, in seconds.
*/
void audioStreamSetLoopPoints(
audiostream_t *stream,
const float_t loopStart,
const float_t loopTo
);
/**
* Computes normalized left/right pan factors (0..1) for the given audio
* stream's directionality. Callers multiply these by their own
* platform-specific base volume representation.
*
* @param stream The audio stream to read directionality from.
* @param outLeft Set to the left channel's pan factor.
* @param outRight Set to the right channel's pan factor.
*/
void audioStreamGetPanFactors(
const audiostream_t *stream,
float_t *outLeft,
float_t *outRight
);
/**
* Computes the stop frame for a playback pass starting at startFrame: the
* current loop segment's end (stream->loopStart, converted to frames and
* clamped to totalFrames) if looping is enabled and a loop start is set,
* or totalFrames itself otherwise - including when startFrame has already
* reached or passed the computed loop end (e.g. a seek landing in an
* outro past the loop point), in which case this pass plays out to the
* true end of the stream once instead of only the loop segment.
*
* @param stream The audio stream to compute this for.
* @param startFrame This pass's starting frame offset.
* @param totalFrames The stream's total decoded frame count
* (audioStreamGetTotalFrames()).
* @return This pass's stop frame offset.
*/
size_t audioStreamComputeEndFrame(
const audiostream_t *stream,
const size_t startFrame,
const size_t totalFrames
);
/**
* Sets the playback volume of the given audio stream.
*
* @param stream The audio stream to update.
* @param volume The new volume, from 0.0 (silent) to 1.0 (loudest).
*/
void audioStreamSetVolume(audiostream_t *stream, const float_t volume);
/**
* Sets the stereo directionality (panning) of the given audio stream.
*
* @param stream The audio stream to update.
* @param directionality The new panning value, from AUDIO_STREAM_LEFT to
* AUDIO_STREAM_RIGHT.
*/
void audioStreamSetDirectionality(
audiostream_t *stream,
const float_t directionality
);
/**
* Sets whether the given audio stream loops back to loopTo (or the start
* of the buffer, by default - see audioStreamSetLoopPoints()) when it
* reaches loopStart (or the end of the buffer), rather than stopping and
* firing onEnd.
*
* @param stream The audio stream to update.
* @param looping Whether the stream should loop.
*/
void audioStreamSetLooping(audiostream_t *stream, const bool_t looping);
/**
* Sets the maximum number of times the given audio stream is allowed to
* loop before stopping (firing onEnd) instead of restarting again. Has no
* effect unless looping is also enabled via audioStreamSetLooping(). Resets
* the stream's current loop-restart progress back to 0, so the stream gets
* the full count from this call onward.
*
* @param stream The audio stream to update.
* @param loopLimit Maximum number of loop restarts, or 0 (the default) to
* loop forever.
*/
void audioStreamSetLoopLimit(audiostream_t *stream, const uint32_t loopLimit);
/**
* Updates the given audio stream, decoding new data and advancing playback
* as needed. Should be called every frame for every active stream.
*
* @param stream The audio stream to update.
* @return Error indicating success or failure.
*/
errorret_t audioStreamUpdate(audiostream_t *stream);
/**
* Disposes the audio stream, stopping playback and releasing any resources
* associated with it.
*
* @param stream The audio stream to dispose.
* @return Error indicating success or failure.
*/
errorret_t audioStreamDispose(audiostream_t *stream);
+167
View File
@@ -0,0 +1,167 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "audiostreammp3.h"
#include "audiostream.h"
#include "assert/assert.h"
#include "util/math.h"
#include "util/memory.h"
errorret_t audioStreamMp3Init(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
assertNotNull(stream->asset, "Stream must have an asset assigned.");
assertTrue(
stream->asset->type == ASSET_LOADER_TYPE_MP3,
"Asset is not an MP3 file."
);
const assetmp3file_t *mp3 = &stream->asset->data.mp3;
stream->type = AUDIO_STREAM_TYPE_MP3;
stream->sampleRate = mp3->sampleRate;
stream->channels = mp3->channels;
stream->duration = (float_t) mp3->totalFrames / (float_t) mp3->sampleRate;
stream->mp3.pendingFrames = 0;
stream->mp3.pendingPosition = 0;
stream->mp3.position = 0;
stream->mp3.totalFrames = mp3->totalFrames;
// Each stream opens its own independent handle to the same underlying
// asset file - see audiostreampcm.c's audioStreamPcmInit() for why.
errorChain(assetFileInit(
&stream->mp3.file, stream->asset->name, NULL, NULL
));
errorChain(assetFileOpen(&stream->mp3.file));
errorChain(assetFileRead(&stream->mp3.file, NULL, mp3->dataOffset));
errorChain(audioStreamMp3DecoderInit(stream));
errorChain(audioStreamPlatformInit(stream));
errorOk();
}
errorret_t audioStreamMp3Dispose(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
errorChain(audioStreamMp3DecoderDispose(stream));
errorChain(assetFileClose(&stream->mp3.file));
errorChain(assetFileDispose(&stream->mp3.file));
errorOk();
}
size_t audioStreamMp3GetTotalFrames(const audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL.");
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
return stream->mp3.totalFrames;
}
errorret_t audioStreamMp3Seek(audiostream_t *stream, const size_t frame) {
assertNotNull(stream, "Stream cannot be NULL.");
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
if(frame == stream->mp3.position) {
errorOk();
}
// Every seek - forward or backward - restarts decoding from the very
// start of the compressed stream and discards frames until reaching the
// target; see this file's own header comment for why MP3 has no cheaper
// option, unlike audioStreamPcmSeek()'s direct byte-offset seek.
errorChain(audioStreamMp3DecoderRewind(stream));
stream->mp3.position = 0;
stream->mp3.pendingFrames = 0;
stream->mp3.pendingPosition = 0;
const size_t channels = stream->channels;
int16_t discard[AUDIO_MP3_MAX_SAMPLES_PER_FRAME];
while(stream->mp3.position < frame) {
size_t decodedFrames = 0;
errorChain(
audioStreamMp3DecoderDecodeFrame(stream, discard, &decodedFrames)
);
if(decodedFrames == 0) {
// Ran out of stream before reaching the target - clamp rather than
// erroring, same tolerance audioStreamPcmRead() has for a
// shorter-than-declared asset.
break;
}
const size_t framesNeeded = frame - stream->mp3.position;
if(decodedFrames <= framesNeeded) {
stream->mp3.position += decodedFrames;
continue;
}
// This frame overshoots the target - keep the excess as pending so
// the next Read() starts exactly at `frame` instead of skipping past
// it.
const size_t keepFrames = decodedFrames - framesNeeded;
memoryMove(
discard, discard + (framesNeeded * channels),
keepFrames * channels * sizeof(int16_t)
);
memoryCopy(
stream->mp3.pending, discard, keepFrames * channels * sizeof(int16_t)
);
stream->mp3.pendingFrames = keepFrames;
stream->mp3.pendingPosition = 0;
stream->mp3.position = frame;
}
errorOk();
}
errorret_t audioStreamMp3Read(
audiostream_t *stream,
int16_t *buffer,
const size_t frameCount,
size_t *outFramesRead
) {
assertNotNull(stream, "Stream cannot be NULL.");
assertNotNull(buffer, "Buffer cannot be NULL.");
assertNotNull(outFramesRead, "outFramesRead cannot be NULL.");
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
const size_t channels = stream->channels;
size_t framesWritten = 0;
while(framesWritten < frameCount) {
if(stream->mp3.pendingPosition >= stream->mp3.pendingFrames) {
size_t decodedFrames = 0;
errorChain(audioStreamMp3DecoderDecodeFrame(
stream, stream->mp3.pending, &decodedFrames
));
stream->mp3.pendingFrames = decodedFrames;
stream->mp3.pendingPosition = 0;
if(decodedFrames == 0) break; // Genuine end of stream.
}
const size_t framesAvailable =
stream->mp3.pendingFrames - stream->mp3.pendingPosition;
const size_t framesToCopy =
mathMin(frameCount - framesWritten, framesAvailable);
memoryCopy(
buffer + (framesWritten * channels),
stream->mp3.pending + (stream->mp3.pendingPosition * channels),
framesToCopy * channels * sizeof(int16_t)
);
stream->mp3.pendingPosition += framesToCopy;
framesWritten += framesToCopy;
}
stream->mp3.position += framesWritten;
*outFramesRead = framesWritten;
errorOk();
}

Some files were not shown because too many files have changed in this diff Show More