diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f2cbf7af..ebd649e8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,3 +33,74 @@ jobs: libssl-dev - name: Run tests 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 diff --git a/PSP_OPTIMIZATION_PLAN.md b/PSP_OPTIMIZATION_PLAN.md new file mode 100644 index 00000000..954fcb2b --- /dev/null +++ b/PSP_OPTIMIZATION_PLAN.md @@ -0,0 +1,260 @@ +# PSP Optimization Plan + +A survey of concrete memory and CPU (especially floating-point) optimization +opportunities for the PSP target, done 2026-07-31 on branch `ac2` at commit +`e61914ba` + this session's scripting work. Point-in-time findings, not a +substitute for reading the referenced source before acting on it. + +## Why this exists + +The PSP (Allegrex MIPS CPU, 222-333MHz, single core, 32-64MB main RAM, 2MB +eDRAM, 16KB I-cache / 16KB D-cache, no virtual memory or memory protection) +is the tightest-constrained target this engine ships to. Two rules guide +everything below, both already correctly applied in one place in this +codebase (`entityposition_t` caching its transform matrices instead of +recomputing them from position/rotation/scale every read — see "Already +correct" below): + +- **Memory is finite and cannot be compacted.** No VM means a fragmented + heap after a few minutes of play can fail an allocation even with + "enough" total free bytes. Prefer static/pool allocation over + malloc/free churn; prefer trading memory for CPU only when the memory + cost is bounded and paid once. +- **CPU is finite and floating-point math is not free**, especially + trig/sqrt on the plain scalar FPU. Prefer caching a computed result + behind a dirty flag over recomputing it unconditionally; prefer + avoiding a sqrt via squared-distance comparison wherever only a + yes/no or ordering result is needed. + +## Priority 1 — Entity/component memory: a union tax paid on every slot, times 4 scenes + +**The single biggest finding.** `entitymanager_t` (`src/dusk/entity/entitymanager.h:11-15`) +is a flat, statically-sized struct: + +```c +typedef struct entitymanager_t { + entity_t entities[ENTITY_COUNT_MAX]; // 64 + component_t components[ENTITY_COUNT_MAX * ENTITY_COMPONENT_COUNT_MAX]; // 64*16 = 1024 + componentid_t entitiesWithComponent[COMPONENT_TYPE_COUNT * ENTITY_COUNT_MAX]; +} entitymanager_t; +``` + +`component_t` (`src/dusk/entity/component.h:69-72`) holds a **tagged union** +(`componentdata_t`) sized to its largest variant. That variant is +`entityrenderable_t`'s spritebatch payload (`entityrenderable.h:25-66`): +`spritebatchsprite_t sprites[64]` at 40 bytes each (`vec3 min + vec3 max + +vec2 uvMin + vec2 uvMax`) = **2560 bytes**, versus ~28-300 bytes for every +other component type (camera, physics, animation, position, trigger). + +Because the union is embedded by value in a flat array — not behind a +pointer, not sparse — **every one of the 1024 component slots in every +entity manager costs ~2580 bytes, whether it holds a 28-byte camera or +nothing at all.** `1024 * 2580 ≈ 2.52MB` per `entitymanager_t`, confirmed +by the engine's own startup log every run: `Entity manager size: 2684992 +bytes (2622.06 KB)`. + +`scene_t` (`src/dusk/scene/scene.h:14-18`) embeds `entitymanager_t` by +value too, and `SCENE_COUNT_MAX = 4` (`scene/scenebase.h:11`) with +`SCENE_MANAGER` declared as a plain global (`scene/scene.c:20`) — so this +~2.52MB exists as static BSS from process start for **all 4 scene slots**, +used or not. **Total: ~10.1MB reserved permanently for entity storage +alone** — 16-31% of PSP main RAM — before a single texture, mesh, or audio +asset is loaded. + +**Options to fix (roughly increasing effort/risk):** +1. Pull `entityrenderablespritebatch_t` out of the component union entirely + — store spritebatch entities via a pointer/handle into a separate, + smaller fixed pool sized to how many spritebatch-renderable entities + actually coexist (almost certainly far fewer than 64 per entity, and + far fewer entities need spritebatch at all vs. mesh/material + rendering). This alone would shrink the union's dominant variant from + ~2560 bytes to whatever the next-largest variant is (~300 bytes, + `entitytrigger_t`) — an ~88% reduction, dropping the ~10.1MB down to + roughly ~1.2MB. +2. Reduce `SCENE_COUNT_MAX` from 4 if 4 concurrent scenes were never a + deliberate requirement (check with the project owner — this may just + be a round-number default nobody revisited), or make scene storage + pointer-based/lazily allocated so unused scene slots cost ~0 instead + of a full `entitymanager_t`. +3. Reduce `ENTITY_COMPONENT_COUNT_MAX` (16) if entities realistically use + far fewer distinct component types simultaneously — check actual + usage across `entityprefablist.h`/`gameprefablist.h` prefabs. + +Do (1) first — it's the highest-leverage, most contained change (touches +`entityrenderable.h`'s data layout and its render/dispose paths, not the +general entity/component system), and re-measure via the same startup +log line before deciding whether (2)/(3) are still worth doing. + +## Priority 2 — VFPU is completely unused; all vec/mat math is scalar + +The PSP's Allegrex CPU has a **VFPU** (vector floating-point unit) capable +of fast SIMD-style 4-wide float ops and hardware-accelerated matrix +operations, exposed by pspsdk's `pspvfpu`/GU "Geometry Utility" (`gum_*`) +helpers. This engine links `pspvfpu` (`cmake/targets/psp.cmake`) but +**never calls into it** — confirmed via a zero-hit grep for +`vfpu|pspmath|gum_|vcst|gu_matrix` across `src/duskpsp/` and `src/duskgl/`. + +All vector/matrix math instead goes through cglm (`glm_vec3_*`, +`glm_mat4_*`), which auto-detects SIMD only for x86 (`CGLM_SSE2`/`AVX`) or +ARM (`CGLM_NEON`) — neither applies to MIPS, and no `CGLM_*` macros are +set anywhere in this repo (confirmed zero hits). **Every `glm_mat4_mul`, +every position/rotation rebuild, every physics vector op runs on the +plain scalar MIPS FPU with the VFPU sitting idle.** + +This is the single largest *available* CPU win identified in this survey +— larger than any specific hot-path fix below, because it's a multiplier +on all of them. Concretely: route `entityposition.c`'s matrix +rebuild/multiply path and `physicsworld.c`'s per-body vector math through +`pspvfpu`/`gum_*` on the PSP build specifically (behind `#ifdef DUSK_PSP`, +matching the project's existing platform-guard convention), while keeping +cglm as the portable fallback for Linux/Knulli/GameCube/Wii. This is a +genuinely large effort (new platform-specific math backend, careful +correctness verification since VFPU has its own quirks around +pipelining/hazards) — scope it as its own project, not a quick pass. + +## Priority 3 — UI/spritebatch rebuilds and redraws every vertex, every frame + +Already flagged in `STATUS.md`/`ROADMAP.md` (item 4/5) as a known open +issue; this session's research adds concrete numbers. `meshvertex_t` +(`display/mesh/meshvertex.h:14-17`) is `{ float uv[2]; float pos[3]; }` = +**20 bytes/vertex**, no packing. `SPRITEBATCH_SPRITES_MAX = 512`, +`SPRITEBATCH_FLUSH_COUNT = 16` → 32 sprites/flush = 192 vertices = **3,840 +bytes rebuilt and redrawn per flush**, with a flush forced on every +shader/material change (`spritebatch.c:38-50`) and used unconditionally +by every widget except `uiconsole.c` (`uitab.c:56`, `uislider.c:183/199/231`, +final flush in `ui.c:51`). A UI screen with ~50-100 sprites and 2-3 +material changes costs an estimated **8-15KB of vertex rebuild + GU +submission every single frame**, regardless of whether the UI changed +since the last frame. + +Confirmed on the PSP legacy-GL path specifically: there's no GPU buffer +re-upload cost (`meshFlushGL` is a literal no-op comment: "we use the +glClientState stuff" — `meshgl.c:91-93`; `meshDrawGL` just points +`glVertexPointer` at the CPU-side array each call), so the real cost is +(a) the CPU-side per-sprite vertex rewrite every frame and (b) GU +re-transforming the full vertex stream from RAM on every draw with no +skip for unchanged geometry. + +**Fix direction** (already captured in `ROADMAP.md`'s debt backlog item 5): +extend `uiconsole.c`'s cached-mesh, dirty-flag-gated rebuild pattern to +the rest of the widget tree. This plan adds one refinement: also consider +packing `meshvertex_t` down (next item) since it multiplies this cost. + +## Priority 4 — All-float vertex format wastes bandwidth and T&L cost + +`meshvertex_t` uses two `float` fields (20 bytes) for every mesh and +sprite vertex, mesh-wide, with no normal or per-vertex color (color is +already handled at the material level, so that part is already optimal). +GU natively supports fixed-point 16-bit positions/UVs +(`GU_VERTEX_16BIT`/`GU_TEXTURE_16BIT`), which would roughly halve +per-vertex size and the corresponding vertex-fetch/transform cost, at the +cost of position precision (fine for UI/sprite work and most world +geometry at this engine's scale; worth checking against the largest +world coordinates actually used before committing). Pair with Priority 3 +so the packing benefit compounds with the dirty-tracking benefit instead +of just reducing the cost of a rebuild that still happens every frame. + +## Priority 5 — Running gameplay logic in JerryScript has a real per-frame cost on PSP + +This is new since last session's work, not a pre-existing issue: `assets/ +scripts/overworldscene.js`'s `update()` is now called every frame via +`scriptManagerCallGlobal("update")` (wired into `engine.c`'s +`engineUpdate()`), replacing what used to be a direct `cosf`/`sinf` C +callback (`entityUpdateAdd`). Per call, `scriptManagerCallGlobal` +(`scriptmanager.c:91-144`) does: a global-object property lookup (key is +cached, but the `jerry_object_get` dispatch still runs), full JS +interpreter call dispatch (frame setup, argument marshaling) for what's a +two-line function, and an unconditional `jerry_value_is_promise` check +every call even though `update()` is synchronous. Inside, `Math.cos`/ +`Math.sin` run as JS builtin calls rather than direct libm calls. + +Additionally: this project's JerryScript fork is already patched to use +32-bit float internally (`JERRY_NUMBER_TYPE_FLOAT64=0`, +`Findjerryscript.cmake`) — a deliberate, already-correct optimization for +this exact concern — but the *public* engine API (`jerry_value_as_number()`) +still returns `double`, so every native↔JS boundary crossing (every +`moduleBaseArgFloat`, every `moduleBaseVec3ToObject`) still pays a +float→double→float round trip. Also note `JERRY_MATH` is off, so +`Math.sin`/`cos` fall through to whatever generic libm the platform +provides rather than JerryScript's own fdlibm implementation — worth +checking whether turning it on changes anything measurable on PSP. + +**This is one `update()` call for one scene-level script today — cheap in +absolute terms.** It becomes a real problem only if the pattern scales: +giving many individual entities their own per-frame JS update callback +would multiply all of the above per entity. **Recommendation: keep +high-frequency, hot per-entity logic (movement, camera math, physics +response) in native C update callbacks (`entityUpdateAdd`, the existing +mechanism), and reserve JS for one-time setup, infrequent/event-driven +logic, and coarse-grained per-scene orchestration** — which is exactly +what `require()` and the typed component wrappers built this session are +suited for, not a per-entity-per-frame hot path. This is a design +guideline to apply going forward, not a regression to fix in the current +`overworldscene.js` (one scene-level `update()` call per frame is fine). + +## Priority 6 — No pooling/arena allocator; plain malloc/free everywhere + +`memoryAllocate`/`memoryFree` (`util/memory.c`) are direct `malloc`/`free` +passthroughs (`memoryAlign`→`memalign`, `memoryReallocate`/`memoryResize`→ +`realloc`), with the only extra behavior being a global allocation-count +tracker used solely for test leak detection. No pool, arena, free-list, +or size-class allocator exists anywhere. + +This survey found **no rogue per-frame heap allocations** in the hot +paths checked (render dispatch in `entityrenderable.c`, all of +`physics/*.c`) — so this is not an active bug today. But it's a +structural risk for a no-VM platform over a long play session: any future +code that does frequent small alloc/free (dynamic lists, string +building, temp buffers) will fragment the 32-64MB heap with no OS-level +recovery mechanism. **Recommendation: before adding any new subsystem +that allocates/frees frequently at runtime (not just at load time), +default to a fixed-size pool or arena for it**, following the same +"static, bounded" philosophy already used for `ENTITY_COUNT_MAX`/ +`SCENE_COUNT_MAX`/`ASSET_ENTRY_COUNT_MAX` elsewhere in the engine, rather +than reaching for `memoryAllocate` per-instance. + +## Already correct — don't touch without new evidence + +- **`entityposition_t`'s matrix caching** (the pattern the project owner + called out as the reason for writing this plan). Verified: a full + "dirty" recompute chain (decompose + rebuild local + rebuild world) is + ~10 trig calls (`asinf`/`cosf`/`atan2f`) plus at most one 4x4 matrix + multiply, and `entityPositionEnsurePRS`/`EnsureLocal`/`EnsureWorld` + (`entityposition.c:596-669`) all early-return on a flag check, only + doing that work when something actually changed. The cache is correct + and clearly worth its ~128-byte-per-entity matrix storage cost. +- **Physics narrow-phase sqrt avoidance.** All three `sqrtf` call sites + in `physicstest.c`/`physicsshapemesh.c` already sit behind a + squared-distance early-reject and only compute the real (linear) + distance once, when actually needed for penetration depth — no further + "use squared distance instead" opportunity was found here. +- **No allocations in the render/physics hot loop** (Priority 6) — this + is good and worth preserving as new code is added to those files. +- **Asset decompression already runs off the main thread.** Assets are + DEFLATE-compressed (not stored) in `dusk.dsk`, so loading pays a real + CPU cost for decompression — but every `*LoaderAsync` function + (`asset/loader/*/*.c`) runs via `ASSET.loadThread` + (`assertNotMainThread` in `asset.c:365`), so this cost is already kept + off the main thread. Low priority to change; if load-time CPU cost + becomes a measured problem later, revisit stored-vs-compressed as a + build-time flag rather than assuming compression is free. + +## Suggested approach + +1. **Measure before changing.** None of the above have been profiled on + real PSP hardware in this pass — this is a source-level survey, not a + profile. Before investing in Priority 1 or 2 especially, confirm with + an actual PSP build/run (or at minimum the existing "Entity manager + size" startup log plus a frame-time counter) that these are the real + bottlenecks, not just the largest numbers on paper. +2. **Priority 1 first** — it's the most contained (one component's data + layout), has the clearest before/after metric (the startup log line), + and doesn't require new platform-specific code. +3. **Priority 3 next** (extend the console's dirty-tracking pattern to + the rest of the UI) — already scoped in `ROADMAP.md`, no new design + needed, just implementation. +4. **Priority 2 (VFPU) as a dedicated project**, not a quick pass — it's + the largest potential win but touches core math plumbing and needs + careful correctness verification on real hardware. +5. Treat Priority 5 as a standing design guideline for all future + scripting work, not a one-time fix. diff --git a/ROADMAP.md b/ROADMAP.md index 27488e7e..a2ed7ea4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,8 @@ # Dusk Roadmap -Tracking upcoming milestones for the engine. +Tracking upcoming milestones for the engine. See `PSP_OPTIMIZATION_PLAN.md` +for a memory/CPU optimization survey specifically targeting the PSP build +(milestone 4 below is its Priority 3). ## Upcoming milestones diff --git a/assets/scripts/init.js b/assets/scripts/init.js new file mode 100644 index 00000000..6e7b0631 --- /dev/null +++ b/assets/scripts/init.js @@ -0,0 +1,9 @@ +// Copyright (c) 2026 Dominic Masters +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +// Entry point run once at startup (see game.c). Swap which scene module +// gets passed to Scene.set() here to change what the game boots into. +var overworldScene = require('./overworldscene.js'); +Scene.set(overworldScene); diff --git a/assets/scripts/overworldscene.js b/assets/scripts/overworldscene.js index 5b426728..6535138d 100644 --- a/assets/scripts/overworldscene.js +++ b/assets/scripts/overworldscene.js @@ -1,3 +1,8 @@ +// Copyright (c) 2026 Dominic Masters +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + // Radius must stay well outside the floor's footprint (a 20x20 plane has a // corner-to-center distance of 10*sqrt(2) =~ 14.1) -- orbiting inside that // puts parts of the floor's own geometry near/behind the camera's view @@ -24,49 +29,57 @@ function updateCameraOrbit() { cameraPosition.lookAt(eyeX, eyeY, eyeZ, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0); } -// Called once per engine frame (see engineUpdate() -> scriptManagerCall -// Global("update")). -function update() { - if(cameraPosition) updateCameraOrbit(); -} +module.exports = { + // Called once by Scene.set(), right after it creates and activates the + // scene this module owns. + init: function() { + // Camera, orbiting the origin (see updateCameraOrbit above). + var camera = new Entity(); + cameraPosition = camera.add(POSITION); + camera.add(CAMERA); + updateCameraOrbit(); -(function setup() { - var scene = new Scene(); - scene.setActive(); + // Static ground plane. Physics ignores the entity's position + // component -- the shape's own normal/distance fully define the + // plane in world space. + var plane = new Entity(); + var planePosition = plane.add(POSITION); + planePosition.setLocalPosition(-10.0, 0.0, -10.0); + planePosition.setLocalScale(20.0, 1.0, 20.0); - // Camera, orbiting the origin (see updateCameraOrbit above). - var camera = new Entity(); - cameraPosition = camera.add(POSITION); - camera.add(CAMERA); - updateCameraOrbit(); + var planePhysics = plane.add(PHYSICS); + planePhysics.setBodyType(PHYSICS_BODY_STATIC); + planePhysics.setShape(PHYSICS_SHAPE_PLANE, 0.0, 1.0, 0.0, 0.0); - // Static ground plane. Physics ignores the entity's position component -- - // the shape's own normal/distance fully define the plane in world space. - var plane = new Entity(); - var planePosition = plane.add(POSITION); - planePosition.setLocalPosition(-10.0, 0.0, -10.0); - planePosition.setLocalScale(20.0, 1.0, 20.0); + var planeRenderable = plane.add(RENDERABLE); + planeRenderable.setMesh(0, MESH_PLANE); + planeRenderable.setColor(128, 128, 128, 255); - var planePhysics = plane.add(PHYSICS); - planePhysics.setBodyType(PHYSICS_BODY_STATIC); - planePhysics.setShape(PHYSICS_SHAPE_PLANE, 0.0, 1.0, 0.0, 0.0); + // Player: dynamic capsule body, moved relative to the camera by + // PLAYER's own update callback (see entityplayer.c). + var player = new Entity(); + var playerPosition = player.add(POSITION); + playerPosition.setLocalPosition(0.0, 2.0, 0.0); - var planeRenderable = plane.add(RENDERABLE); - planeRenderable.setMesh(0, MESH_PLANE); - planeRenderable.setColor(128, 128, 128, 255); + var playerPhysics = player.add(PHYSICS); + playerPhysics.setShape(PHYSICS_SHAPE_CAPSULE, 0.5, 0.5); - // Player: dynamic capsule body, moved relative to the camera by - // PLAYER's own update callback (see entityplayer.c). - var player = new Entity(); - var playerPosition = player.add(POSITION); - playerPosition.setLocalPosition(0.0, 2.0, 0.0); + var playerRenderable = player.add(RENDERABLE); + playerRenderable.setMesh(0, MESH_CAPSULE); + playerRenderable.setColor(0, 0, 255, 255); - var playerPhysics = player.add(PHYSICS); - playerPhysics.setShape(PHYSICS_SHAPE_CAPSULE, 0.5, 0.5); + player.add(PLAYER); + }, - var playerRenderable = player.add(RENDERABLE); - playerRenderable.setMesh(0, MESH_CAPSULE); - playerRenderable.setColor(0, 0, 255, 255); + // Called once per engine frame while this module is the active scene + // (see Scene.set(), engineUpdate() -> moduleSceneUpdateCurrent()). + update: function() { + if(cameraPosition) updateCameraOrbit(); + }, - player.add(PLAYER); -})(); + // Called once by Scene.set() when this module is replaced by another, + // right before the scene it owns is destroyed. + dispose: function() { + cameraPosition = null; + } +}; diff --git a/docker/dolphin-test/Dockerfile b/docker/dolphin-test/Dockerfile new file mode 100644 index 00000000..80998c9a --- /dev/null +++ b/docker/dolphin-test/Dockerfile @@ -0,0 +1,7 @@ +FROM ghcr.io/extremscorner/libogc2 +WORKDIR /workdir +RUN apt update && \ + dkp-pacman -Syu --noconfirm && \ + apt install -y python3 python3-pip python3-polib python3-pil python3-dotenv python3-pyqt5 python3-opengl xorriso dolphin-emu xvfb && \ + dkp-pacman -S --needed --noconfirm gamecube-sdl2 ppc-liblzma ppc-libzip libogc2 gamecube-tools ppc-libmad ppc-zlib-ng ppc-liblzma ppc-bzip2 ppc-zstd +VOLUME ["/workdir"] diff --git a/docker/psp-test/Dockerfile b/docker/psp-test/Dockerfile new file mode 100644 index 00000000..6036cfe7 --- /dev/null +++ b/docker/psp-test/Dockerfile @@ -0,0 +1,28 @@ +FROM pspdev/pspdev:latest +WORKDIR /workdir +RUN apk add --no-cache \ + python3 \ + py3-pip \ + py3-dotenv \ + git \ + cmake \ + make \ + g++ \ + sdl2-dev \ + zlib-dev \ + linux-headers + +# PPSSPP has no Alpine/Linux distro package -- build the "PPSSPPHeadless" +# target (the CLI/no-window build PPSSPP's own CI uses for automated runs) +# from source instead. The target name and exact CMake options have moved +# around across PPSSPP versions -- if this breaks, check +# https://github.com/hrydgard/ppsspp's CMakeLists.txt for the current name. +RUN git clone --recursive --depth 1 \ + https://github.com/hrydgard/ppsspp.git /opt/ppsspp && \ + cd /opt/ppsspp && \ + cmake -B build -DCMAKE_BUILD_TYPE=Release && \ + cmake --build build --target PPSSPPHeadless -- -j$(nproc) && \ + cp build/PPSSPPHeadless /usr/local/bin/PPSSPPHeadless && \ + rm -rf /opt/ppsspp + +VOLUME ["/workdir"] diff --git a/scripts/test-gamecube-dolphin-docker.sh b/scripts/test-gamecube-dolphin-docker.sh new file mode 100755 index 00000000..94dd1269 --- /dev/null +++ b/scripts/test-gamecube-dolphin-docker.sh @@ -0,0 +1,3 @@ +#!/bin/bash +docker build -t dusk-dolphin-test -f docker/dolphin-test/Dockerfile . +docker run --rm -v "$(pwd):/workdir" dusk-dolphin-test /bin/bash -c "./scripts/test-gamecube-dolphin.sh" diff --git a/scripts/test-gamecube-dolphin.sh b/scripts/test-gamecube-dolphin.sh new file mode 100755 index 00000000..eeaf53fa --- /dev/null +++ b/scripts/test-gamecube-dolphin.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -e + +if [ -z "$DEVKITPRO" ]; then + echo "DEVKITPRO environment variable is not set. Please set it to the path of your DEVKITPRO installation." + exit 1 +fi + +DOLPHIN_BIN="${DOLPHIN_BIN:-dolphin-emu}" +if ! command -v "$DOLPHIN_BIN" >/dev/null 2>&1; then + echo "$DOLPHIN_BIN not found. Install Dolphin Emulator (e.g. 'apt install dolphin-emu')" + echo "or set DOLPHIN_BIN to its path." + exit 1 +fi +if ! command -v xvfb-run >/dev/null 2>&1; then + echo "xvfb-run not found. Install it (e.g. 'apt install xvfb') -- Dolphin's" + echo "Qt frontend needs a display even in batch mode." + exit 1 +fi + +./scripts/build-gamecube-iso.sh + +DUSK_TEST_DOLPHIN_SECONDS="${DUSK_TEST_DOLPHIN_SECONDS:-20}" +ISO="build-gamecube-iso/Dusk-NTSC-U.iso" + +echo "Booting $ISO in Dolphin for up to ${DUSK_TEST_DOLPHIN_SECONDS}s (batch mode, headless via Xvfb)..." + +# Dolphin's batch mode (-b -e) runs the disc until told to stop -- there's +# no "the game exited cleanly" signal to wait for, so we bound it with +# timeout and treat "still running when the clock ran out" as success. +set +e +xvfb-run -a timeout "${DUSK_TEST_DOLPHIN_SECONDS}" "$DOLPHIN_BIN" -b -e "$ISO" +status=$? +set -e + +# timeout returns 124 when it had to kill Dolphin after the duration +# elapsed -- that means the disc booted and kept running, the expected +# smoke-test-passed outcome, not a failure. Any other non-zero status +# means Dolphin crashed or refused to boot the disc. +if [ "$status" -ne 0 ] && [ "$status" -ne 124 ]; then + echo "Dolphin exited with unexpected status $status -- treating as a failure." + exit "$status" +fi + +echo "GameCube smoke test passed (Dolphin ran $ISO for ${DUSK_TEST_DOLPHIN_SECONDS}s without crashing)." diff --git a/scripts/test-psp-ppsspp-docker.sh b/scripts/test-psp-ppsspp-docker.sh new file mode 100755 index 00000000..8faa10fb --- /dev/null +++ b/scripts/test-psp-ppsspp-docker.sh @@ -0,0 +1,3 @@ +#!/bin/bash +docker build -t dusk-psp-test -f docker/psp-test/Dockerfile . +docker run --rm -v "$(pwd):/workdir" dusk-psp-test /bin/bash -c "./scripts/test-psp-ppsspp.sh" diff --git a/scripts/test-psp-ppsspp.sh b/scripts/test-psp-ppsspp.sh new file mode 100755 index 00000000..6a2ee37e --- /dev/null +++ b/scripts/test-psp-ppsspp.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -e + +if [ -z "$PSPDEV" ]; then + echo "PSPDEV environment variable is not set. Please set it to the path of your PSP development environment." + exit 1 +fi + +# PPSSPP doesn't ship a standard Linux package -- build its "PPSSPPHeadless" +# target from source (https://github.com/hrydgard/ppsspp) and either put it +# on PATH or point PPSSPP_HEADLESS_BIN at it. See docker/psp-test/Dockerfile +# for a from-scratch build. +PPSSPP_HEADLESS_BIN="${PPSSPP_HEADLESS_BIN:-PPSSPPHeadless}" +if ! command -v "$PPSSPP_HEADLESS_BIN" >/dev/null 2>&1; then + echo "$PPSSPP_HEADLESS_BIN not found. Build PPSSPP's 'PPSSPPHeadless' target" + echo "from source or set PPSSPP_HEADLESS_BIN to its path." + exit 1 +fi + +./scripts/build-psp.sh + +DUSK_TEST_PPSSPP_SECONDS="${DUSK_TEST_PPSSPP_SECONDS:-20}" +EBOOT="build-psp/EBOOT.PBP" + +echo "Booting $EBOOT in PPSSPPHeadless (timeout ${DUSK_TEST_PPSSPP_SECONDS}s)..." + +# PPSSPPHeadless is purpose-built for automated/CI runs -- it renders +# without a window and is expected to exit on its own once --timeout +# elapses, unlike Dolphin's batch mode which has to be killed externally. +# Check `"$PPSSPP_HEADLESS_BIN" --help` if these flags don't match your +# PPSSPP checkout -- the headless CLI has changed across versions. +set +e +"$PPSSPP_HEADLESS_BIN" --timeout="${DUSK_TEST_PPSSPP_SECONDS}" "$EBOOT" +status=$? +set -e + +if [ "$status" -ne 0 ]; then + echo "PPSSPPHeadless exited with status $status" + exit "$status" +fi + +echo "PSP smoke test passed (PPSSPPHeadless ran EBOOT.PBP for ${DUSK_TEST_PPSSPP_SECONDS}s without crashing)." diff --git a/scripts/test-wii-dolphin-docker.sh b/scripts/test-wii-dolphin-docker.sh new file mode 100755 index 00000000..9f85b804 --- /dev/null +++ b/scripts/test-wii-dolphin-docker.sh @@ -0,0 +1,3 @@ +#!/bin/bash +docker build -t dusk-dolphin-test -f docker/dolphin-test/Dockerfile . +docker run --rm -v "$(pwd):/workdir" dusk-dolphin-test /bin/bash -c "./scripts/test-wii-dolphin.sh" diff --git a/scripts/test-wii-dolphin.sh b/scripts/test-wii-dolphin.sh new file mode 100755 index 00000000..18caa28f --- /dev/null +++ b/scripts/test-wii-dolphin.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -e + +if [ -z "$DEVKITPRO" ]; then + echo "DEVKITPRO environment variable is not set. Please set it to the path of your DEVKITPRO installation." + exit 1 +fi + +DOLPHIN_BIN="${DOLPHIN_BIN:-dolphin-emu}" +if ! command -v "$DOLPHIN_BIN" >/dev/null 2>&1; then + echo "$DOLPHIN_BIN not found. Install Dolphin Emulator (e.g. 'apt install dolphin-emu')" + echo "or set DOLPHIN_BIN to its path." + exit 1 +fi +if ! command -v xvfb-run >/dev/null 2>&1; then + echo "xvfb-run not found. Install it (e.g. 'apt install xvfb') -- Dolphin's" + echo "Qt frontend needs a display even in batch mode." + exit 1 +fi + +./scripts/build-wii-iso.sh + +DUSK_TEST_DOLPHIN_SECONDS="${DUSK_TEST_DOLPHIN_SECONDS:-20}" +ISO="build-wii-iso/Dusk-NTSC-U.iso" + +echo "Booting $ISO in Dolphin for up to ${DUSK_TEST_DOLPHIN_SECONDS}s (batch mode, headless via Xvfb)..." + +# Dolphin's batch mode (-b -e) runs the disc until told to stop -- there's +# no "the game exited cleanly" signal to wait for, so we bound it with +# timeout and treat "still running when the clock ran out" as success. +set +e +xvfb-run -a timeout "${DUSK_TEST_DOLPHIN_SECONDS}" "$DOLPHIN_BIN" -b -e "$ISO" +status=$? +set -e + +# timeout returns 124 when it had to kill Dolphin after the duration +# elapsed -- that means the disc booted and kept running, the expected +# smoke-test-passed outcome, not a failure. Any other non-zero status +# means Dolphin crashed or refused to boot the disc. +if [ "$status" -ne 0 ] && [ "$status" -ne 124 ]; then + echo "Dolphin exited with unexpected status $status -- treating as a failure." + exit "$status" +fi + +echo "Wii smoke test passed (Dolphin ran $ISO for ${DUSK_TEST_DOLPHIN_SECONDS}s without crashing)." diff --git a/src/dusk/display/spritebatch/spritebatchsprite.c b/src/dusk/display/spritebatch/spritebatchsprite.c index bc0437db..7e9ae46e 100644 --- a/src/dusk/display/spritebatch/spritebatchsprite.c +++ b/src/dusk/display/spritebatch/spritebatchsprite.c @@ -39,3 +39,16 @@ spritebatchsprite_t spriteBatchSpriteTilesetPosition( sprite.uvMax[1] = uv[3]; return sprite; } + +spritebatchsprite_t spriteBatchSpriteTranslate( + const spritebatchsprite_t *sprite, + const float_t x, + const float_t y +) { + spritebatchsprite_t out = *sprite; + out.min[0] += x; + out.min[1] += y; + out.max[0] += x; + out.max[1] += y; + return out; +} diff --git a/src/dusk/display/spritebatch/spritebatchsprite.h b/src/dusk/display/spritebatch/spritebatchsprite.h index ef7e2924..5fbada49 100644 --- a/src/dusk/display/spritebatch/spritebatchsprite.h +++ b/src/dusk/display/spritebatch/spritebatchsprite.h @@ -38,3 +38,19 @@ spritebatchsprite_t spriteBatchSpriteTilesetPosition( const float_t width, const float_t height ); + +/** + * Returns a copy of sprite translated by (x, y). Used to reposition a + * sprite cached relative to origin (0,0) at draw time, instead of + * re-deriving its geometry from scratch every frame. + * + * @param sprite The cached sprite, relative to origin (0,0). + * @param x X offset to translate by. + * @param y Y offset to translate by. + * @returns The translated sprite. + */ +spritebatchsprite_t spriteBatchSpriteTranslate( + const spritebatchsprite_t *sprite, + const float_t x, + const float_t y +); diff --git a/src/dusk/engine/engine.c b/src/dusk/engine/engine.c index 08d371d7..5881e956 100644 --- a/src/dusk/engine/engine.c +++ b/src/dusk/engine/engine.c @@ -14,6 +14,7 @@ #include "scene/scene.h" #include "asset/asset.h" #include "script/scriptmanager.h" +#include "script/module/scene/modulescene.h" #include "ui/ui.h" #include "assert/assert.h" #include "network/network.h" @@ -74,7 +75,7 @@ errorret_t engineUpdate(void) { consoleUpdate(); errorChain(gameUpdate()); - errorChain(scriptManagerCallGlobal("update")); + errorChain(moduleSceneUpdateCurrent()); errorChain(sceneUpdate()); errorChain(assetUpdate()); errorChain(uiUpdate()); diff --git a/src/dusk/script/module/scene/modulescene.c b/src/dusk/script/module/scene/modulescene.c index 7591123f..08206752 100644 --- a/src/dusk/script/module/scene/modulescene.c +++ b/src/dusk/script/module/scene/modulescene.c @@ -7,11 +7,17 @@ #include "modulescene.h" #include "script/module/modulebase.h" +#include "script/scriptmanager.h" #include "scene/scene.h" #include "util/string.h" scriptproto_t MODULE_SCENE_PROTO; +// The module last installed via Scene.set(), and the scene it owns. +// SCENE_ID_INVALID means no module is currently installed. +static jerry_value_t MODULE_SCENE_CURRENT; +static sceneid_t MODULE_SCENE_CURRENT_ID; + moduleBaseFunction(moduleSceneConstructor) { modulescenehandle_t *inst = (modulescenehandle_t *)memoryAllocate( sizeof(modulescenehandle_t) @@ -49,6 +55,31 @@ moduleBaseFunction(moduleSceneGetActiveStatic) { return scriptProtoCreateValue(&MODULE_SCENE_PROTO, &h); } +moduleBaseFunction(moduleSceneSetStatic) { + moduleBaseRequireArgs(1); moduleBaseRequireObject(0); + + moduleSceneTeardownCurrent(); + + sceneid_t newId = sceneCreate(); + sceneSetActive(newId); + MODULE_SCENE_CURRENT = jerry_value_copy(args[0]); + MODULE_SCENE_CURRENT_ID = newId; + + jerry_value_t initFn = moduleBaseGetProp(MODULE_SCENE_CURRENT, "init"); + if(jerry_value_is_function(initFn)) { + errorret_t ret = scriptManagerCallValue( + MODULE_SCENE_CURRENT, initFn, "Scene module", "init" + ); + if(errorIsNotOk(ret)) { + jerry_value_free(initFn); + return moduleBaseThrowError(ret); + } + } + jerry_value_free(initFn); + + return jerry_undefined(); +} + moduleBaseFunction(moduleSceneToString) { modulescenehandle_t *inst = moduleSceneGet(callInfo); if(!inst) return jerry_string_sz("Scene(?)"); @@ -79,9 +110,15 @@ void moduleSceneInit(void) { scriptProtoDefineStaticFunc( &MODULE_SCENE_PROTO, "getActive", moduleSceneGetActiveStatic ); + scriptProtoDefineStaticFunc( + &MODULE_SCENE_PROTO, "set", moduleSceneSetStatic + ); + + MODULE_SCENE_CURRENT_ID = SCENE_ID_INVALID; } void moduleSceneDispose(void) { + moduleSceneTeardownCurrent(); } modulescenehandle_t *moduleSceneGet(const jerry_call_info_t *callInfo) { @@ -89,3 +126,37 @@ modulescenehandle_t *moduleSceneGet(const jerry_call_info_t *callInfo) { &MODULE_SCENE_PROTO, callInfo->this_value ); } + +void moduleSceneTeardownCurrent(void) { + if(MODULE_SCENE_CURRENT_ID == SCENE_ID_INVALID) return; + + jerry_value_t disposeFn = moduleBaseGetProp(MODULE_SCENE_CURRENT, "dispose"); + if(jerry_value_is_function(disposeFn)) { + errorret_t ret = scriptManagerCallValue( + MODULE_SCENE_CURRENT, disposeFn, "Scene module", "dispose" + ); + errorCatch(errorPrint(ret)); + } + jerry_value_free(disposeFn); + + sceneDestroy(MODULE_SCENE_CURRENT_ID); + jerry_value_free(MODULE_SCENE_CURRENT); + MODULE_SCENE_CURRENT_ID = SCENE_ID_INVALID; +} + +errorret_t moduleSceneUpdateCurrent(void) { + if(MODULE_SCENE_CURRENT_ID == SCENE_ID_INVALID) errorOk(); + + jerry_value_t updateFn = moduleBaseGetProp(MODULE_SCENE_CURRENT, "update"); + if(!jerry_value_is_function(updateFn)) { + jerry_value_free(updateFn); + errorOk(); + } + + errorret_t ret = scriptManagerCallValue( + MODULE_SCENE_CURRENT, updateFn, "Scene module", "update" + ); + jerry_value_free(updateFn); + errorChain(ret); + errorOk(); +} diff --git a/src/dusk/script/module/scene/modulescene.h b/src/dusk/script/module/scene/modulescene.h index 6aaf18a7..c44b9b3d 100644 --- a/src/dusk/script/module/scene/modulescene.h +++ b/src/dusk/script/module/scene/modulescene.h @@ -6,6 +6,7 @@ */ #pragma once +#include "error/error.h" #include "script/scriptproto.h" #include "scene/scenebase.h" #include @@ -27,7 +28,8 @@ extern scriptproto_t MODULE_SCENE_PROTO; void moduleSceneInit(void); /** - * Disposes the Scene class's script resources. + * Disposes the Scene class's script resources, including calling + * dispose() on and freeing whatever module Scene.set() last installed. */ void moduleSceneDispose(void); @@ -39,3 +41,21 @@ void moduleSceneDispose(void); * @return The wrapped handle, or NULL if this_value isn't a Scene. */ modulescenehandle_t *moduleSceneGet(const jerry_call_info_t *callInfo); + +/** + * Internal. Tears down whatever module Scene.set() currently has + * installed, if any: calls its dispose() (errors are logged, not + * propagated), destroys the scene it owns, and frees the held + * reference. No-op if nothing is installed. + */ +void moduleSceneTeardownCurrent(void); + +/** + * Calls update() on whichever module Scene.set() last installed, if + * any, and if it defines one. No-op if Scene.set() has never been + * called. Called once per frame by engineUpdate(). + * + * @return The error return value. An error is thrown if update() itself + * throws, or if it returns a rejected promise. + */ +errorret_t moduleSceneUpdateCurrent(void); diff --git a/src/dusk/script/scriptmanager.c b/src/dusk/script/scriptmanager.c index 7ed3f2f9..4c138f8f 100644 --- a/src/dusk/script/scriptmanager.c +++ b/src/dusk/script/scriptmanager.c @@ -13,6 +13,7 @@ #include "util/string.h" #include "scriptproto.h" #include "script/module/modulelist.h" +#include "script/module/require/modulerequire.h" scriptmanager_t SCRIPT_MANAGER; @@ -82,8 +83,13 @@ errorret_t scriptManagerExecFile( src[size] = '\0'; memoryFree(buffer); + char_t dir[ASSET_FILE_NAME_MAX]; + moduleRequireDirname(fname, dir, sizeof(dir)); + moduleRequireDirPush(dir); + errorret_t ret = scriptManagerExec(src, resultOut); memoryFree(src); + moduleRequireDirPop(); errorChain(ret); errorOk(); } @@ -101,13 +107,24 @@ errorret_t scriptManagerCallGlobal(const char_t *name) { errorOk(); } - jerry_value_t result = jerry_call(fn, jerry_undefined(), NULL, 0); + errorret_t ret = scriptManagerCallValue( + jerry_undefined(), fn, "Global function", name + ); jerry_value_free(fn); + errorChain(ret); + errorOk(); +} + +errorret_t scriptManagerCallValue( + const jerry_value_t thisArg, + const jerry_value_t fn, + const char_t *context, + const char_t *name +) { + jerry_value_t result = jerry_call(fn, thisArg, NULL, 0); if(jerry_value_is_exception(result)) { - errorret_t err = scriptManagerFormatException( - "Global function", name, result - ); + errorret_t err = scriptManagerFormatException(context, name, result); jerry_value_free(result); errorChain(err); } @@ -131,7 +148,7 @@ errorret_t scriptManagerCallGlobal(const char_t *name) { if(jerry_promise_state(result) == JERRY_PROMISE_STATE_REJECTED) { jerry_value_t rejectVal = jerry_promise_result(result); errorret_t err = scriptManagerFormatValueError( - "Global async function", name, rejectVal + context, name, rejectVal ); jerry_value_free(rejectVal); jerry_value_free(result); @@ -144,6 +161,7 @@ errorret_t scriptManagerCallGlobal(const char_t *name) { } errorret_t scriptManagerDispose(void) { + moduleListDispose(); scriptProtoDisposeAll(); for(uint8_t i = 0; i < SCRIPT_MANAGER.globalKeyCacheCount; i++) { diff --git a/src/dusk/script/scriptmanager.h b/src/dusk/script/scriptmanager.h index c8ae7097..c787a69e 100644 --- a/src/dusk/script/scriptmanager.h +++ b/src/dusk/script/scriptmanager.h @@ -50,7 +50,10 @@ errorret_t scriptManagerInit(void); errorret_t scriptManagerExec(const char_t *script, jerry_value_t *result); /** - * Execute a JS file in the active script context. + * Execute a JS file in the active script context. While the file runs, + * fname's own directory is pushed as the require() base directory, so a + * top-level entry script (e.g. "scripts/init.js") can use relative + * require('./foo.js') calls the same way a required file can. * * @param fname The filename of the script to execute. * @param result Optional out-parameter for the script's return value. @@ -81,6 +84,27 @@ errorret_t scriptManagerExecFile( */ errorret_t scriptManagerCallGlobal(const char_t *name); +/** + * Calls an already-resolved JS function value with the given `this`, + * handling a thrown exception and draining a returned promise to + * completion (same async-await semantics as scriptManagerCallGlobal). + * Caller is responsible for checking jerry_value_is_function(fn) first + * and for freeing both thisArg and fn afterward. + * + * @param thisArg The value bound to `this` inside the call. + * @param fn The function value to call. + * @param context Short label for error messages, e.g. "Scene module". + * @param name The name of the function being called, for error messages. + * @return The error return value. An error is thrown if the JS function + * itself throws, or if its returned promise rejects. + */ +errorret_t scriptManagerCallValue( + const jerry_value_t thisArg, + const jerry_value_t fn, + const char_t *context, + const char_t *name +); + /** * Dispose of the script manager. * diff --git a/src/dusk/ui/debug/uiconsole.c b/src/dusk/ui/debug/uiconsole.c index 29af32ca..88e3dc2b 100644 --- a/src/dusk/ui/debug/uiconsole.c +++ b/src/dusk/ui/debug/uiconsole.c @@ -6,20 +6,19 @@ */ #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 "display/mesh/quad.h" #include "util/memory.h" typedef struct { mesh_t mesh; bool_t built; - meshvertex_t *vertices; - int32_t capacity; + meshvertex_t vertices[UI_CONSOLE_CACHE_VERTEX_MAX]; int32_t vertexCount; int32_t cachedScanX; int32_t cachedScanY; @@ -62,22 +61,12 @@ errorret_t uiConsoleRebuild(void) { } } + assertTrue( + spriteCount <= UI_CONSOLE_CACHE_GLYPH_MAX, + "Console history exceeds fixed cache capacity" + ); + const int32_t vertexCount = spriteCount * QUAD_VERTEX_COUNT; - if(vertexCount > UI_CONSOLE_CACHE.capacity) { - if(UI_CONSOLE_CACHE.built) { - errorChain(meshDispose(&UI_CONSOLE_CACHE.mesh)); - UI_CONSOLE_CACHE.built = false; - } - - if(UI_CONSOLE_CACHE.vertices != NULL) { - memoryFree(UI_CONSOLE_CACHE.vertices); - } - - UI_CONSOLE_CACHE.vertices = (meshvertex_t *)memoryAllocate( - sizeof(meshvertex_t) * vertexCount - ); - UI_CONSOLE_CACHE.capacity = vertexCount; - } UI_CONSOLE_CACHE.vertexCount = vertexCount; if(vertexCount > 0) { @@ -124,7 +113,7 @@ errorret_t uiConsoleRebuild(void) { errorChain(meshInit( &UI_CONSOLE_CACHE.mesh, QUAD_PRIMITIVE_TYPE, - UI_CONSOLE_CACHE.capacity, + UI_CONSOLE_CACHE_VERTEX_MAX, UI_CONSOLE_CACHE.vertices )); UI_CONSOLE_CACHE.built = true; @@ -144,10 +133,6 @@ errorret_t uiConsoleDispose(void) { errorChain(meshDispose(&UI_CONSOLE_CACHE.mesh)); } - if(UI_CONSOLE_CACHE.vertices != NULL) { - memoryFree(UI_CONSOLE_CACHE.vertices); - } - memoryZero(&UI_CONSOLE_CACHE, sizeof(uiconsolecache_t)); errorOk(); } diff --git a/src/dusk/ui/debug/uiconsole.h b/src/dusk/ui/debug/uiconsole.h index dc031a07..9c72fed6 100644 --- a/src/dusk/ui/debug/uiconsole.h +++ b/src/dusk/ui/debug/uiconsole.h @@ -7,6 +7,16 @@ #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 @@ -22,6 +32,9 @@ 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. */ diff --git a/src/dusk/ui/debug/uifps.c b/src/dusk/ui/debug/uifps.c index 796fdc49..dc781575 100644 --- a/src/dusk/ui/debug/uifps.c +++ b/src/dusk/ui/debug/uifps.c @@ -59,7 +59,9 @@ errorret_t uiFPSDraw() { } uiLabelSetColor(&UIFPS.fpsLabel, textColor); - uiLabelSetText(&UIFPS.fpsLabel, fpsText); + if(stringCompare(fpsText, UIFPS.fpsLabel.text) != 0) { + uiLabelSetText(&UIFPS.fpsLabel, fpsText); + } errorChain(uiLabelDraw( &UIFPS.fpsLabel, (float_t)SCREEN.scanX, (float_t)SCREEN.scanY )); diff --git a/src/dusk/ui/frame/settings/uisettings.c b/src/dusk/ui/frame/settings/uisettings.c index 03423be5..32c08099 100644 --- a/src/dusk/ui/frame/settings/uisettings.c +++ b/src/dusk/ui/frame/settings/uisettings.c @@ -148,7 +148,7 @@ errorret_t uiSettingsDraw(void) { const float_t y = (float_t)SCREEN.scanY + ((float_t)SCREEN.scanHeight - height) * 0.5f; - errorChain(uiFrameDraw(x, y, width, height)); + 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; diff --git a/src/dusk/ui/frame/settings/uisettings.h b/src/dusk/ui/frame/settings/uisettings.h index 6b41f568..58d50f17 100644 --- a/src/dusk/ui/frame/settings/uisettings.h +++ b/src/dusk/ui/frame/settings/uisettings.h @@ -8,6 +8,7 @@ #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 @@ -29,6 +30,7 @@ typedef struct { 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; diff --git a/src/dusk/ui/frame/uiconfirm.c b/src/dusk/ui/frame/uiconfirm.c index 740fb9e5..0c8ea92e 100644 --- a/src/dusk/ui/frame/uiconfirm.c +++ b/src/dusk/ui/frame/uiconfirm.c @@ -82,7 +82,7 @@ errorret_t uiConfirmDraw(void) { 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(uiFrameDraw(x, y, width, height)); + 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; diff --git a/src/dusk/ui/frame/uiconfirm.h b/src/dusk/ui/frame/uiconfirm.h index 73b362c4..702e1cdb 100644 --- a/src/dusk/ui/frame/uiconfirm.h +++ b/src/dusk/ui/frame/uiconfirm.h @@ -9,6 +9,7 @@ #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 @@ -31,6 +32,7 @@ typedef struct { uiconfirmcallback_t callback; void *user; bool_t result; + uiframecache_t frameCache; } uiconfirm_t; extern uiconfirm_t UI_CONFIRM; diff --git a/src/dusk/ui/frame/uiframe.c b/src/dusk/ui/frame/uiframe.c index 5202b3e1..9166d4c2 100644 --- a/src/dusk/ui/frame/uiframe.c +++ b/src/dusk/ui/frame/uiframe.c @@ -72,6 +72,17 @@ errorret_t uiFrameDraw( } }; 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; @@ -112,8 +123,37 @@ errorret_t uiFrameDraw( &UI_FRAME.tileset, 2, 2, x + width - tileW, y + height - tileH, tileW, tileH ); +} - return spriteBatchBuffer(sprites, 9, &SHADER_UNLIT, material); +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) { diff --git a/src/dusk/ui/frame/uiframe.h b/src/dusk/ui/frame/uiframe.h index 626afd64..b00c764a 100644 --- a/src/dusk/ui/frame/uiframe.h +++ b/src/dusk/ui/frame/uiframe.h @@ -9,6 +9,7 @@ #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 @@ -56,6 +57,63 @@ errorret_t uiFrameDraw( 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. * diff --git a/src/dusk/ui/widget/uislider.c b/src/dusk/ui/widget/uislider.c index ada9e6d8..7c38db48 100644 --- a/src/dusk/ui/widget/uislider.c +++ b/src/dusk/ui/widget/uislider.c @@ -39,6 +39,7 @@ void uiSliderInitFloat( slider->step.f = step; slider->value.f = mathClamp(value, min, max); uiSliderRebuildValueLabel(slider); + uiSliderRebuildGeometry(slider); } void uiSliderInitInt( @@ -64,6 +65,7 @@ void uiSliderInitInt( slider->step.i = step; slider->value.i = mathClamp(value, min, max); uiSliderRebuildValueLabel(slider); + uiSliderRebuildGeometry(slider); } float_t uiSliderGetFloat(const uislider_t *slider) { @@ -87,6 +89,7 @@ void uiSliderSetFloat(uislider_t *slider, const float_t value) { ); slider->value.f = mathClamp(value, slider->min.f, slider->max.f); uiSliderRebuildValueLabel(slider); + uiSliderRebuildGeometry(slider); } void uiSliderSetInt(uislider_t *slider, const int32_t value) { @@ -96,6 +99,7 @@ void uiSliderSetInt(uislider_t *slider, const int32_t value) { ); slider->value.i = mathClamp(value, slider->min.i, slider->max.i); uiSliderRebuildValueLabel(slider); + uiSliderRebuildGeometry(slider); } void uiSliderStepUp(uislider_t *slider) { @@ -158,67 +162,36 @@ errorret_t uiSliderDraw( ) { assertNotNull(slider, "Slider cannot be NULL"); - color_t color = slider->highlighted ? COLOR_RED : COLOR_WHITE; - errorChain(uiWidgetLabelDraw(&slider->label, x, y)); - int32_t labelW, labelH; - uiWidgetLabelGetSize(&slider->label, &labelW, &labelH); - - float_t trackX = x + (float_t)labelW + UI_SLIDER_GAP; - float_t trackY = y + ((float_t)labelH - UI_SLIDER_TRACK_HEIGHT) * 0.5f; - - spritebatchsprite_t trackSprite = { - .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 } - }; shadermaterial_t trackMaterial = { .unlit = { .color = COLOR_DARK_GRAY, .texture = &TEXTURE_WHITE } }; - errorChain(spriteBatchBuffer(&trackSprite, 1, &SHADER_UNLIT, trackMaterial)); + spritebatchsprite_t track = spriteBatchSpriteTranslate( + &slider->cachedTrack, x, y + ); + errorChain(spriteBatchBuffer(&track, 1, &SHADER_UNLIT, trackMaterial)); - float_t fillWidth = UI_SLIDER_TRACK_WIDTH * uiSliderGetRatio(slider); - if(fillWidth > 0.0f) { - spritebatchsprite_t fillSprite = { - .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 } - }; + if(slider->cachedFillVisible) { shadermaterial_t fillMaterial = { .unlit = { - .color = color, + .color = slider->highlighted ? COLOR_RED : COLOR_WHITE, .texture = &TEXTURE_WHITE } }; - errorChain(spriteBatchBuffer(&fillSprite, 1, &SHADER_UNLIT, fillMaterial)); + spritebatchsprite_t fill = spriteBatchSpriteTranslate( + &slider->cachedFill, x, y + ); + errorChain(spriteBatchBuffer(&fill, 1, &SHADER_UNLIT, fillMaterial)); } - int32_t stepCount = uiSliderGetStepCount(slider); - if(stepCount > 0 && stepCount < UI_SLIDER_STEP_MARKERS_MAX) { + if(slider->cachedMarkerCount > 0) { spritebatchsprite_t markers[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; - markers[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 } - }; + for(int32_t i = 0; i < slider->cachedMarkerCount; i++) { + markers[i] = spriteBatchSpriteTranslate(&slider->cachedMarkers[i], x, y); } shadermaterial_t markerMaterial = { @@ -227,13 +200,15 @@ errorret_t uiSliderDraw( .texture = &TEXTURE_WHITE } }; - errorChain( - spriteBatchBuffer(markers, stepCount + 1, &SHADER_UNLIT, markerMaterial) - ); + errorChain(spriteBatchBuffer( + markers, slider->cachedMarkerCount, &SHADER_UNLIT, markerMaterial + )); } errorChain(uiWidgetLabelDraw( - &slider->valueLabel, trackX + UI_SLIDER_TRACK_WIDTH + UI_SLIDER_GAP, y + &slider->valueLabel, + x + slider->cachedTrack.max[0] + UI_SLIDER_GAP, + y )); errorOk(); @@ -252,3 +227,56 @@ void uiSliderRebuildValueLabel(uislider_t *slider) { } 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; + } +} diff --git a/src/dusk/ui/widget/uislider.h b/src/dusk/ui/widget/uislider.h index 8da1b5a0..37d55fed 100644 --- a/src/dusk/ui/widget/uislider.h +++ b/src/dusk/ui/widget/uislider.h @@ -8,6 +8,7 @@ #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 @@ -42,6 +43,16 @@ typedef struct { 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; /** @@ -192,3 +203,13 @@ errorret_t uiSliderDraw( * @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); diff --git a/src/dusk/ui/widget/uitab.c b/src/dusk/ui/widget/uitab.c index 684b2f02..7f1a72c5 100644 --- a/src/dusk/ui/widget/uitab.c +++ b/src/dusk/ui/widget/uitab.c @@ -19,6 +19,7 @@ void uiTabInit(uitab_t *tab, const char_t *label) { uiWidgetLabelInit(&tab->label, &FONT_DEFAULT); uiWidgetLabelSetText(&tab->label, label); tab->active = false; + uiTabRebuildBackground(tab); } bool_t uiTabIsActive(const uitab_t *tab) { @@ -38,15 +39,9 @@ errorret_t uiTabDraw( ) { assertNotNull(tab, "Tab cannot be NULL"); - int32_t labelW, labelH; - uiWidgetLabelGetSize(&tab->label, &labelW, &labelH); - - spritebatchsprite_t sprite = { - .min = { x, y, 0.0f }, - .max = { x + (float_t)labelW, y + (float_t)labelH, 0.0f }, - .uvMin = { 0.0f, 0.0f }, - .uvMax = { 1.0f, 1.0f } - }; + spritebatchsprite_t sprite = spriteBatchSpriteTranslate( + &tab->cachedBackground, x, y + ); shadermaterial_t material = { .unlit = { .color = tab->active ? COLOR_GREEN : COLOR_RED, @@ -59,3 +54,16 @@ errorret_t uiTabDraw( 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 } + }; +} diff --git a/src/dusk/ui/widget/uitab.h b/src/dusk/ui/widget/uitab.h index 2c5e3944..ddcb78d0 100644 --- a/src/dusk/ui/widget/uitab.h +++ b/src/dusk/ui/widget/uitab.h @@ -8,10 +8,16 @@ #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; /** @@ -52,3 +58,13 @@ errorret_t uiTabDraw( 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); diff --git a/src/duskrpg/game/game.c b/src/duskrpg/game/game.c index a2dd2d54..ff51dad5 100644 --- a/src/duskrpg/game/game.c +++ b/src/duskrpg/game/game.c @@ -9,7 +9,7 @@ #include "script/scriptmanager.h" errorret_t gameInit(void) { - errorChain(scriptManagerExecFile("scripts/overworldscene.js", NULL)); + errorChain(scriptManagerExecFile("scripts/init.js", NULL)); errorOk(); } diff --git a/src/duskrpg/ui/textbox/uitextbox.c b/src/duskrpg/ui/textbox/uitextbox.c index 69ab0f92..da34069a 100644 --- a/src/duskrpg/ui/textbox/uitextbox.c +++ b/src/duskrpg/ui/textbox/uitextbox.c @@ -33,6 +33,7 @@ void uiTextboxInit( box->maxLength = maxLength; box->lines = lines; box->linesMax = linesMax; + box->glyphsBuiltForPage = -1; } void uiTextboxSetText(uitextbox_t *box, const char_t *text) { @@ -43,6 +44,7 @@ void uiTextboxSetText(uitextbox_t *box, const char_t *text) { box->scroll = 0; box->layoutWidth = 0.0f; box->layoutHeight = 0.0f; + box->glyphsBuiltForPage = -1; } void uiTextboxBuildLayout( @@ -52,6 +54,7 @@ void uiTextboxBuildLayout( ) { assertNotNull(box, "Textbox cannot be NULL"); + box->glyphsBuiltForPage = -1; box->layoutWidth = width; box->layoutHeight = height; box->lineCount = 0; @@ -169,12 +172,30 @@ errorret_t uiTextboxDraw( uiTextboxBuildLayout(box, contentW, contentH); } - errorChain(uiFrameDraw(x, y, width, height)); + errorChain(uiFrameDrawCached(&box->frameCache, x, y, width, height)); if(box->lineCount == 0 || box->text[0] == '\0') errorOk(); - float_t fontW = (float_t)FONT_DEFAULT.tileset->tileWidth; - float_t fontH = (float_t)FONT_DEFAULT.tileset->tileHeight; + 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 = { @@ -182,34 +203,47 @@ errorret_t uiTextboxDraw( .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; - int32_t charsLeft = box->scroll; + float_t fontW = (float_t)FONT_DEFAULT.tileset->tileWidth; + float_t fontH = (float_t)FONT_DEFAULT.tileset->tileHeight; - for(int32_t li = pageFirst; li < pageLast && charsLeft > 0; li++) { + int32_t consumed = 0; + for(int32_t li = pageFirst; li < pageLast; li++) { uitextboxline_t *line = &box->lines[li]; - int32_t visible = line->count < charsLeft ? line->count : charsLeft; - float_t lineY = contentY + + float_t lineY = (float_t)(li - pageFirst) * (fontH + UI_TEXTBOX_LINE_SPACING); - for(int32_t ci = 0; ci < visible; ci++) { + for(int32_t ci = 0; ci < line->count; ci++) { + consumed++; char_t c = box->text[line->start + ci]; if(c == ' ') continue; - spritebatchsprite_t sprite = textGetSprite( - (vec2){ contentX + (float_t)ci * fontW, lineY }, - c, - &FONT_DEFAULT - ); - errorChain(spriteBatchBuffer(&sprite, 1, &SHADER_UNLIT, material)); - } - charsLeft -= visible; + 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; + } } - errorOk(); + box->glyphsBuiltForPage = box->currentPage; } int32_t uiTextboxGetPageCharCount(const uitextbox_t *box) { diff --git a/src/duskrpg/ui/textbox/uitextbox.h b/src/duskrpg/ui/textbox/uitextbox.h index 047e7e1f..74de3547 100644 --- a/src/duskrpg/ui/textbox/uitextbox.h +++ b/src/duskrpg/ui/textbox/uitextbox.h @@ -7,16 +7,34 @@ #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; @@ -34,6 +52,15 @@ typedef struct { 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; /** @@ -136,3 +163,13 @@ bool_t uiTextboxHasNextPage(const uitextbox_t *box); * @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); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 67a99d04..f48016b6 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -16,4 +16,5 @@ add_subdirectory(scene) # add_subdirectory(item) add_subdirectory(script) add_subdirectory(time) +add_subdirectory(ui) add_subdirectory(util) \ No newline at end of file diff --git a/test/display/CMakeLists.txt b/test/display/CMakeLists.txt index 47bf3628..71dc7132 100644 --- a/test/display/CMakeLists.txt +++ b/test/display/CMakeLists.txt @@ -6,4 +6,5 @@ include(dusktest) # Tests -dusktest(test_color.c) \ No newline at end of file +dusktest(test_color.c) +dusktest(test_spritebatchsprite.c) \ No newline at end of file diff --git a/test/display/test_spritebatchsprite.c b/test/display/test_spritebatchsprite.c new file mode 100644 index 00000000..e4ffd2ef --- /dev/null +++ b/test/display/test_spritebatchsprite.c @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "display/spritebatch/spritebatchsprite.h" + +static void test_spriteBatchSpriteTilesetPosition_create(void **state) { + tileset_t tileset = { + .tileWidth = 8, + .tileHeight = 8, + .tileCount = 4, + .columns = 2, + .rows = 2, + .uv = { 0.5f, 0.5f } + }; + + spritebatchsprite_t sprite = spriteBatchSpriteTilesetPosition( + &tileset, 1, 1, 10.0f, 20.0f, 8.0f, 8.0f + ); + + assert_float_equal(sprite.min[0], 10.0f, 0.0001f); + assert_float_equal(sprite.min[1], 20.0f, 0.0001f); + assert_float_equal(sprite.min[2], 0.0f, 0.0001f); + assert_float_equal(sprite.max[0], 18.0f, 0.0001f); + assert_float_equal(sprite.max[1], 28.0f, 0.0001f); + assert_float_equal(sprite.uvMin[0], 0.5f, 0.0001f); + assert_float_equal(sprite.uvMin[1], 0.5f, 0.0001f); + assert_float_equal(sprite.uvMax[0], 1.0f, 0.0001f); + assert_float_equal(sprite.uvMax[1], 1.0f, 0.0001f); +} + +static void test_spriteBatchSpriteTilesetPosition_zeroSize(void **state) { + tileset_t tileset = { + .tileWidth = 8, + .tileHeight = 8, + .tileCount = 1, + .columns = 1, + .rows = 1, + .uv = { 1.0f, 1.0f } + }; + + spritebatchsprite_t sprite = spriteBatchSpriteTilesetPosition( + &tileset, 0, 0, 10.0f, 20.0f, 0.0f, 8.0f + ); + + assert_float_equal(sprite.min[0], 0.0f, 0.0001f); + assert_float_equal(sprite.min[1], 0.0f, 0.0001f); + assert_float_equal(sprite.max[0], 0.0f, 0.0001f); + assert_float_equal(sprite.max[1], 0.0f, 0.0001f); +} + +static void test_spriteBatchSpriteTranslate(void **state) { + spritebatchsprite_t sprite = { + .min = { 1.0f, 2.0f, 0.0f }, + .max = { 5.0f, 6.0f, 0.0f }, + .uvMin = { 0.25f, 0.5f }, + .uvMax = { 0.75f, 1.0f } + }; + + spritebatchsprite_t translated = spriteBatchSpriteTranslate( + &sprite, 10.0f, -3.0f + ); + + assert_float_equal(translated.min[0], 11.0f, 0.0001f); + assert_float_equal(translated.min[1], -1.0f, 0.0001f); + assert_float_equal(translated.max[0], 15.0f, 0.0001f); + assert_float_equal(translated.max[1], 3.0f, 0.0001f); + + // UVs must be untouched by translation. + assert_float_equal(translated.uvMin[0], 0.25f, 0.0001f); + assert_float_equal(translated.uvMin[1], 0.5f, 0.0001f); + assert_float_equal(translated.uvMax[0], 0.75f, 0.0001f); + assert_float_equal(translated.uvMax[1], 1.0f, 0.0001f); + + // Original sprite must be untouched. + assert_float_equal(sprite.min[0], 1.0f, 0.0001f); + assert_float_equal(sprite.min[1], 2.0f, 0.0001f); +} + +int main(int argc, char **argv) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_spriteBatchSpriteTilesetPosition_create), + cmocka_unit_test(test_spriteBatchSpriteTilesetPosition_zeroSize), + cmocka_unit_test(test_spriteBatchSpriteTranslate), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/script/test_overworldscene.c b/test/script/test_overworldscene.c index 4657de4c..c86a9d89 100644 --- a/test/script/test_overworldscene.c +++ b/test/script/test_overworldscene.c @@ -17,20 +17,18 @@ #include "display/mesh/plane.h" #include "display/mesh/capsule.h" #include "script/scriptmanager.h" +#include "script/module/scene/modulescene.h" #include #include +#include #ifndef DUSK_ASSETS_DIR #error "DUSK_ASSETS_DIR must be defined" #endif -// Reads the real, shipped overworldscene.js from disk (not a copy embedded -// in this test) so this test actually verifies what ships. -static char_t *readScriptSource(void) { +static char_t *readFile(const char_t *relativePath) { char_t path[512]; - snprintf( - path, sizeof(path), "%s/scripts/overworldscene.js", DUSK_ASSETS_DIR - ); + snprintf(path, sizeof(path), "%s/%s", DUSK_ASSETS_DIR, relativePath); FILE *f = fopen(path, "rb"); assert_non_null(f); @@ -47,6 +45,26 @@ static char_t *readScriptSource(void) { return buf; } +// Runs the real, shipped overworldscene.js through Scene.set() the same +// way require()/init.js would -- not a copy embedded in this test, so +// this actually verifies what ships. +static errorret_t installOverworldScene(void) { + char_t *fileSrc = readFile("scripts/overworldscene.js"); + + const char_t *prefix = "var module = { exports: {} };\n(function(module){\n"; + const char_t *suffix = "\n})(module);\nScene.set(module.exports);"; + size_t wrappedLen = strlen(prefix) + strlen(fileSrc) + strlen(suffix); + char_t *wrapped = (char_t *)memoryAllocate(wrappedLen + 1); + snprintf( + wrapped, wrappedLen + 1, "%s%s%s", prefix, fileSrc, suffix + ); + memoryFree(fileSrc); + + errorret_t ret = scriptManagerExec(wrapped, NULL); + memoryFree(wrapped); + return ret; +} + static int overworld_setup(void **state) { sceneInit(); @@ -71,9 +89,7 @@ static int overworld_teardown(void **state) { } static void test_overworldscene_builds_expected_entities(void **state) { - char_t *src = readScriptSource(); - errorret_t ret = scriptManagerExec(src, NULL); - memoryFree(src); + errorret_t ret = installOverworldScene(); assert_true(errorIsOk(ret)); sceneid_t sceneId = sceneGetActive(); @@ -195,9 +211,7 @@ static void test_overworldscene_builds_expected_entities(void **state) { } static void test_overworldscene_update_orbits_camera(void **state) { - char_t *src = readScriptSource(); - errorret_t ret = scriptManagerExec(src, NULL); - memoryFree(src); + errorret_t ret = installOverworldScene(); assert_true(errorIsOk(ret)); sceneid_t sceneId = sceneGetActive(); @@ -210,7 +224,7 @@ static void test_overworldscene_update_orbits_camera(void **state) { // Drive one frame with a known delta and confirm the camera orbited by // exactly angle = delta * speed (0.1 * 0.5 = 0.05 rad). TIME.delta = 0.1f; - ret = scriptManagerCallGlobal("update"); + ret = moduleSceneUpdateCurrent(); assert_true(errorIsOk(ret)); vec3 camPosition; @@ -227,6 +241,66 @@ static void test_overworldscene_update_orbits_camera(void **state) { TIME.delta = 0.0f; } +static void test_scene_set_switches_and_disposes(void **state) { + errorret_t ret = installOverworldScene(); + assert_true(errorIsOk(ret)); + assert_true(sceneGetActive() != SCENE_ID_INVALID); + + // Install a second, trivial scene module in place of the first. This + // must: call the first module's dispose(), destroy its scene, then + // create+activate a new one and call the new module's init(). + ret = scriptManagerExec( + "var switchState = { updateCount: 0, disposed: false };\n" + "Scene.set({\n" + " init: function() { var e = new Entity(); e.add(POSITION); },\n" + " update: function() { switchState.updateCount++; },\n" + " dispose: function() { switchState.disposed = true; }\n" + "});", + NULL + ); + assert_true(errorIsOk(ret)); + + sceneid_t secondSceneId = sceneGetActive(); + assert_true(secondSceneId != SCENE_ID_INVALID); + + entitymanager_t *mgr = sceneGetEntities(secondSceneId); + assert_true( + entityGetComponent(mgr, 0, COMPONENT_TYPE_POSITION) != + COMPONENT_ID_INVALID + ); + // The first module's plane entity (entity 1, POSITION+PHYSICS+ + // RENDERABLE) must be gone -- proves the old scene was actually torn + // down, not just deactivated. (Scene IDs are a small reused pool -- + // SCENE_COUNT_MAX slots -- so secondSceneId == firstSceneId here is + // expected, not a bug: sceneCreate() picks the first free slot, and + // destroying firstSceneId frees that exact slot right back up.) + assert_true( + entityGetComponent(mgr, 1, COMPONENT_TYPE_POSITION) == + COMPONENT_ID_INVALID + ); + + ret = moduleSceneUpdateCurrent(); + assert_true(errorIsOk(ret)); + + jerry_value_t result; + ret = scriptManagerExec("switchState.updateCount", &result); + assert_true(errorIsOk(ret)); + assert_true(jerry_value_is_number(result)); + assert_int_equal((int)jerry_value_as_number(result), 1); + jerry_value_free(result); + + // Switching again must call the second module's dispose(). + ret = scriptManagerExec( + "Scene.set({ init: function() {} });", NULL + ); + assert_true(errorIsOk(ret)); + + ret = scriptManagerExec("switchState.disposed", &result); + assert_true(errorIsOk(ret)); + assert_true(jerry_value_is_true(result)); + jerry_value_free(result); +} + int main(void) { assertInit(); const struct CMUnitTest tests[] = { @@ -238,6 +312,10 @@ int main(void) { test_overworldscene_update_orbits_camera, overworld_setup, overworld_teardown ), + cmocka_unit_test_setup_teardown( + test_scene_set_switches_and_disposes, + overworld_setup, overworld_teardown + ), }; return cmocka_run_group_tests(tests, NULL, NULL); } diff --git a/test/ui/CMakeLists.txt b/test/ui/CMakeLists.txt new file mode 100644 index 00000000..c4b05c65 --- /dev/null +++ b/test/ui/CMakeLists.txt @@ -0,0 +1,11 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +include(dusktest) + +# Tests +dusktest(test_uiframe.c) +dusktest(test_uislider.c) +dusktest(test_uitab.c) diff --git a/test/ui/test_uiframe.c b/test/ui/test_uiframe.c new file mode 100644 index 00000000..f98d8ec0 --- /dev/null +++ b/test/ui/test_uiframe.c @@ -0,0 +1,68 @@ +/** + * 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); +} diff --git a/test/ui/test_uislider.c b/test/ui/test_uislider.c new file mode 100644 index 00000000..8109f982 --- /dev/null +++ b/test/ui/test_uislider.c @@ -0,0 +1,114 @@ +/** + * 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); +} diff --git a/test/ui/test_uitab.c b/test/ui/test_uitab.c new file mode 100644 index 00000000..a34c33b1 --- /dev/null +++ b/test/ui/test_uitab.c @@ -0,0 +1,36 @@ +/** + * 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); +} diff --git a/types/scene/scene.d.ts b/types/scene/scene.d.ts index 4eb14198..043f62ed 100644 --- a/types/scene/scene.d.ts +++ b/types/scene/scene.d.ts @@ -24,4 +24,37 @@ declare class Scene { /** Gets the currently active scene, or undefined if none is active. */ static getActive(): Scene | undefined; + + /** + * Installs a scene module as the current scene, the mechanism for + * switching scenes from script. Tears down whatever module a previous + * Scene.set() call installed (calling its dispose(), then destroying + * the scene it owned), then creates and activates a fresh Scene and + * calls the new module's init() with it active -- so init() can use + * `new Entity()`/etc. as usual. The module's update() (if defined) is + * then called once per engine frame until Scene.set() is called again. + * + * Typical usage (see require.d.ts): + * ```js + * var myScene = require('./myscene.js'); + * Scene.set(myScene); + * ``` + */ + static set(module: SceneModule): void; +} + +/** + * The shape a scene module's `module.exports` should have to be usable + * with Scene.set(). All three are optional -- a module that never moves + * anything, for instance, can omit update(). + */ +interface SceneModule { + /** Called once, right after Scene.set() creates and activates the scene. */ + init?(): void; + + /** Called once per engine frame while this module is the active scene. */ + update?(): void; + + /** Called once when this module is replaced by another Scene.set() call. */ + dispose?(): void; }