Catch
Stream the traffic the box carries, addressed the way a lock isCATCH subscribes to what passes through the box: physical input, the vendor-interface endpoints, proxied control transactions, the raw bytes of HID interfaces the semantic model does not parse, what the clone emitted, and the bus lifecycle.
A subscription is a table of (class, id, dir) entries, addressed the way a LOCK is.
While subscribed the box pushes a MOTION_EVENT for movement and the wheel, a USAGE_EVENT for buttons, keys and media, and a TRAFFIC_EVENT for everything byte-oriented. Subscribing is fire-and-forget; the box streams until you unsubscribe.
real device --USB3--> HOST chip ----link----> DEVICE chip --USB1--> game PC
| |
| clk = 0, host chip | clk = 1, device chip
| |
+- HID_IN +- HID_OUT, every OUT direction
+- VEND_INTR IN +- CONTROL
+- VEND_BULK IN +- EMIT (after inject + lock)
+- BTN KEY MEDIA AXIS +- BUS
at the merge point,
before the lock scale and before injectionAddressing doubles as the filter. The control link runs at 4 Mbaud and vendor bulk alone measures ~250 KiB/s through the box, so every class at once cannot be delivered.
CATCH
Add or remove one subscription-table entryCATCH carries one table entry: an address, a direction, whether to subscribe or unsubscribe, and how much of each packet to capture. Send one frame per entry. Opcode 0x0B.
CATCH 0x0B · payload 6 bytes
Fire-and-forget
| Offset | Field | Type | Notes |
|---|---|---|---|
| 0 | class | u8 | the address class (table below), or 0xFF = every class |
| 1 | id | u16 | class-specific, or 0xFFFF = every id in that class, little-endian |
| 3 | dir | u8 | 0 BOTH, 1 POS/IN, 2 NEG/OUT (the LOCK direction byte) |
| 4 | state | u8 | 1 = subscribe, 0 = unsubscribe |
| 5 | snaplen | u8 | bytes captured per event; 0 = the whole packet |
Classes 0 to 3 are the LOCK classes unchanged. Classes 4 and up reach the byte-oriented traffic.
| Class | Value | id means | With id = 0xFFFF |
|---|---|---|---|
BTN | 0 | button id | every button |
KEY | 1 | HID keyboard usage | every key and modifier |
MEDIA | 2 | 16-bit Consumer usage | every media usage |
AXIS | 3 | TGT_X / TGT_Y / TGT_WHEEL | every axis |
HID_IN | 4 | interface number | every HID interface |
HID_OUT | 5 | endpoint address | every interrupt-OUT endpoint |
VEND_INTR | 6 | endpoint address | every vendor interrupt endpoint |
VEND_BULK | 7 | endpoint address | every vendor bulk endpoint |
CONTROL | 8 | endpoint number (0 = EP0) | every control endpoint |
EMIT | 9 | endpoint address | every emitting endpoint |
BUS | 10 | unused | - |
ANY | 0xFF | must be 0xFFFF | every class |
| Value | Input classes (0 to 3) | Traffic classes (4 to 10) |
|---|---|---|
0 BOTH | press and release | IN and OUT |
1 POS | the press edge, or the + sign of an axis | IN: device to PC |
2 NEG | the release edge, or the - sign of an axis | OUT: PC to device |
snaplen is per entry, so one subscription can take a 64-byte report whole while another cuts a bulk pipe to 16. A cut capture still carries the packet's real length in true_len.
The input classes are captured at the emission merge point before any LOCK scale or injection, so an input you have weighed down, or blocked outright, is still reported here at its full physical value.
EMIT is the mirror: what the clone put on the wire after injection, locks, and the suppression gate.
Subscribe to every vendor interrupt endpoint, both directions, capturing the first 32 bytes of each packet (class = 6, id = 0xFFFF, snaplen = 32):
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+ | A5 | 0B | 00 | 06 00 | 06 | FF FF | 00 | 01 | 20 | lo hi | +--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+ | SOF | TYPE | SEQ | LEN | class | id | dir | state | snaplen| CRC16 | +--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
Subscribe to everything: class=0xFF, id=0xFFFF, dir=BOTH, state=1. Unsubscribe everything: the same with state=0, which clears the whole table in one frame:
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+ | A5 | 0B | 01 | 06 00 | FF | FF FF | 00 | 00 | 00 | lo hi | +--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+ | SOF | TYPE | SEQ | LEN | class | id | dir | state | snaplen| CRC16 | +--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
A blanket is one table entry, not an expansion into per-id entries, which is how a LOCK blanket (id = 0xFFFF) already behaves. Library binding: catch_events.
The table
Most-specific-first matching, 32 entries, and how a refusal shows upAn exact (class, id) entry ranks above a class blanket, which ranks above class = 0xFF; ties go to the earlier entry. The highest-ranked entry supplies the snaplen.
table (insertion order)
#0 class = ANY id = ALL snaplen = 16
#1 class = VEND_INTR id = ALL snaplen = 32
#2 class = VEND_INTR id = 0x83 snaplen = 0
a 64-byte packet on vendor interrupt endpoint 0x83
+- exact (class, id)? #2 HIT --> snaplen 0 -> all 64 bytes captured
+- class blanket? #1 (not reached)
+- class = ANY? #0 (not reached)
the same 64-byte packet on endpoint 0x81
+- exact (class, id)? miss
+- class blanket? #1 HIT --> snaplen 32 -> 32 bytes, true_len = 64
+- class = ANY? #0 (not reached)
a control transaction on EP0
+- exact (class, id)? miss
+- class blanket? miss
+- class = ANY? #0 HIT --> snaplen 16The table holds 32 entries. CATCH has no reply, so a refused entry shows up as its absence from RESP(CATCH), plus the table-full flag in that reply's header.
| Refused when | Why |
|---|---|
| the table already holds 32 entries | nothing is evicted; the header's b0 flag says an entry was turned away |
class is one the firmware does not know | an unknown class has no tap to attach to |
dir is outside 0..2 | a subscription is addressed before any bearing is read, so only 0-2 name anything a tap can match |
class = 0xFF with a specific id | id is class-specific, so a wildcard class with a real id addresses nothing coherent |
A subscription is PC-owned state, cleared by control-PC silence (the ~1 s timeout), a RESET, a mouse detach, inter-chip link loss, or an explicit unsubscribe.
The host library holds an open table past the silence timeout with the same keepalive it uses for injection holds, re-asserting the whole table after a device-side blip and across a control-link reconnect; its own RESET ends the event stream cleanly.
The HEALTH CATCH_ON bit means the table is non-empty.
The clk byte
Two chips, two clocks, one byte saying whichAll three event frames lead with ts_us and then clk. The two ESP32-S3s boot independently, so nothing relates their timers: a stamp is only meaningful against another from the same domain.
clk | Stamped by | Which classes |
|---|---|---|
0 | the host chip, in USB interrupt context, when the real device's transfer completed | MOTION / USAGE, HID_IN, VEND_INTR / VEND_BULK IN |
1 | the device chip, at the tap | HID_OUT, both OUT directions, CONTROL, EMIT, BUS |
Both clocks are box-local, with no relationship to any clock on the control PC.
Each wraps every ~71.6 minutes (a 32-bit microsecond counter) and returns to zero when that chip reboots, so a value below the previous one is a wrap, a reboot, or a domain change.
clk = 0 HID_IN ts_us = 1286497017 (host chip)
clk = 0 HID_IN ts_us = 1286544017 (host chip)
----------
delta = 47000 us / 1000 us poll = 47 polls
-> 46 polls where the device said nothing
clk = 1 EMIT ts_us = 902114550 (device chip)
^ smaller than the stamps above, and NOT earlier:
a different chip, a different epoch. Subtracting across
domains without the measured offset is meaningless.RESP(CATCH) carries a measured offset between the two clocks, its drift rate, and the round trip that bounds its error. Applying it is optional; the clk byte stays authoritative.
Divide a gap by RESP(RATE)'s poll_period_us for a poll count, but only where that reply's CHANGE_DRIVEN flag is clear: a change-driven device never puts its idle polls on the wire, so they cannot be counted.
MOTION_EVENT
One physical relative-axis snapshot, box → PCWhile an AXIS subscription is active the box pushes a MOTION_EVENT for each physical report whose motion changed. Opcode 0x0C.
There's no QUERY to correlate. SEQ is instead a rolling per-event counter shared with USAGE_EVENT and TRAFFIC_EVENT, stamped as each event leaves the box, so it orders the stream whatever mix of frame types is in it.
SEQ is not a drop detector: events are dropped before they reach the stamp, so it runs gapless and losses are read from RESP(CATCH).
MOTION_EVENT 0x0C · payload 11 bytes
Unsolicited
| Offset | Field | Type | Notes |
|---|---|---|---|
| 0 | ts_us | u32 | report arrival time in box microseconds, little-endian |
| 4 | clk | u8 | always 0 (host chip); see the clk byte |
| 5 | dx | i16 | physical X this report; + = right, little-endian |
| 7 | dy | i16 | physical Y this report; + = down, little-endian |
| 9 | dz | i16 | physical wheel delta this report; + = up, little-endian |
The stamp is taken the instant the device's interrupt-IN transfer completed, which is why it is always the host chip's.
The user moves +10 right, no vertical or wheel motion (dx = 10):
+--------+--------+--------+--------+-------------+--------+--------+--------+--------+--------+ | A5 | 0C | 2A | 0B 00 | 40 42 0F 00 | 00 | 0A 00 | 00 00 | 00 00 | lo hi | +--------+--------+--------+--------+-------------+--------+--------+--------+--------+--------+ | SOF | TYPE | SEQ | LEN | ts_us | clk | dx | dy | dz | CRC16 | +--------+--------+--------+--------+-------------+--------+--------+--------+--------+--------+
USAGE_EVENT
One physical held-usage snapshot, box → PCWhile a BTN, KEY, or MEDIA subscription is active the box pushes a USAGE_EVENT when that class changes: a class-tagged snapshot of the usages currently held. Opcode 0x0F.
It's a full snapshot, not edge deltas, so a dropped frame self-corrects on the next one.
USAGE_EVENT 0x0F · payload 8 + 3n bytes
Unsolicited
| Offset | Field | Type | Notes |
|---|---|---|---|
| 0 | ts_us | u32 | report arrival time in box microseconds, little-endian |
| 4 | clk | u8 | always 0 (host chip); see the clk byte |
| 5 | cls | u8 | the snapshot's class: 0=button 1=key 2=media |
| 6 | dir | u8 | the edge that produced it: POS the set grew, NEG it shrank |
| 7 | n | u8 | number of held usages that follow |
| + | class | u8 | per usage: same vocabulary as cls (as INJECT) |
| + | id | u16 | the held usage's id (a button id, HID keycode with 0xE0-0xE7 modifiers, or Consumer usage), little-endian |
Each entry is 3 bytes and the snapshot is n of them, all one class, since one physical report is one class.
Only the held usages that resolve against the table appear, and no event is emitted when none do.
A snapshot lists what is currently held, so the release of a usage is the snapshot that no longer names it. Without cls, "all buttons released" and "all keys released" are the same bytes.
The box resolves each usage against its entry's direction, but while any other subscriber holds a wider entry it emits on both edges, and only dir tells the two apart.
Route these by class, not by which usages appear, and diff successive snapshots for the usages you care about. Matching on the usages present drops the release edge whenever another subscription's usage is still held.
Left Shift held while pressing A (a keys snapshot, two usages both class = 1: Left Shift id = 0xE1, then A id = 0x04):
+--------+--------+--------+--------+-------------+--------+--------+--------+--------+----------+----------+--------+ | A5 | 0F | 2B | 0E 00 | 40 42 0F 00 | 00 | 01 | 01 | 02 | 01 E1 00 | 01 04 00 | lo hi | +--------+--------+--------+--------+-------------+--------+--------+--------+--------+----------+----------+--------+ | SOF | TYPE | SEQ | LEN | ts_us | clk | cls | dir | n | usage[0] | usage[1] | CRC16 | +--------+--------+--------+--------+-------------+--------+--------+--------+--------+----------+----------+--------+
TRAFFIC_EVENT
Bytes off any of the byte-oriented classes, box → PCOne frame type carries HID_IN, HID_OUT, VEND_INTR, VEND_BULK, CONTROL, EMIT and BUS. Opcode 0x16.
TRAFFIC_EVENT 0x16 · payload 12 + n bytes
Unsolicited
| Offset | Field | Type | Notes |
|---|---|---|---|
| 0 | ts_us | u32 | when the tap fired, little-endian |
| 4 | clk | u8 | which chip's clock stamped it; see the clk byte |
| 5 | class | u8 | the address class (table above) |
| 6 | id | u16 | endpoint address, interface number, or endpoint number, little-endian |
| 8 | dir | u8 | 1 = IN (device to PC), 2 = OUT (PC to device), 0 for BUS, which is not a transfer |
| 9 | flags | u8 | class-specific (table below) |
| 10 | true_len | u16 | the packet's length before snaplen truncation, little-endian |
| 12 | bytes | u8[] | up to snaplen bytes; the frame LEN delimits how many arrived |
frame LEN = 12 + 16 -> 16 bytes arrived
true_len = 64 -> the packet was 64 bytes
------------------------
48 bytes were cut by snaplen, not absent from the wire
frame LEN = 12 + 4 -> 4 bytes arrived
true_len = 4 -> the packet really was 4 bytes long| Class | flags |
|---|---|
VEND_BULK | b0 end-of-transfer, b1 zero-length packet |
CONTROL | how the transaction completed: 0 OK, 0xFD STALL, 0xFE NAK to timeout |
BUS | the event kind (table below) |
| every other class | 0 |
CONTROL carries one event per completed transaction, not one per stage: bytes is [setup 8][data…] and dir says which way the data stage went.
A request served from the box's own value cache still produces an event.
bytes = 80 06 00 01 00 00 12 00 12 01 00 02 00 00 00 40 ...
'------ setup (8) ------' '---- data stage -------'
GET_DESCRIPTOR(device) dir = 1 (IN), flags = 0 (completed OK)BUS carries [a][b] in bytes with the kind in flags. A bus event is not a transfer, so its dir is 0 and true_len is just the operand count.
The same events drive HEALTH bits and STATS counters; here they carry a timestamp and their place in the stream.
| Kind | Meaning | a, b |
|---|---|---|
0 | RESET | - |
1 | SUSPEND | - |
2 | RESUME | - |
3 | CONFIGURED | configuration index |
4 | DECONFIGURED | - |
5 | SET_INTERFACE | interface, alternate setting |
6 | DEV_ATTACHED | - |
7 | DEV_DETACHED | - |
8 | CLONE_UP | - |
9 | CLONE_DOWN | - |
A 64-byte vendor interrupt report arriving IN on endpoint 0x83, captured under a snaplen = 16 entry (class = 6, dir = 1, true_len = 64, 16 bytes present, so payload LEN = 28):
+--------+--------+--------+--------+-------------+--------+--------+--------+--------+ | A5 | 16 | 3C | 1C 00 | 40 42 0F 00 | 00 | 06 | 83 00 | 01 | +--------+--------+--------+--------+-------------+--------+--------+--------+--------+ | SOF | TYPE | SEQ | LEN | ts_us | clk | class | id | dir | +--------+--------+--------+--------+-------------+--------+--------+--------+--------+ +--------+--------+---------------------------------------+--------+ | 00 | 40 00 | 04 01 12 00 ... (16 bytes) | lo hi | +--------+--------+---------------------------------------+--------+ | flags |true_len| bytes: 16 of 64, so the rest was cut | CRC16 | +--------+--------+---------------------------------------+--------+
A SET_INTERFACE bus event on interface 1, alternate setting 2 (class = 10, flags = 5, two operand bytes, so payload LEN = 14):
+--------+--------+--------+--------+-------------+--------+--------+--------+--------+ | A5 | 16 | 3D | 0E 00 | 41 42 0F 00 | 01 | 0A | 00 00 | 00 | +--------+--------+--------+--------+-------------+--------+--------+--------+--------+ | SOF | TYPE | SEQ | LEN | ts_us | clk | class | id | dir | +--------+--------+--------+--------+-------------+--------+--------+--------+--------+ +--------+--------+--------+--------+--------+ | 05 | 02 00 | 01 | 02 | lo hi | +--------+--------+--------+--------+--------+ | flags |true_len| a=iface| b=alt | CRC16 | +--------+--------+--------+--------+--------+
Delivery
Best-effort, ranked, and counted per entryEvents drain through strict-priority queues.
BTN KEY MEDIA AXIS BUS --> [ queue 0 ] --+ HID_IN HID_OUT | VEND_INTR EMIT --> [ queue 1 ] --+---> control link, 4 Mbaud CONTROL --> [ queue 2 ] --+ VEND_BULK --> [ queue 3 ] --+ strict priority: each queue drains fully before the next
Bulk can go undrained indefinitely under a busy mouse: bulk plus input is the combination the control link cannot carry.
Under back-pressure the box drops events rather than stalling the report path, so the stream never delays the game-PC-facing reports. Every drop is counted per entry in RESP(CATCH).