Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ zig-pkg
.claude
CLAUDE.md
*.log
*.wav
839 changes: 774 additions & 65 deletions build.zig

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,16 @@
.url = "git+https://github.com/Snektron/vulkan-zig#3adbeefbc833c12656791d304b37a6315b357745",
.hash = "vulkan-0.0.0-r7YtxyBsAwD35FvkFhT2xruTNdCByD3TtGw44XWtfun8",
},
.zaudio = .{
.url = "git+https://github.com/IridescentRose/zaudio?ref=update_zig_16#54e45e002005448623bff0ba06baa919bd036e03",
.hash = "zaudio-0.11.0-dev-_M-91l8yQQDTAb3Hk8V-lyP88DGgLzS9QNUkmpbCN_PK",
},
.system_sdk = .{
.url = "https://github.com/zig-gamedev/system_sdk/archive/c0dbf11cdc17da5904ea8a17eadc54dee26567ec.tar.gz",
.hash = "system_sdk-0.3.0-dev-alwUNnYaaAJAtIdE2fg4NQfDqEKs7QCXy_qYukAOBfmF",
.lazy = true,
},
.sdl3 = .{
.url = "git+https://codeberg.org/7Games/zig-sdl3?ref=master#40c2e4b579aa556db37a502c936426aa1c8b5c95",
.hash = "sdl3-0.2.0-NmT1Q0mFJwBi9kZmArzh2rfJ_mFshydV0zPGULVlpACc",
.lazy = true,
},
},
.paths = .{
"build.zig",
Expand Down
2 changes: 2 additions & 0 deletions src/audio/audio.zig
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const std = @import("std");
const Vec3 = @import("../math/math.zig").Vec3;
const platform_audio = @import("../platform/audio.zig");
const options = @import("options");

// -- types -------------------------------------------------------------------

Expand All @@ -13,6 +14,7 @@ pub const mixer_mod = @import("mixer.zig");
pub const SoundHandle = mixer_mod.SoundHandle;
pub const PlayOptions = mixer_mod.PlayOptions;
pub const Priority = mixer_mod.Priority;
pub const enabled = options.config.audio != .none;

// -- forwarding to the instantiated mixer ------------------------------------

Expand Down
16 changes: 13 additions & 3 deletions src/core/input/input.zig
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ pub fn register_action_set(name: []const u8) !ActionSetHandle {

pub fn add_action(set: ActionSetHandle, name: []const u8, kind: ActionKind) !void {
const s = set_ptr(set) orelse return error.UnknownActionSet;
if (s.actions.contains(name)) return error.ActionAlreadyExists;
if (action_ptr(s, name) != null) return error.ActionAlreadyExists;
try s.actions.put(alloc, name, .{
.kind = kind,
.bindings = .empty,
Expand All @@ -288,7 +288,7 @@ pub fn add_action(set: ActionSetHandle, name: []const u8, kind: ActionKind) !voi

pub fn bind_action(set: ActionSetHandle, action_name: []const u8, b: Binding) !void {
const s = set_ptr(set) orelse return error.UnknownActionSet;
const a = s.actions.getPtr(action_name) orelse return error.ActionNotFound;
const a = action_ptr(s, action_name) orelse return error.ActionNotFound;
if (a.kind == .vector2 and b.component == .none) return error.Vector2BindingNeedsComponent;
try a.bindings.append(alloc, b);
}
Expand All @@ -310,7 +310,7 @@ pub fn get_action(name: []const u8) ?ActionValue {
const top = stack.top() orelse return null;
const s = set_ptr(top.actions) orelse return null;
if (!s.installed) return null;
const a = s.actions.getPtr(name) orelse return null;
const a = action_ptr(s, name) orelse return null;
return a.current_value;
}

Expand Down Expand Up @@ -479,6 +479,16 @@ fn set_ptr_or_null(handle: ActionSetHandle) ?*ActionSet {
return set_ptr(handle);
}

fn action_ptr(set: *ActionSet, name: []const u8) ?*Action {
if (set.actions.getPtr(name)) |action| return action;

var it = set.actions.iterator();
while (it.next()) |entry| {
if (std.mem.eql(u8, entry.key_ptr.*, name)) return entry.value_ptr;
}
return null;
}

fn route_text_to_session(text: []const u8) void {
const top = stack.top() orelse return;
if (!top.consumes_text) return;
Expand Down
28 changes: 26 additions & 2 deletions src/core/paths.zig
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@
//! per project style guide they go through `std.Io` / `std.process`.

const std = @import("std");
const builtin = @import("builtin");
const options = @import("options");

const Io = std.Io;
const NintendoIo = if (options.config.platform == .nintendo_3ds or options.config.platform == .nintendo_switch)
@import("../platform/c_io.zig")
else
void;

/// Engine-owned directory handles. Cleared via `close()` at engine shutdown.
pub const Dirs = struct {
Expand All @@ -48,6 +51,8 @@ pub const Dirs = struct {
if (self.resources.handle != cwd_handle) self.resources.close(io);
if (self.data.handle != cwd_handle and self.data.handle != self.resources.handle)
self.data.close(io);

if (NintendoIo != void) NintendoIo.deinitAppDirs();
}
};

Expand Down Expand Up @@ -82,13 +87,15 @@ pub fn resolve(
// and debug/CI builds where state co-located with the binary is a
// feature, not a bug.
if (options.config.use_cwd) {
if (NintendoIo != void) NintendoIo.useCwdDirs();
return .{ .resources = Io.Dir.cwd(), .data = Io.Dir.cwd() };
}

return switch (builtin.os.tag) {
return switch (options.config.platform) {
.macos => resolve_macos(io, environ_map, app_name),
.windows => resolve_windows(io, environ_map, app_name),
.linux => resolve_linux(io, environ_map, app_name),
.nintendo_3ds, .nintendo_switch => resolve_nintendo(io, app_name),
// PSP: both dirs collapse to CWD. The EBOOT and its siblings all
// live under `ms0:/PSP/GAME/<id>/`; the runtime sets CWD there
// before main. No separation to enforce.
Expand All @@ -100,6 +107,23 @@ pub fn resolve(
};
}

fn resolve_nintendo(io: Io, app_name: []const u8) Error!Dirs {
NintendoIo.mountData();
errdefer NintendoIo.deinitAppDirs();

var data_buf: [Io.Dir.max_path_bytes]u8 = undefined;
const data_path = NintendoIo.dataRoot(&data_buf, app_name) catch return error.PathTooLong;
const data = try Io.Dir.cwd().createDirPathOpen(io, data_path, .{ .open_options = .{ .iterate = true } });
errdefer data.close(io);

const resources = if (NintendoIo.mountResources())
Io.Dir.openDirAbsolute(io, "romfs:/", .{}) catch data
else
data;

return .{ .resources = resources, .data = data };
}

// -- macOS --------------------------------------------------------------------

fn resolve_macos(
Expand Down
119 changes: 81 additions & 38 deletions src/engine.zig
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ pub const Engine = struct {
trackers: [TRACKER_COUNT]CategoryTracker,
running: bool,
vsync: bool,
state: *const Core.State,
state: Core.State,
dirs: Core.paths.Dirs,

pub const Config = struct {
Expand Down Expand Up @@ -127,7 +127,7 @@ pub const Engine = struct {
self.io = sys_io;
self.running = true;
self.vsync = config.vsync;
self.state = state;
self.state = state.*;

self.pool = memory.PoolAlloc.init(mem, "main");
const inner = self.pool.allocator();
Expand All @@ -150,7 +150,7 @@ pub const Engine = struct {

try Platform.init(self, config.width, config.height, config.title, config.fullscreen, config.vsync, config.resizable);
try Rendering.Texture.init_defaults(self.allocator(.render));
try Core.state_machine.init(self, state);
try Core.state_machine.init(self, &self.state);
}

pub fn deinit(self: *Engine) void {
Expand Down Expand Up @@ -199,27 +199,26 @@ pub const Engine = struct {
}

pub fn report(self: *const Engine) void {
const mib = 1024.0 * 1024.0;
Util.engine_logger.info("--- memory pools ---", .{});
inline for (std.meta.fields(Pool)) |f| {
const p: Pool = @enumFromInt(f.value);
const used = self.pool_used(p);
const budget = self.pool_budget(p);
const remaining = self.pool_remaining(p);
Util.engine_logger.info(" {s}: {}/{} bytes ({d:.3}/{d:.3} MiB, {} remaining)", .{
Util.engine_logger.info(" {s}: {}/{} bytes ({}/{} KiB, {} remaining)", .{
f.name,
used,
budget,
@as(f64, @floatFromInt(used)) / mib,
@as(f64, @floatFromInt(budget)) / mib,
used / 1024,
budget / 1024,
remaining,
});
}
Util.engine_logger.info(" total: {}/{} bytes ({d:.3}/{d:.3} MiB)", .{
Util.engine_logger.info(" total: {}/{} bytes ({}/{} KiB)", .{
self.pool.used,
self.pool.budget,
@as(f64, @floatFromInt(self.pool.used)) / mib,
@as(f64, @floatFromInt(self.pool.budget)) / mib,
self.pool.used / 1024,
self.pool.budget / 1024,
});
Util.engine_logger.info("--------------------", .{});
}
Expand All @@ -228,33 +227,58 @@ pub const Engine = struct {
const US_PER_S: u64 = std.time.us_per_s;
const NS_PER_US: i64 = 1000;

// Fixed-step rates -- PSP targets 60 Hz display
const UPDATES_HZ: u32 = if (options.config.platform == .psp) 60 else 144;
// Fixed-step rates -- handheld backends target 60 Hz displays.
const UPDATES_HZ: u32 = if (options.config.platform == .psp or options.config.platform == .nintendo_3ds) 60 else 144;
const TICKS_HZ: u32 = 20;
const UPDATE_US: u64 = US_PER_S / UPDATES_HZ;
const TICK_US: u64 = US_PER_S / TICKS_HZ;

const update_budget_ns: i64 = @as(i64, @intCast(UPDATE_US)) * NS_PER_US;

var clock = std.Io.Clock.real;
var clock = std.Io.Clock.boot;
const run_start_ns = clock.now(self.io).toNanoseconds();
const fps_window_us: i64 = @intCast(US_PER_S);

var last_us: i64 = @truncate(@divTrunc(clock.now(self.io).toNanoseconds(), 1000));
var last_us: i64 = 0;
var update_accum: i64 = 0;
var tick_accum: i64 = 0;
var stale_time_frames: u32 = 0;

const report_fps = options.config.gfx != .headless;
var fps_count: u32 = 0;
var fps_window_end: i64 = last_us + US_PER_S;
var fps_window_end: i64 = saturatingAddI64(last_us, fps_window_us);

while (self.running) {
const now_us: i64 = @truncate(@divTrunc(clock.now(self.io).toNanoseconds(), 1000));
var frame_dt_us: i64 = now_us - last_us;
last_us = now_us;
const now_us = elapsedUsSince(run_start_ns, clock.now(self.io).toNanoseconds());
var frame_dt_us = saturatingSubI64(now_us, last_us);
var synthetic_frame_dt = false;

if (frame_dt_us <= 0) {
frame_dt_us = 0;
if (options.config.platform == .nintendo_3ds and self.vsync) {
stale_time_frames +|= 1;
if (stale_time_frames >= 2) {
frame_dt_us = @intCast(US_PER_S / 60);
synthetic_frame_dt = true;
}
}
} else {
stale_time_frames = 0;
}

if (frame_dt_us > 500_000) frame_dt_us = 500_000;
last_us = if (synthetic_frame_dt) saturatingAddI64(last_us, frame_dt_us) else now_us;

update_accum += frame_dt_us;
tick_accum += frame_dt_us;
update_accum = saturatingAddI64(update_accum, frame_dt_us);
tick_accum = saturatingAddI64(tick_accum, frame_dt_us);

const platform_start_ns = clock.now(self.io).toNanoseconds();
Platform.input.update();
Platform.update(self);
Core.input.update();
const platform_done_ns = clock.now(self.io).toNanoseconds();
var pre_update_elapsed_ns = elapsedNsBetween(platform_start_ns, platform_done_ns);
if (!self.running) break;

// ---- fixed-rate TICK steps (e.g., 20 Hz logic) ----
var is_tick_frame = false;
Expand All @@ -263,43 +287,37 @@ pub const Engine = struct {
while (tick_accum >= tick_us) {
@branchHint(.unpredictable);
is_tick_frame = true;
const tick_start_ns: i64 = @truncate(clock.now(self.io).toNanoseconds());
const tick_start_ns = clock.now(self.io).toNanoseconds();
try Core.state_machine.tick(self);
const tick_end_ns: i64 = @truncate(clock.now(self.io).toNanoseconds());
tick_cost_ns += tick_end_ns - tick_start_ns;
const tick_end_ns = clock.now(self.io).toNanoseconds();
tick_cost_ns = saturatingAddI64(tick_cost_ns, elapsedNsBetween(tick_start_ns, tick_end_ns));
tick_accum -= tick_us;
}

// ---- fixed-rate UPDATE steps (input update & interpolation) ----
// ---- fixed-rate UPDATE steps (simulation & interpolation) ----
const UPDATE_DT_S: f32 = @as(f32, @floatFromInt(UPDATE_US)) / @as(f32, US_PER_S);
while (update_accum >= UPDATE_US) {
@branchHint(.unpredictable);

const step_start_ns: i64 = @truncate(clock.now(self.io).toNanoseconds());
Platform.input.update();
Platform.update(self);
Core.input.update();
const engine_done_ns: i64 = @truncate(clock.now(self.io).toNanoseconds());
const engine_elapsed_ns = engine_done_ns - step_start_ns;

const budget = Util.BudgetContext{
.phase_budget_ns = update_budget_ns,
.engine_elapsed_ns = engine_elapsed_ns,
.remaining_ns = update_budget_ns - engine_elapsed_ns,
.engine_elapsed_ns = pre_update_elapsed_ns,
.remaining_ns = update_budget_ns - pre_update_elapsed_ns,
.is_tick_frame = is_tick_frame,
.tick_cost_ns = tick_cost_ns,
.safety_margin_ns = Util.BudgetContext.DEFAULT_SAFETY_MARGIN_NS,
};

try Core.state_machine.update(self, UPDATE_DT_S, &budget);
pre_update_elapsed_ns = 0;
update_accum -= UPDATE_US;
}

// ---- render ASAP (uncapped when vsync == false) ----
const frame_dt_s: f32 = @as(f32, @floatFromInt(frame_dt_us)) / @as(f32, US_PER_S);
const drew_frame = Platform.gfx.api.start_frame();
if (drew_frame) {
const draw_start_ns: i64 = @truncate(clock.now(self.io).toNanoseconds());
const draw_start_ns = clock.now(self.io).toNanoseconds();
// Time until next update step is due
const slack_us: i64 = @as(i64, @intCast(UPDATE_US)) - @max(0, update_accum);
const draw_budget_ns: i64 = if (self.vsync)
Expand All @@ -326,23 +344,48 @@ pub const Engine = struct {
const next_tick = @as(i64, @intCast(TICK_US)) - tick_accum;
const sleep_us = @max(0, @min(next_update, next_tick));
if (sleep_us > 0) {
try std.Io.sleep(self.io, .fromMicroseconds(sleep_us), clock);
const sleep_ns = sleep_us * NS_PER_US;
try std.Io.sleep(self.io, .fromNanoseconds(@intCast(sleep_ns)), clock);
}
} else if (options.config.platform != .psp) {
try std.Io.sleep(self.io, .fromMilliseconds(50), clock);
try std.Io.sleep(self.io, .fromNanoseconds(50 * std.time.ns_per_ms), clock);
}
}

// ---- FPS counting ----
if (report_fps) {
if (drew_frame) fps_count += 1;
const end_us: i64 = @truncate(@divTrunc(clock.now(self.io).toNanoseconds(), 1000));
const end_us = elapsedUsSince(run_start_ns, clock.now(self.io).toNanoseconds());
if (end_us >= fps_window_end) {
Util.engine_logger.info("FPS: {}", .{fps_count});
fps_count = 0;
fps_window_end = end_us + US_PER_S;
fps_window_end = saturatingAddI64(end_us, fps_window_us);
}
}
}
}
};

fn elapsedNsBetween(start_ns: i96, end_ns: i96) i64 {
return clampI96ToI64(end_ns - start_ns);
}

fn elapsedUsSince(start_ns: i96, end_ns: i96) i64 {
return clampI96ToI64(@divTrunc(end_ns - start_ns, std.time.ns_per_us));
}

fn saturatingAddI64(a: i64, b: i64) i64 {
return clampI96ToI64(@as(i96, a) + @as(i96, b));
}

fn saturatingSubI64(a: i64, b: i64) i64 {
return clampI96ToI64(@as(i96, a) - @as(i96, b));
}

fn clampI96ToI64(value: i96) i64 {
const max: i96 = std.math.maxInt(i64);
const min: i96 = std.math.minInt(i64);
if (value > max) return std.math.maxInt(i64);
if (value < min) return std.math.minInt(i64);
return @intCast(value);
}
Loading