WIP: commit old refactor work from laptop

This commit is contained in:
Tuomas Katajisto 2026-03-11 12:59:45 +02:00
parent 3a7f19c89d
commit 1de37e8719
8 changed files with 963 additions and 228 deletions

508
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,508 @@
# Trueno Engine — Architecture Overview
**Version:** 0.6
**Language:** Jai
**Target platforms:** macOS (native), Linux (native), WebAssembly (via Emscripten)
---
## High-Level Structure
Trueno is a purpose-built 3D game engine for a beach volleyball game. The codebase is split into
three clear layers:
```
┌─────────────────────────────────────────────────┐
│ game/ │ Game logic (volleyball rules, players, state)
├─────────────────────────────────────────────────┤
│ src/ │ Engine systems
├─────────────────────────────────────────────────┤
│ sokol / stb_image / GetRect / Jaison / ... │ Third-party modules/
└─────────────────────────────────────────────────┘
```
The engine layer (`src/`) owns the main loop, rendering, audio, asset loading, and editor. The
game layer (`game/`) only implements game-specific logic and calls into the engine. There is no
formal interface between the two — they share a single Jai compilation unit — but the separation
is clean in practice.
---
## Entry Points and the Frame Loop
```
sokol_app
└─ init() -- one-shot startup
└─ frame() -- called every display frame
└─ event() -- input / window events
└─ cleanup() -- shutdown
```
`src/platform_specific/` contains two thin files: `main_native.jai` (desktop) and `main_web.jai`
(WASM). Both call into `common.jai` which sets up sokol and then calls the engine's `init()`,
`frame()`, `event()`, `cleanup()` in `src/main.jai`.
### Deferred Initialization
Engine startup is split into three phases to handle async asset loading:
```
frame() tick 1..N
├─ asset_manager_tick() ← drives sokol_fetch async I/O
├─ mandatory_loads_done()? ──no─→ return (blocks entire frame)
│ yes
├─ init_after_mandatory() ← (currently empty, reserved)
├─ show_loading_screen()? ──yes→ return (shows loading UI)
│ no
└─ init_after_core() ← init UI, audio, game, pipelines
```
The two boolean flags (`init_after_mandatory_done`, `init_after_core_done`) ensure each phase
runs exactly once after its prerequisite packs have loaded.
### Fixed Timestep Game Loop
```jai
delta_time_accumulator += delta_time;
while delta_time_accumulator > (1.0/60.0) {
game_tick(1.0/60.0);
delta_time_accumulator -= (1.0/60.0);
}
```
Game logic runs at a fixed 60 Hz regardless of render frame rate. Rendering always runs at the
display's native rate. The editor bypasses the accumulator entirely — when `in_editor_view` is
true, no game ticks run.
---
## Directory Map
```
beachgame/
├── first.jai Build metaprogram (compile-time build script)
├── build.sh / build_web.sh Shell wrappers around the Jai compiler
├── src/
│ ├── main.jai Engine core: init, frame loop, window info
│ ├── events.jai Sokol event dispatch → Input module
│ ├── load.jai Asset loading flags, init state
│ ├── time.jai get_time() / get_apollo_time() abstraction
│ ├── trile.jai Trile/Trixel/Material types, JSON serialization
│ ├── world.jai World/chunk data model, binary serialization
│ ├── ray.jai Ray-casting helpers
│ ├── utils.jai sign(), mix() math helpers
│ ├── profiling.jai Frame-level profiling points
│ ├── buffers.jai
│ ├── shapes.jai
│ │
│ ├── assets/
│ │ ├── asset_manager.jai Async fetch queue, pack/world/RDM loading
│ │ ├── loaders.jai PNG / WAV / string loaders from raw memory
│ │ └── rdm_loader.jai RDM chunk streaming enqueue/cancel
│ │
│ ├── audio/
│ │ ├── audio.jai Audio init/cleanup, Audio_Data type
│ │ ├── backend.jai Sokol audio stream callback bridge
│ │ ├── load.jai WAV loading helpers
│ │ └── mixer.jai Software float32 mixer (buses, tasks, mutex)
│ │
│ ├── rendering/
│ │ ├── rendering.jai Module root, window-resize handler
│ │ ├── core.jai init_rendering(), render() entry point
│ │ ├── tasks.jai Rendering_Task types and task→command conversion
│ │ ├── backend.jai Command bucket types, process_command_buckets()
│ │ ├── backend_sokol.jai All sokol draw call implementations
│ │ ├── backend_sokol_helpers.jai
│ │ ├── pipelines.jai Pipeline creation, all render target images
│ │ ├── camera.jai Camera struct, perspective/lookat math
│ │ ├── meshgen.jai Quad mesh generation from trile voxel data
│ │ ├── animation.jai Sprite animation player, Aseprite JSON structs
│ │ ├── helpers.jai create_world_rendering_tasks(), uniform helpers
│ │ ├── arbtri.jai Immediate-mode triangle batch renderer (UI)
│ │ ├── post_processing.jai Post-process config, LUT management
│ │ ├── sky.jai Sky rendering helpers
│ │ └── ssao.jai SSAO kernel/noise generation
│ │
│ ├── editor/
│ │ ├── editor.jai Editor root: three views, F3 toggle
│ │ ├── console.jai In-game console (F1), command dispatch
│ │ ├── level_editor.jai Level editing camera and trile placement tools
│ │ ├── trile_editor.jai Voxel painting editor
│ │ ├── tacoma.jai Path tracer (Tacoma) integration
│ │ ├── no_tacoma.jai Stub when Tacoma is unavailable
│ │ ├── rdm_disk.jai RDM bake result disk I/O
│ │ ├── picker.jai Mouse picker
│ │ ├── iprof.jai Iprof profiler integration
│ │ └── textureDebugger.jai Texture visualization tool
│ │
│ ├── input/
│ │ └── hotkeys.jai
│ │
│ ├── ui/
│ │ ├── ui.jai UI backend: GetRect integration, font, triangles
│ │ ├── component_themes.jai Custom UI themes
│ │ └── autoedit.jai Reflection-based automatic struct editors
│ │
│ ├── pseudophysics/
│ │ ├── core.jai
│ │ └── colliders.jai Rect-circle collision
│ │
│ ├── platform_specific/
│ │ ├── common.jai Sokol imports, sapp_run, context/temp storage
│ │ ├── main_native.jai Desktop entry point
│ │ ├── main_web.jai WASM entry point
│ │ ├── main.c C glue for Emscripten link
│ │ ├── runtime.js JS runtime helpers
│ │ └── shell.html WASM HTML shell
│ │
│ ├── meta/
│ │ ├── meta.jai Metaprogram: message handler entry point
│ │ ├── pack.jai Asset pack creation at build time
│ │ ├── shaderload.jai Adds compiled shaders to workspace
│ │ ├── lint.jai Compile-time snake_case linter
│ │ ├── console_commands.jai @Command auto-registration code generator
│ │ ├── ascii.jai ASCII art logo
│ │ └── hacks.jai Metaprogram workarounds
│ │
│ └── shaders/
│ ├── *.glsl GLSL shader sources (~12 shaders)
│ ├── jai/*.jai Compiled shader descriptors (sokol-shdc output)
│ └── compile_shaders*.sh
├── game/
│ ├── game.jai Game init, fixed-step tick, draw
│ ├── player.jai Player struct and input handling
│ └── state.jai Game state machine and scoring
├── modules/ Third-party / custom Jai modules
│ ├── sokol-jai/ Jai bindings for sokol (gfx, app, audio, fetch)
│ ├── stb_image/ stb_image bindings
│ ├── Jaison/ JSON parsing/serialization
│ ├── Simple_Package_Reader/ .pack file reader
│ ├── Input/ Input module with sokol bindings
│ ├── Tacoma/ Path tracer integration
│ └── Walloc.jai WASM allocator
├── resources/ Engine-level assets (fonts, textures, audio)
├── game/resources/ Game-level assets (worlds, sprites, audio)
└── packs/ Compiled .pack files (build output)
```
---
## Core Systems
### Asset Manager
All file I/O flows through a single sequential fetch queue backed by `sokol_fetch`. Only one file
is ever in-flight at a time.
```
load_pack() / load_world()
fetch_queue [..]Fetch_Request
▼ (asset_manager_tick, once per frame)
sfetch_send() ──async──► fetch_callback()
resolve by Fetch_Type
┌─────────┬──────────┬────────────┐
PACK WORLD RDM_ATLAS RDM_LOOKUP
│ │ │ │
Loaded_Pack World sg_make_image sg_make_image
in loadedPacks in current_world in chunk
```
Static pre-allocated I/O buffers are used for each fetch type (200 MB for packs/worlds, smaller
buffers for RDM data) so no runtime heap allocation occurs during file loading.
**Blocking modes:**
- `should_block_engine` — halt entire frame (used for the boot pack; engine cannot run without it)
- `should_block` — show loading screen (used for core/game packs)
### Rendering Pipeline
The rendering system uses a three-stage data flow:
```
Game/Editor code
│ add_rendering_task()
[Rendering_Task list] (per-frame, temp allocated)
│ tasks_to_commands()
[Command Buckets] setup / shadow / reflection / gbuffer / main / ui
│ backend_process_command_buckets()
Sokol GFX draw calls
```
**Render passes per frame (in order):**
| Pass | Output | Notes |
|--------------|--------------------------------|----------------------------------------|
| Setup | GPU position/uniform buffers | Uploads trile instance stream data |
| Shadow | 1000×1000 depth texture | Orthographic sun directional light; PCF soft edges (Gaussian-weighted sampling) |
| Reflection | Half-res RGBA32F texture | Planar water reflection: camera Y-flipped around water plane; handles dynamic objects that RDMs cannot |
| G-Buffer | Position + Normal RGBA16F | Used by SSAO and deferred effects |
| SSAO | Single-channel AO texture | Random sample kernel, 64 samples |
| SSAO Blur | Blurred AO texture | Separable blur |
| Main | RGBA32F HDR target | Forward render: sun direct + RDM indirect (specular + diffuse) + SSAO |
| Post chain | Bloom → DoF ping-pong | Between postprocess_a / postprocess_b |
| Final | Swapchain | Tonemap, LUT grade, vignette, grain |
**Trile rendering** uses GPU instancing. Each trile type's mesh is generated once (quad mesher,
`src/rendering/meshgen.jai`) and uploaded as a static vertex buffer. Per-frame instance
positions are streamed via `sg_append_buffer` into a dynamic buffer, with one draw call per
trile type per pass.
Mesh generation merges visible trixel faces into larger coplanar quads (greedy meshing), reducing
triangle count dramatically vs. rendering each trixel individually. The mesh triangles no longer
correspond 1:1 to trixels, so the shader resolves the trixel under a fragment by offsetting the
surface position slightly inward along the normal to land cleanly inside the trixel grid.
**Trixel material encoding:** each trixel stores 4 bytes — 3 bytes of RGB albedo and 1 byte
packed as: roughness (3 bits → 8 levels), metallic (2 bits → 4 levels), light emission (2 bits →
4 levels), plus 1 reserved bit. All material values map to the [0 … 1] range in the shader.
**RDM (Rhombic Dodecahedron Mapping)** is the pre-baked indirect lighting system for static
trile geometry. Each chunk optionally carries an `rdm_atlas` (RGBA32F 4096×4096) and an
`rdm_lookup` (RGBA32F 512×512) GPU texture. The trile shader uses these for indirect diffuse
and specular, falling back to flat ambient when unavailable.
The RDM concept: because all trile surface normals are axis-aligned, incoming light for a block
can be captured by storing exactly one hemisphere per face (6 total). Each hemisphere is encoded
with the **hemi-oct** projection (Cigolle et al. 2014) — the hemisphere is folded into a pyramid
and then flattened to a square. The six hemisphere squares are arranged in a 2×3 grid; together
they form a rhombic dodecahedron shape, giving the technique its name.
Each hemisphere stores both **radiance** (RGBA32F) and **depth-to-first-hit** in the same
texel, encoded via a 4-channel HDR image (3 colour channels + 1 depth channel). The depth
is used at runtime for parallax-corrected sampling: a ray is stepped outward from the surface
point to find where it would intersect the geometry visible in the stored hemisphere, correcting
the angular discrepancy between the hemisphere's centre-of-face origin and the actual shading
point.
**Multiple roughness levels:** the Tacoma path tracer pre-filters each RDM for 8 discrete
roughness values. Hemisphere resolution scales as 3·2^roughness × 2·2^roughness pixels, so
rougher (blurrier) RDMs are physically smaller. The maximum-roughness RDM (always baked)
doubles as the diffuse irradiance probe. Per-instance transform data passed alongside the GPU
instancing matrices encodes atlas UV offsets for roughness levels 17; the maximum-roughness
position is looked up separately from the lookup texture.
**Specular evaluation:** the shader selects the RDM for the fragment's roughness, finds the
reflected view direction, steps the reflection vector to find the correct sampling direction via
stored depth, samples the hemisphere, then combines the result with a pre-computed BRDF
split-sum lookup (indexed by roughness and NoV) to form the full split-sum specular.
**Diffuse evaluation:** the shader reads the maximum-roughness RDMs for the block and up to
three adjacent blocks (determined by which quadrant of the block face the fragment lies in),
samples each in the surface normal direction, and bilinearly blends the values. Missing
neighbours fall back to a static ambient constant.
**RDM streaming:** RDM data is loaded per chunk on demand via `src/assets/rdm_loader.jai`.
Each chunk's atlas and lookup textures are allocated as `sg_image` resources and destroyed
when the chunk is unloaded (`src/editor/rdm_disk.jai` handles disk I/O in the editor).
### World / Chunk System
The world is a sparse hash map of chunks. Each chunk is a 32×32×32 block of the world at
integer trile-space coordinates.
```
World
└─ chunks: Table(Chunk_Key{x,y,z}, Chunk)
└─ groups: [..]Chunk_Trile_Group
├─ trile_name: string
└─ instances: [..]Trile_Instance{x,y,z,orientation}
```
**Coordinate math:**
- World integer position → chunk key: floor division (`floor_div`)
- World integer position → local position (0..31): floor modulo (`floor_mod`)
- Both handle negative coordinates correctly (rounds toward -infinity)
**Binary world format:**
```
[magic u32][version u16][name_len u16][name bytes]
[World_Config_Binary]
[num_chunks u32]
[chunk table: per chunk → chunk_x,y,z s32 × 3, data_offset u32, data_size u32]
[chunk data blobs]
```
### Lighting System
Trueno uses a **hybrid lighting model**: real-time direct light from the sun combined with
pre-baked indirect and emissive light stored in per-chunk RDM textures.
#### Direct lighting (sun)
A single directional light ("sun") is evaluated per-fragment at runtime. Visibility is determined
with a shadow map rendered each frame from an orthographic projection centred on the camera
target. Percentage-closer filtering (PCF) with Gaussian-weighted sampling produces soft shadow
edges. Surfaces outside the shadow map area are assumed unshadowed (no cascades currently).
#### Indirect lighting (RDM)
The Rhombic Dodecahedron Mapping technique (described in the Rendering Pipeline section above)
provides pre-baked indirect and emissive light for static trile geometry. The bake captures both
diffuse and specular (including sharp mirror-like reflections) from the path tracer.
**Emissive voxels:** materials with non-zero emission contribute to indirect lighting through the
RDM bake rather than being evaluated as real-time lights. This means dynamic objects only
receive sun light; they do not receive indirect light from emissive triles.
**Split-sum specular:** the specular term combines the pre-filtered RDM sample with a
pre-integrated BRDF lookup texture (indexed by roughness and the angle between view direction
and surface normal), following the split-sum approximation.
#### Water reflections (planar)
The ocean/water surface uses planar reflections: the entire scene is rendered a second time
with the camera flipped across the water plane into a half-resolution RGBA32F target. This
handles dynamic objects (players, ball) that do not appear in the static RDMs, and produces
sharp mirror-like reflections correct for large flat surfaces. Post-processing can add ripple
distortion to the reflection texture.
#### SSAO
Screen-space ambient occlusion darkens cavities and contact areas. A geometry pass outputs
world-space position and normal (RGBA16F G-buffer), then the SSAO pass samples 64 random
offsets to estimate local occlusion. The result is blurred with a separable filter and applied to
the ambient/diffuse contribution.
#### Pre-bake pipeline
RDM data is generated offline by **Tacoma** (`modules/Tacoma/`, integrated via
`src/editor/tacoma.jai`). Tacoma is a GPU path tracer (C++/Vulkan) that:
1. Builds a BLAS per unique trile type and assembles a TLAS with one instance per trile
occurrence in the scene.
2. For each block, detects which of the 8 roughness levels are present in its trixels and
renders one RDM per level. The maximum-roughness RDM is always rendered (needed for
diffuse).
3. Saves per-RDM HDR images (3 colour + 1 depth channel) and packs them into a single atlas
with a companion lookup file.
The editor (`Level Studio`) triggers this bake; results are saved to disk via `src/editor/rdm_disk.jai`
and loaded at runtime via the chunk streaming system.
### Audio Mixer
The mixer runs on a background audio thread (native) or on the main thread (WASM). The sokol
audio callback calls `mixer_get_samples()` which iterates all active `Mixer_Play_Task` entries,
accumulates float samples into the output buffer, and removes finished tasks.
```
Mixer
├─ config: Mixer_Config (per-bus volume, master volume)
├─ tasks: [..]Mixer_Play_Task
│ └─ audio: *Audio_Data (pointer into pack memory)
│ bus: MUSIC | SOUND_EFFECT | DIALOGUE
│ mode: ONESHOT | REPEAT
│ curSample: s64
└─ mutex (native only; no-op on WASM)
```
Thread safety: `mixer_add_task` and `mixer_get_samples` both acquire the mutex. On WASM, sokol
audio is single-threaded so the mutex is compiled out.
### UI System
The UI is built on top of `GetRect_LeftHanded` (immediate-mode retained UI) with a custom
immediate-mode triangle renderer (`arbtri`) as its drawing backend. All UI geometry is batched
into a single vertex buffer and drawn in the `ui` command bucket at the end of each frame.
A viewport-relative unit system (`vw`/`vh`, each = 1/100 of window dimension) is used throughout
for responsive sizing.
`autoedit.jai` provides reflection-based automatic struct editors: struct fields annotated with
`@Color`, `@Slider,min,max,step` etc. automatically get UI widgets generated at compile time via
Jai's type info system.
### Editor
The in-game editor (F3) has three views:
- **Trile Studio** — voxel painting on a 16×16×16 grid with material selection
- **Level Studio** — orbiting camera, ray-based trile placement, Y-layer selection, optional
path-traced RDM bake via Tacoma integration
- **Material Studio** — lighting and world config parameters
The developer console (F1) dispatches commands registered at compile time. Functions annotated
`@Command` are processed by the metaprogram during compilation: argument-parsing front-ends
(`%__command_front`) are auto-generated and registered in `console_command_names` /
`console_command_procs` arrays — zero runtime overhead.
---
## Compile-Time Metaprogramming
`first.jai` is the build script, executed by the Jai compiler as a metaprogram. It:
1. Invokes `sokol-shdc` as a subprocess to compile GLSL shaders to backend-specific bytecode
and generate Jai descriptor structs
2. Packs all files under `resources/` and `game/resources/` (excluding `.aseprite` and `/worlds/`)
into binary `.pack` files
3. Adds the compiled shader descriptors to the compilation workspace
4. Runs the custom snake_case linter over all function declarations
5. Auto-generates `@Command` wrapper functions for the developer console
This means asset pipeline and code generation happen as part of compilation, with no separate
build step needed.
---
## Memory Model
| Lifetime | Mechanism |
|-----------------|-----------------------------------------------------|
| Per-frame temp | `reset_temporary_storage()` at end of each frame |
| Mesh generation | `Pool.Pool` reset after each trile mesh is uploaded |
| World lifetime | `Pool.Pool` per `Current_World`, reset on unload |
| Asset loading | Static pre-allocated I/O buffers (never heap) |
| GPU resources | Explicit `sg_destroy_image/buffer` on replacement |
| Leak detection | `MEM_DEBUG :: true` enables Jai's memory debugger |
---
## Platform Abstraction
Platform differences are handled with `#if OS == .WASM / .MACOS / .LINUX` throughout:
| Feature | Native | WASM |
|---------------------|----------------------|--------------------------|
| Audio thread mutex | Thread.Mutex | no-op |
| File I/O (editor) | File module | disabled |
| UV origin | Metal: flipped | WebGL: normal |
| Allocator | default Jai heap | Walloc |
| Profiler | Iprof available | disabled |
| Stack size | default | 1 GB (emcc flag) |
| WASM features | — | bulk-memory enabled |
---
## Third-Party Dependencies
| Library | Role |
|--------------------------|----------------------------------------------|
| sokol_gfx | Cross-platform graphics (Metal/GL/WebGL) |
| sokol_app | Window, input, event loop |
| sokol_audio | PCM audio stream callback |
| sokol_fetch | Async file / HTTP I/O |
| sokol_time | High-resolution timer |
| sokol_gl / sokol_fontstash | Immediate-mode lines, font rendering |
| stb_image | PNG decode |
| Jaison | JSON parse/serialize |
| GetRect_LeftHanded | Immediate-mode retained UI layout |
| Simple_Package_Reader | Binary .pack file reader |
| Walloc | WASM-compatible allocator |
| Tacoma | GPU path tracer (C++/Vulkan) for offline RDM baking; integrated via editor |
| Iprof | Optional frame profiler |

264
docs/CODE_REVIEW.md Normal file
View File

@ -0,0 +1,264 @@
# Trueno Engine — Code Review
Overall this is a solid, well-structured engine. The architecture is clean, the compile-time
metaprogramming is used thoughtfully, and the explicit resource lifetime management is a real
strength. Below are the issues found, ordered from most critical downward.
---
## 1. Real Bugs
### WAV loader discards right channel but reports stereo
`src/assets/loaders.jai:44-49`
```jai
for sample, i: audio_samples {
if i % 2 == 0 { // only keeps L channel
array_add(*audio.data, cast(float)sample / 32768.0);
}
}
audio.channels = format.nChannels; // but still reports 2
```
The loader keeps every other sample (L only for stereo PCM) but stores the original `nChannels`
count. The mixer then uses `source_channels` to index into this data, reading out-of-range
positions for the right channel. Fix: either (a) fully decode both channels, or (b) store
`channels = 1` when you've downmixed to mono. The channel count and data layout are currently
inconsistent.
### `clear_world` leaks RDM GPU textures
`src/world.jai:148-158`
`clear_world()` frees instances/groups but never calls `sg_destroy_image` for chunks with
`rdm_valid == true`. `unload_current_world()` has the correct GPU cleanup loop — `clear_world`
needs the same treatment, or it should delegate to `unload_current_world` and re-init.
---
## 2. Memory / Resource Leaks
### `free_resources_from_pack` is empty
`src/assets/asset_manager.jai:279-281`
```jai
free_resources_from_pack :: (pack: *Loaded_Pack) {
// empty
}
```
`add_resources_from_pack` uploads GPU textures (`sg_make_image`), copies audio data into heap
arrays, and allocates animation frame arrays. None of this is ever freed. If packs are ever
hot-reloaded or unloaded, every `sg_image`, every `Audio_Data.data` array, and every
`Animation.frames` array leaks.
### `to_c_string` in `asset_manager_tick` leaks per fetch
`src/assets/asset_manager.jai:325-329`
```jai
sfetch_send(*(sfetch_request_t.{
path = to_c_string(req.path), // heap allocation, never freed
...
}));
```
`to_c_string` allocates from the heap allocator. The pointer is discarded after `sfetch_send`.
One small leak per asset load. Store the result, pass it, then free it after the call (or
allocate with `temp` if sokol copies the path internally).
### `fetch_callback` for PACK uses `NewArray` whose memory is never freed
`src/assets/asset_manager.jai:76-77`
```jai
mem := NewArray(res.data.size.(s64), u8, false);
memcpy(mem.data, res.data.ptr, res.data.size.(s64));
```
This heap-allocates the entire pack contents. With `free_resources_from_pack` being empty, this
memory is never freed.
---
## 3. Crash on Corrupt Data
### `read_value`/`read_string` use `assert()` for file bounds checks
`src/world.jai:240-250`
```jai
read_value :: (data: []u8, cursor: *s64, $T: Type) -> T {
assert(cursor.* + size_of(T) <= data.count, "read_value: out of bounds");
...
}
```
`assert` crashes on a malformed or truncated world file. Since this data comes from disk it can
be corrupted or truncated. The `load_world_from_data` pattern of returning `(World, bool)` is
already in place — extend that into `read_value` returning `(T, bool)` so corrupt files are
rejected gracefully rather than crashing the process.
---
## 4. Fixed Timestep "Spiral of Death"
### No cap on `delta_time_accumulator`
`src/main.jai:198-201`
```jai
while delta_time_accumulator > (1.0/60.0) {
game_tick(1.0/60.0);
delta_time_accumulator -= (1.0/60.0);
}
```
If a frame takes longer than one tick (debugging, loading spike, OS sleep), the accumulator grows
without bound. On the next frame many ticks run back-to-back, making that frame slow too — the
classic spiral. Add a cap before the while loop:
```jai
delta_time_accumulator = min(delta_time_accumulator, 0.25);
```
There is also a **first-frame spike**: when loading finishes, `delta_time` includes all the time
spent loading (potentially seconds), blowing up the accumulator immediately. Reset
`last_frame_time` when `init_after_core_done` flips to true.
---
## 5. Missing `sg_commit` During Loading
### No frame commit when returning early
`src/main.jai:168-183`
When `mandatory_loads_done()` returns false, `frame()` returns without calling `render()` (which
calls `sg_commit()`). Some backends accumulate state that must be flushed each frame. At minimum
do a clear pass + `sg_commit()` when returning early during loading, or call it unconditionally
at the end of `frame()`.
---
## 6. World File Robustness
### Instance count silently truncated to `u16`
`src/world.jai:311`
```jai
count := cast(u16) group.instances.count;
```
If a chunk accumulates more than 65,535 instances of one trile type the count wraps silently on
save and the file is then unloadable. At minimum add an `assert(group.instances.count <= 0xFFFF)`
or widen to `u32`.
### No file integrity check
The binary format has magic + version but no CRC32 or checksum. A truncated or bit-flipped file
passes all header checks and then hits the assert crash. Even a simple FNV-1a checksum appended
at the end would let you detect corruption cleanly and emit a helpful error.
### Chunk data offsets are `u32`
Large worlds (>4 GB total chunk data) would overflow `running_offset`. Not a present concern but
worth noting if worlds grow significantly.
---
## 7. Audio Mixer Output Clipping
### No output saturation
`src/audio/mixer.jai:126`
```jai
buffer[out_index] += sample * vol;
```
Multiple loud tracks sum without clamping. Sokol audio expects `[-1.0, 1.0]` float PCM. Values
outside that range clip hard on most backends or cause driver-level distortion. After the mixing
loop, clamp each output sample:
```jai
buffer[i] = clamp(buffer[i], -1.0, 1.0);
```
---
## 8. Queue Performance
### O(n) front removal in `asset_manager_tick`
`src/assets/asset_manager.jai:316-319`
```jai
for i: 0..g_asset_manager.fetch_queue.count - 2 {
g_asset_manager.fetch_queue[i] = g_asset_manager.fetch_queue[i + 1];
}
g_asset_manager.fetch_queue.count -= 1;
```
This shifts the entire array on every dequeue. With only a handful of queued fetches at a time
this is fine in practice, but a simple head-index (`fetch_queue_head: int`) avoids the shift
entirely.
---
## 9. `find_pack_by_name` Returns by Value
`src/assets/asset_manager.jai:283-292`
```jai
find_pack_by_name :: (name: string) -> (bool, Loaded_Pack) { ... }
```
`Loaded_Pack` contains `Table` structs. Copying them and then calling `table_find_pointer` into
the copy is technically safe (the backing heap arrays are shared), but semantically wrong — you
get a copy of the struct, not a stable pointer into `g_asset_manager.loadedPacks`. If the
`loadedPacks` array is ever reallocated (another pack added while the copy is held), things get
subtly wrong. Change the return type to `*Loaded_Pack` and return null on not-found.
---
## 10. `load_string_from_pack` Returns a Raw View
`src/assets/asset_manager.jai:404-419`
The function even comments "you are circumventing the asset management system." The returned
`string.data` points directly into the pack's internal content buffer. If the pack is ever freed
or `loadedPacks` reallocated, this string dangles. Either document that callers must
`copy_string` the result, or return a heap copy directly from this function.
---
## 11. Minor Items
- **`get_window_info()` returns hardcoded 1920x1080** (`src/main.jai:63-70`). The actual window
size is read from `sapp_width()/sapp_height()` everywhere else. This function appears to be
dead code — worth deleting to avoid confusion.
- **`print("Should show loading screen....\n")` on every loading frame** (`src/main.jai:176`).
This spams stdout at 60 fps for the entire loading phase. Remove or guard with a one-shot flag.
- **RDM header doesn't validate `width × height` fits within the static buffer**
(`src/assets/asset_manager.jai:121`). A malformed file with huge dimension values could cause
the `sg_image_data` range to extend past `rdm_atlas_buf`. Add an explicit bounds check:
`if atlas_pixel_bytes > RDM_ATLAS_MAX_BYTES - header_size then { ...; return; }`.
- **`World_Config_Binary` field set is not versioned independently.** Adding new fields to
`World_Config` without bumping `WORLD_VERSION` silently breaks existing saved worlds. Consider
a migration path or at minimum a compile-time size check.
---
## Summary
| Priority | Area | Issue |
|------------|------------|----------------------------------------------------------|
| Bug | Audio | WAV loader discards right channel but reports stereo |
| Bug | World | `clear_world` leaks RDM GPU textures |
| Leak | Assets | `free_resources_from_pack` is empty; nothing freed |
| Leak | Assets | `to_c_string` result never freed per fetch |
| Crash | World | `assert()` on corrupt file data instead of error return |
| Stability | Game loop | No cap on `delta_time_accumulator` (spiral of death) |
| Stability | Game loop | First post-loading frame has enormous delta spike |
| Correctness| Rendering | No `sg_commit` during loading frames |
| Robustness | World | `u16` instance count silently truncates on save |
| Robustness | World | No file checksum / integrity check |
| Audio | Mixer | No output clamping; overlapping tracks clip |
| Performance| Assets | O(n) queue front removal on every tick |
| Correctness| Assets | `find_pack_by_name` returns copy instead of pointer |

Binary file not shown.

389
first.jai
View File

@ -3,233 +3,200 @@
Iprof :: #import "Iprof"(IMPORT_MODE = .METAPROGRAM);
#import "File_Utilities";
#run {
print("%\n", ascii_car);
Trueno_Build_Options :: struct {
wasm_build : bool;
release_build : bool;
tacoma_enabled : bool;
iprof_enabled : bool;
}
// Step 1. Compile shaders.
{
print("--- Shader Compile Step ---\n");
process_result, output, error := run_command("bash", ifx OS == .MACOS then "./compile_shaders_mac.sh" else "./compile_shaders.sh", working_directory=tprint("%/src/shaders", #filepath));
if process_result.exit_code != 0 {
log_error("Shader compilation failed.");
if output {
log_error(output,, logger = runtime_support_default_logger);
}
exit(1);
}
}
build_options_from_args :: (args: []string) -> Trueno_Build_Options {
opts : Trueno_Build_Options;
// Step 2. Create a pack file for all of the assets in ./resources.
create_pack();
opt := get_build_options();
args := opt.compile_time_command_line;
doWasmBuild := false;
hasTacoma := false;
releaseBuild := false;
iprof := false;
uncap_frames := false;
for arg : args {
if arg == "wasm" then doWasmBuild = true;
if arg == "tacoma" then hasTacoma = true;
if arg == "release" then releaseBuild = true;
if arg == "iprof" then iprof = true;
if arg == "iprof" then iprof = true;
if arg == "uncap_frames" then uncap_frames = true;
if arg == {
case "wasm";
opts.wasm_build = true;
case "tacoma";
opts.tacoma_enabled = true;
case "release";
opts.release_build = true;
case "iprof";
opts.iprof_enabled = true;
}
}
if doWasmBuild {
return opts;
}
add_trueno_opts_to_compiler_strings :: (trueno_opts : Trueno_Build_Options, w: Workspace) {
#import "String";
bool_to_string :: (b: bool) -> string {
if b then return tprint("true"); else return tprint("false");
}
trueno_opts_info := type_info(Trueno_Build_Options);
for trueno_opts_info.members {
optPtr := (cast(*bool)(*trueno_opts)) + it.offset_in_bytes;
name := tprint("%", it.name);
to_upper_in_place(name);
add_build_string(tprint("FLAG_% :: %;\n", name, bool_to_string(optPtr.*)), w);
}
}
compile_shaders :: () {
process_result, output, error := run_command("bash", ifx OS == .MACOS then "./compile_shaders_mac.sh" else "./compile_shaders.sh", working_directory=tprint("%/src/shaders", #filepath));
if process_result.exit_code != 0 {
log_error("Shader compilation failed.");
if output {
log_error(output,, logger = runtime_support_default_logger);
}
exit(1);
}
}
wasm_copy_assets :: () {
if is_directory("./packs") {
run_command("cp", "-r", "./packs", "./dist/", working_directory=tprint("%", #filepath));
}
worlds_src := "./game/resources/worlds";
if is_directory(worlds_src) {
make_directory_if_it_does_not_exist("./dist/game/resources", recursive = true);
run_command("cp", "-r", worlds_src, "./dist/game/resources/", working_directory=tprint("%", #filepath));
}
}
metaprogramming_loop :: (trueno_opts: Trueno_Build_Options, w: *Workspace) {
while true {
message := compiler_wait_for_message();
// @ToDo: add iprof to this refactored metaprogram
// if trueno_opts.iprof_enabled then iprof_plugin.message(iprof_plugin, message)
custom_message_handler(message, w);
if message.kind == .COMPLETE then break;
}
compiler_end_intercept(w.*);
}
native_build :: (opts: Build_Options, trueno_opts: Trueno_Build_Options) {
set_build_options_dc(.{do_output=false});
current_w := get_current_workspace();
root_opts := get_build_options();
w := compiler_create_workspace("Target");
opts := get_build_options(w);
copy_commonly_propagated_fields(opts, *root_opts);
opts.cpu_target = root_opts.cpu_target;
opts.os_target = root_opts.os_target;
opts.backend = ifx (trueno_opts.release_build || OS == .MACOS) then .LLVM else .X64;
opts.output_executable_name = root_opts.output_executable_name;
set_build_options(opts, w);
iprof_plugin: *Iprof.My_Plugin;
// profile := iprof;
// if profile {
// iprof_plugin = cast(*Iprof.My_Plugin) Iprof.get_plugin();
// iprof_plugin.workspace = w;
// // Set options
// iprof_plugin.instrument_modules = true;
// iprof_plugin.before_intercept(iprof_plugin, null);
// }
compiler_begin_intercept(w);
add_build_file("src/platform_specific/main_native.jai", w);
add_shaders_to_workspace(w);
metaprogramming_loop(trueno_opts, *w);
if trueno_opts.iprof_enabled {
iprof_plugin.finish(iprof_plugin);
iprof_plugin.shutdown(iprof_plugin);
}
}
wasm_build :: (opts: Build_Options, trueno_opts: Trueno_Build_Options) {
set_build_options_dc(.{do_output = false});
make_directory_if_it_does_not_exist("dist", recursive = true);
{
// Copy compiled packs (produced by create_pack above).
if is_directory("./packs") {
run_command("cp", "-r", "./packs", "./dist/", working_directory=tprint("%", #filepath));
}
// Copy only the worlds subtree — not the whole game dir (avoids .git etc.).
worlds_src := "./game/resources/worlds";
if is_directory(worlds_src) {
make_directory_if_it_does_not_exist("./dist/game/resources", recursive = true);
run_command("cp", "-r", worlds_src, "./dist/game/resources/", working_directory=tprint("%", #filepath));
}
w := compiler_create_workspace("Wasm");
options := get_build_options(w);
copy_commonly_propagated_fields(get_build_options(), *options);
options.output_type = .OBJECT_FILE;
options.backend = .LLVM; // WASM only works with the LLVM backend, obviously.
options.os_target = .WASM;
options.cpu_target = .CUSTOM;
options.emit_debug_info = .DWARF;
options.backtrace_on_crash = .OFF; // Runtime_Support_Crash_Handler doesnt support WASM (yet?)
options.output_path = "dist/";
options.output_executable_name = "main";
options.llvm_options.target_system_features = "+bulk-memory"; // "This options is needed so that "memcpy" and "memset" are mapped to "memory.copy" and "memory.fill" instructions in WASM.
options.llvm_options.enable_split_modules = false;
options.llvm_options.function_sections = true; // To get around "LLVM ERROR: section already has a defining function: .text"
import_paths: [..]string;
// Add our own modules folder first so that we can override modules with our own version, if necessary.
for options.import_path array_add(*import_paths, it);
options.import_path = import_paths;
// This was compiled from https://github.com/wingo/walloc via "clang -Oz --target=wasm64 -nostdlib -c -o walloc.o walloc.c".
// We should probably port this allocator to Jai instead…
// -rluba, 2023-11-15
walloc_object_file_path := "walloc.o";
STACK_SIZE :: 1024 * 1024 * 1024;
options.additional_linker_arguments = .["--stack-first", "-z", tprint("stack-size=%", STACK_SIZE), walloc_object_file_path];
set_build_options(options, w);
// Replace the default allocator with Walloc (https://github.com/wingo/walloc).
remap_import(w, "*", "Default_Allocator", "Walloc");
compiler_begin_intercept(w);
add_build_file("src/platform_specific/main_web.jai", w);
add_build_string("HAS_TACOMA :: false;", w);
add_build_string("HAS_IPROF :: false;", w);
add_build_string("UNCAPPED_FRAMES :: false;", w);
if releaseBuild {
add_build_string("RELEASE_BUILD :: true;", w);
} else {
add_build_string("RELEASE_BUILD :: false;", w);
}
if is_directory("./game") {
add_build_string("USE_SAMPLE_GAME :: false;", w);
} else {
add_build_string("USE_SAMPLE_GAME :: true;", w);
}
add_shaders_to_workspace(w);
while true {
message := compiler_wait_for_message();
custom_message_handler(message, *w);
if message.kind == {
case .TYPECHECKED;
typechecked := cast(*Message_Typechecked) message;
for body: typechecked.procedure_bodies {
header := body.expression.header;
// You could replace individual procedure bodies here, if you needed to.
}
case .COMPLETE;
break;
}
}
compiler_end_intercept(w);
}
{
args := string.[
"emcc",
"src/platform_specific/main.c", "dist/main.o", "modules/sokol-jai/sokol/gfx/sokol_gfx_wasm_gl_release.a", "modules/sokol-jai/sokol/log/sokol_log_wasm_gl_release.a", "modules/sokol-jai/sokol/audio/sokol_audio_wasm_gl_release.a", "modules/sokol-jai/sokol/time/sokol_time_wasm_gl_release.a", "modules/sokol-jai/sokol/app/sokol_app_wasm_gl_release.a", "modules/sokol-jai/sokol/glue/sokol_glue_wasm_gl_release.a", "modules/sokol-jai/sokol/fetch/sokol_fetch_wasm_gl_release.a", "modules/sokol-jai/sokol/gl/sokol_gl_wasm_gl_release.a", "modules/sokol-jai/sokol/fontstash/sokol_fontstash_wasm_gl_release.a",
"modules/sokol-jai/sokol/stbi/stb_image.a",
"-o", "dist/index.html",
"-sSTACK_SIZE=10MB",
"-sALLOW_MEMORY_GROWTH",
"-sERROR_ON_UNDEFINED_SYMBOLS=1", "-sMEMORY64", "-sMAX_WEBGL_VERSION=2",
"--js-library=src/platform_specific/runtime.js",
"--shell-file=src/platform_specific/shell.html",
];
process_result, output, error := run_command(..args, capture_and_return_output = true);
if process_result.exit_code != 0 {
log_error("EMCC compilation failed.");
if error {
log_error(error,, logger = runtime_support_default_logger);
}
exit(1);
}
}
} else {
set_build_options_dc(.{do_output=false});
current_w := get_current_workspace();
root_opts := get_build_options();
w := compiler_create_workspace("Target");
opts := get_build_options(w);
copy_commonly_propagated_fields(opts, *root_opts);
opts.cpu_target = root_opts.cpu_target;
opts.os_target = root_opts.os_target;
opts.backend = ifx (releaseBuild || OS == .MACOS) then .LLVM else .X64;
opts.output_executable_name = root_opts.output_executable_name;
set_build_options(opts, w);
iprof_plugin: *Iprof.My_Plugin;
profile := iprof;
if profile {
iprof_plugin = cast(*Iprof.My_Plugin) Iprof.get_plugin();
iprof_plugin.workspace = w;
// Set options
iprof_plugin.instrument_modules = true;
wasm_copy_assets();
w := compiler_create_workspace("Wasm");
iprof_plugin.before_intercept(iprof_plugin, null);
}
options := get_build_options(w);
copy_commonly_propagated_fields(get_build_options(), *options);
compiler_begin_intercept(w);
if hasTacoma {
add_build_string("HAS_TACOMA :: true;", w);
} else {
add_build_string("HAS_TACOMA :: false;", w);
}
options.output_type = .OBJECT_FILE;
options.backend = .LLVM;
options.os_target = .WASM;
options.cpu_target = .CUSTOM;
options.emit_debug_info = .DWARF;
options.backtrace_on_crash = .OFF;
options.output_path = "dist/";
options.output_executable_name = "main";
options.llvm_options.target_system_features = "+bulk-memory";
options.llvm_options.enable_split_modules = false;
options.llvm_options.function_sections = true;
if is_directory("./game") {
add_build_string("USE_SAMPLE_GAME :: false;", w);
} else {
add_build_string("USE_SAMPLE_GAME :: true;", w);
}
import_paths: [..]string;
if uncap_frames {
add_build_string("UNCAPPED_FRAMES :: true;", w);
} else {
add_build_string("UNCAPPED_FRAMES :: false;", w);
}
for options.import_path array_add(*import_paths, it);
options.import_path = import_paths;
if releaseBuild {
add_build_string("RELEASE_BUILD :: true;", w);
} else {
add_build_string("RELEASE_BUILD :: false;", w);
}
// This was compiled from https://github.com/wingo/walloc via "clang -Oz --target=wasm64 -nostdlib -c -o walloc.o walloc.c".
// We should probably port this allocator to Jai instead…
// -rluba, 2023-11-15
walloc_object_file_path := "walloc.o";
STACK_SIZE :: 1024 * 1024 * 1024;
options.additional_linker_arguments = .["--stack-first", "-z", tprint("stack-size=%", STACK_SIZE), walloc_object_file_path];
if profile {
add_build_string("HAS_IPROF :: true;", w);
iprof_plugin.add_source(iprof_plugin);
} else {
add_build_string("HAS_IPROF :: false;", w);
}
add_build_file("src/platform_specific/main_native.jai", w);
add_shaders_to_workspace(w);
set_build_options(options, w);
remap_import(w, "*", "Default_Allocator", "Walloc");
while true {
message := compiler_wait_for_message();
if profile then iprof_plugin.message(iprof_plugin, message);
custom_message_handler(message, *w);
if message.kind == .COMPLETE then break;
}
compiler_end_intercept(w);
if profile {
iprof_plugin.finish(iprof_plugin);
iprof_plugin.shutdown(iprof_plugin);
}
compiler_begin_intercept(w);
add_trueno_opts_to_compiler_strings(trueno_opts, w);
add_build_file("src/platform_specific/main_web.jai", w);
add_shaders_to_workspace(w);
metaprogramming_loop(trueno_opts, *w);
args := string.[
"emcc",
"src/platform_specific/main.c", "dist/main.o", "modules/sokol-jai/sokol/gfx/sokol_gfx_wasm_gl_release.a", "modules/sokol-jai/sokol/log/sokol_log_wasm_gl_release.a", "modules/sokol-jai/sokol/audio/sokol_audio_wasm_gl_release.a", "modules/sokol-jai/sokol/time/sokol_time_wasm_gl_release.a", "modules/sokol-jai/sokol/app/sokol_app_wasm_gl_release.a", "modules/sokol-jai/sokol/glue/sokol_glue_wasm_gl_release.a", "modules/sokol-jai/sokol/fetch/sokol_fetch_wasm_gl_release.a", "modules/sokol-jai/sokol/gl/sokol_gl_wasm_gl_release.a", "modules/sokol-jai/sokol/fontstash/sokol_fontstash_wasm_gl_release.a",
"modules/sokol-jai/sokol/stbi/stb_image.a",
"-o", "dist/index.html",
"-sSTACK_SIZE=10MB",
"-sALLOW_MEMORY_GROWTH",
"-sERROR_ON_UNDEFINED_SYMBOLS=1", "-sMEMORY64", "-sMAX_WEBGL_VERSION=2",
"--js-library=src/platform_specific/runtime.js",
"--shell-file=src/platform_specific/shell.html",
];
process_result, output, error := run_command(..args, capture_and_return_output = true);
if process_result.exit_code != 0 {
log_error("EMCC compilation failed.");
if error {
log_error(error,, logger = runtime_support_default_logger);
}
exit(1);
}
}
#run {
opt := get_build_options();
trueno_opts := build_options_from_args(opt.compile_time_command_line);
compile_shaders();
create_pack();
if trueno_opts.wasm_build {
wasm_build(opt, trueno_opts);
} else {
native_build(opt, trueno_opts);
}
}

View File

@ -5,7 +5,7 @@
#load "trile_editor.jai";
#load "level_editor.jai";
}
#if HAS_TACOMA { #load "tacoma.jai"; }
#if FLAG_TACOMA_ENABLED { #load "tacoma.jai"; }
#load "console.jai";
#load "textureDebugger.jai";

View File

@ -175,7 +175,7 @@ draw_tacoma_tab :: (theme: *GR.Overall_Theme, total_r: GR.Rect) {
curworld := get_current_world();
r := total_r;
r.h = ui_h(3,0);
#if HAS_TACOMA {
#if FLAG_TACOMA_ENABLED {
if GR.button(r, "Render with Tacoma", *theme.button_theme) {
cam := get_level_editor_camera();
gen_reference(tacomaResolution, tacomaResolution, cam.position, cam.target, tacomaSamples, true, curworld.world);
@ -314,7 +314,7 @@ remove_trile :: (x: s32, y: s32, z: s32) {
tick_level_editor :: () {
#if HAS_TACOMA { rdm_bake_tick(); }
#if FLAG_TACOMA_ENABLED { rdm_bake_tick(); }
tick_level_editor_camera();
ray := get_mouse_ray(*get_level_editor_camera());
hit, point := ray_plane_collision_point(ray, xx editY, 20);

View File

@ -31,11 +31,7 @@ stbi :: #import "stb_image";
#load "audio/audio.jai";
#load "assets/asset_manager.jai";
#if USE_SAMPLE_GAME {
#load "../sample_game/game.jai";
} else {
#load "../game/game.jai";
}
#load "../game/game.jai";
last_frame_time : float64; // timestamp of the last frame
delta\ _time : float64;

View File

@ -1,13 +1,13 @@
#import,dir "../../modules/sokol-jai/sokol/app"(DEBUG = RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/gfx"(DEBUG = RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/gl"(DEBUG = RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/glue"(DEBUG = RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/shape"(DEBUG = RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/app"(DEBUG = FLAG_RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/gfx"(DEBUG = FLAG_RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/gl"(DEBUG = FLAG_RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/glue"(DEBUG = FLAG_RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/shape"(DEBUG = FLAG_RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/fontstash";
#import,dir "../../modules/sokol-jai/sokol/log"(DEBUG = RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/time"(DEBUG = RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/fetch"(DEBUG = RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/audio"(DEBUG = RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/log"(DEBUG = FLAG_RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/time"(DEBUG = FLAG_RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/fetch"(DEBUG = FLAG_RELEASE_BUILD);
#import,dir "../../modules/sokol-jai/sokol/audio"(DEBUG = FLAG_RELEASE_BUILD);
#load "../main.jai";