diff --git a/src/core/gyro.zig b/src/core/gyro.zig index 4b96a70..660b9d6 100644 --- a/src/core/gyro.zig +++ b/src/core/gyro.zig @@ -75,11 +75,11 @@ pub const GyroProcessor = struct { self.ema_x = self.ema_x * cfg.smoothing + fsrc_x * (1.0 - cfg.smoothing); self.ema_y = self.ema_y * cfg.smoothing + fsrc_y * (1.0 - cfg.smoothing); - // [3] normalized curve (vader5): normalize [deadzone,max_val]→[0,1], apply pow, sensitivity scale + // [3] normalized curve: normalize [deadzone,max_val]→[0,1], apply pow, sensitivity scale const scaled_x = applyCurve(self.ema_x, cfg) * cfg.sensitivity_x; const scaled_y = applyCurve(self.ema_y, cfg) * cfg.sensitivity_y; - // [5] invert + // [4] invert const final_x = if (cfg.invert_x) -scaled_x else scaled_x; const final_y = if (cfg.invert_y) -scaled_y else scaled_y; @@ -100,7 +100,7 @@ pub const GyroProcessor = struct { return .{ .rel_x = 0, .rel_y = 0, .joy_x = jx, .joy_y = jy }; } - // [6] sub-pixel accumulation + // [5] sub-pixel accumulation self.accum_x += final_x; self.accum_y += final_y; const dx: i32 = @intFromFloat(@trunc(self.accum_x)); @@ -158,7 +158,7 @@ fn selectTiltAxis(axis: GyroAxis, ax: i16, ay: i16, az: i16) ?f32 { .none => null, .pitch => std.math.atan2(fy, @sqrt(fx * fx + fz * fz)) * radians_to_degrees, .roll => std.math.atan2(-fx, @sqrt(fy * fy + fz * fz)) * radians_to_degrees, - .yaw => 0.0, + .yaw => null, }; } @@ -278,6 +278,20 @@ test "gyro: joystick tilt response ignores missing accelerometer vector" { try testing.expectEqual(@as(f32, -8.0), g.ema_y); } +test "gyro: tilt response yaw axis yields null (no accelerometer representation)" { + var g = GyroProcessor{}; + const cfg = GyroConfig{ + .mode = "joystick", + .response = .tilt, + .axis_x = .roll, + .axis_y = .yaw, + .smoothing = 0.0, + }; + const out = g.processMotion(&cfg, 0, 0, 0, -5735, 0, 8192); + try testing.expect(out.joy_x != null); + try testing.expect(out.joy_y == null); +} + test "gyro: joystick rate response can route roll gyro to X axis" { var g = GyroProcessor{}; const cfg = GyroConfig{ diff --git a/src/core/macro_player.zig b/src/core/macro_player.zig index 142f043..014124e 100644 --- a/src/core/macro_player.zig +++ b/src/core/macro_player.zig @@ -14,7 +14,7 @@ const ButtonId = state_mod.ButtonId; /// Analog floor contributed by macros for LT/RT. Mapper merges this into /// emit_state.lt/rt via @max so a physical press always wins over the macro -/// when stronger (issue #99 — digital BTN_TL2 alone is not seen by SDL/games). +/// when stronger (digital BTN_TL2 alone is not seen by SDL/games). pub const AxisInjection = struct { lt: u8 = 0, rt: u8 = 0, @@ -82,7 +82,7 @@ pub const MacroPlayer = struct { /// of a .gamepad_button target set or clear bits here. /// pending_tap_release: tap bits ORed by this frame; mapper clears them next frame /// (same cadence as the remap tap path — see mapper.emitTapEvent). - /// axes: analog LT/RT floor for this frame (issue #99). `.tap` raises the + /// axes: analog LT/RT floor for this frame. `.tap` raises the /// floor for one frame; `.down`/`.up` flip the player's held-axis state /// which is re-asserted every frame until cancelled. pub fn step( @@ -187,44 +187,34 @@ pub const MacroPlayer = struct { /// Called on layer switch / macro cancel. Drops key-up aux events AND clears /// held gamepad bits from injected_buttons. pub fn emitPendingReleases(self: *MacroPlayer, aux: *AuxEventList, injected_buttons: *u64) void { - // Walk steps up to step_index, track net held state per name (keys / mouse buttons). - // Gamepad bits are tracked live in self.held_gamepad_buttons and cleared below. - var held: [32]?[]const u8 = [_]?[]const u8{null} ** 32; - var held_len: usize = 0; - - for (self.macro.steps[0..self.step_index]) |s| { - switch (s) { - .down => |name| { - if (held_len < held.len) { - held[held_len] = name; - held_len += 1; - } - }, - .up => |name| { - for (held[0..held_len], 0..) |h, i| { - if (h) |hn| { - if (std.mem.eql(u8, hn, name)) { - held[i] = held[held_len - 1]; - held_len -= 1; - break; - } - } - } - }, - .tap => {}, - .delay, .pause_for_release => {}, - else => unreachable, // .press is eliminated by expandMacroPress at parse time + // Walk executed steps in reverse: a `.down` is net-held unless a later + // `.up` (or an already-emitted release for the same name) covers it. + // Gamepad bits are tracked live in self.held_gamepad_buttons. + const steps = self.macro.steps[0..self.step_index]; + var i: usize = steps.len; + while (i > 0) { + i -= 1; + const name = switch (steps[i]) { + .down => |n| n, + else => continue, + }; + var covered = false; + for (steps[i + 1 ..]) |later| { + const ln = switch (later) { + .up, .down => |n| n, + else => continue, + }; + if (std.mem.eql(u8, ln, name)) { + covered = true; + break; + } } - } - - for (held[0..held_len]) |h| { - const name = h orelse continue; + if (covered) continue; const target = resolveTargetSafe(name) orelse continue; switch (target) { .key => |code| aux.append(.{ .key = .{ .code = code, .pressed = false } }) catch {}, .mouse_button => |code| aux.append(.{ .mouse_button = .{ .code = code, .pressed = false } }) catch {}, - .gamepad_button => {}, - .disabled, .macro, .chord, .gesture => {}, + .gamepad_button, .disabled, .macro, .chord, .gesture => {}, } } @@ -372,6 +362,27 @@ test "macro_player: emitPendingReleases down without up emits release" { } } +test "macro_player: emitPendingReleases releases all held keys beyond 32" { + // 40 distinct keys held down, no matching .up — every one must be released. + const names = [_][]const u8{ + "KEY_A", "KEY_B", "KEY_C", "KEY_D", "KEY_E", "KEY_F", "KEY_G", "KEY_H", + "KEY_I", "KEY_J", "KEY_K", "KEY_L", "KEY_M", "KEY_N", "KEY_O", "KEY_P", + "KEY_Q", "KEY_R", "KEY_S", "KEY_T", "KEY_U", "KEY_V", "KEY_W", "KEY_X", + "KEY_Y", "KEY_Z", "KEY_0", "KEY_1", "KEY_2", "KEY_3", "KEY_4", "KEY_5", + "KEY_6", "KEY_7", "KEY_8", "KEY_9", "KEY_F1", "KEY_F2", "KEY_F3", "KEY_F4", + }; + var steps: [names.len]MacroStep = undefined; + for (names, 0..) |n, i| steps[i] = .{ .down = n }; + const m = Macro{ .name = "many", .steps = &steps }; + var player = makePlayer(&m); + player.step_index = steps.len; + + var aux = AuxEventList{}; + var injected: u64 = 0; + player.emitPendingReleases(&aux, &injected); + try testing.expectEqual(@as(usize, names.len), aux.len); +} + test "macro_player: shift_hold — down pause_for_release up" { const allocator = testing.allocator; const steps = [_]MacroStep{ .{ .down = "KEY_LEFTSHIFT" }, .pause_for_release, .{ .up = "KEY_LEFTSHIFT" } }; diff --git a/src/core/mapper.zig b/src/core/mapper.zig index ce0eb0d..a731c2a 100644 --- a/src/core/mapper.zig +++ b/src/core/mapper.zig @@ -378,10 +378,10 @@ pub const Mapper = struct { pub fn apply(self: *Mapper, delta: GamepadStateDelta, dt_ms: u32, now_ns: i128) !OutputEvents { // flush pending tap release from previous frame var aux = AuxEventList{}; - if (self.pending_tap_release) |mask| { - self.injected_buttons &= ~mask; + // The pending tap bit is simply not re-injected this frame — injected_buttons + // is zeroed below before output, so the tap release needs no explicit clear. + if (self.pending_tap_release != null) { self.pending_tap_release = null; - // inject release into emit state at end of this frame } self.state.applyDelta(delta); @@ -448,6 +448,8 @@ pub const Mapper = struct { var gyro_joy_x: ?i16 = null; var gyro_joy_y: ?i16 = null; var gyro_blend_stick: bool = false; + const left_cfg = self.effectiveStickConfig(.left); + const right_cfg = self.effectiveStickConfig(.right); { const gcfg = self.effectiveGyroConfig(); const activate_spec = blk: { @@ -490,7 +492,6 @@ pub const Mapper = struct { self.gyro_proc.reset(); } - const left_cfg = self.effectiveStickConfig(.left); const left_out = self.stick_left.process(&left_cfg, self.state.ax, self.state.ay, dt_ms); if (std.mem.eql(u8, left_cfg.mode, "mouse")) { if (left_out.rel_x != 0) aux.append(.{ .rel = .{ .code = REL_X, .value = left_out.rel_x } }) catch {}; @@ -500,7 +501,6 @@ pub const Mapper = struct { if (left_out.hwheel != 0) aux.append(.{ .rel = .{ .code = REL_HWHEEL, .value = left_out.hwheel } }) catch {}; } - const right_cfg = self.effectiveStickConfig(.right); const right_out = self.stick_right.process(&right_cfg, self.state.rx, self.state.ry, dt_ms); if (std.mem.eql(u8, right_cfg.mode, "mouse")) { if (right_out.rel_x != 0) aux.append(.{ .rel = .{ .code = REL_X, .value = right_out.rel_x } }) catch {}; @@ -640,8 +640,8 @@ pub const Mapper = struct { // assemble emit state var emit_state = self.state; emit_state.buttons = (self.state.buttons & ~self.suppressed_buttons) | self.injected_buttons; - // issue #99: macros driving LT/RT raise the analog axis floor; physical - // input still wins when the user presses harder than the macro. + // macros driving LT/RT raise the analog axis floor; physical input + // still wins when the user presses harder than the macro. if (macro_axes.lt > emit_state.lt) emit_state.lt = macro_axes.lt; if (macro_axes.rt > emit_state.rt) emit_state.rt = macro_axes.rt; emit_state.synthesizeDpadAxes(); @@ -673,8 +673,6 @@ pub const Mapper = struct { } // suppress stick axes when mode != gamepad - const left_cfg = self.effectiveStickConfig(.left); - const right_cfg = self.effectiveStickConfig(.right); if (!suppress_left_stick_gyro and (left_cfg.suppress_gamepad or !std.mem.eql(u8, left_cfg.mode, "gamepad"))) { emit_state.ax = 0; emit_state.ay = 0; @@ -1383,6 +1381,63 @@ test "mapper: base remap key: source -> KEY_F13 aux event" { } } +test "mapper: gesture tap remap emits tap key through apply" { + const allocator = testing.allocator; + const parsed = try makeMapping( + \\[remap] + \\A = { tap = "KEY_X", hold = "KEY_Y" } + , allocator); + defer parsed.deinit(); + + var m = try makeMapper(&parsed.value, allocator); + defer m.deinit(); + + const a_mask = buttonBit("A"); + // Press: no emit yet (tap deferred until release decides leg). + _ = try m.apply(.{ .buttons = a_mask }, 16, 0); + // Release before hold deadline: tap fires. + const ev = try m.apply(.{ .buttons = 0 }, 16, 10 * std.time.ns_per_ms); + try testing.expectEqual(@as(usize, 1), ev.aux.len); + switch (ev.aux.get(0)) { + .key => |k| { + try testing.expectEqual(@as(u16, 45), k.code); // KEY_X + try testing.expect(k.pressed); + }, + else => return error.WrongEventType, + } +} + +test "mapper: gesture hold gamepad bit persists then clears on release" { + const allocator = testing.allocator; + const parsed = try makeMapping( + \\[remap] + \\A = { tap = "X", hold = "RB", hold_ms = 100 } + , allocator); + defer parsed.deinit(); + + var m = try makeMapper(&parsed.value, allocator); + defer m.deinit(); + + const a_mask = buttonBit("A"); + const rb_mask = buttonBit("RB"); + + // Press arms the hold timer. + _ = try m.apply(.{ .buttons = a_mask }, 16, 0); + + // Hold deadline fires while button still held -> gamepad press staged. + _ = m.onMacroTimerExpired(100 * std.time.ns_per_ms + 1); + try testing.expect((m.gesture_held_gamepad & rb_mask) != 0); + + // Next frame re-asserts the held bit into output. + const held_ev = try m.apply(.{ .buttons = a_mask }, 16, 110 * std.time.ns_per_ms); + try testing.expect((held_ev.gamepad.buttons & rb_mask) != 0); + + // Release clears the held bit; output no longer carries RB. + const rel_ev = try m.apply(.{ .buttons = 0 }, 16, 120 * std.time.ns_per_ms); + try testing.expectEqual(@as(u64, 0), m.gesture_held_gamepad & rb_mask); + try testing.expectEqual(@as(u64, 0), rel_ev.gamepad.buttons & rb_mask); +} + test "mapper: releaseHeldAux releases old trigger-threshold aux down" { const allocator = testing.allocator; const old_parsed = try makeMapping( @@ -1693,6 +1748,67 @@ test "mapper: hold_toggle timer transition resets processors" { try testing.expectEqual(@as(f32, 0), m.stick_right.scroll_accum); } +test "mapper: pause_for_release macro drained on layer deactivation" { + const allocator = testing.allocator; + const parsed = try makeMapping( + \\[[layer]] + \\name = "fn" + \\trigger = "Select" + \\activation = "toggle" + \\ + \\[remap] + \\M1 = "macro:shift_hold" + \\ + \\[[macro]] + \\name = "shift_hold" + \\steps = [ + \\ { down = "KEY_LEFTSHIFT" }, + \\ "pause_for_release", + \\ { up = "KEY_LEFTSHIFT" }, + \\] + , allocator); + defer parsed.deinit(); + + var m = try makeMapper(&parsed.value, allocator); + defer m.deinit(); + + const sel_mask = buttonBit("Select"); + const m1_mask = buttonBit("M1"); + + // Toggle the layer on (rising then falling edge on Select). + _ = try m.apply(.{ .buttons = sel_mask }, 16, 0); + _ = try m.apply(.{ .buttons = 0 }, 16, 0); + try testing.expect(m.layer.getActive(m.config.layer.?) != null); + + // Trigger the macro: down LEFTSHIFT fires, pause_for_release halts. + const ev_press = try m.apply(.{ .buttons = m1_mask }, 16, 0); + try testing.expectEqual(@as(usize, 1), m.active_macros.items.len); + try testing.expect(m.active_macros.items[0].waiting_for_release); + var saw_press = false; + for (ev_press.aux.slice()) |e| switch (e) { + .key => |k| if (k.code == 42 and k.pressed) { + saw_press = true; + }, + else => {}, + }; + try testing.expect(saw_press); + + // Toggle the layer off: deactivation drains the paused macro to completion, + // emitting the up=LEFTSHIFT release, and clears active_macros. + _ = try m.apply(.{ .buttons = m1_mask | sel_mask }, 16, 0); + const ev_off = try m.apply(.{ .buttons = m1_mask }, 16, 0); + try testing.expect(m.layer.getActive(m.config.layer.?) == null); + try testing.expectEqual(@as(usize, 0), m.active_macros.items.len); + var saw_release = false; + for (ev_off.aux.slice()) |e| switch (e) { + .key => |k| if (k.code == 42 and !k.pressed) { + saw_release = true; + }, + else => {}, + }; + try testing.expect(saw_release); +} + test "mapper: hold_toggle pending preserves macros but sticky transition cancels them" { const allocator = testing.allocator; const parsed = try makeMapping(