# 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.