control.applyAngleTuning(Math.max(-30, Math.min(30, next)));
return (
@@ -597,6 +606,7 @@ function AngleTuningControl({ value, label }: { value: number; label: string }):
scale={["−30°", "0°", "+30°"]}
formatValue={(shown) => `${shown > 0 ? "+" : ""}${shown}°`}
pendingKey="angle-tuning"
+ disabled={disabled}
onCommit={apply}
/>
);
diff --git a/src/app/cards/LunaFuryCards.tsx b/src/app/cards/LunaFuryCards.tsx
new file mode 100644
index 00000000..44d3f575
--- /dev/null
+++ b/src/app/cards/LunaFuryCards.tsx
@@ -0,0 +1,94 @@
+import type { ReactNode } from "react";
+import type { LunaFuryButton } from "@openmouse/protocol/lamzu";
+import * as control from "../../device/controller";
+import type { ControlSnapshot } from "../../device/types";
+import { t, type I18nKey } from "../../i18n";
+import { Segmented, StepperSlider, SwitchRow } from "../ui";
+
+export function LunaFuryButtonsCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
+ const settings = snapshot.status?.lunafury;
+ if (!settings) return null;
+ const locale = snapshot.preferences.locale;
+ const busy = snapshot.settingInProgress || snapshot.settingsPending;
+ const staged = snapshot.pending.keys.some((key) => key.startsWith("lunafury-button-") || key === "lunafury-lightning");
+ const buttons: Array<{ button: LunaFuryButton; value: number | undefined; label: I18nKey; min: number; max: number }> = [
+ { button: "left", value: settings.leftDebounceMs, label: "luna.leftDebounce", min: 0, max: 15 },
+ { button: "right", value: settings.rightDebounceMs, label: "luna.rightDebounce", min: 0, max: 15 },
+ { button: "middle", value: settings.middleDebounceMs, label: "luna.middleDebounce", min: 1, max: 30 },
+ ];
+ return (
+
+ LUNAFURY
{t(locale, "luna.buttons")}
+ {settings.lightningMode != null ? (
+
+ {t(locale, "luna.lightning")}
+ control.applyLunaFuryLightningMode(mode as 0 | 1 | 2)}
+ />
+
+ ) : null}
+ {buttons.map(({ button, value, label, min, max }) => value == null ? null : (
+ `${shown} ms`}
+ disabled={busy || (button === "left" && settings.lightningMode === 1) || (button === "right" && settings.lightningMode === 2)}
+ pendingKey={`lunafury-button-${button}`}
+ onCommit={(milliseconds) => control.applyLunaFuryButtonDebounce(button, milliseconds)}
+ />
+ ))}
+
+ );
+}
+
+export function LunaFuryWheelCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
+ const guard = snapshot.status?.lunafury?.wheelGuard;
+ if (!guard) return null;
+ const locale = snapshot.preferences.locale;
+ const busy = snapshot.settingInProgress || snapshot.settingsPending;
+ const staged = snapshot.pending.keys.includes("lunafury-wheel-guard");
+ return (
+
+ LUNAFURY
{t(locale, "luna.wheelGuard")}
+ control.applyLunaFuryWheelGuard({ enabled, windowMs: guard.windowMs || 100 })}
+ />
+ {guard.enabled ? (
+ `${shown} ms`}
+ disabled={busy}
+ pendingKey="lunafury-wheel-guard"
+ onCommit={(windowMs) => control.applyLunaFuryWheelGuard({ enabled: true, windowMs })}
+ />
+ ) : null}
+
+ );
+}
diff --git a/src/app/cards/availability.test.ts b/src/app/cards/availability.test.ts
index ab63ca61..ec5e68db 100644
--- a/src/app/cards/availability.test.ts
+++ b/src/app/cards/availability.test.ts
@@ -39,6 +39,41 @@ const CONTROL = {
mappedTo: 0x00c3, diverted: false, remappableTo: [0x0052], remapFlags: 0,
} as unknown as NonNullable[number];
+test("LunaFury exposes only successfully read controls and replaces global debounce", () => {
+ const has = cardAvailability(snapshot({ status: {
+ brand: "LunaFury", ui: { family: "lunafury" }, debounceMs: 4, angleTuning: -12,
+ lunafury: { lightningMode: 0, leftDebounceMs: 0, middleDebounceMs: 1,
+ wheelGuard: { enabled: false, windowMs: 0 } },
+ } }));
+ assert.equal(has.lunafuryButtons, true);
+ assert.equal(has.lunafuryWheelGuard, true);
+ assert.equal(has.processing, true);
+ assert.equal(has.debounce, false);
+});
+
+test("unknown LunaFury controls are hidden without losing basic debounce", () => {
+ const has = cardAvailability(snapshot({ status: {
+ brand: "LunaFury", ui: { family: "lunafury" }, debounceMs: 4, lunafury: {},
+ } }));
+ assert.equal(has.lunafuryButtons, false);
+ assert.equal(has.lunafuryWheelGuard, false);
+ assert.equal(has.debounce, true);
+ const middleOnly = cardAvailability(snapshot({ status: {
+ brand: "LunaFury", ui: { family: "lunafury" }, debounceMs: 4, lunafury: { middleDebounceMs: 1 },
+ } }));
+ assert.equal(middleOnly.lunafuryButtons, true);
+ assert.equal(middleOnly.debounce, true);
+});
+
+test("LunaFury-specific cards are not offered to another CompX brand", () => {
+ const has = cardAvailability(snapshot({ status: {
+ brand: "Attack Shark", ui: { family: "attack-shark" },
+ lunafury: { lightningMode: 1, wheelGuard: { enabled: true, windowMs: 100 } },
+ } }));
+ assert.equal(has.lunafuryButtons, false);
+ assert.equal(has.lunafuryWheelGuard, false);
+});
+
test("the button card appears only when the mouse reports controls", () => {
// The driver answers with an empty list on a mouse without 0x1B04, so this
// must key on the controls themselves rather than on the brand.
@@ -500,6 +535,25 @@ test("the G-Wolves XVI generation opens sleep, debounce and processing, but not
assert.equal(cards.signal, false);
});
+test("LunaFury exposes CompX advanced controls without a signal card", () => {
+ const cards = cardAvailability(snapshot({
+ status: {
+ brand: "LunaFury",
+ ui: { family: "lunafury" },
+ motionSync: true,
+ angleSnapping: false,
+ rippleControl: false,
+ debounceMs: 4,
+ sleepTimeout: 300,
+ },
+ }));
+ assert.equal(cards.advancedHost, true);
+ assert.equal(cards.sleep, true);
+ assert.equal(cards.debounce, true);
+ assert.equal(cards.processing, true);
+ assert.equal(cards.signal, false);
+});
+
test("the Incott card follows the fields the device reported, not a brand trait", () => {
// Wireless: both controls are present.
const wireless = cardAvailability(snapshot({
diff --git a/src/app/cards/availability.ts b/src/app/cards/availability.ts
index 0b4058e0..9dd8ea78 100644
--- a/src/app/cards/availability.ts
+++ b/src/app/cards/availability.ts
@@ -22,6 +22,8 @@ export interface CardAvailability {
teevolutionDpiLighting: boolean;
finalmouse: boolean;
incott: boolean;
+ lunafuryButtons: boolean;
+ lunafuryWheelGuard: boolean;
dongleLed: boolean;
eggFilter: boolean;
eggSpdt: boolean;
@@ -65,6 +67,8 @@ const NOTHING: CardAvailability = {
teevolutionDpiLighting: false,
finalmouse: false,
incott: false,
+ lunafuryButtons: false,
+ lunafuryWheelGuard: false,
dongleLed: false,
eggFilter: false,
eggSpdt: false,
@@ -154,6 +158,7 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
signal: host && traits.signal,
debounce: host && traits.debounce
+ && status.lunafury?.leftDebounceMs == null && status.lunafury?.rightDebounceMs == null
&& status.debounceMs !== null && status.debounceMs !== undefined,
sleep: host && (traits.sleep || capabilities?.sleepOptions != null || razerSleep)
&& ui?.hideSleepCard !== true,
@@ -172,6 +177,11 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
// actually reported.
incott: host
&& (status.incottFireKeyTimes != null || status.incottReceiverLedMode != null),
+ lunafuryButtons: host && status.brand === "LunaFury" && (
+ status.lunafury?.lightningMode != null || status.lunafury?.leftDebounceMs != null
+ || status.lunafury?.rightDebounceMs != null || status.lunafury?.middleDebounceMs != null
+ ),
+ lunafuryWheelGuard: host && status.brand === "LunaFury" && status.lunafury?.wheelGuard != null,
// Drivers report a boolean only for receivers that answer the LED command.
dongleLed: typeof status.dongleLedEnabled === "boolean",
eggFilter: eggs,
diff --git a/src/app/useBridgeProfileApplier.ts b/src/app/useBridgeProfileApplier.ts
index 08ef2318..77e714c0 100644
--- a/src/app/useBridgeProfileApplier.ts
+++ b/src/app/useBridgeProfileApplier.ts
@@ -21,6 +21,7 @@ import * as control from "../device/controller";
import type { BridgeProfile } from "../bridge";
import { subscribeBridgeStatus } from "../bridge-status-store";
import {
+ mergeSnapshots,
pickSnapshot,
sanitizeSnapshot,
type GameProfileSnapshot,
@@ -81,10 +82,10 @@ export function useBridgeProfileApplier(snapshot: ControlSnapshot): void {
const target = profileTarget(profile);
// Capture only fields not already held: when one game's profile gives way
// to another's, the originals from before the first one still win.
- const prior = { ...pickSnapshot(status, target), ...priorSettings.current };
+ const prior = mergeSnapshots(pickSnapshot(status, target), priorSettings.current ?? {});
// Fields the previous profile changed but this one doesn't go back to
// their originals, so the new game starts from the user's own settings.
- const toWrite = { ...priorSettings.current, ...target };
+ const toWrite = mergeSnapshots(priorSettings.current ?? {}, target);
writing.current = true;
void control.flashGameProfileSnapshot(toWrite).then((result) => {
diff --git a/src/app/workspace-tabs.test.ts b/src/app/workspace-tabs.test.ts
index 38370074..f569ad4e 100644
--- a/src/app/workspace-tabs.test.ts
+++ b/src/app/workspace-tabs.test.ts
@@ -8,6 +8,14 @@ function availability(enabled: Partial): CardAvailability {
return new Proxy(enabled, { get: (target, property) => Reflect.get(target, property) ?? false }) as CardAvailability;
}
+test("LunaFury response and wheel guard retain the Buttons tab without global debounce", () => {
+ for (const field of ["lunafuryButtons", "lunafuryWheelGuard"] as const) {
+ assert.deepEqual(availableWorkspaceTabs(true, availability({ [field]: true })), [
+ "overview", "performance", "buttons", "advanced",
+ ]);
+ }
+});
+
test("a connected mouse exposes only tabs backed by available controls", () => {
assert.deepEqual(availableWorkspaceTabs(true, availability({})), ["overview", "performance", "advanced"]);
assert.deepEqual(availableWorkspaceTabs(true, availability({ debounce: true })), [
diff --git a/src/app/workspace-tabs.ts b/src/app/workspace-tabs.ts
index b9b44567..7c2f9c38 100644
--- a/src/app/workspace-tabs.ts
+++ b/src/app/workspace-tabs.ts
@@ -9,7 +9,7 @@ export function availableWorkspaceTabs(
if (!connected) return WORKSPACE_TAB_ORDER;
const hasButtons = has.eggButtons || has.razerButtons || has.mxMasterButtons || has.atkButtons
|| has.buttonMapping || has.debounce || has.lightforce || has.eggSpdt || has.superstrike
- || has.ksnakeScroll;
+ || has.ksnakeScroll || has.lunafuryButtons || has.lunafuryWheelGuard;
const hasProfiles = has.profiles || has.keychronNapeLayers || has.atkProfile
|| has.onboardProfiles || has.pulsarPro;
return WORKSPACE_TAB_ORDER.filter((tab) => {
diff --git a/src/device/controller.ts b/src/device/controller.ts
index d0337290..401db023 100644
--- a/src/device/controller.ts
+++ b/src/device/controller.ts
@@ -12,6 +12,8 @@ import {
type SupportedClient,
} from "../device-clients";
import { closestDpiOption } from "../dpi-presets";
+import { lunafurySettingLabel, lunafurySettingPriority } from "./lunafury-labels.ts";
+import { lunaFuryLightningPriority, stageLunaFuryProfile } from "./lunafury-profile.ts";
import { formatHex, hidTraffic, isMark, markHidActivity, startHidCapture, type HidTrafficEntry } from "../hid-diagnostics";
import {
clearPendingChanges,
@@ -28,9 +30,9 @@ import {
type PendingChange,
} from "../pending-changes";
import {
- changedFields,
- isGameProfileField,
- snapshotDiff,
+ draftProfileSnapshot,
+ editableProfileFields,
+ matchingProfileFields,
type GameProfileSnapshot,
} from "./game-profile-snapshot";
import { deviceImage } from "../ui/device-images";
@@ -1149,8 +1151,7 @@ function gameProfileFieldsOf(change: PendingChange): string[] | null {
const base = withPendingChanges(latestDeviceStatus);
const preview = structuredClone(base);
change.preview(preview);
- const fields = changedFields(base, preview);
- return fields.every(isGameProfileField) ? fields : null;
+ return editableProfileFields(base, preview);
}
/**
@@ -1176,24 +1177,14 @@ function openGameProfileDraft(initial: GameProfileSnapshot): void {
if (!gameProfileDraft || !latestDeviceStatus) return;
applyGameProfileSnapshot(initial);
const preview = withPendingChanges(latestDeviceStatus);
- for (const [field, value] of Object.entries(initial)) {
- if (isGameProfileField(field) && JSON.stringify(preview[field]) === JSON.stringify(value)) {
- gameProfileDraft.touched.add(field);
- }
- }
+ for (const field of matchingProfileFields(preview, initial)) gameProfileDraft.touched.add(field);
}
/** The draft as a profile: every field it differs from the mouse on, plus every field it has touched. */
export function gameProfileDraftSnapshot(): GameProfileSnapshot {
if (!gameProfileDraft || !latestDeviceStatus) return {};
const preview = withPendingChanges(latestDeviceStatus);
- const snapshot: Record = { ...snapshotDiff(latestDeviceStatus, preview) };
- for (const field of gameProfileDraft.touched) {
- if (field in snapshot || !isGameProfileField(field)) continue;
- const value = preview[field];
- if (value !== undefined) snapshot[field] = structuredClone(value);
- }
- return snapshot as GameProfileSnapshot;
+ return draftProfileSnapshot(latestDeviceStatus, preview, gameProfileDraft.touched);
}
/** Replaces the draft's staged values with `snapshot` (the Clear/Revert paths). */
@@ -1517,6 +1508,11 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void {
if (pulsarPro) applyProSetting("angleTuning", s.angleTuning);
else applyAngleTuning(s.angleTuning);
}
+ stageLunaFuryProfile(s.lunafury, status.brand === "LunaFury" ? status.lunafury : undefined, {
+ lightning: applyLunaFuryLightningMode,
+ button: applyLunaFuryButtonDebounce,
+ wheel: applyLunaFuryWheelGuard,
+ });
if (typeof s.wheelAcceleration === "boolean") applyProSetting("wheelAcceleration", s.wheelAcceleration);
if (s.wheelMode) applyWheelMode(s.wheelMode);
@@ -4245,6 +4241,8 @@ export function toggleDongleLed(): void {
}
function settingLabel(setting: PulsarToggleSetting): string {
+ const vendorLabel = lunafurySettingLabel(latestDeviceStatus, interfacePreferences.locale, setting);
+ if (vendorLabel !== undefined) return vendorLabel;
return ({
motionSync: "Motion Sync",
angleSnapping: "angle snapping",
@@ -4274,6 +4272,7 @@ export function applyPulsarToggle(setting: PulsarToggleSetting, enabled: boolean
const method = PULSAR_TOGGLE_METHOD[setting];
stageChange({
key: setting,
+ priority: lunafurySettingPriority(latestDeviceStatus, setting, enabled),
label: `${label} ${enabled ? "on" : "off"}`,
command: `${enabled ? "Enable" : "Disable"} ${label}`,
progress: `${enabled ? "Enabling" : "Disabling"} ${label}…`,
@@ -4951,6 +4950,49 @@ export function applyIncottFireKey(times: number, intervalMs: number): void {
});
}
+export function applyLunaFuryLightningMode(mode: 0 | 1 | 2): void {
+ if (latestDeviceStatus?.lunafury?.lightningMode == null) return;
+ stageChange({
+ key: "lunafury-lightning",
+ label: `Lightning Trigger ${mode === 0 ? "off" : mode === 1 ? "left priority" : "right priority"}`,
+ command: "Change Lightning Trigger mode",
+ progress: "Changing Lightning Trigger mode…",
+ priority: lunaFuryLightningPriority(mode),
+ preview: (status) => { if (status.lunafury) status.lunafury.lightningMode = mode; },
+ apply: () => callClientMethod("setLunaFuryLightningMode", "Lightning Trigger", mode),
+ });
+}
+
+export function applyLunaFuryButtonDebounce(button: "left" | "right" | "middle", milliseconds: number): void {
+ const field = `${button}DebounceMs` as const;
+ if (latestDeviceStatus?.lunafury?.[field] == null) return;
+ stageChange({
+ key: `lunafury-button-${button}`,
+ label: `${button} button latency ${milliseconds} ms`,
+ command: `Set ${button} button latency`,
+ progress: `Setting ${button} button latency…`,
+ preview: (status) => { if (status.lunafury) status.lunafury[field] = milliseconds; },
+ apply: async () => {
+ const client = requireClientMethod("setLunaFuryButtonDebounce", "button latency") as unknown as {
+ setLunaFuryButtonDebounce(button: "left" | "right" | "middle", milliseconds: number): Promise;
+ };
+ await client.setLunaFuryButtonDebounce(button, milliseconds);
+ },
+ });
+}
+
+export function applyLunaFuryWheelGuard(guard: { enabled: boolean; windowMs: number }): void {
+ if (!latestDeviceStatus?.lunafury?.wheelGuard) return;
+ stageChange({
+ key: "lunafury-wheel-guard",
+ label: `Wheel guard ${guard.enabled ? `${guard.windowMs} ms` : "off"}`,
+ command: "Change wheel anti-mistouch settings",
+ progress: "Changing wheel anti-mistouch settings…",
+ preview: (status) => { if (status.lunafury) status.lunafury.wheelGuard = { ...guard }; },
+ apply: () => callClientMethod("setLunaFuryWheelGuard", "wheel anti-mistouch", guard),
+ });
+}
+
function startAutomaticRefresh(): void {
if (refreshTimer !== null) window.clearInterval(refreshTimer);
const client = activeSettingsClient();
diff --git a/src/device/game-profile-snapshot.test.ts b/src/device/game-profile-snapshot.test.ts
index 2e5faf25..4844d5fe 100644
--- a/src/device/game-profile-snapshot.test.ts
+++ b/src/device/game-profile-snapshot.test.ts
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
-import { changedFields, pickSnapshot, sanitizeSnapshot, snapshotDiff, snapshotKey } from "./game-profile-snapshot.ts";
+import { changedFields, draftProfileSnapshot, editableProfileFields, matchingProfileFields, mergeSnapshots, pickSnapshot, sanitizeSnapshot, snapshotDiff, snapshotKey } from "./game-profile-snapshot.ts";
function status(overrides: Partial = {}): MouseStatus {
return {
@@ -51,3 +51,66 @@ test("the comparison key ignores field order", () => {
assert.equal(snapshotKey({ dpi: 800, pollingRateHz: 1000 }), snapshotKey({ pollingRateHz: 1000, dpi: 800 }));
assert.notEqual(snapshotKey({ dpi: 800 }), snapshotKey({ dpi: 1600 }));
});
+
+test("LunaFury edits save and restore only the individual controls changed", () => {
+ const before = status({ brand: "LunaFury", lunafury: {
+ lightningMode: 0, leftDebounceMs: 0, rightDebounceMs: 3, middleDebounceMs: 8,
+ wheelGuard: { enabled: false, windowMs: 0 },
+ } });
+ const after = structuredClone(before);
+ after.lunafury!.middleDebounceMs = 1;
+ after.lunafury!.wheelGuard = { enabled: true, windowMs: 100 };
+ const saved = snapshotDiff(before, after);
+ assert.deepEqual(saved, { lunafury: { middleDebounceMs: 1, wheelGuard: { enabled: true, windowMs: 100 } } });
+ assert.deepEqual(pickSnapshot(before, saved), { lunafury: { middleDebounceMs: 8, wheelGuard: { enabled: false, windowMs: 0 } } });
+ after.lunafury!.wheelGuard!.windowMs = 200;
+ assert.equal(saved.lunafury!.wheelGuard!.windowMs, 100, "snapshots must not alias the device or preview");
+});
+
+test("LunaFury drafts retain explicitly touched baseline values without capturing peers", () => {
+ const before = status({ lunafury: { lightningMode: 0, leftDebounceMs: 0, middleDebounceMs: 8 } });
+ const preview = structuredClone(before);
+ preview.lunafury!.leftDebounceMs = 1;
+ assert.deepEqual(editableProfileFields(before, preview), ["lunafury.leftDebounceMs"]);
+ assert.deepEqual(matchingProfileFields(before, { lunafury: { lightningMode: 0, middleDebounceMs: 10 } }), ["lunafury.lightningMode"]);
+ assert.deepEqual(draftProfileSnapshot(before, preview, ["lunafury.lightningMode", "lunafury.unknown"]), {
+ lunafury: { leftDebounceMs: 1, lightningMode: 0 },
+ });
+ preview.lunafury!.leftDebounceMs = 0;
+ assert.deepEqual(draftProfileSnapshot(before, preview, ["lunafury.leftDebounceMs"]), { lunafury: { leftDebounceMs: 0 } });
+ assert.equal(editableProfileFields(before, { ...preview, friendlyName: "not restorable" }), null);
+});
+
+test("unsupported LunaFury fields are not captured for restoration", () => {
+ const live = status({ lunafury: { middleDebounceMs: 5 } });
+ assert.deepEqual(pickSnapshot(live, { lunafury: { lightningMode: 1, middleDebounceMs: 20 } }), {
+ lunafury: { middleDebounceMs: 5 },
+ });
+ assert.deepEqual(pickSnapshot(status(), { lunafury: { lightningMode: 1 } }), {});
+});
+
+test("imported LunaFury snapshots are validated and canonically compared", () => {
+ assert.deepEqual(sanitizeSnapshot({ dpi: 1600, lunafury: {
+ lightningMode: 4, leftDebounceMs: 0, rightDebounceMs: -1, middleDebounceMs: 0,
+ wheelGuard: { enabled: true, windowMs: 21 }, unknown: 1,
+ } }), { dpi: 1600, lunafury: { leftDebounceMs: 0 } });
+ assert.deepEqual(sanitizeSnapshot({ lunafury: {} }), {});
+ assert.equal(snapshotKey({ lunafury: { leftDebounceMs: 0, lightningMode: 1 } }),
+ snapshotKey({ lunafury: { lightningMode: 1, leftDebounceMs: 0 } }));
+ assert.notEqual(snapshotKey({ lunafury: { lightningMode: 0 } }), snapshotKey({ lunafury: { lightningMode: 1 } }));
+});
+
+test("switching Games profiles restores previous controls and retains every original value", () => {
+ const original = status({ lunafury: { lightningMode: 0, leftDebounceMs: 3, middleDebounceMs: 8 } });
+ const first = { lunafury: { lightningMode: 1 as const, leftDebounceMs: 0 } };
+ const firstPrior = pickSnapshot(original, first);
+ const afterFirst = status({ lunafury: { ...original.lunafury, ...first.lunafury } });
+ const second = { lunafury: { middleDebounceMs: 20, leftDebounceMs: 10 } };
+ const allPrior = mergeSnapshots(pickSnapshot(afterFirst, second), firstPrior);
+ assert.deepEqual(allPrior, { lunafury: { lightningMode: 0, leftDebounceMs: 3, middleDebounceMs: 8 } });
+ assert.deepEqual(mergeSnapshots(firstPrior, second), {
+ lunafury: { lightningMode: 0, leftDebounceMs: 10, middleDebounceMs: 20 },
+ }, "settings omitted by the next game go back to their originals");
+ assert.deepEqual(firstPrior, { lunafury: { lightningMode: 0, leftDebounceMs: 3 } });
+ assert.deepEqual(mergeSnapshots({ dpi: 800 }, { pollingRateHz: 2000, dpi: 1600 }), { dpi: 1600, pollingRateHz: 2000 });
+});
diff --git a/src/device/game-profile-snapshot.ts b/src/device/game-profile-snapshot.ts
index 14272a2a..e99080c4 100644
--- a/src/device/game-profile-snapshot.ts
+++ b/src/device/game-profile-snapshot.ts
@@ -1,4 +1,5 @@
import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
+import { diffLunaFurySettings, isLunaFuryProfileField, pickLunaFurySettings, sanitizeLunaFurySettings } from "./lunafury-profile.ts";
/**
* The device settings a per-game profile can carry. A game profile is stored
@@ -39,6 +40,7 @@ export const GAME_PROFILE_FIELDS = [
"sleepTimeout",
"lowBatteryWarning",
"angleTuning",
+ "lunafury",
"wheelAcceleration",
"wheelMode",
"smartShiftThreshold",
@@ -103,7 +105,10 @@ export function changedFields(before: MouseStatus, after: MouseStatus): string[]
export function snapshotDiff(before: MouseStatus, after: MouseStatus): GameProfileSnapshot {
const diff: Record = {};
for (const field of GAME_PROFILE_FIELDS) {
- if (!same(before[field], after[field])) diff[field] = structuredClone(after[field]);
+ if (field === "lunafury") {
+ const settings = diffLunaFurySettings(before.lunafury, after.lunafury);
+ if (Object.keys(settings).length) diff.lunafury = settings;
+ } else if (!same(before[field], after[field])) diff[field] = structuredClone(after[field]);
}
return diff as GameProfileSnapshot;
}
@@ -113,15 +118,28 @@ export function pickSnapshot(status: MouseStatus, snapshot: GameProfileSnapshot)
const picked: Record = {};
for (const field of Object.keys(snapshot)) {
if (!isGameProfileField(field)) continue;
+ if (field === "lunafury") {
+ const settings = pickLunaFurySettings(status.lunafury, snapshot.lunafury);
+ if (Object.keys(settings).length) picked.lunafury = settings;
+ continue;
+ }
const value = status[field];
if (value !== undefined) picked[field] = structuredClone(value);
}
return picked as GameProfileSnapshot;
}
+/** Merge per-control namespaces when games switch, not entire device settings. */
+export function mergeSnapshots(base: GameProfileSnapshot, override: GameProfileSnapshot): GameProfileSnapshot {
+ const merged = { ...base, ...override };
+ if (base.lunafury || override.lunafury) merged.lunafury = { ...base.lunafury, ...override.lunafury };
+ return merged;
+}
+
/** A comparison key that ignores field order (Bridge and the draft list fields differently). */
export function snapshotKey(snapshot: GameProfileSnapshot): string {
- return JSON.stringify(Object.fromEntries(Object.entries(snapshot).sort(([left], [right]) => left.localeCompare(right))));
+ const canonical = snapshot.lunafury ? { ...snapshot, lunafury: sanitizeLunaFurySettings(snapshot.lunafury) } : snapshot;
+ return JSON.stringify(Object.fromEntries(Object.entries(canonical).sort(([left], [right]) => left.localeCompare(right))));
}
/** Drops anything a stored snapshot carries that this build cannot apply. */
@@ -129,7 +147,44 @@ export function sanitizeSnapshot(raw: unknown): GameProfileSnapshot {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
const clean: Record = {};
for (const [field, value] of Object.entries(raw)) {
- if (isGameProfileField(field) && value !== undefined) clean[field] = value;
+ if (field === "lunafury") {
+ const settings = sanitizeLunaFurySettings(value);
+ if (Object.keys(settings).length) clean.lunafury = settings;
+ } else if (isGameProfileField(field) && value !== undefined) clean[field] = value;
}
return clean as GameProfileSnapshot;
}
+
+/** Track nested LunaFury controls separately while retaining the old field policy. */
+export function editableProfileFields(before: MouseStatus, after: MouseStatus): string[] | null {
+ const fields = changedFields(before, after);
+ if (!fields.every(isGameProfileField)) return null;
+ return fields.flatMap((field) => field === "lunafury"
+ ? Object.keys(diffLunaFurySettings(before.lunafury, after.lunafury)).map((key) => `lunafury.${key}`) : [field]);
+}
+
+export function matchingProfileFields(status: MouseStatus, snapshot: GameProfileSnapshot): string[] {
+ return Object.entries(snapshot).flatMap(([field, value]) => {
+ if (field === "lunafury") {
+ const target = sanitizeLunaFurySettings(value);
+ return Object.keys(target).filter((key) => isLunaFuryProfileField(key) && same(status.lunafury?.[key], target[key]))
+ .map((key) => `lunafury.${key}`);
+ }
+ return isGameProfileField(field) && same(status[field], value) ? [field] : [];
+ });
+}
+
+export function draftProfileSnapshot(before: MouseStatus, preview: MouseStatus, touched: Iterable): GameProfileSnapshot {
+ const snapshot = snapshotDiff(before, preview);
+ for (const field of touched) {
+ if (field.startsWith("lunafury.")) {
+ const key = field.slice("lunafury.".length);
+ if (isLunaFuryProfileField(key) && preview.lunafury?.[key] !== undefined) {
+ snapshot.lunafury = { ...snapshot.lunafury, [key]: structuredClone(preview.lunafury[key]) };
+ }
+ } else if (!(field in snapshot) && isGameProfileField(field) && field !== "lunafury" && preview[field] !== undefined) {
+ Object.assign(snapshot, { [field]: structuredClone(preview[field]) });
+ }
+ }
+ return snapshot;
+}
diff --git a/src/device/lunafury-labels.test.ts b/src/device/lunafury-labels.test.ts
new file mode 100644
index 00000000..753857db
--- /dev/null
+++ b/src/device/lunafury-labels.test.ts
@@ -0,0 +1,58 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { ensureLocale } from "../i18n.ts";
+import { lunafurySettingLabel, lunafurySettingPriority, lunafuryTrackingVisible } from "./lunafury-labels.ts";
+
+test("LunaFury Chinese names match the vendor's command mappings", async () => {
+ await ensureLocale("zh");
+ const status = { brand: "LunaFury" };
+ assert.equal(lunafurySettingLabel(status, "zh", "angleSnapping"), "直线修正");
+ assert.equal(lunafurySettingLabel(status, "zh", "hyperMode"), "竞技模式");
+ assert.equal(lunafurySettingLabel(status, "zh", "performanceMode"), "追踪模式");
+ assert.equal(lunafurySettingLabel(status, "zh", "motionSync"), "运动同步");
+ assert.equal(lunafurySettingLabel(status, "zh", "rippleControl"), "波纹控制");
+});
+
+test("LunaFury English labels also use the official names", () => {
+ const status = { brand: "LunaFury" };
+ assert.equal(lunafurySettingLabel(status, "en", "angleSnapping"), "Angle Snap");
+ assert.equal(lunafurySettingLabel(status, "en", "hyperMode"), "Competitive Mode");
+ assert.equal(lunafurySettingLabel(status, "en", "performanceMode"), "Tracking Mode");
+});
+
+test("other brands and unrelated settings retain their existing labels", () => {
+ for (const brand of ["Lamzu", "WLMouse", "CRDRAKO", "Teevolution", "Attack Shark", "VXE"]) {
+ for (const setting of ["angleSnapping", "hyperMode", "performanceMode", "motionSync", "rippleControl"]) {
+ assert.equal(lunafurySettingLabel({ brand }, "en", setting), undefined);
+ }
+ }
+ assert.equal(lunafurySettingLabel(null, "zh", "hyperMode"), undefined);
+ assert.equal(lunafurySettingLabel({ brand: "LunaFury" }, "en", "turboMode"), undefined);
+ assert.equal(lunafurySettingLabel({ brand: "LunaFury" }, "en", "toString"), undefined);
+});
+
+test("LunaFury tracking appears only when competitive mode is confirmed on", () => {
+ for (const hyperMode of [false, null, undefined]) {
+ assert.equal(lunafuryTrackingVisible({ brand: "LunaFury", hyperMode }), false);
+ }
+ assert.equal(lunafuryTrackingVisible({ brand: "LunaFury", hyperMode: true }), true);
+ const status = { brand: "LunaFury", hyperMode: false, performanceMode: true };
+ assert.equal(lunafuryTrackingVisible(status), false);
+ assert.equal(status.performanceMode, true, "hiding tracking must not change its stored state");
+});
+
+test("other brands keep their existing performance visibility and write order", () => {
+ for (const brand of ["WLMouse", "CRDRAKO", "Lamzu", "Teevolution", "Attack Shark"]) {
+ assert.equal(lunafuryTrackingVisible({ brand, hyperMode: false }), true);
+ assert.equal(lunafurySettingPriority({ brand }, "hyperMode", true), undefined);
+ assert.equal(lunafurySettingPriority({ brand }, "hyperMode", false), undefined);
+ }
+});
+
+test("LunaFury competitive mode enables before tracking and disables after it", () => {
+ const status = { brand: "LunaFury" };
+ assert.equal(lunafurySettingPriority(status, "hyperMode", true), -1);
+ assert.equal(lunafurySettingPriority(status, "hyperMode", false), 1);
+ assert.equal(lunafurySettingPriority(status, "performanceMode", true), undefined);
+ assert.equal(lunafurySettingPriority(null, "hyperMode", true), undefined);
+});
diff --git a/src/device/lunafury-labels.ts b/src/device/lunafury-labels.ts
new file mode 100644
index 00000000..3c75820f
--- /dev/null
+++ b/src/device/lunafury-labels.ts
@@ -0,0 +1,42 @@
+import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
+import { t, type I18nKey } from "../i18n.ts";
+import type { InterfaceLocale } from "../interface-preferences.ts";
+
+// Names verified against LunaFury's language.js and patternModule.vue.
+// Its Competitive Mode calls setHyperMode (0x0b), while Tracking Mode
+// calls setTrackingMode (0x13), exposed by the shared driver as performanceMode.
+const LABEL_KEYS = {
+ angleSnapping: "luna.angleSnap",
+ hyperMode: "luna.competitiveMode",
+ performanceMode: "luna.trackingMode",
+ motionSync: "luna.motionSync",
+ rippleControl: "luna.rippleControl",
+} as const satisfies Record;
+
+export function lunafurySettingLabel(
+ status: Pick | null | undefined,
+ locale: InterfaceLocale,
+ setting: string,
+): string | undefined {
+ if (status?.brand !== "LunaFury" || !Object.hasOwn(LABEL_KEYS, setting)) return undefined;
+ return t(locale, LABEL_KEYS[setting as keyof typeof LABEL_KEYS]);
+}
+
+/** Match the vendor's v-if="competition": hide tracking until competitive
+ * mode is known to be on, without resetting the stored tracking setting. */
+export function lunafuryTrackingVisible(
+ status: Pick,
+): boolean {
+ return status.brand !== "LunaFury" || status.hyperMode === true;
+}
+
+/** Establish competitive mode before staged tracking edits, or leave it
+ * enabled until those edits finish when switching competitive mode off. */
+export function lunafurySettingPriority(
+ status: Pick | null,
+ setting: string,
+ enabled: boolean,
+): number | undefined {
+ return status?.brand === "LunaFury" && setting === "hyperMode"
+ ? enabled ? -1 : 1 : undefined;
+}
diff --git a/src/device/lunafury-profile.test.ts b/src/device/lunafury-profile.test.ts
new file mode 100644
index 00000000..461fa01b
--- /dev/null
+++ b/src/device/lunafury-profile.test.ts
@@ -0,0 +1,83 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import type { LunaFurySettings } from "@openmouse/protocol/lamzu";
+import { clearPendingChanges, pendingChangeBatches, stagePendingChange } from "../pending-changes.ts";
+import { pickSnapshot } from "./game-profile-snapshot.ts";
+import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
+import { lunaFuryLightningPriority, sanitizeLunaFurySettings, stageLunaFuryProfile, type LunaFuryProfileActions } from "./lunafury-profile.ts";
+
+const settings: LunaFurySettings = {
+ lightningMode: 0, leftDebounceMs: 0, rightDebounceMs: 15, middleDebounceMs: 1,
+ wheelGuard: { enabled: false, windowMs: 0 },
+};
+
+test("profile validation accepts endpoints and the disabled zero wheel window", () => {
+ assert.deepEqual(sanitizeLunaFurySettings(settings), settings);
+ assert.deepEqual(sanitizeLunaFurySettings({ lightningMode: 2, leftDebounceMs: 15, middleDebounceMs: 30,
+ wheelGuard: { enabled: true, windowMs: 200 } }), {
+ lightningMode: 2, leftDebounceMs: 15, middleDebounceMs: 30, wheelGuard: { enabled: true, windowMs: 200 },
+ });
+});
+
+test("invalid imported values never become protocol writes", () => {
+ for (const raw of [null, [], "1", { lightningMode: NaN, leftDebounceMs: Infinity, rightDebounceMs: 1.5,
+ middleDebounceMs: 31, wheelGuard: { enabled: "yes", windowMs: 100 }, unknown: true },
+ { lightningMode: -1, leftDebounceMs: 16, middleDebounceMs: 0, wheelGuard: { enabled: true, windowMs: 0 } }]) {
+ assert.deepEqual(sanitizeLunaFurySettings(raw), {});
+ }
+ for (const windowMs of [1, 19, 21, 201, 220, -20, NaN, Infinity]) {
+ assert.deepEqual(sanitizeLunaFurySettings({ wheelGuard: { enabled: false, windowMs } }), {});
+ }
+});
+
+test("profile staging calls only supported controls and ignores unknown commands", () => {
+ const calls: unknown[] = [];
+ const actions: LunaFuryProfileActions = {
+ lightning: (mode) => { calls.push(["lightning", mode]); },
+ button: (button, milliseconds) => { calls.push([button, milliseconds]); },
+ wheel: (guard) => { calls.push(["wheel", guard]); },
+ };
+ stageLunaFuryProfile(settings, undefined, actions);
+ stageLunaFuryProfile({ ...settings, arbitraryCommand: 1 }, { middleDebounceMs: 5 }, actions);
+ assert.deepEqual(calls, [["middle", 1]]);
+ calls.length = 0;
+ stageLunaFuryProfile(settings, settings, actions);
+ assert.deepEqual(calls, [["lightning", 0], ["left", 0], ["right", 15], ["middle", 1], ["wheel", { enabled: false, windowMs: 0 }]]);
+});
+
+test("apply and restore preserve zero values and do not write unrelated peers", () => {
+ const current = structuredClone(settings);
+ const actions: LunaFuryProfileActions = {
+ lightning: (mode) => { current.lightningMode = mode; },
+ button: (button, milliseconds) => { current[`${button}DebounceMs`] = milliseconds; },
+ wheel: (guard) => { current.wheelGuard = { ...guard }; },
+ };
+ const profile = { lunafury: { lightningMode: 2 as const, leftDebounceMs: 8, wheelGuard: { enabled: true, windowMs: 100 } } };
+ const prior = pickSnapshot({ lunafury: current } as MouseStatus, profile);
+ stageLunaFuryProfile(profile.lunafury, current, actions);
+ assert.deepEqual(current, { ...settings, ...profile.lunafury });
+ current.middleDebounceMs = 20; // An unrelated edit made while the game is running.
+ stageLunaFuryProfile(prior.lunafury, current, actions);
+ assert.deepEqual(current, { ...settings, middleDebounceMs: 20 });
+});
+
+test("pending batches honor lightning disable/latency/enable ordering for game profiles", async () => {
+ for (const mode of [0, 1, 2] as const) {
+ clearPendingChanges();
+ const written: string[] = [];
+ const stage = (key: string, priority?: number) => stagePendingChange({
+ key, priority, label: key, command: "", progress: "", apply: async () => { written.push(key); },
+ });
+ try {
+ stageLunaFuryProfile({ lightningMode: mode, leftDebounceMs: 1, rightDebounceMs: 2 }, settings, {
+ lightning: (value) => stage("lightning", lunaFuryLightningPriority(value)),
+ button: (button) => stage(button),
+ wheel: () => stage("wheel"),
+ });
+ for (const batch of pendingChangeBatches()) await batch.at(-1)!.apply();
+ assert.deepEqual(written, mode === 0 ? ["lightning", "left", "right"] : ["left", "right", "lightning"]);
+ } finally {
+ clearPendingChanges();
+ }
+ }
+});
diff --git a/src/device/lunafury-profile.ts b/src/device/lunafury-profile.ts
new file mode 100644
index 00000000..a0658ef7
--- /dev/null
+++ b/src/device/lunafury-profile.ts
@@ -0,0 +1,76 @@
+import type { LunaFuryButton, LunaFuryLightningMode, LunaFurySettings, LunaFuryWheelGuard } from "@openmouse/protocol/lamzu";
+
+export const LUNAFURY_PROFILE_FIELDS = [
+ "lightningMode", "leftDebounceMs", "rightDebounceMs", "middleDebounceMs", "wheelGuard",
+] as const satisfies readonly (keyof LunaFurySettings)[];
+type Field = (typeof LUNAFURY_PROFILE_FIELDS)[number];
+
+export function isLunaFuryProfileField(field: string): field is Field {
+ return LUNAFURY_PROFILE_FIELDS.some((known) => known === field);
+}
+
+function integer(value: unknown, min: number, max: number): value is number {
+ return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
+}
+
+/** Imported profiles must not introduce unknown commands or invalid values. */
+export function sanitizeLunaFurySettings(raw: unknown): LunaFurySettings {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
+ const value = raw as Record;
+ const clean: LunaFurySettings = {};
+ if (integer(value.lightningMode, 0, 2)) clean.lightningMode = value.lightningMode as LunaFuryLightningMode;
+ if (integer(value.leftDebounceMs, 0, 15)) clean.leftDebounceMs = value.leftDebounceMs;
+ if (integer(value.rightDebounceMs, 0, 15)) clean.rightDebounceMs = value.rightDebounceMs;
+ if (integer(value.middleDebounceMs, 1, 30)) clean.middleDebounceMs = value.middleDebounceMs;
+ if (value.wheelGuard && typeof value.wheelGuard === "object" && !Array.isArray(value.wheelGuard)) {
+ const guard = value.wheelGuard as Record;
+ if (typeof guard.enabled === "boolean" &&
+ ((integer(guard.windowMs, 20, 200) && guard.windowMs % 20 === 0) || (!guard.enabled && guard.windowMs === 0))) {
+ clean.wheelGuard = { enabled: guard.enabled, windowMs: guard.windowMs as number };
+ }
+ }
+ return clean;
+}
+
+/** Keep the namespace partial: editing one control must not save its peers. */
+export function diffLunaFurySettings(before: LunaFurySettings | undefined, after: LunaFurySettings | undefined): LunaFurySettings {
+ const diff: Record = {};
+ for (const field of LUNAFURY_PROFILE_FIELDS) {
+ if (after?.[field] !== undefined && JSON.stringify(before?.[field]) !== JSON.stringify(after[field])) {
+ diff[field] = structuredClone(after[field]);
+ }
+ }
+ return sanitizeLunaFurySettings(diff);
+}
+
+/** Capture only the available controls this profile will actually write. */
+export function pickLunaFurySettings(current: LunaFurySettings | undefined, target: unknown): LunaFurySettings {
+ const picked: Record = {};
+ for (const field of Object.keys(sanitizeLunaFurySettings(target))) {
+ if (isLunaFuryProfileField(field) && current?.[field] !== undefined) picked[field] = structuredClone(current[field]);
+ }
+ return sanitizeLunaFurySettings(picked);
+}
+
+export interface LunaFuryProfileActions {
+ lightning(mode: LunaFuryLightningMode): void;
+ button(button: LunaFuryButton, milliseconds: number): void;
+ wheel(guard: LunaFuryWheelGuard): void;
+}
+
+/** Disable priority before changing latencies; enable it after them. */
+export function lunaFuryLightningPriority(mode: LunaFuryLightningMode): number {
+ return mode === 0 ? -10 : 10;
+}
+
+/** Use the ordinary staged setters for both applying and restoring profiles. */
+export function stageLunaFuryProfile(raw: unknown, current: LunaFurySettings | undefined, actions: LunaFuryProfileActions): void {
+ if (!current) return;
+ const target = sanitizeLunaFurySettings(raw);
+ if (target.lightningMode !== undefined && current.lightningMode !== undefined) actions.lightning(target.lightningMode);
+ for (const button of ["left", "right", "middle"] as const) {
+ const field = `${button}DebounceMs` as const;
+ if (target[field] !== undefined && current[field] !== undefined) actions.button(button, target[field]);
+ }
+ if (target.wheelGuard && current.wheelGuard) actions.wheel(target.wheelGuard);
+}
diff --git a/src/device/traits.ts b/src/device/traits.ts
index b9c21047..66cedc10 100644
--- a/src/device/traits.ts
+++ b/src/device/traits.ts
@@ -41,6 +41,7 @@ const BY_FAMILY: Readonly>> = {
vgn: SHARED_ADVANCED,
wlmouse: DIRECT_MODE,
lamzu: DIRECT_MODE,
+ lunafury: DIRECT_MODE,
"attack-shark": DIRECT_MODE,
bytech: DIRECT_MODE,
crdrako: DIRECT_MODE,
diff --git a/src/i18n-ar.ts b/src/i18n-ar.ts
index ab80f54c..a964e410 100644
--- a/src/i18n-ar.ts
+++ b/src/i18n-ar.ts
@@ -3,6 +3,21 @@ import type { I18nKey } from "./i18n.ts";
/** Arabic strings, split into a lazy chunk so the initial bundle ships
English only. Loaded via ensureLocale() before first use. */
export const ar: Record = {
+ "luna.angleSnap": "تصحيح الخط المستقيم",
+ "luna.competitiveMode": "وضع المنافسة",
+ "luna.trackingMode": "وضع التتبع",
+ "luna.motionSync": "مزامنة الحركة",
+ "luna.rippleControl": "التحكم في التموج",
+ "luna.buttons": "استجابة الأزرار",
+ "luna.lightning": "الزناد البرقي",
+ "luna.leftPriority": "أولوية اليسار",
+ "luna.rightPriority": "أولوية اليمين",
+ "luna.leftDebounce": "زمن تأخير الزر الأيسر",
+ "luna.rightDebounce": "زمن تأخير الزر الأيمن",
+ "luna.middleDebounce": "إزالة ارتداد الزر الأوسط",
+ "luna.wheelGuard": "منع التمرير غير المقصود",
+ "luna.wheelGuardWindow": "مدة حماية عجلة التمرير",
+ "luna.sensorAngle": "زاوية المستشعر",
"nav.language": "اللغة",
"nav.settings": "الإعدادات",
"nav.support": "الدعم",
diff --git a/src/i18n-de.ts b/src/i18n-de.ts
index 096736c4..ce3ba6c5 100644
--- a/src/i18n-de.ts
+++ b/src/i18n-de.ts
@@ -3,6 +3,21 @@ import type { I18nKey } from "./i18n.ts";
/** German strings, split into a lazy chunk so the initial bundle ships
English only. Loaded via ensureLocale() before first use. */
export const de: Record = {
+ "luna.angleSnap": "Geradlinienkorrektur",
+ "luna.competitiveMode": "Wettkampfmodus",
+ "luna.trackingMode": "Tracking-Modus",
+ "luna.motionSync": "Bewegungssynchronisierung",
+ "luna.rippleControl": "Welligkeitskontrolle",
+ "luna.buttons": "Tastenreaktion",
+ "luna.lightning": "Blitz-Auslöser",
+ "luna.leftPriority": "Links bevorzugen",
+ "luna.rightPriority": "Rechts bevorzugen",
+ "luna.leftDebounce": "Latenz der linken Taste",
+ "luna.rightDebounce": "Latenz der rechten Taste",
+ "luna.middleDebounce": "Entprellung der mittleren Taste",
+ "luna.wheelGuard": "Schutz vor versehentlichem Scrollen",
+ "luna.wheelGuardWindow": "Schutzzeit des Mausrads",
+ "luna.sensorAngle": "Sensorwinkel",
"nav.language": "Sprache",
"nav.settings": "Einstellungen",
"nav.support": "Unterstützen",
diff --git a/src/i18n-es.ts b/src/i18n-es.ts
index f8c70bac..d4661704 100644
--- a/src/i18n-es.ts
+++ b/src/i18n-es.ts
@@ -3,6 +3,21 @@ import type { I18nKey } from "./i18n.ts";
/** Spanish strings, split into a lazy chunk so the initial bundle ships
English only. Loaded via ensureLocale() before first use. */
export const es: Record = {
+ "luna.angleSnap": "Corrección de línea recta",
+ "luna.competitiveMode": "Modo competitivo",
+ "luna.trackingMode": "Modo de seguimiento",
+ "luna.motionSync": "Sincronización de movimiento",
+ "luna.rippleControl": "Control de ondulación",
+ "luna.buttons": "Respuesta de botones",
+ "luna.lightning": "Gatillo relámpago",
+ "luna.leftPriority": "Prioridad izquierda",
+ "luna.rightPriority": "Prioridad derecha",
+ "luna.leftDebounce": "Latencia del botón izquierdo",
+ "luna.rightDebounce": "Latencia del botón derecho",
+ "luna.middleDebounce": "Antirrebote del botón central",
+ "luna.wheelGuard": "Protección de rueda contra toques accidentales",
+ "luna.wheelGuardWindow": "Ventana de protección de rueda",
+ "luna.sensorAngle": "Ángulo del sensor",
"nav.language": "Idioma",
"nav.settings": "Configuración",
"nav.support": "Apoyar",
diff --git a/src/i18n-fr.ts b/src/i18n-fr.ts
index bc77a122..60c48a3f 100644
--- a/src/i18n-fr.ts
+++ b/src/i18n-fr.ts
@@ -3,6 +3,21 @@ import type { I18nKey } from "./i18n.ts";
/** French strings, split into a lazy chunk so the initial bundle ships
English only. Loaded via ensureLocale() before first use. */
export const fr: Record = {
+ "luna.angleSnap": "Correction de ligne droite",
+ "luna.competitiveMode": "Mode compétitif",
+ "luna.trackingMode": "Mode de suivi",
+ "luna.motionSync": "Synchronisation du mouvement",
+ "luna.rippleControl": "Contrôle des ondulations",
+ "luna.buttons": "Réponse des boutons",
+ "luna.lightning": "Gâchette éclair",
+ "luna.leftPriority": "Priorité gauche",
+ "luna.rightPriority": "Priorité droite",
+ "luna.leftDebounce": "Latence du bouton gauche",
+ "luna.rightDebounce": "Latence du bouton droit",
+ "luna.middleDebounce": "Anti-rebond du bouton central",
+ "luna.wheelGuard": "Protection contre les défilements accidentels",
+ "luna.wheelGuardWindow": "Fenêtre de protection de la molette",
+ "luna.sensorAngle": "Angle du capteur",
"nav.language": "Langue",
"nav.settings": "Paramètres",
"nav.support": "Soutenir",
diff --git a/src/i18n-ja.ts b/src/i18n-ja.ts
index 94910ed2..c44cf628 100644
--- a/src/i18n-ja.ts
+++ b/src/i18n-ja.ts
@@ -3,6 +3,21 @@ import type { I18nKey } from "./i18n.ts";
/** Japanese strings, split into a lazy chunk so the initial bundle ships
English only. Loaded via ensureLocale() before first use. */
export const ja: Record = {
+ "luna.angleSnap": "Angle Snap",
+ "luna.competitiveMode": "Competitive Mode",
+ "luna.trackingMode": "Tracking Mode",
+ "luna.motionSync": "Motion Sync",
+ "luna.rippleControl": "Ripple Control",
+ "luna.buttons": "ボタン応答",
+ "luna.lightning": "ライトニングトリガー",
+ "luna.leftPriority": "左ボタン優先",
+ "luna.rightPriority": "右ボタン優先",
+ "luna.leftDebounce": "左ボタンの遅延",
+ "luna.rightDebounce": "右ボタンの遅延",
+ "luna.middleDebounce": "中央ボタンのデバウンス",
+ "luna.wheelGuard": "ホイールの誤操作防止",
+ "luna.wheelGuardWindow": "ホイール保護時間",
+ "luna.sensorAngle": "センサー角度",
"nav.language": "言語",
"nav.settings": "設定",
"nav.support": "サポート",
diff --git a/src/i18n-ko.ts b/src/i18n-ko.ts
index 324cefd3..0c2e9506 100644
--- a/src/i18n-ko.ts
+++ b/src/i18n-ko.ts
@@ -3,6 +3,21 @@ import type { I18nKey } from "./i18n.ts";
/** Korean strings, split into a lazy chunk so the initial bundle ships
English only. Loaded via ensureLocale() before first use. */
export const ko: Record = {
+ "luna.angleSnap": "직선 보정",
+ "luna.competitiveMode": "경쟁 모드",
+ "luna.trackingMode": "추적 모드",
+ "luna.motionSync": "모션 싱크",
+ "luna.rippleControl": "리플 제어",
+ "luna.buttons": "버튼 응답",
+ "luna.lightning": "라이트닝 트리거",
+ "luna.leftPriority": "왼쪽 우선",
+ "luna.rightPriority": "오른쪽 우선",
+ "luna.leftDebounce": "왼쪽 버튼 지연",
+ "luna.rightDebounce": "오른쪽 버튼 지연",
+ "luna.middleDebounce": "가운데 버튼 디바운스",
+ "luna.wheelGuard": "휠 오작동 방지",
+ "luna.wheelGuardWindow": "휠 보호 시간",
+ "luna.sensorAngle": "센서 각도",
"nav.language": "언어",
"nav.settings": "설정",
"nav.support": "후원",
diff --git a/src/i18n-pt.ts b/src/i18n-pt.ts
index 6f1af090..c4e747ec 100644
--- a/src/i18n-pt.ts
+++ b/src/i18n-pt.ts
@@ -3,6 +3,21 @@ import type { I18nKey } from "./i18n.ts";
/** Portuguese strings, split into a lazy chunk so the initial bundle
ships English only. Loaded via ensureLocale() before first use. */
export const pt: Record = {
+ "luna.angleSnap": "Correção de linha reta",
+ "luna.competitiveMode": "Modo competitivo",
+ "luna.trackingMode": "Modo de rastreamento",
+ "luna.motionSync": "Sincronização de movimento",
+ "luna.rippleControl": "Controle de ondulação",
+ "luna.buttons": "Resposta dos botões",
+ "luna.lightning": "Gatilho relâmpago",
+ "luna.leftPriority": "Prioridade esquerda",
+ "luna.rightPriority": "Prioridade direita",
+ "luna.leftDebounce": "Latência do botão esquerdo",
+ "luna.rightDebounce": "Latência do botão direito",
+ "luna.middleDebounce": "Debounce do botão do meio",
+ "luna.wheelGuard": "Proteção da roda contra toques acidentais",
+ "luna.wheelGuardWindow": "Janela de proteção da roda",
+ "luna.sensorAngle": "Ângulo do sensor",
"nav.language": "Idioma",
"nav.settings": "Configurações",
"nav.support": "Apoiar",
diff --git a/src/i18n-ru.ts b/src/i18n-ru.ts
index 41e28099..4bf05600 100644
--- a/src/i18n-ru.ts
+++ b/src/i18n-ru.ts
@@ -3,6 +3,21 @@ import type { I18nKey } from "./i18n.ts";
/** Russian strings, split into a lazy chunk so the initial bundle ships
English only. Loaded via ensureLocale() before first use. */
export const ru: Record = {
+ "luna.angleSnap": "Коррекция прямой линии",
+ "luna.competitiveMode": "Соревновательный режим",
+ "luna.trackingMode": "Режим отслеживания",
+ "luna.motionSync": "Синхронизация движения",
+ "luna.rippleControl": "Подавление дрожания",
+ "luna.buttons": "Отклик кнопок",
+ "luna.lightning": "Молниеносный спуск",
+ "luna.leftPriority": "Приоритет левой",
+ "luna.rightPriority": "Приоритет правой",
+ "luna.leftDebounce": "Задержка левой кнопки",
+ "luna.rightDebounce": "Задержка правой кнопки",
+ "luna.middleDebounce": "Подавление дребезга средней кнопки",
+ "luna.wheelGuard": "Защита от случайной прокрутки",
+ "luna.wheelGuardWindow": "Интервал защиты колеса",
+ "luna.sensorAngle": "Угол сенсора",
"nav.language": "Язык",
"nav.settings": "Настройки",
"nav.support": "Поддержка",
diff --git a/src/i18n-vi.ts b/src/i18n-vi.ts
index 19924517..19da755c 100644
--- a/src/i18n-vi.ts
+++ b/src/i18n-vi.ts
@@ -3,6 +3,21 @@ import type { I18nKey } from "./i18n";
/** Vietnamese strings, split into a lazy chunk so the initial bundle
ships English only. Loaded via ensureLocale() before first use. */
export const vi: Record = {
+ "luna.angleSnap": "Hiệu chỉnh đường thẳng",
+ "luna.competitiveMode": "Chế độ thi đấu",
+ "luna.trackingMode": "Chế độ theo dõi",
+ "luna.motionSync": "Đồng bộ chuyển động",
+ "luna.rippleControl": "Kiểm soát gợn",
+ "luna.buttons": "Phản hồi nút",
+ "luna.lightning": "Cò chớp",
+ "luna.leftPriority": "Ưu tiên trái",
+ "luna.rightPriority": "Ưu tiên phải",
+ "luna.leftDebounce": "Độ trễ nút trái",
+ "luna.rightDebounce": "Độ trễ nút phải",
+ "luna.middleDebounce": "Chống dội nút giữa",
+ "luna.wheelGuard": "Chống cuộn nhầm",
+ "luna.wheelGuardWindow": "Khoảng bảo vệ con lăn",
+ "luna.sensorAngle": "Góc cảm biến",
"nav.language": "Ngôn ngữ",
"nav.settings": "Cài đặt",
"nav.support": "Hỗ trợ",
diff --git a/src/i18n-zh.ts b/src/i18n-zh.ts
index de10898f..b7b835bd 100644
--- a/src/i18n-zh.ts
+++ b/src/i18n-zh.ts
@@ -3,6 +3,21 @@ import type { I18nKey } from "./i18n.ts";
/** Simplified Chinese strings, split into a lazy chunk so the initial
bundle ships English only. Loaded via ensureLocale() before first use. */
export const zh: Record = {
+ "luna.angleSnap": "直线修正",
+ "luna.competitiveMode": "竞技模式",
+ "luna.trackingMode": "追踪模式",
+ "luna.motionSync": "运动同步",
+ "luna.rippleControl": "波纹控制",
+ "luna.buttons": "按键响应",
+ "luna.lightning": "闪电扳机",
+ "luna.leftPriority": "左键优先",
+ "luna.rightPriority": "右键优先",
+ "luna.leftDebounce": "左键延迟",
+ "luna.rightDebounce": "右键延迟",
+ "luna.middleDebounce": "中键消抖",
+ "luna.wheelGuard": "防误触滚轮",
+ "luna.wheelGuardWindow": "防误触窗口",
+ "luna.sensorAngle": "传感器角度",
"nav.language": "语言",
"nav.settings": "设置",
"nav.support": "支持",
diff --git a/src/i18n.ts b/src/i18n.ts
index edbc7b04..df666dc0 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -6,6 +6,21 @@ import type { InterfaceLocale } from "./interface-preferences";
inside the controller stay English until phase 2. */
const en = {
+ "luna.angleSnap": "Angle Snap",
+ "luna.competitiveMode": "Competitive Mode",
+ "luna.trackingMode": "Tracking Mode",
+ "luna.motionSync": "Motion Sync",
+ "luna.rippleControl": "Ripple Control",
+ "luna.buttons": "Button response",
+ "luna.lightning": "Lightning Trigger",
+ "luna.leftPriority": "Left priority",
+ "luna.rightPriority": "Right priority",
+ "luna.leftDebounce": "Left button latency",
+ "luna.rightDebounce": "Right button latency",
+ "luna.middleDebounce": "Middle button debounce",
+ "luna.wheelGuard": "Wheel anti-mistouch",
+ "luna.wheelGuardWindow": "Wheel guard window",
+ "luna.sensorAngle": "Sensor angle",
"nav.language": "Language",
"nav.settings": "Settings",
"nav.support": "Support",
diff --git a/src/supported-devices-data.ts b/src/supported-devices-data.ts
index b5f87394..e788c9f8 100644
--- a/src/supported-devices-data.ts
+++ b/src/supported-devices-data.ts
@@ -274,6 +274,8 @@ export const SUPPORTED_DEVICES: readonly SupportedDeviceEntry[] = [
{ brand: "Lamzu", model: "Atlantis (shared family ids)", status: "likely", pids: [0xf50f] },
{ brand: "Lamzu", model: "Thorn", status: "quickwin" },
{ brand: "CRDRAKO", model: "KO-ONE", status: "supported", pids: [0x6a, 0x6b] },
+ { brand: "LunaFury", model: "LUNA33", status: "likely", pids: [0x32, 0x33] },
+ { brand: "LunaFury", model: "TYPE33", status: "likely", pids: [0x54, 0x84] },
{ brand: "Ninjutso", model: "Sora V2", status: "supported", pids: [0xae11, 0xae12, 0xae13, 0xae14, 0xae15, 0xae16] },
{ brand: "Ninjutso", model: "Sora V3", status: "supported", pids: [0xe010] },
{ brand: "Ninjutso", model: "TEN", status: "supported", pids: [0xe020, 0xea01] },