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

PAYLOAD
OffsetFieldTypeNotes
0classu8address class (table below), or 0xFF = every class
1idu16class-specific, or 0xFFFF = every id in that class, little-endian
3diru80 BOTH, 1 POS/IN, 2 NEG/OUT (the LOCK direction byte)
4stateu81 = subscribe, 0 = unsubscribe
5snaplenu8bytes captured per event; 0 = the whole packet
ADDRESS CLASSES

Classes 0-3 are the LOCK classes. Classes ≥4 are byte-oriented traffic.

NameValueid meansWith id = 0xFFFF
BTN0button idevery button
KEY1HID keyboard usageevery key and modifier
MEDIA216-bit Consumer usageevery media usage
AXIS3TGT_X / TGT_Y / TGT_WHEEL / TGT_PANevery axis
HID_IN4interface numberevery HID interface
HID_OUT5endpoint numberevery interrupt-OUT endpoint
VEND_INTR6endpoint numberevery vendor interrupt endpoint
VEND_BULK7endpoint numberevery vendor bulk endpoint
CONTROL8endpoint number (0 = EP0; on EP0, the requests the clone relays to the real device)every control endpoint
EMIT9endpoint numberevery emitting endpoint
BUS10unused-
CLIP_XFER11endpoint number (0 = EP0)every control endpoint
ANY0xFFmust be 0xFFFFevery class
DIRECTION
ValueInput classes (0-3)Traffic classes (4-11)
0 BOTHpress and releaseIN and OUT
1 POSthe press edge, or the + sign of an axisIN: device to PC
2 NEGthe release edge, or the - sign of an axisOUT: PC to device
SNAPLEN

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.

CAPTURE POINT

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.

EXAMPLE

Subscribe to every vendor interrupt endpoint, both directions, capturing the first 32 bytes of each packet (class = 6, id = 0xFFFF, snaplen = 32):

A5SOF
0BTYPE
00SEQ
06 00LEN
06class
FF FFid
00dir
01state
20snaplen
lo hiCRC16

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:

A5SOF
0BTYPE
01SEQ
06 00LEN
FFclass
FF FFid
00dir
00state
00snaplen
lo hiCRC16

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 16
CAPACITY

The table holds 32 entries. No reply. A refused entry is absent from RESP(CATCH), whose header carries the table-full flag.

Refused whenWhy
table already at 32 entriesnothing is evicted; the header's b0 flag marks the refusal
unknown classno tap to attach to
dir outside 0..2a subscription is addressed before any bearing is read, so only 0-2 name anything a tap can match
class = 0xFF with a specific idid is class-specific, so a wildcard class with a real id addresses nothing coherent
LIFECYCLE

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 byteWhich 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.

clkStamped byClasses
0the host chip, in USB interrupt context, when the real device's transfer completedMOTION / USAGE, HID_IN, the real device's VEND_INTR / VEND_BULK IN
1the device chip, at the tapHID_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.
CORRELATION

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_EVENTOne 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

PAYLOAD
OffsetFieldTypeNotes
0ts_usu32report arrival time in box microseconds, little-endian
4clku8always 0 (host chip); see the clk byte
5dxi16native X this report; + = right, little-endian
7dyi16native Y this report; + = down, little-endian
9dzi16native wheel delta this report; + = up, little-endian
11dpani16native 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.

EXAMPLE

A native +10 right, no other motion (dx = 10):

A5SOF
0CTYPE
2ASEQ
0D 00LEN
40 42 0F 00ts_us
00clk
0A 00dx
00 00dy
00 00dz
00 00dpan
lo hiCRC16

USAGE_EVENTOne 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

PAYLOAD
OffsetFieldTypeNotes
0ts_usu32report arrival time in box microseconds, little-endian
4clku8always 0 (host chip); see the clk byte
5clsu8snapshot class: 0=button 1=key 2=media
6diru8the edge that produced it: POS the set grew, NEG it shrank
7nu8number of held usages that follow
+classu8per usage: same vocabulary as cls (as INJECT)
+idu16held usage id (a button id, HID keycode with 0xE0-0xE7 modifiers, or Consumer usage), little-endian
SNAPSHOT

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.

EXAMPLE

Left Shift held while pressing A (a keys snapshot, two usages both class = 1: Left Shift id = 0xE1, then A id = 0x04):

A5SOF
0FTYPE
2BSEQ
0E 00LEN
40 42 0F 00ts_us
00clk
01cls
01dir
02n
01 E1 00usage[0]
01 04 00usage[1]
lo hiCRC16

TRAFFIC_EVENTByte-oriented class traffic, box → PC

One frame type carries every traffic class, 4-11. Opcode 0x16.

TRAFFIC_EVENT 0x16 · payload 12 + n bytes

Unsolicited

PAYLOAD
OffsetFieldTypeNotes
0ts_usu32when the tap fired, little-endian
4clku8stamping chip's clock; see the clk byte
5classu8address class (table above)
6idu16endpoint number or interface number, little-endian
8diru81 = IN (device to PC), 2 = OUT (PC to device), 0 for BUS, which is not a transfer
9flagsu8class-specific (table below)
10true_lenu16packet length before truncation (by snaplen or the 172-byte control data-stage cap), little-endian
12bytesu8[]up to snaplen bytes; the frame LEN gives how many arrived
TRUNCATION
  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
FLAGS BY CLASS
Classflags
HID_IN, EMITb1 zero-length packet (on a HID endpoint, also one that ended the report after the event's bytes); b7 RULE
HID_OUT, VEND_INTRb1 zero-length packet, b7 RULE
VEND_BULKb0 end-of-transfer, b1 zero-length packet, b7 RULE
CONTROLb0-b1 the handshake the game PC received: 0 OK, 1 STALL, 2 NAK until the host gave up (endpoint 0 only); b7 RULE
CLIP_XFERhow 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
BUSthe event kind (table below)
ZERO-LENGTH PACKETS

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 sendsEvents
a report it ends with a zero-length packetone 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 forone 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 boxits 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 PCits 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 EVENTS

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)
PartCarries
setupThe 8 setup bytes, from byte 0 of bytes.
IN dataThe reply the PC received: after a REPLY_PATCH or REPLY_REPLACE rule, or an ANSWER's payload.
OUT dataThe data stage the PC sent, before a PATCH or REPLACE rule rewrote it for the real device.
b0-b1The 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.
NO EVENT
TransactionWhy
a standard request on endpoint 0, such as GET_DESCRIPTOR or SET_CONFIGURATIONthe 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 keepthe clone asks the real device for it directly, with no event
one a bus reset cut shortit never completed
on endpoint 0, a request with an OUT data stage >2048 bytes; above endpoint 0, any data stage >2048 bytesthe 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 queuethe box abandons it
CLIP_XFER EVENTS

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 EVENT KINDS

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.

KindMeaninga, b
0RESET-
1SUSPEND-
2RESUME-
3CONFIGUREDconfiguration index
4DECONFIGURED-
5SET_INTERFACEinterface, alternate setting
6DEV_ATTACHED-
7DEV_DETACHED-
8CLONE_UP-
9CLONE_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.

EXAMPLE

A 64-byte vendor interrupt report arriving IN on endpoint 0x83, captured under a snaplen = 16 entry (16 bytes present, so payload LEN = 28):

A5SOF
16TYPE
3CSEQ
1C 00LEN
40 42 0F 00ts_us
00clk
06class
83 00id
01dir
00flags
40 00true_len
04 01 12 00 ... (16 bytes)bytes: 16 of 64, so the rest was cut
lo hiCRC16

A SET_INTERFACE bus event on interface 1, alternate setting 2 (two operand bytes, so payload LEN = 14):

A5SOF
16TYPE
3DSEQ
0E 00LEN
41 42 0F 00ts_us
01clk
0Aclass
00 00id
00dir
05flags
02 00true_len
01a=iface
02b=alt
lo hiCRC16

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):

A5SOF
16TYPE
3ESEQ
16 00LEN
42 42 0F 00ts_us
01clk
08class
00 00id
02dir
81flags
0A 00true_len
21 09 00 02 00 00 02 00setup
01 00data
lo hiCRC16

Rules and tapsTap 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
ClassBytesRULE set when
HID_INthe native report as it arriveda rule then changed or dropped it
HID_OUT, VEND_INTR / VEND_BULK OUTwhat the PC senta rule then changed or dropped it on its way to the real device
VEND_INTR / VEND_BULK INwhat the PC receivesa rule changed it; a packet a rule drops raises no event
EMITthe emitted reporta 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
CONTROLthe transaction the PC received, on every control endpointa 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).