add frontend bindings translation, headless contract driver and App container

This commit is contained in:
2026-08-14 16:26:23 +02:00
parent 0a9f0e3ee4
commit 2946cda8be
5 changed files with 483 additions and 2 deletions
+63
View File
@@ -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 = &.{},
};
+25
View File
@@ -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 {}
};
+367
View File
@@ -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);
}
+24 -2
View File
@@ -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);