Summary
When generating TCode v0.3 L0 (linear) commands, Buttplug serializes the position
magnitude as an integer that omits the leading zero whenever the fractional
value drops below 0.1. Because TCode v0.3 magnitudes are digit-count scaled
(the number of digits is the divisor / implied decimal precision), dropping a
leading zero silently changes the magnitude by a factor of 10, producing large
position spikes at the bottom of a stroke.
- A position of
0.072 is sent as L072 instead of L0072.
- A spec-compliant digit-count decoder reads
L072 as 72 / 100 = 0.72,
not 072 / 1000 = 0.072.
- Result: every position with magnitude
< 0.1 jumps to ~10× its intended value —
a sharp upward spike exactly where the deepest part of a stroke should be.
Environment
- buttplug-rs / Intiface Central (Intiface sits on top of buttplug.io; the
serialization originates in the buttplug TCode device protocol layer)
- TCode device configured with StepCount = 1000 (i.e. a fixed 3-digit
/1000
fractional format is expected by the firmware)
- Transport: Serial and WebSocket TCode (both affected — this is in the command
serializer, not the transport)
Expected behavior
For a device configured with StepCount 1000, the magnitude should be emitted as a
fixed-width 3-digit fraction with leading zeros preserved, so the digit count
always matches the configured scale:
| Intended position |
Expected token |
0.500 |
L0500 |
0.142 |
L0142 |
0.072 |
L0072 |
0.009 |
L0009 |
Actual behavior
The leading zero is stripped for any value < 0.1, narrowing the digit width and
breaking the implied scale:
| Intended position |
Actual token |
Digit-count decode |
Error |
0.500 |
L0500 |
500 / 1000 = 0.500 ✅ |
none |
0.142 |
L0142 |
142 / 1000 = 0.142 ✅ |
none |
0.072 |
L072 |
72 / 100 = 0.720 ❌ |
10× |
0.009 |
L009 |
9 / 100 = 0.090 ❌ |
10× |
Why this is a spec problem (not just our firmware)
TCode v0.3 defines the linear magnitude as a decimal fraction with an implied
0. prefix, where the number of digits sets the precision/scale. There is no
fixed field width in the wire format itself — L0500 means 0.500 and L05000
means 0.5000. A conformant parser therefore must rely on the digit count to
recover the scale.
Stripping the leading zero changes the digit count, which changes the scale. The
only ways a downstream parser can currently survive this are:
- Hardcode a fixed
/1000 divisor (ignoring the spec's digit-count rule), or
- Special-case "re-pad" magnitudes shorter than the configured StepCount width.
Both are workarounds for a serializer that should be emitting fixed-width,
zero-padded magnitudes for a fixed-StepCount device.
Reproduction
- Connect a TCode device (or virtual device) configured with StepCount 1000.
- Command linear positions that cross below
0.1 — e.g. sweep a full-depth
sine/stroke so the bottom of the stroke reaches 0.00–0.10.
- Capture the raw outbound TCode on the wire (serial sniffer or WebSocket log).
- Observe tokens like
L072, L009 (2–3 chars after L0) appearing whenever the
value is < 0.1, instead of the expected zero-padded L0072, L0009.
Captured samples (raw wire)
L0500 -> 0.500 (ok, 3 digits)
L0142 -> 0.142 (ok, 3 digits)
L0986 -> 0.986 (ok, 3 digits)
L072 -> 0.072 intended, decodes as 0.720 (leading zero stripped)
L009 -> 0.009 intended, decodes as 0.090 (leading zero stripped)
Impact
- Visible position spikes at the bottom of every stroke for any content that
reaches below 0.1 depth.
- For a physical actuator this is a sudden ~10× jump in commanded position — a
jarring lurch toward the wrong end of travel, and a potential safety concern on
hardware with real travel limits.
- Any spec-compliant digit-count TCode parser is affected; it is not specific to a
single firmware implementation.
Suggested fix
When serializing a magnitude for a device with a known/configured StepCount (or any
fixed fractional width), zero-pad the integer to the full field width so the
digit count always reflects the intended scale:
width = number_of_digits(StepCount - 1) // e.g. StepCount 1000 -> width 3
token = "L0" + zero_pad(round(position * StepCount), width)
So 0.072 at StepCount 1000 → round(72) → "072" → L0072 (not L072).
This keeps the wire format self-describing under the TCode v0.3 digit-count rule and
removes the need for every downstream parser to special-case short magnitudes.
Workaround (firmware side)
For anyone hitting this today: if your device is configured for a fixed StepCount,
divide by the StepCount (/1000) unconditionally instead of by 10^digits. That
recovers the correct value (72/1000 = 0.072) regardless of whether the leading
zero survived. This is a workaround, not a fix — the serializer should emit
zero-padded, fixed-width magnitudes.
Summary
When generating TCode v0.3
L0(linear) commands, Buttplug serializes the positionmagnitude as an integer that omits the leading zero whenever the fractional
value drops below
0.1. Because TCode v0.3 magnitudes are digit-count scaled(the number of digits is the divisor / implied decimal precision), dropping a
leading zero silently changes the magnitude by a factor of 10, producing large
position spikes at the bottom of a stroke.
0.072is sent asL072instead ofL0072.L072as72 / 100 = 0.72,not
072 / 1000 = 0.072.< 0.1jumps to ~10× its intended value —a sharp upward spike exactly where the deepest part of a stroke should be.
Environment
serialization originates in the buttplug TCode device protocol layer)
/1000fractional format is expected by the firmware)
serializer, not the transport)
Expected behavior
For a device configured with StepCount 1000, the magnitude should be emitted as a
fixed-width 3-digit fraction with leading zeros preserved, so the digit count
always matches the configured scale:
0.500L05000.142L01420.072L00720.009L0009Actual behavior
The leading zero is stripped for any value
< 0.1, narrowing the digit width andbreaking the implied scale:
0.500L0500500 / 1000 = 0.500✅0.142L0142142 / 1000 = 0.142✅0.072L07272 / 100 = 0.720❌0.009L0099 / 100 = 0.090❌Why this is a spec problem (not just our firmware)
TCode v0.3 defines the linear magnitude as a decimal fraction with an implied
0.prefix, where the number of digits sets the precision/scale. There is nofixed field width in the wire format itself —
L0500means0.500andL05000means
0.5000. A conformant parser therefore must rely on the digit count torecover the scale.
Stripping the leading zero changes the digit count, which changes the scale. The
only ways a downstream parser can currently survive this are:
/1000divisor (ignoring the spec's digit-count rule), orBoth are workarounds for a serializer that should be emitting fixed-width,
zero-padded magnitudes for a fixed-StepCount device.
Reproduction
0.1— e.g. sweep a full-depthsine/stroke so the bottom of the stroke reaches
0.00–0.10.L072,L009(2–3 chars afterL0) appearing whenever thevalue is
< 0.1, instead of the expected zero-paddedL0072,L0009.Captured samples (raw wire)
Impact
reaches below 0.1 depth.
jarring lurch toward the wrong end of travel, and a potential safety concern on
hardware with real travel limits.
single firmware implementation.
Suggested fix
When serializing a magnitude for a device with a known/configured StepCount (or any
fixed fractional width), zero-pad the integer to the full field width so the
digit count always reflects the intended scale:
So
0.072at StepCount 1000 →round(72)→"072"→L0072(notL072).This keeps the wire format self-describing under the TCode v0.3 digit-count rule and
removes the need for every downstream parser to special-case short magnitudes.
Workaround (firmware side)
For anyone hitting this today: if your device is configured for a fixed StepCount,
divide by the StepCount (
/1000) unconditionally instead of by10^digits. Thatrecovers the correct value (
72/1000 = 0.072) regardless of whether the leadingzero survived. This is a workaround, not a fix — the serializer should emit
zero-padded, fixed-width magnitudes.