Status: complete for every setting the vendor software exposes. Everything below is observed from
live capture of the vendor app (CASTOR Software.exe v1.44) running under Wine against real hardware,
then verified by writing the bytes back to the device, unless a row says otherwise. Confidence is
marked per field; the handful of remaining unknowns are called out explicitly rather than glossed.
Every claim here is checkable against a file in captures/ — those are the actual frames
the vendor application sent. castty's test suite asserts against them, so a passing test is a frame
the hardware has really accepted.
- Device
22d4:1316, two HID interfaces. Interface 1 is the config channel (bInterfaceProtocol 0). Resolve it via sysfs (HID_ID=0003:000022D4:00001316,HID_PHYSendinginput1) — never a fixed/dev/hidrawNindex. - Two vendor feature reports (usage page
0xFF01):0x60— 63 data bytes (64 with report ID). Command/response.0x61— 1040 data bytes (1041 with ID). Bulk profile/macro blob; this carries every setting.
The channel is request/response over the same report, not a plain register read:
HIDIOCSFEATUREon report0x60— a 64-byte buffer,[0]=0x60,[1]=<command>, rest zero.HIDIOCGFEATUREon report0x60— 64 bytes back,[1]=0x01(ack/status), payload follows.[0]reads0x00in the captures here, but it is not a constant — see "The read path" below, where the same byte was observed taking a different value across power states. Do not validate it.
A bare GET with no preceding SET returns whichever response was latched last, and that survives
re-opening the device; on a freshly powered device it is zeros, which is why probing cold looks dead.
Both ioctls return 64; writes succeed.
All verified against hardware. Command byte is [1]; report ID is [0].
| Report | Cmd | Name | Notes |
|---|---|---|---|
0x60 |
0x02 |
Identify | Returns firmware version + MCU string |
0x60 |
0x03 |
Status poll | Vendor app repeats every 2 s; returns zeros while idle |
0x60 |
0x04 |
Commit | Bare frame, payload all zero. Applies pending profile writes |
0x60 |
0x07 |
Profile read | [5] = profile index 0-4. Reply is read on report 0x61 |
0x61 |
0x08 |
Profile write | 1041-byte blob (below), or short terminator form |
0x60 |
0x05 |
Surface analyzer | [2]=1 start, [2]=2 read result |
offset 10: 89 01 .. .. .. .. 1a 53 54 4d 33 32 "STM32"
[0x10..0x11]LE16 = firmware version. Observed0x0189, matching USBbcdDevice.[0x17..0x1b]= ASCII"STM32", matching USBiSerial.
Decoded from a 56-apply capture session, one setting changed per apply.
| Offset | Size | Field | Confidence |
|---|---|---|---|
[0] |
1 | Report ID 0x61 |
certain |
[1] |
1 | Command 0x08 |
certain |
[5] |
1 | Profile index (0-4) | certain |
[6] |
1 | 0x01 in the short terminator form |
certain |
[16] |
1 | Profile index (repeated) | certain |
[17..26] |
10 | Profile name, ASCII, zero-padded | certain |
[34] |
1 | Constant 0x08 |
certain |
[37] |
1 | Polling rate divisor — see below | high |
[38] |
1 | Constant 0x01, purpose unknown |
— |
[39..62] |
24 | Six colour records, see below | certain |
[66] |
1 | Active DPI step. Constant 0x02 in all captures |
low |
[68..71] |
4 | DPI step 1: X then Y, LE16 each | certain |
[77..80] |
4 | DPI step 2: X then Y, LE16 each | certain |
[86] |
1 | Lift-off distance, 1-31. Factory default 0x0c |
certain |
[88] |
1 | Angle snapping, level 0-15. Default 0 |
certain |
[98..101] |
4 | DPI step 3: X then Y, LE16 each | certain |
[102] |
1 | DPI step count. Constant 0x03 |
high |
[104] |
1 | Angle tuning, signed int8 two's complement, -30..+30 | certain |
[117..158] |
42 | Button table, 6 x 7 bytes | certain |
Six records of <R> <G> <B> <mode> — the colour comes first, the mode byte last:
[39] [43] two independently settable records -> the two physical LEDs
[47] [48] two-byte gap, both zero
[49] [53] [57] [61] four more records -- inert, see below
The channels are ordered <R><G><B> and written verbatim, but the LEDs do not render an sRGB
value faithfully: the blue emitter is far less luminous than red. Full blue 0000ff reads as a dark
navy, while full red is vivid. A colour like 1d00ff -- only 11% red -- therefore looks magenta
rather than violet, because the weak blue cannot compete with it.
This is ordinary RGB LED behaviour, not a fault or a sign of ageing, and the vendor software has the
same characteristic; it writes the picked value unchanged too. castty does the same deliberately: a
correction curve would mean writing different bytes than the colour chosen, and would make our
results diverge from the vendor's for no gain in honesty.
[49], [53], [57] and [61] are real records with the same <R><G><B><mode> shape, but
nothing on this device reads them.
Tested by setting each to a different colour, and then setting all four to magenta -- a colour no physical LED was using -- across all five profiles, and exercising everything that might surface them:
- cycling all three DPI steps with the DPI button: colours unchanged
- switching profiles from the mouse itself, through all five: colours changed to each profile's own LED colour, with no magenta at any point
No capture could have settled this: the vendor software never varies them independently. Only writing distinct values directly could separate them.
They are most likely fields the shared Mionix software stack uses on another model.
The vendor software does not keep their colour in step with the wheel. An earlier version of
this document said it did, and that castty mirroring the wheel colour into them kept our frames
byte-identical to the vendor's. Both halves are wrong, and the captures in this directory show it:
in profile1-red.bin versus profile1-green.bin the only differing bytes are [39], [40],
[43] and [44] -- the wheel and logo. The four inert records stayed at the factory c9ff00
throughout profile1-red, -green, -blue, -macros and -macro-timing, while the wheel moved
through four different colours. The fixtures that do show all six agreeing are either factory blobs,
where every record is the same colour anyway, or castty's own writes.
So castty mirrors and the vendor does not. The mirroring is harmless -- the records are inert, as
proven above -- but it is castty's behaviour, not a reproduction of the vendor's, and the frames
are not byte-identical. A new consumer of this protocol should patch [39] and [43] only and
leave the other four records alone, which is both closer to the vendor and a smaller change to a
blob read from the device.
The mode byte is a separate matter and the advice to write all six stands: every fixture has all
six mode bytes equal, and while no vendor capture varies the mode -- they are all 0x01 -- the
hardware experiments above establish that the firmware reads the mode globally from [42].
[39] is the scroll wheel and [43] is the logo. Established by lighting one at a time: with the
wheel set to red alone, [39] held (255,0,0) and [43] was (0,0,0); with the logo green alone,
the reverse. They are independently settable.
All six mode bytes always change together, and this reflects the hardware, not just the vendor app's habit. Two experiments, covering both nibbles:
- Animation: wheel
0x01(solid) + logo0x02(blinking) -> both solid. - Rainbow: wheel
0x11(rainbow) + logo0x01(solid) -> both rainbow.
In each case the whole device followed the first record's byte ([42]) and the others were
ignored. So the entire mode byte -- animation and rainbow -- is a single global setting read from
[42]. Per-LED colour is independent; nothing else about the lighting is.
Write the same value to all six mode bytes anyway: the vendor app does, and relying on [42] alone is
an assumption about undocumented firmware.
The mode byte is two fields, not an enum. The low nibble selects the animation and the high nibble turns on rainbow (colour cycling), and the two combine freely:
| Low nibble | Animation |
|---|---|
0x1 |
Solid |
0x2 |
Blinking |
0x3 |
Pulsating |
0x4 |
Breathing |
| High nibble | Colour |
|---|---|
0x0 |
The stored colour |
0x1 |
Rainbow — cycles the spectrum, stored colour ignored |
So 0x14 is "breathing and rainbow" and 0x11 is "solid + rainbow" (what the vendor UI calls
Color Shift). Confirmed on hardware: writing 0x14 produced a breathing rainbow.
This was missed at first because the vendor dropdown presents the eight combinations as a flat list;
the structure only appeared when all four 0x1x values were tried in sequence.
Observed 0x01, 0x02, 0x04, 0x08, stepped in that order through the GUI's rate list. These are
divisors of 1000 Hz, so almost certainly 1=1000 Hz, 2=500 Hz, 4=250 Hz, 8=125 Hz.
Not independently verified — confirm before relying on it.
The Castor stores exactly three DPI steps (the GUI slider has three points), which is why the slot
spacing looked irregular: the three slots are [68], [77], [98], with unrelated fields in between.
[102] holds the step count and is constantly 0x03, consistent with three.
Each slot is X then Y as LE16. The axes really are independent: a capture with the vendor UI's
link checkbox cleared produced [68]=850 (X) and [70]=3200 (Y), which also confirms X comes first.
Nothing on the device records whether the axes are linked. No byte changed when the checkbox was
toggled, so that is purely vendor-UI state. castty infers it from the data (x == y for every step)
and writes no flag.
Confirmed values range 400-9150, always multiples of 50.
[66] is constantly 0x02 and is the likely "currently active step" index, but it never changed in any
capture, so that is a guess. Switching the active step (the DPI button on the mouse) would confirm it.
Six entries of 7 bytes, one per physical button, each laid out as:
<type> <param> 00 00 00 00 0f
Entry order is fixed and is not the bitmask order:
| # | Offset | Button | Factory type/param |
|---|---|---|---|
| 0 | [117] |
Left | 00 / 01 |
| 1 | [124] |
Right | 00 / 02 |
| 2 | [131] |
Wheel click | 00 / 04 |
| 3 | [138] |
Side, front | 00 / 10 |
| 4 | [145] |
Side, rear | 00 / 08 |
| 5 | [152] |
DPI | 09 / f1 |
Types, from a capture that reassigned five buttons at once:
| Type | Meaning | Param |
|---|---|---|
0x00 |
Standard mouse button | Button bitmask: 01 left, 02 right, 04 middle, 08 side, 10 side |
0x01 |
Scroll | Signed direction: 01 up, ff (-1) down |
0x02 |
Single key | HID usage code — 0x1a is w |
0x03 |
Macro | See the macro section; the entry carries a pointer and event count |
0x08 |
Profile switch | f0 up, f2 down, f1 roll |
0x09 |
DPI switch | f0 up, f2 down, f1 roll — the same three, captured |
0xff |
Disabled | 00 |
Both switches were captured by assigning each direction in turn and take the same three parameters:
f0 up, f2 down, f1 roll.
The mouse can switch its own profiles. Profile switch is a button function (type 0x08), so no
host software is needed once it is assigned — which is why profiles are worth supporting properly.
An earlier version of this document placed the table at [118] with the fields reversed. The
alignment was settled by noticing that remapped bytes always changed in pairs seven apart.
Signed 8-bit two's complement. Captured across the full GUI range, which pins the encoding exactly:
| GUI value | Byte |
|---|---|
| -30 | 0xe2 |
| -15 | 0xf1 |
| 0 | 0x00 |
| +15 | 0x0f |
| +30 | 0x1e |
Mionix's Surface Quality Analyzer Tool. Per Mionix's own description it measures the data loss between successive images taken by the sensor and reports a rating where higher means less loss; their marketing rates mousepads on a 1-10 scale and calls 8+ "no loss of tracking".
Two steps on the command report, not part of the profile blob:
[1]=0x05,[2]=0x01— start. Response is a bare ack.[1]=0x05,[2]=0x02— read result, returned at[16].
The user must move the mouse across the surface between the two calls. The vendor UI (recovered
from its skin bitmaps) shows "Move the mouse over the surface area... make sure that you cover as much
as possible of the area", with an elapsed-seconds counter and a separate "Show result" button.
Reading immediately after starting measures nothing -- the 0x28 (40) captured early in this project
was taken after ~200 ms with the mouse stationary and should not be treated as a real reading.
Raw-to-score mapping: score = round(raw x 0.18), and the vendor UI displays that score times
ten -- which is why a raw of ~40 appears as "70" and looked like a discrepancy between our reading and
theirs. Our raw values match the vendor's exactly; only the presentation differed.
Fitted by running the vendor tool across five surfaces while capturing the raw byte behind each displayed score:
| Surface | Raw | Vendor score |
|---|---|---|
| A4 paper, mouse not moved | 0 | 0 |
| Clear plastic case | 13 | 20 |
| Wooden desk | 28 | 50 |
| Aluminium laptop lid | 38 | 70 |
| Mousepad | 39 | 70 |
The vendor quantises to whole units, so raw 38, 39 and 40 all show as 70. The top of the range is unverified -- no surface tested scored above 7, so the slope is fitted from the lower two thirds.
A reading of 0 means the sensor gathered nothing, normally because the mouse was not moved during the measurement window.
The vendor's measurement window is about 10 s (it shows a countdown), with the result read roughly 12-15 s after the start command.
There is no reset command. The vendor app implements it client-side: it writes a full profile blob
of factory values to every profile and commits, exactly like any other apply. castty should do the
same — the captured defaults are in captures/factory-default-p*.bin.
Factory defaults: DPI 3000, LED (201,255,0) mode 0x01 solid, polling [37]=1, angle snapping 0,
angle tuning 0.
Verified end to end: with a button bound to profile switch, pressing it cycles all five profiles with no software running, each showing its own stored LED colour. The LEDs go dark for roughly 300 ms during a switch, then come up in the new colour -- no flash or blink pattern.
That confirms the whole mechanism together: the commit byte selecting the active profile, five profiles persisting independently on the device, and the button function doing what we decoded.
Byte [5] of the commit frame (0x60 / 0x04) is the active profile index. The commit both
applies pending writes and switches the mouse to that profile; there is no separate select command.
Verified across four sessions: sessions 1-2 always committed [5]=0 (editing Profile1), session 3
cycles 0-4 as profiles were switched, and session 4 sits on [5]=1 throughout, dropping to 0
exactly at the switch to Profile1 and returning to 1 on the way back.
Renaming works and round-trips ([17..26], 10 bytes).
Note a vendor-app quirk worth not reproducing: switching away and back collapses per-LED colours, rewriting both LEDs to the same value. The hardware has no such limitation.
Not adjustable, but worth recording -- no vendor documentation states these, and the UI preview is driven from them. Measured by counting cycles against a stopwatch:
| Effect | Period | How measured |
|---|---|---|
| Blinking | 1.154 s | 26 cycles in 30 s |
| Pulsating | 1.58 s | 19 heartbeats in 30 s (each = two blackouts) |
| Breathing | 6.0 s | 5 cycles in 30 s |
| Rainbow hue loop | ~5 s | one full loop timed |
Pulsating is a heartbeat, not a sine. Each cycle is two blackouts in quick succession (together under half a second), then the LEDs are held lit for the remainder. Breathing is the smooth one. Getting the waveform wrong makes a preview that looks nothing like the hardware even when the period is exactly right -- this one took three corrections to get from "sine" to "double beat".
Tested and ruled out. The vendor software exposes no speed control, no byte in four capture sessions correlated with timing, and direct probing found nothing:
- Mode byte high nibble
0x2,0x3,0x4(as0x24/0x34/0x44) all behaved as plain breathing. Note0x34has bit0x10set yet showed no rainbow, so the firmware matches the high nibble against an exact value rather than testing a bit; anything but0or1falls back to0. [38](always0x01),[47]and[48](the always-zero gap inside the LED block), and[86]were each swept across their range while breathing. No change of any kind.
Effect timing is fixed in firmware. Do not re-investigate without new evidence.
A profile blob write alone changes nothing visible -- the LEDs keep their previous state. Only the
0x60/0x04 commit applies staged data. Verified by writing a full green profile without a commit
(no change) and then committing the identical data (LEDs turned green).
Consequence: there is no volatile path. Every visible change costs a commit, and commits persist to the MCU's flash. Host-driven animation -- repainting colours per frame to synthesise custom effects -- would mean tens of flash writes per second against an endurance budget on the order of 10,000 cycles, and would destroy the device in about an hour. Do not build it on this path. Custom lighting is limited to what the firmware implements unless a direct-control command is found.
Every profile can be read back from flash. An earlier version of this document stated the
opposite. That was wrong, and the error is worth recording: it reasoned from the vendor
application's behaviour rather than the firmware's. Across 1260 GET_FEATURE calls in two capture
sessions the vendor app never reads a profile back — it only ever reads identify (0x02) and the
status poll (0x03) — and that absence was taken as proof no such command existed. The firmware
implements one anyway. No capture could ever have revealed it; only probing the command space could.
The command follows the same request/response pattern as the rest of the 0x60 channel, but the
reply is read on report 0x61 because a profile does not fit in 64 bytes:
HIDIOCSFEATUREon report0x60—[0]=0x60,[1]=0x07,[5]=profile index 0-4.HIDIOCGFEATUREon report0x61— 1041 bytes back.
[5] is the profile index, the same slot the write frame and the commit frame use. An index written
to [2], [3], [4], [7] or [16] is ignored.
The response uses the same field layout as the write blob, so every offset in the table above
applies unchanged. Only the header differs, and only [1] of it is dependable:
[1]is0x01, the ack the whole0x60channel answers with, where a write frame carries the0x08command. Solid across every read taken.[0]carries no reliable value. Do not test it. The captures incaptures/all show00, and an earlier version of this section stated the header was00 01on that basis. It is not a constant. The same device, read with the same tool, answered[0]=0x00in one session and[0]=0x60in another hours later — stable within a session, different across power states. The five fixtures were all taken in one sitting shortly after a replug, so they record one of the values[0]can take, not a rule. A consumer that validated[0]=0x00refused every read on a device that had been up for a while, which is exactly how this was found. libratbag's driver for the same hardware ignores[0], and that is the right call.
What does identify a good reply is [1], the structural constants ([34]=0x08,
[102]=0x03), the DPI slots being in range, and the profile index the device echoes at [16]
matching the one that was asked for — the last of these also catches a stale latched response.
Thirty consecutive reads across all five profiles held all four of those without exception.
Verified against hardware:
- All five profiles read back byte-exactly. Profiles 0 and 1 matched
castty's persisted blobs and profiles 2-4 matched the factory-default captures, with zero differing bytes across the whole payload[16..1041)in all five cases — including the 880-byte macro region. - The data is in flash, not RAM. After unplugging and replugging the mouse, the colours last written were still returned. A RAM copy of the last write would not survive losing power.
Warm-up caveat. For a short window after enumeration — still true about five seconds in — the
device answers 0x07 with an all-zero buffer instead of refusing. Anything reading a profile must
validate the response before trusting it: check the ack at [1] is 0x01, the constant at [34] is
0x08, the DPI step count at [102] is 0x03, and the DPI values are in range. A consumer that
patches a field into a zero-filled buffer and writes it back would erase the profile's DPI, buttons
and macros. Retry against a wall-clock deadline longer than five seconds, not a small number of
attempts: being asked too early is the ordinary case, and giving up early means falling back to
whatever local state the tool has, permanently.
Do not validate the DPI values against the 400-9150 figure below. That is the range observed in
captures, not a hardware limit, and a validated range narrower than what some tool can write is a
trap: the profile writes fine, and then every later read of it fails and the tool falls back to its
own stale copy for good. castty's own slider spans 100-10000, so its validator does too. The
validated range must be at least as wide as anything any tool can write. Rejecting zero is all the
warm-up buffer needs.
Consequence for castty: the application no longer persists its own state as the primary record. It
reads all five profiles on connect and shows those; the state file is kept only as the fallback for
when no mouse is attached, or when a read will not validate. Any new consumer of this protocol
should do the same — read the device, and validate before believing it.
A button assigned a macro uses more of its 7-byte entry:
03 00 <ptr lo> <ptr hi> <event count> 00 0f
type0x03means macro.ptris LE16. The event data lives at blob offset16 + ptr— the pointer is relative to the start of the profile payload at[16], not to the blob.event countcounts individual events, and each keypress is two events (press and release).
Each event is 7 bytes:
01 <hid usage> 00 <0 = press, 1 = release> <delay, LE24 milliseconds>
The delay is milliseconds since the previous event, little-endian across three bytes. On a press
it is the gap since the last key; on a release it is how long the key was held. Verified against the
vendor editor, which displays exactly these numbers -- a recording of a, b, c with deliberate
pauses stored 2175, 119, 2701, 111, 3927, 93, matching its display of "2175 ms down, 119 ms up" and
so on.
Macros are packed sequentially and each is followed by a 7-byte zero terminator, so the next
macro's pointer is ptr + 7 * (count + 1). Observed: a 3-key macro at ptr 800 (6 events) is
followed by a 6-key macro at ptr 849 = 800 + 7 x 7.
The first macro was allocated at ptr 800, i.e. blob offset 816, leaving [816..1040] — 224 bytes,
or 32 event slots — for macro storage. Everything before that in the tail stayed zero, so 816 appears
to be the base of the macro area.
Worked example, a macro of a b c:
01 04 00 00 00 00 00 a press
01 04 00 01 00 00 00 a release
01 05 00 00 00 00 00 b press
01 05 00 01 00 00 00 b release
01 06 00 00 00 00 00 c press
01 06 00 01 00 00 00 c release
00 00 00 00 00 00 00 terminator
The vendor editor offers "record delay" and "record hold", and they are mutually exclusive -- they are two uses of the same storage, selected by byte 1 of the button entry:
| Byte 1 | Mode | Storage |
|---|---|---|
0x00 |
Timed playback | Press and release events with real millisecond deltas |
0xfe |
Hold | A single press event per key, no release, all delays zero |
A hold macro keeps its keys down for as long as the mouse button is held, so there is nothing to time and no release to record -- which is why the editor shows 0 ms for one.
Event type 0x01 is "keyboard". The editor does not allow mouse clicks inside a macro, so no other
event type exists to capture.
Per Apply the vendor app sends, for every profile 0-4:
0x61/0x08, full 1041-byte blob,[5]= profile index0x61/0x08, short frame with[5]= index,[6]=0x01(per-profile terminator)
then once, globally:
0x60/0x04— commit
This has been replayed successfully from Linux with no Wine involved: taking a captured blob,
substituting [39..41] and [43..45], and sending blob → terminator → commit changes the physical LED
colour. Writing only the active profile (not all five) is sufficient.
Reference implementation of the above: tools/castty_probe.py in this directory. Golden fixtures for the
three captured colours are in captures/ and should be used as regression inputs for the Rust decoder.
See README.md in this directory for the capture rig. Two non-obvious prerequisites:
-
udev rule tagging
22d4:1316uaccess(/dev/hidraw*isroot:root 0600by default). -
Wine hides the device otherwise: interface 1's descriptor leads with a keyboard collection (usage
0001:0006), andis_hidraw_enabled()blanket-rejects mouse/keyboard hidraw devices. TheEnableHidrawmulti-string is checked after that rejection, so it cannot help. The per-device override is checked before it and does work:HKLM\System\CurrentControlSet\Services\WineBus\Devices\22d4/1316 → Hidraw (REG_DWORD) = 1Subkey name format is
%04x/%04x, lowercase.