From 2946cda8be7f0a379d3f2c1f3237d83e5d122fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20=C5=9Een?= Date: Fri, 14 Aug 2026 16:26:23 +0200 Subject: [PATCH] add frontend bindings translation, headless contract driver and App container --- build.zig | 4 + src/frontend/bindings.zig | 63 +++++++ src/frontend/headless.zig | 25 +++ src/frontend/root.zig | 367 ++++++++++++++++++++++++++++++++++++++ src/main.zig | 26 ++- 5 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 src/frontend/bindings.zig create mode 100644 src/frontend/headless.zig diff --git a/build.zig b/build.zig index 83279ff..5c6e704 100644 --- a/build.zig +++ b/build.zig @@ -45,6 +45,8 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/frontend/root.zig"), }); frontend_mod.addImport("contract", contract_mod); + frontend_mod.addImport("platform", platform_mod); + frontend_mod.addImport("system", system_mod); const exe_mod = b.createModule(.{ .target = target, @@ -53,6 +55,8 @@ pub fn build(b: *std.Build) void { }); exe_mod.addImport("contract", contract_mod); exe_mod.addImport("system", system_mod); + exe_mod.addImport("platform", platform_mod); + exe_mod.addImport("frontend", frontend_mod); const exe = b.addExecutable(.{ .name = "6soz", diff --git a/src/frontend/bindings.zig b/src/frontend/bindings.zig new file mode 100644 index 0000000..84d9f07 --- /dev/null +++ b/src/frontend/bindings.zig @@ -0,0 +1,63 @@ +const platform = @import("platform"); + +pub const AppAction = enum { + toggle_pause, + reset, + quick_save, + quick_load, + stop, +}; + +pub const ButtonTarget = struct { + device: u8, + button: u8, +}; + +pub const AxisTarget = struct { + device: u8, + axis: u8, +}; + +pub const PointerTarget = struct { + device: u8, + pointer: u8, +}; + +pub const DigitalTarget = union(enum) { + button: ButtonTarget, + action: AppAction, +}; + +pub const KeyBinding = struct { + key: platform.Key, + target: DigitalTarget, +}; + +pub const GamepadButtonBinding = struct { + gamepad: u8, + button: u8, + + target: DigitalTarget, +}; + +pub const GamepadAxisBinding = struct { + gamepad: u8, + axis: u8, + + target: AxisTarget, +}; + +pub const PointerBinding = struct { + source: u8, + target: PointerTarget, +}; + +pub const Bindings = struct { + keys: []const KeyBinding = &.{}, + + gamepad_buttons: []const GamepadButtonBinding = &.{}, + + gamepad_axes: []const GamepadAxisBinding = &.{}, + + pointers: []const PointerBinding = &.{}, +}; diff --git a/src/frontend/headless.zig b/src/frontend/headless.zig new file mode 100644 index 0000000..3eef7c2 --- /dev/null +++ b/src/frontend/headless.zig @@ -0,0 +1,25 @@ +const contract = @import("contract"); +const platform = @import("platform"); + +pub const Driver = struct { + video_frame_count: u64 = 0, + audio_frame_count: u64 = 0, + + pub fn init() Driver { + return .{}; + } + + pub fn pollEvent(_: *Driver) ?platform.Event { + return null; + } + + pub fn renderVideo(self: *Driver, _: usize, _: contract.VideoFrame) void { + self.video_frame_count += 1; + } + + pub fn queueAudio(self: *Driver, _: usize, buffer: contract.AudioBuffer) void { + self.audio_frame_count += buffer.frames; + } + + pub fn setDeviceFeedback(_: *Driver, _: usize, _: contract.DeviceFeedback) void {} +}; diff --git a/src/frontend/root.zig b/src/frontend/root.zig index e69de29..b3ff1f7 100644 --- a/src/frontend/root.zig +++ b/src/frontend/root.zig @@ -0,0 +1,367 @@ +const std = @import("std"); + +const contract = @import("contract"); +const platform = @import("platform"); + +pub const bindings = @import("bindings.zig"); +pub const headless = @import("headless.zig"); + +pub const Bindings = bindings.Bindings; +pub const AppAction = bindings.AppAction; +pub const HeadlessDriver = headless.Driver; + +pub const State = enum { + running, + paused, + stopped, +}; + +pub const Options = struct { + /// Caller-owned storage for quick save states. + /// + /// Keeping this external avoids imposing heap allocation or + /// an arbitrary fixed limit on every frontend. + quick_state_buffer: ?[]u8 = null, +}; + +fn maxAxisCount(comptime specs: []const contract.InputDeviceSpec) usize { + var result: usize = 0; + for (specs) |spec| { + result = @max(result, @as(usize, spec.axis_count)); + } + return result; +} + +fn maxPointerCount(comptime specs: []const contract.InputDeviceSpec) usize { + var result: usize = 0; + for (specs) |spec| { + result = @max(result, @as(usize, spec.pointer_count)); + } + return result; +} + +pub fn App(comptime System: type) type { + comptime { + contract.validateSystem(System); + } + + const device_count = System.spec.input_devices.len; + const max_axes = maxAxisCount(System.spec.input_devices); + const max_pointers = maxPointerCount(System.spec.input_devices); + + const DeviceState = struct { + buttons: u64 = 0, + axes: [max_axes]contract.AxisValue = [_]contract.AxisValue{0} ** max_axes, + pointers: [max_pointers]contract.PointerState = [_]contract.PointerState{.{}} ** max_pointers, + motion: ?contract.MotionState = null, + + fn view(self: *const @This(), spec: contract.InputDeviceSpec) contract.DeviceInput { + return .{ + .buttons = self.buttons, + .axes = self.axes[0..spec.axis_count], + .pointers = self.pointers[0..spec.pointer_count], + .motion = self.motion, + }; + } + }; + + return struct { + const Self = @This(); + + /// Borrowed. + /// + /// App does NOT own or move the system. + system: *System, + + state: State = .running, + + bindings: Bindings, + + devices: [device_count]DeviceState = [_]DeviceState{.{}} ** device_count, + + quick_state_buffer: ?[]u8 = null, + quick_state_len: usize = 0, + + pub fn init( + system: *System, + app_bindings: Bindings, + options: Options, + ) Self { + if (System.spec.save_state) |state_spec| { + if (options.quick_state_buffer) |buffer| { + std.debug.assert(buffer.len >= state_spec.max_size); + } + } + + return .{ + .system = system, + .bindings = app_bindings, + .quick_state_buffer = options.quick_state_buffer, + }; + } + + pub fn handleEvent(self: *Self, event: platform.Event) void { + switch (event) { + .quit => self.state = .stopped, + .key_down => |key| self.handleKey(key.key, true, key.repeat), + .key_up => |key| self.handleKey(key.key, false, false), + .gamepad_button_down => |event_| self.handleGamepadButton(event_, true), + .gamepad_button_up => |event_| self.handleGamepadButton(event_, false), + .gamepad_axis => |event_| self.handleGamepadAxis(event_), + .pointer => |event_| self.handlePointer(event_), + } + } + + fn handleKey(self: *Self, key: platform.Key, pressed: bool, repeat: bool) void { + for (self.bindings.keys) |entry| { + if (entry.key != key) continue; + + // App commands shouldn't repeatedly fire because the OS is repeating a held key. + if (repeat) { + switch (entry.target) { + .action => continue, + else => {}, + } + } + + self.applyDigitalTarget(entry.target, pressed); + } + } + + fn handleGamepadButton(self: *Self, event: platform.GamepadButtonEvent, pressed: bool) void { + for (self.bindings.gamepad_buttons) |entry| { + if (entry.gamepad != event.gamepad or entry.button != event.button) continue; + self.applyDigitalTarget(entry.target, pressed); + } + } + + fn handleGamepadAxis(self: *Self, event: platform.GamepadAxisEvent) void { + if (max_axes == 0) return; + for (self.bindings.gamepad_axes) |entry| { + if (entry.gamepad != event.gamepad or entry.axis != event.axis) continue; + + const target = entry.target; + std.debug.assert(target.device < device_count); + const spec = System.spec.input_devices[target.device]; + std.debug.assert(target.axis < spec.axis_count); + + self.devices[target.device].axes[target.axis] = event.value; + } + } + + fn handlePointer(self: *Self, event: platform.PointerEvent) void { + if (max_pointers == 0) return; + for (self.bindings.pointers) |entry| { + if (entry.source != event.pointer) continue; + + const target = entry.target; + std.debug.assert(target.device < device_count); + const spec = System.spec.input_devices[target.device]; + std.debug.assert(target.pointer < spec.pointer_count); + + self.devices[target.device].pointers[target.pointer] = .{ + .active = event.active, + .x = event.x, + .y = event.y, + .pressure = event.pressure, + }; + } + } + + fn applyDigitalTarget(self: *Self, target: bindings.DigitalTarget, pressed: bool) void { + switch (target) { + .button => |button| { + std.debug.assert(button.device < device_count); + const spec = System.spec.input_devices[button.device]; + std.debug.assert(button.button < spec.button_count); + + const mask = @as(u64, 1) << @intCast(button.button); + if (pressed) { + self.devices[button.device].buttons |= mask; + } else { + self.devices[button.device].buttons &= ~mask; + } + }, + .action => |action| { + if (pressed) self.handleAction(action); + }, + } + } + + fn handleAction(self: *Self, action: AppAction) void { + switch (action) { + .toggle_pause => { + self.state = switch (self.state) { + .running => .paused, + .paused => .running, + .stopped => .stopped, + }; + }, + .reset => { + self.system.reset(); + }, + .quick_save => self.quickSave(), + .quick_load => self.quickLoad(), + .stop => self.state = .stopped, + } + } + + pub fn updateInput(self: *Self) void { + if (device_count == 0) return; + + for (System.spec.input_devices, 0..) |spec, device| { + self.system.setInput(device, self.devices[device].view(spec)); + } + } + + pub fn quickSave(self: *Self) void { + if (System.spec.save_state == null) return; + const buffer = self.quick_state_buffer orelse return; + + self.quick_state_len = self.system.saveState(buffer) catch return; + } + + pub fn quickLoad(self: *Self) void { + if (System.spec.save_state == null) return; + if (self.quick_state_len == 0) return; + const buffer = self.quick_state_buffer orelse return; + + self.system.loadState(buffer[0..self.quick_state_len]) catch {}; + } + + pub fn tickFrame(self: *Self, driver: anytype) void { + if (self.state != .running) return; + + self.updateInput(); + self.system.runFrame(); + + for (0..System.spec.video_outputs.len) |output| { + driver.renderVideo(output, self.system.videoFrame(output)); + } + + for (0..System.spec.audio_outputs.len) |output| { + driver.queueAudio(output, self.system.audioBuffer(output)); + } + + self.updateFeedback(driver); + } + + fn updateFeedback(self: *Self, driver: anytype) void { + comptime var has_haptics = false; + inline for (System.spec.input_devices) |spec| { + if (spec.haptic_count != 0) has_haptics = true; + } + + if (comptime has_haptics) { + for (System.spec.input_devices, 0..) |spec, device| { + if (spec.haptic_count == 0) continue; + driver.setDeviceFeedback(device, self.system.deviceFeedback(device)); + } + } + } + }; +} + +test "Frontend - Generic App Container & Key Bindings" { + const DummySystem = struct { + const Self = @This(); + pub const spec = contract.SystemSpec{ + .name = "Dummy System", + .video_outputs = &.{.{ + .max_width = 256, + .max_height = 240, + .format = .rgb565, + .aspect_ratio = .{ .numerator = 4, .denominator = 3 }, + .refresh_rate = .{ .numerator = 60, .denominator = 1 }, + }}, + .audio_outputs = &.{.{ + .sample_rate = 44100, + .channels = 1, + .format = .i16, + }}, + .input_devices = &.{.{ .button_count = 8 }}, + .storage_devices = &.{.{ + .name = "SRAM", + .min_size = 0, + .max_size = 8192, + .persistent = true, + .removable = false, + .writable = true, + }}, + .save_state = .{ + .max_size = 1024, + }, + }; + + input: u64 = 0, + frame_count: usize = 0, + + pub fn reset(_: *Self) void {} + pub fn runFrame(self: *Self) void { + self.frame_count += 1; + } + pub fn videoFrame(_: *const Self, _: usize) contract.VideoFrame { + return .{ + .data = &.{}, + .width = 256, + .height = 240, + .pitch = 256, + .format = .rgb565, + }; + } + pub fn audioBuffer(_: *const Self, _: usize) contract.AudioBuffer { + return .{ + .data = &.{}, + .frames = 0, + .sample_rate = 44100, + .channels = 1, + .format = .i16, + }; + } + pub fn setInput(self: *Self, _: usize, inp: contract.DeviceInput) void { + self.input = inp.buttons; + } + pub fn saveState(_: *const Self, buf: []u8) contract.state.Error!usize { + if (buf.len < 4) return error.BufferTooSmall; + @memcpy(buf[0..4], "TEST"); + return 4; + } + pub fn loadState(_: *Self, _: []const u8) contract.state.Error!void {} + pub fn storageView(_: *const Self, _: usize) contract.StorageView { + return .{ .data = &.{} }; + } + pub fn loadStorage(_: *Self, _: usize, _: []const u8) contract.StorageLoadError!void {} + }; + + var sys = DummySystem{}; + var save_buf: [1024]u8 = undefined; + + const dummy_bindings = Bindings{ + .keys = &.{ + .{ .key = .z, .target = .{ .button = .{ .device = 0, .button = 0 } } }, + .{ .key = .f5, .target = .{ .action = .quick_save } }, + .{ .key = .p, .target = .{ .action = .toggle_pause } }, + }, + }; + + var app = App(DummySystem).init(&sys, dummy_bindings, .{ .quick_state_buffer = &save_buf }); + var driver = HeadlessDriver.init(); + + try std.testing.expectEqual(State.running, app.state); + app.tickFrame(&driver); + try std.testing.expectEqual(@as(usize, 1), app.system.frame_count); + try std.testing.expectEqual(@as(u64, 1), driver.video_frame_count); + + // Test Key Binding Translation + app.handleEvent(.{ .key_down = .{ .key = .z } }); + app.updateInput(); + try std.testing.expectEqual(@as(u64, 1), app.system.input); + + // Test Pause Action + app.handleEvent(.{ .key_down = .{ .key = .p } }); + try std.testing.expectEqual(State.paused, app.state); + + // Test Quick Save + app.quickSave(); + try std.testing.expectEqual(@as(usize, 4), app.quick_state_len); +} diff --git a/src/main.zig b/src/main.zig index 1c35f6e..76940ed 100644 --- a/src/main.zig +++ b/src/main.zig @@ -101,12 +101,34 @@ pub fn main(init: std.process.Init) !void { } } + const frontend = @import("frontend"); + + const nes_bindings = frontend.Bindings{ + .keys = &.{ + .{ .key = .z, .target = .{ .button = .{ .device = 0, .button = 0 } } }, + .{ .key = .x, .target = .{ .button = .{ .device = 0, .button = 1 } } }, + .{ .key = .right_shift, .target = .{ .button = .{ .device = 0, .button = 2 } } }, + .{ .key = .enter, .target = .{ .button = .{ .device = 0, .button = 3 } } }, + .{ .key = .up, .target = .{ .button = .{ .device = 0, .button = 4 } } }, + .{ .key = .down, .target = .{ .button = .{ .device = 0, .button = 5 } } }, + .{ .key = .left, .target = .{ .button = .{ .device = 0, .button = 6 } } }, + .{ .key = .right, .target = .{ .button = .{ .device = 0, .button = 7 } } }, + .{ .key = .f5, .target = .{ .action = .quick_save } }, + .{ .key = .f8, .target = .{ .action = .quick_load } }, + .{ .key = .p, .target = .{ .action = .toggle_pause } }, + }, + }; + + var quick_save_buffer: [64 * 1024]u8 = undefined; + var app = frontend.App(Nes).init(&nes, nes_bindings, .{ .quick_state_buffer = &quick_save_buffer }); + var driver = frontend.HeadlessDriver.init(); + std.debug.print("[6soz] Running NES emulator for {d} frames...\n", .{target_frames}); const start_time = std.Io.Clock.Timestamp.now(init.io, .awake); var frame: u64 = 0; - while (frame < target_frames) : (frame += 1) { - nes.runFrame(); + while (frame < target_frames and app.state == .running) : (frame += 1) { + app.tickFrame(&driver); } const end_time = std.Io.Clock.Timestamp.now(init.io, .awake);