From ea8e0ee2c5ba698061b5aabde6c1adab84c235c5 Mon Sep 17 00:00:00 2001 From: Onyx Date: Mon, 5 Oct 2026 09:03:23 +0200 Subject: [PATCH 1/2] logitech: factory reset for the PRO X 3 SUPERSTRIKE, and set up a mouse with no profiles Captured over USB while G HUB reset every profile on a linked X3: all five profile sectors got one 255-byte image (CRC 0x2a38, identical to the format 8 fixture) and the directory (CRC 0x4037) lists sectors 1 to 5 with only the first enabled. A reset leaves the directory unchanged. - Format 8 now has a factory image, so resetAllOnboardProfiles works on the X3. - factoryDirectoryForFormat builds the captured directory; isBlankDirectory recognises a directory that lists no profiles (a mouse G HUB never linked). - initializeBlankOnboardProfiles writes the five factory sectors, then the directory, then selects profile 1, reading each back; a failure part-way leaves the directory blank and the mouse back in onboard mode. The write sequence is G HUB's reset sequence. It has not been run against a never-linked mouse, and G HUB's own first-link sequence was not captured. --- docs/logitech-onboard-profiles.md | 21 +++++ src/drivers/logitech/hidpp.test.ts | 80 +++++++++++++++++++ src/drivers/logitech/hidpp.ts | 69 ++++++++++++++++ src/drivers/logitech/onboard-profiles.test.ts | 35 +++++++- src/drivers/logitech/onboard-profiles.ts | 60 +++++++++++++- 5 files changed, 261 insertions(+), 4 deletions(-) diff --git a/docs/logitech-onboard-profiles.md b/docs/logitech-onboard-profiles.md index d5bc0a0..4134811 100644 --- a/docs/logitech-onboard-profiles.md +++ b/docs/logitech-onboard-profiles.md @@ -719,3 +719,24 @@ time (actuation 5, sensitivity 2 off, haptics 3) while the live values differed and while a live-only apply came back to it after a power cycle. A HITS change is therefore written both ways: the live feature for immediate effect, and `persistAnalogButtonTuning` for the profile, in one sector write. + +## Factory reset and blank-mouse setup (format 8) + +Captured over USB (USBPcap) while G HUB reset every profile on a linked PRO X 3 +SUPERSTRIKE: + +- Each of sectors 1 to 5 received the same 255-byte image, CRC `0x2a38`. It is + byte-identical to the format 8 sector in the tests, including the HITS defaults + (actuation 5, rapid trigger 2 off, haptics 3). +- The directory (sector 0) was written once: `00 01 01 ff | 00 02 00 ff | 00 03 00 ff | + 00 04 00 ff | 00 05 00 ff`, then `ff` to the end, CRC `0x4037`. Sector 1 is + enabled, 2 to 5 disabled. It was byte-identical to the directory before the reset, + so a reset does not change the directory. +- G HUB then selected profile 1 (`setCurrentProfile 00 01`). + +`resetAllOnboardProfiles` uses this image for format 8, so the X3 can be reset from +OpenMouse. `initializeBlankOnboardProfiles` writes the same end state to a mouse +whose directory lists no profiles (a mouse G HUB has never linked): the five +sectors first, the directory last, then profile 1 selected, each read back. The +write sequence is the one captured from a reset; it has not been run against a +never-linked mouse, and G HUB's own first-link sequence was not captured. diff --git a/src/drivers/logitech/hidpp.test.ts b/src/drivers/logitech/hidpp.test.ts index f903fe2..bffa769 100644 --- a/src/drivers/logitech/hidpp.test.ts +++ b/src/drivers/logitech/hidpp.test.ts @@ -24,6 +24,7 @@ import { isWiredHidppConnection, supportsLiveLiftOffControl, } from "./hidpp.ts"; +import { factoryDirectoryForFormat, factoryProfileForFormat } from "./onboard-profiles.ts"; const G402 = 0xc07e; const G403_HERO = 0xc08f; @@ -600,3 +601,82 @@ test("HITS press stream start replays G HUB's captured arm sequence, stop clears assert.deepEqual(Array.from(short[0].data.slice(3, 6)), [0x01, 0x3c, 0x00]); assert.deepEqual(Array.from(short[1].data.slice(3, 6)), [0x00, 0x00, 0x00]); }); + +/** + * A format 8 mouse whose onboard memory is a map of 255-byte sectors, driven by + * the same HID++ 0x8100 functions the driver uses: info, mode, current profile, + * memory read, and the address/data/end write sequence. Starts blank (every + * sector erased) like a mouse G HUB has never linked. + */ +function onboardMemoryMouse(options: { ignoreWrites?: boolean; preload?: Record } = {}) { + const FEATURE_INDEX = 0x21; + const memory = new Map(Object.entries(options.preload ?? {}).map(([sector, bytes]) => [Number(sector), bytes.slice()])); + const state = { mode: 0x01, current: 0x0001, writes: 0 }; + let pending: { sector: number; length: number; bytes: number[] } | null = null; + const { client, device } = harness(0xc54d, { 1: "mouse" }); + const base = hidppResponder({ 1: "mouse" }); + const sectorBytes = (sector: number) => memory.get(sector) ?? new Uint8Array(255).fill(0xff); + device.onRequest = (request) => { + const deviceIndex = request[0]; + const featureId = (request[3] << 8) | request[4]; + if (request[1] === 0x00 && featureId === 0x8100) return successReply(deviceIndex, 0x00, 0x00, [FEATURE_INDEX, 0x00, 0x00]); + if (request[1] !== FEATURE_INDEX) return base(request); + const fn = request[2] >> 4; + const params = Array.from(request.slice(3)); + if (fn === 0) return successReply(deviceIndex, FEATURE_INDEX, 0x00, [0x01, 0x08, 0x00, 0x05, 0x00, 0x06, 0x05, 0x00, 0xff]); + if (fn === 1) { state.mode = params[0]; return successReply(deviceIndex, FEATURE_INDEX, 0x10); } + if (fn === 2) return successReply(deviceIndex, FEATURE_INDEX, 0x20, [state.mode]); + if (fn === 3) { state.current = (params[0] << 8) | params[1]; return successReply(deviceIndex, FEATURE_INDEX, 0x30); } + if (fn === 4) return successReply(deviceIndex, FEATURE_INDEX, 0x40, [state.current >> 8, state.current & 0xff]); + if (fn === 5) { + const sector = (params[0] << 8) | params[1]; + const offset = (params[2] << 8) | params[3]; + return successReply(deviceIndex, FEATURE_INDEX, 0x50, Array.from(sectorBytes(sector).slice(offset, offset + 16))); + } + if (fn === 6) { + pending = { sector: (params[0] << 8) | params[1], length: (params[4] << 8) | params[5], bytes: [] }; + return successReply(deviceIndex, FEATURE_INDEX, 0x60); + } + if (fn === 7 && pending) { pending.bytes.push(...params.slice(0, 16)); return successReply(deviceIndex, FEATURE_INDEX, 0x70); } + if (fn === 8 && pending) { + if (!options.ignoreWrites) memory.set(pending.sector, Uint8Array.from(pending.bytes.slice(0, pending.length))); + state.writes += 1; + pending = null; + return successReply(deviceIndex, FEATURE_INDEX, 0x80); + } + return null; + }; + return { client, memory, state }; +} + +test("initialising a blank format 8 mouse writes the factory sectors, then the directory, then selects profile 1", async () => { + const { client, memory, state } = onboardMemoryMouse(); + await resolveIndex(client); + await client.initializeBlankOnboardProfiles(); + + const factory = factoryProfileForFormat(8, 255); + const directory = factoryDirectoryForFormat(8, 255); + assert.ok(factory && directory); + for (const sector of [1, 2, 3, 4, 5]) assert.deepEqual([...memory.get(sector)!], [...factory], `sector ${sector}`); + assert.deepEqual([...memory.get(0)!], [...directory]); + assert.equal(state.current, 0x0001); + assert.equal(state.mode, 0x01, "left in onboard mode"); + assert.equal(state.writes, 6); +}); + +test("initialising refuses a mouse that already has profiles and writes nothing", async () => { + const directory = factoryDirectoryForFormat(8, 255)!; + const { client, state } = onboardMemoryMouse({ preload: { 0: directory } }); + await resolveIndex(client); + await assert.rejects(client.initializeBlankOnboardProfiles(), /already has onboard profiles/); + assert.equal(state.writes, 0); + assert.equal(state.mode, 0x01); +}); + +test("initialising leaves the directory blank and the mouse in onboard mode when a write does not stick", async () => { + const { client, memory, state } = onboardMemoryMouse({ ignoreWrites: true }); + await resolveIndex(client); + await assert.rejects(client.initializeBlankOnboardProfiles(), /did not confirm/); + assert.equal(memory.has(0), false, "directory never written"); + assert.equal(state.mode, 0x01, "not stranded in host mode"); +}); diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index a0fc894..59ed985 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -106,7 +106,9 @@ import { encodeMacroSector, encodeProfileName, encodeReportRate, + factoryDirectoryForFormat, factoryProfileForFormat, + isBlankDirectory, validateDpiStagePlan, type DpiStagePlan, type LogitechButtonAction, @@ -1491,6 +1493,73 @@ export class LogitechHidppClient { } } + /** + * Gives a mouse that has no profiles yet (G HUB has never linked it) the + * factory set, so OpenMouse does not need G HUB to initialise it: every + * factory profile sector, then a directory listing them with the first + * enabled, then profile 1 selected. + * + * WRITES FLASH. Only for a format whose factory sectors and directory were + * captured from G HUB, and only when the directory is truly blank. The + * directory goes last, so a failure part-way leaves the mouse as blank as it + * started instead of pointing at half-written sectors. The sequence was + * captured from G HUB's reset on a linked PRO X 3 SUPERSTRIKE; it has not + * been run against a never-linked mouse. + */ + async initializeBlankOnboardProfiles(): Promise { + await this.open(); + const feature = await this.getFeature(FEATURE.onboardProfiles); + if (!feature.index) { + throw new Error("This Logitech mouse does not expose onboard-profile controls."); + } + + const info = parseProfilesInfo(await this.request(feature.index, PROFILE_FN.getInfo)); + const sectorSize = info.sectorSize > 0 && info.sectorSize <= 1024 ? info.sectorSize : 255; + const factoryProfile = factoryProfileForFormat(info.profileFormatId, sectorSize); + const factoryDirectory = factoryDirectoryForFormat(info.profileFormatId, sectorSize); + if (!factoryProfile || !factoryDirectory) { + throw new Error(`Factory profiles have not been captured for profile format ${info.profileFormatId}.`); + } + if (profileCrc(factoryProfile) !== storedCrc(factoryProfile) || profileCrc(factoryDirectory) !== storedCrc(factoryDirectory)) { + throw new Error("The built-in factory profiles failed their checksum; refusing to write."); + } + + const existing = await this.readProfileSector(feature.index, 0x0000, sectorSize); + if (!isBlankDirectory(existing)) { + throw new Error("This mouse already has onboard profiles; use reset instead."); + } + const sectors = parseDirectory(factoryDirectory).map((entry) => entry.sector); + const firstSector = sectors[0]; + + // Host mode keeps the mouse from loading a sector while it is being filled. + await this.setOnboardMode("Host"); + let finished = false; + try { + for (const sector of sectors) { + await this.writeProfileSector(feature.index, sector, factoryProfile); + const confirmed = await this.readProfileSector(feature.index, sector, sectorSize); + if (!confirmed.every((byte, index) => byte === factoryProfile[index])) { + throw new Error(`Profile ${sector} did not confirm its factory image.`); + } + } + await this.writeProfileSector(feature.index, 0x0000, factoryDirectory); + const confirmedDirectory = await this.readProfileSector(feature.index, 0x0000, sectorSize); + if (!confirmedDirectory.every((byte, index) => byte === factoryDirectory[index])) { + throw new Error("The mouse did not confirm the new profile directory."); + } + + await this.request(feature.index, PROFILE_FN.setCurrentProfile, (firstSector >> 8) & 0xff, firstSector & 0xff); + await this.setOnboardMode("Onboard"); + const current = await this.request(feature.index, PROFILE_FN.getCurrentProfile); + if ((((current[3] ?? 0) << 8) | (current[4] ?? 0)) !== firstSector) { + throw new Error("The profiles were created, but the mouse did not select the first one."); + } + finished = true; + } finally { + if (!finished) await this.setOnboardMode("Onboard").catch(() => undefined); + } + } + /** * Sets the bunny-hop timeout on the active profile. * diff --git a/src/drivers/logitech/onboard-profiles.test.ts b/src/drivers/logitech/onboard-profiles.test.ts index 7eebfa3..c3fc9b1 100644 --- a/src/drivers/logitech/onboard-profiles.test.ts +++ b/src/drivers/logitech/onboard-profiles.test.ts @@ -26,7 +26,9 @@ import { decodeAnalogButtons, encodeAnalogButtons, encodeReportRate, + factoryDirectoryForFormat, factoryProfileForFormat, + isBlankDirectory, supportsFactoryReset, reportRateCapabilitiesFor, reportRatesFor, @@ -729,7 +731,7 @@ test("format-4 encoders prepare reversible scalar DPI, shared-rate and name prob test("factory reset image is exact, CRC-valid and limited to captured geometry", () => { assert.equal(supportsFactoryReset(7), true); - for (const format of [1, 2, 3, 4, 5, 6, 8, null, undefined]) { + for (const format of [1, 2, 3, 4, 5, 6, null, undefined]) { assert.equal(supportsFactoryReset(format), false, `format ${format ?? "missing"}`); } const factory = factoryProfileForFormat(7, 255); @@ -737,7 +739,7 @@ test("factory reset image is exact, CRC-valid and limited to captured geometry", assert.deepEqual([...factory], [...SECTOR_2]); assert.equal(profileCrc(factory), storedCrc(factory)); assert.equal(factoryProfileForFormat(7, 256), null); - assert.equal(factoryProfileForFormat(8, 255), null); + assert.equal(factoryProfileForFormat(6, 255), null); }); test("factory reset reproduces erased name, bunny-hop and G-Shift regions", () => { @@ -1323,3 +1325,32 @@ test("HITS in the profile: one button at a time, the on/off bit, and the checks" assert.throws(() => encodeAnalogButtons(SECTOR_1_SUPERSTRIKE, 8, [{ button: 2, actuation: 5, rapidTrigger: 2, haptics: 3 }]), /left and right/); assert.throws(() => encodeAnalogButtons(SECTOR_1_SUPERSTRIKE, 8, [{ button: 0, actuation: 64, rapidTrigger: 2, haptics: 3 }]), /outside/); }); + +test("format 8 factory sector is the captured G HUB reset image", () => { + assert.equal(supportsFactoryReset(8), true); + const factory = factoryProfileForFormat(8, 255); + assert.ok(factory); + assert.deepEqual([...factory], [...SECTOR_1_SUPERSTRIKE]); + assert.equal(profileCrc(factory), storedCrc(factory)); + assert.equal(storedCrc(factory), 0x2a38); + assert.equal(factoryProfileForFormat(8, 256), null); +}); + +test("format 8 factory directory lists sectors 1 to 5 with only the first enabled, CRC 0x4037", () => { + const directory = factoryDirectoryForFormat(8, 255); + assert.ok(directory); + assert.equal(directory.length, 255); + assert.equal(profileCrc(directory), storedCrc(directory)); + assert.equal(storedCrc(directory), 0x4037); + assert.deepEqual([...directory.slice(0, 24)], [0, 1, 1, 255, 0, 2, 0, 255, 0, 3, 0, 255, 0, 4, 0, 255, 0, 5, 0, 255, 255, 255, 255, 255]); + assert.deepEqual(parseDirectory(directory).map((entry) => [entry.sector, entry.enabled]), [[1, true], [2, false], [3, false], [4, false], [5, false]]); + assert.equal(factoryDirectoryForFormat(7, 255), null, "only captured for format 8"); + assert.equal(factoryDirectoryForFormat(8, 256), null); +}); + +test("a directory is blank only when it lists no profiles", () => { + assert.equal(isBlankDirectory(new Uint8Array(255).fill(0xff)), true); + assert.equal(isBlankDirectory(new Uint8Array(255)), true); + assert.equal(isBlankDirectory(factoryDirectoryForFormat(8, 255)!), false); + assert.equal(isBlankDirectory(new Uint8Array(2)), false, "a short read is not proof of blank"); +}); diff --git a/src/drivers/logitech/onboard-profiles.ts b/src/drivers/logitech/onboard-profiles.ts index 6a71437..30ff43a 100644 --- a/src/drivers/logitech/onboard-profiles.ts +++ b/src/drivers/logitech/onboard-profiles.ts @@ -71,7 +71,7 @@ const VERIFIED_FORMATS = new Set([2, 3, 4, 7, 8]); */ const WRITABLE_FORMATS = new Set([2, 3, 4, 7, 8]); const PROFILE_WRITE_PROBE_FORMATS = new Set([2, 3, 4]); -const FACTORY_RESET_FORMATS = new Set([7]); +const FACTORY_RESET_FORMATS = new Set([7, 8]); /** Whether profile-content writes for `profileFormatId` are trusted at all. */ export function isProfileWritable(profileFormatId: number | null | undefined): boolean { @@ -795,15 +795,71 @@ const FACTORY_PROFILE_FORMAT_7 = ` 00 03 00 00 00 00 00 1f 40 32 00 00 03 84 db `; +/** + * Complete factory profile for format 8 (PRO X 3 SUPERSTRIKE), captured over USB + * while G HUB reset every profile: all five profile sectors received this exact + * image, CRC 0x2a38. It includes the HITS defaults (actuation 5, rapid trigger 2 + * off, haptics 3). + */ +const FACTORY_PROFILE_FORMAT_8 = ` + 03 03 00 00 20 03 20 03 02 b0 04 b0 04 02 40 06 + 40 06 02 60 09 60 09 02 80 0c 80 0c 02 00 00 00 + 00 ff 00 ff ff ff 14 08 0c 14 08 0c 3c 00 2c 01 + 80 01 00 01 80 01 00 02 80 01 00 04 80 01 00 08 + 80 01 00 10 ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff + 03 00 00 00 00 00 1f 40 00 00 00 03 00 00 00 00 + 00 1f 40 00 00 00 03 00 00 00 00 00 1f 40 32 00 + 00 03 00 00 00 00 00 1f 40 32 00 00 03 2a 38 +`; + +const FACTORY_PROFILES: Record = { + 7: FACTORY_PROFILE_FORMAT_7, + 8: FACTORY_PROFILE_FORMAT_8, +}; + /** Returns a fresh, CRC-valid factory sector only for a captured geometry. */ export function factoryProfileForFormat(profileFormatId: number, sectorSize: number): Uint8Array | null { if (!supportsFactoryReset(profileFormatId) || sectorSize !== 255) return null; return Uint8Array.from( - FACTORY_PROFILE_FORMAT_7.trim().split(/\s+/), + FACTORY_PROFILES[profileFormatId].trim().split(/\s+/), (byte) => Number.parseInt(byte, 16), ); } +/** Profile sectors a format 8 mouse ships with: 1 to 5, as G HUB's reset left them. */ +const FACTORY_DIRECTORY_SECTORS_FORMAT_8 = [1, 2, 3, 4, 5]; + +/** + * The directory (sector 0) of a factory mouse: every profile listed, only the + * first enabled, rest of the sector erased. Captured from G HUB's reset on a + * PRO X 3 SUPERSTRIKE (CRC 0x4037); only captured for format 8. + */ +export function factoryDirectoryForFormat(profileFormatId: number, sectorSize: number): Uint8Array | null { + if (profileFormatId !== 8 || sectorSize !== 255) return null; + const directory = new Uint8Array(sectorSize).fill(0xff); + FACTORY_DIRECTORY_SECTORS_FORMAT_8.forEach((sector, index) => { + directory.set([sector >> 8, sector & 0xff, index === 0 ? 0x01 : 0x00, 0xff], index * 4); + }); + return applyCrc(directory); +} + +/** + * True when a directory sector lists no profiles at all, as on a mouse G HUB + * has never linked. Erased flash reads 0xffff; a first entry of 0x0000 is the + * other terminator parseDirectory stops on. + */ +export function isBlankDirectory(sector: Uint8Array): boolean { + return sector.length >= 4 && parseDirectory(sector).length === 0; +} + /** * Returns a copy of the directory sector with one profile's enabled flag * changed and the checksum recomputed. Every other byte is carried through From 9b844fb02ace9d83e145e59c608ae0262595a3dc Mon Sep 17 00:00:00 2001 From: Onyx Date: Mon, 5 Oct 2026 09:47:08 +0200 Subject: [PATCH 2/2] logitech: only set up blank profiles on a mouse that reports enough of them The factory directory layout (5 profiles) was captured from a PRO X 3 SUPERSTRIKE. The PRO X 2 shares profile format 8, so it also reaches initializeBlankOnboardProfiles, but nothing confirms it has the same layout. Refuse unless the mouse's own getInfo profile count is at least the number of profiles the captured directory lists. --- src/drivers/logitech/hidpp.test.ts | 12 ++++++++++-- src/drivers/logitech/hidpp.ts | 5 +++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/drivers/logitech/hidpp.test.ts b/src/drivers/logitech/hidpp.test.ts index bffa769..515bfc4 100644 --- a/src/drivers/logitech/hidpp.test.ts +++ b/src/drivers/logitech/hidpp.test.ts @@ -608,7 +608,7 @@ test("HITS press stream start replays G HUB's captured arm sequence, stop clears * memory read, and the address/data/end write sequence. Starts blank (every * sector erased) like a mouse G HUB has never linked. */ -function onboardMemoryMouse(options: { ignoreWrites?: boolean; preload?: Record } = {}) { +function onboardMemoryMouse(options: { ignoreWrites?: boolean; profileCount?: number; preload?: Record } = {}) { const FEATURE_INDEX = 0x21; const memory = new Map(Object.entries(options.preload ?? {}).map(([sector, bytes]) => [Number(sector), bytes.slice()])); const state = { mode: 0x01, current: 0x0001, writes: 0 }; @@ -623,7 +623,7 @@ function onboardMemoryMouse(options: { ignoreWrites?: boolean; preload?: Record< if (request[1] !== FEATURE_INDEX) return base(request); const fn = request[2] >> 4; const params = Array.from(request.slice(3)); - if (fn === 0) return successReply(deviceIndex, FEATURE_INDEX, 0x00, [0x01, 0x08, 0x00, 0x05, 0x00, 0x06, 0x05, 0x00, 0xff]); + if (fn === 0) return successReply(deviceIndex, FEATURE_INDEX, 0x00, [0x01, 0x08, 0x00, options.profileCount ?? 0x05, 0x00, 0x06, 0x05, 0x00, 0xff]); if (fn === 1) { state.mode = params[0]; return successReply(deviceIndex, FEATURE_INDEX, 0x10); } if (fn === 2) return successReply(deviceIndex, FEATURE_INDEX, 0x20, [state.mode]); if (fn === 3) { state.current = (params[0] << 8) | params[1]; return successReply(deviceIndex, FEATURE_INDEX, 0x30); } @@ -680,3 +680,11 @@ test("initialising leaves the directory blank and the mouse in onboard mode when assert.equal(memory.has(0), false, "directory never written"); assert.equal(state.mode, 0x01, "not stranded in host mode"); }); + +test("initialising refuses a mouse that reports fewer profiles than the captured layout and writes nothing", async () => { + const { client, state } = onboardMemoryMouse({ profileCount: 3 }); + await resolveIndex(client); + await assert.rejects(client.initializeBlankOnboardProfiles(), /reports 3 profiles/); + assert.equal(state.writes, 0); + assert.equal(state.mode, 0x01); +}); diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index 59ed985..2bc434d 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -1529,6 +1529,11 @@ export class LogitechHidppClient { throw new Error("This mouse already has onboard profiles; use reset instead."); } const sectors = parseDirectory(factoryDirectory).map((entry) => entry.sector); + // The directory layout was captured from one model. Only write it to a mouse + // that itself reports at least that many profiles. + if (info.profileCount < sectors.length) { + throw new Error(`This mouse reports ${info.profileCount} profiles but the captured factory layout has ${sectors.length}; refusing to write it.`); + } const firstSector = sectors[0]; // Host mode keeps the mouse from loading a sector while it is being filled.