Catch
CATCH subscribes to traffic through the box: native input, vendor-interface endpoints, proxied control transactions, raw bytes of HID interfaces the semantic model doesn't parse, what the clone emitted, bus lifecycle, and the control transfers a clip runs.
A subscription is a table of (class, id, dir) entries, addressed like a LOCK.
While subscribed, the box pushes a MOTION_EVENT for cursor motion, the wheel, and pan, 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 unsubscribed.
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, CLIP_XFER
+- VEND_BULK IN +- EMIT (after inject + lock)
+- BTN KEY MEDIA AXIS +- BUS
at the merge point, +- VEND_INTR, VEND_BULK IN
before the lock scale from RAW or a clip raw entry
and before injectionAddressing is the filter. The control link runs at 6 Mbaud, and vendor bulk alone measures ~250 KiB/s through the box, so every class at once can't be delivered.
CATCHAdd or remove one subscription-table entry
CATCH carries one table entry: address, direction, subscribe or unsubscribe, and how much of each packet to capture. Opcode 0x0B.
CATCH 0x0B · payload 6 bytes
Fire-and-forget
| Offset | Field | Type | Notes |
|---|---|---|---|
| 0 | class | u8 | 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-3 are the LOCK classes. Classes ≥4 are byte-oriented traffic.
| Name | 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 / TGT_PAN | every axis |
HID_IN | 4 | interface number | every HID interface |
HID_OUT | 5 | endpoint number | every interrupt-OUT endpoint |
VEND_INTR | 6 | endpoint number | every vendor interrupt endpoint |
VEND_BULK | 7 | endpoint number | every vendor bulk endpoint |
CONTROL | 8 | endpoint number (0 = EP0; on EP0, the requests the clone relays to the real device) | every control endpoint |
EMIT | 9 | endpoint number | every emitting endpoint |
BUS | 10 | unused | - |
CLIP_XFER | 11 | endpoint number (0 = EP0) | every control endpoint |
ANY | 0xFF | must be 0xFFFF | every class |
| Value | Input classes (0-3) | Traffic classes (4-11) |
|---|---|---|
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: one subscription can take a 64-byte report whole while another cuts a bulk pipe to 16. A cut capture still carries the real length in true_len.
Input classes are captured at the emission merge point before any LOCK scale or injection, so a weighed or blocked input still reports its full native value.
EMIT is what the clone emitted 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):
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:
A blanket (id = 0xFFFF) stays one wildcard entry, unlike a LOCK button or axis blanket, which expands per member. Library binding: catch_events.
TableMatching, capacity, refusals
Rank: an exact (class, id) entry, then a class blanket, then 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. No reply. A refused entry is absent from RESP(CATCH), whose header carries the table-full flag.
| Refused when | Why |
|---|---|
| table already at 32 entries | nothing is evicted; the header's b0 flag marks the refusal |
unknown class | no tap to attach to |
dir 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, a re-clone, inter-chip link loss, or an explicit unsubscribe. Every clear except the unsubscribe moves the session count.
The library's injection keepalive holds an open table past the silence timeout. The library re-asserts the whole table after the box clears it and across a control-link reconnect. The library's RESET ends the event stream.
The HEALTH CATCH_ON bit means the table is non-empty.
clk byte Which chip's clock stamped an event
All three event frames lead with ts_us then clk. The two ESP32-S3s boot independently with unrelated timers, so a stamp compares only against another from the same domain.
clk | Stamped by | Classes |
|---|---|---|
0 | the host chip, in USB interrupt context, when the real device's transfer completed | MOTION / USAGE, HID_IN, the real device's VEND_INTR / VEND_BULK IN |
1 | the device chip, at the tap | HID_OUT, VEND_INTR/VEND_BULK OUT, a vendor IN packet from RAW or a clip raw entry, CONTROL, CLIP_XFER, EMIT, BUS |
Both clocks are box-local, unrelated to any control-PC clock.
Each clock wraps every ~71.6 minutes (a 32-bit microsecond counter) and returns to zero when its chip reboots. A value below the previous one is therefore 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 µs / 1000 µs poll = 47 polls
-> 46 polls where the device sent 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.
Divide a gap by RESP(RATE)'s poll_period_us for a poll count, only if its CHANGE_DRIVEN flag is clear. A change-driven device's idle polls carry no report and can't be counted.
MOTION_EVENT One native relative-axis snapshot, box → PC
While an AXIS subscription is active the box pushes a MOTION_EVENT for each native report whose motion changed. Opcode 0x0C.
SEQ is a rolling per-event counter shared with USAGE_EVENT and TRAFFIC_EVENT, stamped as each event is sent, so it orders a mixed stream.
SEQ doesn't detect drops: events drop before the stamp, so SEQ runs gapless. Losses are counted in RESP(CATCH).
MOTION_EVENT 0x0C · payload 13 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 | native X this report; + = right, little-endian |
| 7 | dy | i16 | native Y this report; + = down, little-endian |
| 9 | dz | i16 | native wheel delta this report; + = up, little-endian |
| 11 | dpan | i16 | native AC Pan (horizontal scroll) delta this report; + = right, little-endian |
The stamp is taken when the real device's interrupt-IN transfer completes, so it is always the host chip's.
A native +10 right, no other motion (dx = 10):
USAGE_EVENT One native held-usage snapshot, box → PC
While a BTN, KEY, or MEDIA subscription is active, the box pushes a USAGE_EVENT when that class changes. The event is 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 | snapshot 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 | held usage 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 are one class, since one native report is one class.
Only held usages that match the table appear. If none match, no event is sent.
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. While another subscriber holds a wider entry, though, the box sends an event on both edges, and only dir tells them apart.
Route by class, not by the usages present, and diff successive snapshots for the usages of interest. Matching on present usages misses the release edge while another subscription's usage is held.
Left Shift held while pressing A (a keys snapshot, two usages both class = 1: Left Shift id = 0xE1, then A id = 0x04):
TRAFFIC_EVENT Byte-oriented class traffic, box → PC
One frame type carries every traffic class, 4-11. 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 | stamping chip's clock; see the clk byte |
| 5 | class | u8 | address class (table above) |
| 6 | id | u16 | endpoint number or interface 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 | packet length before truncation (by snaplen or the 172-byte control data-stage cap), little-endian |
| 12 | bytes | u8[] | up to snaplen bytes; the frame LEN gives 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 |
|---|---|
HID_IN, EMIT | b1 zero-length packet (on a HID endpoint, also one that ended the report after the event's bytes); b7 RULE |
HID_OUT, VEND_INTR | b1 zero-length packet, b7 RULE |
VEND_BULK | b0 end-of-transfer, b1 zero-length packet, b7 RULE |
CONTROL | b0-b1 the handshake the game PC received: 0 OK, 1 STALL, 2 NAK until the host gave up (endpoint 0 only); b7 RULE |
CLIP_XFER | how the transfer ended, as TRANSFER_RESP's status: 0 OK, 0xFD STALL, 0xFE no answer (NAKed past the host chip's timeout, failed on the bus, an undeclared endpoint, no reply within 4 s, or the clone suspended by the game PC), 0xFF no device, 0xFC refused |
BUS | the event kind (table below) |
An event is one packet, except a HID_IN or EMIT event on a HID endpoint. That event carries a report of ≤64 bytes whole, however many packets it took. A longer report comes a packet at a time.
| Device sends | Events |
|---|---|
| a report it ends with a zero-length packet | one event carrying the report's bytes, b1 set: the PC's read ends only at the zero-length packet |
| a zero-length packet alone, its answer to a poll it had nothing for | one event of no bytes, b1 set, in HID_IN as it came and in EMIT as the PC took it |
| a zero-length packet whose poll went to a report from the box | its HID_IN event, and no EMIT event |
| a zero-length packet answering a poll the PC missed: what the real device sent before it still waits for the PC | its HID_IN event, and no EMIT event |
the device sends HID_IN the PC takes EMIT [ 32 ] [ ZLP ] --> 32 bytes, b1 [ 32 ] [ ZLP ] --> 32 bytes, b1 [ 32 ] [ 32 ] --> 64 bytes [ 32 ] [ 32 ] --> 64 bytes [ ZLP ] --> 0 bytes, b1 [ ZLP ] --> 0 bytes, b1 [ ZLP ] --> 0 bytes, b1 a report of the box's --> its bytes (b1 if it ends in one) vendor endpoint: one event a packet, so [ 64 ] [ ZLP ] is 64 bytes, then 0 bytes with b1
CONTROL carries one event per completed transaction: bytes is [setup 8][data ...] and dir is the data stage's direction.
The event is the transaction as the game PC received it, the same on every control endpoint. A request served from the box's value cache still produces an event.
bytes = A1 01 00 01 00 00 08 00 01 00 00 00 00 00 00 00
'------ setup (8) ------' '---- data stage -------'
HID GET_REPORT(Input) dir = 1 (IN), flags = 0 (completed OK)| Part | Carries |
|---|---|
| setup | The 8 setup bytes, from byte 0 of bytes. |
| IN data | The reply the PC received: after a REPLY_PATCH or REPLY_REPLACE rule, or an ANSWER's payload. |
| OUT data | The data stage the PC sent, before a PATCH or REPLACE rule rewrote it for the real device. |
| b0-b1 | The handshake the PC got. A request a rule refused reads STALL (NAK for a NAK rule on endpoint 0). A failed request above endpoint 0 reads STALL, since the clone STALLs it there. |
| Transaction | Why |
|---|---|
a standard request on endpoint 0, such as GET_DESCRIPTOR or SET_CONFIGURATION | the clone serves it itself, and a configuration or interface change still raises a BUS event. Some standard requests are relayed to the real device and raise a CONTROL event: an interface's GET_DESCRIPTOR for a class descriptor other than the HID and report descriptors. While the clone is configured, so does a device qualifier or other-speed configuration the box did not keep (one too long for it, or at an index past 0) |
| a string the box did not keep | the clone asks the real device for it directly, with no event |
| one a bus reset cut short | it never completed |
| on endpoint 0, a request with an OUT data stage >2048 bytes; above endpoint 0, any data stage >2048 bytes | the box STALLs it before proxying it |
| a request a new SETUP on its endpoint replaced, or, above endpoint 0, one the box had no room to queue | the box abandons it |
CLIP_XFER carries one event per control transfer a clip sent to the real device, in CONTROL's shape: bytes is [setup 8][IN data].
Only a transfer the box sent raises an event. The rest are counted in RESP(CLIP).
BUS carries [a][b] in bytes with the kind in flags, and true_len is 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 | - |
The box suspends the real device on SUSPEND and resumes it on RESUME. A device the PC armed for remote wakeup wakes the PC through the clone.
A 64-byte vendor interrupt report arriving IN on endpoint 0x83, captured under a snaplen = 16 entry (16 bytes present, so payload LEN = 28):
A SET_INTERFACE bus event on interface 1, alternate setting 2 (two operand bytes, so payload LEN = 14):
A SET_REPORT on EP0 that a STALL rule refused: flags = 0x81, RULE with handshake 1 (STALL), and the two data bytes the PC sent (payload LEN = 22):
Rules and taps Tap positions around the rewrite table
Flags bit 7, RULE, marks a packet a rewrite rule acted on at the event's class: changed its bytes, dropped it, answered it or refused it. A PASS, or a PATCH that changed no byte, leaves the bit clear.
packet
|
+--> tap: HID_IN, HID_OUT, vendor OUT the bytes as they arrived
v
[ rewrite table ] RULE set when the rule acts
|
+--> tap: vendor IN, EMIT the bytes delivered
v
game PC or real device| Class | Bytes | RULE set when |
|---|---|---|
HID_IN | the native report as it arrived | a rule then changed or dropped it |
HID_OUT, VEND_INTR / VEND_BULK OUT | what the PC sent | a rule then changed or dropped it on its way to the real device |
VEND_INTR / VEND_BULK IN | what the PC receives | a rule changed it; a packet a rule drops raises no event |
EMIT | the emitted report | a rule changed the report, or a report merged into it. Never on a vendor endpoint, whose rules act at VEND_INTR and VEND_BULK. A dropped report raises no event |
CONTROL | the transaction the PC received, on every control endpoint | a rule rewrote its data, answered it or refused it |
CLIP_XFER carries a transfer status in its flags byte and never RULE: a clip's transfer runs past every rule.
DeliveryBest-effort, ranked, and counted per entry
Events drain through strict-priority queues.
BTN KEY MEDIA AXIS BUS --> [ queue 0 ] --+ HID_IN HID_OUT | VEND_INTR EMIT --> [ queue 1 ] --+---> control link, 6 Mbaud CONTROL CLIP_XFER --> [ queue 2 ] --+ VEND_BULK --> [ queue 3 ] --+ strict priority: each queue drains fully before the next
Under a busy mouse, bulk can go undrained indefinitely: the control link can't carry bulk plus input.
Under back-pressure the box drops events rather than stall the report path, so the stream never delays reports to the game PC. Every drop is counted per entry in RESP(CATCH).