Medius - Native APICatch

Catch

Stream the traffic the box carries, addressed the way a lock is

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

WHERE THE TAPS SIT
  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 injection

Addressing 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 entry

CATCH 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

PAYLOAD
OffsetFieldTypeNotes
0classu8the address 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 to 3 are the LOCK classes unchanged. Classes 4 and up reach the byte-oriented traffic.

ClassValueid meansWith id = 0xFFFF
BTN0button idevery button
KEY1HID keyboard usageevery key and modifier
MEDIA216-bit Consumer usageevery media usage
AXIS3TGT_X / TGT_Y / TGT_WHEELevery axis
HID_IN4interface numberevery HID interface
HID_OUT5endpoint addressevery interrupt-OUT endpoint
VEND_INTR6endpoint addressevery vendor interrupt endpoint
VEND_BULK7endpoint addressevery vendor bulk endpoint
CONTROL8endpoint number (0 = EP0)every control endpoint
EMIT9endpoint addressevery emitting endpoint
BUS10unused-
ANY0xFFmust be 0xFFFFevery class
DIRECTION
ValueInput classes (0 to 3)Traffic classes (4 to 10)
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, 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.

PHYSICAL ONLY, AND BEFORE THE SCALE

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.

EXAMPLE

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 up

An 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 16
CAPACITY AND REFUSAL

The 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 whenWhy
the table already holds 32 entriesnothing is evicted; the header's b0 flag says an entry was turned away
class is one the firmware does not knowan unknown class has no tap to attach to
dir is 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, 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 which

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

clkStamped byWhich classes
0the host chip, in USB interrupt context, when the real device's transfer completedMOTION / USAGE, HID_IN, VEND_INTR / VEND_BULK IN
1the device chip, at the tapHID_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.
PUTTING BOTH DOMAINS ON ONE TIMELINE

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 → PC

While 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

PAYLOAD
OffsetFieldTypeNotes
0ts_usu32report arrival time in box microseconds, little-endian
4clku8always 0 (host chip); see the clk byte
5dxi16physical X this report; + = right, little-endian
7dyi16physical Y this report; + = down, little-endian
9dzi16physical 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.

EXAMPLE

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 → PC

While 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

PAYLOAD
OffsetFieldTypeNotes
0ts_usu32report arrival time in box microseconds, little-endian
4clku8always 0 (host chip); see the clk byte
5clsu8the snapshot's 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)
+idu16the held usage's id (a button id, HID keycode with 0xE0-0xE7 modifiers, or Consumer usage), little-endian
ONE CLASS PER EVENT

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.

EXAMPLE

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 → PC

One 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

PAYLOAD
OffsetFieldTypeNotes
0ts_usu32when the tap fired, little-endian
4clku8which chip's clock stamped it; see the clk byte
5classu8the address class (table above)
6idu16endpoint address, interface number, or endpoint 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_lenu16the packet's length before snaplen truncation, little-endian
12bytesu8[]up to snaplen bytes; the frame LEN delimits 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
VEND_BULKb0 end-of-transfer, b1 zero-length packet
CONTROLhow the transaction completed: 0 OK, 0xFD STALL, 0xFE NAK to timeout
BUSthe event kind (table below)
every other class0
CONTROL: ONE EVENT PER TRANSACTION

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

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.

KindMeaninga, b
0RESET-
1SUSPEND-
2RESUME-
3CONFIGUREDconfiguration index
4DECONFIGURED-
5SET_INTERFACEinterface, alternate setting
6DEV_ATTACHED-
7DEV_DETACHED-
8CLONE_UP-
9CLONE_DOWN-
EXAMPLE

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 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, 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).