From 157eb5d6e19158158e055691482b1a67a78f3518 Mon Sep 17 00:00:00 2001
From: giantorth <56210456+giantorth@users.noreply.github.com>
Date: Wed, 12 Aug 2026 21:23:05 -0700
Subject: [PATCH 01/22] Update README.md
---
README.md | 12 +++++-------
1 file changed, 5 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index fc8b9e00..32482b35 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ Built using the amazing work of [Boxflat](https://github.com/Lawstorant/boxflat)
## Why This Exists
-MOZA makes excellent sim racing hardware, but their companion software — Pithouse — is Windows-only. Linux users have no official way to manage LED effects or stream telemetry to your wheel's dashboard. SimHub, on the other hand, runs on Linux (via Proton/Wine), opening the door for cross-platform hardware control with built-in telemetry support.
+MOZA makes excellent sim racing hardware, but their companion software — Pithouse — is Windows-only. Linux users have no official way to manage LED effects or stream telemetry to your wheel's dashboard. SimHub, on the other hand, runs on Windows and Linux (via Proton/Wine), opening the door for multi-platform hardware control with built-in telemetry support.
This plugin opens up MOZA hardware to the wider world of SimHub. Drive your leds using [ATSR-EVO](https://github.com/ATSR-Alex/ATSR-Hub-EVO/) plugin. Map any data point from the thousands in SimHub to display on your wheel dashboards.
The goal is to expand the functionality of MOZA devices to a wider audience by providing tools that work across multiple platforms.
@@ -79,19 +79,17 @@ Restart SimHub — the plugin appears under Settings > Plugins as "AZOM".
## Videos
+Spanish language with English dub and subtitles availble.
+
-Guide about this plugin from an early tester (español and english dubbing)
-[](https://www.youtube.com/watch?v=iVBn3PWbf4c)
-
+[](https://www.youtube.com/watch?v=apPXgjnGqD0)
-Another video, available in español and english dubbing
-
-[](https://www.youtube.com/watch?v=HPIme1-_cnQ)
+[](https://www.youtube.com/watch?v=D_ZmB0xn_KY)
From 5f43a576f0fdd1864e7378c7a4bfd335eda2bc86 Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Fri, 14 Aug 2026 09:51:57 +1200
Subject: [PATCH 02/22] ignoring vscode folder
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index ab4eb326..cd868be8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
_todo.md
+.vscode/*
## Claude stuff
.claude*
From 9762b9d111492a1d320f03c776ac07b0307ba72b Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Wed, 19 Aug 2026 10:11:43 +1200
Subject: [PATCH 03/22] bug fix for max force and max threshold
miscommunication
---
Devices/MBoosterDeviceController.cs | 42 +++-
Devices/MBoosterTypes.cs | 66 ++++---
Devices/MozaMBoosterRegistry.cs | 143 ++++----------
MozaPlugin.cs | 74 +++++--
Protocol/MozaCommandDatabase.cs | 25 +++
UI/SettingsControl.xaml | 8 +-
UI/SettingsControl.xaml.cs | 116 ++++++-----
docs/protocol/devices/mbooster.md | 292 +++++++++++++++-------------
8 files changed, 426 insertions(+), 340 deletions(-)
diff --git a/Devices/MBoosterDeviceController.cs b/Devices/MBoosterDeviceController.cs
index 14c1a45c..70b26245 100644
--- a/Devices/MBoosterDeviceController.cs
+++ b/Devices/MBoosterDeviceController.cs
@@ -94,10 +94,10 @@ public sealed class MBoosterDeviceController : IDisposable
// (mbooster-brake-threshold); -1 until it answers. Live only, never
// persisted and never copied into MBoosterDeviceSettings.MaxThresholdKg:
// that field's -1 means "user set no override", and seeding it would make
- // the plugin start writing the value back on every connect. Used purely as
- // ApplyDeadzoneAndMaxForce's fullScaleKg reference in place of the old
- // hardcoded 200kg guess. Volatile: written on the serial read thread, read
- // by the HID thread and the UI.
+ // the plugin start writing the value back on every connect. Surfaced in
+ // Diagnostics (see DiagnosticsTextBuilder) as a read-back sanity check.
+ // Volatile: written on the serial read thread, read by the HID thread
+ // and the UI.
private volatile float _deviceReportedMaxThresholdKg = -1;
public float DeviceReportedMaxThresholdKg
{
@@ -1188,9 +1188,16 @@ public bool SendFloatWrite(string commandName, float value, byte? device = null)
/// omitting it is what made Travel Start/End silently no-op on
/// hardware despite the raw register write reading back fine — see
/// MozaCommandDatabase.cs's mbooster-brake-curve7-* comment. Callers
- /// use this after any of Travel/Endstop/Ratio/Threshold's own writes
- /// on the theory that the same firmware requirement applies to all of
- /// them, not just Travel — unconfirmed for the others.
+ /// use this after Direction/Min/Max/CurveY/Endstop/Friction/
+ /// SegmentedDamping/Ratio's own writes too, on the theory that the
+ /// same firmware requirement applies to all of them, not just
+ /// Travel — unconfirmed for those. CONFIRMED NOT required for Max
+ /// Threshold or Deadzone/Max Force specifically: isolated captures
+ /// for both (max-threshold-4-41-105-153-200.pcapng,
+ /// max-force-24-75-128-166-200.pcapng, deadzone-0-5-11-14.pcapng)
+ /// show zero curve7-1..6 traffic alongside their real writes — see
+ /// MozaPlugin.ApplyMBoosterToHardware's needsCurve7Resync and
+ /// MBoosterDeviceController.PushFeelCurveResync.
///
public void PushCurve7Resync(float[]? curveX, float[]? curveY, byte device)
{
@@ -1199,6 +1206,27 @@ public void PushCurve7Resync(float[]? curveX, float[]? curveY, byte device)
SendIntWrite($"mbooster-brake-curve7-{i + 1}", MozaMBoosterProtocol.EncodeCurve7Point(curve7[i]), device);
}
+ ///
+ /// Write Deadzone, Max Force, and the 6 interpolated points between
+ /// them (cmdId 0xAB selectors 0x07-0x0E) as one atomic burst — CONFIRMED
+ /// real hardware calibration, reverse-engineered from
+ /// max-force-24-75-128-166-200.pcapng and deadzone-0-5-11-14.pcapng
+ /// (bug bundle 5VR5AQ8Y): every Deadzone or Max Force change in both
+ /// captures resent the whole 8-value family together, not just the
+ /// field that moved — same "no partial update" shape as Segmented
+ /// Damping. Both values use the identical kg encoding as Max
+ /// Threshold ().
+ /// See .
+ ///
+ public void PushFeelCurveResync(double deadzoneKg, double maxForceKg, byte device)
+ {
+ SendIntWrite("mbooster-brake-deadzone", MozaMBoosterProtocol.EncodeThresholdKg(deadzoneKg), device);
+ var mid = MozaMBoosterRegistry.ComputeFeelCurve(deadzoneKg, maxForceKg);
+ for (int i = 0; i < mid.Length; i++)
+ SendIntWrite($"mbooster-brake-feelcurve-{i + 1}", MozaMBoosterProtocol.EncodeThresholdKg(mid[i]), device);
+ SendIntWrite("mbooster-brake-maxforce", MozaMBoosterProtocol.EncodeThresholdKg(maxForceKg), device);
+ }
+
// ── Coalescing gate for UI-driven calibration writes ──
// A slider raises ValueChanged per tick, and every one of these commands
// is a flash-backed calibration register that additionally drags a
diff --git a/Devices/MBoosterTypes.cs b/Devices/MBoosterTypes.cs
index 3320edee..04b11a94 100644
--- a/Devices/MBoosterTypes.cs
+++ b/Devices/MBoosterTypes.cs
@@ -463,10 +463,12 @@ public sealed class MBoosterPedalSettings : IMBoosterPedalConfig
public float SensorOutputRatioPct { get; set; } = -1;
public float MaxThresholdKg { get; set; } = -1;
- // Pedal Feel (host-side shaping + brake-only wire calibration).
+ // Pedal Feel (InputCurveY is host-side shaping; Deadzone/MaxForce are
+ // real brake-only wire calibration — see MBoosterDeviceSettings for
+ // the field semantics).
public float[]? InputCurveY { get; set; } = null;
- public float DeadzoneKg { get; set; } = 0;
- public float MaxForceKg { get; set; } = 200;
+ public float DeadzoneKg { get; set; } = -1;
+ public float MaxForceKg { get; set; } = -1;
public float TravelStartMm { get; set; } = -1;
public float TravelEndMm { get; set; } = -1;
public float EndstopFrontStiffness { get; set; } = -1;
@@ -621,8 +623,13 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig
// load-cell force to reach 100%, capped at
// MBoosterUiConstants.BrakeFadeMaxThresholdKg — this is what
// actually makes the pedal feel "softer" (more effort needed for
- // the same signal), unlike the host-side-only MaxForceKg, which
- // has no wire command and wouldn't affect what the game receives.
+ // the same signal). MaxForceKg is now also a real wire calibration
+ // (see docs/protocol/devices/mbooster.md "Pedal Feel"), but Brake
+ // Fade deliberately still ramps MaxThresholdKg, not MaxForceKg:
+ // Threshold rescales the sensor's own 0-100% span, while Max Force
+ // only lowers the effort needed below whatever that span already
+ // is — ramping it couldn't demand MORE force than Threshold
+ // already caps at, so it can't reproduce "harder to press."
// Both restore to their configured values as brake temp cools. If
// the user has never configured a given base value, that ONE
// calibration stays fully inert (the other can still ramp
@@ -694,28 +701,29 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig
// docs/protocol/devices/mbooster.md "Pedal Feel".
public float[]? InputCurveY { get; set; } = null;
- // Deadzone at the start of pedal travel, in kg of force (0..40).
- // Host-side only, applied before InputCurveY (a physical/sensor
- // characteristic — the resting force before the load cell means
- // anything — should shape the signal before the user's "feel"
- // curve does). See MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce.
- // 0 = off (default).
- public float DeadzoneKg { get; set; } = 0;
-
- // Force (kg, 0..200) at which the Pedal Feel input curve's X-axis
- // reaches 100%. Host-side only. Raw 0-100% pedal travel isn't a
- // fixed 0-200kg scale — 100% raw is whatever MaxThresholdKg (Sim
- // Input Mapping) currently calibrates the device itself to reach
- // 100% at (200kg is only a fallback guess when MaxThresholdKg is
- // still -1/unset — see MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce).
- // 200 = off IF the device's real threshold is also 200kg; if it's
- // lower (real Pit House captures commonly show ~100-125kg), 200
- // has no additional effect beyond whatever the device already
- // saturates at, since there's no headroom above the device's own
- // calibrated max for software to require more force. Lower it if
- // you never press hard enough to reach the curve's right edge
- // otherwise.
- public float MaxForceKg { get; set; } = 200;
+ // Deadzone at the start of pedal travel, in kg of force (0..40) —
+ // REAL hardware calibration (wire command mbooster-brake-deadzone,
+ // cmdId 0xAB selector 0x07), reverse-engineered from
+ // deadzone-0-5-11-14.pcapng (bug bundle 5VR5AQ8Y). Same kg encoding
+ // as MaxThresholdKg — see MozaMBoosterProtocol.EncodeThresholdKg and
+ // MBoosterDeviceController.PushFeelCurveResync. -1 = "not yet set /
+ // no override", same sentinel convention as every other real
+ // calibration field, so a fresh profile never overwrites whatever
+ // the device already has. Previously host-side-only (0 = off
+ // default); see docs/protocol/devices/mbooster.md "Pedal Feel".
+ public float DeadzoneKg { get; set; } = -1;
+
+ // Force (kg, 0..200) at which the pedal's raw HID axis reaches
+ // 100% travel — REAL hardware calibration (wire command
+ // mbooster-brake-maxforce, cmdId 0xAB selector 0x0E), reverse-
+ // engineered from max-force-24-75-128-166-200.pcapng (bug bundle
+ // 5VR5AQ8Y). Same kg encoding as MaxThresholdKg. Confirmed NOT
+ // clamped to MaxThresholdKg on the wire (128/166kg sent while
+ // Threshold read back 125kg) — it's an independent parameter, not
+ // a rescale of Threshold's own ceiling. -1 = "not yet set / no
+ // override". Previously host-side-only (200 = off default); see
+ // docs/protocol/devices/mbooster.md "Pedal Feel".
+ public float MaxForceKg { get; set; } = -1;
// Start/End of pedal travel, in mm (Pit House's own calibration
// control, not a host-side shim). Reverse-engineered from two real
@@ -745,8 +753,8 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig
public float EndstopEndStiffness { get; set; } = -1;
// Natural Friction (Pit House-style), 0-100%. Real hardware write
- // (not host-side-only like Deadzone/MaxForce) — reverse-engineered
- // from two real Pit House USB captures (a toggle on/off, and a
+ // (like Deadzone/MaxForce above) — reverse-engineered from two
+ // real Pit House USB captures (a toggle on/off, and a
// 0/25/50/75/100% slider sweep): wire commands
// mbooster-brake-friction-0/-1 (cmdId 0xAE with a selector byte,
// always written together with the same value), 2-byte int, fixed
diff --git a/Devices/MozaMBoosterRegistry.cs b/Devices/MozaMBoosterRegistry.cs
index 7262f1eb..b37e6a29 100644
--- a/Devices/MozaMBoosterRegistry.cs
+++ b/Devices/MozaMBoosterRegistry.cs
@@ -425,26 +425,20 @@ public void OnHidAxisUpdate(string identity, string containerId, int axisIndex,
}
if (c == null) return;
- // Pedal Feel — host-side shaping of the raw HID position,
- // applied here so every downstream consumer (position bar,
- // MergePositions -> game telemetry, the effect worker's
- // brake-position fallback) sees the same shaped value. Does not
- // touch CurveY (still written to the device's own output-curve
- // command unchanged) — see docs/protocol/devices/mbooster.md
- // "Pedal Feel". Start/End of Travel (mm) is NOT shaped here —
- // it's a real hardware calibration write (mbooster-brake-travel-
- // start/end); the device's own firmware already clips/rescales
- // the raw signal before this HID read ever sees it.
- // Axis 0 (the master unit's pedal) carries the host-side Pedal Feel
- // shaping (deadzone / max force / input curve) exactly as the
- // single-axis path always has — those controls are calibrated
- // against the master pedal. Chained axes (1+) route raw for now;
- // per-axis Pedal Feel is a follow-up (Stage 3).
- // Per-axis Pedal Feel: shape EACH pedal's HID by ITS OWN config —
- // the master (axis 0) from the lane's flat fields, each chained pedal
- // from its per-pedal entry. Host-side only (deadzone / max force /
- // input curve); the wire calibration is applied separately in
- // ApplyMBoosterToHardware.
+ // Pedal Feel — Deadzone and Max Force are now REAL hardware
+ // calibration (mbooster-brake-deadzone / -maxforce, cmdId 0xAB
+ // selectors 0x07/0x0E — see MBoosterDeviceController
+ // .PushFeelCurveResync and docs/protocol/devices/mbooster.md
+ // "Pedal Feel"): the device reshapes the raw HID axis itself
+ // before this read ever sees it, so there is nothing left to
+ // reshape here. Only InputCurveY remains a host-side-only
+ // shaping step (there is no wire command for it) — applied so
+ // every downstream consumer (position bar, MergePositions ->
+ // game telemetry, the effect worker's brake-position fallback)
+ // sees the same shaped value. Does not touch CurveY (still
+ // written to the device's own output-curve command unchanged).
+ // Per-axis: the master (axis 0) uses the lane's flat fields,
+ // each chained pedal uses its own per-pedal entry.
var laneSettings = _settingsLookup(c.Identity);
IMBoosterPedalConfig? cfg = laneSettings;
if (axisIndex > 0)
@@ -460,12 +454,6 @@ public void OnHidAxisUpdate(string identity, string containerId, int axisIndex,
double posPct = pos01 * 100.0;
if (cfg != null)
{
- // Raw 0-100% HID travel isn't a fixed 0-200kg scale — it's
- // whatever this pedal's OWN Max Threshold calibration (Sim Input
- // Mapping) currently says 100% is.
- double fullScaleKg = ResolveFullScaleKg(cfg, c);
- if (cfg.DeadzoneKg > 0 || cfg.MaxForceKg < fullScaleKg)
- posPct = ApplyDeadzoneAndMaxForce(posPct, cfg.DeadzoneKg, cfg.MaxForceKg, fullScaleKg);
// Store the pre-input-curve percent for EVERY axis so the UI's
// live curve markers follow whichever pedal is selected (axis 0
// also mirrored to LastRawPercentPreCurve for legacy callers).
@@ -491,81 +479,6 @@ public void OnHidAxisUpdate(string identity, string containerId, int axisIndex,
MergePositions();
}
- ///
- /// The force (kg) at which this pedal's raw HID axis reaches 100% — the
- /// reference scale and the Max
- /// Force slider's own ceiling are both expressed against. Three rungs:
- ///
- /// The user's own Max Threshold override, when set (they calibrated
- /// the device to it from this plugin, so it IS the device's scale).
- /// Otherwise the value the DEVICE reported for
- /// mbooster-brake-threshold — a real read-back, not a guess.
- /// Only if neither exists (routed lane / firmware never answered),
- /// the historical 200kg fallback.
- ///
- /// See docs/protocol/devices/mbooster.md "Sim Input Mapping".
- ///
- internal static double ResolveFullScaleKg(IMBoosterPedalConfig? cfg, MBoosterDeviceController? c)
- {
- if (cfg != null && cfg.MaxThresholdKg >= 0) return cfg.MaxThresholdKg;
- float reported = c?.DeviceReportedMaxThresholdKg ?? -1;
- if (reported > 0) return reported;
- return 200.0;
- }
-
- ///
- /// Deadzone + Max Force, in kg of force — both host-side only.
- /// is the force at which raw 0-100%
- /// HID travel reaches 100% — resolve it with
- /// , never inline: getting it wrong
- /// makes Max Threshold read as INVERTED, since it enters here only as
- /// this denominator (bundle KY3HK4QP). Combined into one kg-space remap
- /// rather than two independent percent-space steps:
- ///
- /// Deadzone (0..40kg): force below this clamps to 0.
- /// Max Force (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 (out of the device's
- /// real ) use the curve's full
- /// 0-100% range instead of only ever reaching its midpoint. Values
- /// at or above are a no-op: the raw
- /// axis is already pegged at 100% by the device itself at that
- /// point, so there's no more resolution above it for software to
- /// require.
- ///
- /// Everything between the two rescales linearly. See
- /// docs/protocol/devices/mbooster.md "Pedal Feel".
- ///
- internal static double ApplyDeadzoneAndMaxForce(double xPercent, double deadzoneKg, double maxForceKg, double fullScaleKg)
- {
- if (fullScaleKg <= 0) fullScaleKg = 200.0;
- double loPercent = Math.Max(0, Math.Min(fullScaleKg, deadzoneKg)) / fullScaleKg * 100.0;
- double hiPercent = Math.Max(0, Math.Min(fullScaleKg, maxForceKg)) / fullScaleKg * 100.0;
- return ClipAndRescale(xPercent, loPercent, hiPercent);
- }
-
- ///
- /// Shared clip-and-rescale: positions at or below
- /// clip to 0, positions at or above
- /// clip to 100, everything between
- /// rescales linearly to the full 0-100 range.
- ///
- private static double ClipAndRescale(double xPercent, double loPercent, double hiPercent)
- {
- xPercent = Math.Max(0, Math.Min(100, xPercent));
- loPercent = Math.Max(0, Math.Min(100, loPercent));
- hiPercent = Math.Max(0, Math.Min(100, hiPercent));
-
- double effective = Math.Max(0, xPercent - loPercent);
- double range = hiPercent - loPercent;
- if (range <= 0) return effective > 0 ? 100 : 0;
-
- double result = effective / range * 100.0;
- if (result < 0) return 0;
- if (result > 100) return 100;
- return result;
- }
-
///
/// Evaluate a 5-point Pedal Feel curve at a given X (0..100),
/// reproducing 's
@@ -706,6 +619,34 @@ internal static float[] ResampleCurveAtSevenths(float[]? curveX, float[]? curveY
return result;
}
+ // Fixed fractions of the way from Deadzone to Max Force for the 6
+ // interpolated points the device holds between those two anchors
+ // (mbooster-brake-feelcurve-1..6, cmdId 0xAB selectors 0x08-0x0D) —
+ // empirically measured across both max-force-24-75-128-166-200.pcapng
+ // (Deadzone fixed, Max Force swept 75/128/166kg) and
+ // deadzone-0-5-11-14.pcapng (Max Force fixed, Deadzone swept
+ // 5/11/14kg): (value - deadzone) / (maxForce - deadzone) landed on
+ // the identical constant per selector in all 6 write bursts (std-dev
+ // < 0.0001), cross-validating the same fixed shape regardless of
+ // which endpoint moved. See docs/protocol/devices/mbooster.md
+ // "Pedal Feel" and bug bundle 5VR5AQ8Y.
+ private static readonly double[] FeelCurveFractions =
+ { 0.08049, 0.19495, 0.44245, 0.72433, 0.90040, 0.97910 };
+
+ ///
+ /// The 6 points the device's own Deadzone-to-Max-Force curve holds
+ /// between its two anchors, in kg — see
+ /// and .
+ ///
+ internal static double[] ComputeFeelCurve(double deadzoneKg, double maxForceKg)
+ {
+ double range = maxForceKg - deadzoneKg;
+ var result = new double[FeelCurveFractions.Length];
+ for (int i = 0; i < result.Length; i++)
+ result[i] = deadzoneKg + FeelCurveFractions[i] * range;
+ return result;
+ }
+
///
/// Fallback pairing for when the HID
/// identity doesn't exactly match a known CDC identity. Per
diff --git a/MozaPlugin.cs b/MozaPlugin.cs
index ae4f5925..bb19b730 100644
--- a/MozaPlugin.cs
+++ b/MozaPlugin.cs
@@ -2672,7 +2672,15 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos
else if (axis == soleAxis) cfg = s;
else continue;
- bool wroteAnyCalibration = false;
+ // Named for what it gates below, NOT "wrote anything" — Max
+ // Threshold and Deadzone/Max Force are deliberately excluded
+ // (see their own write blocks below) because isolated capture
+ // evidence now DISCONFIRMS the curve7-1..6 resync for them
+ // specifically (bug bundle 5VR5AQ8Y's max-threshold-4-41-105-
+ // 153-200.pcapng shows zero 0xAB traffic of any kind
+ // alongside 4 clean Threshold writes) — unlike Travel, which
+ // pedal_travel.pcapng directly confirmed DOES need it.
+ bool needsCurve7Resync = false;
// Every per-pedal calibration here is a PHYSICAL setting stored
// on that pedal's own mBooster unit (confirmed on hardware: each
@@ -2689,12 +2697,12 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos
: role == global::MozaPlugin.Devices.MBoosterRole.Clutch ? 2 : -1;
byte dev = controller.MotorDeviceForRole(roleIdx, axis);
- if (cfg.Direction >= 0) { controller.SendIntWrite($"mbooster-{prefix}-dir", cfg.Direction, dev); wroteAnyCalibration = true; }
- if (cfg.Min >= 0) { controller.SendIntWrite($"mbooster-{prefix}-min", cfg.Min, dev); wroteAnyCalibration = true; }
- if (cfg.Max >= 0) { controller.SendIntWrite($"mbooster-{prefix}-max", cfg.Max, dev); wroteAnyCalibration = true; }
+ if (cfg.Direction >= 0) { controller.SendIntWrite($"mbooster-{prefix}-dir", cfg.Direction, dev); needsCurve7Resync = true; }
+ if (cfg.Min >= 0) { controller.SendIntWrite($"mbooster-{prefix}-min", cfg.Min, dev); needsCurve7Resync = true; }
+ if (cfg.Max >= 0) { controller.SendIntWrite($"mbooster-{prefix}-max", cfg.Max, dev); needsCurve7Resync = true; }
if (cfg.CurveY != null && cfg.CurveY.Length == 5)
{
- wroteAnyCalibration = true;
+ needsCurve7Resync = true;
// Resample at the fixed 20/40/60/80/100 breakpoints in case
// CurveX has been horizontally dragged (see
// MozaMBoosterRegistry.ResampleCurveAtFixedBreakpoints) —
@@ -2719,32 +2727,32 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos
{
controller.SendIntWrite("mbooster-brake-travel-start",
global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeTravelMm(cfg.TravelStartMm), dev);
- wroteAnyCalibration = true;
+ needsCurve7Resync = true;
}
if (ownsPedalFeelHardware && cfg.TravelEndMm >= 0)
{
controller.SendIntWrite("mbooster-brake-travel-end",
global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeTravelMm(cfg.TravelEndMm), dev);
- wroteAnyCalibration = true;
+ needsCurve7Resync = true;
}
if (ownsPedalFeelHardware && cfg.EndstopFrontStiffness >= 0)
{
controller.SendIntWrite("mbooster-brake-endstop-front",
global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeEndstopStiffness(cfg.EndstopFrontStiffness), dev);
- wroteAnyCalibration = true;
+ needsCurve7Resync = true;
}
if (ownsPedalFeelHardware && cfg.EndstopEndStiffness >= 0)
{
controller.SendIntWrite("mbooster-brake-endstop-end",
global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeEndstopStiffness(cfg.EndstopEndStiffness), dev);
- wroteAnyCalibration = true;
+ needsCurve7Resync = true;
}
if (ownsPedalFeelHardware && cfg.NaturalFrictionPct >= 0)
{
int frictionRaw = global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeFrictionPct(cfg.NaturalFrictionPct);
controller.SendIntWrite("mbooster-brake-friction-0", frictionRaw, dev);
controller.SendIntWrite("mbooster-brake-friction-1", frictionRaw, dev);
- wroteAnyCalibration = true;
+ needsCurve7Resync = true;
}
// Segmented Damping (both "When Pressed" and "When
// Released" — see cfg.SegmentedDamping). One wire command
@@ -2774,32 +2782,60 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos
sd.Seg3Released >= 0 ? sd.Seg3Released : c,
dev);
controller.SendOneShot(frame);
- wroteAnyCalibration = true;
+ needsCurve7Resync = true;
}
if (role == global::MozaPlugin.Devices.MBoosterRole.Brake)
{
if (cfg.SensorOutputRatioPct >= 0)
{
controller.SendFloatWrite("mbooster-brake-angle-ratio", cfg.SensorOutputRatioPct, dev);
- wroteAnyCalibration = true;
+ needsCurve7Resync = true;
}
+ // Max Threshold does NOT set needsCurve7Resync — see the
+ // variable's own doc comment above. Confirmed by an
+ // isolated capture (max-threshold-4-41-105-153-200.pcapng,
+ // Max Force held static): zero 0xAB traffic of any kind
+ // alongside 4 clean Threshold writes.
if (cfg.MaxThresholdKg >= 0)
{
controller.SendIntWrite("mbooster-brake-threshold",
global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeThresholdKg(cfg.MaxThresholdKg), dev);
- wroteAnyCalibration = true;
}
}
+ // Deadzone / Max Force — CONFIRMED real hardware calibration
+ // (see MBoosterDeviceController.PushFeelCurveResync). Fresh
+ // profile with neither set (-1) sends nothing, same guarantee
+ // as every other calibration write here. Once EITHER is set,
+ // the whole 8-value family is pushed together (the device has
+ // no partial-update form for it), using the pedal's own sane
+ // "off" default for whichever side has no override — 0kg
+ // deadzone, 200kg max force (an out-of-range pedal never
+ // presses hard enough for Max Force to matter). Does NOT set
+ // needsCurve7Resync: both max-force-24-75-128-166-200.pcapng
+ // and deadzone-0-5-11-14.pcapng show this family's own
+ // resync (selectors 0x07-0x0E) is everything the device
+ // needs — neither ever included a curve7-1..6 (selectors
+ // 0x01-0x06) frame.
+ if (ownsPedalFeelHardware && (cfg.DeadzoneKg >= 0 || cfg.MaxForceKg >= 0))
+ {
+ double dz = cfg.DeadzoneKg >= 0 ? cfg.DeadzoneKg : 0;
+ double mf = cfg.MaxForceKg >= 0 ? cfg.MaxForceKg : 200;
+ controller.PushFeelCurveResync(dz, mf, dev);
+ }
+
// EXPERIMENTAL / unverified — confirmed on hardware to be
// required for a Travel edit to actually take effect; applied
// here too on the theory the same firmware requirement covers
- // every write above, not just Travel. See
- // MBoosterDeviceController.PushCurve7Resync. Guarded like the
- // writes above (not unconditional) to preserve this method's
- // "fresh profile with no overrides produces zero hardware
- // writes" guarantee.
- if (wroteAnyCalibration)
+ // Direction/Min/Max/CurveY/Endstop/Friction/SegmentedDamping/
+ // Ratio as well — unconfirmed for those specifically, unlike
+ // Threshold and Deadzone/MaxForce (see needsCurve7Resync's own
+ // comment), which now have direct capture evidence against
+ // it. See MBoosterDeviceController.PushCurve7Resync. Guarded
+ // like the writes above (not unconditional) to preserve this
+ // method's "fresh profile with no overrides produces zero
+ // hardware writes" guarantee.
+ if (needsCurve7Resync)
controller.PushCurve7Resync(cfg.CurveX, cfg.CurveY, dev);
}
}
diff --git a/Protocol/MozaCommandDatabase.cs b/Protocol/MozaCommandDatabase.cs
index 8fb74a16..0dc5ef72 100644
--- a/Protocol/MozaCommandDatabase.cs
+++ b/Protocol/MozaCommandDatabase.cs
@@ -618,6 +618,31 @@ static MozaCommandDatabase()
AddCommand("mbooster-brake-curve7-4", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x04 }, 2, "int");
AddCommand("mbooster-brake-curve7-5", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x05 }, 2, "int");
AddCommand("mbooster-brake-curve7-6", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x06 }, 2, "int");
+ // Pit House "Deadzone" and "Max Force" (Pedal Feel) — CONFIRMED real
+ // hardware calibration, reverse-engineered from two real Pit House
+ // captures (max-force-24-75-128-166-200.pcapng,
+ // deadzone-0-5-11-14.pcapng — see bug bundle 5VR5AQ8Y). Same cmdId
+ // 0xAB indexed-register family as curve7-1..6 above, but a
+ // DIFFERENT selector range (0x07-0x0E) carrying a genuinely separate
+ // 8-point curve: selector 0x07 = Deadzone, selector 0x0E = Max
+ // Force, both in kg using the identical encoding as Max Threshold
+ // (raw = round(kg * 65536 / 200) — see
+ // MozaMBoosterProtocol.EncodeThresholdKg). Selectors 0x08-0x0D are
+ // 6 interpolated points between the two anchors — see
+ // MozaMBoosterRegistry.ComputeFeelCurve. Unlike curve7-1..6 (never
+ // confirmed as a real requirement), every Max Force / Deadzone
+ // sweep in both captures resent this whole 8-value family as one
+ // atomic burst, and real Pit House does NOT clamp Max Force to Max
+ // Threshold (128kg/166kg were sent while Threshold read back as
+ // 125kg) — see docs/protocol/devices/mbooster.md "Pedal Feel".
+ AddCommand("mbooster-brake-deadzone", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x07 }, 2, "int");
+ AddCommand("mbooster-brake-feelcurve-1", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x08 }, 2, "int");
+ AddCommand("mbooster-brake-feelcurve-2", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x09 }, 2, "int");
+ AddCommand("mbooster-brake-feelcurve-3", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x0A }, 2, "int");
+ AddCommand("mbooster-brake-feelcurve-4", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x0B }, 2, "int");
+ AddCommand("mbooster-brake-feelcurve-5", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x0C }, 2, "int");
+ AddCommand("mbooster-brake-feelcurve-6", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x0D }, 2, "int");
+ AddCommand("mbooster-brake-maxforce", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x0E }, 2, "int");
// Pit House "End Stop Stiffness" (Front Limit / End Limit) —
// reverse-engineered from two real Pit House USB captures, each
// sweeping one slider through all 10 values (1-10). Both share
diff --git a/UI/SettingsControl.xaml b/UI/SettingsControl.xaml
index c5b5a9eb..0c39000c 100644
--- a/UI/SettingsControl.xaml
+++ b/UI/SettingsControl.xaml
@@ -1893,6 +1893,11 @@
Text="1"/>
+
+
@@ -1921,14 +1926,13 @@
-
+
diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs
index 14ebffb2..8edb06af 100644
--- a/UI/SettingsControl.xaml.cs
+++ b/UI/SettingsControl.xaml.cs
@@ -3263,10 +3263,11 @@ 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;
@@ -3286,29 +3287,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 +3475,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 +3496,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;
}
@@ -4284,9 +4264,14 @@ private static byte MBoosterCalibDevice(global::MozaPlugin.Devices.MBoosterDevic
/// 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.
+ /// be reordered ahead of the write it is committing — unless
+ /// is false, which
+ /// MBoosterMaxThresholdSlider_ValueChanged passes: an isolated capture
+ /// (max-threshold-4-41-105-153-200.pcapng) shows zero 0xAB traffic of
+ /// any kind alongside 4 clean Threshold writes, directly disconfirming
+ /// the resync for Threshold specifically.
///
- private void QueueMBoosterCalibPush(string key, Action push)
+ private void QueueMBoosterCalibPush(string key, Action push, bool includeCurve7Resync = true)
{
var controller = CurrentMBoosterController();
if (controller == null) return;
@@ -4296,10 +4281,33 @@ private void QueueMBoosterCalibPush(string key, Action
{
push(controller, dev);
- controller.PushCurve7Resync(curveX, curveY, dev);
+ if (includeCurve7Resync) controller.PushCurve7Resync(curveX, curveY, 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;
+ controller.QueueCalibWrite($"{dev:x2}:feel-curve", () =>
+ controller.PushFeelCurveResync(dz, mf, dev));
+ }
+
private void MBoosterTravelRangeSlider_RangeChanged(object sender, EventArgs e)
{
if (_suppressEvents) return;
@@ -4320,10 +4328,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 +4343,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
@@ -4368,7 +4385,11 @@ private void MBoosterRatioSlider_ValueChanged(object sender, RoutedPropertyChang
// mbooster-brake-threshold (cmdId 0xB3), a 4-byte big-endian raw
// uint (NOT a float) on a fixed 0-200kg scale — see
// MozaMBoosterProtocol.EncodeThresholdKg and
- // docs/protocol/devices/mbooster.md "Sim Input Mapping".
+ // docs/protocol/devices/mbooster.md "Sim Input Mapping". No curve7
+ // resync (unlike every other QueueMBoosterCalibPush caller): an
+ // isolated capture (max-threshold-4-41-105-153-200.pcapng, Max Force
+ // held static) shows zero 0xAB traffic of any kind alongside 4
+ // clean Threshold writes.
private void MBoosterMaxThresholdSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) =>
OnIntSliderChanged(e.NewValue, MBoosterMaxThresholdValue, "", v =>
{
@@ -4377,11 +4398,8 @@ private void MBoosterMaxThresholdSlider_ValueChanged(object sender, RoutedProper
s.MaxThresholdKg = v;
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);
+ global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeThresholdKg(v), dev),
+ includeCurve7Resync: false);
});
// End Stop Stiffness (Front Limit / End Limit), 1-10 — Pit House's
diff --git a/docs/protocol/devices/mbooster.md b/docs/protocol/devices/mbooster.md
index 37c53f97..7a8c8f4e 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,16 +938,24 @@ 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
@@ -994,7 +1002,7 @@ 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)
+## Pedal Feel
A card above Sim Input Mapping holds a second 5-point curve,
`InputCurveY` on `MBoosterDeviceSettings`. Unlike `CurveY`, this one has
@@ -1124,7 +1132,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 +1180,84 @@ 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.
+### 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.
+
+`InputCurveY` (the 5-point curve above) remains genuinely host-side —
+no wire command was found for it in either capture, and it still shapes
+`MozaData.{Throttle,Brake,Clutch}Position` (the `AZOM.*` properties, the
+pedal traces, the live curve markers) *after* whatever the device now
+delivers already-shaped on the raw HID axis.
### Traction Control — new effect, no verified wire type
@@ -1278,7 +1304,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 +1489,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 +1518,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 +1601,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 +1625,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 |
-|---|---|---|
-| `
_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 +1719,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 |
-|---|---|---|
-| `
_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,
From e825d6b22e6631a458c290ee9aa2e5712d75f386 Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Wed, 19 Aug 2026 12:04:00 +1200
Subject: [PATCH 04/22] redesign input curve editors to match pithouse config
---
Devices/MBoosterDeviceController.cs | 67 +++------
Devices/MBoosterTypes.cs | 57 ++++---
Devices/MozaMBoosterRegistry.cs | 208 ++++++++++---------------
MozaPlugin.cs | 166 ++++++++++++--------
Protocol/MozaCommandDatabase.cs | 71 +++------
Protocol/MozaMBoosterProtocol.cs | 23 ---
UI/Controls/MozaCurveEditor.cs | 34 +++--
UI/Import/PitHousePedalsMapper.cs | 29 +++-
UI/MozaPluginSettings.cs | 7 +
UI/SettingsControl.Redesign.cs | 14 +-
UI/SettingsControl.xaml | 31 ++--
UI/SettingsControl.xaml.cs | 226 +++++++++++++++-------------
docs/protocol/devices/mbooster.md | 181 +++++++++++++++-------
13 files changed, 583 insertions(+), 531 deletions(-)
diff --git a/Devices/MBoosterDeviceController.cs b/Devices/MBoosterDeviceController.cs
index 70b26245..dc701f4e 100644
--- a/Devices/MBoosterDeviceController.cs
+++ b/Devices/MBoosterDeviceController.cs
@@ -1181,62 +1181,43 @@ public bool SendFloatWrite(string commandName, float value, byte? device = null)
}
///
- /// EXPERIMENTAL / unverified — resend the output curve at 7
- /// breakpoints (mbooster-brake-curve7-*, cmdId 0xAB) after a
- /// real hardware calibration write. pedal_travel.pcapng showed Pit
- /// House doing exactly this alongside a Travel Start write, and
- /// omitting it is what made Travel Start/End silently no-op on
- /// hardware despite the raw register write reading back fine — see
- /// MozaCommandDatabase.cs's mbooster-brake-curve7-* comment. Callers
- /// use this after Direction/Min/Max/CurveY/Endstop/Friction/
- /// SegmentedDamping/Ratio's own writes too, on the theory that the
- /// same firmware requirement applies to all of them, not just
- /// Travel — unconfirmed for those. CONFIRMED NOT required for Max
- /// Threshold or Deadzone/Max Force specifically: isolated captures
- /// for both (max-threshold-4-41-105-153-200.pcapng,
- /// max-force-24-75-128-166-200.pcapng, deadzone-0-5-11-14.pcapng)
- /// show zero curve7-1..6 traffic alongside their real writes — see
- /// MozaPlugin.ApplyMBoosterToHardware's needsCurve7Resync and
- /// MBoosterDeviceController.PushFeelCurveResync.
- ///
- public void PushCurve7Resync(float[]? curveX, float[]? curveY, byte device)
- {
- var curve7 = MozaMBoosterRegistry.ResampleCurveAtSevenths(curveX, curveY);
- for (int i = 0; i < curve7.Length; i++)
- SendIntWrite($"mbooster-brake-curve7-{i + 1}", MozaMBoosterProtocol.EncodeCurve7Point(curve7[i]), device);
- }
-
- ///
- /// Write Deadzone, Max Force, and the 6 interpolated points between
- /// them (cmdId 0xAB selectors 0x07-0x0E) as one atomic burst — CONFIRMED
- /// real hardware calibration, reverse-engineered from
+ /// Write Deadzone, Max Force, and the Pedal Feel curve's 6 nodes
+ /// between them (cmdId 0xAB selectors 0x07-0x0E) as one atomic burst —
+ /// CONFIRMED real hardware calibration, reverse-engineered from
/// max-force-24-75-128-166-200.pcapng and deadzone-0-5-11-14.pcapng
/// (bug bundle 5VR5AQ8Y): every Deadzone or Max Force change in both
/// captures resent the whole 8-value family together, not just the
/// field that moved — same "no partial update" shape as Segmented
- /// Damping. Both values use the identical kg encoding as Max
- /// Threshold ().
- /// See .
+ /// Damping. All three use the identical kg encoding as Max Threshold
+ /// ().
+ /// is the Pedal Feel curve's own 6
+ /// user-adjustable nodes (0-100%, null/wrong-length = use the
+ /// default Linear shape) — see
+ /// .
///
- public void PushFeelCurveResync(double deadzoneKg, double maxForceKg, byte device)
+ public void PushFeelCurveResync(double deadzoneKg, double maxForceKg, float[]? inputCurveY, byte device)
{
SendIntWrite("mbooster-brake-deadzone", MozaMBoosterProtocol.EncodeThresholdKg(deadzoneKg), device);
- var mid = MozaMBoosterRegistry.ComputeFeelCurve(deadzoneKg, maxForceKg);
+ var mid = MozaMBoosterRegistry.ComputeFeelCurve(deadzoneKg, maxForceKg, inputCurveY);
for (int i = 0; i < mid.Length; i++)
SendIntWrite($"mbooster-brake-feelcurve-{i + 1}", MozaMBoosterProtocol.EncodeThresholdKg(mid[i]), device);
SendIntWrite("mbooster-brake-maxforce", MozaMBoosterProtocol.EncodeThresholdKg(maxForceKg), device);
}
// ── Coalescing gate for UI-driven calibration writes ──
- // A slider raises ValueChanged per tick, and every one of these commands
- // is a flash-backed calibration register that additionally drags a
- // 6-frame PushCurve7Resync burst behind it. Bundle KY3HK4QP shows what
- // that costs unthrottled: a ~2 s Max Threshold drag emitted 77 threshold
- // + 462 curve7 frames, ~40 writes/second into flash. So UI writes are
- // parked in a latest-wins slot per (device, command) and flushed once the
- // user stops moving, collapsing a whole drag into one write set. Same
- // pending+coalesce+throttle shape HardwareApplier.QueueWheelCfgWrite uses
- // for the wheel's own flash-backed writes, minus its change cache.
+ // A slider raises ValueChanged per tick, and every one of these
+ // commands is a flash-backed calibration register — writing it on
+ // every tick of a drag would hammer flash unnecessarily. (An
+ // earlier design also dragged a 6-frame curve7 resync behind every
+ // write here, motivated by bundle KY3HK4QP's "~2s Max Threshold
+ // drag emitted 77 threshold + 462 curve7 frames" cost — that resync
+ // was later removed as unconfirmed/unneeded, but the coalescing
+ // below is still worth it purely for the primary writes.) UI writes
+ // are parked in a latest-wins slot per (device, command) and
+ // flushed once the user stops moving, collapsing a whole drag into
+ // one write set. Same pending+coalesce+throttle shape
+ // HardwareApplier.QueueWheelCfgWrite uses for the wheel's own
+ // flash-backed writes, minus its change cache.
//
// The connect-time apply (MozaPlugin.ApplyMBoosterToHardware) deliberately
// does NOT go through this — it fires once and must not be deferred.
diff --git a/Devices/MBoosterTypes.cs b/Devices/MBoosterTypes.cs
index 04b11a94..343b012e 100644
--- a/Devices/MBoosterTypes.cs
+++ b/Devices/MBoosterTypes.cs
@@ -134,6 +134,17 @@ public static class MBoosterUiConstants
public const float SegDampDivider1ReleasedDefaultPct = 20f;
public const float SegDampDivider2ReleasedDefaultPct = 70f;
public const float SegDampSegDefaultPct = 0f;
+
+ // Node counts for the two mBooster curve editors (both were 5-point
+ // originally). Sim Input Mapping (CurveY/CurveX) is purely host-side,
+ // no wire command — see MozaMBoosterRegistry.EvaluateCurveArbitraryX.
+ // Pedal Feel (InputCurveY) is a REAL hardware write, populating
+ // mbooster-brake-feelcurve-1..6 (cmdId 0xAB selectors 0x08-0x0D) —
+ // see MozaMBoosterRegistry.ComputeFeelCurve and
+ // MBoosterDeviceController.PushFeelCurveResync. See
+ // docs/protocol/devices/mbooster.md "Sim Input Mapping" / "Pedal Feel".
+ public const int SimInputMappingNodeCount = 6;
+ public const int PedalFeelNodeCount = 6;
}
///
@@ -456,16 +467,16 @@ public sealed class MBoosterPedalSettings : IMBoosterPedalConfig
public int Direction { get; set; } = -1;
public int Min { get; set; } = -1;
public int Max { get; set; } = -1;
- public float[]? CurveY { get; set; } = null; // 5-point output curve
+ public float[]? CurveY { get; set; } = null; // 6-point output curve (host-side only)
public float[]? CurveX { get; set; } = null; // draggable node X (null = fixed breakpoints)
// Sim Input Mapping (see MBoosterDeviceSettings for the field semantics).
public float SensorOutputRatioPct { get; set; } = -1;
public float MaxThresholdKg { get; set; } = -1;
- // Pedal Feel (InputCurveY is host-side shaping; Deadzone/MaxForce are
+ // Pedal Feel — InputCurveY (6-point), Deadzone, and MaxForce are ALL
// real brake-only wire calibration — see MBoosterDeviceSettings for
- // the field semantics).
+ // the field semantics.
public float[]? InputCurveY { get; set; } = null;
public float DeadzoneKg { get; set; } = -1;
public float MaxForceKg { get; set; } = -1;
@@ -648,19 +659,23 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig
public int Direction { get; set; } = -1;
public int Min { get; set; } = -1;
public int Max { get; set; } = -1;
- public float[]? CurveY { get; set; } = null; // 5-point output curve
+
+ // Sim Input Mapping output curve (6-point) — PURELY host-side, no
+ // wire command at all. Remaps the pedal's raw HID position (which
+ // by this point already reflects Deadzone/Max Force/the Pedal Feel
+ // curve's hardware shaping) into what AZOM reports as game
+ // telemetry (MozaData.{Throttle,Brake,Clutch}Position) — see
+ // MozaMBoosterRegistry.OnHidAxisUpdate/EvaluateCurveArbitraryX and
+ // docs/protocol/devices/mbooster.md "Sim Input Mapping". CurveY
+ // holds the 6 node Y-values; CurveX (below) holds their X
+ // positions, draggable in the curve editor. Null = identity / no
+ // remapping — existing profiles are unaffected until the user
+ // opens this section.
+ public float[]? CurveY { get; set; } = null;
// X position (0..100) of each output-curve node, draggable in the
// Sim Input Mapping curve editor. Null = default fixed breakpoints
- // (20/40/60/80/100 — identical to every other curve in the app).
- // There is no hardware command for this (unlike the wheelbase's own
- // FFB curve, which has base-ffb-curve-x1..x4) — moving a node here
- // instead RESAMPLES the (CurveX, CurveY) shape at the fixed
- // 20/40/60/80/100 breakpoints and pushes those 5 values through the
- // existing mbooster-throttle-y1..y5 commands, so "100% output before
- // 100% input" works using only the wire commands that actually
- // exist. See MozaMBoosterRegistry.EvaluateCurveArbitraryX and
- // docs/protocol/devices/mbooster.md "Sim Input Mapping".
+ // (100/7 * k for k=1..6 — see MozaMBoosterRegistry.DefaultCurveX).
public float[]? CurveX { get; set; } = null;
// Per-pedal calibration for the ADDITIONAL pedals on a chained mBooster
@@ -690,14 +705,14 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig
// value is already on the device.
public float MaxThresholdKg { get; set; } = -1;
- // Pedal Feel (Pit House-style). Host-side only — there is no wire
- // command for this; it shapes the raw HID axis position BEFORE it
- // becomes MozaData.{Throttle,Brake,Clutch}Position (and before the
- // effect worker's brake-position fallback), independent of CurveY
- // (which still writes to the device's own output-curve command
- // unchanged). Null = identity / no shaping — existing profiles are
- // unaffected until the user opens the new Pedal Feel section. See
- // MozaMBoosterRegistry.EvaluateInputCurve and
+ // Pedal Feel input curve (6-point, Pit House-style) — REAL hardware
+ // calibration: its nodes (0-100% of the Deadzone-Max Force span)
+ // populate mbooster-brake-feelcurve-1..6 directly (cmdId 0xAB
+ // selectors 0x08-0x0D). Null = use the default Linear shape
+ // (MozaMBoosterRegistry.FeelCurveFractions) — existing profiles are
+ // unaffected until the user opens this section. See
+ // MozaMBoosterRegistry.ComputeFeelCurve,
+ // MBoosterDeviceController.PushFeelCurveResync, and
// docs/protocol/devices/mbooster.md "Pedal Feel".
public float[]? InputCurveY { get; set; } = null;
diff --git a/Devices/MozaMBoosterRegistry.cs b/Devices/MozaMBoosterRegistry.cs
index b37e6a29..cab59ed6 100644
--- a/Devices/MozaMBoosterRegistry.cs
+++ b/Devices/MozaMBoosterRegistry.cs
@@ -425,20 +425,23 @@ public void OnHidAxisUpdate(string identity, string containerId, int axisIndex,
}
if (c == null) return;
- // Pedal Feel — Deadzone and Max Force are now REAL hardware
- // calibration (mbooster-brake-deadzone / -maxforce, cmdId 0xAB
- // selectors 0x07/0x0E — see MBoosterDeviceController
- // .PushFeelCurveResync and docs/protocol/devices/mbooster.md
- // "Pedal Feel"): the device reshapes the raw HID axis itself
- // before this read ever sees it, so there is nothing left to
- // reshape here. Only InputCurveY remains a host-side-only
- // shaping step (there is no wire command for it) — applied so
- // every downstream consumer (position bar, MergePositions ->
- // game telemetry, the effect worker's brake-position fallback)
- // sees the same shaped value. Does not touch CurveY (still
- // written to the device's own output-curve command unchanged).
- // Per-axis: the master (axis 0) uses the lane's flat fields,
- // each chained pedal uses its own per-pedal entry.
+ // Pedal Feel — Deadzone, Max Force, and the Pedal Feel curve
+ // (InputCurveY) are all now REAL hardware calibration
+ // (mbooster-brake-deadzone/-maxforce/-feelcurve-1..6, cmdId
+ // 0xAB selectors 0x07-0x0E — see
+ // MBoosterDeviceController.PushFeelCurveResync and
+ // docs/protocol/devices/mbooster.md "Pedal Feel"): the device
+ // reshapes the raw HID axis itself before this read ever sees
+ // it, so there is nothing left to reshape here for those.
+ // Sim Input Mapping (CurveY/CurveX) is the opposite: it has NO
+ // wire command at all (see docs "Sim Input Mapping") — it
+ // remaps THIS already-hardware-shaped value into what AZOM
+ // reports to the sim, applied here so every downstream
+ // consumer (position bar, MergePositions -> game telemetry,
+ // the effect worker's brake-position fallback) sees the same
+ // remapped value. Per-axis: the master (axis 0) uses the
+ // lane's flat fields, each chained pedal uses its own per-pedal
+ // entry.
var laneSettings = _settingsLookup(c.Identity);
IMBoosterPedalConfig? cfg = laneSettings;
if (axisIndex > 0)
@@ -454,13 +457,13 @@ public void OnHidAxisUpdate(string identity, string containerId, int axisIndex,
double posPct = pos01 * 100.0;
if (cfg != null)
{
- // Store the pre-input-curve percent for EVERY axis so the UI's
+ // Store the pre-remap percent for EVERY axis so the UI's
// live curve markers follow whichever pedal is selected (axis 0
// also mirrored to LastRawPercentPreCurve for legacy callers).
if (axisIndex < c.LastAxisRawPercentPreCurve.Length) c.LastAxisRawPercentPreCurve[axisIndex] = posPct;
if (axisIndex == 0) c.LastRawPercentPreCurve = posPct;
- if (cfg.InputCurveY != null && cfg.InputCurveY.Length == 5)
- posPct = EvaluateInputCurve(cfg.InputCurveY, posPct);
+ if (cfg.CurveY != null && cfg.CurveY.Length == MBoosterUiConstants.SimInputMappingNodeCount)
+ posPct = EvaluateCurveArbitraryX(cfg.CurveX ?? DefaultCurveX, cfg.CurveY, posPct);
}
else
{
@@ -479,51 +482,6 @@ public void OnHidAxisUpdate(string identity, string containerId, int axisIndex,
MergePositions();
}
- ///
- /// Evaluate a 5-point Pedal Feel curve at a given X (0..100),
- /// reproducing 's
- /// Catmull-Rom rendering exactly (same 1/6-tangent formula, anchored
- /// at the origin) so the applied shaping matches what the user sees
- /// drawn. holds the 5 node Y-values for
- /// X=20,40,60,80,100; X=0 is an implicit (0,0) anchor. The control
- /// points this formula produces always fall between their segment's
- /// endpoints in X, so the segment's X(t) is monotonic — bisection
- /// reliably inverts it to find t for the requested X.
- ///
- internal static double EvaluateInputCurve(float[] y, double x)
- {
- if (y == null || y.Length != 5) return x;
- x = Math.Max(0, Math.Min(100, x));
-
- var xs = new double[] { 0, 20, 40, 60, 80, 100, 100 };
- var ys = new double[] { 0, y[0], y[1], y[2], y[3], y[4], y[4] };
-
- int i = (int)Math.Min(4, Math.Floor(x / 20.0));
- int p0i = i == 0 ? 0 : i - 1;
- int p2i = i + 1;
- int p3i = (i + 2 >= xs.Length) ? i + 1 : i + 2;
-
- double p0x = xs[p0i], p0y = ys[p0i];
- double p1x = xs[i], p1y = ys[i];
- double p2x = xs[p2i], p2y = ys[p2i];
- double p3x = xs[p3i], p3y = ys[p3i];
-
- double c1x = p1x + (p2x - p0x) / 6.0, c1y = p1y + (p2y - p0y) / 6.0;
- double c2x = p2x - (p3x - p1x) / 6.0, c2y = p2y - (p3y - p1y) / 6.0;
-
- double lo = 0, hi = 1;
- for (int iter = 0; iter < 24; iter++)
- {
- double t = (lo + hi) / 2.0;
- double bx = CubicBezier(p1x, c1x, c2x, p2x, t);
- if (bx < x) lo = t; else hi = t;
- }
- double result = CubicBezier(p1y, c1y, c2y, p2y, (lo + hi) / 2.0);
- if (result < 0) result = 0;
- if (result > 100) result = 100;
- return result;
- }
-
private static double CubicBezier(double p0, double c1, double c2, double p1, double t)
{
double mt = 1 - t;
@@ -531,26 +489,39 @@ private static double CubicBezier(double p0, double c1, double c2, double p1, do
}
///
- /// Same Catmull-Rom evaluation as ,
- /// generalized to arbitrary (draggable) node X positions instead of
- /// the fixed 20/40/60/80/100 — used for the Sim Input Mapping output
- /// curve's horizontal node drag (MBoosterDeviceSettings.CurveX).
- /// Beyond the last node's X, returns that node's Y (flat plateau) —
- /// this is what makes "100% output before 100% input" work: drag the
- /// last node left and everything past it just stays at that Y.
+ /// Catmull-Rom evaluation generalized to arbitrary (draggable) node
+ /// X positions instead of a fixed spacing — used for the Sim Input
+ /// Mapping output curve's horizontal node drag
+ /// (MBoosterDeviceSettings.CurveX/CurveY). Purely
+ /// host-side (see docs/protocol/devices/mbooster.md "Sim Input
+ /// Mapping") — this remaps the pedal's already-hardware-shaped raw
+ /// HID position into what AZOM reports as game telemetry; there is
+ /// no wire command for it. Beyond the last node's X, returns that
+ /// node's Y (flat plateau) — this is what makes "100% output
+ /// before 100% input" work: drag the last node left and everything
+ /// past it just stays at that Y. Node count is derived from
+ /// 's own length (not hardcoded to the current
+ /// ) so
+ /// this same evaluator can also resample an OLDER saved curve (e.g.
+ /// a legacy 5-node one) at a NEW breakpoint set during migration —
+ /// see MozaPlugin's curve-array migration.
///
internal static double EvaluateCurveArbitraryX(float[] xs, float[] ys, double x)
{
- if (xs == null || ys == null || xs.Length != 5 || ys.Length != 5) return x;
+ if (xs == null || ys == null || xs.Length < 2 || xs.Length != ys.Length) return x;
+ int n = xs.Length;
- var px = new double[] { 0, xs[0], xs[1], xs[2], xs[3], xs[4], xs[4] };
- var py = new double[] { 0, ys[0], ys[1], ys[2], ys[3], ys[4], ys[4] };
+ var px = new double[n + 2];
+ var py = new double[n + 2];
+ px[0] = 0; py[0] = 0;
+ for (int k = 0; k < n; k++) { px[k + 1] = xs[k]; py[k + 1] = ys[k]; }
+ px[n + 1] = xs[n - 1]; py[n + 1] = ys[n - 1];
if (x <= 0) return 0;
- if (x >= px[5]) return py[5];
+ if (x >= px[n + 1]) return py[n + 1];
int i = 0;
- for (int k = 0; k < 5; k++)
+ for (int k = 0; k <= n; k++)
{
if (x >= px[k] && x <= px[k + 1]) { i = k; break; }
}
@@ -577,73 +548,54 @@ internal static double EvaluateCurveArbitraryX(float[] xs, float[] ys, double x)
return CubicBezier(p1y, c1y, c2y, p2y, (lo + hi) / 2.0);
}
- private static readonly float[] DefaultCurveX = { 20, 40, 60, 80, 100 };
-
- ///
- /// Resample a (possibly horizontally-dragged) output curve at the
- /// fixed 20/40/60/80/100 breakpoints the wire protocol actually
- /// supports. When is null (node never
- /// dragged), this is the identity — sampling
- /// exactly at a node's own X
- /// returns that node's own Y — so callers can always resample
- /// unconditionally without a "has the user customized X" branch.
- ///
- internal static float[] ResampleCurveAtFixedBreakpoints(float[]? curveX, float[] curveY)
- {
- var xs = (curveX != null && curveX.Length == 5) ? curveX : DefaultCurveX;
- var result = new float[5];
- for (int i = 0; i < 5; i++)
- result[i] = (float)EvaluateCurveArbitraryX(xs, curveY, DefaultCurveX[i]);
- return result;
- }
-
- ///
- /// EXPERIMENTAL / unverified — resample the output curve at 6 evenly
- /// spaced breakpoints (100/7, 200/7, ..., 600/7 percent) instead of
- /// the wire protocol's usual 20/40/60/80/100, for the
- /// mbooster-brake-curve7-* commands (cmdId 0xAB — see
- /// MozaCommandDatabase.cs and MozaMBoosterProtocol.EncodeCurve7Point).
- /// Spotted once in pedal_travel.pcapng sent alongside a Travel Start
- /// write; not confirmed as an actual protocol requirement. Same
- /// null-curveY-is-identity fallback as
- /// (via 's own null guard).
- /// Returned array is indexed 0..5 for wire selectors 1..6
- /// (result[i] is selector i + 1's value).
- ///
- internal static float[] ResampleCurveAtSevenths(float[]? curveX, float[]? curveY)
- {
- var xs = (curveX != null && curveX.Length == 5) ? curveX : DefaultCurveX;
- var result = new float[6];
- for (int i = 1; i <= 6; i++)
- result[i - 1] = (float)EvaluateCurveArbitraryX(xs, curveY!, i * 100.0 / 7.0);
- return result;
- }
+ // Default (un-dragged) node X breakpoints for the Sim Input Mapping
+ // output curve, 100/7 * k for k=1..6 — evenly spaced, ending short
+ // of 100% so the curve can plateau before full physical travel
+ // (see EvaluateCurveArbitraryX's "100% output before 100% input").
+ private static readonly float[] DefaultCurveX =
+ { 100f / 7f, 200f / 7f, 300f / 7f, 400f / 7f, 500f / 7f, 600f / 7f };
- // Fixed fractions of the way from Deadzone to Max Force for the 6
- // interpolated points the device holds between those two anchors
- // (mbooster-brake-feelcurve-1..6, cmdId 0xAB selectors 0x08-0x0D) —
+ // Default/un-dragged shape of the Pedal Feel curve's 6 nodes
+ // (mbooster-brake-feelcurve-1..6, cmdId 0xAB selectors 0x08-0x0D),
+ // as a fraction (0-1) of the way from Deadzone to Max Force —
// empirically measured across both max-force-24-75-128-166-200.pcapng
// (Deadzone fixed, Max Force swept 75/128/166kg) and
// deadzone-0-5-11-14.pcapng (Max Force fixed, Deadzone swept
// 5/11/14kg): (value - deadzone) / (maxForce - deadzone) landed on
// the identical constant per selector in all 6 write bursts (std-dev
- // < 0.0001), cross-validating the same fixed shape regardless of
- // which endpoint moved. See docs/protocol/devices/mbooster.md
+ // < 0.0001). This is Pit House's own un-dragged default shape (a
+ // Linear/identity curve — Y=X trivially holds for any untouched
+ // curve regardless of its real X-breakpoint spacing), NOT a fixed
+ // rule: the 6 points are genuinely user-adjustable via
+ // MBoosterDeviceSettings.InputCurveY (see ComputeFeelCurve below).
+ // Also doubles as each node's fixed X breakpoint (in % of the
+ // Deadzone-Max Force span) since no capture has yet isolated a
+ // dragged (non-identity) curve to independently confirm the
+ // breakpoints' spacing. See docs/protocol/devices/mbooster.md
// "Pedal Feel" and bug bundle 5VR5AQ8Y.
- private static readonly double[] FeelCurveFractions =
+ internal static readonly double[] FeelCurveFractions =
{ 0.08049, 0.19495, 0.44245, 0.72433, 0.90040, 0.97910 };
///
- /// The 6 points the device's own Deadzone-to-Max-Force curve holds
- /// between its two anchors, in kg — see
- /// and .
+ /// The 6 points of the Pedal Feel curve, in kg, ready to write to
+ /// mbooster-brake-feelcurve-1..6 — see
+ /// .
+ /// Each node in is a percentage
+ /// (0-100) of the Deadzone-Max Force span; falls back to
+ /// (the Linear default) for any
+ /// node the user hasn't customized (null or wrong-length array).
///
- internal static double[] ComputeFeelCurve(double deadzoneKg, double maxForceKg)
+ internal static double[] ComputeFeelCurve(double deadzoneKg, double maxForceKg, float[]? inputCurveY = null)
{
double range = maxForceKg - deadzoneKg;
- var result = new double[FeelCurveFractions.Length];
- for (int i = 0; i < result.Length; i++)
- result[i] = deadzoneKg + FeelCurveFractions[i] * range;
+ int n = FeelCurveFractions.Length;
+ bool haveCurve = inputCurveY != null && inputCurveY.Length == n;
+ var result = new double[n];
+ for (int i = 0; i < n; i++)
+ {
+ double frac01 = haveCurve ? inputCurveY![i] / 100.0 : FeelCurveFractions[i];
+ result[i] = deadzoneKg + frac01 * range;
+ }
return result;
}
diff --git a/MozaPlugin.cs b/MozaPlugin.cs
index bb19b730..4b838dde 100644
--- a/MozaPlugin.cs
+++ b/MozaPlugin.cs
@@ -1056,6 +1056,19 @@ public void Init(PluginManager pluginManager)
_settings.VerboseWireDebugLog = false;
}
+ // The mBooster CurveY/CurveX (Sim Input Mapping) and
+ // InputCurveY (Pedal Feel) arrays moved from 5 to 6 nodes.
+ // Every other call site treats a wrong-length array as
+ // "unset" and falls back to a default shape — fine for new
+ // profiles, but it would silently discard an existing
+ // user's tuned curve the first time this version runs.
+ // Resample once instead, preserving each curve's shape.
+ if (!_settings.MBoosterCurveArraysMigratedTo6)
+ {
+ _settings.MBoosterCurveArraysMigratedTo6 = true;
+ MigrateMBoosterCurveArraysTo6();
+ }
+
// Initialise the GUID↔model registry up front — page-GUID
// resolution (current-wheel page lookup, per-page settings dicts)
// depends on it throughout runtime.
@@ -2595,6 +2608,71 @@ private static bool HealMBoosterAxisRoles(MBoosterDeviceSettings s, bool[] conne
return changed;
}
+ // Old 5-node output curve's fixed X breakpoints — what CurveX
+ // defaulted to (and what InputCurveY was always implicitly fixed
+ // at) before the redesign to 6 nodes. Used only by the one-shot
+ // migration below.
+ private static readonly float[] LegacyMBoosterCurveDefaultX = { 20, 40, 60, 80, 100 };
+
+ ///
+ /// One-shot migration (see
+ /// ):
+ /// resamples every saved mBooster CurveY/CurveX (Sim Input Mapping)
+ /// and InputCurveY (Pedal Feel) array from its old 5-node shape to
+ /// the current 6-node one, across every profile's master settings
+ /// and every chained pedal — preserving each curve's visual shape
+ /// instead of letting the ordinary "wrong length = unset" guards
+ /// elsewhere silently discard it to a default. CurveX itself does
+ /// not carry over (the old dragged X positions don't map cleanly
+ /// onto the new node count) — only the resulting Y-shape does; a
+ /// fresh CurveX default takes over on the next edit.
+ ///
+ private void MigrateMBoosterCurveArraysTo6()
+ {
+ var profiles = _settings?.ProfileStore?.Profiles;
+ if (profiles == null) return;
+ foreach (var profile in profiles)
+ {
+ if (profile?.MBoosterSettings == null) continue;
+ foreach (var device in profile.MBoosterSettings.Values)
+ {
+ if (device == null) continue;
+ MigrateOneMBoosterCurveSet(device);
+ if (device.Pedals != null)
+ foreach (var pedal in device.Pedals.Values)
+ if (pedal != null) MigrateOneMBoosterCurveSet(pedal);
+ }
+ }
+ }
+
+ private static void MigrateOneMBoosterCurveSet(global::MozaPlugin.Devices.IMBoosterPedalConfig cfg)
+ {
+ const int oldNodeCount = 5;
+ if (cfg.CurveY != null && cfg.CurveY.Length == oldNodeCount)
+ {
+ var oldXs = (cfg.CurveX != null && cfg.CurveX.Length == oldNodeCount)
+ ? cfg.CurveX : LegacyMBoosterCurveDefaultX;
+ var newY = new float[global::MozaPlugin.Devices.MBoosterUiConstants.SimInputMappingNodeCount];
+ for (int i = 0; i < newY.Length; i++)
+ {
+ double x = (i + 1) * 100.0 / 7.0;
+ newY[i] = (float)global::MozaPlugin.Devices.MozaMBoosterRegistry.EvaluateCurveArbitraryX(oldXs, cfg.CurveY, x);
+ }
+ cfg.CurveY = newY;
+ cfg.CurveX = null;
+ }
+ if (cfg.InputCurveY != null && cfg.InputCurveY.Length == oldNodeCount)
+ {
+ var newInput = new float[global::MozaPlugin.Devices.MBoosterUiConstants.PedalFeelNodeCount];
+ for (int i = 0; i < newInput.Length; i++)
+ {
+ double x = global::MozaPlugin.Devices.MozaMBoosterRegistry.FeelCurveFractions[i] * 100.0;
+ newInput[i] = (float)global::MozaPlugin.Devices.MozaMBoosterRegistry.EvaluateCurveArbitraryX(LegacyMBoosterCurveDefaultX, cfg.InputCurveY, x);
+ }
+ cfg.InputCurveY = newInput;
+ }
+ }
+
///
/// Called once per detection rising edge by the registry. Pushes any
/// saved calibration values to the device and kicks off a read-back
@@ -2672,16 +2750,6 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos
else if (axis == soleAxis) cfg = s;
else continue;
- // Named for what it gates below, NOT "wrote anything" — Max
- // Threshold and Deadzone/Max Force are deliberately excluded
- // (see their own write blocks below) because isolated capture
- // evidence now DISCONFIRMS the curve7-1..6 resync for them
- // specifically (bug bundle 5VR5AQ8Y's max-threshold-4-41-105-
- // 153-200.pcapng shows zero 0xAB traffic of any kind
- // alongside 4 clean Threshold writes) — unlike Travel, which
- // pedal_travel.pcapng directly confirmed DOES need it.
- bool needsCurve7Resync = false;
-
// Every per-pedal calibration here is a PHYSICAL setting stored
// on that pedal's own mBooster unit (confirmed on hardware: each
// unit reports only its own pedal's calibration, under its own
@@ -2697,20 +2765,13 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos
: role == global::MozaPlugin.Devices.MBoosterRole.Clutch ? 2 : -1;
byte dev = controller.MotorDeviceForRole(roleIdx, axis);
- if (cfg.Direction >= 0) { controller.SendIntWrite($"mbooster-{prefix}-dir", cfg.Direction, dev); needsCurve7Resync = true; }
- if (cfg.Min >= 0) { controller.SendIntWrite($"mbooster-{prefix}-min", cfg.Min, dev); needsCurve7Resync = true; }
- if (cfg.Max >= 0) { controller.SendIntWrite($"mbooster-{prefix}-max", cfg.Max, dev); needsCurve7Resync = true; }
- if (cfg.CurveY != null && cfg.CurveY.Length == 5)
- {
- needsCurve7Resync = true;
- // Resample at the fixed 20/40/60/80/100 breakpoints in case
- // CurveX has been horizontally dragged (see
- // MozaMBoosterRegistry.ResampleCurveAtFixedBreakpoints) —
- // identity when it hasn't.
- var resampled = global::MozaPlugin.Devices.MozaMBoosterRegistry.ResampleCurveAtFixedBreakpoints(cfg.CurveX, cfg.CurveY);
- for (int k = 0; k < 5; k++)
- controller.SendFloatWrite($"mbooster-{prefix}-y{k + 1}", resampled[k], dev);
- }
+ if (cfg.Direction >= 0) controller.SendIntWrite($"mbooster-{prefix}-dir", cfg.Direction, dev);
+ if (cfg.Min >= 0) controller.SendIntWrite($"mbooster-{prefix}-min", cfg.Min, dev);
+ if (cfg.Max >= 0) controller.SendIntWrite($"mbooster-{prefix}-max", cfg.Max, dev);
+ // CurveY/CurveX (Sim Input Mapping output curve) are NOT
+ // pushed here — purely host-side now, no wire command at
+ // all (see MozaMBoosterRegistry.EvaluateCurveArbitraryX and
+ // docs/protocol/devices/mbooster.md "Sim Input Mapping").
// Travel / End Stop / Natural Friction / Segmented Damping are
// load-cell + motor Pedal Feel features living on brake-named
// SINGLETON cmdIds (0x84/0x85, 0xB2, 0xAE, 0xB7) with no
@@ -2727,32 +2788,27 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos
{
controller.SendIntWrite("mbooster-brake-travel-start",
global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeTravelMm(cfg.TravelStartMm), dev);
- needsCurve7Resync = true;
}
if (ownsPedalFeelHardware && cfg.TravelEndMm >= 0)
{
controller.SendIntWrite("mbooster-brake-travel-end",
global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeTravelMm(cfg.TravelEndMm), dev);
- needsCurve7Resync = true;
}
if (ownsPedalFeelHardware && cfg.EndstopFrontStiffness >= 0)
{
controller.SendIntWrite("mbooster-brake-endstop-front",
global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeEndstopStiffness(cfg.EndstopFrontStiffness), dev);
- needsCurve7Resync = true;
}
if (ownsPedalFeelHardware && cfg.EndstopEndStiffness >= 0)
{
controller.SendIntWrite("mbooster-brake-endstop-end",
global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeEndstopStiffness(cfg.EndstopEndStiffness), dev);
- needsCurve7Resync = true;
}
if (ownsPedalFeelHardware && cfg.NaturalFrictionPct >= 0)
{
int frictionRaw = global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeFrictionPct(cfg.NaturalFrictionPct);
controller.SendIntWrite("mbooster-brake-friction-0", frictionRaw, dev);
controller.SendIntWrite("mbooster-brake-friction-1", frictionRaw, dev);
- needsCurve7Resync = true;
}
// Segmented Damping (both "When Pressed" and "When
// Released" — see cfg.SegmentedDamping). One wire command
@@ -2782,20 +2838,13 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos
sd.Seg3Released >= 0 ? sd.Seg3Released : c,
dev);
controller.SendOneShot(frame);
- needsCurve7Resync = true;
}
if (role == global::MozaPlugin.Devices.MBoosterRole.Brake)
{
if (cfg.SensorOutputRatioPct >= 0)
{
controller.SendFloatWrite("mbooster-brake-angle-ratio", cfg.SensorOutputRatioPct, dev);
- needsCurve7Resync = true;
}
- // Max Threshold does NOT set needsCurve7Resync — see the
- // variable's own doc comment above. Confirmed by an
- // isolated capture (max-threshold-4-41-105-153-200.pcapng,
- // Max Force held static): zero 0xAB traffic of any kind
- // alongside 4 clean Threshold writes.
if (cfg.MaxThresholdKg >= 0)
{
controller.SendIntWrite("mbooster-brake-threshold",
@@ -2803,40 +2852,25 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos
}
}
- // Deadzone / Max Force — CONFIRMED real hardware calibration
- // (see MBoosterDeviceController.PushFeelCurveResync). Fresh
- // profile with neither set (-1) sends nothing, same guarantee
- // as every other calibration write here. Once EITHER is set,
- // the whole 8-value family is pushed together (the device has
- // no partial-update form for it), using the pedal's own sane
- // "off" default for whichever side has no override — 0kg
- // deadzone, 200kg max force (an out-of-range pedal never
- // presses hard enough for Max Force to matter). Does NOT set
- // needsCurve7Resync: both max-force-24-75-128-166-200.pcapng
- // and deadzone-0-5-11-14.pcapng show this family's own
- // resync (selectors 0x07-0x0E) is everything the device
- // needs — neither ever included a curve7-1..6 (selectors
- // 0x01-0x06) frame.
- if (ownsPedalFeelHardware && (cfg.DeadzoneKg >= 0 || cfg.MaxForceKg >= 0))
+ // Deadzone / Max Force / Pedal Feel curve — CONFIRMED real
+ // hardware calibration (see
+ // MBoosterDeviceController.PushFeelCurveResync). Fresh
+ // profile with none set sends nothing, same guarantee as
+ // every other calibration write here. Once ANY of the three
+ // is set, the whole 8-value family is pushed together (the
+ // device has no partial-update form for it), using the
+ // pedal's own sane "off" default for whichever side has no
+ // override — 0kg deadzone, 200kg max force, and the curve's
+ // own default Linear shape (MozaMBoosterRegistry
+ // .FeelCurveFractions) for an uncustomized curve.
+ bool curveCustomized = cfg.InputCurveY != null
+ && cfg.InputCurveY.Length == global::MozaPlugin.Devices.MBoosterUiConstants.PedalFeelNodeCount;
+ if (ownsPedalFeelHardware && (cfg.DeadzoneKg >= 0 || cfg.MaxForceKg >= 0 || curveCustomized))
{
double dz = cfg.DeadzoneKg >= 0 ? cfg.DeadzoneKg : 0;
double mf = cfg.MaxForceKg >= 0 ? cfg.MaxForceKg : 200;
- controller.PushFeelCurveResync(dz, mf, dev);
+ controller.PushFeelCurveResync(dz, mf, cfg.InputCurveY, dev);
}
-
- // EXPERIMENTAL / unverified — confirmed on hardware to be
- // required for a Travel edit to actually take effect; applied
- // here too on the theory the same firmware requirement covers
- // Direction/Min/Max/CurveY/Endstop/Friction/SegmentedDamping/
- // Ratio as well — unconfirmed for those specifically, unlike
- // Threshold and Deadzone/MaxForce (see needsCurve7Resync's own
- // comment), which now have direct capture evidence against
- // it. See MBoosterDeviceController.PushCurve7Resync. Guarded
- // like the writes above (not unconditional) to preserve this
- // method's "fresh profile with no overrides produces zero
- // hardware writes" guarantee.
- if (needsCurve7Resync)
- controller.PushCurve7Resync(cfg.CurveX, cfg.CurveY, dev);
}
}
diff --git a/Protocol/MozaCommandDatabase.cs b/Protocol/MozaCommandDatabase.cs
index 0dc5ef72..16694961 100644
--- a/Protocol/MozaCommandDatabase.cs
+++ b/Protocol/MozaCommandDatabase.cs
@@ -596,45 +596,25 @@ static MozaCommandDatabase()
// docs/protocol/devices/mbooster.md "Pedal Feel".
AddCommand("mbooster-brake-travel-start", "mbooster", 35, 36, new byte[] { 0x84 }, 2, "int");
AddCommand("mbooster-brake-travel-end", "mbooster", 35, 36, new byte[] { 0x85 }, 2, "int");
- // EXPERIMENTAL / unverified — spotted in pedal_travel.pcapng: Pit
- // House sent these 6 alongside a single Travel Start write, never
- // in isolation, so this is a correlation from one capture, not a
- // confirmed protocol requirement. cmdId 0xAB, same "prefix bytes
- // then payload" shape as endstop above: a fixed 0x00 byte + a 1-6
- // selector before the 2-byte value. Values decoded near a linear
- // ramp (selector/7 * 65535) with two outliers, consistent with
- // this being the 5-point output curve
- // (CurveX/CurveY) re-expressed at 7 evenly-spaced breakpoints
- // instead of the usual 20/40/60/80/100 — see
- // MozaMBoosterRegistry.ResampleCurveAtSevenths and
- // MozaMBoosterProtocol.EncodeCurve7Point. Hypothesis: the
- // firmware needs this resent for a Travel/Pedal-Feel write to
- // actually take effect, even though the raw Travel register
- // itself reads back correctly without it. Needs on-hardware
- // confirmation — see docs/protocol/devices/mbooster.md "Pedal Feel".
- AddCommand("mbooster-brake-curve7-1", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x01 }, 2, "int");
- AddCommand("mbooster-brake-curve7-2", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x02 }, 2, "int");
- AddCommand("mbooster-brake-curve7-3", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x03 }, 2, "int");
- AddCommand("mbooster-brake-curve7-4", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x04 }, 2, "int");
- AddCommand("mbooster-brake-curve7-5", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x05 }, 2, "int");
- AddCommand("mbooster-brake-curve7-6", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x06 }, 2, "int");
// Pit House "Deadzone" and "Max Force" (Pedal Feel) — CONFIRMED real
// hardware calibration, reverse-engineered from two real Pit House
// captures (max-force-24-75-128-166-200.pcapng,
- // deadzone-0-5-11-14.pcapng — see bug bundle 5VR5AQ8Y). Same cmdId
- // 0xAB indexed-register family as curve7-1..6 above, but a
- // DIFFERENT selector range (0x07-0x0E) carrying a genuinely separate
- // 8-point curve: selector 0x07 = Deadzone, selector 0x0E = Max
- // Force, both in kg using the identical encoding as Max Threshold
- // (raw = round(kg * 65536 / 200) — see
+ // deadzone-0-5-11-14.pcapng — see bug bundle 5VR5AQ8Y). cmdId 0xAB
+ // indexed-register family, selectors 0x07-0x0E carrying a genuinely
+ // separate 8-point curve: selector 0x07 = Deadzone, selector 0x0E =
+ // Max Force, both in kg using the identical encoding as Max
+ // Threshold (raw = round(kg * 65536 / 200) — see
// MozaMBoosterProtocol.EncodeThresholdKg). Selectors 0x08-0x0D are
- // 6 interpolated points between the two anchors — see
- // MozaMBoosterRegistry.ComputeFeelCurve. Unlike curve7-1..6 (never
- // confirmed as a real requirement), every Max Force / Deadzone
- // sweep in both captures resent this whole 8-value family as one
- // atomic burst, and real Pit House does NOT clamp Max Force to Max
- // Threshold (128kg/166kg were sent while Threshold read back as
- // 125kg) — see docs/protocol/devices/mbooster.md "Pedal Feel".
+ // the Pedal Feel curve's own 6 user-adjustable nodes (0-100% of the
+ // Deadzone-Max Force span) — see MozaMBoosterRegistry
+ // .ComputeFeelCurve. Every Max Force / Deadzone sweep in both
+ // captures resent this whole 8-value family as one atomic burst,
+ // and real Pit House does NOT clamp Max Force to Max Threshold
+ // (128kg/166kg were sent while Threshold read back as 125kg) — see
+ // docs/protocol/devices/mbooster.md "Pedal Feel". (An earlier,
+ // separate 0xAB selector range 0x01-0x06, "curve7", was removed —
+ // it was only ever an experimental, unconfirmed resync guess for
+ // an unrelated curve; see docs for the historical writeup.)
AddCommand("mbooster-brake-deadzone", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x07 }, 2, "int");
AddCommand("mbooster-brake-feelcurve-1", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x08 }, 2, "int");
AddCommand("mbooster-brake-feelcurve-2", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x09 }, 2, "int");
@@ -669,22 +649,11 @@ static MozaCommandDatabase()
// docs/protocol/devices/mbooster.md "Pedal Feel".
AddCommand("mbooster-brake-friction-0", "mbooster", 35, 36, new byte[] { 0xAE, 0x00, 0x00 }, 2, "int");
AddCommand("mbooster-brake-friction-1", "mbooster", 35, 36, new byte[] { 0xAE, 0x00, 0x01 }, 2, "int");
- // 5-point output curves per pedal (4-byte float, read 35 / write 36)
- AddCommand("mbooster-throttle-y1", "mbooster", 35, 36, new byte[] { 14 }, 4, "float");
- AddCommand("mbooster-throttle-y2", "mbooster", 35, 36, new byte[] { 15 }, 4, "float");
- AddCommand("mbooster-throttle-y3", "mbooster", 35, 36, new byte[] { 16 }, 4, "float");
- AddCommand("mbooster-throttle-y4", "mbooster", 35, 36, new byte[] { 17 }, 4, "float");
- AddCommand("mbooster-throttle-y5", "mbooster", 35, 36, new byte[] { 27 }, 4, "float");
- AddCommand("mbooster-brake-y1", "mbooster", 35, 36, new byte[] { 18 }, 4, "float");
- AddCommand("mbooster-brake-y2", "mbooster", 35, 36, new byte[] { 19 }, 4, "float");
- AddCommand("mbooster-brake-y3", "mbooster", 35, 36, new byte[] { 20 }, 4, "float");
- AddCommand("mbooster-brake-y4", "mbooster", 35, 36, new byte[] { 21 }, 4, "float");
- AddCommand("mbooster-brake-y5", "mbooster", 35, 36, new byte[] { 28 }, 4, "float");
- AddCommand("mbooster-clutch-y1", "mbooster", 35, 36, new byte[] { 22 }, 4, "float");
- AddCommand("mbooster-clutch-y2", "mbooster", 35, 36, new byte[] { 23 }, 4, "float");
- AddCommand("mbooster-clutch-y3", "mbooster", 35, 36, new byte[] { 24 }, 4, "float");
- AddCommand("mbooster-clutch-y4", "mbooster", 35, 36, new byte[] { 25 }, 4, "float");
- AddCommand("mbooster-clutch-y5", "mbooster", 35, 36, new byte[] { 29 }, 4, "float");
+ // (The per-role 5-point output curve commands (cmdIds 14-29,
+ // mbooster-{throttle,brake,clutch}-y1..y5) were removed — the Sim
+ // Input Mapping output curve is now purely host-side, with no
+ // wire encoding at all; see docs/protocol/devices/mbooster.md
+ // "Sim Input Mapping" for the historical writeup.)
// Live outputs (read-only group 37) — fallback live-position source
// if HID identity pairing fails on a particular unit.
AddCommand("mbooster-throttle-output", "mbooster", 37, 0xFF, new byte[] { 1 }, 2, "int");
diff --git a/Protocol/MozaMBoosterProtocol.cs b/Protocol/MozaMBoosterProtocol.cs
index 4a212303..6f98a812 100644
--- a/Protocol/MozaMBoosterProtocol.cs
+++ b/Protocol/MozaMBoosterProtocol.cs
@@ -348,29 +348,6 @@ public static double DecodeTravelMm(int raw)
return raw * 53.5 / 65536.0;
}
- ///
- /// EXPERIMENTAL / unverified — encoding for the mbooster-brake-
- /// curve7-* commands (cmdId 0xAB) spotted alongside a Travel
- /// Start write in pedal_travel.pcapng (see
- /// MozaCommandDatabase.cs and MozaMBoosterRegistry.ResampleCurveAtSevenths).
- /// Values decoded near selector/7 * 65535, i.e. a plain
- /// fraction-of-full-scale over the 0-65535 range — same
- /// "value * 65535 / fullscale" family as ,
- /// with fullscale = 100 (a curve-node percentage, 0-100 like
- /// MBoosterDeviceSettings.CurveY): raw = round(pct * 65535 / 100).
- /// Not cross-checked against a second capture — treat with more
- /// suspicion than this file's other Encode/Decode pairs.
- ///
- public static int EncodeCurve7Point(double pct)
- {
- if (double.IsNaN(pct)) pct = 0;
- pct = Math.Max(0, Math.Min(100, pct));
- double raw = Math.Round(pct * 65535.0 / 100.0);
- if (raw <= 0) return 0;
- if (raw >= 0xFFFF) return 0xFFFF;
- return (int)raw;
- }
-
///
/// Pit House "End Stop Stiffness" (Front Limit / End Limit) encoding
/// — reverse-engineered from two real Pit House USB captures (wire
diff --git a/UI/Controls/MozaCurveEditor.cs b/UI/Controls/MozaCurveEditor.cs
index 46c4e177..8607bd49 100644
--- a/UI/Controls/MozaCurveEditor.cs
+++ b/UI/Controls/MozaCurveEditor.cs
@@ -65,16 +65,19 @@ private static DependencyProperty RegisterY(string name, double dflt)
public double Y10 { get => (double)GetValue(Y10Property); set => SetValue(Y10Property, value); }
// -------- X values (data-space 0..100, only meaningful when
- // AllowHorizontalDrag is true — 5-node curves only, no X6). Defaults
- // match the fixed 20/40/60/80/100 breakpoints every other curve in
- // this app uses, so a fresh instance renders identically to one
- // driven by NodeXFractions until the user actually drags a node
- // sideways. --------
+ // AllowHorizontalDrag is true — 5-node curves default to the fixed
+ // 20/40/60/80/100 breakpoints every other curve in this app uses;
+ // the 6-node Sim Input Mapping curve overwrites X1-X6 from its own
+ // seeding code (100/7 * k for k=1..6) immediately on load, so X6's
+ // own DP default below is cosmetic. A fresh instance renders
+ // identically to one driven by NodeXFractions until the user
+ // actually drags a node sideways. --------
public static readonly DependencyProperty X1Property = RegisterX(nameof(X1), 20);
public static readonly DependencyProperty X2Property = RegisterX(nameof(X2), 40);
public static readonly DependencyProperty X3Property = RegisterX(nameof(X3), 60);
public static readonly DependencyProperty X4Property = RegisterX(nameof(X4), 80);
public static readonly DependencyProperty X5Property = RegisterX(nameof(X5), 100);
+ public static readonly DependencyProperty X6Property = RegisterX(nameof(X6), 600.0 / 7.0);
private static DependencyProperty RegisterX(string name, double dflt)
=> DependencyProperty.Register(name, typeof(double), typeof(MozaCurveEditor),
@@ -87,6 +90,7 @@ private static DependencyProperty RegisterX(string name, double dflt)
public double X3 { get => (double)GetValue(X3Property); set => SetValue(X3Property, value); }
public double X4 { get => (double)GetValue(X4Property); set => SetValue(X4Property, value); }
public double X5 { get => (double)GetValue(X5Property); set => SetValue(X5Property, value); }
+ public double X6 { get => (double)GetValue(X6Property); set => SetValue(X6Property, value); }
// When true, nodes can be dragged horizontally (within their
// neighbours' bounds) as well as vertically — used only by the
@@ -534,10 +538,10 @@ private void ApplyDrag(Point p)
// Horizontal drag (output curve only — see AllowHorizontalDrag).
// Clamped between immediate neighbours (min 1-unit gap) so nodes
// can never cross, which would make the curve's X non-monotonic
- // and the Bezier-inversion evaluators (EvaluateInputCurve-style)
- // ill-defined.
+ // and the Bezier-inversion evaluator
+ // (MozaMBoosterRegistry.EvaluateCurveArbitraryX) ill-defined.
int lastNode = ClampedNodeCount() - 1;
- if (AllowHorizontalDrag && _dragNode >= 0 && _dragNode < 5
+ if (AllowHorizontalDrag && _dragNode >= 0 && _dragNode < 6
&& !(LockLastNodeX && _dragNode == lastNode))
{
double w = _canvas?.ActualWidth ?? ActualWidth;
@@ -546,7 +550,7 @@ private void ApplyDrag(Point p)
double dataX = x01 * 100.0;
double lo = _dragNode == 0 ? 1.0 : GetX(_dragNode - 1) + 1.0;
- double hi = _dragNode == 4 ? 100.0 : GetX(_dragNode + 1) - 1.0;
+ double hi = _dragNode == lastNode ? 100.0 : GetX(_dragNode + 1) - 1.0;
if (hi < lo) hi = lo;
dataX = Math.Max(lo, Math.Min(hi, dataX));
SetX(_dragNode, Math.Round(dataX));
@@ -579,6 +583,7 @@ private double GetX(int i)
case 2: return X3;
case 3: return X4;
case 4: return X5;
+ case 5: return X6;
default: return 0;
}
}
@@ -592,6 +597,7 @@ private void SetX(int i, double v)
case 2: X3 = v; break;
case 3: X4 = v; break;
case 4: X5 = v; break;
+ case 5: X6 = v; break;
}
}
@@ -638,13 +644,13 @@ private void Recompute()
// ---- Node X fractions / Y values ----
double[] nodeFracs;
- if (AllowHorizontalDrag && nodeCount <= 5)
+ if (AllowHorizontalDrag && nodeCount <= 6)
{
// Nodes are user-draggable in X (see ApplyDrag) — derive
- // fractions from X1..X5 instead of the fixed NodeXFractions
+ // fractions from X1..X6 instead of the fixed NodeXFractions
// string. Same 0.98 compression as Default5NodeFractions so
// a never-dragged node lands exactly where it always has.
- double[] xs = { X1, X2, X3, X4, X5 };
+ double[] xs = { X1, X2, X3, X4, X5, X6 };
nodeFracs = new double[nodeCount];
for (int i = 0; i < nodeCount; i++)
nodeFracs[i] = Math.Max(0, Math.Min(1, (xs[i] / 100.0) * 0.98));
@@ -844,7 +850,7 @@ private void Recompute()
/// the already-built spline: map the data-space X to a pixel X, find
/// which segment contains it, then invert that segment's Bezier X(t)
/// via bisection (same approach as
- /// MozaMBoosterRegistry.EvaluateInputCurve) to read off both the
+ /// MozaMBoosterRegistry.EvaluateCurveArbitraryX) to read off both the
/// pixel X and Y at that point — i.e. the dot always sits ON the
/// curve as currently configured, not just sliding horizontally.
///
@@ -870,7 +876,7 @@ private void UpdateLiveMarker((Point p1, Point c1, Point c2, Point p2)[] segment
// fraction, so linear interpolation between two known
// node pairs reproduces the true mapping exactly whether
// or not it's been dragged from its default.
- double[] dataXs = { X1, X2, X3, X4, X5 };
+ double[] dataXs = { X1, X2, X3, X4, X5, X6 };
int n = Math.Min(nodePts.Length, dataXs.Length);
double clampedX = Math.Max(0, Math.Min(dataXs[n - 1], liveX));
double x0 = 0, px0 = PadLeft, x1 = dataXs[0], px1 = nodePts[0].X;
diff --git a/UI/Import/PitHousePedalsMapper.cs b/UI/Import/PitHousePedalsMapper.cs
index 12a54e3e..f9398144 100644
--- a/UI/Import/PitHousePedalsMapper.cs
+++ b/UI/Import/PitHousePedalsMapper.cs
@@ -517,27 +517,44 @@ public void Float(string suffix, string label, float lo, float hi, string fmt,
c => set(c, nv));
}
- /// Output curve: nonlinear1..5 → CurveY, one combined row.
+ // PitHouse's own preset file format is fixed at 5 points
+ // (nonlinear1..5) at 20/40/60/80/100% — that's external and
+ // won't change. AZOM's CurveY is now 6 points at 100/7 * k
+ // (see MozaMBoosterRegistry.EvaluateCurveArbitraryX /
+ // MBoosterUiConstants.SimInputMappingNodeCount), so the
+ // imported 5-point shape is resampled at the 6 new breakpoints
+ // rather than mapped 1:1 onto the first 5 of 6 slots.
+ private static readonly float[] PitHouseOutputCurveX = { 20, 40, 60, 80, 100 };
+
+ /// Output curve: nonlinear1..5 → CurveY (resampled to 6 nodes).
public void OutputCurve()
{
- var y = new float[5];
+ var y5 = new float[5];
bool any = false;
for (int i = 0; i < 5; i++)
{
var v = Num("nonlinear" + (i + 1));
if (v == null) continue;
- y[i] = (float)Clamp(v.Value, 0, 100);
+ y5[i] = (float)Clamp(v.Value, 0, 100);
any = true;
}
if (!any) return;
+ int n = global::MozaPlugin.Devices.MBoosterUiConstants.SimInputMappingNodeCount;
+ var y = new float[n];
+ for (int i = 0; i < n; i++)
+ {
+ double x = (i + 1) * 100.0 / 7.0;
+ y[i] = (float)global::MozaPlugin.Devices.MozaMBoosterRegistry.EvaluateCurveArbitraryX(PitHouseOutputCurveX, y5, x);
+ }
+
var oldCurve = _read.CurveY;
- string oldDisplay = oldCurve == null || oldCurve.Length < 5
+ string oldDisplay = oldCurve == null || oldCurve.Length < n
? "(unset)"
- : string.Join("/", oldCurve.Take(5).Select(FormatCurvePoint));
+ : string.Join("/", oldCurve.Take(n).Select(FormatCurvePoint));
string newDisplay = string.Join("/", y.Select(FormatCurvePoint));
- Add("Output curve (Y at 20/40/60/80/100%)", oldDisplay, newDisplay,
+ Add("Output curve (Y at 100/7% breakpoints)", oldDisplay, newDisplay,
c => c.CurveY = (float[])y.Clone());
}
diff --git a/UI/MozaPluginSettings.cs b/UI/MozaPluginSettings.cs
index aaddbf37..b3ef4494 100644
--- a/UI/MozaPluginSettings.cs
+++ b/UI/MozaPluginSettings.cs
@@ -269,6 +269,13 @@ public class MozaPluginSettings
// VerboseWireDebugLog=true. See MozaPlugin.Init.
public bool VerboseWireDebugLogDefaultMigrated { get; set; }
+ // One-shot marker for the migration that resamples every saved
+ // mBooster CurveY/CurveX/InputCurveY array from its old 5-node
+ // shape to the current 6-node one, preserving each curve's visual
+ // shape instead of silently discarding it to a default. See
+ // MozaPlugin.Init and MozaMBoosterRegistry.MigrateCurveArraysTo6.
+ public bool MBoosterCurveArraysMigratedTo6 { get; set; }
+
// ~1/min pull of the wheel display's own log via session FF kind=14,
// acked with kind=15 (which clears those lines on the device). No UI —
// flip to false in MozaPluginSettings.json to stop the pull entirely.
diff --git a/UI/SettingsControl.Redesign.cs b/UI/SettingsControl.Redesign.cs
index 28e5a384..db5cd935 100644
--- a/UI/SettingsControl.Redesign.cs
+++ b/UI/SettingsControl.Redesign.cs
@@ -107,17 +107,17 @@ private void InitRedesignControls()
BindEditorToSliders(MBoosterCurveEditor, new[]
{
MBoosterY1Slider, MBoosterY2Slider, MBoosterY3Slider,
- MBoosterY4Slider, MBoosterY5Slider
+ MBoosterY4Slider, MBoosterY5Slider, MBoosterY6Slider
});
BindEditorXToSliders(MBoosterCurveEditor, new[]
{
MBoosterX1Slider, MBoosterX2Slider, MBoosterX3Slider,
- MBoosterX4Slider, MBoosterX5Slider
+ MBoosterX4Slider, MBoosterX5Slider, MBoosterX6Slider
});
BindEditorToSliders(MBoosterInputCurveEditor, new[]
{
MBoosterInputY1Slider, MBoosterInputY2Slider, MBoosterInputY3Slider,
- MBoosterInputY4Slider, MBoosterInputY5Slider
+ MBoosterInputY4Slider, MBoosterInputY5Slider, MBoosterInputY6Slider
});
// Two-way bindings: CurveEditor.YN ↔ EqNSlider.Value (FFB EQ
@@ -257,17 +257,17 @@ private void BindEditorToSliders(MozaControls.MozaCurveEditor editor, Slider[] s
// Two-way bind a MozaCurveEditor's X dependency properties to sliders —
// only meaningful when the editor has AllowHorizontalDrag="True". Accepts
- // 5 sliders (mBooster Sim Input Mapping — all nodes draggable) or 4 (the
+ // 6 sliders (mBooster Sim Input Mapping — all nodes draggable) or 4 (the
// wheelbase FFB curve, whose last node is pinned at input=100 via
- // LockLastNodeX so X5 keeps its DP default). No X6 — horizontal drag
- // isn't offered on the 6-band EQ.
+ // LockLastNodeX so X5 keeps its DP default). Horizontal drag isn't
+ // offered on the 6-band EQ.
private void BindEditorXToSliders(MozaControls.MozaCurveEditor editor, Slider[] sliders)
{
if (editor == null || sliders == null || sliders.Length < 4) return;
var xs = new[] {
MozaControls.MozaCurveEditor.X1Property, MozaControls.MozaCurveEditor.X2Property,
MozaControls.MozaCurveEditor.X3Property, MozaControls.MozaCurveEditor.X4Property,
- MozaControls.MozaCurveEditor.X5Property };
+ MozaControls.MozaCurveEditor.X5Property, MozaControls.MozaCurveEditor.X6Property };
int n = Math.Min(sliders.Length, xs.Length);
for (int i = 0; i < n; i++)
{
diff --git a/UI/SettingsControl.xaml b/UI/SettingsControl.xaml
index 0c39000c..68ceaec3 100644
--- a/UI/SettingsControl.xaml
+++ b/UI/SettingsControl.xaml
@@ -1845,13 +1845,6 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -1960,6 +1961,8 @@
+
+
@@ -1969,7 +1972,7 @@
-
@@ -2033,6 +2036,8 @@
+
+
@@ -2043,6 +2048,8 @@
+
+
diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs
index 8edb06af..5d0aee99 100644
--- a/UI/SettingsControl.xaml.cs
+++ b/UI/SettingsControl.xaml.cs
@@ -323,18 +323,20 @@ private void UpdateMBoosterCurveMarkers(bool hidConnected)
// Track the SELECTED pedal's own axis, not always the master's —
// otherwise every pedal page showed the master (throttle) input.
int idx = _mboosterEffectPedalIndex;
- double shaped = (idx >= 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;
+ // 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 "input to that curve" value to plot; no marker shown.
+ // The Sim Input Mapping curve is the opposite: purely host-side
+ // (see EvaluateCurveArbitraryX), so its live marker uses
+ // preCurve — the already-hardware-shaped raw position that's
+ // actually fed INTO this curve, not pct (which is the curve's
+ // own output).
+ MBoosterInputCurveEditor.LiveX = double.NaN;
+ MBoosterCurveEditor.LiveX = preCurve;
// Effects card pedal trace — same 30 Hz cadence as the Inputs
// tab's pedal bars above, and the same merged 0-100 values
@@ -3227,18 +3229,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 +3251,14 @@ 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"));
float ts = fx?.TravelStartMm ?? -1;
MBoosterTravelRangeSlider.LowValue = ts >= 0 ? ts : MBoosterUiConstants.TravelMinMm;
float te = fx?.TravelEndMm ?? -1;
@@ -3526,6 +3532,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;
@@ -3533,10 +3540,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())
{
@@ -4074,59 +4082,51 @@ 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/7 * 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.
+ private static readonly int[][] MBoosterCurvePresets =
+ {
+ new[] { 14, 29, 43, 57, 71, 86 }, // Linear
+ new[] { 5, 12, 30, 70, 88, 95 }, // S Curve
+ new[] { 4, 9, 16, 25, 41, 66 }, // Exponential
+ new[] { 34, 59, 75, 84, 91, 96 }, // 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
@@ -4148,18 +4148,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;
@@ -4170,33 +4173,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));
@@ -4204,12 +4230,14 @@ 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));
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];
using (_suppressor.Begin())
{
MBoosterInputY1Slider.Value = curve[0]; SetValueText(MBoosterInputY1Value, curve[0].ToString());
@@ -4217,16 +4245,18 @@ 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());
}
- 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:
@@ -4260,29 +4290,15 @@ 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 — unless
- /// is false, which
- /// MBoosterMaxThresholdSlider_ValueChanged passes: an isolated capture
- /// (max-threshold-4-41-105-153-200.pcapng) shows zero 0xAB traffic of
- /// any kind alongside 4 clean Threshold writes, directly disconfirming
- /// the resync for Threshold specifically.
+ /// 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, bool includeCurve7Resync = true)
+ 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);
- if (includeCurve7Resync) controller.PushCurve7Resync(curveX, curveY, dev);
- });
+ controller.QueueCalibWrite($"{dev:x2}:{key}", () => push(controller, dev));
}
///
@@ -4304,8 +4320,9 @@ private void PushMBoosterFeelCurve(IMBoosterPedalConfig s)
byte dev = MBoosterCalibDevice(controller, _mboosterEffectPedalIndex);
double dz = s.DeadzoneKg >= 0 ? s.DeadzoneKg : 0;
double mf = s.MaxForceKg >= 0 ? s.MaxForceKg : 200;
+ float[]? curve = s.InputCurveY;
controller.QueueCalibWrite($"{dev:x2}:feel-curve", () =>
- controller.PushFeelCurveResync(dz, mf, dev));
+ controller.PushFeelCurveResync(dz, mf, curve, dev));
}
private void MBoosterTravelRangeSlider_RangeChanged(object sender, EventArgs e)
@@ -4385,11 +4402,7 @@ private void MBoosterRatioSlider_ValueChanged(object sender, RoutedPropertyChang
// mbooster-brake-threshold (cmdId 0xB3), a 4-byte big-endian raw
// uint (NOT a float) on a fixed 0-200kg scale — see
// MozaMBoosterProtocol.EncodeThresholdKg and
- // docs/protocol/devices/mbooster.md "Sim Input Mapping". No curve7
- // resync (unlike every other QueueMBoosterCalibPush caller): an
- // isolated capture (max-threshold-4-41-105-153-200.pcapng, Max Force
- // held static) shows zero 0xAB traffic of any kind alongside 4
- // clean Threshold writes.
+ // docs/protocol/devices/mbooster.md "Sim Input Mapping".
private void MBoosterMaxThresholdSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) =>
OnIntSliderChanged(e.NewValue, MBoosterMaxThresholdValue, "", v =>
{
@@ -4398,8 +4411,7 @@ private void MBoosterMaxThresholdSlider_ValueChanged(object sender, RoutedProper
s.MaxThresholdKg = v;
QueueMBoosterCalibPush("brake-threshold", (c, dev) =>
c.SendIntWrite("mbooster-brake-threshold",
- global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeThresholdKg(v), dev),
- includeCurve7Resync: false);
+ global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeThresholdKg(v), dev));
});
// End Stop Stiffness (Front Limit / End Limit), 1-10 — Pit House's
diff --git a/docs/protocol/devices/mbooster.md b/docs/protocol/devices/mbooster.md
index 7a8c8f4e..27904e80 100644
--- a/docs/protocol/devices/mbooster.md
+++ b/docs/protocol/devices/mbooster.md
@@ -959,8 +959,8 @@ 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
@@ -974,55 +974,87 @@ pedal's own unit (`MotorDeviceForRole` — see
`raw = round(kg * 65536 / 200)`. Verified on two capture points (4 kg →
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).
+ `MozaMBoosterProtocol.EncodeThresholdKg`/`DecodeThresholdKg`. This
+ recalibrates the sensor's own full-scale range on the DEVICE — the raw
+ HID axis itself reads exactly `MaxThresholdKg` of force at 100% travel,
+ which is what the game reads directly (bypassing AZOM entirely) if it
+ binds to the pedal's raw joystick axis.
+- **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). Six wire breakpoints
+ `100/7 × k` for k=1..6 (≈14.29/28.57/42.86/57.14/71.43/85.71%) were kept
+ as the curve's fixed node-count reference/default shape even though
+ nothing sends them over the wire anymore — chosen to match what the
+ (now-removed) `curve7` mechanism's own selectors were, so a node that's
+ never been dragged renders identically to before.
+
+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 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.
+A card to the left of Sim Input Mapping holds `InputCurveY` on
+`MBoosterDeviceSettings` — 6 nodes, Y-only (no X-dragging).
+**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 6 fixed 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: they were
+originally measured as a fixed interpolation *formula*, but are actually
+Pit House's own un-dragged default shape (a Linear/identity curve — Y=X
+trivially holds for any untouched curve regardless of the real breakpoint
+spacing, so the measurement couldn't distinguish "fixed rule" from
+"default shape" until this session's clarification that the curve is
+genuinely user-adjustable). `null` (the default) 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.
The same card also has a **Start/End of Travel (mm)** control —
`TravelStartMm`/`TravelEndMm` on `MBoosterDeviceSettings`. Unlike every
@@ -1253,11 +1285,54 @@ 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.
-`InputCurveY` (the 5-point curve above) remains genuinely host-side —
-no wire command was found for it in either capture, and it still shapes
-`MozaData.{Throttle,Brake,Clutch}Position` (the `AZOM.*` properties, the
-pedal traces, the live curve markers) *after* whatever the device now
-delivers already-shaped on the raw HID axis.
+**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) — always 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.
+
+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
From f30f3c950d1f256062df54ee4b204075d999d096 Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Wed, 19 Aug 2026 12:15:38 +1200
Subject: [PATCH 05/22] shifting pedal feel UI to mirror pithouse design
---
Themes/MozaTheme.xaml | 68 +++++++++++++++++++++++++++++++-
UI/SettingsControl.xaml | 80 +++++++++++++++++++++-----------------
UI/SettingsControl.xaml.cs | 22 +++++++++++
3 files changed, 133 insertions(+), 37 deletions(-)
diff --git a/Themes/MozaTheme.xaml b/Themes/MozaTheme.xaml
index aa8cb9be..87d57625 100644
--- a/Themes/MozaTheme.xaml
+++ b/Themes/MozaTheme.xaml
@@ -405,9 +405,73 @@
-
-
diff --git a/UI/SettingsControl.xaml b/UI/SettingsControl.xaml
index 68ceaec3..dd4cc384 100644
--- a/UI/SettingsControl.xaml
+++ b/UI/SettingsControl.xaml
@@ -1892,47 +1892,57 @@
since editing them from a passive pedal's page would
overwrite the active pedal's registers instead. -->
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs
index 5d0aee99..411c570e 100644
--- a/UI/SettingsControl.xaml.cs
+++ b/UI/SettingsControl.xaml.cs
@@ -338,6 +338,28 @@ private void UpdateMBoosterCurveMarkers(bool hidConnected)
MBoosterInputCurveEditor.LiveX = double.NaN;
MBoosterCurveEditor.LiveX = preCurve;
+ // Live "position % · kg force" readout above the Pedal Feel
+ // curve editor (MBoosterPedalFeelLiveLabel). kg is an estimate,
+ // not a directly-read sensor value: the raw HID axis only ever
+ // reports a 0-100% position, calibrated so 100% == this pedal's
+ // Max Threshold force (see docs/protocol/devices/mbooster.md
+ // "Sim Input Mapping") — so force = pct/100 * that threshold.
+ // Resolution order matches the removed ResolveFullScaleKg: the
+ // user's own MaxThresholdKg override, else the device's own
+ // mbooster-brake-threshold read-back, else a 200kg last resort.
+ if (hidConnected)
+ {
+ var cfg = PeekMBoosterEffectTarget();
+ double fullScaleKg = (cfg != null && cfg.MaxThresholdKg >= 0) ? cfg.MaxThresholdKg
+ : (selected.DeviceReportedMaxThresholdKg > 0 ? selected.DeviceReportedMaxThresholdKg : 200.0);
+ double kg = preCurve / 100.0 * fullScaleKg;
+ MBoosterPedalFeelLiveLabel.Text = $"{Strings.Label_Position}: {preCurve:F0}% · {kg:F1} kg";
+ }
+ else
+ {
+ MBoosterPedalFeelLiveLabel.Text = Strings.Label_Position;
+ }
+
// Effects card pedal trace — same 30 Hz cadence as the Inputs
// tab's pedal bars above, and the same merged 0-100 values
// (_data.*Position), so it shows every connected pedal's live
From 3b36fa3ce945c1c2f8adeb99183565abe38d8074 Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Wed, 19 Aug 2026 12:20:10 +1200
Subject: [PATCH 06/22] UI change to segmented damping
---
Themes/MozaTheme.xaml | 7 +--
UI/Controls/MozaSegmentedBarEditor.cs | 68 +++++++++++++++++++++------
2 files changed, 58 insertions(+), 17 deletions(-)
diff --git a/Themes/MozaTheme.xaml b/Themes/MozaTheme.xaml
index 87d57625..04c5790d 100644
--- a/Themes/MozaTheme.xaml
+++ b/Themes/MozaTheme.xaml
@@ -1209,11 +1209,12 @@
Data="{Binding Seg2Rect, RelativeSource={RelativeSource TemplatedParent}}"/>
-
+ overlay), giving the damping profile one continuous,
+ rounded shape instead of three separate bars (or a sharp
+ step) to compare by eye. -->
(Geometry?)GetValue(PlotBackgroundRectProperty);
- /// Step line tracing the three segments' current values —
- /// flat across each segment's travel range, jumping vertically at
- /// each divider — so the damping profile reads as one continuous
- /// shape instead of three disconnected bars.
+ /// Smoothed line tracing the three segments' current values —
+ /// flat across the middle of each segment's travel range, easing
+ /// through a short Catmull-Rom-style curve around each divider
+ /// instead of jumping vertically — so the damping profile reads as
+ /// one continuous shape instead of three disconnected bars. See
+ /// (same 1/6-tangent Bezier
+ /// conversion MozaCurveEditor uses for its own curves).
private static readonly DependencyPropertyKey StepLineGeometryKey =
DependencyProperty.RegisterReadOnly(nameof(StepLineGeometry), typeof(Geometry), typeof(MozaSegmentedBarEditor), new PropertyMetadata(null));
public static readonly DependencyProperty StepLineGeometryProperty = StepLineGeometryKey.DependencyProperty;
@@ -429,16 +432,27 @@ double YOf(double pct)
bg.Freeze();
SetValue(PlotBackgroundRectKey, bg);
- // Step line ON TOP of the bars, at each segment's own height —
- // flat across its travel range, a vertical jump at each divider —
- // the same shape the three bars already imply, just traced as one
- // line so the overall profile is easier to read at a glance.
- var stepFig = new PathFigure { StartPoint = new Point(EdgePad, YOf(s1v)), IsClosed = false, IsFilled = false };
- stepFig.Segments.Add(new LineSegment(new Point(d1x, YOf(s1v)), true));
- stepFig.Segments.Add(new LineSegment(new Point(d1x, YOf(s2v)), true));
- stepFig.Segments.Add(new LineSegment(new Point(d2x, YOf(s2v)), true));
- stepFig.Segments.Add(new LineSegment(new Point(d2x, YOf(s3v)), true));
- stepFig.Segments.Add(new LineSegment(new Point(EdgePad + plotW, YOf(s3v)), true));
+ // Smoothed line ON TOP of the bars, at each segment's own height —
+ // flat across the middle of its travel range, easing through a
+ // short curve around each divider instead of jumping vertically
+ // — the same shape the three bars already imply, just traced as
+ // one continuous, rounded line so the overall profile is easier
+ // to read at a glance. The transition half-width is capped at
+ // 18px and shrunk for narrow segments/gaps so the six control
+ // points below can never cross each other or the plot edges.
+ double transitionHalfWidth = Math.Max(2.0, Math.Min(18.0,
+ Math.Min(d1x - EdgePad, Math.Min(d2x - d1x, EdgePad + plotW - d2x)) / 3.0));
+ var stepPts = new[]
+ {
+ new Point(EdgePad, YOf(s1v)),
+ new Point(d1x - transitionHalfWidth, YOf(s1v)),
+ new Point(d1x + transitionHalfWidth, YOf(s2v)),
+ new Point(d2x - transitionHalfWidth, YOf(s2v)),
+ new Point(d2x + transitionHalfWidth, YOf(s3v)),
+ new Point(EdgePad + plotW, YOf(s3v)),
+ };
+ var stepFig = new PathFigure { StartPoint = stepPts[0], IsClosed = false, IsFilled = false };
+ AddSmoothPolyline(stepFig, stepPts);
var stepGeom = new PathGeometry();
stepGeom.Figures.Add(stepFig);
stepGeom.Freeze();
@@ -461,5 +475,31 @@ double YOf(double pct)
SetValue(Seg2LabelTopKey, LabelTopFor(YOf(s2v)));
SetValue(Seg3LabelTopKey, LabelTopFor(YOf(s3v)));
}
+
+ ///
+ /// Append a smooth Catmull-Rom-style curve through
+ /// to as a chain of cubic Bezier segments — same
+ /// 1/6-tangent conversion MozaCurveEditor.Recompute uses for its
+ /// own curves, so this reads as the same "smooth line" visual language
+ /// elsewhere in the app. .StartPoint must already
+ /// be set to pts[0]. The first/last points are their own
+ /// duplicated neighbour (rather than wrapping or extrapolating), so the
+ /// curve starts/ends exactly AT pts[0]/pts[^1] with a
+ /// sensible (non-overshooting) tangent instead of curving past them.
+ ///
+ private static void AddSmoothPolyline(PathFigure fig, Point[] pts)
+ {
+ int n = pts.Length;
+ for (int i = 0; i < n - 1; i++)
+ {
+ Point p0 = i == 0 ? pts[0] : pts[i - 1];
+ Point p1 = pts[i];
+ Point p2 = pts[i + 1];
+ Point p3 = (i + 2 < n) ? pts[i + 2] : pts[n - 1];
+ Point c1 = new Point(p1.X + (p2.X - p0.X) / 6.0, p1.Y + (p2.Y - p0.Y) / 6.0);
+ Point c2 = new Point(p2.X - (p3.X - p1.X) / 6.0, p2.Y - (p3.Y - p1.Y) / 6.0);
+ fig.Segments.Add(new BezierSegment(c1, c2, p2, true));
+ }
+ }
}
}
From 1f0a6eb3e32ad6078d63eba56f17c89c523f72ee Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Wed, 19 Aug 2026 12:27:44 +1200
Subject: [PATCH 07/22] fixing segmented editor
---
UI/Controls/MozaSegmentedBarEditor.cs | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/UI/Controls/MozaSegmentedBarEditor.cs b/UI/Controls/MozaSegmentedBarEditor.cs
index d1aa9604..b9c7d271 100644
--- a/UI/Controls/MozaSegmentedBarEditor.cs
+++ b/UI/Controls/MozaSegmentedBarEditor.cs
@@ -434,14 +434,16 @@ double YOf(double pct)
// Smoothed line ON TOP of the bars, at each segment's own height —
// flat across the middle of its travel range, easing through a
- // short curve around each divider instead of jumping vertically
+ // wide curve around each divider instead of jumping vertically
// — the same shape the three bars already imply, just traced as
// one continuous, rounded line so the overall profile is easier
- // to read at a glance. The transition half-width is capped at
- // 18px and shrunk for narrow segments/gaps so the six control
- // points below can never cross each other or the plot edges.
- double transitionHalfWidth = Math.Max(2.0, Math.Min(18.0,
- Math.Min(d1x - EdgePad, Math.Min(d2x - d1x, EdgePad + plotW - d2x)) / 3.0));
+ // to read at a glance. The transition half-width reaches well
+ // into each neighboring segment/gap for a gradual blend, but is
+ // still capped and shrunk for narrow segments/gaps so the six
+ // control points below can never cross each other or the plot
+ // edges.
+ double transitionHalfWidth = Math.Max(2.0, Math.Min(36.0,
+ Math.Min(d1x - EdgePad, Math.Min(d2x - d1x, EdgePad + plotW - d2x)) / 2.2));
var stepPts = new[]
{
new Point(EdgePad, YOf(s1v)),
From 988f2290ca0ba2d8cd96dfe4ffd394a767c39c07 Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Wed, 19 Aug 2026 12:35:22 +1200
Subject: [PATCH 08/22] adding toggles to friction and damping controls
---
Devices/MBoosterTypes.cs | 23 +++++++++++
MozaPlugin.cs | 34 +++++++++++------
UI/SettingsControl.xaml | 9 +++--
UI/SettingsControl.xaml.cs | 63 ++++++++++++++++++++++++++++---
docs/protocol/devices/mbooster.md | 23 +++++++++++
5 files changed, 131 insertions(+), 21 deletions(-)
diff --git a/Devices/MBoosterTypes.cs b/Devices/MBoosterTypes.cs
index 343b012e..eb8df245 100644
--- a/Devices/MBoosterTypes.cs
+++ b/Devices/MBoosterTypes.cs
@@ -325,6 +325,14 @@ public sealed class MBoosterSegmentedDampingSettings
public float Seg2Released { get; set; } = -1;
public float Seg3Released { get; set; } = -1;
+ // Master on/off for the whole feature. Off is a software-side
+ // convention, not a separate wire command: it sends the same
+ // BuildSegmentedDampingFrame with all six segment-damping fields
+ // forced to 0% (dividers left as-is — they're inert once every
+ // segment damps at 0%). Defaults true so untouched profiles match
+ // Pit House's own factory-enabled state.
+ public bool DampingEnabled { get; set; } = true;
+
public MBoosterSegmentedDampingSettings Clone() =>
new MBoosterSegmentedDampingSettings
{
@@ -338,6 +346,7 @@ public MBoosterSegmentedDampingSettings Clone() =>
Seg1Released = Seg1Released,
Seg2Released = Seg2Released,
Seg3Released = Seg3Released,
+ DampingEnabled = DampingEnabled,
};
}
@@ -448,6 +457,7 @@ public interface IMBoosterPedalConfig : IMBoosterEffects
float EndstopFrontStiffness { get; set; }
float EndstopEndStiffness { get; set; }
float NaturalFrictionPct { get; set; }
+ bool NaturalFrictionEnabled { get; set; }
MBoosterSegmentedDampingSettings SegmentedDamping { get; set; }
}
@@ -485,6 +495,8 @@ public sealed class MBoosterPedalSettings : IMBoosterPedalConfig
public float EndstopFrontStiffness { get; set; } = -1;
public float EndstopEndStiffness { get; set; } = -1;
public float NaturalFrictionPct { get; set; } = -1;
+ // Master on/off — see MBoosterDeviceSettings.NaturalFrictionEnabled.
+ public bool NaturalFrictionEnabled { get; set; } = true;
public MBoosterSegmentedDampingSettings SegmentedDamping { get; set; } = new MBoosterSegmentedDampingSettings();
// Per-pedal vibration effects (same defaults as the master's flat fields).
@@ -517,6 +529,7 @@ public MBoosterPedalSettings Clone() =>
EndstopFrontStiffness = EndstopFrontStiffness,
EndstopEndStiffness = EndstopEndStiffness,
NaturalFrictionPct = NaturalFrictionPct,
+ NaturalFrictionEnabled = NaturalFrictionEnabled,
SegmentedDamping = SegmentedDamping?.Clone() ?? new MBoosterSegmentedDampingSettings(),
Abs = Abs?.Clone() ?? new MBoosterEffectSettings(),
Lockup = Lockup?.Clone() ?? new MBoosterEffectSettings(),
@@ -781,6 +794,15 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig
// overwrites whatever value is already on the device.
public float NaturalFrictionPct { get; set; } = -1;
+ // Master on/off for Natural Friction. Not a separate wire concept —
+ // Pit House's own toggle-off capture simply sent raw 0 (see the doc
+ // comment above), so AZOM's toggle reproduces that in software: off
+ // forces the pushed value to 0% regardless of NaturalFrictionPct,
+ // same pattern as MBoosterSegmentedDampingSettings.DampingEnabled.
+ // Defaults true so untouched profiles behave as before this toggle
+ // existed.
+ public bool NaturalFrictionEnabled { get; set; } = true;
+
// Segmented Damping (Pit House-style) — see
// MBoosterSegmentedDampingSettings and
// docs/protocol/devices/mbooster.md "Segmented Damping".
@@ -825,6 +847,7 @@ public MBoosterDeviceSettings Clone()
EndstopFrontStiffness = EndstopFrontStiffness,
EndstopEndStiffness = EndstopEndStiffness,
NaturalFrictionPct = NaturalFrictionPct,
+ NaturalFrictionEnabled = NaturalFrictionEnabled,
SegmentedDamping = SegmentedDamping?.Clone() ?? new MBoosterSegmentedDampingSettings(),
DisplayName = DisplayName,
};
diff --git a/MozaPlugin.cs b/MozaPlugin.cs
index 4b838dde..8587fd14 100644
--- a/MozaPlugin.cs
+++ b/MozaPlugin.cs
@@ -2804,9 +2804,15 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos
controller.SendIntWrite("mbooster-brake-endstop-end",
global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeEndstopStiffness(cfg.EndstopEndStiffness), dev);
}
- if (ownsPedalFeelHardware && cfg.NaturalFrictionPct >= 0)
+ // NaturalFrictionEnabled == false forces the pushed value to
+ // 0% regardless of NaturalFrictionPct — same convention as
+ // SegmentedDampingSettings.DampingEnabled below — so it also
+ // has to fire on an otherwise-untouched profile once the
+ // feature has been explicitly switched off.
+ if (ownsPedalFeelHardware && (cfg.NaturalFrictionPct >= 0 || !cfg.NaturalFrictionEnabled))
{
- int frictionRaw = global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeFrictionPct(cfg.NaturalFrictionPct);
+ float frictionPct = cfg.NaturalFrictionEnabled ? cfg.NaturalFrictionPct : 0f;
+ int frictionRaw = global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeFrictionPct(frictionPct);
controller.SendIntWrite("mbooster-brake-friction-0", frictionRaw, dev);
controller.SendIntWrite("mbooster-brake-friction-1", frictionRaw, dev);
}
@@ -2815,27 +2821,31 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos
// carries the whole feature's state at once, so a fresh
// profile with no override on EITHER side still sends
// nothing here (guarded like every other calibration write
- // above); once ANY field on either side is set, the frame
- // is filled out using factory defaults for whichever side
- // still has no override.
+ // above); once ANY field on either side is set — or the
+ // feature has been switched off via DampingEnabled — the
+ // frame is filled out using factory defaults for whichever
+ // side still has no override. DampingEnabled == false forces
+ // every segment field to 0%, same as PushSegmentedDamping in
+ // UI/SettingsControl.xaml.cs.
var sd = ownsPedalFeelHardware ? cfg.SegmentedDamping : null;
- if (sd != null && (sd.Divider1Pressed >= 0 || sd.Divider2Pressed >= 0
+ if (sd != null && (!sd.DampingEnabled || sd.Divider1Pressed >= 0 || sd.Divider2Pressed >= 0
|| sd.Seg1Pressed >= 0 || sd.Seg2Pressed >= 0 || sd.Seg3Pressed >= 0
|| sd.Divider1Released >= 0 || sd.Divider2Released >= 0
|| sd.Seg1Released >= 0 || sd.Seg2Released >= 0 || sd.Seg3Released >= 0))
{
+ bool sdEnabled = sd.DampingEnabled;
var c = global::MozaPlugin.Devices.MBoosterUiConstants.SegDampSegDefaultPct;
var frame = global::MozaPlugin.Protocol.MozaMBoosterProtocol.BuildSegmentedDampingFrame(
sd.Divider1Pressed >= 0 ? sd.Divider1Pressed : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider1PressedDefaultPct,
sd.Divider2Pressed >= 0 ? sd.Divider2Pressed : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider2PressedDefaultPct,
sd.Divider1Released >= 0 ? sd.Divider1Released : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider1ReleasedDefaultPct,
sd.Divider2Released >= 0 ? sd.Divider2Released : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider2ReleasedDefaultPct,
- sd.Seg1Pressed >= 0 ? sd.Seg1Pressed : c,
- sd.Seg1Released >= 0 ? sd.Seg1Released : c,
- sd.Seg2Pressed >= 0 ? sd.Seg2Pressed : c,
- sd.Seg2Released >= 0 ? sd.Seg2Released : c,
- sd.Seg3Pressed >= 0 ? sd.Seg3Pressed : c,
- sd.Seg3Released >= 0 ? sd.Seg3Released : c,
+ !sdEnabled ? 0 : sd.Seg1Pressed >= 0 ? sd.Seg1Pressed : c,
+ !sdEnabled ? 0 : sd.Seg1Released >= 0 ? sd.Seg1Released : c,
+ !sdEnabled ? 0 : sd.Seg2Pressed >= 0 ? sd.Seg2Pressed : c,
+ !sdEnabled ? 0 : sd.Seg2Released >= 0 ? sd.Seg2Released : c,
+ !sdEnabled ? 0 : sd.Seg3Pressed >= 0 ? sd.Seg3Pressed : c,
+ !sdEnabled ? 0 : sd.Seg3Released >= 0 ? sd.Seg3Released : c,
dev);
controller.SendOneShot(frame);
}
diff --git a/UI/SettingsControl.xaml b/UI/SettingsControl.xaml
index dd4cc384..99394014 100644
--- a/UI/SettingsControl.xaml
+++ b/UI/SettingsControl.xaml
@@ -1948,13 +1948,15 @@
(cmdId 0xAE) — same passive-pedal gating as Travel/End Stop. -->
+
-
-
-
+
+
+
@@ -2072,6 +2074,7 @@
Icon="{StaticResource IconDamper}"
IsCollapsible="True">
+ = 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;
@@ -4490,6 +4497,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
@@ -4540,11 +4570,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) =>
@@ -4553,15 +4589,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 27904e80..729bfd67 100644
--- a/docs/protocol/devices/mbooster.md
+++ b/docs/protocol/devices/mbooster.md
@@ -1136,6 +1136,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
@@ -1212,6 +1223,18 @@ 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).
+**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`,
From 88b7bb6d1a8b38618267f84c0f90a9ca39bffbd3 Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Wed, 19 Aug 2026 13:03:30 +1200
Subject: [PATCH 09/22] adding localisation, fixing ui bugs
---
Resources/Strings.Designer.cs | 1 +
Resources/Strings.de.resx | 5 +++++
Resources/Strings.el.resx | 5 +++++
Resources/Strings.es.resx | 5 +++++
Resources/Strings.fr.resx | 5 +++++
Resources/Strings.it.resx | 5 +++++
Resources/Strings.ko.resx | 5 +++++
Resources/Strings.nb.resx | 5 +++++
Resources/Strings.pt.resx | 11 +++++++++++
Resources/Strings.qps-ploc.resx | 13 ++++++++++++-
Resources/Strings.resx | 1 +
Resources/Strings.ru.resx | 5 +++++
Resources/Strings.vi.resx | 5 +++++
Resources/Strings.zh-Hans.resx | 5 +++++
UI/SettingsControl.xaml | 2 +-
UI/SettingsControl.xaml.cs | 28 +++++++++++++++++++++++-----
16 files changed, 99 insertions(+), 7 deletions(-)
diff --git a/Resources/Strings.Designer.cs b/Resources/Strings.Designer.cs
index 5cd38fe2..af387ad5 100644
--- a/Resources/Strings.Designer.cs
+++ b/Resources/Strings.Designer.cs
@@ -151,6 +151,7 @@ private static string Get(string key)
public static string Section_Position => Get("Section_Position");
public static string Subtitle_LiveHandbrakeInput => Get("Subtitle_LiveHandbrakeInput");
public static string Label_Position => Get("Label_Position");
+ public static string Label_OutputForce => Get("Label_OutputForce");
public static string Section_Calibration => Get("Section_Calibration");
public static string Subtitle_PullHandbrakeFully => Get("Subtitle_PullHandbrakeFully");
public static string Button_StartCalibration => Get("Button_StartCalibration");
diff --git a/Resources/Strings.de.resx b/Resources/Strings.de.resx
index ae49d06a..f53191c4 100644
--- a/Resources/Strings.de.resx
+++ b/Resources/Strings.de.resx
@@ -765,4 +765,9 @@ Id: {2}
PR geschlossen — auf Stabil umgestellt// Verstärkung pro Band · 100 % = neutral · 500 % = max. BoostEMPFINDLICHKEIT
+ Ausgabekraft
+ G-Kraft (Trägheits-Pedalgefühl) (Experimentell)
+ Max. Pedalweg (mm)
+ Reaktionsgeschwindigkeit (%)
+ Experimentell — bewegt das Pedal selbst unter Ihrem Fuß proportional zur aktuellen Längs-G-Kraft (Beschleunigen drückt nach vorne, Bremsen drückt zurück), anstatt zu vibrieren. Max. Pedalweg legt fest, wie weit es sich bei voller G-Kraft bewegt; Reaktionsgeschwindigkeit legt fest, wie schnell das Pedal zur neuen Position übergeht.
diff --git a/Resources/Strings.el.resx b/Resources/Strings.el.resx
index 65fb66d0..980823b5 100644
--- a/Resources/Strings.el.resx
+++ b/Resources/Strings.el.resx
@@ -764,4 +764,9 @@ Id: {2}
Το PR έκλεισε — μετάβαση σε Σταθερό// κέρδος ανά band · 100% = ουδέτερο · 500% = μέγιστη ενίσχυσηΕΥΑΙΣΘΗΣΙΑ
+ Δύναμη εξόδου
+ Δύναμη G (Αδρανειακή Αίσθηση Πεντάλ) (Πειραματικό)
+ Μέγιστη διαδρομή πεντάλ (mm)
+ Ταχύτητα απόκρισης (%)
+ Πειραματικό — μετακινεί το ίδιο το πεντάλ κάτω από το πόδι σας ανάλογα με την τρέχουσα διαμήκη δύναμη G (η επιτάχυνση το σπρώχνει προς τα εμπρός, το φρενάρισμα προς τα πίσω), αντί να δονείται. Η Μέγιστη διαδρομή πεντάλ ορίζει πόσο μετακινείται στη μέγιστη δύναμη G· η Ταχύτητα απόκρισης ορίζει πόσο γρήγορα το πεντάλ μεταβαίνει στη νέα θέση.
diff --git a/Resources/Strings.es.resx b/Resources/Strings.es.resx
index 2de47e8b..c3ea49ba 100644
--- a/Resources/Strings.es.resx
+++ b/Resources/Strings.es.resx
@@ -770,4 +770,9 @@ Id: {2}
PR cerrado — se cambió a Estable// ganancia por banda · 100% = neutro · 500% = realce máximoSENSIBILIDAD
+ Fuerza de salida
+ Fuerza G (sensación inercial del pedal) (experimental)
+ Recorrido máximo del pedal (mm)
+ Velocidad de respuesta (%)
+ Experimental — mueve el propio pedal bajo tu pie en proporción a la G longitudinal en tiempo real (acelerar lo empuja hacia adelante, frenar lo empuja hacia atrás), en lugar de vibrar. El Recorrido máximo del pedal define hasta dónde se mueve con G máxima; la Velocidad de respuesta define con qué rapidez el pedal avanza hacia la nueva posición.
diff --git a/Resources/Strings.fr.resx b/Resources/Strings.fr.resx
index e6199703..989796fe 100644
--- a/Resources/Strings.fr.resx
+++ b/Resources/Strings.fr.resx
@@ -765,4 +765,9 @@ Id : {2}
PR fermée — retour à Stable// gain par bande · 100 % = neutre · 500 % = boost maxSENSIBILITÉ
+ Force de sortie
+ Force G (ressenti inertiel de la pédale) (expérimental)
+ Course maximale de la pédale (mm)
+ Vitesse de réponse (%)
+ Expérimental — pousse la pédale elle-même sous votre pied en proportion de la G longitudinale en temps réel (accélérer la pousse vers l'avant, freiner la pousse vers l'arrière), plutôt que de vibrer. La Course maximale de la pédale définit jusqu'où elle se déplace à G maximale ; la Vitesse de réponse définit la rapidité avec laquelle la pédale rejoint la nouvelle position.
diff --git a/Resources/Strings.it.resx b/Resources/Strings.it.resx
index 270d8aa3..71f81a8e 100644
--- a/Resources/Strings.it.resx
+++ b/Resources/Strings.it.resx
@@ -768,4 +768,9 @@ Id: {2}
PR chiusa — passato a Stabile// guadagno per banda · 100% = neutro · 500% = amplificazione massimaSENSIBILITÀ
+ Forza di uscita
+ Forza G (sensazione inerziale del pedale) (sperimentale)
+ Corsa massima del pedale (mm)
+ Velocità di risposta (%)
+ Sperimentale — spinge il pedale stesso sotto il tuo piede in proporzione alla G longitudinale in tempo reale (l'accelerazione lo spinge in avanti, la frenata lo spinge indietro), invece di vibrare. La Corsa massima del pedale imposta quanto si muove a G massima; la Velocità di risposta imposta quanto velocemente il pedale raggiunge la nuova posizione.
diff --git a/Resources/Strings.ko.resx b/Resources/Strings.ko.resx
index 98a43e9b..b72804a7 100644
--- a/Resources/Strings.ko.resx
+++ b/Resources/Strings.ko.resx
@@ -764,4 +764,9 @@ Id: {2}
PR 종료됨 — 안정 채널로 전환됨// 대역별 게인 · 100% = 중립 · 500% = 최대 부스트감도
+ 출력 힘
+ G포스 (관성 페달 느낌) (실험적)
+ 최대 페달 이동 거리 (mm)
+ 반응 속도 (%)
+ 실험적 — 진동 대신, 실시간 종방향 G에 비례하여 발밑의 페달 자체를 밀어냅니다(가속 시 앞으로, 제동 시 뒤로 밀림). 최대 페달 이동 거리는 최대 G에서 얼마나 움직이는지를 설정하고, 반응 속도는 페달이 새 위치로 얼마나 빠르게 이동하는지를 설정합니다.
diff --git a/Resources/Strings.nb.resx b/Resources/Strings.nb.resx
index f2cd4556..0b002d46 100644
--- a/Resources/Strings.nb.resx
+++ b/Resources/Strings.nb.resx
@@ -770,4 +770,9 @@ Id: {2}
PR lukket — byttet til Stabil// forsterkning per bånd · 100 % = nøytral · 500 % = maks boostFØLSOMHET
+ Utgangskraft
+ G-kraft (treghets-pedalfølelse) (eksperimentell)
+ Maks pedalbevegelse (mm)
+ Responshastighet (%)
+ Eksperimentell — skyver selve pedalen under foten din proporsjonalt med sanntids langsgående G-kraft (akselerasjon skyver fremover, bremsing skyver bakover), i stedet for å vibrere. Maks pedalbevegelse angir hvor langt den beveger seg ved full G; Responshastighet angir hvor raskt pedalen beveger seg til den nye posisjonen.
diff --git a/Resources/Strings.pt.resx b/Resources/Strings.pt.resx
index 081884a1..8ca5762f 100644
--- a/Resources/Strings.pt.resx
+++ b/Resources/Strings.pt.resx
@@ -758,4 +758,15 @@ Id: {2}
PR fechado — alterado para Estável// ganho por banda · 100% = neutro · 500% = reforço máx.SENSIBILIDADE
+ Força de saída
+ Força G (sensação inercial do pedal) (experimental)
+ Curso máx. do pedal (mm)
+ Velocidade de resposta (%)
+ Experimental — empurra o próprio pedal sob o seu pé em proporção à força G longitudinal em tempo real (acelerar empurra para frente, frear empurra para trás), em vez de vibrar. O Curso máx. do pedal define até onde ele se move na G máxima; a Velocidade de resposta define com que rapidez o pedal avança até a nova posição.
+ Simula uma força de fricção independente da saída do jogo.
+ AMORTECIMENTO SEGMENTADO
+ Força de amortecimento independente da saída do jogo, por segmento de curso do pedal
+ Simula uma força de amortecimento independente da saída do jogo. O curso do pedal é dividido em vários segmentos, cada um com um intervalo ajustável e seu próprio amortecimento natural. Arraste um divisor para redimensionar um segmento; arraste dentro de um segmento para definir sua quantidade de amortecimento.
+ Ao pressionar
+ Ao soltar
diff --git a/Resources/Strings.qps-ploc.resx b/Resources/Strings.qps-ploc.resx
index 7436a4ef..48921656 100644
--- a/Resources/Strings.qps-ploc.resx
+++ b/Resources/Strings.qps-ploc.resx
@@ -19,7 +19,7 @@
Arf!ARF!!Boof!
- Wuff!
+ GOD DAMMIT FRANKBark!GRRR!!Wroof!
@@ -749,4 +749,15 @@
Bark Arf!Ruff Bark Grrowl Wuff!AWOO!!
+ Wroof!
+ Yowl!
+ Boof!
+ Yap!
+ Grrowl Yip Wuff Bork Howl Arf!
+ Snarf Wan Boof!
+ GRRR!!
+ Yowl Bork!
+ Ruff Yowl Grr Wuff Yip Bark!
+ Arf!
+ Grr!
diff --git a/Resources/Strings.resx b/Resources/Strings.resx
index 481063af..fefee8b2 100644
--- a/Resources/Strings.resx
+++ b/Resources/Strings.resx
@@ -89,6 +89,7 @@
POSITION// live handbrake inputPosition
+ Output ForceCALIBRATION// pull the handbrake fully onceSTART CALIBRATION
diff --git a/Resources/Strings.ru.resx b/Resources/Strings.ru.resx
index 675e0cf7..700d90b8 100644
--- a/Resources/Strings.ru.resx
+++ b/Resources/Strings.ru.resx
@@ -765,4 +765,9 @@ Id: {2}
PR закрыт — переключено на Стабильный// усиление по полосам · 100% = нейтраль · 500% = максимумЧУВСТВИТЕЛЬНОСТЬ
+ Выходное усилие
+ G-сила (инерционное ощущение педали) (экспериментально)
+ Макс. ход педали (мм)
+ Скорость отклика (%)
+ Экспериментально — толкает саму педаль под вашей ногой пропорционально текущей продольной перегрузке G (ускорение толкает вперёд, торможение — назад), вместо вибрации. Макс. ход педали задаёт, насколько далеко она движется при максимальной G; Скорость отклика задаёт, как быстро педаль переходит в новое положение.
diff --git a/Resources/Strings.vi.resx b/Resources/Strings.vi.resx
index 4279abd6..419f6b43 100644
--- a/Resources/Strings.vi.resx
+++ b/Resources/Strings.vi.resx
@@ -765,4 +765,9 @@ Id: {2}
PR đã đóng — đã chuyển về Ổn định// khuếch đại từng dải · 100% = trung tính · 500% = tăng tối đaĐỘ NHẠY
+ Lực đầu ra
+ Lực G (cảm giác bàn đạp quán tính) (thử nghiệm)
+ Hành trình bàn đạp tối đa (mm)
+ Tốc độ phản hồi (%)
+ Thử nghiệm — đẩy chính bàn đạp dưới chân bạn tỷ lệ với lực G dọc theo thời gian thực (tăng tốc đẩy về phía trước, phanh đẩy về phía sau), thay vì rung. Hành trình bàn đạp tối đa quy định nó di chuyển bao xa ở G tối đa; Tốc độ phản hồi quy định bàn đạp chuyển đến vị trí mới nhanh như thế nào.
diff --git a/Resources/Strings.zh-Hans.resx b/Resources/Strings.zh-Hans.resx
index 630ac762..2fe13b26 100644
--- a/Resources/Strings.zh-Hans.resx
+++ b/Resources/Strings.zh-Hans.resx
@@ -765,4 +765,9 @@ Id: {2}
PR 已关闭 — 已切换到稳定版// 各频段增益 · 100% = 中性 · 500% = 最大增益灵敏度
+ 输出力值
+ G力(惯性踏板手感)(实验性)
+ 最大踏板行程 (mm)
+ 响应速度 (%)
+ 实验性 — 根据实时纵向 G 力推动脚下的踏板本身(加速时向前推,刹车时向后推),而不是震动。最大踏板行程设置在最大 G 力下移动的距离;响应速度设置踏板过渡到新位置的快慢。
diff --git a/UI/SettingsControl.xaml b/UI/SettingsControl.xaml
index 99394014..4df41255 100644
--- a/UI/SettingsControl.xaml
+++ b/UI/SettingsControl.xaml
@@ -1908,7 +1908,7 @@
displays. kg is a computed estimate (raw position % scaled by
this pedal's own Max Threshold, i.e. what force reads 100%
travel), not a directly-read sensor value. -->
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ overwrite the active pedal's registers instead. Placed first
+ in this card so the curve (the primary control) is
+ immediately visible without scrolling. -->
@@ -1902,37 +1864,45 @@
-
+
+
-
+
-
+
-
+
-
+
@@ -1944,6 +1914,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1975,6 +1986,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1986,7 +2009,8 @@
+ AllowHorizontalDrag="True"
+ EndpointsOnlyDraggableInX="True"/>
diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs
index b31c7abb..49a57b84 100644
--- a/UI/SettingsControl.xaml.cs
+++ b/UI/SettingsControl.xaml.cs
@@ -3281,6 +3281,14 @@ private void SeedMBoosterConfigControls(IMBoosterPedalConfig? fx)
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;
@@ -4272,12 +4280,39 @@ private void SetMBoosterInputCurveY(int index, int 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;
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());
@@ -4286,6 +4321,12 @@ private void ApplyMBoosterInputCurvePreset(int[] curve)
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 < n; i++)
s.InputCurveY[i] = curve[i];
@@ -4367,9 +4408,10 @@ private void PushMBoosterFeelCurve(IMBoosterPedalConfig s)
byte dev = MBoosterCalibDevice(controller, _mboosterEffectPedalIndex);
double dz = s.DeadzoneKg >= 0 ? s.DeadzoneKg : 0;
double mf = s.MaxForceKg >= 0 ? s.MaxForceKg : 200;
- float[]? curve = s.InputCurveY;
+ float[]? curveY = s.InputCurveY;
+ float[]? curveX = s.InputCurveX;
controller.QueueCalibWrite($"{dev:x2}:feel-curve", () =>
- controller.PushFeelCurveResync(dz, mf, curve, dev));
+ controller.PushFeelCurveResync(dz, mf, curveY, curveX, dev));
}
private void MBoosterTravelRangeSlider_RangeChanged(object sender, EventArgs e)
diff --git a/docs/protocol/devices/mbooster.md b/docs/protocol/devices/mbooster.md
index 729bfd67..03886842 100644
--- a/docs/protocol/devices/mbooster.md
+++ b/docs/protocol/devices/mbooster.md
@@ -1015,7 +1015,10 @@ device. `CurveY`/`CurveX` are `null` by default (identity / no remapping)
## Pedal Feel
A card to the left of Sim Input Mapping holds `InputCurveY` on
-`MBoosterDeviceSettings` — 6 nodes, Y-only (no X-dragging).
+`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
@@ -1039,23 +1042,57 @@ 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 6 fixed X breakpoints — `{8.049, 19.495, 44.245, 72.433,
-90.040, 97.910}%` of the Deadzone→Max Force span — are the SAME
+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: they were
-originally measured as a fixed interpolation *formula*, but are actually
-Pit House's own un-dragged default shape (a Linear/identity curve — Y=X
-trivially holds for any untouched curve regardless of the real breakpoint
-spacing, so the measurement couldn't distinguish "fixed rule" from
-"default shape" until this session's clarification that the curve is
-genuinely user-adjustable). `null` (the default) means "use this Linear
-default" — existing profiles are unaffected until a user opens this
+(`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
other control in this section, this one is a **real hardware
@@ -1335,8 +1372,9 @@ rather than deleting it:
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) — always EXPERIMENTAL/unconfirmed: spotted
- exactly once, alongside a Travel Start write in `pedal_travel.pcapng`,
+ 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/
@@ -1351,6 +1389,15 @@ rather than deleting it:
— 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
@@ -1855,11 +1902,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
From 91bd9b38a602fdcf8b3edeea4ed3ac83ba4e63ef Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Thu, 20 Aug 2026 10:39:00 +1200
Subject: [PATCH 11/22] fixed role flipping bug
---
Devices/MozaMBoosterRegistry.cs | 29 ++++++++++++++++-----
UI/Controls/MozaCurveEditor.cs | 46 ++++++++++++++++++++++++---------
UI/SettingsControl.xaml | 7 +++++
3 files changed, 63 insertions(+), 19 deletions(-)
diff --git a/Devices/MozaMBoosterRegistry.cs b/Devices/MozaMBoosterRegistry.cs
index 49727b9d..058dd7c7 100644
--- a/Devices/MozaMBoosterRegistry.cs
+++ b/Devices/MozaMBoosterRegistry.cs
@@ -816,13 +816,18 @@ private void MergePositions()
/// (, set by the UI when
/// the user remaps) always wins. Otherwise: a single-axis device uses
/// the legacy (exact backward
- /// compat); a multi-pedal chain defaults by axis order to
- /// [Throttle, Brake, Clutch]. That order is the standard Moza pedal
- /// usage convention — real hardware (support bundle 2026-07-07) exposes
- /// the chain's pedals as GenericDesktop axes Rx(0x33)/Ry(0x34)/Rz(0x35),
- /// which ascending-sorted give index 0/1/2, and Moza maps Rx→throttle,
- /// Ry→brake, Rz→clutch (see MozaHidClass.Pedals). The user remaps via
- /// the UI if a given unit's wiring differs.
+ /// compat); axis 0 of a grown-but-not-yet-remapped chain ALSO honors an
+ /// already-explicit Role rather than the position default, so a pedal
+ /// the user set to Brake while solo doesn't silently become "Throttle"
+ /// the moment a second pedal gets chained onto the same lane. Any other
+ /// axis (or axis 0 with Role still at its Disabled default) falls back
+ /// to axis order: [Throttle, Brake, Clutch]. That order is the standard
+ /// Moza pedal usage convention — real hardware (support bundle
+ /// 2026-07-07) exposes the chain's pedals as GenericDesktop axes
+ /// Rx(0x33)/Ry(0x34)/Rz(0x35), which ascending-sorted give index 0/1/2,
+ /// and Moza maps Rx→throttle, Ry→brake, Rz→clutch (see
+ /// MozaHidClass.Pedals). The user remaps via the UI if a given unit's
+ /// wiring differs.
///
internal static MBoosterRole ResolveAxisRole(MBoosterDeviceSettings? s, int axisIndex, int axisCount)
{
@@ -831,6 +836,16 @@ internal static MBoosterRole ResolveAxisRole(MBoosterDeviceSettings? s, int axis
return roles[axisIndex];
if (axisCount <= 1)
return s?.Role ?? MBoosterRole.Disabled;
+ // Axis 0 IS the legacy Role field's slot for a single-axis device
+ // (see the axisCount<=1 branch above) — if the chain then grows to
+ // a multi-pedal lane before AxisRoles is ever explicitly seeded,
+ // honor whatever the user already set there instead of silently
+ // reverting axis 0 to the position-based Throttle default below.
+ // Losing an explicitly-set Brake there used to hide the brake-only
+ // Sensor Output Ratio/Max Threshold sliders (and mis-route
+ // calibration writes) for a pedal the user never touched.
+ if (axisIndex == 0 && s?.Role is MBoosterRole role0 && role0 != MBoosterRole.Disabled)
+ return role0;
switch (axisIndex)
{
case 0: return MBoosterRole.Throttle; // Rx (0x33)
diff --git a/UI/Controls/MozaCurveEditor.cs b/UI/Controls/MozaCurveEditor.cs
index 71db9af2..794b4a91 100644
--- a/UI/Controls/MozaCurveEditor.cs
+++ b/UI/Controls/MozaCurveEditor.cs
@@ -509,6 +509,19 @@ protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo)
private int _dragNode = -1;
private Canvas? _canvas;
+ // Endpoint-drag rescale baseline (see EndpointsOnlyDraggableInX) —
+ // captured ONCE at the start of an endpoint drag, not re-derived
+ // every tick from the current (already-rescaled, already-rounded)
+ // positions. Re-deriving it every tick let a middle node's fraction
+ // collapse to exactly 0 or 1 once heavy compression rounded its X
+ // onto an endpoint's own X: every later tick read frac=0 (or 1)
+ // again from that same now-stuck position, so the curve could
+ // compress but never re-expand — this fixes that by keeping the
+ // reference fractions stable for the whole drag gesture.
+ private double[]? _dragBaseFracs;
+ private double _dragBaseFirstX;
+ private double _dragBaseSpan;
+
private void HookCanvas()
{
_canvas = GetTemplateChild("PART_Canvas") as Canvas;
@@ -528,12 +541,25 @@ private void OnMouseDown(object sender, MouseButtonEventArgs e)
_dragNode = FindClosestNode(p);
if (_dragNode >= 0)
{
+ int lastNode = ClampedNodeCount() - 1;
+ if (EndpointsOnlyDraggableInX && (_dragNode == 0 || _dragNode == lastNode))
+ CaptureEndpointDragBaseline(lastNode);
_canvas.CaptureMouse();
ApplyDrag(p);
e.Handled = true;
}
}
+ private void CaptureEndpointDragBaseline(int lastNode)
+ {
+ _dragBaseFirstX = GetX(0);
+ _dragBaseSpan = GetX(lastNode) - _dragBaseFirstX;
+ _dragBaseFracs = new double[lastNode + 1];
+ if (_dragBaseSpan > 0.0001)
+ for (int m = 1; m < lastNode; m++)
+ _dragBaseFracs[m] = (GetX(m) - _dragBaseFirstX) / _dragBaseSpan;
+ }
+
private void OnMouseMove(object sender, MouseEventArgs e)
{
if (_dragNode < 0 || _canvas == null) return;
@@ -620,24 +646,20 @@ private void ApplyDrag(Point p)
if (EndpointsOnlyDraggableInX && isEndpoint)
{
- // Rescale every in-between node's X to keep its old
- // fractional position between the two endpoints, so the
- // curve's shape follows the endpoint being dragged
- // instead of being left bunched up behind it.
- double oldFirstX = GetX(0);
- double oldLastX = GetX(lastNode);
- double oldSpan = oldLastX - oldFirstX;
+ // Rescale every in-between node's X to keep its
+ // fractional position — captured once at drag start in
+ // _dragBaseFracs, see CaptureEndpointDragBaseline — between
+ // the two endpoints, so the curve's shape follows the
+ // endpoint being dragged instead of being left bunched up
+ // behind it.
SetX(_dragNode, dataX);
- if (oldSpan > 0.0001)
+ if (_dragBaseFracs != null && _dragBaseSpan > 0.0001)
{
double newFirstX = GetX(0);
double newLastX = GetX(lastNode);
double newSpan = newLastX - newFirstX;
for (int m = 1; m < lastNode; m++)
- {
- double frac = (GetX(m) - oldFirstX) / oldSpan;
- SetX(m, Math.Round(newFirstX + frac * newSpan));
- }
+ SetX(m, Math.Round(newFirstX + _dragBaseFracs[m] * newSpan));
}
}
else
diff --git a/UI/SettingsControl.xaml b/UI/SettingsControl.xaml
index 520e70b9..6ad1bab6 100644
--- a/UI/SettingsControl.xaml
+++ b/UI/SettingsControl.xaml
@@ -2007,6 +2007,13 @@
+
+
Date: Thu, 20 Aug 2026 10:50:11 +1200
Subject: [PATCH 12/22] profile loads correct vlaues on start up
---
UI/SettingsControl.xaml.cs | 39 ++++++++++++++++++++++++++++++++++++--
1 file changed, 37 insertions(+), 2 deletions(-)
diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs
index 49a57b84..f2189aef 100644
--- a/UI/SettingsControl.xaml.cs
+++ b/UI/SettingsControl.xaml.cs
@@ -145,9 +145,26 @@ private void OnLoadedStartTimers(object sender, RoutedEventArgs e)
// WPF can fire Loaded more than once if the control is reparented
// (SimHub's tab containers do this during settings-panel layout).
// Calling Start() twice would double the tick rate.
+ bool wasRunning = _refreshTimer.IsEnabled;
if (!_refreshTimer.IsEnabled) _refreshTimer.Start();
if (!_steeringAngleTimer.IsEnabled) _steeringAngleTimer.Start();
if (_bandwidthTimer != null && !_bandwidthTimer.IsEnabled) _bandwidthTimer.Start();
+
+ // A genuine (re)load — not just a redundant Loaded firing while
+ // everything's already running — means this control's timers were
+ // stopped (OnUnloadedStopTimers) for however long it was off-screen
+ // (navigated away to another plugin's page, or the settings window
+ // was closed). RefreshMBoosterTab never ran during that window, so
+ // if the active SimHub profile changed while this page was hidden,
+ // waiting for _refreshTimer's first post-reload tick would show up
+ // to 500ms of the PREVIOUS profile's mBooster values the instant the
+ // tab becomes visible again. Force one immediate, synchronous
+ // reseed instead of waiting for that first tick.
+ if (!wasRunning)
+ {
+ _mboosterUiSeeded = false;
+ RefreshMBoosterTab();
+ }
}
private void OnUnloadedStopTimers(object sender, RoutedEventArgs e)
@@ -2936,6 +2953,26 @@ 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
@@ -2960,8 +2997,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"));
From 71031f5aa947a524a81274ca8c1def799e67abf2 Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Thu, 20 Aug 2026 10:54:41 +1200
Subject: [PATCH 13/22] updated damping curve rendering
---
UI/Controls/MozaSegmentedBarEditor.cs | 26 ++++++++++++++++++++++++--
1 file changed, 24 insertions(+), 2 deletions(-)
diff --git a/UI/Controls/MozaSegmentedBarEditor.cs b/UI/Controls/MozaSegmentedBarEditor.cs
index b9c7d271..fe97e8cf 100644
--- a/UI/Controls/MozaSegmentedBarEditor.cs
+++ b/UI/Controls/MozaSegmentedBarEditor.cs
@@ -498,8 +498,30 @@ private static void AddSmoothPolyline(PathFigure fig, Point[] pts)
Point p1 = pts[i];
Point p2 = pts[i + 1];
Point p3 = (i + 2 < n) ? pts[i + 2] : pts[n - 1];
- Point c1 = new Point(p1.X + (p2.X - p0.X) / 6.0, p1.Y + (p2.Y - p0.Y) / 6.0);
- Point c2 = new Point(p2.X - (p3.X - p1.X) / 6.0, p2.Y - (p3.Y - p1.Y) / 6.0);
+ Point c1, c2;
+ if (p1.Y == p2.Y)
+ {
+ // A flat run (both endpoints at the same height — the
+ // plateau segments in Recompute's 6-point layout) must
+ // stay flat. Reaching past it to p0/p3 at a DIFFERENT
+ // height (the neighbouring divider transition) leaks a
+ // phantom slope into this segment's own tangent,
+ // drawing a small dip/bump right at the plateau's edge
+ // instead of a straight line — exactly the artifact
+ // visible right before/after each divider. Force a
+ // zero-slope tangent instead; the transition segment on
+ // the other side of the divider still computes its own
+ // tangent correctly, since IT reaches back into a flat
+ // neighbour that's at the SAME height as its own near
+ // endpoint.
+ c1 = new Point(p1.X + (p2.X - p0.X) / 6.0, p1.Y);
+ c2 = new Point(p2.X - (p3.X - p1.X) / 6.0, p2.Y);
+ }
+ else
+ {
+ c1 = new Point(p1.X + (p2.X - p0.X) / 6.0, p1.Y + (p2.Y - p0.Y) / 6.0);
+ c2 = new Point(p2.X - (p3.X - p1.X) / 6.0, p2.Y - (p3.Y - p1.Y) / 6.0);
+ }
fig.Segments.Add(new BezierSegment(c1, c2, p2, true));
}
}
From c66833af182b76ee721654c1f5725d35cbf9b4c5 Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Thu, 20 Aug 2026 11:38:37 +1200
Subject: [PATCH 14/22] seeding ui with loaded profile values
---
UI/Controls/MozaSegmentedBarEditor.cs | 29 +++++++++++++-------
UI/SettingsControl.xaml.cs | 38 ++++++++++++++++++++-------
2 files changed, 47 insertions(+), 20 deletions(-)
diff --git a/UI/Controls/MozaSegmentedBarEditor.cs b/UI/Controls/MozaSegmentedBarEditor.cs
index fe97e8cf..419c2f51 100644
--- a/UI/Controls/MozaSegmentedBarEditor.cs
+++ b/UI/Controls/MozaSegmentedBarEditor.cs
@@ -434,16 +434,25 @@ double YOf(double pct)
// Smoothed line ON TOP of the bars, at each segment's own height —
// flat across the middle of its travel range, easing through a
- // wide curve around each divider instead of jumping vertically
- // — the same shape the three bars already imply, just traced as
- // one continuous, rounded line so the overall profile is easier
- // to read at a glance. The transition half-width reaches well
- // into each neighboring segment/gap for a gradual blend, but is
- // still capped and shrunk for narrow segments/gaps so the six
- // control points below can never cross each other or the plot
- // edges.
- double transitionHalfWidth = Math.Max(2.0, Math.Min(36.0,
- Math.Min(d1x - EdgePad, Math.Min(d2x - d1x, EdgePad + plotW - d2x)) / 2.2));
+ // short, tight curve right at each divider instead of jumping
+ // vertically — the same shape the three bars already imply, just
+ // traced as one continuous line so the overall profile is easier
+ // to read at a glance. Sized to match Pit House's own rendering
+ // (a quick S right at the divider, flat everywhere else): the
+ // half-width is ~1/4 of the SMALLEST segment's width, measured
+ // against a real Pit House screenshot's proportions (its
+ // transition-to-segment-width ratio came out close to that,
+ // vs. the previous /2.2 divisor here which was visibly wider/
+ // more "curvy" than the reference — a long, gradual bow reaching
+ // deep into each segment instead of a quick kink at the
+ // divider). The 60px ceiling is just a backstop for an unusually
+ // wide segment, not the normal-case constraint; the /4.0 term
+ // does the real work and scales with the control's actual
+ // rendered size. Still shrinks for narrow segments/gaps so the
+ // six control points below can never cross each other or the
+ // plot edges.
+ double transitionHalfWidth = Math.Max(2.0, Math.Min(60.0,
+ Math.Min(d1x - EdgePad, Math.Min(d2x - d1x, EdgePad + plotW - d2x)) / 4.0));
var stepPts = new[]
{
new Point(EdgePad, YOf(s1v)),
diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs
index f2189aef..3acb9136 100644
--- a/UI/SettingsControl.xaml.cs
+++ b/UI/SettingsControl.xaml.cs
@@ -2775,6 +2775,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.
@@ -2974,20 +2991,20 @@ private void RefreshMBoosterTab()
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);
using (_suppressor.Begin())
{
// Role is seeded per-row by the device rows block above (each
@@ -3008,6 +3025,7 @@ private void RefreshMBoosterTab()
_mboosterUiSeeded = true;
_mboosterSeededProfileName = currentProfileName;
_mboosterSeededIdentity = selected.Identity;
+ _mboosterSeededSettings = s;
}
/// Click handler for a pedal row's label Button (see
From 6eaf02dca9353d95e4c8ad72ad4815e85c4c31ba Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Thu, 20 Aug 2026 11:59:29 +1200
Subject: [PATCH 15/22] adding diagnostics for profile seeding
---
MozaPlugin.cs | 13 +++++++++++++
UI/SettingsControl.xaml.cs | 11 +++++++++++
2 files changed, 24 insertions(+)
diff --git a/MozaPlugin.cs b/MozaPlugin.cs
index bf85fd22..9788ff43 100644
--- a/MozaPlugin.cs
+++ b/MozaPlugin.cs
@@ -2352,6 +2352,13 @@ internal MBoosterDeviceSettings GetOrCreateMBoosterSettings(string identity)
if (!dict.TryGetValue(key, out var s) || s == null)
{
+ // Diagnostic trail for the "curve values wrong until profile
+ // reload" class of bug — this is the moment a caller gets
+ // handed a brand-new, all-defaults placeholder instead of
+ // the real saved entry, e.g. because `key` is still the raw
+ // transport identity (serial not resolved/re-keyed yet) at
+ // the moment the settings UI first seeds from it.
+ MozaLog.Info($"[AZOM\\mBooster] GetOrCreateMBoosterSettings: NEW placeholder for key='{key}' (original='{original}', resolvedSerial={!string.Equals(original, key, StringComparison.OrdinalIgnoreCase)}) in profile '{profile.Name}'");
s = new MBoosterDeviceSettings();
dict[key] = s;
}
@@ -2370,6 +2377,12 @@ internal MBoosterDeviceSettings GetOrCreateMBoosterSettings(string identity)
private void OnMBoosterSerialResolved(string identity, string serial)
{
if (IsShuttingDown || string.IsNullOrEmpty(identity) || string.IsNullOrEmpty(serial)) return;
+ // Diagnostic trail alongside GetOrCreateMBoosterSettings's own
+ // placeholder-creation log — if this fires well AFTER the settings
+ // UI has already seeded from a transport-keyed placeholder for the
+ // same identity, that's the race: the UI showed defaults/stale data
+ // before this re-key ever ran, and nothing told it to reseed.
+ MozaLog.Info($"[AZOM\\mBooster] OnMBoosterSerialResolved: identity={MBoosterDeviceController.ShortIdentity(identity)} serial={serial}");
_mboosterSerialByIdentity[identity] = "mbooster:" + serial;
try
{
diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs
index 3acb9136..7c87bcf5 100644
--- a/UI/SettingsControl.xaml.cs
+++ b/UI/SettingsControl.xaml.cs
@@ -3005,6 +3005,17 @@ private void RefreshMBoosterTab()
_mboosterUiSeeded = false;
if (_mboosterUiSeeded) return;
+ // 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
From 01927cc8b8fae8a772f29814fc6f10298ad61c18 Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Thu, 20 Aug 2026 12:36:11 +1200
Subject: [PATCH 16/22] fixing profile re-seeding issue, still
---
MozaPlugin.cs | 76 ++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 72 insertions(+), 4 deletions(-)
diff --git a/MozaPlugin.cs b/MozaPlugin.cs
index 9788ff43..3f7b757a 100644
--- a/MozaPlugin.cs
+++ b/MozaPlugin.cs
@@ -2340,13 +2340,47 @@ internal MBoosterDeviceSettings GetOrCreateMBoosterSettings(string identity)
var dict = profile.MBoosterSettings;
// Lazily migrate a transient transport-keyed entry to the serial
- // key in the current profile. A serial-keyed entry, if one
- // already exists (the user's saved config from a prior session),
- // wins — the transport entry is a just-created placeholder.
+ // key in the current profile.
+ //
+ // A brand-new transport-keyed placeholder gets created (below)
+ // the instant the device is first detected, BEFORE its serial
+ // has been read back — this is normal and happens every single
+ // session. If the user starts editing (dragging a curve node,
+ // say) in the brief window before OnMBoosterSerialResolved
+ // fires and migrates it, those edits land on THIS placeholder.
+ // The old version of this migration always kept whichever
+ // object was ALREADY under the serial key and silently deleted
+ // the transport-keyed one — meaning a live edit made in that
+ // window was discarded outright, with no warning, the moment
+ // the serial resolved (bug: a real drag-tested curve edit
+ // vanished, reverting to whatever stale data pre-dated it, even
+ // though the whole session shut down cleanly afterwards).
+ //
+ // Fix: an untouched placeholder (see IsUntouchedMBoosterPlaceholder)
+ // still loses to whatever's already at the serial key, same as
+ // before. But once the transport-keyed entry holds real,
+ // user-visible data, it wins — it can only have gotten that data
+ // via a live edit moments ago (it started as an empty placeholder
+ // THIS session), so it's the freshest thing we know about. Only
+ // log (not silently overwrite) when the serial-keyed side ALSO
+ // already holds real data — a genuine two-real-datasets conflict
+ // this heuristic can't perfectly resolve, but at least it's now
+ // visible instead of an invisible, permanent data loss.
if (!string.Equals(original, key, StringComparison.OrdinalIgnoreCase)
&& dict.TryGetValue(original, out var stale))
{
- if (!dict.ContainsKey(key)) dict[key] = stale;
+ bool staleUntouched = IsUntouchedMBoosterPlaceholder(stale);
+ bool keyHasEntry = dict.TryGetValue(key, out var existing);
+ if (!keyHasEntry)
+ {
+ dict[key] = stale;
+ }
+ else if (!staleUntouched)
+ {
+ if (!IsUntouchedMBoosterPlaceholder(existing))
+ MozaLog.Warn($"[AZOM\\mBooster] GetOrCreateMBoosterSettings: BOTH the transport-keyed entry ('{original}') and the serial-keyed entry ('{key}') hold real data in profile '{profile.Name}' — keeping the transport-keyed (more recently touched) one; the serial-keyed one's prior values are discarded.");
+ dict[key] = stale;
+ }
dict.Remove(original);
}
@@ -2366,6 +2400,40 @@ internal MBoosterDeviceSettings GetOrCreateMBoosterSettings(string identity)
}
}
+ ///
+ /// True if every field GetOrCreateMBoosterSettings's re-key migration
+ /// cares about is still at its untouched sentinel/default — i.e. this
+ /// looks exactly like the placeholder GetOrCreateMBoosterSettings
+ /// itself creates for a just-detected device, not something a user
+ /// (or an import/migration) has actually written real values into.
+ /// Used to decide which of two colliding entries (transport-keyed vs
+ /// serial-keyed) is safe to discard during migration — see the caller.
+ /// Deliberately does NOT check the effect settings (Abs/Lockup/etc.)
+ /// or CustomEffects: those aren't part of the bug this guards against,
+ /// and their own field-level defaults are less clear-cut, so skipping
+ /// them only makes this check slightly less strict, never wrong in a
+ /// way that would newly discard real data it didn't already discard.
+ ///
+ private static bool IsUntouchedMBoosterPlaceholder(MBoosterDeviceSettings s)
+ {
+ return s.Role == global::MozaPlugin.Devices.MBoosterRole.Disabled
+ && s.AxisRoles == null
+ && s.Direction < 0 && s.Min < 0 && s.Max < 0
+ && s.CurveY == null && s.CurveX == null
+ && s.SensorOutputRatioPct < 0 && s.MaxThresholdKg < 0
+ && s.InputCurveY == null && s.InputCurveX == null
+ && s.DeadzoneKg < 0 && s.MaxForceKg < 0
+ && s.TravelStartMm < 0 && s.TravelEndMm < 0
+ && s.EndstopFrontStiffness < 0 && s.EndstopEndStiffness < 0
+ && s.NaturalFrictionPct < 0
+ && string.IsNullOrEmpty(s.DisplayName)
+ && (s.Pedals == null || s.Pedals.Count == 0)
+ && s.SegmentedDamping.Divider1Pressed < 0 && s.SegmentedDamping.Divider2Pressed < 0
+ && s.SegmentedDamping.Seg1Pressed < 0 && s.SegmentedDamping.Seg2Pressed < 0 && s.SegmentedDamping.Seg3Pressed < 0
+ && s.SegmentedDamping.Divider1Released < 0 && s.SegmentedDamping.Divider2Released < 0
+ && s.SegmentedDamping.Seg1Released < 0 && s.SegmentedDamping.Seg2Released < 0 && s.SegmentedDamping.Seg3Released < 0;
+ }
+
///
/// A lane's 32-char Moza serial has been interrogated. Record the
/// identity→serial mapping (so settings lookups re-key to it), migrate
From c4cac8a1728d1d2bccda728039e1569a72f67245 Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Thu, 20 Aug 2026 12:49:30 +1200
Subject: [PATCH 17/22] fixing profile re-seeding
---
UI/SettingsControl.xaml.cs | 30 +++++++++++++++++++++++++++++-
1 file changed, 29 insertions(+), 1 deletion(-)
diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs
index 7c87bcf5..03501a7d 100644
--- a/UI/SettingsControl.xaml.cs
+++ b/UI/SettingsControl.xaml.cs
@@ -2889,8 +2889,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;
}
}
From 4b7802015828e77da85b3199a53afd95c8fa497e Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Wed, 26 Aug 2026 10:05:12 +1200
Subject: [PATCH 18/22] correcting input curve editors with additional nodes
---
Devices/MBoosterTypes.cs | 3 +-
Devices/MozaMBoosterRegistry.cs | 13 +++--
MozaPlugin.cs | 84 ++++++++++++++++++++++++++++++-
UI/Controls/MozaCurveEditor.cs | 27 +++++++---
UI/Import/PitHousePedalsMapper.cs | 6 +--
UI/MozaPluginSettings.cs | 10 ++++
UI/SettingsControl.xaml.cs | 16 +++---
docs/protocol/devices/mbooster.md | 18 ++++---
8 files changed, 148 insertions(+), 29 deletions(-)
diff --git a/Devices/MBoosterTypes.cs b/Devices/MBoosterTypes.cs
index 593c7429..2a5c6ecf 100644
--- a/Devices/MBoosterTypes.cs
+++ b/Devices/MBoosterTypes.cs
@@ -693,7 +693,8 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig
// X position (0..100) of each output-curve node, draggable in the
// Sim Input Mapping curve editor. Null = default fixed breakpoints
- // (100/7 * k for k=1..6 — see MozaMBoosterRegistry.DefaultCurveX).
+ // (100/6 * k for k=1..6, last node at 100% — see
+ // MozaMBoosterRegistry.DefaultCurveX).
public float[]? CurveX { get; set; } = null;
// Per-pedal calibration for the ADDITIONAL pedals on a chained mBooster
diff --git a/Devices/MozaMBoosterRegistry.cs b/Devices/MozaMBoosterRegistry.cs
index 058dd7c7..272f0839 100644
--- a/Devices/MozaMBoosterRegistry.cs
+++ b/Devices/MozaMBoosterRegistry.cs
@@ -549,11 +549,16 @@ internal static double EvaluateCurveArbitraryX(float[] xs, float[] ys, double x)
}
// Default (un-dragged) node X breakpoints for the Sim Input Mapping
- // output curve, 100/7 * k for k=1..6 — evenly spaced, ending short
- // of 100% so the curve can plateau before full physical travel
- // (see EvaluateCurveArbitraryX's "100% output before 100% input").
+ // output curve, 100/6 * k for k=1..6 — evenly spaced, last node at
+ // exactly 100% so an untouched curve maps full input to full output.
+ // (Previously 100/7 * k, inherited from the disproven/removed
+ // curve7 mechanism's selectors purely for cosmetic continuity — see
+ // docs/protocol/devices/mbooster.md "Sim Input Mapping" — which left
+ // the last node short at ~85.7%, so "100% output before 100% input"
+ // via EvaluateCurveArbitraryX's plateau only needs a user's explicit
+ // drag now, not an already-shortened default.)
private static readonly float[] DefaultCurveX =
- { 100f / 7f, 200f / 7f, 300f / 7f, 400f / 7f, 500f / 7f, 600f / 7f };
+ { 100f / 6f, 200f / 6f, 300f / 6f, 400f / 6f, 500f / 6f, 600f / 6f };
// Default/un-dragged shape of the Pedal Feel curve's 6 nodes on
// EITHER axis (mbooster-brake-feelcurve-1..6 for Y, cmdId 0xAB
diff --git a/MozaPlugin.cs b/MozaPlugin.cs
index 3f7b757a..d41ac422 100644
--- a/MozaPlugin.cs
+++ b/MozaPlugin.cs
@@ -1069,6 +1069,16 @@ public void Init(PluginManager pluginManager)
MigrateMBoosterCurveArraysTo6();
}
+ // Follow-up fix for the 100/7-breakpoint bug (see
+ // FixMBoosterCurveArraysSeventhsBug) — separate flag/pass so
+ // it also catches profiles that only clicked a preset button
+ // and never went through the 5->6 migration above.
+ if (!_settings.MBoosterCurveArraysFixedSeventhsBug)
+ {
+ _settings.MBoosterCurveArraysFixedSeventhsBug = true;
+ FixMBoosterCurveArraysSeventhsBug();
+ }
+
// Initialise the GUID↔model registry up front — page-GUID
// resolution (current-wheel page lookup, per-page settings dicts)
// depends on it throughout runtime.
@@ -2736,7 +2746,7 @@ private static void MigrateOneMBoosterCurveSet(global::MozaPlugin.Devices.IMBoos
var newY = new float[global::MozaPlugin.Devices.MBoosterUiConstants.SimInputMappingNodeCount];
for (int i = 0; i < newY.Length; i++)
{
- double x = (i + 1) * 100.0 / 7.0;
+ double x = (i + 1) * 100.0 / 6.0;
newY[i] = (float)global::MozaPlugin.Devices.MozaMBoosterRegistry.EvaluateCurveArbitraryX(oldXs, cfg.CurveY, x);
}
cfg.CurveY = newY;
@@ -2754,6 +2764,78 @@ private static void MigrateOneMBoosterCurveSet(global::MozaPlugin.Devices.IMBoos
}
}
+ // The Sim Input Mapping curve's default X breakpoints used to be
+ // 100/7 * k (last node ~85.7%, not 100% — see DefaultCurveX's
+ // history in Devices/MozaMBoosterRegistry.cs). Any profile that hit
+ // MBoosterCurveArraysMigratedTo6, or simply clicked a preset button,
+ // under that bug got a CurveY baked to one of these too-low shapes.
+ // Matched against UI.SettingsControl's MBoosterCurvePresets (old →
+ // new) so the follow-up migration below can restore the exact
+ // preset shape a user actually clicked, not just the default.
+ private static readonly float[][] OldMBoosterCurvePresetsSeventhsBug =
+ {
+ new float[] { 14, 29, 43, 57, 71, 86 }, // Linear
+ new float[] { 5, 12, 30, 70, 88, 95 }, // S Curve
+ new float[] { 4, 9, 16, 25, 41, 66 }, // Exponential
+ new float[] { 34, 59, 75, 84, 91, 96 }, // Parabolic
+ };
+ private static readonly float[][] NewMBoosterCurvePresetsSeventhsBug =
+ {
+ new float[] { 17, 33, 50, 67, 83, 100 }, // Linear
+ new float[] { 6, 16, 50, 84, 94, 100 }, // S Curve
+ new float[] { 5, 11, 20, 35, 61, 100 }, // Exponential
+ new float[] { 39, 65, 80, 89, 95, 100 }, // Parabolic
+ };
+
+ ///
+ /// One-shot follow-up migration (see
+ /// ):
+ /// a saved Sim Input Mapping curve that exactly matches one of the
+ /// old, too-low preset shapes (baked in by the 100/7 breakpoint bug,
+ /// either directly via a preset button or via
+ /// before this fix) is
+ /// swapped for the corresponding corrected shape. A curve the user
+ /// has since custom-dragged away from any preset is left alone —
+ /// the original 5-node source is long gone, so there's nothing
+ /// reliable to re-derive it from; a fresh Linear/S-Curve/etc. click
+ /// or a small manual touch-up fixes it going forward.
+ ///
+ private void FixMBoosterCurveArraysSeventhsBug()
+ {
+ var profiles = _settings?.ProfileStore?.Profiles;
+ if (profiles == null) return;
+ foreach (var profile in profiles)
+ {
+ if (profile?.MBoosterSettings == null) continue;
+ foreach (var device in profile.MBoosterSettings.Values)
+ {
+ if (device == null) continue;
+ FixOneMBoosterCurveSeventhsBug(device);
+ if (device.Pedals != null)
+ foreach (var pedal in device.Pedals.Values)
+ if (pedal != null) FixOneMBoosterCurveSeventhsBug(pedal);
+ }
+ }
+ }
+
+ private static void FixOneMBoosterCurveSeventhsBug(global::MozaPlugin.Devices.IMBoosterPedalConfig cfg)
+ {
+ if (cfg.CurveX != null) return; // user has dragged X — not a stock preset shape
+ if (cfg.CurveY == null || cfg.CurveY.Length != global::MozaPlugin.Devices.MBoosterUiConstants.SimInputMappingNodeCount) return;
+ for (int p = 0; p < OldMBoosterCurvePresetsSeventhsBug.Length; p++)
+ {
+ var old = OldMBoosterCurvePresetsSeventhsBug[p];
+ bool match = true;
+ for (int i = 0; i < old.Length; i++)
+ if (Math.Abs(cfg.CurveY[i] - old[i]) > 0.01f) { match = false; break; }
+ if (match)
+ {
+ cfg.CurveY = (float[])NewMBoosterCurvePresetsSeventhsBug[p].Clone();
+ return;
+ }
+ }
+ }
+
///
/// Called once per detection rising edge by the registry. Pushes any
/// saved calibration values to the device and kicks off a read-back
diff --git a/UI/Controls/MozaCurveEditor.cs b/UI/Controls/MozaCurveEditor.cs
index 794b4a91..647f82eb 100644
--- a/UI/Controls/MozaCurveEditor.cs
+++ b/UI/Controls/MozaCurveEditor.cs
@@ -68,7 +68,7 @@ private static DependencyProperty RegisterY(string name, double dflt)
// AllowHorizontalDrag is true — 5-node curves default to the fixed
// 20/40/60/80/100 breakpoints every other curve in this app uses;
// the 6-node Sim Input Mapping curve overwrites X1-X6 from its own
- // seeding code (100/7 * k for k=1..6) immediately on load, so X6's
+ // seeding code (100/6 * k for k=1..6) immediately on load, so X6's
// own DP default below is cosmetic. A fresh instance renders
// identically to one driven by NodeXFractions until the user
// actually drags a node sideways. --------
@@ -77,7 +77,7 @@ private static DependencyProperty RegisterY(string name, double dflt)
public static readonly DependencyProperty X3Property = RegisterX(nameof(X3), 60);
public static readonly DependencyProperty X4Property = RegisterX(nameof(X4), 80);
public static readonly DependencyProperty X5Property = RegisterX(nameof(X5), 100);
- public static readonly DependencyProperty X6Property = RegisterX(nameof(X6), 600.0 / 7.0);
+ public static readonly DependencyProperty X6Property = RegisterX(nameof(X6), 600.0 / 6.0);
private static DependencyProperty RegisterX(string name, double dflt)
=> DependencyProperty.Register(name, typeof(double), typeof(MozaCurveEditor),
@@ -926,16 +926,27 @@ private void Recompute()
}
// ---- Optional y=x identity / nominal line (output curves only) ----
- // The line ends at the rightmost node's X (not the plot's right
- // pixel edge) so the LINEAR preset's last dot lands exactly on
- // the diagonal — the dots are pulled slightly inside the plot to
- // avoid clipping, and the reference must follow them.
+ // Always the TRUE diagonal from data (0,0) to (100,100) — same
+ // 0.98 inset every node/anchor pixel position uses (so the line
+ // doesn't get clipped at the plot's true right edge), but that's
+ // a rendering-only margin, not a stand-in for data-space X.
+ // BUG (fixed): this used to end at the rightmost NODE's own
+ // fraction instead of a fixed 0.98 — harmless for curves whose
+ // last node always sits at data (100,100) by construction (FFB,
+ // Sim Input Mapping, Throttle/Brake/Clutch), but for a curve
+ // like Pedal Feel — AnchorAtTopRight, whose last DRAGGABLE node
+ // legitimately defaults short of 100% (e.g. ~98%) while the
+ // curve itself still runs on to a separate fixed (100,100)
+ // corner anchor — the old logic stopped the dashed reference
+ // short of that corner AND pinned its Y to YMax at the node's
+ // (too-far-left) X, producing a line that wasn't really y=x at
+ // all and visibly diverged from the actual plotted curve near
+ // the top-right (see linear.png).
if (ShowIdentityLine)
{
- double rightFrac = Math.Max(0, Math.Min(1, nodeFracs[nodeCount - 1]));
var ident = new LineGeometry(
new Point(PadLeft, PadTop + plotH),
- new Point(PadLeft + rightFrac * plotW, PadTop));
+ new Point(PadLeft + 0.98 * plotW, PadTop));
ident.Freeze();
SetValue(IdentityLineGeometryKey, ident);
}
diff --git a/UI/Import/PitHousePedalsMapper.cs b/UI/Import/PitHousePedalsMapper.cs
index f9398144..b405d8b7 100644
--- a/UI/Import/PitHousePedalsMapper.cs
+++ b/UI/Import/PitHousePedalsMapper.cs
@@ -519,7 +519,7 @@ public void Float(string suffix, string label, float lo, float hi, string fmt,
// PitHouse's own preset file format is fixed at 5 points
// (nonlinear1..5) at 20/40/60/80/100% — that's external and
- // won't change. AZOM's CurveY is now 6 points at 100/7 * k
+ // won't change. AZOM's CurveY is now 6 points at 100/6 * k
// (see MozaMBoosterRegistry.EvaluateCurveArbitraryX /
// MBoosterUiConstants.SimInputMappingNodeCount), so the
// imported 5-point shape is resampled at the 6 new breakpoints
@@ -544,7 +544,7 @@ public void OutputCurve()
var y = new float[n];
for (int i = 0; i < n; i++)
{
- double x = (i + 1) * 100.0 / 7.0;
+ double x = (i + 1) * 100.0 / 6.0;
y[i] = (float)global::MozaPlugin.Devices.MozaMBoosterRegistry.EvaluateCurveArbitraryX(PitHouseOutputCurveX, y5, x);
}
@@ -554,7 +554,7 @@ public void OutputCurve()
: string.Join("/", oldCurve.Take(n).Select(FormatCurvePoint));
string newDisplay = string.Join("/", y.Select(FormatCurvePoint));
- Add("Output curve (Y at 100/7% breakpoints)", oldDisplay, newDisplay,
+ Add("Output curve (Y at 100/6% breakpoints)", oldDisplay, newDisplay,
c => c.CurveY = (float[])y.Clone());
}
diff --git a/UI/MozaPluginSettings.cs b/UI/MozaPluginSettings.cs
index b3ef4494..055c7ee5 100644
--- a/UI/MozaPluginSettings.cs
+++ b/UI/MozaPluginSettings.cs
@@ -276,6 +276,16 @@ public class MozaPluginSettings
// MozaPlugin.Init and MozaMBoosterRegistry.MigrateCurveArraysTo6.
public bool MBoosterCurveArraysMigratedTo6 { get; set; }
+ // One-shot marker for the follow-up migration that fixes the Sim
+ // Input Mapping curve's default X breakpoints — they were 100/7 * k
+ // (last node ~85.7%, inherited from the disproven/removed curve7
+ // mechanism), capping Linear/preset/migrated curves at ~86% output
+ // instead of reaching 100%. Any profile already run through
+ // MBoosterCurveArraysMigratedTo6, or that clicked a preset button,
+ // baked in the too-low shape. See MozaPlugin.Init and
+ // FixMBoosterCurveArraysSeventhsBug.
+ public bool MBoosterCurveArraysFixedSeventhsBug { get; set; }
+
// ~1/min pull of the wheel display's own log via session FF kind=14,
// acked with kind=15 (which clears those lines on the device). No UI —
// flip to false in MozaPluginSettings.json to stop the pull entirely.
diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs
index 03501a7d..0d68c8e9 100644
--- a/UI/SettingsControl.xaml.cs
+++ b/UI/SettingsControl.xaml.cs
@@ -4213,16 +4213,20 @@ private void MBoosterMaxSlider_ValueChanged(object sender, RoutedPropertyChanged
// 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/7 * k for k=1..6, matching
+ // 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.
+ // 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[] { 14, 29, 43, 57, 71, 86 }, // Linear
- new[] { 5, 12, 30, 70, 88, 95 }, // S Curve
- new[] { 4, 9, 16, 25, 41, 66 }, // Exponential
- new[] { 34, 59, 75, 84, 91, 96 }, // Parabolic
+ 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);
diff --git a/docs/protocol/devices/mbooster.md b/docs/protocol/devices/mbooster.md
index 03886842..e6455753 100644
--- a/docs/protocol/devices/mbooster.md
+++ b/docs/protocol/devices/mbooster.md
@@ -1000,12 +1000,18 @@ on the pedal's own unit (`MotorDeviceForRole` — see
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). Six wire breakpoints
- `100/7 × k` for k=1..6 (≈14.29/28.57/42.86/57.14/71.43/85.71%) were kept
- as the curve's fixed node-count reference/default shape even though
- nothing sends them over the wire anymore — chosen to match what the
- (now-removed) `curve7` mechanism's own selectors were, so a node that's
- never been dragged renders identically to before.
+ 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
From fd89ab85b01e596506f9f60ba801c7773a06f5d2 Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Wed, 26 Aug 2026 10:51:45 +1200
Subject: [PATCH 19/22] int overflow on max force
---
Protocol/MozaMBoosterProtocol.cs | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/Protocol/MozaMBoosterProtocol.cs b/Protocol/MozaMBoosterProtocol.cs
index 6f98a812..419783dc 100644
--- a/Protocol/MozaMBoosterProtocol.cs
+++ b/Protocol/MozaMBoosterProtocol.cs
@@ -302,13 +302,26 @@ public static byte ComputeParam1(double paramK, double freqHz)
/// data points: 4 kg → 1311 exactly, and an unlabeled capture whose
/// raw value decoded to ~126 kg, matching an independently-reported
/// real Pit House setting of ~125 kg.
+ /// BUG (fixed): at kg=200 exactly, this formula rounds to 65536
+ /// (0x10000) — one bit past the 16-bit range every consumer of this
+ /// encoding actually uses on the wire (Max Force/Deadzone/Feel Curve
+ /// nodes are 2-byte fields; even Max Threshold's 4-byte field only
+ /// ever carries a 16-bit-range value). The old bounds check compared
+ /// against int.MaxValue, which never caught this, so BuildWriteInt's
+ /// byte-packing silently truncated 0x10000 to 0x0000 for any 2-byte
+ /// command — sending 0kg instead of ~200kg for Max Force's own
+ /// slider maximum (confirmed via azom-max-force-sweep.pcapng: the
+ /// wire write for Max Force=200 was literally raw=0). Clamping to
+ /// 65535 instead matches Pit House's own observed encoding — its
+ /// max-force-140-threshold-4-200sweep.pcapng capture sent Threshold
+ /// =200kg as raw 0xFFFF (65535), not 0x10000.
///
public static int EncodeThresholdKg(double kg)
{
if (double.IsNaN(kg) || kg <= 0) return 0;
double raw = Math.Round(kg * 65536.0 / 200.0);
if (raw <= 0) return 0;
- if (raw >= int.MaxValue) return int.MaxValue;
+ if (raw >= 65535.0) return 65535;
return (int)raw;
}
From d6415515b4230a4326c5c081143f0792c2e7580d Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Wed, 26 Aug 2026 11:24:30 +1200
Subject: [PATCH 20/22] max force fixes
---
UI/SettingsControl.xaml.cs | 16 +++++++++++-----
1 file changed, 11 insertions(+), 5 deletions(-)
diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs
index 0d68c8e9..4cd84092 100644
--- a/UI/SettingsControl.xaml.cs
+++ b/UI/SettingsControl.xaml.cs
@@ -346,13 +346,19 @@ private void UpdateMBoosterCurveMarkers(bool hidConnected)
// 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 "input to that curve" value to plot; no marker shown.
+ // 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: preCurve, the same post-reshape % the
+ // "Output Force" live label above this curve already estimates
+ // kg from — not positionally exact against the curve's own
+ // Deadzone-Max Force X axis, but enough to see live movement
+ // while testing instead of nothing at all.
// The Sim Input Mapping curve is the opposite: purely host-side
// (see EvaluateCurveArbitraryX), so its live marker uses
- // preCurve — the already-hardware-shaped raw position that's
- // actually fed INTO this curve, not pct (which is the curve's
- // own output).
- MBoosterInputCurveEditor.LiveX = double.NaN;
+ // preCurve exactly — the already-hardware-shaped raw position
+ // that's actually fed INTO this curve, not pct (which is the
+ // curve's own output).
+ MBoosterInputCurveEditor.LiveX = hidConnected ? preCurve : double.NaN;
MBoosterCurveEditor.LiveX = preCurve;
// Live "position % · kg force" readout above the Pedal Feel
From 6faf868c89de99d0dfc8fda3b2818a83ebd626d9 Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Thu, 27 Aug 2026 08:57:34 +1200
Subject: [PATCH 21/22] re-implementation of max-threshold
---
Devices/MozaMBoosterRegistry.cs | 19 +++++++++++++++++++
UI/SettingsControl.xaml.cs | 21 ++++++++++++---------
docs/protocol/devices/mbooster.md | 26 +++++++++++++++++++++-----
3 files changed, 52 insertions(+), 14 deletions(-)
diff --git a/Devices/MozaMBoosterRegistry.cs b/Devices/MozaMBoosterRegistry.cs
index 272f0839..d0c82388 100644
--- a/Devices/MozaMBoosterRegistry.cs
+++ b/Devices/MozaMBoosterRegistry.cs
@@ -457,6 +457,25 @@ public void OnHidAxisUpdate(string identity, string containerId, int axisIndex,
double posPct = pos01 * 100.0;
if (cfg != null)
{
+ // Max Threshold — HOST-SIDE rescale. Raw HID 100% is the
+ // Pedal Feel curve's own hardware ceiling (Max Force's kg
+ // value — see MBoosterDeviceController.PushFeelCurveResync),
+ // NOT Max Threshold: the mbooster-brake-threshold wire write
+ // (cmdId 0xB3) does not reliably change that on-device, per
+ // hardware testing (bug bundle — "Max Threshold does
+ // nothing" investigation). Max Threshold is meant to be a
+ // purely host-side remap of the ALREADY-Max-Force-scaled raw
+ // position into "100% at Threshold's kg" for the sim, same
+ // category as Sim Input Mapping's CurveY/CurveX below (no
+ // wire command actually does the real work). Unset (-1) or
+ // non-positive Threshold is a no-op (ratio 1, same as
+ // Threshold == Max Force) so an uncustomized profile keeps
+ // its previous raw-passthrough behavior unchanged.
+ double maxForceKg = cfg.MaxForceKg >= 0 ? cfg.MaxForceKg : 200.0;
+ double thresholdKg = cfg.MaxThresholdKg > 0 ? cfg.MaxThresholdKg : maxForceKg;
+ if (Math.Abs(thresholdKg - maxForceKg) > 0.0001)
+ posPct = Math.Min(100.0, posPct * (maxForceKg / thresholdKg));
+
// Store the pre-remap percent for EVERY axis so the UI's
// live curve markers follow whichever pedal is selected (axis 0
// also mirrored to LastRawPercentPreCurve for legacy callers).
diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs
index 4cd84092..d3fc74f1 100644
--- a/UI/SettingsControl.xaml.cs
+++ b/UI/SettingsControl.xaml.cs
@@ -363,18 +363,21 @@ private void UpdateMBoosterCurveMarkers(bool hidConnected)
// Live "position % · kg force" readout above the Pedal Feel
// curve editor (MBoosterPedalFeelLiveLabel). kg is an estimate,
- // not a directly-read sensor value: the raw HID axis only ever
- // reports a 0-100% position, calibrated so 100% == this pedal's
- // Max Threshold force (see docs/protocol/devices/mbooster.md
- // "Sim Input Mapping") — so force = pct/100 * that threshold.
- // Resolution order matches the removed ResolveFullScaleKg: the
- // user's own MaxThresholdKg override, else the device's own
- // mbooster-brake-threshold read-back, else a 200kg last resort.
+ // not a directly-read sensor value: preCurve is now genuinely
+ // "% of Threshold's span" — MozaMBoosterRegistry.OnHidAxisUpdate
+ // host-side rescales the raw HID position (which is % of Max
+ // Force's own hardware ceiling) into that before storing it —
+ // see that method's comment for why this moved host-side
+ // (mbooster-brake-threshold's wire write doesn't reliably do it
+ // on-device). So force = pct/100 * that SAME threshold, using
+ // the identical fallback OnHidAxisUpdate uses: the user's own
+ // MaxThresholdKg override, else this pedal's own MaxForceKg
+ // (no-op case — see OnHidAxisUpdate), else a 200kg last resort.
if (hidConnected)
{
var cfg = PeekMBoosterEffectTarget();
- double fullScaleKg = (cfg != null && cfg.MaxThresholdKg >= 0) ? cfg.MaxThresholdKg
- : (selected.DeviceReportedMaxThresholdKg > 0 ? selected.DeviceReportedMaxThresholdKg : 200.0);
+ double fullScaleKg = (cfg != null && cfg.MaxThresholdKg > 0) ? cfg.MaxThresholdKg
+ : (cfg != null && cfg.MaxForceKg >= 0) ? cfg.MaxForceKg : 200.0;
double kg = preCurve / 100.0 * fullScaleKg;
MBoosterPedalFeelLiveLabel.Text = $"{Strings.Label_OutputForce}: {preCurve:F0}% · {kg:F1} kg";
}
diff --git a/docs/protocol/devices/mbooster.md b/docs/protocol/devices/mbooster.md
index e6455753..09a29e30 100644
--- a/docs/protocol/devices/mbooster.md
+++ b/docs/protocol/devices/mbooster.md
@@ -974,11 +974,27 @@ on the pedal's own unit (`MotorDeviceForRole` — see
`raw = round(kg * 65536 / 200)`. Verified on two capture points (4 kg →
1311 exactly; an unlabeled capture decoding to ~126 kg against an
independently-reported real Pit House setting of ~125 kg). See
- `MozaMBoosterProtocol.EncodeThresholdKg`/`DecodeThresholdKg`. This
- recalibrates the sensor's own full-scale range on the DEVICE — the raw
- HID axis itself reads exactly `MaxThresholdKg` of force at 100% travel,
- which is what the game reads directly (bypassing AZOM entirely) if it
- binds to the pedal's raw joystick axis.
+ `MozaMBoosterProtocol.EncodeThresholdKg`/`DecodeThresholdKg`.
+ **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
From 737699653e80b4772cf9cb5353e5b9721791c7bf Mon Sep 17 00:00:00 2001
From: tacodevhaydz
Date: Thu, 27 Aug 2026 09:33:36 +1200
Subject: [PATCH 22/22] input force measured on screen
---
Devices/MBoosterDeviceController.cs | 16 ++++++++-
Devices/MozaMBoosterRegistry.cs | 7 ++++
Resources/Strings.Designer.cs | 2 +-
Resources/Strings.de.resx | 2 +-
Resources/Strings.el.resx | 2 +-
Resources/Strings.es.resx | 2 +-
Resources/Strings.fr.resx | 2 +-
Resources/Strings.it.resx | 2 +-
Resources/Strings.ko.resx | 2 +-
Resources/Strings.nb.resx | 2 +-
Resources/Strings.pt.resx | 2 +-
Resources/Strings.qps-ploc.resx | 2 +-
Resources/Strings.resx | 2 +-
Resources/Strings.ru.resx | 2 +-
Resources/Strings.vi.resx | 2 +-
Resources/Strings.zh-Hans.resx | 2 +-
UI/SettingsControl.xaml | 2 +-
UI/SettingsControl.xaml.cs | 53 +++++++++++++++--------------
18 files changed, 65 insertions(+), 41 deletions(-)
diff --git a/Devices/MBoosterDeviceController.cs b/Devices/MBoosterDeviceController.cs
index 284eef05..59944f3d 100644
--- a/Devices/MBoosterDeviceController.cs
+++ b/Devices/MBoosterDeviceController.cs
@@ -246,9 +246,23 @@ public int SoleActiveAxis()
// Per-axis pre-input-curve percent (0..100) — the same signal as
// LastRawPercentPreCurve (after deadzone/max-force, before the input
// curve) but for EVERY pedal, so the settings tab's live curve markers
- // track whichever pedal is selected, not just the master.
+ // track whichever pedal is selected, not just the master. NOTE: since
+ // MozaMBoosterRegistry.OnHidAxisUpdate added the host-side Max
+ // Threshold rescale, this is "% of Threshold's span" (the Sim Input
+ // Mapping curve's own input domain) — see LastAxisRawPercentPreThreshold
+ // below for the true raw reading (% of Max Force's span) instead.
public readonly double[] LastAxisRawPercentPreCurve = new double[MaxAxes];
+ // Per-axis TRUE raw HID percent (0..100), captured BEFORE the host-side
+ // Max Threshold rescale (see MozaMBoosterRegistry.OnHidAxisUpdate) —
+ // i.e. genuinely "% of Max Force's own hardware ceiling", the physical
+ // force the user is actually applying to the pedal. This is the
+ // Pedal Feel curve's real input domain (Deadzone-Max Force span), and
+ // what the "Input Force" live label/marker should show — unlike
+ // LastAxisRawPercentPreCurve, which is now post-Threshold-rescale and
+ // represents the Sim Input Mapping curve's own (different) domain.
+ public readonly double[] LastAxisRawPercentPreThreshold = new double[MaxAxes];
+
// Highest axis index + 1 the HID has reported for this lane: 1 for a
// lone pedal, up to 3 for a full chain. 0 until the first axis update.
public int AxisCount { get; internal set; }
diff --git a/Devices/MozaMBoosterRegistry.cs b/Devices/MozaMBoosterRegistry.cs
index d0c82388..8018dc47 100644
--- a/Devices/MozaMBoosterRegistry.cs
+++ b/Devices/MozaMBoosterRegistry.cs
@@ -457,6 +457,13 @@ public void OnHidAxisUpdate(string identity, string containerId, int axisIndex,
double posPct = pos01 * 100.0;
if (cfg != null)
{
+ // Capture the TRUE raw reading — % of Max Force's own
+ // hardware ceiling, i.e. the physical force the user is
+ // actually applying — BEFORE the Threshold rescale below
+ // changes posPct's meaning. Powers the "Input Force" live
+ // label/marker; see LastAxisRawPercentPreThreshold's doc.
+ if (axisIndex < c.LastAxisRawPercentPreThreshold.Length) c.LastAxisRawPercentPreThreshold[axisIndex] = posPct;
+
// Max Threshold — HOST-SIDE rescale. Raw HID 100% is the
// Pedal Feel curve's own hardware ceiling (Max Force's kg
// value — see MBoosterDeviceController.PushFeelCurveResync),
diff --git a/Resources/Strings.Designer.cs b/Resources/Strings.Designer.cs
index af387ad5..a4def4ff 100644
--- a/Resources/Strings.Designer.cs
+++ b/Resources/Strings.Designer.cs
@@ -151,7 +151,7 @@ private static string Get(string key)
public static string Section_Position => Get("Section_Position");
public static string Subtitle_LiveHandbrakeInput => Get("Subtitle_LiveHandbrakeInput");
public static string Label_Position => Get("Label_Position");
- public static string Label_OutputForce => Get("Label_OutputForce");
+ public static string Label_InputForce => Get("Label_InputForce");
public static string Section_Calibration => Get("Section_Calibration");
public static string Subtitle_PullHandbrakeFully => Get("Subtitle_PullHandbrakeFully");
public static string Button_StartCalibration => Get("Button_StartCalibration");
diff --git a/Resources/Strings.de.resx b/Resources/Strings.de.resx
index f53191c4..939ad380 100644
--- a/Resources/Strings.de.resx
+++ b/Resources/Strings.de.resx
@@ -765,7 +765,7 @@ Id: {2}
PR geschlossen — auf Stabil umgestellt// Verstärkung pro Band · 100 % = neutral · 500 % = max. BoostEMPFINDLICHKEIT
- Ausgabekraft
+ EingabekraftG-Kraft (Trägheits-Pedalgefühl) (Experimentell)Max. Pedalweg (mm)Reaktionsgeschwindigkeit (%)
diff --git a/Resources/Strings.el.resx b/Resources/Strings.el.resx
index 980823b5..ee499da0 100644
--- a/Resources/Strings.el.resx
+++ b/Resources/Strings.el.resx
@@ -764,7 +764,7 @@ Id: {2}
Το PR έκλεισε — μετάβαση σε Σταθερό// κέρδος ανά band · 100% = ουδέτερο · 500% = μέγιστη ενίσχυσηΕΥΑΙΣΘΗΣΙΑ
- Δύναμη εξόδου
+ Δύναμη εισόδουΔύναμη G (Αδρανειακή Αίσθηση Πεντάλ) (Πειραματικό)Μέγιστη διαδρομή πεντάλ (mm)Ταχύτητα απόκρισης (%)
diff --git a/Resources/Strings.es.resx b/Resources/Strings.es.resx
index c3ea49ba..053f7fa3 100644
--- a/Resources/Strings.es.resx
+++ b/Resources/Strings.es.resx
@@ -770,7 +770,7 @@ Id: {2}
PR cerrado — se cambió a Estable// ganancia por banda · 100% = neutro · 500% = realce máximoSENSIBILIDAD
- Fuerza de salida
+ Fuerza de entradaFuerza G (sensación inercial del pedal) (experimental)Recorrido máximo del pedal (mm)Velocidad de respuesta (%)
diff --git a/Resources/Strings.fr.resx b/Resources/Strings.fr.resx
index 989796fe..5d78a6b4 100644
--- a/Resources/Strings.fr.resx
+++ b/Resources/Strings.fr.resx
@@ -765,7 +765,7 @@ Id : {2}
PR fermée — retour à Stable// gain par bande · 100 % = neutre · 500 % = boost maxSENSIBILITÉ
- Force de sortie
+ Force d'entréeForce G (ressenti inertiel de la pédale) (expérimental)Course maximale de la pédale (mm)Vitesse de réponse (%)
diff --git a/Resources/Strings.it.resx b/Resources/Strings.it.resx
index 71f81a8e..da25ff8d 100644
--- a/Resources/Strings.it.resx
+++ b/Resources/Strings.it.resx
@@ -768,7 +768,7 @@ Id: {2}
PR chiusa — passato a Stabile// guadagno per banda · 100% = neutro · 500% = amplificazione massimaSENSIBILITÀ
- Forza di uscita
+ Forza in ingressoForza G (sensazione inerziale del pedale) (sperimentale)Corsa massima del pedale (mm)Velocità di risposta (%)
diff --git a/Resources/Strings.ko.resx b/Resources/Strings.ko.resx
index b72804a7..bbc98a2d 100644
--- a/Resources/Strings.ko.resx
+++ b/Resources/Strings.ko.resx
@@ -764,7 +764,7 @@ Id: {2}
PR 종료됨 — 안정 채널로 전환됨// 대역별 게인 · 100% = 중립 · 500% = 최대 부스트감도
- 출력 힘
+ 입력 힘G포스 (관성 페달 느낌) (실험적)최대 페달 이동 거리 (mm)반응 속도 (%)
diff --git a/Resources/Strings.nb.resx b/Resources/Strings.nb.resx
index 0b002d46..08de5b48 100644
--- a/Resources/Strings.nb.resx
+++ b/Resources/Strings.nb.resx
@@ -770,7 +770,7 @@ Id: {2}
PR lukket — byttet til Stabil// forsterkning per bånd · 100 % = nøytral · 500 % = maks boostFØLSOMHET
- Utgangskraft
+ InngangskraftG-kraft (treghets-pedalfølelse) (eksperimentell)Maks pedalbevegelse (mm)Responshastighet (%)
diff --git a/Resources/Strings.pt.resx b/Resources/Strings.pt.resx
index 8ca5762f..35ceff1d 100644
--- a/Resources/Strings.pt.resx
+++ b/Resources/Strings.pt.resx
@@ -758,7 +758,7 @@ Id: {2}
PR fechado — alterado para Estável// ganho por banda · 100% = neutro · 500% = reforço máx.SENSIBILIDADE
- Força de saída
+ Força de entradaForça G (sensação inercial do pedal) (experimental)Curso máx. do pedal (mm)Velocidade de resposta (%)
diff --git a/Resources/Strings.qps-ploc.resx b/Resources/Strings.qps-ploc.resx
index 48921656..6adeefa8 100644
--- a/Resources/Strings.qps-ploc.resx
+++ b/Resources/Strings.qps-ploc.resx
@@ -749,7 +749,7 @@
Bark Arf!Ruff Bark Grrowl Wuff!AWOO!!
- Wroof!
+ Wroof!Yowl!Boof!Yap!
diff --git a/Resources/Strings.resx b/Resources/Strings.resx
index fefee8b2..a42ce326 100644
--- a/Resources/Strings.resx
+++ b/Resources/Strings.resx
@@ -89,7 +89,7 @@
POSITION// live handbrake inputPosition
- Output Force
+ Input ForceCALIBRATION// pull the handbrake fully onceSTART CALIBRATION
diff --git a/Resources/Strings.ru.resx b/Resources/Strings.ru.resx
index 700d90b8..ae8fd636 100644
--- a/Resources/Strings.ru.resx
+++ b/Resources/Strings.ru.resx
@@ -765,7 +765,7 @@ Id: {2}
PR закрыт — переключено на Стабильный// усиление по полосам · 100% = нейтраль · 500% = максимумЧУВСТВИТЕЛЬНОСТЬ
- Выходное усилие
+ Входное усилиеG-сила (инерционное ощущение педали) (экспериментально)Макс. ход педали (мм)Скорость отклика (%)
diff --git a/Resources/Strings.vi.resx b/Resources/Strings.vi.resx
index 419f6b43..60848da4 100644
--- a/Resources/Strings.vi.resx
+++ b/Resources/Strings.vi.resx
@@ -765,7 +765,7 @@ Id: {2}
PR đã đóng — đã chuyển về Ổn định// khuếch đại từng dải · 100% = trung tính · 500% = tăng tối đaĐỘ NHẠY
- Lực đầu ra
+ Lực đầu vàoLực G (cảm giác bàn đạp quán tính) (thử nghiệm)Hành trình bàn đạp tối đa (mm)Tốc độ phản hồi (%)
diff --git a/Resources/Strings.zh-Hans.resx b/Resources/Strings.zh-Hans.resx
index 2fe13b26..8663a9bd 100644
--- a/Resources/Strings.zh-Hans.resx
+++ b/Resources/Strings.zh-Hans.resx
@@ -765,7 +765,7 @@ Id: {2}
PR 已关闭 — 已切换到稳定版// 各频段增益 · 100% = 中性 · 500% = 最大增益灵敏度
- 输出力值
+ 输入力值G力(惯性踏板手感)(实验性)最大踏板行程 (mm)响应速度 (%)
diff --git a/UI/SettingsControl.xaml b/UI/SettingsControl.xaml
index 6ad1bab6..9c85f680 100644
--- a/UI/SettingsControl.xaml
+++ b/UI/SettingsControl.xaml
@@ -1875,7 +1875,7 @@
displays. kg is a computed estimate (raw position % scaled by
this pedal's own Max Threshold, i.e. what force reads 100%
travel), not a directly-read sensor value. -->
-
+