Summary
When thehandy-v3 sends RequestHdspXpTSet with the current hardcoded stop_on_target: true, Handy 2 Standard and Handy 2 Pro exhibit asymmetric motion: rising direction completes noticeably faster than falling direction, even when given identical position deltas over identical durations. Original Handy (v1, using thehandy / handyplug.LinearCmd) is unaffected.
Environment
- Intiface Central 4.x (bundling buttplug ~10.x)
thehandy-v3 protocol, hw_position_with_duration feature
- Devices tested: The Handy 2 Standard (
OHD_hw3_*), The Handy 2 Pro (OHD_hw4_*)
- Client: custom WebSocket app sending
HwPositionWithDuration (OutputCmd)
Steps to reproduce
-
Connect a Handy 2 Standard or Pro via Intiface
-
From the client, send the following sequence of HwPositionWithDuration commands (16 segments, mixed deltas and durations, including direction-symmetric pairs):
| fire time (s) |
position |
duration (ms) |
direction (vs prev) |
| (initial sync) |
100 |
— |
— |
| 0.00 |
60 |
1000 |
↓ Δ=40 |
| 1.00 |
80 |
800 |
↑ Δ=20 |
| 1.80 |
60 |
1000 |
↓ Δ=20 |
| 2.80 |
100 |
1100 |
↑ Δ=40 |
| 3.90 |
80 |
500 |
↓ Δ=20 |
| 4.40 |
100 |
800 |
↑ Δ=20 |
| 5.20 |
60 |
1100 |
↓ Δ=40 |
| 6.30 |
100 |
900 |
↑ Δ=40 |
| 7.20 |
80 |
500 |
↓ Δ=20 |
| 7.70 |
100 |
500 |
↑ Δ=20 |
| 8.20 |
60 |
900 |
↓ Δ=40 |
| 9.10 |
90 |
700 |
↑ Δ=30 |
| 9.80 |
65 |
900 |
↓ Δ=25 |
| 10.70 |
90 |
900 |
↑ Δ=25 |
| 11.60 |
70 |
1100 |
↓ Δ=20 |
| 12.70 |
100 |
1000 |
↑ Δ=30 |
Notable symmetric pairs for direct A/B comparison:
- Δ=40, dur=1100: rising (60→100) at t=2.80 ↔ falling (100→60) at t=5.20
- Δ=20, dur=500: falling (100→80) at t=3.90 ↔ rising (80→100) at t=7.70
-
Observe motion on the device
Expected
Symmetric pairs (same delta, same duration, opposite direction) feel perceptually equal in motion time.
Actual
Rising completes noticeably faster than falling for the symmetric pairs ("snap-back" feel on every ↑ stroke). Falling appears to honor the requested duration; rising appears to consume less than the requested duration.
Verification that client and buttplug are symmetric
Before filing, I traced the full pipeline using the test sequence above:
- Client (verified by inspecting both the in-app engine log and the WebSocket frames sent to Intiface): emits identical
position + duration for the symmetric pairs. All 16 segments matched expected values exactly.
crates/buttplug_server/src/device/protocol_impl/thehandy_v3/mod.rs: handle_hw_position_with_duration_cmd (L176–205) is stateless and has zero direction-dependent code. No delta/velocity computation; no v1/v2/v2pro variant branching (grep confirmed; hw3 and hw4 are only distinguished by display name in thehandy-v3.yml).
crates/buttplug_server/src/device/device_task.rs batching (Batch deadline reached, sending N commands log): direction-agnostic dedup by command_id.
Therefore the asymmetry must originate in the Handy 2 firmware's interpretation of the RequestHdspXpTSet { stop_on_target: true } command.
Root cause hypothesis
The hardcoded stop_on_target: true at L190:
params: Some(handy_rpc::request::Params::RequestHdspXpTSet(
handy_rpc::RequestHdspXpTSet {
stop_on_target: true, // <-- hardcoded
t: duration,
xp: position as f32 / 100f32,
},
)),
forces the firmware to plan a motion profile that decelerates to a stop at the target. The Handy 2 servo + mechanism appears to have asymmetric maximum deceleration between the lifting and lowering directions (likely mechanical: gravity, spring tension, sleeve mass). When the firmware tries to satisfy "reach xp at time t and stop there", the rising direction can stop sooner than the falling direction can, resulting in the rising stroke effectively finishing before the requested t.
Confirmation by patch
Changing L190 to stop_on_target: false, rebuilding intiface-engine (4.0.2 from current dev branch), and replaying the exact same test sequence above on a Handy 2 Standard: the rising/falling asymmetry is fully resolved — both directions now feel time-accurate. Continuous-motion use is unaffected for this client because the next segment is always sent before the device would coast off-target.
(Tested only on Handy 2 Standard so far; Handy 2 Pro was not re-tested with the patched build but uses the identical code path so the same result is expected.)
Suggested fix
Options, ordered by preference:
- Default to
false for thehandy-v3 HwPositionWithDuration. Rationale: clients sending repeated HwPositionWithDuration generally expect time-accurate motion segments stitched together, not absolute target snapping. Devices that need to hold position at end of a stream can rely on the absence of further commands.
- Expose
stop_on_target as a per-call option via a Buttplug message extension or device feature flag.
- Document the asymmetry and let downstream tools work around it.
Happy to submit a PR for (1) if that's the preferred direction.
Hardware tested
| Device |
Protocol |
Behavior with stop_on_target: true |
Behavior with stop_on_target: false |
The Handy 2 Standard (hw3) |
thehandy-v3 HDSP |
asymmetric (rising fast) ✓ reproduced |
symmetric ✓ verified |
The Handy 2 Pro (hw4) |
thehandy-v3 HDSP |
asymmetric (rising fast) ✓ reproduced |
not re-tested with patched build (same code path as Standard) |
| The Handy v1 |
thehandy (handyplug.LinearCmd) |
unaffected (different code path) ✓ verified |
n/a |
Summary
When
thehandy-v3sendsRequestHdspXpTSetwith the current hardcodedstop_on_target: true, Handy 2 Standard and Handy 2 Pro exhibit asymmetric motion: rising direction completes noticeably faster than falling direction, even when given identical position deltas over identical durations. Original Handy (v1, usingthehandy/handyplug.LinearCmd) is unaffected.Environment
thehandy-v3protocol,hw_position_with_durationfeatureOHD_hw3_*), The Handy 2 Pro (OHD_hw4_*)HwPositionWithDuration(OutputCmd)Steps to reproduce
Connect a Handy 2 Standard or Pro via Intiface
From the client, send the following sequence of
HwPositionWithDurationcommands (16 segments, mixed deltas and durations, including direction-symmetric pairs):Notable symmetric pairs for direct A/B comparison:
Observe motion on the device
Expected
Symmetric pairs (same delta, same duration, opposite direction) feel perceptually equal in motion time.
Actual
Rising completes noticeably faster than falling for the symmetric pairs ("snap-back" feel on every ↑ stroke). Falling appears to honor the requested duration; rising appears to consume less than the requested duration.
Verification that client and buttplug are symmetric
Before filing, I traced the full pipeline using the test sequence above:
position+durationfor the symmetric pairs. All 16 segments matched expected values exactly.crates/buttplug_server/src/device/protocol_impl/thehandy_v3/mod.rs:handle_hw_position_with_duration_cmd(L176–205) is stateless and has zero direction-dependent code. No delta/velocity computation; no v1/v2/v2pro variant branching (grepconfirmed;hw3andhw4are only distinguished by display name inthehandy-v3.yml).crates/buttplug_server/src/device/device_task.rsbatching (Batch deadline reached, sending N commandslog): direction-agnostic dedup bycommand_id.Therefore the asymmetry must originate in the Handy 2 firmware's interpretation of the
RequestHdspXpTSet { stop_on_target: true }command.Root cause hypothesis
The hardcoded
stop_on_target: trueat L190:forces the firmware to plan a motion profile that decelerates to a stop at the target. The Handy 2 servo + mechanism appears to have asymmetric maximum deceleration between the lifting and lowering directions (likely mechanical: gravity, spring tension, sleeve mass). When the firmware tries to satisfy "reach
xpat timetand stop there", the rising direction can stop sooner than the falling direction can, resulting in the rising stroke effectively finishing before the requestedt.Confirmation by patch
Changing L190 to
stop_on_target: false, rebuilding intiface-engine (4.0.2 from currentdevbranch), and replaying the exact same test sequence above on a Handy 2 Standard: the rising/falling asymmetry is fully resolved — both directions now feel time-accurate. Continuous-motion use is unaffected for this client because the next segment is always sent before the device would coast off-target.(Tested only on Handy 2 Standard so far; Handy 2 Pro was not re-tested with the patched build but uses the identical code path so the same result is expected.)
Suggested fix
Options, ordered by preference:
falseforthehandy-v3HwPositionWithDuration. Rationale: clients sending repeated HwPositionWithDuration generally expect time-accurate motion segments stitched together, not absolute target snapping. Devices that need to hold position at end of a stream can rely on the absence of further commands.stop_on_targetas a per-call option via a Buttplug message extension or device feature flag.Happy to submit a PR for (1) if that's the preferred direction.
Hardware tested
stop_on_target: truestop_on_target: falsehw3)thehandy-v3HDSPhw4)thehandy-v3HDSPthehandy(handyplug.LinearCmd)