Skip to content

TCode L-command magnitude drops leading zero for fractions < 0.1 (truncation produces position spikes) #909

@AtlanticTM

Description

@AtlanticTM

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:

  1. Hardcode a fixed /1000 divisor (ignoring the spec's digit-count rule), or
  2. 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

  1. Connect a TCode device (or virtual device) configured with StepCount 1000.
  2. Command linear positions that cross below 0.1 — e.g. sweep a full-depth
    sine/stroke so the bottom of the stroke reaches 0.000.10.
  3. Capture the raw outbound TCode on the wire (serial sniffer or WebSocket log).
  4. 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.

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions