184 Commits
Author SHA1 Message Date
YourWishesandClaude Sonnet 5 9a24b3d7db Bring the entity-component system online: rendering, scene manager, scripting
entityRenderAll() (src/dusk/entity/entityrender.c) ports ac2's
scene.c:sceneRender() camera/view-matrix/draw-loop logic as a standalone
function - find the active camera, bind SHADER_UNLIT with its projection/
view, draw every COMPONENT_TYPE_RENDERABLE entity. display.c calls it each
frame between the screen clear and screenUnbind(), the same spot the old
sceneRender() used to sit.

A single-scene scene manager (src/dusk/scene/, ported from ac2 minus its
prefab system and physics/trigger stepping - that module hasn't been
ported yet) sits alongside this but isn't wired into the engine loop yet;
only one scene can ever exist (no SCENE_COUNT_MAX array).

entitymanager.c/h removed entirely: its three arrays (entities/components/
entitiesWithComponent) are now plain globals in entity.h/.c
(ENTITIES/COMPONENTS/ENTITIES_WITH_COMPONENT), and every entity/component
function across the whole ECS (entity, component, position, camera,
renderable, physics, trigger, animation) drops its entitymanager_t *mgr
parameter to operate on them directly - there was only ever one instance of
that struct in the whole engine, so the indirection bought nothing.

New duskscript bindings (src/dusk/script/native/entity/) expose entityAdd,
entityAddComponent/entityGetComponent, entitySetName/entityGetName/
entityFindByName, entityDispose/entityDisposeDeep,
entityPositionSetLocalPosition/entityPositionLookAt,
entityRenderableSetMesh (by name - cube/plane/sphere/capsule/triprism/quad),
and COMPONENT_TYPE_*/ENTITY_ID_INVALID/COMPONENT_ID_INVALID constants.
assets/init.ds uses them to spawn a camera (via LookAt from (3,3,3) at the
origin), a cube and a plane as the engine's first real content on the new
ECS.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-15 09:08:20 -05:00
YourWishesandClaude Sonnet 5 fdfdfa3a66 Fix the build after archiving rpg/ui/scene: drop their last live dependents
rpg/ui/scene moved to archive/ earlier left a handful of otherwise-generic
systems with dead references into them:
- asset/loader/chunk existed solely to load rpg overworld map chunks
  (tile_t, worldpos_t) - deleted outright, along with its
  ASSET_LOADER_TYPE_CHUNK entry in assetloader.c/h.
- save/slot wrapped party_t/storyflagval_t/map-name/cutscene-path, which is
  100% rpg save data - deleted outright (save.c/h, savedevice.c/h and the
  Linux save backend keep their generic settings/device-discovery logic,
  just lose the slot read/write pieces that only existed for it).
- locale/localemanager.c and test/CMakeLists.txt/test/save/* had a few
  stray includes and test cases that only existed for the now-gone code.

Also restores assets/locale/en_US.po (copied back from archive/assets/) -
locale init still needs a real locale file to boot into, even with the
asset-moving/archiving done separately.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-15 09:08:02 -05:00
YourWishesandClaude Sonnet 5.5 f6b884bf7f Allow script arrays to hold any value type, add script engine tests, archive old rpg code
Remove the per-array type category so arrays can mix types like structs.
Add 13 passing and 125 expected-failure duskscript test fixtures with a
table-driven runner. Also includes the in-progress entity/physics and
archive moves from the working tree.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 23:25:26 -05:00
YourWishesandClaude Sonnet 5 5c259e46e2 init.ds: print Test to confirm it runs on boot
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-14 22:59:42 -05:00
YourWishesandClaude Sonnet 5 00b73050ad Run init.ds from engineInit once every other system is ready
assets/init.ds is compiled and packed as init.dsb like any other script, and
engineInit() runs it as the very last init step - after scriptInit() and
sceneInit(), right before the initial scene is set - so it's free to use any
engine system (display, audio, save, rpg, ui, scenes, ...) without having to
special-case startup ordering. A syntax error or runtime failure in init.ds
is fatal, same as every other errorChain()'d init step.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-14 22:33:50 -05:00
YourWishesandClaude Sonnet 5 a0294aae98 Load duskscript files through the asset system, not the real filesystem
scriptLoadHandler used fopen/fread directly, but every .ds a script include()s
is packed into dusk.dsk (compiled to .dsb) like every other asset now, not a
loose file next to the binary - raw filesystem reads never found them outside
a dev checkout. It now goes through assetFileInit/Open/Read/Close like the
rest of the engine's asset loaders, failing the load the same way (returning
SCRIPT_LOAD_FAILED) for a missing entry or one bigger than capacity.

test_duskscript's fixtures moved to their own assets/ subfolder and pack into
a small test.dsk with the same tools.asset.pack used for the real dusk.dsk,
and the test opens that archive straight into ASSET.zip (skipping the full
assetInit()/assetInitPlatform(), which expects a launched engine) so the
tests exercise the real compile -> pack -> asset -> runtime pipeline instead
of a filesystem shortcut around it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-14 22:33:46 -05:00
YourWishesandClaude Sonnet 5 5cf0678429 Subdirify src/dusk/script's core/ and vm/ CMakeLists
core/CMakeLists.txt and vm/CMakeLists.txt each listed every .c file across
every sub-folder in one flat target_sources() call. Split that into one
add_subdirectory() per folder (array, bytecode, call, coroutine, error, gc,
heap, number, ops, promise, runtime, scope, string, struct, text, value and
builtin/{array,core,math,string} under core/; execute, image and ops under
vm/), each with its own small CMakeLists.txt listing just that folder's
sources - matching the convention already used in src/dusk/display/.

Verified the Dusk binary and the duskscript test suite still build and pass
identically; test_battle and test_entityinteract already failed the same
way before this change (stale against unrelated rpg/battle API changes), so
left untouched here.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-14 15:00:16 -05:00
YourWishesandClaude Sonnet 5 7e932e67a2 Compile .ds scripts under assets/ to .dsb before packing into dusk.dsk
tools/asset/pack now special-cases .ds files: each one is compiled with
duskscriptcc to a temp .dsb (no .map, nothing reads one yet) and that gets
packed under the same path with the extension swapped - the raw .ds source
never ends up in the archive. Every other asset type is unchanged, packed
byte for byte as before.

CMake passes --duskscriptcc "$<TARGET_FILE:duskscriptcc>" and adds a DEPENDS
on it, but only on the linux/knulli host target where duskscriptcc is
actually built (see its own gating in src/CMakeLists.txt) - a cross-compiled
console target has no host-built compiler to point at, so for now it can
only pack an assets/ with no .ds in it; the packer raises a clear error
rather than silently skipping or mis-packing one if it finds one anyway.

The manifest's cache-skip check also now folds in duskscriptcc's own mtime,
so a compiler rebuild invalidates every .ds's cached result even when none
of their own source files changed.

Verified by hand: a .ds under assets/scripts/ packs as the matching .dsb
(confirmed by unzipping the resulting dusk.dsk), an unrelated rebuild
correctly skips re-packing, touching duskscriptcc correctly forces a
re-pack, and a syntax error in the .ds fails the build loudly with
duskscriptcc's own error message rather than silently succeeding.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-14 14:21:20 -05:00
YourWishesandClaude Sonnet 5 d878463ccb Make script.h the one include for everything duskscript-related
Folds what used to be duskscriptnative.h's includes (builtin/value/array/
struct/promise helpers a native function needs) directly into script.h,
alongside the runtime and VM includes already there. scriptnativeconsole.h
now just includes "script/script.h" instead of enumerating the same list
itself - any new native function bindings can do the same.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-14 13:54:08 -05:00
YourWishesandClaude Sonnet 5 5d8337af7e Always build and init duskscript, remove the DUSK_SCRIPT option
Scripting is now a standard part of the engine rather than an optional
feature: removes the DUSK_SCRIPT CMake option and its compile definition,
makes src/dusk/CMakeLists.txt add_subdirectory(script) unconditionally, and
drops the #ifdef DUSK_SCRIPT guards around scriptInit()/scriptDispose() in
engine.c. duskscriptcc and its test still only build on the linux/knulli
host target (it's a dev tool, not part of the runtime), now gated on that
alone instead of DUSK_SCRIPT AND that.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-14 13:54:02 -05:00
YourWishesandClaude Sonnet 5 6d056560e7 Remove duskscriptnative.h, move scriptnativeconsole into src/dusk/script/native/
duskscriptnative.h had exactly one consumer (scriptnativeconsole.h) left
after the duskscriptvm.h removal; folds its includes in directly rather than
keeping a single-consumer indirection.

Moves scriptnativeconsole.c/.h into their own native/ subdirectory, giving
it the same add_subdirectory(native) + CMakeLists.txt treatment as core/
and vm/, so future native function bindings have an obvious home.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-14 13:48:07 -05:00
YourWishesandClaude Sonnet 5 4d2d57fdb4 Remove duskscriptvm.h, fold its includes into script.h
It had exactly one consumer (script.h) and added nothing of its own beyond
re-including duskscript.h + image/scriptvmattach.h + image/scriptvmrun.h -
a pointless indirection now that it's not a standalone embedding SDK with
multiple external consumers.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-14 13:43:50 -05:00
YourWishesandClaude Sonnet 5 d35058598c Split the ported "types dump" headers into one type per file
scriptvmtypes.h, scriptcompiletypes.h and scriptnode.h each crammed several
unrelated struct definitions into one file, unlike the rest of dusk (see
src/dusk/display or src/dusk/rpg/battle), which always gives a type its own
file named after it, with other types reached by an explicit include or a
forward declaration.

- scriptvmtypes.h: scriptvmfunction_t moves into scriptvmfunction.h (next to
  the functions that already work with it), scriptvmname_t into
  scriptvmname.h, scriptvmhandler_t into scriptvmtable.h, and scriptvmimage_t
  gets a new scriptvmimage.h. scriptvmtypes.h keeps working as a thin
  re-include umbrella for the ~45 vm files that reach into several of these
  at once, the same role scripttypes.h already plays for the base types.

- scriptcompiletypes.h: scriptlabel_t moves into scriptbuilderlabel.h,
  scriptloopcontext_t into scriptcompileloopcontext.h, scriptincluderesolver_t
  into scriptcompile.h, and the big scriptbuilder_t gets a new scriptbuilder.h.
  Breaking the resulting include cycle needed scriptbuilderlabel.h and
  scriptcompileloopcontext.h to forward-declare scriptbuilder_t/scriptlabel_t
  rather than pull in full definitions they only ever use by pointer, and
  scriptcompile.h to define scriptincluderesolver_t before including
  scriptcompiletypes.h instead of after.

- scriptnode.h: scriptfuncnode_t (a function declaration, not a kind of
  expression/statement node) moves into a new scriptfuncnode.h.

Also gives scriptbuilder_t and scriptlabel_t proper struct tags (previously
anonymous, which doesn't allow forward declaration) to match the project's
_s tag / _t typedef convention from the previous pass.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-14 13:40:30 -05:00
YourWishesandClaude Sonnet 5 af2f0617e0 Consistency pass: house struct tags, pragma once guards, pack bools into flags
Struct tags throughout duskscript reused the typedef name itself (struct
foo_t { ... }), unlike the rest of dusk which always tags with foo_s and
typedefs foo_t - renames all 15 tagged structs in src/dusk/script and
src/duskscriptcc to match. Also drops a vestigial duplicate forward
declaration of scriptsource_t left over from the port.

Unifies all 144 #ifndef/#define/#endif include guards in the ported code to
#pragma once, matching the rest of the codebase (which was already a mix of
25 files doing it the house way and 144 not).

Packs scriptvmfunction_t's isAsync/isRoot/isScript and scriptfuncnode_t's
isAsync/isInline/isNoInline/isRoot/isScript into single uint8_t flags
fields with bit constants, mirroring the SCRIPT_BINARY_FLAG_ASYNC pattern
the bytecode format already uses and the flags-byte convention used
elsewhere in dusk (animation, display). While there, zero-initializes both
scriptfuncnode_t allocation sites (scriptAlloc never zeroes memory, so
several of its fields - not just the ones being packed here - were
previously read uninitialized before their first explicit write), and
removes scriptfunction_t's dead `plain` field, which was always the exact
inverse of isAsync and never actually read anywhere.

Converts the couple of struct-level doc comments that used the ported
project's Javadoc-with-@param style to dusk's plain // prose convention,
and adds missing rationale comments to scriptruntime_t and scriptstring_t,
which had none at all.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-14 13:22:39 -05:00
YourWishesandClaude Sonnet 5 703d89a9ac Consistency pass: fix util/memory.h include ordering, drop stray comment and unused include
util/memory.h had landed as the very first #include in ~30 files from the
mechanical memcpy/memcmp conversion, ahead of each file's own umbrella type
header - moves it back after, matching the rest of the codebase's ordering.
Also updates a scriptplatform.h comment left over from the int/fixed/bignum
split, and drops an unused util/string.h include in scriptcompile.c.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-14 13:05:06 -05:00
YourWishesandClaude Sonnet 5 c1d6a97293 Port the duskscript language into the engine as src/dusk/script + duskscriptcc
Brings over the duskscript VM and compiler from dusk-vm: the runtime
(core/vm) lives under src/dusk/script, gated by DUSK_SCRIPT and wired into
the engine lifecycle with print()/consolePrint() routed to the console; the
standalone duskscriptcc compiler stays a lean host tool (linux/knulli only).

Collapses int/fixed/bignum into a single float-backed SCRIPT_VALUE_NUMBER
throughout the lexer, compiler, bytecode format and VM, removing the old
int16-rollover fast paths and the type-conversion builtins they needed.

Fixes a real big-endian bug in the bytecode's 32-bit number reader (found
while adding GameCube/Wii endian support via util/endian.h) and converts
src/dusk/script and duskscriptcc to dusk's memory/string/assert utilities,
which surfaced two more latent bugs: a conservative GC stack scan reading
its own local variable's address, and an uninitialized path buffer in
scriptReadScript when the caller path has no directory component.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-14 12:59:46 -05:00
YourWishesandClaude Sonnet 5 3948607b1e Rename cutscenesystemrefs_t to cutsceneref_t, cutsceneRefsReset to cutsceneRefReset, and cutscenerefs.c/.h to cutsceneref.c/.h
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-25 10:23:38 -05:00
YourWishes 6c180372fe First pass cleaning cutscene system. 2026-09-25 09:57:48 -05:00
YourWishesandClaude Sonnet 5 fed04b66b8 Persist and resume in-progress cutscene via save slot; add species stub
Save slots now track the currently-active cutscene file (defaulting to a
new placeholder new_game.jsonc intro) so loading a save resumes it;
cutsceneSystemStartCutsceneWith persists this on every cutscene start, and
a cutsceneSystemLoad ordering bug (loadedFile stamped after starting) is
fixed so the persisted path is always correct. Also adds a
battlefighterspecies_t/battlespeciesid_t stub for upcoming species data.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-22 18:44:45 -05:00
YourWishesandClaude Sonnet 5 a4ca718070 Add shared battleFighterParse/Serialize, use it everywhere a fighter is (de)serialized
Replaces three independently hand-rolled battlefighter_t JSON readers/
writers (party.jsonc roster loading, BATTLE_START enemy definitions, and
the v1 save-file party member format) with one generic pair in
battlefighter.c: battleFighterParse(fighter, obj) and its inverse
battleFighterSerialize(fighter, doc). Covers team/controller/status
(optional, default ally/player/normal), the 7 base stats (required),
current health/mp (optional, default to max), and an optional abilities
name array.

- party.c: partyInit now parses each roster member via battleFighterParse
  directly, dropping the old partyParseStats/partyParseStatField.
- cutscenebattlestart.c/.h: cutscenebattlestartenemy_t now embeds a full
  battlefighter_t, parsed once at Load time; Start just copies it into the
  enemy pool instead of re-initializing. battletest.jsonc's enemies are
  now flat (dropped the "stats" wrapper) to match.
- saveslotver1.c/.h: rewritten in place to use the shared parser/
  serializer, changing the v1 on-disk field names/shape (no migration -
  acceptable pre-release, documented in saveslotver1.h).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-22 14:20:45 -05:00
YourWishesandClaude Sonnet 5 8dbd3e1dd2 Key-based party roster, show party member names in battle UI
- party.jsonc's "members" is now a key/value object (e.g. "hero",
  "companion") instead of an array - partyInit seeds party_t.keys
  alongside members, and partyGetMemberIndexByKey/partyGetMemberKey do the
  lookups. Cutscene items (PARTY_ADD_MEMBER/PARTY_REMOVE_MEMBER/
  PARTY_HEAL_MEMBER) now take a "key" JSON field, resolved to a roster
  index at load time, instead of a raw index.
- Battle UI shows who's acting: uibattlemenu.c draws the current fighter's
  party key above the action menu, and uibattlehud.c draws each ally
  fighter's key as an extra line above their HP/MP.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-21 22:38:04 -05:00
YourWishesandClaude Sonnet 5 7a9a2b9832 Add order-based party roster, back-navigation in battle selection, fix win-state cutscene leak
- Battle selection menu can now go back a fighter to re-decide their move,
  and battle win/loss no longer leaks the round's remaining queued move
  cutscenes into the ending state (cutsceneSystemDiscardQueued).
- Party roster is now driven by party.jsonc: every defined member always
  exists (partyInit), while party->order alone determines who's currently
  active - partyAddMember/partyRemoveMember (and their cutscene items) now
  activate/deactivate an existing roster member by index instead of
  creating/destroying one, with removal compacting order to stay packed.
- New PARTY_ADD_MEMBER/PARTY_REMOVE_MEMBER/PARTY_HEAL_MEMBER/PARTY_HEAL_ALL
  cutscene items.
- saveSlotInit/partyInit now return errorret_t and take their target
  struct directly (saveslot_t*/party_t*) instead of implicitly operating
  on SAVE.slot; saveSlotReadJSON resets through saveSlotInit rather than
  duplicating the reset logic.
- Test coverage: stub the JSON asset loader in save test fixtures so
  partyInit's real party.jsonc load runs against an in-memory doc instead
  of hanging with no asset system initialized.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-21 22:27:46 -05:00
YourWishes e8757c33e3 Fix U_IDLE asset error from cutscene item pointer aliasing
cutsceneCutsceneResolve and cutsceneSystemLoad could be called with a
name/path pointer that aliases into CUTSCENE_SYSTEM.loadedItems (e.g. a
CUTSCENE item's own name field). cutsceneLoadParse then overwrites that
same buffer while loading the target cutscene, so the subsequent
stringCopy into loadedFile read stale, corrupted memory. Copy the
name/path into a local buffer before use in both functions.
2026-09-21 19:48:28 -05:00
YourWishes b498a12458 Merge branch 'battle-refactor' 2026-09-21 15:04:52 -05:00
YourWishesandClaude Sonnet 5 c9f098693c Wire up battle AI/scene-return, reclaim cutscene item slots, fix GOTO
- battleStateSelectionAiDecide: AI-controlled fighters now actually
  queue a basic attack against the first living enemy instead of doing
  nothing every round.
- battleStateEndingInit: disposes the battle and switches back to
  SCENE_TYPE_OVERWORLD instead of leaving the game stuck in the battle
  scene forever.
- cutsceneSystemNext now pops each finished item off the front of the
  running cutscene (cutsceneRemoveFront) instead of leaving it in place
  forever, so a long-running cutscene (many battle rounds splicing
  items in repeatedly) no longer grows scene->items unboundedly.
- cutsceneGoTo reworked to match: since consumed items are evicted, a
  marker behind the current position can't be found in the live queue
  anymore, so it now re-parses the cutscene fresh from its source file
  (cutsceneLoadParse, into a persistent scratch buffer, not the stack)
  and replaces the live queue with marker-onward from that fresh parse.
  Only supported when the running cutscene is the one
  cutsceneSystemLoad/cutsceneCutsceneResolve populated.
- Fixed a real bug this surfaced: cutsceneSystemPrepare unconditionally
  cleared loadedFile, which ran *after* cutsceneCutsceneResolve had
  just stamped it but *before* the cutscene started - wiping it out
  immediately and breaking cutsceneGoTo for any cutscene entered via a
  CUTSCENE item or cutsceneCutsceneResolve (e.g. the main menu's
  NEW_GAME/OPTIONS/QUIT navigation). Now only cleared when switching
  away from loadedScene entirely.
- Added cutsceneLoadParse, the shared "lock asset, require loaded,
  parse items, unlock" sequence, replacing four near-identical copies
  across cutsceneSystemLoad/cutsceneCutsceneResolve/
  cutsceneInsertResolveAndSplice/battleStateExecutingInit. Each call
  site handles its own fatal-error display; cutsceneLoadParse itself
  just propagates.
- Added test_cutscene.c (cutsceneRemoveFront) and a regression test for
  the loadedFile bug. Writing the zero-count RemoveFront test caught a
  real self-move bug (count == 0 passed dest == src into memoryMove).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-21 15:04:41 -05:00
YourWishesandClaude Sonnet 5 7aeffac9fc Add battle fighter damage/heal cutscene items, wire up battle results
BATTLE_FIGHTER_DAMAGE/BATTLE_FIGHTER_HEAL let a move's own cutscene
apply damage/healing to "target" or "user", resolved from
BATTLE_FIGHTER_POS_TARGET/USER sentinels against the current action by
battleStateExecutingInit. fire/slash now call BATTLE_FIGHTER_DAMAGE on
their target.

BATTLE_POST_MOVE checks fighter health after each move and sets
BATTLE.result (now a real field, previously commented out) to
win/loss, transitioning into BATTLE_STATE_WON/LOST; fleeing sets
BATTLE_RESULT_FLED. All three end states now show a textbox before
moving on to BATTLE_STATE_ENDING.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-20 21:14:00 -05:00
YourWishesandClaude Sonnet 5 37e7e95ace Resolve moves into per-move cutscenes during battle execution
Each queued action now resolves to its move's cutscene
(cutscenes/battle/moves/<name>.jsonc), spliced into the executing state
with a BATTLE_POST_MOVE checkpoint after each one. Fighter turn order is
tracked via fighterOrder/fighterOrderCount, and resolved actions/pending
counts are cleared so fighters aren't skipped in later rounds. Also fixes
a BATTLE_WAIT_SELECTION deadlock and adds an optional "blocking" flag to
MODAL cutscene items.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-20 17:49:06 -05:00
YourWishesandClaude Sonnet 5 e3f56c4d12 Drive battle rounds through cutscene items, fix render/init bugs
Adds BATTLE_START, BATTLE_SET_STATE and BATTLE_WAIT_SELECTION cutscene
items so a battle's state transitions (opening -> pre-round -> selection
-> executing) are chained through the running cutscene, with
cutsceneSystemInsertItemsNext/InsertItems splitting "splice" from
"splice and advance immediately". Adds battlestateselection's
get-current-fighter/player-attack/player-flee API backing the battle UI,
and battleMoveGetIdByName for JSON-authored ability lookups.

Fixes: off-by-one in battleMoveInit that let the first-defined move
alias BATTLE_MOVE_ID_NULL; a segfault in scenebattle.c from rendering
unfilled (NULL) fighter slots; stale battleGetCurrentFighter/
battlePlayerAttack/battlePlayerFlee references in the battle UI.

Adds assertions across the battle/scene/UI code for fighter-index
bounds and non-null invariants. Updates battletest.jsonc for the new
BATTLE_START item schema.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-20 13:32:52 -05:00
YourWishesandClaude Sonnet 5 3675ec634f Rework battle system: pointer-based fighters, per-state files, move split
Splits battlemoveaction_t into its own header, adds a per-state
struct/init/update/dispose file under rpg/battle/state driven by a
dispatch table, switches BATTLE.fighters to battlefighter_t* backed by a
pool, folds current/max health and mp into battlefighterstats_t, and adds
battleFighterDamage/Heal/AbilityAdd. Removes the now-unused battle
cutscene items (START_BATTLE, BATTLE_WAIT_STATE, BATTLE_FORCE_MOVE,
REGULAR_BATTLE) and their JSON lookup helpers, and adds JSON validation
to battleMoveInit. battle.h/battle.c are mid-rewrite around the new state
machine, so some UI consumers (uibattlehud.c, uibattlemenu.c) and
test_battle.c are currently out of sync with the new API.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-19 23:17:27 -05:00
YourWishes d8273a225c Battle changes, first pass. 2026-09-19 22:30:01 -05:00
YourWishesandClaude Sonnet 5 c1c8174b27 Use CUTSCENE/INSERT item names as full asset paths directly
cutsceneCutsceneResolve/cutsceneInsertResolveAndSplice no longer inject
a "cutscenes/%s.jsonc" prefix/suffix around an item's "name" field -
matching cutsceneSystemLoad, which already takes a full path. Fixes a
latent double-prefix bug in initial.jsonc's CUTSCENE reference (also had
a "jsonsc" typo) that this change would otherwise have exposed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-19 21:38:09 -05:00
YourWishes 5697f5c064 Fix main menu crash 2026-09-19 21:33:21 -05:00
YourWishesandClaude Sonnet 5 3ce16cf7f4 Fix uilabel dirty bug and add word-wrap support
uiLabelInit now marks a fresh label dirty itself, instead of every call
site having to remember to do it. Adds uiLabelSetWrap (NULL buffer
disables wrapping, otherwise requires a positive width) built on the
existing textWrap(), and wires the fatal error overlay's message label
up to wrap at 80% of screen width.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-19 21:32:24 -05:00
YourWishes 2abd5b0c3c Beginning a bit of cleanup 2026-09-19 21:17:11 -05:00
YourWishesandClaude Sonnet 5 4b29bf7a30 Wire story flags into the save slot and add cutscene controls
Implements storyInit/storyGetFlagByName/storyGetFlagValue/etc, moves flag
storage into saveslot_t (persisted keyed by name so save files survive
flag reordering), and adds STORY_SET_FLAG/STORY_WAIT_FLAG cutscene items.
Also removes the now-superseded storyflag.c/h stub.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-19 20:39:53 -05:00
YourWishes 6f1bbe4f2f First pass story flags 2026-09-19 10:45:06 -05:00
YourWishesandClaude Sonnet 5 f64364f37d Trim cutscenesystem.h doc comments to the essentials
Comments-only change - drops implementation rationale/cross-referencing
detail in favor of shorter one-liners, matching the simplified style
already applied to part of the file.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-18 11:21:12 -05:00
YourWishesandClaude Sonnet 5 d16e0bbfbf Stop stealing/reaping cutscene JSON docs - parse in place instead
cutsceneSystemLoad, cutsceneCutsceneResolve and
cutsceneInsertResolveAndSplice all used to steal ownership of the
asset entry's parsed yyjson_doc (nulling entry->data.json) and force
an immediate assetReapUnused() to avoid handing back a corrupted
cache entry on a later load of the same file - which doubled as a
latent crash if that reap was ever skipped (as it briefly was),
since a stale cache hit would then have a NULL doc.

Turns out none of that was needed: every cutsceneitem_t field that
could reference the doc's memory already copies its string data by
value (cutsceneJsonCopyString into fixed buffers), so the parsed
items don't depend on the doc surviving past cutsceneParseDoc. All
three call sites now parse straight out of entry->data.json while
still locked, then unlock normally - the asset entry stays a normal,
correctly-cached resource, and CUTSCENE_LOADED_DOC/cutsceneLoadedSetDoc
are gone entirely.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-18 09:47:05 -05:00
YourWishesandClaude Sonnet 5 70ae41bd29 Track the loaded cutscene filename and re-load it on restart
Adds CUTSCENE_SYSTEM.loadedFile, set by cutsceneSystemLoad and cleared
by cutsceneSystemPrepare. cutsceneRestart now re-reads and re-parses
that file via cutsceneSystemLoad instead of just rerunning whatever's
still resident in loadedScene/loadedItems, so restarting a file-loaded
cutscene always reflects its current on-disk contents - restart of a
C-authored cutscene is unchanged.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-18 09:28:15 -05:00
YourWishesandClaude Sonnet 5 abf6863044 Assert NULL pointer/marker args in cutscenesystem.c entry points
Matches the existing assertNotNull convention in cutscene.c
(cutsceneAppend/cutsceneParseDoc etc.) - these are internal-API
preconditions on trusted callers, not untrusted input.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-18 09:09:35 -05:00
YourWishesandClaude Sonnet 5 4279bd9675 Remove unused CUTSCENE_SYSTEM.userData scratch buffer
Nothing ever wrote to it - it only ever passed its own address through
to onComplete/CUTSCENE_CALLBACK/modal-option callbacks, so every call
site now passes NULL directly and the 8KB buffer is gone.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-17 22:32:09 -05:00
YourWishesandClaude Sonnet 5 5910110b95 Rework CUTSCENE_ITEM_TYPE_INSERT to splice into the running scene's item array
Replaces the separate insert buffer/scene and insertStack return-frame
mechanism with cutsceneAppend/cutsceneAppendNext, so an inserted
cutscene's items become part of the running scene's own array instead
of a jump-and-return. Moves the loaded-items buffer/scene from file-scope
globals into cutscenesystem_t, and extracts shared start-cutscene setup
into cutsceneSystemPrepare.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-17 22:26:36 -05:00
YourWishesandClaude Sonnet 5 44594f7011 Add cutsceneAppend/cutsceneAppendNext for mutating a running cutscene
cutsceneAppend adds items to the end of a cutscene_t's item array;
cutsceneAppendNext inserts them at a given index instead (e.g.
CUTSCENE_SYSTEM.currentItem + 1, to run immediately after the
currently-executing item), shifting everything from that index
onward to make room. Both are capacity-bounded against the caller-
supplied itemsMax, matching cutsceneParseDoc's convention of never
assuming ownership of the backing buffer's size.

cutscene_t.items drops its const qualifier to allow this in-place
mutation; cutsceneitem_t backing arrays in the test fixtures that get
assigned into a mutable cutscene_t.items are no longer declared const
either, since a const source would otherwise silently violate the
field's new type.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-15 13:16:02 -05:00
YourWishesandClaude Sonnet 5 dbb5301ade Extract battleactiontype_t/battleaction_t into battleaction.h
Matches the existing battleability.h/battlefighter.h split - keeps
battle.h focused on the overall battle_t state machine rather than
also owning the action data shape.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-14 12:28:40 -05:00
YourWishesandClaude Sonnet 5 74a4290be4 Add optional cutscene field to move schema
Lets a move reference a cutscene to play when used, resolved by name
the same way a CUTSCENE item's "name" field is. Still just a schema
placeholder - no loader consumes moves.jsonc yet.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-14 12:10:06 -05:00
YourWishesandClaude Sonnet 5 3ce892f846 Move items.jsonc under items/, add starter battle/moves.jsonc
Groups item data under assets/items/ to match the cutscenes/ folder
convention, updating itemInit()'s asset path accordingly. Also adds
assets/battle/moves.jsonc as a starting point for move data - there's
no JSON loader consuming it yet (battleability_t is still just a bare
enum), so its field shape is a placeholder rather than a contract.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-14 12:00:46 -05:00
YourWishesandClaude Sonnet 5 68cebce04a Remove per-item cutscene authoring macros
Every CUTSCENE_XXX(...) shorthand (CUTSCENE_WAIT, CUTSCENE_TEXT,
CUTSCENE_ENTITY_WALK_TO, etc.) existed to build a cutsceneitem_t literal
in C; real cutscenes are now authored exclusively as .jsonc files, and
the only other users were test fixtures. Deletes all ~55 of these
macros (and the shared CUTSCENE_ITEM(...) helper they built on) along
with their doc comments, leaving each item's struct/enum/Start/Update/
Load declarations untouched.

test_battle.c, test_cutscenecontrol.c, test_cutscenemaparea.c,
test_cutscenesystem.c and test_entityinteract.c now build their
cutsceneitem_t fixtures as plain struct literals instead.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-14 11:20:58 -05:00
YourWishesandClaude Sonnet 5 1646e964b5 Remove the C-authoring shorthand for declaring cutscenes
CUTSCENE(...)/CUTSCENE_REFERENCE/CUTSCENE_CUTSCENE/CUTSCENE_INSERT
existed to build a cutscene_t and its item array directly in C; real
cutscenes are now authored exclusively as .jsonc files parsed via
cutsceneParseDoc, so these are dead weight in production. Deletes the
demo scene that still used them (testcutscene.h, wired to NPC #3) along
with its now-dead include in npc.c. test_cutscenesystem.c - the only
other user, building cutscenes in-memory for tests - now writes the
equivalent struct literals directly, matching the convention already
used by test_battle.c/test_entityinteract.c.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-14 09:52:42 -05:00
YourWishesandClaude Sonnet 5 2ee97ad4ce Sunset the dedicated cutscene asset loader and the item pool
Cutscenes now load through the generic ASSET_LOADER_TYPE_JSON loader
and parse straight into fixed static buffers via new cutsceneParseDoc/
cutsceneParseItem/cutsceneCountItems helpers (cutscene.c), removing the
bespoke ASSET_LOADER_TYPE_CUTSCENE pipeline entirely. Only one cutscene
(plus one INSERT target) is ever resident, so each resolve locks,
steals the parsed doc, unlocks, and force-reaps immediately - fixing a
stale-cache-reuse bug where revisiting a cutscene name after a
different one could return data from the shared buffer's previous
occupant.

ENTITY_WALK_TO and MAP_AREA_WAIT now store their waypoints/area ids in
fixed-size arrays inline on their own item struct instead of a shared
bump-allocated pool, so pool/poolOffset are gone from every item type's
Load callback. cutscene_t.dataSize (and the CUTSCENE(...) macro's SIZE
parameter) is removed along with it - it sized entries out of that same
now-deleted pool and had no other reader.

NPC #4's battletest cutscene and the main menu's cutscene now resolve
their target by name on interact instead of eagerly at init, so a
resolved-once pointer is never held past the point its backing buffer
can be reused for something else.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-13 15:18:57 -05:00
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
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
1240 changed files with 41107 additions and 39205 deletions

No files matched your search

-71
View File
@@ -33,74 +33,3 @@ jobs:
libssl-dev libssl-dev
- name: Run tests - name: Run tests
run: ./scripts/test-linux.sh run: ./scripts/test-linux.sh
# Emulator smoke tests: boot the built disc/EBOOT for a fixed window and
# confirm the emulator doesn't crash. Not yet verified against a real
# runner (no CI run has exercised these) -- continue-on-error so a
# flaky/broken emulator step doesn't block the required Linux test job.
run-tests-gamecube-dolphin:
runs-on: ubuntu-latest
continue-on-error: true
container:
image: ghcr.io/extremscorner/libogc2:latest
steps:
- name: Install Node.js
run: apt-get update && apt-get install -y nodejs
- name: Checkout repository
uses: actions/checkout@v4
- name: Install additional dependencies
run: |
apt-get install -y \
python3-pip python3-polib python3-pil \
python3-dotenv python3-pyqt5 python3-opengl xorriso \
dolphin-emu xvfb
dkp-pacman -Syu --noconfirm
dkp-pacman -S --needed --noconfirm \
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build GameCube ISO and boot it in Dolphin
run: ./scripts/test-gamecube-dolphin.sh
run-tests-wii-dolphin:
runs-on: ubuntu-latest
continue-on-error: true
container:
image: ghcr.io/extremscorner/libogc2:latest
steps:
- name: Install Node.js
run: apt-get update && apt-get install -y nodejs
- name: Checkout repository
uses: actions/checkout@v4
- name: Install additional dependencies
run: |
apt-get install -y \
python3-pip python3-polib python3-pil \
python3-dotenv python3-pyqt5 python3-opengl xorriso \
dolphin-emu xvfb
dkp-pacman -Syu --noconfirm
dkp-pacman -S --needed --noconfirm \
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build Wii ISO and boot it in Dolphin
run: ./scripts/test-wii-dolphin.sh
run-tests-psp-ppsspp:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pspdev
uses: ./.github/actions/setup-pspdev
- name: Install PPSSPPHeadless build dependencies
run: |
sudo apt-get update
sudo apt-get install -y git cmake ninja-build libsdl2-dev zlib1g-dev
- name: Build PPSSPPHeadless
run: |
git clone --recursive --depth 1 https://github.com/hrydgard/ppsspp.git /tmp/ppsspp
cmake -S /tmp/ppsspp -B /tmp/ppsspp/build -DCMAKE_BUILD_TYPE=Release
cmake --build /tmp/ppsspp/build --target PPSSPPHeadless -- -j$(nproc)
echo "PPSSPP_HEADLESS_BIN=/tmp/ppsspp/build/PPSSPPHeadless" >> "$GITHUB_ENV"
- name: Build PSP EBOOT and boot it in PPSSPPHeadless
run: ./scripts/test-psp-ppsspp.sh
+3 -2
View File
@@ -91,7 +91,7 @@ assets/borrowed
/archive0 /archive0
/archive1 /archive1
# /archive /archive
__pycache__ __pycache__
@@ -106,4 +106,5 @@ yarn.lock
/build* /build*
/assets/test /assets/test
/tools_old /tools_old
/assets/test.png /assets/test.png
/assets/audio/boa.mp3
+53 -22
View File
@@ -8,17 +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_NETWORKING "Enable networking support" 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")
@@ -32,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")
@@ -73,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)
@@ -91,9 +104,9 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME}
DUSK_VERSION="${DUSK_VERSION}" DUSK_VERSION="${DUSK_VERSION}"
) )
if(DUSK_NETWORKING) if(DUSK_NETWORK)
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_NETWORKING DUSK_NETWORK
) )
endif() endif()
@@ -122,20 +135,38 @@ if(DUSK_BUILD_TESTS)
add_subdirectory(test) add_subdirectory(test)
endif() endif()
# Build assets # Build assets. Runs unconditionally on every build (rather than being
# Deliberately not CONFIGURE_DEPENDS: that reruns the full CMake configure # gated behind a custom_command's file-level DEPENDS list) because
# step (which invalidates generated headers and forces a huge rebuild) on # CONFIGURE_DEPENDS globs only pick up added/removed files on the next
# every single asset edit. Re-run cmake manually when assets are added or # CMake configure, so that approach could miss/lag a rebuild behind an
# removed. # asset edit. tools.asset.pack itself decides whether anything actually
file(GLOB_RECURSE DUSK_ASSET_FILES "${DUSK_ASSETS_DIR}/*") # changed (see its manifest check) and no-ops quickly when nothing did,
add_custom_command( # so this stays cheap on a build where assets are untouched.
OUTPUT "${DUSK_ASSETS_ZIP}" #
# A .ds script under assets/ is compiled to .dsb by duskscriptcc as part of
# packing, so dusk.dsk only ever carries bytecode. duskscriptcc is a host
# tool, only built when target_system is the build host (linux/knulli, see
# src/CMakeLists.txt) - a cross-compiled console target has no duskscriptcc
# of its own to depend on or pass down, so it can only pack assets with no
# .ds in them until something sets up a host-built duskscriptcc for it too.
if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli")
set(DUSK_ASSET_PACK_DUSKSCRIPTCC_ARGS --duskscriptcc "$<TARGET_FILE:duskscriptcc>")
set(DUSK_ASSET_PACK_DEPENDS duskscriptcc)
else()
set(DUSK_ASSET_PACK_DUSKSCRIPTCC_ARGS "")
set(DUSK_ASSET_PACK_DEPENDS "")
endif()
add_custom_target(DUSK_ASSETS_BUILT ALL
COMMAND ${CMAKE_COMMAND} -E make_directory "${DUSK_ASSETS_DIR}" COMMAND ${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} ${DUSK_ASSET_PACK_DUSKSCRIPTCC_ARGS}
WORKING_DIRECTORY "${DUSK_ROOT_DIR}"
BYPRODUCTS "${DUSK_ASSETS_ZIP}"
DEPENDS ${DUSK_ASSET_PACK_DEPENDS}
COMMENT "Packing assets into dusk.dsk"
VERBATIM 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
``` ```
-18
View File
@@ -1,18 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
add_subdirectory(debug)
add_subdirectory(frame)
add_subdirectory(focus)
add_subdirectory(overlay)
add_subdirectory(transition)
add_subdirectory(widget)
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
ui.c
uielement.c
)
-138
View File
@@ -1,138 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uiconsole.h"
#include "assert/assert.h"
#include "console/console.h"
#include "display/screen/screen.h"
#include "display/text/text.h"
#include "display/spritebatch/spritebatch.h"
#include "display/shader/shaderunlit.h"
#include "display/mesh/mesh.h"
#include "util/memory.h"
typedef struct {
mesh_t mesh;
bool_t built;
meshvertex_t vertices[UI_CONSOLE_CACHE_VERTEX_MAX];
int32_t vertexCount;
int32_t cachedScanX;
int32_t cachedScanY;
} uiconsolecache_t;
uiconsolecache_t UI_CONSOLE_CACHE;
errorret_t uiConsoleDraw(void) {
if(!CONSOLE.visible) errorOk();
if(
CONSOLE.dirty ||
UI_CONSOLE_CACHE.cachedScanX != SCREEN.scanX ||
UI_CONSOLE_CACHE.cachedScanY != SCREEN.scanY
) {
errorChain(uiConsoleRebuild());
}
if(UI_CONSOLE_CACHE.vertexCount == 0) errorOk();
shadermaterial_t material = {
.unlit = {
.color = COLOR_RED,
.texture = FONT_DEFAULT.texture
}
};
errorChain(shaderBind(&SHADER_UNLIT));
errorChain(shaderSetMaterial(&SHADER_UNLIT, &material));
return meshDraw(&UI_CONSOLE_CACHE.mesh, 0, UI_CONSOLE_CACHE.vertexCount);
}
errorret_t uiConsoleRebuild(void) {
int32_t spriteCount = 0;
for(uint32_t i = 0; i < CONSOLE_HISTORY_MAX; i++) {
char_t c;
int32_t j = 0;
while((c = CONSOLE.line[i][j++]) != '\0') {
if(c != ' ' && c != '\n') spriteCount++;
}
}
assertTrue(
spriteCount <= UI_CONSOLE_CACHE_GLYPH_MAX,
"Console history exceeds fixed cache capacity"
);
const int32_t vertexCount = spriteCount * QUAD_VERTEX_COUNT;
UI_CONSOLE_CACHE.vertexCount = vertexCount;
if(vertexCount > 0) {
const float_t lineH = (float_t)FONT_DEFAULT.tileset->tileHeight;
const float_t charW = (float_t)FONT_DEFAULT.tileset->tileWidth;
int32_t index = 0;
int32_t row = 0;
for(uint32_t i = 0; i < CONSOLE_HISTORY_MAX; i++) {
float_t posX = (float_t)SCREEN.scanX;
float_t posY = (float_t)SCREEN.scanY + lineH * (float_t)row;
char_t c;
int32_t j = 0;
while((c = CONSOLE.line[i][j++]) != '\0') {
if(c == '\n') {
posX = (float_t)SCREEN.scanX;
posY += lineH;
row++;
continue;
}
if(c == ' ') {
posX += charW;
continue;
}
const spritebatchsprite_t sprite = textGetSprite(
(vec2){ posX, posY }, c, &FONT_DEFAULT
);
spriteBatchBufferToMesh(
&sprite, 1,
&UI_CONSOLE_CACHE.vertices[index], QUAD_VERTEX_COUNT
);
index += QUAD_VERTEX_COUNT;
posX += charW;
}
row++;
}
if(!UI_CONSOLE_CACHE.built) {
errorChain(meshInit(
&UI_CONSOLE_CACHE.mesh,
QUAD_PRIMITIVE_TYPE,
UI_CONSOLE_CACHE_VERTEX_MAX,
UI_CONSOLE_CACHE.vertices
));
UI_CONSOLE_CACHE.built = true;
} else {
errorChain(meshFlush(&UI_CONSOLE_CACHE.mesh, 0, vertexCount));
}
}
UI_CONSOLE_CACHE.cachedScanX = SCREEN.scanX;
UI_CONSOLE_CACHE.cachedScanY = SCREEN.scanY;
CONSOLE.dirty = false;
errorOk();
}
errorret_t uiConsoleDispose(void) {
if(UI_CONSOLE_CACHE.built) {
errorChain(meshDispose(&UI_CONSOLE_CACHE.mesh));
}
memoryZero(&UI_CONSOLE_CACHE, sizeof(uiconsolecache_t));
errorOk();
}
-48
View File
@@ -1,48 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "display/mesh/quad.h"
// Fixed capacity for the console's cached glyph mesh, in glyphs -- the
// mesh/vertex buffer is sized to this once and never grown/shrunk, so
// history producing more non-space characters than this asserts (see
// uiConsoleRebuild) instead of reallocating.
#define UI_CONSOLE_CACHE_GLYPH_MAX 512
#define UI_CONSOLE_CACHE_VERTEX_MAX (\
UI_CONSOLE_CACHE_GLYPH_MAX * QUAD_VERTEX_COUNT \
)
/**
* Renders the console history into the scan-safe area, drawing a mesh
* that is only rebuilt when the history changes or the scan-safe origin
* moves, rather than rebuffering vertices every frame. No-ops when the
* console is not visible.
*
* @return Any error that occurs.
*/
errorret_t uiConsoleDraw(void);
/**
* Rebuilds the cached mesh for the console's current history and
* scan-safe origin. Called automatically by uiConsoleDraw() whenever
* needed; only needs calling directly to force an immediate rebuild.
* The underlying vertex buffer is a fixed-size array (see
* UI_CONSOLE_CACHE_VERTEX_MAX) -- asserts if the history produces more
* glyphs than that capacity, rather than growing it.
*
* @return Any error that occurs.
*/
errorret_t uiConsoleRebuild(void);
/**
* Frees the mesh and vertex buffer built up by uiConsoleDraw().
*
* @return Any error that occurs.
*/
errorret_t uiConsoleDispose(void);
-78
View File
@@ -1,78 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uifps.h"
#include "time/time.h"
#include "util/string.h"
#include "display/text/text.h"
#include "display/screen/screen.h"
#include "engine/engine.h"
uifps_t UIFPS;
errorret_t uiFPSInit() {
uiLabelInit(&UIFPS.fpsLabel, &FONT_DEFAULT);
uiLabelInit(&UIFPS.versionLabel, &FONT_DEFAULT);
uiLabelSetText(&UIFPS.versionLabel, ENGINE.version);
uiLabelSetColor(&UIFPS.versionLabel, color(255, 255, 255, 128));
errorOk();
}
errorret_t uiFPSDraw() {
char_t fpsText[32];
// Get now.
dusktimeepoch_t now = timeGetEpoch();
double_t delta = now.time - UIFPS.lastTick.time;
UIFPS.lastTick = now;
// Raw current FPS
float_t fps = delta > 0 ? 1.0 / delta : 0.0;
// Average FPS using exponential moving average
const float_t alpha = 0.1f; // Smoothing factor
if(UIFPS.fpsAverage == 0.0f) {
UIFPS.fpsAverage = fps; // Initialize average on first run
} else {
UIFPS.fpsAverage = alpha * fps + (1.0f - alpha) * UIFPS.fpsAverage;
}
snprintf(
fpsText,
sizeof(fpsText),
"%.1f/%.1fms",
UIFPS.fpsAverage,
delta * 1000.0f
);
color_t textColor;
if(fps >= 55.0f) {
textColor = COLOR_GREEN;
} else if(fps >= 45.0f) {
textColor = COLOR_YELLOW;
} else {
textColor = COLOR_RED;
}
uiLabelSetColor(&UIFPS.fpsLabel, textColor);
if(stringCompare(fpsText, UIFPS.fpsLabel.text) != 0) {
uiLabelSetText(&UIFPS.fpsLabel, fpsText);
}
errorChain(uiLabelDraw(
&UIFPS.fpsLabel, (float_t)SCREEN.scanX, (float_t)SCREEN.scanY
));
int32_t versionWidth, versionHeight;
uiLabelGetSize(&UIFPS.versionLabel, &versionWidth, &versionHeight);
errorChain(uiLabelDraw(
&UIFPS.versionLabel,
(float_t)(SCREEN.scanX + SCREEN.scanWidth - versionWidth),
(float_t)(SCREEN.scanY + SCREEN.scanHeight - versionHeight)
));
errorOk();
}
-34
View File
@@ -1,34 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "time/timeepoch.h"
#include "ui/widget/uilabel.h"
typedef struct {
dusktimeepoch_t lastTick;
float_t fpsAverage;
uilabel_t fpsLabel;
uilabel_t versionLabel;
} uifps_t;
extern uifps_t UIFPS;
/**
* Initializes the FPS counter, caching the (static) version label.
*
* @return Any error that occurs.
*/
errorret_t uiFPSInit();
/**
* Draws the FPS counter on the screen, and also does the update (for now).
*
* @return Any error that occurs.
*/
errorret_t uiFPSDraw();
-190
View File
@@ -1,190 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uifocus.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "input/input.h"
#include "time/time.h"
const uifocusdirmap_t UI_FOCUS_DIR_MAP[] = {
{ INPUT_BIND_UP, UI_FOCUS_DIRECTION_UP, 0, -1 },
{ INPUT_BIND_DOWN, UI_FOCUS_DIRECTION_DOWN, 0, 1 },
{ INPUT_BIND_LEFT, UI_FOCUS_DIRECTION_LEFT, -1, 0 },
{ INPUT_BIND_RIGHT, UI_FOCUS_DIRECTION_RIGHT, 1, 0 },
{ INPUT_BIND_NULL, UI_FOCUS_DIRECTION_NONE, 0, 0 },
};
uifocus_t UI_FOCUS;
void uiFocusInit(void) {
memoryZero(&UI_FOCUS, sizeof(uifocus_t));
}
uifocusitem_t * uiFocusPush(
const uint8_t cols,
const uint8_t rows,
uifocusitemcallback_t selected,
uifocusitemcallback_t changed,
uifocusitemcallback_t closed,
uifocusitemdirectioncallback_t direction,
void *user
) {
assertTrue(
UI_FOCUS.count < UI_FOCUS_STACK_MAX,
"UI focus stack overflow"
);
assertTrue(cols > 0, "Focus item cols must be > 0");
assertTrue(rows > 0, "Focus item rows must be > 0");
uifocusitem_t *item = &UI_FOCUS.items[UI_FOCUS.count];
memoryZero(item, sizeof(uifocusitem_t));
item->cols = cols;
item->rows = rows;
item->selected = selected;
item->changed = changed;
item->closed = closed;
item->direction = direction;
item->user = user;
UI_FOCUS.count++;
UI_FOCUS.pushedThisTick = true;
if(item->changed != NULL) item->changed(item);
return item;
}
void uiFocusPop(void) {
assertTrue(UI_FOCUS.count > 0, "UI focus stack underflow");
uifocusitem_t *item = &UI_FOCUS.items[UI_FOCUS.count - 1];
if(item->closed != NULL) item->closed(item);
UI_FOCUS.count--;
}
void uiFocusPopItem(uifocusitem_t *item) {
assertTrue(UI_FOCUS.count > 0, "UI focus stack underflow");
assertTrue(item >= UI_FOCUS.items, "Item is not on the focus stack");
assertTrue(
item < UI_FOCUS.items + UI_FOCUS.count,
"Item is not on the focus stack"
);
while(&UI_FOCUS.items[UI_FOCUS.count - 1] != item) {
uiFocusPop();
}
uiFocusPop();
}
void uiFocusSetPosition(uifocusitem_t *item, const uint8_t x, const uint8_t y) {
assertTrue(UI_FOCUS.count > 0, "No active focus item");
assertTrue(item->cols > 0, "Focus item cols must be > 0");
assertTrue(item->rows > 0, "Focus item rows must be > 0");
uint8_t newX = x % item->cols;
uint8_t newY = y % item->rows;
if(item->changed != NULL) {
uint8_t oldX = item->x;
uint8_t oldY = item->y;
item->x = newX;
item->y = newY;
if(!item->changed(item)) {
item->x = oldX;
item->y = oldY;
}
return;
}
item->x = newX;
item->y = newY;
}
void uiFocusMoveDirection(
uifocusitem_t *item,
const uifocusdirection_t dir
) {
for(
const uifocusdirmap_t *m = UI_FOCUS_DIR_MAP;
m->action != INPUT_BIND_NULL;
m++
) {
if(m->direction != dir) continue;
uint8_t x = (uint8_t)(item->x + m->dx);
uint8_t y = (uint8_t)(item->y + m->dy);
uiFocusSetPosition(item, x, y);
break;
}
}
void uiFocusHandleDirection(
uifocusitem_t *item,
const uifocusdirection_t dir
) {
if(item->direction != NULL && item->direction(item, dir)) return;
uiFocusMoveDirection(item, dir);
}
void uiFocusUpdate(void) {
if(UI_FOCUS.count == 0) return;
#ifdef DUSK_TIME_DYNAMIC
if(TIME.dynamicUpdate) return;
#endif
if(UI_FOCUS.pushedThisTick) {
UI_FOCUS.pushedThisTick = false;
return;
}
uifocusitem_t *item = &UI_FOCUS.items[UI_FOCUS.count - 1];
if(inputPressed(INPUT_BIND_ACCEPT)) {
if(item->selected != NULL) item->selected(item);
return;
}
if(inputPressed(INPUT_BIND_CANCEL)) {
uiFocusPop();
return;
}
for(
const uifocusdirmap_t *m = UI_FOCUS_DIR_MAP;
m->action != INPUT_BIND_NULL;
m++
) {
if(!inputPressed(m->action)) continue;
UI_FOCUS.direction = m->direction;
UI_FOCUS.timeHeld = 0.0f;
uiFocusHandleDirection(item, m->direction);
return;
}
bool_t held = false;
for(
const uifocusdirmap_t *m = UI_FOCUS_DIR_MAP;
m->action != INPUT_BIND_NULL;
m++
) {
if(m->direction != UI_FOCUS.direction) continue;
held = inputIsDown(m->action);
break;
}
if(!held) {
UI_FOCUS.direction = UI_FOCUS_DIRECTION_NONE;
UI_FOCUS.timeHeld = 0.0f;
return;
}
UI_FOCUS.timeHeld += TIME.delta;
if(UI_FOCUS.timeHeld < UI_FOCUS_HOLD_DELAY) return;
if(UI_FOCUS.timeHeld < UI_FOCUS_HOLD_DELAY + UI_FOCUS_HOLD_REPEAT) return;
UI_FOCUS.timeHeld = UI_FOCUS_HOLD_DELAY;
uiFocusHandleDirection(item, UI_FOCUS.direction);
}
-133
View File
@@ -1,133 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "uifocusitem.h"
#include "input/inputaction.h"
/** Maximum depth of the focus stack. */
#define UI_FOCUS_STACK_MAX 8
/**
* How long a direction must be held before repeating begins, in seconds.
*/
#define UI_FOCUS_HOLD_DELAY 0.5f
/**
* Interval between repeated moves while a direction is held, in seconds.
*/
#define UI_FOCUS_HOLD_REPEAT 0.1f
typedef struct {
inputbind_t action;
uifocusdirection_t direction;
int8_t dx;
int8_t dy;
} uifocusdirmap_t;
/**
* Mapping of input actions to focus directions, terminated by an
* entry with action == INPUT_BIND_NULL.
*/
extern const uifocusdirmap_t UI_FOCUS_DIR_MAP[];
/**
* A stack of focused UI items. Push an item when a widget captures
* focus; pop it when focus is released. The topmost item is always
* the active focus context.
*/
typedef struct {
uifocusitem_t items[UI_FOCUS_STACK_MAX];
uint8_t count;
uifocusdirection_t direction;
float_t timeHeld;
bool_t pushedThisTick;
} uifocus_t;
extern uifocus_t UI_FOCUS;
/**
* Initializes the focus system, zeroing all state.
*/
void uiFocusInit(void);
/**
* Pushes a new focus item onto the stack with the given grid dimensions
* and callbacks. x and y are initialized to 0.
*
* @param cols Number of columns in the focus grid.
* @param rows Number of rows in the focus grid.
* @param selected Called when the user selects the focused cell.
* @param changed Called when the focused cell position changes.
* @param closed Called when this focus item is popped.
* @param direction Called on a direction press/hold before the default
* cell-to-cell movement is applied; may be NULL.
* @param user Arbitrary pointer stored on the item before changed fires.
* @returns Pointer to the newly pushed focus item.
*/
uifocusitem_t * uiFocusPush(
const uint8_t cols,
const uint8_t rows,
uifocusitemcallback_t selected,
uifocusitemcallback_t changed,
uifocusitemcallback_t closed,
uifocusitemdirectioncallback_t direction,
void *user
);
/**
* Pops the topmost focus item from the stack, invoking its closed
* callback if one is set.
*/
void uiFocusPop(void);
/**
* Pops an item and anything that was pushed after it from the focus stack.
*
* @param item The focus item to pop to. Must be on the stack.
*/
void uiFocusPopItem(uifocusitem_t *item);
/**
* Manually sets the cursor position of the topmost focus item and
* fires its changed callback.
*
* @param x Column to move to.
* @param y Row to move to.
*/
void uiFocusSetPosition(uifocusitem_t *item, const uint8_t x, const uint8_t y);
/**
* Moves the topmost focus item one step in the given direction,
* wrapping at the grid edges, and fires its changed callback.
*
* @param item The focus item to move.
* @param dir Direction to move.
*/
void uiFocusMoveDirection(
uifocusitem_t *item,
const uifocusdirection_t dir
);
/**
* Handles a direction press/hold for the given item: gives the item's
* direction callback (if any) first refusal, falling back to the
* default cell-to-cell movement if it's unset or returns false.
*
* @param item The focus item to move.
* @param dir Direction that was pressed or held.
*/
void uiFocusHandleDirection(
uifocusitem_t *item,
const uifocusdirection_t dir
);
/**
* Updates the focus system. Handles first-press movement and
* held-direction repeating. Called once per game tick.
*/
void uiFocusUpdate(void);
-57
View File
@@ -1,57 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
typedef struct uifocusitem_s uifocusitem_t;
typedef enum {
UI_FOCUS_DIRECTION_NONE,
UI_FOCUS_DIRECTION_UP,
UI_FOCUS_DIRECTION_DOWN,
UI_FOCUS_DIRECTION_LEFT,
UI_FOCUS_DIRECTION_RIGHT
} uifocusdirection_t;
/**
* Callback invoked when a focus item's selected cell changes.
*
* @param item The focus item that changed.
*/
typedef bool_t (*uifocusitemcallback_t)(const uifocusitem_t *item);
/**
* Callback invoked when a direction is pressed or held-repeated while
* this item is focused, before the default cell-to-cell movement is
* applied.
*
* @param item The focus item that is currently focused.
* @param direction The direction that was pressed.
* @returns True if the direction was fully handled and the default
* grid movement should be skipped; false to fall through to it.
*/
typedef bool_t (*uifocusitemdirectioncallback_t)(
const uifocusitem_t *item,
const uifocusdirection_t direction
);
/**
* A single entry on the UI focus stack. Tracks the focused cell
* within a grid of cols x rows, and the current x/y position.
*/
struct uifocusitem_s {
uint8_t cols;
uint8_t rows;
uint8_t x;
uint8_t y;
uifocusitemcallback_t selected;
uifocusitemcallback_t changed;
uifocusitemcallback_t closed;
uifocusitemdirectioncallback_t direction;
void *user;
};
-248
View File
@@ -1,248 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uisettings.h"
#include "uisettingsgeneral.h"
#include "uisettingsinput.h"
#include "uisettingsdisplay.h"
#include "uisettingsaudio.h"
#include "ui/frame/uiframe.h"
#include "ui/frame/uiconfirm.h"
#include "util/memory.h"
#include "display/text/text.h"
#include "display/screen/screen.h"
#include "assert/assert.h"
#include "locale/localemanager.h"
#include "asset/loader/locale/assetlocaleloader.h"
uisettings_t UI_SETTINGS;
uisettingstabdef_t UI_SETTINGS_TAB_DEFS[UI_SETTINGS_TAB_COUNT] = {
{
.localeId = "ui.settings.tabs.general",
.init = uiSettingsGeneralInit,
.menu = (uimenu_t *)&UI_SETTINGS.data,
.load = uiSettingsGeneralLoad,
.apply = uiSettingsGeneralApply,
.hasChanges = uiSettingsGeneralHasChanges
},
{
.localeId = "ui.settings.tabs.input",
.init = uiSettingsInputInit,
.menu = (uimenu_t *)&UI_SETTINGS.data,
.load = uiSettingsInputLoad,
.apply = uiSettingsInputApply,
.hasChanges = uiSettingsInputHasChanges
},
{
.localeId = "ui.settings.tabs.display",
.init = uiSettingsDisplayInit,
.menu = (uimenu_t *)&UI_SETTINGS.data,
.load = uiSettingsDisplayLoad,
.apply = uiSettingsDisplayApply,
.hasChanges = uiSettingsDisplayHasChanges
},
{
.localeId = "ui.settings.tabs.audio",
.init = uiSettingsAudioInit,
.menu = (uimenu_t *)&UI_SETTINGS.data,
.load = uiSettingsAudioLoad,
.apply = uiSettingsAudioApply,
.hasChanges = uiSettingsAudioHasChanges
}
};
static uisettingstabdef_t UI_SETTINGS_TOP_DEF = {
.menu = &UI_SETTINGS.tabsMenu,
.load = uiSettingsLoad,
.hasChanges = uiSettingsHasChanges
};
static uisettingstabdef_t *UI_SETTINGS_PENDING_DISCARD = NULL;
static uisettingstabdef_t *UI_SETTINGS_ACTIVE_TAB = NULL;
errorret_t uiSettingsActivateTab(uisettingstabdef_t *def) {
if(def == UI_SETTINGS_ACTIVE_TAB) errorOk();
errorChain(def->init(&UI_SETTINGS.data));
def->menu->user = def;
def->load();
UI_SETTINGS_ACTIVE_TAB = def;
errorOk();
}
void uiSettingsTabChanged(
const uimenu_t *menu,
const uint8_t index,
const uimenuitem_t *item
) {
if(index >= UI_SETTINGS_TAB_COUNT) return;
errorCatch(uiSettingsActivateTab(&UI_SETTINGS_TAB_DEFS[index]));
}
void uiSettingsTabSelected(
const uimenu_t *menu,
const uint8_t index,
const uimenuitem_t *item
) {
if(index >= UI_SETTINGS_TAB_COUNT) return;
uisettingstabdef_t *def = &UI_SETTINGS_TAB_DEFS[index];
errorCatch(uiSettingsActivateTab(def));
uiMenuOpen(def->menu);
}
errorret_t uiSettingsInit(void) {
memoryZero(&UI_SETTINGS, sizeof(uisettings_t));
UI_SETTINGS_ACTIVE_TAB = NULL;
for(uint8_t i = 0; i < UI_SETTINGS_TAB_COUNT; i++) {
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
UI_SETTINGS_TAB_DEFS[i].localeId,
0,
UI_SETTINGS.tabLabels[i],
UI_SETTINGS_TAB_LABEL_MAX
));
}
MENU_BEGIN(
&UI_SETTINGS.tabsMenu, UI_SETTINGS.tabs, uiSettingsTabSelected,
uiSettingsDiscardMenuClosed, uiSettingsTabChanged
);
for(uint8_t i = 0; i < UI_SETTINGS_TAB_COUNT; i++) {
MENU_TAB(UI_SETTINGS.tabLabels[i]);
}
MENU_END(UI_SETTINGS.tabs, menuIndex);
UI_SETTINGS.tabsMenu.user = &UI_SETTINGS_TOP_DEF;
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.settings.apply",
0,
UI_SETTINGS.applyLabel,
UI_SETTINGS_APPLY_LABEL_MAX
));
errorChain(uiSettingsActivateTab(&UI_SETTINGS_TAB_DEFS[0]));
errorOk();
}
errorret_t uiSettingsDraw(void) {
if(!uiMenuIsActive(&UI_SETTINGS.tabsMenu)) errorOk();
const float_t width = SCREEN.width;
const float_t height = SCREEN.height;
const float_t x = (float_t)SCREEN.scanX +
((float_t)SCREEN.scanWidth - width) * 0.5f;
const float_t y = (float_t)SCREEN.scanY +
((float_t)SCREEN.scanHeight - height) * 0.5f;
errorChain(uiFrameDrawCached(&UI_SETTINGS.frameCache, x, y, width, height));
const float_t contentX = x + UI_FRAME_START_X;
const float_t contentY = y + UI_FRAME_START_Y;
const float_t contentWidth = width - (UI_FRAME_START_X * 2);
const float_t contentHeight = height - (UI_FRAME_START_Y * 2);
errorChain(uiMenuDraw(
&UI_SETTINGS.tabsMenu,
contentX,
contentY,
contentWidth,
contentHeight
));
const float_t tabsRowHeight = (float_t)FONT_DEFAULT.tileset->tileHeight;
const float_t pageY = contentY + tabsRowHeight + UI_FRAME_PADDING_Y;
const float_t pageHeight = contentHeight - tabsRowHeight - UI_FRAME_PADDING_Y;
if(UI_SETTINGS_ACTIVE_TAB != NULL) {
errorChain(uiMenuDraw(
UI_SETTINGS_ACTIVE_TAB->menu, contentX, pageY, contentWidth, pageHeight
));
}
errorOk();
}
errorret_t uiSettingsUpdate(void) {
if(!UI_SETTINGS_PENDING_DISCARD) errorOk();
uisettingstabdef_t *def = UI_SETTINGS_PENDING_DISCARD;
UI_SETTINGS_PENDING_DISCARD = NULL;
return uiSettingsDiscardUpdate(def);
}
bool_t uiSettingsIsOpen(void) {
return uiMenuIsActive(&UI_SETTINGS.tabsMenu);
}
void uiSettingsOpen() {
uiSettingsLoad();
uiMenuOpen(&UI_SETTINGS.tabsMenu);
}
void uiSettingsClose() {
uiMenuClose(&UI_SETTINGS.tabsMenu);
}
errorret_t uiSettingsDispose(void) {
errorOk();
}
void uiSettingsLoad(void) {
if(UI_SETTINGS_ACTIVE_TAB != NULL) UI_SETTINGS_ACTIVE_TAB->load();
}
void uiSettingsApply(void) {
if(UI_SETTINGS_ACTIVE_TAB != NULL) UI_SETTINGS_ACTIVE_TAB->apply();
}
bool_t uiSettingsHasChanges(void) {
return UI_SETTINGS_ACTIVE_TAB != NULL && UI_SETTINGS_ACTIVE_TAB->hasChanges();
}
void uiSettingsDiscardMenuClosed(const uimenu_t *menu) {
uisettingstabdef_t *def = (uisettingstabdef_t *)menu->user;
if(def->hasChanges()) UI_SETTINGS_PENDING_DISCARD = def;
}
errorret_t uiSettingsDiscardUpdate(uisettingstabdef_t *def) {
#define UI_SETTINGS_DISCARD_TEXT_MAX 128
char_t text[UI_SETTINGS_DISCARD_TEXT_MAX];
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.confirm.discard_changes",
0,
text,
UI_SETTINGS_DISCARD_TEXT_MAX
));
#undef UI_SETTINGS_DISCARD_TEXT_MAX
uiConfirmOpen(text, uiSettingsDiscardConfirmResult, def);
errorOk();
}
void uiSettingsDiscardConfirmResult(const bool_t result, void *user) {
uisettingstabdef_t *def = (uisettingstabdef_t *)user;
if(!result) {
uiMenuOpen(def->menu);
return;
}
def->load();
}
-135
View File
@@ -1,135 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "ui/widget/uimenu.h"
#include "ui/frame/uiframe.h"
#include "uisettingsdata.h"
#define UI_SETTINGS_TAB_COUNT 4
#define UI_SETTINGS_TAB_LABEL_MAX 32
#define UI_SETTINGS_APPLY_LABEL_MAX 32
typedef struct {
const char_t *localeId;
errorret_t (*init)(uisettingsdata_t *data);
uimenu_t *menu;
void (*load)(void);
void (*apply)(void);
bool_t (*hasChanges)(void);
} uisettingstabdef_t;
typedef struct {
uimenu_t tabsMenu;
uimenuitem_t tabs[UI_SETTINGS_TAB_COUNT];
char_t tabLabels[UI_SETTINGS_TAB_COUNT][UI_SETTINGS_TAB_LABEL_MAX];
char_t applyLabel[UI_SETTINGS_APPLY_LABEL_MAX];
uisettingsdata_t data;
uiframecache_t frameCache;
} uisettings_t;
extern uisettings_t UI_SETTINGS;
extern uisettingstabdef_t UI_SETTINGS_TAB_DEFS[UI_SETTINGS_TAB_COUNT];
/**
* Menu closed callback that flags a pending discard-changes
* confirmation if the menu's settings page/panel reports unsaved
* changes. Pass this directly as a settings page menu's closed
* callback. The pending def is picked up by uiSettingsUpdate on the
* next tick.
*
* @param menu The menu that was just closed.
*/
void uiSettingsDiscardMenuClosed(const uimenu_t *menu);
/**
* Shows the discard-changes confirmation dialog for def. Called once
* by uiSettingsUpdate when uiSettingsDiscardMenuClosed has flagged a
* pending def.
*
* @param def The tab/panel def to show the confirmation for.
* @return Any error that occurs.
*/
errorret_t uiSettingsDiscardUpdate(uisettingstabdef_t *def);
/**
* Callback passed to uiConfirmOpen by uiSettingsDiscardUpdate. Reopens
* the menu if the user chose to keep editing, otherwise reloads the
* widgets back to the actual engine values.
*
* @param result True if the user confirmed discarding changes, false
* to keep editing.
* @param user The uisettingstabdef_t* this confirmation belongs to.
*/
void uiSettingsDiscardConfirmResult(const bool_t result, void *user);
/**
* Initializes the settings panel and all of its category pages.
*
* @return Any error that occurs.
*/
errorret_t uiSettingsInit(void);
/**
* Draws the settings panel. No-op when not visible.
*
* @return Any error that occurs.
*/
errorret_t uiSettingsDraw(void);
/**
* Updates the settings panel. Shows a discard-changes confirmation if
* the panel was just closed with unsaved changes.
*
* @return Any error that occurs.
*/
errorret_t uiSettingsUpdate(void);
/**
* Returns true when the settings panel is currently open.
*
* @returns True if open.
*/
bool_t uiSettingsIsOpen(void);
/**
* Opens the settings panel. No-op when already open.
*/
void uiSettingsOpen();
/**
* Closes the settings panel. No-op when already closed.
*/
void uiSettingsClose();
/**
* Disposes of the settings panel.
*
* @return Any error that occurs.
*/
errorret_t uiSettingsDispose(void);
/**
* Loads every category page's widgets from the current engine settings.
* Called automatically by uiSettingsOpen.
*/
void uiSettingsLoad(void);
/**
* Applies every category page's widget values to the engine settings.
* Called automatically when a page's own Apply button is pressed.
*/
void uiSettingsApply(void);
/**
* Returns whether any category page has a widget value that differs
* from what was loaded.
*
* @returns True if there are unsaved changes.
*/
bool_t uiSettingsHasChanges(void);
@@ -1,47 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uisettingsaudio.h"
#include "uisettings.h"
#include "assert/assert.h"
#include "util/memory.h"
void uiSettingsAudioSelected(
const uimenu_t *menu,
const uint8_t index,
const uimenuitem_t *item
) {
if(index != UI_SETTINGS_AUDIO_INDEX_APPLY) return;
uiSettingsAudioApply();
}
errorret_t uiSettingsAudioInit(uisettingsdata_t *data) {
uisettingsaudio_t *audio = &data->audio;
memoryZero(audio, sizeof(uisettingsaudio_t));
MENU_BEGIN(
&audio->menu, audio->items,
uiSettingsAudioSelected, uiSettingsDiscardMenuClosed, NULL
);
MENU_LABEL("No audio settings yet");
MENU_BUTTON(UI_SETTINGS.applyLabel);
MENU_END(audio->items, 1);
errorOk();
}
void uiSettingsAudioLoad(void) {
}
void uiSettingsAudioApply(void) {
uiMenuClose(&UI_SETTINGS.data.audio.menu);
}
bool_t uiSettingsAudioHasChanges(void) {
return false;
}
@@ -1,36 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "uisettingsdata.h"
/**
* Initializes the audio settings page into the shared settings data
* union.
*
* @param data The shared settings data union to initialize into.
* @return Any error that occurs.
*/
errorret_t uiSettingsAudioInit(uisettingsdata_t *data);
/**
* Loads the page's widgets from the current engine audio settings.
*/
void uiSettingsAudioLoad(void);
/**
* Applies the page's widget values to the engine audio settings.
*/
void uiSettingsAudioApply(void);
/**
* Returns whether any widget's value differs from what was loaded.
*
* @returns True if there are unsaved changes.
*/
bool_t uiSettingsAudioHasChanges(void);
@@ -1,67 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "ui/widget/uimenu.h"
#define UI_SETTINGS_GENERAL_ITEM_COUNT 3
#define UI_SETTINGS_GENERAL_INDEX_LANGUAGE 0
#define UI_SETTINGS_GENERAL_INDEX_LANGUAGE_DETAIL 1
#define UI_SETTINGS_GENERAL_INDEX_APPLY 2
#define UI_SETTINGS_GENERAL_LOCALE_COUNT 3
#define UI_SETTINGS_GENERAL_LABEL_MAX 32
#define UI_SETTINGS_GENERAL_INFO_MAX 64
typedef struct {
uimenu_t menu;
uimenuitem_t items[UI_SETTINGS_GENERAL_ITEM_COUNT];
const char_t *localeNames[UI_SETTINGS_GENERAL_LOCALE_COUNT];
char_t languageLabel[UI_SETTINGS_GENERAL_LABEL_MAX];
char_t labelInfo[UI_SETTINGS_GENERAL_INFO_MAX];
} uisettingsgeneral_t;
#define UI_SETTINGS_INPUT_ITEM_COUNT 2
#define UI_SETTINGS_INPUT_LABEL_MAX 32
#define UI_SETTINGS_INPUT_INDEX_DEADZONE 0
#define UI_SETTINGS_INPUT_INDEX_APPLY 1
typedef struct {
uimenu_t menu;
uimenuitem_t items[UI_SETTINGS_INPUT_ITEM_COUNT];
char_t deadzoneLabel[UI_SETTINGS_INPUT_LABEL_MAX];
} uisettingsinput_t;
#define UI_SETTINGS_DISPLAY_ITEM_COUNT 2
#define UI_SETTINGS_DISPLAY_INDEX_APPLY 1
typedef struct {
uimenu_t menu;
uimenuitem_t items[UI_SETTINGS_DISPLAY_ITEM_COUNT];
} uisettingsdisplay_t;
#define UI_SETTINGS_AUDIO_ITEM_COUNT 2
#define UI_SETTINGS_AUDIO_INDEX_APPLY 1
typedef struct {
uimenu_t menu;
uimenuitem_t items[UI_SETTINGS_AUDIO_ITEM_COUNT];
} uisettingsaudio_t;
/**
* Shared, overlapping storage for every settings tab's page data. Only
* one member is ever live at a time -- the active tab's Init writes
* into it, overwriting whatever the previously active tab left there.
* This trades simultaneous access to every tab for a memory footprint
* of only the single largest page.
*/
typedef union {
uisettingsgeneral_t general;
uisettingsinput_t input;
uisettingsdisplay_t display;
uisettingsaudio_t audio;
} uisettingsdata_t;
@@ -1,47 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uisettingsdisplay.h"
#include "uisettings.h"
#include "assert/assert.h"
#include "util/memory.h"
void uiSettingsDisplaySelected(
const uimenu_t *menu,
const uint8_t index,
const uimenuitem_t *item
) {
if(index != UI_SETTINGS_DISPLAY_INDEX_APPLY) return;
uiSettingsDisplayApply();
}
errorret_t uiSettingsDisplayInit(uisettingsdata_t *data) {
uisettingsdisplay_t *display = &data->display;
memoryZero(display, sizeof(uisettingsdisplay_t));
MENU_BEGIN(
&display->menu, display->items,
uiSettingsDisplaySelected, uiSettingsDiscardMenuClosed, NULL
);
MENU_LABEL("No display settings yet");
MENU_BUTTON(UI_SETTINGS.applyLabel);
MENU_END(display->items, 1);
errorOk();
}
void uiSettingsDisplayLoad(void) {
}
void uiSettingsDisplayApply(void) {
uiMenuClose(&UI_SETTINGS.data.display.menu);
}
bool_t uiSettingsDisplayHasChanges(void) {
return false;
}
@@ -1,36 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "uisettingsdata.h"
/**
* Initializes the display settings page into the shared settings data
* union.
*
* @param data The shared settings data union to initialize into.
* @return Any error that occurs.
*/
errorret_t uiSettingsDisplayInit(uisettingsdata_t *data);
/**
* Loads the page's widgets from the current engine display settings.
*/
void uiSettingsDisplayLoad(void);
/**
* Applies the page's widget values to the engine display settings.
*/
void uiSettingsDisplayApply(void);
/**
* Returns whether any widget's value differs from what was loaded.
*
* @returns True if there are unsaved changes.
*/
bool_t uiSettingsDisplayHasChanges(void);
@@ -1,117 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uisettingsgeneral.h"
#include "uisettings.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include "locale/localemanager.h"
#include "locale/localeinfo.h"
#include "asset/loader/locale/assetlocaleloader.h"
static const localeinfo_t *const UI_SETTINGS_GENERAL_LOCALES[
UI_SETTINGS_GENERAL_LOCALE_COUNT
] = {
&LOCALE_EN_US,
&LOCALE_JP_JP,
&LOCALE_ES_MX
};
void uiSettingsGeneralSelected(
const uimenu_t *menu,
const uint8_t index,
const uimenuitem_t *item
) {
switch(index) {
case UI_SETTINGS_GENERAL_INDEX_APPLY:
uiSettingsGeneralApply();
break;
default:
break;
}
}
errorret_t uiSettingsGeneralInit(uisettingsdata_t *data) {
uisettingsgeneral_t *general = &data->general;
memoryZero(general, sizeof(uisettingsgeneral_t));
for(uint8_t i = 0; i < UI_SETTINGS_GENERAL_LOCALE_COUNT; i++) {
general->localeNames[i] = UI_SETTINGS_GENERAL_LOCALES[i]->name;
}
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.settings.general.language",
0,
general->languageLabel,
UI_SETTINGS_GENERAL_LABEL_MAX
));
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.settings.general.language_detail",
0,
general->labelInfo,
UI_SETTINGS_GENERAL_INFO_MAX
));
MENU_BEGIN(
&general->menu, general->items,
uiSettingsGeneralSelected, uiSettingsDiscardMenuClosed, NULL
);
MENU_DROPDOWN(
general->languageLabel, general->localeNames,
UI_SETTINGS_GENERAL_LOCALE_COUNT, 0
);
MENU_LABEL(general->labelInfo);
MENU_BUTTON(UI_SETTINGS.applyLabel);
MENU_END(general->items, 1);
errorOk();
}
void uiSettingsGeneralLoad(void) {
uisettingsgeneral_t *general = &UI_SETTINGS.data.general;
uint8_t index = 0;
for(uint8_t i = 0; i < UI_SETTINGS_GENERAL_LOCALE_COUNT; i++) {
if(stringCompare(LOCALE.locale->file, UI_SETTINGS_GENERAL_LOCALES[i]->file) != 0) {
continue;
}
index = i;
break;
}
uiDropdownSetSelectedIndex(
&general->items[UI_SETTINGS_GENERAL_INDEX_LANGUAGE].dropdown, index
);
}
void uiSettingsGeneralApply(void) {
uisettingsgeneral_t *general = &UI_SETTINGS.data.general;
uint8_t index = uiDropdownGetSelectedIndex(
&general->items[UI_SETTINGS_GENERAL_INDEX_LANGUAGE].dropdown
);
errorCatch(localeManagerSetLocale(UI_SETTINGS_GENERAL_LOCALES[index]));
uiMenuClose(&general->menu);
}
bool_t uiSettingsGeneralHasChanges(void) {
uisettingsgeneral_t *general = &UI_SETTINGS.data.general;
uint8_t index = uiDropdownGetSelectedIndex(
&general->items[UI_SETTINGS_GENERAL_INDEX_LANGUAGE].dropdown
);
return stringCompare(
LOCALE.locale->file, UI_SETTINGS_GENERAL_LOCALES[index]->file
) != 0;
}
@@ -1,36 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "uisettingsdata.h"
/**
* Initializes the general settings page into the shared settings data
* union.
*
* @param data The shared settings data union to initialize into.
* @return Any error that occurs.
*/
errorret_t uiSettingsGeneralInit(uisettingsdata_t *data);
/**
* Loads the page's widgets from the current engine general settings.
*/
void uiSettingsGeneralLoad(void);
/**
* Applies the page's widget values to the engine general settings.
*/
void uiSettingsGeneralApply(void);
/**
* Returns whether any widget's value differs from what was loaded.
*
* @returns True if there are unsaved changes.
*/
bool_t uiSettingsGeneralHasChanges(void);
@@ -1,82 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uisettingsinput.h"
#include "uisettings.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "locale/localemanager.h"
#include "asset/loader/locale/assetlocaleloader.h"
#include "input/input.h"
#include "save/savesettings.h"
void uiSettingsInputSelected(
const uimenu_t *menu,
const uint8_t index,
const uimenuitem_t *item
) {
if(index != UI_SETTINGS_INPUT_INDEX_APPLY) return;
uiSettingsInputApply();
}
errorret_t uiSettingsInputInit(uisettingsdata_t *data) {
uisettingsinput_t *input = &data->input;
memoryZero(input, sizeof(uisettingsinput_t));
MENU_BEGIN(
&input->menu, input->items,
uiSettingsInputSelected, uiSettingsDiscardMenuClosed, NULL
);
#ifdef DUSK_INPUT_GAMEPAD
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.settings.input.deadzone",
0,
input->deadzoneLabel,
UI_SETTINGS_INPUT_LABEL_MAX
));
MENU_SLIDER_FLOAT(
input->deadzoneLabel, INPUT_DEADZONE_DEFAULT, 0.0f, 1.0f, 0.05f
);
#else
MENU_LABEL("No input settings yet");
#endif
MENU_BUTTON(UI_SETTINGS.applyLabel);
MENU_END(input->items, 1);
errorOk();
}
void uiSettingsInputLoad(void) {
#ifdef DUSK_INPUT_GAMEPAD
uiSliderSetFloat(
&UI_SETTINGS.data.input.items[UI_SETTINGS_INPUT_INDEX_DEADZONE].slider,
INPUT.deadzone
);
#endif
}
void uiSettingsInputApply(void) {
#ifdef DUSK_INPUT_GAMEPAD
INPUT.deadzone = uiSliderGetFloat(
&UI_SETTINGS.data.input.items[UI_SETTINGS_INPUT_INDEX_DEADZONE].slider
);
#endif
errorCatch(errorPrint(saveSettingsWrite()));
uiMenuClose(&UI_SETTINGS.data.input.menu);
}
bool_t uiSettingsInputHasChanges(void) {
#ifdef DUSK_INPUT_GAMEPAD
if(uiSliderGetFloat(
&UI_SETTINGS.data.input.items[UI_SETTINGS_INPUT_INDEX_DEADZONE].slider
) != INPUT.deadzone) return true;
#endif
return false;
}
@@ -1,36 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "uisettingsdata.h"
/**
* Initializes the input settings page into the shared settings data
* union.
*
* @param data The shared settings data union to initialize into.
* @return Any error that occurs.
*/
errorret_t uiSettingsInputInit(uisettingsdata_t *data);
/**
* Loads the page's widgets from the current engine input settings.
*/
void uiSettingsInputLoad(void);
/**
* Applies the page's widget values to the engine input settings.
*/
void uiSettingsInputApply(void);
/**
* Returns whether any widget's value differs from what was loaded.
*
* @returns True if there are unsaved changes.
*/
bool_t uiSettingsInputHasChanges(void);
-126
View File
@@ -1,126 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uiconfirm.h"
#include "uiframe.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/math.h"
#include "display/screen/screen.h"
#include "display/text/text.h"
#include "display/color.h"
#include "display/spritebatch/spritebatch.h"
#include "display/texture/texture.h"
#include "display/shader/shaderunlit.h"
#define UI_CONFIRM_BACKDROP_COLOR color4b(0, 0, 0, 160)
uiconfirm_t UI_CONFIRM;
void uiConfirmSelected(
const uimenu_t *menu,
const uint8_t index,
const uimenuitem_t *item
) {
UI_CONFIRM.result = index == UI_CONFIRM_INDEX_CONFIRM;
uiConfirmClose();
}
void uiConfirmClosed(const uimenu_t *menu) {
if(UI_CONFIRM.callback != NULL) {
UI_CONFIRM.callback(UI_CONFIRM.result, UI_CONFIRM.user);
}
}
errorret_t uiConfirmInit(void) {
memoryZero(&UI_CONFIRM, sizeof(uiconfirm_t));
uiLabelInit(&UI_CONFIRM.textLabel, &FONT_DEFAULT);
MENU_BEGIN(
&UI_CONFIRM.menu, UI_CONFIRM.items, uiConfirmSelected, uiConfirmClosed, NULL
);
MENU_BUTTON("Confirm");
MENU_BUTTON("Cancel");
MENU_END(UI_CONFIRM.items, menuIndex);
errorOk();
}
errorret_t uiConfirmDraw(void) {
if(!uiMenuIsActive(&UI_CONFIRM.menu)) errorOk();
spritebatchsprite_t backdropSprite = {
.min = { 0.0f, 0.0f, 0.0f },
.max = { (float_t)SCREEN.width, (float_t)SCREEN.height, 0.0f },
.uvMin = { 0.0f, 0.0f },
.uvMax = { 1.0f, 1.0f }
};
shadermaterial_t backdropMaterial = {
.unlit = {
.color = UI_CONFIRM_BACKDROP_COLOR,
.texture = &TEXTURE_WHITE
}
};
errorChain(
spriteBatchBuffer(&backdropSprite, 1, &SHADER_UNLIT, backdropMaterial)
);
int32_t textW, textH;
uiLabelGetSize(&UI_CONFIRM.textLabel, &textW, &textH);
float_t rowHeight = (float_t)FONT_DEFAULT.tileset->tileHeight;
float_t width = mathMax(
(float_t)textW + (UI_FRAME_START_X * 2), UI_CONFIRM_MIN_WIDTH
);
float_t height = (UI_FRAME_START_Y * 2) + rowHeight + UI_FRAME_PADDING_Y +
rowHeight;
float_t x = (float_t)SCREEN.scanX + ((float_t)SCREEN.scanWidth - width) * 0.5f;
float_t y = (float_t)SCREEN.scanY + ((float_t)SCREEN.scanHeight - height) * 0.5f;
errorChain(uiFrameDrawCached(&UI_CONFIRM.frameCache, x, y, width, height));
float_t contentX = x + UI_FRAME_START_X;
float_t contentY = y + UI_FRAME_START_Y;
float_t contentWidth = width - (UI_FRAME_START_X * 2);
errorChain(uiLabelDraw(&UI_CONFIRM.textLabel, contentX, contentY));
float_t buttonsY = contentY + rowHeight + UI_FRAME_PADDING_Y;
errorChain(uiMenuDraw(&UI_CONFIRM.menu, contentX, buttonsY, contentWidth, rowHeight));
errorOk();
}
bool_t uiConfirmIsOpen(void) {
return uiMenuIsActive(&UI_CONFIRM.menu);
}
bool_t uiConfirmGetResult(void) {
return UI_CONFIRM.result;
}
void uiConfirmOpen(
const char_t *question,
uiconfirmcallback_t callback,
void *user
) {
assertNotNull(question, "Question cannot be NULL");
uiLabelSetText(&UI_CONFIRM.textLabel, question);
UI_CONFIRM.callback = callback;
UI_CONFIRM.user = user;
UI_CONFIRM.result = false;
uiMenuOpen(&UI_CONFIRM.menu);
}
void uiConfirmClose(void) {
uiMenuClose(&UI_CONFIRM.menu);
}
errorret_t uiConfirmDispose(void) {
errorOk();
}
-96
View File
@@ -1,96 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "ui/widget/uimenu.h"
#include "ui/widget/uilabel.h"
#include "ui/frame/uiframe.h"
#define UI_CONFIRM_MIN_WIDTH 160.0f
#define UI_CONFIRM_INDEX_CONFIRM 0
#define UI_CONFIRM_INDEX_CANCEL 1
#define UI_CONFIRM_ITEM_COUNT 2
/**
* Callback invoked once a confirm dialog is dismissed.
*
* @param result True if Confirm was selected, false if Cancel was
* selected or the dialog was backed out of.
* @param user Arbitrary pointer passed to uiConfirmOpen.
*/
typedef void (*uiconfirmcallback_t)(const bool_t result, void *user);
typedef struct {
uilabel_t textLabel;
uimenu_t menu;
uimenuitem_t items[UI_CONFIRM_ITEM_COUNT];
uiconfirmcallback_t callback;
void *user;
bool_t result;
uiframecache_t frameCache;
} uiconfirm_t;
extern uiconfirm_t UI_CONFIRM;
/**
* Initializes the confirm dialog.
*
* @return Any error that occurs.
*/
errorret_t uiConfirmInit(void);
/**
* Draws the confirm dialog: a semi-transparent black backdrop covering
* the whole screen, then its own centered frame with the question text
* and Confirm/Cancel buttons. No-op when not open.
*
* @return Any error that occurs.
*/
errorret_t uiConfirmDraw(void);
/**
* Returns true when the confirm dialog is currently open.
*
* @returns True if open.
*/
bool_t uiConfirmIsOpen(void);
/**
* Returns the result of the most recently dismissed confirm dialog.
*
* @returns True if Confirm was selected, false otherwise.
*/
bool_t uiConfirmGetResult(void);
/**
* Opens the confirm dialog with the given question text. callback is
* invoked exactly once with the result, whether the dialog was
* dismissed by selecting a button or by pressing cancel/back.
*
* @param question Display text; copied internally, safe to be transient.
* @param callback Called with the result once the dialog closes. May be
* NULL.
* @param user Arbitrary pointer passed through to callback.
*/
void uiConfirmOpen(
const char_t *question,
uiconfirmcallback_t callback,
void *user
);
/**
* Closes the confirm dialog. No-op when already closed.
*/
void uiConfirmClose(void);
/**
* Disposes of the confirm dialog.
*
* @return Any error that occurs.
*/
errorret_t uiConfirmDispose(void);
-161
View File
@@ -1,161 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uiframe.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "display/texture/texture.h"
#include "display/spritebatch/spritebatch.h"
#include "display/shader/shaderunlit.h"
#include "display/color.h"
uiframe_t UI_FRAME;
errorret_t uiFrameInit(void) {
memoryZero(&UI_FRAME, sizeof(uiframe_t));
// Init texture.
color_t border = color4b(0, 50, 255, 255);
color_t center = color4b(0, 100, 220, 240);
for(uint8_t y = 0; y < UI_FRAME_TEXTURE_HEIGHT_POW2; y++) {
for(uint8_t x = 0; x < UI_FRAME_TEXTURE_WIDTH_POW2; x++) {
color_t c;
if(x >= UI_FRAME_TEXTURE_WIDTH || y >= UI_FRAME_TEXTURE_HEIGHT) {
c = COLOR_TRANSPARENT;
} else if(
y < UI_FRAME_TILE_HEIGHT || y >= UI_FRAME_TILE_HEIGHT * 2 ||
x < UI_FRAME_TILE_WIDTH || x >= UI_FRAME_TILE_WIDTH * 2
) {
c = border;
} else {
c = center;
}
UI_FRAME.pixels[y * UI_FRAME_TEXTURE_WIDTH_POW2 + x] = c;
}
}
errorChain(textureInit(
&UI_FRAME.texture,
UI_FRAME_TEXTURE_WIDTH_POW2, UI_FRAME_TEXTURE_HEIGHT_POW2,
TEXTURE_FORMAT_RGBA,
(texturedata_t){ .rgbaColors = UI_FRAME.pixels }
));
UI_FRAME.tileset.tileWidth = UI_FRAME_TILE_WIDTH;
UI_FRAME.tileset.tileHeight = UI_FRAME_TILE_HEIGHT;
UI_FRAME.tileset.columns = 3;
UI_FRAME.tileset.rows = 3;
UI_FRAME.tileset.tileCount = 9;
UI_FRAME.tileset.uv[0] =
(float_t)UI_FRAME_TILE_WIDTH / UI_FRAME_TEXTURE_WIDTH_POW2;
UI_FRAME.tileset.uv[1] =
(float_t)UI_FRAME_TILE_HEIGHT / UI_FRAME_TEXTURE_HEIGHT_POW2;
errorOk();
}
errorret_t uiFrameDraw(
const float_t x,
const float_t y,
const float_t width,
const float_t height
) {
shadermaterial_t material = {
.unlit = {
.color = COLOR_WHITE,
.texture = &UI_FRAME.texture
}
};
spritebatchsprite_t sprites[9];
uiFrameBuildSprites(sprites, x, y, width, height);
return spriteBatchBuffer(sprites, 9, &SHADER_UNLIT, material);
}
void uiFrameBuildSprites(
spritebatchsprite_t sprites[9],
const float_t x,
const float_t y,
const float_t width,
const float_t height
) {
float_t tileW = (float_t)UI_FRAME_BORDER_WIDTH;
float_t tileH = (float_t)UI_FRAME_BORDER_HEIGHT;
sprites[0] = spriteBatchSpriteTilesetPosition(
&UI_FRAME.tileset, 0, 0,
x, y, tileW, tileH
);
sprites[1] = spriteBatchSpriteTilesetPosition(
&UI_FRAME.tileset, 1, 0,
x + tileW, y, width - (tileW * 2.0f), tileH
);
sprites[2] = spriteBatchSpriteTilesetPosition(
&UI_FRAME.tileset, 2, 0,
x + width - tileW, y, tileW, tileH
);
sprites[3] = spriteBatchSpriteTilesetPosition(
&UI_FRAME.tileset, 0, 1,
x, y + tileH, tileW, height - (tileH * 2.0f)
);
sprites[4] = spriteBatchSpriteTilesetPosition(
&UI_FRAME.tileset, 1, 1,
x + tileW, y + tileH,
width - (tileW * 2.0f), height - (tileH * 2.0f)
);
sprites[5] = spriteBatchSpriteTilesetPosition(
&UI_FRAME.tileset, 2, 1,
x + width - tileW, y + tileH, tileW, height - (tileH * 2.0f)
);
sprites[6] = spriteBatchSpriteTilesetPosition(
&UI_FRAME.tileset, 0, 2,
x, y + height - tileH, tileW, tileH
);
sprites[7] = spriteBatchSpriteTilesetPosition(
&UI_FRAME.tileset, 1, 2,
x + tileW, y + height - tileH, width - (tileW * 2.0f), tileH
);
sprites[8] = spriteBatchSpriteTilesetPosition(
&UI_FRAME.tileset, 2, 2,
x + width - tileW, y + height - tileH, tileW, tileH
);
}
errorret_t uiFrameDrawCached(
uiframecache_t *cache,
const float_t x,
const float_t y,
const float_t width,
const float_t height
) {
assertNotNull(cache, "Frame cache cannot be NULL");
if(
!cache->built ||
cache->lastX != x || cache->lastY != y ||
cache->lastWidth != width || cache->lastHeight != height
) {
uiFrameBuildSprites(cache->sprites, x, y, width, height);
cache->lastX = x;
cache->lastY = y;
cache->lastWidth = width;
cache->lastHeight = height;
cache->built = true;
}
shadermaterial_t material = {
.unlit = {
.color = COLOR_WHITE,
.texture = &UI_FRAME.texture
}
};
return spriteBatchBuffer(cache->sprites, 9, &SHADER_UNLIT, material);
}
errorret_t uiFrameDispose(void) {
return textureDispose(&UI_FRAME.texture);
}
-122
View File
@@ -1,122 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "display/texture/texture.h"
#include "display/texture/tileset.h"
#include "display/spritebatch/spritebatchsprite.h"
#define UI_FRAME_BORDER_WIDTH 6
#define UI_FRAME_BORDER_HEIGHT 6
#define UI_FRAME_PADDING_X 2
#define UI_FRAME_PADDING_Y 2
#define UI_FRAME_START_X (UI_FRAME_BORDER_WIDTH + UI_FRAME_PADDING_X)
#define UI_FRAME_START_Y (UI_FRAME_BORDER_HEIGHT + UI_FRAME_PADDING_Y)
#define UI_FRAME_TILE_WIDTH 1
#define UI_FRAME_TILE_HEIGHT 1
#define UI_FRAME_TEXTURE_WIDTH (UI_FRAME_TILE_WIDTH * 3)
#define UI_FRAME_TEXTURE_HEIGHT (UI_FRAME_TILE_HEIGHT * 3)
#define UI_FRAME_TEXTURE_WIDTH_POW2 4
#define UI_FRAME_TEXTURE_HEIGHT_POW2 4
typedef struct {
tileset_t tileset;
texture_t texture;
color_t pixels[UI_FRAME_BORDER_WIDTH * UI_FRAME_BORDER_HEIGHT];
} uiframe_t;
extern uiframe_t UI_FRAME;
/**
* Initializes the global UI_FRAME: builds the dummy texture and
* configures the tileset. Call once after displayInit().
*
* @return Any error that occurs.
*/
errorret_t uiFrameInit(void);
/**
* Draws UI_FRAME using 9-slice rendering from its 3x3 tileset.
* Pushes quads to the sprite batch without flushing.
*
* @param x Screen x position.
* @param y Screen y position.
* @param width Total width of the frame.
* @param height Total height of the frame.
* @return Any error that occurs.
*/
errorret_t uiFrameDraw(
const float_t x,
const float_t y,
const float_t width,
const float_t height
);
/**
* Builds the 9 sprites for a 9-slice frame at the given rect. Used
* internally by uiFrameDraw and uiFrameDrawCached -- call directly only
* if you need the raw sprites instead of buffering them.
*
* @param sprites Destination array of exactly 9 sprites.
* @param x Screen x position.
* @param y Screen y position.
* @param width Total width of the frame.
* @param height Total height of the frame.
*/
void uiFrameBuildSprites(
spritebatchsprite_t sprites[9],
const float_t x,
const float_t y,
const float_t width,
const float_t height
);
/**
* A frame's cached 9-slice sprites, plus the rect they were built for.
* Owned by whichever widget/screen draws a frame repeatedly (dialogs,
* textboxes, settings panels) so uiFrameDrawCached can skip rebuilding
* the sprites when the rect hasn't moved since the last draw.
*/
typedef struct {
spritebatchsprite_t sprites[9];
bool_t built;
float_t lastX;
float_t lastY;
float_t lastWidth;
float_t lastHeight;
} uiframecache_t;
/**
* Draws a 9-slice frame using a caller-owned cache, only rebuilding the
* sprites (via uiFrameBuildSprites) when x/y/width/height differ from
* the last call -- unlike uiFrameDraw, which rebuilds unconditionally
* every time. Prefer this for frames redrawn every frame at a fixed or
* rarely-changing rect (dialogs, panels); use plain uiFrameDraw for
* genuinely one-off or per-frame-varying rects.
*
* @param cache Caller-owned cache, zero-initialized before first use.
* @param x Screen x position.
* @param y Screen y position.
* @param width Total width of the frame.
* @param height Total height of the frame.
* @return Any error that occurs.
*/
errorret_t uiFrameDrawCached(
uiframecache_t *cache,
const float_t x,
const float_t y,
const float_t width,
const float_t height
);
/**
* Disposes of the global UI_FRAME, releasing its GPU texture.
*
* @return Any error that occurs.
*/
errorret_t uiFrameDispose(void);
-89
View File
@@ -1,89 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uicrop.h"
#include "display/screen/screen.h"
#include "display/spritebatch/spritebatch.h"
#include "display/shader/shaderunlit.h"
#include "display/texture/texture.h"
uicrop_t UI_CROP;
errorret_t uiCropInit(void) {
UI_CROP.color = COLOR_BLACK;
errorOk();
}
errorret_t uiCropDraw(void) {
#ifndef DUSK_DISPLAY_SIZE_DYNAMIC
errorOk();
#endif
if(
SCREEN.scanX == 0 &&
SCREEN.scanY == 0 &&
SCREEN.scanWidth == SCREEN.width &&
SCREEN.scanHeight == SCREEN.height
) errorOk();
float_t x0 = (float_t)SCREEN.scanX;
float_t y0 = (float_t)SCREEN.scanY;
float_t x1 = (float_t)(SCREEN.scanX + SCREEN.scanWidth);
float_t y1 = (float_t)(SCREEN.scanY + SCREEN.scanHeight);
float_t w = (float_t)SCREEN.width;
float_t h = (float_t)SCREEN.height;
spritebatchsprite_t sprites[4];
int32_t count = 0;
if(SCREEN.scanX > 0) {
sprites[count++] = (spritebatchsprite_t){
.min = { 0.0f, 0.0f, 0.0f },
.max = { x0, h, 0.0f },
.uvMin = { 0.0f, 0.0f },
.uvMax = { 1.0f, 1.0f }
};
}
if(SCREEN.scanX + SCREEN.scanWidth < SCREEN.width) {
sprites[count++] = (spritebatchsprite_t){
.min = { x1, 0.0f, 0.0f },
.max = { w, h, 0.0f },
.uvMin = { 0.0f, 0.0f },
.uvMax = { 1.0f, 1.0f }
};
}
if(SCREEN.scanY > 0) {
sprites[count++] = (spritebatchsprite_t){
.min = { x0, 0.0f, 0.0f },
.max = { x1, y0, 0.0f },
.uvMin = { 0.0f, 0.0f },
.uvMax = { 1.0f, 1.0f }
};
}
if(SCREEN.scanY + SCREEN.scanHeight < SCREEN.height) {
sprites[count++] = (spritebatchsprite_t){
.min = { x0, y1, 0.0f },
.max = { x1, h, 0.0f },
.uvMin = { 0.0f, 0.0f },
.uvMax = { 1.0f, 1.0f }
};
}
if(count == 0) errorOk();
shadermaterial_t material = {
.unlit = {
.color = UI_CROP.color,
.texture = &TEXTURE_WHITE
}
};
errorChain(spriteBatchBuffer(sprites, count, &SHADER_UNLIT, material));
errorOk();
}
-32
View File
@@ -1,32 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "display/color.h"
typedef struct {
color_t color;
} uicrop_t;
extern uicrop_t UI_CROP;
/**
* Initializes the crop bars to opaque black.
*
* @return Any error that occurs.
*/
errorret_t uiCropInit(void);
/**
* Renders solid-color bars covering every area outside the
* current scan-safe region. No-ops when the scan area equals
* the full viewport.
*
* @return Any error that occurs.
*/
errorret_t uiCropDraw(void);
-122
View File
@@ -1,122 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uifullbox.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "time/time.h"
#include "display/screen/screen.h"
#include "display/texture/texture.h"
#include "display/spritebatch/spritebatch.h"
#include "display/shader/shaderunlit.h"
uifullbox_t UI_FULLBOX_UNDER;
uifullbox_t UI_FULLBOX_OVER;
void uiFullboxInit(uifullbox_t *fullbox) {
assertNotNull(fullbox, "fullbox must not be NULL");
memoryZero(fullbox, sizeof(uifullbox_t));
eventInit(
&fullbox->onTransitionEnd,
fullbox->onTransitionEndCallbacks,
fullbox->onTransitionEndUsers,
4
);
}
void uiFullboxUpdate(uifullbox_t *fullbox, float_t delta) {
assertNotNull(fullbox, "fullbox must not be NULL");
if(fullbox->duration <= 0.0f || fullbox->time >= fullbox->duration) return;
fullbox->time += delta;
if(fullbox->time >= fullbox->duration) {
fullbox->time = fullbox->duration;
eventInvoke(&fullbox->onTransitionEnd, fullbox);
}
}
static color_t uiFullboxGetColor(const uifullbox_t *fullbox) {
if(fullbox->duration <= 0.0f || fullbox->time >= fullbox->duration) {
return fullbox->toColor;
}
float_t t = easingApply(fullbox->easing, fullbox->time / fullbox->duration);
return color4b(
(uint8_t)((float_t)fullbox->fromColor.r +
((float_t)fullbox->toColor.r - (float_t)fullbox->fromColor.r) * t),
(uint8_t)((float_t)fullbox->fromColor.g +
((float_t)fullbox->toColor.g - (float_t)fullbox->fromColor.g) * t),
(uint8_t)((float_t)fullbox->fromColor.b +
((float_t)fullbox->toColor.b - (float_t)fullbox->fromColor.b) * t),
(uint8_t)((float_t)fullbox->fromColor.a +
((float_t)fullbox->toColor.a - (float_t)fullbox->fromColor.a) * t)
);
}
errorret_t uiFullboxDraw(uifullbox_t *fullbox) {
assertNotNull(fullbox, "fullbox must not be NULL");
color_t color = uiFullboxGetColor(fullbox);
if(color.a == 0) errorOk();
spritebatchsprite_t sprite = {
.min = { 0.0f, 0.0f, 0.0f },
.max = { (float_t)SCREEN.width, (float_t)SCREEN.height, 0.0f },
.uvMin = { 0.0f, 0.0f },
.uvMax = { 1.0f, 1.0f }
};
shadermaterial_t material = {
.unlit = {
.color = color,
.texture = &TEXTURE_WHITE
}
};
errorChain(spriteBatchBuffer(&sprite, 1, &SHADER_UNLIT, material));
errorOk();
}
void uiFullboxTransition(
uifullbox_t *fullbox,
color_t from,
color_t to,
float_t duration,
easingtype_t easing
) {
assertNotNull(fullbox, "fullbox must not be NULL");
fullbox->fromColor = from;
fullbox->toColor = to;
fullbox->duration = duration;
fullbox->time = 0.0f;
fullbox->easing = easing;
}
errorret_t uiFullboxUnderInit(void) {
uiFullboxInit(&UI_FULLBOX_UNDER);
errorOk();
}
errorret_t uiFullboxUnderUpdate(void) {
uiFullboxUpdate(&UI_FULLBOX_UNDER, TIME.delta);
errorOk();
}
errorret_t uiFullboxUnderDraw(void) {
return uiFullboxDraw(&UI_FULLBOX_UNDER);
}
errorret_t uiFullboxOverInit(void) {
uiFullboxInit(&UI_FULLBOX_OVER);
errorOk();
}
errorret_t uiFullboxOverUpdate(void) {
uiFullboxUpdate(&UI_FULLBOX_OVER, TIME.delta);
errorOk();
}
errorret_t uiFullboxOverDraw(void) {
return uiFullboxDraw(&UI_FULLBOX_OVER);
}
-109
View File
@@ -1,109 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "display/color.h"
#include "animation/easing.h"
#include "event/event.h"
typedef struct {
color_t fromColor;
color_t toColor;
float_t duration;
float_t time;
easingtype_t easing;
eventcallback_t onTransitionEndCallbacks[4];
void *onTransitionEndUsers[4];
event_t onTransitionEnd;
} uifullbox_t;
extern uifullbox_t UI_FULLBOX_UNDER;
extern uifullbox_t UI_FULLBOX_OVER;
/**
* Initializes a fullbox, zeroing all fields.
*
* @param fullbox The fullbox to initialize.
*/
void uiFullboxInit(uifullbox_t *fullbox);
/**
* Advances the fullbox transition. Fires onTransitionEnd once when the
* transition completes. Safe to call when no transition is running.
*
* @param fullbox The fullbox to update.
* @param delta Seconds elapsed since last update.
*/
void uiFullboxUpdate(uifullbox_t *fullbox, float_t delta);
/**
* Renders the fullbox. Skipped entirely when the current alpha is zero.
*
* @param fullbox The fullbox to draw.
* @return Any error that occurs.
*/
errorret_t uiFullboxDraw(uifullbox_t *fullbox);
/**
* Begins a color transition.
*
* @param fullbox The fullbox to transition.
* @param from Start color.
* @param to End color.
* @param duration Transition duration in seconds.
* @param easing Easing function to apply.
*/
void uiFullboxTransition(
uifullbox_t *fullbox,
color_t from,
color_t to,
float_t duration,
easingtype_t easing
);
/**
* Init function for UI_FULLBOX_UNDER (registered in UI_ELEMENTS).
*
* @return Any error that occurs.
*/
errorret_t uiFullboxUnderInit(void);
/**
* Update function for UI_FULLBOX_UNDER (registered in UI_ELEMENTS).
*
* @return Any error that occurs.
*/
errorret_t uiFullboxUnderUpdate(void);
/**
* Draw function for UI_FULLBOX_UNDER (registered in UI_ELEMENTS).
*
* @return Any error that occurs.
*/
errorret_t uiFullboxUnderDraw(void);
/**
* Init function for UI_FULLBOX_OVER (registered in UI_ELEMENTS).
*
* @return Any error that occurs.
*/
errorret_t uiFullboxOverInit(void);
/**
* Update function for UI_FULLBOX_OVER (registered in UI_ELEMENTS).
*
* @return Any error that occurs.
*/
errorret_t uiFullboxOverUpdate(void);
/**
* Draw function for UI_FULLBOX_OVER (registered in UI_ELEMENTS).
*
* @return Any error that occurs.
*/
errorret_t uiFullboxOverDraw(void);
-111
View File
@@ -1,111 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uiloading.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "time/time.h"
#include "display/text/text.h"
#include "display/screen/screen.h"
#include "uifullbox.h"
#define UI_LOADING_TEXT "loading"
uiloading_t UI_LOADING;
errorret_t uiLoadingInit(void) {
memoryZero(&UI_LOADING, sizeof(uiloading_t));
uiLabelInit(&UI_LOADING.textLabel, &FONT_DEFAULT);
uiLabelSetText(&UI_LOADING.textLabel, UI_LOADING_TEXT);
eventInit(
&UI_LOADING.onTransitionEnd,
UI_LOADING.onTransitionEndCallbacks,
UI_LOADING.onTransitionEndUsers,
4
);
errorOk();
}
errorret_t uiLoadingUpdate(void) {
if(UI_LOADING.duration > 0.0f && UI_LOADING.time < UI_LOADING.duration) {
UI_LOADING.time += TIME.delta;
if(UI_LOADING.time >= UI_LOADING.duration) {
UI_LOADING.time = UI_LOADING.duration;
eventInvoke(&UI_LOADING.onTransitionEnd, &UI_LOADING);
}
}
errorOk();
}
errorret_t uiLoadingDraw(void) {
float_t alpha;
if(UI_LOADING.duration <= 0.0f || UI_LOADING.time >= UI_LOADING.duration) {
alpha = UI_LOADING.toAlpha;
} else {
float_t t = UI_LOADING.time / UI_LOADING.duration;
alpha = UI_LOADING.fromAlpha +
(UI_LOADING.toAlpha - UI_LOADING.fromAlpha) * t;
}
if(alpha <= 0.0f) errorOk();
int32_t textW, textH;
uiLabelGetSize(&UI_LOADING.textLabel, &textW, &textH);
float_t x = (float_t)(SCREEN.scanX + SCREEN.scanWidth) -
(float_t)textW - UI_LOADING_MARGIN;
float_t y = (float_t)(SCREEN.scanY + SCREEN.scanHeight) -
(float_t)textH - UI_LOADING_MARGIN;
color_t color = COLOR_WHITE;
color.a = (uint8_t)(alpha * 255.0f);
uiLabelSetColor(&UI_LOADING.textLabel, color);
errorChain(uiLabelDraw(&UI_LOADING.textLabel, x, y));
errorOk();
}
static void uiLoadingTransition(
const float_t from,
const float_t to,
const eventcallback_t callback,
void *user
) {
UI_LOADING.fromAlpha = from;
UI_LOADING.toAlpha = to;
UI_LOADING.duration = UI_LOADING_FADE_DURATION;
UI_LOADING.time = 0.0f;
eventInit(
&UI_LOADING.onTransitionEnd,
UI_LOADING.onTransitionEndCallbacks,
UI_LOADING.onTransitionEndUsers,
4
);
if(callback) eventSubscribe(&UI_LOADING.onTransitionEnd, callback, user);
}
void uiLoadingShow(eventcallback_t callback, void *user) {
uiLoadingTransition(0.0f, 1.0f, callback, user);
uiFullboxTransition(
&UI_FULLBOX_OVER,
COLOR_TRANSPARENT_BLACK,
COLOR_BLACK,
UI_LOADING_FADE_DURATION,
EASING_LINEAR
);
}
void uiLoadingHide(eventcallback_t callback, void *user) {
uiLoadingTransition(1.0f, 0.0f, callback, user);
uiFullboxTransition(
&UI_FULLBOX_OVER,
COLOR_BLACK,
COLOR_TRANSPARENT_BLACK,
UI_LOADING_FADE_DURATION,
EASING_LINEAR
);
}
-65
View File
@@ -1,65 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "event/event.h"
#include "ui/widget/uilabel.h"
#define UI_LOADING_FADE_DURATION 0.5f
#define UI_LOADING_MARGIN 8.0f
typedef struct {
float_t fromAlpha;
float_t toAlpha;
float_t duration;
float_t time;
eventcallback_t onTransitionEndCallbacks[4];
void *onTransitionEndUsers[4];
event_t onTransitionEnd;
uilabel_t textLabel;
} uiloading_t;
extern uiloading_t UI_LOADING;
/**
* Initializes the loading indicator.
*
* @return Any error that occurs.
*/
errorret_t uiLoadingInit(void);
/**
* Advances the loading indicator fade transition. Fires onTransitionEnd once
* when the transition completes.
*
* @return Any error that occurs.
*/
errorret_t uiLoadingUpdate(void);
/**
* Draws the loading indicator. No-op when fully transparent.
*
* @return Error state.
*/
errorret_t uiLoadingDraw(void);
/**
* Fades the loading indicator in. Invokes callback when fully visible.
*
* @param callback Called when the fade-in completes. May be NULL.
* @param user Forwarded to the callback unchanged.
*/
void uiLoadingShow(eventcallback_t callback, void *user);
/**
* Fades the loading indicator out. Invokes callback when fully hidden.
*
* @param callback Called when the fade-out completes. May be NULL.
* @param user Forwarded to the callback unchanged.
*/
void uiLoadingHide(eventcallback_t callback, void *user);
-81
View File
@@ -1,81 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uitransition.h"
#include "uitransitionfade.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "time/time.h"
#include "util/math.h"
uitransition_t UI_TRANSITION;
uitransitiondrawfunc_t UI_TRANSITION_TYPE_DRAW[UI_TRANSITION_TYPE_COUNT] = {
[UI_TRANSITION_TYPE_FADE] = uiTransitionFadeDraw
};
errorret_t uiTransitionInit(void) {
memoryZero(&UI_TRANSITION, sizeof(uitransition_t));
// uiTransitionStart((uitransitionstartparams_t){
// .duration = 5.0f,
// .type = UI_TRANSITION_TYPE_FADE,
// .params = {
// .fade = {
// .easing = EASING_OUT_QUART,
// .fromColor = COLOR_TRANSPARENT_BLACK,
// .toColor = COLOR_BLACK
// }
// },
// .finished = NULL,
// .user = NULL
// });
errorOk();
}
errorret_t uiTransitionUpdate(void) {
// Update time.
float_t newTime = UI_TRANSITION.data.time + TIME.delta;
UI_TRANSITION.data.time = mathClamp(
newTime,
0.0f,
UI_TRANSITION.data.duration
);
UI_TRANSITION.data.t = (
(UI_TRANSITION.data.duration <= 0.0f) ?
1.0f :
(UI_TRANSITION.data.time / UI_TRANSITION.data.duration)
);
if(UI_TRANSITION.data.t >= 1.0f && UI_TRANSITION.finished) {
UI_TRANSITION.finished(UI_TRANSITION.user);
}
errorOk();
}
errorret_t uiTransitionDraw(void) {
uitransitiondrawfunc_t draw = UI_TRANSITION_TYPE_DRAW[UI_TRANSITION.type];
if(!draw) errorOk();
return draw(&UI_TRANSITION.data);
}
void uiTransitionStart(const uitransitionstartparams_t info) {
assertTrue(info.type > UI_TRANSITION_TYPE_NULL, "Invalid type");
assertTrue(info.type < UI_TRANSITION_TYPE_COUNT, "Invalid type");
assertTrue(info.duration >= 0.0f, "Duration must be non-negative");
memoryZero(&UI_TRANSITION, sizeof(uitransition_t));
UI_TRANSITION.data.duration = info.duration;
UI_TRANSITION.type = info.type;
UI_TRANSITION.data.params = info.params;
UI_TRANSITION.finished = info.finished;
UI_TRANSITION.user = info.user;
}
-74
View File
@@ -1,74 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "display/color.h"
#include "animation/easing.h"
#include "uitransitiondata.h"
typedef void (*uitransitioncallback_t)(void *user);
typedef enum {
UI_TRANSITION_TYPE_NULL,
UI_TRANSITION_TYPE_FADE,
UI_TRANSITION_TYPE_COUNT
} uitransitiontype_t;
extern uitransitiondrawfunc_t UI_TRANSITION_TYPE_DRAW[UI_TRANSITION_TYPE_COUNT];
typedef struct {
uitransitiondata_t data;
uitransitiontype_t type;
uitransitioncallback_t finished;
void *user;
} uitransition_t;
extern uitransition_t UI_TRANSITION;
/**
* Initializes the transition overlay, zeroing all fields and wiring the
* onTransitionEnd event.
*
* @return Any error that occurs.
*/
errorret_t uiTransitionInit(void);
/**
* Advances the transition. Fires onTransitionEnd once when it completes.
* Safe to call when no transition is running.
*
* @return Any error that occurs.
*/
errorret_t uiTransitionUpdate(void);
/**
* Renders the transition overlay. Skipped entirely when the current alpha
* is zero.
*
* @return Any error that occurs.
*/
errorret_t uiTransitionDraw(void);
typedef struct {
float_t duration;
uitransitiontype_t type;
uitransitionparams_t params;
uitransitioncallback_t finished;
void *user;
} uitransitionstartparams_t;
/**
* Starts a transition described by an info struct.
*
* @param info Transition parameters.
*/
void uiTransitionStart(const uitransitionstartparams_t info);
@@ -1,23 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "uitransitionfade.h"
typedef union uitransitionparams_u {
uitransitionfade_t fade;
} uitransitionparams_t;
typedef struct uitransitiondata_s {
float_t time;
float_t duration;
float_t t;
uitransitionparams_t params;
} uitransitiondata_t;
typedef errorret_t (*uitransitiondrawfunc_t)(const uitransitiondata_t *data);
@@ -1,56 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uitransition.h"
#include "assert/assert.h"
#include "display/screen/screen.h"
#include "display/texture/texture.h"
#include "display/spritebatch/spritebatch.h"
#include "display/shader/shaderunlit.h"
errorret_t uiTransitionFadeDraw(const uitransitiondata_t *data) {
assertNotNull(data, "data must not be NULL");
float_t e = easingApply(data->params.fade.easing, data->t);
float_t r = (float_t)(
data->params.fade.toColor.r - data->params.fade.fromColor.r
) * e;
float_t g = (float_t)(
data->params.fade.toColor.g - data->params.fade.fromColor.g
) * e;
float_t b = (float_t)(
data->params.fade.toColor.b - data->params.fade.fromColor.b
) * e;
float_t a = (float_t)(
data->params.fade.toColor.a - data->params.fade.fromColor.a
) * e;
color_t color = color4b(
data->params.fade.fromColor.r + (uint8_t)r,
data->params.fade.fromColor.g + (uint8_t)g,
data->params.fade.fromColor.b + (uint8_t)b,
data->params.fade.fromColor.a + (uint8_t)a
);
if(color.a == 0) errorOk();
spritebatchsprite_t sprite = {
.min = { 0.0f, 0.0f, 0.0f },
.max = { (float_t)SCREEN.width, (float_t)SCREEN.height, 0.0f },
.uvMin = { 0.0f, 0.0f },
.uvMax = { 1.0f, 1.0f }
};
shadermaterial_t material = {
.unlit = {
.color = color,
.texture = NULL
}
};
errorChain(spriteBatchBuffer(&sprite, 1, &SHADER_UNLIT, material));
errorOk();
}
@@ -1,29 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "display/color.h"
#include "animation/easing.h"
typedef struct uitransitiondata_s uitransitiondata_t;
typedef struct {
color_t fromColor;
color_t toColor;
easingtype_t easing;
} uitransitionfade_t;
/**
* Renders a full-screen color overlay interpolated between fade->fromColor
* and fade->toColor using fade->easing. t is the raw normalized progress
* in [0, 1] before easing is applied. No-op when the resulting alpha is zero.
*
* @param fade The fade parameters.
* @return Any error that occurs.
*/
errorret_t uiTransitionFadeDraw(const uitransitiondata_t *fade);
-62
View File
@@ -1,62 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "ui.h"
#include "util/memory.h"
#include "assert/assert.h"
#include "display/spritebatch/spritebatch.h"
#include "display/screen/screen.h"
#include "ui/uielement.h"
#include "ui/focus/uifocus.h"
ui_t UI;
errorret_t uiInit(void) {
memoryZero(&UI, sizeof(ui_t));
uiFocusInit();
uiElementsSort();
uielement_t *element = &UI_ELEMENTS[0];
while(!uiElementIsNull(element)) {
errorChain(uiElementInit(element));
element++;
}
errorOk();
}
errorret_t uiUpdate(void) {
uiFocusUpdate();
uielement_t *element = &UI_ELEMENTS[0];
while(!uiElementIsNull(element)) {
errorChain(uiElementUpdate(element));
element++;
}
errorOk();
}
errorret_t uiRender(void) {
const uielement_t *element = &UI_ELEMENTS[0];
while(!uiElementIsNull(element)) {
errorChain(uiElementDraw(element));
element++;
}
errorChain(spriteBatchFlush());
errorOk();
}
errorret_t uiDispose(void) {
uielement_t *element = &UI_ELEMENTS[0];
while(!uiElementIsNull(element)) {
errorChain(uiElementDispose(element));
element++;
}
errorOk();
}
-41
View File
@@ -1,41 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
typedef struct {
void *nothing;
} ui_t;
extern ui_t UI;
/**
* Initializes the UI system.
*/
errorret_t uiInit(void);
/**
* Updates the UI system.
*
* @return Any error that occurs.
*/
errorret_t uiUpdate(void);
/**
* Renders the UI system.
*
* @return Any error that occurs.
*/
errorret_t uiRender(void);
/**
* Disposes of the UI system.
*
* @return Any error that occurs.
*/
errorret_t uiDispose(void);
-86
View File
@@ -1,86 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uielement.h"
#include "assert/assert.h"
#include "util/sort.h"
#include "ui/frame/uiframe.h"
#include "ui/debug/uifps.h"
#include "engine/engine.h"
#include "ui/overlay/uifullbox.h"
#include "ui/overlay/uiloading.h"
#include "ui/overlay/uicrop.h"
#include "ui/transition/uitransition.h"
#include "ui/debug/uiconsole.h"
#include "ui/frame/uiconfirm.h"
// Priming pass: X does nothing here, so this only exists to process any
// #include directives nested in uielementlist.h/ui/uielementgame.h at
// file scope (each such header's own #pragma once makes it a no-op on
// the real pass below, which happens inside UI_ELEMENTS[]'s braces,
// where a raw #include of a declaration would be invalid).
#define X(initFn, updateFn, drawFn, disposeFn, order) // do nothing
#include "uielementlist.h"
#undef X
uielement_t UI_ELEMENTS[] = {
#define X(initFn, updateFn, drawFn, disposeFn, elementOrder) \
{ \
.init = initFn, .update = updateFn, .draw = drawFn, \
.dispose = disposeFn, .order = elementOrder \
},
#include "uielementlist.h"
#undef X
{ 0 } // Null terminator
};
bool_t uiElementIsNull(const uielement_t *element) {
return element->init == NULL &&
element->update == NULL &&
element->draw == NULL &&
element->dispose == NULL;
}
int_t uiElementCompareOrder(const void *a, const void *b) {
const uielement_t *elementA = (const uielement_t *)a;
const uielement_t *elementB = (const uielement_t *)b;
if(elementA->order < elementB->order) return -1;
if(elementA->order > elementB->order) return 1;
return 0;
}
void uiElementsSort(void) {
// The trailing null terminator is always the last static entry -- sort
// everything before it, and leave it in place.
const size_t count = (sizeof(UI_ELEMENTS) / sizeof(UI_ELEMENTS[0])) - 1;
sortBubble(UI_ELEMENTS, count, sizeof(uielement_t), uiElementCompareOrder);
}
errorret_t uiElementInit(uielement_t *element) {
assertNotNull(element, "element must not be NULL");
if(element->init != NULL) errorChain(element->init());
errorOk();
}
errorret_t uiElementUpdate(uielement_t *element) {
assertNotNull(element, "element must not be NULL");
if(element->update != NULL) errorChain(element->update());
errorOk();
}
errorret_t uiElementDraw(const uielement_t *element) {
assertNotNull(element, "element must not be NULL");
if(element->draw != NULL) errorChain(element->draw());
errorOk();
}
errorret_t uiElementDispose(uielement_t *element) {
assertNotNull(element, "element must not be NULL");
if(element->dispose != NULL) errorChain(element->dispose());
errorOk();
}
-84
View File
@@ -1,84 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
// Built-in order tiers. Lower values update/render first; ties preserve
// their relative UI_ELEMENTS declaration order (uiElementsSort uses a
// stable sort). Game-specific elements can use any int32_t value -- these
// are just the ones the engine itself relies on.
#define UI_ELEMENT_ORDER_DEFAULT 0
#define UI_ELEMENT_ORDER_DEBUG 1000
typedef struct {
errorret_t (*init)();
errorret_t (*update)();
errorret_t (*draw)();
errorret_t (*dispose)();
int32_t order;
} uielement_t;
extern uielement_t UI_ELEMENTS[];
/**
* Returns true when all four callbacks on the element are NULL,
* which marks the end of the UI_ELEMENTS array.
*
* @param element The element to test.
* @returns True if the element is the null terminator.
*/
bool_t uiElementIsNull(const uielement_t *element);
/**
* Compares two elements by their .order field, ascending. Matches
* sortcompare_t, for use with the project's sort utilities.
*
* @param a First uielement_t to compare.
* @param b Second uielement_t to compare.
* @return Negative if a < b, zero if a == b, positive if a > b.
*/
int_t uiElementCompareOrder(const void *a, const void *b);
/**
* Stably sorts UI_ELEMENTS in place by .order, ascending. The trailing
* null terminator is never moved. Called once by uiInit -- element order
* is static after that, so there's no need to re-sort every frame.
*/
void uiElementsSort(void);
/**
* Initializes a UI element, invoking its init callback if set.
*
* @param element The element to initialize.
* @return Any error that occurs.
*/
errorret_t uiElementInit(uielement_t *element);
/**
* Updates a UI element, calling its update callback if set.
*
* @param element The element to update.
* @return Any error that occurs.
*/
errorret_t uiElementUpdate(uielement_t *element);
/**
* Draws a UI element, calling its draw callback if set.
*
* @param element The element to render.
* @return Any error that occurs.
*/
errorret_t uiElementDraw(const uielement_t *element);
/**
* Disposes of a UI element, invoking its dispose callback if set.
*
* @param element The element to dispose.
* @return Any error that occurs.
*/
errorret_t uiElementDispose(uielement_t *element);
-47
View File
@@ -1,47 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
// X(init, update, draw, dispose, order) -- pass NULL for any callback the
// element doesn't need. order controls update/draw sequence (lower first,
// see UI_ELEMENT_ORDER_* in uielement.h); ties preserve declaration order.
// See uielement.c for how this expands.
X(uiFrameInit, NULL, NULL, uiFrameDispose, UI_ELEMENT_ORDER_DEFAULT)
// Fullbox under: above scene, below system UI.
X(
uiFullboxUnderInit, uiFullboxUnderUpdate, uiFullboxUnderDraw, NULL,
UI_ELEMENT_ORDER_DEFAULT
)
// Text stuffs
X(uiConfirmInit, NULL, uiConfirmDraw, uiConfirmDispose, UI_ELEMENT_ORDER_DEFAULT)
X(
uiTransitionInit, uiTransitionUpdate, uiTransitionDraw, NULL,
UI_ELEMENT_ORDER_DEFAULT
)
// Fullbox over: above absolutely everything (except debug).
X(
uiFullboxOverInit, uiFullboxOverUpdate, uiFullboxOverDraw, NULL,
UI_ELEMENT_ORDER_DEFAULT
)
X(
uiLoadingInit, uiLoadingUpdate, uiLoadingDraw, NULL,
UI_ELEMENT_ORDER_DEFAULT
)
X(uiCropInit, NULL, uiCropDraw, NULL, UI_ELEMENT_ORDER_DEFAULT)
// Debug items -- always last.
X(NULL, NULL, uiConsoleDraw, uiConsoleDispose, UI_ELEMENT_ORDER_DEBUG)
X(uiFPSInit, NULL, uiFPSDraw, NULL, UI_ELEMENT_ORDER_DEBUG)
// Game-specific UI elements (duskrpg)
#include "ui/uielementgame.h"
-17
View File
@@ -1,17 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
uibutton.c
uicheckbox.c
uitab.c
uislider.c
uidropdown.c
uiscrolling.c
uimenu.c
uilabel.c
uiwidgetlabel.c
)
-42
View File
@@ -1,42 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uibutton.h"
#include "assert/assert.h"
#include "display/text/text.h"
#include "display/color.h"
void uiButtonInit(uibutton_t *button, const char_t *label) {
assertNotNull(button, "Button cannot be NULL");
assertNotNull(label, "Label cannot be NULL");
uiWidgetLabelInit(&button->label, &FONT_DEFAULT);
uiWidgetLabelSetText(&button->label, label);
button->highlighted = false;
}
bool_t uiButtonIsHighlighted(const uibutton_t *button) {
assertNotNull(button, "Button cannot be NULL");
return button->highlighted;
}
void uiButtonSetHighlighted(uibutton_t *button, const bool_t highlighted) {
assertNotNull(button, "Button cannot be NULL");
button->highlighted = highlighted;
uiWidgetLabelSetColor(
&button->label, highlighted ? COLOR_RED : COLOR_WHITE
);
}
errorret_t uiButtonDraw(
const uibutton_t *button,
const float_t x,
const float_t y
) {
assertNotNull(button, "Button cannot be NULL");
errorChain(uiWidgetLabelDraw(&button->label, x, y));
errorOk();
}
-53
View File
@@ -1,53 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "ui/widget/uiwidgetlabel.h"
typedef struct {
uiwidgetlabel_t label;
bool_t highlighted;
} uibutton_t;
/**
* Initializes a button.
*
* @param button The button to initialize.
* @param label Display label.
*/
void uiButtonInit(uibutton_t *button, const char_t *label);
/**
* Returns whether the button is highlighted.
*
* @param button The button to query.
* @returns True if highlighted.
*/
bool_t uiButtonIsHighlighted(const uibutton_t *button);
/**
* Sets the highlighted state of the button.
*
* @param button The button to update.
* @param highlighted The new highlighted state.
*/
void uiButtonSetHighlighted(uibutton_t *button, const bool_t highlighted);
/**
* Draws the button at the given screen position.
*
* @param button The button to draw.
* @param x Screen x position.
* @param y Screen y position.
* @return Any error that occurs.
*/
errorret_t uiButtonDraw(
const uibutton_t *button,
const float_t x,
const float_t y
);
-64
View File
@@ -1,64 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uicheckbox.h"
#include "util/memory.h"
#include "util/string.h"
#include "display/text/text.h"
#include "display/color.h"
void uiCheckboxInit(
uicheckbox_t *checkbox,
const char_t *label
) {
memoryZero(checkbox, sizeof(uicheckbox_t));
checkbox->rawLabel = label;
uiWidgetLabelInit(&checkbox->label, &FONT_DEFAULT);
uiCheckboxRebuildLabel(checkbox);
}
bool_t uiCheckboxIsChecked(const uicheckbox_t *checkbox) {
return checkbox->checked;
}
void uiCheckboxSetChecked(uicheckbox_t *checkbox, const bool_t checked) {
checkbox->checked = checked;
uiCheckboxRebuildLabel(checkbox);
}
void uiCheckboxToggle(uicheckbox_t *checkbox) {
uiCheckboxSetChecked(checkbox, !uiCheckboxIsChecked(checkbox));
}
bool_t uiCheckboxIsHighlighted(const uicheckbox_t *checkbox) {
return checkbox->highlighted;
}
void uiCheckboxSetHighlighted(uicheckbox_t *checkbox, const bool_t highlighted) {
checkbox->highlighted = highlighted;
uiWidgetLabelSetColor(
&checkbox->label, highlighted ? COLOR_RED : COLOR_WHITE
);
}
errorret_t uiCheckboxDraw(
const uicheckbox_t *checkbox,
const float_t x,
const float_t y
) {
errorChain(uiWidgetLabelDraw(&checkbox->label, x, y));
errorOk();
}
void uiCheckboxRebuildLabel(uicheckbox_t *checkbox) {
char_t combined[UI_WIDGET_LABEL_TEXT_MAX];
stringFormat(
combined, UI_WIDGET_LABEL_TEXT_MAX - 1, "%s %s",
checkbox->checked ? "Y" : "N", checkbox->rawLabel
);
uiWidgetLabelSetText(&checkbox->label, combined);
}
-90
View File
@@ -1,90 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "ui/widget/uiwidgetlabel.h"
typedef struct uicheckbox_s {
const char_t *rawLabel;
uiwidgetlabel_t label;
bool_t checked;
bool_t highlighted;
} uicheckbox_t;
/**
* Initializes a checkbox.
*
* @param checkbox The checkbox to initialize.
* @param label Display label.
*/
void uiCheckboxInit(
uicheckbox_t *checkbox,
const char_t *label
);
/**
* Returns whether the checkbox is checked.
*
* @param checkbox The checkbox to query.
* @returns True if checked.
*/
bool_t uiCheckboxIsChecked(const uicheckbox_t *checkbox);
/**
* Sets the checked state of the checkbox.
*
* @param checkbox The checkbox to update.
* @param checked The new checked state.
*/
void uiCheckboxSetChecked(uicheckbox_t *checkbox, const bool_t checked);
/**
* Toggles the checked state of the checkbox.
*
* @param checkbox The checkbox to toggle.
*/
void uiCheckboxToggle(uicheckbox_t *checkbox);
/**
* Returns whether the checkbox is highlighted.
*
* @param checkbox The checkbox to query.
* @returns True if highlighted.
*/
bool_t uiCheckboxIsHighlighted(const uicheckbox_t *checkbox);
/**
* Sets the highlighted state of the checkbox.
*
* @param checkbox The checkbox to update.
* @param highlighted The new highlighted state.
*/
void uiCheckboxSetHighlighted(uicheckbox_t *checkbox, const bool_t highlighted);
/**
* Draws the checkbox at the given screen position.
*
* @param checkbox The checkbox to draw.
* @param x Screen x position.
* @param y Screen y position.
* @return Any error that occurs.
*/
errorret_t uiCheckboxDraw(
const uicheckbox_t *checkbox,
const float_t x,
const float_t y
);
/**
* Rebuilds the checkbox's cached label ("Y "/"N " mark plus rawLabel)
* from its current checked state. Called internally whenever checked
* changes.
*
* @param checkbox The checkbox to update.
*/
void uiCheckboxRebuildLabel(uicheckbox_t *checkbox);
-110
View File
@@ -1,110 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uidropdown.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include "display/text/text.h"
#include "display/color.h"
void uiDropdownInit(
uidropdown_t *dropdown,
const char_t *label,
const char_t *const *options,
const uint8_t optionCount,
const uint8_t selectedIndex
) {
assertNotNull(dropdown, "Dropdown cannot be NULL");
assertNotNull(label, "Label cannot be NULL");
assertNotNull(options, "Options cannot be NULL");
assertTrue(optionCount > 0, "Dropdown must have at least one option");
memoryZero(dropdown, sizeof(uidropdown_t));
uiWidgetLabelInit(&dropdown->label, &FONT_DEFAULT);
uiWidgetLabelSetText(&dropdown->label, label);
uiWidgetLabelInit(&dropdown->value, &FONT_DEFAULT);
dropdown->options = options;
dropdown->optionCount = optionCount;
dropdown->selectedIndex = selectedIndex < optionCount ? selectedIndex : 0;
uiDropdownRebuildValue(dropdown);
}
uint8_t uiDropdownGetSelectedIndex(const uidropdown_t *dropdown) {
assertNotNull(dropdown, "Dropdown cannot be NULL");
return dropdown->selectedIndex;
}
const char_t *uiDropdownGetSelectedOption(const uidropdown_t *dropdown) {
assertNotNull(dropdown, "Dropdown cannot be NULL");
return dropdown->options[dropdown->selectedIndex];
}
void uiDropdownSetSelectedIndex(uidropdown_t *dropdown, const uint8_t index) {
assertNotNull(dropdown, "Dropdown cannot be NULL");
assertTrue(index < dropdown->optionCount, "Dropdown index out of range");
dropdown->selectedIndex = index;
uiDropdownRebuildValue(dropdown);
}
void uiDropdownStepNext(uidropdown_t *dropdown) {
assertNotNull(dropdown, "Dropdown cannot be NULL");
dropdown->selectedIndex =
(dropdown->selectedIndex + 1) % dropdown->optionCount;
uiDropdownRebuildValue(dropdown);
}
void uiDropdownStepPrev(uidropdown_t *dropdown) {
assertNotNull(dropdown, "Dropdown cannot be NULL");
dropdown->selectedIndex = dropdown->selectedIndex == 0 ?
dropdown->optionCount - 1 : dropdown->selectedIndex - 1;
uiDropdownRebuildValue(dropdown);
}
bool_t uiDropdownIsHighlighted(const uidropdown_t *dropdown) {
assertNotNull(dropdown, "Dropdown cannot be NULL");
return dropdown->highlighted;
}
void uiDropdownSetHighlighted(
uidropdown_t *dropdown,
const bool_t highlighted
) {
assertNotNull(dropdown, "Dropdown cannot be NULL");
dropdown->highlighted = highlighted;
color_t color = highlighted ? COLOR_RED : COLOR_WHITE;
uiWidgetLabelSetColor(&dropdown->label, color);
uiWidgetLabelSetColor(&dropdown->value, color);
}
errorret_t uiDropdownDraw(
const uidropdown_t *dropdown,
const float_t x,
const float_t y
) {
assertNotNull(dropdown, "Dropdown cannot be NULL");
errorChain(uiWidgetLabelDraw(&dropdown->label, x, y));
int32_t labelW, labelH;
uiWidgetLabelGetSize(&dropdown->label, &labelW, &labelH);
errorChain(uiWidgetLabelDraw(
&dropdown->value, x + (float_t)labelW + UI_DROPDOWN_GAP, y
));
errorOk();
}
void uiDropdownRebuildValue(uidropdown_t *dropdown) {
char_t valueText[UI_WIDGET_LABEL_TEXT_MAX];
stringFormat(
valueText, UI_WIDGET_LABEL_TEXT_MAX - 1, "< %s >",
uiDropdownGetSelectedOption(dropdown)
);
uiWidgetLabelSetText(&dropdown->value, valueText);
}
-123
View File
@@ -1,123 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "ui/widget/uiwidgetlabel.h"
#define UI_DROPDOWN_GAP 4.0f
typedef struct {
uiwidgetlabel_t label;
uiwidgetlabel_t value;
const char_t *const *options;
uint8_t optionCount;
uint8_t selectedIndex;
bool_t highlighted;
} uidropdown_t;
/**
* Initializes a dropdown.
*
* @param dropdown The dropdown to initialize.
* @param label Display label.
* @param options Array of option display strings; caller-owned, must
* outlive the dropdown.
* @param optionCount Number of entries in options. Must be > 0.
* @param selectedIndex Initial selected option index, clamped to
* [0, optionCount - 1].
*/
void uiDropdownInit(
uidropdown_t *dropdown,
const char_t *label,
const char_t *const *options,
const uint8_t optionCount,
const uint8_t selectedIndex
);
/**
* Returns the index of the currently selected option.
*
* @param dropdown The dropdown to query.
* @returns The selected option index.
*/
uint8_t uiDropdownGetSelectedIndex(const uidropdown_t *dropdown);
/**
* Returns the currently selected option's display string.
*
* @param dropdown The dropdown to query.
* @returns The selected option string.
*/
const char_t *uiDropdownGetSelectedOption(const uidropdown_t *dropdown);
/**
* Sets the selected option by index.
*
* @param dropdown The dropdown to update.
* @param index The new selected option index. Must be < optionCount.
*/
void uiDropdownSetSelectedIndex(uidropdown_t *dropdown, const uint8_t index);
/**
* Selects the next option, wrapping around to the first option past
* the last.
*
* @param dropdown The dropdown to update.
*/
void uiDropdownStepNext(uidropdown_t *dropdown);
/**
* Selects the previous option, wrapping around to the last option
* before the first.
*
* @param dropdown The dropdown to update.
*/
void uiDropdownStepPrev(uidropdown_t *dropdown);
/**
* Returns whether the dropdown is highlighted.
*
* @param dropdown The dropdown to query.
* @returns True if highlighted.
*/
bool_t uiDropdownIsHighlighted(const uidropdown_t *dropdown);
/**
* Sets the highlighted state of the dropdown.
*
* @param dropdown The dropdown to update.
* @param highlighted The new highlighted state.
*/
void uiDropdownSetHighlighted(
uidropdown_t *dropdown,
const bool_t highlighted
);
/**
* Draws the dropdown at the given screen position: label, then the
* currently selected option surrounded by cycle arrows.
*
* @param dropdown The dropdown to draw.
* @param x Screen x position.
* @param y Screen y position.
* @return Any error that occurs.
*/
errorret_t uiDropdownDraw(
const uidropdown_t *dropdown,
const float_t x,
const float_t y
);
/**
* Rebuilds the dropdown's cached value label ("< option >") from its
* current selectedIndex. Called internally whenever selectedIndex
* changes.
*
* @param dropdown The dropdown to update.
*/
void uiDropdownRebuildValue(uidropdown_t *dropdown);
-70
View File
@@ -1,70 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uilabel.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include "display/text/text.h"
void uiLabelInit(uilabel_t *label, font_t *font) {
assertNotNull(label, "Label cannot be NULL");
assertNotNull(font, "Font cannot be NULL");
memoryZero(label, sizeof(uilabel_t));
label->font = font;
label->color = COLOR_WHITE;
}
void uiLabelSetText(uilabel_t *label, const char_t *text) {
assertNotNull(label, "Label cannot be NULL");
assertNotNull(text, "Text cannot be NULL");
assertStrLenMax(text, UI_LABEL_TEXT_MAX, "Label text too long");
stringCopy(label->text, text, UI_LABEL_TEXT_MAX);
label->spriteCount = textBuildSpriteCache(
label->text, label->font, label->sprites, UI_LABEL_SPRITE_COUNT_MAX,
&label->width, &label->height
);
}
void uiLabelSetColor(uilabel_t *label, const color_t color) {
assertNotNull(label, "Label cannot be NULL");
label->color = color;
}
void uiLabelGetSize(
const uilabel_t *label,
int32_t *outWidth,
int32_t *outHeight
) {
assertNotNull(label, "Label cannot be NULL");
assertNotNull(outWidth, "Output width cannot be NULL");
assertNotNull(outHeight, "Output height cannot be NULL");
*outWidth = label->width;
*outHeight = label->height;
}
errorret_t uiLabelDraw(
const uilabel_t *label,
const float_t x,
const float_t y
) {
assertNotNull(label, "Label cannot be NULL");
if(label->spriteCount == 0) errorOk();
// Cached sprites are relative to (0,0); textDrawSpriteCache translates
// into the requested screen position here instead of in
// uiLabelSetText, so a label can be repositioned every frame without
// rebuilding the (much more expensive) glyph/UV cache.
spritebatchsprite_t scratch[UI_LABEL_SPRITE_COUNT_MAX];
errorChain(textDrawSpriteCache(
label->sprites, label->spriteCount, scratch, x, y, label->color,
label->font->texture
));
errorOk();
}
-85
View File
@@ -1,85 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "display/text/font.h"
#include "display/spritebatch/spritebatchsprite.h"
#include "display/color.h"
#define UI_LABEL_TEXT_MAX 256
#define UI_LABEL_SPRITE_COUNT_MAX UI_LABEL_TEXT_MAX
typedef struct {
char_t text[UI_LABEL_TEXT_MAX];
color_t color;
font_t *font;
spritebatchsprite_t sprites[UI_LABEL_SPRITE_COUNT_MAX];
uint32_t spriteCount;
int32_t width;
int32_t height;
} uilabel_t;
/**
* Initializes a label, defaulting to white text and no text set.
*
* @param label The label to initialize.
* @param font Font to use for rendering. Must outlive the label.
*/
void uiLabelInit(uilabel_t *label, font_t *font);
/**
* Sets the label's text, rebuilding its cached sprites (glyph lookup, UV,
* and layout) immediately. This is the only way the sprite cache gets
* rebuilt -- uiLabelDraw never recomputes it, so call this whenever the
* text changes rather than once up front and expecting it to stay in sync.
*
* @param label The label to update.
* @param text Null-terminated string to display. Must be shorter than
* UI_LABEL_TEXT_MAX.
*/
void uiLabelSetText(uilabel_t *label, const char_t *text);
/**
* Sets the label's tint color. Cheap -- doesn't touch the sprite cache,
* since color is applied via the draw material, not baked into sprites.
*
* @param label The label to update.
* @param color The new tint color.
*/
void uiLabelSetColor(uilabel_t *label, const color_t color);
/**
* Gets the measured size (in pixels) of the label's current text, cached
* from the last uiLabelSetText call.
*
* @param label The label to query.
* @param outWidth Pointer to store the width.
* @param outHeight Pointer to store the height.
*/
void uiLabelGetSize(
const uilabel_t *label,
int32_t *outWidth,
int32_t *outHeight
);
/**
* Draws the label's cached sprites at the given screen position in a
* single batched buffer call. Does not recompute glyph layout -- call
* uiLabelSetText first whenever the text changes.
*
* @param label The label to draw.
* @param x Screen x position.
* @param y Screen y position.
* @return Any error that occurs.
*/
errorret_t uiLabelDraw(
const uilabel_t *label,
const float_t x,
const float_t y
);
-293
View File
@@ -1,293 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uimenu.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "display/text/text.h"
#include "display/color.h"
#include "ui/widget/uiwidgetlabel.h"
#include "ui/widget/uibutton.h"
#include "ui/widget/uitab.h"
#include "ui/widget/uislider.h"
#include "ui/widget/uidropdown.h"
void uiMenuInit(
uimenu_t *menu,
uimenuselectedcallback_t selected,
uimenuclosedcallback_t closed,
uimenuchangedcallback_t changed
) {
assertNotNull(menu, "Menu cannot be NULL");
memoryZero(menu, sizeof(uimenu_t));
menu->selected = selected;
menu->closed = closed;
menu->changed = changed;
}
void uiMenuSetItems(
uimenu_t *menu,
const uimenuitem_t *items,
const uint8_t itemCount,
const uint8_t columns
) {
assertNotNull(menu, "Menu cannot be NULL");
assertNotNull(items, "Items cannot be NULL");
assertTrue(itemCount > 0, "Item count must be > 0");
assertTrue(columns > 0, "Columns must be > 0");
menu->items = (uimenuitem_t *)items;
menu->itemCount = itemCount;
menu->columns = columns;
}
void uiMenuSetPosition(uimenu_t *menu, const uint8_t x, const uint8_t y) {
assertNotNull(menu, "Menu cannot be NULL");
if(menu->focusItem == NULL) return;
uiFocusSetPosition(menu->focusItem, x, y);
}
void uiMenuOpen(uimenu_t *menu) {
assertNotNull(menu, "Menu cannot be NULL");
assertNotNull(menu->items, "Menu items cannot be NULL");
assertTrue(menu->itemCount > 0, "Menu item count must be > 0");
assertTrue(menu->columns > 0, "Menu columns must be > 0");
if(menu->focusItem != NULL) return;
uint8_t focusable = uiMenuFocusableCount(menu);
uint8_t rows = focusable > 0 ?
(focusable + menu->columns - 1) / menu->columns : 1;
menu->focusItem = uiFocusPush(
menu->columns, rows,
uiMenuFocusSelected,
uiMenuFocusChanged,
uiMenuFocusClosed,
uiMenuFocusDirection,
menu
);
}
void uiMenuClose(uimenu_t *menu) {
assertNotNull(menu, "Menu cannot be NULL");
if(menu->focusItem == NULL) return;
uiFocusPopItem(menu->focusItem);
menu->focusItem = NULL;
}
bool_t uiMenuIsActive(const uimenu_t *menu) {
assertNotNull(menu, "Menu cannot be NULL");
return menu->focusItem != NULL;
}
errorret_t uiMenuDraw(
const uimenu_t *menu,
const float_t x,
const float_t y,
const float_t width,
const float_t height
) {
assertNotNull(menu, "Menu cannot be NULL");
if(menu->itemCount == 0) errorOk();
float_t colStep = width / (float_t)menu->columns;
float_t rowHeight = (float_t)FONT_DEFAULT.tileset->tileHeight;
uint8_t col = 0;
uint8_t row = 0;
for(uint8_t i = 0; i < menu->itemCount; i++) {
const uimenuitem_t *item = &menu->items[i];
if(item->type == UI_MENU_WIDGET_TYPE_LABEL) {
if(col > 0) { row++; col = 0; }
errorChain(uiWidgetLabelDraw(
&item->label, x, y + (float_t)row * rowHeight
));
row++;
continue;
}
if(item->type == UI_MENU_WIDGET_TYPE_SPACER) {
if(col > 0) { row++; col = 0; }
row++;
continue;
}
float_t ix = x + (float_t)col * colStep;
float_t iy = y + (float_t)row * rowHeight;
switch(item->type) {
case UI_MENU_WIDGET_TYPE_CHECKBOX:
errorChain(uiCheckboxDraw(&item->checkbox, ix, iy));
break;
case UI_MENU_WIDGET_TYPE_BUTTON:
errorChain(uiButtonDraw(&item->button, ix, iy));
break;
case UI_MENU_WIDGET_TYPE_TAB:
errorChain(uiTabDraw(&item->tab, ix, iy));
break;
case UI_MENU_WIDGET_TYPE_SLIDER:
errorChain(uiSliderDraw(&item->slider, ix, iy));
break;
case UI_MENU_WIDGET_TYPE_DROPDOWN:
errorChain(uiDropdownDraw(&item->dropdown, ix, iy));
break;
default:
break;
}
col++;
if(col >= menu->columns) { col = 0; row++; }
}
errorOk();
}
bool_t uiMenuFocusSelected(const uifocusitem_t *focusItem) {
assertNotNull(focusItem, "Focus item cannot be NULL");
assertNotNull(focusItem->user, "Focus item user cannot be NULL");
uimenu_t *menu = (uimenu_t *)focusItem->user;
if(menu->selected == NULL) return true;
uint8_t slot = focusItem->y * menu->columns + focusItem->x;
uint8_t index = uiMenuFocusSlotToIndex(menu, slot);
if(index == 0xFF) return true;
menu->selected(menu, index, &menu->items[index]);
return true;
}
bool_t uiMenuFocusChanged(const uifocusitem_t *focusItem) {
assertNotNull(focusItem, "Focus item cannot be NULL");
assertNotNull(focusItem->user, "Focus item user cannot be NULL");
uimenu_t *menu = (uimenu_t *)focusItem->user;
uint8_t focusSlot = focusItem->y * menu->columns + focusItem->x;
uint8_t slot = 0;
for(uint8_t i = 0; i < menu->itemCount; i++) {
uimenuitem_t *item = &menu->items[i];
if(
item->type == UI_MENU_WIDGET_TYPE_LABEL ||
item->type == UI_MENU_WIDGET_TYPE_SPACER
) continue;
uiMenuItemSetHighlighted(item, slot == focusSlot);
slot++;
}
if(menu->changed == NULL) return true;
uint8_t index = uiMenuFocusSlotToIndex(menu, focusSlot);
if(index == 0xFF) return true;
menu->changed(menu, index, &menu->items[index]);
return true;
}
bool_t uiMenuFocusClosed(const uifocusitem_t *focusItem) {
assertNotNull(focusItem, "Focus item cannot be NULL");
assertNotNull(focusItem->user, "Focus item user cannot be NULL");
uimenu_t *menu = (uimenu_t *)focusItem->user;
menu->focusItem = NULL;
for(uint8_t i = 0; i < menu->itemCount; i++) {
uiMenuItemSetHighlighted(&menu->items[i], false);
}
if(menu->closed != NULL) menu->closed(menu);
return true;
}
bool_t uiMenuFocusDirection(
const uifocusitem_t *focusItem,
const uifocusdirection_t direction
) {
assertNotNull(focusItem, "Focus item cannot be NULL");
assertNotNull(focusItem->user, "Focus item user cannot be NULL");
if(direction != UI_FOCUS_DIRECTION_LEFT && direction != UI_FOCUS_DIRECTION_RIGHT) {
return false;
}
uimenu_t *menu = (uimenu_t *)focusItem->user;
uint8_t slot = focusItem->y * menu->columns + focusItem->x;
uint8_t index = uiMenuFocusSlotToIndex(menu, slot);
if(index == 0xFF) return false;
uimenuitem_t *item = &menu->items[index];
bool_t right = direction == UI_FOCUS_DIRECTION_RIGHT;
if(item->type == UI_MENU_WIDGET_TYPE_SLIDER) {
if(right) {
uiSliderStepUp(&item->slider);
} else {
uiSliderStepDown(&item->slider);
}
return true;
}
if(item->type == UI_MENU_WIDGET_TYPE_CHECKBOX) {
uiCheckboxToggle(&item->checkbox);
return true;
}
if(item->type == UI_MENU_WIDGET_TYPE_DROPDOWN) {
if(right) {
uiDropdownStepNext(&item->dropdown);
} else {
uiDropdownStepPrev(&item->dropdown);
}
return true;
}
return false;
}
uint8_t uiMenuFocusableCount(const uimenu_t *menu) {
assertNotNull(menu, "Menu cannot be NULL");
uint8_t count = 0;
for(uint8_t i = 0; i < menu->itemCount; i++) {
uimenuwidgettype_t t = menu->items[i].type;
if(t != UI_MENU_WIDGET_TYPE_LABEL && t != UI_MENU_WIDGET_TYPE_SPACER) count++;
}
return count;
}
uint8_t uiMenuFocusSlotToIndex(const uimenu_t *menu, const uint8_t slot) {
assertNotNull(menu, "Menu cannot be NULL");
uint8_t focusable = 0;
for(uint8_t i = 0; i < menu->itemCount; i++) {
uimenuwidgettype_t t = menu->items[i].type;
if(t == UI_MENU_WIDGET_TYPE_LABEL || t == UI_MENU_WIDGET_TYPE_SPACER) continue;
if(focusable == slot) return i;
focusable++;
}
return 0xFF;
}
void uiMenuLabelInit(uimenuitem_t *item, const char_t *text) {
assertNotNull(item, "Item cannot be NULL");
assertNotNull(text, "Text cannot be NULL");
uiWidgetLabelInit(&item->label, &FONT_DEFAULT);
uiWidgetLabelSetText(&item->label, text);
}
void uiMenuItemSetHighlighted(uimenuitem_t *item, const bool_t highlighted) {
if(item->type == UI_MENU_WIDGET_TYPE_CHECKBOX) {
uiCheckboxSetHighlighted(&item->checkbox, highlighted);
} else if(item->type == UI_MENU_WIDGET_TYPE_BUTTON) {
uiButtonSetHighlighted(&item->button, highlighted);
} else if(item->type == UI_MENU_WIDGET_TYPE_TAB) {
uiTabSetActive(&item->tab, highlighted);
} else if(item->type == UI_MENU_WIDGET_TYPE_SLIDER) {
uiSliderSetHighlighted(&item->slider, highlighted);
} else if(item->type == UI_MENU_WIDGET_TYPE_DROPDOWN) {
uiDropdownSetHighlighted(&item->dropdown, highlighted);
}
}
-292
View File
@@ -1,292 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "ui/focus/uifocus.h"
#include "ui/widget/uiwidgetlabel.h"
#include "ui/widget/uibutton.h"
#include "ui/widget/uicheckbox.h"
#include "ui/widget/uitab.h"
#include "ui/widget/uislider.h"
#include "ui/widget/uidropdown.h"
typedef struct uimenu_s uimenu_t;
typedef enum {
UI_MENU_WIDGET_TYPE_NONE,
UI_MENU_WIDGET_TYPE_LABEL,
UI_MENU_WIDGET_TYPE_SPACER,
UI_MENU_WIDGET_TYPE_CHECKBOX,
UI_MENU_WIDGET_TYPE_BUTTON,
UI_MENU_WIDGET_TYPE_TAB,
UI_MENU_WIDGET_TYPE_SLIDER,
UI_MENU_WIDGET_TYPE_DROPDOWN,
} uimenuwidgettype_t;
typedef struct {
uimenuwidgettype_t type;
union {
uiwidgetlabel_t label;
uicheckbox_t checkbox;
uibutton_t button;
uitab_t tab;
uislider_t slider;
uidropdown_t dropdown;
};
} uimenuitem_t;
typedef void (*uimenuselectedcallback_t)(
const uimenu_t *menu,
const uint8_t index,
const uimenuitem_t *item
);
typedef void (*uimenuchangedcallback_t)(
const uimenu_t *menu,
const uint8_t index,
const uimenuitem_t *item
);
typedef void (*uimenuclosedcallback_t)(const uimenu_t *menu);
typedef struct uimenu_s {
uimenuitem_t *items;
uint8_t itemCount;
uint8_t columns;
uifocusitem_t *focusItem;
uimenuselectedcallback_t selected;
uimenuclosedcallback_t closed;
uimenuchangedcallback_t changed;
void *user;
} uimenu_t;
/**
* Initializes a menu, clearing all items and focus state.
*
* @param menu The menu to initialize.
* @param items The list of items to display in the menu.
* @param itemCount The number of items in the list.
* @param columns The number of columns to display the items in.
* @param selected The callback to invoke when an item is selected.
* @param closed The callback to invoke when the menu is closed.
* @param changed The callback to invoke when the menu changes.
*/
void uiMenuInit(
uimenu_t *menu,
uimenuselectedcallback_t selected,
uimenuclosedcallback_t closed,
uimenuchangedcallback_t changed
);
/**
* Sets the items to display in the menu.
*
* @param menu The menu to update.
* @param items The list of items to display in the menu.
* @param itemCount The number of items in the list.
* @param columns The number of columns to display the items in.
*/
void uiMenuSetItems(
uimenu_t *menu,
const uimenuitem_t *items,
const uint8_t itemCount,
const uint8_t columns
);
/**
* Sets the position of the menu on the screen.
*
* @param menu The menu to position.
* @param x The x-coordinate to position the menu at.
* @param y The y-coordinate to position the menu at.
*/
void uiMenuSetPosition(uimenu_t *menu, const uint8_t x, const uint8_t y);
/**
* Pushes a menu onto the UI focus stack, making it the active menu.
*
* @param menu The menu to push.
*/
void uiMenuOpen(uimenu_t *menu);
/**
* Pops a menu from the UI focus stack, removing it from the active menu.
*
* @param menu The menu to pop.
*/
void uiMenuClose(uimenu_t *menu);
/**
* Returns whether the menu is currently active (on the UI focus stack).
*
* @param menu The menu to query.
* @returns True if the menu is active.
*/
bool_t uiMenuIsActive(const uimenu_t *menu);
/**
* Draws the menu at the specified position and size.
*
* @param menu The menu to draw.
* @param x The x-coordinate to draw the menu at.
* @param y The y-coordinate to draw the menu at.
* @param width The width of the menu.
* @param height The height of the menu.
* @returns An error code indicating success or failure.
*/
errorret_t uiMenuDraw(
const uimenu_t *menu,
const float_t x,
const float_t y,
const float_t width,
const float_t height
);
/**
* Returns the number of focusable (non-label) items in the menu.
*
* @param menu The menu to query.
* @returns Count of non-label items.
*/
uint8_t uiMenuFocusableCount(const uimenu_t *menu);
/**
* Maps a flat focus slot index to the corresponding item array index,
* skipping over label items which are not focusable.
*
* @param menu The menu to query.
* @param slot The focus slot index (y * columns + x).
* @returns The item array index, or 0xFF if out of range.
*/
uint8_t uiMenuFocusSlotToIndex(const uimenu_t *menu, const uint8_t slot);
/**
* Sets the highlighted/active state on a menu item, dispatching to the
* matching widget's setter based on its type. No-op for label/spacer
* items and any other type with no highlight concept.
*
* @param item The item to update.
* @param highlighted The new highlighted/active state.
*/
void uiMenuItemSetHighlighted(uimenuitem_t *item, const bool_t highlighted);
/**
* Initializes a menu item as a standalone label, caching its text.
* Used internally by the MENU_LABEL helper macro.
*
* @param item The item to initialize.
* @param text Display text.
*/
void uiMenuLabelInit(uimenuitem_t *item, const char_t *text);
/**
* Internal focus callback - forwards selection to the menu's selected handler.
*
* @param focusItem The active focus item; user field must point to uimenu_t.
* @returns True.
*/
bool_t uiMenuFocusSelected(const uifocusitem_t *focusItem);
/**
* Internal focus callback - updates widget highlights and fires changed.
*
* @param focusItem The active focus item; user field must point to uimenu_t.
* @returns True.
*/
bool_t uiMenuFocusChanged(const uifocusitem_t *focusItem);
/**
* Internal focus callback - clears focusItem and fires the closed handler.
*
* @param focusItem The active focus item; user field must point to uimenu_t.
* @returns True.
*/
bool_t uiMenuFocusClosed(const uifocusitem_t *focusItem);
/**
* Internal focus callback - gives the focused item's widget a chance to
* handle LEFT/RIGHT itself (e.g. a slider adjusting its value) before
* falling back to the default cell-to-cell movement.
*
* @param focusItem The active focus item; user field must point to uimenu_t.
* @param direction The direction that was pressed or held.
* @returns True if the focused widget handled the direction.
*/
bool_t uiMenuFocusDirection(
const uifocusitem_t *focusItem,
const uifocusdirection_t direction
);
// Helper macros
#define MENU_BEGIN(menuPtr, itemsArray, selected, closed, changed) \
uimenu_t *menu = (menuPtr); \
uiMenuInit(menu, selected, closed, changed); \
menu->items = (itemsArray); \
uint8_t menuIndex = 0; \
uint8_t menuCapacity = sizeof(itemsArray) / sizeof((itemsArray)[0])
#define MENU_LABEL(text) \
assertTrue(menuIndex < menuCapacity, "Menu item count exceeds capacity"); \
menu->items[menuIndex].type = UI_MENU_WIDGET_TYPE_LABEL; \
uiMenuLabelInit(&menu->items[menuIndex], text); \
++menuIndex
#define MENU_SPACER() \
assertTrue(menuIndex < menuCapacity, "Menu item count exceeds capacity"); \
menu->items[menuIndex].type = UI_MENU_WIDGET_TYPE_SPACER; \
++menuIndex
#define MENU_CHECKBOX(label) \
assertTrue(menuIndex < menuCapacity, "Menu item count exceeds capacity"); \
menu->items[menuIndex].type = UI_MENU_WIDGET_TYPE_CHECKBOX; \
uiCheckboxInit(&menu->items[menuIndex].checkbox, label); \
++menuIndex
#define MENU_BUTTON(label) \
assertTrue(menuIndex < menuCapacity, "Menu item count exceeds capacity"); \
menu->items[menuIndex].type = UI_MENU_WIDGET_TYPE_BUTTON; \
uiButtonInit(&menu->items[menuIndex].button, label); \
++menuIndex
#define MENU_TAB(label) \
assertTrue(menuIndex < menuCapacity, "Menu item count exceeds capacity"); \
menu->items[menuIndex].type = UI_MENU_WIDGET_TYPE_TAB; \
uiTabInit(&menu->items[menuIndex].tab, label); \
++menuIndex
#define MENU_SLIDER_FLOAT(label, value, min, max, step) \
assertTrue(menuIndex < menuCapacity, "Menu item count exceeds capacity"); \
menu->items[menuIndex].type = UI_MENU_WIDGET_TYPE_SLIDER; \
uiSliderInitFloat( \
&menu->items[menuIndex].slider, label, value, min, max, step \
); \
++menuIndex
#define MENU_SLIDER_INT(label, value, min, max, step) \
assertTrue(menuIndex < menuCapacity, "Menu item count exceeds capacity"); \
menu->items[menuIndex].type = UI_MENU_WIDGET_TYPE_SLIDER; \
uiSliderInitInt( \
&menu->items[menuIndex].slider, label, value, min, max, step \
); \
++menuIndex
#define MENU_DROPDOWN(label, options, optionCount, selectedIndex) \
assertTrue(menuIndex < menuCapacity, "Menu item count exceeds capacity"); \
menu->items[menuIndex].type = UI_MENU_WIDGET_TYPE_DROPDOWN; \
uiDropdownInit( \
&menu->items[menuIndex].dropdown, label, options, optionCount, \
selectedIndex \
); \
++menuIndex
#define MENU_END(itemsArray, columns) \
uiMenuSetItems(menu, (itemsArray), menuIndex, (columns))
//EOF
-15
View File
@@ -1,15 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uiscrolling.h"
#include "assert/assert.h"
void uiScrollingInit(uiscrolling_t *scrolling) {
assertNotNull(scrolling, "Scrolling container cannot be NULL");
// Nothing to initialize yet -- uiscrolling_t is currently a
// placeholder with no fields.
}
-21
View File
@@ -1,21 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
typedef struct {
} uiscrolling_t;
/**
* Initializes a scrolling container. Currently a placeholder -- no
* scrolling behavior is implemented yet.
*
* @param scrolling The scrolling container to initialize.
*/
void uiScrollingInit(uiscrolling_t *scrolling);
-282
View File
@@ -1,282 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uislider.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/math.h"
#include "util/string.h"
#include "display/text/text.h"
#include "display/color.h"
#include "display/spritebatch/spritebatch.h"
#include "display/texture/texture.h"
#include "display/shader/shaderunlit.h"
void uiSliderInitFloat(
uislider_t *slider,
const char_t *label,
const float_t value,
const float_t min,
const float_t max,
const float_t step
) {
assertNotNull(slider, "Slider cannot be NULL");
assertNotNull(label, "Label cannot be NULL");
assertTrue(max > min, "Slider max must be greater than min");
assertTrue(step > 0.0f, "Slider step must be greater than zero");
memoryZero(slider, sizeof(uislider_t));
uiWidgetLabelInit(&slider->label, &FONT_DEFAULT);
uiWidgetLabelSetText(&slider->label, label);
uiWidgetLabelInit(&slider->valueLabel, &FONT_DEFAULT);
slider->type = UI_SLIDER_TYPE_FLOAT;
slider->min.f = min;
slider->max.f = max;
slider->step.f = step;
slider->value.f = mathClamp(value, min, max);
uiSliderRebuildValueLabel(slider);
uiSliderRebuildGeometry(slider);
}
void uiSliderInitInt(
uislider_t *slider,
const char_t *label,
const int32_t value,
const int32_t min,
const int32_t max,
const int32_t step
) {
assertNotNull(slider, "Slider cannot be NULL");
assertNotNull(label, "Label cannot be NULL");
assertTrue(max > min, "Slider max must be greater than min");
assertTrue(step > 0, "Slider step must be greater than zero");
memoryZero(slider, sizeof(uislider_t));
uiWidgetLabelInit(&slider->label, &FONT_DEFAULT);
uiWidgetLabelSetText(&slider->label, label);
uiWidgetLabelInit(&slider->valueLabel, &FONT_DEFAULT);
slider->type = UI_SLIDER_TYPE_INT;
slider->min.i = min;
slider->max.i = max;
slider->step.i = step;
slider->value.i = mathClamp(value, min, max);
uiSliderRebuildValueLabel(slider);
uiSliderRebuildGeometry(slider);
}
float_t uiSliderGetFloat(const uislider_t *slider) {
assertNotNull(slider, "Slider cannot be NULL");
return slider->type == UI_SLIDER_TYPE_INT ?
(float_t)slider->value.i : slider->value.f;
}
int32_t uiSliderGetInt(const uislider_t *slider) {
assertNotNull(slider, "Slider cannot be NULL");
assertTrue(
slider->type == UI_SLIDER_TYPE_INT, "Slider is not an int slider"
);
return slider->value.i;
}
void uiSliderSetFloat(uislider_t *slider, const float_t value) {
assertNotNull(slider, "Slider cannot be NULL");
assertTrue(
slider->type == UI_SLIDER_TYPE_FLOAT, "Slider is not a float slider"
);
slider->value.f = mathClamp(value, slider->min.f, slider->max.f);
uiSliderRebuildValueLabel(slider);
uiSliderRebuildGeometry(slider);
}
void uiSliderSetInt(uislider_t *slider, const int32_t value) {
assertNotNull(slider, "Slider cannot be NULL");
assertTrue(
slider->type == UI_SLIDER_TYPE_INT, "Slider is not an int slider"
);
slider->value.i = mathClamp(value, slider->min.i, slider->max.i);
uiSliderRebuildValueLabel(slider);
uiSliderRebuildGeometry(slider);
}
void uiSliderStepUp(uislider_t *slider) {
assertNotNull(slider, "Slider cannot be NULL");
if(slider->type == UI_SLIDER_TYPE_INT) {
int32_t next = slider->value.i + slider->step.i;
uiSliderSetInt(slider, next > slider->max.i ? slider->min.i : next);
} else {
float_t next = slider->value.f + slider->step.f;
uiSliderSetFloat(slider, next > slider->max.f ? slider->min.f : next);
}
}
void uiSliderStepDown(uislider_t *slider) {
assertNotNull(slider, "Slider cannot be NULL");
if(slider->type == UI_SLIDER_TYPE_INT) {
int32_t next = slider->value.i - slider->step.i;
uiSliderSetInt(slider, next < slider->min.i ? slider->max.i : next);
} else {
float_t next = slider->value.f - slider->step.f;
uiSliderSetFloat(slider, next < slider->min.f ? slider->max.f : next);
}
}
float_t uiSliderGetRatio(const uislider_t *slider) {
assertNotNull(slider, "Slider cannot be NULL");
if(slider->type == UI_SLIDER_TYPE_INT) {
if(slider->max.i == slider->min.i) return 0.0f;
return (float_t)(slider->value.i - slider->min.i) /
(float_t)(slider->max.i - slider->min.i);
}
if(slider->max.f == slider->min.f) return 0.0f;
return (slider->value.f - slider->min.f) / (slider->max.f - slider->min.f);
}
int32_t uiSliderGetStepCount(const uislider_t *slider) {
assertNotNull(slider, "Slider cannot be NULL");
if(slider->type != UI_SLIDER_TYPE_INT || slider->step.i <= 0) return 0;
return (slider->max.i - slider->min.i) / slider->step.i;
}
bool_t uiSliderIsHighlighted(const uislider_t *slider) {
assertNotNull(slider, "Slider cannot be NULL");
return slider->highlighted;
}
void uiSliderSetHighlighted(uislider_t *slider, const bool_t highlighted) {
assertNotNull(slider, "Slider cannot be NULL");
slider->highlighted = highlighted;
color_t color = highlighted ? COLOR_RED : COLOR_WHITE;
uiWidgetLabelSetColor(&slider->label, color);
uiWidgetLabelSetColor(&slider->valueLabel, color);
}
errorret_t uiSliderDraw(
const uislider_t *slider,
const float_t x,
const float_t y
) {
assertNotNull(slider, "Slider cannot be NULL");
errorChain(uiWidgetLabelDraw(&slider->label, x, y));
shadermaterial_t trackMaterial = {
.unlit = {
.color = COLOR_DARK_GRAY,
.texture = &TEXTURE_WHITE
}
};
spritebatchsprite_t track = spriteBatchSpriteTranslate(
&slider->cachedTrack, x, y
);
errorChain(spriteBatchBuffer(&track, 1, &SHADER_UNLIT, trackMaterial));
if(slider->cachedFillVisible) {
shadermaterial_t fillMaterial = {
.unlit = {
.color = slider->highlighted ? COLOR_RED : COLOR_WHITE,
.texture = &TEXTURE_WHITE
}
};
spritebatchsprite_t fill = spriteBatchSpriteTranslate(
&slider->cachedFill, x, y
);
errorChain(spriteBatchBuffer(&fill, 1, &SHADER_UNLIT, fillMaterial));
}
if(slider->cachedMarkerCount > 0) {
spritebatchsprite_t markers[UI_SLIDER_STEP_MARKERS_MAX];
for(int32_t i = 0; i < slider->cachedMarkerCount; i++) {
markers[i] = spriteBatchSpriteTranslate(&slider->cachedMarkers[i], x, y);
}
shadermaterial_t markerMaterial = {
.unlit = {
.color = COLOR_WHITE,
.texture = &TEXTURE_WHITE
}
};
errorChain(spriteBatchBuffer(
markers, slider->cachedMarkerCount, &SHADER_UNLIT, markerMaterial
));
}
errorChain(uiWidgetLabelDraw(
&slider->valueLabel,
x + slider->cachedTrack.max[0] + UI_SLIDER_GAP,
y
));
errorOk();
}
void uiSliderRebuildValueLabel(uislider_t *slider) {
char_t valueText[UI_WIDGET_LABEL_TEXT_MAX];
if(slider->type == UI_SLIDER_TYPE_INT) {
stringFormat(
valueText, UI_WIDGET_LABEL_TEXT_MAX - 1, "%d", slider->value.i
);
} else {
stringFormat(
valueText, UI_WIDGET_LABEL_TEXT_MAX - 1, "%.2f", slider->value.f
);
}
uiWidgetLabelSetText(&slider->valueLabel, valueText);
}
void uiSliderRebuildGeometry(uislider_t *slider) {
int32_t labelW, labelH;
uiWidgetLabelGetSize(&slider->label, &labelW, &labelH);
float_t trackX = (float_t)labelW + UI_SLIDER_GAP;
float_t trackY = ((float_t)labelH - UI_SLIDER_TRACK_HEIGHT) * 0.5f;
slider->cachedTrack = (spritebatchsprite_t){
.min = { trackX, trackY, 0.0f },
.max = {
trackX + UI_SLIDER_TRACK_WIDTH, trackY + UI_SLIDER_TRACK_HEIGHT, 0.0f
},
.uvMin = { 0.0f, 0.0f },
.uvMax = { 1.0f, 1.0f }
};
float_t fillWidth = UI_SLIDER_TRACK_WIDTH * uiSliderGetRatio(slider);
slider->cachedFillVisible = fillWidth > 0.0f;
if(slider->cachedFillVisible) {
slider->cachedFill = (spritebatchsprite_t){
.min = { trackX, trackY, 0.0f },
.max = { trackX + fillWidth, trackY + UI_SLIDER_TRACK_HEIGHT, 0.0f },
.uvMin = { 0.0f, 0.0f },
.uvMax = { 1.0f, 1.0f }
};
}
int32_t stepCount = uiSliderGetStepCount(slider);
if(stepCount > 0 && stepCount < UI_SLIDER_STEP_MARKERS_MAX) {
for(int32_t i = 0; i <= stepCount; i++) {
float_t markerX = trackX +
UI_SLIDER_TRACK_WIDTH * (float_t)i / (float_t)stepCount;
slider->cachedMarkers[i] = (spritebatchsprite_t){
.min = {
markerX - UI_SLIDER_STEP_MARKER_WIDTH * 0.5f,
trackY - UI_SLIDER_STEP_MARKER_OVERHANG,
0.0f
},
.max = {
markerX + UI_SLIDER_STEP_MARKER_WIDTH * 0.5f,
trackY + UI_SLIDER_TRACK_HEIGHT + UI_SLIDER_STEP_MARKER_OVERHANG,
0.0f
},
.uvMin = { 0.0f, 0.0f },
.uvMax = { 1.0f, 1.0f }
};
}
slider->cachedMarkerCount = stepCount + 1;
} else {
slider->cachedMarkerCount = 0;
}
}
-215
View File
@@ -1,215 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "ui/widget/uiwidgetlabel.h"
#include "display/spritebatch/spritebatchsprite.h"
#define UI_SLIDER_TRACK_WIDTH 80.0f
#define UI_SLIDER_TRACK_HEIGHT 4.0f
#define UI_SLIDER_STEP_MARKER_WIDTH 2.0f
#define UI_SLIDER_STEP_MARKER_OVERHANG 1.0f
#define UI_SLIDER_GAP 4.0f
/**
* Number of discrete steps below which an int slider renders individual
* step markers on its track instead of a smooth fill.
*/
#define UI_SLIDER_STEP_MARKERS_MAX 10
typedef enum {
UI_SLIDER_TYPE_FLOAT,
UI_SLIDER_TYPE_INT,
UI_SLIDER_TYPE_COUNT
} uislidertype_t;
typedef union {
float_t f;
int32_t i;
} uislidervalue_t;
typedef struct {
uiwidgetlabel_t label;
uiwidgetlabel_t valueLabel;
uislidertype_t type;
uislidervalue_t value;
uislidervalue_t min;
uislidervalue_t max;
uislidervalue_t step;
bool_t highlighted;
// Track/fill/marker geometry, cached relative to origin (0,0) and only
// rebuilt when the value or step configuration changes (see
// uiSliderRebuildGeometry) -- uiSliderDraw just translates these into
// position instead of re-deriving them every frame.
spritebatchsprite_t cachedTrack;
spritebatchsprite_t cachedFill;
bool_t cachedFillVisible;
spritebatchsprite_t cachedMarkers[UI_SLIDER_STEP_MARKERS_MAX];
int32_t cachedMarkerCount;
} uislider_t;
/**
* Initializes a floating point slider.
*
* @param slider The slider to initialize.
* @param label Display label.
* @param value Initial value, clamped to [min, max].
* @param min Minimum value.
* @param max Maximum value.
* @param step The amount a single step moves the value by.
*/
void uiSliderInitFloat(
uislider_t *slider,
const char_t *label,
const float_t value,
const float_t min,
const float_t max,
const float_t step
);
/**
* Initializes an integer slider.
*
* @param slider The slider to initialize.
* @param label Display label.
* @param value Initial value, clamped to [min, max].
* @param min Minimum value.
* @param max Maximum value.
* @param step The amount a single step moves the value by.
*/
void uiSliderInitInt(
uislider_t *slider,
const char_t *label,
const int32_t value,
const int32_t min,
const int32_t max,
const int32_t step
);
/**
* Returns the slider's current value as a float. Works for both slider
* types; int values are widened to float.
*
* @param slider The slider to query.
* @returns The current value.
*/
float_t uiSliderGetFloat(const uislider_t *slider);
/**
* Returns the slider's current value as an int. Only valid for
* UI_SLIDER_TYPE_INT sliders.
*
* @param slider The slider to query.
* @returns The current value.
*/
int32_t uiSliderGetInt(const uislider_t *slider);
/**
* Sets the slider's value, clamped to [min, max]. Only valid for
* UI_SLIDER_TYPE_FLOAT sliders.
*
* @param slider The slider to update.
* @param value The new value.
*/
void uiSliderSetFloat(uislider_t *slider, const float_t value);
/**
* Sets the slider's value, clamped to [min, max]. Only valid for
* UI_SLIDER_TYPE_INT sliders.
*
* @param slider The slider to update.
* @param value The new value.
*/
void uiSliderSetInt(uislider_t *slider, const int32_t value);
/**
* Moves the slider's value up by one step, wrapping around to its min
* if the step would exceed its max.
*
* @param slider The slider to update.
*/
void uiSliderStepUp(uislider_t *slider);
/**
* Moves the slider's value down by one step, wrapping around to its
* max if the step would go below its min.
*
* @param slider The slider to update.
*/
void uiSliderStepDown(uislider_t *slider);
/**
* Returns the slider's current value normalized to a 0..1 range based
* on its min/max.
*
* @param slider The slider to query.
* @returns The normalized value.
*/
float_t uiSliderGetRatio(const uislider_t *slider);
/**
* Returns the number of discrete steps between min and max. Always 0
* for float sliders.
*
* @param slider The slider to query.
* @returns The step count.
*/
int32_t uiSliderGetStepCount(const uislider_t *slider);
/**
* Returns whether the slider is highlighted.
*
* @param slider The slider to query.
* @returns True if highlighted.
*/
bool_t uiSliderIsHighlighted(const uislider_t *slider);
/**
* Sets the highlighted state of the slider.
*
* @param slider The slider to update.
* @param highlighted The new highlighted state.
*/
void uiSliderSetHighlighted(uislider_t *slider, const bool_t highlighted);
/**
* Draws the slider at the given screen position: label, then track,
* then the current value as text. Int sliders with fewer than
* UI_SLIDER_STEP_MARKERS_MAX discrete steps render a marker per step
* instead of a plain track.
*
* @param slider The slider to draw.
* @param x Screen x position.
* @param y Screen y position.
* @return Any error that occurs.
*/
errorret_t uiSliderDraw(
const uislider_t *slider,
const float_t x,
const float_t y
);
/**
* Rebuilds the slider's cached value label from its current value.
* Called internally whenever value changes.
*
* @param slider The slider to update.
*/
void uiSliderRebuildValueLabel(uislider_t *slider);
/**
* Rebuilds the slider's cached track/fill/marker geometry (relative to
* origin (0,0)) from its current label size, value, and step
* configuration. Called internally on init and whenever the value
* changes -- uiSliderDraw never recomputes this itself.
*
* @param slider The slider to update.
*/
void uiSliderRebuildGeometry(uislider_t *slider);
-69
View File
@@ -1,69 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uitab.h"
#include "assert/assert.h"
#include "display/text/text.h"
#include "display/color.h"
#include "display/spritebatch/spritebatch.h"
#include "display/texture/texture.h"
#include "display/shader/shaderunlit.h"
void uiTabInit(uitab_t *tab, const char_t *label) {
assertNotNull(tab, "Tab cannot be NULL");
assertNotNull(label, "Label cannot be NULL");
uiWidgetLabelInit(&tab->label, &FONT_DEFAULT);
uiWidgetLabelSetText(&tab->label, label);
tab->active = false;
uiTabRebuildBackground(tab);
}
bool_t uiTabIsActive(const uitab_t *tab) {
assertNotNull(tab, "Tab cannot be NULL");
return tab->active;
}
void uiTabSetActive(uitab_t *tab, const bool_t active) {
assertNotNull(tab, "Tab cannot be NULL");
tab->active = active;
}
errorret_t uiTabDraw(
const uitab_t *tab,
const float_t x,
const float_t y
) {
assertNotNull(tab, "Tab cannot be NULL");
spritebatchsprite_t sprite = spriteBatchSpriteTranslate(
&tab->cachedBackground, x, y
);
shadermaterial_t material = {
.unlit = {
.color = tab->active ? COLOR_GREEN : COLOR_RED,
.texture = &TEXTURE_WHITE
}
};
errorChain(spriteBatchBuffer(&sprite, 1, &SHADER_UNLIT, material));
errorChain(uiWidgetLabelDraw(&tab->label, x, y));
errorOk();
}
void uiTabRebuildBackground(uitab_t *tab) {
assertNotNull(tab, "Tab cannot be NULL");
int32_t labelW, labelH;
uiWidgetLabelGetSize(&tab->label, &labelW, &labelH);
tab->cachedBackground = (spritebatchsprite_t){
.min = { 0.0f, 0.0f, 0.0f },
.max = { (float_t)labelW, (float_t)labelH, 0.0f },
.uvMin = { 0.0f, 0.0f },
.uvMax = { 1.0f, 1.0f }
};
}
-70
View File
@@ -1,70 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "ui/widget/uiwidgetlabel.h"
#include "display/spritebatch/spritebatchsprite.h"
typedef struct {
uiwidgetlabel_t label;
bool_t active;
// Background quad, cached relative to origin (0,0) from the label's
// size at init -- uiTabDraw only translates this into position, since
// active/inactive only changes the material color, not the geometry.
spritebatchsprite_t cachedBackground;
} uitab_t;
/**
* Initializes a tab.
*
* @param tab The tab to initialize.
* @param label Display label.
*/
void uiTabInit(uitab_t *tab, const char_t *label);
/**
* Returns whether the tab is active.
*
* @param tab The tab to query.
* @returns True if active.
*/
bool_t uiTabIsActive(const uitab_t *tab);
/**
* Sets the active state of the tab.
*
* @param tab The tab to update.
* @param active The new active state.
*/
void uiTabSetActive(uitab_t *tab, const bool_t active);
/**
* Draws the tab at the given screen position. Active tabs draw with a
* green background; inactive tabs draw with a red background.
*
* @param tab The tab to draw.
* @param x Screen x position.
* @param y Screen y position.
* @return Any error that occurs.
*/
errorret_t uiTabDraw(
const uitab_t *tab,
const float_t x,
const float_t y
);
/**
* Rebuilds the tab's cached background quad (relative to origin (0,0))
* from its current label size. Called internally by uiTabInit -- the
* background's geometry never changes afterwards, since the tab has no
* API to change its label text.
*
* @param tab The tab to update.
*/
void uiTabRebuildBackground(uitab_t *tab);
-66
View File
@@ -1,66 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uiwidgetlabel.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include "display/text/text.h"
void uiWidgetLabelInit(uiwidgetlabel_t *label, font_t *font) {
assertNotNull(label, "Label cannot be NULL");
assertNotNull(font, "Font cannot be NULL");
memoryZero(label, sizeof(uiwidgetlabel_t));
label->font = font;
label->color = COLOR_WHITE;
}
void uiWidgetLabelSetText(uiwidgetlabel_t *label, const char_t *text) {
assertNotNull(label, "Label cannot be NULL");
assertNotNull(text, "Text cannot be NULL");
assertStrLenMax(text, UI_WIDGET_LABEL_TEXT_MAX, "Label text too long");
stringCopy(label->text, text, UI_WIDGET_LABEL_TEXT_MAX);
label->spriteCount = textBuildSpriteCache(
label->text, label->font, label->sprites,
UI_WIDGET_LABEL_SPRITE_COUNT_MAX, &label->width, &label->height
);
}
void uiWidgetLabelSetColor(uiwidgetlabel_t *label, const color_t color) {
assertNotNull(label, "Label cannot be NULL");
label->color = color;
}
void uiWidgetLabelGetSize(
const uiwidgetlabel_t *label,
int32_t *outWidth,
int32_t *outHeight
) {
assertNotNull(label, "Label cannot be NULL");
assertNotNull(outWidth, "Output width cannot be NULL");
assertNotNull(outHeight, "Output height cannot be NULL");
*outWidth = label->width;
*outHeight = label->height;
}
errorret_t uiWidgetLabelDraw(
const uiwidgetlabel_t *label,
const float_t x,
const float_t y
) {
assertNotNull(label, "Label cannot be NULL");
if(label->spriteCount == 0) errorOk();
spritebatchsprite_t scratch[UI_WIDGET_LABEL_SPRITE_COUNT_MAX];
errorChain(textDrawSpriteCache(
label->sprites, label->spriteCount, scratch, x, y, label->color,
label->font->texture
));
errorOk();
}
-90
View File
@@ -1,90 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "display/text/font.h"
#include "display/spritebatch/spritebatchsprite.h"
#include "display/color.h"
// A cached, cheap-to-redraw label sized for embedding directly inside
// small widgets (buttons, checkboxes, sliders, ...) rather than
// referencing text by pointer and re-deriving glyph geometry every
// frame. Capacity is intentionally much smaller than uilabel_t's -- it
// gets embedded by value in every widget instance (and inside
// uimenuitem_t's union, which is often arrayed), so keeping it small
// matters on memory-constrained targets. See uilabel_t for the
// general-purpose, larger-capacity equivalent.
#define UI_WIDGET_LABEL_TEXT_MAX 64
#define UI_WIDGET_LABEL_SPRITE_COUNT_MAX UI_WIDGET_LABEL_TEXT_MAX
typedef struct {
char_t text[UI_WIDGET_LABEL_TEXT_MAX];
color_t color;
font_t *font;
spritebatchsprite_t sprites[UI_WIDGET_LABEL_SPRITE_COUNT_MAX];
uint32_t spriteCount;
int32_t width;
int32_t height;
} uiwidgetlabel_t;
/**
* Initializes a widget label, defaulting to white text and no text set.
*
* @param label The label to initialize.
* @param font Font to use for rendering. Must outlive the label.
*/
void uiWidgetLabelInit(uiwidgetlabel_t *label, font_t *font);
/**
* Sets the label's text, rebuilding its cached sprites immediately.
* Call this whenever the displayed text changes -- uiWidgetLabelDraw
* never recomputes it.
*
* @param label The label to update.
* @param text Null-terminated string to display. Must be shorter than
* UI_WIDGET_LABEL_TEXT_MAX.
*/
void uiWidgetLabelSetText(uiwidgetlabel_t *label, const char_t *text);
/**
* Sets the label's tint color. Cheap -- doesn't touch the sprite cache.
*
* @param label The label to update.
* @param color The new tint color.
*/
void uiWidgetLabelSetColor(uiwidgetlabel_t *label, const color_t color);
/**
* Gets the measured size (in pixels) of the label's current text, cached
* from the last uiWidgetLabelSetText call.
*
* @param label The label to query.
* @param outWidth Pointer to store the width.
* @param outHeight Pointer to store the height.
*/
void uiWidgetLabelGetSize(
const uiwidgetlabel_t *label,
int32_t *outWidth,
int32_t *outHeight
);
/**
* Draws the label's cached sprites at the given screen position in a
* single batched buffer call. Does not recompute glyph layout.
*
* @param label The label to draw.
* @param x Screen x position.
* @param y Screen y position.
* @return Any error that occurs.
*/
errorret_t uiWidgetLabelDraw(
const uiwidgetlabel_t *label,
const float_t x,
const float_t y
);
-276
View File
@@ -1,276 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uitextbox.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include "time/time.h"
#include "display/text/text.h"
#include "display/color.h"
#include "display/spritebatch/spritebatch.h"
#include "display/shader/shaderunlit.h"
#include "ui/frame/uiframe.h"
void uiTextboxInit(
uitextbox_t *box,
char_t *text,
const uint32_t maxLength,
uitextboxline_t *lines,
const uint32_t linesMax
) {
assertNotNull(box, "Textbox cannot be NULL");
assertNotNull(text, "Text buffer cannot be NULL");
assertTrue(maxLength >= 1, "maxLength must be at least 1");
assertNotNull(lines, "Lines buffer cannot be NULL");
assertTrue(linesMax >= 1, "linesMax must be at least 1");
memoryZero(box, sizeof(uitextbox_t));
box->text = text;
box->maxLength = maxLength;
box->lines = lines;
box->linesMax = linesMax;
box->glyphsBuiltForPage = -1;
}
void uiTextboxSetText(uitextbox_t *box, const char_t *text) {
assertNotNull(box, "Textbox cannot be NULL");
assertNotNull(text, "Text cannot be NULL");
stringCopy(box->text, text, box->maxLength);
box->currentPage = 0;
box->scroll = 0;
box->layoutWidth = 0.0f;
box->layoutHeight = 0.0f;
box->glyphsBuiltForPage = -1;
}
void uiTextboxBuildLayout(
uitextbox_t *box,
const float_t width,
const float_t height
) {
assertNotNull(box, "Textbox cannot be NULL");
box->glyphsBuiltForPage = -1;
box->layoutWidth = width;
box->layoutHeight = height;
box->lineCount = 0;
box->pageCount = 1;
float_t fontW = (float_t)FONT_DEFAULT.tileset->tileWidth;
float_t fontH = (float_t)FONT_DEFAULT.tileset->tileHeight;
if(fontW <= 0.0f || fontH <= 0.0f) return;
box->charsPerLine = (int32_t)(width / fontW);
box->linesPerPage = (int32_t)(height / (fontH + UI_TEXTBOX_LINE_SPACING));
if(box->linesPerPage > UI_TEXTBOX_LINES_PER_PAGE_MAX) {
box->linesPerPage = UI_TEXTBOX_LINES_PER_PAGE_MAX;
}
if(box->charsPerLine <= 0 || box->linesPerPage <= 0) return;
if(box->text[0] == '\0') return;
char_t *src = box->text;
int32_t i = 0;
while(src[i] != '\0' && box->lineCount < (int32_t)box->linesMax) {
if(src[i] == '\t') {
i++;
int32_t rem = box->lineCount % box->linesPerPage;
int32_t pad = rem > 0 ? box->linesPerPage - rem : 0;
while(pad > 0 && box->lineCount < (int32_t)box->linesMax) {
box->lines[box->lineCount].start = i;
box->lines[box->lineCount].count = 0;
box->lineCount++;
pad--;
}
continue;
}
int32_t lineStart = i;
int32_t lineWidth = 0;
while(src[i] != '\0') {
char_t c = src[i];
if(c == '\n') { i++; break; }
if(c == '\t') break;
if(c == ' ') {
int32_t wordLen = 0;
int32_t j = i + 1;
while(
src[j] != ' ' && src[j] != '\n' &&
src[j] != '\t' && src[j] != '\0'
) {
wordLen++;
j++;
}
if(lineWidth > 0 && lineWidth + 1 + wordLen > box->charsPerLine) {
i++;
break;
}
lineWidth++;
i++;
} else {
if(lineWidth >= box->charsPerLine) break;
lineWidth++;
i++;
}
}
box->lines[box->lineCount].start = lineStart;
box->lines[box->lineCount].count = lineWidth;
box->lineCount++;
}
if(box->lineCount == 0) {
box->pageCount = 1;
} else {
box->pageCount =
(box->lineCount + box->linesPerPage - 1) / box->linesPerPage;
}
}
errorret_t uiTextboxUpdate(uitextbox_t *box) {
assertNotNull(box, "Textbox cannot be NULL");
#ifdef DUSK_TIME_DYNAMIC
if(TIME.dynamicUpdate) errorOk();
#endif
if(!uiTextboxPageIsComplete(box)) {
box->scroll += UI_TEXTBOX_SCROLL_CHARS_PER_TICK;
}
errorOk();
}
errorret_t uiTextboxDraw(
uitextbox_t *box,
const float_t x,
const float_t y,
const float_t width,
const float_t height
) {
assertNotNull(box, "Textbox cannot be NULL");
float_t startX = (float_t)UI_FRAME_START_X;
float_t startY = (float_t)UI_FRAME_START_Y;
float_t contentX = x + startX;
float_t contentY = y + startY;
float_t contentW = width - 2.0f * startX;
float_t contentH = height - 2.0f * startY;
if(contentW != box->layoutWidth || contentH != box->layoutHeight) {
uiTextboxBuildLayout(box, contentW, contentH);
}
errorChain(uiFrameDrawCached(&box->frameCache, x, y, width, height));
if(box->lineCount == 0 || box->text[0] == '\0') errorOk();
if(box->glyphsBuiltForPage != box->currentPage) {
uiTextboxBuildPageGlyphs(box);
}
if(box->glyphCount == 0) errorOk();
int32_t visibleCount = 0;
while(
visibleCount < box->glyphCount &&
box->glyphs[visibleCount].revealAt <= box->scroll
) visibleCount++;
if(visibleCount == 0) errorOk();
spritebatchsprite_t scratch[UI_TEXTBOX_PAGE_GLYPHS_MAX];
for(int32_t i = 0; i < visibleCount; i++) {
scratch[i] = spriteBatchSpriteTranslate(
&box->glyphs[i].sprite, contentX, contentY
);
}
shadermaterial_t material = {
.unlit = {
.color = COLOR_WHITE,
.texture = FONT_DEFAULT.texture
}
};
errorChain(spriteBatchBuffer(scratch, visibleCount, &SHADER_UNLIT, material));
errorOk();
}
void uiTextboxBuildPageGlyphs(uitextbox_t *box) {
assertNotNull(box, "Textbox cannot be NULL");
box->glyphCount = 0;
int32_t pageFirst = box->currentPage * box->linesPerPage;
int32_t pageLast = pageFirst + box->linesPerPage;
if(pageLast > box->lineCount) pageLast = box->lineCount;
float_t fontW = (float_t)FONT_DEFAULT.tileset->tileWidth;
float_t fontH = (float_t)FONT_DEFAULT.tileset->tileHeight;
int32_t consumed = 0;
for(int32_t li = pageFirst; li < pageLast; li++) {
uitextboxline_t *line = &box->lines[li];
float_t lineY =
(float_t)(li - pageFirst) * (fontH + UI_TEXTBOX_LINE_SPACING);
for(int32_t ci = 0; ci < line->count; ci++) {
consumed++;
char_t c = box->text[line->start + ci];
if(c == ' ') continue;
assertTrue(
box->glyphCount < UI_TEXTBOX_PAGE_GLYPHS_MAX,
"Textbox page produces too many glyphs"
);
uitextboxglyph_t *glyph = &box->glyphs[box->glyphCount++];
glyph->sprite = textGetSprite(
(vec2){ (float_t)ci * fontW, lineY }, c, &FONT_DEFAULT
);
glyph->revealAt = consumed;
}
}
box->glyphsBuiltForPage = box->currentPage;
}
int32_t uiTextboxGetPageCharCount(const uitextbox_t *box) {
assertNotNull(box, "Textbox cannot be NULL");
int32_t first = box->currentPage * box->linesPerPage;
int32_t last = first + box->linesPerPage;
if(last > box->lineCount) last = box->lineCount;
int32_t total = 0;
for(int32_t i = first; i < last; i++) {
total += box->lines[i].count;
}
return total;
}
bool_t uiTextboxPageIsComplete(const uitextbox_t *box) {
assertNotNull(box, "Textbox cannot be NULL");
return box->scroll >= uiTextboxGetPageCharCount(box);
}
bool_t uiTextboxHasNextPage(const uitextbox_t *box) {
assertNotNull(box, "Textbox cannot be NULL");
return box->currentPage + 1 < box->pageCount;
}
void uiTextboxNextPage(uitextbox_t *box) {
assertNotNull(box, "Textbox cannot be NULL");
if(!uiTextboxHasNextPage(box)) return;
box->currentPage++;
box->scroll = 0;
}
-175
View File
@@ -1,175 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "ui/frame/uiframe.h"
#include "display/spritebatch/spritebatchsprite.h"
#define UI_TEXTBOX_LINES_PER_PAGE_MAX 4
#define UI_TEXTBOX_SCROLL_CHARS_PER_TICK 1
#define UI_TEXTBOX_LINE_SPACING 0.0f
// Fixed capacity for a page's cached glyph sprites (see uitextboxglyph_t).
// Sized generously above UI_TEXTBOX_LINES_PER_PAGE_MAX worth of glyphs at
// typical textbox widths; uiTextboxBuildPageGlyphs asserts if a page
// somehow produces more than this.
#define UI_TEXTBOX_PAGE_GLYPHS_MAX 512
typedef struct {
int32_t start;
int32_t count;
} uitextboxline_t;
// A single visible glyph's cached sprite (relative to the textbox's
// content origin, i.e. (0,0)) plus the scroll value at/after which it
// becomes visible -- lets uiTextboxDraw turn the typewriter scroll into
// a simple prefix-count instead of recomputing glyph geometry every
// frame.
typedef struct {
spritebatchsprite_t sprite;
int32_t revealAt;
} uitextboxglyph_t;
typedef struct {
char_t *text;
uint32_t maxLength;
uitextboxline_t *lines;
uint32_t linesMax;
int32_t lineCount;
int32_t charsPerLine;
int32_t linesPerPage;
int32_t pageCount;
// last dimensions used for layout; rebuild triggers when these change
float_t layoutWidth;
float_t layoutHeight;
int32_t currentPage;
int32_t scroll;
// Cached glyph sprites for the current page (see uitextboxglyph_t),
// rebuilt only when currentPage no longer matches
// glyphsBuiltForPage -- not every frame/scroll tick.
uitextboxglyph_t glyphs[UI_TEXTBOX_PAGE_GLYPHS_MAX];
int32_t glyphCount;
int32_t glyphsBuiltForPage;
uiframecache_t frameCache;
} uitextbox_t;
/**
* Initializes a textbox, zeroing all state and binding it to caller-owned
* text and line storage.
*
* @param box The textbox to initialize.
* @param text Caller-owned buffer the textbox copies its text into.
* @param maxLength Capacity of text, in characters.
* @param lines Caller-owned buffer the textbox lays lines out into.
* @param linesMax Capacity of lines, in entries.
*/
void uiTextboxInit(
uitextbox_t *box,
char_t *text,
const uint32_t maxLength,
uitextboxline_t *lines,
const uint32_t linesMax
);
/**
* Copies text into the textbox and marks layout as dirty.
* Resets currentPage and scroll to 0.
*
* @param box The textbox to update.
* @param text Null-terminated source string.
*/
void uiTextboxSetText(uitextbox_t *box, const char_t *text);
/**
* Rebuilds word-wrap and page layout for the given draw dimensions.
* Called automatically by uiTextboxDraw when width or height changes.
*
* @param box The textbox to rebuild.
* @param width Available content width in pixels.
* @param height Available content height in pixels.
*/
void uiTextboxBuildLayout(
uitextbox_t *box,
const float_t width,
const float_t height
);
/**
* Advances the typewriter scroll by UI_TEXTBOX_SCROLL_CHARS_PER_TICK.
* Skipped on dynamic ticks.
*
* @param box The textbox to update.
* @returns Any error that occurs.
*/
errorret_t uiTextboxUpdate(uitextbox_t *box);
/**
* Draws the textbox frame and visible text. Rebuilds layout automatically
* if width or height differs from the last draw call.
*
* @param box The textbox to draw.
* @param x Screen x position.
* @param y Screen y position.
* @param width Draw width in pixels.
* @param height Draw height in pixels.
* @returns Any error that occurs.
*/
errorret_t uiTextboxDraw(
uitextbox_t *box,
const float_t x,
const float_t y,
const float_t width,
const float_t height
);
/**
* Returns the total visible char count for the current page.
*
* @param box The textbox to query.
* @returns Total chars on the current page.
*/
int32_t uiTextboxGetPageCharCount(const uitextbox_t *box);
/**
* Returns true when scroll has fully revealed the current page.
*
* @param box The textbox to query.
* @returns True if the current page is fully visible.
*/
bool_t uiTextboxPageIsComplete(const uitextbox_t *box);
/**
* Returns true when there is at least one more page after the current one.
*
* @param box The textbox to query.
* @returns True if a next page exists.
*/
bool_t uiTextboxHasNextPage(const uitextbox_t *box);
/**
* Advances to the next page and resets scroll to 0.
* Has no effect if already on the last page.
*
* @param box The textbox to advance.
*/
void uiTextboxNextPage(uitextbox_t *box);
/**
* Rebuilds the cached glyph sprites (see uitextboxglyph_t) for the
* current page from its line layout. Called automatically by
* uiTextboxDraw whenever currentPage no longer matches the page the
* cache was last built for.
*
* @param box The textbox to rebuild.
*/
void uiTextboxBuildPageGlyphs(uitextbox_t *box);
-121
View File
@@ -1,121 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uitextboxmain.h"
#include "ui/focus/uifocus.h"
#include "display/screen/screen.h"
#include "display/text/text.h"
#include "display/color.h"
#include "display/spritebatch/spritebatch.h"
#include "display/shader/shaderunlit.h"
#include "ui/frame/uiframe.h"
uitextboxmain_t UI_TEXTBOX_MAIN;
static uifocusitem_t *focusItem = NULL;
errorret_t uiTextboxMainInit(void) {
uiTextboxInit(
&UI_TEXTBOX_MAIN.box,
UI_TEXTBOX_MAIN.text, UI_TEXTBOX_MAIN_TEXT_MAX,
UI_TEXTBOX_MAIN.lines, UI_TEXTBOX_MAIN_LINES_MAX
);
errorOk();
}
void uiTextboxMainSetText(const char_t *text) {
uiTextboxSetText(&UI_TEXTBOX_MAIN.box, text);
if(focusItem != NULL) return;
focusItem = uiFocusPush(
1, 1,
uiTextboxMainFocusSelected,
NULL,
uiTextboxMainFocusClosed,
NULL,
NULL
);
}
errorret_t uiTextboxMainUpdate(void) {
if(focusItem == NULL) errorOk();
return uiTextboxUpdate(&UI_TEXTBOX_MAIN.box);
}
errorret_t uiTextboxMainDraw(void) {
if(focusItem == NULL) errorOk();
float_t fontH = (float_t)FONT_DEFAULT.tileset->tileHeight;
float_t h = (float_t)UI_TEXTBOX_MAIN_LINES * fontH +
(float_t)(UI_TEXTBOX_MAIN_LINES - 1) * UI_TEXTBOX_LINE_SPACING +
2.0f * (float_t)UI_FRAME_START_Y;
float_t w = (float_t)SCREEN.scanWidth;
float_t x = (float_t)SCREEN.scanX;
float_t y = (float_t)(SCREEN.scanY + SCREEN.scanHeight) - h;
errorChain(uiTextboxDraw(&UI_TEXTBOX_MAIN.box, x, y, w, h));
if(!uiTextboxPageIsComplete(&UI_TEXTBOX_MAIN.box)) errorOk();
float_t fontW = (float_t)FONT_DEFAULT.tileset->tileWidth;
float_t contentX = x + (float_t)UI_FRAME_START_X;
float_t contentY = y + (float_t)UI_FRAME_START_Y;
float_t contentW = w - 2.0f * (float_t)UI_FRAME_START_X;
float_t contentH = h - 2.0f * (float_t)UI_FRAME_START_Y;
shadermaterial_t material = {
.unlit = {
.color = COLOR_WHITE,
.texture = FONT_DEFAULT.texture
}
};
spritebatchsprite_t caret = textGetSprite(
(vec2){
contentX + contentW - fontW,
contentY + contentH - fontH
},
'v',
&FONT_DEFAULT
);
errorChain(spriteBatchBuffer(&caret, 1, &SHADER_UNLIT, material));
errorOk();
}
bool_t uiTextboxMainPageIsComplete(void) {
return uiTextboxPageIsComplete(&UI_TEXTBOX_MAIN.box);
}
bool_t uiTextboxMainHasNextPage(void) {
return uiTextboxHasNextPage(&UI_TEXTBOX_MAIN.box);
}
void uiTextboxMainNextPage(void) {
uiTextboxNextPage(&UI_TEXTBOX_MAIN.box);
}
bool_t uiTextboxMainIsActive(void) {
return focusItem != NULL;
}
bool_t uiTextboxMainFocusSelected(const uifocusitem_t *item) {
if(!uiTextboxPageIsComplete(&UI_TEXTBOX_MAIN.box)) {
UI_TEXTBOX_MAIN.box.scroll =
uiTextboxGetPageCharCount(&UI_TEXTBOX_MAIN.box);
return true;
}
if(uiTextboxHasNextPage(&UI_TEXTBOX_MAIN.box)) {
uiTextboxNextPage(&UI_TEXTBOX_MAIN.box);
return true;
}
uiFocusPopItem(focusItem);
return true;
}
bool_t uiTextboxMainFocusClosed(const uifocusitem_t *item) {
focusItem = NULL;
return true;
}
@@ -1,94 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "uitextbox.h"
#include "ui/focus/uifocusitem.h"
#define UI_TEXTBOX_MAIN_LINES 4
#define UI_TEXTBOX_MAIN_TEXT_MAX 1024
#define UI_TEXTBOX_MAIN_LINES_MAX 64
typedef struct {
uitextbox_t box;
char_t text[UI_TEXTBOX_MAIN_TEXT_MAX];
uitextboxline_t lines[UI_TEXTBOX_MAIN_LINES_MAX];
} uitextboxmain_t;
extern uitextboxmain_t UI_TEXTBOX_MAIN;
/**
* Initializes UI_TEXTBOX_MAIN.
*
* @returns Any error that occurs.
*/
errorret_t uiTextboxMainInit(void);
/**
* Copies text into UI_TEXTBOX_MAIN and resets page and scroll.
*
* @param text Null-terminated source string.
*/
void uiTextboxMainSetText(const char_t *text);
/**
* Advances the typewriter scroll for UI_TEXTBOX_MAIN.
*
* @returns Any error that occurs.
*/
errorret_t uiTextboxMainUpdate(void);
/**
* Draws UI_TEXTBOX_MAIN full-width at the bottom of the screen.
* Position and size are derived from SCREEN each call.
*
* @returns Any error that occurs.
*/
errorret_t uiTextboxMainDraw(void);
/**
* Returns true when the current page is fully scrolled in.
*
* @returns True if the current page is complete.
*/
bool_t uiTextboxMainPageIsComplete(void);
/**
* Returns true when at least one more page follows the current one.
*
* @returns True if a next page exists.
*/
bool_t uiTextboxMainHasNextPage(void);
/**
* Advances UI_TEXTBOX_MAIN to the next page and resets scroll.
* Has no effect if already on the last page.
*/
void uiTextboxMainNextPage(void);
/**
* Returns true when UI_TEXTBOX_MAIN has focus (is visible and active).
*
* @returns True if the textbox is currently active.
*/
bool_t uiTextboxMainIsActive(void);
/**
* Internal focus callback - skip scroll or advance page or dismiss.
*
* @param item The active focus item.
* @returns True.
*/
bool_t uiTextboxMainFocusSelected(const uifocusitem_t *item);
/**
* Internal focus callback - clears the focus item pointer on dismiss.
*
* @param item The focus item being closed.
* @returns True.
*/
bool_t uiTextboxMainFocusClosed(const uifocusitem_t *item);
-35
View File
@@ -1,35 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uitestlabel.h"
#include "ui/widget/uilabel.h"
#include "display/text/text.h"
#include "display/screen/screen.h"
#include "engine/engine.h"
uilabel_t UI_TEST_LABEL;
errorret_t uiTestLabelInit() {
uiLabelInit(&UI_TEST_LABEL, &FONT_DEFAULT);
uiLabelSetText(&UI_TEST_LABEL, "duskrpg");
errorOk();
}
errorret_t uiTestLabelDraw() {
int32_t versionWidth, versionHeight;
textMeasure(ENGINE.version, &FONT_DEFAULT, &versionWidth, &versionHeight);
errorChain(uiLabelDraw(
&UI_TEST_LABEL,
(float_t)(SCREEN.scanX + SCREEN.scanWidth - UI_TEST_LABEL.width),
(float_t)(
SCREEN.scanY + SCREEN.scanHeight - versionHeight -
UI_TEST_LABEL.height
)
));
errorOk();
}
-24
View File
@@ -1,24 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
/**
* Initializes the test label, a smoke test for the duskrpg-side UI
* element extension point (uielementgame.h).
*
* @return Any error that occurs.
*/
errorret_t uiTestLabelInit();
/**
* Draws the test label above the engine's debug version number.
*
* @return Any error that occurs.
*/
errorret_t uiTestLabelDraw();
-68
View File
@@ -1,68 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "ui/frame/uiframe.h"
static void test_uiFrameBuildSprites_layout(void **state) {
UI_FRAME.tileset = (tileset_t){
.tileWidth = 1,
.tileHeight = 1,
.tileCount = 9,
.columns = 3,
.rows = 3,
.uv = { 0.25f, 0.25f }
};
const float_t x = 100.0f;
const float_t y = 50.0f;
const float_t width = 40.0f;
const float_t height = 30.0f;
const float_t tileW = (float_t)UI_FRAME_BORDER_WIDTH;
const float_t tileH = (float_t)UI_FRAME_BORDER_HEIGHT;
spritebatchsprite_t sprites[9];
uiFrameBuildSprites(sprites, x, y, width, height);
// Top-left corner sits exactly at the rect's origin.
assert_float_equal(sprites[0].min[0], x, 0.0001f);
assert_float_equal(sprites[0].min[1], y, 0.0001f);
assert_float_equal(sprites[0].max[0], x + tileW, 0.0001f);
assert_float_equal(sprites[0].max[1], y + tileH, 0.0001f);
assert_float_equal(sprites[0].uvMin[0], 0.0f, 0.0001f);
assert_float_equal(sprites[0].uvMin[1], 0.0f, 0.0001f);
assert_float_equal(sprites[0].uvMax[0], 0.25f, 0.0001f);
assert_float_equal(sprites[0].uvMax[1], 0.25f, 0.0001f);
// Top-middle edge stretches to fill the width minus both corners.
assert_float_equal(sprites[1].min[0], x + tileW, 0.0001f);
assert_float_equal(sprites[1].max[0], x + width - tileW, 0.0001f);
assert_float_equal(sprites[1].max[1], y + tileH, 0.0001f);
// Center tile fills the remaining interior on both axes.
assert_float_equal(sprites[4].min[0], x + tileW, 0.0001f);
assert_float_equal(sprites[4].min[1], y + tileH, 0.0001f);
assert_float_equal(sprites[4].max[0], x + width - tileW, 0.0001f);
assert_float_equal(sprites[4].max[1], y + height - tileH, 0.0001f);
// Bottom-right corner sits exactly at the rect's far corner, sampling
// tileset column 2, row 2.
assert_float_equal(sprites[8].min[0], x + width - tileW, 0.0001f);
assert_float_equal(sprites[8].min[1], y + height - tileH, 0.0001f);
assert_float_equal(sprites[8].max[0], x + width, 0.0001f);
assert_float_equal(sprites[8].max[1], y + height, 0.0001f);
assert_float_equal(sprites[8].uvMin[0], 0.5f, 0.0001f);
assert_float_equal(sprites[8].uvMin[1], 0.5f, 0.0001f);
}
int main(int argc, char **argv) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_uiFrameBuildSprites_layout),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
-114
View File
@@ -1,114 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "ui/widget/uislider.h"
#include "util/memory.h"
// uiSliderInitFloat/InitInt require a real font (FONT_DEFAULT) to build
// the label's glyph cache, which needs a live GL context this test binary
// doesn't have -- see the label size caching contract for why this is
// safe to bypass here (uiSliderRebuildGeometry only reads slider->label's
// already-measured width/height, it never touches the font itself).
static void sliderSetupFloat(
uislider_t *slider,
const float_t value,
const float_t min,
const float_t max,
const float_t step
) {
memoryZero(slider, sizeof(uislider_t));
slider->label.width = 20;
slider->label.height = 10;
slider->type = UI_SLIDER_TYPE_FLOAT;
slider->min.f = min;
slider->max.f = max;
slider->step.f = step;
slider->value.f = value;
}
static void sliderSetupInt(
uislider_t *slider,
const int32_t value,
const int32_t min,
const int32_t max,
const int32_t step
) {
memoryZero(slider, sizeof(uislider_t));
slider->label.width = 20;
slider->label.height = 10;
slider->type = UI_SLIDER_TYPE_INT;
slider->min.i = min;
slider->max.i = max;
slider->step.i = step;
slider->value.i = value;
}
static void test_uiSliderRebuildGeometry_trackPosition(void **state) {
uislider_t slider;
sliderSetupFloat(&slider, 0.0f, 0.0f, 1.0f, 0.1f);
uiSliderRebuildGeometry(&slider);
const float_t expectedX = (float_t)slider.label.width + UI_SLIDER_GAP;
const float_t expectedY =
((float_t)slider.label.height - UI_SLIDER_TRACK_HEIGHT) * 0.5f;
assert_float_equal(slider.cachedTrack.min[0], expectedX, 0.0001f);
assert_float_equal(slider.cachedTrack.min[1], expectedY, 0.0001f);
assert_float_equal(
slider.cachedTrack.max[0], expectedX + UI_SLIDER_TRACK_WIDTH, 0.0001f
);
}
static void test_uiSliderRebuildGeometry_fillVisibility(void **state) {
uislider_t slider;
// At the minimum value, the ratio is 0 -- no fill should be visible.
sliderSetupFloat(&slider, 0.0f, 0.0f, 10.0f, 1.0f);
uiSliderRebuildGeometry(&slider);
assert_false(slider.cachedFillVisible);
// Halfway to max -- fill should cover half the track.
sliderSetupFloat(&slider, 5.0f, 0.0f, 10.0f, 1.0f);
uiSliderRebuildGeometry(&slider);
assert_true(slider.cachedFillVisible);
assert_float_equal(
slider.cachedFill.max[0] - slider.cachedFill.min[0],
UI_SLIDER_TRACK_WIDTH * 0.5f,
0.0001f
);
}
static void test_uiSliderRebuildGeometry_stepMarkers(void **state) {
uislider_t slider;
// 5 steps between 0 and 10 -- under the marker cap, so markers render.
sliderSetupInt(&slider, 0, 0, 10, 2);
uiSliderRebuildGeometry(&slider);
assert_int_equal(slider.cachedMarkerCount, 6);
assert_float_equal(
slider.cachedMarkers[0].min[0], slider.cachedTrack.min[0] -
UI_SLIDER_STEP_MARKER_WIDTH * 0.5f,
0.0001f
);
// 100 steps -- over the marker cap, so it falls back to a smooth fill
// with no markers.
sliderSetupInt(&slider, 0, 0, 100, 1);
uiSliderRebuildGeometry(&slider);
assert_int_equal(slider.cachedMarkerCount, 0);
}
int main(int argc, char **argv) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_uiSliderRebuildGeometry_trackPosition),
cmocka_unit_test(test_uiSliderRebuildGeometry_fillVisibility),
cmocka_unit_test(test_uiSliderRebuildGeometry_stepMarkers),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
-36
View File
@@ -1,36 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "ui/widget/uitab.h"
#include "util/memory.h"
// uiTabInit requires a real font (FONT_DEFAULT) to build the label's
// glyph cache, which needs a live GL context this test binary doesn't
// have. uiTabRebuildBackground only reads the label's already-measured
// width/height, so it's safe to set those directly and exercise it here.
static void test_uiTabRebuildBackground_matchesLabelSize(void **state) {
uitab_t tab;
memoryZero(&tab, sizeof(uitab_t));
tab.label.width = 42;
tab.label.height = 12;
uiTabRebuildBackground(&tab);
assert_float_equal(tab.cachedBackground.min[0], 0.0f, 0.0001f);
assert_float_equal(tab.cachedBackground.min[1], 0.0f, 0.0001f);
assert_float_equal(tab.cachedBackground.max[0], 42.0f, 0.0001f);
assert_float_equal(tab.cachedBackground.max[1], 12.0f, 0.0001f);
}
int main(int argc, char **argv) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_uiTabRebuildBackground_matchesLabelSize),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+20
View File
@@ -0,0 +1,20 @@
// Entry point, run once by the engine after every other system is ready.
var camera = entityAdd();
var cameraPosition = entityAddComponent(camera, COMPONENT_TYPE_POSITION);
entityAddComponent(camera, COMPONENT_TYPE_CAMERA);
entityPositionLookAt(camera, cameraPosition, 3, 3, 3, 0, 0, 0);
var cube = entityAdd();
var cubePosition = entityAddComponent(cube, COMPONENT_TYPE_POSITION);
var cubeRenderable = entityAddComponent(cube, COMPONENT_TYPE_RENDERABLE);
entityRenderableSetMesh(cube, cubeRenderable, "cube");
entityPositionSetLocalPosition(cube, cubePosition, 0, 0, 0);
var plane = entityAdd();
var planePosition = entityAddComponent(plane, COMPONENT_TYPE_POSITION);
var planeRenderable = entityAddComponent(plane, COMPONENT_TYPE_RENDERABLE);
entityRenderableSetMesh(plane, planeRenderable, "plane");
entityPositionSetLocalPosition(plane, planePosition, 0, 0, 0);
consolePrint("Camera, cube and plane created");
+224 -1
View File
@@ -5,11 +5,113 @@ 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.corrupt"
msgstr "Corrupted Save"
msgid "ui.select_save.corrupt_warning"
msgstr "This save file is corrupted and could not be loaded. Please contact support if this issue persists."
msgid "ui.select_save.slot_format"
msgstr "%s Lv.%d %s"
msgid "ui.select_save.delete_mode"
msgstr "Delete a Save"
msgid "ui.select_save.delete_confirm"
msgstr "Are you sure you want to delete this save?"
msgid "ui.select_save.name_title"
msgstr "Enter game save file name"
msgid "ui.save_slot.number_format"
msgstr "Slot %d"
#: ui/menu.c:10 #: ui/menu.c:10
msgid "ui.title" msgid "ui.title"
msgstr "" msgstr ""
"Welcome" "Welcome"
msgid "save.linux.mkdirp_failed"
msgstr "Failed to create save directory, check the disk is not full or write-protected."
#: src/dusk/ui/frame/settings/uisettings.c #: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.general" msgid "ui.settings.tabs.general"
msgstr "General" msgstr "General"
@@ -56,6 +158,118 @@ msgstr "Items"
msgid "ui.game_menu.settings" msgid "ui.game_menu.settings"
msgstr "Settings" msgstr "Settings"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save"
msgstr "Save"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_success"
msgstr "Game saved."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_cancelled"
msgstr "Save cancelled."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_unavailable"
msgstr "Can't save - no save device found."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_temporary"
msgstr "This session is temporary - no save device was found, so saving is disabled."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_create_confirm"
msgstr "No save data found. Create a new save?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_failed_format"
msgstr "Save failed: %s"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_check_failed_format"
msgstr "Can't save: %s"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.message"
msgstr "No save device found. You can continue, but\nprogress will not be saved."
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.retry"
msgstr "Retry"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.continue"
msgstr "Continue Anyway"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.message"
msgstr "No save data found. Create a new save?"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.yes"
msgstr "Yes"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.no"
msgstr "No"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.confirm"
msgstr "Confirm"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.cancel"
msgstr "Cancel"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.attack"
msgstr "Attack"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.flee"
msgstr "Flee"
#: src/dusk/ui/frame/battle/uibattlemenu.c
msgid "ui.battle.menu.target_format"
msgstr "Enemy %u (%u/%u HP)"
#: src/dusk/ui/frame/battle/uibattlehud.c
msgid "ui.battle.hud.hp_format"
msgstr "HP %u/%u"
#: src/dusk/ui/frame/battle/uibattlehud.c
msgid "ui.battle.hud.mp_format"
msgstr "MP %u/%u"
#: src/dusk/ui/frame/settings/uisettingsaudio.c
msgid "ui.settings.audio.placeholder"
msgstr "No audio settings yet"
#: src/dusk/ui/frame/settings/uisettingsdisplay.c
msgid "ui.settings.display.placeholder"
msgstr "No display settings yet"
#: src/dusk/ui/frame/settings/uisettingsinput.c
msgid "ui.settings.input.placeholder"
msgstr "No input settings yet"
#: src/dusk/ui/frame/backpack/uibackpack.c
msgid "ui.backpack.category_format"
msgstr "Category %u"
#: src/dusk/ui/overlay/uiloading.c
msgid "ui.loading.text"
msgstr "loading"
#: src/dusk/ui/overlay/uiautosave.c
msgid "ui.autosave.saving"
msgstr "SAVING"
#
# Items
#
msgid "item.potion.name" msgid "item.potion.name"
msgstr "Potion" msgstr "Potion"
@@ -63,4 +277,13 @@ msgid "item.potato.name"
msgstr "Potato" msgstr "Potato"
msgid "item.apple.name" msgid "item.apple.name"
msgstr "Apple" msgstr "Apple"
#
# Moves
#
msgid "move.fire.name"
msgstr "Fire"
msgid "move.slash.name"
msgstr "Slash"
-70
View File
@@ -1,70 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: ExampleApp 1.0\n"
"Language: es\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n==1 ? 0 : 1);\n"
#: ui/menu.c:10
msgid "ui.title"
msgstr ""
"Bienvenido"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.general"
msgstr "General"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.input"
msgstr "Entrada"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.display"
msgstr "Pantalla"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.audio"
msgstr "Audio"
msgid "ui.settings.input.deadzone"
msgstr "Deadzone"
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "ui.settings.general.language"
msgstr "Idioma"
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "ui.settings.general.language_detail"
msgstr "Se aplica después de reiniciar la aplicación."
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.apply"
msgstr "Aplicar"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.discard_changes"
msgstr "¿Descartar los cambios no guardados?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.characters"
msgstr "Personajes"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.items"
msgstr "Objetos"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.settings"
msgstr "Configuración"
#: src/dusk/rpg/item/item.json
msgid "item.potion.name"
msgstr "Poción"
#: src/dusk/rpg/item/item.json
msgid "item.potato.name"
msgstr "Papa"
#: src/dusk/rpg/item/item.json
msgid "item.apple.name"
msgstr "Manzana"
-70
View File
@@ -1,70 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: ExampleApp 1.0\n"
"Language: ja\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=1; plural=(0);\n"
#: ui/menu.c:10
msgid "ui.title"
msgstr ""
"歓迎"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.general"
msgstr "一般"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.input"
msgstr "入力"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.display"
msgstr "表示"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.audio"
msgstr "オーディオ"
msgid "ui.settings.input.deadzone"
msgstr "デッドゾーン"
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "ui.settings.general.language"
msgstr "言語"
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "ui.settings.general.language_detail"
msgstr "アプリケーションを再起動すると適用されます。"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.apply"
msgstr "適用"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.discard_changes"
msgstr "未保存の変更を破棄しますか?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.characters"
msgstr "キャラクター"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.items"
msgstr "アイテム"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.settings"
msgstr "設定"
#: src/dusk/rpg/item/item.json
msgid "item.potion.name"
msgstr "ポーション"
#: src/dusk/rpg/item/item.json
msgid "item.potato.name"
msgstr "ジャガイモ"
#: src/dusk/rpg/item/item.json
msgid "item.apple.name"
msgstr "リンゴ"
-49
View File
@@ -1,49 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Gamepad/keyboard-only for now -- no mouse support exists in the input
// system yet. Something else (e.g. a future Menu widget) is responsible
// for moving `highlighted` between multiple buttons; a Button only knows
// how to fire onSelect() when ACCEPT is pressed while it's the highlighted
// one.
class Button extends UIElement {
init() {
this.background = new Rectangle();
this.background.width = 120;
this.background.height = 32;
this.background.color = Button.COLOR_NORMAL;
this.add(this.background);
this.label = new Label();
this.label.x = 8;
this.label.y = 8;
this.add(this.label);
this.highlighted = false;
this.onSelect = null;
}
get text() { return this.label.text; }
set text(value) { this.label.text = value; }
setHighlighted(highlighted) {
this.highlighted = highlighted;
this.background.color = highlighted
? Button.COLOR_HIGHLIGHTED
: Button.COLOR_NORMAL;
}
update() {
this.updateChildren();
if(this.highlighted && Input.pressed(InputBind.ACCEPT) && this.onSelect) {
this.onSelect();
}
}
}
Button.COLOR_NORMAL = { r: 255, g: 255, b: 255, a: 255 };
Button.COLOR_HIGHLIGHTED = { r: 255, g: 0, b: 0, a: 255 };
module.exports = Button;
-61
View File
@@ -1,61 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// A single label showing "Y "/"N " + the caller's text -- no background,
// matching the old widget exactly. Toggling itself (checked) is driven
// externally, typically by a Menu handing it LEFT/RIGHT via
// directionInput() while this checkbox is the highlighted item.
class Checkbox extends UIElement {
init() {
this.label = new Label();
this.add(this.label);
this._text = '';
this._checked = false;
this.highlighted = false;
this._refresh();
}
get text() { return this._text; }
set text(value) {
this._text = value;
this._refresh();
}
get checked() { return this._checked; }
set checked(value) {
this._checked = value;
this._refresh();
}
toggle() {
this.checked = !this.checked;
}
setHighlighted(highlighted) {
this.highlighted = highlighted;
this.label.color = highlighted
? Checkbox.COLOR_HIGHLIGHTED
: Checkbox.COLOR_NORMAL;
}
// Menu direction-input opt-out hook: LEFT/RIGHT toggles and consumes
// the input; UP/DOWN falls through to default cursor movement.
directionInput(dx, dy) {
if(dx === 0) return false;
this.toggle();
return true;
}
_refresh() {
this.label.text = (this._checked ? 'Y ' : 'N ') + this._text;
}
}
Checkbox.COLOR_NORMAL = { r: 255, g: 255, b: 255, a: 255 };
Checkbox.COLOR_HIGHLIGHTED = { r: 255, g: 0, b: 0, a: 255 };
module.exports = Checkbox;
-110
View File
@@ -1,110 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
var Menu = require('./Menu.js');
var Button = require('./Button.js');
// A Yes/No dialog: dim full-screen backdrop + a flat-color panel (see
// Fullbox.js/plan notes on why there's no real 9-slice Frame yet) +
// text + a 2-item Menu. Confirm.open(text, callback) lazily creates and
// reuses ONE shared instance rather than allocating fresh every time --
// the Menu stack already guarantees at most one confirm is ever
// meaningfully active, so there's nothing to gain from a fresh instance
// per call, and it sidesteps ever needing to dispose one (see Fullbox.js
// for why disposing from inside your own callback chain is risky).
class Confirm extends UIElement {
init() {
this.backdrop = new Rectangle();
this.backdrop.color = Confirm.COLOR_BACKDROP;
this.add(this.backdrop);
this.panel = new Rectangle();
this.panel.width = Confirm.PANEL_WIDTH;
this.panel.height = Confirm.PANEL_HEIGHT;
this.panel.color = Confirm.COLOR_PANEL;
this.add(this.panel);
this.label = new Label();
this.add(this.label);
this.menu = new Menu();
this.menu.cols = 2;
this.add(this.menu);
this.confirmButton = new Button();
this.confirmButton.text = 'Confirm';
this.menu.addItem(this.confirmButton);
this.cancelButton = new Button();
this.cancelButton.text = 'Cancel';
this.menu.addItem(this.cancelButton);
this.menu.onSelected = (index) => this._finish(index === 0);
// CANCEL (the bind, not the button) also closes the menu without
// firing onSelected -- treat that the same as picking Cancel.
this.menu.onClosed = () => this._finish(false);
this._callback = null;
this._finishing = false;
}
open(text, callback) {
this.label.text = text;
this._callback = callback;
this._finishing = false;
this._layout();
UI.add(this);
this.menu.open();
}
_finish(result) {
// menu.onClosed calling back into _finish (via close() below) would
// otherwise recurse into itself.
if(this._finishing) return;
this._finishing = true;
this.menu.close();
UI.remove(this);
const callback = this._callback;
this._callback = null;
if(callback) callback(result);
}
_layout() {
this.backdrop.width = Screen.width;
this.backdrop.height = Screen.height;
const panelX = (Screen.width - Confirm.PANEL_WIDTH) / 2;
const panelY = (Screen.height - Confirm.PANEL_HEIGHT) / 2;
this.panel.x = panelX;
this.panel.y = panelY;
this.label.x = panelX + Confirm.PADDING;
this.label.y = panelY + Confirm.PADDING;
this.confirmButton.x = panelX + Confirm.PADDING;
this.confirmButton.y = panelY + Confirm.PANEL_HEIGHT - Confirm.PADDING - 32;
this.cancelButton.x = this.confirmButton.x + 130;
this.cancelButton.y = this.confirmButton.y;
}
}
Confirm.PANEL_WIDTH = 300;
Confirm.PANEL_HEIGHT = 120;
Confirm.PADDING = 16;
Confirm.COLOR_BACKDROP = { r: 0, g: 0, b: 0, a: 160 };
Confirm.COLOR_PANEL = { r: 40, g: 40, b: 40, a: 255 };
Confirm.open = function(text, callback) {
if(!Confirm._shared) Confirm._shared = new Confirm();
Confirm._shared.open(text, callback);
return Confirm._shared;
};
module.exports = Confirm;
-60
View File
@@ -1,60 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// One Label per console history slot, refreshed only when
// Console.consumeDirty() says the history actually changed -- reuses
// the normal Label/spritebatch render path instead of the old
// hand-rolled mesh-cache renderer.
//
// These Labels are NOT add()ed as children -- UIElement.add() caps at 8
// children per element, and CONSOLE_HISTORY_MAX (Console.lineCount) is
// 16, over that cap. Instead they're driven directly: render()/update()
// called explicitly below, and their world position is just their own
// x/y (never having a parent means nothing offsets it).
class ConsoleOverlay extends UIElement {
init() {
this.lines = [];
for(let i = 0; i < Console.lineCount; i++) {
const label = new Label();
label.x = 4;
label.y = 4 + i * ConsoleOverlay.LINE_HEIGHT;
this.lines.push(label);
}
this._refresh();
}
update() {
this.updateChildren();
if(Console.consumeDirty()) this._refresh();
for(let i = 0; i < this.lines.length; i++) this.lines[i].update();
}
// Overridden (rather than left to auto-render) so the console can be
// hidden entirely without disposing/rebuilding its Labels.
render() {
this.renderChildren();
if(!Console.visible) return;
for(let i = 0; i < this.lines.length; i++) this.lines[i].render();
}
// this.lines was never add()ed, so the normal dispose() cascade can't
// reach them -- release them explicitly first, or they'd leak their
// pool slots.
dispose() {
for(let i = 0; i < this.lines.length; i++) this.lines[i].dispose();
super.dispose();
}
_refresh() {
for(let i = 0; i < this.lines.length; i++) {
this.lines[i].text = Console.getLine(i);
}
}
}
ConsoleOverlay.LINE_HEIGHT = 12;
module.exports = ConsoleOverlay;
-64
View File
@@ -1,64 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Up to 4 black letterbox/pillarbox bars sized from Screen.scanX/Y/
// scanWidth/scanHeight vs Screen.width/height. Recomputed every tick,
// unconditionally -- cheap (four rectangle field writes), and a no-op
// when the scan area already covers the full viewport (Rectangle
// already skips rendering at width/height <= 0).
class Crop extends UIElement {
init() {
this.top = new Rectangle();
this.bottom = new Rectangle();
this.left = new Rectangle();
this.right = new Rectangle();
this.top.color = Crop.COLOR_BARS;
this.bottom.color = Crop.COLOR_BARS;
this.left.color = Crop.COLOR_BARS;
this.right.color = Crop.COLOR_BARS;
this.add(this.top);
this.add(this.bottom);
this.add(this.left);
this.add(this.right);
}
update() {
this.updateChildren();
this._refresh();
}
_refresh() {
const scanX = Screen.scanX;
const scanY = Screen.scanY;
const scanRight = scanX + Screen.scanWidth;
const scanBottom = scanY + Screen.scanHeight;
this.top.x = 0;
this.top.y = 0;
this.top.width = Screen.width;
this.top.height = scanY;
this.bottom.x = 0;
this.bottom.y = scanBottom;
this.bottom.width = Screen.width;
this.bottom.height = Screen.height - scanBottom;
this.left.x = 0;
this.left.y = scanY;
this.left.width = scanX;
this.left.height = Screen.scanHeight;
this.right.x = scanRight;
this.right.y = scanY;
this.right.width = Screen.width - scanRight;
this.right.height = Screen.scanHeight;
}
}
Crop.COLOR_BARS = { r: 0, g: 0, b: 0, a: 255 };
module.exports = Crop;
-68
View File
@@ -1,68 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Label + a "< option >"-style value label, cycling a caller-provided
// options array. Same directionInput()-consumes-LEFT/RIGHT shape as
// Slider, stepping the selected index (with wraparound) instead of a
// numeric value.
class Dropdown extends UIElement {
init() {
this.label = new Label();
this.add(this.label);
this.valueLabel = new Label();
this.valueLabel.x = 100;
this.add(this.valueLabel);
this.options = [];
this._index = 0;
this.highlighted = false;
this.onChange = null;
this._refresh();
}
get text() { return this.label.text; }
set text(value) { this.label.text = value; }
get selectedIndex() { return this._index; }
set selectedIndex(index) {
if(this.options.length === 0) {
this._index = 0;
this._refresh();
return;
}
const count = this.options.length;
this._index = ((index % count) + count) % count;
this._refresh();
if(this.onChange) this.onChange(this._index, this.options[this._index]);
}
get selected() { return this.options[this._index]; }
setHighlighted(highlighted) {
this.highlighted = highlighted;
this.label.color = highlighted
? Dropdown.COLOR_HIGHLIGHTED
: Dropdown.COLOR_NORMAL;
}
directionInput(dx, dy) {
if(dx === 0) return false;
this.selectedIndex = this._index + dx;
return true;
}
_refresh() {
const value = this.options[this._index];
this.valueLabel.text = value !== undefined ? ('< ' + value + ' >') : '< >';
}
}
Dropdown.COLOR_NORMAL = { r: 255, g: 255, b: 255, a: 255 };
Dropdown.COLOR_HIGHLIGHTED = { r: 255, g: 0, b: 0, a: 255 };
module.exports = Dropdown;
-43
View File
@@ -1,43 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// extends UIElement (not Label) deliberately: only a UIElement/SCRIPTED
// instance's render()/update() override actually gets dispatched by the
// engine (the native Label/Rectangle callback tables never consult a
// subclass's override -- they're not scripted types themselves). So this
// composes a child Label instead of subclassing one, same as Button.
//
// Overrides render() rather than update(): update()/uiUpdate() only run
// on the one fixed-timestep tick per real frame that actually crosses a
// DUSK_TIME_STEP boundary (see Time.renderDelta's doc), but an FPS
// counter needs to sample every real frame -- which is exactly what
// render() does.
class FpsCounter extends UIElement {
init() {
this.label = new Label();
this.add(this.label);
this._average = 0;
}
render() {
const delta = Time.renderDelta;
if(delta > 0) {
const fps = 1 / delta;
this._average = this._average === 0
? fps
: this._average + (fps - this._average) * FpsCounter.SMOOTHING;
}
this.label.text = 'FPS: ' + Math.round(this._average);
this.renderChildren();
}
}
// Exponential-moving-average weight per sample -- lower is smoother/
// slower to react, higher tracks instantaneous frame time more closely.
FpsCounter.SMOOTHING = 0.1;
module.exports = FpsCounter;
-98
View File
@@ -1,98 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// A tweened full-screen color quad -- covers both the old uifullbox
// (full-screen fade) and uitransition (fade transition with a
// completion callback): same shape, just parameterized, so there's no
// separate transition class/global.
//
// extends UIElement composing a child Rectangle (not extends Rectangle)
// for the same reason as FpsCounter: only a UIElement/SCRIPTED
// instance's render() override is actually dispatched by the engine.
// Overrides render() (not update()) so the tween advances every real
// frame, not just on fixed-timestep ticks.
//
// A finished Fullbox is only removed from the overlay roots, never
// disposed -- disposing an element from inside its own render()
// callback would free its JS instance registration while a native C
// caller further up the same call stack still holds it, risking the
// exact kind of delayed JerryScript refcount corruption this project
// has already hit once. Fullbox.under()/over() lazily create and cache
// one shared, reusable instance each (mirroring the old UNDER/OVER
// singletons), so repeated fades cost zero additional pool slots.
class Fullbox extends UIElement {
init() {
this.rect = new Rectangle();
this.add(this.rect);
this._from = Fullbox.COLOR_TRANSPARENT;
this._to = Fullbox.COLOR_TRANSPARENT;
this._duration = 0;
this._t = 0;
this._onComplete = null;
this._active = false;
}
// Starts (or restarts) this Fullbox tweening rect.color from
// options.fromColor to options.toColor over options.duration seconds,
// calling options.onComplete() once when done. Adds itself to the
// overlay roots if it isn't already there.
play(options) {
this._from = options.fromColor || Fullbox.COLOR_TRANSPARENT;
this._to = options.toColor || Fullbox.COLOR_TRANSPARENT;
this._duration = options.duration || 0;
this._t = 0;
this._onComplete = options.onComplete || null;
this._active = true;
this.rect.width = Screen.width;
this.rect.height = Screen.height;
this.rect.color = this._from;
UI.addOverlay(this);
}
render() {
if(this._active) {
this._t += Time.renderDelta;
const ratio = this._duration > 0
? Math.min(1, this._t / this._duration)
: 1;
this.rect.color = Fullbox._lerpColor(this._from, this._to, ratio);
if(ratio >= 1) {
this._active = false;
UI.removeOverlay(this);
if(this._onComplete) this._onComplete();
}
}
this.renderChildren();
}
}
Fullbox.COLOR_TRANSPARENT = { r: 0, g: 0, b: 0, a: 0 };
Fullbox._lerpColor = function(from, to, t) {
return {
r: from.r + (to.r - from.r) * t,
g: from.g + (to.g - from.g) * t,
b: from.b + (to.b - from.b) * t,
a: from.a + (to.a - from.a) * t
};
};
Fullbox.under = function() {
if(!Fullbox._under) Fullbox._under = new Fullbox();
return Fullbox._under;
};
Fullbox.over = function() {
if(!Fullbox._over) Fullbox._over = new Fullbox();
return Fullbox._over;
};
module.exports = Fullbox;
-187
View File
@@ -1,187 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Gamepad/keyboard focus-navigation, replacing the old global uifocus_t
// stack. A Menu's children still render/update through the normal tree
// (add()), but navigation uses its own `items` list -- addItem() calls
// add() and also tracks the widget as focusable, so decorative elements
// (a title Label, a Rectangle background) just use add() directly and
// never enter navigation.
//
// open()/close() push/pop this Menu on a small global stack -- only the
// topmost entry consumes CANCEL/ACCEPT/direction input each tick, so a
// Menu opened on top of another (e.g. a Settings sub-page menu, or a
// Confirm dialog) doesn't fight it for input; everything underneath
// keeps rendering/updating, just doesn't react to input until it's
// topmost again.
class Menu extends UIElement {
init() {
this.items = [];
this.cols = 1;
this.index = 0;
this.onSelected = null;
this.onChanged = null;
this.onClosed = null;
this._heldDir = null;
this._heldTime = 0;
}
addItem(widget) {
this.add(widget);
this.items.push(widget);
return widget;
}
open() {
if(Menu._stack.indexOf(this) !== -1) return;
Menu._stack.push(this);
const item = this.items[this.index];
if(item && item.setHighlighted) item.setHighlighted(true);
}
// Closes this Menu and anything opened on top of it, matching the old
// uifocus_t's uiFocusPopItem (popping a mid-stack item also pops
// everything above it).
close() {
const idx = Menu._stack.indexOf(this);
if(idx === -1) return;
while(Menu._stack.length > idx) {
Menu._stack.pop()._onClosed();
}
}
// Safety net: dispose() has no override-preserving trampoline the way
// render()/update() do (see UIElement's dispose() docs), so a bare
// this.menu.dispose() would leave a disposed instance stuck at the top
// of Menu._stack forever if the caller forgot to close() first --
// always close() (a no-op if never opened) before the native cascade.
dispose() {
this.close();
super.dispose();
}
_onClosed() {
for(let i = 0; i < this.items.length; i++) {
const item = this.items[i];
if(item.setHighlighted) item.setHighlighted(false);
}
if(this.onClosed) this.onClosed();
}
update() {
this.updateChildren();
if(Menu.top() !== this) return;
if(Input.pressed(InputBind.CANCEL)) {
this.close();
return;
}
if(Input.pressed(InputBind.ACCEPT) && this.onSelected) {
this.onSelected(this.index, this.items[this.index]);
}
this._handleDirection();
}
_handleDirection() {
const dir = this._currentDirection();
if(!dir) {
this._heldDir = null;
this._heldTime = 0;
return;
}
if(dir !== this._heldDir) {
this._heldDir = dir;
this._heldTime = 0;
this._moveDirection(dir.dx, dir.dy);
return;
}
this._heldTime += Time.delta;
if(this._heldTime >= Menu.HOLD_DELAY) {
this._heldTime -= Menu.HOLD_REPEAT;
this._moveDirection(dir.dx, dir.dy);
}
}
// Identity-stable direction markers so _handleDirection's `dir !==
// this._heldDir` check works without allocating a fresh object (and
// therefore never matching) every tick.
_currentDirection() {
if(Input.isDown(InputBind.LEFT)) return Menu.DIR_LEFT;
if(Input.isDown(InputBind.RIGHT)) return Menu.DIR_RIGHT;
if(Input.isDown(InputBind.UP)) return Menu.DIR_UP;
if(Input.isDown(InputBind.DOWN)) return Menu.DIR_DOWN;
return null;
}
// Gives the currently-highlighted item first refusal (a Slider/
// Dropdown/Checkbox consuming LEFT/RIGHT itself), falling back to
// moving the cursor -- mirrors uifocus_t's per-item `direction`
// callback.
_moveDirection(dx, dy) {
if(this.items.length === 0) return;
const item = this.items[this.index];
if(item && typeof item.directionInput === 'function') {
if(item.directionInput(dx, dy)) return;
}
this._moveIndex(dx, dy);
}
_moveIndex(dx, dy) {
const count = this.items.length;
if(count === 0) return;
const cols = Math.max(1, this.cols);
const rows = Math.ceil(count / cols);
const col = ((this.index % cols) + dx + cols) % cols;
const row = ((Math.floor(this.index / cols)) + dy + rows) % rows;
let next = row * cols + col;
if(next >= count) next = count - 1;
this._setIndex(next);
}
_setIndex(next) {
if(next === this.index) return;
const oldItem = this.items[this.index];
if(oldItem && oldItem.setHighlighted) oldItem.setHighlighted(false);
this.index = next;
const newItem = this.items[this.index];
if(newItem && newItem.setHighlighted) newItem.setHighlighted(true);
if(this.onChanged) this.onChanged(this.index, newItem);
}
}
Menu._stack = [];
Menu.top = function() {
return Menu._stack.length > 0 ? Menu._stack[Menu._stack.length - 1] : null;
};
Menu.HOLD_DELAY = 0.5;
Menu.HOLD_REPEAT = 0.1;
Menu.DIR_LEFT = { dx: -1, dy: 0 };
Menu.DIR_RIGHT = { dx: 1, dy: 0 };
Menu.DIR_UP = { dx: 0, dy: -1 };
Menu.DIR_DOWN = { dx: 0, dy: 1 };
module.exports = Menu;
-52
View File
@@ -1,52 +0,0 @@
var Player = require('./Player.js');
var PlayerCamera = require('./PlayerCamera.js');
var TestPlane = require('./TestPlane.js');
var TestUIElement = require('./TestUIElement.js');
var FpsCounter = require('./FpsCounter.js');
var ConsoleOverlay = require('./ConsoleOverlay.js');
class OverworldScene {
constructor() {
}
init() {
this.player = new Player();
this.plane = new TestPlane();
this.camera = new PlayerCamera(this.player);
this.testUi = new TestUIElement();
UI.add(this.testUi);
this.fpsCounter = new FpsCounter();
UI.addOverlay(this.fpsCounter);
this.consoleOverlay = new ConsoleOverlay();
UI.addOverlay(this.consoleOverlay);
}
lateUpdate() {
this.camera.update();
}
dispose() {
this.camera.dispose();
this.player.dispose();
this.plane.dispose();
// testUi.dispose() cascades to its background/label children and
// removes itself from UI's render roots -- no separate UI.remove()
// needed.
this.testUi.dispose();
this.fpsCounter.dispose();
this.consoleOverlay.dispose();
this.camera = null;
this.player = null;
this.plane = null;
this.testUi = null;
this.fpsCounter = null;
this.consoleOverlay = null;
}
}
module.exports = OverworldScene;
-28
View File
@@ -1,28 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
class Player extends Entity {
constructor() {
super();
this.position = this.add(POSITION);
this.position.setLocalPosition(0.0, 2.0, 0.0);
this.physics = this.add(PHYSICS);
this.physics.setShape(PHYSICS_SHAPE_CAPSULE, 0.5, 0.5);
this.renderable = this.add(RENDERABLE);
this.renderable.setMesh(0, MESH_CAPSULE);
this.renderable.setColor(0, 0, 255, 255);
this.add(PLAYER);
}
dispose() {
}
}
module.exports = Player;
-41
View File
@@ -1,41 +0,0 @@
var CAMERA_OFFSET_ANGLE = 0.0;
var CAMERA_OFFSET_RADIUS = 18.0;
var CAMERA_OFFSET_HEIGHT = 10.0;
class PlayerCamera extends Entity {
constructor(target) {
super();
this.target = target;
this.position = this.add(POSITION);
this.add(CAMERA);
this.update();
}
update() {
var targetPosition = this.target.position.getLocalPosition();
var eyeX = (
targetPosition.x + Math.cos(CAMERA_OFFSET_ANGLE) * CAMERA_OFFSET_RADIUS
);
var eyeY = (
targetPosition.y + CAMERA_OFFSET_HEIGHT
);
var eyeZ = (
targetPosition.z + Math.sin(CAMERA_OFFSET_ANGLE) * CAMERA_OFFSET_RADIUS
);
this.position.lookAt(
eyeX, eyeY, eyeZ,
targetPosition.x, targetPosition.y, targetPosition.z,
0.0, 1.0, 0.0
);
}
dispose() {
this.target = null;
}
}
module.exports = PlayerCamera;
-96
View File
@@ -1,96 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
var Menu = require('./Menu.js');
var Tab = require('./Tab.js');
var Confirm = require('./Confirm.js');
var SettingsGeneral = require('./SettingsGeneral.js');
var SettingsInput = require('./SettingsInput.js');
// Display/Audio pages are deliberately not here -- neither has any real
// engine system behind it, even in the old code (no resolution/
// fullscreen system, no audio subsystem at all).
var TABS = [
{ name: 'General', ctor: SettingsGeneral },
{ name: 'Input', ctor: SettingsInput }
];
// Owns a tabs Menu (General/Input) and swaps in the active tab's page
// widgets into a second, page-scoped Menu -- both Menus are open on the
// navigation stack simultaneously (tabsMenu below, pageMenu on top),
// matching the old settings screen's nested-menu behavior. Switching
// away from a dirty page opens a Confirm first.
class Settings extends UIElement {
init() {
this.tabsMenu = new Menu();
this.tabsMenu.cols = TABS.length;
this.add(this.tabsMenu);
this.tabs = TABS.map((def, index) => {
const tab = new Tab();
tab.text = def.name;
tab.x = index * 90;
this.tabsMenu.addItem(tab);
return tab;
});
this.tabsMenu.onSelected = (index) => this._activateTab(index);
this.page = null;
this.pageMenu = null;
this._activeTabIndex = -1;
}
open() {
UI.add(this);
this.tabsMenu.open();
this._activateTab(0);
}
close() {
this.tabsMenu.close();
if(this.pageMenu) this.pageMenu.close();
UI.remove(this);
}
_activateTab(index) {
if(index === this._activeTabIndex) return;
if(this.page && this.page.hasChanges && this.page.hasChanges()) {
Confirm.open('Discard changes?', (confirmed) => {
if(confirmed) this._swapTab(index);
});
return;
}
this._swapTab(index);
}
_swapTab(index) {
// pageMenu.dispose() cascades to every widget the outgoing page
// handed it via menuItems -- the page itself is a plain object (see
// SettingsGeneral.js), nothing else to release.
if(this.pageMenu) this.pageMenu.dispose();
for(let i = 0; i < this.tabs.length; i++) {
this.tabs[i].setHighlighted(i === index);
}
const def = TABS[index];
this.page = new def.ctor();
if(this.page.load) this.page.load();
this.pageMenu = new Menu();
this.pageMenu.y = 40;
const items = this.page.menuItems || [];
for(let i = 0; i < items.length; i++) this.pageMenu.addItem(items[i]);
this.add(this.pageMenu);
this.pageMenu.open();
this._activeTabIndex = index;
}
}
module.exports = Settings;
-48
View File
@@ -1,48 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
var Dropdown = require('./Dropdown.js');
var Button = require('./Button.js');
// A plain class, not a UIElement -- it owns no visual tree of its own.
// Its widgets are handed to Settings.js's pageMenu (via menuItems),
// which owns their parenting/rendering/disposal; this only holds
// load()/apply()/hasChanges() logic, mirroring the old settings system's
// per-tab def struct.
class SettingsGeneral {
constructor() {
this.dropdown = new Dropdown();
this.dropdown.text = 'Language';
this.dropdown.options = ['en-US', 'ja-JP', 'es-MX'];
this.applyButton = new Button();
this.applyButton.text = 'Apply';
this.applyButton.y = 40;
this.applyButton.onSelect = () => this.apply();
this._loadedIndex = 0;
}
get menuItems() {
return [this.dropdown, this.applyButton];
}
load() {
const index = this.dropdown.options.indexOf(Locale.current);
this.dropdown.selectedIndex = index >= 0 ? index : 0;
this._loadedIndex = this.dropdown.selectedIndex;
}
apply() {
Locale.set(this.dropdown.options[this.dropdown.selectedIndex]);
this._loadedIndex = this.dropdown.selectedIndex;
}
hasChanges() {
return this.dropdown.selectedIndex !== this._loadedIndex;
}
}
module.exports = SettingsGeneral;
-46
View File
@@ -1,46 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
var Slider = require('./Slider.js');
var Button = require('./Button.js');
// A plain class, not a UIElement -- see SettingsGeneral.js for why.
class SettingsInput {
constructor() {
this.deadzoneSlider = new Slider();
this.deadzoneSlider.text = 'Deadzone';
this.deadzoneSlider.min = 0;
this.deadzoneSlider.max = 1;
this.deadzoneSlider.step = 0.05;
this.applyButton = new Button();
this.applyButton.text = 'Apply';
this.applyButton.y = 40;
this.applyButton.onSelect = () => this.apply();
this._loadedValue = 0;
}
get menuItems() {
return [this.deadzoneSlider, this.applyButton];
}
load() {
this.deadzoneSlider.value = Input.deadzone;
this._loadedValue = this.deadzoneSlider.value;
}
apply() {
Input.deadzone = this.deadzoneSlider.value;
Save.write();
this._loadedValue = this.deadzoneSlider.value;
}
hasChanges() {
return this.deadzoneSlider.value !== this._loadedValue;
}
}
module.exports = SettingsInput;
-94
View File
@@ -1,94 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Name label + value label + a track/fill Rectangle pair. Unlike Button,
// a Slider doesn't fire on ACCEPT -- it consumes LEFT/RIGHT itself via
// directionInput(), stepping value with wraparound at min/max, which is
// how a Menu gives the currently-highlighted item first refusal on
// direction input before falling back to moving the cursor.
class Slider extends UIElement {
init() {
this.nameLabel = new Label();
this.add(this.nameLabel);
this.valueLabel = new Label();
this.valueLabel.x = 100;
this.add(this.valueLabel);
this.track = new Rectangle();
this.track.y = 16;
this.track.width = 100;
this.track.height = 4;
this.track.color = Slider.COLOR_TRACK;
this.add(this.track);
this.fill = new Rectangle();
this.fill.y = 16;
this.fill.height = 4;
this.fill.color = Slider.COLOR_FILL;
this.add(this.fill);
this._min = 0;
this._max = 1;
this.step = 0.1;
this._value = 0;
this.highlighted = false;
this.onChange = null;
this._refresh();
}
get text() { return this.nameLabel.text; }
set text(value) { this.nameLabel.text = value; }
get min() { return this._min; }
set min(value) { this._min = value; this._refresh(); }
get max() { return this._max; }
set max(value) { this._max = value; this._refresh(); }
get value() { return this._value; }
set value(v) {
this._value = Math.min(this._max, Math.max(this._min, v));
this._refresh();
if(this.onChange) this.onChange(this._value);
}
setHighlighted(highlighted) {
this.highlighted = highlighted;
this.nameLabel.color = highlighted
? Slider.COLOR_HIGHLIGHTED
: Slider.COLOR_NORMAL;
}
directionInput(dx, dy) {
if(dx === 0) return false;
let next = this._value + dx * this.step;
const range = this._max - this._min;
if(range > 0) {
// Wrap within [min, max) rather than clamp, matching the old
// slider's step-with-wraparound behavior.
next = this._min + (((next - this._min) % range) + range) % range;
}
this.value = next;
return true;
}
_refresh() {
this.valueLabel.text = String(Math.round(this._value * 100) / 100);
const ratio = this._max > this._min
? (this._value - this._min) / (this._max - this._min)
: 0;
this.fill.width = this.track.width * ratio;
}
}
Slider.COLOR_NORMAL = { r: 255, g: 255, b: 255, a: 255 };
Slider.COLOR_HIGHLIGHTED = { r: 255, g: 0, b: 0, a: 255 };
Slider.COLOR_TRACK = { r: 80, g: 80, b: 80, a: 255 };
Slider.COLOR_FILL = { r: 0, g: 160, b: 220, a: 255 };
module.exports = Slider;
-45
View File
@@ -1,45 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// A background Rectangle + Label, structurally a re-skinned Button --
// "active" (which content tab is currently shown) plays the same role
// Button's "highlighted" plays, so setHighlighted() is how a Menu drives
// it: a tab strip's Menu treats "cursor is here" and "this tab's content
// is showing" as the same thing, matching the old widget's behavior.
class Tab extends UIElement {
init() {
this.background = new Rectangle();
this.background.width = 80;
this.background.height = 24;
this.add(this.background);
this.label = new Label();
this.label.x = 4;
this.label.y = 4;
this.add(this.label);
this.active = false;
this._refresh();
}
get text() { return this.label.text; }
set text(value) { this.label.text = value; }
setHighlighted(active) {
this.active = active;
this._refresh();
}
_refresh() {
this.background.color = this.active
? Tab.COLOR_ACTIVE
: Tab.COLOR_INACTIVE;
}
}
Tab.COLOR_ACTIVE = { r: 0, g: 160, b: 0, a: 255 };
Tab.COLOR_INACTIVE = { r: 160, g: 0, b: 0, a: 255 };
module.exports = Tab;
-23
View File
@@ -1,23 +0,0 @@
class TestPlane extends Entity {
constructor() {
super();
this.position = this.add(POSITION);
this.position.setLocalPosition(-10.0, 0.0, -10.0);
this.position.setLocalScale(20.0, 1.0, 20.0);
this.physics = this.add(PHYSICS);
this.physics.setBodyType(PHYSICS_BODY_STATIC);
this.physics.setShape(PHYSICS_SHAPE_PLANE, 0.0, 1.0, 0.0, 0.0);
this.renderable = this.add(RENDERABLE);
this.renderable.setMesh(0, MESH_PLANE);
this.renderable.setColor(128, 128, 128, 255);
}
dispose() {
}
}
module.exports = TestPlane;
-56
View File
@@ -1,56 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
var Button = require('./Button.js');
var Menu = require('./Menu.js');
var Settings = require('./Settings.js');
// No render() override -- UIElement's base render() auto-renders whatever
// was added() here, in add-order: background first, labels, then the
// menu (and its two buttons) on top of it.
class TestUIElement extends UIElement {
init() {
this.background = new Rectangle();
this.background.width = 220;
this.background.height = 128;
this.background.color = { r: 255, g: 0, b: 0, a: 255 };
this.add(this.background);
this.label = new Label();
this.label.text = 'Hello World!';
this.label.x = 8;
this.label.y = 8;
this.add(this.label);
this.label2 = new Label();
this.label2.text = 'Second label!';
this.label2.x = 8;
this.label2.y = 24;
this.add(this.label2);
// A navigable 2-item Menu -- UP/DOWN moves the highlighted cursor
// between the buttons, ACCEPT fires whichever one is highlighted.
this.menu = new Menu();
this.add(this.menu);
this.helloButton = new Button();
this.helloButton.text = 'Say hello';
this.helloButton.x = 8;
this.helloButton.y = 48;
this.helloButton.onSelect = () => Console.print('Hello!');
this.menu.addItem(this.helloButton);
this.settingsButton = new Button();
this.settingsButton.text = 'Open settings';
this.settingsButton.x = 8;
this.settingsButton.y = 84;
this.settingsButton.onSelect = () => new Settings().open();
this.menu.addItem(this.settingsButton);
this.menu.open();
}
}
module.exports = TestUIElement;
-5
View File
@@ -1,5 +0,0 @@
require('./input.js');
var OverworldScene = require('./OverworldScene.js');
Scene.set(new OverworldScene());
-88
View File
@@ -1,88 +0,0 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Default input bindings, ported from the old
// src/duskrpg/input/inputbindmap.h (removed) -- one INPUT_BIND_MAP per
// compile-time target became one branch here on Platform.current.
function bindAll(pairs) {
for(var i = 0; i < pairs.length; i++) {
Input.bind(pairs[i][0], pairs[i][1]);
}
}
if(Platform.current === 'linux' || Platform.current === 'knulli') {
bindAll([
// Keyboard
[InputButton.W, InputBind.UP],
[InputButton.S, InputBind.DOWN],
[InputButton.A, InputBind.LEFT],
[InputButton.D, InputBind.RIGHT],
[InputButton.LEFT, InputBind.LEFT],
[InputButton.RIGHT, InputBind.RIGHT],
[InputButton.UP, InputBind.UP],
[InputButton.DOWN, InputBind.DOWN],
[InputButton.ENTER, InputBind.ACCEPT],
[InputButton.E, InputBind.ACCEPT],
[InputButton.SPACE, InputBind.ACCEPT],
[InputButton.TAB, InputBind.CANCEL],
[InputButton.Q, InputBind.CANCEL],
[InputButton.ESCAPE, InputBind.RAGEQUIT],
[InputButton.ENTER, InputBind.PAUSE],
['`', InputBind.CONSOLE],
// Gamepad
[InputButton.GAMEPAD_UP, InputBind.UP],
[InputButton.GAMEPAD_DOWN, InputBind.DOWN],
[InputButton.GAMEPAD_LEFT, InputBind.LEFT],
[InputButton.GAMEPAD_RIGHT, InputBind.RIGHT],
[InputButton.GAMEPAD_A, InputBind.ACCEPT],
[InputButton.GAMEPAD_B, InputBind.CANCEL],
[InputButton.GAMEPAD_BACK, InputBind.RAGEQUIT],
[InputButton.GAMEPAD_START, InputBind.PAUSE],
[InputButton.GAMEPAD_LSTICK_UP, InputBind.UP],
[InputButton.GAMEPAD_LSTICK_DOWN, InputBind.DOWN],
[InputButton.GAMEPAD_LSTICK_LEFT, InputBind.LEFT],
[InputButton.GAMEPAD_LSTICK_RIGHT, InputBind.RIGHT],
// Mouse
[InputButton.MOUSE_X, InputBind.POINTERX],
[InputButton.MOUSE_Y, InputBind.POINTERY],
]);
} else if(Platform.current === 'psp') {
bindAll([
[InputButton.UP, InputBind.UP],
[InputButton.DOWN, InputBind.DOWN],
[InputButton.LEFT, InputBind.LEFT],
[InputButton.RIGHT, InputBind.RIGHT],
[InputButton.ACCEPT, InputBind.ACCEPT],
[InputButton.CANCEL, InputBind.CANCEL],
[InputButton.TRIANGLE, InputBind.CONSOLE],
[InputButton.SELECT, InputBind.RAGEQUIT],
[InputButton.START, InputBind.PAUSE],
[InputButton.LSTICK_UP, InputBind.UP],
[InputButton.LSTICK_DOWN, InputBind.DOWN],
[InputButton.LSTICK_LEFT, InputBind.LEFT],
[InputButton.LSTICK_RIGHT, InputBind.RIGHT],
]);
} else if(Platform.current === 'gamecube' || Platform.current === 'wii') {
// GameCube and Wii share the same pad layout for now.
// TODO: Wiimote, USB Keyboard, probably more.
bindAll([
[InputButton.UP, InputBind.UP],
[InputButton.DOWN, InputBind.DOWN],
[InputButton.LEFT, InputBind.LEFT],
[InputButton.RIGHT, InputBind.RIGHT],
[InputButton.LSTICK_UP, InputBind.UP],
[InputButton.LSTICK_DOWN, InputBind.DOWN],
[InputButton.LSTICK_LEFT, InputBind.LEFT],
[InputButton.LSTICK_RIGHT, InputBind.RIGHT],
[InputButton.A, InputBind.ACCEPT],
[InputButton.B, InputBind.CANCEL],
[InputButton.Z, InputBind.CONSOLE],
[InputButton.START, InputBind.RAGEQUIT],
[InputButton.START, InputBind.PAUSE],
]);
}
-96
View File
@@ -1,96 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Turn things off we don't need
set(JERRY_CMDLINE OFF CACHE BOOL "" FORCE)
set(JERRY_EXT ON CACHE BOOL "" FORCE)
set(JERRY_DEBUGGER OFF CACHE BOOL "" FORCE)
set(JERRY_BUILTIN_DATE OFF CACHE BOOL "" FORCE)
set(ENABLE_LTO OFF CACHE BOOL "" FORCE)
# Fetch Jerry
include(FetchContent)
FetchContent_Declare(
jerryscript
GIT_REPOSITORY https://git.wish.moe/YourWishes/jerryscript
GIT_TAG float32-fix
)
FetchContent_MakeAvailable(jerryscript)
# Mark found
set(jerryscript_FOUND ON)
# Define targets
if(TARGET jerryscript-core)
set(JERRY_CORE_TARGET jerryscript-core)
elseif(TARGET jerry-core)
set(JERRY_CORE_TARGET jerry-core)
endif()
if(TARGET jerryscript-ext)
set(JERRY_EXT_TARGET jerryscript-ext)
elseif(TARGET jerry-ext)
set(JERRY_EXT_TARGET jerry-ext)
endif()
if(TARGET jerryscript-port-default)
set(JERRY_PORT_TARGET jerryscript-port-default)
elseif(TARGET jerry-port-default)
set(JERRY_PORT_TARGET jerry-port-default)
elseif(TARGET jerryscript-port)
set(JERRY_PORT_TARGET jerryscript-port)
elseif(TARGET jerry-port)
set(JERRY_PORT_TARGET jerry-port)
endif()
if(NOT JERRY_CORE_TARGET)
message(FATAL_ERROR "JerryScript core target not found")
endif()
if(NOT JERRY_EXT_TARGET)
message(FATAL_ERROR "JerryScript ext target not found")
endif()
if(NOT JERRY_PORT_TARGET)
message(FATAL_ERROR "JerryScript port target not found")
endif()
foreach(tgt IN ITEMS
${JERRY_CORE_TARGET}
${JERRY_EXT_TARGET}
${JERRY_PORT_TARGET}
)
if(TARGET ${tgt})
set_property(TARGET ${tgt} PROPERTY INTERPROCEDURAL_OPTIMIZATION OFF)
target_compile_definitions(${JERRY_CORE_TARGET} PRIVATE
JERRY_NUMBER_TYPE_FLOAT64=0
JERRY_BUILTIN_DATE=0
)
endif()
endforeach()
# Export include dirs through the targets
target_include_directories(${JERRY_CORE_TARGET} INTERFACE
${jerryscript_SOURCE_DIR}/jerry-core/include
)
target_include_directories(${JERRY_EXT_TARGET} INTERFACE
${jerryscript_SOURCE_DIR}/jerry-ext/include
)
target_include_directories(${JERRY_PORT_TARGET} INTERFACE
${jerryscript_SOURCE_DIR}/jerry-port/default/include
)
# Suppress JerryScript-only warning
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(${JERRY_CORE_TARGET} PRIVATE
-Wno-error
)
endif()
add_library(jerryscript::core ALIAS ${JERRY_CORE_TARGET})
add_library(jerryscript::ext ALIAS ${JERRY_EXT_TARGET})
add_library(jerryscript::port ALIAS ${JERRY_PORT_TARGET})
+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()
+2
View File
@@ -61,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
@@ -6,7 +6,7 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
# Link libraries # Link libraries
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
bba # bba
) )
# ISO post-build: produce NTSC-J, NTSC-U and PAL disc images # ISO post-build: produce NTSC-J, NTSC-U and PAL disc images
Loaded 100 of 1240 files, more files were not shown because too many files have changed in this diff. Show more