From e25281cc64b00f218fd3a653855bfdffdcdb1b2e Mon Sep 17 00:00:00 2001 From: Stephen Gutekanst Date: Fri, 15 Mar 2024 15:26:28 -0700 Subject: [PATCH] module: add event arguments & dependency injection support Signed-off-by: Stephen Gutekanst --- src/ecs/systems.zig | 3 +- src/module.zig | 317 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 283 insertions(+), 37 deletions(-) diff --git a/src/ecs/systems.zig b/src/ecs/systems.zig index 50112db5..08ce1623 100644 --- a/src/ecs/systems.zig +++ b/src/ecs/systems.zig @@ -8,7 +8,8 @@ const EntityID = @import("entities.zig").EntityID; const comp = @import("comptime.zig"); pub fn World(comptime mods: anytype) type { - const modules = mach.Modules(mods); + const Injectable = struct {}; // TODO + const modules = mach.Modules(mods, Injectable); return struct { allocator: mem.Allocator, diff --git a/src/module.zig b/src/module.zig index f70ae7af..54bacb10 100644 --- a/src/module.zig +++ b/src/module.zig @@ -23,8 +23,8 @@ fn Serializable(comptime T: type) type { return T; } -// Manages comptime .{A, B, C} modules and runtime modules. -pub fn Modules(comptime mods: anytype) type { +/// Manages comptime .{A, B, C} modules and runtime modules. +pub fn Modules(comptime mods: anytype, comptime Injectable: type) type { // Verify that each module is valid. inline for (mods) |M| _ = Module(M); @@ -67,31 +67,84 @@ pub fn Modules(comptime mods: anytype) type { m.events.deinit(); } - // Send a global event - pub fn send(m: *@This(), event_name: EventName(mods), args: anytype) void { + /// Returns an args tuple representing the standard, uninjected, arguments which the given + /// local event handler requires. + fn LocalArgs(module_name: ModuleName(mods), event_name: EventName(mods)) type { + const M = @field(NamespacedModules(@This().modules){}, @tagName(module_name)); + const handler = @field(M.local, @tagName(event_name)); + switch (@typeInfo(@TypeOf(handler))) { + .Fn => return UninjectedArgsTuple(@TypeOf(handler), Injectable), + // Note: This means the module does have some other field by the same name, but it is not a function. + else => @compileError("Module " ++ @tagName(M.name) ++ " has no global event handler " ++ @tagName(event_name)), + } + } + + /// Returns an args tuple representing the standard, uninjected, arguments which the given + /// global event handler requires. + /// + /// If the returned type would differ from EventArgs, a compile-time error will occur. + /// + /// If no module currently has a global event handler of this name, then its argument type + /// is currently undefined and assumed to be EventArgs. + fn Args(comptime EventArgs: type, event_name: EventName(mods)) type { + inline for (modules) |M| { + if (@hasDecl(M, @tagName(event_name))) { + switch (@typeInfo(@TypeOf(@field(M, @tagName(event_name))))) { + .Fn => { + const handler = @field(M, @tagName(event_name)); + // TODO: worth checking if the return type is == EventArgs here? Could + // that lead to better UX? + return UninjectedArgsTuple(@TypeOf(handler), Injectable); + }, + else => {}, + } + } + } + return EventArgs; + } + + /// Send a global event + pub fn send( + m: *@This(), + // TODO: is a variant of this function where event_name is not comptime known, but asserted to be a valid enum, useful? + comptime event_name: EventName(mods), + comptime EventArgs: type, + args: Args(EventArgs, event_name), + ) void { // TODO: comptime safety/debugging m.sendInternal(null, @intFromEnum(event_name), args); } - // Send an event to a specific module - pub fn sendToModule(m: *@This(), module_name: ModuleName(mods), event_name: EventName(mods), args: anytype) void { + /// Send an event to a specific module + pub fn sendToModule( + m: *@This(), + // TODO: is a variant of this function where module_name/event_name is not comptime known, but asserted to be a valid enum, useful? + comptime module_name: ModuleName(mods), + comptime event_name: EventName(mods), + args: LocalArgs(module_name, event_name), + ) void { // TODO: comptime safety/debugging m.sendInternal(@intFromEnum(module_name), @intFromEnum(event_name), args); } - // Send a global event, using a dynamic (not known to the compiled program) event name. + /// Send a global event, using a dynamic (not known to the compiled program) event name. pub fn sendDynamic(m: *@This(), event_name: EventID, args: anytype) void { // TODO: runtime safety/debugging + // TODO: check args do not have obviously wrong things, like comptime values + // TODO: if module_name and event_name are valid enums, can we type-check args at comptime? m.sendInternal(null, event_name, args); } - // Send an event to a specific module, using a dynamic (not known to the compiled program) module and event name. + /// Send an event to a specific module, using a dynamic (not known to the compiled program) module and event name. pub fn sendToModuleDynamic(m: *@This(), module_name: ModuleID, event_name: EventID, args: anytype) void { // TODO: runtime safety/debugging + // TODO: check args do not have obviously wrong things, like comptime values + // TODO: if module_name and event_name are valid enums, can we type-check args at comptime? m.sendInternal(module_name, event_name, args); } fn sendInternal(m: *@This(), module_name: ?ModuleID, event_name: EventID, args: anytype) void { + // TODO: verify arguments are valid, e.g. not comptime types _ = Serializable(@TypeOf(args)); // TODO: debugging @@ -108,7 +161,10 @@ pub fn Modules(comptime mods: anytype) type { }); } - pub fn dispatch(m: *@This()) void { + /// Dispatches pending events, invoking their event handlers. + pub fn dispatch(m: *@This(), injectable: Injectable) void { + // TODO: verify injectable arguments are valid, e.g. not comptime types + // TODO: optimize to reduce send contention // TODO: parallel / multi-threaded dispatch // TODO: PGO @@ -116,20 +172,19 @@ pub fn Modules(comptime mods: anytype) type { defer m.events_mu.unlock(); while (m.events.readItem()) |ev| { - _ = ev.args_slice; // TODO: dispatch arguments if (ev.module_name) |module_name| { // TODO: dispatch arguments - @This().callLocal(@enumFromInt(module_name), @enumFromInt(ev.event_name), .{}); + @This().callLocal(@enumFromInt(module_name), @enumFromInt(ev.event_name), ev.args_slice, injectable); } else { // TODO: dispatch arguments - @This().call(@enumFromInt(ev.event_name), .{}); + @This().call(@enumFromInt(ev.event_name), ev.args_slice, injectable); } } m.args_queue.clearRetainingCapacity(); } - // Call global event handler with the specified name in all modules - inline fn call(event_name: EventName(mods), args: anytype) void { + /// Call global event handler with the specified name in all modules + inline fn call(event_name: EventName(mods), args: []u8, injectable: anytype) void { switch (event_name) { inline else => |name| { inline for (modules) |M| { @@ -137,7 +192,7 @@ pub fn Modules(comptime mods: anytype) type { switch (@typeInfo(@TypeOf(@field(M, @tagName(name))))) { .Fn => { const handler = @field(M, @tagName(name)); - callHandler(handler, args); + callHandler(handler, args, injectable); }, else => {}, } @@ -147,18 +202,20 @@ pub fn Modules(comptime mods: anytype) type { } } - // Call local event handler with the specified name in the specified module - inline fn callLocal(module_name: ModuleName(mods), event_name: EventName(mods), args: anytype) void { + /// Call local event handler with the specified name in the specified module + inline fn callLocal(module_name: ModuleName(mods), event_name: EventName(mods), args: []u8, injectable: anytype) void { + // TODO: invert switch case for hypothetically better branch prediction switch (module_name) { inline else => |mod_name| { switch (event_name) { inline else => |ev_name| { const M = @field(NamespacedModules(@This().modules){}, @tagName(mod_name)); + // TODO: no need for hasDecl, assertion should be event can be sent at send() time. if (@hasDecl(M.local, @tagName(ev_name))) { const handler = @field(M.local, @tagName(ev_name)); switch (@typeInfo(@TypeOf(handler))) { .Fn => { - callHandler(handler, args); + callHandler(handler, args, injectable); }, else => {}, } @@ -169,12 +226,75 @@ pub fn Modules(comptime mods: anytype) type { } } - inline fn callHandler(handler: anytype, args: anytype) void { + /// Invokes an event handler with optionally injected arguments. + inline fn callHandler(handler: anytype, args_data: []u8, injectable: Injectable) void { + const StdArgs = UninjectedArgsTuple(@TypeOf(handler), Injectable); + const std_args: *StdArgs = @alignCast(@ptrCast(args_data.ptr)); + const args = injectArgs(@TypeOf(handler), Injectable, injectable, std_args.*); @call(.auto, handler, args); } }; } +// Given a function, its standard arguments and injectable arguments, performs injection and +// returns the actual argument tuple which would be used to call the function. +inline fn injectArgs( + comptime Function: type, + comptime Injectable: type, + injectable_args: Injectable, + std_args: UninjectedArgsTuple(Function, Injectable), +) std.meta.ArgsTuple(Function) { + var args: std.meta.ArgsTuple(Function) = undefined; + comptime var std_args_index = 0; + outer: inline for (@typeInfo(std.meta.ArgsTuple(Function)).Struct.fields) |arg| { + // Injected arguments always go first, then standard (non-injected) arguments. + if (std_args_index > 0) { + @field(args, arg.name) = std_args[std_args_index]; + std_args_index += 1; + continue; + } + + // Is this argument matching the type of an argument we could inject? + inline for (@typeInfo(Injectable).Struct.fields) |inject_field| { + if (inject_field.type == arg.type and @alignOf(inject_field.type) == @alignOf(arg.type)) { + // Inject argument + @field(args, arg.name) = @field(injectable_args, inject_field.name); + continue :outer; + } + } + + // First standard argument + @field(args, arg.name) = std_args[std_args_index]; + std_args_index += 1; + } + return args; +} + +// Given a function type, and an args tuple of injectable parameters, returns the set of function +// parameters which would **not** be injected. +fn UninjectedArgsTuple(comptime Function: type, comptime Injectable: type) type { + var std_args: []const type = &[0]type{}; + inline for (@typeInfo(std.meta.ArgsTuple(Function)).Struct.fields) |arg| { + // Injected arguments always go first, then standard (non-injected) arguments. + if (std_args.len > 0) { + std_args = std_args ++ [_]type{arg.type}; + continue; + } + // Is this argument matching the type of an argument we could inject? + const injectable = blk: { + inline for (@typeInfo(Injectable).Struct.fields) |inject| { + if (inject.type == arg.type and @alignOf(inject.type) == arg.alignment) { + break :blk true; + } + } + break :blk false; + }; + if (injectable) continue; // legitimate injected argument, ignore it + std_args = std_args ++ [_]type{arg.type}; + } + return std.meta.Tuple(std_args); +} + /// enum describing every possible comptime-known event name fn EventName(comptime mods: anytype) type { var enum_fields: []const std.builtin.Type.EnumField = &[0]std.builtin.Type.EnumField{}; @@ -383,11 +503,12 @@ test Modules { pub const name = .engine_sprite2d; }); + const Injectable = struct {}; var modules: Modules(.{ Physics, Renderer, Sprite2D, - }) = undefined; + }, Injectable) = undefined; try modules.init(testing.allocator); defer modules.deinit(testing.allocator); testing.refAllDeclsRecursive(Physics); @@ -434,11 +555,12 @@ test EventName { }; }); + const Injectable = struct {}; const Mods = Modules(.{ Physics, Renderer, Sprite2D, - }); + }, Injectable); const info = @typeInfo(EventName(Mods.modules)).Enum; try testing.expect(type, u3).eql(info.tag_type); @@ -461,11 +583,12 @@ test ModuleName { const Sprite2D = Module(struct { pub const name = .engine_sprite2d; }); + const Injectable = struct {}; const Mods = Modules(.{ Physics, Renderer, Sprite2D, - }); + }, Injectable); const info = @typeInfo(ModuleName(Mods.modules)).Enum; try testing.expect(type, u2).eql(info.tag_type); @@ -475,6 +598,106 @@ test ModuleName { try testing.expect([]const u8, "engine_sprite2d").eql(info.fields[2].name); } +const TupleTester = struct { + fn assertTypeEqual(comptime Expected: type, comptime Actual: type) void { + if (Expected != Actual) @compileError("Expected type " ++ @typeName(Expected) ++ ", but got type " ++ @typeName(Actual)); + } + + fn assertTuple(comptime expected: anytype, comptime Actual: type) void { + const info = @typeInfo(Actual); + if (info != .Struct) @compileError("Expected struct type"); + if (!info.Struct.is_tuple) @compileError("Struct type must be a tuple type"); + + const fields_list = std.meta.fields(Actual); + if (expected.len != fields_list.len) @compileError("Argument count mismatch"); + + inline for (fields_list, 0..) |fld, i| { + if (expected[i] != fld.type) { + @compileError("Field " ++ fld.name ++ " expected to be type " ++ @typeName(expected[i]) ++ ", but was type " ++ @typeName(fld.type)); + } + } + } +}; + +test injectArgs { + // Injected arguments should generally be *struct types to avoid conflicts with any user-passed + // parameters, though we do not require it - so we test with other types here. + var i: i32 = 1234; + const i32_ptr: *i32 = &i; + var f: f32 = 0.1234; + const f32_ptr: *f32 = &f; + const Foo = struct { foo: f32 }; + var foo: Foo = .{ .foo = 1234 }; + const foo_ptr: *Foo = &foo; + + // No standard, no injected + try testing.expect(struct {}, .{}).eql(injectArgs(fn () void, @TypeOf(.{}), .{}, .{})); + const injectable = .{ i32_ptr, f32_ptr, foo_ptr }; + try testing.expect(struct {}, .{}).eql(injectArgs(fn () void, @TypeOf(injectable), injectable, .{})); + + // Standard parameters only, no injected + try testing.expect(std.meta.Tuple(&.{i32}), .{0}).eql(injectArgs(fn (a: i32) void, @TypeOf(injectable), injectable, .{0})); + try testing.expect(std.meta.Tuple(&.{ i32, f32 }), .{ 1, 0.5 }).eql(injectArgs(fn (a: i32, b: f32) void, @TypeOf(injectable), injectable, .{ 1, 0.5 })); + + // Injected parameters only, no standard + try testing.expect(std.meta.Tuple(&.{*i32}), .{i32_ptr}).eql(injectArgs(fn (a: *i32) void, @TypeOf(injectable), injectable, .{})); + try testing.expect(std.meta.Tuple(&.{*f32}), .{f32_ptr}).eql(injectArgs(fn (a: *f32) void, @TypeOf(injectable), injectable, .{})); + try testing.expect(std.meta.Tuple(&.{*Foo}), .{foo_ptr}).eql(injectArgs(fn (a: *Foo) void, @TypeOf(injectable), injectable, .{})); + try testing.expect(std.meta.Tuple(&.{ *i32, *f32, *Foo }), .{ i32_ptr, f32_ptr, foo_ptr }).eql(injectArgs(fn (a: *i32, b: *f32, c: *Foo) void, @TypeOf(injectable), injectable, .{})); + + // Once a standard parameter is encountered, all parameters after that are considered standard + // and not injected. + var my_f32: f32 = 0.1337; + var my_i32: i32 = 1337; + try testing.expect(std.meta.Tuple(&.{f32}), .{1234}).eql(injectArgs(fn (a: f32) void, @TypeOf(injectable), injectable, .{1234})); + try testing.expect(std.meta.Tuple(&.{ i32, *f32 }), .{ 1234, &my_f32 }).eql(injectArgs(fn (a: i32, b: *f32) void, @TypeOf(injectable), injectable, .{ 1234, &my_f32 })); + try testing.expect(std.meta.Tuple(&.{ i32, *i32, *f32 }), .{ 1234, &my_i32, &my_f32 }).eql(injectArgs(fn (a: i32, b: *i32, c: *f32) void, @TypeOf(injectable), injectable, .{ 1234, &my_i32, &my_f32 })); + + // First parameter (*f32) matches an injectable parameter type, so it is injected. + try testing.expect(std.meta.Tuple(&.{ *f32, i32, *i32, *f32 }), .{ f32_ptr, 1234, &my_i32, &my_f32 }).eql(injectArgs(fn (a: *f32, b: i32, c: *i32, d: *f32) void, @TypeOf(injectable), injectable, .{ 1234, &my_i32, &my_f32 })); + + // First parameter (*f32) matches an injectable parameter type, so it is injected. 2nd + // parameter is not injectable, so all remaining parameters are not injected. + var my_foo = foo; + try testing.expect(std.meta.Tuple(&.{ *f32, i32, *Foo, *i32, *f32 }), .{ f32_ptr, 1234, &my_foo, &my_i32, &my_f32 }).eql(injectArgs(fn (a: *f32, b: i32, c: *Foo, d: *i32, e: *f32) void, @TypeOf(injectable), injectable, .{ 1234, &my_foo, &my_i32, &my_f32 })); +} + +test UninjectedArgsTuple { + // Injected arguments should generally be *struct types to avoid conflicts with any user-passed + // parameters, though we do not require it - so we test with other types here. + const i32_ptr: *i32 = undefined; + const f32_ptr: *f32 = undefined; + const Foo = struct { foo: f32 }; + const foo_ptr: *Foo = undefined; + + // No standard, no injected + TupleTester.assertTuple(.{}, UninjectedArgsTuple(fn () void, @TypeOf(.{}))); + TupleTester.assertTuple(.{}, UninjectedArgsTuple(fn () void, @TypeOf(.{ i32_ptr, f32_ptr, foo_ptr }))); + + // Standard parameters only, no injected + TupleTester.assertTuple(.{i32}, UninjectedArgsTuple(fn (a: i32) void, @TypeOf(.{ i32_ptr, f32_ptr, foo_ptr }))); + TupleTester.assertTuple(.{ i32, f32 }, UninjectedArgsTuple(fn (a: i32, b: f32) void, @TypeOf(.{ i32_ptr, f32_ptr, foo_ptr }))); + + // Injected parameters only, no standard + TupleTester.assertTuple(.{}, UninjectedArgsTuple(fn (a: *i32) void, @TypeOf(.{ i32_ptr, f32_ptr, foo_ptr }))); + TupleTester.assertTuple(.{}, UninjectedArgsTuple(fn (a: *f32) void, @TypeOf(.{ i32_ptr, f32_ptr, foo_ptr }))); + TupleTester.assertTuple(.{}, UninjectedArgsTuple(fn (a: *Foo) void, @TypeOf(.{ i32_ptr, f32_ptr, foo_ptr }))); + TupleTester.assertTuple(.{}, UninjectedArgsTuple(fn (a: *f32, b: *Foo, c: *i32) void, @TypeOf(.{ i32_ptr, f32_ptr, foo_ptr }))); + + // Once a standard parameter is encountered, all parameters after that are considered standard + // and not injected. + TupleTester.assertTuple(.{f32}, UninjectedArgsTuple(fn (a: f32) void, @TypeOf(.{ i32_ptr, f32_ptr, foo_ptr }))); + TupleTester.assertTuple(.{ i32, *f32 }, UninjectedArgsTuple(fn (a: i32, b: *f32) void, @TypeOf(.{ i32_ptr, f32_ptr, foo_ptr }))); + TupleTester.assertTuple(.{ i32, *i32, *f32 }, UninjectedArgsTuple(fn (a: i32, b: *i32, c: *f32) void, @TypeOf(.{ i32_ptr, f32_ptr, foo_ptr }))); + + // First parameter (*f32) matches an injectable parameter type, so it is injected. + TupleTester.assertTuple(.{ i32, *i32, *f32 }, UninjectedArgsTuple(fn (a: *f32, b: i32, c: *i32, d: *f32) void, @TypeOf(.{ i32_ptr, f32_ptr, foo_ptr }))); + + // First parameter (*f32) matches an injectable parameter type, so it is injected. 2nd + // parameter is not injectable, so all remaining parameters are not injected. + TupleTester.assertTuple(.{ i32, *Foo, *i32, *f32 }, UninjectedArgsTuple(fn (a: *f32, b: i32, c: *Foo, d: *i32, e: *f32) void, @TypeOf(.{ i32_ptr, f32_ptr, foo_ptr }))); +} + test "event name calling" { // TODO: verify that event handlers error return signatures are correct const global = struct { @@ -516,14 +739,15 @@ test "event name calling" { }; }); + const Injectable = struct {}; var modules: Modules(.{ Physics, Renderer, - }) = undefined; + }, Injectable) = undefined; try modules.init(testing.allocator); defer modules.deinit(testing.allocator); - @TypeOf(modules).call(.tick, .{}); + @TypeOf(modules).call(.tick, &.{}, .{}); try testing.expect(usize, 2).eql(global.ticks); // Check we can use .call() with a runtime-known event name. @@ -533,13 +757,13 @@ test "event name calling" { alloc.* = @intFromEnum(@as(E, .tick)); var event_name = @as(E, @enumFromInt(alloc.*)); - @TypeOf(modules).call(event_name, .{}); + @TypeOf(modules).call(event_name, &.{}, .{}); try testing.expect(usize, 4).eql(global.ticks); // Check call() behavior with a valid event name enum, but not a valid global event handler name alloc.* = @intFromEnum(@as(E, .update)); event_name = @as(E, @enumFromInt(alloc.*)); - @TypeOf(modules).call(event_name, .{}); + @TypeOf(modules).call(event_name, &.{}, .{}); try testing.expect(usize, 4).eql(global.ticks); try testing.expect(usize, 0).eql(global.physics_updates); try testing.expect(usize, 0).eql(global.renderer_updates); @@ -551,8 +775,8 @@ test "event name calling" { m_alloc.* = @intFromEnum(@as(M, .engine_renderer)); alloc.* = @intFromEnum(@as(E, .update)); var module_name = @as(M, @enumFromInt(m_alloc.*)); - @TypeOf(modules).callLocal(module_name, event_name, .{}); - @TypeOf(modules).callLocal(module_name, event_name, .{}); + @TypeOf(modules).callLocal(module_name, event_name, &.{}, .{}); + @TypeOf(modules).callLocal(module_name, event_name, &.{}, .{}); try testing.expect(usize, 4).eql(global.ticks); try testing.expect(usize, 0).eql(global.physics_updates); try testing.expect(usize, 2).eql(global.renderer_updates); @@ -561,14 +785,14 @@ test "event name calling" { alloc.* = @intFromEnum(@as(E, .update)); module_name = @as(M, @enumFromInt(m_alloc.*)); event_name = @as(E, @enumFromInt(alloc.*)); - @TypeOf(modules).callLocal(module_name, event_name, .{}); + @TypeOf(modules).callLocal(module_name, event_name, &.{}, .{}); try testing.expect(usize, 1).eql(global.physics_updates); m_alloc.* = @intFromEnum(@as(M, .engine_physics)); alloc.* = @intFromEnum(@as(E, .calc)); module_name = @as(M, @enumFromInt(m_alloc.*)); event_name = @as(E, @enumFromInt(alloc.*)); - @TypeOf(modules).callLocal(module_name, event_name, .{}); + @TypeOf(modules).callLocal(module_name, event_name, &.{}, .{}); try testing.expect(usize, 4).eql(global.ticks); try testing.expect(usize, 1).eql(global.physics_calc); try testing.expect(usize, 1).eql(global.physics_updates); @@ -581,7 +805,11 @@ test "dispatch" { var physics_updates: usize = 0; var physics_calc: usize = 0; var renderer_updates: usize = 0; + var basic_args_sum: usize = 0; }; + var foo = struct { + injected_args_sum: usize = 0, + }{}; const Physics = Module(struct { pub const name = .engine_physics; pub const components = struct {}; @@ -612,13 +840,22 @@ test "dispatch" { pub fn update() void { global.renderer_updates += 1; } + + pub fn basicArgs(a: u32, b: u32) void { + global.basic_args_sum = a + b; + } + + pub fn injectedArgs(foo_ptr: *@TypeOf(foo), a: u32, b: u32) void { + foo_ptr.*.injected_args_sum = a + b; + } }; }); + const injectable = .{&foo}; var modules: Modules(.{ Physics, Renderer, - }) = undefined; + }, @TypeOf(injectable)) = undefined; try modules.init(testing.allocator); defer modules.deinit(testing.allocator); @@ -626,17 +863,18 @@ test "dispatch" { const M = ModuleName(@TypeOf(modules).modules); // Global events - modules.send(.tick, .{}); + modules.send(.tick, struct {}, .{}); try testing.expect(usize, 0).eql(global.ticks); - modules.dispatch(); + modules.dispatch(.{&foo}); try testing.expect(usize, 2).eql(global.ticks); + // TODO: make sendDynamic take an args type to avoid footguns with comptime values, etc. modules.sendDynamic(@intFromEnum(@as(E, .tick)), .{}); - modules.dispatch(); + modules.dispatch(.{&foo}); try testing.expect(usize, 4).eql(global.ticks); // Local events modules.sendToModule(.engine_renderer, .update, .{}); - modules.dispatch(); + modules.dispatch(.{&foo}); try testing.expect(usize, 1).eql(global.renderer_updates); modules.sendToModule(.engine_physics, .update, .{}); modules.sendToModuleDynamic( @@ -644,7 +882,14 @@ test "dispatch" { @intFromEnum(@as(E, .calc)), .{}, ); - modules.dispatch(); + modules.dispatch(.{&foo}); try testing.expect(usize, 1).eql(global.physics_updates); try testing.expect(usize, 1).eql(global.physics_calc); + + // Local events + modules.sendToModule(.engine_renderer, .basicArgs, .{ @as(u32, 1), @as(u32, 2) }); // TODO: match arguments against fn ArgsTuple, for correctness and type inference + modules.sendToModule(.engine_renderer, .injectedArgs, .{ @as(u32, 1), @as(u32, 2) }); + modules.dispatch(.{&foo}); + try testing.expect(usize, 3).eql(global.basic_args_sum); + try testing.expect(usize, 3).eql(foo.injected_args_sum); }