= 0 && idx < selected.LastAxisPositions.Length)
- ? selected.LastAxisPositions[idx] : selected.LastHidPosition;
double preCurve = (idx >= 0 && idx < selected.LastAxisRawPercentPreCurve.Length)
? selected.LastAxisRawPercentPreCurve[idx] : selected.LastRawPercentPreCurve;
- int pct = (int)Math.Round(shaped * 100);
- if (pct < 0) pct = 0; if (pct > 100) pct = 100;
-
- // Input Curve sees the pre-shaping value (what it actually
- // receives); the output curve sees the post-Pedal-Feel value
- // (what's effectively sent onward).
- MBoosterInputCurveEditor.LiveX = preCurve;
- MBoosterCurveEditor.LiveX = pct;
+ // TRUE raw reading — % of Max Force's own hardware ceiling, i.e.
+ // the physical force the user is actually applying to the pedal,
+ // captured in OnHidAxisUpdate BEFORE the host-side Max Threshold
+ // rescale. This is the Pedal Feel curve's real input domain
+ // (Deadzone-Max Force span) and what "Input Force" should show —
+ // preCurve above is post-Threshold-rescale now (Sim Input
+ // Mapping's own, different domain), not this pedal's raw input.
+ double rawInput = (idx >= 0 && idx < selected.LastAxisRawPercentPreThreshold.Length)
+ ? selected.LastAxisRawPercentPreThreshold[idx] : 0.0;
+
+ // Pedal Feel's curve is now a REAL hardware effect (see
+ // MozaMBoosterRegistry.ComputeFeelCurve) — the device reshapes
+ // the raw force before this HID read ever sees it, so AZOM has
+ // no live TRUE "input to that curve" value to plot (that would
+ // need the raw pre-reshape force, which AZOM never receives).
+ // Best available proxy: rawInput — positionally correct against
+ // the curve's own Deadzone-Max Force X axis (unlike preCurve,
+ // which is now Threshold-rescaled and belongs to a different
+ // domain), even though it can't reflect the device's own
+ // internal reshaping.
+ // The Sim Input Mapping curve is the opposite: purely host-side
+ // (see EvaluateCurveArbitraryX), so its live marker uses
+ // preCurve exactly — the already-hardware-shaped, Threshold-
+ // rescaled position that's actually fed INTO this curve, not
+ // pct (which is the curve's own output).
+ MBoosterInputCurveEditor.LiveX = hidConnected ? rawInput : double.NaN;
+ MBoosterCurveEditor.LiveX = preCurve;
+
+ // Live "position % · kg force" readout above the Pedal Feel
+ // curve editor (MBoosterPedalFeelLiveLabel) — the raw force the
+ // user is applying to the pedal, independent of Max Threshold's
+ // sim-facing output scaling. kg is an estimate, not a directly-
+ // read sensor value: rawInput/100 * Max Force's own kg ceiling
+ // (the reference its own 100% represents — see OnHidAxisUpdate),
+ // falling back to 200kg if Max Force itself is unset.
+ if (hidConnected)
+ {
+ var cfg = PeekMBoosterEffectTarget();
+ double fullScaleKg = (cfg != null && cfg.MaxForceKg >= 0) ? cfg.MaxForceKg : 200.0;
+ double kg = rawInput / 100.0 * fullScaleKg;
+ MBoosterPedalFeelLiveLabel.Text = $"{Strings.Label_InputForce}: {rawInput:F0}% · {kg:F1} kg";
+ }
+ else
+ {
+ MBoosterPedalFeelLiveLabel.Text = Strings.Label_InputForce;
+ }
// Effects card pedal trace — same 30 Hz cadence as the Inputs
// tab's pedal bars above, and the same merged 0-100 values
@@ -2734,6 +2787,23 @@ private void Ab9GearShiftDebounceSlider_ValueChanged(object s, RoutedPropertyCha
private string? _mboosterSeededProfileName;
private string? _mboosterSeededIdentity;
+ // The exact MBoosterDeviceSettings INSTANCE last seeded from — not
+ // just a string identity check, because MozaPlugin
+ // .GetOrCreateMBoosterSettings can return a genuinely DIFFERENT
+ // object for the SAME identity string across two calls: it first
+ // hands back a fresh, all-defaults placeholder keyed by the raw
+ // transport identity (before the device's serial has been read
+ // back), then — once OnMBoosterSerialResolved fires, asynchronously,
+ // on the connection thread — silently swaps in the real, saved
+ // profile object under the resolved serial key. If this tab's first
+ // seed pass raced that swap, the string-only checks above never
+ // noticed the object underneath had changed, so the tab kept
+ // displaying the placeholder's all-defaults values forever instead
+ // of the actual saved profile (the values were never lost — this
+ // was a display bug, not a persistence one). Comparing the object
+ // reference catches that swap and forces a proper reseed.
+ private MBoosterDeviceSettings? _mboosterSeededSettings;
+
// Custom Effects (Experimental) — dynamic per-device list, rebuilt
// (not incrementally synced) on every seed/device-switch. See
// PopulateMBoosterCustomEffectsList.
@@ -2831,8 +2901,36 @@ private void RefreshMBoosterTab()
_mboosterEffectPedalIndex = sameDeviceRetargetAxis;
else
{
+ // First-ever selection (a brand-new SettingsControl, or
+ // the previously-selected device vanished entirely) —
+ // this used to always land on axis 0, even when axis 0
+ // isn't actually wired (a standalone unit's sole pedal
+ // commonly reports on a non-zero axis — see the
+ // ConnectedAxes-based retarget above, and
+ // MBoosterDeviceController's own ConnectedAxes doc
+ // comment). Connectivity is frequently ALREADY known at
+ // this point from the persisted cache (seeded well
+ // before the live "PD Linked" diagnostic confirms it —
+ // see MozaPlugin.LookupMBoosterKnownPedals), so defaulting
+ // to axis 0 blindly showed this device's (often
+ // long-stale/orphaned) axis-0 flat-field data — e.g. a
+ // Sim Input Mapping/Pedal Feel curve nobody's touched in
+ // ages — until a later refresh tick corrected the axis
+ // once the live diagnostic caught up. Pick the first
+ // known-connected axis instead, same as the retarget
+ // logic above; fall back to axis 0 only when
+ // connectivity isn't known yet at all.
_mboosterSelectedIdentity = devices[0].Identity;
- _mboosterEffectPedalIndex = 0;
+ var initialConnected = devices[0].ConnectedAxes;
+ int initialAxis = 0;
+ if (initialConnected != null)
+ {
+ for (int axis = 0; axis < initialConnected.Length; axis++)
+ {
+ if (initialConnected[axis]) { initialAxis = axis; break; }
+ }
+ }
+ _mboosterEffectPedalIndex = initialAxis;
}
}
@@ -2912,21 +3010,52 @@ private void RefreshMBoosterTab()
// instead of here — this 500ms pass felt sluggish for direct
// pedal feedback.
+ // Resynced on EVERY pass, not gated by the seed-once latch below —
+ // both depend on state that can resolve strictly AFTER the tab's
+ // first seed: the pedal's Role may still be sitting on a fresh,
+ // Disabled-default MBoosterDeviceSettings if this first seed raced
+ // OnMBoosterSerialResolved (which migrates the real saved settings
+ // in under the device's serial key, asynchronously, once the
+ // serial has actually been read back over the wire — see
+ // MozaPlugin.GetOrCreateMBoosterSettings/OnMBoosterSerialResolved);
+ // AxisTypes (passive-pedal detection) similarly only populates once
+ // the 0x0E diagnostic arrives. Neither call bumps _mboosterUiSeeded
+ // above, and re-selecting the identity string never changes once
+ // that race resolves, so gating these behind the seed-once latch
+ // left a pedal that's genuinely Brake (or genuinely active)
+ // permanently showing as if it weren't, from first tab-open until
+ // the user forced a reseed some other way (switching pedals/
+ // profiles). Cheap, idempotent Visibility pushes — safe every tick,
+ // same reasoning as the per-row Role/IsSelected resync above.
+ UpdateMBoosterEffectPassiveState();
+ UpdateMBoosterConfigVisibilityForRole();
+
// Re-seed when the active profile or the selected device changed
- // since the last seed — otherwise the gate below keeps the
- // previously-seeded values on screen while edits write to the
- // now-current profile/device (mBooster settings are per-profile,
- // per-device).
- var currentProfileName = _plugin?.Settings?.ProfileStore?.CurrentProfile?.Name;
+ // since the last seed, OR the settings object itself is a
+ // different instance than last time (see _mboosterSeededSettings)
+ // — otherwise the gate below keeps the previously-seeded values
+ // on screen while edits write to the now-current profile/device
+ // (mBooster settings are per-profile, per-device).
+ if (_plugin == null) return;
+ var s = _plugin.GetOrCreateMBoosterSettings(selected.Identity);
+ var currentProfileName = _plugin.Settings?.ProfileStore?.CurrentProfile?.Name;
if (!string.Equals(currentProfileName, _mboosterSeededProfileName, StringComparison.Ordinal)
- || !string.Equals(selected.Identity, _mboosterSeededIdentity, StringComparison.OrdinalIgnoreCase))
+ || !string.Equals(selected.Identity, _mboosterSeededIdentity, StringComparison.OrdinalIgnoreCase)
+ || !ReferenceEquals(s, _mboosterSeededSettings))
_mboosterUiSeeded = false;
if (_mboosterUiSeeded) return;
- // Seed slider/checkbox values from the profile entry. _plugin is
- // never null past Init (the constructor stores it); guard anyway.
- if (_plugin == null) return;
- var s = _plugin.GetOrCreateMBoosterSettings(selected.Identity);
+ // Diagnostic trail for the "curve values wrong until profile
+ // reload" bug — logs exactly what this seed pass is about to push
+ // into the curve editors, timestamped, so it can be correlated
+ // against GetOrCreateMBoosterSettings's "NEW placeholder" log and
+ // OnMBoosterSerialResolved's re-key log from the same session.
+ {
+ var fxLog = PeekMBoosterEffectTarget();
+ string Fmt(float[]? a) => a == null ? "null" : "[" + string.Join(",", a) + "]";
+ MozaLog.Info($"[AZOM\\mBooster] RefreshMBoosterTab seeding: profile='{currentProfileName}' identity='{selected.Identity}' pedalIdx={_mboosterEffectPedalIndex} "
+ + $"CurveY={Fmt(fxLog?.CurveY)} CurveX={Fmt(fxLog?.CurveX)} InputCurveY={Fmt(fxLog?.InputCurveY)} InputCurveX={Fmt(fxLog?.InputCurveX)}");
+ }
using (_suppressor.Begin())
{
// Role is seeded per-row by the device rows block above (each
@@ -2936,8 +3065,6 @@ private void RefreshMBoosterTab()
// settled on. (Test toggles are never persisted;
// SeedMBoosterEffectControls always clears them.)
SeedMBoosterEffectControls(PeekMBoosterEffectTarget());
- UpdateMBoosterEffectPassiveState();
- UpdateMBoosterConfigVisibilityForRole();
MBoosterBrakeFadeEnable.IsChecked = s.BrakeFade?.Enabled ?? false;
MBoosterBrakeFadeOnsetSlider.Value = s.BrakeFade?.BrakeFadeOnsetC ?? 550;
SetValueText(MBoosterBrakeFadeOnsetValue, MBoosterBrakeFadeOnsetSlider.Value.ToString("F0"));
@@ -2949,6 +3076,7 @@ private void RefreshMBoosterTab()
_mboosterUiSeeded = true;
_mboosterSeededProfileName = currentProfileName;
_mboosterSeededIdentity = selected.Identity;
+ _mboosterSeededSettings = s;
}
/// Click handler for a pedal row's label Button (see
@@ -3227,18 +3355,20 @@ private void SeedMBoosterConfigControls(IMBoosterPedalConfig? fx)
int max = fx?.Max ?? -1;
MBoosterMaxSlider.Value = max >= 0 ? max : 0;
SetValueText(MBoosterMaxValue, MBoosterMaxSlider.Value.ToString("F0"));
- var curve = (fx?.CurveY != null && fx.CurveY.Length == 5) ? fx.CurveY : MBoosterDefaultCurve;
+ var curve = (fx?.CurveY != null && fx.CurveY.Length == MBoosterUiConstants.SimInputMappingNodeCount) ? fx.CurveY : MBoosterOutputCurveDefault;
MBoosterY1Slider.Value = curve[0]; SetValueText(MBoosterY1Value, curve[0].ToString("F0"));
MBoosterY2Slider.Value = curve[1]; SetValueText(MBoosterY2Value, curve[1].ToString("F0"));
MBoosterY3Slider.Value = curve[2]; SetValueText(MBoosterY3Value, curve[2].ToString("F0"));
MBoosterY4Slider.Value = curve[3]; SetValueText(MBoosterY4Value, curve[3].ToString("F0"));
MBoosterY5Slider.Value = curve[4]; SetValueText(MBoosterY5Value, curve[4].ToString("F0"));
- var curveX = (fx?.CurveX != null && fx.CurveX.Length == 5) ? fx.CurveX : MBoosterDefaultCurve;
+ MBoosterY6Slider.Value = curve[5]; SetValueText(MBoosterY6Value, curve[5].ToString("F0"));
+ var curveX = (fx?.CurveX != null && fx.CurveX.Length == MBoosterUiConstants.SimInputMappingNodeCount) ? fx.CurveX : MBoosterOutputCurveDefault;
MBoosterX1Slider.Value = curveX[0]; SetValueText(MBoosterX1Value, curveX[0].ToString("F0"));
MBoosterX2Slider.Value = curveX[1]; SetValueText(MBoosterX2Value, curveX[1].ToString("F0"));
MBoosterX3Slider.Value = curveX[2]; SetValueText(MBoosterX3Value, curveX[2].ToString("F0"));
MBoosterX4Slider.Value = curveX[3]; SetValueText(MBoosterX4Value, curveX[3].ToString("F0"));
MBoosterX5Slider.Value = curveX[4]; SetValueText(MBoosterX5Value, curveX[4].ToString("F0"));
+ MBoosterX6Slider.Value = curveX[5]; SetValueText(MBoosterX6Value, curveX[5].ToString("F0"));
// Sim Input Mapping
float ratio = fx?.SensorOutputRatioPct ?? -1;
MBoosterRatioSlider.Value = ratio >= 0 ? ratio : 0;
@@ -3247,12 +3377,22 @@ private void SeedMBoosterConfigControls(IMBoosterPedalConfig? fx)
MBoosterMaxThresholdSlider.Value = thr >= 0 ? thr : 100;
SetValueText(MBoosterMaxThresholdValue, MBoosterMaxThresholdSlider.Value.ToString("F0"));
// Pedal Feel
- var inputCurve = (fx?.InputCurveY != null && fx.InputCurveY.Length == 5) ? fx.InputCurveY : MBoosterDefaultCurve;
+ var inputCurve = (fx?.InputCurveY != null && fx.InputCurveY.Length == MBoosterUiConstants.PedalFeelNodeCount)
+ ? fx.InputCurveY : MBoosterInputCurveDefault;
MBoosterInputY1Slider.Value = inputCurve[0]; SetValueText(MBoosterInputY1Value, inputCurve[0].ToString("F0"));
MBoosterInputY2Slider.Value = inputCurve[1]; SetValueText(MBoosterInputY2Value, inputCurve[1].ToString("F0"));
MBoosterInputY3Slider.Value = inputCurve[2]; SetValueText(MBoosterInputY3Value, inputCurve[2].ToString("F0"));
MBoosterInputY4Slider.Value = inputCurve[3]; SetValueText(MBoosterInputY4Value, inputCurve[3].ToString("F0"));
MBoosterInputY5Slider.Value = inputCurve[4]; SetValueText(MBoosterInputY5Value, inputCurve[4].ToString("F0"));
+ MBoosterInputY6Slider.Value = inputCurve[5]; SetValueText(MBoosterInputY6Value, inputCurve[5].ToString("F0"));
+ var inputCurveX = (fx?.InputCurveX != null && fx.InputCurveX.Length == MBoosterUiConstants.PedalFeelNodeCount)
+ ? fx.InputCurveX : MBoosterInputCurveDefault;
+ MBoosterInputX1Slider.Value = inputCurveX[0]; SetValueText(MBoosterInputX1Value, inputCurveX[0].ToString("F0"));
+ MBoosterInputX2Slider.Value = inputCurveX[1]; SetValueText(MBoosterInputX2Value, inputCurveX[1].ToString("F0"));
+ MBoosterInputX3Slider.Value = inputCurveX[2]; SetValueText(MBoosterInputX3Value, inputCurveX[2].ToString("F0"));
+ MBoosterInputX4Slider.Value = inputCurveX[3]; SetValueText(MBoosterInputX4Value, inputCurveX[3].ToString("F0"));
+ MBoosterInputX5Slider.Value = inputCurveX[4]; SetValueText(MBoosterInputX5Value, inputCurveX[4].ToString("F0"));
+ MBoosterInputX6Slider.Value = inputCurveX[5]; SetValueText(MBoosterInputX6Value, inputCurveX[5].ToString("F0"));
float ts = fx?.TravelStartMm ?? -1;
MBoosterTravelRangeSlider.LowValue = ts >= 0 ? ts : MBoosterUiConstants.TravelMinMm;
float te = fx?.TravelEndMm ?? -1;
@@ -3263,16 +3403,24 @@ private void SeedMBoosterConfigControls(IMBoosterPedalConfig? fx)
float ee = fx?.EndstopEndStiffness ?? -1;
MBoosterEndstopEndSlider.Value = ee >= 0 ? ee : 1;
SetValueText(MBoosterEndstopEndValue, MBoosterEndstopEndSlider.Value.ToString("F0"));
- MBoosterDeadzoneSlider.Value = fx?.DeadzoneKg ?? 0;
- SetValueText(MBoosterDeadzoneValue, (fx?.DeadzoneKg ?? 0).ToString("F1"));
- ApplyMBoosterMaxForceCeiling(fx);
- MBoosterMaxForceSlider.Value = Math.Min(fx?.MaxForceKg ?? 200, MBoosterMaxForceSlider.Maximum);
+ float dz = fx?.DeadzoneKg ?? -1;
+ MBoosterDeadzoneSlider.Value = dz >= 0 ? dz : 0;
+ SetValueText(MBoosterDeadzoneValue, MBoosterDeadzoneSlider.Value.ToString("F1"));
+ float mf = fx?.MaxForceKg ?? -1;
+ MBoosterMaxForceSlider.Value = mf >= 0 ? mf : 200;
SetValueText(MBoosterMaxForceValue, MBoosterMaxForceSlider.Value.ToString("F0"));
float nf = fx?.NaturalFrictionPct ?? -1;
MBoosterNaturalFrictionSlider.Value = nf >= 0 ? nf : 0;
SetValueText(MBoosterNaturalFrictionValue, MBoosterNaturalFrictionSlider.Value.ToString("F0"));
+ bool frictionEnabled = fx?.NaturalFrictionEnabled ?? true;
+ MBoosterNaturalFrictionEnable.IsChecked = frictionEnabled;
+ MBoosterNaturalFrictionSlider.IsEnabled = frictionEnabled;
var sd = fx?.SegmentedDamping;
+ bool dampingEnabled = sd?.DampingEnabled ?? true;
+ MBoosterSegDampEnable.IsChecked = dampingEnabled;
+ MBoosterSegDampPressedPlot.IsEnabled = dampingEnabled;
+ MBoosterSegDampReleasedPlot.IsEnabled = dampingEnabled;
MBoosterSegDampPressedPlot.Divider1 = (sd?.Divider1Pressed ?? -1) >= 0 ? sd!.Divider1Pressed : MBoosterUiConstants.SegDampDivider1PressedDefaultPct;
MBoosterSegDampPressedPlot.Divider2 = (sd?.Divider2Pressed ?? -1) >= 0 ? sd!.Divider2Pressed : MBoosterUiConstants.SegDampDivider2PressedDefaultPct;
MBoosterSegDampPressedPlot.Seg1Value = (sd?.Seg1Pressed ?? -1) >= 0 ? sd!.Seg1Pressed : MBoosterUiConstants.SegDampSegDefaultPct;
@@ -3286,29 +3434,6 @@ private void SeedMBoosterConfigControls(IMBoosterPedalConfig? fx)
MBoosterSegDampReleasedPlot.Seg3Value = (sd?.Seg3Released ?? -1) >= 0 ? sd!.Seg3Released : MBoosterUiConstants.SegDampSegDefaultPct;
}
- ///
- /// Cap the Max Force slider at the force the pedal's raw HID axis
- /// actually reaches 100% at ().
- /// Above that point the device has already pegged its own output, so
- /// there is no resolution left for software to require more force —
- /// every position past it was silently inert, which with Max Threshold
- /// at 140kg left the whole top 30% of a 0-200 slider doing nothing
- /// (bundle KY3HK4QP). The XAML's static "200" end label would then be
- /// wrong, so it is rewritten to match.
- ///
- private void ApplyMBoosterMaxForceCeiling(IMBoosterPedalConfig? fx)
- {
- double ceiling = MozaMBoosterRegistry.ResolveFullScaleKg(fx, CurrentMBoosterController());
- if (ceiling <= 0) ceiling = 200;
- MBoosterMaxForceSlider.Maximum = ceiling;
- MBoosterMaxForceRangeEndLabel.Text = ceiling.ToString("F0");
- if (MBoosterMaxForceSlider.Value > ceiling)
- {
- MBoosterMaxForceSlider.Value = ceiling;
- SetValueText(MBoosterMaxForceValue, ceiling.ToString("F0"));
- }
- }
-
private MBoosterDeviceController? CurrentMBoosterController()
{
return _plugin?.MBoosterRegistry?.FindByIdentity(_mboosterSelectedIdentity ?? "");
@@ -3497,15 +3622,16 @@ private async void MBoosterAdvancedEditFormula_Click(object sender, RoutedEventA
///
/// The same gate hides the Pedal Feel controls that are real hardware
/// writes on brake-named SINGLETON cmdIds — Travel (0x84/0x85), End Stop
- /// (0xB2), Natural Friction (0xAE) and Segmented Damping (0xB7). None of
- /// them carries a per-pedal selector, so editing them from a passive
- /// pedal's page didn't configure that pedal — it silently overwrote the
- /// ACTIVE pedal's registers (bundle KY3HK4QP: the passive throttle page's
+ /// (0xB2), Deadzone/Max Force (0xAB selectors 0x07/0x0E), Natural
+ /// Friction (0xAE) and Segmented Damping (0xB7). None of them carries a
+ /// per-pedal selector, so editing them from a passive pedal's page
+ /// didn't configure that pedal — it silently overwrote the ACTIVE
+ /// pedal's registers (bundle KY3HK4QP: the passive throttle page's
/// 3.8/35.9mm travel is what the brake unit committed as Params 48/49).
/// Inferred from the wire shape rather than from a Pit House capture of a
/// passive-pedal edit — see docs/protocol/devices/mbooster.md.
- /// Host-side-only controls (Deadzone, Max Force, the input curve) and the
- /// per-role output curve stay visible for every pedal.
+ /// The input curve (host-side-only) and the per-role output curve stay
+ /// visible for every pedal.
///
private void UpdateMBoosterEffectPassiveState()
{
@@ -3517,6 +3643,7 @@ private void UpdateMBoosterEffectPassiveState()
MBoosterEffectsPassiveNote.Visibility = passive ? Visibility.Visible : Visibility.Collapsed;
var hwVisibility = passive ? Visibility.Collapsed : Visibility.Visible;
MBoosterTravelEndstopPanel.Visibility = hwVisibility;
+ MBoosterDeadzoneMaxForcePanel.Visibility = hwVisibility;
MBoosterNaturalFrictionPanel.Visibility = hwVisibility;
MBoosterSegDampCard.Visibility = hwVisibility;
}
@@ -3546,6 +3673,7 @@ private void ShowMBoosterDemoPanels()
MBoosterEffectsPassiveNote.Visibility = Visibility.Collapsed;
MBoosterBrakeOnlyPanel.Visibility = Visibility.Visible;
MBoosterTravelEndstopPanel.Visibility = Visibility.Visible;
+ MBoosterDeadzoneMaxForcePanel.Visibility = Visibility.Visible;
MBoosterNaturalFrictionPanel.Visibility = Visibility.Visible;
MBoosterSegDampCard.Visibility = Visibility.Visible;
@@ -3553,10 +3681,11 @@ private void ShowMBoosterDemoPanels()
// node data of their own — they two-way bind to the hidden data-store
// sliders (BindEditorToSliders), so without a seed those sliders sit at
// 0 and the editors draw a collapsed/garbage curve. A null target makes
- // both seeders fall back to MBoosterDefaultCurve (linear 20/40/60/80/100)
- // and sane per-control defaults; the seed writes go through _suppressor
- // so the slider ValueChanged handlers (which would no-op on the null
- // target anyway) stay quiet while the bindings still update the editors.
+ // both seeders fall back to their own Linear-preset default array
+ // (MBoosterOutputCurveDefault / MBoosterInputCurveDefault) and sane
+ // per-control defaults; the seed writes go through _suppressor so the
+ // slider ValueChanged handlers (which would no-op on the null target
+ // anyway) stay quiet while the bindings still update the editors.
if (_mboosterDemoSeeded) return;
using (_suppressor.Begin())
{
@@ -4094,59 +4223,55 @@ private void MBoosterMaxSlider_ValueChanged(object sender, RoutedPropertyChanged
_plugin.SaveSettings();
}
- private static readonly float[] MBoosterDefaultCurve = { 20, 40, 60, 80, 100 };
-
- // Output curve (5-point, mirrors the wheelbase pedal Y curves). The
- // mBooster's single physical axis always writes through the
- // "throttle" command slot regardless of assigned role — same
- // convention as Direction/Min/Max above (see ApplyMBoosterToHardware).
- //
- // Nodes are also draggable horizontally (AllowHorizontalDrag on the
- // editor) so "100% output before 100% input" works without a
- // (nonexistent) hardware X-breakpoint command: every Y or X change
- // resamples the whole (CurveX, CurveY) shape at the fixed
- // 20/40/60/80/100 breakpoints the wire protocol actually supports
- // and pushes all 5 through the existing y1-y5 commands, instead of
- // pushing just the one changed value. When CurveX is still the
- // default, resampling is the identity, so this is a no-op change in
- // behavior for anyone who never drags a node sideways.
+ // Sim Input Mapping output curve presets (6 nodes) — derived by
+ // sampling the existing 5-point PedalCurvePresets shapes at this
+ // curve's own fixed breakpoints (100/6 * k for k=1..6, matching
+ // MozaMBoosterRegistry.DefaultCurveX), not new hand-picked values.
+ // Linear is the identity (Y[k] == breakpoint[k]), so it also serves
+ // as the default X breakpoints below. (Previously 100/7 * k, which
+ // left the last breakpoint ~85.7% instead of 100% — see
+ // MozaMBoosterRegistry.DefaultCurveX's history — so Linear capped
+ // at ~86% instead of reaching 100%; MozaPlugin.FixMBoosterCurveArraysSeventhsBug
+ // migrates any profile that saved one of the old values below.)
+ private static readonly int[][] MBoosterCurvePresets =
+ {
+ new[] { 17, 33, 50, 67, 83, 100 }, // Linear
+ new[] { 6, 16, 50, 84, 94, 100 }, // S Curve
+ new[] { 5, 11, 20, 35, 61, 100 }, // Exponential
+ new[] { 39, 65, 80, 89, 95, 100 }, // Parabolic
+ };
+ private static readonly float[] MBoosterOutputCurveDefault =
+ Array.ConvertAll(MBoosterCurvePresets[0], x => (float)x);
+
+ // Sim Input Mapping output curve (6-point) — PURELY host-side, no
+ // wire command (see MozaMBoosterRegistry.EvaluateCurveArbitraryX and
+ // docs/protocol/devices/mbooster.md "Sim Input Mapping"): remaps the
+ // pedal's already-hardware-shaped raw HID position into what AZOM
+ // reports as game telemetry. Nodes are also draggable horizontally
+ // (AllowHorizontalDrag on the editor) so "100% output before 100%
+ // input" works — see MozaMBoosterRegistry.OnHidAxisUpdate, which
+ // evaluates (CurveX, CurveY) directly at the live position rather
+ // than resampling to any fixed set of breakpoints.
private void SetMBoosterCurveY(int index, int v)
{
var s = CurrentMBoosterEffectTarget();
if (s == null) return;
- if (s.CurveY == null || s.CurveY.Length != 5) s.CurveY = (float[])MBoosterDefaultCurve.Clone();
+ if (s.CurveY == null || s.CurveY.Length != MBoosterUiConstants.SimInputMappingNodeCount)
+ s.CurveY = (float[])MBoosterOutputCurveDefault.Clone();
s.CurveY[index] = v;
- PushResampledMBoosterCurve(s);
+ _plugin.SaveSettings();
}
private void SetMBoosterCurveX(int index, int v)
{
var s = CurrentMBoosterEffectTarget();
if (s == null) return;
- if (s.CurveX == null || s.CurveX.Length != 5) s.CurveX = (float[])MBoosterDefaultCurve.Clone();
- if (s.CurveY == null || s.CurveY.Length != 5) s.CurveY = (float[])MBoosterDefaultCurve.Clone();
+ if (s.CurveX == null || s.CurveX.Length != MBoosterUiConstants.SimInputMappingNodeCount)
+ s.CurveX = (float[])MBoosterOutputCurveDefault.Clone();
+ if (s.CurveY == null || s.CurveY.Length != MBoosterUiConstants.SimInputMappingNodeCount)
+ s.CurveY = (float[])MBoosterOutputCurveDefault.Clone();
s.CurveX[index] = v;
- PushResampledMBoosterCurve(s);
- }
-
- private void PushResampledMBoosterCurve(IMBoosterPedalConfig s)
- {
- if (s.CurveY == null || s.CurveY.Length != 5) return;
- var controller = CurrentMBoosterController();
- if (controller == null) return;
- string? prefix = MBoosterSelectedPedalRolePrefix();
- if (prefix == null) return;
- // Pushed to the SELECTED pedal's own role command (not always
- // throttle, and not always the host 0x12) so the curve lands on the
- // right pedal. Coalesced rather than live-per-node now: these are
- // flash-backed registers and a node drag fires per pixel — the
- // device sees the settled shape ~400ms after the drag stops.
- var resampled = global::MozaPlugin.Devices.MozaMBoosterRegistry.ResampleCurveAtFixedBreakpoints(s.CurveX, s.CurveY);
- QueueMBoosterCalibPush($"curve-{prefix}", (c, dev) =>
- {
- for (int i = 0; i < 5; i++)
- c.SendFloatWrite($"mbooster-{prefix}-y{i + 1}", resampled[i], dev);
- });
+ _plugin.SaveSettings();
}
/// The wire-command role prefix (throttle/brake/clutch) for the
@@ -4156,8 +4281,19 @@ private void PushResampledMBoosterCurve(IMBoosterPedalConfig s)
var s = CurrentMBoosterSettings();
var c = CurrentMBoosterController();
if (s == null || c == null) return null;
- int axisCount = c.AxisCount > 0 ? c.AxisCount : 1;
- var role = global::MozaPlugin.Devices.MozaMBoosterRegistry.ResolveAxisRole(s, _mboosterEffectPedalIndex, axisCount);
+ // Resolve against the CONNECTED axis count, not the raw HID axis
+ // count (bug: a chain-capable hub exposes all 3 GenericDesktop
+ // axes even with only one pedal plugged in, so using the raw
+ // count here fell into ResolveAxisRole's axis-order fallback
+ // instead of reading the single pedal's own Role — silently
+ // showing "Throttle" for axis 0 regardless of what the Role
+ // dropdown said, and hiding the brake-only Sensor Output Ratio /
+ // Max Threshold sliders even for a pedal explicitly set to
+ // Brake). Same fix as RefreshMBoosterTab already applies when
+ // building the device row list.
+ int connectedAxisCount = c.ConnectedAxisIndices().Count;
+ if (connectedAxisCount <= 0) connectedAxisCount = 1;
+ var role = global::MozaPlugin.Devices.MozaMBoosterRegistry.ResolveAxisRole(s, _mboosterEffectPedalIndex, connectedAxisCount);
return role == global::MozaPlugin.Devices.MBoosterRole.Throttle ? "throttle"
: role == global::MozaPlugin.Devices.MBoosterRole.Brake ? "brake"
: role == global::MozaPlugin.Devices.MBoosterRole.Clutch ? "clutch" : null;
@@ -4168,18 +4304,21 @@ private void PushResampledMBoosterCurve(IMBoosterPedalConfig s)
private void MBoosterY3Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterY3Value, "", v => SetMBoosterCurveY(2, v));
private void MBoosterY4Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterY4Value, "", v => SetMBoosterCurveY(3, v));
private void MBoosterY5Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterY5Value, "", v => SetMBoosterCurveY(4, v));
+ private void MBoosterY6Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterY6Value, "", v => SetMBoosterCurveY(5, v));
private void MBoosterX1Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterX1Value, "", v => SetMBoosterCurveX(0, v));
private void MBoosterX2Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterX2Value, "", v => SetMBoosterCurveX(1, v));
private void MBoosterX3Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterX3Value, "", v => SetMBoosterCurveX(2, v));
private void MBoosterX4Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterX4Value, "", v => SetMBoosterCurveX(3, v));
private void MBoosterX5Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterX5Value, "", v => SetMBoosterCurveX(4, v));
+ private void MBoosterX6Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterX6Value, "", v => SetMBoosterCurveX(5, v));
private void ApplyMBoosterCurvePreset(int[] curve)
{
var s = CurrentMBoosterEffectTarget();
if (s == null) return;
- if (s.CurveY == null || s.CurveY.Length != 5) s.CurveY = new float[5];
+ int n = MBoosterUiConstants.SimInputMappingNodeCount;
+ if (s.CurveY == null || s.CurveY.Length != n) s.CurveY = new float[n];
// Presets are a clean, standard shape — reset any dragged X
// positions back to the fixed breakpoints too.
s.CurveX = null;
@@ -4190,33 +4329,56 @@ private void ApplyMBoosterCurvePreset(int[] curve)
MBoosterY3Slider.Value = curve[2]; SetValueText(MBoosterY3Value, curve[2].ToString());
MBoosterY4Slider.Value = curve[3]; SetValueText(MBoosterY4Value, curve[3].ToString());
MBoosterY5Slider.Value = curve[4]; SetValueText(MBoosterY5Value, curve[4].ToString());
- MBoosterX1Slider.Value = MBoosterDefaultCurve[0]; SetValueText(MBoosterX1Value, MBoosterDefaultCurve[0].ToString("F0"));
- MBoosterX2Slider.Value = MBoosterDefaultCurve[1]; SetValueText(MBoosterX2Value, MBoosterDefaultCurve[1].ToString("F0"));
- MBoosterX3Slider.Value = MBoosterDefaultCurve[2]; SetValueText(MBoosterX3Value, MBoosterDefaultCurve[2].ToString("F0"));
- MBoosterX4Slider.Value = MBoosterDefaultCurve[3]; SetValueText(MBoosterX4Value, MBoosterDefaultCurve[3].ToString("F0"));
- MBoosterX5Slider.Value = MBoosterDefaultCurve[4]; SetValueText(MBoosterX5Value, MBoosterDefaultCurve[4].ToString("F0"));
+ MBoosterY6Slider.Value = curve[5]; SetValueText(MBoosterY6Value, curve[5].ToString());
+ MBoosterX1Slider.Value = MBoosterOutputCurveDefault[0]; SetValueText(MBoosterX1Value, MBoosterOutputCurveDefault[0].ToString("F0"));
+ MBoosterX2Slider.Value = MBoosterOutputCurveDefault[1]; SetValueText(MBoosterX2Value, MBoosterOutputCurveDefault[1].ToString("F0"));
+ MBoosterX3Slider.Value = MBoosterOutputCurveDefault[2]; SetValueText(MBoosterX3Value, MBoosterOutputCurveDefault[2].ToString("F0"));
+ MBoosterX4Slider.Value = MBoosterOutputCurveDefault[3]; SetValueText(MBoosterX4Value, MBoosterOutputCurveDefault[3].ToString("F0"));
+ MBoosterX5Slider.Value = MBoosterOutputCurveDefault[4]; SetValueText(MBoosterX5Value, MBoosterOutputCurveDefault[4].ToString("F0"));
+ MBoosterX6Slider.Value = MBoosterOutputCurveDefault[5]; SetValueText(MBoosterX6Value, MBoosterOutputCurveDefault[5].ToString("F0"));
}
- for (int i = 0; i < 5; i++)
+ for (int i = 0; i < n; i++)
s.CurveY[i] = curve[i];
- PushResampledMBoosterCurve(s);
_plugin.SaveSettings();
}
- private void MBoosterCurvePreset_Linear(object s, RoutedEventArgs e) => ApplyMBoosterCurvePreset(PedalCurvePresets[0]);
- private void MBoosterCurvePreset_SCurve(object s, RoutedEventArgs e) => ApplyMBoosterCurvePreset(PedalCurvePresets[1]);
- private void MBoosterCurvePreset_Exponential(object s, RoutedEventArgs e) => ApplyMBoosterCurvePreset(PedalCurvePresets[2]);
- private void MBoosterCurvePreset_Parabolic(object s, RoutedEventArgs e) => ApplyMBoosterCurvePreset(PedalCurvePresets[3]);
-
- // Pedal Feel input curve (host-side only — see MozaMBoosterRegistry.
- // EvaluateInputCurve). Reshapes the reported HID position before it
- // reaches the game or the Sim Input Mapping output curve above;
- // never writes to the device, unlike SetMBoosterCurveY.
+ private void MBoosterCurvePreset_Linear(object s, RoutedEventArgs e) => ApplyMBoosterCurvePreset(MBoosterCurvePresets[0]);
+ private void MBoosterCurvePreset_SCurve(object s, RoutedEventArgs e) => ApplyMBoosterCurvePreset(MBoosterCurvePresets[1]);
+ private void MBoosterCurvePreset_Exponential(object s, RoutedEventArgs e) => ApplyMBoosterCurvePreset(MBoosterCurvePresets[2]);
+ private void MBoosterCurvePreset_Parabolic(object s, RoutedEventArgs e) => ApplyMBoosterCurvePreset(MBoosterCurvePresets[3]);
+
+ // Pedal Feel curve presets (6 nodes) — derived by sampling the
+ // existing 5-point PedalCurvePresets shapes (Linear/S-Curve/
+ // Exponential/Parabolic) at this curve's own fixed breakpoints
+ // (8.05/19.5/44.2/72.4/90.0/97.9% — see
+ // MozaMBoosterRegistry.FeelCurveFractions), not new hand-picked
+ // values, so the presets keep the same visual identity users
+ // already know from the 5-point curve.
+ private static readonly int[][] MBoosterInputCurvePresets =
+ {
+ new[] { 8, 19, 44, 72, 90, 98 }, // Linear
+ new[] { 3, 8, 33, 88, 97, 99 }, // S Curve
+ new[] { 2, 6, 16, 42, 76, 95 }, // Exponential
+ new[] { 20, 45, 76, 91, 97, 99 }, // Parabolic
+ };
+ private static readonly float[] MBoosterInputCurveDefault =
+ Array.ConvertAll(MBoosterInputCurvePresets[0], x => (float)x);
+
+ // Pedal Feel input curve — CONFIRMED real hardware calibration (see
+ // MozaMBoosterRegistry.ComputeFeelCurve and
+ // MBoosterDeviceController.PushFeelCurveResync): its 6 nodes (0-100%
+ // of the Deadzone-Max Force span) populate mbooster-brake-
+ // feelcurve-1..6 directly. Unlike SetMBoosterCurveY (host-side,
+ // never pushes), every edit here calls PushMBoosterFeelCurve.
private void SetMBoosterInputCurveY(int index, int v)
{
var s = CurrentMBoosterEffectTarget();
if (s == null) return;
- if (s.InputCurveY == null || s.InputCurveY.Length != 5) s.InputCurveY = (float[])MBoosterDefaultCurve.Clone();
+ int n = global::MozaPlugin.Devices.MBoosterUiConstants.PedalFeelNodeCount;
+ if (s.InputCurveY == null || s.InputCurveY.Length != n)
+ s.InputCurveY = Array.ConvertAll(MBoosterInputCurvePresets[0], x => (float)x);
s.InputCurveY[index] = v;
+ PushMBoosterFeelCurve(s);
}
private void MBoosterInputY1Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterInputY1Value, "", v => SetMBoosterInputCurveY(0, v));
@@ -4224,12 +4386,41 @@ private void SetMBoosterInputCurveY(int index, int v)
private void MBoosterInputY3Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterInputY3Value, "", v => SetMBoosterInputCurveY(2, v));
private void MBoosterInputY4Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterInputY4Value, "", v => SetMBoosterInputCurveY(3, v));
private void MBoosterInputY5Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterInputY5Value, "", v => SetMBoosterInputCurveY(4, v));
+ private void MBoosterInputY6Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterInputY6Value, "", v => SetMBoosterInputCurveY(5, v));
+
+ // Pedal Feel node X position (0-100% of the Deadzone-Max Force span)
+ // — CONFIRMED real hardware calibration alongside InputCurveY (see
+ // MBoosterDeviceSettings.InputCurveX and
+ // MBoosterDeviceController.PushFeelCurveResync): every edit here
+ // pushes the same atomic (X, Y) resync SetMBoosterInputCurveY does.
+ private void SetMBoosterInputCurveX(int index, int v)
+ {
+ var s = CurrentMBoosterEffectTarget();
+ if (s == null) return;
+ int n = global::MozaPlugin.Devices.MBoosterUiConstants.PedalFeelNodeCount;
+ if (s.InputCurveX == null || s.InputCurveX.Length != n)
+ s.InputCurveX = Array.ConvertAll(MBoosterInputCurvePresets[0], x => (float)x);
+ s.InputCurveX[index] = v;
+ PushMBoosterFeelCurve(s);
+ }
+
+ private void MBoosterInputX1Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterInputX1Value, "", v => SetMBoosterInputCurveX(0, v));
+ private void MBoosterInputX2Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterInputX2Value, "", v => SetMBoosterInputCurveX(1, v));
+ private void MBoosterInputX3Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterInputX3Value, "", v => SetMBoosterInputCurveX(2, v));
+ private void MBoosterInputX4Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterInputX4Value, "", v => SetMBoosterInputCurveX(3, v));
+ private void MBoosterInputX5Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterInputX5Value, "", v => SetMBoosterInputCurveX(4, v));
+ private void MBoosterInputX6Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterInputX6Value, "", v => SetMBoosterInputCurveX(5, v));
private void ApplyMBoosterInputCurvePreset(int[] curve)
{
var s = CurrentMBoosterEffectTarget();
if (s == null) return;
- if (s.InputCurveY == null || s.InputCurveY.Length != 5) s.InputCurveY = new float[5];
+ int n = global::MozaPlugin.Devices.MBoosterUiConstants.PedalFeelNodeCount;
+ if (s.InputCurveY == null || s.InputCurveY.Length != n) s.InputCurveY = new float[n];
+ // Presets are a clean, standard shape — reset any dragged X
+ // positions back to the fixed breakpoints too (same convention
+ // as ApplyMBoosterCurvePreset for Sim Input Mapping).
+ s.InputCurveX = null;
using (_suppressor.Begin())
{
MBoosterInputY1Slider.Value = curve[0]; SetValueText(MBoosterInputY1Value, curve[0].ToString());
@@ -4237,16 +4428,24 @@ private void ApplyMBoosterInputCurvePreset(int[] curve)
MBoosterInputY3Slider.Value = curve[2]; SetValueText(MBoosterInputY3Value, curve[2].ToString());
MBoosterInputY4Slider.Value = curve[3]; SetValueText(MBoosterInputY4Value, curve[3].ToString());
MBoosterInputY5Slider.Value = curve[4]; SetValueText(MBoosterInputY5Value, curve[4].ToString());
+ MBoosterInputY6Slider.Value = curve[5]; SetValueText(MBoosterInputY6Value, curve[5].ToString());
+ MBoosterInputX1Slider.Value = MBoosterInputCurveDefault[0]; SetValueText(MBoosterInputX1Value, MBoosterInputCurveDefault[0].ToString("F0"));
+ MBoosterInputX2Slider.Value = MBoosterInputCurveDefault[1]; SetValueText(MBoosterInputX2Value, MBoosterInputCurveDefault[1].ToString("F0"));
+ MBoosterInputX3Slider.Value = MBoosterInputCurveDefault[2]; SetValueText(MBoosterInputX3Value, MBoosterInputCurveDefault[2].ToString("F0"));
+ MBoosterInputX4Slider.Value = MBoosterInputCurveDefault[3]; SetValueText(MBoosterInputX4Value, MBoosterInputCurveDefault[3].ToString("F0"));
+ MBoosterInputX5Slider.Value = MBoosterInputCurveDefault[4]; SetValueText(MBoosterInputX5Value, MBoosterInputCurveDefault[4].ToString("F0"));
+ MBoosterInputX6Slider.Value = MBoosterInputCurveDefault[5]; SetValueText(MBoosterInputX6Value, MBoosterInputCurveDefault[5].ToString("F0"));
}
- for (int i = 0; i < 5; i++)
+ for (int i = 0; i < n; i++)
s.InputCurveY[i] = curve[i];
+ PushMBoosterFeelCurve(s);
_plugin.SaveSettings();
}
- private void MBoosterInputCurvePreset_Linear(object s, RoutedEventArgs e) => ApplyMBoosterInputCurvePreset(PedalCurvePresets[0]);
- private void MBoosterInputCurvePreset_SCurve(object s, RoutedEventArgs e) => ApplyMBoosterInputCurvePreset(PedalCurvePresets[1]);
- private void MBoosterInputCurvePreset_Exponential(object s, RoutedEventArgs e) => ApplyMBoosterInputCurvePreset(PedalCurvePresets[2]);
- private void MBoosterInputCurvePreset_Parabolic(object s, RoutedEventArgs e) => ApplyMBoosterInputCurvePreset(PedalCurvePresets[3]);
+ private void MBoosterInputCurvePreset_Linear(object s, RoutedEventArgs e) => ApplyMBoosterInputCurvePreset(MBoosterInputCurvePresets[0]);
+ private void MBoosterInputCurvePreset_SCurve(object s, RoutedEventArgs e) => ApplyMBoosterInputCurvePreset(MBoosterInputCurvePresets[1]);
+ private void MBoosterInputCurvePreset_Exponential(object s, RoutedEventArgs e) => ApplyMBoosterInputCurvePreset(MBoosterInputCurvePresets[2]);
+ private void MBoosterInputCurvePreset_Parabolic(object s, RoutedEventArgs e) => ApplyMBoosterInputCurvePreset(MBoosterInputCurvePresets[3]);
// Start/End of pedal travel (mm) — a real hardware calibration
// write, reverse-engineered from two real Pit House USB captures:
@@ -4270,7 +4469,14 @@ private void ApplyMBoosterInputCurvePreset(int[] curve)
private static byte MBoosterCalibDevice(global::MozaPlugin.Devices.MBoosterDeviceController? controller, int axisIndex)
{
if (controller == null) return global::MozaPlugin.Protocol.MozaProtocol.DeviceMain;
- int axisCount = controller.AxisCount > 0 ? controller.AxisCount : 1;
+ // Resolve against the CONNECTED axis count, not the raw HID axis
+ // count — same fix as MBoosterSelectedPedalRolePrefix. Otherwise
+ // a chain-capable hub with fewer pedals wired than raw axis slots
+ // falls into ResolveAxisRole's axis-order fallback here too,
+ // routing calibration writes (Travel/Endstop/Max Threshold/
+ // Sensor Ratio) to the wrong physical MotorDeviceForRole.
+ int axisCount = controller.ConnectedAxisIndices().Count;
+ if (axisCount <= 0) axisCount = 1;
var role = global::MozaPlugin.Devices.MozaMBoosterRegistry.ResolveAxisRole(controller.CurrentSettings, axisIndex, axisCount);
int roleIdx = role == global::MozaPlugin.Devices.MBoosterRole.Throttle ? 0
: role == global::MozaPlugin.Devices.MBoosterRole.Brake ? 1
@@ -4280,24 +4486,40 @@ private static byte MBoosterCalibDevice(global::MozaPlugin.Devices.MBoosterDevic
///
/// Park a slider-driven mBooster calibration write on the selected
- /// pedal's own unit, coalesced so a drag emits one write set instead of
- /// one per tick (see MBoosterDeviceController.QueueCalibWrite). The
- /// EXPERIMENTAL curve7 resync every one of these writes needs to
- /// actually commit rides inside the same parked action, so it can never
- /// be reordered ahead of the write it is committing.
+ /// pedal's own unit, coalesced so a drag emits one write set instead
+ /// of one per tick (see MBoosterDeviceController.QueueCalibWrite).
///
private void QueueMBoosterCalibPush(string key, Action push)
{
var controller = CurrentMBoosterController();
if (controller == null) return;
byte dev = MBoosterCalibDevice(controller, _mboosterEffectPedalIndex);
- var s = CurrentMBoosterEffectTarget();
- float[]? curveX = s?.CurveX, curveY = s?.CurveY;
- controller.QueueCalibWrite($"{dev:x2}:{key}", () =>
- {
- push(controller, dev);
- controller.PushCurve7Resync(curveX, curveY, dev);
- });
+ controller.QueueCalibWrite($"{dev:x2}:{key}", () => push(controller, dev));
+ }
+
+ ///
+ /// Park a Deadzone/Max Force write (cmdId 0xAB selectors 0x07-0x0E) —
+ /// separate from because neither
+ /// capture that confirmed this family (max-force-24-75-128-166-200
+ /// .pcapng, deadzone-0-5-11-14.pcapng) included the curve7-1..6
+ /// resync that helper tacks on for every other calibration write, so
+ /// reusing it here would send frames Pit House itself never sends
+ /// for this field. Uses the CURRENT value of whichever of the two
+ /// fields didn't just change, since the device has no partial-update
+ /// form for this 8-value family — see
+ /// MBoosterDeviceController.PushFeelCurveResync.
+ ///
+ private void PushMBoosterFeelCurve(IMBoosterPedalConfig s)
+ {
+ var controller = CurrentMBoosterController();
+ if (controller == null) return;
+ byte dev = MBoosterCalibDevice(controller, _mboosterEffectPedalIndex);
+ double dz = s.DeadzoneKg >= 0 ? s.DeadzoneKg : 0;
+ double mf = s.MaxForceKg >= 0 ? s.MaxForceKg : 200;
+ float[]? curveY = s.InputCurveY;
+ float[]? curveX = s.InputCurveX;
+ controller.QueueCalibWrite($"{dev:x2}:feel-curve", () =>
+ controller.PushFeelCurveResync(dz, mf, curveY, curveX, dev));
}
private void MBoosterTravelRangeSlider_RangeChanged(object sender, EventArgs e)
@@ -4320,10 +4542,13 @@ private void MBoosterTravelRangeSlider_RangeChanged(object sender, EventArgs e)
_plugin.SaveSettings();
}
- // Deadzone at the start of pedal travel (0..40kg, host-side only —
- // see MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce). Decimal
- // precision (0.1kg ticks), so this doesn't reuse OnIntSliderChanged
- // (which rounds to whole numbers like the other mBooster sliders).
+ // Deadzone at the start of pedal travel (0..40kg) — CONFIRMED real
+ // hardware calibration (mbooster-brake-deadzone, cmdId 0xAB selector
+ // 0x07), reverse-engineered from deadzone-0-5-11-14.pcapng (bug
+ // bundle 5VR5AQ8Y). See MBoosterDeviceController.PushFeelCurveResync
+ // and PushMBoosterFeelCurve. Decimal precision (0.1kg ticks), so this
+ // doesn't reuse OnIntSliderChanged (which rounds to whole numbers
+ // like the other mBooster sliders).
private void MBoosterDeadzoneSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
{
if (_suppressEvents) return;
@@ -4332,18 +4557,24 @@ private void MBoosterDeadzoneSlider_ValueChanged(object sender, RoutedPropertyCh
var s = CurrentMBoosterEffectTarget();
if (s == null) return;
s.DeadzoneKg = (float)v;
+ PushMBoosterFeelCurve(s);
_plugin.SaveSettings();
}
- // Max Force (0..200kg, host-side only, default 200 = off) — the
- // force at which the Pedal Feel input curve's X-axis reaches 100%.
- // See MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce.
+ // Max Force (0..200kg) — the force at which the pedal's raw HID axis
+ // reaches 100% travel. CONFIRMED real hardware calibration
+ // (mbooster-brake-maxforce, cmdId 0xAB selector 0x0E), reverse-
+ // engineered from max-force-24-75-128-166-200.pcapng (bug bundle
+ // 5VR5AQ8Y) — not clamped to Max Threshold on the wire. See
+ // MBoosterDeviceController.PushFeelCurveResync and
+ // PushMBoosterFeelCurve.
private void MBoosterMaxForceSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) =>
OnIntSliderChanged(e.NewValue, MBoosterMaxForceValue, "", v =>
{
var s = CurrentMBoosterEffectTarget();
if (s == null) return;
s.MaxForceKg = v;
+ PushMBoosterFeelCurve(s);
});
// Sensor Output Ratio — blend between the mBooster's angle sensor
@@ -4378,10 +4609,6 @@ private void MBoosterMaxThresholdSlider_ValueChanged(object sender, RoutedProper
QueueMBoosterCalibPush("brake-threshold", (c, dev) =>
c.SendIntWrite("mbooster-brake-threshold",
global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeThresholdKg(v), dev));
- // This IS the raw axis's full scale, so it is also the ceiling
- // Max Force is expressed against — re-scale that slider now
- // rather than leaving its top span silently inert.
- ApplyMBoosterMaxForceCeiling(s);
});
// End Stop Stiffness (Front Limit / End Limit), 1-10 — Pit House's
@@ -4438,6 +4665,29 @@ private void MBoosterNaturalFrictionSlider_ValueChanged(object sender, RoutedPro
});
});
+ // Master on/off for Natural Friction — see
+ // MBoosterDeviceSettings.NaturalFrictionEnabled. Off pushes raw 0
+ // immediately (same effect as dragging the slider to 0, per Pit
+ // House's own toggle-off capture) without touching the stored
+ // NaturalFrictionPct, so switching back on restores it. The slider
+ // is disabled while off to avoid a drag implicitly re-enabling it.
+ private void MBoosterNaturalFrictionEnable_Changed(object sender, RoutedEventArgs e)
+ {
+ if (_suppressEvents) return;
+ var s = CurrentMBoosterEffectTarget();
+ if (s == null) return;
+ s.NaturalFrictionEnabled = MBoosterNaturalFrictionEnable.IsChecked == true;
+ MBoosterNaturalFrictionSlider.IsEnabled = s.NaturalFrictionEnabled;
+ _plugin.SaveSettings();
+ float pct = s.NaturalFrictionEnabled ? (float)MBoosterNaturalFrictionSlider.Value : 0f;
+ int raw = global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeFrictionPct(pct);
+ QueueMBoosterCalibPush("friction", (c, dev) =>
+ {
+ c.SendIntWrite("mbooster-brake-friction-0", raw, dev);
+ c.SendIntWrite("mbooster-brake-friction-1", raw, dev);
+ });
+ }
+
// Segmented Damping — "When Pressed". Reverse-engineered from real
// Pit House USB captures (see docs/protocol/devices/mbooster.md
// "Segmented Damping"): a SINGLE wire command (cmdId 0xB7) carries
@@ -4488,11 +4738,17 @@ private void MBoosterSegDampReleasedPlot_ValuesChanged(object sender, EventArgs
/// wire command has no partial-update form). Not-yet-set fields
/// (-1 sentinel) fall back to Pit House's own factory defaults, same
/// as does on connect.
+ /// When
+ /// is off, every segment field is forced to 0% regardless of what's
+ /// stored/displayed — same wire effect as the user zeroing all six
+ /// sliders themselves.
///
private void PushSegmentedDamping(MBoosterSegmentedDampingSettings sd)
{
_plugin.SaveSettings();
+ bool enabled = sd.DampingEnabled;
+
// Built inside the parked action so the flush sends whatever the
// plots hold when the drag settles, not a mid-drag snapshot.
QueueMBoosterCalibPush("segdamp", (c, dev) =>
@@ -4501,15 +4757,30 @@ private void PushSegmentedDamping(MBoosterSegmentedDampingSettings sd)
sd.Divider2Pressed >= 0 ? sd.Divider2Pressed : MBoosterUiConstants.SegDampDivider2PressedDefaultPct,
sd.Divider1Released >= 0 ? sd.Divider1Released : MBoosterUiConstants.SegDampDivider1ReleasedDefaultPct,
sd.Divider2Released >= 0 ? sd.Divider2Released : MBoosterUiConstants.SegDampDivider2ReleasedDefaultPct,
- sd.Seg1Pressed >= 0 ? sd.Seg1Pressed : MBoosterUiConstants.SegDampSegDefaultPct,
- sd.Seg1Released >= 0 ? sd.Seg1Released : MBoosterUiConstants.SegDampSegDefaultPct,
- sd.Seg2Pressed >= 0 ? sd.Seg2Pressed : MBoosterUiConstants.SegDampSegDefaultPct,
- sd.Seg2Released >= 0 ? sd.Seg2Released : MBoosterUiConstants.SegDampSegDefaultPct,
- sd.Seg3Pressed >= 0 ? sd.Seg3Pressed : MBoosterUiConstants.SegDampSegDefaultPct,
- sd.Seg3Released >= 0 ? sd.Seg3Released : MBoosterUiConstants.SegDampSegDefaultPct,
+ !enabled ? 0 : sd.Seg1Pressed >= 0 ? sd.Seg1Pressed : MBoosterUiConstants.SegDampSegDefaultPct,
+ !enabled ? 0 : sd.Seg1Released >= 0 ? sd.Seg1Released : MBoosterUiConstants.SegDampSegDefaultPct,
+ !enabled ? 0 : sd.Seg2Pressed >= 0 ? sd.Seg2Pressed : MBoosterUiConstants.SegDampSegDefaultPct,
+ !enabled ? 0 : sd.Seg2Released >= 0 ? sd.Seg2Released : MBoosterUiConstants.SegDampSegDefaultPct,
+ !enabled ? 0 : sd.Seg3Pressed >= 0 ? sd.Seg3Pressed : MBoosterUiConstants.SegDampSegDefaultPct,
+ !enabled ? 0 : sd.Seg3Released >= 0 ? sd.Seg3Released : MBoosterUiConstants.SegDampSegDefaultPct,
dev)));
}
+ // Master on/off for the whole Segmented Damping feature — see
+ // MBoosterSegmentedDampingSettings.DampingEnabled and
+ // PushSegmentedDamping's zero-forcing above.
+ private void MBoosterSegDampEnable_Changed(object sender, RoutedEventArgs e)
+ {
+ if (_suppressEvents) return;
+ var s = CurrentMBoosterEffectTarget();
+ if (s == null) return;
+ var sd = s.SegmentedDamping ??= new MBoosterSegmentedDampingSettings();
+ sd.DampingEnabled = MBoosterSegDampEnable.IsChecked == true;
+ MBoosterSegDampPressedPlot.IsEnabled = sd.DampingEnabled;
+ MBoosterSegDampReleasedPlot.IsEnabled = sd.DampingEnabled;
+ PushSegmentedDamping(sd);
+ }
+
private void MBoosterReadCalButton_Click(object sender, RoutedEventArgs e)
{
CurrentMBoosterController()?.RequestCalibrationReads();
diff --git a/docs/protocol/devices/mbooster.md b/docs/protocol/devices/mbooster.md
index 37c53f97..09a29e30 100644
--- a/docs/protocol/devices/mbooster.md
+++ b/docs/protocol/devices/mbooster.md
@@ -33,13 +33,13 @@ two ways:
## USB identification
-| Field | Value |
-|-----------------|---------------------------------------------|
-| Vendor ID | `0x346E` (Gudsen / Moza) |
-| Product ID | `0x0008` |
-| Category | `MozaDeviceCategory.MBooster` |
-| HID match | VID+PID (no name regex — see [`MozaHidReader.cs`](../../../Protocol/MozaHidReader.cs)) |
-| Baud rate | 115200 |
+| Field | Value |
+| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Vendor ID | `0x346E` (Gudsen / Moza) |
+| Product ID | `0x0008` |
+| Category | `MozaDeviceCategory.MBooster` |
+| HID match | VID+PID (no name regex — see [`MozaHidReader.cs`](../../../Protocol/MozaHidReader.cs)) |
+| Baud rate | 115200 |
| Stable identity | USB device instance segment from the registry walk; fallback to the device instance ID surfaced by `HidDevice.DevicePath` — see `MBoosterDeviceController.Identity` |
## Chain topology & connectivity diagnostics
@@ -62,7 +62,7 @@ line separates them.
Support bundle KY3HK4QP (W17, 1.5.5) is the full failure. The device
reported:
-```
+```text
Throttle pedal is connected, type: passive pedal
Brake pedal is connected, type: active pedal
Clutch pedal is connected, type: passive pedal
@@ -74,9 +74,9 @@ plugin read `PD Linked`'s count of 3 as a chain and addressed the brake at
`0x1d`. The capture's response tally is unambiguous:
| Target | Writes sent | Responses / write-echoes (`a4 21`) |
-|---|---|---|
-| `0x12` | 16 | **16** |
-| `0x1d` | 1212 | **0** |
+| ------ | ----------- | ---------------------------------- |
+| `0x12` | 16 | **16** |
+| `0x1d` | 1212 | **0** |
Reads: 52 to `0x12` all answered; 16 to `0x1d`/`0x1e` answered **none**.
Every RX frame in both capture files carries source byte `21` (= `0x12`
@@ -146,13 +146,13 @@ The only reliable connectivity source is the group-`0x0E` firmware
diagnostic stream, which the device re-emits about once a minute in one
of two dialects:
-| | Short form (device-type `01-02-00-08`, 3 HID axes) | Long form (device-type `01-02-07-05`, 4 HID axes) |
-|---|---|---|
-| Connectivity | `PD Linked:[T 0 B 1 C 0]` | `Pedals connected state: [throttle 0 brake 1 clutch 0]` |
-| Per-pedal type | `Brake pedal is connected, type: active pedal` / `Throttle pedal is not connected !` | same |
-| Sensor dir | `Sensor Dir:[T 1 B -1 C -1]` | `Sensor direction: [throttle 1 brake -1 clutch -1]` |
-| Output dir | `OP Dir:[T 0 B 0 C 0]` | `Output direction: [throttle 0 brake 0 clutch 0]` |
-| Pot angles | `T-PD:[min … max … angle …]` | `Throttle calibrate theta:[min … max … angle …]` |
+| | Short form (device-type `01-02-00-08`, 3 HID axes) | Long form (device-type `01-02-07-05`, 4 HID axes) |
+| -------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------- |
+| Connectivity | `PD Linked:[T 0 B 1 C 0]` | `Pedals connected state: [throttle 0 brake 1 clutch 0]` |
+| Per-pedal type | `Brake pedal is connected, type: active pedal` / `Throttle pedal is not connected !` | same |
+| Sensor dir | `Sensor Dir:[T 1 B -1 C -1]` | `Sensor direction: [throttle 1 brake -1 clutch -1]` |
+| Output dir | `OP Dir:[T 0 B 0 C 0]` | `Output direction: [throttle 0 brake 0 clutch 0]` |
+| Pot angles | `T-PD:[min … max … angle …]` | `Throttle calibrate theta:[min … max … angle …]` |
`MBoosterDeviceController.LogPedalDiagnosticIfRelevant` parses both
connectivity forms into `ConnectedAxes` (which pedal slots exist — drives
@@ -224,7 +224,7 @@ stuffing routines handle all framing.
Built inline by [`MozaMBoosterProtocol.BuildMotorFrame`](../../../Protocol/MozaMBoosterProtocol.cs).
14 bytes pre-stuffing.
-```
+```text
7e 09 24 12 b1 EF EN 00 P1 FH FL AH AL CK
│ │ │ │ │ └─┴─freq u16 BE
│ │ │ │ └ param1 (1..255)
@@ -236,18 +236,18 @@ Built inline by [`MozaMBoosterProtocol.BuildMotorFrame`](../../../Protocol/MozaM
**Effect IDs** (enum [`MBoosterEffectId`](../../../Protocol/MozaMBoosterProtocol.cs)):
-| ID | Name | ParamK | Trigger condition (host-side, doc § 4) |
-|-----|-----------|--------|----------------------------------------|
-| `1` | ABS | 2000 | `absActive > 0.1` from SimHub |
+| ID | Name | ParamK | Trigger condition (host-side, doc § 4) |
+| --- | --------- | ------ | -------------------------------------------------------------------------------------------------------- |
+| `1` | ABS | 2000 | `absActive > 0.1` from SimHub |
| `2` | Lockup | 2640 | Heavy brake (>0.8) + wheels < 30 % of vehicle speed (fallback: brake > 0.9 when wheel speed unavailable) |
-| `3` | Threshold | 3080 | Rising edge on brake > 0.6; release at < 0.3 (hysteresis) |
-| `4` | Engine | 1000 | `rpm > 0.8 × idleRpm` — runs continuously |
+| `3` | Threshold | 3080 | Rising edge on brake > 0.6; release at < 0.3 (hysteresis) |
+| `4` | Engine | 1000 | `rpm > 0.8 × idleRpm` — runs continuously |
**Known-good frames** (verified against the protocol note's hardware
captures — diff against these in a `SerialTrafficCapture` export to
confirm wire correctness):
-```
+```text
ABS on, 22Hz, amp=0x08e8: 7e 09 24 12 b1 01 01 00 5a 1c 28 08 e8 0b
ABS off: 7e 09 24 12 b1 01 00 00 00 00 00 00 00 7c
Lockup on, 55Hz, ramp 0: 7e 09 24 12 b1 02 01 00 30 46 66 00 00 5a
@@ -286,7 +286,7 @@ the motor after the port closes.
All motor frames go through a single per-connection lane:
-```
+```text
StreamKind.MBoosterEffect = 17
```
@@ -304,11 +304,11 @@ they aren't coalesced.
reproduces protocol note § 4 verbatim:
| Effect | Waveform |
-|-----------|-------------------------------------------------------------|
-| ABS | `wave = 0.9 + 0.1 * sin(phase); amp = wave * intensity` |
-| Lockup | `ramp = clamp(elapsed / 0.5, 0, 1); amp = ramp * intensity` |
-| Threshold | 5 Hz envelope: 20 ms full + 120 ms 80 % + 60 ms gap |
-| Engine | `wave = 0.5 + 0.5 * sin(phase); amp = wave * intensity` |
+| --------- | ----------------------------------------------------------- |
+| ABS | `wave = 0.9 + 0.1 * sin(phase); amp = wave * intensity` |
+| Lockup | `ramp = clamp(elapsed / 0.5, 0, 1); amp = ramp * intensity` |
+| Threshold | 5 Hz envelope: 20 ms full + 120 ms 80 % + 60 ms gap |
+| Engine | `wave = 0.5 + 0.5 * sin(phase); amp = wave * intensity` |
Engine intensity is clamped to 10 % at apply time (doc § 4 default
`engineScale = 0.01`, clamped to `[0, 0.1]`) — engine runs
@@ -464,7 +464,7 @@ firmware — sustained valid frames, not silently dropped.
**The wire payload shape is materially different from the other four**,
reverse-engineered from the stepped capture:
-```
+```text
7e 09 24 12 b1 09 EN SH SL NH NL IH IL CK
│ │ │ └─┴─smoothness u16 BE └─┴─intensity u16 BE
│ │ └ enable (0 = off, 1 = on)
@@ -938,21 +938,29 @@ Calibration card (Direction / Min Raw / Max Raw / Read from device /
Apply) surfaces this as experimental with a yellow warning.
Every one of these registers is **flash-backed**, and each write
-additionally drags the 6-frame [curve7 resync](#pedal-feel-host-side-only)
+additionally drags the 6-frame [curve7 resync](#pedal-feel)
behind it, so the slider handlers must not write per tick. Bundle KY3HK4QP
-shows the cost unthrottled: a ~2 s Max Threshold drag emitted 77 threshold
-+ 462 curve7 frames — ~40 writes/second into flash. UI writes are therefore
-parked latest-wins per (device, command) and flushed once the drag settles
+(AZOM's own traffic, not a Pit House capture — the plugin unconditionally
+tacked the resync onto every calibration write at the time) shows the cost
+unthrottled: a ~2 s Max Threshold drag emitted 77 threshold and 462 curve7
+frames — ~40 writes/second into flash. UI writes are therefore parked
+latest-wins per (device, command) and flushed once the drag settles
(`MBoosterDeviceController.QueueCalibWrite`, ~400 ms quiet window — the
same shape `HardwareApplier.QueueWheelCfgWrite` uses for the wheel's own
flash-backed writes). The resync rides *inside* the parked action so it can
never be reordered ahead of the write it commits. The connect-time apply
(`MozaPlugin.ApplyMBoosterToHardware`) fires immediately and is not parked.
+**Update (bug bundle 5VR5AQ8Y):** an isolated real Pit House capture of
+Max Threshold alone (`max-threshold-4-41-105-153-200.pcapng`) shows zero
+curve7 traffic — Pit House itself never sent the 462 frames the KY3HK4QP
+number implied were needed. The resync is no longer sent for Max
+Threshold (nor for Deadzone/Max Force — see below); this quiet-window
+parking still applies to whichever calibrations still carry it.
## Sim Input Mapping
-Two real hardware calibrations plus the per-role output curve, all on the
-pedal's own unit (`MotorDeviceForRole` — see
+Two real hardware calibrations, plus a purely host-side output curve, all
+on the pedal's own unit (`MotorDeviceForRole` — see
[Chain topology](#chain-topology--connectivity-diagnostics)).
- **Sensor Output Ratio** (`SensorOutputRatioPct`, 0–100%) — blends the
@@ -967,54 +975,145 @@ pedal's own unit (`MotorDeviceForRole` — see
1311 exactly; an unlabeled capture decoding to ~126 kg against an
independently-reported real Pit House setting of ~125 kg). See
`MozaMBoosterProtocol.EncodeThresholdKg`/`DecodeThresholdKg`.
-- **Output curve** — 5 points through `mbooster-{throttle,brake,clutch}-y1..y5`,
- resampled at the fixed 20/40/60/80/100 breakpoints the wire supports (see
- `MozaMBoosterRegistry.ResampleCurveAtFixedBreakpoints`; a horizontally
- dragged node has no wire command of its own).
-
-Both calibrations use the shared `-1` "not yet set / no override" sentinel,
-so a fresh profile never overwrites what is already on the device.
-
-Max Threshold **is** the raw HID axis's full scale: at 100% travel the axis
-reads exactly `MaxThresholdKg` of force, and the device pegs its own output
-there. That makes it the reference every host-side kg-space control is
-expressed against (`MozaMBoosterRegistry.ResolveFullScaleKg`, resolved in
-three rungs: the user's own override, else the device's
-`mbooster-brake-threshold` read-back, else a 200 kg last resort).
-
-Getting that reference wrong, or failing to land the write, makes Max
-Threshold read as **inverted** — it enters the host-side path only as the
-`ApplyDeadzoneAndMaxForce` *denominator*, so raising it shrinks both the
-deadzone and the Max Force ceiling in raw-travel terms and the reported
-pedal position rises *faster*. When the hardware write does land the two
-effects cancel exactly (shaped output ends up a function of force and Max
-Force alone), which is what makes the control feel correct. Bundle
-KY3HK4QP hit precisely this: the write was going to a phantom device id.
-The device read-back is now consumed (it is deliberately *not* copied into
-`MaxThresholdKg`, whose `-1` means "user set no override" — seeding it
-would make the plugin write the value back on every connect).
-
-## Pedal Feel (host-side only)
-
-A card above Sim Input Mapping holds a second 5-point curve,
-`InputCurveY` on `MBoosterDeviceSettings`. Unlike `CurveY`, this one has
-**no wire command at all** — it's pure host-side shaping, applied in
-`MozaMBoosterRegistry.OnHidAxisUpdate` to the raw HID axis position
-before it becomes `c.LastHidPosition`, i.e. before it reaches
-`MozaData.{Throttle,Brake,Clutch}Position` (game telemetry) *and*
-before the effect worker's brake-position test-pulse fallback. `CurveY`
-is completely unaffected — it still writes to the device's own
-output-curve command exactly as before.
-
-`MozaMBoosterRegistry.EvaluateInputCurve` reproduces
-`MozaControls.MozaCurveEditor`'s Catmull-Rom rendering exactly (same
-1/6-tangent formula, anchored at the origin), inverted via bisection to
-solve X(t)=x for the requested input X — so the applied shaping always
-matches what's drawn on screen. Verified: the Linear preset is an exact
-identity function (not just at the 5 breakpoints), and the S-Curve
-preset interpolates smoothly through all 5 breakpoints. `null` (the
-default) means no shaping — existing profiles are unaffected until a
-user opens this section.
+ **CORRECTED**: earlier text here claimed this write "recalibrates the
+ sensor's own full-scale range on the DEVICE" (raw HID axis reads
+ `MaxThresholdKg` of force at 100%). Hardware testing disproved that: the
+ write demonstrably reaches the device and reads back correctly (same
+ "write succeeds ⇒ assumed real" reasoning that also mis-closed the Max
+ Force "does nothing" reports twice — KY3HK4QP, 5VR5AQ8Y — before *that*
+ turned out to need the Pedal Feel curve to actually have a shape), but
+ changing it does not change how much force the raw HID axis needs to
+ reach 100%, confirmed with Max Force held constant and Threshold swept
+ full range both directions. The raw HID axis's 100% is actually **Max
+ Force's own kg ceiling** (the Pedal Feel curve's real hardware full
+ scale — see below). Max Threshold is therefore implemented **host-side**
+ instead (`MozaMBoosterRegistry.OnHidAxisUpdate`): it rescales the raw
+ position — already 0–100% of Max Force's span — into 0–100% of
+ Threshold's span (`posPct * (MaxForceKg / ThresholdKg)`, clamped to 100)
+ before the Sim Input Mapping curve ever sees it, the same category as
+ Sim Input Mapping's own CurveY/CurveX below (no wire command actually
+ does the real work). The `mbooster-brake-threshold` write is still sent
+ (harmless, matches whatever Pit House itself does with the field even if
+ it isn't the mechanism that matters) but AZOM no longer depends on it.
+- **Output curve** (`CurveY`/`CurveX`, 6 nodes + an implicit fixed origin
+ at (0,0)) — **REVISED, bug bundle 5VR5AQ8Y**: this is now confirmed
+ **purely host-side, with no wire command at all**. It used to be
+ believed to write through `mbooster-{throttle,brake,clutch}-y1..y5`
+ (15 commands, confirmed-real but for the wrong shape) and, in an
+ even earlier iteration, an experimental `curve7` resync (`0xAB`
+ selectors `0x01`-`0x06`) — both are now removed; see
+ [Removed: `y1..y5` and `curve7`](#removed-y1y5-and-curve7-historical)
+ below. What this curve actually does: it remaps the pedal's raw HID
+ position — which by the time AZOM reads it already reflects Deadzone,
+ Max Force, and the Pedal Feel curve's real hardware shaping (see
+ [Pedal Feel](#pedal-feel) below) — into whatever value AZOM reports as
+ game telemetry (`MozaData.{Throttle,Brake,Clutch}Position`). Applied in
+ `MozaMBoosterRegistry.OnHidAxisUpdate` via
+ `EvaluateCurveArbitraryX(cfg.CurveX, cfg.CurveY, posPct)`, in the exact
+ spot `InputCurveY`'s host-side application used to occupy before Pedal
+ Feel moved to hardware. Nodes are draggable both vertically (`CurveY`)
+ and horizontally (`CurveX`, via `AllowHorizontalDrag` on the curve
+ editor) — a dragged last node lets "100% output" happen before "100%
+ input," since the evaluator plateaus at the last node's Y beyond its X
+ (same trick as before, just now the ONLY consumer of the shaped value
+ is AZOM's own telemetry, not a second wire push). Default (un-dragged)
+ breakpoints are `100/6 × k` for k=1..6 (≈16.67/33.33/50/66.67/83.33/100%),
+ evenly spaced with the last node at exactly 100% — so an untouched
+ curve maps full input to full output, and "100% before 100%" only
+ happens once a user explicitly drags the last node inward. **Bug,
+ fixed**: this used to be `100/7 × k` (last node ~85.71%, not 100%),
+ inherited from matching the (now-removed, disproven) `curve7`
+ mechanism's own selectors purely so a never-dragged node would render
+ identically to the old experimental shape — which meant Linear (and
+ every other preset) topped out around 86% instead of reaching 100%.
+ `MozaPlugin.FixMBoosterCurveArraysSeventhsBug` is a one-shot migration
+ that repairs any profile that saved one of the old preset shapes.
+
+Both hardware calibrations use the shared `-1` "not yet set / no override"
+sentinel, so a fresh profile never overwrites what is already on the
+device. `CurveY`/`CurveX` are `null` by default (identity / no remapping)
+— existing profiles are unaffected until a user opens this section.
+
+## Pedal Feel
+
+A card to the left of Sim Input Mapping holds `InputCurveY` on
+`MBoosterDeviceSettings` — 6 nodes.
+**FURTHER REVISED**: each node is also draggable horizontally via
+`InputCurveX` — see [Node X position](#pedal-feel-node-x-position) below;
+the "Y-only (no X-dragging)" claim that used to be here was wrong.
+**REVISED, bug bundle 5VR5AQ8Y**: this is now confirmed **real hardware
+calibration**, not host-side shaping. It directly populates the 6
+interpolated selectors already reverse-engineered for Deadzone/Max Force
+(`mbooster-brake-feelcurve-1..6`, cmdId `0xAB` selectors `0x08`-`0x0D` —
+see [Deadzone / Max Force](#deadzone--max-force--revised-real-hardware-calibration-not-host-side-bug-bundle-5vr5aq8y)
+below) — Deadzone (`0x07`) and Max Force (`0x0E`) stay their own separate
+anchor sliders, untouched by the curve. Each node's UI value is a
+percentage (0-100%) of the Deadzone→Max Force span; the wire value per
+node is `kg = deadzoneKg + (nodePct/100) × (maxForceKg − deadzoneKg)`,
+encoded via the same `MozaMBoosterProtocol.EncodeThresholdKg` every kg
+field in this family uses. See `MozaMBoosterRegistry.ComputeFeelCurve`
+and `MBoosterDeviceController.PushFeelCurveResync`.
+
+Since the device now shapes this curve's effect into the raw HID axis
+itself, the OLD host-side application (`MozaMBoosterRegistry
+.EvaluateInputCurve`, applied to the raw HID position before it became
+`c.LastHidPosition`/game telemetry) has been **removed** — keeping it
+would have double-applied the curve on top of what the hardware already
+did, the same class of bug the original Deadzone/Max Force host-side
+implementation had. `CurveY` (Sim Input Mapping, above) is unaffected —
+it's a completely separate, still-host-side remap that runs where
+`EvaluateInputCurve` used to.
+
+The curve's default (un-dragged) X breakpoints — `{8.049, 19.495, 44.245,
+72.433, 90.040, 97.910}%` of the Deadzone→Max Force span — are the SAME
+constants documented under Deadzone/Max Force below
+(`MozaMBoosterRegistry.FeelCurveFractions`), now reframed twice over: they
+were originally measured as a fixed interpolation *formula*, then
+recognized as Pit House's own un-dragged default SHAPE (Y=X trivially
+holds for any untouched curve regardless of the real breakpoint spacing),
+and — see [Node X position](#pedal-feel-node-x-position) immediately below —
+now confirmed to be draggable on the wire too, not just visually. `null`
+(the default, on either `InputCurveY` or `InputCurveX`) means "use this
+Linear default" — existing profiles are unaffected until a user opens this
+section. Same passive-pedal protection as Deadzone/Max Force
+(`MBoosterDeadzoneMaxForcePanel` in `SettingsControl.xaml`) — this is a
+brake-named singleton `0xAB` write with no per-pedal selector, so editing
+it from a passive pedal's page would overwrite the active pedal's
+registers instead.
+
+### Pedal Feel node X position
+
+**REVISED**: each of the 6 Pedal Feel nodes is draggable on BOTH axes —
+`InputCurveX` on `MBoosterDeviceSettings`/`MBoosterPedalSettings`, a real
+hardware write exactly like `InputCurveY`, not just a UI convenience.
+Reverse-engineered from four isolated single-node-drag Pit House captures,
+`pedal-feel-node{2,5}-{x,y}-adjust.pcapng` (one node dragged on one axis
+only, per capture): every drag — on EITHER axis — wrote TWO `0xAB`
+selectors together, X first: the node's own `feelcurve-N` Y selector
+(`0x08`-`0x0D`, already known) AND a second, distinct selector equal to
+the node's own 1-based index (`0x01`-`0x06`), using the identical
+kg-relative-to-Deadzone/Max-Force-span encoding as Y. Node 2's low
+selector read back `0x02` = 19.29% (user-reported drag target ≈20%); node
+5's read back `0x05` ≈ 60% (user-reported drag target ≈64% — the
+imprecision here is attributed to eyeballing an on-screen drag rather
+than a scale mismatch, since kg-scale and percent-of-span encodings are
+numerically identical at the default 0kg/200kg anchors these captures
+were taken at).
+
+This selector range (`0x01`-`0x06`) is the SAME one an earlier, less
+rigorous investigation spotted exactly once — alongside an unrelated
+Travel Start write, not an isolated node drag — and removed as an
+unconfirmed guess wired to the wrong curve entirely (see
+[Removed: `y1..y5` and `curve7`](#removed-y1y5-and-curve7-historical)
+below); these new isolated single-axis captures are the missing evidence
+that investigation lacked, and settle it: it's Pedal Feel's own node X,
+not a Sim Input Mapping mechanism and not a universal per-write resync.
+New wire command names (`mbooster-brake-feelcurve-x-1..6`) were added
+rather than reusing the old removed `mbooster-brake-curve7-N` names, to
+avoid conflating with that disproven theory. Pushed by the SAME
+`MBoosterDeviceController.PushFeelCurveResync` atomic burst as Deadzone/
+Max Force/`InputCurveY` — never attached to any other calibration write,
+learning from the earlier mechanism's "resync everything" mistake.
The same card also has a **Start/End of Travel (mm)** control —
`TravelStartMm`/`TravelEndMm` on `MBoosterDeviceSettings`. Unlike every
@@ -1096,6 +1195,17 @@ when off and restores the last slider value when on. See
"not yet set / no override" sentinel convention as
`EndstopFrontStiffness`/`EndstopEndStiffness`.
+AZOM's own UI (`MBoosterNaturalFrictionEnable`,
+`NaturalFrictionEnabled` on both `MBoosterDeviceSettings` and
+`MBoosterPedalSettings`, default `true`) reproduces that exact behavior
+rather than inventing a new wire concept: switching it off pushes raw 0
+immediately (`MBoosterNaturalFrictionEnable_Changed`) without touching
+the stored `NaturalFrictionPct`, and disables the slider so a drag can't
+implicitly re-enable it; switching back on restores whatever the slider
+currently shows. `MozaPlugin.ApplyMBoosterToHardware` mirrors the same
+zero-forcing on connect so a profile saved with friction switched off
+reconnects silent rather than restoring its last on-wire value.
+
**Segmented Damping** (labeled "SEGMENTED DAMPING" with its own card, two
plots — "When Pressed" and "When Released") is Pit House's "simulate a
damping force independent of in-game output, dividing pedal travel into
@@ -1124,7 +1234,7 @@ including ones unrelated to whatever the user was actually dragging in
that particular capture, proving this is always a whole-feature
snapshot, never a partial update:
-```
+```text
cmd=0xB7 Div1Pressed Div2Pressed Div1Released Div2Released
Seg1Pressed Seg1Released Seg2Pressed Seg2Released Seg3Pressed Seg3Released
```
@@ -1172,66 +1282,149 @@ divider or a segment on EITHER plot, at which point any still-unset
field on the OTHER plot is filled from the factory defaults above rather
than left blank (the wire frame has no concept of "not sent" per field).
-The same card also has two force-based sliders, both host-side only and
-both applied in `MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce`, which
-runs *before* `EvaluateInputCurve`:
-
-- **Deadzone** (`DeadzoneKg`, 0–40kg, default 0 = off) — force below
- this clamps to 0.
-- **Max Force** (`MaxForceKg`, 0–200kg, default 200 = off) — the force
- at which the *input curve's* X-axis reaches 100%. Lets a user who
- never presses past, say, 100kg use the curve's full 0–100% range
- instead of only ever reaching its midpoint.
-
-**Update**: originally both were combined into a kg-space remap that
-treated raw 0–100% travel as a fixed 0–200kg full scale. That's wrong
-whenever the device's real calibration isn't 200kg — and real Pit
-House captures already on file for `MaxThresholdKg` show ~100-125kg,
-not 200kg. Raw 100% travel is only ever as many kg as
-`MaxThresholdKg` (Sim Input Mapping, a genuine hardware calibration —
-see above) currently says it is; past that point the device itself
-has already pegged its own output at 100%, so there is no more
-resolution left for software to detect additional force. Concretely,
-the bug this caused: setting Max Force to 200kg (its slider max) was
-silently a no-op whenever `MaxThresholdKg` was lower, because
-`hiPercent` degenerated to the same 100% raw-travel point the axis
-already saturates at — pressing anywhere near the device's real max
-already read as 100% input, never requiring the full 200kg the slider
-implied.
-
-Fixed by threading the pedal's actual full scale through as
-`ApplyDeadzoneAndMaxForce`'s `fullScaleKg` reference, resolved by
-`MozaMBoosterRegistry.ResolveFullScaleKg` — the user's own
-`MaxThresholdKg` override, else the value the DEVICE reported for
-`mbooster-brake-threshold` (`MBoosterDeviceController
-.DeviceReportedMaxThresholdKg` — a real read-back, live only and never
-persisted), else the historical 200 kg last resort. Force below the
-deadzone clamps to 0, and everything between the deadzone and Max Force
-rescales linearly to 0–100%, same as before — just against the real
-reference scale instead of a hardcoded one. (This used to be spelled out
-in a UI hint, `Hint_DeadzoneScaleAssumption`, but that string was never
-actually filled in for English — only other locales had it translated —
-so the English UI showed a blank line; the hint has since been removed
-entirely rather than backfilled.)
-
-Practical implication for users: Max Force can only ever *lower* the
-effort needed to reach 100% below the pedal's real full scale — it can't
-demand *more* force than the device's own calibration already saturates
-at, so getting a genuine "200kg to reach 100%" feel requires setting Max
-Threshold to 200kg first (Sim Input Mapping), not just Max Force. Every
-Max Force position above the full scale was therefore silently inert,
-which with Max Threshold at 140 kg left the top 30% of a 0–200 slider
-doing nothing — reported as "Max force seems to be doing absolutely
-nothing" in bundle KY3HK4QP. The slider's `Maximum` (and its end label)
-are now clamped to the resolved full scale
-(`SettingsControl.ApplyMBoosterMaxForceCeiling`), re-applied whenever Max
-Threshold changes.
-
-Both remain host-side only: they shape
-`MozaData.{Throttle,Brake,Clutch}Position`, which feeds the `AZOM.*`
-properties, the pedal traces and the live curve markers — **not** the
-game, which reads the pedal's HID directly, and not the pedal's own feel.
-There is no wire command for either.
+**Enable toggle** (`MBoosterSegDampEnable`,
+`MBoosterSegmentedDampingSettings.DampingEnabled`, default `true`): not a
+separate wire command — Pit House's own "toggle off/on" capture
+(mentioned above) showed all-zero segment values on disable, so AZOM's
+toggle reproduces that exactly in software: switching it off sends the
+same `BuildSegmentedDampingFrame` with all six segment fields forced to
+`0%` (dividers untouched, since they're inert once every segment damps
+at 0%), both from the UI (`SettingsControl.PushSegmentedDamping`) and on
+connect (`MozaPlugin.ApplyMBoosterToHardware`). Switching it back on
+resumes whatever divider/segment values were last stored (or factory
+defaults for a still-untouched profile).
+
+### Deadzone / Max Force — REVISED: real hardware calibration, not host-side (bug bundle 5VR5AQ8Y)
+
+The same card also has two force-based sliders, **Deadzone** (`DeadzoneKg`,
+0–40kg) and **Max Force** (`MaxForceKg`, 0–200kg). These were originally
+implemented as a purely host-side kg-space remap
+(`MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce`, applied to the raw HID
+axis position before `EvaluateInputCurve`, and clamped to whatever
+`MaxThresholdKg` currently resolved to — see git history for that design
+and the ceiling bug it needed, `SettingsControl.ApplyMBoosterMaxForceCeiling`).
+
+Two more bug reports for "Max Force does nothing" (5VR5AQ8Y, following
+KY3HK4QP) prompted two fresh Pit House USB captures made specifically to
+settle the question: `max-force-24-75-128-166-200.pcapng` (Threshold held
+fixed, Max Force dragged through 75/128/166kg) and
+`deadzone-0-5-11-14.pcapng` (Max Force held fixed at 24kg, Deadzone
+dragged through 5/11/14kg). Both confirm the user's own description of
+Pit House's real behavior: Deadzone and Max Force **are** real hardware
+calibration, not a host-side shim — the previous design's core assumption
+was wrong.
+
+Every drag stop in both captures wrote the same family: cmdId `0xAB`
+(the same command `mbooster-brake-curve7-*` above uses, but a
+**different, previously-undiscovered selector range**), fixed `0x00`
+byte, one of 8 selectors, 2-byte big-endian value using the *identical*
+kg encoding as `MaxThresholdKg` (`raw = round(kg * 65536 / 200)` — see
+`MozaMBoosterProtocol.EncodeThresholdKg`, reused as-is):
+
+- **selector `0x07` = Deadzone** (`mbooster-brake-deadzone`) — confirmed
+ exact: 5/11/14kg encoded and decoded back to 5.0/11.0/14.0kg.
+- **selector `0x0E` = Max Force** (`mbooster-brake-maxforce`) — confirmed
+ exact: 75/128/166kg encoded and decoded back to 75.0/128.0/166.0kg.
+- **selectors `0x08`–`0x0D`** = 6 interpolated points between the two
+ anchors. Computing `(value - deadzone) / (maxForce - deadzone)` for
+ each of the 6 across all 6 write bursts (3 per capture) landed on the
+ same constant per selector every time (std-dev < 0.0001), confirming a
+ fixed shape independent of which endpoint moved:
+ `{0.08049, 0.19495, 0.44245, 0.72433, 0.90040, 0.97910}` for selectors
+ `0x08`.. `0x0D` respectively — see
+ `MozaMBoosterRegistry.ComputeFeelCurve`/`FeelCurveFractions` and
+ `MBoosterDeviceController.PushFeelCurveResync`, which pushes all 8
+ values as one burst (same "no partial update" shape as Segmented
+ Damping — the device has no way to change one point in isolation).
+ Why these specific fractions, rather than an evenly-spaced ramp: not
+ determined — treated as an empirically-measured constant, same
+ epistemic status as the Segmented Damping factory defaults above.
+
+Selector `0x04` also rides along unchanged in every single burst across
+both captures (raw `0x9126`, every time) — it doesn't correlate with
+either Deadzone or Max Force, so it's presumably some other Pedal Feel
+field Pit House's UI flushes as part of the same batch. Not needed for
+Deadzone/Max Force to work and not written by AZOM's own push.
+
+**Max Force is confirmed NOT clamped to Max Threshold on the wire** —
+128kg and 166kg were sent as Max Force while Max Threshold read back as
+125kg (`mbooster-brake-threshold` readback, same read-all poll that
+confirmed selector `0x07`). This directly contradicts the original
+design's ceiling logic (`ApplyMBoosterMaxForceCeiling`,
+`ResolveFullScaleKg` — both removed): Max Force is an independent
+parameter, not a rescale of Threshold's own span. The slider's range is
+simply the fixed 0–200kg the XAML always declared.
+
+Like every other real calibration field, both use the shared `-1` "not
+yet set / no override" sentinel (previously 0/200 = "off"), so a fresh
+profile never overwrites whatever the device already has; once either is
+set, the missing one falls back to a sane "off" default (0kg deadzone,
+200kg max force) so the write is always a complete, valid curve. Same
+brake-named-singleton passive-pedal gating as Travel/End Stop/Friction/
+Segmented Damping applies (`MBoosterDeadzoneMaxForcePanel` in
+`SettingsControl.xaml`) — cmdId `0xAB`'s selectors carry no per-pedal
+address either, so editing them from a passive pedal's page would
+overwrite the active pedal's registers the same way KY3HK4QP found for
+Travel.
+
+**Further revision**: `InputCurveY`'s 6 nodes are what populate selectors
+`0x08`-`0x0D` above — see [Pedal Feel](#pedal-feel). An earlier pass
+through this doc (during the same investigation) described those 6
+selectors as a fixed, non-adjustable interpolation formula and treated
+`InputCurveY` as staying host-side; that turned out to be wrong once the
+user clarified Pedal Feel is specifically the curve meant to change the
+pedal's physical feel. `FeelCurveFractions` (the constant array measured
+below) is kept, just reframed as the curve's default/Linear shape rather
+than a hard rule.
+
+### Removed: `y1..y5` and `curve7` (historical)
+
+Two mechanisms this investigation built, then removed once the Sim Input
+Mapping / Pedal Feel split above was clarified — kept here for context,
+matching this doc's convention of preserving past-bug/decision history
+rather than deleting it:
+
+- **`mbooster-{throttle,brake,clutch}-y1..y5`** (cmdIds 14-29,
+ non-sequential per role, 4-byte float, group 35/36) — a genuinely
+ confirmed-via-capture wire mechanism, believed to be the Sim Input
+ Mapping output curve's real encoding (5 points at fixed 20/40/60/80/100%
+ breakpoints). Removed once it became clear the output curve is purely
+ host-side (see above) — the confirmed capture evidence for these 15
+ commands existing is not in question, only whether AZOM's output curve
+ should be sending them, and it turns out it shouldn't.
+- **`curve7`** (`0xAB` selectors `0x01`-`0x06`, cmdId shared with the
+ entirely separate Deadzone/Max Force/Pedal-Feel-curve family at
+ selectors `0x07`-`0x0E` above) — at the time, EXPERIMENTAL/unconfirmed:
+ spotted exactly once, alongside a Travel Start write in
+ `pedal_travel.pcapng`,
+ and speculatively wired into `QueueMBoosterCalibPush` (`SettingsControl
+ .xaml.cs`) as an automatic resync tacked onto EVERY other calibration
+ write (Direction/Min/Max/CurveY/Travel/Endstop/Friction/SegmentedDamping/
+ Ratio), on the theory the same firmware requirement applied broadly.
+ This session's more rigorous, isolated captures directly disconfirmed it
+ for Max Threshold and Deadzone/Max Force specifically (zero `0xAB`
+ selector `0x01`-`0x06` traffic alongside their real writes) — and once
+ neither redesigned curve needed it either, the whole mechanism (
+ `ResampleCurveAtSevenths`, `EncodeCurve7Point`, `PushCurve7Resync`, the 6
+ `AddCommand` entries, and every `needsCurve7Resync`/`includeCurve7Resync`
+ call site) was removed rather than kept half-justified for Travel alone
+ — Travel's own write already reads back correctly without it (per the
+ original Travel writeup above); only the resync's *additional* benefit
+ was ever unconfirmed, not the base write.
+ **Later confirmed, not disproven**: this exact selector range turned out
+ to be real after all — just not a universal resync, and not tied to
+ Travel/Sim Input Mapping/`stroke_curve` (all speculated elsewhere in this
+ doc). Four fresh isolated captures that actually dragged individual
+ Pedal Feel nodes (rather than an unrelated control) showed `0x01`-`0x06`
+ is that curve's own per-node X position — see
+ [Pedal Feel node X position](#pedal-feel-node-x-position) above, added
+ back under new command names (`mbooster-brake-feelcurve-x-1..6`) so as
+ not to resurrect the old, wrong `curve7`/universal-resync theory.
+
+Net effect: Direction/Min/Max/Travel/Endstop/Friction/SegmentedDamping/
+Ratio/Threshold no longer drag any resync behind their writes — just the
+one write each already documented above, with `QueueCalibWrite`'s 400ms
+debounce still doing its job of collapsing a drag into one write set.
### Traction Control — new effect, no verified wire type
@@ -1278,7 +1471,7 @@ raw wheel-slip physics heuristic in `UpdateWheelSpinRequest` — the
acceleration-side counterpart to Lockup's braking-side heuristic (see
"Lockup rebuild" above):
-```
+```text
isSpinning = throttle > 0.8 && vehicleSpeed < 40 && avgWheelSpeed > vehicleSpeed * 1.3
```
@@ -1463,14 +1656,14 @@ mapping") holds the Pit House-parity controls, all still under
identity; dragging the last node from X=100 to X=60 (Y unchanged)
makes breakpoints 60/80/100 all resample to that node's Y.
-| Command | Group (R/W) | CmdId | Bytes | Type |
-|-------------------------------|-------------|-------|-------|-------|
-| `mbooster-throttle-dir/min/max` | 35 / 36 | 1/2/3 | 2 | int |
-| `mbooster-brake-dir/min/max` | 35 / 36 | 4/5/6 | 2 | int |
-| `mbooster-clutch-dir/min/max` | 35 / 36 | 7/8/9 | 2 | int |
-| `mbooster-{throttle,brake,clutch}-y1..y5` | 35 / 36 | 14-29 | 4 | float |
-| `mbooster-{throttle,brake,clutch}-output` | 37 / — | 1/2/3 | 2 | int |
-| `mbooster-brake-angle-ratio` | 35 / 36 | 26 | 4 | float |
+| Command | Group (R/W) | CmdId | Bytes | Type |
+| ----------------------------------------- | ----------- | ----- | ----- | ----- |
+| `mbooster-throttle-dir/min/max` | 35 / 36 | 1/2/3 | 2 | int |
+| `mbooster-brake-dir/min/max` | 35 / 36 | 4/5/6 | 2 | int |
+| `mbooster-clutch-dir/min/max` | 35 / 36 | 7/8/9 | 2 | int |
+| `mbooster-{throttle,brake,clutch}-y1..y5` | 35 / 36 | 14-29 | 4 | float |
+| `mbooster-{throttle,brake,clutch}-output` | 37 / — | 1/2/3 | 2 | int |
+| `mbooster-brake-angle-ratio` | 35 / 36 | 26 | 4 | float |
All targeted at device id `0x12` on the mBooster's own CDC port. The
plugin's [`MozaResponseParser`](../../../Protocol/MozaResponseParser.cs)
@@ -1492,7 +1685,7 @@ position bar stuck at 0 despite the device showing "Connected"): the
"shared prefix, differing only in trailing interface index" theory
above is **wrong**. A real capture showed:
-```
+```text
HID: 9&1bd82a3a&0&0000
CDC: 8&1709245b&0&0000
```
@@ -1575,7 +1768,7 @@ three sections** — but only its own role gets the extended block (effects,
travel limits, damping/friction, force curves). The other two hold just the
device-wide snapshot:
-```
+```text
channlRoleType, outdir, min, max, nonlinear1..5, press_combine
```
@@ -1599,23 +1792,23 @@ Prefixed `` = `throttle` / `brake` / `clutch`. Every effect field is
host-rendered (see [Effect synthesis](#effect-synthesis)); the calibration
rows reach the device through `MozaPlugin.ApplyMBoosterToHardware`.
-| PitHouse key | Plugin field | Notes |
-|---|---|---|
-| `
_outdir` | `Direction` | `mbooster-
-dir` |
-| `
_min` / `
_max` | *(not imported)* | **unit mismatch**, see below |
-| `
_nonlinear1..5` | `CurveY[0..4]` | output curve, `mbooster-
-y1..y5`; both sides are 0–100 |
-| `
_abs_switch/_amp/_freq/_smoothness` | `Abs.Enabled/.IntensityPct/.FrequencyHz/.SmoothnessPct` | brake-only in PitHouse |
-| `
_lockup_switch/_amp/_freq` | `Lockup.*` | brake-only |
-| `
_brakethreshold_switch/_amp/_freq` | `Threshold.Enabled/.IntensityPct/.FrequencyHz` | brake-only |
-| `
_brakethreshold_trigger_input` | `Threshold.TriggerLevelPct` | same 50–100 range |
-| `
_brakethreshold_fade_amount` | `Threshold.DecayPct` | UI label "Vibration Decay" |
-| `
_tc_switch/_amp/_freq` | `TractionControl.*` | |
-| `
_wheel_slip_switch/_amp/_freq` | `WheelSpin.*` | plugin's range (30–80 Hz) is narrower than PitHouse's |
-| `
_gear_shift_vibration_switch/_amp/_freq` | `GearShift.*` | plugin's `VibrateOnNeutral`/`DebounceMs` have no PitHouse counterpart |
-| `
_road_texture_switch/_intensity/_smoothness` | `RoadTexture.*` | |
-| `
_machinelimit_min` / `_max` | `TravelStartMm` / `TravelEndMm` | **inferred**, see below |
-| `
_softlimit_hardness_press` / `_release` | `EndstopFrontStiffness` / `EndstopEndStiffness` | **inferred**, see below |
-| `brake_press_combine` | `SensorOutputRatioPct` | **inferred**; brake role only (`mbooster-brake-angle-ratio` is written only for Brake) |
+| PitHouse key | Plugin field | Notes |
+| ------------------------------------------------ | ------------------------------------------------------- | -------------------------------------------------------------------------------------- |
+| `
_outdir` | `Direction` | `mbooster-
-dir` |
+| `
_min` / `
_max` | *(not imported)* | **unit mismatch**, see below |
+| `
_nonlinear1..5` | `CurveY[0..4]` | output curve, `mbooster-
-y1..y5`; both sides are 0–100 |
+| `
_abs_switch/_amp/_freq/_smoothness` | `Abs.Enabled/.IntensityPct/.FrequencyHz/.SmoothnessPct` | brake-only in PitHouse |
+| `
_lockup_switch/_amp/_freq` | `Lockup.*` | brake-only |
+| `
_brakethreshold_switch/_amp/_freq` | `Threshold.Enabled/.IntensityPct/.FrequencyHz` | brake-only |
+| `
_brakethreshold_trigger_input` | `Threshold.TriggerLevelPct` | same 50–100 range |
+| `
_brakethreshold_fade_amount` | `Threshold.DecayPct` | UI label "Vibration Decay" |
+| `
_tc_switch/_amp/_freq` | `TractionControl.*` | |
+| `
_wheel_slip_switch/_amp/_freq` | `WheelSpin.*` | plugin's range (30–80 Hz) is narrower than PitHouse's |
+| `
_gear_shift_vibration_switch/_amp/_freq` | `GearShift.*` | plugin's `VibrateOnNeutral`/`DebounceMs` have no PitHouse counterpart |
+| `
_road_texture_switch/_intensity/_smoothness` | `RoadTexture.*` | |
+| `
_machinelimit_min` / `_max` | `TravelStartMm` / `TravelEndMm` | **inferred**, see below |
+| `
_softlimit_hardness_press` / `_release` | `EndstopFrontStiffness` / `EndstopEndStiffness` | **inferred**, see below |
+| `brake_press_combine` | `SensorOutputRatioPct` | **inferred**; brake role only (`mbooster-brake-angle-ratio` is written only for Brake) |
Values are clamped to the plugin's own slider bounds (`MBoosterUiConstants`)
on import, and the travel pair additionally honours `TravelMinGapMm` /
@@ -1693,13 +1886,13 @@ name the mBooster goes to the CRP surface whenever CRP-family pedals are
detected; with none detected it stays on the mBooster path so the "no mBooster
pedal attached" note is what the user sees.
-| PitHouse key | Plugin field | Wire command |
-|---|---|---|
-| `
_outdir` | `MozaProfile.Pedals
Dir` | `pedals-
-dir` |
-| `
_min` / `
_max` | `Pedals
Min` / `Pedals
Max` | `pedals-
-min` / `-max` |
-| `
_nonlinear1..5` | `Pedals
Curve[0..4]` | `pedals-
-y1..y5` |
-| `brake_press_combine` | `PedalsBrakeAngleRatio` | `pedals-brake-angle-ratio` |
-| `
_channlRoleType` | *(not imported)* | — (CRP roles are fixed) |
+| PitHouse key | Plugin field | Wire command |
+| --------------------- | ------------------------------- | -------------------------- |
+| `
_outdir` | `MozaProfile.Pedals
Dir` | `pedals-
-dir` |
+| `
_min` / `
_max` | `Pedals
Min` / `Pedals
Max` | `pedals-
-min` / `-max` |
+| `
_nonlinear1..5` | `Pedals
Curve[0..4]` | `pedals-
-y1..y5` |
+| `brake_press_combine` | `PedalsBrakeAngleRatio` | `pedals-brake-angle-ratio` |
+| `
_channlRoleType` | *(not imported)* | — (CRP roles are fixed) |
`min`/`max` **are** imported here, unlike on the mBooster path: the CRP fields
are percent on both sides (`MozaProfile.PedalsThrottleMin` is documented 0-100,
@@ -1731,11 +1924,16 @@ equivalent of `ImportPlan.TouchedMBoosters`.
16.1–47.0, the same range as `brake_forcelimit_min/max` (11/47, ⇒ likely
**kg**). The throttle preset's equivalents are lighter throughout
(4.3–12.0 kg vs the brake's 16–47), which is what a throttle-vs-brake pedal
- pair should look like. The plugin's `mbooster-brake-curve7-1..6` family
- (`0xAB`, 6 selectors, fed by `ResampleCurveAtSevenths`) is a shape candidate
- for `stroke_curve`, but curve7 is always *derived* from `(CurveX, CurveY)`
- and has no settings field of its own, so this stays unmapped until a capture
- confirms it.
+ pair should look like. The old `mbooster-brake-curve7-1..6` family this
+ paragraph originally pointed at (`0xAB`, 6 selectors, fed by
+ `ResampleCurveAtSevenths`) was removed as an unconfirmed guess (see
+ "Removed: y1..y5 and curve7") — but that exact `0xAB` `0x01`-`0x06`
+ selector range was later confirmed real, as Pedal Feel's own per-node X
+ position (`InputCurveX`, see [Pedal Feel node X position]
+ (#pedal-feel-node-x-position)), which is a plausible match for
+ `stroke_curve` on its face (position-along-travel, mm-scale) — still
+ unconfirmed against this specific PitHouse-export field until a capture
+ ties the two together.
## Source-of-truth files in this repo