Commit Graph
100 Commits
Author SHA1 Message Date
YourWishesandClaude Sonnet 5 109ddc9e1e Switch item definitions to a JSON-driven runtime registry
Replaces the build-time item.json/tools.item.py code generator with a
single item_t ITEM registry populated at runtime by parsing
assets/items.jsonc, so items/types can grow without a rebuild. Adds
itemGetDefById/itemGetTypeById/itemGetIdByName/itemTypeGetOrCreate,
updates inventory/backpack to size off the new *_DEF_COUNT_MAX caps
and look up types dynamically, and reorders engine init so rpgInit
(which loads items) runs before uiInit (which needs the item type
count to size the backpack's tabs).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-13 11:36:50 -05:00
YourWishesandClaude Sonnet 5 804f8aa1fa Re-add CUTSCENE_INSERT item with its own dedicated load buffer
Splicing a JSON-authored INSERT target by name previously resolved
through the same shared CUTSCENE_LOADED_ITEMS/POOL buffer as whatever
outer cutscene was currently running, so loading it would clobber the
outer cutscene's own items mid-playback - that's why it was reverted.
Gives INSERT its own smaller CUTSCENE_INSERT_ITEMS/POOL buffer and lets
the cutscene asset loader be told which buffer set to parse into, so an
INSERT's dynamic load no longer stomps the running cutscene.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-13 11:12:43 -05:00
YourWishes 49b97ba508 Switch cutscene system to a set of pre deffed buffers 2026-09-13 09:58:57 -05:00
YourWishes 6c11d8165d Revert "Add CUTSCENE_INSERT item to splice another cutscene's items in place"
This reverts commit 6af0e9cf28.
2026-09-13 09:29:23 -05:00
YourWishesandClaude Sonnet 5 93554b39ba Add DUSK_TRACK_MEMORY allocation tracker and memoryCheckUnfreed report
memoryAllocate() becomes a macro capturing __FILE__/__LINE__ into a
fixed-size tracking table when DUSK_TRACK_MEMORY is enabled (Linux only
for now), so a leak surviving to shutdown can be traced back to its call
site. Replaces engineDispose's hard assertTrue with memoryCheckUnfreed(),
which reports unfreed allocations (with call sites when tracking is on,
just a count otherwise) instead of aborting.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-13 09:19:24 -05:00
YourWishesandClaude Sonnet 5 6af0e9cf28 Add CUTSCENE_INSERT item to splice another cutscene's items in place
Unlike CUTSCENE (a one-way jump that replaces the running cutscene),
INSERT splices a referenced cutscene's items into the running one and
resumes right after itself once they run out. Backed by a small return
stack on CUTSCENE_SYSTEM so inserts can nest.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-13 08:56:26 -05:00
YourWishesandClaude Sonnet 5 083deb8357 Move cutscene item type enum and authoring macros to their own headers
cutsceneitemtype_t moves from cutsceneitem.h into cutsceneitembase.h,
alongside a new CUTSCENE_ITEM(TYPE, UNION_NAME, ...) shorthand that
fills in a cutsceneitem_t literal's .type and union member together.

Every CUTSCENE_* authoring macro (CUTSCENE_WAIT, CUTSCENE_TEXT,
CUTSCENE_AUDIO_PLAY, etc.) moves out of the single, ever-growing
cutscene.h into the header of the item type it actually authors,
built on top of CUTSCENE_ITEM. cutscene.h now only holds cutscene_t
itself plus the CUTSCENE/CUTSCENE_REFERENCE/CUTSCENE_CUTSCENE macros,
which aren't tied to any one item type. Every relocated macro also
gained a proper JSDoc-style comment block with @param tags.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 22:30:23 -05:00
YourWishesandClaude Sonnet 5 ffb375faeb Add battle abilities and wire them into START_BATTLE cutscene parsing
Adds a battleability_t enum (starting with FIRE) and a fixed
abilities[8] array on battlefighter_t. Extends the START_BATTLE
cutscene item to parse an optional per-enemy "abilities" JSON array
(via a new cutsceneJsonLookupBattleAbility name lookup) and copies
abilities onto every spawned fighter, from the party roster for
allies and from the parsed config for enemies.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 22:30:12 -05:00
YourWishesandClaude Sonnet 5 b8cbd8ff6a Make party and save-slot init/new-game operate on SAVE.slot directly
party.c's functions no longer take a party_t* - they always operate on
SAVE.slot.party, since gameplay only ever has one active party. That
forced the same change on saveSlotInit/saveSlotNewGame (they called
partyInit/partyAddMember internally), so those are now parameterless
too, always resetting/seeding SAVE.slot. Callers building a save at a
specific index (new-game creation, slot deletion) now set
SAVE.slotCurrent first instead of building an independent local struct -
safe since the select-save screen is only ever reached before any
gameplay starts.

saveSlotWriteJSON/saveSlotReadJSON deliberately keep their explicit
saveslot_t* parameter - they're genuine serialization primitives used
by the save-device layer and tests against arbitrary structs, unrelated
to the single-current-party assumption. saveSlotReadJSON's internal
reset is now a plain memset instead of delegating to the now-global-only
saveSlotInit().

Updates test_save/test_savedevice/test_savedevicelinux/test_saveslot for
the new signatures. Note: test_savedevice/test_savedevicelinux/test_save
have pre-existing failures in this sandbox unrelated to this change -
savetestfixture.c swaps $HOME, but saveDeviceLinuxGetDirectory actually
derives the save path from ASSET.baseDirectory (the executable's own
location), so the fixture's sandboxing never actually applies. test_
saveslot.c (which doesn't depend on that fixture) passes 15/15.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 21:30:08 -05:00
YourWishesandClaude Sonnet 5 c2bd761383 Remove testbattle - superseded by the JSON-driven battle cutscene
testBattleStart() was never actually called anywhere (its only trace
was a stray unused include in uimainmenu.c) - battletest.jsonc's
START_BATTLE/REGULAR_BATTLE flow replaces it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 21:14:50 -05:00
YourWishesandClaude Sonnet 5 878e3438c8 Complete REGULAR_BATTLE immediately, fix stale saveslot tests
REGULAR_BATTLE previously never completed on its own, hanging any
cutscene that reached it - START_BATTLE already waits out the whole
encounter and returns to the overworld before this item ever runs, so
it's now a completes-immediately hook point for future regular-battle-
specific follow-up.

Also updates test_saveslot.c for the mapName/partyMembers schema
additions: the round-trip test now writes a valid map name for its
in-use slot, "missingFieldsUseDefaults" is replaced with a test for the
blank-slot short-circuit it was actually hitting, and a new regression
test locks in that an in-use slot with no map name is treated as corrupt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 19:59:16 -05:00
YourWishesandClaude Sonnet 5 3da65370e0 Add requireStrMin save-JSON shorthand
Replaces the hand-rolled mapName missing/null/empty check in
saveSlotVer1ReadJSON with a reusable macro.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 19:55:17 -05:00
YourWishesandClaude Sonnet 5 83e646cba9 Load the save's map on select, seed a real new-game slot, fix readString off-by-one
New games now go through saveSlotNewGame (starting party + default map
name) instead of the empty saveSlotInit, and selecting a save loads its
stored map instead of the hardcoded boot-time overworld. Save loading now
requires an in-use slot to have a non-empty map name, treating a blank one
as corruption instead of silently faking a default.

Also fixes a real off-by-one in the readString save-JSON macro: it passed
its maxLength (documented as excluding the null terminator) straight
through as stringCopy's destSize, which needs +1 for the terminator - so a
string exactly at the length limit (e.g. "overworld", 9 chars) always
asserted "src is too long".

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-10 23:14:44 -05:00
YourWishesandClaude Sonnet 5 0566166af4 Persist party across saves and flag/warn on corrupt save slots
Party moves off a global singleton onto each save slot, letting
START_BATTLE pick specific active-order members to bring into a fight,
and round-trips through save/load via saveslotver1. A corrupt slot load
now flags the cache and shows a red "Corrupted Save" warning in the
select-save UI instead of silently resetting.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-10 14:19:03 -05:00
YourWishesandClaude Sonnet 5 ed0b8c3911 Exit delete mode on save select after delete or back button
Previously deleting a save left the panel in delete mode, and pressing
back while in delete mode closed the whole save-select screen instead
of just returning to the normal load view.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 21:38:28 -05:00
YourWishesandClaude Sonnet 5 03d753a83f Move cutscene JSON parsing entirely into the async loader phase
File read, yyjson parse, and all item dispatch now happen in
assetCutsceneLoaderAsync - none of it touches the main thread now that
CUTSCENE items defer their nested asset resolution to start time. The
sync phase is now just a state flip, kept only so assetUpdate()'s
onLoaded dispatch (which only fires for entries finishing via the sync
path) still runs.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 21:34:47 -05:00
YourWishes cf51ed1988 Fixed cutscene item cutscene trying to recursively load 2026-09-09 21:21:50 -05:00
YourWishesandClaude Sonnet 5 70f46e89d3 Merge branch 'raw-json-maps' into main
Load map chunks from raw JSON at runtime instead of a baked DCF/DMF
step, add a reusable perf timing module, fix a real PSP logDebug/logError
Memory Stick I/O cost bug, and switch chunk tile storage to a dense
minified grid.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 15:34:08 -05:00
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