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. + 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 | -|---|---|---| -| `

_outdir` | `Direction` | `mbooster-

-dir` | -| `

_min` / `

_max` | *(not imported)* | **unit mismatch**, see below | -| `

_nonlinear1..5` | `CurveY[0..4]` | output curve, `mbooster-

-y1..y5`; both sides are 0–100 | -| `

_abs_switch/_amp/_freq/_smoothness` | `Abs.Enabled/.IntensityPct/.FrequencyHz/.SmoothnessPct` | brake-only in PitHouse | -| `

_lockup_switch/_amp/_freq` | `Lockup.*` | brake-only | -| `

_brakethreshold_switch/_amp/_freq` | `Threshold.Enabled/.IntensityPct/.FrequencyHz` | brake-only | -| `

_brakethreshold_trigger_input` | `Threshold.TriggerLevelPct` | same 50–100 range | -| `

_brakethreshold_fade_amount` | `Threshold.DecayPct` | UI label "Vibration Decay" | -| `

_tc_switch/_amp/_freq` | `TractionControl.*` | | -| `

_wheel_slip_switch/_amp/_freq` | `WheelSpin.*` | plugin's range (30–80 Hz) is narrower than PitHouse's | -| `

_gear_shift_vibration_switch/_amp/_freq` | `GearShift.*` | plugin's `VibrateOnNeutral`/`DebounceMs` have no PitHouse counterpart | -| `

_road_texture_switch/_intensity/_smoothness` | `RoadTexture.*` | | -| `

_machinelimit_min` / `_max` | `TravelStartMm` / `TravelEndMm` | **inferred**, see below | -| `

_softlimit_hardness_press` / `_release` | `EndstopFrontStiffness` / `EndstopEndStiffness` | **inferred**, see below | -| `brake_press_combine` | `SensorOutputRatioPct` | **inferred**; brake role only (`mbooster-brake-angle-ratio` is written only for Brake) | +| PitHouse key | Plugin field | Notes | +| ------------------------------------------------ | ------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `

_outdir` | `Direction` | `mbooster-

-dir` | +| `

_min` / `

_max` | *(not imported)* | **unit mismatch**, see below | +| `

_nonlinear1..5` | `CurveY[0..4]` | output curve, `mbooster-

-y1..y5`; both sides are 0–100 | +| `

_abs_switch/_amp/_freq/_smoothness` | `Abs.Enabled/.IntensityPct/.FrequencyHz/.SmoothnessPct` | brake-only in PitHouse | +| `

_lockup_switch/_amp/_freq` | `Lockup.*` | brake-only | +| `

_brakethreshold_switch/_amp/_freq` | `Threshold.Enabled/.IntensityPct/.FrequencyHz` | brake-only | +| `

_brakethreshold_trigger_input` | `Threshold.TriggerLevelPct` | same 50–100 range | +| `

_brakethreshold_fade_amount` | `Threshold.DecayPct` | UI label "Vibration Decay" | +| `

_tc_switch/_amp/_freq` | `TractionControl.*` | | +| `

_wheel_slip_switch/_amp/_freq` | `WheelSpin.*` | plugin's range (30–80 Hz) is narrower than PitHouse's | +| `

_gear_shift_vibration_switch/_amp/_freq` | `GearShift.*` | plugin's `VibrateOnNeutral`/`DebounceMs` have no PitHouse counterpart | +| `

_road_texture_switch/_intensity/_smoothness` | `RoadTexture.*` | | +| `

_machinelimit_min` / `_max` | `TravelStartMm` / `TravelEndMm` | **inferred**, see below | +| `

_softlimit_hardness_press` / `_release` | `EndstopFrontStiffness` / `EndstopEndStiffness` | **inferred**, see below | +| `brake_press_combine` | `SensorOutputRatioPct` | **inferred**; brake role only (`mbooster-brake-angle-ratio` is written only for Brake) | Values are clamped to the plugin's own slider bounds (`MBoosterUiConstants`) on import, and the travel pair additionally honours `TravelMinGapMm` / @@ -1693,13 +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 | -|---|---|---| -| `

_outdir` | `MozaProfile.Pedals

Dir` | `pedals-

-dir` | -| `

_min` / `

_max` | `Pedals

Min` / `Pedals

Max` | `pedals-

-min` / `-max` | -| `

_nonlinear1..5` | `Pedals

Curve[0..4]` | `pedals-

-y1..y5` | -| `brake_press_combine` | `PedalsBrakeAngleRatio` | `pedals-brake-angle-ratio` | -| `

_channlRoleType` | *(not imported)* | — (CRP roles are fixed) | +| PitHouse key | Plugin field | Wire command | +| --------------------- | ------------------------------- | -------------------------- | +| `

_outdir` | `MozaProfile.Pedals

Dir` | `pedals-

-dir` | +| `

_min` / `

_max` | `Pedals

Min` / `Pedals

Max` | `pedals-

-min` / `-max` | +| `

_nonlinear1..5` | `Pedals

Curve[0..4]` | `pedals-

-y1..y5` | +| `brake_press_combine` | `PedalsBrakeAngleRatio` | `pedals-brake-angle-ratio` | +| `

_channlRoleType` | *(not imported)* | — (CRP roles are fixed) | `min`/`max` **are** imported here, unlike on the mBooster path: the CRP fields are percent on both sides (`MozaProfile.PedalsThrottleMin` is documented 0-100, 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 @@ - - - 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. Boost EMPFINDLICHKEIT + 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áximo SENSIBILIDAD + 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 max SENSIBILITÉ + 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 massima SENSIBILITÀ + 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 boost FØ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 FRANK Bark! 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 input Position + Output Force CALIBRATION // pull the handbrake fully once START 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 @@ 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. Boost EMPFINDLICHKEIT - Ausgabekraft + Eingabekraft G-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áximo SENSIBILIDAD - Fuerza de salida + Fuerza de entrada Fuerza 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 max SENSIBILITÉ - Force de sortie + Force d'entrée Force 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 massima SENSIBILITÀ - Forza di uscita + Forza in ingresso Forza 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 boost FØLSOMHET - Utgangskraft + Inngangskraft G-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 entrada Forç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 input Position - Output Force + Input Force CALIBRATION // pull the handbrake fully once START 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ào 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 (%) 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. --> - +
-Guide about this plugin from an early tester (español and english dubbing) -[![Youtube Video](https://github.com/user-attachments/assets/5ab8ee11-6bbb-4eee-9e54-dc23a6917681)](https://www.youtube.com/watch?v=iVBn3PWbf4c) - +[![Youtube Video](https://github.com/user-attachments/assets/f19a20b7-13ff-4ff5-a23b-b015149d37cb)](https://www.youtube.com/watch?v=apPXgjnGqD0) -Another video, available in español and english dubbing - -[![Youtube Video](https://github.com/user-attachments/assets/fe44c5ac-a63e-4559-b42f-7290884eef12)](https://www.youtube.com/watch?v=HPIme1-_cnQ) +[![Youtube Video](https://github.com/user-attachments/assets/31d05cff-9009-4954-8008-d6c0cdabd9b8)](https://www.youtube.com/watch?v=D_ZmB0xn_KY)