add frontend bindings translation, headless contract driver and App container
This commit is contained in:
@@ -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 = &.{},
|
||||
};
|
||||
@@ -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 {}
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user