# Developer reference
Last verified: 2026-10-16

This is the interoperability spec for the BRX tagger and headset: transport, framing, every known command and event with its field map, the `$WEAP` / `$GSET` / `$PSET` / `$SIR` tables, the optical IR word, the USB console, and the headset link. Every command, token, field name and wire value on this page is literal.

> **Credit, first.** The BRX serial protocol was discovered and proven by **LaserTagMods** (the JEDGE / JBOX / NRFL-Bases projects, github.com/LaserTagMods). Everything on this page is an independent, clean-room restatement. We verified it on our own taggers, decoded it from public app metadata, or captured it off the air. It contains no Battle Company code or assets. If you build on this, credit them too.

> **Ground rule.** Stock BRX firmware is **never modified**. Every capability below is reached over the tagger's own serial protocol, and **power-cycling always restores a tagger** to normal operation.

## Transport, framing and safety

One text protocol, three ways in. The BRX speaks a plain ASCII, comma-delimited command language on a hardware UART. Gen1 exposes it over Bluetooth Classic, Gen2/3 over BLE, and the community drives it from a wire. The frames are identical on all three.

> **Who found this.** Protocol discovery for the BRX platform is the work of **LaserTagMods** (JEDGE / JBOX). This page restates their findings independently, with our own bench verification.

**Transport by generation**

| Generation | Link | Speed | How you connect |
|---|---|---|---|
| Gen1 | Bluetooth Classic (SPP) | 57600 baud | Pair an HC-05 module (PIN `0001`, master role). The headset must be connected for Bluetooth to function. |
| Gen2/3 | BLE (Nordic UART Service, NUS) | UART bridge at 115200 behind the radio | Connect from any BLE central: laptop (bleak), ESP32, phone. No pairing/PIN. |
| Any | Hardware UART inside the gun | 115200 | Not what JEDGE drives day to day. Jay's JEDGE 6.0 ESP32 source links to the gun over BLE, as a Nordic UART central, for Gen2/3, or over Bluetooth Classic SPP as master with PIN `0001` for Gen1; an HC-05 module on a UART at 9600 baud is a third option. Only his serial test sketch uses `Serial1` directly. No external accessory port exists on the BRX. A wired tap means opening the gun. Untested by us. |
| Any | Micro-USB "Programing Port" | USB CDC (baud ignored) | **Not** the `$` protocol. It is a separate `QUERY`/`SETUP` console. See the USB serial console section. |

**BLE: Nordic UART Service UUIDs**

| Item | Value |
|---|---|
| Service | `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` |
| RX characteristic (**write** to tagger) | `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` |
| TX characteristic (**notify** from tagger) | `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` |
| Advertised name | `Tactix-XXXX` (the last two bytes of the BLE MAC). The NUS service UUID **is** present in the advertisement, so scan-time generation detection works. |
| ATT MTU | Negotiates to **23 bytes**. Chunk writes to ~20-byte payloads. This is required, not defensive. |
| Round trip | `$PING,*` to `$PONG,*` is about 59 ms over BLE. |

> **Generation detection heuristic.** Power on the tagger and run a BLE scan. If it advertises the UART service it is Gen2/3. If nothing appears on BLE but the device pairs over Bluetooth Classic it is Gen1.

**Frame anatomy**

```text
$ + COMMAND + (, + token)* + ,*
```

- ASCII, comma-delimited tokens. Starts with `$COMMAND`, ends with `,*`.
- **Empty tokens are legal and meaningful**: consecutive commas mean "leave unchanged / not applicable". `$SPAWN,,*` (one empty token) is a different command from `$SPAWN,*`.
- Example: `$PING,*` replies `$PONG,*`.
- Tokens may not contain a comma, `*` or a line break (that is the validator in `protocol.py`: `^\$[A-Z0-9!]+(,[^,*\r\n]*)*,\*$`).
- Notifications can arrive merged (`$ALCD,…$BUT,0,1,*`); split on the next `$` as well as on `*`.

**Waking a gun and holding the link**

1. Connect and subscribe to the TX notify characteristic. Establishing a link is **intermittent** (the official app behaves the same) and the link holds once it is up. Retry. That *is* the fix.
2. On a **fresh power-up**, send `$STOP,*` then `$PHONE,*` (after a power-cycle `$PHONE,*` alone wakes it). A just-booted gun ignores a bare `$VERSION,*` until then.
3. `$PHONE,*` opens the **event tap**: the gun says "phone connected", answers `$BUT,3,0,*`, streams button events and `$VOLTS` telemetry, and locks its on-gun menu until a game is configured or it is power-cycled.
4. Idle taggers are **silent**: outside app mode no unsolicited messages are sent, so no button, trigger or hit traffic.
5. The official app's connect ritual (captured, fw v4.32) is `$STOP,*`, `$PLAYX,0,*`, `$VOL,69,0,*`, `$PLAY,VA20,3,6,,,,,*` ("connection established"), then once per session `$NAME,<name>,*` plus `$VERSION,*`. It never sends `$PHONE,*`.
6. **The headset must be linked** or the gun will connect, answer a quick `$PING`, then drop within seconds and echo nothing to config. After a gun-initiated `$DISCONNECT,*`, back off at least 5 s before reconnecting.

> **The safety model.** Three layers, in order of what they protect:
>
> 1. **Firmware is never written**, so a power-cycle always restores a tagger.
> 2. **A host refuses malformed frames** and requires an explicit confirm for any command outside the known-safe list (below).
> 3. **The panic sequence** `$CLEAR,*` then `$SP,99,*` silences and stops a gun.
>
> The panic sequence leaves the gun with **no `$SIR` table, so it cannot be hit** until it is re-armed or power cycled. That is intended for a panic stop and must not be mistaken for a playable state. Battle Company's official USB updater is the factory-restore path.

`mcp/brx_mcp/protocol.py` enforces four buckets with three outcomes, not one flat known-safe list:

1. **`KNOWN_COMMANDS`.** The instrument sends these with no confirm. Each row records the agreed token count when
   available, whether the effect is bench-proven on v4.32, and an evidence note. Examples include `$STUN,<ms>`,
   `$BUMP` in its 5-field shape, `$TMP`, `$DIE`, `$TEAM`, `$PID`, `$IRTX` and `$RADSK`.
2. **`CONFIRM_REQUIRED_COMMANDS`.** Recognised names with version-conflicted evidence: `$PRES`, `$INVU`, `$BHIT`
   and `$FIREX`. They remain unsupported until the named bench checks settle their shape and effect.
3. **`DENIED_COMMANDS`.** Refused even with `confirm=true`, each with a reason: factory restore (`$FACTORY`); DFU (`$CDFU`, `$HEADDFU`, `$DDFU`); pairing (`$PAIR`, `$PIN`, `$CLEARDEVICE`, `$INQ`, `$GPAIR`, `$GPAIRX`); the gun's own radio-module control frames (`$!…`, `$^…`, `$&…`); the IR word-format switch (`$IRT`); factory and hardware tests (`$FTST`, `$BURN`, `$DUTY` the laser duty cycle, `$SOL`, `$MUZ`); provisioning writes (`$DTYPE`, `$DEV`, `$SITE`, `$TSTRNAME`); `$VIBTOGGLE`; `$ASKSN` (prints the headset PIN); `$RESET`; the zombie headset family (`$ZOM`, `$ZTOG`, `$ZON`, `$ZOFF`, `$BOOM`, `$ZombieKeyActive`); and the USB console word `SETUP`. **`HANG_PRONE_COMMANDS`** is a separate list, today only `$DPLAY`: the `send` tool passes it only with `confirm=true` and `allow_hang=true` together, one frame at a time, so a supervised bench run can try to make a lock-up on demand. Both lists reach the phone app as `NODE_DENIED_COMMANDS`, and the phone refuses to write any of them.
4. **Anything else** needs `confirm=true`.

```python
PANIC_SEQUENCE = ["$CLEAR,*", "$SP,99,*"]
```


### Is the baud rate real over BLE?

No. BLE has no baud. 115200 is the UART behind the radio bridge. That is why BLE and a wire speak identical frames.

### Why does my client drop at ~6.6 s?

The link *holds* fine once up (80 s+ sessions with the official app, multi-minute sessions with ours). Establishment is intermittent; retry in a loop. If it dies within seconds *and echoes nothing to config*, the headset is not linked.

### Can a command brick the gun?

Nothing in the protocol writes firmware. Every state written over BLE is wiped by a power-cycle (except `$NAME`, which persists).

## Command reference

Every known command, with args and meaning: host to tagger, tagger to host, and headset. Copy the frames exactly as written.

These tables are a summary for readers. The full row for each command, with its evidence and every retraction, is in [the protocol reference](https://github.com/tony99nyr/open-brx/blob/main/protocol/brx-protocol.md). Where the two disagree, the protocol reference is right, and a repo test fails until this page agrees with it. The protocol was first discovered and proven by LaserTagMods (JEDGE / JBOX).

> **How to read the tables.** "Direction" is host to gun (`>>`) or gun to host (`<<`). Some commands were sent and received on hardware; others carry field names taken from the Callsign app's IL2CPP metadata (names certain, wire position = declaration order) or come from LaserTagMods sources and community captures, unreproduced on hardware. Each row says which. Commands the app knows that are untested on hardware sit in their own table at the end of this section. Where a command's field map has its own section (`$WEAP`, `$GSET`/`$PSET`, `$SIR`), the row says so.

**Host to tagger: lifecycle and configuration**

| Command | Dir | Args | Meaning |
|---|---|---|---|
| `$PING,*` | >> | n/a | Connectivity check. Reply `$PONG,*`. |
| `$STOP,*` | >> | n/a | Stop. First frame the official app sends on every (re)connect; also part of the end-of-game tail. **Confirmed 2026-09-18: it blocks a hit's damage, not the hit itself** (a hit while stopped still returns `$HIR`, with no `$HP` and no pool change), **and it survives `$SPAWN`**: only `$START,*` (plus a `$GSET`/`$TID` re-send) reopens it. Design rule: anything that sends `$STOP` must send `$START` before the next life. |
| `$PHONE,*` | >> | n/a | App-controlled mode: opens the live event tap (buttons, `$VOLTS`), locks the on-gun menu. Reply `$BUT,3,0,*`. |
| `$CONNECT,*` / `$INIT,*` | >> | n/a | On the known-safe list. Sent on v4.32: **no observable reply**. |
| `$CLEAR,*` | >> | n/a | Clear current game state. First frame of every arm sequence; half of the panic sequence. **It also wipes the `$SIR` table**, and because unmatched `$SIR` cells are silently ignored, a gun left with no rows ignores every hit while still reporting alive: no `$HIR`, no headset flash, pools untouched. Always re-send `$SIR` after `$CLEAR`. |
| `$START,*` | >> | n/a | Begin the configuration sequence. Gun echoes `$LCD,0,0,0,0,0,0,*`. |
| `$GSET,…,*` | >> | 8 tokens | Global game settings: friendly fire, APK-named `outdoorMode`, region, ambient light, gyro, BT secondaries, crit modifier, mods. Token 2 is a measured receive gate, not the physical indoor/outdoor toggle. **No respawn/time/lives token.** See the `$GSET` and `$PSET` section. |
| `$PSET,…,*` | >> | id, team, HP, armor, shield, crit bonus, deathAlarm, voice-pack… | Player settings: **token 1 = player id (0-63)**, tokens 3-5 = HP/armor/shield pools, token 6 = crit damage bonus (Battle Company's sheet name), then a 17-sound voice pack at t7-t23. See the `$GSET` and `$PSET` section. |
| `$WEAP,<slot>,…,*` | >> | slot 0-5 + ~43 tokens | Define a weapon in a slot: damage, fire interval, fire mode, clip/reserve, reload, sounds, IR type. See the `$WEAP` section. |
| `$SIR,<proto>,<subtype>,<sound>,<fn>,p5,p6,p7,p8,*` | >> | 8 tokens | Incoming-IR effects matrix: what an IR word with protocol B / subtype U does to this gun. **Unmatched cells are silently ignored.** See the `$SIR` section. |
| `$BMAP,<button>,<function>,<swap0..3>,*` | >> | button id, function, 4 swap slots | Remap physical controls. Buttons: 0 trigger, 1 alt-fire, 2 reload handle, 3 select, 4 left, 5 right, 8 gyro. Functions seen: 0 fire, 97 reload, 98 (select/left/right), 100 weapon-cycle, 4 melee (gyro). **Mandatory**: without it the trigger only chirps "disabled". **With one `$WEAP` slot loaded, function 100 has nothing to cycle to and falls back to reloading** (bench 2026-09-17: "the alt button is reloading the charge rifle"). A single-weapon loadout therefore maps ALT to 98, the inert function, so alt-fire does nothing. |
| `$TID,<team>,*` | >> | team | Team id. **Use only 0-3.** The gun masks the team into the 2 bits the IR word carries (`team & 3`), but **the victim compares the full `$TID` value**, so 4-7 are not harmless aliases: two teammates on tid 4 each transmit team 0 and each reads 0 against its own 4 as an enemy, so they damage each other and friendly fire cannot be turned off for them; their shots read as friendly to a real tid-0 player and take nothing off; and a gun can kill itself off a nearby reflective surface (bench 2026-09-07: a gun on tid 5 shot its own headset down through armor and HP, with `$HIR` naming the shooter's own player id). Values 4-7 are accepted and breathe the matching palette colour, which is why they look like extra teams. Drives the gun LED colour at `$SPAWN` (1 = blue, 2 = yellow observed) and is echoed as `$HIR` token 4 on the victim. A live write changes hit resolution immediately but does not repaint LEDs. |
| `$SPAWN,,*` | >> | **one empty token** | **Go-live** and **respawn**. Restores HP/armor and (on respawn) ammo; echoes `$LCD,<hp>,<armor>,0,0,<mag>,<reserve>,*`. Also clears the `$SIR` fn-23 state (`$ALCD` token 2 back to 100). `$SPAWN,*` (no empty token) is not the same command. **Leave at least 3 seconds after a death before respawning**, or the headset stays stuck in the green out-blink. |
| `$AMMO,<slot>,<mag>,<reserve>,<flag>,*` | >> | slot, magazine, reserve, 1 | Load magazines. Must follow `$SPAWN` at initial go-live or the gun is live with no ammunition. e.g. `$AMMO,0,36,108,1,*`. A bare `$WEAP` re-push resets ammo to the frame's baked values. Re-send `$AMMO` after any weapon swap. |
| `$PLAYX,0,*` | >> | 0 | Stop/clear sound playback. Sent right after `$STOP` on connect and just before the go-live cue. |
| `$PLAY,<sound>,<vol>,<prio>,<announcer>,,,,*` | >> | 8 tokens | Play a sound id (2,477 on the gun; see [the sound catalog](../reference/sound-catalog.md) for what each one is). An id the gun does not have plays a fallback sound, not silence. **Two slots that behave differently, and the behaviour belongs to the slot, not to the kind of id** (bench 2026-09-11, six trials): **token 1 INTERRUPTS**, cutting whatever is playing in either slot, mid-word if it has to, and **token 4 QUEUES**, waiting its turn behind whatever is ahead of it (depth 3 or more observed). An effect id dropped into token 4 queues exactly like a voice line. `$PLAY,,4,6,V3A,,,,*` speaks "kill" with token 1 empty; `$PLAY,VSF,4,6,JAY,,,,*` uses both in one frame, with no layering. A sound that must not step on an announcer line goes in token 4; a hit-path or urgent sound goes in token 1 and accepts that it can cut a line off. **Tokens 2-3 are required**: `$PLAY,VA33,,,,,,,*` is silent, `$PLAY,VA33,4,6,,,,,*` speaks. Numeric values vary by client (`3,9` Android app, `3,6` iOS, `4,6` JEDGE). APK field names: soundName, addToQue1, addToQue2, loopingTime, stun, isNeedQueue. |
| `$VOL,<0-100>,<n2>,*` | >> | volume, 0 | Master volume. Android app sends `$VOL,100,0,*`; iOS `$VOL,69,0,*`. Levels to use: see [Sound](/manual/sound#volume). |
| `$NAME,<name>,*` | >> | name | Sets the gun's **persistent** name (the USB `Gun Name` field; survives power-cycle). Opening the official app rewrites it to `Tactix2`. |
| `$VERSION,*` | >> | n/a | Query firmware. Reply `$VERSION,v4.32,?,4,,devhost.03,*`. Token 2 is the **headset** firmware (`hds.59`) when a headset is linked. |
| `$SP,<n>,*` | >> | n | End-of-game / stop. `$SP,99,*` is the second half of the panic sequence. Do not probe it mid-game hoping for a score. The gun keeps none. (LaserTagMods / community.) |
| `$QUERY,*` | >> | n/a | Over BLE returns a `$`-framed status array (`$QUERY,0,0,0,0,0,,1,0,,0,…`) plus a `$LCD`. **Not** the USB device record (that is USB-only; see the USB serial console section). **On a dead gun the `$LCD` half arrives at once and the rest of the body about 2 s later**, with no trailing `*` (bench 2026-09-18), so a repeated poll should use `$LIFE,*` instead and keep `$QUERY` for a one-off correction. |

**Host to tagger: in-game effects, feedback and pools**

| Command | Dir | Args | Meaning |
|---|---|---|---|
| `$SFLASH,*` | >> | n/a | **The shooter's green-sight kill-confirm flash.** The host sends exactly one per kill the holder scores, ~0.4 s after the trigger burst ends. (The APK lists it under notifications; on the wire the phone sends it.) |
| `$LIFE,<hp>,<armor>,<shields>,*` | >> | addedHP, addedArmor, addedShields | Grant **or drain** a pool. Additive, clamped at the pool max, never an absolute set, and it **does self-emit `$HP`** (bench 2026-09-09). It **accepts negative values and drains**: `$LIFE,0,-5,0,*` took armour 66 to 61. A negative is per pool and floors at 0 with no spill into the next pool. A negative that empties health really kills the gun, but announces it with `$LCD,0,0,0,0,<mag>,<reserve>,*` and never `$HP,0,0,0`. **A bare `$LIFE,*` or an all-zero `$LIFE,0,0,0,*` is a read probe**: it changes nothing and self-emits `$HP` regardless of whether the gun is alive or dead, so a dead gun answers `$HP,0,0,0` at once (bench 2026-09-18). Poll gun state this way, not with `$QUERY`. ⚠️ **A 4th token is a revive switch: any non-zero mode brings a dead gun back.** `$LIFE,30,0,0,1,*` (bench 2026-09-18) on a dead gun returned `$HP,30,0,0` and the gun took hits again at once. Keep a probe on mode 0 (or the token omitted); send a non-zero mode only when a revive is intended. |
| `$BUMP,<amount>,<hp>,<armour>,<shield>,<sound>,*` | >> | amount, then three pool flags (0/1) and a sound id | **Confirmed in full (bench 2026-09-18).** A negative amount removes from armour first and overflows into HP once armour is empty; a positive amount heals HP first and overflows into armour once HP is full. Each of the `hp`/`armour` flags gates its own pool independently: `$BUMP,-5,1,0,0,,*` moves HP only, `$BUMP,-5,0,1,0,,*` moves armour only. **With every pool flag 0 the gun does nothing at all and sends no `$HP` reply.** Callsign's own shield recharge sends `$BUMP,12,,1,,,*` (armour flag only), adding 12 to the armour pool up to its maximum (capture, 2026-09-18). **The shield flag and the sound token are confirmed too (bench 2026-09-18):** the shield flag gates the shield pool independently, the same as hp/armour, and a negative amount drains it first and cascades into whichever other selected pool is open; the sound token plays the named id and stays silent when empty. |
| `$BHIT,…,*` | >> | version-conflicted | The available evidence disagrees about this command's shape. The instrument therefore requires `confirm=true`; levers §3 must settle the v4.32 shape on the bench before this is a supported host hit. |
| `$HFIRE,…,*` | >> | **11 fields** (names not recorded) | Heavy or burst fire. The metadata read on 2026-09-04 gives 11 fields including a `FlashLED` flag; the four names this row used to list came from an earlier partial read. **The real shape is untested.** The five shapes tried on v4.32 were 4-field guesses and emitted zero IR, with a receiver control passing before and after. |
| `$IRTX,<Direction>,<BulletType>,<PlayerId>,<ImmuneTeamColor>,<Damage>,<IsCriticalShot>,<PowerLevel>,<IrRange>,<ToggleIRLoop>,<TimeFireLoop>,<FlashLED>,*` | >> | 11 fields | Raw IR transmit, named in Battle Company's own sheet. Direction 0 = front, 100 = all. Field 4 is **not** the shooter's team; BC's own note says the gun treats team 2 as the broadcast team. ToggleIRLoop: 0 = no loop, 1-99 = fire that many times, 100 = loop; TimeFireLoop = ms between pulses. The 2018 BC app sent `$IRTX,0,14,<pid>,<team>,1,0,0,<range>,1,,1,*` once a second as a revive beam. A sample from BC's sheet: `$IRTX,100,15,63,0,6,1,0,100,100,5000,1,*`. **Bench-confirmed 2026-09-18 (levers §13/§16):** the host gun forwards `$IRTX` out through its own headset and the headset emits the exact word, on a LIVE gun and on a DEAD one alike (a dying gun otherwise emits no IR of its own). The loop fields work as read: `ToggleIRLoop` = 100 with `TimeFireLoop` = 1000 made the other gun register the same `$HIR` once a second, 8 times, with no further host frame; `ToggleIRLoop` = 0 stopped it at once. A revive-beam-shaped word (protocol 14) registered on a gun armed with `$SIR,14,0,NULL,34,,,,,*` even while that gun was dead (fn 34 registers on a dead gun), and a follow-up `$LIFE,30,0,0,1,*` revived it to take hits again. |
| `$MELEE,<intensity>,*` | >> | intensity | Fires no IR. An early bench saw `$BUT,4,0,*` come back, but on 2026-09-24 `$MELEE` gave no reply at all, the same as an unknown command, so that reply was a coincidence. A physical swing is the melee path (see below). |
| `$STUN,<ms>,*` | >> | one token, milliseconds | **A real, native, SILENT stun (bench 2026-09-18).** `$STUN,6000,*` blocked the trigger (`$BUT,0,1` with no `$ALCD`, so no round left the gun) for about 6 s, then fired normally again. The gun plays no sound of its own while stunned, so a host that uses this must play its own cue; bench pick by ear is `X17`, matching Battle Company's own UART sheet entry for the concussion grenade. A bare `$STUN,*` (no duration) is a no-op, which is the earlier "no effect" reading. |
| `$TMP,<t1>..<t11>,*` | >> | up to 11 optional tokens, per-player modifiers | **Confirmed over BLE for t4, t8 and t9 (bench 2026-09-18), none of them touching the magazine unless noted.** t4 sets live accuracy as an ABSOLUTE value (not cumulative), matching `$ALCD` token 2 at once and holding with no walk-back; a `$WEAP` push does not clear it. t8 reduces incoming damage: -50 took 4 off a 9-damage hit, -100 took 0 (spawn protection with no `$SIR` table needed). t9 tops the magazine up by `clip x t9 / 100` RAW ROUNDS at once and raises the reload cap to `clip + t9`; sending the same t9 write twice adds again rather than replacing. An `$AMMO` set-mode write (`$AMMO,<slot>,<n>,<reserve>,1,*`) clamps back to the base clip and ignores a raised t9 cap, so re-assert t9 after any `$AMMO` set. `$SPAWN` zeroes every `$TMP` token (so a protection write must land AFTER `$SPAWN`, never before); `$CLEAR` also zeroes it. **t4 is last-writer-wins against the gun's own `$SIR` fn 23 (smoke) effect, and fn 23's own ~6 s timer resets t4 to 0 regardless of the last write.** Any host writer of t4 must never write while a smoke is active and must re-send its own value once the smoke's timer ends. **t5 (fire interval, full-auto only), t6 (reload time) and t7 (outgoing damage) are confirmed too (bench 2026-09-18).** t5 and t6 both scale their `$WEAP` interval by `(100 + t) / 100`: t5 slowed a full-auto AR from a round every 105 ms to about 205 ms at t5 = 100, with no effect measured on a shell-reload weapon; t6 scaled a reload from 1784 ms to 2756 ms at t6 = 50 (x1.55, close to the x1.5 prediction). t7 scales the barrel word's outgoing damage before it leaves the shooter's gun: a control 9-damage hit read 13 at t7 = 50. |
| `$GLED,<led1>,<led2>,<led3>,<apply-gate>,<brightness>,,*` | >> | three LED colours | **Gun LED colour, per LED.** Tokens 1 to 3 are the three body LEDs, each a direct palette index. The palette is nine colours: **0 red, 1 blue, 2 yellow, 3 green, 4 purple, 5 teal, 6 white, 7 pink, 8 orange**; 9 and 10 are dark. **Token 4 is an apply gate**, not an effect enum and not an off switch: it decides whether the colour tokens in the same frame take effect at all. Values **0, 6, 7, 8, 9 and 10 apply** the colours at full brightness. Value **5 turns the LEDs OFF**, whatever the colour tokens say. Values **1, 2, 3 and 4 are no-ops**: the colour tokens are ignored and the gun keeps whatever it was already showing, which is why a sweep of this token reads differently depending on whether it blanks between rows. No token-4 value animates. `$GLED,,,,5,,,*`, the frame Callsign itself sends on death, blanks the gun because **token 4 = 5 is the off value**. Token 4 = 5 is therefore the one gate value never to send with real colour tokens: it discards them. Sending the blank once takes the strip out of the firmware's breathing loop for the rest of the life, and it is **mandatory before any paint holds**: a dark paint sent without a prior blank is simply overwritten by the breathing. It is idempotent, and after it every revert should be a dark PAINT (`$GLED,9,9,9,0,10,,*`) rather than another blank. **An EMPTY colour token is RED, not "leave this LED alone".** A blank field parses as 0, and 0 is red. Measured on the gun 2026-09-09 by a controlled test: with the strip held at three solid purple, `$GLED,,9,,0,10,,*` produced red, dark, red. **Always write all three colour tokens**, or the ones you leave empty turn red. There is no way to move one segment without restating the others. Token 5 is brightness and is the ONLY brightness control: 0 off, 1 dim, 2 and above full, saturating at 2 so that 2 through 255 are indistinguishable. It is **global**, applying to all three LEDs at once, so a bar of two bright segments and one dim one is not possible; a partial step has to be a blinking segment instead. Token 5 = 1 is what night mode uses. A held paint survives ordinary game traffic (`$AMMO`, `$PLAY`, `$HLED`, `$LED`) untouched, and keeps its hue at the dim setting. On a spawned gun the set colour alternates with the team colour, because a spawned gun is also using these LEDs as its own health gauge. Verified on the bench 2026-08-30: a gun held on team 1 took six different colours on command, and `$GLED,3,2,1,0,10` was predicted and confirmed as green, yellow, blue. Palette completed on 2026-09-02 with a camera rig, and token 4 re-measured the same day from a known lit start, three trials per value; normalised R/G/B signatures, all three LEDs agreeing on every row: 0 = 1.00/0.16/0.26, 1 = 0.19/0.59/1.00, 2 = 0.88/1.00/0.59, 3 = 0.18/1.00/0.54, 4 = 0.59/0.49/1.00, 5 = 0.23/1.00/0.85, 6 = 0.73/0.79/1.00, 7 = 1.00/0.30/0.66, 8 = 1.00/0.38/0.30. The camera separates the indices from each other; it does not name absolute hues, so the reading rests on relative separation measured back to back under identical conditions, on three LEDs agreeing, and on a match with an independent community source. Token 4 = 5 is OFF, not dim: measured on a blanked, host-owned strip, alternating `$GLED,3,3,3,0,10` against `$GLED,3,3,3,5,10` four times, it is bright then off with no step in between. Token 5, A/B tested the same way, is the only control that dims. **Do NOT repaint this at speed to hold a colour against the gun's own animation**: it takes roughly 30 writes a second to win, and the result strobes. Flicker in the 10 to 25 Hz band is the photosensitive epilepsy trigger range. Signal an event with a short burst of three flashes instead. |
| `$GREN,…,*` | >> | iRType, crit, modifier, indoorMode, operationMode, channel, GrenadeType, MaxCount | Smart Grenade configuration frame, addressed to the **gun**. GrenadeMode enum: FlashBang / Gas / Confusion / Molotov. Sent on the bench: the gun emitted IR, but the emitted bits did not track the arguments. |
| `$PBGAME,$PBTEAM,$PBWEAP,$PBPERK,$PBLIVES,$PBTIME,$PBSPAWN,$PBINDOOR,$PBLOCK,$PBSTART` | >> | enum index | The **"playbook"** pre-battle family mirroring the on-gun menu. A second remote-start path captured on fw **v4.30** (`$PBGAME,0` = FFA, `$PBWEAP,0` = M4 AUTO, `$PBPERK,2` = Body Armor, `$PBLIVES,2` = 5 lives, `$PBTIME,5` = infinite). `$PBWEAP,0,*` produced a "game starting" reload sound on our v4.32. (Community.) |
| `$DD,<killerId>,<killerTeam>,<victimId>,<nonce>,*` | host to host | n/a | JEDGE's **device-to-device** kill notification. A host-side convention, not a tagger command. (Community.) **Bench-confirmed 2026-09-18: in a hosted (BLE app-mode) game, the gun did not send `$DD`** on a one-hit kill (`$HP,0,0,0` then `$LCD,0,…` followed, with nothing in between); native mode is untested. Do not build a death detector on `$DD` from the gun. |

**Headset commands (host to gun to headset)**

| Command | Args (APK) | Meaning |
|---|---|---|
| `$HLED,<colour>,<effect>,,,,,*` | LedColorType (White, Pink, Orange; plus green via `isUsedGreenLed`), BlinkLoopType (Once, ThreeTimes, Infinite), LedEffectType (incl. Heartbeat) | Headset LED. `$HLED,,6,,,,,*` is sent in the app's end-of-game tail and in the lobby. **Effect 6 is the blank, and sending it disables the firmware's own death-flash loop for the rest of that life** (bench 2026-09-07). A colour write does not. Never send it during play: the flash it removes is the brightest signal a downed player has, and nothing reports that it is gone. Use colour index 9 for a dark headset in play instead. Token 1 is a colour index sharing the gun's palette for 0 to 7 (0 red, 1 blue, 2 yellow, 3 green, 4 purple, 5 teal, 6 white, 7 pink); the two devices diverge at 8, which reads red on the headset and orange on the gun. 9 and 10 are dark. Measured 2026-09-02 with the camera rig, all visible headset modules agreeing. |
| `$HLOOP,<mode>,<period_ms>,*` | a, b | Drives the headset's small flash LED as a repeating loop, at the firmware's own drive level. `$HLOOP,2,750,*` on a downed player flashes it at about the rate the stock down signal uses, and an operator judged it at least as bright as native. `$HLOOP,0,0,*` stops the loop and is what the app sends about 1.7 s after every death. `$SPAWN` also clears it. It restores the flash even on a life where an `$HLED` blank had suppressed it. Rates of 750 and 2000 ms both work; the usable ends of the range are not measured. |
| `$BLINK,3,0,300,300,10,*` | | Holds the headset's big LED solid green (bench 2026-09-04). The fields after the colour are not settled. |
| `$LED,9,1,1,1,*` | | One green flash on the headset's small flash LED; the big LED is left alone (bench 2026-09-04). |

**Tagger to host: events and echoes**

| Message | Fields | Meaning |
|---|---|---|
| `$PONG,*` | n/a | Reply to `$PING`. |
| `$VERSION,<gun fw>,<headset fw>,<n>,,<host image>,*` | e.g. `v4.32,?,4,,devhost.03` | Version reply; token 2 reads `hds.59` with a headset linked, `?` otherwise. `devhost.*` = developer/host image. |
| `$DISCONNECT,*` | n/a | Gun-initiated disconnect notice (e.g. the moment its headset is switched off). |
| `$VOLTS,<pack_mV>,<cell_mV>,<t3>,<t4>,*` | e.g. `7662,3921,55,70` | Battery telemetry, about every 30 s in app mode. Token 1 = pack millivolts (7.662 V), token 2 = cell millivolts (3.921 V). Tokens 3-4: (unknown). |
| `$LCD,<hp>,<armor>,<shield>,<weaponSlot>,<mag>,<reserve>,*` | e.g. `45,70,0,0,36,216` | **Health/armor HUD echo.** `$START` gives all zeros; `$SPAWN` gives pools plus the current weapon's ammo; death gives `$LCD,0,0,0,<slot>,<mag>,<reserve>,*` (bench 2026-09-18: `$LCD,0,0,0,0,6,24`). The 2018 BC app's parser names tokens 3 and 4 `shield` and `weaponSlot`. Token 3 is the live shield: after `$LIFE,0,0,30`, a `$QUERY` gave `$LCD,45,70,30,0,36,216` (bench 2026-10-16). Bench 2026-10-17: token 4 is the slot of the last shot, not the selected slot. After an ALT with no shot, `$QUERY,*` still gave the old slot: `$LCD,45,70,0,0,30,188`. A zeroed `$LCD` after `$SPAWN` means "no config loaded" (post power-cycle tell). |
| `$ALCD,<mag>,<t2>,<slot>,<reserve>,<heat>,*` | e.g. `36,100,0,108,0` | **Ammo/weapon HUD stream.** Per-round during fire *and* reload (mag 0, 1, 2 and up as reserve drains). **Token 2 is live accuracy**, one value per round fired: it starts each life at the weapon's `$WEAP` t21 ceiling, walks down toward the t22 floor under sustained fire, and resets to the ceiling on reload. ⚠️ **The walk is not the same on every gun (2026-09-17, three guns, one frame):** one gun walked to the floor in 9 to 16 rounds, repeatedly, and it is the gun the 2026-09-09 model was measured on; the other two took one or two steps and then held, through a re-arm, a power cycle, both indoor and outdoor modes, still and moving. The hit roll itself works on all three. Treat the walk as gun-dependent. The cause is unknown, and Open BRX frames set t21 = t22, so they never walk. See the `$WEAP` t21/t22 rows above. It also drops to 0 after a `$SIR` fn 23 hit and recovers over about 6-8 s (see the `$SIR` function map). Token 3 = weapon slot. Token 5 = **weapon heat**: raw, above 100 at lockout, and non-zero only on a weapon whose overheat is switched on by `$WEAP` t38. An Energy Rifle at t38 = 150 climbed about 3 per shot under full auto, stopped firing at **99** after about 30 rounds, and read 102 while locked (2026-09-17). **Self-cooling is per weapon**: the Charge Rifle cooled about 30 per second on its own, and the Energy Rifle did not fall at all while locked. Working the reload lever vents about 35 per pull. Only streams on ammo events. Silence is not "no change". |
| `$HIR,<sensor>,<irProto>,<shooterId>,<shooterTeam>,<magnitude>,<crit>,<subtype>,*` | e.g. `4,0,19,2,9,0,3` | **Hit received.** See the events section for the full decode. |
| `$HP,<hp>,<armor>,<shield>,*` | e.g. `43,0,0` | Pools after a hit; arrives in the same millisecond as its `$HIR`. `$HP,0,0,0` = death. |
| `$BUT,<id>,<state>,*` | id 0-5, or 8 for the gyro melee swing when `$BMAP,8,…` maps it; state 1 press / 0 release | Button or gyro event (ids match `$BMAP`). Streams only in app mode. |
| `$QUERY,…` | ~11 `value,,` pairs | Status array in reply to BLE `$QUERY,*`. |
| `$WEAP` / `$PERK` / `$HS` | n/a | Selection echoes from the on-gun menus (LaserTagMods). |

**Seen in the app's vocabulary, not exercised by us.** These are facts about the Callsign app's request namespace only. On-tagger behaviour has not been observed.

| Command | Direction | APK fields / enums | Note |
|---|---|---|---|
| `$VIB` | >> | `isEnableVibration` | Untested. |
| `$ZOOM` | >> | n/a | Untested. |
| `$FSET` | >> | ~38 event to sound slots: ActionKey, DeathAlarm, TickTock, HitHp, HitArmor, HitShield, HitCrit, EmpStart/Loop/End, IncendiaryStart/…, TearGasHit, … | Untested. |
| `$ASSIST` | >> | `soundName` (+ SetVolume) | Untested. |
| `$DLC` / `$ASKDLC` to `$GOTDLC` | >> / << | `hiddenFeatures` | The app's premium-content (BattleCoins) handshake. The metadata names three premium modes: Generals, Commanders, Swarm. Untested. |
| `$CHASE` | >> (headset) | n/a | Listed with the headset commands. No visible effect in any shape tried. |
| `$TIME` | << | n/a | Listed as a notification. Never observed on the wire. |

> **Complete vocabulary from the app's own request namespace.** AMMO ASKDLC ASSIST BHIT BMAP BUMP CLEAR DLC FSET GLED GREN GSET HFIRE IRTX LIFE MELEE NAME PLAY PLAYX PSET SIR SPAWN START STOP STUN VERSION VIB VOL WEAP ZOOM · headset BLINK CHASE HLED HLOOP LED · notifications ALCD BUT GOTDLC HIR HP LCD SFLASH TIME VERSION VOLTS. `$PING`, `$TID`, `$SP`, and the `$RV`/`$RP`/`$UR`/`$KK`/`$DD` family are **not** in it. They come from LaserTagMods and our bench. Absence from the app does not mean non-existent.

## The arm sequence: from `$CLEAR` to a live gun

The exact frame order that takes a tagger live, respawns it, and ends the game. Send these frames in this order. We captured the order from the official app and reproduced it with our own host.

> **The headset is a second device and it needs time.** A command that the tagger must relay to the headset is received, processed and executed there, not instantly. Send `$SPAWN` within about 2 seconds of a death and the headset never executes it: it stays stuck flashing the green out-blink while the gun is alive and registering hits normally, so the player looks dead while playing normally. Measured 2026-09-02: gaps of 1.0 s and 2.0 s stick, and 2.5 s, 3.0 s and 6.0 s are clean. **Leave at least 3 seconds.** The same applies to anything else with a headset side effect, such as `$HLOOP` and `$HLED`. Note the gun queues commands and drains them one at a time, so a frame echoing back proves the gun received it, not that the headset executed it.

> **`$CLEAR` wipes the `$SIR` table and the gun then ignores every hit.** This is the most confusing failure mode the gun has: the gun arms, spawns, reports full pools, answers `$QUERY` normally and looks perfectly healthy, while every shot that reaches it is discarded. There is no `$HIR`, the headset stays dark, and the pools never move, so it presents as a broken headset or a dead sensor. It is neither. The `$SIR` matrix decides what an incoming IR word does to this gun, unmatched cells are silently ignored, and after `$CLEAR` there are no cells at all. Re-sending the `$SIR` rows alone restores it immediately. Bench-proven 2026-09-02: deterministic 5/5, and independent of how long you wait between `$CLEAR` and `$SPAWN` (tested 0.05 s to 1.0 s). Note the table SIZE does not matter, only its absence: a one-row table and the full ten-row table both registered 24/24 in an interleaved A/B.

> This sequence is the official iOS app driving a live game on firmware v4.32, reproduced byte-for-byte on real taggers. Three pieces are easy to leave out, and the gun spawns wrong without them: **`$AMMO` after spawn**, **`$BMAP` before *and* after spawn**, and the **empty token in `$SPAWN,,*`**.

```text
$CLEAR,*
$START,*
$GSET,1,0,1,0,1,0,50,1,*
$PSET,0,0,45,70,70,50,,H44,JAD,V33,V3I,V3C,V3G,V3E,V37,H06,H55,H13,H21,H02,U15,W71,A10,*
$WEAP,0,...                (primary)
$WEAP,1,...                (secondary)
$WEAP,4,...                (melee, always sent)
$SIR,... x10               (incoming-IR effects table)
$BMAP,0,0,,,,,*   $BMAP,1,100,0,1,99,99,*   $BMAP,2,97,,,,,*
$BMAP,3,98,,,,,*  $BMAP,4,98,,,,,*          $BMAP,5,98,,,,,*
$BMAP,8,4,,,,,*
$TID,<team>,*              (ours; the app relies on defaults)
$PLAYX,0,*
$PLAY,VA81,4,6,,,,,*       (go-live voice cue)
$SPAWN,,*                  <-- go-live (note the empty token)
$AMMO,0,36,108,1,*         <-- load magazines
$AMMO,1,6,12,1,*
$BMAP,0,0,,,,,*            <-- trigger re-mapped AFTER spawn
```

> **`$BMAP,1,100` only works with two weapons loaded.** The sequence above sends `$WEAP,0` and `$WEAP,1`, so ALT
> cycles between them. Send one `$WEAP` slot and function 100 has nothing to cycle to: the gun reloads instead
> (bench 2026-09-17). A single-weapon loadout maps ALT to 98, the inert function, so the button does nothing.

**What the gun echoes back**

| After | Echo | Meaning |
|---|---|---|
| `$START,*` | `$LCD,0,0,0,0,0,0,*` | Cleared state |
| `$SPAWN,,*` | `$LCD,45,70,0,0,36,216,*` | Live: HP 45, armor 70, mag 36, reserve 216 (this config) |
| each shot | `$ALCD,35,100,0,108,0,*` … | Mag decrementing, slot 0, reserve, heat |
| each trigger | `$BUT,0,1,*` / `$BUT,0,0,*` | Press / release |
| ~30 s | `$VOLTS,7634,3770,53,45,*` | Telemetry continues in-game |

> **The config head is silent and safe.** Writing `$CLEAR` through `$TID` without `$SPAWN` plays nothing, and **a configured-but-unspawned gun ignores IR**: no `$HIR`, no `$HP`. "Get some" and the cocking sound belong to `$SPAWN`. A head held unspawned for about 2 minutes then spawned went live with config intact.

**Death and respawn are host-driven**

1. Victim reports `$HP,0,0,0,*` then `$LCD,0,0,0,<slot>,<mag>,<reserve>,*`. The gun does **not** revive itself, and a dead gun's trigger produces `$BUT` events but no `$ALCD` decrement (it cannot fire).
2. The app sends `$HLOOP,0,0,*` about 1.7 s after death.
3. After the game's respawn delay (the app's own timer, about 10 s in the capture; community reports a per-death ramp capping at 45/90 s) the host sends `$SPAWN,,*`.
4. Gun echoes `$LCD,45,70,0,0,36,216,*`: HP, armor **and ammo** restored with no `$AMMO` needed.
5. **A dead gun in a hosted game ignores incoming IR**: 448 distinct words, including every grenade-beacon shape, failed to revive one. Only the host can. The one exception is fn 34, which registers a `$HIR` on a dead gun and moves nothing (bench 2026-09-18). The 2018 BC app's revive beam is fn 34 on protocol 14, counted by the phone. Our 448-word test had no fn 34 or fn 35 row in the table.

> **After a BLE drop, re-send the whole head.** Re-sending the full sequence (`$CLEAR`, `$START`, …, `$SPAWN,,*`, `$AMMO`) on a fresh link brought a gun back in every bench case.

```text
# Clean end-of-game, as the official app does it (note the ~3.9 s settle before the last $PLAY):
$VOL,69,0,*
$HLED,,6,,,,,*
$STOP,*
$CLEAR,*
$PLAY,VSF,4,6,JAY,,,,*     # victory sting (slot 1) + "victory" announcer (slot 4); solo game: $PLAY,VS6,4,6,,,,,*
```

### Do I need the reload-handle pull?

No. The manual's reload-handle pull is the *local* start; `$SPAWN,,*` is the *remote* one. Both exist.

### Where do respawn time, game time, lives and score-to-win go?

Nowhere on the gun. Three captures at respawn 5/15/30 s gave byte-identical `$GSET`/`$PSET`. Your host keeps the clock.

### Is the second `$BMAP,0,0` (after `$SPAWN`) needed?

Yes. Omit it and the trigger is dead.

## `$WEAP`: the weapon definition

Forty-odd comma-separated tokens make a weapon out of data: damage, cadence, fire mode, ammo, sounds, IR type. This section maps every token.

> **A weapon is data, not firmware.** The gun has no weapons baked in; the host sends a full `$WEAP` frame into one of **six slots (0-5)** by Callsign convention, not a firmware limit: the 2018 BC app loaded weapons into slots 0-7, with melee in slot 7 (`$BMAP,8,7`). The field *names* come from the Callsign app's metadata (declaration order = wire order); the *positions and meanings* below were then pinned by capturing the 19 stock weapons (20 frames) with the operator naming each one, and by flipping single tokens on a live gun.

```text
# Two known-good frames (slot 0 Assault Rifle, slot 1 Charge Rifle):
$WEAP,0,,100,0,0,24,0,,,,,,,,100,850,32,32768,1400,0,0,100,100,,0,,,R01,,,,D04,D03,D02,D18,,,,,32,9999999,75,,*
$WEAP,1,,100,8,0,150,0,,,,,,,,1250,850,100,32768,2500,0,14,100,100,,14,,,E03,C15,C17,,D30,D29,D37,A73,C19,C04,20,150,100,9999999,75,,*
# A captured Shotgun (slot 1): the only stock weapon with the extra-headset block populated:
$WEAP,1,2,100,0,0,45,0,,,,,,70,80,900,850,6,24,400,2,7,100,100,,0,,,T01,,,,D01,D28,D27,D18,,,,,6,12,75,30,*
```

**Token map** (0-indexed: `$WEAP,<t0>,<t1>,…,*`; the slot is t0)

| tok | Field | AR | Charge Rifle | Meaning |
|---|---|---|---|---|
| 0 | slot | 0 | 1 | Weapon slot 0-5. Slot 4 = melee by convention (gyro swing, `$BMAP,8,4`). |
| 1 | slotType / mode flag (BC sheet: `WeaponIRSource`) | n/a | n/a | `2` on exactly the three weapons carrying an extra-headset payload (t12/t13/t42 populated); `1` on melee; `0` rail gun; empty otherwise. Not fire mode. Battle Company's own sheet names this field `WeaponIRSource`, an enum of 0 GunLaser, 1 HeadSetOnly, 2 GunAndHead, 3 DoubleGun, 4 DoubleGunAndHead, 5 DRY_FIRE. Every stock melee row ships 1 (HeadSetOnly). |
| 2 | **gunRangeOutdoor** | 100 | 100 | **The outdoor range lever** (bench 2026-09-17, garden ladder, two guns in outdoor mode). Every captured gun reads 100 here (melee 90). A value of 5 landed 0 of 38 shots, even muzzle to dome. Values from 13 to 26 are a transition band. From about 31 to 100 is a flat shelf where every value behaved alike at every distance the garden could pace, so differences we ship above the shelf are probably not felt. This corrects the earlier reading that named t41 as the range lever. Open BRX ships a per-weapon starting value outdoors from this token (docs/weapon-design.md §4.2) and never compiles a value under 13. A firmware code reading of how the token works is in [the protocol file, §6](https://github.com/tony99nyr/open-brx/blob/main/protocol/brx-protocol.md). |
| 3 | primaryDamageType | 0 | 8 | **The IR word's B field / `$SIR` protocol key.** Writing a type here is echoed by the victim in `$HIR` token 2 and selects its `$SIR` row. DamageType enum: 0 Standard, 1 MedicHeal, 2 ActivateShield, 3 RallyPulse, 4 Radiation, 5 Cryogenic, 6 ArmorPiercing, 7 EMP, 8 Shrapnel, 9 StickyBomb, 10 StandardLethalExplosive, 11 NonLethalExplosive, 12 ShottyPellets, 13 MeleeDamage, 14 Plasma. Stock: 8 charge, 10 rocket, 11 gas, 13 melee. The wire position is bench-proven; the enum names come from the APK. |
| 4 | primaryPowerType | 0 | 0 | IRSource enum: DeviceCommand, IRSource, GunLaser, HeadSetOnly, GunAndHead, DoubleGun, DoubleGunAndHead, DRY_FIRE, MuzzleFlash, MuzOnly, VibOnly, MuzAndVib. Order relative to t3 was settled by t3 behaving as damageType. |
| 5 | primaryDamage | 24 | 150 | **The raw magnitude put in the IR word** (= `$HIR` token 5). Applied damage depends on the victim's `$SIR` row. The stock AR emits 9. The 24 in the sample frame comes from Battle Company's own "Boss WEAP Modified" sheet, which boosts enemy stats; it is not a stale manual figure or an error. |
| 6 | primaryCriticalChance | 0 | 0 | **Crit chance in percent, rolled by the gun** (bench 2026-09-18). t6 = 20 gave 9 crits in 64 hits (14.1%); t6 = 50 gave 54 in 119 (45.4%). A crit lands the magnitude x1.5, truncated (9 becomes 13), and the victim's `$HIR` token 6 reads 1 on it (0 on a normal hit). 0 on every stock weapon. The `$GSET` t7 scaling is a separate axis: it rides fn 36/37 by SENSOR. |
| 7-11 | secondaryFireChance, secondaryDamageType, secondaryPowerType, secondaryDamage, secondaryCriticalChance | n/a | n/a | **Dormant**: empty on all 20 captured stock frames. No stock BRX weapon has a secondary fire mode. |
| 12 | extraHeadsetDamage | n/a | n/a | Populated with t1=2: Shotgun 70, Rocket 115, Plasma Sniper 80. |
| 13 | extraHeadsetRangeOutdoor | n/a | n/a | 80 on the same three weapons. |
| 14 | **fire interval / charge time (ms)** | 100 | 1250 | **Proven by one-field flip**: a sniper with t14=1250 slowed to one shot every 1.25 s (timed by ear as roughly one per second). Stock cadences read from the captured frames: burst 75, SMG 90, AR 100, sniper 300, AMR 360, launcher 360, shotgun 900, melee 1000, rail gun 1200, charge rifle 1250. For charge weapons this is the hold time. |
| 15 | weaponSwapDelay | 850 | 850 | Weapon-swap delay in ms: how long after an ALT (weapon-cycle) press the gun refuses to fire while it draws the other weapon. Bench 2026-09-04: 1700 doubled the swap, 425 halved it, 100 ran at 100; linear with no floor. The gun uses the larger of the two loaded slots' values in both directions, so a fast pistol paired with a rifle draws at the rifle's speed. Melee ships 100. |
| 16 | maxClip | 32 | 100 | Magazine size. |
| 17 | maxAmmo | 32768 | 32768 | **The reserve the player carries** (corrected 2026-09-18). Always `2 × t40` in captured frames (or 32768 as an unlimited flag). With a compiled AR frame (t17 192, t40 96) the gun reports 96 after `$WEAP`, then 192 after `$SPAWN` and after the `$AMMO` restore. |
| 18 | reloadSpeed (ms) | 1400 | 2500 | Reload time. |
| 19 | ReloadType | 0 | 0 | Battle Company's own sheet names this field `ReloadType`, an enum of 0 Magazine, 1 Quiver, 2 Shells, 3 SingleBolt, 4 BoltWithMagazine, 5 AutoReload, 6 NoReloadRequired, 10 bottomless magazine (melee ships 10). Untested on our bench, both stock frames read 0. |
| 20 | **fire mode** (BC sheet: `GunWeaponType`) | 0 | 14 | **Proven by one-field flip**: `0` full-auto, `7` single-shot/bolt, `9` burst (cycle in t23), `2` charge, auto-release (tap = weak shot), `3` hold-to-charge, auto-fire (tap = sound only), `14` tap-fire OR charge-release, `13` melee. Battle Company's own sheet names this field `GunWeaponType`, a fuller enum: 0 FullAutoFire, 1 Bow, 2 ChargeAndAutoRelease, 3 ChargeAndRelease, 4 TapChargeAndRelease, 5 ChargeAndLooping, 6 Passive, 7-12 Burst1-Burst6, 13 MeleeWeaponSlot, 14 PlasmaPistol. This agrees with our bench: 7 = one round per pull, 9 = three rounds per pull. |
| 21 | maxAccuracy | 100 | 100 | **The accuracy ceiling.** `$ALCD` token 2 (live accuracy) starts each life here and never rises above it. Proven by one-field flip: t21=0 read 0 on `$ALCD` token 2 at arm time, before a shot was fired. The ceiling applies on every gun tested (2026-09-17: a gun armed at 50/50 landed 12 of 32 shots, about 38%). |
| 22 | singleShotAccuracy | 100 | 100 | **The accuracy floor.** Under sustained fire `$ALCD` token 2 walks down from the t21 ceiling toward this value and holds there; it does not go lower. Proven by one-field flip: t22=50 walked down and held at exactly 50, t22=0 walked to 0. ⚠️ **Measured on one gun.** Two other guns on the same firmware and frame took one or two steps and then held (2026-09-17, F230), so a floor below the ceiling does much less on them. Stock ships t21 = t22 = 100 on every weapon, which makes the ceiling equal the floor and disables the model. |
| 23 | burstWeaponTime (ms) | n/a | n/a | Burst cycle: 275 Burst Rifle, 250 Force Rifle, empty on everything else. |
| 24 | overheat (heat per shot) | 0 | 14 | SMG 5, Energy Rifle 6, Charge Rifle 14, Plasma Sniper 30. **Inert unless t38 is set.** |
| 25 | MuzzleFlash | n/a | n/a | Battle Company's own sheet names this field `MuzzleFlash`. Untested on our bench. |
| 26 | Volume | n/a | n/a | Battle Company's own sheet names this field `Volume`; it is the Suppressor's second populated field, alongside t25. Untested on our bench. |
| 27 | primaryFire_SoundName | R01 | E03 | Fire sound id. **A sound, not an identity**. Rocket Launcher and Rail Gun both fire `C03` with different stat lines. |
| 28 | extra action sound A | n/a | C15 | Engage sound. `C…` ids = charge (rail gun C08, laser cannon C11, charge rifle C15); `D…` ids = extra reload parts on five-part reloads (sniper/AMR/force rifle D20, ion sniper D32). |
| 29 | extra action sound B | n/a | C17 | Release sound. Present exactly when the weapon has a distinct release event (charge rifle C17, bolt weapons D19/D31); absent on auto-firing chargers. |
| 30 | secondary_Mix_SoundName | n/a | n/a | |
| 31 | reloadPart1_SoundName | D04 | D30 | |
| 32 | reloadPart2_SoundName | D03 | D29 | |
| 33 | reloadPart3_SoundName | D02 | D37 | |
| 34 | noAmmo_SoundName | D18 | A73 | |
| 35 | weaponFeatureA | n/a | C19 | **Overheat sound.** The captured ids are SMG `D11`, Charge Rifle `C19`, Plasma Sniper and Energy Rifle `D122`, and they are not all the same cue: heard on a real lockout (2026-09-17), `D11` is the overheat sound, `C19` is the charge weapon's early-release cue and `D122` sounds like a reload. Open BRX gives the Energy Rifle `D11`. Battle Company's own sheet lists `weaponFeatureA`/`B` as sound ids. |
| 36 | weaponFeatureB | n/a | C04 | Second feature sound. |
| 37 | charge tap damage | n/a | 20 | **The damage a charge weapon's tap lands** (bench 2026-09-17). t37 = 30 made every tap land 30 while the full charge still landed t5. It is not a heat token: the older reading, that t37 and t38 were an enable pair, is retracted. Battle Company's own sheet names t37/t38 `HeadsetDirection`/`HeadsetRepeat`; the bench result stands and the disagreement is open. |
| 38 | **overheat enable** | n/a | 150 | **t38 alone switches the overheat system on** (bench 2026-09-17). An Energy Rifle frame with only t38 = 150 added locked out at heat 99 after about 30 full-auto rounds, from a frame that had never overheated. The heat gauge then streams in `$ALCD` token 5. |
| 39 | clipStartingAmmo | 32 | 100 | Equals t16 in every captured frame. |
| 40 | ammoReserv | 9999999 | 9999999 | **What a bare `$WEAP` leaves in the reserve**; 9999999 = unlimited. `t17 == 2 × t40` in stock frames. The gun's `$ALCD` reserve reads t40 only in the ~200 ms between `$WEAP` and `$SPAWN`; `$SPAWN` sets it to t17 (bench 2026-09-18). A mid-match `$WEAP` re-push resets the reserve to t40, so send an `$AMMO` restore with it. |
| 41 | gunRangeIndoor | 75 | 75 | APK name `gunRangeIndoor`. It reads 75 on all eighteen guns and 20 on melee. **Corrected 2026-09-17: it does nothing outdoors** (two slots differing only here, 5 vs 75, scored 27/27 vs 55/57 at every paced distance from 3 m to about 200 ft). Indoor behaviour of this token is untested; `$GSET` token 2 does not select an emitted-range profile either, and is a different command (see the `$GSET` table). |
| 42 | extraHeadsetRangeIndoor | n/a | n/a | APK name `extraHeadsetRangeIndoor`. It is 30/30/40 on the three t1=2 weapons and blank elsewhere. Its effect is untested. |

> **Two positions to get right.**
>
> 1. The metadata's field order has `rateOfFire` before `weaponSwapDelay`, but the wire has the *rate* at **t14** and the constant 850 at t15. A compiler that trusts the field order ships every weapon at 10 shots/s.
> 2. Keying weapons by their fire sound (t27) silently merges distinct weapons. The Rocket Launcher and the Rail Gun both fire `C03`.

**Stock weapon signatures** (fire sound to weapon to behaviour, as named by the operator at capture)

| t27 | Weapon | Fire mode / notes |
|---|---|---|
| `R01` | Assault Rifle | full auto |
| `R18` | Burst Rifle | 3-round burst (t20=9, t23=275) |
| `R23` | Force Rifle | 3-round burst (t23=250), five-part reload |
| `R12` | Bolt Rifle | single shot; carries no t28/t29 |
| `G03` | SMG | full auto, overheat mechanic (t24=5) |
| `Q06` | Suppressor | full auto, quiet, no muzzle flash (t25=2, t26=50) |
| `E11` | Stinger | full auto |
| `E12` | Energy Rifle | full auto, overheat (t24=6), 300-round clip |
| `S16` | Sniper | single shot, bolt action (t28/t29 = D20/D19) |
| `S07` | AMR | single shot, same bolt pair |
| `E07` | Ion Sniper | single shot, 2-round clip (D32/D31) |
| `E17` | Plasma Sniper | single shot, overheat (t24=30) |
| `T01` | Shotgun | single shot (t20=7), t19=2, extra-headset block, magnitude 45 |
| `J15` | Energy Launcher | clip 1, `t40` 3, so `t17` 6 (the player carries `t17`) |
| `C03` | Rail Gun | charges on hold, auto-fires ~1.2 s (t20=2, t28=C08) |
| `C03` | Rocket Launcher | single shot, protocol 10, magnitude 115 |
| `C06` | Laser Cannon | must be held (t20=3, t28=C11) |
| `E03` | Charge Rifle | hold, fires on release; overheats (t20=14) |
| `M92` | Melee | gyro swing, protocol 13, magnitude 90 |

> **Overheat is a balance lever on any weapon.** Set t38 to switch the mechanism on, t24 for the heat per shot and t35 for the overheat sound, and the live heat gauge streams in `$ALCD` token 5. Measured on 2026-09-17: an Energy Rifle at t38 = 150 climbed about 3 per shot, locked the trigger out at heat 99 after about 30 rounds and read 102 while locked. **Cooling is per weapon, so measure it.** The Charge Rifle cooled about 30 per second by itself and restarted from 0 after a 4.8 s lockout. The Energy Rifle did not cool at all while locked: only the reload lever vented it, about 35 per pull, and the same held pull recharged the cell. A HUD heat bar needs no new protocol.

> **Accuracy is a live per-shot value, not a static stat.** `$ALCD` token 2 is a **hit probability**, not a threshold: a shot fired below the ceiling can still emit IR at magnitude 0, which is the miss the manual describes elsewhere. It starts each life at t21 and drops under sustained fire in five steps of one fifth of the t21-to-t22 range, until it reaches t22 and holds. The steps do not land one per shot: the two walks below reached their floor on shot 8 and shot 11. It resets to t21 on reload. A native time-based recovery races the drop, so the fire interval decides how hard the model bites: single shots about 2 s apart at t22=0 held a flat 80 and never went lower, while a held trigger reached 0 in eight rounds. The recovery rate itself is not yet measured. Two full-magazine walks on the Assault Rifle (t14=100 ms, trigger held, t21=100):
>
> - t22=100 (stock): token 2 held at 100 for all 32 rounds. No drift at all.
> - t22=0: 80, 60, 40, 40, 40, 20, 20, 0, then 0 for the rest of the magazine.
> - t22=50: 90, 90, 90, 90, 90, 80, 70, 70, 70, 60, 50, then 50 for the remaining 22 rounds.
>
> Each run fell in five steps, and each step is one fifth of that weapon's ceiling-to-floor range: steps of 20 when the floor is 0, steps of 10 when it is 50. The number of shots differed (8 and 11), so how many rounds it takes to reach the floor is not characterised from two runs. Stock ships t21 = t22 = 100 on every weapon, which is why every capture ever taken of this model showed it inert. The exact hit probability at a given accuracy value is not calibrated; do not treat it as a percentage.

### Can I build a semi-auto rifle?

Yes. Set `$WEAP` t20 to `7`: one shot per trigger pull. The Shotgun and the Rocket Launcher both
ship that way.

### What bounds a custom weapon?

The firmware's behaviour vocabulary: the DamageType and PowerType enums, ReloadType, six slots, and the 2,477 on-gun sound ids. Any *combination* with arbitrary numbers is buildable; a brand-new damage *behaviour* is not.

### Does a `$WEAP` re-push mid-game keep the ammo count?

No. It resets mag/reserve to the frame's values. Re-send `$AMMO`.

## `$GSET` and `$PSET`: game and player settings

These two frames set the on-gun rules and the player's pools, identity and voice pack.

**`$GSET,<t1>,…,<t8>,*`**, validated against the captured `$GSET,0,0,1,0,1,0,50,1,*`

| # | Field | Captured | Meaning |
|---|---|---|---|
| 1 | friendlyFire | 0 / 1 | **Firmware-enforced, both directions.** 0 blocks same-team damage *and* heals from enemies; 1 opens the gate. Replicated twice with alternating values plus control (one 2026-09-07 bench run registered team hits the other way round and is still unexplained; treat the rule as documented, not proven). |
| 2 | outdoorMode | 0 | APK field name only. **This is not the physical ALT-hold toggle and it does not change emitted range.** It gates reception on the gun receiving a shot: at 30 ft, t2=1 registered 0 hits from a full clip and worked only from inches; t2=0 registered 16 of 27 shots and then every aimed shot under the same field conditions. Open BRX pins it to 0 at both venues, including try-outs and utility paths. What t2=1 physically changes, whether it affects every sensor equally, and whether it rejects indoor reflections are untested. The physical ALT toggle separately widened aim tolerance by roughly 2x on three guns; native shots reached about 200 ft in both toggle states. See [Operating the BRX](/manual/operate). |
| 3 | gunLaserRegion | 1 | APK name only. Its effect is untested on the bench. |
| 4 | autoAmbientLight | 0 | APK field name. The user guide describes a sunlight IR-noise filter; whether this field is that control is unmapped. Not exercised on the bench. |
| 5 | gyroscope | 1 | APK field name; not exercised on the bench. |
| 6 | secondaryBluetoothWeapons | 0 | APK field name; not exercised on the bench. |
| 7 | criticalShotModifier | 50 | APK field name. **Not** score-to-win (byte-identical across captures with different win conditions). |
| 8 | gameMods | 1 | APK field name; not exercised on the bench. |

> **There is no respawn, time, lives or score token.** Three captures at respawn 5/15/30 s and different clocks produced byte-identical `$GSET` and `$PSET`, and the 8-field map from the app metadata contains none of them. Those live in the host. Stop looking.

**`$PSET,<t1>,…,*`**, sample `$PSET,6,0,45,70,70,50,,H44,JAD,V33,V3I,V3C,V3G,V3E,V37,H06,H55,H13,H21,H02,U15,W71,A10,*`

| tok | Field | Sample | Meaning |
|---|---|---|---|
| 1 | **player id** | 6 | **0-based, 0-63 (6 bits)**. The app's UI shows 1-64 and writes id−1 (app 7 gives wire 6, app 64 gives 63, an out-of-range 69 clamps to 63). Ends up in every IR shot's P field and comes back as `$HIR` token 3 on whoever you hit. |
| 2 | **team** | 0 | **The gun keeps ONE team byte, and `$TID`, `$TEAM` and this token all write it: the last one sent wins (V4_31 disassembly; confirmed at the wire level 2026-09-18).** Our earlier reading, "0/1/7 gave byte-identical behaviour, inert", was measured with `$TID` sent *after* the `$PSET`, so `$TID` was the last writer in every trial and this token's own effect never showed. A `$PSET` re-send with this token at 0 therefore silently resets the gun's team to 0 until `$TID` is sent again, which was the root cause of F206 (team games registering no hits). Send the real team in this token, or re-assert `$TID` after every `$PSET`. |
| 3 | HP | 45 | Starting/max HP. Echoed as `$LCD` token 1 after `$SPAWN`. |
| 4 | armor | 70 | Armor pool (`$LCD` token 2, `$HP` token 2). |
| 5 | shield | 70 | Shield **maximum**, not a starting value: the pool starts at 0. It fills from an IR `$SIR` grant function (or armor overflow), and over BLE from `$LIFE,0,0,<n>,*`, which adds to the pool and takes negatives (bench 2026-09-11, measured with no shield row in the `$SIR` table). Both saturate at this token. |
| 6 | **CriticalDamageBonus** | 50 | Field name from Battle Company's own sheet. Shipped as 50. The bench measured a crit as x1.5 with `$GSET` t7 at 0 (2026-09-18). The 2026-09-07 sweep of this token had no crits in it, so it could not see a crit effect. |
| 7 | **deathAlarm** | (empty) | Empty in every Callsign capture; the 2018 BC app sends `NA0` here. This is the first slot of a **seventeen**-sound voice pack that runs t7-t23, not sixteen from t8: Battle Company's own sheet names 17 fields at t7-t23. |
| 8-23 | **positional voice pack** | H44 JAD V33 V3I V3C V3G V3E V37 H06 H55 H13 H21 H02 U15 W71 A10 | The remaining sixteen ids of the voice pack. See the table below for the full seventeen-slot map. |

**The voice pack, slot by slot.** The alignment was confirmed on the bench on 2026-09-07: a voice line placed in the `hitShield` position was heard on a shield hit, and an intermediate "the slots are swapped" reading was retracted after a control showed the sound had not moved. That bench result was measured on the token positions below, which is why the table still holds: the ids on the wire are unchanged, only the field name bound to each token is corrected here, from Battle Company's own sheet, which names **seventeen** fields for **seventeen** wire slots (t7-t23), not sixteen from t8.

| Token | Field name | Captured id |
|---:|---|---|
| 7 | deathAlarm | (empty) |
| 8 | stealthDeathScream | `H44` |
| 9 | musicMixOnDeath | `JAD` |
| 10 | deathScream | `V33` |
| 11 | battleRespawnCry | `V3I` |
| 12 | meleeGrunt | `V3C` |
| 13 | shortPain | `V3G` |
| 14 | longPain | `V3E` |
| 15 | painRelief | `V37` |
| 16 | missShotHit | `H06` |
| 17 | hitHp | `H55` |
| 18 | hitArmor | `H13` |
| 19 | hitShield | `H21` |
| 20 | hitCrit | `H02` |
| 21 | emptyUnboundButtonSound | `U15` |
| 22 | ammoOrGearPickUp | `W71` |
| 23 | energyShieldLoop | `A10` |

Our own frame builder (`gameconfig._PSET_HEAD`) has always sent ids in these positions, and two of them are ear-confirmed against this corrected map: `H06` at t16 is the near-miss (bench 2026-09-09), and the hitCrit slot at t20 (bench 2026-09-11).

Three more things about this tail, all measured the same day:

- **An empty effect field is not silence. It falls through outward to the neighbouring pool's clip.** An empty `hitShield` plays the armor clip. `hitHp` is silent when empty only because it is the innermost pool, with nothing further in to fall to. This is not the same rule as an empty voice field, which really does make the gun say nothing.
- **`energyShieldLoop` is a real loop.** It runs for as long as the shield is up and survives a `$PSET` rewrite. Callsign's stock `A10` is a ticking clip, so it plays under every shield-band hit. Refuted 2026-09-25: `$PLAYX,0,*` does not stop it while the shield is up. Only the shield reaching zero stops it, and it can restart after a delay of 15 seconds or more once the shield is up again. Treat it as blocking the audio queue for the rest of the shield's life, not just until the next `$PLAYX`. Open BRX ships this field empty, so the gun plays no loop and a queued clip plays at once (F347).
- **A non-empty `$SIR` `<soundID>` overrides the `$PSET` sound** on the row that fired. The two do not layer, so per-weapon and per-pool audio compete for one hit.

> **Numbering a fleet is one token.** Give every gun a distinct `$PSET` token 1 at arm time and per-player kill attribution is BLE-native: no cable, no IR receiver. Show operators 1-based ids; write `id − 1`.

## `$SIR`: the incoming-IR effects matrix

What an IR hit does to a gun is decided by the victim's table, not by the shooter's weapon. This section shows how to write that table.

> **The key idea.** An incoming IR word carries a 4-bit protocol (B) and a 2-bit subtype (U). The victim looks up the `$SIR` row with that `<protocol, subtype>` key; the row's **function** decides what the word's 8-bit magnitude is applied to: damage, heal, armor, shield, or a status. **No matching row means the hit is silently ignored.** The same is true of a **wrongly-teamed** shot: damage applies only from an enemy team and support only from your own, and a rejected frame emits **no `$HIR` at all**. It never reaches BLE. 16 × 4 = 64 addressable cells, all writable per game over BLE.

| Item | Value |
|---|---|
| Format | `$SIR,<irProtocol>,<subtype>,<soundID>,<function>,<p5>,<p6>,<p7>,<p8>,*` |
| `<soundID>` | Plays **on the victim** when the row fires (`VA16` "armor suit", `VA8C` "shields online", `H29` stim-pack). |
| `<p5>` to `<p8>` | Do **not** scale damage (`0,0,1`, `0,50,1`, `0,100,2,60`, `0,200,2,60`, `50,100,2,60` all landed exactly the magnitude). |
| Max distinct IR recognitions per game | 14 (community figure). |

Battle Company's own sheet names p5-p8: p5 `Modifier`, p6 `RangeOutdoor`, p7 `Random`, p8 `RangeIndoor`. Our own five-shape probe found p5-p8 do not scale damage.

```text
# The stock 10-row table the official app sends (Team Arena):
$SIR,0,0,,1,0,0,1,,*        standard weapons (AR, SMG, snipers, shotgun…): plain damage
$SIR,0,1,,36,0,0,1,,*       Force Rifle / Sniper Rifle: fn 36 (floor of magnitude ×1.25)
$SIR,0,3,,37,0,0,1,,*       AMR / Bolt Rifle / Burst Rifle: fn 37 (magnitude ×2)
$SIR,1,0,H29,10,0,0,1,,*    respawn + add HP
$SIR,2,1,VA8C,11,0,0,1,,*   add shields
$SIR,3,0,VA16,13,0,0,1,,*   add armor
$SIR,6,0,H02,1,0,90,1,40,*  Rail Gun
$SIR,8,0,,38,0,0,1,,*       Charge Rifle (fn 38 HALVES the magnitude, F225 -- MC re-keys this cell to fn 1)
$SIR,9,3,,24,10,0,,,*       Energy Launcher (fn 24: a phantom hit generator, see the function map)
$SIR,10,0,X13,1,0,100,2,60,* Rocket Launcher
$SIR,11,0,VA2,28,0,0,1,,*   Tear gas
$SIR,13,0,H50,… / 13,1,H57 / 13,3,H49   Energy Blade / Rifle Bash / War Hammer (melee)
```

**Function map.** Measured at magnitude 20, baseline HP 45 / armor 70 / shield 0, **at the gun-body sensor (`$HIR` tok1 = 4) from about 40 cm**. Protocol independence is measured for 10 of the 41 functions (fn 1, 3, 8, 23, 24, 25, 26, 27, 28, 35). Those ran on the enemy team at subtype 0 only, across protocols 0, 5, 7, 9 and 10. That is 50 cells, none of which varied. Applying the result to the grant and ally functions is an extrapolation, not a measurement. The two multiplier functions, 36 and 37, were measured again on 2026-09-02 across magnitudes 20, 40, 9 and 7 and across 8 different `$SIR` row-tail shapes, 16 trials, each with an fn 1 control that had to read the magnitude exactly. The row tail does not change the multiplier.

**The headset-dome question is now answered (bench 2026-09-11).** Fn 36 and 37 only scale on the headset dome (sensor 0 measured; sensors 1-3 untested): the gun body applies the raw magnitude for every function, fn 36/37 included. The headset scale is a function of the `$GSET` critical shot modifier (t7): fn 36 lands `floor(magnitude times (1 plus t7/200))`, fn 37 lands `floor(magnitude times (1 plus 2 times t7/100))`. At t7 equal 50, that is the x1.25 and x2 read on 2026-09-02, so both earlier readings were correct at once; the 2026-08-27 run that read x1.0 was pinned to the gun body the whole time. A t7 equal 0 closing control read fn 37 back to a plain x1 on the headset, confirming t7 as the driver.

| Class | Function ids | Measured behaviour | Polarity |
|---|---|---|---|
| Standard damage | 1, **3**, 4, 5, 7, 29, 30, 33 | −20 per hit, drains shields, then armor, then HP | enemy only |
| **Armor-piercing** | 2, 6 (+17, 21 enemy-side) | HP 45 to 25 to 5 with armor **and shields** untouched | enemy only |
| **×1.25 damage on the headset, ×1 on the gun body** | 36 | Gun body: always ×1 (magnitude 20 lands as 20). Headset at the captured t7=50: magnitude 20 lands as **25**, 40 as **50**, 9 as **11**, 7 as **8**. The result is the **floor**: 7 × 1.25 = 8.75 lands as 8, not 9 | enemy only |
| **×2 damage on the headset, ×1 on the gun body** | 37 | Gun body: always ×1 (magnitude 20 lands as 20). Headset at the captured t7=50: magnitude 20 lands as **40**, 40 as **80**, 9 as **18**, 7 as **14** | enemy only |
| **×0.5 damage** | 38 | Bench 2026-09-17 (F225), headset front dome, gun sensor covered: a Charge Rifle charge of magnitude 100 landed **50**, a tap of magnitude 20 landed **10**, at `$GSET` t7 0 and 50 alike. Not one of the 10 protocol-independence-tested functions and not re-checked on the gun-body sensor, so its earlier place in the Standard damage row above was an assumption, not a measurement; MC no longer keys any weapon to it (`_SIR_TABLE`'s `<8,0>` cell moved to fn 1) | enemy only, headset-measured |
| Add HP, overflow to armor | 9, 12, 16, 19 | 15 to 35 to 45, then +armor | ally only (16/19 also damage enemies) |
| Add HP, clamp | 10, 17 | 15 to 35 to 45, no overflow | ally only (17 also AP-damages enemies) |
| Add HP, overflow to shield | 14, 21 | 15 to 35 to 45, then +shield | ally only |
| Add armor | 13, 15, 20, 22 | 0 to 20 to 40; overflow spills to shields | ally only (20 also strips enemy armor) |
| Add shield | 11, 18 | 0 to 20 to 40 | ally only |
| **`$ALCD` token-2 drop** | 23 | Registers a hit, no pool change; `$ALCD` token 2 drops 100 to 0 and recovers over about 6-8 s while the gun keeps firing. The state clears on `$SPAWN,,*`. | enemy |
| Registers, no pool change | enemy 8, 28, 35 · ally 31, 32, 34 | `$HIR` fires and no pool changes at the cell itself. **fn 28 registers with no sound, no headset flash and no vibration** (bench 2026-09-10), so a host can read an IR event the player never perceives. `fn 8` is silent but still flashes and vibrates. The enemy functions are identical on protocols 0/5/7/9/10, `fn 28` on protocol 5 included. The ally functions are not protocol tested. **The reading is scoped, not general.** The victim is at full health for these trials (HP 45, armour 70), and a heal or armour grant into a full pool is clamped, so it reads as no change: `fn 10` is respawn plus add HP and belongs in that class, not this one. The enemy functions here moved no pool with 150 shield available, so for them the reading is real. `fn 3` drains shield exactly as plain damage does and is classed as damage. | n/a |
| **Phantom hit generator** | 24, 25, 26, 27 | **No damage, then an endless fake hit** (bench 2026-09-18, two guns, single hand-aimed shots). The shot registers on the struck sensor with the pools unchanged. From about 5 s later the victim's gun raises a `$HIR` on sensor 0 every 5.07 s, with the original magnitude, shooter id and team, and the pools still unchanged, until the next `$SPAWN` (13 replays over 61 s; the shooting gun was idle). Each hit starts its own timer. To the player it is the same as being shot: the ticking clip, the hit sound, vibration and a headset flash. fn 25, 26 and 27 behave the same; fn 28 and 35 do not. **Never ship fn 24-27 on a cell a weapon can reach.** | enemy, as measured |
| No registration | 0, 39-45 | n/a. 0/39/40 measured 2026-08-27; 41-45 untested. | n/a |

> **Support functions are team-gated in firmware.** With `$GSET` friendlyFire = 0, heals/armor/shield grants register **only from a same-team source**, and damage registers only from another team. Set friendlyFire = 1 and everything lands from anyone. A medic gun enforces "allies only" with zero host logic (one 2026-09-07 bench run registered team hits the other way round and is still unexplained; treat the rule as documented, not proven).

- **applied (gun body) = magnitude.** **applied (headset dome; sensor 0 measured, sensors 1-3 untested) = magnitude × fn multiplier**, and only fn 36/37 carry one: fn 36 is `1 + $GSET t7/200`, fn 37 is `1 + 2 × $GSET t7/100`. The `$HIR` crit bit (token 6) is a separate field that read 0 on every headset hit measured; it is not what drives this. The critical shot modifier (t7) is a **per-game tunable**: t7=0 disables the fn 36/37 scaling on the headset, t7=100 triples fn 37 (×3) and lifts fn 36 to ×1.5. The Callsign capture carries t7=50, which is why fn 36/37 read as a fixed ×1.25/×2. Open BRX compiles t7=0, so both land ×1 on the headset as well: four of the five hit sensors are on the headset and BRX players aim there.
- **Drain order: shields, then armor, then HP.** Armor absorbs 1:1 with no per-hit cap; overflow spills into HP (a sniper's 80 split exactly 70/10).
- **Heals clamp** at the pool max. Magnitude 200 is a fill, not a stack.
- **No function is a damage-over-time.** fn 24-27 repeat a hit that moves no pool (the row above); no function drains a pool over time.
- **Dead guns accept no IR in app mode, with one exception: fn 34 registers a `$HIR` on a dead gun and moves nothing** (bench 2026-09-18). A native game's Respawn station beacon does revive an armed dead gun (see *Native emissions captured off the air* below).
- **A magnitude-0 word is a miss, and it is invisible over Bluetooth.** The victim's gun vibrates and plays its `$PSET` miss sound, but emits **no `$HIR` and no `$HP` at all** (bench 2026-09-09, against a same-aim magnitude-9 control that produced both). A miss reaches the player and reaches the software not at all, so nothing on either phone can react to one.

### Is there a stun?

Yes. `$STUN,<ms>,*` is a native, silent stun on v4.32 (bench 2026-09-18): it blocks the trigger for the given time, then the gun fires normally again. The gun plays no sound of its own, so the host must play the cue. See the `$STUN` row above. Over IR there is no stun function: `$SIR` fn 23 is the only function that visibly changes anything without touching a pool, and it leaves the gun firing. The Callsign app's own abilities are a separate matter: it ships stun content of its own, such as the Sentinel's EMP blast and the Concussion Grenade, driven by app-side logic this page does not cover.

### Can I read a native game's `$SIR` table?

No. The gun never reports it. Capturing an ability's IR word tells you its protocol, not what a native victim binds to it.

### Which protocols are free?

Stock uses 0, 8, 10, 11, 13 and 15 (grenade beacon); the app's table also ships rows on 1, 2, 3, 6, 9. Jay's sample arm sequence uses protocol 4 too (`$SIR,4,0,,1,0,0,1,,*`, a "captured flag pole" row), so protocol 4 is not free either. Truly unused: 5, 7, 12. Every cell is still re-definable per game, since you push the table.

## Events: what the gun tells you

Hits, health, HUD echoes, buttons and telemetry, plus the proof that the gun keeps no game state.

**`$HIR,<sensor>,<irProto>,<shooterId>,<shooterTeam>,<magnitude>,<crit>,<subtype>,*`**

| tok | Field | Values | Notes |
|---|---|---|---|
| 1 | sensor that caught the IR | 0 headset **front** dome · 1 headset **back** dome · 4 gun body | Isolated with every other sensor covered. Trust for directional logic **only at field distance**. Point-blank floods every receiver, and the first to decode reports. At 2 m the gun-body sensor still caught every headset shot (bench 2026-09-17), so cover the gun sensor for any sensor reading at the bench. |
| 2 | shooter's IR protocol | 0 standard · 10 rocket · 13 melee … | = the shooter's `$WEAP` t3 / IR word B field. |
| 3 | **shooter player id** | 0-63 | = the shooter's `$PSET` token 1. 32/32 hits both directions on two guns with distinct ids. |
| 4 | **shooter team** | 0-3 | = the shooter's effective `$TID & 3`. |
| 5 | raw magnitude | e.g. 9, 45, 80, 115 | The IR word's D field (= shooter's t5). **Not the applied damage** where a multiplier row or crit is in play. Derive damage from the `$HP` delta. On a killing blow it can report the victim's remaining pool instead (overkill clamp). |
| 6 | crit flag | 0/1 | Echoes the IR word's C bit. 0 on every stock weapon. |
| 7 | subtype (alias: `power` / `PowerLevel`) | 0-3 | Echoes the IR word's U field (sniper = 1). The 2018 app and Battle Company's own sheet name this field `power`/`PowerLevel`; either name, it is the same U field. |

> **Not every `$HIR` is damage.** Pickups, heals and status effects arrive on the same message type. The protocol/subtype tells you which row fired.

**The other events**

| Message | Decode |
|---|---|
| `$HP,<hp>,<armor>,<shield>,*` | Pools after the hit; same millisecond as its `$HIR`. `$HP,0,0,0` = death. Example run at 9/hit: armor 70 to 61 down to 0, then HP 45 to 43 to 34 down to 0. A `$LIFE` write does self-emit `$HP`, but only while it is non lethal: a `$LIFE` that empties a pool sends `$LCD,0,0,0,0,<mag>,<reserve>,*` instead, and no `$HP` at all. |
| `$LCD,<hp>,<armor>,<shield>,<weaponSlot>,<mag>,<reserve>,*` | Health/armor HUD echo on `$START`/`$SPAWN`/death. Token 3 is the current shield. Token 4 is the slot of the last shot, not the selected slot. Bench 2026-10-17: after shots on slot 0 and an ALT with no shot, `$QUERY,*` still gave slot 0 (`$LCD,45,70,0,0,30,188`). After a shot on slot 1 it gave slot 1. |
| `$ALCD,<mag>,<t2>,<slot>,<reserve>,<heat>,*` | Ammo/weapon HUD: one frame per round fired *and* per round reloaded; slot changes on alt-fire cycle (0 to 1 and back); melee (slot 4) appears as an isolated frame. Token 2 is live accuracy (see the `$WEAP` t21/t22 section). Heat is a raw level that goes above 100 at lockout, and only on a weapon whose `$WEAP` t38 switches overheat on. |
| `$BUT,<id>,<state>,*` | 0 trigger · 1 alt-fire · 2 reload handle · 3 select · 4 left · 5 right · 8 the gyro melee swing (when mapped); 1 press / 0 release. In phone mode pre-game the trigger reports but does not fire. |
| `$VOLTS,<pack_mV>,<cell_mV>,<n3>,<n4>,*` | Battery every ~30 s in app mode. **Only reliably returned at good RSSI**. Weak-signal guns in a fleet sweep returned none. |
| `$DISCONNECT,*` | The gun is hanging up (headset switched off, or the app closing). |

> **The gun keeps no game state.** Three separate proofs:
>
> 1. Three captures at respawn 5, 15 and 30 s produce byte-identical config. Nothing on the wire encodes a respawn time or a clock.
> 2. The complete end-of-game tail is `$VOL`, `$HLED`, `$STOP`, `$CLEAR`, `$PLAY`. **The app never asks the gun for a score.**
> 3. Reconnecting after out-of-range play yields zero frames, and a bare `$UP,*` gets no reply.
>
> The phone tallies `$HIR`/`$HP` live, and it is the only place the score ever existed. Anything needing respawn, a clock or scoring needs a host in range for the whole match.

**Per-player attribution and native kill feedback over BLE: the recipe**

1. At arm time give every gun a distinct `$PSET` token 1 (0-63) and a `$TID`.
2. On the victim, store `<shooterId, shooterTeam>` from each `$HIR`; when `$HP,0,0,0` arrives, the stored shooter gets the kill.
3. Send the **shooter's** gun `$SFLASH,*` (green-sight kill confirm) and `$PLAY,,4,6,V3A,,,,*` ("kill" on the announcer slot). The official app does exactly this, three kills giving three pairs.
4. Score lines go to every gun's announcer slot from its own host: on a lead change, `$PLAY,,4,6,VB17,,,,*` ("takes the lead"). Nothing propagates gun-to-gun; there is no nRF score channel to discover.
5. Game end: `$PLAY,VSF,4,6,JAY,,,,*` on the winner's guns (victory sting plus "victory").

### Why does a capture show shooter id 0,0?

Every gun on that capture sat on the default id. The field is always present; `$PSET` token 1 is what makes it vary.

### Why is `$SFLASH` in the victim's capture "never near a hit"?

Because a kill you *score* is invisible in your own `$HIR`/`$HP` stream. Correlate it with `$BUT` trigger bursts.

### Can a dead gun fire?

No: `$BUT,0,1/0` with no `$ALCD` decrement.

## The IR word: what a shot carries through the air

A 25-bit pulse-width-encoded word on a 38 kHz carrier, decoded from LaserTagMods' base-station source and verified on our own receiver and emitter. Read this section if you are building your own IR sender or receiver.

> **Credit.** The layout was decoded from **LaserTagMods' NRFL-Bases** `node1.ino` (a referee-free domination base that receives BRX shots), then bench-verified: timings, bit count, field offsets and the parity rule were all confirmed by pushing known `$WEAP` frames over BLE and watching only the expected bits move. A stock tagger then **accepted a fully synthetic word** from our emitter (invented player 42 / team 2 / damage 33 landed as a real `$HIR` and killed the player).

**Physical layer**

| Item | Value |
|---|---|
| Carrier | **38 kHz**, **980 nm** (the wavelength on the gun's Class 1 IEC 60825-1 laser label; we have not measured it ourselves). Many hobby IR parts are centred on 940 nm, so pick 980 nm-capable receivers. A standard VS1838B/TSOP demodulating receiver recovers this carrier. Laser rated 16.9 mW on the gun's USB record. |
| Sync | One ~2 ms LOW pulse before the frame, measured **1988-1991 µs**. |
| Bits | Each bit is a LOW pulse; **long ≈ 1000 µs = 1** (measured 990-994), **short ≈ 500 µs = 0** (489-512), spaces 489-512 µs; decision threshold ~750 µs. |
| End of frame | A trailing short pulse (< 250 µs in node1's test). |

> **Sync gating.** A `> 1500 µs` sync gate is not BRX-unique (a Sony SIRC remote's 2390 µs header passes it). Bound sync to about 1800-2200 µs and require 25 bits plus the parity rule.

**Word layout** (transmit order after sync; 25 bits)

| Field | Bits | Offset | Meaning | Bench evidence |
|---|---:|---|---|---|
| **B** | 4 | 0-3 | IR protocol / damage type = `$WEAP` t3 = `$HIR` tok2 = `$SIR` protocol key | AR read 0; rocket (t3=10) read 10; native melee read 13 |
| **P** | 6 | 4-9 | player id 0-63 = `$PSET` token 1 = `$HIR` tok3 | matched the registry |
| **T** | 2 | 10-11 | team id 0-3 = `$TID & 3` = `$HIR` tok4 | matched |
| **D** | 8 | 12-19 | magnitude = `$WEAP` t5 = `$HIR` tok5 | pushed 22, then 9, then 115; only these bits moved |
| **C** | 1 | 20 | critical flag, echoes to `$HIR` tok6; damage effect unconfirmed (read 0 on every measured headset hit; the t7 multiplier rides fn 36/37 by sensor, not this bit) | emitted crit=1 gave `$HIR,…,1,…` |
| **U** | 2 | 21-22 | `$SIR` subtype to `$HIR` tok7 | U=0/1/3 registered with rows; U=2 (no row) ignored |
| **Z** | 2 | 23-24 | parity trailer | see rule |

A genuine captured melee swing decodes as `1101000111010101101000110` = protocol 13, player 7, team 1, magnitude 90, crit 0, subtype 1.

> **Parity: what genuine frames emit vs what the gun checks.** Real BRX frames set Z by parity over bits 0-22: **odd number of ones gives `01`, even gives `10`** (4/4 captured frames). But the gun's acceptance test is only **`Z0 ≠ Z1`**: `01` and `10` both land 8/8, `00` and `11` are rejected 0/8. Compute the true parity for fidelity; use the mismatch to tell your own traffic from a real gun's.

```python
def encode_word(proto: int, player: int, team: int, magnitude: int, crit: int, subtype: int) -> str:
    """25-bit BRX IR word as a bit string (MSB of each field first), Z = genuine-frame parity."""
    bits = (f"{proto & 0xF:04b}{player & 0x3F:06b}{team & 0x3:02b}"
            f"{magnitude & 0xFF:08b}{crit & 1:01b}{subtype & 0x3:02b}")
    ones = bits.count("1")
    return bits + ("01" if ones % 2 else "10")

def decode_word(bits: str) -> dict:
    assert len(bits) == 25
    payload, z = bits[:23], bits[23:]
    return {
        "proto": int(payload[0:4], 2), "player": int(payload[4:10], 2),
        "team": int(payload[10:12], 2), "magnitude": int(payload[12:20], 2),
        "crit": int(payload[20]), "subtype": int(payload[21:23], 2),
        "accepted": z[0] != z[1],
        "parity_matches": z == ("01" if payload.count("1") % 2 else "10"),
    }
```

**Native emissions captured off the air**

| Source | Word | Note |
|---|---|---|
| Assault Rifle | proto 0, magnitude 9 | The stock AR emits 9, not the printed 24 |
| Shotgun | proto 0, magnitude 45 | |
| Sniper | proto 0, subtype 1, magnitude 80 | |
| Rocket Launcher | proto 10, magnitude 115 | |
| Melee (gyro swing, native game) | proto 13, subtype 1, magnitude 90 | Subtype 1 = Rifle Bash |
| Supremacy Sentinel death-nova (headset) | proto 10, magnitude 125, player/team = the **dying** player | Out-damages the rocket; credits kills to the corpse |
| Smart Grenade, Respawn station: boot word | proto 15, player 0, team 0, magnitude 56 | Sent once at power-up; before a game starts it arms a tagger to the station (self-respawn off) |
| Respawn station: beacon | proto 15, player 0, team = owner, magnitude 6 | Every ~2.5 s; revives a dead, armed gun of that team (4/4; wrong team 0/1). Does not arm a running game |
| Respawn station: button | proto 15, team = owner, magnitude 6, crit 1 | The beacon with the crit bit set; arms a tagger mid-game. All three were replayed from our ESP32 emitter with the grenade out of the building (2026-09-04); host-driven games ignore them |

> **Headset emission cannot be forced over BLE.** `$IRTX`, `$HFIRE`, `$MELEE` and `$BHIT` produced zero IR with a receiver control passing before and after. The headset emits only for a physical melee swing and for the Sentinel death-nova. A physical melee swing works in a compiled, host-driven game too, with the shipped `$WEAP,4` melee frame and `$SIR,13,*` rows (bench-confirmed 2026-09-24, two guns, two hits): it is not native-game-only. An emitter A/B (bench 2026-09-24, small n) confirms the swing itself leaves the shooter's headset, not the barrel: covering the barrel by hand still landed a hit, and covering the headset domes landed none in two swings.

### Do I need a victim gun to test an emitter?

No. A VS1838B on an ESP32 decodes the word, and the sync/mark timings above are the acceptance spec.

### Can a station revive a dead player by IR?

Not in a hosted game. There a dead gun ignores IR, except that fn 34 registers a `$HIR` and moves nothing, so the host can see the beam and revive the gun over BLE. The 2018 BC app's revive beam used `$SIR` fn 34 on protocol 14. In a native game, yes: the Respawn station beacon revives a dead gun that the station armed (see *Native emissions captured off the air*).

### Why do my captured frames come out as prefixes (16/17/20/21 bits)?

Your capture sketch is printing while the next frame lands. Turn the RAW dump off.

## The USB serial console: `QUERY` and `SETUP`

The micro-USB "Programing Port" is a Teensy serial console with two commands. It is not the `$` protocol and not SSH.

> The BRX has two ports: charging, and a separate micro-USB **"Programing Port"**. Plugged into a computer it enumerates as a **Teensyduino USB Serial** CDC device (Windows `COMx`, macOS `/dev/tty.usbmodem*`, Linux `/dev/ttyACM*`, VID `16C0`). Baud is ignored. This is what the community means by "PuTTY into the tagger". The command set came from LaserTagMods' headset-pairing note.

**What the port does and does not do**

| Sent | Result |
|---|---|
| `$PING,*`, `$VERSION,*`, any `$` frame | **Echoed back** (local echo is on, which is easy to mistake for a reply); with CR: `ERROR` |
| `?`, `help`, `AT`, `status`, … | `ERROR` |
| `QUERY` + CR | Dumps the device record (below). Case-insensitive. |
| `SETUP` + CR | Enters factory provisioning; prompts (EN/中文) for the **headset's** serial number |
| Hold SELECT while powering on with USB connected | Mass-storage mode exposing the on-board sound storage (the sound-pack update path). This is a different mode from the console (community) |

```text
QUERY
Gun Info
Gun Version: v4.32
Serial Number/Head PIN: <SERIAL>      <- matches the sticker on the paired headset
Gun Name: Tactix2                     <- the field $NAME writes
Headset Version: hds.59               <- reads '?' briefly after a power-cycle until the headset re-handshakes
Gun: 7.671 VOLTS
PlayerID 0                            <- device-level player id (separate from the per-game $PSET id)
FieldID1
NRFhost 1
NRFslave 1
devHost 1                             <- developer/host image, not retail
Head: 3.837 VOLTS                     <- headset battery
Head Tested:
Head BURN in test: 0
Gun BURN in test: 3hours28minutes
Grenade Pin: <PIN>
Laser: 16.9 mW
PCB-5
BTchip- 4
BT central V: devhost.03
```

> **Real-format quirks** the parser has to survive: `Gun Name` is NUL-padded, lines end `\r\r\n`, `Laser` can read `UNTESTED` instead of a number, `Grenade Pin` is a real non-zero value. `brx-mcp` ships `parse_query()` and `python -m brx_mcp usb-query [port]`, which saves a backup to `~/.brx-mcp/device-backups/`.

**`SETUP`: the provisioning/re-pair path (identity lives here)**

1. Run `QUERY` on both gun and headset-side records first; `SETUP` on the gun asks for the **headset's** serial number. That is the gun to headset pairing mechanism.
2. The "Factory Defaults" banner is a **mode header, not an action**: entering `SETUP` and power-cycling out changed nothing (field-by-field diff).
3. The community's "change tagger ID / re-pair the headset" procedure is this console (LaserTagMods' headset-pairing note); the identity fields it concerns are the ones `QUERY` prints: `PlayerID`, `FieldID`, `Serial Number/Head PIN`, `Grenade Pin`.
4. Only the first prompt (the headset serial) has been walked on our bench. Do it only on a gun you can afford to re-pair.
5. For per-game identity you do not need this: `$PSET` token 1 over BLE sets the player id each game.

> **Firmware backup is impossible; do not reflash.** Teensy's HalfKay bootloader is write-only by design, so no image can be read back. Rollback depends entirely on Battle Company supplying the original image. The official app's version gate (supports "until v2.01e") is an *upper* bound; it warns and still runs a game.

**Community fixes, from Jay's JEDGE install document (untested by us):**

- For a Bluetooth fault: send `QUERY`, then `BLUETOOTH`, then `4`, then `Disconnect`.
- For a PCB fault: send `QUERY`, then `PCB`, then `4`.

We do not record any code, PIN or unlock value here.

### Is `$QUERY,*` over BLE the same thing?

No. Over BLE it returns a `$`-framed status array with no serial, PIN or version. The device record is USB-only.

### What is the advertised BLE name vs `Gun Name`?

Two fields: the advertisement is `Tactix-XXXX` from the MAC tail; `Gun Name` is what `$NAME` writes.

## Headset, link and what survives

Which state lives where, and what a BLE drop, a headset switch-off, or a power-cycle each wipe. Read this before you write code that reconnects to a gun.

**State survival matrix**

| State | BLE drop / reconnect | Headset switched off | Power-cycle |
|---|---|---|---|
| Game config (`$GSET`/`$PSET`/`$WEAP`/`$SIR`/`$BMAP`) | (unknown). Re-send the full head after a reconnect | Gun sends `$DISCONNECT,*` and drops the link; config: (unknown) | **Wiped**. `$SPAWN` then echoes `$LCD,0,0,0,0,0,0` + `$ALCD,0,0,0,0,0` |
| Alive/dead + pools | Survives (a dead gun stays dead) | n/a | Reset |
| Ammo | Survives | n/a | Wiped |
| `$TID` team / LED colour | Survives; colour is painted at `$SPAWN`. **Any `$PSET` clears the team** (shots carry team 0) until `$TID` is sent again; `$SIR` and `$SPAWN` do not (bench 2026-09-16) | n/a | Reset |
| `$NAME` | Persists | Persists | **Persists** (only the official app rewrites it) |
| Player id (`$PSET` t1) | Survives with config | n/a | Wiped (the USB `PlayerID` is separate and persistent) |
| Score, clock, respawn timer | **Never on the gun** | n/a | n/a |
| `$SIR` fn-23 state (`$ALCD` token 2 at 0) | Persists until `$SPAWN` | n/a | Cleared |
| `$SIR` function table (the rows themselves) | **Only `$CLEAR` wipes it. `$SPAWN` and death do not** (bench 2026-09-18): a row sent once at arm still registered hits after a `$SPAWN,,*` with no re-send. | n/a | Wiped |
| `$TMP` tokens (t4 accuracy, t8 incoming damage, t9 magazine, …) | Zeroed by `$SPAWN`, so a per-life write must land AFTER `$SPAWN` (bench 2026-09-18) | n/a | Cleared |

> **Headset off means no BLE.** Switching a linked headset off makes the gun send `$DISCONNECT,*` and drop. A headset-less gun "connects", answers a quick `$PING`, then dies within seconds and echoes **nothing** to a config head. A power-cycled gun needs its headset re-linked before BLE holds. **A held link plus `$ALCD` echoes *is* the headset check.** There is no dedicated probe. The official app silently drops a headset-less gun within about 1.2 s.

**Headset behaviours** (native and autonomous: they work under any host's game head)

| Headset LED | When | Evidence |
|---|---|---|
| Slow **rainbow** blink | Disconnected / not paired. The pre-game tell for "this gun will not join" | operator, repeatable |
| Team colour (red/blue…) | **Pre-game only**; goes dark once the game starts | operator |
| Dark | During play (normal) | operator |

**Other headset facts**

| Fact | Source |
|---|---|
| The headset syncs team colour from the tagger | bench observation |
| `$VERSION` token 2 = headset firmware (`hds.59`); USB `QUERY` shows headset version and battery | captures / USB |
| Headset disconnecting **mid-game** locks the gun until it reconnects (anti-cheat); a gun booted with **no** headset shoots normally in local play | Battle Company manual V7 |
| Headset pairing can take up to 3 minutes with many BT devices nearby | manual V7 |
| Headset sensor ids: `$HIR` tok1 0 = front dome, 1 = back dome (4 = gun body) | shield-isolated bench |
| Gun to headset pairing PIN = the headset's serial, set via USB `SETUP` | LaserTagMods note + USB |
| Headset LED commands `$HLED`/`$BLINK`/`$CHASE`/`$HLOOP`/`$LED` exist. `$HLED`, `$HLOOP`, `$LED` and `$BLINK` work on our guns (`$LED,9,1,1,1,*` gives one green flash; `$BLINK,3,0,300,300,10,*` holds the headset LED green); `$CHASE` shows no effect | APK + captures + bench 2026-09-04 |

> **Screamers.** A tagger left powered all day can stop holding BLE. It still advertises, but the connection drops or hangs. The community calls this the "screamer" state. Power-rest guns between sessions; keep them charged (firmware will not re-pair below a battery threshold).

### What a gun answers in each state, and other bench facts (2026-10-16)

All bench-proven on v4.32 (Tactix-9498 and Tactix-E20D, 2026-10-16).

- **Three states after a reset.** A gun that was just power-cycled answers nothing at all before `$START` or `$PHONE`: `$LIFE,0,0,0`, `$QUERY`, `$AMMO` and `$VERSION` all get no reply, and no `$VOLTS` arrives for over a minute. `$PHONE,*` wakes it, but the first read then answers all zeros (`$HP,0,0,0`, which looks like a death). After `$START`, `$QUERY` answers `$LCD,0,0,0,0,0,0` and an array with every field 0. After a `$CLEAR` teardown the gun keeps stock pools (`$LIFE,0,0,0` gives `$HP,45,70,0`) and `$QUERY` gives `$LCD,45,70,0,0,0,0` with every weapon field empty. Treat all-zero weapon fields as "the head is gone".
- **An unspawned but configured gun** answers `$QUERY` with `$LCD,0,0,0,4,1,0` (slot 4, not a live slot 0). The array body arrives about 8 s late and has no closing `*`, as on a dead gun. A later `$SPAWN` still lands normally.
- **The death `$LCD` reports the live slot.** A kill while the gun was on slot 1 gave `$LCD,0,0,0,1,5,24` (slot 1, magazine 5, reserve 24). The `$QUERY` reply's `$LCD` also carries the live shield in token 3.
- **`$GLED` has no blink.** Token 6 values 1, 2, 3 and 400 all gave solid LEDs. Blinking is done by the host.
- **ALT swaps during a stun disarm, and melee still works.** With both slots disarmed (`$AMMO` zeroed), a press of ALT still swaps the active slot, and a melee swing still emits the melee word (protocol 13). A stun therefore jams firing only.
- **The stock gun sometimes ignores the first slot change** after a `$SPAWN`, a re-arm or a pickup equip, and it ignores ALT while a reload runs. A host must confirm a swap from the next `$ALCD`, not assume it.
- **Every energy weapon refills on a tap.** The Energy Rifle, Plasma Sniper and Rail Gun refill after a 50 ms lever tap exactly as after a hold, at the weapon's reload time (t19) plus 0.5 to 0.6 s.
- **Team mode drops a teammate's word inside the gun.** With distinct player ids on one team, 12 shots from a teammate produced no `$HIR` and no damage. The same shooter on another team produced `$HIR` and damage.
- **`$SIR` protocol 11 function 24 registers a hit with zero damage**, with no damage over time and no native poison sound.

## Tooling and captures

The `brx-mcp` command line and MCP server, which is how this project drives a tagger, is documented in [the `mcp/` directory of the repository](https://github.com/tony99nyr/open-brx/tree/main/mcp), in its `README.md`.

How every fact on this page was captured, and how to take the next capture, is the [capture runbook](https://github.com/tony99nyr/open-brx/blob/main/docs/capture-runbook.md).
