From 8dcdd79c731033450e87483d2e67d0fa1d6eaa3b Mon Sep 17 00:00:00 2001
From: Breakdown <169538186+RINZE1@users.noreply.github.com>
Date: Mon, 5 Oct 2026 22:27:14 +0800
Subject: [PATCH 1/2] feat: add LunaFury LUNA33 and TYPE33 support
---
docs/lunafury-testing.md | 148 ++++++++++++++++++++++++++
src/drivers/lamzu/hid.test.ts | 53 ++++++++-
src/drivers/lamzu/hid.ts | 95 +++++++++++++++--
src/drivers/lamzu/lunafury.test.ts | 165 +++++++++++++++++++++++++++++
src/drivers/mouse-types.ts | 4 +-
src/drivers/vendors.ts | 7 +-
src/lamzu/index.ts | 27 ++++-
src/lamzu/lunafury.test.ts | 99 +++++++++++++++++
src/lamzu/lunafury.ts | 90 ++++++++++++++++
9 files changed, 676 insertions(+), 12 deletions(-)
create mode 100644 docs/lunafury-testing.md
create mode 100644 src/drivers/lamzu/lunafury.test.ts
create mode 100644 src/lamzu/lunafury.test.ts
create mode 100644 src/lamzu/lunafury.ts
diff --git a/docs/lunafury-testing.md b/docs/lunafury-testing.md
new file mode 100644
index 0000000..5c1448a
--- /dev/null
+++ b/docs/lunafury-testing.md
@@ -0,0 +1,148 @@
+# LunaFury LUNA33 / TYPE33 protocol evidence
+
+This integration is based on the public LunaFury WebHID configurator at
+. The site and its source map were inspected on
+2026-10-05:
+
+- `/js/app.14b76f96.js` and `/js/app.14b76f96.js.map` contain the device table
+ and UI-side command calls.
+- `/js/output.xvi3.min.js` contains the `XviUpdater` transport and the concrete
+ feature-report packets.
+
+The owner completed full-function testing on LUNA33 with firmware
+`0.0.26.0`, over both cable and 8K receiver connections. This verifies the
+LUNA33 runtime identities `0x0032` and `0x0033` on that firmware. TYPE33
+identities and packet layouts are source-verified but have not been tested
+on hardware.
+
+The owner has not supplied separate results for power-cycle persistence,
+other firmware versions, or native-Bridge end-to-end testing. The stated
+Corded/20 kHz behavior has not been independently measured.
+
+## Runtime identities
+
+All devices use the shared CompX VID `0x373e`.
+
+| Model | Connection | PID | Polling rates exposed by LunaFury |
+| --- | --- | --- | --- |
+| LUNA33 | cable | `0x0032` | 125, 250, 500, 1000 Hz |
+| LUNA33 | 8K receiver | `0x0033` | 125, 250, 500, 1000, 2000, 4000, 8000 Hz |
+| TYPE33 | cable | `0x0054` | 125, 250, 500, 1000, 2000, 4000, 8000 Hz |
+| TYPE33 | 8K receiver | `0x0084` | 125, 250, 500, 1000, 2000, 4000, 8000 Hz |
+
+The configurator also lists `0xb032`, `0xb033`, `0xb054`, and `0xb084` as
+bootloader PIDs. They are intentionally excluded: firmware-update identities
+must not be claimed by the normal settings driver.
+
+Both models advertise a 30,000 DPI ceiling. LUNA33 exposes up to six DPI
+stages and TYPE33 up to five. The stage-read request carries that model limit,
+so each product profile supplies the correct maximum while the reply still
+determines the active stage list at runtime.
+
+## Protocol match
+
+The configurator sends report ID 0 with a 64-byte payload. That is the same
+CompX/XVI envelope implemented by `src/compx/codec.ts` and
+`src/drivers/lamzu/hid.ts`.
+
+| Setting | Read | Write / encoding |
+| --- | --- | --- |
+| Firmware | page `0x00`, command `0x81` | read only |
+| Battery | page `0x00`, command `0x83` | read only |
+| Active profile | page `0x00`, command `0x85` | read only |
+| Polling rate | page `0x01`, command `0x80` | command `0x00`; `08/04/02/01/20/40/80` = 125 through 8000 Hz |
+| Sleep | page `0x00`, command `0x87` | command `0x07` |
+| Debounce | page `0x00`, command `0x88` | command `0x08` |
+| Lift-off distance | page `0x01`, command `0x88` | command `0x08`; `87/01/02` = low/medium/high |
+| DPI stages | page `0x01`, command `0x81` | command `0x01` |
+| Angle snap | page `0x01`, command `0x84` | command `0x04` |
+| Motion sync | page `0x01`, command `0x89` | command `0x09` |
+| Ripple control | page `0x01`, command `0x8a` | command `0x0a` |
+| Competitive Mode / 竞技模式 | page `0x01`, command `0x8b` | command `0x0b`; vendor `setHyperMode`, shared field `hyperMode` |
+| Tracking Mode / 追踪模式 | page `0x01`, command `0x93` | command `0x13`; vendor `setTrackingMode`, shared field `performanceMode` |
+| Sensor angle | page `0x01`, command `0x94`, length 2 | command `0x14`; signed byte, −30° through +30° |
+| Lightning Trigger | page `0x00`, command `0x98`, length 4 | command `0x18`; 0 = off, 1 = left priority, 2 = right priority |
+| Per-button latency | page `0x00`, command `0x92`, length 19 | command `0x12`; button IDs 1/2/3 = left/right/middle |
+| Wheel anti-mistouch | page `0x00`, command `0x99`, length 4 | command `0x19`; enable byte and big-endian window in milliseconds |
+
+The cable transport rewrites mouse target `0x02` to target `0x00`. The 2.4 GHz
+receiver keeps mouse commands on target `0x02`, while receiver firmware remains
+on target `0x00`. These are represented by the product profiles rather than a
+forked LunaFury protocol implementation.
+
+### LunaFury-specific controls
+
+LunaFury labels follow its official `language.js` and `patternModule.vue`:
+`angleSnapping` is 直线修正, `hyperMode` is 竞技模式, and `performanceMode`
+is 追踪模式. Motion Sync and Ripple Control use 运动同步 and 波纹控制 in
+Chinese. These are display overrides for LunaFury only; commands and labels
+for other brands remain unchanged.
+
+The owner clarified the hardware semantics on 2026-10-05: Competitive Mode
+is Corded mode and Tracking Mode locks sensor sampling at 20 kHz. This
+description is owner-provided, not an independently measured result.
+The UI mirrors the vendor's `v-if="competition"`: tracking is shown only
+while competitive mode is confirmed on. Hiding it preserves its stored
+value. For staged changes, competitive mode enables before tracking edits
+and disables after them; other brands retain their existing ordering.
+
+`XviUpdater.hidIndex` is 1: received WebHID buffers do not include the report
+ID. Decoders use the payload after the six-byte CompX header. Lightning mode
+and sensor angle are payload byte 1; button ID is byte 2 and button latency
+is bytes 3–4; wheel enable is byte 1 and its window is bytes 2–3.
+
+Left/right latency uses 0–15 ms in 1 ms steps; middle-button debounce uses
+1–30 ms. The 19-byte latency write payload is
+`[profile, 0, buttonId, hi, lo, 0, 0, hi, lo, 0, 0, 0, 20, 0, 0, 0, 20, 0, 0]`.
+Wheel guard uses 20–200 ms in 20 ms steps. A disabled zero window is a valid
+read, not a guessed default; enabling it from that state proposes 100 ms.
+Writing a disabled zero window is supported so restoring a Games profile
+does not invent a different prior value.
+
+All added setters read back the value and reject a mismatch. Optional reads
+that fail or return invalid values leave their controls absent, while basic
+device settings remain available. Only the LunaFury product profiles issue
+these extra commands; other CompX brands are unchanged.
+
+The UI places Lightning Trigger, independent left/right latency, middle
+debounce, and wheel guard on the Buttons tab. Sensor angle is in Processing
+on the Performance tab. As in the vendor UI, left/right priority disables
+the corresponding latency slider. Turning priority off is applied before
+staged latency edits, and enabling priority is applied after them. These
+settings can also be saved in Games profiles as a partial LunaFury namespace.
+Each profile restores only the controls it changed, preserving unrelated
+settings and valid zero values.
+
+## Required hardware check
+
+Connect each available cable/receiver identity in Chromium and confirm:
+
+1. OpenMouse labels it `LunaFury LUNA33` or `LunaFury TYPE33`, never Attack
+ Shark.
+2. Firmware, battery, DPI stages, polling, LOD, sleep, debounce, motion sync,
+ angle snap, and ripple control read successfully.
+3. Change one reversible setting at a time, read it back, then restore the
+ original value.
+4. Confirm only the rates in the table are offered for that connection.
+5. Confirm the driver refuses bootloader/DFU identities. The shared broad
+ VID filter may still let Chromium show them in its native picker; do not
+ select or send settings commands to an upgrade-mode interface.
+6. With Lightning Trigger off, test independent left/right latencies and
+ middle debounce, refresh/reconnect, and compare their values with the
+ vendor configurator. Test each priority mode separately, restoring the
+ initial mode afterward.
+7. Test sensor angles −1°, 0°, and +1° first. Confirm direction and persistence
+ on hardware, then restore the original angle.
+8. Test wheel guard off and on at 20/100/200 ms. Confirm the actual firmware
+ behavior, then restore both the original enable state and window.
+9. Save a Games profile with a single LunaFury control, apply it, and restore
+ the prior settings. Confirm unrelated controls remain unchanged, including
+ the originally disabled zero wheel window if reported by this firmware.
+10. Confirm Competitive Mode controls Tracking Mode's visibility, including
+ staged on/off changes, without silently resetting its stored value.
+
+Automated coverage includes all four runtime IDs, cable/receiver targeting,
+profile 3, full packet fixtures, signed angles, invalid ranges, unsupported
+reads, ignored writes, and the other-brand guard. LUNA33 hardware testing
+is recorded above; TYPE33 still needs a real-device pass. The owner's
+full-function report does not include item-by-item results for this checklist.
diff --git a/src/drivers/lamzu/hid.test.ts b/src/drivers/lamzu/hid.test.ts
index 78d2943..d73601a 100644
--- a/src/drivers/lamzu/hid.test.ts
+++ b/src/drivers/lamzu/hid.test.ts
@@ -3,12 +3,14 @@ import test from "node:test";
import { LamzuHidClient } from "./hid.ts";
import { AttackSharkHidClient } from "../attackshark/hid.ts";
-import { deviceBrand } from "../registry.ts";
+import { DEVICE_DRIVERS, deviceBrand } from "../registry.ts";
import {
ATTACKSHARK_PRODUCT_IDS,
LAMZU_INCA_PRODUCTS,
LAMZU_INCA_VENDOR_ID,
+ LAMZU_PRODUCTS,
LAMZU_VENDOR_ID,
+ LUNAFURY_PRODUCT_IDS,
lamzuProduct,
} from "@openmouse/protocol/lamzu";
@@ -259,6 +261,55 @@ test("the Attack Shark R6 and R8 run on the CompX driver, not the Attack Shark o
}
});
+test("the LunaFury catalog contains only the four runtime identities", () => {
+ assert.deepEqual([...LUNAFURY_PRODUCT_IDS], [0x0032, 0x0033, 0x0054, 0x0084]);
+ for (const bootloader of [0xb032, 0xb033, 0xb054, 0xb084]) {
+ assert.equal(LAMZU_PRODUCTS.has(bootloader), false);
+ }
+});
+
+test("LunaFury LUNA33 and TYPE33 use the CompX driver with their own brand", async () => {
+ const cases = [
+ [0x0032, "LUNA33", "Wired", 6, [125, 250, 500, 1000]],
+ [0x0033, "LUNA33", "Wireless", 6, [125, 250, 500, 1000, 2000, 4000, 8000]],
+ [0x0054, "TYPE33", "Wired", 5, [125, 250, 500, 1000, 2000, 4000, 8000]],
+ [0x0084, "TYPE33", "Wireless", 5, [125, 250, 500, 1000, 2000, 4000, 8000]],
+ ] as const;
+
+ for (const [productId, model, connectionType, maxDpiStages, pollingRates] of cases) {
+ const { device, sent } = fakeR5Ultra(productId);
+ assert.equal(LamzuHidClient.isSupported(device), true);
+ assert.equal(AttackSharkHidClient.isSupported(device), false);
+ assert.deepEqual(
+ DEVICE_DRIVERS.filter((driver) => driver.supports(device)).map((driver) => driver.brand),
+ ["Lamzu"],
+ );
+
+ const client = new LamzuHidClient(device);
+ const status = await client.readStatus();
+ assert.equal(status.brand, "LunaFury");
+ assert.equal(status.name, `LunaFury ${model}`);
+ assert.equal(status.ui?.family, "lunafury");
+ assert.equal(deviceBrand(client), "LunaFury");
+ assert.equal(status.connectionType, connectionType);
+ assert.equal(client.getDpiOptions().at(-1), 30000);
+ assert.deepEqual(status.supportedPollingRates, pollingRates);
+ const dpiRead = sent.find((packet) => packet[4] === 0x01 && packet[5] === 0x81);
+ assert.equal(dpiRead?.[7], maxDpiStages);
+
+ if (connectionType === "Wired") {
+ assert.ok(sent.every((packet) => packet[2] === 0x00));
+ } else {
+ const dongleFirmware = sent.filter((packet) =>
+ packet[2] === 0x00 && packet[4] === 0x00 && packet[5] === 0x81);
+ const mouseRequests = sent.filter((packet) => !dongleFirmware.includes(packet));
+ assert.equal(dongleFirmware.length, 1);
+ assert.ok(mouseRequests.length > 0);
+ assert.ok(mouseRequests.every((packet) => packet[2] === 0x02));
+ }
+ }
+});
+
/**
* Byte-for-byte replay of a real Lamzu Inca 8K on its receiver, captured with
* hidapi on Windows 11 (docs/lamzu-inca-testing.md). Keyed by target:page:
diff --git a/src/drivers/lamzu/hid.ts b/src/drivers/lamzu/hid.ts
index 19a8dd8..6691d25 100644
--- a/src/drivers/lamzu/hid.ts
+++ b/src/drivers/lamzu/hid.ts
@@ -13,6 +13,16 @@ import {
LAMZU_POLLING_RATES as POLLING_RATES,
LAMZU_VENDOR_IDS,
lamzuProduct,
+ LUNAFURY_READ,
+ LUNAFURY_WRITE,
+ lunafuryDecodeAngle,
+ lunafuryDecodeLightning,
+ lunafuryDecodeButtonDebounce,
+ lunafuryDecodeWheelGuard,
+ type LunaFuryButton,
+ type LunaFuryLightningMode,
+ type LunaFurySettings,
+ type LunaFuryWheelGuard,
type CompaxDpiStage,
type LamzuProduct,
} from "@openmouse/protocol/lamzu";
@@ -72,8 +82,8 @@ const PROFILE_READ = {
({ target: TARGET.mouse, page: PAGE.device, command: 0x87, length: 0x03, args: [profile] }),
debounce: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.device, command: 0x88, length: 0x02, args: [profile] }),
- dpiStages: (profile: number): LamzuRequest =>
- ({ target: TARGET.mouse, page: PAGE.profile, command: 0x81, length: 0x0a, args: [profile, 0x06] }),
+ dpiStages: (profile: number, maxStages: number): LamzuRequest =>
+ ({ target: TARGET.mouse, page: PAGE.profile, command: 0x81, length: 0x0a, args: [profile, maxStages] }),
activeStage: (profile: number): LamzuRequest =>
({ target: TARGET.mouse, page: PAGE.profile, command: 0x82, length: 0x02, args: [profile] }),
pollingRate: (profile: number): LamzuRequest =>
@@ -195,7 +205,10 @@ export class LamzuHidClient {
displayName(): string {
const known = this.profile();
- return known ? `${this.deviceBrand()} ${known.model}` : this.device.productName || "Lamzu";
+ if (!known) return this.device.productName || "Lamzu";
+ const color = known.brand === "LunaFury"
+ ? this.device.productName?.match(/\b(EL|ES)\b/i)?.[1]?.toUpperCase() : undefined;
+ return `${this.deviceBrand()} ${known.model}${color ? ` ${color}` : ""}`;
}
deviceBrand(): MouseStatus["brand"] {
@@ -206,6 +219,10 @@ export class LamzuHidClient {
return this.profile()?.maxDpi ?? DPI_MAX;
}
+ maxDpiStages(): number {
+ return this.profile()?.maxDpiStages ?? 6;
+ }
+
getSleepOptions(): readonly number[] {
return this.profile()?.sleepOptions ?? SLEEP_SECONDS;
}
@@ -249,7 +266,7 @@ export class LamzuHidClient {
this.activeProfile = profile;
const sleepTimeout = await this.request(PROFILE_READ.sleepTimeout(profile)).catch(() => null);
const debounce = await this.request(PROFILE_READ.debounce(profile)).catch(() => null);
- const stages = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile)));
+ const stages = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile, this.maxDpiStages())));
const activeStage = this.stageIndex((await this.request(PROFILE_READ.activeStage(profile)))[1], stages.length);
const pollingRate = await this.request(PROFILE_READ.pollingRate(profile));
const liftOffDistance = await this.request(PROFILE_READ.liftOffDistance(profile));
@@ -262,6 +279,8 @@ export class LamzuHidClient {
const dongleLed = this.profile()?.dongleLed
? await this.request(PROFILE_READ.dongleLed(profile)).catch(() => null)
: null;
+ const lunafury = this.deviceBrand() === "LunaFury"
+ ? await this.readLunaFurySettings(profile) : undefined;
const stage = stages[activeStage];
if (!stage) throw new Error("The mouse did not report any DPI stages.");
return this.lastStatus = {
@@ -285,6 +304,7 @@ export class LamzuHidClient {
performanceMode: competitiveMode ? competitiveMode[1] === 1 : null,
hyperMode: hyperMode ? hyperMode[1] === 1 : null,
rippleControl: rippleControl ? rippleControl[1] === 1 : null,
+ ...(lunafury ? { lunafury: lunafury.settings, angleTuning: lunafury.angle } : {}),
dongleLedEnabled: dongleLed ? dongleLed[1] === 1 : null,
connectionType: wireless ? "Wireless" : "Wired",
connectionDetail: wireless ? "2.4 GHz receiver" : "Wired USB",
@@ -308,6 +328,67 @@ export class LamzuHidClient {
};
}
+ private requireLunaFury(): void {
+ if (this.deviceBrand() !== "LunaFury") throw new Error("This control is only available on LunaFury mice.");
+ }
+
+ private async readLunaFurySettings(profile: number): Promise<{ settings: LunaFurySettings; angle: number | null }> {
+ const optional = (spec: LamzuRequest) => this.request({ ...spec, attempts: 2 }).catch(() => null);
+ const lightningMode = lunafuryDecodeLightning(await optional(LUNAFURY_READ.lightning(profile)));
+ const leftDebounceMs = lunafuryDecodeButtonDebounce(await optional(LUNAFURY_READ.buttonDebounce(profile, "left")), "left");
+ const rightDebounceMs = lunafuryDecodeButtonDebounce(await optional(LUNAFURY_READ.buttonDebounce(profile, "right")), "right");
+ const middleDebounceMs = lunafuryDecodeButtonDebounce(await optional(LUNAFURY_READ.buttonDebounce(profile, "middle")), "middle");
+ const wheelGuard = lunafuryDecodeWheelGuard(await optional(LUNAFURY_READ.wheelGuard(profile)));
+ const angle = lunafuryDecodeAngle(await optional(LUNAFURY_READ.angle(profile)));
+ return { settings: { lightningMode, leftDebounceMs, rightDebounceMs, middleDebounceMs, wheelGuard }, angle };
+ }
+
+ private patchLunaFury(changes: Partial): void {
+ this.patch({ lunafury: { ...this.lastStatus?.lunafury, ...changes } });
+ }
+
+ async setAngleTuning(degrees: number): Promise {
+ this.requireLunaFury();
+ const profile = await this.currentProfile();
+ await this.request(LUNAFURY_WRITE.angle(profile, degrees));
+ const confirmed = lunafuryDecodeAngle(await this.request(LUNAFURY_READ.angle(profile)));
+ if (confirmed !== degrees) throw new Error(`The mouse did not confirm the ${degrees}° sensor angle.`);
+ this.patch({ angleTuning: confirmed });
+ return confirmed;
+ }
+
+ async setLunaFuryLightningMode(mode: LunaFuryLightningMode): Promise {
+ this.requireLunaFury();
+ const profile = await this.currentProfile();
+ await this.request(LUNAFURY_WRITE.lightning(profile, mode));
+ const confirmed = lunafuryDecodeLightning(await this.request(LUNAFURY_READ.lightning(profile)));
+ if (confirmed !== mode) throw new Error("The mouse did not confirm the Lightning Trigger mode.");
+ this.patchLunaFury({ lightningMode: confirmed });
+ return confirmed;
+ }
+
+ async setLunaFuryButtonDebounce(button: LunaFuryButton, milliseconds: number): Promise {
+ this.requireLunaFury();
+ const profile = await this.currentProfile();
+ await this.request(LUNAFURY_WRITE.buttonDebounce(profile, button, milliseconds));
+ const confirmed = lunafuryDecodeButtonDebounce(await this.request(LUNAFURY_READ.buttonDebounce(profile, button)), button);
+ if (confirmed !== milliseconds) throw new Error(`The mouse did not confirm ${milliseconds} ms latency for the ${button} button.`);
+ this.patchLunaFury({ [`${button}DebounceMs`]: confirmed });
+ return confirmed;
+ }
+
+ async setLunaFuryWheelGuard(guard: LunaFuryWheelGuard): Promise {
+ this.requireLunaFury();
+ const profile = await this.currentProfile();
+ await this.request(LUNAFURY_WRITE.wheelGuard(profile, guard));
+ const confirmed = lunafuryDecodeWheelGuard(await this.request(LUNAFURY_READ.wheelGuard(profile)));
+ if (!confirmed || confirmed.enabled !== guard.enabled || confirmed.windowMs !== guard.windowMs) {
+ throw new Error("The mouse did not confirm the wheel guard settings.");
+ }
+ this.patchLunaFury({ wheelGuard: confirmed });
+ return confirmed;
+ }
+
async setPollingRate(pollingRateHz: number): Promise {
const encoded = POLLING_RATES.find(([, hertz]) => hertz === pollingRateHz);
if (!encoded || !this.getSupportedPollingRates().includes(pollingRateHz)) {
@@ -422,7 +503,7 @@ export class LamzuHidClient {
}
}
const profile = await this.currentProfile();
- const stages = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile)));
+ const stages = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile, this.maxDpiStages())));
const active = this.stageIndex((await this.request(PROFILE_READ.activeStage(profile)))[1], stages.length);
if (!stages[active]) throw new Error("The mouse did not report any DPI stages.");
stages[active] = { x: dpi, y: dpiY };
@@ -430,7 +511,9 @@ export class LamzuHidClient {
stages.length,
...stages.flatMap((stage) => [stage.x >> 8 & 0xff, stage.x & 0xff, stage.y >> 8 & 0xff, stage.y & 0xff]),
]);
- const confirmed = this.decodeDpiStages(await this.request(PROFILE_READ.dpiStages(profile)))[active];
+ const confirmed = this.decodeDpiStages(
+ await this.request(PROFILE_READ.dpiStages(profile, this.maxDpiStages())),
+ )[active];
if (!confirmed || confirmed.x !== dpi || confirmed.y !== dpiY) {
throw new Error(`The mouse kept ${confirmed ? confirmed.x.toLocaleString() : "an unknown"} DPI instead of ${dpi.toLocaleString()}.`);
}
diff --git a/src/drivers/lamzu/lunafury.test.ts b/src/drivers/lamzu/lunafury.test.ts
new file mode 100644
index 0000000..8b9223b
--- /dev/null
+++ b/src/drivers/lamzu/lunafury.test.ts
@@ -0,0 +1,165 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { LamzuHidClient } from "./hid.ts";
+
+const globals = globalThis as { window?: { setTimeout: typeof setTimeout } };
+globals.window ??= { setTimeout };
+const EXTRA_READS = new Set(["1:148", "0:152", "0:146", "0:153"]);
+
+function fakeLunaFury(productId = 0x0033, options: { unsupported?: boolean; ignoreWrites?: boolean } = {}) {
+ const sent: Uint8Array[] = [];
+ const state = { angle: -12, lightning: 1, latency: [0, 3, 7, 10], wheel: true, window: 200 };
+ const device = {
+ vendorId: 0x373e, productId, productName: "LunaFury", opened: true,
+ collections: [{ usagePage: 0xff00, usage: 1, type: 1, children: [],
+ featureReports: [{ reportId: 0, items: [{ reportSize: 8, reportCount: 64 }] }],
+ inputReports: [], outputReports: [] }],
+ open: async () => {}, close: async () => {},
+ addEventListener: () => {}, removeEventListener: () => {},
+ sendFeatureReport: async (id: number, packet: Uint8Array) => {
+ assert.equal(id, 0);
+ sent.push(new Uint8Array(packet));
+ if (options.ignoreWrites) return;
+ const page = packet[4], command = packet[5];
+ if (page === 1 && command === 0x14) state.angle = packet[7]! > 127 ? packet[7]! - 256 : packet[7]!;
+ if (page === 0 && command === 0x18) state.lightning = packet[7]!;
+ if (page === 0 && command === 0x12) state.latency[packet[8]!] = (packet[9]! << 8) | packet[10]!;
+ if (page === 0 && command === 0x19) { state.wheel = packet[7] === 1; state.window = (packet[8]! << 8) | packet[9]!; }
+ },
+ receiveFeatureReport: async () => {
+ const packet = sent.at(-1)!;
+ const page = packet[4]!, command = packet[5]!, profile = packet[6]!;
+ const key = `${page}:${command}`;
+ const reply = new Uint8Array(64);
+ reply[0] = options.unsupported && EXTRA_READS.has(key) ? 0xa2 : 0xa1;
+ reply[4] = page; reply[5] = command;
+ let payload: number[] = [profile, 0];
+ if (key === "0:133") payload = [3];
+ if (key === "0:129") payload = [0, 0, 1, 2];
+ if (key === "0:131") payload = [0, 80];
+ if (key === "0:135") payload = [profile, 1, 44];
+ if (key === "1:129") payload = [profile, 1, 3, 32, 3, 32];
+ if (key === "1:128" || key === "1:136") payload = [profile, 1];
+ if (key === "1:148") payload = [profile, state.angle & 255];
+ if (key === "0:152") payload = [profile, state.lightning, 0, 0];
+ if (key === "0:146") payload = [profile, 0, packet[8]!, 0, state.latency[packet[8]!]!, ...Array(14).fill(0)];
+ if (key === "0:153") payload = [profile, +state.wheel, state.window >> 8, state.window & 255];
+ reply[3] = payload.length; reply.set(payload, 6);
+ // Non-zero byteOffset exercises DataView framing without a report ID.
+ const buffer = new Uint8Array(70); buffer.set(reply, 3);
+ return new DataView(buffer.buffer, 3, 64);
+ },
+ } as unknown as HIDDevice;
+ return { device, sent, state };
+}
+
+for (const [pid, target] of [[0x0032, 0], [0x0033, 2], [0x0054, 0], [0x0084, 2]] as const) {
+ test(`LunaFury 0x${pid.toString(16)} reads independent settings on the active profile`, async () => {
+ const { device, sent } = fakeLunaFury(pid);
+ const client = new LamzuHidClient(device);
+ const status = await client.readStatus();
+ assert.equal(status.brand, "LunaFury");
+ assert.equal(status.angleTuning, -12);
+ assert.deepEqual(status.lunafury, {
+ lightningMode: 1, leftDebounceMs: 3, rightDebounceMs: 7,
+ middleDebounceMs: 10, wheelGuard: { enabled: true, windowMs: 200 },
+ });
+ const extra = sent.filter((packet) => EXTRA_READS.has(`${packet[4]}:${packet[5]}`));
+ assert.equal(extra.length, 6);
+ assert.ok(extra.every((packet) => packet[2] === target && packet[6] === 3));
+ assert.deepEqual(extra.filter((packet) => packet[5] === 0x92).map((packet) => packet[8]), [1, 2, 3]);
+ const count = sent.length;
+ await client.readStatus(true);
+ assert.ok(sent.slice(count).every((packet) => !EXTRA_READS.has(`${packet[4]}:${packet[5]}`)), "live polling must not repeatedly read static controls");
+ });
+
+ test(`LunaFury 0x${pid.toString(16)} writes and verifies all added controls`, async () => {
+ const { device, sent } = fakeLunaFury(pid);
+ const client = new LamzuHidClient(device);
+ await client.readStatus();
+ const before = sent.length;
+ assert.equal(await client.setAngleTuning(-30), -30);
+ assert.equal(await client.setLunaFuryLightningMode(2), 2);
+ assert.equal(await client.setLunaFuryButtonDebounce("left", 15), 15);
+ assert.equal(await client.setLunaFuryButtonDebounce("right", 0), 0);
+ assert.equal(await client.setLunaFuryButtonDebounce("middle", 1), 1);
+ assert.deepEqual(await client.setLunaFuryWheelGuard({ enabled: false, windowMs: 100 }), { enabled: false, windowMs: 100 });
+ const writes = sent.slice(before).filter((packet) => packet[5]! < 0x80);
+ assert.equal(writes.length, 6);
+ assert.ok(writes.every((packet) => packet[2] === target && packet[6] === 3));
+ const status = await client.readStatus();
+ assert.equal(status.angleTuning, -30);
+ assert.deepEqual(status.lunafury, {
+ lightningMode: 2, leftDebounceMs: 15, rightDebounceMs: 0,
+ middleDebounceMs: 1, wheelGuard: { enabled: false, windowMs: 100 },
+ });
+ });
+}
+
+test("unsupported LunaFury controls do not break the base device status", async () => {
+ const { device } = fakeLunaFury(0x0033, { unsupported: true });
+ const status = await new LamzuHidClient(device).readStatus();
+ assert.equal(status.dpi, 800);
+ assert.equal(status.batteryPercent, 80);
+ assert.equal(status.angleTuning, null);
+ assert.ok(Object.values(status.lunafury!).every((value) => value === undefined));
+});
+
+test("LunaFury keeps only explicitly reported EL/ES color suffixes", () => {
+ for (const pid of [0x0032, 0x0033, 0x0054, 0x0084]) {
+ const { device } = fakeLunaFury(pid);
+ const model = pid === 0x0032 || pid === 0x0033 ? "LUNA33" : "TYPE33";
+ for (const suffix of ["EL", "ES", "el", "es"]) {
+ Object.assign(device, { productName: `LunaFury ${model} ${suffix}` });
+ assert.equal(new LamzuHidClient(device).displayName(), `LunaFury ${model} ${suffix.toUpperCase()}`);
+ }
+ for (const name of ["LunaFury", "WIRELESS RECEIVER", "ELSE", "SHELL", undefined]) {
+ Object.assign(device, { productName: name });
+ assert.equal(new LamzuHidClient(device).displayName(), `LunaFury ${model}`);
+ }
+ }
+ const { device } = fakeLunaFury(0x006a);
+ Object.assign(device, { productName: "CRDRAKO KO-ONE ES" });
+ assert.equal(new LamzuHidClient(device).displayName(), "CRDRAKO KO-ONE");
+});
+
+test("a disabled zero wheel window can be restored exactly", async () => {
+ const { device, state } = fakeLunaFury();
+ state.wheel = false;
+ state.window = 0;
+ const client = new LamzuHidClient(device);
+ const before = (await client.readStatus()).lunafury!.wheelGuard!;
+ await client.setLunaFuryWheelGuard({ enabled: true, windowMs: 100 });
+ assert.deepEqual(await client.setLunaFuryWheelGuard(before), { enabled: false, windowMs: 0 });
+ assert.deepEqual((await client.readStatus()).lunafury!.wheelGuard, before);
+});
+
+test("ignored writes are rejected instead of updating the cached status", async () => {
+ const { device } = fakeLunaFury(0x0033, { ignoreWrites: true });
+ const client = new LamzuHidClient(device);
+ await client.readStatus();
+ await assert.rejects(client.setAngleTuning(10), /did not confirm/);
+ await assert.rejects(client.setLunaFuryLightningMode(0), /did not confirm/);
+ await assert.rejects(client.setLunaFuryButtonDebounce("left", 5), /did not confirm/);
+ await assert.rejects(client.setLunaFuryButtonDebounce("right", 5), /did not confirm/);
+ await assert.rejects(client.setLunaFuryButtonDebounce("middle", 5), /did not confirm/);
+ await assert.rejects(client.setLunaFuryWheelGuard({ enabled: false, windowMs: 20 }), /did not confirm/);
+ const status = await client.readStatus();
+ assert.equal(status.angleTuning, -12);
+ assert.equal(status.lunafury?.lightningMode, 1);
+ assert.equal(status.lunafury?.leftDebounceMs, 3);
+ assert.deepEqual(status.lunafury?.wheelGuard, { enabled: true, windowMs: 200 });
+});
+
+test("other CompX brands never receive LunaFury commands", async () => {
+ const { device, sent } = fakeLunaFury(0x006a);
+ const client = new LamzuHidClient(device);
+ assert.equal((await client.readStatus()).lunafury, undefined);
+ const count = sent.length;
+ await assert.rejects(client.setAngleTuning(1), /only available on LunaFury/);
+ await assert.rejects(client.setLunaFuryLightningMode(1), /only available on LunaFury/);
+ await assert.rejects(client.setLunaFuryButtonDebounce("left", 1), /only available on LunaFury/);
+ await assert.rejects(client.setLunaFuryWheelGuard({ enabled: true, windowMs: 100 }), /only available on LunaFury/);
+ assert.equal(sent.length, count);
+ assert.ok(sent.every((packet) => !EXTRA_READS.has(`${packet[4]}:${packet[5]}`)));
+});
diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts
index 9ccba98..c078e81 100644
--- a/src/drivers/mouse-types.ts
+++ b/src/drivers/mouse-types.ts
@@ -153,8 +153,10 @@ export interface AtkReceiverInfo {
}
export interface MouseStatus {
- brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master";
+ brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master";
name: string;
+ /** Optional LunaFury controls, populated only after successful device reads. */
+ lunafury?: import("../lamzu/lunafury.js").LunaFurySettings;
/** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */
ui?: MouseUiHints;
batteryPercent: number | null;
diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts
index eb002e9..d45d15c 100644
--- a/src/drivers/vendors.ts
+++ b/src/drivers/vendors.ts
@@ -134,6 +134,7 @@ export const VENDOR_ID = {
endgameGear: 0x3367,
wlmouse: 0x36a7,
lamzu: 0x373e,
+ lunafury: 0x373e,
bytech: 0x372e,
lamzuInca: LAMZU_INCA_VENDOR_ID,
attackshark: 0x373e,
@@ -761,9 +762,9 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [
{ vendorId: VENDOR_ID.vgn },
{ vendorId: VENDOR_ID.endgameGear },
{ vendorId: VENDOR_ID.wlmouse },
- // 0x373e is the shared CompX ODM vendor id behind Lamzu, CRDRAKO, and
- // Attack Shark. The broad filter surfaces all of them; each driver rejects
- // interfaces that lack the feature-report-0 control channel.
+ // 0x373e is the shared CompX ODM vendor id behind Lamzu, CRDRAKO,
+ // LunaFury, and Attack Shark. The broad filter surfaces all of them; each
+ // driver rejects interfaces that lack the feature-report-0 control channel.
{ vendorId: VENDOR_ID.lamzu },
...LAMZU_INCA_HID_FILTERS,
{ vendorId: VENDOR_ID.bytech, usagePage: 0xff00, usage: 0x0001 },
diff --git a/src/lamzu/index.ts b/src/lamzu/index.ts
index b655bfd..7b69be4 100644
--- a/src/lamzu/index.ts
+++ b/src/lamzu/index.ts
@@ -1,13 +1,15 @@
export * from "../compx/codec.js";
export * from "./atlantis.js";
+export * from "./lunafury.js";
export interface LamzuProduct {
model: string;
wireless: boolean;
pollingRates: readonly number[];
- brand?: "Lamzu" | "CRDRAKO" | "Attack Shark";
+ brand?: "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury";
uiFamily?: string;
mouseTarget?: number;
maxDpi?: number;
+ maxDpiStages?: number;
sleepOptions?: readonly number[];
/** The receiver answers the dongle LED on/off command (page 0x02, 0x04/0x84). */
dongleLed?: boolean;
@@ -17,6 +19,11 @@ const RATES_1K = [125, 250, 500, 1000] as const;
const RATES_8K = [500, 1000, 2000, 4000, 8000] as const;
const RATES_8K_FULL = [125, 250, 500, 1000, 2000, 4000, 8000] as const;
export const CRDRAKO_PRODUCT_IDS = [0x006a, 0x006b] as const;
+// LunaFury's public web configurator (mouse.lunafury.games, inspected
+// 2026-10-05) names these runtime identities and uses the same 64-byte CompX
+// feature-report framing as this driver. Its B032/B033/B054/B084 identities
+// are firmware-update bootloaders and are intentionally not catalogued here.
+export const LUNAFURY_PRODUCT_IDS = [0x0032, 0x0033, 0x0054, 0x0084] as const;
// Attack Shark Core 2.0.7.9 (Config/xvi_models.xlsx) lists all three on the
// same CompX platform: wired 125-1000, 8K receiver 125-8000, DPIMax 42000.
// Only the R5 Ultra has been seen on hardware.
@@ -33,6 +40,24 @@ export const LAMZU_PRODUCTS: ReadonlyMap = new Map([
brand: "CRDRAKO", model: "KO-ONE", wireless: true,
pollingRates: RATES_8K_FULL, mouseTarget: 0x02, uiFamily: "crdrako",
}],
+ [0x0032, {
+ brand: "LunaFury", model: "LUNA33", wireless: false,
+ pollingRates: RATES_1K, mouseTarget: 0x00, maxDpi: 30000, maxDpiStages: 6,
+ uiFamily: "lunafury",
+ }],
+ [0x0033, {
+ brand: "LunaFury", model: "LUNA33", wireless: true,
+ pollingRates: RATES_8K_FULL, maxDpi: 30000, maxDpiStages: 6, uiFamily: "lunafury",
+ }],
+ [0x0054, {
+ brand: "LunaFury", model: "TYPE33", wireless: false,
+ pollingRates: RATES_8K_FULL, mouseTarget: 0x00, maxDpi: 30000, maxDpiStages: 5,
+ uiFamily: "lunafury",
+ }],
+ [0x0084, {
+ brand: "LunaFury", model: "TYPE33", wireless: true,
+ pollingRates: RATES_8K_FULL, maxDpi: 30000, maxDpiStages: 5, uiFamily: "lunafury",
+ }],
[0x0046, {
brand: "Attack Shark", model: "R5 Ultra", wireless: false,
pollingRates: RATES_1K, maxDpi: 42000, uiFamily: "attack-shark",
diff --git a/src/lamzu/lunafury.test.ts b/src/lamzu/lunafury.test.ts
new file mode 100644
index 0000000..59c5fce
--- /dev/null
+++ b/src/lamzu/lunafury.test.ts
@@ -0,0 +1,99 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { compaxEncodeRequest } from "../compx/codec.ts";
+import {
+ LUNAFURY_READ, LUNAFURY_WRITE, lunafuryDecodeAngle,
+ lunafuryDecodeLightning, lunafuryDecodeButtonDebounce, lunafuryDecodeWheelGuard,
+} from "./lunafury.ts";
+
+test("LunaFury read requests preserve page, length, profile and button ID", () => {
+ const fixtures = [
+ [LUNAFURY_READ.angle(3), [0, 0, 2, 2, 1, 0x94, 3]],
+ [LUNAFURY_READ.lightning(3), [0, 0, 2, 4, 0, 0x98, 3]],
+ [LUNAFURY_READ.buttonDebounce(3, "left"), [0, 0, 2, 19, 0, 0x92, 3, 0, 1]],
+ [LUNAFURY_READ.buttonDebounce(3, "right"), [0, 0, 2, 19, 0, 0x92, 3, 0, 2]],
+ [LUNAFURY_READ.buttonDebounce(3, "middle"), [0, 0, 2, 19, 0, 0x92, 3, 0, 3]],
+ [LUNAFURY_READ.wheelGuard(3), [0, 0, 2, 4, 0, 0x99, 3]],
+ ] as const;
+ for (const [request, prefix] of fixtures) {
+ const packet = compaxEncodeRequest(request);
+ assert.equal(packet.length, 64);
+ assert.deepEqual([...packet.slice(0, prefix.length)], prefix);
+ assert.ok(packet.slice(prefix.length).every((byte) => byte === 0));
+ }
+});
+
+test("sensor angle uses signed eight-bit degrees, including both boundaries", () => {
+ for (const degrees of [-30, -12, 0, 12, 30]) {
+ const packet = compaxEncodeRequest(LUNAFURY_WRITE.angle(3, degrees));
+ assert.deepEqual([...packet.slice(0, 8)], [0, 0, 2, 2, 1, 0x14, 3, degrees & 255]);
+ assert.equal(lunafuryDecodeAngle(Uint8Array.of(3, degrees & 255)), degrees);
+ }
+});
+
+test("Lightning Trigger supports off, left priority and right priority", () => {
+ for (const mode of [0, 1, 2] as const) {
+ assert.deepEqual([...compaxEncodeRequest(LUNAFURY_WRITE.lightning(3, mode)).slice(0, 10)],
+ [0, 0, 2, 4, 0, 0x18, 3, mode, 0, 0]);
+ assert.equal(lunafuryDecodeLightning(Uint8Array.of(3, mode, 0, 0)), mode);
+ }
+});
+
+test("per-button latency writes the complete vendor 19-byte timing layout", () => {
+ for (const [button, id, ms] of [["left", 1, 0], ["right", 2, 15], ["middle", 3, 30]] as const) {
+ const packet = compaxEncodeRequest(LUNAFURY_WRITE.buttonDebounce(3, button, ms));
+ assert.deepEqual([...packet.slice(0, 25)], [0, 0, 2, 19, 0, 0x12,
+ 3, 0, id, 0, ms, 0, 0, 0, ms, 0, 0, 0, 20, 0, 0, 0, 20, 0, 0]);
+ assert.equal(lunafuryDecodeButtonDebounce(packet.slice(6, 25), button), ms);
+ assert.ok(packet.slice(25).every((byte) => byte === 0));
+ }
+});
+
+test("wheel guard keeps the enable bit separate from the big-endian window", () => {
+ for (const enabled of [false, true]) {
+ for (const windowMs of [20, 100, 200]) {
+ const guard = { enabled, windowMs };
+ const packet = compaxEncodeRequest(LUNAFURY_WRITE.wheelGuard(3, guard));
+ assert.deepEqual([...packet.slice(0, 10)], [0, 0, 2, 4, 0, 0x19, 3, +enabled, 0, windowMs]);
+ assert.deepEqual(lunafuryDecodeWheelGuard(packet.slice(6, 10)), guard);
+ }
+ }
+ assert.deepEqual(lunafuryDecodeWheelGuard(Uint8Array.of(3, 0, 0, 0)), { enabled: false, windowMs: 0 });
+ assert.deepEqual([...compaxEncodeRequest(LUNAFURY_WRITE.wheelGuard(3, { enabled: false, windowMs: 0 })).slice(0, 10)],
+ [0, 0, 2, 4, 0, 0x19, 3, 0, 0, 0]);
+});
+
+test("LunaFury requests reject invalid profile bytes instead of wrapping them", () => {
+ for (const profile of [0, -1, 256, 1.5, NaN, Infinity]) {
+ assert.throws(() => LUNAFURY_READ.angle(profile), /Profile ID/);
+ assert.throws(() => LUNAFURY_WRITE.lightning(profile, 1), /Profile ID/);
+ assert.throws(() => LUNAFURY_WRITE.wheelGuard(profile, { enabled: false, windowMs: 0 }), /Profile ID/);
+ }
+});
+
+test("invalid values cannot produce write requests", () => {
+ for (const value of [-31, 31, 0.5, NaN, Infinity]) assert.throws(() => LUNAFURY_WRITE.angle(1, value));
+ for (const mode of [-1, 3, 1.5]) assert.throws(() => LUNAFURY_WRITE.lightning(1, mode as 0));
+ for (const value of [-1, 16, 0.5]) assert.throws(() => LUNAFURY_WRITE.buttonDebounce(1, "left", value));
+ for (const value of [0, 31, 1.5]) assert.throws(() => LUNAFURY_WRITE.buttonDebounce(1, "middle", value));
+ assert.throws(() => LUNAFURY_WRITE.buttonDebounce(1, "toString" as "left", 1));
+ for (const windowMs of [0, 10, 21, 201, NaN]) assert.throws(() => LUNAFURY_WRITE.wheelGuard(1, { enabled: true, windowMs }));
+ assert.throws(() => LUNAFURY_WRITE.wheelGuard(1, { enabled: 1 as unknown as boolean, windowMs: 100 }));
+});
+
+test("malformed or unsupported responses stay unknown instead of inventing defaults", () => {
+ for (const payload of [null, new Uint8Array(), Uint8Array.of(1)]) {
+ assert.equal(lunafuryDecodeAngle(payload), null);
+ assert.equal(lunafuryDecodeLightning(payload), undefined);
+ assert.equal(lunafuryDecodeButtonDebounce(payload, "left"), undefined);
+ assert.equal(lunafuryDecodeWheelGuard(payload), undefined);
+ }
+ assert.equal(lunafuryDecodeAngle(Uint8Array.of(1, 31)), null);
+ assert.equal(lunafuryDecodeLightning(Uint8Array.of(1, 3)), undefined);
+ assert.equal(lunafuryDecodeButtonDebounce(Uint8Array.of(1, 0, 2, 0, 10), "left"), undefined);
+ assert.equal(lunafuryDecodeButtonDebounce(Uint8Array.of(1, 0, 1, 1, 0), "left"), undefined);
+ assert.equal(lunafuryDecodeButtonDebounce(Uint8Array.of(1, 0, 3, 0, 0), "middle"), undefined);
+ for (const bytes of [[1, 2, 0, 100], [1, 1, 0, 0], [1, 0, 0, 21], [1, 1, 1, 20]]) {
+ assert.equal(lunafuryDecodeWheelGuard(Uint8Array.from(bytes)), undefined);
+ }
+});
diff --git a/src/lamzu/lunafury.ts b/src/lamzu/lunafury.ts
new file mode 100644
index 0000000..e26e2d5
--- /dev/null
+++ b/src/lamzu/lunafury.ts
@@ -0,0 +1,90 @@
+/** LunaFury's public XviUpdater commands, inspected 2026-10-05.
+ * Report ID 0 is absent from received WebHID buffers (vendor hidIndex = 1).
+ * Offsets below are relative to the shared six-byte CompX response header.
+ */
+export type LunaFuryLightningMode = 0 | 1 | 2;
+export type LunaFuryButton = "left" | "right" | "middle";
+export interface LunaFuryWheelGuard { enabled: boolean; windowMs: number }
+export interface LunaFurySettings {
+ lightningMode?: LunaFuryLightningMode;
+ leftDebounceMs?: number;
+ rightDebounceMs?: number;
+ middleDebounceMs?: number;
+ wheelGuard?: LunaFuryWheelGuard;
+}
+
+const BUTTON_ID = { left: 1, right: 2, middle: 3 } as const;
+const request = (page: number, command: number, length: number, args: number[]) => {
+ whole(args[0], 1, 255, "Profile ID");
+ return { target: 0x02, page, command, length, args };
+};
+
+export const LUNAFURY_READ = {
+ angle: (profile: number) => request(0x01, 0x94, 2, [profile]),
+ lightning: (profile: number) => request(0x00, 0x98, 4, [profile]),
+ buttonDebounce: (profile: number, button: LunaFuryButton) =>
+ request(0x00, 0x92, 19, [profile, 0, BUTTON_ID[button]]),
+ wheelGuard: (profile: number) => request(0x00, 0x99, 4, [profile]),
+};
+
+function whole(value: number, min: number, max: number, label: string): void {
+ if (!Number.isInteger(value) || value < min || value > max) {
+ throw new Error(`${label} must be a whole number between ${min} and ${max}.`);
+ }
+}
+
+export const LUNAFURY_WRITE = {
+ angle(profile: number, degrees: number) {
+ whole(degrees, -30, 30, "Sensor angle");
+ return request(0x01, 0x14, 2, [profile, degrees & 0xff]);
+ },
+ lightning(profile: number, mode: LunaFuryLightningMode) {
+ whole(mode, 0, 2, "Lightning Trigger mode");
+ return request(0x00, 0x18, 4, [profile, mode]);
+ },
+ buttonDebounce(profile: number, button: LunaFuryButton, milliseconds: number) {
+ if (!Object.hasOwn(BUTTON_ID, button)) throw new Error("Unknown LunaFury button.");
+ whole(milliseconds, button === "middle" ? 1 : 0, button === "middle" ? 30 : 15, "Button latency");
+ // The vendor writes the same latency into both timing fields, with the
+ // remaining timing fields fixed at 0/20. Preserve the full 19-byte layout.
+ return request(0x00, 0x12, 19, [
+ profile, 0, BUTTON_ID[button], 0, milliseconds, 0, 0,
+ 0, milliseconds, 0, 0, 0, 20, 0, 0, 0, 20, 0, 0,
+ ]);
+ },
+ wheelGuard(profile: number, guard: LunaFuryWheelGuard) {
+ if (typeof guard.enabled !== "boolean") throw new Error("Wheel guard needs an on/off value.");
+ // Firmware can report a disabled zero window. It must be writable too
+ // so applying and restoring a game profile preserves the original value.
+ if (!guard.enabled && guard.windowMs === 0) return request(0x00, 0x19, 4, [profile, 0, 0, 0]);
+ whole(guard.windowMs, 20, 200, "Wheel guard window");
+ if (guard.windowMs % 20 !== 0) throw new Error("Wheel guard window must use 20 ms steps.");
+ return request(0x00, 0x19, 4, [profile, guard.enabled ? 1 : 0, 0, guard.windowMs]);
+ },
+};
+
+export function lunafuryDecodeAngle(payload: Uint8Array | null): number | null {
+ if (!payload || payload.length < 2) return null;
+ const angle = payload[1] > 127 ? payload[1] - 256 : payload[1];
+ return angle >= -30 && angle <= 30 ? angle : null;
+}
+
+export function lunafuryDecodeLightning(payload: Uint8Array | null): LunaFuryLightningMode | undefined {
+ return payload && payload.length >= 2 && payload[1] <= 2
+ ? payload[1] as LunaFuryLightningMode : undefined;
+}
+
+export function lunafuryDecodeButtonDebounce(payload: Uint8Array | null, button: LunaFuryButton): number | undefined {
+ if (!payload || payload.length < 5 || payload[2] !== BUTTON_ID[button]) return undefined;
+ const value = (payload[3] << 8) | payload[4];
+ return value >= (button === "middle" ? 1 : 0) && value <= (button === "middle" ? 30 : 15)
+ ? value : undefined;
+}
+
+export function lunafuryDecodeWheelGuard(payload: Uint8Array | null): LunaFuryWheelGuard | undefined {
+ if (!payload || payload.length < 4 || payload[1] > 1) return undefined;
+ const windowMs = (payload[2] << 8) | payload[3];
+ const enabled = payload[1] === 1;
+ if (!(windowMs >= 20 && windowMs <= 200 && windowMs % 20 === 0) && !(!enabled && windowMs === 0)) return undefined;
+ return { enabled, windowMs };
+}
From c1e49689f2ebd4573a9be8081c588751291d19a9 Mon Sep 17 00:00:00 2001
From: Breakdown <169538186+RINZE1@users.noreply.github.com>
Date: Tue, 6 Oct 2026 00:40:16 +0800
Subject: [PATCH 2/2] fix(lunafury): correlate extension replies with profile
and button
---
docs/lunafury-testing.md | 7 ++
src/drivers/lamzu/hid.ts | 26 ++++--
src/drivers/lamzu/lunafury.test.ts | 124 ++++++++++++++++++++++++++++-
3 files changed, 150 insertions(+), 7 deletions(-)
diff --git a/docs/lunafury-testing.md b/docs/lunafury-testing.md
index 5c1448a..f26d9c4 100644
--- a/docs/lunafury-testing.md
+++ b/docs/lunafury-testing.md
@@ -104,6 +104,13 @@ that fail or return invalid values leave their controls absent, while basic
device settings remain available. Only the LunaFury product profiles issue
these extra commands; other CompX brands are unchanged.
+LunaFury extension reads accept a reply only when its payload echoes the
+requested profile; button-latency reads also match the button ID. A successful
+reply for another profile or button is ignored while the same request waits
+for a matching reply, within its existing attempt limit. This applies to both
+initial reads and write readbacks. Generic CompX requests and write
+acknowledgments retain their existing matching rules.
+
The UI places Lightning Trigger, independent left/right latency, middle
debounce, and wheel guard on the Buttons tab. Sensor angle is in Processing
on the Performance tab. As in the vendor UI, left/right priority disables
diff --git a/src/drivers/lamzu/hid.ts b/src/drivers/lamzu/hid.ts
index 6691d25..25638fb 100644
--- a/src/drivers/lamzu/hid.ts
+++ b/src/drivers/lamzu/hid.ts
@@ -68,6 +68,7 @@ interface LamzuRequest {
length: number;
args: readonly number[];
attempts?: number;
+ matchesReply?: (payload: Uint8Array) => boolean;
}
const READ = {
@@ -332,8 +333,20 @@ export class LamzuHidClient {
if (this.deviceBrand() !== "LunaFury") throw new Error("This control is only available on LunaFury mice.");
}
+ private readLunaFury(spec: LamzuRequest): Promise {
+ const profile = spec.args[0];
+ const button = spec.page === PAGE.device && spec.command === 0x92 ? spec.args[2] : undefined;
+ // These reads echo their profile and, for latency, the button selector.
+ // A successful same-command reply can still belong to the previous read.
+ return this.request({
+ ...spec,
+ matchesReply: (payload) => payload[0] === profile
+ && (button === undefined || payload[2] === button),
+ });
+ }
+
private async readLunaFurySettings(profile: number): Promise<{ settings: LunaFurySettings; angle: number | null }> {
- const optional = (spec: LamzuRequest) => this.request({ ...spec, attempts: 2 }).catch(() => null);
+ const optional = (spec: LamzuRequest) => this.readLunaFury({ ...spec, attempts: 2 }).catch(() => null);
const lightningMode = lunafuryDecodeLightning(await optional(LUNAFURY_READ.lightning(profile)));
const leftDebounceMs = lunafuryDecodeButtonDebounce(await optional(LUNAFURY_READ.buttonDebounce(profile, "left")), "left");
const rightDebounceMs = lunafuryDecodeButtonDebounce(await optional(LUNAFURY_READ.buttonDebounce(profile, "right")), "right");
@@ -351,7 +364,7 @@ export class LamzuHidClient {
this.requireLunaFury();
const profile = await this.currentProfile();
await this.request(LUNAFURY_WRITE.angle(profile, degrees));
- const confirmed = lunafuryDecodeAngle(await this.request(LUNAFURY_READ.angle(profile)));
+ const confirmed = lunafuryDecodeAngle(await this.readLunaFury(LUNAFURY_READ.angle(profile)));
if (confirmed !== degrees) throw new Error(`The mouse did not confirm the ${degrees}° sensor angle.`);
this.patch({ angleTuning: confirmed });
return confirmed;
@@ -361,7 +374,7 @@ export class LamzuHidClient {
this.requireLunaFury();
const profile = await this.currentProfile();
await this.request(LUNAFURY_WRITE.lightning(profile, mode));
- const confirmed = lunafuryDecodeLightning(await this.request(LUNAFURY_READ.lightning(profile)));
+ const confirmed = lunafuryDecodeLightning(await this.readLunaFury(LUNAFURY_READ.lightning(profile)));
if (confirmed !== mode) throw new Error("The mouse did not confirm the Lightning Trigger mode.");
this.patchLunaFury({ lightningMode: confirmed });
return confirmed;
@@ -371,7 +384,7 @@ export class LamzuHidClient {
this.requireLunaFury();
const profile = await this.currentProfile();
await this.request(LUNAFURY_WRITE.buttonDebounce(profile, button, milliseconds));
- const confirmed = lunafuryDecodeButtonDebounce(await this.request(LUNAFURY_READ.buttonDebounce(profile, button)), button);
+ const confirmed = lunafuryDecodeButtonDebounce(await this.readLunaFury(LUNAFURY_READ.buttonDebounce(profile, button)), button);
if (confirmed !== milliseconds) throw new Error(`The mouse did not confirm ${milliseconds} ms latency for the ${button} button.`);
this.patchLunaFury({ [`${button}DebounceMs`]: confirmed });
return confirmed;
@@ -381,7 +394,7 @@ export class LamzuHidClient {
this.requireLunaFury();
const profile = await this.currentProfile();
await this.request(LUNAFURY_WRITE.wheelGuard(profile, guard));
- const confirmed = lunafuryDecodeWheelGuard(await this.request(LUNAFURY_READ.wheelGuard(profile)));
+ const confirmed = lunafuryDecodeWheelGuard(await this.readLunaFury(LUNAFURY_READ.wheelGuard(profile)));
if (!confirmed || confirmed.enabled !== guard.enabled || confirmed.windowMs !== guard.windowMs) {
throw new Error("The mouse did not confirm the wheel guard settings.");
}
@@ -560,7 +573,8 @@ export class LamzuHidClient {
if (reply[0] === STATUS.unsupported) throw new Error(this.describe(spec, "is not supported by this mouse"));
if (reply[0] === STATUS.ok && reply[4] === spec.page && reply[5] === spec.command) {
const length = Math.min(reply[3], PACKET_LENGTH - HEADER_LENGTH);
- return reply.slice(HEADER_LENGTH, HEADER_LENGTH + length);
+ const payload = reply.slice(HEADER_LENGTH, HEADER_LENGTH + length);
+ if (!spec.matchesReply || spec.matchesReply(payload)) return payload;
}
if (reply[0] !== STATUS.pending && reply[0] !== STATUS.busy && reply[0] !== STATUS.ok) {
throw new Error(this.describe(spec, `returned an unexpected status 0x${reply[0].toString(16)}`));
diff --git a/src/drivers/lamzu/lunafury.test.ts b/src/drivers/lamzu/lunafury.test.ts
index 8b9223b..d602a4d 100644
--- a/src/drivers/lamzu/lunafury.test.ts
+++ b/src/drivers/lamzu/lunafury.test.ts
@@ -6,8 +6,13 @@ const globals = globalThis as { window?: { setTimeout: typeof setTimeout } };
globals.window ??= { setTimeout };
const EXTRA_READS = new Set(["1:148", "0:152", "0:146", "0:153"]);
-function fakeLunaFury(productId = 0x0033, options: { unsupported?: boolean; ignoreWrites?: boolean } = {}) {
+function fakeLunaFury(productId = 0x0033, options: {
+ unsupported?: boolean;
+ ignoreWrites?: boolean;
+ mutateReply?: (packet: Uint8Array, reply: Uint8Array, attempt: number) => void;
+} = {}) {
const sent: Uint8Array[] = [];
+ let receiveAttempt = 0;
const state = { angle: -12, lightning: 1, latency: [0, 3, 7, 10], wheel: true, window: 200 };
const device = {
vendorId: 0x373e, productId, productName: "LunaFury", opened: true,
@@ -19,6 +24,7 @@ function fakeLunaFury(productId = 0x0033, options: { unsupported?: boolean; igno
sendFeatureReport: async (id: number, packet: Uint8Array) => {
assert.equal(id, 0);
sent.push(new Uint8Array(packet));
+ receiveAttempt = 0;
if (options.ignoreWrites) return;
const page = packet[4], command = packet[5];
if (page === 1 && command === 0x14) state.angle = packet[7]! > 127 ? packet[7]! - 256 : packet[7]!;
@@ -45,6 +51,7 @@ function fakeLunaFury(productId = 0x0033, options: { unsupported?: boolean; igno
if (key === "0:146") payload = [profile, 0, packet[8]!, 0, state.latency[packet[8]!]!, ...Array(14).fill(0)];
if (key === "0:153") payload = [profile, +state.wheel, state.window >> 8, state.window & 255];
reply[3] = payload.length; reply.set(payload, 6);
+ options.mutateReply?.(packet, reply, receiveAttempt++);
// Non-zero byteOffset exercises DataView framing without a report ID.
const buffer = new Uint8Array(70); buffer.set(reply, 3);
return new DataView(buffer.buffer, 3, 64);
@@ -94,8 +101,123 @@ for (const [pid, target] of [[0x0032, 0], [0x0033, 2], [0x0054, 0], [0x0084, 2]]
middleDebounceMs: 1, wheelGuard: { enabled: false, windowMs: 100 },
});
});
+
+ test(`LunaFury 0x${pid.toString(16)} waits past stale button replies`, async () => {
+ const receives: number[][] = [];
+ const { device, sent, state } = fakeLunaFury(pid, { mutateReply(packet, reply, attempt) {
+ if (packet[4] !== 0 || packet[5] !== 0x92) return;
+ const button = packet[8]!;
+ receives.push([button, attempt]);
+ if (attempt === 0 && button > 1) {
+ reply[8] = button - 1;
+ reply[10] = state.latency[button - 1]!;
+ }
+ } });
+ state.latency[2] = 8;
+ const status = await new LamzuHidClient(device).readStatus();
+ assert.deepEqual(status.lunafury, {
+ lightningMode: 1, leftDebounceMs: 3, rightDebounceMs: 8,
+ middleDebounceMs: 10, wheelGuard: { enabled: true, windowMs: 200 },
+ });
+ assert.deepEqual(receives, [[1, 0], [2, 0], [2, 1], [3, 0], [3, 1]]);
+ assert.equal(sent.filter((packet) => packet[5] === 0x92).length, 3,
+ "ignore stale replies without resending the request");
+ });
+
+ test(`LunaFury 0x${pid.toString(16)} waits past stale profile replies`, async () => {
+ const receives: string[] = [];
+ const { device } = fakeLunaFury(pid, { mutateReply(packet, reply, attempt) {
+ const key = `${packet[4]}:${packet[5]}`;
+ if (!EXTRA_READS.has(key)) return;
+ receives.push(`${key}:${attempt}`);
+ if (attempt !== 0) return;
+ reply[6] = packet[6]! - 1;
+ if (key === "0:146") reply[10] = 1;
+ else reply[7] = 0;
+ if (key === "0:153") { reply[8] = 0; reply[9] = 20; }
+ } });
+ const status = await new LamzuHidClient(device).readStatus();
+ assert.equal(status.angleTuning, -12);
+ assert.deepEqual(status.lunafury, {
+ lightningMode: 1, leftDebounceMs: 3, rightDebounceMs: 7,
+ middleDebounceMs: 10, wheelGuard: { enabled: true, windowMs: 200 },
+ });
+ assert.equal(receives.length, 12);
+ assert.equal(receives.filter((key) => key.endsWith(":1")).length, 6);
+ });
}
+test("LunaFury readback waits past stale profiles and button selectors", async () => {
+ let inject = false;
+ let readbacks = 0;
+ const { device, sent } = fakeLunaFury(0x0033, { mutateReply(packet, reply, attempt) {
+ if (!inject || !EXTRA_READS.has(`${packet[4]}:${packet[5]}`)) return;
+ readbacks += 1;
+ if (attempt === 0) reply[6] = packet[6]! - 1;
+ else if (attempt === 1 && packet[5] === 0x92) reply[8] = packet[8] === 1 ? 2 : 1;
+ } });
+ const client = new LamzuHidClient(device);
+ await client.readStatus();
+ inject = true;
+ const before = sent.length;
+ assert.equal(await client.setAngleTuning(15), 15);
+ assert.equal(await client.setLunaFuryLightningMode(2), 2);
+ assert.equal(await client.setLunaFuryButtonDebounce("left", 4), 4);
+ assert.equal(await client.setLunaFuryButtonDebounce("right", 8), 8);
+ assert.equal(await client.setLunaFuryButtonDebounce("middle", 12), 12);
+ assert.deepEqual(await client.setLunaFuryWheelGuard({ enabled: false, windowMs: 100 }),
+ { enabled: false, windowMs: 100 });
+ assert.equal(readbacks, 15);
+ assert.equal(sent.slice(before).filter((packet) => EXTRA_READS.has(`${packet[4]}:${packet[5]}`)).length, 6);
+ const status = await client.readStatus(true);
+ assert.equal(status.angleTuning, 15);
+ assert.deepEqual(status.lunafury, {
+ lightningMode: 2, leftDebounceMs: 4, rightDebounceMs: 8,
+ middleDebounceMs: 12, wheelGuard: { enabled: false, windowMs: 100 },
+ });
+});
+
+test("LunaFury stale readback cannot confirm an ignored write", async () => {
+ let inject = false;
+ const { device } = fakeLunaFury(0x0033, { ignoreWrites: true, mutateReply(packet, reply, attempt) {
+ if (!inject || packet[4] !== 0 || packet[5] !== 0x92 || attempt !== 0) return;
+ reply[6] = packet[6]! - 1;
+ reply[10] = 5;
+ } });
+ const client = new LamzuHidClient(device);
+ await client.readStatus();
+ inject = true;
+ await assert.rejects(client.setLunaFuryButtonDebounce("left", 5), /did not confirm/);
+ assert.equal((await client.readStatus(true)).lunafury?.leftDebounceMs, 3);
+});
+
+test("LunaFury exhausted stale replies stay absent and bounded", async () => {
+ let receives = 0;
+ const { device } = fakeLunaFury(0x0033, { mutateReply(packet, reply) {
+ if (!EXTRA_READS.has(`${packet[4]}:${packet[5]}`)) return;
+ receives += 1;
+ reply[6] = packet[6]! - 1;
+ } });
+ const status = await new LamzuHidClient(device).readStatus();
+ assert.equal(status.dpi, 800);
+ assert.equal(status.batteryPercent, 80);
+ assert.equal(status.angleTuning, null);
+ assert.ok(Object.values(status.lunafury!).every((value) => value === undefined));
+ assert.equal(receives, 12, "each optional read keeps its two-attempt limit");
+});
+
+test("other CompX profile replies retain their existing matching rules", async () => {
+ const { device } = fakeLunaFury(0x006a, { mutateReply(packet, reply) {
+ if (packet[5] === 0x88 || (packet[4] === 1 && packet[5] === 0x80)) reply[6] = 1;
+ } });
+ const status = await new LamzuHidClient(device).readStatus();
+ assert.equal(status.brand, "CRDRAKO");
+ assert.equal(status.activeProfile, 3);
+ assert.equal(status.debounceMs, 0);
+ assert.equal(status.pollingRateHz, 1000);
+ assert.equal(status.lunafury, undefined);
+});
+
test("unsupported LunaFury controls do not break the base device status", async () => {
const { device } = fakeLunaFury(0x0033, { unsupported: true });
const status = await new LamzuHidClient(device).readStatus();