+ );
+}
diff --git a/src/app/cards/LunaFuryKeyboardBinding.tsx b/src/app/cards/LunaFuryKeyboardBinding.tsx
new file mode 100644
index 00000000..0fa8320c
--- /dev/null
+++ b/src/app/cards/LunaFuryKeyboardBinding.tsx
@@ -0,0 +1,78 @@
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import { lunafuryKeyboardAction, lunafuryParseKeyboardAction } from "@openmouse/protocol/lamzu";
+import { LUNAFURY_KEY_CHOICES, LUNAFURY_MODIFIERS, LUNAFURY_MODIFIER_CODES,
+ lunaFuryKeyboardLabel, lunaFuryKeyAction } from "../../device/lunafury-buttons.ts";
+import { applyDeviceButtonMapping } from "../../device/controller.ts";
+import type { InterfaceLocale } from "../../interface-preferences.ts";
+import { t } from "../../i18n.ts";
+
+export function LunaFuryKeyboardBinding({ button, assigned, profile, disabled, locale }: {
+ button: string; assigned: string; profile: number | null | undefined; disabled: boolean; locale: InterfaceLocale;
+}): ReactNode {
+ const initial = lunafuryParseKeyboardAction(assigned);
+ const [usage, setUsage] = useState(initial?.usage ?? 4);
+ const [modifiers, setModifiers] = useState(initial?.modifiers ?? 0);
+ const [unsupported, setUnsupported] = useState(false);
+ const details = useRef(null);
+ const held = useRef(0);
+ useEffect(() => {
+ const key = lunafuryParseKeyboardAction(assigned);
+ setUsage(key?.usage ?? 4); setModifiers(key?.modifiers ?? 0); setUnsupported(false);
+ held.current = 0;
+ if (details.current) details.current.open = false;
+ }, [assigned, profile]);
+ const action = lunafuryKeyboardAction(modifiers, usage);
+ return (
+
+ {t(locale, "prof.keyboardShortcut")}
+
+
+ );
+}
diff --git a/src/app/cards/availability.test.ts b/src/app/cards/availability.test.ts
index 4835206b..6fa1b222 100644
--- a/src/app/cards/availability.test.ts
+++ b/src/app/cards/availability.test.ts
@@ -74,6 +74,13 @@ test("LunaFury-specific cards are not offered to another CompX brand", () => {
assert.equal(has.lunafuryWheelGuard, false);
});
+test("the bottom-button card requires a successful LunaFury read", () => {
+ assert.equal(cardAvailability(snapshot({ status: { brand: "LunaFury", lunafury: { bottomButtonMode: 1 } } })).lunafuryBottomButton, true);
+ assert.equal(cardAvailability(snapshot({ status: { brand: "LunaFury", lunafury: {} } })).lunafuryBottomButton, false);
+ assert.equal(cardAvailability(snapshot({ status: { brand: "Attack Shark", lunafury: { bottomButtonMode: 2 } } })).lunafuryBottomButton, false);
+ assert.equal(cardAvailability(snapshot({ status: null })).lunafuryBottomButton, 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.
diff --git a/src/app/cards/availability.ts b/src/app/cards/availability.ts
index 204561b9..e8c94724 100644
--- a/src/app/cards/availability.ts
+++ b/src/app/cards/availability.ts
@@ -25,6 +25,8 @@ export interface CardAvailability {
incott: boolean;
lunafuryButtons: boolean;
lunafuryWheelGuard: boolean;
+ lunafuryReceiverLight: boolean;
+ lunafuryBottomButton: boolean;
dongleLed: boolean;
eggFilter: boolean;
eggSpdt: boolean;
@@ -71,6 +73,8 @@ const NOTHING: CardAvailability = {
incott: false,
lunafuryButtons: false,
lunafuryWheelGuard: false,
+ lunafuryReceiverLight: false,
+ lunafuryBottomButton: false,
dongleLed: false,
eggFilter: false,
eggSpdt: false,
@@ -183,8 +187,11 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
lunafuryButtons: host && status.brand === "LunaFury" && (
status.lunafury?.lightningMode != null || status.lunafury?.leftDebounceMs != null
|| status.lunafury?.rightDebounceMs != null || status.lunafury?.middleDebounceMs != null
+ || status.lunafury?.buttonPollingRateHz != null
),
lunafuryWheelGuard: host && status.brand === "LunaFury" && status.lunafury?.wheelGuard != null,
+ lunafuryReceiverLight: host && status.brand === "LunaFury" && status.lunafury?.receiverLightMode != null,
+ lunafuryBottomButton: host && status.brand === "LunaFury" && status.lunafury?.bottomButtonMode != 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/workspace-tabs.test.ts b/src/app/workspace-tabs.test.ts
index 60e44f09..67a61753 100644
--- a/src/app/workspace-tabs.test.ts
+++ b/src/app/workspace-tabs.test.ts
@@ -9,7 +9,7 @@ function availability(enabled: Partial): CardAvailability {
}
test("LunaFury response and wheel guard retain the Buttons tab without global debounce", () => {
- for (const field of ["lunafuryButtons", "lunafuryWheelGuard"] as const) {
+ for (const field of ["lunafuryButtons", "lunafuryWheelGuard", "lunafuryBottomButton"] as const) {
assert.deepEqual(availableWorkspaceTabs(true, availability({ [field]: true })), [
"overview", "performance", "buttons", "advanced",
]);
diff --git a/src/app/workspace-tabs.ts b/src/app/workspace-tabs.ts
index d9d31fa7..8b52e3a3 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.magnetic
- || has.ksnakeScroll || has.lunafuryButtons || has.lunafuryWheelGuard;
+ || has.ksnakeScroll || has.lunafuryButtons || has.lunafuryWheelGuard || has.lunafuryBottomButton;
const hasProfiles = has.profiles || has.keychronNapeLayers || has.atkProfile
|| has.onboardProfiles || has.pulsarPro;
return WORKSPACE_TAB_ORDER.filter((tab) => {
diff --git a/src/control-profiles.css b/src/control-profiles.css
index 536e790e..7daa89e7 100644
--- a/src/control-profiles.css
+++ b/src/control-profiles.css
@@ -103,6 +103,34 @@
.shortcut-dialog-actions button.is-primary { border-color:var(--ui-accent);background:var(--ui-accent);color:var(--ui-accent-ink);font-weight:650 }
.shortcut-dialog-actions button:disabled { opacity:.4;cursor:default }
+.control-shell .dpi-card.lunafury-dpi-card.has-lift-off { grid-template-columns:minmax(0,1fr) }
+.control-shell .dpi-card.lunafury-dpi-card.has-lift-off > .dpi-card-lift { border-inline-start:0;border-top:1px solid var(--line-strong);padding:1rem 0 0 }
+.lunafury-dpi-editor { display:grid;gap:1rem;min-width:0 }
+.lunafury-dpi-editor .switch-row { font-size:.875rem }
+.lunafury-dpi-editor .stepper-slider { margin:0 }
+.lunafury-dpi-stage-bar { display:flex;flex-wrap:wrap;align-items:flex-start;gap:1rem }
+.lunafury-dpi-stage-list { display:flex;flex-wrap:wrap;gap:.75rem;flex:0 1 auto;min-width:0 }
+.lunafury-dpi-stage { display:grid;gap:.45rem;width:3.5rem;flex:0 0 3.5rem;min-width:0 }
+.lunafury-dpi-stage.stage-1 { --luna-stage-color:var(--luna-stage-1) }
+.lunafury-dpi-stage.stage-2 { --luna-stage-color:var(--luna-stage-2) }
+.lunafury-dpi-stage.stage-3 { --luna-stage-color:var(--luna-stage-3) }
+.lunafury-dpi-stage.stage-4 { --luna-stage-color:var(--luna-stage-4) }
+.lunafury-dpi-stage.stage-5 { --luna-stage-color:var(--luna-stage-5) }
+.lunafury-dpi-stage.stage-6 { --luna-stage-color:var(--luna-stage-6) }
+.lunafury-dpi-stage-tile { justify-self:center;box-sizing:border-box;width:2.5rem;height:2.5rem;aspect-ratio:1;padding:0;border:2px solid transparent;border-radius:.75rem;background:var(--luna-stage-color);cursor:pointer }
+.lunafury-dpi-stage.is-active .lunafury-dpi-stage-tile { border-color:var(--text-strong) }
+.lunafury-dpi-stage-tile:hover:not(:disabled) { box-shadow:0 0 0 3px var(--hover) }
+.lunafury-dpi-stage-tile:focus-visible { outline:3px solid var(--info);outline-offset:3px }
+.lunafury-dpi-stage-value { display:flex;flex-direction:column;align-items:stretch;gap:.15rem;min-width:0;color:var(--text-soft);font-size:.875rem }
+.lunafury-dpi-stage-value > span { font-size:.75rem;text-align:center }
+.lunafury-dpi-stage-value input { box-sizing:border-box;width:100%;min-width:0;padding:.4rem .15rem;border:1px solid var(--border);border-radius:.5rem;background:var(--surface-input);color:var(--text-input);font:600 .875rem/1.3 var(--font-mono);text-align:center;appearance:textfield }
+.lunafury-dpi-stage-value input::-webkit-inner-spin-button { appearance:none }
+.lunafury-dpi-stage-value input:focus-visible { outline:2px solid var(--info);outline-offset:1px }
+.lunafury-dpi-count { display:grid;gap:.45rem;flex:0 0 5rem;margin-inline-start:auto;color:var(--text-soft);font-size:.875rem }
+.lunafury-dpi-count .option-menu { width:100% }
+.lunafury-dpi-editor .setting-note { margin:0;font-size:.8125rem }
+.lunafury-dpi-editor :disabled { cursor:not-allowed }
+
/* `.segmented` and friends set display, which outranks the user-agent
`[hidden] { display: none }` rule — so hiding those rows by setting the
hidden property silently did nothing. Make the attribute win everywhere. */
@@ -223,7 +251,9 @@
/* Stage/Single mode switch. A compact pill sitting on the right of the DPI
card's heading text; the active segment inverts like the shared
`.segmented` controls do. */
-.dpi-card .dpi-heading-label { display:flex;align-items:center;gap:.8rem;min-width:0 }
+.dpi-card-main > .setting-heading { flex-wrap:wrap;gap:.75rem }
+.dpi-card .dpi-heading-label { display:flex;align-items:center;gap:.8rem;min-width:0;flex-shrink:0;flex-wrap:wrap;max-width:100% }
+.dpi-card .dpi-heading-label > div:first-child { flex-shrink:0 }
.dpi-view-toggle { display:inline-flex;align-items:center;gap:2px;padding:3px;border:1px solid var(--border);border-radius:999px;background:var(--surface-control) }
.dpi-view-toggle button { padding:.45rem .95rem;border:0;border-radius:999px;background:transparent;color:var(--text-soft);font:600 .75rem/1 var(--font-sans);white-space:nowrap;cursor:pointer;transition:background .15s ease,color .15s ease }
.dpi-view-toggle button:hover { color:var(--text-hover) }
@@ -301,3 +331,39 @@
.preview-launcher-link small { color:var(--ghost);font-family:'JetBrains Mono',monospace;font-size:.55rem }
.preview-launcher-link.is-active { border-color:var(--ui-accent);color:var(--ui-accent) }
.preview-launcher-link.is-active small { color:var(--ui-accent) }
+.luna-key-binding {
+ margin: .25rem .65rem .6rem;
+ color: var(--text-soft);
+ font-size: .875rem;
+}
+.luna-key-binding summary { cursor: pointer; padding: .4rem 0; }
+.luna-key-binding fieldset {
+ display: grid;
+ gap: .65rem;
+ min-width: 0;
+ margin: .35rem 0 0;
+ padding: .8rem;
+ border: 0;
+ border-radius: 12px;
+ background: var(--surface-strong);
+ box-shadow: 0 2px 8px var(--ui-accent-soft);
+}
+.luna-key-binding fieldset > label { display: flex; flex-wrap: wrap; align-items: center; gap: .65rem; }
+.luna-key-binding select, .luna-key-binding button {
+ min-height: 2.5rem;
+ max-width: 100%;
+ padding: .5rem .75rem;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--input);
+ color: var(--text-input);
+ font: inherit;
+}
+.luna-key-binding button { cursor: pointer; }
+.luna-key-binding button:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: 2px; }
+.luna-key-binding button:hover:not(:disabled) { background: var(--surface-hover-soft); }
+.luna-key-binding :disabled { opacity: .6; cursor: not-allowed; }
+.luna-key-modifiers { display: flex; flex-wrap: wrap; gap: .6rem 1rem; }
+.luna-key-modifiers label { display: flex; align-items: center; gap: .35rem; }
+.luna-key-actions { display: flex; flex-wrap: wrap; gap: .6rem; }
+.luna-key-binding output { overflow-wrap: anywhere; color: var(--text-hover); }
diff --git a/src/control.css b/src/control.css
index 8e4bbcfb..498f2785 100644
--- a/src/control.css
+++ b/src/control.css
@@ -69,6 +69,13 @@
background: var(--ink);
font-family: var(--font-sans);
font-synthesis: none;
+ /* Fixed vendor stage identifiers; no color-editing controls or HID writes. */
+ --luna-stage-1: #9e11f5;
+ --luna-stage-2: #07c160;
+ --luna-stage-3: #2b61fe;
+ --luna-stage-4: #24d5e2;
+ --luna-stage-5: #fdc34c;
+ --luna-stage-6: #ff00ff;
/* Per-stage DPI ramp used by the M2-NEX editor (has-m2nex-stage-colors). */
--stage-color-1: #ff5a5a;
--stage-color-2: #4ade80;
@@ -5086,6 +5093,148 @@ body { height: 100vh; overflow: hidden; }
}
}
+/* LunaFury Buttons: remapping and a continuous adjustment panel. Only the
+ outer grid changes columns; its setting sections always stay stacked. */
+.luna-buttons-layout {
+ grid-template-columns: 240px minmax(0, 1fr);
+ container: luna-buttons / inline-size;
+}
+.control-shell .luna-button-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 1rem;
+ align-items: start;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+}
+.luna-button-extras {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 0;
+ align-items: start;
+ min-width: 0;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--surface-panel);
+ box-shadow: 0 8px 24px -12px color-mix(in srgb, var(--text-strong) 18%, transparent);
+}
+.control-shell .luna-button-grid > .setting-card {
+ min-width: 0;
+ margin: 0;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--surface-panel);
+ box-shadow: 0 8px 24px -12px color-mix(in srgb, var(--text-strong) 18%, transparent);
+}
+.control-shell .luna-button-extras > .setting-card {
+ min-width: 0;
+ margin: 0;
+ padding: 1.25rem;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ box-shadow: none;
+}
+.control-shell .luna-button-extras > .setting-card + .setting-card {
+ border-top: 1px solid var(--line-strong);
+}
+.control-shell .luna-button-extras > .setting-card:first-child { border-start-start-radius: 16px; border-start-end-radius: 16px; }
+.control-shell .luna-button-extras > .setting-card:last-child { border-end-start-radius: 16px; border-end-end-radius: 16px; }
+.luna-button-extras > .setting-card + .setting-card .setting-heading p { display: none; }
+.control-shell .luna-button-grid [data-pending-key].is-staged {
+ box-shadow: inset 0 0 0 1px var(--ui-accent);
+}
+#button-mapping-settings.luna-button-mapping { max-width: none; container: luna-remap / inline-size; }
+.control-shell .luna-button-grid .setting-heading h2 { color: var(--text-hover); }
+.luna-button-extras #lunafury-wheel-guard {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+}
+.luna-button-extras #lunafury-wheel-guard .setting-heading { margin: 0; }
+.luna-button-extras #lunafury-wheel-guard .switch-row > span { display: none; }
+.luna-button-extras #lunafury-wheel-guard .stepper-slider { grid-column: 1 / -1; }
+.luna-button-extras #lunafury-bottom-button .option-menu-list {
+ top: auto;
+ bottom: calc(100% + .25rem);
+}
+.control-shell .luna-power-card .setting-heading h2 { color: var(--text-hover); }
+.luna-button-mapping .button-map-list { gap: .5rem; }
+.luna-map-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: .5rem;
+ min-width: 0;
+ padding: .6rem .7rem;
+ border-radius: 12px;
+ background: var(--surface-control);
+}
+.luna-map-item:hover { background: var(--surface-hover-soft); }
+.luna-map-item:focus-within { box-shadow: inset 0 0 0 1px var(--ui-accent); }
+.luna-map-item .button-map-row {
+ grid-template-columns: minmax(6rem, 7.5rem) minmax(0, 1fr);
+ gap: .5rem;
+ min-height: 2.5rem;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+ font-size: .875rem;
+}
+.luna-map-item .button-map-connector { display: none; }
+.luna-map-item .button-map-select-wrap select { min-height: 2.5rem; font-size: .875rem; }
+.luna-map-item .luna-key-binding { margin: 0; font-size: .8125rem; }
+.luna-map-item .luna-key-binding summary {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: .5rem;
+ min-height: 2.5rem;
+ padding: .3rem .55rem;
+ box-sizing: border-box;
+ border-radius: 8px;
+ background: var(--surface-panel-strong);
+ list-style: none;
+}
+.luna-map-item .luna-key-binding summary::after {
+ width: .35rem;
+ height: .35rem;
+ border-right: 1px solid currentColor;
+ border-bottom: 1px solid currentColor;
+ content: "";
+ transform: rotate(45deg) translateY(-2px);
+}
+.luna-map-item .luna-key-binding summary:hover { color: var(--text-hover); }
+.luna-map-item .luna-key-binding summary:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: 2px; }
+.luna-map-item:has(.luna-key-binding[open]) { grid-template-columns: minmax(0, 1fr); }
+.luna-map-item .luna-key-binding[open] { grid-column: 1 / -1; }
+.luna-map-item .luna-key-binding[open] summary { width: fit-content; margin-inline-start: auto; }
+.luna-map-item .luna-key-binding[open] summary::after { transform: rotate(225deg) translateY(-2px); }
+.luna-button-mapping .field-note { margin: 1rem 0 0; font-size: .75rem; line-height: 1.6; color: var(--text-soft); }
+@container luna-buttons (min-width: 1120px) {
+ .control-shell .luna-button-grid { grid-template-columns: minmax(0, 1.2fr) minmax(22rem, 1fr); }
+}
+@media (max-width: 980px) {
+ .luna-buttons-layout { grid-template-columns: minmax(0, 1fr); }
+ .luna-buttons-layout .showcase-sidebar {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: .25rem 1rem;
+ text-align: start;
+ justify-items: start;
+ }
+ .luna-buttons-layout .showcase-sidebar-visual { grid-row: 1 / 3; width: 80px; }
+ .luna-buttons-layout .showcase-sidebar-image { max-width: 80px; max-height: 151px; }
+ .luna-buttons-layout .showcase-sidebar-info { align-self: end; }
+ .luna-buttons-layout .showcase-sidebar-status { align-self: start; }
+}
+@container luna-remap (max-width: 400px) {
+ .luna-map-item { grid-template-columns: minmax(0, 1fr); }
+ .luna-map-item .luna-key-binding summary { width: fit-content; margin-inline-start: auto; }
+}
+
/* Right-to-left (Arabic). The stylesheet uses CSS logical properties for
direction-sensitive spacing, borders and offsets, so most of the layout
mirrors on its own once is set. The rules below cover the
diff --git a/src/device/controller.ts b/src/device/controller.ts
index 1ae066c1..3be21659 100644
--- a/src/device/controller.ts
+++ b/src/device/controller.ts
@@ -14,6 +14,7 @@ import {
import { closestDpiOption } from "../dpi-presets";
import { lunafurySettingLabel, lunafurySettingPriority } from "./lunafury-labels.ts";
import { lunaFuryLightningPriority, stageLunaFuryProfile } from "./lunafury-profile.ts";
+import { canEditLunaFuryMapping, pickLunaFuryMappings } from "./lunafury-buttons.ts";
import { formatHex, hidTraffic, isMark, markHidActivity, startHidCapture, type HidTrafficEntry } from "../hid-diagnostics";
import {
clearPendingChanges,
@@ -1093,6 +1094,11 @@ function stageChange(change: PendingChange): void {
setReadStatus(st("ctl.waitFlash"));
return;
}
+ if (latestDeviceStatus?.brand === "LunaFury" && change.key !== "onboard-profile" && isPendingChange("onboard-profile")) {
+ setReadStatus(st("luna.profileSwitchPending"));
+ emit();
+ return;
+ }
if (gameProfileDraft) {
const fields = gameProfileFieldsOf(change);
if (!fields) {
@@ -1491,9 +1497,15 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void {
if (stageEditor.countEditable === true && s.dpiStages.length !== status.dpiStages.length) {
applyDpiStageCount(s.dpiStages.length);
}
- s.dpiStages.forEach((value, stage) => applyDpiStageValue(stage, value));
+ s.dpiStages.forEach((value, stage) => {
+ if (status.brand === "LunaFury" && s.dpiStagesY?.[stage] != null) applyLunaFuryDpiStageAxes(stage, value, s.dpiStagesY[stage]);
+ else applyDpiStageValue(stage, value);
+ });
+ } else if (status.brand === "LunaFury" && s.dpiStagesY?.length) {
+ s.dpiStagesY.forEach((value, stage) => applyLunaFuryDpiStageAxes(stage, status.dpiStages![stage], value));
} else if (s.dpi != null) {
- applyDpiValue(s.dpi);
+ if (status.brand === "LunaFury" && s.dpiY != null) applyLunaFuryDpiStageAxes(status.activeDpiStage ?? 0, s.dpi, s.dpiY);
+ else applyDpiValue(s.dpi);
}
s.dpiStageColors?.forEach((color, stage) => applyDpiStageColor(stage, color));
if (s.activeDpiStage != null) applyActiveDpiStage(s.activeDpiStage);
@@ -1526,6 +1538,8 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void {
lightning: applyLunaFuryLightningMode,
button: applyLunaFuryButtonDebounce,
wheel: applyLunaFuryWheelGuard,
+ buttonRate: applyLunaFuryButtonPollingRate,
+ separateAxes: applyLunaFurySeparateDpiAxes,
});
if (typeof s.wheelAcceleration === "boolean") applyProSetting("wheelAcceleration", s.wheelAcceleration);
@@ -1560,7 +1574,8 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void {
s.eggButtonMappings?.forEach((mapping, button) =>
applyEggButtonMapping(button as EggButtonIndex, mapping as EggButtonMapping));
- for (const [button, action] of Object.entries(s.buttonMappings ?? {})) applyDeviceButtonMapping(button, action);
+ const mappings = status.brand === "LunaFury" ? pickLunaFuryMappings(status, s.buttonMappings) : s.buttonMappings ?? {};
+ for (const [button, action] of Object.entries(mappings)) applyDeviceButtonMapping(button, action);
for (const [control, mapping] of Object.entries(s.razerButtonMappings ?? {})) {
applyRazerButtonMapping(control as RazerButtonControl, mapping as RazerButtonMapping);
}
@@ -2969,6 +2984,13 @@ export function cancelCustomDpi(dpi?: number): void {
export function applyDpiValue(dpi: number): boolean {
if (!hasActiveClient() || !dpiOptions.includes(dpi)) return false;
+ const staged = latestDeviceStatus ? withPendingChanges(latestDeviceStatus) : null;
+ if (staged?.brand === "LunaFury" && staged.dpiStagesY && staged.activeDpiStage != null) {
+ const stage = staged.activeDpiStage;
+ const y = staged.lunafury?.separateDpiAxes !== false ? staged.dpiStagesY[stage] ?? dpi : dpi;
+ applyLunaFuryDpiStageAxes(stage, dpi, y);
+ return true;
+ }
stageChange({
key: "dpi",
label: `DPI ${dpi.toLocaleString()}`,
@@ -2982,6 +3004,7 @@ export function applyDpiValue(dpi: number): boolean {
status.dpiStages = status.dpiStages.map((value, index) => (
index === status.activeDpiStage ? dpi : value
));
+ if (status.dpiStagesY) status.dpiStagesY = status.dpiStagesY.map((value, index) => index === status.activeDpiStage ? dpi : value);
}
},
apply: async () => {
@@ -3016,6 +3039,11 @@ export function applyDpiStageCount(count: number): void {
status.dpiStages = count <= current.length
? current.slice(0, count)
: [...current, ...Array.from({ length: count - current.length }, () => current.at(-1) ?? status.dpi)];
+ if (status.dpiStagesY) {
+ const ys = status.dpiStagesY.slice();
+ status.dpiStagesY = count <= ys.length ? ys.slice(0, count)
+ : [...ys, ...Array.from({ length: count - ys.length }, () => ys.at(-1) ?? status.dpiY ?? status.dpi)];
+ }
if (status.dpiStageColors) {
status.dpiStageColors = count <= status.dpiStageColors.length
? status.dpiStageColors.slice(0, count)
@@ -3027,6 +3055,7 @@ export function applyDpiStageCount(count: number): void {
if ((status.activeDpiStage ?? 0) >= count) {
status.activeDpiStage = count - 1;
status.dpi = status.dpiStages[count - 1] ?? status.dpi;
+ if (status.dpiStagesY) status.dpiY = status.dpiStagesY[count - 1];
}
},
apply: async () => {
@@ -3068,6 +3097,7 @@ export function applyActiveDpiStage(stage: number): void {
preview: (status) => {
status.activeDpiStage = stage;
status.dpi = status.dpiStages?.[stage] ?? status.dpi;
+ if (status.dpiStagesY) status.dpiY = status.dpiStagesY[stage];
},
apply: async () => {
await requireClientMethod("setActiveDpiStage", "DPI stage").setActiveDpiStage(stage);
@@ -3086,6 +3116,11 @@ export function applyDpiStageValue(stage: number, rawDpi: number): void {
emit();
return;
}
+ if (stagedStatus?.brand === "LunaFury" && stagedStatus.dpiStagesY) {
+ const y = stagedStatus.lunafury?.separateDpiAxes !== false ? stagedStatus.dpiStagesY[stage] ?? dpi : dpi;
+ applyLunaFuryDpiStageAxes(stage, dpi, y);
+ return;
+ }
stageChange({
key: `dpi-stage-${stage}`,
label: `Stage ${stage + 1} · ${dpi.toLocaleString()} DPI`,
@@ -4957,6 +4992,8 @@ export function applyPowerMode(mode: string): void {
* above, which reassigns a Logitech control by id.
*/
export function applyDeviceButtonMapping(button: string, action: string): void {
+ if (latestDeviceStatus?.brand === "LunaFury"
+ && !canEditLunaFuryMapping(withPendingChanges(latestDeviceStatus), button, action)) return;
stageChange({
key: `button-${button}`,
label: `${button}: ${action}`,
@@ -5026,6 +5063,14 @@ export function resetKsnakeButtonMappings(): void {
* rather than previewing a value that is about to be replaced wholesale.
*/
export function applyProfileSelection(profile: number): void {
+ if (latestDeviceStatus?.brand === "LunaFury") {
+ if (!Number.isInteger(profile) || profile < 1 || profile > (latestDeviceStatus.profileCount ?? 0)) return;
+ if (blockedByGameProfileDraft()) return;
+ if (hasPendingChanges()) {
+ if (!window.confirm(st("luna.discardBeforeSwitch"))) return;
+ clearPendingChanges();
+ }
+ }
stageChange({
key: "onboard-profile",
label: st("adv.profileOpt", { n: profile }),
@@ -5130,6 +5175,60 @@ export function applyIncottFireKey(times: number, intervalMs: number): void {
});
}
+export function applyLunaFuryButtonPollingRate(hertz: 1000 | 2000 | 4000 | 8000): void {
+ const settings = latestDeviceStatus?.lunafury;
+ if (settings?.buttonPollingRateHz == null || !settings.supportedButtonPollingRates?.includes(hertz)) return;
+ stageChange({
+ key: "lunafury-button-rate",
+ label: `${st("luna.buttonPollingRate")} · ${hertz} Hz`,
+ command: `Set button polling rate to ${hertz} Hz`,
+ progress: `Setting button polling rate to ${hertz} Hz…`,
+ preview: (status) => { if (status.lunafury) status.lunafury.buttonPollingRateHz = hertz; },
+ apply: () => callClientMethod("setLunaFuryButtonPollingRate", "button polling rate", hertz),
+ });
+}
+
+export function applyLunaFurySeparateDpiAxes(enabled: boolean): void {
+ if (latestDeviceStatus?.lunafury?.separateDpiAxes == null) return;
+ stageChange({
+ key: "lunafury-dpi-axes",
+ priority: enabled ? -5 : 5,
+ label: `${st("luna.separateAxes")} · ${st(enabled ? "common.on" : "common.off")}`,
+ command: `Set separate DPI axes to ${enabled}`,
+ progress: "Setting DPI axis mode…",
+ preview: (status) => { if (status.lunafury) status.lunafury.separateDpiAxes = enabled; },
+ apply: () => callClientMethod("setLunaFurySeparateDpiAxes", "DPI axis mode", enabled),
+ });
+}
+
+export function applyLunaFuryDpiStageAxes(stage: number, x: number, y: number): void {
+ const status = latestDeviceStatus ? withPendingChanges(latestDeviceStatus) : null;
+ if (status?.brand !== "LunaFury" || !status.dpiStagesY || !status.dpiStages ||
+ !Number.isInteger(stage) || stage < 0 || stage >= status.dpiStages.length ||
+ !dpiOptions.includes(x) || !dpiOptions.includes(y)) return;
+ stageChange({
+ key: `dpi-stage-${stage}`,
+ label: `Stage ${stage + 1} · X ${x} / Y ${y} DPI`,
+ command: `Set DPI stage ${stage + 1} to X ${x} / Y ${y}`,
+ progress: `Setting DPI stage ${stage + 1}…`,
+ preview: (target) => {
+ const xs = target.dpiStages?.slice() ?? [];
+ const ys = target.dpiStagesY?.slice() ?? [];
+ while (xs.length <= stage) xs.push(xs.at(-1) ?? target.dpi);
+ while (ys.length <= stage) ys.push(ys.at(-1) ?? target.dpiY ?? target.dpi);
+ xs[stage] = x; ys[stage] = y;
+ target.dpiStages = xs; target.dpiStagesY = ys;
+ if (target.activeDpiStage === stage) { target.dpi = x; target.dpiY = y; }
+ },
+ apply: async () => {
+ const client = requireClientMethod("setLunaFuryDpiStageAxes", "DPI stage axes") as unknown as {
+ setLunaFuryDpiStageAxes(stage: number, x: number, y: number): Promise;
+ };
+ await client.setLunaFuryDpiStageAxes(stage, x, y);
+ },
+ });
+}
+
export function applyLunaFuryLightningMode(mode: 0 | 1 | 2): void {
if (latestDeviceStatus?.lunafury?.lightningMode == null) return;
stageChange({
@@ -5161,6 +5260,34 @@ export function applyLunaFuryButtonDebounce(button: "left" | "right" | "middle",
});
}
+export function applyLunaFuryBottomButtonMode(mode: 1 | 2): void {
+ if (gameProfileDraft || latestDeviceStatus?.brand !== "LunaFury"
+ || latestDeviceStatus.lunafury?.bottomButtonMode == null || (mode !== 1 && mode !== 2)) return;
+ const label = st(mode === 1 ? "luna.bottomPower" : "luna.bottomPowerDpi");
+ stageChange({
+ key: "lunafury-bottom-button",
+ label: `${st("luna.bottomButton")} · ${label}`,
+ command: `Set bottom-button mode to ${mode}`,
+ progress: "Setting bottom-button mode…",
+ preview: (status) => { if (status.lunafury) status.lunafury.bottomButtonMode = mode; },
+ apply: () => callClientMethod("setLunaFuryBottomButtonMode", "bottom-button behavior", mode),
+ });
+}
+
+export function applyLunaFuryReceiverLightMode(mode: 0 | 2 | 6 | 11): void {
+ if (gameProfileDraft || latestDeviceStatus?.brand !== "LunaFury"
+ || latestDeviceStatus.lunafury?.receiverLightMode == null || ![0, 2, 6, 11].includes(mode)) return;
+ const label = st(mode === 2 ? "luna.lightMixed" : mode === 6 ? "luna.lightBattery" : mode === 11 ? "luna.lightDpi" : "common.off");
+ stageChange({
+ key: "lunafury-receiver-light",
+ label: `${st("luna.receiverLight")} · ${label}`,
+ command: `Set receiver light mode to ${mode}`,
+ progress: "Setting receiver light mode…",
+ preview: (status) => { if (status.lunafury) status.lunafury.receiverLightMode = mode; },
+ apply: () => callClientMethod("setLunaFuryReceiverLightMode", "receiver lighting", mode),
+ });
+}
+
export function applyLunaFuryWheelGuard(guard: { enabled: boolean; windowMs: number }): void {
if (!latestDeviceStatus?.lunafury?.wheelGuard) return;
stageChange({
@@ -5439,6 +5566,7 @@ async function showFixturePreview(name: PreviewMode): Promise {
// Populate brand capabilities so preview cards that gate on capabilities still render.
capabilities = readCapabilities();
if (fixture.status.magneticButtons) active = magneticPreviewClient(fixture.status);
+ if (name === "lunafury") capabilities = { ...capabilities, sleepOptions: [0, ...Array.from({ length: 30 }, (_, i) => (i + 1) * 60)] };
applyStatus(fixture.status);
if (name === "nape-pro") {
const layer = fixture.status.napeLayer ?? 1;
diff --git a/src/device/game-profile-snapshot.test.ts b/src/device/game-profile-snapshot.test.ts
index 4844d5fe..a0cd9f32 100644
--- a/src/device/game-profile-snapshot.test.ts
+++ b/src/device/game-profile-snapshot.test.ts
@@ -32,6 +32,18 @@ test("nested values are compared by content", () => {
assert.deepEqual(snapshotDiff(before, status({ dpiStages: [400, 1600] })), { dpiStages: [400, 1600] });
});
+test("LunaFury game profiles capture and restore Y stages independently", () => {
+ const before = status({ brand: "LunaFury", dpiStages: [400, 800], dpiStagesY: [800, 1600],
+ lunafury: { separateDpiAxes: false, buttonPollingRateHz: 1000, supportedButtonPollingRates: [1000, 2000, 4000, 8000] } });
+ const after = { ...before, dpiStagesY: [1200, 1600], lunafury: { ...before.lunafury, separateDpiAxes: true } };
+ const saved = snapshotDiff(before, after);
+ assert.deepEqual(saved, { dpiStagesY: [1200, 1600], lunafury: { separateDpiAxes: true } });
+ assert.deepEqual(pickSnapshot(before, saved), { dpiStagesY: [800, 1600], lunafury: { separateDpiAxes: false } });
+ assert.deepEqual(editableProfileFields(before, after), ["dpiStagesY", "lunafury.separateDpiAxes"]);
+ after.dpiStagesY[0] = 2400;
+ assert.deepEqual(saved.dpiStagesY, [1200, 1600]);
+});
+
test("picking the prior values covers exactly the fields the profile sets", () => {
const live = status({ dpi: 800, gamingSurfaceMode: "Auto" });
assert.deepEqual(pickSnapshot(live, { dpi: 1600, gamingSurfaceMode: "On" }), { dpi: 800, gamingSurfaceMode: "Auto" });
@@ -114,3 +126,20 @@ test("switching Games profiles restores previous controls and retains every orig
assert.deepEqual(firstPrior, { lunafury: { lightningMode: 0, leftDebounceMs: 3 } });
assert.deepEqual(mergeSnapshots({ dpi: 800 }, { pollingRateHz: 2000, dpi: 1600 }), { dpi: 1600, pollingRateHz: 2000 });
});
+test("LunaFury Games saves and restores only touched writable buttons", () => {
+ const before = status({ brand: "LunaFury", buttonMappings: { Left: "Left Click", Right: "Right Click", Middle: "Middle Click",
+ Back: "Keyboard:16:6", Forward: "Macro:16:1" }, fixedButtons: ["Left", "Forward"] });
+ const after = { ...before, buttonMappings: { ...before.buttonMappings, Right: "DPI Cycle" } };
+ const saved = snapshotDiff(before, after);
+ assert.deepEqual(saved, { buttonMappings: { Right: "DPI Cycle" } });
+ assert.deepEqual(pickSnapshot(before, saved), { buttonMappings: { Right: "Right Click" } });
+ assert.deepEqual(editableProfileFields(before, after), ["buttonMappings.Right"]);
+ assert.deepEqual(matchingProfileFields(after, saved), ["buttonMappings.Right"]);
+ assert.deepEqual(draftProfileSnapshot(before, before, ["buttonMappings.Back"]),
+ { buttonMappings: { Back: "Keyboard:16:6" } });
+ assert.deepEqual(draftProfileSnapshot(before, before, ["buttonMappings.Left", "buttonMappings.Forward"]), {});
+ assert.deepEqual(mergeSnapshots({ buttonMappings: { Back: "Back" } }, saved),
+ { buttonMappings: { Back: "Back", Right: "DPI Cycle" } });
+ assert.deepEqual(snapshotDiff(before, { ...before, buttonMappings: { ...before.buttonMappings,
+ Left: "Disabled", Forward: "DPI Up", Middle: "Keyboard:0:0" } }), {});
+});
diff --git a/src/device/game-profile-snapshot.ts b/src/device/game-profile-snapshot.ts
index e99080c4..67e4e7bb 100644
--- a/src/device/game-profile-snapshot.ts
+++ b/src/device/game-profile-snapshot.ts
@@ -1,5 +1,6 @@
import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
import { diffLunaFurySettings, isLunaFuryProfileField, pickLunaFurySettings, sanitizeLunaFurySettings } from "./lunafury-profile.ts";
+import { pickLunaFuryMappings } from "./lunafury-buttons.ts";
/**
* The device settings a per-game profile can carry. A game profile is stored
@@ -17,6 +18,7 @@ export const GAME_PROFILE_FIELDS = [
"dpi",
"dpiY",
"dpiStages",
+ "dpiStagesY",
"activeDpiStage",
"dpiStageColors",
"pollingRateHz",
@@ -108,6 +110,10 @@ export function snapshotDiff(before: MouseStatus, after: MouseStatus): GameProfi
if (field === "lunafury") {
const settings = diffLunaFurySettings(before.lunafury, after.lunafury);
if (Object.keys(settings).length) diff.lunafury = settings;
+ } else if (field === "buttonMappings" && before.brand === "LunaFury") {
+ const mappings = pickLunaFuryMappings(before, after.buttonMappings);
+ for (const button of Object.keys(mappings)) if (before.buttonMappings?.[button] === mappings[button]) delete mappings[button];
+ if (Object.keys(mappings).length) diff.buttonMappings = mappings;
} else if (!same(before[field], after[field])) diff[field] = structuredClone(after[field]);
}
return diff as GameProfileSnapshot;
@@ -118,6 +124,11 @@ export function pickSnapshot(status: MouseStatus, snapshot: GameProfileSnapshot)
const picked: Record = {};
for (const field of Object.keys(snapshot)) {
if (!isGameProfileField(field)) continue;
+ if (field === "buttonMappings" && status.brand === "LunaFury") {
+ const mappings = pickLunaFuryMappings(status, snapshot.buttonMappings, true);
+ if (Object.keys(mappings).length) picked.buttonMappings = mappings;
+ continue;
+ }
if (field === "lunafury") {
const settings = pickLunaFurySettings(status.lunafury, snapshot.lunafury);
if (Object.keys(settings).length) picked.lunafury = settings;
@@ -133,6 +144,7 @@ export function pickSnapshot(status: MouseStatus, snapshot: GameProfileSnapshot)
export function mergeSnapshots(base: GameProfileSnapshot, override: GameProfileSnapshot): GameProfileSnapshot {
const merged = { ...base, ...override };
if (base.lunafury || override.lunafury) merged.lunafury = { ...base.lunafury, ...override.lunafury };
+ if (base.buttonMappings || override.buttonMappings) merged.buttonMappings = { ...base.buttonMappings, ...override.buttonMappings };
return merged;
}
@@ -159,12 +171,21 @@ export function sanitizeSnapshot(raw: unknown): GameProfileSnapshot {
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]);
+ return fields.flatMap((field) => {
+ if (field === "lunafury") return Object.keys(diffLunaFurySettings(before.lunafury, after.lunafury)).map((key) => `lunafury.${key}`);
+ if (field === "buttonMappings" && before.brand === "LunaFury") {
+ return Object.keys(snapshotDiff(before, after).buttonMappings ?? {}).map((button) => `buttonMappings.${button}`);
+ }
+ return [field];
+ });
}
export function matchingProfileFields(status: MouseStatus, snapshot: GameProfileSnapshot): string[] {
return Object.entries(snapshot).flatMap(([field, value]) => {
+ if (field === "buttonMappings" && status.brand === "LunaFury") {
+ return Object.entries(pickLunaFuryMappings(status, value))
+ .filter(([button, action]) => status.buttonMappings?.[button] === action).map(([button]) => `buttonMappings.${button}`);
+ }
if (field === "lunafury") {
const target = sanitizeLunaFurySettings(value);
return Object.keys(target).filter((key) => isLunaFuryProfileField(key) && same(status.lunafury?.[key], target[key]))
@@ -177,7 +198,11 @@ export function matchingProfileFields(status: MouseStatus, snapshot: GameProfile
export function draftProfileSnapshot(before: MouseStatus, preview: MouseStatus, touched: Iterable): GameProfileSnapshot {
const snapshot = snapshotDiff(before, preview);
for (const field of touched) {
- if (field.startsWith("lunafury.")) {
+ if (before.brand === "LunaFury" && field.startsWith("buttonMappings.")) {
+ const button = field.slice("buttonMappings.".length);
+ const mappings = pickLunaFuryMappings(before, { [button]: preview.buttonMappings?.[button] });
+ if (Object.keys(mappings).length) snapshot.buttonMappings = { ...snapshot.buttonMappings, ...mappings };
+ } else 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]) };
diff --git a/src/device/lunafury-buttons.test.ts b/src/device/lunafury-buttons.test.ts
new file mode 100644
index 00000000..5e971280
--- /dev/null
+++ b/src/device/lunafury-buttons.test.ts
@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { LUNAFURY_BUTTON_ACTIONS, LUNAFURY_BUTTON_IDS } from "@openmouse/protocol/lamzu";
+import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
+import { ensureLocale, t } from "../i18n.ts";
+import { LOCALES } from "../interface-preferences.ts";
+import { LUNAFURY_KEY_CHOICES, canEditLunaFuryMapping, lunaFuryKeyAction, lunaFuryKeyboardLabel,
+ lunaFuryMappingLabel, pickLunaFuryMappings } from "./lunafury-buttons.ts";
+
+const status = { brand: "LunaFury", buttonMappings: { Left: "Left Click", Right: "Right Click", Middle: "Keyboard:32:4",
+ Back: "Back", Forward: "Macro:16:1" }, fixedButtons: ["Left", "Forward"] } as MouseStatus;
+
+test("LunaFury profile imports cannot target protected, unreadable or unknown assignments", () => {
+ assert.deepEqual(pickLunaFuryMappings(status, { Left: "Disabled", Right: "Keyboard:129:4", Middle: "Disabled",
+ Back: "Unknown:255:", Forward: "DPI Up", Receiver: "Back" }), { Right: "Keyboard:129:4", Middle: "Disabled" });
+ assert.deepEqual(pickLunaFuryMappings(status, { Right: "Mute", Middle: "Disabled" }, true),
+ { Right: "Right Click", Middle: "Keyboard:32:4" });
+ for (const raw of [null, [], "Right", 2, { Right: 4 }, JSON.parse('{"__proto__":"Mute"}')]) {
+ assert.deepEqual(pickLunaFuryMappings(status, raw), {});
+ }
+ assert.equal(canEditLunaFuryMapping({ ...status, brand: "Lamzu" }, "Right", "Mute"), false);
+ assert.equal(canEditLunaFuryMapping({ ...status, buttonMappings: {} }, "Right", "Mute"), false);
+});
+
+test("removed extension actions cannot be imported as button assignments", () => {
+ for (const action of ["Left Double Click", "Browser Refresh", "Open Browser"]) {
+ assert.equal(canEditLunaFuryMapping(status, "Back", action), false);
+ assert.deepEqual(pickLunaFuryMappings(status, { Back: action }), {});
+ }
+});
+
+test("physical keyboard codes and left/right modifiers are preserved", () => {
+ assert.equal(lunaFuryKeyAction("KeyC", 1), "Keyboard:1:6");
+ assert.equal(lunaFuryKeyAction("NumpadEnter", 128), "Keyboard:128:88");
+ assert.equal(lunaFuryKeyAction("Tab", 0), "Keyboard:0:43");
+ assert.equal(lunaFuryKeyAction("MetaLeft", 0), undefined);
+ assert.equal(lunaFuryKeyAction("Unidentified", 0), undefined);
+ assert.equal(lunaFuryKeyAction("__proto__", 0), undefined);
+ assert.equal(lunaFuryKeyboardLabel("Keyboard:129:4"), "Ctrl L + Win R + A");
+ assert.equal(lunaFuryKeyboardLabel("Keyboard:32:40"), "Shift R + Enter");
+ assert.equal(lunaFuryKeyboardLabel("Unknown:255:"), undefined);
+ for (const key of LUNAFURY_KEY_CHOICES) assert.ok(lunaFuryKeyAction(key.code, 255));
+});
+
+test("every locale labels the complete physical-button and action vocabularies", async () => {
+ for (const { code: locale } of LOCALES) {
+ await ensureLocale(locale);
+ assert.equal(t(locale, "luna.physicalButtons").split("|").length, Object.keys(LUNAFURY_BUTTON_IDS).length);
+ assert.equal(t(locale, "luna.buttonActions").split("|").length, LUNAFURY_BUTTON_ACTIONS.length);
+ for (const action of LUNAFURY_BUTTON_ACTIONS) assert.ok(lunaFuryMappingLabel(locale, action));
+ }
+ assert.equal(lunaFuryMappingLabel("zh", "Right", true), "右键");
+ assert.equal(lunaFuryMappingLabel("zh", "DPI Cycle"), "循环切换 DPI");
+ assert.equal(lunaFuryMappingLabel("zh", "Macro:16:1"), t("zh", "prof.customPreserved"));
+});
diff --git a/src/device/lunafury-buttons.ts b/src/device/lunafury-buttons.ts
new file mode 100644
index 00000000..65b0b51c
--- /dev/null
+++ b/src/device/lunafury-buttons.ts
@@ -0,0 +1,68 @@
+import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
+import { LUNAFURY_BUTTON_ACTIONS, LUNAFURY_BUTTON_IDS, lunafuryButtonActionWritable,
+ lunafuryIsMappedButton, lunafuryKeyboardAction, lunafuryParseKeyboardAction } from "@openmouse/protocol/lamzu";
+import { t } from "../i18n.ts";
+import type { InterfaceLocale } from "../interface-preferences.ts";
+
+export function canEditLunaFuryMapping(status: MouseStatus, button: string, action: string): boolean {
+ const current = status.buttonMappings?.[button];
+ return status.brand === "LunaFury" && lunafuryIsMappedButton(button) && button !== "Left"
+ && !status.fixedButtons?.includes(button) && typeof current === "string"
+ && lunafuryButtonActionWritable(current) && lunafuryButtonActionWritable(action);
+}
+
+/** Only already-readable, supported assignments can be applied and restored. */
+export function pickLunaFuryMappings(status: MouseStatus, raw: unknown, prior = false): Record {
+ const picked: Record = {};
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return picked;
+ for (const [button, action] of Object.entries(raw)) {
+ if (typeof action === "string" && canEditLunaFuryMapping(status, button, action)) {
+ picked[button] = prior ? status.buttonMappings![button] : action;
+ }
+ }
+ return picked;
+}
+
+export const LUNAFURY_MODIFIERS = ["Ctrl L", "Shift L", "Alt L", "Win L", "Ctrl R", "Shift R", "Alt R", "Win R"] as const;
+
+// KeyboardEvent.code -> USB HID usage. The physical key, not the text produced
+// by the current keyboard layout, determines a saved mouse assignment.
+const KEY_CODES: Record = {};
+for (let index = 0; index < 26; index++) KEY_CODES[`Key${String.fromCharCode(65 + index)}`] = 4 + index;
+for (let index = 1; index <= 9; index++) KEY_CODES[`Digit${index}`] = 29 + index;
+KEY_CODES.Digit0 = 39;
+for (let index = 1; index <= 12; index++) KEY_CODES[`F${index}`] = 57 + index;
+Object.assign(KEY_CODES, { Enter: 40, Escape: 41, Backspace: 42, Tab: 43, Space: 44,
+ Minus: 45, Equal: 46, BracketLeft: 47, BracketRight: 48, Backslash: 49, Semicolon: 51,
+ Quote: 52, Backquote: 53, Comma: 54, Period: 55, Slash: 56, CapsLock: 57,
+ PrintScreen: 70, ScrollLock: 71, Pause: 72, Insert: 73, Home: 74, PageUp: 75,
+ Delete: 76, End: 77, PageDown: 78, ArrowRight: 79, ArrowLeft: 80, ArrowDown: 81, ArrowUp: 82,
+ NumLock: 83, NumpadDivide: 84, NumpadMultiply: 85, NumpadSubtract: 86, NumpadAdd: 87,
+ NumpadEnter: 88, Numpad0: 98, NumpadDecimal: 99,
+ ControlLeft: 224, ShiftLeft: 225, AltLeft: 226, ControlRight: 228, ShiftRight: 229, AltRight: 230 });
+for (let index = 1; index <= 9; index++) KEY_CODES[`Numpad${index}`] = 88 + index;
+
+export const LUNAFURY_KEY_CHOICES = Object.entries(KEY_CODES).map(([code, usage]) => ({ code, usage }));
+export const LUNAFURY_MODIFIER_CODES: Readonly> = {
+ ControlLeft: 1, ShiftLeft: 2, AltLeft: 4, MetaLeft: 8,
+ ControlRight: 16, ShiftRight: 32, AltRight: 64, MetaRight: 128,
+};
+
+export function lunaFuryKeyAction(code: string, modifiers: number): string | undefined {
+ return Object.hasOwn(KEY_CODES, code) ? lunafuryKeyboardAction(modifiers, KEY_CODES[code]) : undefined;
+}
+
+export function lunaFuryKeyboardLabel(action: string): string | undefined {
+ const key = lunafuryParseKeyboardAction(action);
+ if (!key) return undefined;
+ const name = LUNAFURY_KEY_CHOICES.find((choice) => choice.usage === key.usage)?.code ?? String(key.usage);
+ return [...LUNAFURY_MODIFIERS.filter((_, bit) => key.modifiers & (1 << bit)), name.replace(/^Key|^Digit/, "")].join(" + ");
+}
+
+export function lunaFuryMappingLabel(locale: InterfaceLocale, value: string, physical = false): string {
+ // These two fixed-order vocabularies share the codec's order. Tests check every locale.
+ const names = physical ? Object.keys(LUNAFURY_BUTTON_IDS) : LUNAFURY_BUTTON_ACTIONS;
+ const index = names.indexOf(value);
+ if (index >= 0) return t(locale, physical ? "luna.physicalButtons" : "luna.buttonActions").split("|")[index] ?? value;
+ return lunaFuryKeyboardLabel(value) ?? t(locale, "prof.customPreserved");
+}
diff --git a/src/device/lunafury-dpi.test.ts b/src/device/lunafury-dpi.test.ts
new file mode 100644
index 00000000..4f10531c
--- /dev/null
+++ b/src/device/lunafury-dpi.test.ts
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { lunaFuryDpiEdits, lunaFuryDpiInput } from "./lunafury-dpi.ts";
+
+test("vendor DPI inputs round down to 50 and stay within the sensor range", () => {
+ for (const [raw, expected] of [["", 50], ["0", 50], ["-100", 50], ["849", 800],
+ ["850", 850], ["30001", 30000], ["Infinity", 50], ["bad", 50]] as const) {
+ assert.equal(lunaFuryDpiInput(raw), expected);
+ }
+});
+
+const rows = [
+ { enabled: true, x: 400, y: 800 },
+ { enabled: true, x: 800, y: 1600 },
+ { enabled: true, x: 1600, y: 3200 },
+];
+
+test("removing a middle DPI stage moves its surviving X/Y pairs together", () => {
+ assert.deepEqual(lunaFuryDpiEdits(rows.map((row, index) => ({ ...row, enabled: index !== 1 })),
+ [400, 800, 1600], [800, 1600, 3200], true), [{ stage: 1, x: 1600, y: 3200 }]);
+});
+
+test("removing the first stage retains the Y values of both surviving stages", () => {
+ assert.deepEqual(lunaFuryDpiEdits(rows.map((row, index) => ({ ...row, enabled: index !== 0 })),
+ [400, 800, 1600], [800, 1600, 3200], true), [
+ { stage: 0, x: 800, y: 1600 }, { stage: 1, x: 1600, y: 3200 },
+ ]);
+});
+
+test("editing X preserves independent Y and linked axes use X for both", () => {
+ const changed = [{ ...rows[0], x: 1200 }];
+ assert.deepEqual(lunaFuryDpiEdits(changed, [400], [800], true), [{ stage: 0, x: 1200, y: 800 }]);
+ assert.deepEqual(lunaFuryDpiEdits(changed, [400], [800], false), [{ stage: 0, x: 1200, y: 1200 }]);
+ assert.deepEqual(lunaFuryDpiEdits(rows, [400, 800, 1600], [800, 1600, 3200], true), []);
+});
diff --git a/src/device/lunafury-dpi.ts b/src/device/lunafury-dpi.ts
new file mode 100644
index 00000000..0dd9707a
--- /dev/null
+++ b/src/device/lunafury-dpi.ts
@@ -0,0 +1,18 @@
+/** Official numeric inputs clamp to the sensor range and round down to 50 DPI. */
+export function lunaFuryDpiInput(raw: string, max = 30000): number {
+ const value = Number(raw);
+ return Math.min(max, Math.max(50, Number.isFinite(value) ? Math.floor(value / 50) * 50 : 50));
+}
+
+/** Keep X/Y pairs together when the shared editor removes a middle stage. */
+export function lunaFuryDpiEdits(
+ rows: readonly { enabled: boolean; x: number; y?: number }[],
+ currentX: readonly number[],
+ currentY: readonly number[],
+ separate: boolean,
+): Array<{ stage: number; x: number; y: number }> {
+ return rows.filter((row) => row.enabled).flatMap((row, stage) => {
+ const y = separate ? row.y ?? row.x : row.x;
+ return currentX[stage] === row.x && currentY[stage] === y ? [] : [{ stage, x: row.x, y }];
+ });
+}
diff --git a/src/device/lunafury-profile.test.ts b/src/device/lunafury-profile.test.ts
index 461fa01b..4411319c 100644
--- a/src/device/lunafury-profile.test.ts
+++ b/src/device/lunafury-profile.test.ts
@@ -11,6 +11,19 @@ const settings: LunaFurySettings = {
wheelGuard: { enabled: false, windowMs: 0 },
};
+test("receiver-global lighting is excluded from Games and profile imports", () => {
+ assert.deepEqual(sanitizeLunaFurySettings({ ...settings, receiverLightMode: 6 }), settings);
+ const saved = pickSnapshot({ lunafury: { ...settings, receiverLightMode: 2 }, sleepTimeout: 0 } as MouseStatus,
+ { lunafury: { receiverLightMode: 6 } as LunaFurySettings, sleepTimeout: 300 });
+ assert.deepEqual(saved, { sleepTimeout: 0 });
+});
+
+test("bottom-button behavior is device-only and excluded from Games and imports", () => {
+ assert.deepEqual(sanitizeLunaFurySettings({ ...settings, bottomButtonMode: 1 }), settings);
+ assert.deepEqual(pickSnapshot({ lunafury: { ...settings, bottomButtonMode: 2 } } as MouseStatus,
+ { lunafury: { bottomButtonMode: 1 } as LunaFurySettings }), {});
+});
+
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,
@@ -30,6 +43,31 @@ test("invalid imported values never become protocol writes", () => {
}
});
+test("button polling and axis mode are validated without saving capability metadata", () => {
+ assert.deepEqual(sanitizeLunaFurySettings({ buttonPollingRateHz: 8000, separateDpiAxes: false,
+ supportedButtonPollingRates: [1000, 8000] }), { buttonPollingRateHz: 8000, separateDpiAxes: false });
+ for (const value of [0, 125, 1000.5, "1000", NaN]) {
+ assert.deepEqual(sanitizeLunaFurySettings({ buttonPollingRateHz: value, separateDpiAxes: 1 }), {});
+ }
+});
+
+test("Games use only supported button rates and restore the original axis mode", () => {
+ const calls: unknown[] = [];
+ const actions: LunaFuryProfileActions = {
+ lightning: () => {}, button: () => {}, wheel: () => {},
+ buttonRate: (value) => { calls.push(["rate", value]); },
+ separateAxes: (value) => { calls.push(["axes", value]); },
+ };
+ const current: LunaFurySettings = { buttonPollingRateHz: 1000, supportedButtonPollingRates: [1000], separateDpiAxes: true };
+ stageLunaFuryProfile({ buttonPollingRateHz: 8000, separateDpiAxes: false }, current, actions);
+ assert.deepEqual(calls, [["axes", false]], "the LUNA33 wired link must not stage an 8K button rate");
+ calls.length = 0;
+ stageLunaFuryProfile({ buttonPollingRateHz: 4000 }, { ...current, supportedButtonPollingRates: [1000, 2000, 4000, 8000] }, actions);
+ assert.deepEqual(calls, [["rate", 4000]]);
+ const saved = pickSnapshot({ lunafury: current } as MouseStatus, { lunafury: { separateDpiAxes: false } });
+ assert.deepEqual(saved, { lunafury: { separateDpiAxes: true } });
+});
+
test("profile staging calls only supported controls and ignores unknown commands", () => {
const calls: unknown[] = [];
const actions: LunaFuryProfileActions = {
diff --git a/src/device/lunafury-profile.ts b/src/device/lunafury-profile.ts
index a0658ef7..8afeda01 100644
--- a/src/device/lunafury-profile.ts
+++ b/src/device/lunafury-profile.ts
@@ -1,7 +1,7 @@
import type { LunaFuryButton, LunaFuryLightningMode, LunaFurySettings, LunaFuryWheelGuard } from "@openmouse/protocol/lamzu";
export const LUNAFURY_PROFILE_FIELDS = [
- "lightningMode", "leftDebounceMs", "rightDebounceMs", "middleDebounceMs", "wheelGuard",
+ "lightningMode", "leftDebounceMs", "rightDebounceMs", "middleDebounceMs", "wheelGuard", "buttonPollingRateHz", "separateDpiAxes",
] as const satisfies readonly (keyof LunaFurySettings)[];
type Field = (typeof LUNAFURY_PROFILE_FIELDS)[number];
@@ -18,6 +18,10 @@ export function sanitizeLunaFurySettings(raw: unknown): LunaFurySettings {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const value = raw as Record;
const clean: LunaFurySettings = {};
+ if ([1000, 2000, 4000, 8000].includes(value.buttonPollingRateHz as number)) {
+ clean.buttonPollingRateHz = value.buttonPollingRateHz as LunaFurySettings["buttonPollingRateHz"];
+ }
+ if (typeof value.separateDpiAxes === "boolean") clean.separateDpiAxes = value.separateDpiAxes;
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;
@@ -56,6 +60,8 @@ export interface LunaFuryProfileActions {
lightning(mode: LunaFuryLightningMode): void;
button(button: LunaFuryButton, milliseconds: number): void;
wheel(guard: LunaFuryWheelGuard): void;
+ buttonRate?(hertz: NonNullable): void;
+ separateAxes?(enabled: boolean): void;
}
/** Disable priority before changing latencies; enable it after them. */
@@ -67,6 +73,9 @@ export function lunaFuryLightningPriority(mode: LunaFuryLightningMode): number {
export function stageLunaFuryProfile(raw: unknown, current: LunaFurySettings | undefined, actions: LunaFuryProfileActions): void {
if (!current) return;
const target = sanitizeLunaFurySettings(raw);
+ if (target.buttonPollingRateHz !== undefined && current.buttonPollingRateHz !== undefined &&
+ current.supportedButtonPollingRates?.includes(target.buttonPollingRateHz)) actions.buttonRate?.(target.buttonPollingRateHz);
+ if (target.separateDpiAxes !== undefined && current.separateDpiAxes !== undefined) actions.separateAxes?.(target.separateDpiAxes);
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;
diff --git a/src/i18n-ar.ts b/src/i18n-ar.ts
index bf5a16d7..f185b312 100644
--- a/src/i18n-ar.ts
+++ b/src/i18n-ar.ts
@@ -3,6 +3,30 @@ 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.bottomButton": "الزر السفلي",
+ "luna.bottomPower": "ضغطة قصيرة: تشغيل/إيقاف",
+ "luna.bottomPowerDpi": "ضغطة طويلة: تشغيل/إيقاف / قصيرة: DPI",
+ "luna.bottomButtonNote": "يُحفظ في ملف التعريف الداخلي الحالي. اضغط تطبيق للحفظ.",
+ "luna.dpiStageCount": "عدد مستويات DPI",
+ "luna.dpiStageNote": "انقر على مربع ملون لتبديل DPI. عدّل المستوى أدناه، ثم اضغط تطبيق للحفظ.",
+ "luna.receiverLight": "إضاءة المستقبل",
+ "luna.lightMixed": "الوضع المختلط",
+ "luna.lightBattery": "وضع البطارية",
+ "luna.lightDpi": "وضع DPI",
+ "luna.receiverLightNote": "ينطبق على مستقبل 8K، بشكل مستقل عن ملفات التعريف الداخلية وملفات الألعاب.",
+ "luna.physicalButtons": "الأيسر|الأيمن|الأوسط|رجوع|تقدم",
+ "luna.buttonActions": "معطل|نقر أيسر|نقر أيمن|نقر أوسط|رجوع|تقدم|تمرير لأعلى|تمرير لأسفل|تبديل DPI|زيادة DPI|خفض DPI|مشغل الوسائط|تشغيل / إيقاف مؤقت|المقطع التالي|المقطع السابق|رفع الصوت|خفض الصوت|كتم|إيقاف|الحاسبة|هذا الكمبيوتر|صفحة المتصفح الرئيسية|البريد|زيادة السطوع|خفض السطوع",
+ "luna.mappingNote": "تُحفظ التعيينات في ملف الذاكرة الحالي. الزر الأيسر محمي؛ وحدات الماكرو والتعيينات غير المعروفة للقراءة فقط. L/R يعني مفتاح التعديل الأيسر/الأيمن.",
+ "luna.keyHint": "ضع التركيز هنا ثم اضغط مفتاحًا أو اختصارًا",
+ "luna.keyLabel": "المفتاح",
+ "luna.modifiers": "مفاتيح التعديل",
+ "luna.keyUnsupported": "هذا المفتاح غير مدعوم. اختر من القائمة.",
+ "luna.buttonPollingRate": "معدل استطلاع الأزرار",
+ "luna.buttonPollingNote": "مستقل عن معدل تقارير USB.",
+ "luna.separateAxes": "DPI منفصل لمحوري X/Y",
+ "luna.stageYDpi": "المستوى {n} · DPI لمحور Y",
+ "luna.profileSwitchPending": "طبّق تبديل الملف أو ألغِه قبل تعديل الإعدادات.",
+ "luna.discardBeforeSwitch": "تجاهل التغييرات غير المطبقة وتبديل الملف المدمج؟",
"luna.angleSnap": "تصحيح الخط المستقيم",
"luna.competitiveMode": "وضع المنافسة",
"luna.trackingMode": "وضع التتبع",
diff --git a/src/i18n-de.ts b/src/i18n-de.ts
index 8459b32c..3b46381f 100644
--- a/src/i18n-de.ts
+++ b/src/i18n-de.ts
@@ -3,6 +3,30 @@ 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.bottomButton": "Unterseitentaste",
+ "luna.bottomPower": "Kurz drücken: ein-/ausschalten",
+ "luna.bottomPowerDpi": "Lang drücken: ein-/ausschalten / kurz drücken: DPI",
+ "luna.bottomButtonNote": "Im aktuellen Onboard-Profil gespeichert. Zum Speichern anwenden.",
+ "luna.dpiStageCount": "DPI-Stufen",
+ "luna.dpiStageNote": "Farbige Kachel zum DPI-Wechsel anklicken. Werte darunter bearbeiten und mit Anwenden speichern.",
+ "luna.receiverLight": "Empfängerbeleuchtung",
+ "luna.lightMixed": "Gemischter Modus",
+ "luna.lightBattery": "Akkumodus",
+ "luna.lightDpi": "DPI-Modus",
+ "luna.receiverLightNote": "Gilt für den 8K-Empfänger, unabhängig von Onboard- und Spielprofilen.",
+ "luna.physicalButtons": "Links|Rechts|Mitte|Zurück|Vorwärts",
+ "luna.buttonActions": "Deaktiviert|Linksklick|Rechtsklick|Mittelklick|Zurück|Vorwärts|Nach oben scrollen|Nach unten scrollen|DPI wechseln|DPI erhöhen|DPI verringern|Mediaplayer|Wiedergabe / Pause|Nächster Titel|Vorheriger Titel|Lauter|Leiser|Stumm|Stopp|Rechner|Dieser PC|Browser-Startseite|E-Mail|Heller|Dunkler",
+ "luna.mappingNote": "Belegungen gelten für das aktuelle Onboard-Profil. Links ist geschützt; vorhandene Makros und unbekannte Belegungen sind schreibgeschützt. L/R steht für linke/rechte Modifikatortaste.",
+ "luna.keyHint": "Hier fokussieren, dann Taste oder Tastenkürzel drücken",
+ "luna.keyLabel": "Taste",
+ "luna.modifiers": "Modifikatoren",
+ "luna.keyUnsupported": "Diese Taste wird nicht unterstützt. Wähle eine Taste aus der Liste.",
+ "luna.buttonPollingRate": "Tasten-Abfragerate",
+ "luna.buttonPollingNote": "Unabhängig von der USB-Abfragerate.",
+ "luna.separateAxes": "Getrennte X/Y-DPI",
+ "luna.stageYDpi": "Stufe {n} · Y-DPI",
+ "luna.profileSwitchPending": "Wende den Profilwechsel an oder brich ihn ab, bevor du Einstellungen änderst.",
+ "luna.discardBeforeSwitch": "Nicht angewendete Änderungen verwerfen und das interne Profil wechseln?",
"luna.angleSnap": "Geradlinienkorrektur",
"luna.competitiveMode": "Wettkampfmodus",
"luna.trackingMode": "Tracking-Modus",
diff --git a/src/i18n-es.ts b/src/i18n-es.ts
index ed428c88..566585b8 100644
--- a/src/i18n-es.ts
+++ b/src/i18n-es.ts
@@ -3,6 +3,30 @@ 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.bottomButton": "Botón inferior",
+ "luna.bottomPower": "Pulsación corta: encender/apagar",
+ "luna.bottomPowerDpi": "Pulsación larga: encender/apagar / corta: DPI",
+ "luna.bottomButtonNote": "Se guarda en el perfil integrado actual. Aplica para guardar.",
+ "luna.dpiStageCount": "Número de niveles",
+ "luna.dpiStageNote": "Pulsa un bloque de color para cambiar el DPI. Edita los valores debajo y pulsa Aplicar para guardar.",
+ "luna.receiverLight": "Iluminación del receptor",
+ "luna.lightMixed": "Modo mixto",
+ "luna.lightBattery": "Modo batería",
+ "luna.lightDpi": "Modo DPI",
+ "luna.receiverLightNote": "Se aplica al receptor 8K, independientemente de los perfiles integrados y de juego.",
+ "luna.physicalButtons": "Izquierdo|Derecho|Central|Atrás|Adelante",
+ "luna.buttonActions": "Desactivado|Clic izquierdo|Clic derecho|Clic central|Atrás|Adelante|Desplazar arriba|Desplazar abajo|Cambiar DPI|Subir DPI|Bajar DPI|Reproductor multimedia|Reproducir / pausa|Pista siguiente|Pista anterior|Subir volumen|Bajar volumen|Silenciar|Detener|Calculadora|Este equipo|Inicio del navegador|Correo|Subir brillo|Bajar brillo",
+ "luna.mappingNote": "Las asignaciones se guardan en el perfil integrado actual. El botón izquierdo está protegido; las macros y asignaciones desconocidas son de solo lectura. L/R indica el modificador izquierdo/derecho.",
+ "luna.keyHint": "Enfoca aquí y pulsa una tecla o combinación",
+ "luna.keyLabel": "Tecla",
+ "luna.modifiers": "Modificadores",
+ "luna.keyUnsupported": "Esta tecla no es compatible. Elige una de la lista.",
+ "luna.buttonPollingRate": "Frecuencia de los botones",
+ "luna.buttonPollingNote": "Independiente de la frecuencia de informes USB.",
+ "luna.separateAxes": "DPI X/Y independientes",
+ "luna.stageYDpi": "Nivel {n} · DPI Y",
+ "luna.profileSwitchPending": "Aplica o cancela el cambio de perfil antes de editar los ajustes.",
+ "luna.discardBeforeSwitch": "¿Descartar los cambios sin aplicar y cambiar de perfil interno?",
"luna.angleSnap": "Corrección de línea recta",
"luna.competitiveMode": "Modo competitivo",
"luna.trackingMode": "Modo de seguimiento",
diff --git a/src/i18n-fr.ts b/src/i18n-fr.ts
index 4a3527a7..66ac545a 100644
--- a/src/i18n-fr.ts
+++ b/src/i18n-fr.ts
@@ -3,6 +3,30 @@ 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.bottomButton": "Bouton inférieur",
+ "luna.bottomPower": "Appui court : marche/arrêt",
+ "luna.bottomPowerDpi": "Appui long : marche/arrêt / appui court : DPI",
+ "luna.bottomButtonNote": "Enregistré dans le profil embarqué actuel. Appliquez pour enregistrer.",
+ "luna.dpiStageCount": "Nombre de paliers",
+ "luna.dpiStageNote": "Cliquez sur une case colorée pour changer les DPI. Modifiez les valeurs dessous, puis cliquez sur Appliquer.",
+ "luna.receiverLight": "Éclairage du récepteur",
+ "luna.lightMixed": "Mode mixte",
+ "luna.lightBattery": "Mode batterie",
+ "luna.lightDpi": "Mode DPI",
+ "luna.receiverLightNote": "Concerne le récepteur 8K, indépendamment des profils embarqués et de jeu.",
+ "luna.physicalButtons": "Gauche|Droite|Milieu|Précédent|Suivant",
+ "luna.buttonActions": "Désactivé|Clic gauche|Clic droit|Clic milieu|Précédent|Suivant|Défilement haut|Défilement bas|Cycle DPI|Augmenter DPI|Réduire DPI|Lecteur multimédia|Lecture / pause|Piste suivante|Piste précédente|Monter le volume|Baisser le volume|Muet|Arrêt|Calculatrice|Ce PC|Accueil du navigateur|E-mail|Augmenter la luminosité|Réduire la luminosité",
+ "luna.mappingNote": "Les affectations sont enregistrées dans le profil embarqué actuel. Le bouton gauche est protégé ; les macros et affectations inconnues sont en lecture seule. L/R désigne le modificateur gauche/droit.",
+ "luna.keyHint": "Placer le focus ici, puis appuyer sur une touche ou un raccourci",
+ "luna.keyLabel": "Touche",
+ "luna.modifiers": "Modificateurs",
+ "luna.keyUnsupported": "Cette touche n’est pas prise en charge. Choisissez-la dans la liste.",
+ "luna.buttonPollingRate": "Fréquence des boutons",
+ "luna.buttonPollingNote": "Indépendante de la fréquence des rapports USB.",
+ "luna.separateAxes": "DPI X/Y séparés",
+ "luna.stageYDpi": "Palier {n} · DPI Y",
+ "luna.profileSwitchPending": "Appliquez ou annulez le changement de profil avant de modifier les réglages.",
+ "luna.discardBeforeSwitch": "Abandonner les modifications non appliquées et changer de profil intégré ?",
"luna.angleSnap": "Correction de ligne droite",
"luna.competitiveMode": "Mode compétitif",
"luna.trackingMode": "Mode de suivi",
diff --git a/src/i18n-ja.ts b/src/i18n-ja.ts
index 17d66f45..15e3a940 100644
--- a/src/i18n-ja.ts
+++ b/src/i18n-ja.ts
@@ -3,6 +3,30 @@ 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.bottomButton": "底面ボタン",
+ "luna.bottomPower": "短押しで電源オン/オフ",
+ "luna.bottomPowerDpi": "長押しで電源オン/オフ・短押しで DPI 切り替え",
+ "luna.bottomButtonNote": "現在のオンボードプロファイルに保存されます。「適用」で保存してください。",
+ "luna.dpiStageCount": "DPI 段階数",
+ "luna.dpiStageNote": "色付きのタイルをクリックして DPI を切り替えます。下の数値を編集し、適用を押して保存します。",
+ "luna.receiverLight": "レシーバーの照明",
+ "luna.lightMixed": "ミックスモード",
+ "luna.lightBattery": "バッテリーモード",
+ "luna.lightDpi": "DPI モード",
+ "luna.receiverLightNote": "8K レシーバーの設定です。オンボード・ゲームプロファイルとは独立しています。",
+ "luna.physicalButtons": "左ボタン|右ボタン|中央ボタン|戻る|進む",
+ "luna.buttonActions": "無効|左クリック|右クリック|中央クリック|戻る|進む|上にスクロール|下にスクロール|DPI 切替|DPI を上げる|DPI を下げる|メディアプレーヤー|再生/一時停止|次の曲|前の曲|音量を上げる|音量を下げる|ミュート|停止|電卓|PC|ブラウザーのホーム|メール|明るくする|暗くする",
+ "luna.mappingNote": "割り当ては現在のオンボードプロファイルに保存されます。左ボタンは保護され、既存のマクロと不明な割り当ては読み取り専用です。L/R は左/右の修飾キーです。",
+ "luna.keyHint": "ここにフォーカスし、キーまたはショートカットを押してください",
+ "luna.keyLabel": "キー",
+ "luna.modifiers": "修飾キー",
+ "luna.keyUnsupported": "このキーは未対応です。一覧から選択してください。",
+ "luna.buttonPollingRate": "ボタンポーリングレート",
+ "luna.buttonPollingNote": "USB レポートレートとは独立した設定です。",
+ "luna.separateAxes": "X/Y DPI を個別に設定",
+ "luna.stageYDpi": "ステージ {n} · Y DPI",
+ "luna.profileSwitchPending": "設定を編集する前にプロファイルの切り替えを適用またはキャンセルしてください。",
+ "luna.discardBeforeSwitch": "未適用の変更を破棄してオンボードプロファイルを切り替えますか?",
"luna.angleSnap": "Angle Snap",
"luna.competitiveMode": "Competitive Mode",
"luna.trackingMode": "Tracking Mode",
diff --git a/src/i18n-ko.ts b/src/i18n-ko.ts
index 59c0535c..f213b7dd 100644
--- a/src/i18n-ko.ts
+++ b/src/i18n-ko.ts
@@ -3,6 +3,30 @@ 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.bottomButton": "하단 버튼",
+ "luna.bottomPower": "짧게 눌러 전원 켜기/끄기",
+ "luna.bottomPowerDpi": "길게 눌러 전원 켜기/끄기 · 짧게 눌러 DPI 전환",
+ "luna.bottomButtonNote": "현재 온보드 프로필에 저장됩니다. 적용을 눌러 저장하세요.",
+ "luna.dpiStageCount": "DPI 단계 수",
+ "luna.dpiStageNote": "색상 타일을 눌러 DPI를 전환하세요. 아래 값을 수정하고 적용을 눌러 저장하세요.",
+ "luna.receiverLight": "수신기 조명",
+ "luna.lightMixed": "혼합 모드",
+ "luna.lightBattery": "배터리 모드",
+ "luna.lightDpi": "DPI 모드",
+ "luna.receiverLightNote": "8K 수신기에 적용되며 온보드 및 게임 프로필과 독립적입니다.",
+ "luna.physicalButtons": "왼쪽|오른쪽|가운데|뒤로|앞으로",
+ "luna.buttonActions": "사용 안 함|왼쪽 클릭|오른쪽 클릭|가운데 클릭|뒤로|앞으로|위로 스크롤|아래로 스크롤|DPI 순환|DPI 증가|DPI 감소|미디어 플레이어|재생 / 일시 정지|다음 곡|이전 곡|볼륨 증가|볼륨 감소|음소거|정지|계산기|내 PC|브라우저 홈|이메일|밝기 증가|밝기 감소",
+ "luna.mappingNote": "할당은 현재 온보드 프로필에 저장됩니다. 왼쪽 버튼은 보호되며 기존 매크로와 알 수 없는 할당은 읽기 전용입니다. L/R은 왼쪽/오른쪽 보조 키입니다.",
+ "luna.keyHint": "여기에 초점을 맞춘 뒤 키 또는 단축키를 누르세요",
+ "luna.keyLabel": "키",
+ "luna.modifiers": "보조 키",
+ "luna.keyUnsupported": "지원하지 않는 키입니다. 목록에서 선택하세요.",
+ "luna.buttonPollingRate": "버튼 폴링 레이트",
+ "luna.buttonPollingNote": "USB 보고 주기와 별개의 설정입니다.",
+ "luna.separateAxes": "X/Y DPI 개별 설정",
+ "luna.stageYDpi": "단계 {n} · Y DPI",
+ "luna.profileSwitchPending": "설정을 변경하기 전에 프로필 전환을 적용하거나 취소하세요.",
+ "luna.discardBeforeSwitch": "적용하지 않은 변경 사항을 버리고 온보드 프로필을 전환할까요?",
"luna.angleSnap": "직선 보정",
"luna.competitiveMode": "경쟁 모드",
"luna.trackingMode": "추적 모드",
diff --git a/src/i18n-pt.ts b/src/i18n-pt.ts
index 88316988..b7edf934 100644
--- a/src/i18n-pt.ts
+++ b/src/i18n-pt.ts
@@ -3,6 +3,30 @@ 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.bottomButton": "Botão inferior",
+ "luna.bottomPower": "Pressão curta: ligar/desligar",
+ "luna.bottomPowerDpi": "Pressão longa: ligar/desligar / curta: DPI",
+ "luna.bottomButtonNote": "Guardado no perfil interno atual. Aplique para guardar.",
+ "luna.dpiStageCount": "Número de estágios",
+ "luna.dpiStageNote": "Clique em um bloco colorido para trocar o DPI. Edite os valores abaixo e clique em Aplicar para salvar.",
+ "luna.receiverLight": "Iluminação do receptor",
+ "luna.lightMixed": "Modo misto",
+ "luna.lightBattery": "Modo bateria",
+ "luna.lightDpi": "Modo DPI",
+ "luna.receiverLightNote": "Aplica-se ao receptor 8K, independentemente dos perfis internos e de jogos.",
+ "luna.physicalButtons": "Esquerdo|Direito|Central|Voltar|Avançar",
+ "luna.buttonActions": "Desativado|Clique esquerdo|Clique direito|Clique central|Voltar|Avançar|Rolar para cima|Rolar para baixo|Alternar DPI|Aumentar DPI|Diminuir DPI|Reprodutor de mídia|Reproduzir / pausar|Próxima faixa|Faixa anterior|Aumentar volume|Diminuir volume|Silenciar|Parar|Calculadora|Este computador|Página inicial|E-mail|Aumentar brilho|Diminuir brilho",
+ "luna.mappingNote": "As atribuições pertencem ao perfil interno atual. O botão esquerdo está protegido; macros existentes e atribuições desconhecidas são somente leitura. L/R indica o modificador esquerdo/direito.",
+ "luna.keyHint": "Foque aqui e pressione uma tecla ou atalho",
+ "luna.keyLabel": "Tecla",
+ "luna.modifiers": "Modificadores",
+ "luna.keyUnsupported": "Esta tecla não é compatível. Escolha uma na lista.",
+ "luna.buttonPollingRate": "Taxa de consulta dos botões",
+ "luna.buttonPollingNote": "Independente da taxa de relatórios USB.",
+ "luna.separateAxes": "DPI X/Y independentes",
+ "luna.stageYDpi": "Nível {n} · DPI Y",
+ "luna.profileSwitchPending": "Aplique ou cancele a troca de perfil antes de editar as configurações.",
+ "luna.discardBeforeSwitch": "Descartar alterações não aplicadas e trocar o perfil interno?",
"luna.angleSnap": "Correção de linha reta",
"luna.competitiveMode": "Modo competitivo",
"luna.trackingMode": "Modo de rastreamento",
diff --git a/src/i18n-ru.ts b/src/i18n-ru.ts
index c241690d..660ece9b 100644
--- a/src/i18n-ru.ts
+++ b/src/i18n-ru.ts
@@ -3,6 +3,30 @@ 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.bottomButton": "Нижняя кнопка",
+ "luna.bottomPower": "Короткое нажатие: включение/выключение",
+ "luna.bottomPowerDpi": "Долгое нажатие: питание / короткое: DPI",
+ "luna.bottomButtonNote": "Сохраняется в текущем встроенном профиле. Нажмите «Применить».",
+ "luna.dpiStageCount": "Количество ступеней",
+ "luna.dpiStageNote": "Нажмите на цветную плитку для смены DPI. Измените значения под ней и нажмите «Применить».",
+ "luna.receiverLight": "Подсветка приёмника",
+ "luna.lightMixed": "Смешанный режим",
+ "luna.lightBattery": "Режим батареи",
+ "luna.lightDpi": "Режим DPI",
+ "luna.receiverLightNote": "Настройка приёмника 8K, независимая от встроенных и игровых профилей.",
+ "luna.physicalButtons": "Левая|Правая|Средняя|Назад|Вперёд",
+ "luna.buttonActions": "Отключено|Левый щелчок|Правый щелчок|Средний щелчок|Назад|Вперёд|Прокрутка вверх|Прокрутка вниз|Смена DPI|Повысить DPI|Понизить DPI|Медиаплеер|Воспроизведение / пауза|Следующий трек|Предыдущий трек|Громче|Тише|Без звука|Стоп|Калькулятор|Этот компьютер|Главная браузера|Почта|Повысить яркость|Понизить яркость",
+ "luna.mappingNote": "Назначения сохраняются в текущем встроенном профиле. Левая кнопка защищена; существующие макросы и неизвестные назначения доступны только для чтения. L/R — левый/правый модификатор.",
+ "luna.keyHint": "Установите фокус здесь и нажмите клавишу или сочетание",
+ "luna.keyLabel": "Клавиша",
+ "luna.modifiers": "Модификаторы",
+ "luna.keyUnsupported": "Эта клавиша не поддерживается. Выберите клавишу из списка.",
+ "luna.buttonPollingRate": "Частота опроса кнопок",
+ "luna.buttonPollingNote": "Не зависит от частоты отчётов USB.",
+ "luna.separateAxes": "Раздельный DPI по X/Y",
+ "luna.stageYDpi": "Уровень {n} · DPI Y",
+ "luna.profileSwitchPending": "Примените или отмените переключение профиля перед изменением настроек.",
+ "luna.discardBeforeSwitch": "Отбросить неприменённые изменения и переключить встроенный профиль?",
"luna.angleSnap": "Коррекция прямой линии",
"luna.competitiveMode": "Соревновательный режим",
"luna.trackingMode": "Режим отслеживания",
diff --git a/src/i18n-vi.ts b/src/i18n-vi.ts
index 647c6d13..9c7dffcd 100644
--- a/src/i18n-vi.ts
+++ b/src/i18n-vi.ts
@@ -3,6 +3,30 @@ 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.bottomButton": "Nút dưới đáy",
+ "luna.bottomPower": "Nhấn ngắn: bật/tắt nguồn",
+ "luna.bottomPowerDpi": "Nhấn lâu: bật/tắt nguồn / nhấn ngắn: DPI",
+ "luna.bottomButtonNote": "Lưu trong hồ sơ trên chuột hiện tại. Nhấn Áp dụng để lưu.",
+ "luna.dpiStageCount": "Số mức DPI",
+ "luna.dpiStageNote": "Nhấp ô màu để đổi DPI. Sửa giá trị bên dưới, rồi nhấn Áp dụng để lưu.",
+ "luna.receiverLight": "Đèn bộ thu",
+ "luna.lightMixed": "Chế độ kết hợp",
+ "luna.lightBattery": "Chế độ pin",
+ "luna.lightDpi": "Chế độ DPI",
+ "luna.receiverLightNote": "Áp dụng cho bộ thu 8K, độc lập với hồ sơ trên chuột và hồ sơ trò chơi.",
+ "luna.physicalButtons": "Trái|Phải|Giữa|Lùi|Tiến",
+ "luna.buttonActions": "Tắt|Nhấp trái|Nhấp phải|Nhấp giữa|Lùi|Tiến|Cuộn lên|Cuộn xuống|Chuyển DPI|Tăng DPI|Giảm DPI|Trình phát media|Phát / tạm dừng|Bài tiếp theo|Bài trước|Tăng âm lượng|Giảm âm lượng|Tắt tiếng|Dừng|Máy tính|Máy tính này|Trang chủ trình duyệt|Email|Tăng độ sáng|Giảm độ sáng",
+ "luna.mappingNote": "Gán nút được lưu trong cấu hình tích hợp hiện tại. Nút trái được bảo vệ; macro có sẵn và gán không xác định chỉ đọc. L/R là phím bổ trợ trái/phải.",
+ "luna.keyHint": "Đặt tiêu điểm ở đây rồi nhấn phím hoặc tổ hợp phím",
+ "luna.keyLabel": "Phím",
+ "luna.modifiers": "Phím bổ trợ",
+ "luna.keyUnsupported": "Phím này không được hỗ trợ. Hãy chọn từ danh sách.",
+ "luna.buttonPollingRate": "Tần số quét nút",
+ "luna.buttonPollingNote": "Độc lập với tần số báo cáo USB.",
+ "luna.separateAxes": "DPI X/Y riêng biệt",
+ "luna.stageYDpi": "Mức {n} · DPI Y",
+ "luna.profileSwitchPending": "Áp dụng hoặc hủy chuyển hồ sơ trước khi sửa cài đặt.",
+ "luna.discardBeforeSwitch": "Bỏ các thay đổi chưa áp dụng và chuyển hồ sơ trên chuột?",
"luna.angleSnap": "Hiệu chỉnh đường thẳng",
"luna.competitiveMode": "Chế độ thi đấu",
"luna.trackingMode": "Chế độ theo dõi",
diff --git a/src/i18n-zh.ts b/src/i18n-zh.ts
index 151373a7..c8e8b4fe 100644
--- a/src/i18n-zh.ts
+++ b/src/i18n-zh.ts
@@ -3,6 +3,30 @@ 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.bottomButton": "底部按键",
+ "luna.bottomPower": "短按开关机",
+ "luna.bottomPowerDpi": "长按开关机/短按 DPI",
+ "luna.bottomButtonNote": "保存在当前板载配置中,点击应用后生效。",
+ "luna.dpiStageCount": "档位数量",
+ "luna.dpiStageNote": "点击彩色档位块切换 DPI,在下方输入框修改数值。点击应用后保存到鼠标。",
+ "luna.receiverLight": "接收器灯光",
+ "luna.lightMixed": "混合模式",
+ "luna.lightBattery": "电量模式",
+ "luna.lightDpi": "DPI 模式",
+ "luna.receiverLightNote": "仅控制 8K 接收器灯光,不随板载配置或游戏配置切换。",
+ "luna.physicalButtons": "左键|右键|中键|后退侧键|前进侧键",
+ "luna.buttonActions": "禁用|左键点击|右键点击|中键点击|后退|前进|向上滚动|向下滚动|循环切换 DPI|提高 DPI|降低 DPI|媒体播放器|播放/暂停|下一曲|上一曲|提高音量|降低音量|静音|停止播放|计算器|此电脑|浏览器主页|电子邮件|提高亮度|降低亮度",
+ "luna.mappingNote": "映射保存在当前板载配置中。左键受保护,已有宏和未知绑定只读保留。L/R 表示左/右修饰键。",
+ "luna.keyHint": "点击此处并按键;也可在下方选择按键和修饰键",
+ "luna.keyLabel": "按键",
+ "luna.modifiers": "修饰键",
+ "luna.keyUnsupported": "不支持此按键,请从列表中选择。",
+ "luna.buttonPollingRate": "按键轮询率",
+ "luna.buttonPollingNote": "此设置独立于 USB 回报率。",
+ "luna.separateAxes": "独立 X/Y DPI",
+ "luna.stageYDpi": "第 {n} 档 · Y 轴 DPI",
+ "luna.profileSwitchPending": "请先应用或取消配置切换,再修改设置。",
+ "luna.discardBeforeSwitch": "丢弃尚未应用的修改并切换板载配置?",
"luna.angleSnap": "直线修正",
"luna.competitiveMode": "竞技模式",
"luna.trackingMode": "追踪模式",
@@ -308,27 +332,27 @@ export const zh: Record = {
"dpi.noLiftOff": "此配置文件格式没有抬起高度设置",
"dpi.slotReadonly": "只读:将 DPI 挡位写入配置文件的功能尚未启用,因为写入序列还没有在硬件上验证过。",
"dpi.slotNote": "每个挡位存储各自的 X/Y 灵敏度和抬起高度等级。{min}–{max} DPI,步进 {step}。高亮的挡位是鼠标开机时所使用的挡位。",
- "dpi.stagesInUse": "已使用阶段",
- "dpi.stagesCount": "DPI 阶段数量",
- "dpi.activeStage": "当前阶段",
- "dpi.makeActive": "设为当前阶段",
- "dpi.stageDpi": "阶段 {n} 的 DPI",
+ "dpi.stagesInUse": "已启用档位",
+ "dpi.stagesCount": "DPI 档位数量",
+ "dpi.activeStage": "当前档位",
+ "dpi.makeActive": "切换到此档位",
+ "dpi.stageDpi": "第 {n} 档 DPI",
"dpi.color": "颜色",
- "dpi.stageColor": "阶段 {n} 的颜色",
- "dpi.stageNoteCount": "预设和自定义会编辑高亮的阶段。{min}–{max} DPI。鼠标上的 DPI 按键会循环切换这些阶段。",
- "dpi.stageNoteSteps": "预设和自定义会编辑高亮的阶段。{min}–{max} DPI,步进 {step}。鼠标上的 DPI 按键会循环切换全部 {total} 个阶段。",
- "dpi.stageToggle": "Include this DPI stage",
- "dpi.editorCountNote": "One row per DPI stage. Tick a row to include it in the DPI cycle; untick to disable it and keep the value for later. Click a number to make it the starting stage.",
- "dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.",
- "dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.",
- "dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.",
+ "dpi.stageColor": "第 {n} 档的颜色",
+ "dpi.stageNoteCount": "预设和自定义会修改高亮档位的数值。{min}–{max} DPI。鼠标上的 DPI 按键会循环切换这些档位。",
+ "dpi.stageNoteSteps": "预设和自定义会修改高亮档位的数值。{min}–{max} DPI,步进 {step}。鼠标上的 DPI 按键会循环切换全部 {total} 个档位。",
+ "dpi.stageToggle": "启用此 DPI 档位",
+ "dpi.editorCountNote": "每行对应鼠标的一档 DPI。勾选或取消勾选可调整启用的档位数量;点击左侧编号选择档位。",
+ "dpi.editorFixedNote": "鼠标共有 {total} 个固定 DPI 档位。输入数值或拖动滑块可修改该档 DPI;点击左侧编号可切换到该档。",
+ "dpi.stagesReadOnly": "这些 DPI 档位只能读取,无法在此修改。请使用官方驱动调整。",
+ "dpi.editorGenericNote": "每行是一个快捷 DPI 预设,勾选或点击左侧编号可设置当前 DPI。这里显示的是快捷选项,不是鼠标保存的档位列表。",
"dpi.xAxis": "X 轴",
"dpi.yAxis": "Y 轴",
- "dpi.viewStages": "阶段",
- "dpi.viewSingle": "单个",
- "dpi.viewSlots": "挡位",
- "dpi.viewSlotsToggle": "将 DPI 作为多个挡位或单个值编辑",
- "dpi.viewToggle": "将 DPI 作为多个阶段或单个值编辑",
+ "dpi.viewStages": "全部档位",
+ "dpi.viewSingle": "当前 DPI",
+ "dpi.viewSlots": "全部档位",
+ "dpi.viewSlotsToggle": "切换全部档位或当前 DPI 视图",
+ "dpi.viewToggle": "切换全部档位或当前 DPI 视图",
"perf.reportFrequency": "报告频率",
"perf.reportRate": "回报率",
"perf.sharedNote": "在此配置文件中作为一个共享区间存储,最高 {max} Hz。",
diff --git a/src/i18n.test.ts b/src/i18n.test.ts
index 2d008b1f..b2f6aa0f 100644
--- a/src/i18n.test.ts
+++ b/src/i18n.test.ts
@@ -34,3 +34,23 @@ test("tp renders provided placeholders and blanks missing ones", () => {
assert.equal(tp("en", "dpi.slotX", { n: 2 }), "Slot 2 X DPI");
assert.equal(tp("en", "dpi.slotX", {}), "Slot X DPI");
});
+
+test("Chinese DPI views and editor notes use clear localized wording", async () => {
+ await ensureLocale("zh");
+ assert.equal(t("zh", "dpi.viewStages"), "全部档位");
+ assert.equal(t("zh", "dpi.viewSingle"), "当前 DPI");
+ assert.equal(t("zh", "dpi.viewSlots"), "全部档位");
+ for (const key of ["dpi.stageToggle", "dpi.editorCountNote", "dpi.editorFixedNote",
+ "dpi.stagesReadOnly", "dpi.editorGenericNote"] as const) {
+ const text = t("zh", key);
+ assert.match(text, /[\u4e00-\u9fff]/);
+ assert.doesNotMatch(text, /One row|Include this|These DPI stages/);
+ }
+ assert.equal(tp("zh", "dpi.stageDpi", { n: 2 }), "第 2 档 DPI");
+ assert.match(tp("zh", "dpi.editorFixedNote", { total: 6 }), /6 个固定 DPI 档位/);
+});
+test("LunaFury official-style DPI controls have localized count and apply guidance", async () => {
+ await ensureLocale("zh");
+ assert.equal(t("zh", "luna.dpiStageCount"), "档位数量");
+ assert.match(t("zh", "luna.dpiStageNote"), /彩色档位块.*应用.*鼠标/);
+});
diff --git a/src/i18n.ts b/src/i18n.ts
index 9c538d5f..3dc9a8ad 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -6,6 +6,30 @@ import type { InterfaceLocale } from "./interface-preferences";
inside the controller stay English until phase 2. */
const en = {
+ "luna.bottomButton": "Bottom button",
+ "luna.bottomPower": "Short press: power on/off",
+ "luna.bottomPowerDpi": "Long press: power on/off / short press: DPI",
+ "luna.bottomButtonNote": "Saved in the current onboard profile. Apply to save.",
+ "luna.dpiStageCount": "DPI stages",
+ "luna.dpiStageNote": "Click a colored tile to switch DPI. Edit a stage below, then Apply to save.",
+ "luna.receiverLight": "Receiver lighting",
+ "luna.lightMixed": "Mixed mode",
+ "luna.lightBattery": "Battery mode",
+ "luna.lightDpi": "DPI mode",
+ "luna.receiverLightNote": "Applies to the 8K receiver, independently of onboard and game profiles.",
+ "luna.physicalButtons": "Left|Right|Middle|Back|Forward",
+ "luna.buttonActions": "Disabled|Left click|Right click|Middle click|Back|Forward|Scroll up|Scroll down|DPI cycle|DPI up|DPI down|Media player|Play / pause|Next track|Previous track|Volume up|Volume down|Mute|Stop|Calculator|My computer|Browser home|Email|Brightness up|Brightness down",
+ "luna.mappingNote": "Assignments belong to the current onboard profile. Left is protected; existing macros and unknown bindings are read-only. L/R identifies the left/right modifier key.",
+ "luna.keyHint": "Focus here and press a key, or choose the key and modifiers below",
+ "luna.keyLabel": "Key",
+ "luna.modifiers": "Modifiers",
+ "luna.keyUnsupported": "This key is not supported. Choose a key from the list.",
+ "luna.buttonPollingRate": "Button polling rate",
+ "luna.buttonPollingNote": "Independent of the USB report rate.",
+ "luna.separateAxes": "Separate X/Y DPI",
+ "luna.stageYDpi": "Stage {n} · Y DPI",
+ "luna.profileSwitchPending": "Apply or cancel the profile switch before editing settings.",
+ "luna.discardBeforeSwitch": "Discard unapplied changes and switch onboard profiles?",
"luna.angleSnap": "Angle Snap",
"luna.competitiveMode": "Competitive Mode",
"luna.trackingMode": "Tracking Mode",
diff --git a/src/preview-fixtures.test.ts b/src/preview-fixtures.test.ts
index c4e97ab2..8f376d24 100644
--- a/src/preview-fixtures.test.ts
+++ b/src/preview-fixtures.test.ts
@@ -46,3 +46,27 @@ test("atk-f1 fixture lights up the offender cards", () => {
assert.equal(has.atkF1Sensor, true);
assert.equal(has.atkF1Dongle, true);
});
+
+test("LunaFury synthetic preview exposes paired stages, three banks and independent button rates", () => {
+ const status = PREVIEW_FIXTURES.lunafury.status;
+ assert.equal(status.brand, "LunaFury");
+ assert.equal(status.profileCount, 3);
+ assert.equal(status.dpiStages?.length, 6);
+ assert.equal(status.dpiStagesY?.length, 6);
+ assert.notEqual(status.dpi, status.dpiY);
+ assert.notEqual(status.pollingRateHz, status.lunafury?.buttonPollingRateHz);
+ const has = cardAvailability({ status, traits: traitsFor(status), settingsPending: false } as ControlSnapshot);
+ assert.equal(has.onboardProfiles, true);
+ assert.equal(has.lunafuryButtons, true);
+ assert.equal(has.lunafuryWheelGuard, true);
+ assert.equal(has.lunafuryReceiverLight, true);
+ assert.equal(has.lunafuryBottomButton, true);
+ assert.equal(status.lunafury?.bottomButtonMode, 2);
+ assert.equal(status.sleepTimeout, 0);
+ for (const receiverLightMode of [0, undefined] as const) {
+ const variant = { ...status, lunafury: { ...status.lunafury, receiverLightMode } };
+ assert.equal(cardAvailability({ status: variant, traits: traitsFor(variant) } as ControlSnapshot).lunafuryReceiverLight,
+ receiverLightMode !== undefined);
+ }
+ assert.equal(has.signal, false);
+});
diff --git a/src/preview-fixtures.ts b/src/preview-fixtures.ts
index 0a653a9b..fa5f5b9f 100644
--- a/src/preview-fixtures.ts
+++ b/src/preview-fixtures.ts
@@ -1,5 +1,6 @@
import type { MouseLighting, MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
import { PREVIEW_KEYS, type FixturePreviewMode } from "./preview-modes.ts";
+import { LUNAFURY_BUTTON_ACTIONS } from "@openmouse/protocol/lamzu";
export { PREVIEW_KEYS };
@@ -853,6 +854,23 @@ export const PREVIEW_FIXTURES: Record = {
"egg-xm2we": { label: "Endgame Gear XM2we", status: EGG_XM2WE },
wlmouse: { label: "WLMouse BEAST X", status: WLMOUSE },
lamzu: { label: "Lamzu Atlantis OG V2", status: LAMZU },
+ lunafury: { label: "LunaFury LUNA33 (synthetic)", status: {
+ ...LAMZU, brand: "LunaFury", name: "LunaFury LUNA33",
+ ui: { family: "lunafury", hideUnsupportedPollingRates: true, forceShowBattery: true,
+ dpiStageEditor: { maxStages: 6, countEditable: true, minDpi: 50, maxDpi: 30000, stepDpi: 50 } },
+ dpi: 800, dpiY: 1600, dpiStages: [400, 800, 1600, 3200, 6400, 30000],
+ dpiStagesY: [800, 1600, 3200, 3200, 6400, 30000], activeDpiStage: 1,
+ activeProfile: 2, profileCount: 3, pollingRateHz: 8000,
+ sleepTimeout: 0,
+ buttonMappings: { Left: "Left Click", Right: "Right Click", Middle: "Middle Click", Back: "Keyboard:1:6", Forward: "Macro:16:1" },
+ buttonOptions: [...LUNAFURY_BUTTON_ACTIONS], fixedButtons: ["Left", "Forward"],
+ supportedPollingRates: [125, 250, 500, 1000, 2000, 4000, 8000],
+ angleTuning: -12, angleSnapping: false, hyperMode: true, performanceMode: true, rippleControl: false,
+ lunafury: { bottomButtonMode: 2, receiverLightMode: 2, buttonPollingRateHz: 4000, supportedButtonPollingRates: [1000, 2000, 4000, 8000],
+ separateDpiAxes: true, lightningMode: 1, leftDebounceMs: 3, rightDebounceMs: 7,
+ middleDebounceMs: 10, wheelGuard: { enabled: true, windowMs: 100 } },
+ firmware: ["Synthetic preview — not a hardware capture"],
+ } },
"attack-shark": { label: "Attack Shark R5 Ultra", status: ATTACKSHARK },
crdrako: { label: "CRDRAKO KO-ONE", status: CRDRAKO },
m3k: { label: "Zaunkoenig M3K", status: M3K },
diff --git a/src/preview-modes.ts b/src/preview-modes.ts
index a33c3c25..4c442309 100644
--- a/src/preview-modes.ts
+++ b/src/preview-modes.ts
@@ -15,6 +15,7 @@ export const PREVIEW_KEYS = [
"egg-xm2we",
"wlmouse",
"lamzu",
+ "lunafury",
"attack-shark",
"crdrako",
"m3k",
@@ -60,6 +61,7 @@ export function parsePreviewMode(value: string | null): PreviewMode | null {
case "egg-xm2we": return "egg-xm2we";
case "wlmouse": return "wlmouse";
case "lamzu": return "lamzu";
+ case "lunafury": return "lunafury";
case "attack-shark": return "attack-shark";
case "crdrako": return "crdrako";
case "m3k": return "m3k";
diff --git a/vite.config.ts b/vite.config.ts
index e97c0b0c..0589aa59 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -50,6 +50,8 @@ export default defineConfig({
__BUILD_ID__: JSON.stringify(buildId),
},
build: {
+ // Keep the aggregate bundle budget unchanged as device support grows.
+ minify: "terser",
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
From fb5c4a99f12c47f49983e4ee9a0bde9cba569844 Mon Sep 17 00:00:00 2001
From: Breakdown <169538186+RINZE1@users.noreply.github.com>
Date: Fri, 9 Oct 2026 13:47:53 +0800
Subject: [PATCH 02/14] fix(lunafury): preserve DPI tables across Games apply
and restore
---
build/lunafury-controller.test.ts | 190 +++++++++++++++++++++++++++
docs/lunafury-development.md | 14 ++
src/device/controller.ts | 9 +-
src/device/game-profile-snapshot.ts | 8 +-
src/device/lunafury-game-dpi.test.ts | 78 +++++++++++
src/device/lunafury-game-dpi.ts | 62 +++++++++
6 files changed, 356 insertions(+), 5 deletions(-)
create mode 100644 build/lunafury-controller.test.ts
create mode 100644 src/device/lunafury-game-dpi.test.ts
create mode 100644 src/device/lunafury-game-dpi.ts
diff --git a/build/lunafury-controller.test.ts b/build/lunafury-controller.test.ts
new file mode 100644
index 00000000..561264b9
--- /dev/null
+++ b/build/lunafury-controller.test.ts
@@ -0,0 +1,190 @@
+import assert from "node:assert/strict";
+import { fileURLToPath } from "node:url";
+import { before, test } from "node:test";
+import { build } from "vite";
+
+let controller: any;
+const controllerPath = fileURLToPath(new URL("../src/device/controller.ts", import.meta.url)).replaceAll("\\", "/");
+
+before(async () => {
+ const storage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
+ // No browser, native Bridge or real HID device is opened by this harness.
+ (globalThis as any).localStorage = storage;
+ (globalThis as any).window = { localStorage: storage, location: { search: "" },
+ setTimeout: (fn: () => void, ms = 0) => setTimeout(fn, Math.min(ms, 1)), clearTimeout,
+ matchMedia: () => ({ matches: false }) };
+ (globalThis as any).document = { title: "OpenMouse test" };
+ const result: any = await build({
+ configFile: false, logLevel: "silent",
+ define: { __BUILD_CHANNEL__: '"stable"', __APP_VERSION__: '"test"', __BUILD_ID__: '"test"' },
+ plugins: [{ name: "controller-test-state", transform(source, id) {
+ if (id.replaceAll("\\", "/") !== controllerPath) return null;
+ // Only inject device setup/observability. All staging, Games apply and
+ // flash code below is the real controller, bundled by the normal toolchain.
+ return source + `
+ export async function testConnect(device) {
+ clearPendingChanges(); gameProfileDraft = null;
+ active = new LamzuHidClient(device); activeDevice = device;
+ dpiOptions = active.getDpiOptions(); lastDiagnosticError = null;
+ interfacePreferences = { ...DEFAULT_INTERFACE_PREFERENCES, reducedMotion: true };
+ applyStatus(await active.readStatus());
+ return latestDeviceStatus;
+ }
+ export function testState() {
+ return { status: latestDeviceStatus, preview: withPendingChanges(latestDeviceStatus),
+ pending: pendingChanges().map(change => change.key), error: lastDiagnosticError };
+ }
+ export { snapshotDiff as testSnapshotDiff, pickSnapshot as testPickSnapshot } from "./game-profile-snapshot.ts";
+ `;
+ } }],
+ build: { write: false, minify: false, lib: { entry: controllerPath, formats: ["es"] },
+ rollupOptions: { output: { inlineDynamicImports: true } } },
+ });
+ const output = (Array.isArray(result) ? result : [result]).flatMap(entry => entry.output);
+ const code = output.find((entry: any) => entry.type === "chunk").code;
+ controller = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
+});
+
+function fixture(productId = 0x0033) {
+ let profile = 1;
+ const banks = Array.from({ length: 3 }, () => ({ count: 3, active: 2, axes: true,
+ table: [400, 800, 800, 1600, 1600, 3200, 3200, 6400, 6400, 6400, 30000, 30000] }));
+ const sent: Uint8Array[] = [];
+ const device = { vendorId: 0x373e, productId, productName: "LUNA33", opened: true, collections: [],
+ open: async () => {}, close: async () => {},
+ sendFeatureReport: async (_id: number, report: Uint8Array) => {
+ const packet = new Uint8Array(report); sent.push(packet);
+ const key = `${packet[4]}:${packet[5]}`;
+ if (key === "0:5") profile = packet[6];
+ const bank = banks[packet[6] - 1];
+ if (!bank) return;
+ if (key === "1:2") bank.active = packet[7];
+ if (key === "1:13") bank.axes = packet[7] === 1;
+ if (key === "1:1") {
+ bank.count = packet[7];
+ bank.table = Array.from({ length: 12 }, (_, i) => (packet[8 + i * 2] << 8) | packet[9 + i * 2]);
+ }
+ },
+ receiveFeatureReport: async () => {
+ const packet = sent.at(-1)!;
+ const key = `${packet[4]}:${packet[5]}`, requested = packet[6];
+ const bank = banks[requested - 1] ?? banks[profile - 1];
+ let payload = [requested, 0];
+ if (key === "0:129") payload = [0, 0, 26, 0];
+ if (key === "0:131") payload = [0, 90];
+ if (key === "0:133") payload = [profile];
+ if (key === "0:135") payload = [requested, 1, 44];
+ if (key === "0:159") payload = [requested, 1];
+ if (key === "1:128" || key === "1:136") payload = [requested, 1];
+ if (key === "1:129") payload = [requested, bank.count, ...bank.table.flatMap(value => [value >> 8, value & 255])];
+ if (key === "1:130") payload = [requested, bank.active];
+ if (key === "1:141") payload = [requested, +bank.axes];
+ const reply = new Uint8Array(64);
+ reply[0] = 0xa1; reply[3] = payload.length; reply[4] = packet[4]; reply[5] = packet[5]; reply.set(payload, 6);
+ return new DataView(reply.buffer);
+ },
+ };
+ return { device, banks, sent };
+}
+
+for (const pid of [0x32, 0x33, 0x54, 0x84]) {
+ test(`controller Games selection/restore on ${pid.toString(16)} never writes either DPI table`, async () => {
+ const { device, banks, sent } = fixture(pid);
+ const before = await controller.testConnect(device);
+ const original = [...banks[0].table];
+ const target = { ...before, activeDpiStage: 2, dpi: 1600, dpiY: 3200 };
+ const saved = controller.testSnapshotDiff(before, target);
+ assert.deepEqual(saved, { activeDpiStage: 2 });
+ const prior = controller.testPickSnapshot(before, saved);
+ assert.equal(await controller.flashGameProfileSnapshot(saved), "written");
+ assert.equal(banks[0].active, 3); assert.deepEqual(banks[0].table, original);
+ assert.equal(await controller.flashGameProfileSnapshot(prior), "written");
+ assert.equal(banks[0].active, 2); assert.deepEqual(banks[0].table, original);
+ assert.equal(sent.some(packet => packet[4] === 1 && packet[5] === 1), false);
+ assert.equal(controller.testState().error, null);
+ });
+}
+
+test("controller restores legacy target-stage edits and preserves unrelated pending user changes", async () => {
+ const { device, banks } = fixture();
+ const before = await controller.testConnect(device);
+ const original = [...banks[0].table];
+ controller.applyLunaFuryDpiStageAxes(0, 1200, 2400);
+ const target = { dpi: 2000, dpiY: 4000, activeDpiStage: 2 };
+ const prior = controller.testPickSnapshot(before, target);
+ await controller.flashGameProfileSnapshot(target);
+ assert.deepEqual(banks[0].table, [400, 800, 800, 1600, 2000, 4000, ...original.slice(6)]);
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]);
+ await controller.flashGameProfileSnapshot(prior);
+ assert.deepEqual(banks[0].table, original); assert.equal(banks[0].active, 2);
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]);
+ assert.deepEqual(controller.testState().preview.dpiStages, [1200, 800, 1600]);
+ assert.equal(controller.testState().error, null);
+});
+
+test("controller applies and restores old selection snapshots without DPI-table writes", async () => {
+ const { device, banks, sent } = fixture();
+ const before = await controller.testConnect(device);
+ const original = [...banks[0].table];
+ const target = { dpi: 1600, dpiY: 3200, activeDpiStage: 2 };
+ const prior = controller.testPickSnapshot(before, target);
+ await controller.flashGameProfileSnapshot(target);
+ assert.equal(banks[0].active, 3);
+ await controller.flashGameProfileSnapshot(prior);
+ assert.equal(banks[0].active, 2); assert.deepEqual(banks[0].table, original);
+ assert.equal(sent.some(packet => packet[4] === 1 && packet[5] === 1), false);
+ assert.equal(controller.testState().error, null);
+});
+
+test("controller restores a Y-only edit and a stage-count shrink with the original selected stage", async () => {
+ const { device, banks } = fixture();
+ const before = await controller.testConnect(device);
+ const original = [...banks[0].table];
+ const target = { dpiStagesY: [1200, 1600, 3200] };
+ await controller.flashGameProfileSnapshot(target);
+ assert.deepEqual(banks[0].table, [400, 1200, ...original.slice(2)]);
+ await controller.flashGameProfileSnapshot(controller.testPickSnapshot(before, target));
+ assert.deepEqual(banks[0].table, original);
+ const fewer = { dpiStages: [400] };
+ const prior = controller.testPickSnapshot(before, fewer);
+ await controller.flashGameProfileSnapshot(fewer);
+ assert.equal(banks[0].count, 1); assert.equal(banks[0].active, 1);
+ await controller.flashGameProfileSnapshot(prior);
+ assert.equal(banks[0].count, 3); assert.equal(banks[0].active, 2);
+ assert.deepEqual(banks[0].table, original);
+ assert.equal(controller.testState().error, null);
+});
+
+test("controller drafts distinguish pure selection from edits to X/Y on another stage", async () => {
+ const { device, banks } = fixture();
+ const before = await controller.testConnect(device);
+ const original = [...banks[0].table];
+ assert.equal(controller.beginGameProfileDraft({}), true);
+ controller.applyActiveDpiStage(2);
+ assert.deepEqual(controller.gameProfileDraftSnapshot(), { activeDpiStage: 2 });
+ controller.applyLunaFuryDpiStageAxes(2, 2000, 4000);
+ const saved = controller.gameProfileDraftSnapshot();
+ assert.deepEqual(saved, { dpiStages: [400, 800, 2000], dpiStagesY: [800, 1600, 4000], activeDpiStage: 2 });
+ controller.endGameProfileDraft();
+ assert.deepEqual(banks[0].table, original, "drafts must not send writes");
+ await controller.flashGameProfileSnapshot(saved);
+ assert.deepEqual(banks[0].table.slice(0, 6), [400, 800, 800, 1600, 2000, 4000]);
+ await controller.flashGameProfileSnapshot(controller.testPickSnapshot(before, saved));
+ assert.deepEqual(banks[0].table, original); assert.equal(banks[0].active, 2);
+ assert.equal(controller.testState().error, null);
+});
+
+test("controller restores the axis mode and dormant Y table after a combined X edit", async () => {
+ const { device, banks } = fixture();
+ banks[0].axes = false;
+ const before = await controller.testConnect(device);
+ const original = [...banks[0].table];
+ const target = { dpiStages: [400, 1200, 1600], lunafury: { separateDpiAxes: true } };
+ const prior = controller.testPickSnapshot(before, target);
+ await controller.flashGameProfileSnapshot(target);
+ assert.equal(banks[0].axes, true);
+ assert.deepEqual(banks[0].table, [400, 800, 1200, 1600, ...original.slice(4)]);
+ await controller.flashGameProfileSnapshot(prior);
+ assert.equal(banks[0].axes, false); assert.deepEqual(banks[0].table, original);
+ assert.equal(controller.testState().error, null);
+});
diff --git a/docs/lunafury-development.md b/docs/lunafury-development.md
index 89ba1572..a8f8a276 100644
--- a/docs/lunafury-development.md
+++ b/docs/lunafury-development.md
@@ -41,3 +41,17 @@ The protocol repository contains the packet evidence and hardware checklist
in [`docs/lunafury-testing.md`](https://github.com/sekiranunn/mouse-protocol/blob/codex/lunafury-onboard-controls/docs/lunafury-testing.md).
This public branch uses generic artwork and excludes the private test-site
files and vendor images.
+
+## DPI and bank-operation regressions
+
+Games snapshots now save a stage selection independently of its displayed
+DPI. Older scalar snapshots address the selected target stage; actual edits
+back up both tables so restoration can undo them. The controller integration
+tests use its real draft, staging, Apply and restore paths with a synthetic
+HID device, including pending user edits and stage-count changes.
+
+The paired protocol branch refreshes the active DPI stage and both tables
+during LunaFury live reads. Its older bank-scoped setters and status reads
+now share the queue used by bank switches and the newer setters. Other
+brands retain their existing execution paths. These regression checks are
+automated; they do not add a new hardware-verification result.
diff --git a/src/device/controller.ts b/src/device/controller.ts
index 3be21659..b7d8cbbe 100644
--- a/src/device/controller.ts
+++ b/src/device/controller.ts
@@ -15,6 +15,7 @@ import { closestDpiOption } from "../dpi-presets";
import { lunafurySettingLabel, lunafurySettingPriority } from "./lunafury-labels.ts";
import { lunaFuryLightningPriority, stageLunaFuryProfile } from "./lunafury-profile.ts";
import { canEditLunaFuryMapping, pickLunaFuryMappings } from "./lunafury-buttons.ts";
+import { stageLunaFuryGameDpi } from "./lunafury-game-dpi.ts";
import { formatHex, hidTraffic, isMark, markHidActivity, startHidCapture, type HidTrafficEntry } from "../hid-diagnostics";
import {
clearPendingChanges,
@@ -1492,7 +1493,11 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void {
// the snapshot: a plain DPI change on a stage-aware mouse also rewrites the
// active entry of dpiStages, so both fields can be present.
const stageEditor = status.ui?.dpiStageEditor;
- if (stageEditor && status.dpiStages?.length) {
+ // LunaFury also accepts older snapshots carrying a selected stage's scalars.
+ const lunaDpi = stageLunaFuryGameDpi(status, s, {
+ count: applyDpiStageCount, axes: applyLunaFuryDpiStageAxes, active: applyActiveDpiStage,
+ });
+ if (!lunaDpi && stageEditor && status.dpiStages?.length) {
if (s.dpiStages?.length) {
if (stageEditor.countEditable === true && s.dpiStages.length !== status.dpiStages.length) {
applyDpiStageCount(s.dpiStages.length);
@@ -1509,7 +1514,7 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void {
}
s.dpiStageColors?.forEach((color, stage) => applyDpiStageColor(stage, color));
if (s.activeDpiStage != null) applyActiveDpiStage(s.activeDpiStage);
- } else if (s.dpi != null) {
+ } else if (!lunaDpi && s.dpi != null) {
if (s.dpiY != null && s.dpiY !== s.dpi && status.supportsSeparateDpiAxes) applyLogitechAxisDpi(s.dpi, s.dpiY);
else applyDpiValue(s.dpi);
}
diff --git a/src/device/game-profile-snapshot.ts b/src/device/game-profile-snapshot.ts
index 67e4e7bb..604aeb55 100644
--- a/src/device/game-profile-snapshot.ts
+++ b/src/device/game-profile-snapshot.ts
@@ -1,6 +1,7 @@
import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
import { diffLunaFurySettings, isLunaFuryProfileField, pickLunaFurySettings, sanitizeLunaFurySettings } from "./lunafury-profile.ts";
import { pickLunaFuryMappings } from "./lunafury-buttons.ts";
+import { preserveLunaFuryDpiTables, withoutDerivedLunaFuryDpi } from "./lunafury-game-dpi.ts";
/**
* The device settings a per-game profile can carry. A game profile is stored
@@ -116,10 +117,10 @@ export function snapshotDiff(before: MouseStatus, after: MouseStatus): GameProfi
if (Object.keys(mappings).length) diff.buttonMappings = mappings;
} else if (!same(before[field], after[field])) diff[field] = structuredClone(after[field]);
}
- return diff as GameProfileSnapshot;
+ return withoutDerivedLunaFuryDpi(after, diff as GameProfileSnapshot);
}
-/** `status`'s current values for exactly the fields `snapshot` sets. */
+/** Prior values for the profile's writes; LunaFury scalar/count edits also back up their table storage. */
export function pickSnapshot(status: MouseStatus, snapshot: GameProfileSnapshot): GameProfileSnapshot {
const picked: Record = {};
for (const field of Object.keys(snapshot)) {
@@ -137,6 +138,7 @@ export function pickSnapshot(status: MouseStatus, snapshot: GameProfileSnapshot)
const value = status[field];
if (value !== undefined) picked[field] = structuredClone(value);
}
+ preserveLunaFuryDpiTables(status, snapshot, picked as GameProfileSnapshot);
return picked as GameProfileSnapshot;
}
@@ -211,5 +213,5 @@ export function draftProfileSnapshot(before: MouseStatus, preview: MouseStatus,
Object.assign(snapshot, { [field]: structuredClone(preview[field]) });
}
}
- return snapshot;
+ return withoutDerivedLunaFuryDpi(preview, snapshot);
}
diff --git a/src/device/lunafury-game-dpi.test.ts b/src/device/lunafury-game-dpi.test.ts
new file mode 100644
index 00000000..aa6a6f26
--- /dev/null
+++ b/src/device/lunafury-game-dpi.test.ts
@@ -0,0 +1,78 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
+import { draftProfileSnapshot, mergeSnapshots, pickSnapshot, snapshotDiff } from "./game-profile-snapshot.ts";
+import { lunaFuryGameDpiPlan, stageLunaFuryGameDpi } from "./lunafury-game-dpi.ts";
+
+function status(): MouseStatus {
+ return { brand: "LunaFury", name: "LUNA33", batteryPercent: 90, batteryState: "Discharging", firmware: [],
+ dpi: 800, dpiY: 1600, activeDpiStage: 1, dpiStages: [400, 800, 1600], dpiStagesY: [800, 1600, 3200],
+ pollingRateHz: 1000, lunafury: { separateDpiAxes: true },
+ ui: { dpiStageEditor: { maxStages: 6, countEditable: true, minDpi: 50, maxDpi: 30000, stepDpi: 50 } } };
+}
+
+test("a LunaFury selection diff and touched draft save only the stage, not derived DPI", () => {
+ const before = status();
+ const after = { ...before, activeDpiStage: 2, dpi: 1600, dpiY: 3200 };
+ assert.deepEqual(snapshotDiff(before, after), { activeDpiStage: 2 });
+ assert.deepEqual(draftProfileSnapshot(before, after, ["dpi", "dpiY", "activeDpiStage"]), { activeDpiStage: 2 });
+ assert.deepEqual(pickSnapshot(before, snapshotDiff(before, after)), { activeDpiStage: 1 });
+ assert.deepEqual(lunaFuryGameDpiPlan(before, { dpi: 1600, dpiY: 3200, activeDpiStage: 2 }), { stages: [], activeStage: 2 });
+});
+
+test("legacy DPI scalars edit and back up the target stage when selecting another stage", () => {
+ const before = status();
+ const target = { dpi: 2000, dpiY: 4000, activeDpiStage: 2 };
+ assert.deepEqual(lunaFuryGameDpiPlan(before, target), { stages: [{ stage: 2, x: 2000, y: 4000 }], activeStage: 2 });
+ assert.deepEqual(pickSnapshot(before, target), { dpiStages: [400, 800, 1600], dpiStagesY: [800, 1600, 3200], activeDpiStage: 1 });
+ const after = { ...before, dpi: 2000, dpiY: 4000, activeDpiStage: 2, dpiStages: [400, 800, 2000], dpiStagesY: [800, 1600, 4000] };
+ assert.deepEqual(lunaFuryGameDpiPlan(after, pickSnapshot(before, target)), { stages: [{ stage: 2, x: 1600, y: 3200 }], activeStage: 1 });
+});
+
+test("Y-only legacy edits restore their table without copying X over unrelated Y", () => {
+ const before = status();
+ assert.deepEqual(lunaFuryGameDpiPlan(before, { dpiY: 2400 }), { stages: [{ stage: 1, x: 800, y: 2400 }] });
+ assert.deepEqual(pickSnapshot(before, { dpiY: 2400 }), { dpiStages: before.dpiStages, dpiStagesY: before.dpiStagesY });
+ assert.deepEqual(lunaFuryGameDpiPlan(before, { dpiStagesY: [1200, 1600, 3200] }), { stages: [{ stage: 0, x: 400, y: 1200 }] });
+});
+
+test("table edits keep their explicit stage target and omit redundant displayed DPI", () => {
+ const before = status();
+ const after = { ...before, dpiStages: [400, 800, 2000], dpiStagesY: [800, 1600, 4000], activeDpiStage: 2, dpi: 2000, dpiY: 4000 };
+ const saved = snapshotDiff(before, after);
+ assert.deepEqual(saved, { dpiStages: after.dpiStages, dpiStagesY: after.dpiStagesY, activeDpiStage: 2 });
+ assert.deepEqual(lunaFuryGameDpiPlan(before, saved), { stages: [{ stage: 2, x: 2000, y: 4000 }], activeStage: 2 });
+ const switched = mergeSnapshots(pickSnapshot(before, saved), { dpi: 2400, dpiY: 4800, activeDpiStage: 0 });
+ assert.deepEqual(lunaFuryGameDpiPlan(after, switched), {
+ stages: [{ stage: 0, x: 2400, y: 4800 }, { stage: 2, x: 1600, y: 3200 }], activeStage: 0,
+ });
+});
+
+test("stage-count restore includes both axes and the old active stage", () => {
+ const before = status();
+ const saved = { dpiStages: [400] };
+ assert.deepEqual(pickSnapshot(before, saved), { dpiStages: before.dpiStages, dpiStagesY: before.dpiStagesY, activeDpiStage: 1 });
+ const calls: unknown[] = [];
+ assert.equal(stageLunaFuryGameDpi(before, { dpiStages: [400, 800, 1600, 2000], activeDpiStage: 3 }, {
+ count: (count) => calls.push(["count", count]), axes: (...args) => calls.push(["axes", ...args]), active: (stage) => calls.push(["active", stage]),
+ }), true);
+ assert.deepEqual(calls, [["count", 4], ["axes", 3, 2000, 2000], ["active", 3]]);
+});
+
+test("linked scalar edits update X/Y together without changing peers; other brands keep their policy", () => {
+ const before = { ...status(), lunafury: { separateDpiAxes: false } };
+ assert.deepEqual(lunaFuryGameDpiPlan(before, { dpi: 1200 }), { stages: [{ stage: 1, x: 1200, y: 1200 }] });
+ const other = { ...before, brand: "Logitech" };
+ assert.equal(lunaFuryGameDpiPlan(other, { dpi: 1600 }), null);
+ assert.deepEqual(snapshotDiff(other, { ...other, dpi: 1600, activeDpiStage: 2 }), { dpi: 1600, activeDpiStage: 2 });
+});
+
+test("combined axis-mode and X-table changes restore dormant Y values under axis lock", () => {
+ const before = { ...status(), lunafury: { separateDpiAxes: false } };
+ const target = { dpiStages: [400, 1200, 1600], lunafury: { separateDpiAxes: true } };
+ assert.deepEqual(lunaFuryGameDpiPlan(before, target), { stages: [{ stage: 1, x: 1200, y: 1600 }] });
+ const prior = pickSnapshot(before, target);
+ assert.deepEqual(prior, { dpiStages: before.dpiStages, dpiStagesY: before.dpiStagesY, lunafury: { separateDpiAxes: false } });
+ const after = { ...before, dpiStages: target.dpiStages, lunafury: target.lunafury };
+ assert.deepEqual(lunaFuryGameDpiPlan(after, prior), { stages: [{ stage: 1, x: 800, y: 1600 }] });
+});
diff --git a/src/device/lunafury-game-dpi.ts b/src/device/lunafury-game-dpi.ts
new file mode 100644
index 00000000..1d1397d6
--- /dev/null
+++ b/src/device/lunafury-game-dpi.ts
@@ -0,0 +1,62 @@
+import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
+import type { GameProfileSnapshot } from "./game-profile-snapshot.ts";
+
+export interface LunaFuryGameDpiPlan {
+ count?: number;
+ stages: Array<{ stage: number; x: number; y: number }>;
+ activeStage?: number;
+}
+
+/** Scalars in older snapshots describe the target stage, not the old active one. */
+export function lunaFuryGameDpiPlan(status: MouseStatus, snapshot: GameProfileSnapshot): LunaFuryGameDpiPlan | null {
+ if (status.brand !== "LunaFury" || !status.ui?.dpiStageEditor || !status.dpiStages?.length || !status.dpiStagesY?.length) return null;
+ const separate = snapshot.lunafury?.separateDpiAxes ?? status.lunafury?.separateDpiAxes;
+ const xs = snapshot.dpiStages?.length ? [...snapshot.dpiStages] : [...status.dpiStages];
+ const ys = xs.map((x, stage) => snapshot.dpiStagesY?.[stage]
+ ?? (x !== status.dpiStages![stage] && separate === false ? x : status.dpiStagesY![stage] ?? x));
+ const target = snapshot.activeDpiStage ?? status.activeDpiStage ?? 0;
+ if (Number.isInteger(target) && target >= 0 && target < xs.length) {
+ if (snapshot.dpi != null) xs[target] = snapshot.dpi;
+ if (snapshot.dpiY != null) ys[target] = snapshot.dpiY;
+ else if (snapshot.dpi != null && separate === false && snapshot.dpi !== status.dpiStages[target]) ys[target] = snapshot.dpi;
+ }
+ return {
+ ...(xs.length !== status.dpiStages.length && status.ui.dpiStageEditor.countEditable === true ? { count: xs.length } : {}),
+ stages: xs.flatMap((x, stage) => x === status.dpiStages![stage] && ys[stage] === status.dpiStagesY![stage]
+ ? [] : [{ stage, x, y: ys[stage] }]),
+ ...(snapshot.activeDpiStage != null ? { activeStage: snapshot.activeDpiStage } : {}),
+ };
+}
+
+export function stageLunaFuryGameDpi(status: MouseStatus, snapshot: GameProfileSnapshot, actions: {
+ count(count: number): void;
+ axes(stage: number, x: number, y: number): void;
+ active(stage: number): void;
+}): boolean {
+ const plan = lunaFuryGameDpiPlan(status, snapshot);
+ if (!plan) return false;
+ if (plan.count != null) actions.count(plan.count);
+ for (const { stage, x, y } of plan.stages) actions.axes(stage, x, y);
+ if (plan.activeStage != null) actions.active(plan.activeStage);
+ return true;
+}
+
+/** Stage selection changes the displayed DPI without editing either table. */
+export function withoutDerivedLunaFuryDpi(status: MouseStatus, snapshot: GameProfileSnapshot): GameProfileSnapshot {
+ if (!lunaFuryGameDpiPlan(status, {}) || !(snapshot.dpiStages?.length || snapshot.dpiStagesY?.length || snapshot.activeDpiStage != null)) return snapshot;
+ const { dpi: _dpi, dpiY: _dpiY, ...rest } = snapshot;
+ return rest;
+}
+
+/** X/scalar edits also back up Y storage, including values dormant under axis lock. */
+export function preserveLunaFuryDpiTables(status: MouseStatus, snapshot: GameProfileSnapshot, picked: GameProfileSnapshot): void {
+ const plan = lunaFuryGameDpiPlan(status, snapshot);
+ if (!plan) return;
+ if (((snapshot.dpi != null || snapshot.dpiY != null || snapshot.dpiStages?.length) && plan.stages.length > 0) || plan.count != null) {
+ picked.dpiStages = [...status.dpiStages!];
+ picked.dpiStagesY = [...status.dpiStagesY!];
+ delete picked.dpi;
+ delete picked.dpiY;
+ }
+ if (plan.count != null && status.activeDpiStage != null) picked.activeDpiStage = status.activeDpiStage;
+}
From 00f967224587d604a544b1177964a358aece4bb6 Mon Sep 17 00:00:00 2001
From: Breakdown <169538186+RINZE1@users.noreply.github.com>
Date: Sat, 10 Oct 2026 23:04:48 +0800
Subject: [PATCH 03/14] fix(lunafury): preserve Games DPI targets and hidden
slots
Resolve lower-priority legacy DPI scalars before merging a new Games target. Restore complete stored DPI slots after temporary stage expansion, keep backups out of saved profiles and imports, and update regression and release documentation.
---
README.md | 7 +-
build/lunafury-controller.test.ts | 46 ++++++++++++-
docs/lunafury-development.md | 99 +++++++++++++++++++++-------
src/app/useBridgeProfileApplier.ts | 4 +-
src/device/controller.ts | 20 ++++++
src/device/game-profile-snapshot.ts | 13 +++-
src/device/lunafury-game-dpi.test.ts | 32 ++++++++-
src/device/lunafury-game-dpi.ts | 38 +++++++++--
8 files changed, 221 insertions(+), 38 deletions(-)
diff --git a/README.md b/README.md
index cad03cf8..a30c0957 100644
--- a/README.md
+++ b/README.md
@@ -9,9 +9,10 @@ This branch is deployed as the public development control panel.
## Development
-For this LunaFury feature branch, first follow the
-[paired protocol setup](docs/lunafury-development.md). Its new protocol APIs
-are not yet available in the pinned npm release.
+The LunaFury controls on this branch require the
+[paired protocol setup](docs/lunafury-development.md). The pinned npm release
+does not contain the new APIs. That guide also covers the release order and
+the remaining checks before the application PR can be merged.
```bash
npm install
diff --git a/build/lunafury-controller.test.ts b/build/lunafury-controller.test.ts
index 561264b9..81377fbd 100644
--- a/build/lunafury-controller.test.ts
+++ b/build/lunafury-controller.test.ts
@@ -34,7 +34,8 @@ before(async () => {
return { status: latestDeviceStatus, preview: withPendingChanges(latestDeviceStatus),
pending: pendingChanges().map(change => change.key), error: lastDiagnosticError };
}
- export { snapshotDiff as testSnapshotDiff, pickSnapshot as testPickSnapshot } from "./game-profile-snapshot.ts";
+ export { snapshotDiff as testSnapshotDiff, pickSnapshot as testPickSnapshot,
+ mergeSnapshots as testMergeSnapshots } from "./game-profile-snapshot.ts";
`;
} }],
build: { write: false, minify: false, lib: { entry: controllerPath, formats: ["es"] },
@@ -188,3 +189,46 @@ test("controller restores the axis mode and dormant Y table after a combined X e
assert.equal(banks[0].axes, false); assert.deepEqual(banks[0].table, original);
assert.equal(controller.testState().error, null);
});
+
+for (const reverse of [false, true]) {
+ test(`controller switches ${reverse ? "table to legacy" : "legacy to table"} Games snapshots and restores the originals`, async () => {
+ const { device, banks } = fixture();
+ const before = await controller.testConnect(device);
+ const original = [...banks[0].table];
+ const legacy = { dpi: 1600, dpiY: 3200, activeDpiStage: 2 };
+ const table = { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] };
+ const first = reverse ? table : legacy, second = reverse ? legacy : table;
+ const prior = controller.testPickSnapshot(before, first);
+ await controller.flashGameProfileSnapshot(first);
+ const afterFirst = controller.testState().status;
+ const allPrior = controller.testMergeSnapshots(controller.testPickSnapshot(afterFirst, second), prior, afterFirst);
+ const toWrite = controller.testMergeSnapshots(prior, second, afterFirst);
+ await controller.flashGameProfileSnapshot(toWrite);
+ assert.deepEqual(controller.testState().status.dpiStages, reverse ? before.dpiStages : table.dpiStages);
+ assert.deepEqual(controller.testState().status.dpiStagesY, reverse ? before.dpiStagesY : table.dpiStagesY);
+ await controller.flashGameProfileSnapshot(allPrior);
+ assert.deepEqual(banks[0].table, original); assert.equal(banks[0].active, 2);
+ assert.equal(controller.testState().error, null);
+ });
+}
+
+for (const pid of [0x32, 0x33, 0x54, 0x84]) {
+ test(`controller Games expansion on ${pid.toString(16)} restores hidden slots before re-enabling them`, async () => {
+ const { device, banks } = fixture(pid);
+ const before = await controller.testConnect(device);
+ const original = [...banks[0].table];
+ const target = { dpiStages: [400, 800, 1600, 2000], dpiStagesY: [800, 1600, 3200, 4000] };
+ const prior = controller.testPickSnapshot(before, target);
+ await controller.flashGameProfileSnapshot(target);
+ assert.equal(banks[0].count, 4); assert.equal(banks[0].table[6], 2000);
+ await controller.flashGameProfileSnapshot(prior);
+ assert.equal(banks[0].count, 3); assert.equal(banks[0].active, 2);
+ assert.deepEqual(banks[0].table, original, "restore must include disabled slots and TYPE33 trailing bytes");
+ controller.applyDpiStageCount(4);
+ await controller.flashPendingChanges();
+ assert.equal(controller.testState().status.dpiStages[3], 3200);
+ assert.equal(controller.testState().status.dpiStagesY[3], 6400);
+ assert.deepEqual(banks[0].table, original);
+ assert.equal(controller.testState().error, null);
+ });
+}
diff --git a/docs/lunafury-development.md b/docs/lunafury-development.md
index a8f8a276..6c561176 100644
--- a/docs/lunafury-development.md
+++ b/docs/lunafury-development.md
@@ -1,10 +1,11 @@
-# LunaFury branch development
+# LunaFury development and release
-This branch uses new APIs from the matching `mouse-protocol` branch. The app
-still pins the official `@openmouse/protocol` release, `0.27.0`, so running
-`npm ci` alone does not install those APIs. The existing CI workflow also
-uses that release; it cannot validate this branch until the protocol changes
-are published and the dependency is updated.
+The onboard controls, button remapping and receiver settings on this branch
+use new APIs from the matching `mouse-protocol` branch. The app still pins
+`@openmouse/protocol` at `0.27.0`, which does not contain those APIs. A plain
+`npm ci` restores that release and the application build fails. Use the
+paired setup below for local review; the application PR must remain a draft
+until a published protocol release provides the APIs.
## Local setup
@@ -25,33 +26,87 @@ npm run size
npm run dev
```
-`0.1.0` is the protocol checkout's current local package version. If it
-changes, use the filename printed by `npm pack`. The local install leaves
-the app's manifest and lockfile unchanged. Repeat it after any app `npm ci`
-or protocol source change; rebuild and repack the protocol first when needed.
+`0.1.0` is the protocol checkout's local package version, not the version of
+the published release. If it changes, use the filename printed by `npm pack`.
+This install leaves the app's manifest and lockfile unchanged. Repeat it
+after any app `npm ci`. After a protocol source change, rebuild and repack
+before reinstalling it in the app.
+
+## Release order
+
+1. Merge the protocol changes first. The protocol repository uses
+ Conventional Commits and semantic-release; a `feat:` squash title allows
+ its release workflow to publish the added APIs after `main` passes CI.
+2. Confirm that the published package contains the new LunaFury exports and
+ driver methods. A newer version number alone does not establish that.
+3. Update the app's protocol dependency to that exact stable npm version and
+ regenerate the lockfile. Do not commit the local tarball override or a
+ Git/file dependency. The approved runtime-dependency policy is unchanged.
+4. Run a clean `npm ci`, `npm run check`, `npm run size` and the CI audit
+ against the registry package, without reinstalling the local tarball.
+ Remove the branch-only README warning and replace this setup note when
+ the published dependency works. Then make the app PR ready for review.
+
+Both topic branches also need to incorporate the current upstream `main`
+before final validation. The checks below describe the existing paired
+branches, not a future upstream merge or published-package installation.
## Hardware status
-The owner confirmed the existing LunaFury controls, onboard profiles, button
-remapping, receiver lighting, sleep and revised DPI editor on LUNA33 firmware
-`0.0.26.0`, over cable and the 8K receiver. The new bottom-button power/DPI
-behavior still needs hardware testing; TYPE33 also remains unverified.
+The owner tested LUNA33 firmware `0.0.26.0` over cable and the 8K receiver.
+They confirmed the existing LunaFury controls, button polling, all three
+onboard profiles, button remapping, sleep and the revised DPI editor. All
+four receiver light modes worked over the 8K receiver, and cable connections
+did not show the lighting control. Reconnect readback was also confirmed.
+The new bottom-button power/DPI behavior still needs hardware testing;
+TYPE33 remains unverified. Games apply/restore has automated coverage but
+has not received a separate hardware result.
The protocol repository contains the packet evidence and hardware checklist
in [`docs/lunafury-testing.md`](https://github.com/sekiranunn/mouse-protocol/blob/codex/lunafury-onboard-controls/docs/lunafury-testing.md).
This public branch uses generic artwork and excludes the private test-site
files and vendor images.
-## DPI and bank-operation regressions
+## Automated validation
+
+The paired application build and all 334 application tests pass, including
+15 controller integration tests. Bundle checks pass at 205,473 / 210,000
+bytes for CSS and 2,119,670 / 2,150,000 bytes for JavaScript. These results
+use the local protocol tarball; they do not establish that a registry-only
+install builds.
+
+The protocol build and all 151 Lamzu driver/codec tests pass. The broader
+protocol regression run on 2026-10-10 passed 2,252 tests but excluded two
+registry exhaustive checks: `the probe matrix can trigger every driver` and
+`no device can be claimed by more than one driver`. A full protocol CI pass
+is still required before merge.
+
+### DPI and bank-operation regressions
+
+Games snapshots distinguish selecting a stage from editing its DPI. Older
+scalar snapshots address the selected target stage. When games switch,
+the lower-priority legacy scalars are resolved into tables before the new
+target is merged. New tables therefore override prior restore values;
+legacy targets still override restore tables in the opposite direction.
+
+DPI edits and count changes back up the complete five- or six-slot X/Y
+storage separately from the enabled count. Restore puts dormant slots back
+before applying table edits and selection. Re-enabling a hidden slot then
+reads its original value, including Y values stored while the axes are
+linked. This backup is held in memory and excluded from saved Games
+snapshots and imports. The protocol rejects a backup for another bank and
+verifies every stored slot on readback.
-Games snapshots now save a stage selection independently of its displayed
-DPI. Older scalar snapshots address the selected target stage; actual edits
-back up both tables so restoration can undo them. The controller integration
-tests use its real draft, staging, Apply and restore paths with a synthetic
-HID device, including pending user edits and stage-count changes.
+The controller tests use the real draft, staging, Apply and restore paths
+with synthetic HID devices. They cover both cross-format switch directions,
+expansion followed by restore and re-enabling on all four runtime PIDs,
+pending user edits, shrinking counts and combined axis-mode/DPI edits.
+The two cross-format cases and four expansion cases failed before the
+2026-10-10 fixes and pass with them. The Bridge hook itself is not mounted
+by this harness; the tests call the same merge functions and controller path.
The paired protocol branch refreshes the active DPI stage and both tables
during LunaFury live reads. Its older bank-scoped setters and status reads
now share the queue used by bank switches and the newer setters. Other
-brands retain their existing execution paths. These regression checks are
-automated; they do not add a new hardware-verification result.
+brands retain their existing execution paths. These tests use simulated
+devices and do not add a hardware-verification result.
diff --git a/src/app/useBridgeProfileApplier.ts b/src/app/useBridgeProfileApplier.ts
index 77e714c0..bed80394 100644
--- a/src/app/useBridgeProfileApplier.ts
+++ b/src/app/useBridgeProfileApplier.ts
@@ -82,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 = mergeSnapshots(pickSnapshot(status, target), priorSettings.current ?? {});
+ const prior = mergeSnapshots(pickSnapshot(status, target), priorSettings.current ?? {}, status);
// 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 = mergeSnapshots(priorSettings.current ?? {}, target);
+ const toWrite = mergeSnapshots(priorSettings.current ?? {}, target, status);
writing.current = true;
void control.flashGameProfileSnapshot(toWrite).then((result) => {
diff --git a/src/device/controller.ts b/src/device/controller.ts
index b7d8cbbe..ffc2c6dc 100644
--- a/src/device/controller.ts
+++ b/src/device/controller.ts
@@ -16,6 +16,7 @@ import { lunafurySettingLabel, lunafurySettingPriority } from "./lunafury-labels
import { lunaFuryLightningPriority, stageLunaFuryProfile } from "./lunafury-profile.ts";
import { canEditLunaFuryMapping, pickLunaFuryMappings } from "./lunafury-buttons.ts";
import { stageLunaFuryGameDpi } from "./lunafury-game-dpi.ts";
+import type { LunaFuryDpiStorage } from "@openmouse/protocol/lamzu";
import { formatHex, hidTraffic, isMark, markHidActivity, startHidCapture, type HidTrafficEntry } from "../hid-diagnostics";
import {
clearPendingChanges,
@@ -1495,6 +1496,7 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void {
const stageEditor = status.ui?.dpiStageEditor;
// LunaFury also accepts older snapshots carrying a selected stage's scalars.
const lunaDpi = stageLunaFuryGameDpi(status, s, {
+ storage: stageLunaFuryDpiStorage,
count: applyDpiStageCount, axes: applyLunaFuryDpiStageAxes, active: applyActiveDpiStage,
});
if (!lunaDpi && stageEditor && status.dpiStages?.length) {
@@ -1612,6 +1614,24 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void {
});
}
+/** Internal Games backup; never available as a saved/imported control. */
+function stageLunaFuryDpiStorage(storage: LunaFuryDpiStorage): void {
+ const saved = structuredClone(storage);
+ stageChange({
+ key: "lunafury-dpi-storage", priority: -20,
+ label: "Restore stored DPI slots", command: "Restore complete onboard DPI storage",
+ progress: "Restoring DPI storage…",
+ preview: (status) => {
+ status.dpiStages = saved.stages.slice(0, saved.count).map(stage => stage.x);
+ status.dpiStagesY = saved.stages.slice(0, saved.count).map(stage => stage.y);
+ status.activeDpiStage = Math.min(status.activeDpiStage ?? 0, saved.count - 1);
+ status.dpi = status.dpiStages[status.activeDpiStage]; status.dpiY = status.dpiStagesY[status.activeDpiStage];
+ status.lunafury = { ...status.lunafury, dpiStorage: structuredClone(saved) };
+ },
+ apply: () => callClientMethod("setLunaFuryDpiStorage", "stored DPI slots", saved),
+ });
+}
+
export async function requestHostSwitch(slot: number): Promise {
if (blockedByGameProfileDraft()) return;
const client = logitechClient();
diff --git a/src/device/game-profile-snapshot.ts b/src/device/game-profile-snapshot.ts
index 604aeb55..da7500fc 100644
--- a/src/device/game-profile-snapshot.ts
+++ b/src/device/game-profile-snapshot.ts
@@ -1,7 +1,8 @@
import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
+import type { LunaFuryDpiStorage } from "@openmouse/protocol/lamzu";
import { diffLunaFurySettings, isLunaFuryProfileField, pickLunaFurySettings, sanitizeLunaFurySettings } from "./lunafury-profile.ts";
import { pickLunaFuryMappings } from "./lunafury-buttons.ts";
-import { preserveLunaFuryDpiTables, withoutDerivedLunaFuryDpi } from "./lunafury-game-dpi.ts";
+import { canonicalLunaFuryDpiSnapshot, preserveLunaFuryDpiTables, withoutDerivedLunaFuryDpi } from "./lunafury-game-dpi.ts";
/**
* The device settings a per-game profile can carry. A game profile is stored
@@ -85,7 +86,10 @@ export const GAME_PROFILE_FIELDS = [
] as const satisfies readonly (keyof MouseStatus)[];
export type GameProfileField = (typeof GAME_PROFILE_FIELDS)[number];
-export type GameProfileSnapshot = Partial>;
+export type GameProfileSnapshot = Partial> & {
+ /** In-memory restoration only; sanitized imports and saved drafts exclude it. */
+ lunaFuryDpiStorage?: LunaFuryDpiStorage;
+};
const FIELD_SET: ReadonlySet = new Set(GAME_PROFILE_FIELDS);
@@ -143,7 +147,10 @@ export function pickSnapshot(status: MouseStatus, snapshot: GameProfileSnapshot)
}
/** Merge per-control namespaces when games switch, not entire device settings. */
-export function mergeSnapshots(base: GameProfileSnapshot, override: GameProfileSnapshot): GameProfileSnapshot {
+export function mergeSnapshots(base: GameProfileSnapshot, override: GameProfileSnapshot, status?: MouseStatus): GameProfileSnapshot {
+ // Normalize the lower-priority legacy values before merging a newer table.
+ // Override scalars still apply after the base table when switching the other way.
+ if (status) base = canonicalLunaFuryDpiSnapshot(status, base);
const merged = { ...base, ...override };
if (base.lunafury || override.lunafury) merged.lunafury = { ...base.lunafury, ...override.lunafury };
if (base.buttonMappings || override.buttonMappings) merged.buttonMappings = { ...base.buttonMappings, ...override.buttonMappings };
diff --git a/src/device/lunafury-game-dpi.test.ts b/src/device/lunafury-game-dpi.test.ts
index aa6a6f26..1aeba0c4 100644
--- a/src/device/lunafury-game-dpi.test.ts
+++ b/src/device/lunafury-game-dpi.test.ts
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
-import { draftProfileSnapshot, mergeSnapshots, pickSnapshot, snapshotDiff } from "./game-profile-snapshot.ts";
+import { draftProfileSnapshot, mergeSnapshots, pickSnapshot, sanitizeSnapshot, snapshotDiff } from "./game-profile-snapshot.ts";
import { lunaFuryGameDpiPlan, stageLunaFuryGameDpi } from "./lunafury-game-dpi.ts";
function status(): MouseStatus {
@@ -76,3 +76,33 @@ test("combined axis-mode and X-table changes restore dormant Y values under axis
const after = { ...before, dpiStages: target.dpiStages, lunafury: target.lunafury };
assert.deepEqual(lunaFuryGameDpiPlan(after, prior), { stages: [{ stage: 1, x: 800, y: 1600 }] });
});
+
+test("LunaFury merges normalize base scalars but keep override scalars above restored tables", () => {
+ const before = status();
+ const base = { dpi: 800, dpiY: 1600, activeDpiStage: 1 };
+ const target = { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] };
+ const saved = structuredClone(base);
+ assert.deepEqual(lunaFuryGameDpiPlan(before, mergeSnapshots(base, target, before)), {
+ stages: [{ stage: 1, x: 1200, y: 2400 }], activeStage: 1,
+ });
+ assert.deepEqual(lunaFuryGameDpiPlan(before, mergeSnapshots(target, { dpi: 2000, dpiY: 4000, activeDpiStage: 0 }, before)), {
+ stages: [{ stage: 0, x: 2000, y: 4000 }, { stage: 1, x: 1200, y: 2400 }], activeStage: 0,
+ });
+ assert.deepEqual(base, saved);
+ assert.deepEqual(mergeSnapshots(base, target, { ...before, brand: "Logitech" }), { ...base, ...target });
+});
+
+test("LunaFury restoration metadata stays out of sanitized imports and saved drafts", () => {
+ const before = status();
+ before.lunafury!.dpiStorage = { profile: 1, count: 3,
+ stages: [400, 800, 1600, 3200, 6400, 30000].map((x, index) => ({ x, y: [800, 1600, 3200, 6400, 6400, 30000][index] })) };
+ const target = { dpiStages: [400, 800, 1600, 2000] };
+ const picked = pickSnapshot(before, target);
+ assert.deepEqual(picked.lunaFuryDpiStorage, before.lunafury!.dpiStorage);
+ assert.notEqual(picked.lunaFuryDpiStorage, before.lunafury!.dpiStorage);
+ assert.equal(sanitizeSnapshot(picked).lunaFuryDpiStorage, undefined);
+ assert.deepEqual(sanitizeSnapshot({ lunafury: { dpiStorage: before.lunafury!.dpiStorage } }), {});
+ const after = structuredClone(before);
+ after.lunafury!.dpiStorage!.stages[3].x = 2000;
+ assert.deepEqual(draftProfileSnapshot(before, after, []), {});
+});
diff --git a/src/device/lunafury-game-dpi.ts b/src/device/lunafury-game-dpi.ts
index 1d1397d6..78f08145 100644
--- a/src/device/lunafury-game-dpi.ts
+++ b/src/device/lunafury-game-dpi.ts
@@ -1,7 +1,9 @@
import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
+import type { LunaFuryDpiStorage } from "@openmouse/protocol/lamzu";
import type { GameProfileSnapshot } from "./game-profile-snapshot.ts";
export interface LunaFuryGameDpiPlan {
+ storage?: LunaFuryDpiStorage;
count?: number;
stages: Array<{ stage: number; x: number; y: number }>;
activeStage?: number;
@@ -10,31 +12,52 @@ export interface LunaFuryGameDpiPlan {
/** Scalars in older snapshots describe the target stage, not the old active one. */
export function lunaFuryGameDpiPlan(status: MouseStatus, snapshot: GameProfileSnapshot): LunaFuryGameDpiPlan | null {
if (status.brand !== "LunaFury" || !status.ui?.dpiStageEditor || !status.dpiStages?.length || !status.dpiStagesY?.length) return null;
+ const storage = snapshot.lunaFuryDpiStorage;
+ const restore = storage && JSON.stringify(storage) !== JSON.stringify(status.lunafury?.dpiStorage);
+ const baselineX = storage ? storage.stages.slice(0, storage.count).map(stage => stage.x) : status.dpiStages;
+ const baselineY = storage ? storage.stages.slice(0, storage.count).map(stage => stage.y) : status.dpiStagesY;
+ const baselineActive = storage ? Math.min(status.activeDpiStage ?? 0, storage.count - 1) : status.activeDpiStage ?? 0;
const separate = snapshot.lunafury?.separateDpiAxes ?? status.lunafury?.separateDpiAxes;
- const xs = snapshot.dpiStages?.length ? [...snapshot.dpiStages] : [...status.dpiStages];
+ const xs = snapshot.dpiStages?.length ? [...snapshot.dpiStages] : [...baselineX];
const ys = xs.map((x, stage) => snapshot.dpiStagesY?.[stage]
- ?? (x !== status.dpiStages![stage] && separate === false ? x : status.dpiStagesY![stage] ?? x));
- const target = snapshot.activeDpiStage ?? status.activeDpiStage ?? 0;
+ ?? (x !== baselineX[stage] && separate === false ? x : baselineY[stage] ?? x));
+ const target = snapshot.activeDpiStage ?? baselineActive;
if (Number.isInteger(target) && target >= 0 && target < xs.length) {
if (snapshot.dpi != null) xs[target] = snapshot.dpi;
if (snapshot.dpiY != null) ys[target] = snapshot.dpiY;
- else if (snapshot.dpi != null && separate === false && snapshot.dpi !== status.dpiStages[target]) ys[target] = snapshot.dpi;
+ else if (snapshot.dpi != null && separate === false && snapshot.dpi !== baselineX[target]) ys[target] = snapshot.dpi;
}
return {
- ...(xs.length !== status.dpiStages.length && status.ui.dpiStageEditor.countEditable === true ? { count: xs.length } : {}),
- stages: xs.flatMap((x, stage) => x === status.dpiStages![stage] && ys[stage] === status.dpiStagesY![stage]
+ ...(restore ? { storage } : {}),
+ ...(xs.length !== baselineX.length && status.ui.dpiStageEditor.countEditable === true ? { count: xs.length } : {}),
+ stages: xs.flatMap((x, stage) => x === baselineX[stage] && ys[stage] === baselineY[stage]
? [] : [{ stage, x, y: ys[stage] }]),
...(snapshot.activeDpiStage != null ? { activeStage: snapshot.activeDpiStage } : {}),
};
}
+/** Resolve lower-priority legacy scalars at their own stage before overlaying tables. */
+export function canonicalLunaFuryDpiSnapshot(status: MouseStatus, snapshot: GameProfileSnapshot): GameProfileSnapshot {
+ if (snapshot.dpi == null && snapshot.dpiY == null) return snapshot;
+ const plan = lunaFuryGameDpiPlan(status, snapshot);
+ if (!plan) return snapshot;
+ const storage = snapshot.lunaFuryDpiStorage;
+ const xs = snapshot.dpiStages?.slice() ?? storage?.stages.slice(0, storage.count).map(stage => stage.x) ?? status.dpiStages!.slice();
+ const ys = xs.map((x, stage) => snapshot.dpiStagesY?.[stage] ?? storage?.stages[stage]?.y ?? status.dpiStagesY![stage] ?? x);
+ for (const { stage, x, y } of plan.stages) { xs[stage] = x; ys[stage] = y; }
+ const { dpi: _dpi, dpiY: _dpiY, ...rest } = snapshot;
+ return { ...rest, dpiStages: xs, dpiStagesY: ys };
+}
+
export function stageLunaFuryGameDpi(status: MouseStatus, snapshot: GameProfileSnapshot, actions: {
+ storage?(storage: LunaFuryDpiStorage): void;
count(count: number): void;
axes(stage: number, x: number, y: number): void;
active(stage: number): void;
}): boolean {
const plan = lunaFuryGameDpiPlan(status, snapshot);
if (!plan) return false;
+ if (plan.storage) actions.storage?.(plan.storage);
if (plan.count != null) actions.count(plan.count);
for (const { stage, x, y } of plan.stages) actions.axes(stage, x, y);
if (plan.activeStage != null) actions.active(plan.activeStage);
@@ -52,6 +75,9 @@ export function withoutDerivedLunaFuryDpi(status: MouseStatus, snapshot: GamePro
export function preserveLunaFuryDpiTables(status: MouseStatus, snapshot: GameProfileSnapshot, picked: GameProfileSnapshot): void {
const plan = lunaFuryGameDpiPlan(status, snapshot);
if (!plan) return;
+ if ((plan.stages.length > 0 || plan.count != null) && status.lunafury?.dpiStorage) {
+ picked.lunaFuryDpiStorage = structuredClone(status.lunafury.dpiStorage);
+ }
if (((snapshot.dpi != null || snapshot.dpiY != null || snapshot.dpiStages?.length) && plan.stages.length > 0) || plan.count != null) {
picked.dpiStages = [...status.dpiStages!];
picked.dpiStagesY = [...status.dpiStagesY!];
From cc1fe9979482ef4eba2068ab73a1e0370ef7a06b Mon Sep 17 00:00:00 2001
From: Breakdown <169538186+RINZE1@users.noreply.github.com>
Date: Sat, 10 Oct 2026 23:44:33 +0800
Subject: [PATCH 04/14] fix(lunafury): compose final DPI storage for Games
switches
Merge complete backups and new Games targets before staging one count/X-Y table write. Avoid dropping matching target edits or sorting them against the previous game. Add the three switch counterexamples across all four runtime PIDs, unit coverage and updated validation notes.
---
build/lunafury-controller.test.ts | 76 ++++++++++++++++++++++++++++
docs/lunafury-development.md | 38 ++++++++++----
src/device/controller.ts | 2 +-
src/device/lunafury-game-dpi.test.ts | 44 ++++++++++++++++
src/device/lunafury-game-dpi.ts | 19 ++++---
5 files changed, 162 insertions(+), 17 deletions(-)
diff --git a/build/lunafury-controller.test.ts b/build/lunafury-controller.test.ts
index 81377fbd..1cf0bb24 100644
--- a/build/lunafury-controller.test.ts
+++ b/build/lunafury-controller.test.ts
@@ -232,3 +232,79 @@ for (const pid of [0x32, 0x33, 0x54, 0x84]) {
assert.equal(controller.testState().error, null);
});
}
+
+for (const pid of [0x32, 0x33, 0x54, 0x84]) {
+ test(`controller game switch on ${pid.toString(16)} keeps DPI shared by both games`, async () => {
+ const { device, banks, sent } = fixture(pid);
+ const before = await controller.testConnect(device);
+ const original = [...banks[0].table];
+ const first = { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] };
+ const second = { ...first, activeDpiStage: 2 };
+ const prior = controller.testPickSnapshot(before, first);
+ await controller.flashGameProfileSnapshot(first);
+ const afterFirst = controller.testState().status;
+ const allPrior = controller.testMergeSnapshots(controller.testPickSnapshot(afterFirst, second), prior, afterFirst);
+ const toWrite = controller.testMergeSnapshots(prior, second, afterFirst);
+ const start = sent.length;
+ assert.equal(await controller.flashGameProfileSnapshot(toWrite), "written");
+ assert.deepEqual(controller.testState().status.dpiStages, second.dpiStages);
+ assert.deepEqual(controller.testState().status.dpiStagesY, second.dpiStagesY);
+ assert.equal(banks[0].active, 3);
+ assert.equal(sent.slice(start).some(packet => packet[4] === 1 && packet[5] === 1), false,
+ "a shared final table needs no restore-then-rewrite");
+ assert.equal(controller.testState().error, null);
+ await controller.flashGameProfileSnapshot(allPrior);
+ assert.deepEqual(banks[0].table, original); assert.equal(banks[0].count, 3); assert.equal(banks[0].active, 2);
+ assert.equal(controller.testState().error, null);
+ });
+
+ test(`controller game switch on ${pid.toString(16)} retains four stages while editing the fourth`, async () => {
+ const { device, banks, sent } = fixture(pid);
+ const before = await controller.testConnect(device);
+ const original = [...banks[0].table];
+ const first = { dpiStages: [400, 800, 1600, 2000], dpiStagesY: [800, 1600, 3200, 4000] };
+ const second = { dpiStages: [400, 800, 1600, 2400], dpiStagesY: [800, 1600, 3200, 4800], activeDpiStage: 3 };
+ const prior = controller.testPickSnapshot(before, first);
+ await controller.flashGameProfileSnapshot(first);
+ const afterFirst = controller.testState().status;
+ const allPrior = controller.testMergeSnapshots(controller.testPickSnapshot(afterFirst, second), prior, afterFirst);
+ const start = sent.length;
+ await controller.flashGameProfileSnapshot(controller.testMergeSnapshots(prior, second, afterFirst));
+ assert.equal(controller.testState().error, null);
+ assert.equal(banks[0].count, 4); assert.equal(banks[0].active, 4);
+ assert.deepEqual(controller.testState().status.dpiStages, second.dpiStages);
+ assert.deepEqual(controller.testState().status.dpiStagesY, second.dpiStagesY);
+ assert.deepEqual(banks[0].table, [400, 800, 800, 1600, 1600, 3200, 2400, 4800, ...original.slice(8)]);
+ assert.deepEqual(sent.slice(start).filter(packet => packet[4] === 1 && packet[5] === 1).map(packet => packet[7]), [4],
+ "write the final count/table without an intermediate three-stage restore");
+ await controller.flashGameProfileSnapshot(allPrior);
+ assert.deepEqual(banks[0].table, original); assert.equal(banks[0].count, 3); assert.equal(banks[0].active, 2);
+ assert.equal(controller.testState().error, null);
+ });
+
+ test(`controller game switch on ${pid.toString(16)} reduces max stages to four without writing disabled slots`, async () => {
+ const { device, banks, sent } = fixture(pid);
+ const before = await controller.testConnect(device);
+ const original = [...banks[0].table];
+ const max = before.ui.dpiStageEditor.maxStages;
+ const first = { dpiStages: [400, 800, 1600, 2000, 4000, 8000].slice(0, max),
+ dpiStagesY: [800, 1600, 3200, 4000, 8000, 16000].slice(0, max) };
+ const second = { dpiStages: [400, 800, 1600, 2400], dpiStagesY: [800, 1600, 3200, 4800], activeDpiStage: 3 };
+ const prior = controller.testPickSnapshot(before, first);
+ await controller.flashGameProfileSnapshot(first);
+ assert.equal(banks[0].count, max);
+ const afterFirst = controller.testState().status;
+ const allPrior = controller.testMergeSnapshots(controller.testPickSnapshot(afterFirst, second), prior, afterFirst);
+ const start = sent.length;
+ await controller.flashGameProfileSnapshot(controller.testMergeSnapshots(prior, second, afterFirst));
+ assert.equal(controller.testState().error, null);
+ assert.equal(banks[0].count, 4); assert.equal(banks[0].active, 4);
+ assert.deepEqual(controller.testState().status.dpiStages, second.dpiStages);
+ assert.deepEqual(controller.testState().status.dpiStagesY, second.dpiStagesY);
+ assert.deepEqual(banks[0].table, [400, 800, 800, 1600, 1600, 3200, 2400, 4800, ...original.slice(8)]);
+ assert.deepEqual(sent.slice(start).filter(packet => packet[4] === 1 && packet[5] === 1).map(packet => packet[7]), [4]);
+ await controller.flashGameProfileSnapshot(allPrior);
+ assert.deepEqual(banks[0].table, original); assert.equal(banks[0].count, 3); assert.equal(banks[0].active, 2);
+ assert.equal(controller.testState().error, null);
+ });
+}
diff --git a/docs/lunafury-development.md b/docs/lunafury-development.md
index 6c561176..eae53077 100644
--- a/docs/lunafury-development.md
+++ b/docs/lunafury-development.md
@@ -69,9 +69,9 @@ files and vendor images.
## Automated validation
-The paired application build and all 334 application tests pass, including
-15 controller integration tests. Bundle checks pass at 205,473 / 210,000
-bytes for CSS and 2,119,670 / 2,150,000 bytes for JavaScript. These results
+The paired application build and all 349 application tests pass, including
+27 controller integration tests. Bundle checks pass at 205,473 / 210,000
+bytes for CSS and 2,119,772 / 2,150,000 bytes for JavaScript. These results
use the local protocol tarball; they do not establish that a registry-only
install builds.
@@ -90,12 +90,16 @@ target is merged. New tables therefore override prior restore values;
legacy targets still override restore tables in the opposite direction.
DPI edits and count changes back up the complete five- or six-slot X/Y
-storage separately from the enabled count. Restore puts dormant slots back
-before applying table edits and selection. Re-enabling a hidden slot then
-reads its original value, including Y values stored while the axes are
-linked. This backup is held in memory and excluded from saved Games
-snapshots and imports. The protocol rejects a backup for another bank and
-verifies every stored slot on readback.
+storage separately from the enabled count. When switching games, the app
+composes the final enabled count and X/Y slots from that backup and the new
+target, then stages one complete-table write followed by stage selection.
+It does not restore the old count first and queue individual slot edits
+against the previous game's status. A final table already on the mouse
+requires no table write. Exiting restores the original complete storage,
+so re-enabling a hidden slot reads its original value, including Y values
+stored while the axes are linked. The backup stays in memory and is excluded
+from saved Games snapshots and imports. The protocol rejects another bank
+and verifies every stored slot on readback.
The controller tests use the real draft, staging, Apply and restore paths
with synthetic HID devices. They cover both cross-format switch directions,
@@ -105,6 +109,22 @@ The two cross-format cases and four expansion cases failed before the
2026-10-10 fixes and pass with them. The Bridge hook itself is not mounted
by this harness; the tests call the same merge functions and controller path.
+Three further switch counterexamples are included in the controller suite:
+both games use the same edited DPI table but a different selection; both
+require four stages but different fourth-slot values; and the first uses
+the model's maximum count while the second needs four stages. The last is
+six-to-four on LUNA33 and five-to-four on TYPE33. All three run on each of
+the four PIDs, producing 12 cases that failed before final-table composition
+and pass with it. They check both axes, count, selection, dormant slots,
+TYPE33 trailing bytes and restoration of the accumulated originals. Three
+unit tests cover composition, unchanged final tables and legacy scalars.
+
+The protocol source did not change for these switch fixes. Its build and
+151 focused tests were rerun; the 2,252-test result above is the earlier
+broader run. These results do not verify the existing Games failure-return
+contract: `flashGameProfileSnapshot` can still return `written` after a
+device write fails. That separate error-propagation behavior is unchanged.
+
The paired protocol branch refreshes the active DPI stage and both tables
during LunaFury live reads. Its older bank-scoped setters and status reads
now share the queue used by bank switches and the newer setters. Other
diff --git a/src/device/controller.ts b/src/device/controller.ts
index ffc2c6dc..4fc43445 100644
--- a/src/device/controller.ts
+++ b/src/device/controller.ts
@@ -1614,7 +1614,7 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void {
});
}
-/** Internal Games backup; never available as a saved/imported control. */
+/** Final Games DPI storage, composed from its backup and target; never saved/imported. */
function stageLunaFuryDpiStorage(storage: LunaFuryDpiStorage): void {
const saved = structuredClone(storage);
stageChange({
diff --git a/src/device/lunafury-game-dpi.test.ts b/src/device/lunafury-game-dpi.test.ts
index 1aeba0c4..ed814673 100644
--- a/src/device/lunafury-game-dpi.test.ts
+++ b/src/device/lunafury-game-dpi.test.ts
@@ -54,6 +54,7 @@ test("stage-count restore includes both axes and the old active stage", () => {
assert.deepEqual(pickSnapshot(before, saved), { dpiStages: before.dpiStages, dpiStagesY: before.dpiStagesY, activeDpiStage: 1 });
const calls: unknown[] = [];
assert.equal(stageLunaFuryGameDpi(before, { dpiStages: [400, 800, 1600, 2000], activeDpiStage: 3 }, {
+ storage: (storage) => calls.push(["storage", storage]),
count: (count) => calls.push(["count", count]), axes: (...args) => calls.push(["axes", ...args]), active: (stage) => calls.push(["active", stage]),
}), true);
assert.deepEqual(calls, [["count", 4], ["axes", 3, 2000, 2000], ["active", 3]]);
@@ -106,3 +107,46 @@ test("LunaFury restoration metadata stays out of sanitized imports and saved dra
after.lunafury!.dpiStorage!.stages[3].x = 2000;
assert.deepEqual(draftProfileSnapshot(before, after, []), {});
});
+
+test("complete Games backups and targets stage one final table with dormant slots intact", () => {
+ const before = status();
+ const backup = { profile: 1, count: 3,
+ stages: [400, 800, 1600, 3200, 6400, 0].map((x, index) => ({ x, y: [800, 1600, 3200, 6400, 6400, 65535][index] })) };
+ const original = structuredClone(backup);
+ const calls: unknown[] = [];
+ assert.equal(stageLunaFuryGameDpi(before, { lunaFuryDpiStorage: backup,
+ dpiStages: [400, 800, 1600, 2400], dpiStagesY: [800, 1600, 3200, 4800], activeDpiStage: 3 }, {
+ storage: (storage) => calls.push(["storage", storage]), count: (count) => calls.push(["count", count]),
+ axes: (...args) => calls.push(["axes", ...args]), active: (stage) => calls.push(["active", stage]),
+ }), true);
+ assert.deepEqual(calls, [["storage", { ...original, count: 4,
+ stages: [...original.stages.slice(0, 3), { x: 2400, y: 4800 }, ...original.stages.slice(4)] }], ["active", 3]]);
+ assert.deepEqual(backup, original);
+ assert.notEqual((calls[0] as any[])[1].stages[4], backup.stages[4]);
+});
+
+test("a composed Games table already on the mouse stages selection only", () => {
+ const before = status();
+ const backup = { profile: 1, count: 3, stages: [400, 800, 1600, 3200, 6400, 30000].map(x => ({ x, y: x * 2 })) };
+ before.dpiStages = [400, 1200, 1600]; before.dpiStagesY = [800, 2400, 3200];
+ before.lunafury!.dpiStorage = structuredClone(backup);
+ before.lunafury!.dpiStorage.stages[1] = { x: 1200, y: 2400 };
+ const calls: unknown[] = [];
+ stageLunaFuryGameDpi(before, { lunaFuryDpiStorage: backup, dpiStages: before.dpiStages,
+ dpiStagesY: before.dpiStagesY, activeDpiStage: 2 }, {
+ storage: (storage) => calls.push(["storage", storage]), count: (count) => calls.push(["count", count]),
+ axes: (...args) => calls.push(["axes", ...args]), active: (stage) => calls.push(["active", stage]),
+ });
+ assert.deepEqual(calls, [["active", 2]]);
+});
+
+test("complete-storage targets still apply legacy scalars at the selected target stage", () => {
+ const before = status();
+ const backup = { profile: 1, count: 3, stages: [400, 800, 1600, 3200, 6400, 30000].map(x => ({ x, y: x })) };
+ const calls: unknown[] = [];
+ stageLunaFuryGameDpi(before, { lunaFuryDpiStorage: backup, dpi: 2400, dpiY: 4800, activeDpiStage: 2 }, {
+ storage: (storage) => calls.push(["storage", storage]), count: (count) => calls.push(["count", count]),
+ axes: (...args) => calls.push(["axes", ...args]), active: (stage) => calls.push(["active", stage]),
+ });
+ assert.deepEqual(calls, [["storage", { ...backup, stages: backup.stages.map((stage, index) => index === 2 ? { x: 2400, y: 4800 } : stage) }], ["active", 2]]);
+});
diff --git a/src/device/lunafury-game-dpi.ts b/src/device/lunafury-game-dpi.ts
index 78f08145..293b4149 100644
--- a/src/device/lunafury-game-dpi.ts
+++ b/src/device/lunafury-game-dpi.ts
@@ -3,7 +3,6 @@ import type { LunaFuryDpiStorage } from "@openmouse/protocol/lamzu";
import type { GameProfileSnapshot } from "./game-profile-snapshot.ts";
export interface LunaFuryGameDpiPlan {
- storage?: LunaFuryDpiStorage;
count?: number;
stages: Array<{ stage: number; x: number; y: number }>;
activeStage?: number;
@@ -13,7 +12,6 @@ export interface LunaFuryGameDpiPlan {
export function lunaFuryGameDpiPlan(status: MouseStatus, snapshot: GameProfileSnapshot): LunaFuryGameDpiPlan | null {
if (status.brand !== "LunaFury" || !status.ui?.dpiStageEditor || !status.dpiStages?.length || !status.dpiStagesY?.length) return null;
const storage = snapshot.lunaFuryDpiStorage;
- const restore = storage && JSON.stringify(storage) !== JSON.stringify(status.lunafury?.dpiStorage);
const baselineX = storage ? storage.stages.slice(0, storage.count).map(stage => stage.x) : status.dpiStages;
const baselineY = storage ? storage.stages.slice(0, storage.count).map(stage => stage.y) : status.dpiStagesY;
const baselineActive = storage ? Math.min(status.activeDpiStage ?? 0, storage.count - 1) : status.activeDpiStage ?? 0;
@@ -28,7 +26,6 @@ export function lunaFuryGameDpiPlan(status: MouseStatus, snapshot: GameProfileSn
else if (snapshot.dpi != null && separate === false && snapshot.dpi !== baselineX[target]) ys[target] = snapshot.dpi;
}
return {
- ...(restore ? { storage } : {}),
...(xs.length !== baselineX.length && status.ui.dpiStageEditor.countEditable === true ? { count: xs.length } : {}),
stages: xs.flatMap((x, stage) => x === baselineX[stage] && ys[stage] === baselineY[stage]
? [] : [{ stage, x, y: ys[stage] }]),
@@ -50,16 +47,24 @@ export function canonicalLunaFuryDpiSnapshot(status: MouseStatus, snapshot: Game
}
export function stageLunaFuryGameDpi(status: MouseStatus, snapshot: GameProfileSnapshot, actions: {
- storage?(storage: LunaFuryDpiStorage): void;
+ storage(storage: LunaFuryDpiStorage): void;
count(count: number): void;
axes(stage: number, x: number, y: number): void;
active(stage: number): void;
}): boolean {
const plan = lunaFuryGameDpiPlan(status, snapshot);
if (!plan) return false;
- if (plan.storage) actions.storage?.(plan.storage);
- if (plan.count != null) actions.count(plan.count);
- for (const { stage, x, y } of plan.stages) actions.axes(stage, x, y);
+ if (snapshot.lunaFuryDpiStorage) {
+ // Compose the final table before staging. Restoring first would make the
+ // ordinary count/slot setters deduplicate and order against stale hardware.
+ const target = structuredClone(snapshot.lunaFuryDpiStorage);
+ if (plan.count != null) target.count = plan.count;
+ for (const { stage, x, y } of plan.stages) target.stages[stage] = { x, y };
+ if (JSON.stringify(target) !== JSON.stringify(status.lunafury?.dpiStorage)) actions.storage(target);
+ } else {
+ if (plan.count != null) actions.count(plan.count);
+ for (const { stage, x, y } of plan.stages) actions.axes(stage, x, y);
+ }
if (plan.activeStage != null) actions.active(plan.activeStage);
return true;
}
From b2fab92c8d732764a5cdbaa316903ee4fa9fd208 Mon Sep 17 00:00:00 2001
From: Breakdown <169538186+RINZE1@users.noreply.github.com>
Date: Sun, 11 Oct 2026 00:09:41 +0800
Subject: [PATCH 05/14] fix(games): propagate failed writes and preserve retry
backups
---
build/lunafury-controller.test.ts | 309 ++++++++++++++++++++++++++++-
docs/lunafury-development.md | 46 ++++-
src/app/useBridgeProfileApplier.ts | 18 +-
src/device/controller.ts | 57 ++++--
4 files changed, 389 insertions(+), 41 deletions(-)
diff --git a/build/lunafury-controller.test.ts b/build/lunafury-controller.test.ts
index 1cf0bb24..bab24f31 100644
--- a/build/lunafury-controller.test.ts
+++ b/build/lunafury-controller.test.ts
@@ -2,9 +2,11 @@ import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { before, test } from "node:test";
import { build } from "vite";
+import type { BridgeProfile } from "../src/bridge.ts";
let controller: any;
const controllerPath = fileURLToPath(new URL("../src/device/controller.ts", import.meta.url)).replaceAll("\\", "/");
+const bridgeHookPath = fileURLToPath(new URL("../src/app/useBridgeProfileApplier.ts", import.meta.url)).replaceAll("\\", "/");
before(async () => {
const storage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
@@ -17,12 +19,44 @@ before(async () => {
const result: any = await build({
configFile: false, logLevel: "silent",
define: { __BUILD_CHANNEL__: '"stable"', __APP_VERSION__: '"test"', __BUILD_ID__: '"test"' },
- plugins: [{ name: "controller-test-state", transform(source, id) {
- if (id.replaceAll("\\", "/") !== controllerPath) return null;
- // Only inject device setup/observability. All staging, Games apply and
- // flash code below is the real controller, bundled by the normal toolchain.
- return source + `
+ plugins: [{ name: "controller-test-state", enforce: "pre",
+ resolveId(id, importer) {
+ if (id === "test:bridge-hooks" || id === "react") return "\0test:bridge-hooks";
+ if (id === "test:bridge-status" || (id === "../bridge-status-store" && importer?.replaceAll("\\", "/") === bridgeHookPath)) {
+ return "\0test:bridge-status";
+ }
+ },
+ load(id) {
+ // Run the real hook's subscription callback without a DOM renderer or
+ // native Bridge. Refs persist across renders; the effect mounts once.
+ if (id === "\0test:bridge-hooks") return `
+ let refs = [], cursor = 0, mounted = false, cleanup;
+ export function useRef(initial) { return refs[cursor++] ??= { current: initial }; }
+ export function useEffect(setup) { if (!mounted) { mounted = true; cleanup = setup(); } }
+ export function beginRender() { cursor = 0; }
+ export function resetHook() { cleanup?.(); refs = []; cursor = 0; mounted = false; cleanup = null; }
+ export function hookWriting() { return refs.at(-1)?.current === true; }
+ `;
+ if (id === "\0test:bridge-status") return `
+ const listeners = new Set();
+ export function subscribeBridgeStatus(listener) { listeners.add(listener); listener(null); return () => listeners.delete(listener); }
+ export function emitBridge(activeProfile) { for (const listener of listeners) listener({ activeProfile }); }
+ `;
+ },
+ transform(source, id) {
+ if (id.replaceAll("\\", "/") !== controllerPath) return null;
+ // Only inject device setup/observability. All staging, Games apply and
+ // flash code below is the real controller, bundled by the normal toolchain.
+ return source.replace(
+ "export function pushToast(kind: ToastKind, title: string, detail?: string, options: ToastOptions = {}): void {",
+ "$&\n testToastLog.push({ kind, title, detail });",
+ ) + `
+ import { useBridgeProfileApplier } from "../app/useBridgeProfileApplier";
+ import { beginRender, resetHook, hookWriting } from "test:bridge-hooks";
+ import { emitBridge } from "test:bridge-status";
+ export const testToastLog = [];
export async function testConnect(device) {
+ resetHook(); testToastLog.length = 0;
clearPendingChanges(); gameProfileDraft = null;
active = new LamzuHidClient(device); activeDevice = device;
dpiOptions = active.getDpiOptions(); lastDiagnosticError = null;
@@ -34,15 +68,26 @@ before(async () => {
return { status: latestDeviceStatus, preview: withPendingChanges(latestDeviceStatus),
pending: pendingChanges().map(change => change.key), error: lastDiagnosticError };
}
+ export function testBridgePoll(profile) {
+ beginRender(); useBridgeProfileApplier(getSnapshot()); emitBridge(profile);
+ }
+ export function testRejectNextStatusRead() {
+ const read = active.readStatus.bind(active);
+ active.readStatus = async () => {
+ active.readStatus = read;
+ throw new Error("Injected post-write status failure.");
+ };
+ }
+ export { hookWriting as testBridgeWriting };
export { snapshotDiff as testSnapshotDiff, pickSnapshot as testPickSnapshot,
mergeSnapshots as testMergeSnapshots } from "./game-profile-snapshot.ts";
- `;
- } }],
+ `;
+ } }],
build: { write: false, minify: false, lib: { entry: controllerPath, formats: ["es"] },
rollupOptions: { output: { inlineDynamicImports: true } } },
});
const output = (Array.isArray(result) ? result : [result]).flatMap(entry => entry.output);
- const code = output.find((entry: any) => entry.type === "chunk").code;
+ const code = output.find((entry: any) => entry.type === "chunk").code + "\n//# sourceURL=openmouse-controller-test.mjs";
controller = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
});
@@ -88,6 +133,35 @@ function fixture(productId = 0x0033) {
return { device, banks, sent };
}
+function ignoreWrites(mouse: ReturnType, command = 1) {
+ let ignored = true;
+ const send = mouse.device.sendFeatureReport;
+ mouse.device.sendFeatureReport = async (id, report) => {
+ const original = structuredClone(mouse.banks[0]);
+ await send(id, report);
+ if (ignored && report[4] === 1 && report[5] === command) Object.assign(mouse.banks[0], original);
+ };
+ return () => { ignored = false; };
+}
+
+function game(name: string, snapshot: BridgeProfile["settings"]["snapshot"], deviceName: string): BridgeProfile {
+ return { application: { name, executable: `${name}.exe`, path: `${name}.exe` },
+ device: { id: "test-mouse", name: deviceName }, settings: { dpi: null, pollingRateHz: null, snapshot } };
+}
+
+async function bridgePoll(profile: BridgeProfile | null): Promise {
+ controller.testBridgePoll(profile);
+ for (let step = 0; step < 1000 && controller.testBridgeWriting(); step++) {
+ await new Promise(resolve => setTimeout(resolve, 1));
+ }
+ assert.equal(controller.testBridgeWriting(), false, "the hook must release its writing guard");
+}
+
+function bridgeSuccessNotices() {
+ return controller.testToastLog.filter((toast: { title: string }) =>
+ toast.title === "Applied game profile" || toast.title === "Restored your previous mouse settings");
+}
+
for (const pid of [0x32, 0x33, 0x54, 0x84]) {
test(`controller Games selection/restore on ${pid.toString(16)} never writes either DPI table`, async () => {
const { device, banks, sent } = fixture(pid);
@@ -106,6 +180,225 @@ for (const pid of [0x32, 0x33, 0x54, 0x84]) {
});
}
+for (const pid of [0x32, 0x33, 0x54, 0x84]) {
+ test(`controller on ${pid.toString(16)} reports a rejected Games table and retries without losing user edits`, async () => {
+ const mouse = fixture(pid);
+ const before = await controller.testConnect(mouse.device);
+ const first = { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] };
+ const second = { dpiStages: [400, 1400, 1600], dpiStagesY: [800, 2800, 3200] };
+ const prior = controller.testPickSnapshot(before, first);
+ assert.equal(await controller.flashGameProfileSnapshot(first), "written");
+ const toWrite = controller.testMergeSnapshots(prior, second, controller.testState().status);
+ controller.applyLunaFuryDpiStageAxes(0, 600, 1200);
+ const allowWrites = ignoreWrites(mouse);
+ assert.equal(await controller.flashGameProfileSnapshot(toWrite), "failed");
+ assert.match(controller.testState().error, /did not confirm the complete stored DPI table/);
+ assert.deepEqual(controller.testState().status.dpiStages, first.dpiStages);
+ assert.deepEqual(controller.testState().status.dpiStagesY, first.dpiStagesY);
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]);
+ assert.deepEqual(controller.testState().preview.dpiStages, [600, 1200, 1600]);
+ allowWrites();
+ assert.equal(await controller.flashGameProfileSnapshot(toWrite), "written");
+ assert.deepEqual(controller.testState().status.dpiStages, second.dpiStages);
+ assert.deepEqual(controller.testState().status.dpiStagesY, second.dpiStagesY);
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]);
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.equal(mouse.banks[0].table[0], 600);
+ assert.equal(await controller.flashPendingChanges(), "unchanged");
+ });
+
+ test(`controller on ${pid.toString(16)} reports failed restore and retries the complete original storage`, async () => {
+ const mouse = fixture(pid);
+ const before = await controller.testConnect(mouse.device);
+ const original = structuredClone(mouse.banks[0]);
+ const target = { dpiStages: [400, 1200, 1600, 2000], dpiStagesY: [800, 2400, 3200, 4000], activeDpiStage: 3 };
+ const prior = controller.testPickSnapshot(before, target);
+ assert.equal(await controller.flashGameProfileSnapshot(target), "written");
+ const allowWrites = ignoreWrites(mouse);
+ assert.equal(await controller.flashGameProfileSnapshot(prior), "failed");
+ assert.match(controller.testState().error, /did not confirm/);
+ assert.notEqual(mouse.banks[0].active, original.active, "a driver clamp is not the original selection being restored");
+ assert.equal(mouse.banks[0].count, 4);
+ assert.deepEqual(controller.testState().pending, []);
+ allowWrites();
+ assert.equal(await controller.flashGameProfileSnapshot(prior), "written");
+ assert.deepEqual(mouse.banks[0], original);
+ assert.equal(await controller.flashGameProfileSnapshot(prior), "unchanged");
+ });
+}
+
+test("controller keeps a failed batch failed when another flash was queued", async () => {
+ const mouse = fixture();
+ await controller.testConnect(mouse.device);
+ controller.applyLunaFuryDpiStageAxes(1, 1200, 2400);
+ const allowWrites = ignoreWrites(mouse);
+ const send = mouse.device.sendFeatureReport;
+ let queued: unknown;
+ let didQueue = false;
+ mouse.device.sendFeatureReport = async (id, report) => {
+ if (report[4] === 1 && report[5] === 1 && !didQueue) {
+ didQueue = true;
+ queued = await controller.flashPendingChanges();
+ }
+ await send(id, report);
+ };
+ assert.equal(await controller.flashPendingChanges(), "failed");
+ assert.equal(queued, "busy");
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-1"]);
+ assert.equal(mouse.sent.filter(packet => packet[4] === 1 && packet[5] === 1).length, 1);
+ allowWrites();
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.deepEqual(controller.testState().pending, []);
+});
+
+for (const games of [false, true]) {
+ test(`controller reports post-write status failure for ${games ? "Games" : "manual Apply"} and permits retry`, async () => {
+ const mouse = fixture();
+ await controller.testConnect(mouse.device);
+ controller.testRejectNextStatusRead();
+ const target = { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] };
+ if (!games) controller.applyLunaFuryDpiStageAxes(1, 1200, 2400);
+ assert.equal(await (games ? controller.flashGameProfileSnapshot(target) : controller.flashPendingChanges()), "failed");
+ assert.equal(mouse.banks[0].table[2], 1200, "the write may land before readback fails");
+ assert.match(controller.testState().error, /Injected post-write status failure/);
+ assert.equal(controller.getSnapshot().settingInProgress, false);
+ assert.equal(controller.testToastLog.some((toast: { kind: string }) => toast.kind === "success"), false);
+ assert.equal(await controller.flashGameProfileSnapshot(target), "written");
+ assert.deepEqual(controller.testState().status.dpiStages, target.dpiStages);
+ assert.deepEqual(controller.testState().status.dpiStagesY, target.dpiStagesY);
+ });
+}
+
+test("controller restores the user's stash even if Games staging unexpectedly throws", async () => {
+ const mouse = fixture();
+ await controller.testConnect(mouse.device);
+ const original = structuredClone(mouse.banks[0]);
+ controller.applyLunaFuryDpiStageAxes(0, 600, 1200);
+ const target = { dpiStages: [400, 1200, 1600], get pollingRateHz() { throw new Error("Injected staging failure."); } };
+ assert.equal(await controller.flashGameProfileSnapshot(target), "failed");
+ assert.deepEqual(mouse.banks[0], original);
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]);
+ assert.deepEqual(controller.testState().preview.dpiStages, [600, 800, 1600]);
+ assert.match(controller.testState().error, /Injected staging failure/);
+});
+
+test("Bridge retries the same failed game switch and deduplicates only confirmed success", async () => {
+ const mouse = fixture();
+ const before = await controller.testConnect(mouse.device);
+ const original = structuredClone(mouse.banks[0]);
+ const first = game("A", { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }, before.name);
+ const second = game("B", { dpiStages: [400, 1400, 1600], dpiStagesY: [800, 2800, 3200] }, before.name);
+ await bridgePoll(first);
+ controller.testToastLog.length = 0;
+ const allowWrites = ignoreWrites(mouse);
+ await bridgePoll(second);
+ assert.equal(mouse.banks[0].table[2], 1200);
+ assert.match(controller.testState().error, /did not confirm/);
+ assert.deepEqual(bridgeSuccessNotices(), []);
+ allowWrites();
+ await bridgePoll(second);
+ assert.equal(mouse.banks[0].table[2], 1400);
+ assert.equal(bridgeSuccessNotices().length, 1);
+ const sent = mouse.sent.length;
+ await bridgePoll(second);
+ assert.equal(mouse.sent.length, sent);
+ assert.equal(bridgeSuccessNotices().length, 1);
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks[0], original);
+});
+
+test("Bridge keeps its original backup after restore fails and retries on the next poll", async () => {
+ const mouse = fixture();
+ const before = await controller.testConnect(mouse.device);
+ const original = structuredClone(mouse.banks[0]);
+ await bridgePoll(game("A", { dpiStages: [400, 1200, 1600, 2000], dpiStagesY: [800, 2400, 3200, 4000], activeDpiStage: 3 }, before.name));
+ const allowWrites = ignoreWrites(mouse);
+ controller.testToastLog.length = 0;
+ await bridgePoll(null);
+ assert.equal(mouse.banks[0].count, 4);
+ assert.notEqual(mouse.banks[0].active, original.active);
+ assert.deepEqual(bridgeSuccessNotices(), []);
+ allowWrites();
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks[0], original);
+ assert.equal(bridgeSuccessNotices().length, 1);
+ const sent = mouse.sent.length;
+ await bridgePoll(null);
+ assert.equal(mouse.sent.length, sent, "clear the backup only after a confirmed restore");
+});
+
+test("Bridge restores a partial first application even when no game signature was confirmed", async () => {
+ const mouse = fixture();
+ mouse.banks[0].axes = false;
+ const before = await controller.testConnect(mouse.device);
+ const original = structuredClone(mouse.banks[0]);
+ const allowWrites = ignoreWrites(mouse);
+ await bridgePoll(game("A", { dpiStages: [400, 1200, 1600], lunafury: { separateDpiAxes: true } }, before.name));
+ assert.equal(mouse.banks[0].axes, true, "the first batch landed before the DPI write failed");
+ assert.equal(mouse.banks[0].table[2], 800);
+ assert.match(controller.testState().error, /did not confirm/);
+ assert.deepEqual(bridgeSuccessNotices(), []);
+ allowWrites();
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks[0], original);
+ assert.equal(bridgeSuccessNotices().length, 1);
+});
+
+test("Bridge retains backup through a partial restore and retries its remaining axis change", async () => {
+ const mouse = fixture();
+ mouse.banks[0].axes = false;
+ const before = await controller.testConnect(mouse.device);
+ const original = structuredClone(mouse.banks[0]);
+ await bridgePoll(game("A", { dpiStages: [400, 1200, 1600], lunafury: { separateDpiAxes: true } }, before.name));
+ const allowWrites = ignoreWrites(mouse, 13);
+ controller.testToastLog.length = 0;
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks[0].table, original.table, "the DPI restore landed before the axis-mode failure");
+ assert.equal(mouse.banks[0].axes, true);
+ assert.deepEqual(bridgeSuccessNotices(), []);
+ allowWrites();
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks[0], original);
+ assert.equal(bridgeSuccessNotices().length, 1);
+});
+
+test("Bridge invalidates the previous signature after a partial switch fails", async () => {
+ const mouse = fixture();
+ const before = await controller.testConnect(mouse.device);
+ const original = structuredClone(mouse.banks[0]);
+ const first = game("A", { dpiStages: [400, 1200, 1600] }, before.name);
+ const second = game("B", { dpiStages: [400, 1400, 1600], lunafury: { separateDpiAxes: false } }, before.name);
+ await bridgePoll(first);
+ const allowWrites = ignoreWrites(mouse, 13);
+ controller.testToastLog.length = 0;
+ await bridgePoll(second);
+ assert.equal(mouse.banks[0].table[2], 1400, "the DPI write landed before B's axis-mode failure");
+ assert.deepEqual(bridgeSuccessNotices(), []);
+ allowWrites();
+ await bridgePoll(first);
+ assert.equal(mouse.banks[0].table[2], 1200, "A must not be skipped using its old confirmed signature");
+ assert.equal(bridgeSuccessNotices().length, 1);
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks[0], original);
+});
+
+test("Bridge retries a busy game application after the draft closes", async () => {
+ const mouse = fixture();
+ const before = await controller.testConnect(mouse.device);
+ const original = structuredClone(mouse.banks[0]);
+ const target = game("A", { dpiStages: [400, 1200, 1600] }, before.name);
+ assert.equal(controller.beginGameProfileDraft({}), true);
+ await bridgePoll(target);
+ assert.deepEqual(mouse.banks[0], original);
+ assert.deepEqual(bridgeSuccessNotices(), []);
+ controller.endGameProfileDraft();
+ await bridgePoll(target);
+ assert.equal(mouse.banks[0].table[2], 1200);
+ assert.equal(bridgeSuccessNotices().length, 1);
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks[0], original);
+});
+
test("controller restores legacy target-stage edits and preserves unrelated pending user changes", async () => {
const { device, banks } = fixture();
const before = await controller.testConnect(device);
diff --git a/docs/lunafury-development.md b/docs/lunafury-development.md
index eae53077..29ca4b26 100644
--- a/docs/lunafury-development.md
+++ b/docs/lunafury-development.md
@@ -69,9 +69,9 @@ files and vendor images.
## Automated validation
-The paired application build and all 349 application tests pass, including
-27 controller integration tests. Bundle checks pass at 205,473 / 210,000
-bytes for CSS and 2,119,772 / 2,150,000 bytes for JavaScript. These results
+The paired application build and all 367 application tests pass, including
+45 controller/Bridge integration tests. Bundle checks pass at 205,473 / 210,000
+bytes for CSS and 2,120,074 / 2,150,000 bytes for JavaScript. These results
use the local protocol tarball; they do not establish that a registry-only
install builds.
@@ -106,8 +106,8 @@ with synthetic HID devices. They cover both cross-format switch directions,
expansion followed by restore and re-enabling on all four runtime PIDs,
pending user edits, shrinking counts and combined axis-mode/DPI edits.
The two cross-format cases and four expansion cases failed before the
-2026-10-10 fixes and pass with them. The Bridge hook itself is not mounted
-by this harness; the tests call the same merge functions and controller path.
+2026-10-10 fixes and pass with them. Those cases call the same merge
+functions and controller path used by the Bridge hook.
Three further switch counterexamples are included in the controller suite:
both games use the same edited DPI table but a different selection; both
@@ -119,11 +119,37 @@ and pass with it. They check both axes, count, selection, dormant slots,
TYPE33 trailing bytes and restoration of the accumulated originals. Three
unit tests cover composition, unchanged final tables and legacy scalars.
-The protocol source did not change for these switch fixes. Its build and
-151 focused tests were rerun; the 2,252-test result above is the earlier
-broader run. These results do not verify the existing Games failure-return
-contract: `flashGameProfileSnapshot` can still return `written` after a
-device write fails. That separate error-propagation behavior is unchanged.
+The protocol source did not change for these switch fixes or the failure
+handling below. Its build and 151 focused tests were rerun on 2026-10-11;
+the 2,252-test result above is the earlier broader run.
+
+### Games failure and retry regressions (2026-10-11)
+
+`flashPendingChanges` and `flashGameProfileSnapshot` return `failed` when a
+write is rejected or the final status read fails. A queued flash does not
+hide a failed batch by immediately retrying it. Games always restores the
+user's unrelated staged edits, including when snapshot staging throws.
+`written` means the attempt completed; `unchanged` and `busy` keep their
+existing meaning. Failure does not imply that every earlier write was undone.
+
+The Bridge hook records an applied signature only after `written` or
+`unchanged`. A failed application invalidates any previous signature and
+keeps the accumulated originals, including fields from a partially applied
+first game. A failed restore keeps that backup. The next Bridge status poll
+can retry the same game or the restore; success notifications and backup
+cleanup happen only after confirmation.
+
+Eighteen new integration cases cover ignored complete-table writes and
+restore retries on all four PIDs, a queued flash, final-status read failures,
+staging exceptions, partial application/restoration, old-signature
+invalidation and busy-to-ready retry. Fourteen fault cases failed against
+the previous implementation before the fix. The six Bridge cases execute
+the actual hook subscription callback with persistent synthetic refs and a
+synthetic status subscription, using the real controller and protocol
+driver. They do not mount a DOM/Preact renderer or start a native Bridge
+process. Failed cases assert no success notification and then verify the
+synthetic device's values after retry and restore; they do not treat an
+expected `written` on failure as a passing contract.
The paired protocol branch refreshes the active DPI stage and both tables
during LunaFury live reads. Its older bank-scoped setters and status reads
diff --git a/src/app/useBridgeProfileApplier.ts b/src/app/useBridgeProfileApplier.ts
index bed80394..fb86c60d 100644
--- a/src/app/useBridgeProfileApplier.ts
+++ b/src/app/useBridgeProfileApplier.ts
@@ -60,16 +60,22 @@ export function useBridgeProfileApplier(snapshot: ControlSnapshot): void {
const profile = bridge?.activeProfile ?? null;
if (!profile || !status || profile.device.name !== status.name) {
- // Nothing (recognized) is active for this mouse right now. If we were
- // the one who applied a profile, put back what was there before it.
+ // Nothing (recognized) is active for this mouse right now. Put back
+ // what was there before any successful or partial profile application.
const prior = priorSettings.current;
- if (appliedSignature.current === null || !prior) return;
+ // A failed first application can still have changed earlier batches,
+ // even though it never earned an applied signature.
+ if (!status || !prior) return;
writing.current = true;
void control.flashGameProfileSnapshot(prior).then((result) => {
if (result === "busy") return;
appliedSignature.current = null;
+ if (result === "failed") return;
priorSettings.current = null;
if (result === "written") control.pushToast("info", t(localeRef.current, "bridge.profileRestored"));
+ }).catch(() => {
+ // Keep the backup if an unexpected rejection followed a partial write.
+ appliedSignature.current = null;
}).finally(() => {
writing.current = false;
});
@@ -90,11 +96,15 @@ export function useBridgeProfileApplier(snapshot: ControlSnapshot): void {
writing.current = true;
void control.flashGameProfileSnapshot(toWrite).then((result) => {
if (result === "busy") return;
+ // Back up attempted fields too: an unsuccessful write is not atomic.
priorSettings.current = prior;
- appliedSignature.current = sig;
+ appliedSignature.current = result === "failed" ? null : sig;
if (result === "written") {
control.pushToast("info", t(localeRef.current, "bridge.profileAppliedHere"), profile.application.name);
}
+ }).catch(() => {
+ priorSettings.current = prior;
+ appliedSignature.current = null;
}).finally(() => {
writing.current = false;
});
diff --git a/src/device/controller.ts b/src/device/controller.ts
index 4fc43445..41c7914a 100644
--- a/src/device/controller.ts
+++ b/src/device/controller.ts
@@ -1230,23 +1230,34 @@ export function isGameProfileDraftActive(): boolean {
return gameProfileDraft !== null;
}
+export type FlashResult = "written" | "unchanged" | "busy" | "failed";
+
/**
* Writes `snapshot` to the mouse now (a game starting or stopping). Only the
* snapshot is flashed: the user's own unflashed edits are set aside for the
* write and staged again afterwards. "busy" means nothing was attempted (a
* draft is open or another write is running) and the caller should retry;
- * "unchanged" means the mouse already had every value.
+ * "unchanged" means the mouse already had every value. "failed" means the
+ * attempt did not complete; earlier batches may already have landed.
*/
export async function flashGameProfileSnapshot(
snapshot: GameProfileSnapshot,
-): Promise<"written" | "unchanged" | "busy"> {
+): Promise {
if (gameProfileDraft || settingInProgress || !latestDeviceStatus) return "busy";
const stash = stashPendingChanges();
- applyGameProfileSnapshot(snapshot);
- const staged = hasPendingChanges();
- if (staged) await flashPendingChanges();
- restorePendingChanges(stash);
- return staged ? "written" : "unchanged";
+ try {
+ applyGameProfileSnapshot(snapshot);
+ return hasPendingChanges() ? await flashPendingChanges() : "unchanged";
+ } catch (error) {
+ recordDiagnosticError(error, st("ctl.unableFlash"));
+ const failure = diagnosticErrorMessage(error, st("ctl.unableFlash"));
+ setReadStatus(failure);
+ pushToast("error", st("ctl.flashFailed"), failure);
+ return "failed";
+ } finally {
+ // Failed game writes must not replace the user's unrelated staged edits.
+ restorePendingChanges(stash);
+ }
}
/** Writes that bypass staging must not reach the mouse while a game profile is being edited. */
@@ -1892,14 +1903,14 @@ async function flashPause(milliseconds = FLASH_STEP_DELAY_MS): Promise {
await wait(milliseconds);
}
-export async function flashPendingChanges(): Promise {
- if (gameProfileDraft) return;
+export async function flashPendingChanges(): Promise {
+ if (gameProfileDraft) return "busy";
const batches = pendingChangeBatches();
- if (batches.length === 0) return;
+ if (batches.length === 0) return "unchanged";
if (settingInProgress) {
flashQueued = true;
setReadStatus(st("ctl.waitFlash"));
- return;
+ return "busy";
}
settingInProgress = true;
pendingBusy = true;
@@ -1936,21 +1947,28 @@ export async function flashPendingChanges(): Promise {
recordDiagnosticError(error, st("ctl.unableFlash"));
failure = error instanceof Error ? error.message : "Unable to flash the staged changes.";
}
- await flashPause(FLASH_SETTLE_MS);
- const client = activeSettingsClient();
- const status = client ? await statusAfterWrite(client).catch(() => null) : null;
- if (status) applyStatus(status);
- endDeviceWrite();
- pendingBusy = false;
+ try {
+ await flashPause(FLASH_SETTLE_MS);
+ applyStatus(await statusAfterWrite(requireSettingsClient()));
+ } catch (error) {
+ if (!failure) {
+ recordDiagnosticError(error, st("ctl.unableFlash"));
+ failure = diagnosticErrorMessage(error, st("ctl.unableFlash"));
+ }
+ } finally {
+ endDeviceWrite();
+ pendingBusy = false;
+ }
if (flashQueued) {
flashQueued = false;
- if (hasPendingChanges()) return flashPendingChanges();
+ // A queued retry must not turn this failed attempt into reported success.
+ if (!failure && hasPendingChanges()) return flashPendingChanges();
}
if (failure) {
pendingStatusText = failure;
setReadStatus(failure);
pushToast("error", st("ctl.flashFailed"), failure);
- return;
+ return "failed";
}
pendingStatusText = null;
const flashed = written === 1
@@ -1958,6 +1976,7 @@ export async function flashPendingChanges(): Promise {
: st("ctl.flashedMany", { n: written });
setReadStatus(flashed);
pushToast("success", flashed);
+ return "written";
}
function endDeviceWrite(): void {
From 1b7513379f305933dfe7ba7896125ca6d39018a0 Mon Sep 17 00:00:00 2001
From: Breakdown <169538186+RINZE1@users.noreply.github.com>
Date: Sun, 11 Oct 2026 23:45:27 +0800
Subject: [PATCH 06/14] fix: refresh unconfirmed status before Games
deduplication
---
build/lunafury-controller.test.ts | 233 ++++++++++++++++++++++++++++-
docs/lunafury-development.md | 40 ++++-
src/app/useBridgeProfileApplier.ts | 66 ++++----
src/device/controller.ts | 56 ++++++-
4 files changed, 354 insertions(+), 41 deletions(-)
diff --git a/build/lunafury-controller.test.ts b/build/lunafury-controller.test.ts
index bab24f31..f529a3c0 100644
--- a/build/lunafury-controller.test.ts
+++ b/build/lunafury-controller.test.ts
@@ -78,6 +78,42 @@ before(async () => {
throw new Error("Injected post-write status failure.");
};
}
+ export function testFailStatusReads() {
+ const read = active.readStatus.bind(active);
+ let failing = true, attempts = 0;
+ active.readStatus = async () => {
+ attempts++;
+ if (failing) throw new Error("Injected persistent status failure.");
+ return read();
+ };
+ return { allow: () => { failing = false; }, attempts: () => attempts };
+ }
+ export function testDeferNextStatusRead() {
+ const client = active, read = client.readStatus.bind(client);
+ let release, started = false;
+ const gate = new Promise(resolve => { release = resolve; });
+ client.readStatus = async () => {
+ client.readStatus = read; started = true;
+ await gate;
+ return read();
+ };
+ return { release: () => release(), started: () => started };
+ }
+ export async function testConnectGeneric(device) {
+ const template = await testConnect(device);
+ const stored = { ...template, brand: "Generic", name: "Generic test mouse",
+ dpi: 800, dpiY: 800, dpiStages: undefined, dpiStagesY: undefined,
+ activeDpiStage: undefined, lunafury: undefined, ui: undefined };
+ const writes = [];
+ let reads = 0;
+ active = { device, getDpiOptions: () => [400, 800, 1200, 1600],
+ readStatus: async () => { reads++; return structuredClone(stored); },
+ setDpi: async (dpi) => { writes.push(dpi); stored.dpi = stored.dpiY = dpi; },
+ setPollingRate: async (rate) => { stored.pollingRateHz = rate; } };
+ applyStatus(await active.readStatus());
+ return { stored, writes, reads: () => reads };
+ }
+ export { refreshStatus as testRefreshStatus };
export { hookWriting as testBridgeWriting };
export { snapshotDiff as testSnapshotDiff, pickSnapshot as testPickSnapshot,
mergeSnapshots as testMergeSnapshots } from "./game-profile-snapshot.ts";
@@ -263,7 +299,10 @@ for (const games of [false, true]) {
assert.match(controller.testState().error, /Injected post-write status failure/);
assert.equal(controller.getSnapshot().settingInProgress, false);
assert.equal(controller.testToastLog.some((toast: { kind: string }) => toast.kind === "success"), false);
- assert.equal(await controller.flashGameProfileSnapshot(target), "written");
+ const start = mouse.sent.length;
+ assert.equal(await controller.flashGameProfileSnapshot(target), "unchanged",
+ "fresh read confirms the first write landed, so retry needs no duplicate write");
+ assert.equal(mouse.sent.slice(start).some(packet => packet[5] < 0x80), false);
assert.deepEqual(controller.testState().status.dpiStages, target.dpiStages);
assert.deepEqual(controller.testState().status.dpiStagesY, target.dpiStagesY);
});
@@ -399,6 +438,198 @@ test("Bridge retries a busy game application after the draft closes", async () =
assert.deepEqual(mouse.banks[0], original);
});
+for (const pid of [0x32, 0x33, 0x54, 0x84]) {
+ test(`Bridge on ${pid.toString(16)} restores after apply lands but final read fails`, async () => {
+ const mouse = fixture(pid);
+ const before = await controller.testConnect(mouse.device);
+ const original = structuredClone(mouse.banks[0]);
+ controller.testRejectNextStatusRead();
+ await bridgePoll(game("A", { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }, before.name));
+ assert.equal(mouse.banks[0].table[2], 1200);
+ assert.equal(controller.testState().status.dpiStages[1], 800, "cache predates the unconfirmed write");
+ assert.deepEqual(bridgeSuccessNotices(), []);
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks[0], original, "do not clear backup using the stale original-looking cache");
+ const sent = mouse.sent.length;
+ await controller.testRefreshStatus();
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks[0], original);
+ assert.equal(mouse.sent.slice(sent).some(packet => packet[5] < 0x80), false);
+ assert.equal(bridgeSuccessNotices().length, 1);
+ });
+
+ test(`Bridge on ${pid.toString(16)} reapplies after restore lands but final read fails`, async () => {
+ const mouse = fixture(pid);
+ const before = await controller.testConnect(mouse.device);
+ const original = structuredClone(mouse.banks[0]);
+ const target = game("A", { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }, before.name);
+ await bridgePoll(target);
+ controller.testToastLog.length = 0;
+ controller.testRejectNextStatusRead();
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks[0], original);
+ assert.equal(controller.testState().status.dpiStages[1], 1200, "cache still looks like game A");
+ assert.deepEqual(bridgeSuccessNotices(), []);
+ await bridgePoll(target);
+ assert.equal(mouse.banks[0].table[2], 1200, "do not record A's signature using the stale game-looking cache");
+ assert.equal(mouse.banks[0].table[3], 2400);
+ const sent = mouse.sent.length;
+ await controller.testRefreshStatus();
+ await bridgePoll(target);
+ assert.equal(mouse.banks[0].table[2], 1200);
+ assert.equal(mouse.sent.slice(sent).some(packet => packet[5] < 0x80), false);
+ assert.equal(bridgeSuccessNotices().length, 1);
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks[0], original);
+ });
+}
+
+for (const failedRestore of [false, true]) {
+ test(`Bridge preserves retry state while refresh keeps failing after ${failedRestore ? "restore" : "apply"}`, async () => {
+ const mouse = fixture();
+ const before = await controller.testConnect(mouse.device);
+ const original = structuredClone(mouse.banks[0]);
+ const target = game("A", { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }, before.name);
+ if (failedRestore) await bridgePoll(target);
+ const fault = controller.testFailStatusReads();
+ controller.testToastLog.length = 0;
+ await bridgePoll(failedRestore ? null : target);
+ const afterUnconfirmedWrite = structuredClone(mouse.banks[0]);
+ const sent = mouse.sent.length;
+ for (let poll = 0; poll < 3; poll++) {
+ await controller.testRefreshStatus();
+ await bridgePoll(failedRestore ? target : null);
+ assert.deepEqual(mouse.banks[0], afterUnconfirmedWrite);
+ assert.deepEqual(bridgeSuccessNotices(), []);
+ assert.equal(mouse.sent.length, sent, "no speculative write without a successful status refresh");
+ }
+ assert.equal(fault.attempts(), 7, "one final read plus three background and three foreground retries");
+ fault.allow();
+ await bridgePoll(failedRestore ? target : null);
+ if (failedRestore) {
+ assert.equal(mouse.banks[0].table[2], 1200);
+ assert.equal(mouse.banks[0].table[3], 2400);
+ await bridgePoll(null);
+ }
+ assert.deepEqual(mouse.banks[0], original, "retained originals remain restorable after recovery");
+ });
+}
+
+test("controller does not report an empty manual Apply unchanged until status is confirmed", async () => {
+ const mouse = fixture();
+ await controller.testConnect(mouse.device);
+ const fault = controller.testFailStatusReads();
+ controller.applyLunaFuryDpiStageAxes(1, 1200, 2400);
+ assert.equal(await controller.flashPendingChanges(), "failed");
+ assert.deepEqual(controller.testState().pending, []);
+ assert.equal(await controller.flashPendingChanges(), "failed");
+ fault.allow();
+ const sent = mouse.sent.length;
+ assert.equal(await controller.flashPendingChanges(), "unchanged");
+ assert.equal(controller.testState().status.dpiStages[1], 1200);
+ assert.equal(mouse.sent.slice(sent).some(packet => packet[5] < 0x80), false);
+});
+
+test("a same-JSON background read confirms an untrusted cache without another read or write", async () => {
+ const mouse = fixture();
+ await controller.testConnect(mouse.device);
+ const original = structuredClone(mouse.banks[0]);
+ ignoreWrites(mouse);
+ const fault = controller.testFailStatusReads();
+ assert.equal(await controller.flashGameProfileSnapshot({ dpiStages: [400, 1200, 1600] }), "failed");
+ fault.allow();
+ await controller.testRefreshStatus();
+ assert.deepEqual(mouse.banks[0], original);
+ const attempts = fault.attempts(), sent = mouse.sent.length;
+ assert.equal(await controller.flashGameProfileSnapshot({ dpiStages: [400, 800, 1600] }), "unchanged");
+ assert.equal(fault.attempts(), attempts, "a successful background read restores trust even when JSON did not change");
+ assert.equal(mouse.sent.length, sent);
+});
+
+test("Bridge captures newly touched fields from the refreshed status after an unconfirmed write", async () => {
+ const mouse = fixture();
+ const generic = await controller.testConnectGeneric(mouse.device);
+ const name = generic.stored.name;
+ controller.testRejectNextStatusRead();
+ await bridgePoll(game("A", { dpi: 1200 }, name));
+ assert.equal(generic.stored.dpi, 1200);
+ assert.equal(controller.testState().status.dpi, 800);
+ generic.stored.pollingRateHz = 2000; // A did not touch this externally changed field.
+ assert.equal(controller.testState().status.pollingRateHz, 1000);
+ await bridgePoll(game("B", { dpi: 1200, pollingRateHz: 1000 }, name));
+ assert.equal(generic.stored.pollingRateHz, 1000);
+ await bridgePoll(null);
+ assert.equal(generic.stored.dpi, 800, "A's accumulated original still wins for DPI");
+ assert.equal(generic.stored.pollingRateHz, 2000, "B captures its newly touched field after refresh, not from stale cache");
+});
+
+test("generic scalar-DPI Games writes obey status trust and avoid redundant confirmed writes", async () => {
+ const mouse = fixture();
+ const generic = await controller.testConnectGeneric(mouse.device);
+ assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "written");
+ assert.deepEqual(generic.writes, [1200]);
+ const reads = generic.reads();
+ assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "unchanged");
+ assert.equal(generic.reads(), reads);
+ controller.testRejectNextStatusRead();
+ assert.equal(await controller.flashGameProfileSnapshot({ dpi: 800 }), "failed");
+ assert.equal(generic.stored.dpi, 800);
+ const fault = controller.testFailStatusReads();
+ assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "failed");
+ assert.deepEqual(generic.writes, [1200, 800]);
+ fault.allow();
+ assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "written");
+ assert.deepEqual(generic.writes, [1200, 800, 1200]);
+ assert.equal(await controller.flashGameProfileSnapshot({ dpi: 800 }), "written");
+});
+
+test("manual staging does not drop an edit matching only the untrusted old cache", async () => {
+ const mouse = fixture();
+ const generic = await controller.testConnectGeneric(mouse.device);
+ controller.testRejectNextStatusRead();
+ assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "failed");
+ assert.equal(controller.testState().status.dpi, 800);
+ assert.equal(generic.stored.dpi, 1200);
+ controller.applyDpiValue(800);
+ assert.deepEqual(controller.testState().pending, ["dpi"]);
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.equal(generic.stored.dpi, 800);
+});
+
+test("failed foreground refresh preserves pending edits, then confirmed recovery rebases their preview", async () => {
+ const mouse = fixture();
+ await controller.testConnect(mouse.device);
+ controller.applyLunaFuryDpiStageAxes(0, 600, 1200);
+ const fault = controller.testFailStatusReads();
+ const target = { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] };
+ assert.equal(await controller.flashGameProfileSnapshot(target), "failed");
+ assert.equal(await controller.flashGameProfileSnapshot(target), "failed");
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]);
+ assert.deepEqual(controller.testState().preview.dpiStages, [600, 800, 1600]);
+ fault.allow();
+ assert.equal(await controller.flashGameProfileSnapshot(target), "unchanged");
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]);
+ assert.deepEqual(controller.testState().preview.dpiStages, [600, 1200, 1600]);
+});
+
+test("an in-flight foreground recovery blocks another Games attempt until the read completes", async () => {
+ const mouse = fixture();
+ const generic = await controller.testConnectGeneric(mouse.device);
+ controller.testRejectNextStatusRead();
+ assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "failed");
+ const gate = controller.testDeferNextStatusRead();
+ const first = controller.flashGameProfileSnapshot({ dpi: 1200 });
+ assert.equal(gate.started(), true);
+ try {
+ assert.equal(await controller.flashGameProfileSnapshot({ dpi: 800 }), "busy");
+ assert.deepEqual(generic.writes, [1200]);
+ } finally {
+ gate.release();
+ }
+ assert.equal(await first, "unchanged");
+ assert.equal(await controller.flashGameProfileSnapshot({ dpi: 800 }), "written");
+});
+
test("controller restores legacy target-stage edits and preserves unrelated pending user changes", async () => {
const { device, banks } = fixture();
const before = await controller.testConnect(device);
diff --git a/docs/lunafury-development.md b/docs/lunafury-development.md
index 29ca4b26..fef57bd4 100644
--- a/docs/lunafury-development.md
+++ b/docs/lunafury-development.md
@@ -69,9 +69,9 @@ files and vendor images.
## Automated validation
-The paired application build and all 367 application tests pass, including
-45 controller/Bridge integration tests. Bundle checks pass at 205,473 / 210,000
-bytes for CSS and 2,120,074 / 2,150,000 bytes for JavaScript. These results
+The paired application build and all 384 application tests pass, including
+62 controller/Bridge integration tests. Bundle checks pass at 205,473 / 210,000
+bytes for CSS and 2,120,700 / 2,150,000 bytes for JavaScript. These results
use the local protocol tarball; they do not establish that a registry-only
install builds.
@@ -156,3 +156,37 @@ during LunaFury live reads. Its older bank-scoped setters and status reads
now share the queue used by bank switches and the newer setters. Other
brands retain their existing execution paths. These tests use simulated
devices and do not add a hardware-verification result.
+
+### Unconfirmed status-cache regressions (2026-10-11)
+
+A failed final read now marks the displayed snapshot untrusted. Games must
+obtain a successful status read before planning writes, capturing newly
+touched originals or confirming `unchanged`. An empty manual Apply follows
+the same rule. Manual staging cannot discard an edit just because it matches
+that old snapshot. A successful background read restores trust even when
+its JSON is unchanged; a local-only layer preview does not.
+
+If the foreground refresh also fails, Games returns `failed` without
+attempting another write. Bridge keeps its original-settings backup and
+does not record an applied signature or notify success. Once reads recover,
+the next poll can apply or restore from the confirmed status. If the previous
+write already landed, the fresh read can confirm `unchanged` without sending
+a duplicate write. An in-flight recovery returns `busy` to another Games
+attempt rather than allowing it to plan against the stale cache.
+
+Seventeen integration cases were added. Both counterexamples run on all
+four runtime PIDs: apply lands but final read fails, followed by exit before
+background refresh; restore lands but final read fails, followed by reopening
+the same game. Two cases inject sustained foreground and background read
+failures, then check recovery and restoration of the retained originals.
+The remaining cases cover empty Apply, same-JSON recovery, newly touched
+fields, generic scalar-DPI control, manual deduplication, pending user edits
+and overlapping recovery. Sixteen cases failed against `b2fab92` before this
+fix; the same-JSON background recovery was an already-passing control case.
+
+All 62 controller/Bridge cases and all 384 application tests pass. The 17
+Bridge cases across both failure-handling rounds run the real subscription
+callback with synthetic refs and status events, not a DOM renderer or native
+Bridge process. Protocol source, packets and dependencies are unchanged;
+its build, 151 focused tests, package dry-run and runtime audit were rerun.
+No new hardware result is claimed.
diff --git a/src/app/useBridgeProfileApplier.ts b/src/app/useBridgeProfileApplier.ts
index fb86c60d..1645a209 100644
--- a/src/app/useBridgeProfileApplier.ts
+++ b/src/app/useBridgeProfileApplier.ts
@@ -59,42 +59,47 @@ export function useBridgeProfileApplier(snapshot: ControlSnapshot): void {
const status = deviceStatusRef.current;
const profile = bridge?.activeProfile ?? null;
- if (!profile || !status || profile.device.name !== status.name) {
- // Nothing (recognized) is active for this mouse right now. Put back
- // what was there before any successful or partial profile application.
- const prior = priorSettings.current;
- // A failed first application can still have changed earlier batches,
- // even though it never earned an applied signature.
- if (!status || !prior) return;
- writing.current = true;
- void control.flashGameProfileSnapshot(prior).then((result) => {
+ if (!status || (!profile && !priorSettings.current)) return;
+
+ writing.current = true;
+ let attemptedPrior: GameProfileSnapshot | null = null;
+ void (async () => {
+ // A failed final read leaves a display snapshot, not a reliable baseline.
+ // Refresh before both no-op detection and capturing newly touched fields.
+ const confirmed = await control.prepareGameProfileStatus();
+ if (typeof confirmed === "string") {
+ if (confirmed === "failed") appliedSignature.current = null;
+ return;
+ }
+ if (!profile || profile.device.name !== confirmed.name) {
+ // Nothing (recognized) is active for this mouse right now. Put back
+ // what was there before any successful or partial profile application.
+ const prior = priorSettings.current;
+ // A failed first application can still have changed earlier batches,
+ // even though it never earned an applied signature.
+ if (!prior) return;
+ const result = await control.flashGameProfileSnapshot(prior);
if (result === "busy") return;
appliedSignature.current = null;
if (result === "failed") return;
priorSettings.current = null;
if (result === "written") control.pushToast("info", t(localeRef.current, "bridge.profileRestored"));
- }).catch(() => {
- // Keep the backup if an unexpected rejection followed a partial write.
- appliedSignature.current = null;
- }).finally(() => {
- writing.current = false;
- });
- return;
- }
+ return;
+ }
- const sig = signature(profile);
- if (sig === appliedSignature.current) return;
+ const sig = signature(profile);
+ if (sig === appliedSignature.current) return;
- 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 = mergeSnapshots(pickSnapshot(status, target), priorSettings.current ?? {}, status);
- // 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 = mergeSnapshots(priorSettings.current ?? {}, target, status);
+ 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 = mergeSnapshots(pickSnapshot(confirmed, target), priorSettings.current ?? {}, confirmed);
+ // 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 = mergeSnapshots(priorSettings.current ?? {}, target, confirmed);
- writing.current = true;
- void control.flashGameProfileSnapshot(toWrite).then((result) => {
+ attemptedPrior = prior;
+ const result = await control.flashGameProfileSnapshot(toWrite);
if (result === "busy") return;
// Back up attempted fields too: an unsuccessful write is not atomic.
priorSettings.current = prior;
@@ -102,8 +107,9 @@ export function useBridgeProfileApplier(snapshot: ControlSnapshot): void {
if (result === "written") {
control.pushToast("info", t(localeRef.current, "bridge.profileAppliedHere"), profile.application.name);
}
- }).catch(() => {
- priorSettings.current = prior;
+ })().catch(() => {
+ // Keep originals even if an unexpected rejection followed a partial write.
+ if (attemptedPrior) priorSettings.current = attemptedPrior;
appliedSignature.current = null;
}).finally(() => {
writing.current = false;
diff --git a/src/device/controller.ts b/src/device/controller.ts
index 41c7914a..bb5ac440 100644
--- a/src/device/controller.ts
+++ b/src/device/controller.ts
@@ -345,6 +345,9 @@ const deviceStatuses = new Map();
let latestDiagnosticsSnapshot: Record | null = null;
let latestDiagnosticStatus: MouseStatus | null = null;
let latestDeviceStatus: MouseStatus | null = null;
+// Keep the last snapshot visible after a read failure, but do not use it to
+// confirm no-op writes or capture Games originals after an unconfirmed write.
+let deviceStatusNeedsRefresh = false;
let lastDiagnosticCommand: string | null = null;
let lastDiagnosticError: string | null = null;
let reconnectInFlight = false;
@@ -1147,7 +1150,7 @@ function queueInstantFlash(): void {
let matchesBaseline: { status: MouseStatus; json: string } | null = null;
function matchesDeviceStatus(change: PendingChange): boolean {
- if (!latestDeviceStatus) return false;
+ if (!latestDeviceStatus || deviceStatusNeedsRefresh) return false;
if (!change.preview) return false;
if (matchesBaseline?.status !== latestDeviceStatus) {
matchesBaseline = { status: latestDeviceStatus, json: JSON.stringify(latestDeviceStatus) };
@@ -1232,17 +1235,44 @@ export function isGameProfileDraftActive(): boolean {
export type FlashResult = "written" | "unchanged" | "busy" | "failed";
+/** Refresh an unconfirmed post-write cache before Games planning or deduplication. */
+export async function prepareGameProfileStatus(): Promise {
+ if (gameProfileDraft || settingInProgress || activationInProgress || !latestDeviceStatus) return "busy";
+ if (!deviceStatusNeedsRefresh) return latestDeviceStatus;
+ if (refreshInProgress) return "busy";
+ const client = activeSettingsClient();
+ if (!client) return "busy";
+ refreshInProgress = true;
+ try {
+ const status = await statusAfterWrite(client);
+ if (client !== activeSettingsClient() || client.device !== activeDevice) return "busy";
+ applyStatus(status);
+ return latestDeviceStatus!;
+ } catch (error) {
+ recordDiagnosticError(error, st("ctl.unableRefresh"));
+ const failure = diagnosticErrorMessage(error, st("ctl.unableRefresh"));
+ setReadStatus(failure);
+ pushToast("error", st("ctl.flashFailed"), failure);
+ return "failed";
+ } finally {
+ refreshInProgress = false;
+ }
+}
+
/**
* Writes `snapshot` to the mouse now (a game starting or stopping). Only the
* snapshot is flashed: the user's own unflashed edits are set aside for the
* write and staged again afterwards. "busy" means nothing was attempted (a
* draft is open or another write is running) and the caller should retry;
- * "unchanged" means the mouse already had every value. "failed" means the
+ * "unchanged" means a confirmed status already had every value. "failed" means the
* attempt did not complete; earlier batches may already have landed.
*/
export async function flashGameProfileSnapshot(
snapshot: GameProfileSnapshot,
): Promise {
+ const status = await prepareGameProfileStatus();
+ if (typeof status === "string") return status;
+ // Another writer or a draft may start while the refresh yields.
if (gameProfileDraft || settingInProgress || !latestDeviceStatus) return "busy";
const stash = stashPendingChanges();
try {
@@ -1906,12 +1936,16 @@ async function flashPause(milliseconds = FLASH_STEP_DELAY_MS): Promise {
export async function flashPendingChanges(): Promise {
if (gameProfileDraft) return "busy";
const batches = pendingChangeBatches();
- if (batches.length === 0) return "unchanged";
if (settingInProgress) {
flashQueued = true;
setReadStatus(st("ctl.waitFlash"));
return "busy";
}
+ if (batches.length === 0) {
+ if (!deviceStatusNeedsRefresh) return "unchanged";
+ const status = await prepareGameProfileStatus();
+ return typeof status === "string" ? status : "unchanged";
+ }
settingInProgress = true;
pendingBusy = true;
emit();
@@ -1951,6 +1985,9 @@ export async function flashPendingChanges(): Promise {
await flashPause(FLASH_SETTLE_MS);
applyStatus(await statusAfterWrite(requireSettingsClient()));
} catch (error) {
+ deviceStatusNeedsRefresh = true;
+ lastRenderedStatusKey = null;
+ matchesBaseline = null;
if (!failure) {
recordDiagnosticError(error, st("ctl.unableFlash"));
failure = diagnosticErrorMessage(error, st("ctl.unableFlash"));
@@ -2336,8 +2373,11 @@ function setPageTitle(prefix?: string): void {
document.title = prefix ? `${prefix} - ${DEFAULT_TITLE}` : DEFAULT_TITLE;
}
-function applyStatus(deviceStatus: MouseStatus, statusKey?: string): void {
- batch(() => applyStatusInner(deviceStatus, statusKey));
+function applyStatus(deviceStatus: MouseStatus, statusKey?: string, confirmed = true): void {
+ batch(() => {
+ if (confirmed) deviceStatusNeedsRefresh = false;
+ applyStatusInner(deviceStatus, statusKey);
+ });
}
function applyStatusInner(deviceStatus: MouseStatus, statusKey?: string): void {
@@ -2596,6 +2636,7 @@ async function activateClientNow(client: SupportedClient): Promise {
if (active && active !== client) await active.close().catch(() => undefined);
clearPendingChanges();
latestDeviceStatus = null;
+ deviceStatusNeedsRefresh = false;
clearActiveClients();
if (switchingDevice) {
onboardProfiles = null;
@@ -2705,6 +2746,7 @@ function showDisconnectedState(): void {
capabilities = null;
clearPendingChanges();
latestDeviceStatus = null;
+ deviceStatusNeedsRefresh = false;
buttons = null;
clearNapeKeymaps();
setPageTitle();
@@ -4034,7 +4076,7 @@ export async function switchNapeLayer(layer: number): Promise {
const status = latestDeviceStatus;
if (!status) return;
editedNapeLayer = layer;
- applyStatus({ ...status, napeLayer: layer });
+ applyStatus({ ...status, napeLayer: layer }, undefined, false);
installPreviewNapeKeymap(layer);
setReadStatus(st("ctl.previewSwitched", { label: layerLabel(interfacePreferences.locale, keychronLayerLabel(layer)) }));
return;
@@ -5371,7 +5413,7 @@ async function refreshStatus(): Promise {
const currentClient = activeSettingsClient();
if (client !== currentClient || client.device !== activeDevice) return;
const key = JSON.stringify(status);
- if (key !== lastRenderedStatusKey) applyStatus(status, key);
+ if (deviceStatusNeedsRefresh || key !== lastRenderedStatusKey) applyStatus(status, key);
} catch (error) {
lastRenderedStatusKey = null;
deviceStatusText = st("ctl.waitingRefresh");
From 54de88887ecaea27e2f10a140165ec2db49a1eb7 Mon Sep 17 00:00:00 2001
From: Breakdown <169538186+RINZE1@users.noreply.github.com>
Date: Mon, 12 Oct 2026 02:48:05 +0800
Subject: [PATCH 07/14] fix: replan staged Apply from confirmed device status
---
build/lunafury-controller.test.ts | 251 ++++++++++++++++++++++++++++++
docs/lunafury-development.md | 46 +++++-
src/device/controller.ts | 69 +++++---
src/pending-changes.test.ts | 51 ++++++
src/pending-changes.ts | 17 +-
5 files changed, 408 insertions(+), 26 deletions(-)
diff --git a/build/lunafury-controller.test.ts b/build/lunafury-controller.test.ts
index f529a3c0..1e333f0d 100644
--- a/build/lunafury-controller.test.ts
+++ b/build/lunafury-controller.test.ts
@@ -113,7 +113,30 @@ before(async () => {
applyStatus(await active.readStatus());
return { stored, writes, reads: () => reads };
}
+ export async function testConnectGenericStages(device) {
+ const generic = await testConnectGeneric(device), stored = generic.stored;
+ stored.dpiStages = [400, 800, 1600, 3200, 6400, 8000];
+ stored.activeDpiStage = 1;
+ stored.ui = { dpiStageEditor: { maxStages: 6, countEditable: true } };
+ dpiOptions = [400, 800, 1200, 1600, 2400, 3200, 4800, 6400, 8000];
+ active.setDpiStageCount = async count => {
+ generic.writes.push({ count });
+ stored.dpiStages = count <= stored.dpiStages.length
+ ? stored.dpiStages.slice(0, count)
+ : [...stored.dpiStages, ...Array(count - stored.dpiStages.length).fill(3200)];
+ stored.activeDpiStage = Math.min(stored.activeDpiStage, count - 1);
+ stored.dpi = stored.dpiStages[stored.activeDpiStage];
+ };
+ active.setDpiStageValue = async (stage, dpi) => {
+ if (stage >= stored.dpiStages.length) throw new Error("Generic stage is not enabled.");
+ generic.writes.push({ stage, dpi });
+ stored.dpiStages[stage] = dpi;
+ };
+ applyStatus(await active.readStatus());
+ return generic;
+ }
export { refreshStatus as testRefreshStatus };
+ export function testClientOnly() { activeDevice = null; }
export { hookWriting as testBridgeWriting };
export { snapshotDiff as testSnapshotDiff, pickSnapshot as testPickSnapshot,
mergeSnapshots as testMergeSnapshots } from "./game-profile-snapshot.ts";
@@ -630,6 +653,234 @@ test("an in-flight foreground recovery blocks another Games attempt until the re
assert.equal(await controller.flashGameProfileSnapshot({ dpi: 800 }), "written");
});
+function dpiTableWrites(mouse: ReturnType, start = 0) {
+ return mouse.sent.slice(start).filter(packet => packet[4] === 1 && packet[5] === 1);
+}
+
+async function unconfirmedCount(mouse: ReturnType, from: number, to: number) {
+ mouse.banks[0].count = from;
+ await controller.testConnect(mouse.device);
+ controller.applyDpiStageCount(to);
+ controller.testRejectNextStatusRead();
+ assert.equal(await controller.flashPendingChanges(), "failed");
+ assert.equal(mouse.banks[0].count, to);
+ assert.equal(controller.testState().status.dpiStages.length, from);
+}
+
+for (const pid of [0x32, 0x33, 0x54, 0x84]) {
+ const max = pid === 0x32 || pid === 0x33 ? 6 : 5;
+ for (const background of [false, true]) {
+ test(`manual count recovery on ${pid.toString(16)} expands before slot edits ${background ? "after background refresh" : "before refresh"}`, async () => {
+ const mouse = fixture(pid);
+ await unconfirmedCount(mouse, max, 3);
+ const original = [...mouse.banks[0].table];
+ controller.applyDpiStageCount(4);
+ controller.applyLunaFuryDpiStageAxes(3, 2400, 4800);
+ if (background) await controller.testRefreshStatus();
+ const start = mouse.sent.length;
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.equal(mouse.banks[0].count, 4);
+ assert.deepEqual(mouse.banks[0].table, [...original.slice(0, 6), 2400, 4800, ...original.slice(8)]);
+ assert.deepEqual(dpiTableWrites(mouse, start).map(packet => packet[7]), [4, 4]);
+ assert.equal((dpiTableWrites(mouse, start)[0][20] << 8) | dpiTableWrites(mouse, start)[0][21], original[6],
+ "the first write expands storage before the fourth-slot value changes");
+ assert.deepEqual(controller.testState().pending, []);
+ assert.equal(await controller.flashPendingChanges(), "unchanged");
+ });
+ }
+
+ test(`manual count recovery on ${pid.toString(16)} edits before a recovered reduction`, async () => {
+ const mouse = fixture(pid);
+ await unconfirmedCount(mouse, 3, max);
+ controller.applyDpiStageCount(4);
+ controller.applyLunaFuryDpiStageAxes(1, 2400, 4800);
+ const start = mouse.sent.length;
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.deepEqual(dpiTableWrites(mouse, start).map(packet => packet[7]), [max, 4]);
+ assert.equal(mouse.banks[0].count, 4);
+ assert.deepEqual(mouse.banks[0].table.slice(2, 4), [2400, 4800]);
+ });
+
+ test(`manual count recovery on ${pid.toString(16)} preserves nonempty staging while reads keep failing`, async () => {
+ const mouse = fixture(pid);
+ await unconfirmedCount(mouse, max, 3);
+ controller.applyDpiStageCount(4);
+ controller.applyLunaFuryDpiStageAxes(3, 2400, 4800);
+ const fault = controller.testFailStatusReads();
+ const start = mouse.sent.length;
+ controller.testToastLog.length = 0;
+ for (let retry = 0; retry < 3; retry++) {
+ assert.equal(await controller.flashPendingChanges(), "failed");
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-count", "dpi-stage-3"]);
+ assert.equal(mouse.banks[0].count, 3);
+ assert.equal(dpiTableWrites(mouse, start).length, 0);
+ assert.equal(controller.getSnapshot().settingInProgress, false);
+ assert.equal(controller.testToastLog.some((toast: { kind: string }) => toast.kind === "success"), false);
+ }
+ assert.equal(fault.attempts(), 3);
+ fault.allow();
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.equal(mouse.banks[0].count, 4);
+ assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800]);
+ });
+
+ test(`manual count recovery on ${pid.toString(16)} retries a failed slot write after expansion landed`, async () => {
+ const mouse = fixture(pid);
+ await unconfirmedCount(mouse, max, 3);
+ controller.applyDpiStageCount(4);
+ controller.applyLunaFuryDpiStageAxes(3, 2400, 4800);
+ const send = mouse.device.sendFeatureReport;
+ let tables = 0, rejectSlot = true;
+ mouse.device.sendFeatureReport = async (id, report) => {
+ const original = structuredClone(mouse.banks[0]);
+ await send(id, report);
+ if (report[4] === 1 && report[5] === 1 && ++tables === 2 && rejectSlot) {
+ Object.assign(mouse.banks[0], original);
+ }
+ };
+ assert.equal(await controller.flashPendingChanges(), "failed");
+ assert.equal(mouse.banks[0].count, 4, "expansion landed before the rejected slot edit");
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-3"]);
+ rejectSlot = false;
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800]);
+ assert.deepEqual(controller.testState().pending, []);
+ });
+
+ test(`manual restaging on ${pid.toString(16)} drops edits and selection outside the final count`, async () => {
+ const mouse = fixture(pid);
+ await controller.testConnect(mouse.device);
+ controller.applyDpiStageCount(max);
+ controller.applyLunaFuryDpiStageAxes(max - 1, 2400, 4800);
+ controller.applyActiveDpiStage(max - 1);
+ controller.applyDpiStageCount(4);
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-count"]);
+ controller.applyLunaFuryDpiStageAxes(3, 2400, 4800);
+ controller.applyActiveDpiStage(3);
+ // Re-selecting the same count must not discard a valid stage selection.
+ controller.applyDpiStageCount(4);
+ assert.equal(controller.testState().pending.includes("dpi-active-stage"), true);
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.equal(mouse.banks[0].count, 4);
+ assert.equal(mouse.banks[0].active, 4);
+ assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800]);
+ });
+
+ test(`manual priorities on ${pid.toString(16)} survive a Games stash while hardware count changes`, async () => {
+ const mouse = fixture(pid);
+ mouse.banks[0].count = max;
+ await controller.testConnect(mouse.device);
+ controller.applyDpiStageCount(4);
+ controller.applyLunaFuryDpiStageAxes(3, 2400, 4800);
+ assert.equal(await controller.flashGameProfileSnapshot({
+ dpiStages: [400, 800, 1600], dpiStagesY: [800, 1600, 3200],
+ }), "written");
+ assert.equal(mouse.banks[0].count, 3);
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-count", "dpi-stage-3"]);
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.equal(mouse.banks[0].count, 4);
+ assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800]);
+ });
+}
+
+test("manual preflight retains its queue after an in-flight background read and foreground recovery both fail", async () => {
+ const mouse = fixture();
+ await unconfirmedCount(mouse, 6, 3);
+ controller.applyDpiStageCount(4);
+ controller.applyLunaFuryDpiStageAxes(3, 2400, 4800);
+ const fault = controller.testFailStatusReads();
+ const gate = controller.testDeferNextStatusRead();
+ const start = mouse.sent.length;
+ const background = controller.testRefreshStatus();
+ const apply = controller.flashPendingChanges();
+ try {
+ assert.equal(await controller.flashPendingChanges(), "busy");
+ } finally {
+ gate.release();
+ }
+ await background;
+ assert.equal(await apply, "failed");
+ assert.equal(fault.attempts(), 2, "no post-write read or queued auto-retry when preflight failed");
+ assert.equal(dpiTableWrites(mouse, start).length, 0);
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-count", "dpi-stage-3"]);
+ assert.equal(controller.getSnapshot().settingInProgress, false);
+ fault.allow();
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800]);
+});
+
+test("nonempty manual Apply waits for an in-flight background read before resolving priorities", async () => {
+ const mouse = fixture();
+ await unconfirmedCount(mouse, 6, 3);
+ controller.applyDpiStageCount(4);
+ controller.applyLunaFuryDpiStageAxes(3, 2400, 4800);
+ const gate = controller.testDeferNextStatusRead();
+ const background = controller.testRefreshStatus();
+ const apply = controller.flashPendingChanges();
+ assert.equal(gate.started(), true);
+ assert.equal(controller.getSnapshot().settingInProgress, true);
+ try {
+ assert.equal(await controller.flashPendingChanges(), "busy");
+ controller.applyLunaFuryDpiStageAxes(3, 3200, 6400);
+ } finally {
+ gate.release();
+ }
+ await background;
+ assert.equal(await apply, "written");
+ assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800], "the write guard protects the staged plan while waiting");
+});
+
+test("nonempty manual Apply locks foreground recovery and leaves Games/user staging intact", async () => {
+ const mouse = fixture();
+ await unconfirmedCount(mouse, 6, 3);
+ controller.applyDpiStageCount(4);
+ controller.applyLunaFuryDpiStageAxes(3, 2400, 4800);
+ const gate = controller.testDeferNextStatusRead();
+ const apply = controller.flashPendingChanges();
+ try {
+ assert.equal(gate.started(), true);
+ assert.equal(controller.getSnapshot().settingInProgress, true);
+ assert.equal(await controller.flashGameProfileSnapshot({ dpiStages: [400, 800, 1600] }), "busy");
+ assert.equal(controller.beginGameProfileDraft({}), false);
+ controller.applyDpiStageCount(2);
+ assert.deepEqual(controller.testState().pending, ["dpi-stage-count", "dpi-stage-3"]);
+ } finally {
+ gate.release();
+ await apply;
+ }
+ assert.equal(await apply, "written");
+ assert.equal(mouse.banks[0].count, 4);
+ assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800]);
+});
+
+test("generic staged-DPI manual Apply also replans expansion against recovered status", async () => {
+ const mouse = fixture();
+ const generic = await controller.testConnectGenericStages(mouse.device);
+ controller.applyDpiStageCount(3);
+ controller.testRejectNextStatusRead();
+ assert.equal(await controller.flashPendingChanges(), "failed");
+ controller.applyDpiStageCount(4);
+ controller.applyDpiStageValue(3, 2400);
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.deepEqual(generic.writes, [{ count: 3 }, { count: 4 }, { stage: 3, dpi: 2400 }]);
+ assert.deepEqual(generic.stored.dpiStages, [400, 800, 1600, 2400]);
+});
+
+test("client-only stand-ins still support manual Apply and recovery without a physical HID selection", async () => {
+ const mouse = fixture();
+ const generic = await controller.testConnectGeneric(mouse.device);
+ controller.testClientOnly();
+ controller.applyDpiValue(1200);
+ assert.equal(await controller.flashPendingChanges(), "written");
+ controller.applyDpiValue(800);
+ controller.testRejectNextStatusRead();
+ assert.equal(await controller.flashPendingChanges(), "failed");
+ assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "written");
+ controller.applyDpiValue(400);
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.equal(generic.stored.dpi, 400);
+});
+
test("controller restores legacy target-stage edits and preserves unrelated pending user changes", async () => {
const { device, banks } = fixture();
const before = await controller.testConnect(device);
diff --git a/docs/lunafury-development.md b/docs/lunafury-development.md
index fef57bd4..577d3d5e 100644
--- a/docs/lunafury-development.md
+++ b/docs/lunafury-development.md
@@ -69,9 +69,9 @@ files and vendor images.
## Automated validation
-The paired application build and all 384 application tests pass, including
-62 controller/Bridge integration tests. Bundle checks pass at 205,473 / 210,000
-bytes for CSS and 2,120,700 / 2,150,000 bytes for JavaScript. These results
+The paired application build and all 420 application tests pass, including
+95 controller/Bridge integration tests. Bundle checks pass at 205,473 / 210,000
+bytes for CSS and 2,121,349 / 2,150,000 bytes for JavaScript. These results
use the local protocol tarball; they do not establish that a registry-only
install builds.
@@ -184,9 +184,47 @@ fields, generic scalar-DPI control, manual deduplication, pending user edits
and overlapping recovery. Sixteen cases failed against `b2fab92` before this
fix; the same-JSON background recovery was an already-passing control case.
-All 62 controller/Bridge cases and all 384 application tests pass. The 17
+At the end of that round, all 62 controller/Bridge cases and all 384
+application tests passed. The 17
Bridge cases across both failure-handling rounds run the real subscription
callback with synthetic refs and status events, not a DOM renderer or native
Bridge process. Protocol source, packets and dependencies are unchanged;
its build, 151 focused tests, package dry-run and runtime audit were rerun.
No new hardware result is claimed.
+
+### Manual Apply recovery and ordering (2026-10-12)
+
+Nonempty manual Apply now recovers an untrusted snapshot before resolving
+its pending batches. It waits for an in-flight background read while holding
+the write guard. If recovery fails, it returns `failed`, sends no setting
+writes and retains the pending queue. It does not send a redundant final
+read or run a queued auto-retry after a failed preflight.
+
+DPI count ordering is resolved from the confirmed enabled count on every
+Apply, rather than stored as a priority when the user stages the edit.
+Expansion therefore precedes dependent slot edits; reduction follows them.
+This also applies when a background read has already restored trust, when
+Games has stashed and restored manual edits, and when a partially completed
+batch is retried. Existing grouped last-writer behavior and equal-priority
+staging order are preserved. Changing the requested count drops slot edits
+and selections outside that final count, but retains valid selections.
+Count restaging cannot remove pending edits while Apply is locked.
+
+Thirty-three integration cases and three pending-order unit tests were
+added. On each of the four runtime PIDs, they cover unconfirmed reduction
+followed by expansion and fourth-slot X/Y edits, with and without intervening
+background recovery; the reverse count direction; sustained read failures;
+partial-write retry; consecutive count edits; and manual staging carried
+through a Games switch. Other cases cover overlapping background/foreground
+failure, locked recovery, generic stage-DPI clients and client-only stand-ins.
+The 32 fault/order integration cases and all three unit cases failed against
+`1b75133` before the fix. The separate owner-supplied six-to-three-to-four
+reproduction now returns `written`, clears the queue and leaves four stages;
+a second Apply returns `unchanged`.
+
+The build and all 420 app tests pass, including 95 controller/Bridge cases.
+The existing 17 hook callback cases remain unchanged. Bundle checks and
+application audit pass. Protocol source, packets and dependencies did not
+change; its build, 151 focused tests, package dry-run and runtime audit were
+rerun. These are synthetic tests with the local protocol package, not new
+hardware, native-Bridge, full-protocol-CI or registry-only results.
diff --git a/src/device/controller.ts b/src/device/controller.ts
index bb5ac440..286d130f 100644
--- a/src/device/controller.ts
+++ b/src/device/controller.ts
@@ -1242,10 +1242,11 @@ export async function prepareGameProfileStatus(): Promise {
}
export async function flashPendingChanges(): Promise {
- if (gameProfileDraft) return "busy";
- const batches = pendingChangeBatches();
+ if (gameProfileDraft || activationInProgress) return "busy";
if (settingInProgress) {
flashQueued = true;
setReadStatus(st("ctl.waitFlash"));
return "busy";
}
- if (batches.length === 0) {
- if (!deviceStatusNeedsRefresh) return "unchanged";
- const status = await prepareGameProfileStatus();
- return typeof status === "string" ? status : "unchanged";
- }
+ if (!hasPendingChanges() && !deviceStatusNeedsRefresh) return "unchanged";
+ const client = activeSettingsClient();
+ if (!client || !latestDeviceStatus) return "busy";
+ const device = activeDevice;
+ // Lock staging before waiting/reading and bind this attempt to its client.
+ // Batch ordering must use the recovered status, not a pre-await plan.
settingInProgress = true;
- pendingBusy = true;
+ pendingBusy = hasPendingChanges();
emit();
let written = 0;
+ let writeAttempted = false;
let failure: string | null = null;
if (refreshInProgress) {
pendingStatusText = st("ctl.waitRefresh");
@@ -1964,6 +1966,17 @@ export async function flashPendingChanges(): Promise {
}
}
try {
+ if (!failure) {
+ if (client !== activeSettingsClient() || device !== activeDevice) throw new Error(st("ctl.gone"));
+ if (deviceStatusNeedsRefresh) {
+ const status = await statusAfterWrite(client);
+ if (client !== activeSettingsClient() || device !== activeDevice) throw new Error(st("ctl.gone"));
+ applyStatus(status);
+ }
+ }
+ // Resolve on every Apply, including when a background read already
+ // recovered trust but the user's pending operations predate that read.
+ const batches = failure ? [] : pendingChangeBatches(latestDeviceStatus!);
for (const batch of batches) {
if (failure) break;
const writer = batch[batch.length - 1];
@@ -1972,7 +1985,9 @@ export async function flashPendingChanges(): Promise {
readStatus = writer.progress;
emit();
await flashPause();
+ if (client !== activeSettingsClient() || device !== activeDevice) throw new Error(st("ctl.gone"));
for (const change of batch) recordDiagnosticCommand(change.command);
+ writeAttempted = true;
await writer.apply();
for (const change of batch) dropPendingChange(change.key);
written += batch.length;
@@ -1982,8 +1997,15 @@ export async function flashPendingChanges(): Promise {
failure = error instanceof Error ? error.message : "Unable to flash the staged changes.";
}
try {
- await flashPause(FLASH_SETTLE_MS);
- applyStatus(await statusAfterWrite(requireSettingsClient()));
+ // A failed preflight must leave staging intact and perform no speculative
+ // writes or second status read. Actual writes still require final readback.
+ if (writeAttempted) {
+ await flashPause(FLASH_SETTLE_MS);
+ if (client !== activeSettingsClient() || device !== activeDevice) throw new Error(st("ctl.gone"));
+ const status = await statusAfterWrite(client);
+ if (client !== activeSettingsClient() || device !== activeDevice) throw new Error(st("ctl.gone"));
+ applyStatus(status);
+ }
} catch (error) {
deviceStatusNeedsRefresh = true;
lastRenderedStatusKey = null;
@@ -1993,8 +2015,8 @@ export async function flashPendingChanges(): Promise {
failure = diagnosticErrorMessage(error, st("ctl.unableFlash"));
}
} finally {
- endDeviceWrite();
pendingBusy = false;
+ endDeviceWrite();
}
if (flashQueued) {
flashQueued = false;
@@ -2008,6 +2030,7 @@ export async function flashPendingChanges(): Promise {
return "failed";
}
pendingStatusText = null;
+ if (written === 0) return "unchanged";
const flashed = written === 1
? st("ctl.flashedOne")
: st("ctl.flashedMany", { n: written });
@@ -3101,22 +3124,28 @@ export function applyDpiValue(dpi: number): boolean {
}
export function applyDpiStageCount(count: number): void {
- if (!hasActiveClient()) return;
+ if (!hasActiveClient() || settingInProgress) return;
const editor = latestDeviceStatus?.ui?.dpiStageEditor;
if (!editor || editor.countEditable !== true) return;
if (!Number.isInteger(count) || count < 1 || count > editor.maxStages) return;
if (!("setDpiStageCount" in requireSettingsClient())) return;
- const currentCount = latestDeviceStatus?.dpiStages?.length ?? count;
- if (count < currentCount) {
- for (const change of pendingChanges()) {
- const match = /^dpi-stage(?:-color)?-(\d+)$/.exec(change.key);
- if (match && Number(match[1]) >= count) dropPendingChange(change.key);
+ // The user's final count bounds all staged slots, even when both the
+ // previous and new requested counts exceed the current hardware count.
+ for (const change of pendingChanges()) {
+ const match = /^dpi-stage(?:-color)?-(\d+)$/.exec(change.key);
+ if (match && Number(match[1]) >= count) dropPendingChange(change.key);
+ if (change.key === "dpi-active-stage" && latestDeviceStatus) {
+ const target = structuredClone(latestDeviceStatus);
+ change.preview?.(target);
+ if ((target.activeDpiStage ?? 0) >= count) dropPendingChange(change.key);
}
- dropPendingChange("dpi-active-stage");
}
stageChange({
key: "dpi-stage-count",
- priority: count > currentCount ? -1 : count < currentCount ? 1 : 0,
+ resolvePriority: (status) => {
+ const currentCount = status.dpiStages?.length ?? count;
+ return count > currentCount ? -1 : count < currentCount ? 1 : 0;
+ },
label: `${count} DPI stage${count === 1 ? "" : "s"}`,
command: `Set DPI stage count to ${count}`,
progress: `Setting ${count} DPI stages…`,
diff --git a/src/pending-changes.test.ts b/src/pending-changes.test.ts
index 507f2069..d95847eb 100644
--- a/src/pending-changes.test.ts
+++ b/src/pending-changes.test.ts
@@ -131,6 +131,57 @@ test("pending changes preview without mutating the device status", () => {
assert.equal(hasPendingChanges(), false);
});
+test("state-dependent priorities re-evaluate the same staging after hardware count changes", () => {
+ clearPendingChanges();
+ try {
+ stagePendingChange({ key: "count", label: "", command: "", progress: "",
+ resolvePriority: current => 4 > current.dpiStages!.length ? -1 : 4 < current.dpiStages!.length ? 1 : 0,
+ apply: async () => {} });
+ stagePendingChange({ key: "slot", label: "", command: "", progress: "", apply: async () => {} });
+ const current = { ...status(), dpiStages: [400, 800, 1600, 3200, 6400, 8000] };
+ const staged = pendingChanges();
+ assert.deepEqual(pendingChangeBatches(current).map(([change]) => change?.key), ["slot", "count"]);
+ current.dpiStages = current.dpiStages.slice(0, 3);
+ assert.deepEqual(pendingChangeBatches(current).map(([change]) => change?.key), ["count", "slot"]);
+ current.dpiStages.push(3200);
+ assert.deepEqual(pendingChangeBatches(current).map(([change]) => change?.key), ["count", "slot"]);
+ assert.deepEqual(pendingChanges(), staged, "resolving must not replace or reorder the stored user edits");
+ } finally {
+ clearPendingChanges();
+ }
+});
+
+test("dynamic ordering requires status even for a single batch", () => {
+ clearPendingChanges();
+ try {
+ stagePendingChange({ key: "count", label: "", command: "", progress: "",
+ resolvePriority: () => -1, apply: async () => {} });
+ assert.throws(() => pendingChangeBatches(), /Confirmed device status is required/);
+ } finally {
+ clearPendingChanges();
+ }
+});
+
+test("dynamic ordering preserves grouped last-writer semantics and stable equal priorities", () => {
+ clearPendingChanges();
+ try {
+ const stage = (key: string, group?: string) => stagePendingChange({
+ key, group, label: "", command: "", progress: "", apply: async () => {} });
+ stagePendingChange({ key: "mode-first", group: "mode", label: "", command: "", progress: "",
+ resolvePriority: () => 0, apply: async () => {} });
+ stage("unrelated");
+ stage("mode-last", "mode");
+ stagePendingChange({ key: "count", label: "", command: "", progress: "",
+ resolvePriority: () => -1, apply: async () => {} });
+ const batches = pendingChangeBatches(status());
+ assert.deepEqual(batches.map(batch => batch.map(change => change.key)),
+ [["count"], ["mode-first", "mode-last"], ["unrelated"]]);
+ assert.equal(batches[1].at(-1)?.key, "mode-last");
+ } finally {
+ clearPendingChanges();
+ }
+});
+
test("stashed changes come back in their original order after another set was staged", () => {
clearPendingChanges();
const change = (key: string) => ({ key, label: key, command: "", progress: "", apply: async () => {} });
diff --git a/src/pending-changes.ts b/src/pending-changes.ts
index d7e71d82..42ad458c 100644
--- a/src/pending-changes.ts
+++ b/src/pending-changes.ts
@@ -17,6 +17,10 @@ export interface PendingChange {
progress: string;
// Lower priorities flash first when one setting must establish storage used by another.
priority?: number;
+ // State-dependent ordering is resolved from confirmed hardware status at
+ // Apply time, never frozen from the UI snapshot used when staging.
+ // As with priority, a group's first member determines its batch ordering.
+ resolvePriority?(status: MouseStatus): number;
// Mirrors the staged value onto a status snapshot so the UI can render it.
// Omitted when the setting is not part of MouseStatus (a Logitech onboard
// profile value, say); such a change renders itself and cannot be compared
@@ -56,8 +60,9 @@ export function pendingChanges(): PendingChange[] {
* Changes sharing a group collapse into one batch; an ungrouped change is a
* batch of its own. The last entry of a batch is the one that performs the
* write, so a batch is applied by calling `apply` on `batch.at(-1)`.
+ * A confirmed status is required when a batch has a dynamic priority.
*/
-export function pendingChangeBatches(): PendingChange[][] {
+export function pendingChangeBatches(status?: MouseStatus): PendingChange[][] {
const batches: PendingChange[][] = [];
const byGroup = new Map();
for (const change of changes.values()) {
@@ -76,7 +81,15 @@ export function pendingChangeBatches(): PendingChange[][] {
byGroup.set(change.group, batch);
batches.push(batch);
}
- return batches.sort((left, right) => (left[0]?.priority ?? 0) - (right[0]?.priority ?? 0));
+ return batches.map(batch => {
+ const first = batch[0];
+ if (first?.resolvePriority && !status) {
+ throw new Error("Confirmed device status is required to order these pending changes.");
+ }
+ const priority = first?.resolvePriority && status
+ ? first.resolvePriority(status) : first?.priority ?? 0;
+ return { batch, priority };
+ }).sort((left, right) => left.priority - right.priority).map(entry => entry.batch);
}
export function pendingChangeCount(): number {
From 3b64b527adb7d14e4757d91e4f19757a62f1793f Mon Sep 17 00:00:00 2001
From: Breakdown <169538186+RINZE1@users.noreply.github.com>
Date: Mon, 12 Oct 2026 17:03:21 +0800
Subject: [PATCH 08/14] docs(lunafury): simplify setup and release notes
---
README.md | 6 +-
docs/lunafury-development.md | 224 ++++-------------------------------
2 files changed, 23 insertions(+), 207 deletions(-)
diff --git a/README.md b/README.md
index a30c0957..6ea6f99b 100644
--- a/README.md
+++ b/README.md
@@ -9,10 +9,8 @@ This branch is deployed as the public development control panel.
## Development
-The LunaFury controls on this branch require the
-[paired protocol setup](docs/lunafury-development.md). The pinned npm release
-does not contain the new APIs. That guide also covers the release order and
-the remaining checks before the application PR can be merged.
+LunaFury controls require the [paired protocol setup](docs/lunafury-development.md)
+until the required APIs are published and the pinned npm dependency is updated.
```bash
npm install
diff --git a/docs/lunafury-development.md b/docs/lunafury-development.md
index 577d3d5e..c2f62b3a 100644
--- a/docs/lunafury-development.md
+++ b/docs/lunafury-development.md
@@ -1,15 +1,11 @@
-# LunaFury development and release
+# LunaFury setup and release
-The onboard controls, button remapping and receiver settings on this branch
-use new APIs from the matching `mouse-protocol` branch. The app still pins
-`@openmouse/protocol` at `0.27.0`, which does not contain those APIs. A plain
-`npm ci` restores that release and the application build fails. Use the
-paired setup below for local review; the application PR must remain a draft
-until a published protocol release provides the APIs.
+These controls require APIs from the paired protocol branch.
+The app's pinned `@openmouse/protocol` `0.27.0` lacks them.
## Local setup
-Use Node.js 24 and clone both branches into sibling directories:
+Use Node.js 24 with the two branches in sibling directories:
```bash
git clone --branch codex/lunafury-onboard-controls https://github.com/sekiranunn/mouse-protocol.git mouse-protocol
@@ -26,205 +22,27 @@ npm run size
npm run dev
```
-`0.1.0` is the protocol checkout's local package version, not the version of
-the published release. If it changes, use the filename printed by `npm pack`.
-This install leaves the app's manifest and lockfile unchanged. Repeat it
-after any app `npm ci`. After a protocol source change, rebuild and repack
-before reinstalling it in the app.
+`0.1.0` is the checkout's local package version. Use the filename printed by
+`npm pack` if it changes. Repeat the no-save install after an app `npm ci`;
+rebuild and repack after protocol changes. Manifest and lockfile remain unchanged.
## Release order
-1. Merge the protocol changes first. The protocol repository uses
- Conventional Commits and semantic-release; a `feat:` squash title allows
- its release workflow to publish the added APIs after `main` passes CI.
-2. Confirm that the published package contains the new LunaFury exports and
- driver methods. A newer version number alone does not establish that.
-3. Update the app's protocol dependency to that exact stable npm version and
- regenerate the lockfile. Do not commit the local tarball override or a
- Git/file dependency. The approved runtime-dependency policy is unchanged.
-4. Run a clean `npm ci`, `npm run check`, `npm run size` and the CI audit
- against the registry package, without reinstalling the local tarball.
- Remove the branch-only README warning and replace this setup note when
- the published dependency works. Then make the app PR ready for review.
+1. Sync both branches with upstream, resolve conflicts and complete protocol CI.
+2. Merge and publish the protocol, then confirm the package contains the required APIs.
+3. Pin that exact stable npm version in the app and update its lockfile.
+4. Run registry-only `npm ci`, `npm run check`, `npm run size` and audit.
+ Remove the temporary README warning and make the app PR ready for review.
-Both topic branches also need to incorporate the current upstream `main`
-before final validation. The checks below describe the existing paired
-branches, not a future upstream merge or published-package installation.
+Do not commit a local tarball override or Git/file dependency.
-## Hardware status
+## Validation scope
-The owner tested LUNA33 firmware `0.0.26.0` over cable and the 8K receiver.
-They confirmed the existing LunaFury controls, button polling, all three
-onboard profiles, button remapping, sleep and the revised DPI editor. All
-four receiver light modes worked over the 8K receiver, and cable connections
-did not show the lighting control. Reconnect readback was also confirmed.
-The new bottom-button power/DPI behavior still needs hardware testing;
-TYPE33 remains unverified. Games apply/restore has automated coverage but
-has not received a separate hardware result.
+The paired app passes its build, 420 tests and bundle checks with the local
+protocol package. The protocol passes its build and 151 focused tests.
+These results do not replace full protocol CI or registry-only app validation.
-The protocol repository contains the packet evidence and hardware checklist
-in [`docs/lunafury-testing.md`](https://github.com/sekiranunn/mouse-protocol/blob/codex/lunafury-onboard-controls/docs/lunafury-testing.md).
-This public branch uses generic artwork and excludes the private test-site
-files and vendor images.
-
-## Automated validation
-
-The paired application build and all 420 application tests pass, including
-95 controller/Bridge integration tests. Bundle checks pass at 205,473 / 210,000
-bytes for CSS and 2,121,349 / 2,150,000 bytes for JavaScript. These results
-use the local protocol tarball; they do not establish that a registry-only
-install builds.
-
-The protocol build and all 151 Lamzu driver/codec tests pass. The broader
-protocol regression run on 2026-10-10 passed 2,252 tests but excluded two
-registry exhaustive checks: `the probe matrix can trigger every driver` and
-`no device can be claimed by more than one driver`. A full protocol CI pass
-is still required before merge.
-
-### DPI and bank-operation regressions
-
-Games snapshots distinguish selecting a stage from editing its DPI. Older
-scalar snapshots address the selected target stage. When games switch,
-the lower-priority legacy scalars are resolved into tables before the new
-target is merged. New tables therefore override prior restore values;
-legacy targets still override restore tables in the opposite direction.
-
-DPI edits and count changes back up the complete five- or six-slot X/Y
-storage separately from the enabled count. When switching games, the app
-composes the final enabled count and X/Y slots from that backup and the new
-target, then stages one complete-table write followed by stage selection.
-It does not restore the old count first and queue individual slot edits
-against the previous game's status. A final table already on the mouse
-requires no table write. Exiting restores the original complete storage,
-so re-enabling a hidden slot reads its original value, including Y values
-stored while the axes are linked. The backup stays in memory and is excluded
-from saved Games snapshots and imports. The protocol rejects another bank
-and verifies every stored slot on readback.
-
-The controller tests use the real draft, staging, Apply and restore paths
-with synthetic HID devices. They cover both cross-format switch directions,
-expansion followed by restore and re-enabling on all four runtime PIDs,
-pending user edits, shrinking counts and combined axis-mode/DPI edits.
-The two cross-format cases and four expansion cases failed before the
-2026-10-10 fixes and pass with them. Those cases call the same merge
-functions and controller path used by the Bridge hook.
-
-Three further switch counterexamples are included in the controller suite:
-both games use the same edited DPI table but a different selection; both
-require four stages but different fourth-slot values; and the first uses
-the model's maximum count while the second needs four stages. The last is
-six-to-four on LUNA33 and five-to-four on TYPE33. All three run on each of
-the four PIDs, producing 12 cases that failed before final-table composition
-and pass with it. They check both axes, count, selection, dormant slots,
-TYPE33 trailing bytes and restoration of the accumulated originals. Three
-unit tests cover composition, unchanged final tables and legacy scalars.
-
-The protocol source did not change for these switch fixes or the failure
-handling below. Its build and 151 focused tests were rerun on 2026-10-11;
-the 2,252-test result above is the earlier broader run.
-
-### Games failure and retry regressions (2026-10-11)
-
-`flashPendingChanges` and `flashGameProfileSnapshot` return `failed` when a
-write is rejected or the final status read fails. A queued flash does not
-hide a failed batch by immediately retrying it. Games always restores the
-user's unrelated staged edits, including when snapshot staging throws.
-`written` means the attempt completed; `unchanged` and `busy` keep their
-existing meaning. Failure does not imply that every earlier write was undone.
-
-The Bridge hook records an applied signature only after `written` or
-`unchanged`. A failed application invalidates any previous signature and
-keeps the accumulated originals, including fields from a partially applied
-first game. A failed restore keeps that backup. The next Bridge status poll
-can retry the same game or the restore; success notifications and backup
-cleanup happen only after confirmation.
-
-Eighteen new integration cases cover ignored complete-table writes and
-restore retries on all four PIDs, a queued flash, final-status read failures,
-staging exceptions, partial application/restoration, old-signature
-invalidation and busy-to-ready retry. Fourteen fault cases failed against
-the previous implementation before the fix. The six Bridge cases execute
-the actual hook subscription callback with persistent synthetic refs and a
-synthetic status subscription, using the real controller and protocol
-driver. They do not mount a DOM/Preact renderer or start a native Bridge
-process. Failed cases assert no success notification and then verify the
-synthetic device's values after retry and restore; they do not treat an
-expected `written` on failure as a passing contract.
-
-The paired protocol branch refreshes the active DPI stage and both tables
-during LunaFury live reads. Its older bank-scoped setters and status reads
-now share the queue used by bank switches and the newer setters. Other
-brands retain their existing execution paths. These tests use simulated
-devices and do not add a hardware-verification result.
-
-### Unconfirmed status-cache regressions (2026-10-11)
-
-A failed final read now marks the displayed snapshot untrusted. Games must
-obtain a successful status read before planning writes, capturing newly
-touched originals or confirming `unchanged`. An empty manual Apply follows
-the same rule. Manual staging cannot discard an edit just because it matches
-that old snapshot. A successful background read restores trust even when
-its JSON is unchanged; a local-only layer preview does not.
-
-If the foreground refresh also fails, Games returns `failed` without
-attempting another write. Bridge keeps its original-settings backup and
-does not record an applied signature or notify success. Once reads recover,
-the next poll can apply or restore from the confirmed status. If the previous
-write already landed, the fresh read can confirm `unchanged` without sending
-a duplicate write. An in-flight recovery returns `busy` to another Games
-attempt rather than allowing it to plan against the stale cache.
-
-Seventeen integration cases were added. Both counterexamples run on all
-four runtime PIDs: apply lands but final read fails, followed by exit before
-background refresh; restore lands but final read fails, followed by reopening
-the same game. Two cases inject sustained foreground and background read
-failures, then check recovery and restoration of the retained originals.
-The remaining cases cover empty Apply, same-JSON recovery, newly touched
-fields, generic scalar-DPI control, manual deduplication, pending user edits
-and overlapping recovery. Sixteen cases failed against `b2fab92` before this
-fix; the same-JSON background recovery was an already-passing control case.
-
-At the end of that round, all 62 controller/Bridge cases and all 384
-application tests passed. The 17
-Bridge cases across both failure-handling rounds run the real subscription
-callback with synthetic refs and status events, not a DOM renderer or native
-Bridge process. Protocol source, packets and dependencies are unchanged;
-its build, 151 focused tests, package dry-run and runtime audit were rerun.
-No new hardware result is claimed.
-
-### Manual Apply recovery and ordering (2026-10-12)
-
-Nonempty manual Apply now recovers an untrusted snapshot before resolving
-its pending batches. It waits for an in-flight background read while holding
-the write guard. If recovery fails, it returns `failed`, sends no setting
-writes and retains the pending queue. It does not send a redundant final
-read or run a queued auto-retry after a failed preflight.
-
-DPI count ordering is resolved from the confirmed enabled count on every
-Apply, rather than stored as a priority when the user stages the edit.
-Expansion therefore precedes dependent slot edits; reduction follows them.
-This also applies when a background read has already restored trust, when
-Games has stashed and restored manual edits, and when a partially completed
-batch is retried. Existing grouped last-writer behavior and equal-priority
-staging order are preserved. Changing the requested count drops slot edits
-and selections outside that final count, but retains valid selections.
-Count restaging cannot remove pending edits while Apply is locked.
-
-Thirty-three integration cases and three pending-order unit tests were
-added. On each of the four runtime PIDs, they cover unconfirmed reduction
-followed by expansion and fourth-slot X/Y edits, with and without intervening
-background recovery; the reverse count direction; sustained read failures;
-partial-write retry; consecutive count edits; and manual staging carried
-through a Games switch. Other cases cover overlapping background/foreground
-failure, locked recovery, generic stage-DPI clients and client-only stand-ins.
-The 32 fault/order integration cases and all three unit cases failed against
-`1b75133` before the fix. The separate owner-supplied six-to-three-to-four
-reproduction now returns `written`, clears the queue and leaves four stages;
-a second Apply returns `unchanged`.
-
-The build and all 420 app tests pass, including 95 controller/Bridge cases.
-The existing 17 hook callback cases remain unchanged. Bundle checks and
-application audit pass. Protocol source, packets and dependencies did not
-change; its build, 151 focused tests, package dry-run and runtime audit were
-rerun. These are synthetic tests with the local protocol package, not new
-hardware, native-Bridge, full-protocol-CI or registry-only results.
+The owner verified LUNA33 firmware `0.0.26.0` over cable and 8K:
+button polling, profiles, DPI, mappings, sleep, receiver lighting and reconnect.
+TYPE33, bottom-button behavior and Games apply/restore have no separate hardware result.
+See the [protocol notes](https://github.com/sekiranunn/mouse-protocol/blob/codex/lunafury-onboard-controls/docs/lunafury-testing.md).
From fd9ab6681eedd92c7d7893e1f5f4e041e030b59f Mon Sep 17 00:00:00 2001
From: Breakdown <169538186+RINZE1@users.noreply.github.com>
Date: Mon, 12 Oct 2026 19:39:00 +0800
Subject: [PATCH 09/14] docs: update paired LunaFury protocol validation
---
docs/lunafury-development.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/lunafury-development.md b/docs/lunafury-development.md
index 9e48bfd1..6d806be9 100644
--- a/docs/lunafury-development.md
+++ b/docs/lunafury-development.md
@@ -39,8 +39,8 @@ Do not commit a local tarball override or Git/file dependency.
## Validation scope
The paired app passes its build, 511 tests, bundle checks and audit with the local
-protocol package. The protocol passes its build, all 2,470 tests and package checks;
-158 Lamzu driver/codec tests also pass with real timers. Local checks do not
+protocol package. The protocol passes its build, all 2,474 tests and package checks;
+162 Lamzu driver/codec tests also pass with real timers. Local checks do not
replace upstream CI or registry-only app validation.
The owner verified LUNA33 firmware `0.0.26.0` over cable and 8K:
From 948618ee97cffc1453962e3615fae581721106d6 Mon Sep 17 00:00:00 2001
From: Breakdown <169538186+RINZE1@users.noreply.github.com>
Date: Mon, 12 Oct 2026 20:10:42 +0800
Subject: [PATCH 10/14] fix(lunafury): preserve axes and scope Games backups
per bank
---
build/lunafury-controller.test.ts | 136 +++++++++++++++++++++++++++-
docs/lunafury-development.md | 8 +-
src/app/cards/LunaFuryDpiEditor.tsx | 7 +-
src/app/useBridgeProfileApplier.ts | 51 +++++++----
src/device/controller.ts | 17 ++++
5 files changed, 195 insertions(+), 24 deletions(-)
diff --git a/build/lunafury-controller.test.ts b/build/lunafury-controller.test.ts
index 1e333f0d..98067953 100644
--- a/build/lunafury-controller.test.ts
+++ b/build/lunafury-controller.test.ts
@@ -7,6 +7,7 @@ import type { BridgeProfile } from "../src/bridge.ts";
let controller: any;
const controllerPath = fileURLToPath(new URL("../src/device/controller.ts", import.meta.url)).replaceAll("\\", "/");
const bridgeHookPath = fileURLToPath(new URL("../src/app/useBridgeProfileApplier.ts", import.meta.url)).replaceAll("\\", "/");
+const dpiEditorPath = fileURLToPath(new URL("../src/app/cards/LunaFuryDpiEditor.tsx", import.meta.url)).replaceAll("\\", "/");
before(async () => {
const storage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
@@ -22,6 +23,7 @@ before(async () => {
plugins: [{ name: "controller-test-state", enforce: "pre",
resolveId(id, importer) {
if (id === "test:bridge-hooks" || id === "react") return "\0test:bridge-hooks";
+ if (id === "../ui" && importer?.replaceAll("\\", "/") === dpiEditorPath) return "\0test:dpi-ui";
if (id === "test:bridge-status" || (id === "../bridge-status-store" && importer?.replaceAll("\\", "/") === bridgeHookPath)) {
return "\0test:bridge-status";
}
@@ -33,10 +35,13 @@ before(async () => {
let refs = [], cursor = 0, mounted = false, cleanup;
export function useRef(initial) { return refs[cursor++] ??= { current: initial }; }
export function useEffect(setup) { if (!mounted) { mounted = true; cleanup = setup(); } }
+ export function useState(initial) { return [typeof initial === "function" ? initial() : initial, () => {}]; }
export function beginRender() { cursor = 0; }
export function resetHook() { cleanup?.(); refs = []; cursor = 0; mounted = false; cleanup = null; }
export function hookWriting() { return refs.at(-1)?.current === true; }
`;
+ // Keep the editor's real event handlers; shared widgets need no DOM here.
+ if (id === "\0test:dpi-ui") return `export const OptionMenu = "OptionMenu", StepperSlider = "StepperSlider", SwitchRow = "SwitchRow";`;
if (id === "\0test:bridge-status") return `
const listeners = new Set();
export function subscribeBridgeStatus(listener) { listeners.add(listener); listener(null); return () => listeners.delete(listener); }
@@ -55,8 +60,9 @@ before(async () => {
import { beginRender, resetHook, hookWriting } from "test:bridge-hooks";
import { emitBridge } from "test:bridge-status";
export const testToastLog = [];
- export async function testConnect(device) {
- resetHook(); testToastLog.length = 0;
+ export async function testConnect(device, preserveSessions = false) {
+ if (!preserveSessions) resetHook();
+ testToastLog.length = 0;
clearPendingChanges(); gameProfileDraft = null;
active = new LamzuHidClient(device); activeDevice = device;
dpiOptions = active.getDpiOptions(); lastDiagnosticError = null;
@@ -138,6 +144,7 @@ before(async () => {
export { refreshStatus as testRefreshStatus };
export function testClientOnly() { activeDevice = null; }
export { hookWriting as testBridgeWriting };
+ export { LunaFuryDpiEditor as testDpiEditor } from "../app/cards/LunaFuryDpiEditor";
export { snapshotDiff as testSnapshotDiff, pickSnapshot as testPickSnapshot,
mergeSnapshots as testMergeSnapshots } from "./game-profile-snapshot.ts";
`;
@@ -221,6 +228,131 @@ function bridgeSuccessNotices() {
toast.title === "Applied game profile" || toast.title === "Restored your previous mouse settings");
}
+function dpiEditorNodes(status: unknown): any[] {
+ const root = controller.testDpiEditor({ snapshot: { status, preferences: { locale: "en" },
+ capabilities: { dpiStagesWritable: true, activeDpiStageWritable: true } } });
+ const nodes: any[] = [];
+ const visit = (node: any) => {
+ if (Array.isArray(node)) { node.forEach(visit); return; }
+ if (!node?.props) return;
+ nodes.push(node); visit(node.props.children);
+ };
+ visit(root);
+ return nodes;
+}
+
+for (const pid of [0x32, 0x33, 0x54, 0x84]) {
+ for (const input of ["slider", "number"]) {
+ test(`DPI editor ${input} on ${pid.toString(16)} preserves Y after an axis-mode read failure`, async () => {
+ const mouse = fixture(pid), receive = mouse.device.receiveFeatureReport;
+ mouse.device.receiveFeatureReport = async () => {
+ const request = mouse.sent.at(-1)!;
+ if (request[4] === 1 && request[5] === 0x8d) throw new Error("Injected axis-mode read failure.");
+ return receive();
+ };
+ const before = await controller.testConnect(mouse.device);
+ assert.equal(before.lunafury.separateDpiAxes, undefined);
+ const original = [...mouse.banks[0].table];
+ const nodes = dpiEditorNodes(before);
+ if (input === "slider") nodes.find(node => node.props.id === "lunafury-dpi-x").props.onCommit(1200);
+ else nodes.filter(node => node.type === "input")[1].props.onfocusout({ currentTarget: { value: "1200" } });
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.deepEqual(mouse.banks[0].table, [...original.slice(0, 2), 1200, ...original.slice(3)]);
+ });
+ }
+
+ test(`Bridge on ${pid.toString(16)} applies and restores Games separately for each onboard bank`, async () => {
+ const mouse = fixture(pid);
+ mouse.banks[1].table[2] = 1000; mouse.banks[1].table[3] = 2000;
+ const original = structuredClone(mouse.banks);
+ const before = await controller.testConnect(mouse.device);
+ const profile = game("A", { dpi: 1200 }, before.name);
+ await bridgePoll(profile);
+ assert.equal(mouse.banks[0].table[2], 1200);
+ controller.applyProfileSelection(2);
+ assert.equal(await controller.flashPendingChanges(), "written");
+ await bridgePoll(profile);
+ assert.equal(mouse.banks[1].table[2], 1200, "the same game must also apply to the newly selected bank");
+ assert.equal(mouse.banks[1].table[3], 2000);
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks[1], original[1]);
+ assert.equal(mouse.banks[0].table[2], 1200, "do not switch the selected bank during restoration");
+ controller.applyProfileSelection(1);
+ assert.equal(await controller.flashPendingChanges(), "written");
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks, original, "the inactive bank's backup must survive until it is selected again");
+ assert.equal(controller.testState().error, null);
+ assert.equal(bridgeSuccessNotices().length, 4);
+ });
+}
+
+for (const separate of [false, true]) {
+ test(`DPI editor respects a confirmed ${separate ? "independent" : "linked"} axis mode`, async () => {
+ const mouse = fixture(); mouse.banks[0].axes = separate;
+ const before = await controller.testConnect(mouse.device);
+ dpiEditorNodes(before).find(node => node.props.id === "lunafury-dpi-x").props.onCommit(1200);
+ assert.equal(await controller.flashPendingChanges(), "written");
+ assert.deepEqual(mouse.banks[0].table.slice(2, 4), [1200, separate ? 1600 : 1200]);
+ });
+}
+
+test("Bridge keeps bank backups through a failed restore and a visit to another bank", async () => {
+ const mouse = fixture(), original = structuredClone(mouse.banks);
+ const before = await controller.testConnect(mouse.device);
+ const profile = game("A", { dpi: 1200 }, before.name);
+ await bridgePoll(profile);
+ const allowWrites = ignoreWrites(mouse);
+ await bridgePoll(null);
+ assert.equal(mouse.banks[0].table[2], 1200);
+ controller.applyProfileSelection(2);
+ assert.equal(await controller.flashPendingChanges(), "written");
+ await bridgePoll(profile);
+ assert.equal(mouse.banks[1].table[2], 1200);
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks[1], original[1]);
+ controller.applyProfileSelection(1);
+ assert.equal(await controller.flashPendingChanges(), "written");
+ allowWrites();
+ await bridgePoll(null);
+ assert.deepEqual(mouse.banks, original);
+});
+
+test("Bridge keeps separate backups for two mice with the same name and bank number", async () => {
+ const first = fixture(), second = fixture();
+ second.banks[0].table[2] = 1000; second.banks[0].table[3] = 2000;
+ const originals = [structuredClone(first.banks), structuredClone(second.banks)];
+ const before = await controller.testConnect(first.device);
+ const profile = game("A", { dpi: 1200 }, before.name);
+ await bridgePoll(profile);
+ await controller.testConnect(second.device, true);
+ await bridgePoll(null);
+ assert.deepEqual(second.banks, originals[1], "the first mouse's backup must not reach the second mouse");
+ await bridgePoll(profile);
+ assert.equal(second.banks[0].table[2], 1200);
+ await bridgePoll(null);
+ assert.deepEqual(second.banks, originals[1]);
+ await controller.testConnect(first.device, true);
+ await bridgePoll(null);
+ assert.deepEqual(first.banks, originals[0]);
+ assert.equal(controller.testState().error, null);
+});
+
+test("Games rejects a saved scope after the selected bank or device changes", async () => {
+ const mouse = fixture();
+ await controller.testConnect(mouse.device);
+ const scope = controller.currentGameProfileScope();
+ controller.applyProfileSelection(2);
+ assert.equal(await controller.flashPendingChanges(), "written");
+ const sent = mouse.sent.length;
+ assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }, scope), "busy");
+ assert.equal(mouse.sent.length, sent);
+ const other = fixture();
+ await controller.testConnect(other.device, true);
+ const otherSent = other.sent.length;
+ assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }, scope), "busy");
+ assert.equal(other.sent.length, otherSent);
+});
+
for (const pid of [0x32, 0x33, 0x54, 0x84]) {
test(`controller Games selection/restore on ${pid.toString(16)} never writes either DPI table`, async () => {
const { device, banks, sent } = fixture(pid);
diff --git a/docs/lunafury-development.md b/docs/lunafury-development.md
index 6d806be9..e5d54d83 100644
--- a/docs/lunafury-development.md
+++ b/docs/lunafury-development.md
@@ -38,7 +38,13 @@ Do not commit a local tarball override or Git/file dependency.
## Validation scope
-The paired app passes its build, 511 tests, bundle checks and audit with the local
+Games keeps originals separately for each device connection and onboard bank.
+Switching banks lets the same game apply to the newly selected bank without
+reusing another bank's backup. After the game exits, the selected bank is restored;
+inactive banks retain their in-memory backups until selected again. Restoration
+does not switch the active bank. Failed restores retain that bank's backup for retry.
+
+The paired app passes its build, 528 tests, bundle checks and audit with the local
protocol package. The protocol passes its build, all 2,474 tests and package checks;
162 Lamzu driver/codec tests also pass with real timers. Local checks do not
replace upstream CI or registry-only app validation.
diff --git a/src/app/cards/LunaFuryDpiEditor.tsx b/src/app/cards/LunaFuryDpiEditor.tsx
index 918ccf37..431be7d7 100644
--- a/src/app/cards/LunaFuryDpiEditor.tsx
+++ b/src/app/cards/LunaFuryDpiEditor.tsx
@@ -11,7 +11,7 @@ export function LunaFuryDpiEditor({ snapshot }: { snapshot: ControlSnapshot }):
const locale = snapshot.preferences.locale;
const xs = status.dpiStages ?? [];
const ys = status.dpiStagesY ?? xs;
- const separate = status.lunafury?.separateDpiAxes === true;
+ const separate = status.lunafury?.separateDpiAxes;
const limits = status.ui?.dpiStageEditor;
const max = limits?.maxDpi ?? 30000;
const active = Math.min(Math.max(status.activeDpiStage ?? 0, 0), Math.max(0, xs.length - 1));
@@ -33,7 +33,8 @@ export function LunaFuryDpiEditor({ snapshot }: { snapshot: ControlSnapshot }):
if (busy || !writable || !xs[stage]) return;
const value = lunaFuryDpiInput(raw, max);
const x = axis === "x" ? value : xs[stage];
- const y = !separate ? x : axis === "y" ? value : ys[stage] ?? x;
+ // An unread axis mode is not proof of axis lock; preserve stored Y.
+ const y = separate === false ? x : axis === "y" ? value : ys[stage] ?? x;
setRows((current) => current.map((row, i) => i === stage ? { x: String(x), y: String(y) } : row));
if (x !== xs[stage] || y !== ys[stage]) control.applyLunaFuryDpiStageAxes(stage, x, y);
};
@@ -41,7 +42,7 @@ export function LunaFuryDpiEditor({ snapshot }: { snapshot: ControlSnapshot }):
return (
{status.lunafury?.separateDpiAxes != null ? : null}
{xs.length ? (separate ? ["x", "y"] as const : ["x"] as const).map((axis) => (
(null);
- // What the mouse had, before any profile, for every field a profile has
- // changed since. Grows as profiles switch; written back when none is active.
- const priorSettings = useRef(null);
+ // Keep inactive banks' originals until that device/bank is selected again.
+ // Never restore them into whichever mouse happens to be active now.
+ const sessions = useRef(new WeakMap