9.5 KiB
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
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
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
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
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
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
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:
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
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
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:
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
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
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 fromsapp_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 × heightfits within the static buffer (src/assets/asset_manager.jai:121). A malformed file with huge dimension values could cause thesg_image_datarange to extend pastrdm_atlas_buf. Add an explicit bounds check:if atlas_pixel_bytes > RDM_ATLAS_MAX_BYTES - header_size then { ...; return; }. -
World_Config_Binaryfield set is not versioned independently. Adding new fields toWorld_Configwithout bumpingWORLD_VERSIONsilently 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 |