From dec812e1884e448a077926ea91b9e6363fe997cd Mon Sep 17 00:00:00 2001 From: tacodevhaydz Date: Thu, 30 Jul 2026 11:00:02 +1200 Subject: [PATCH 01/13] experimental gforce effect implementation --- Devices/MBoosterDeviceController.cs | 15 ++++ Devices/MBoosterEffectWorker.cs | 96 +++++++++++++++++++++++ Devices/MBoosterTypes.cs | 59 +++++++++++++- MozaPlugin.cs | 9 +++ Protocol/MozaMBoosterProtocol.cs | 58 ++++++++++++++ Resources/Strings.Designer.cs | 4 + Resources/Strings.resx | 4 + Telemetry/TestMode/TestSignalOverrides.cs | 7 ++ UI/SettingsControl.xaml | 35 +++++++++ UI/SettingsControl.xaml.cs | 54 +++++++++++++ 10 files changed, 339 insertions(+), 2 deletions(-) diff --git a/Devices/MBoosterDeviceController.cs b/Devices/MBoosterDeviceController.cs index 0d1a8b78..c2c7d4d3 100644 --- a/Devices/MBoosterDeviceController.cs +++ b/Devices/MBoosterDeviceController.cs @@ -929,6 +929,21 @@ public void SetRoadTextureTestActive(bool on, int pedalIndex = 0) WorkerFor(pedalIndex)?.SetRoadTextureTestSustained(on); } + /// + /// Continuously alternates G-Force's commanded travel offset + /// forward/backward at the currently configured Max Travel/Response + /// Speed while is true, bypassing Enabled and + /// the game-running gate — mirrors Pit House's own "Test" demo. See + /// for the analogous Engine + /// toggle; same live-tracking and always-allow-off semantics apply + /// here. + /// + public void SetGForceTestActive(bool on, int pedalIndex = 0) + { + if (on && !_connection.IsConnected) return; + WorkerFor(pedalIndex)?.SetGForceTestSustained(on); + } + /// /// Continuously runs Lockup — substituting live brake position for /// the wheel-slip detection heuristic (which needs vehicle speed), diff --git a/Devices/MBoosterEffectWorker.cs b/Devices/MBoosterEffectWorker.cs index eea81ce0..495ef9ee 100644 --- a/Devices/MBoosterEffectWorker.cs +++ b/Devices/MBoosterEffectWorker.cs @@ -88,6 +88,7 @@ internal sealed class MBoosterEffectWorker : IDisposable private EffectState _threshold; private EffectState _engine; private EffectState _roadTexture; + private EffectState _gforce; private bool _thresholdLatched; // hysteresis flag for the Threshold effect (doc § 4) // Debounce countdown for the Gear Shift effect, decremented each // tick by TickPeriodSec — separate from EffectState.ElapsedSec @@ -140,6 +141,7 @@ internal sealed class MBoosterEffectWorker : IDisposable private volatile bool _roadTextureTestSustained; private volatile bool _lockupTestSustained; private volatile bool _thresholdTestSustained; + private volatile bool _gforceTestSustained; // Custom effects' sustained Test toggles — same semantics as the five // built-ins above (runs indefinitely, live-tracks Frequency/Intensity, @@ -267,6 +269,7 @@ private struct EffectState public double SmoothnessRequest01; // 0..1, ABS (user-set); Traction Control/Wheel Spin fix this at 1 public double RoadTextureRoughness01; // 0..1, Road-Texture-only: live suspension-derived intensity scale public double ThresholdDecayRequest01; // 0..1, Threshold-only: sustain-decay depth + public double GForceSigned01; // -1..1, G-Force-only: live longitudinal-G fraction (+ accel, - brake) } public MBoosterEffectWorker( @@ -359,6 +362,9 @@ public void PostFrame(in MBoosterTelemetrySnapshot snap) /// Turn Threshold's sustained test toggle on/off. See . public void SetThresholdTestSustained(bool on) => _thresholdTestSustained = on; + /// Turn G-Force's sustained test toggle on/off. See . + public void SetGForceTestSustained(bool on) => _gforceTestSustained = on; + /// Turn Brake Fade's sustained test toggle on/off. See . public void SetBrakeFadeTestSustained(bool on) => _brakeFadeTestActive = on; @@ -438,6 +444,7 @@ private void Tick() UpdateLockupRequest(effects, brakeSignal, snap, ref _lockup); UpdateThresholdRequest(effects, brakeSignal, snap, ref _threshold); UpdateRoadTextureRequest(effects, snap, ref _roadTexture); + UpdateGForceRequest(effects, snap, ref _gforce); // --- Apply per-effect activation edges + emit motor frame ------ // @@ -464,6 +471,13 @@ private void Tick() // threshold pulse that lands in the same tick as a bump always // wins instead of being masked by it. ProcessRoadTextureEffect(effects, ref _roadTexture); + // G-Force — same ambient tier as Engine/Road Texture (before + // the wheel-slip cues) so a lockup/ABS/TC/wheel-spin/ + // threshold/gear-shift pulse always wins if it lands in the + // same tick, matching every other continuous effect's + // priority. Its own frame shape is unrelated to any of the + // others' (see ProcessGForceEffect). + ProcessGForceEffect(effects, ref _gforce); // Custom (NCalc) effects — Experimental. Placed in the ambient // tier (after Engine/Road Texture, before the wheel-slip cues) so // a user-authored effect can override built-in ambient vibration @@ -1100,6 +1114,42 @@ private void UpdateRoadTextureRequest(IMBoosterEffects? effects, in MBoosterTele st.IntensityRequest = envelope > 0.01 ? 1 : 0; } + // How many G reads as "100 %" commanded travel. Not a Pit House + // control — its own "Test" demo always commands the full configured + // Max Travel regardless of any G reading, so this mapping is the + // plugin's own choice (Experimental). 1.0G covers hard braking/ + // acceleration in most sim content without the effect maxing out on + // every firm stop. + private const double GForceFullScaleG = 1.0; + // Test-toggle demo cadence — mirrors Pit House's own alternating + // "Test" cycle (~0.6-0.7s per phase in capture) so the user can feel + // both directions, not a wire-protocol requirement. + private const double GForceTestPhaseSec = 0.6; + + private void UpdateGForceRequest(IMBoosterEffects? effects, in MBoosterTelemetrySnapshot snap, ref EffectState st) + { + if (_gforceTestSustained) + { + st.ElapsedSec += TickPeriodSec; + double cycle = st.ElapsedSec % (GForceTestPhaseSec * 2); + st.GForceSigned01 = cycle < GForceTestPhaseSec ? 1.0 : -1.0; + st.IntensityRequest = 1; + return; + } + + bool active = effects?.GForce != null && effects.GForce.Enabled && snap.GameRunning; + if (!active) + { + st.IntensityRequest = 0; + st.GForceSigned01 = 0; + return; + } + + double signed = snap.LongitudinalG / GForceFullScaleG; + st.GForceSigned01 = Math.Max(-1.0, Math.Min(1.0, signed)); + st.IntensityRequest = 1; + } + // Brake Fade — NOT a vibration effect. Dynamically rewrites TWO real // hardware calibrations in lockstep as brake temp climbs past // BrakeFadeOnsetC, using the SAME ramp01 fraction for both so they @@ -1320,6 +1370,52 @@ private void ProcessRoadTextureEffect(IMBoosterEffects? effects, ref EffectState SendMotor(frame); } + /// + /// G-Force (Inertial Pedal Feel) — Experimental. NOT a vibration + /// effect: unlike every other Process* method here, this holds + /// enable=1 continuously while active and streams a live directional + /// TRAVEL OFFSET target every tick (see + /// MozaMBoosterProtocol.BuildGForceFrame) rather than synthesizing a + /// waveform. (computed in + /// UpdateGForceRequest, -1..1) selects which of the wire's two + /// offset slots carries the magnitude — positive (accelerating) + /// pushes the forward slot, negative (braking) the backward slot — + /// scaled by the user's MaxTravelMm against the wire's fixed 15mm + /// full-scale range. ResponseSpeedPct is sent unshaped every frame; + /// the firmware does the actual ramping, not this worker. + /// + private void ProcessGForceEffect(IMBoosterEffects? effects, ref EffectState st) + { + const MBoosterEffectId id = MBoosterEffectId.GForce; + bool wantActive = st.IntensityRequest > 0; + + if (!wantActive) + { + if (st.Active) + { + _device.SendOneShot(MozaMBoosterProtocol.BuildDisableFrame(id, TargetDevice)); + st.Active = false; + } + return; + } + st.Active = true; + + var gforce = effects?.GForce; + double maxTravelMm = Math.Max(0, gforce?.MaxTravelMm ?? 0); + double responseSpeedPct = Clamp01((gforce?.ResponseSpeedPct ?? 0) / 100.0); + + double travelFraction01 = Clamp01(Math.Abs(st.GForceSigned01)) + * (maxTravelMm / MBoosterUiConstants.GForceMaxTravelMaxMm); + + ushort responseRaw = MozaMBoosterProtocol.EncodeAmp(responseSpeedPct); + ushort magnitudeRaw = MozaMBoosterProtocol.EncodeAmp(travelFraction01); + ushort forwardRaw = st.GForceSigned01 >= 0 ? magnitudeRaw : (ushort)0; + ushort backwardRaw = st.GForceSigned01 < 0 ? magnitudeRaw : (ushort)0; + + var frame = MozaMBoosterProtocol.BuildGForceFrame(true, responseRaw, forwardRaw, backwardRaw, TargetDevice); + SendMotor(frame); + } + /// /// Update + process every user-created custom effect for one tick /// (Experimental — docs/protocol/devices/mbooster.md "Custom diff --git a/Devices/MBoosterTypes.cs b/Devices/MBoosterTypes.cs index 12cfb155..739c881b 100644 --- a/Devices/MBoosterTypes.cs +++ b/Devices/MBoosterTypes.cs @@ -92,6 +92,22 @@ public static class MBoosterUiConstants // .ProcessCustomEffect — so the frequency range matches Engine's. public const float CustomEffectFreqMinHz = 5f; public const float CustomEffectFreqMaxHz = 200f; + + // G-Force (Inertial Pedal Feel) — Max Pedal Travel slider bounds, + // matching Pit House's own "Max Pedal Travelment" control exactly + // (0-15mm — reverse-engineered from capture, see + // MozaMBoosterProtocol.BuildGForceFrame). This is also the wire + // protocol's fixed full-scale denominator: the encoded travel + // fraction is always relative to 15mm, not to some other range. + public const float GForceMaxTravelMinMm = 0f; + public const float GForceMaxTravelMaxMm = 15f; + + // G-Force's Response Speed slider bounds, matching Pit House's own + // control exactly (0-100%). Sent to the firmware every frame — it's + // not host-side smoothing, the device itself ramps toward the + // commanded offset at this rate. + public const float GForceResponseSpeedMinPct = 0f; + public const float GForceResponseSpeedMaxPct = 100f; } /// @@ -197,6 +213,22 @@ public sealed class MBoosterEffectSettings // matches the wheelbase's own GearshiftDebounceMs default. public int DebounceMs { get; set; } = 500; + // G-Force-only (Experimental), millimeters (MBoosterUiConstants + // .GForceMaxTravelMinMm/MaxMm) — how far the pedal pushes at 100% + // commanded G, matching Pit House's own "Max Pedal Travelment" + // slider exactly. Sent every frame as a fraction of the wire's + // fixed 15mm full scale — see MBoosterEffectWorker.ProcessGForceEffect + // and MozaMBoosterProtocol.BuildGForceFrame. + public float MaxTravelMm { get; set; } = 10f; + + // G-Force-only (Experimental), 0..100 (MBoosterUiConstants + // .GForceResponseSpeedMinPct/MaxPct) — how fast the firmware ramps + // the pedal toward the newly commanded offset, matching Pit House's + // own "Response Speed" slider exactly. Unlike every other + // Intensity/Frequency knob this isn't host-side shaping — the raw + // percentage is sent straight to the device every frame. + public int ResponseSpeedPct { get; set; } = 50; + public MBoosterEffectSettings Clone() => new MBoosterEffectSettings { @@ -209,6 +241,8 @@ public MBoosterEffectSettings Clone() => BrakeFadeOnsetC = BrakeFadeOnsetC, VibrateOnNeutral = VibrateOnNeutral, DebounceMs = DebounceMs, + MaxTravelMm = MaxTravelMm, + ResponseSpeedPct = ResponseSpeedPct, }; } @@ -286,6 +320,7 @@ public interface IMBoosterEffects MBoosterEffectSettings TractionControl { get; set; } MBoosterEffectSettings WheelSpin { get; set; } MBoosterEffectSettings GearShift { get; set; } + MBoosterEffectSettings GForce { get; set; } System.Collections.Generic.List CustomEffects { get; set; } } @@ -360,6 +395,7 @@ public sealed class MBoosterPedalSettings : IMBoosterPedalConfig public MBoosterEffectSettings TractionControl { get; set; } = new MBoosterEffectSettings { FrequencyHz = 22 }; public MBoosterEffectSettings WheelSpin { get; set; } = new MBoosterEffectSettings { FrequencyHz = 30 }; public MBoosterEffectSettings GearShift { get; set; } = new MBoosterEffectSettings { FrequencyHz = 22 }; + public MBoosterEffectSettings GForce { get; set; } = new MBoosterEffectSettings { MaxTravelMm = 10, ResponseSpeedPct = 50 }; public List CustomEffects { get; set; } = new List(); public MBoosterPedalSettings Clone() => @@ -387,6 +423,7 @@ public MBoosterPedalSettings Clone() => TractionControl = TractionControl?.Clone() ?? new MBoosterEffectSettings(), WheelSpin = WheelSpin?.Clone() ?? new MBoosterEffectSettings(), GearShift = GearShift?.Clone() ?? new MBoosterEffectSettings(), + GForce = GForce?.Clone() ?? new MBoosterEffectSettings(), CustomEffects = CustomEffects?.Select(c => c.Clone()).ToList() ?? new List(), }; } @@ -452,6 +489,15 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig // Traction Control/Wheel Spin (no verified wire effect type of its // own — see MBoosterEffectWorker.ProcessGearShiftEffect). public MBoosterEffectSettings GearShift { get; set; } = new MBoosterEffectSettings { FrequencyHz = 22 }; + // G-Force (Inertial Pedal Feel) — Experimental. NOT a vibration + // effect: a sustained, directional pedal travel offset scaled by + // live longitudinal G (MBoosterTelemetrySnapshot.LongitudinalG), + // reverse-engineered from real Pit House "Test" captures (see + // docs/protocol/devices/mbooster.md "G-Force"). MaxTravelMm and + // ResponseSpeedPct mirror Pit House's own two sliders exactly; see + // MBoosterEffectWorker.UpdateGForceRequest/ProcessGForceEffect and + // MozaMBoosterProtocol.BuildGForceFrame. + public MBoosterEffectSettings GForce { get; set; } = new MBoosterEffectSettings { MaxTravelMm = 10, ResponseSpeedPct = 50 }; // FrequencyHz defaults to 55 — the exact value from the "known-good" // real Pit House capture (docs/protocol/devices/mbooster.md: // "Lockup on, 55 Hz, start of ramp"). @@ -625,6 +671,7 @@ public MBoosterDeviceSettings Clone() TractionControl = TractionControl?.Clone() ?? new MBoosterEffectSettings(), WheelSpin = WheelSpin?.Clone() ?? new MBoosterEffectSettings(), GearShift = GearShift?.Clone() ?? new MBoosterEffectSettings(), + GForce = GForce?.Clone() ?? new MBoosterEffectSettings(), Lockup = Lockup?.Clone() ?? new MBoosterEffectSettings(), Threshold = Threshold?.Clone() ?? new MBoosterEffectSettings(), Engine = Engine?.Clone() ?? new MBoosterEffectSettings(), @@ -679,6 +726,13 @@ public readonly struct MBoosterTelemetrySnapshot // this is a proxy for road-surface roughness used by Road Texture. // See docs/protocol/devices/mbooster.md "Road Texture". public readonly double SuspensionHeaveG; + // Longitudinal chassis acceleration, in G — SimHub's + // StatusDataBase.AccelerationSurge (nullable; 0 when a game doesn't + // report it). Positive = accelerating, negative = braking. Drives + // the G-Force (Inertial Pedal Feel) effect — see + // MBoosterEffectWorker.UpdateGForceRequest and + // docs/protocol/devices/mbooster.md "G-Force". + public readonly double LongitudinalG; // Peak brake temperature across all 4 corners, normalized to // Celsius regardless of the game's reported TemperatureUnit — // sourced from StatusDataBase.BrakesTemperatureMax (nullable; 0 @@ -710,7 +764,7 @@ public readonly struct MBoosterTelemetrySnapshot public MBoosterTelemetrySnapshot( bool gameRunning, double rpm, double maxRpm, double idleRpm, double brake, double throttle, bool absActive, bool tcActive, - double vehicleSpeedMs, double avgWheelSpeedMs, double suspensionHeaveG, + double vehicleSpeedMs, double avgWheelSpeedMs, double suspensionHeaveG, double longitudinalG, double brakeTempC, int gearShiftSeq, bool gearIsNeutral) { GameRunning = gameRunning; @@ -724,12 +778,13 @@ public MBoosterTelemetrySnapshot( VehicleSpeedMs = vehicleSpeedMs; AvgWheelSpeedMs = avgWheelSpeedMs; SuspensionHeaveG = suspensionHeaveG; + LongitudinalG = longitudinalG; BrakeTempC = brakeTempC; GearShiftSeq = gearShiftSeq; GearIsNeutral = gearIsNeutral; } public static readonly MBoosterTelemetrySnapshot Empty = - new MBoosterTelemetrySnapshot(false, 0, 0, 800, 0, 0, false, false, 0, 0, 0, 0, 0, false); + new MBoosterTelemetrySnapshot(false, 0, 0, 800, 0, 0, false, false, 0, 0, 0, 0, 0, 0, false); } } diff --git a/MozaPlugin.cs b/MozaPlugin.cs index cd88d9e7..fa347f10 100644 --- a/MozaPlugin.cs +++ b/MozaPlugin.cs @@ -1945,6 +1945,14 @@ public void DataUpdate(PluginManager pluginManager, ref GameData data) // road-surface roughness. Nullable — 0 for games that don't // report it, same fail-soft style as the rest of this block. double suspensionHeaveG = nd?.AccelerationHeave ?? 0.0; + // Longitudinal chassis acceleration, in G — SimHub's + // StatusDataBase.AccelerationSurge (= AccelerationX), same + // family/convention as AccelerationHeave above. Positive = + // accelerating, negative = braking/decelerating. Drives the + // G-Force (Inertial Pedal Feel) effect — see + // MBoosterEffectWorker.UpdateGForceRequest. Nullable — 0 for + // games that don't report it. + double longitudinalG = nd?.AccelerationSurge ?? 0.0; // Brake Fade's temperature signal — peak across all 4 // corners (any one wheel overheating should trigger the // warning, not just the average). BrakesTemperatureMax is @@ -1995,6 +2003,7 @@ public void DataUpdate(PluginManager pluginManager, ref GameData data) vehicleSpeedMs: vehicleMs, avgWheelSpeedMs: avgWheelMs, suspensionHeaveG: suspensionHeaveG, + longitudinalG: longitudinalG, brakeTempC: brakeTempC, gearShiftSeq: _mboosterShiftSeq, gearIsNeutral: gearIsNeutral); diff --git a/Protocol/MozaMBoosterProtocol.cs b/Protocol/MozaMBoosterProtocol.cs index 93e35c63..a0f0580e 100644 --- a/Protocol/MozaMBoosterProtocol.cs +++ b/Protocol/MozaMBoosterProtocol.cs @@ -20,6 +20,13 @@ public enum MBoosterEffectId : byte // with this effect type. Uses a materially different payload shape // from the other four — see BuildRoadTextureFrame. RoadTexture = 9, + // G-Force (Inertial Pedal Feel) — reverse-engineered from four real + // Pit House "Test" USB captures at different Max Travel/Response + // Speed settings (see docs/protocol/devices/mbooster.md "G-Force"). + // Not a vibration waveform at all: a sustained, directional TRAVEL + // OFFSET target the firmware moves the pedal to and holds — see + // BuildGForceFrame. + GForce = 6, } /// @@ -161,6 +168,57 @@ public static byte[] BuildRoadTextureFrame(bool enable, ushort intensityRaw, ush return frame; } + /// + /// Build the motor-write frame for the G-Force (Inertial Pedal Feel) + /// effect — effect type 6, a genuinely different mechanism from + /// every other mBooster effect: not a vibration waveform, but a + /// sustained, directional TRAVEL OFFSET target the firmware moves + /// the pedal to and holds, at a firmware-side ramp rate set by + /// . Reverse-engineered from four + /// real Pit House "Test" captures at different Max Travel/Response + /// Speed settings (see docs/protocol/devices/mbooster.md "G-Force"): + ///
+        /// 7e  09  24  12   b1  06  EN   RH  RL   FH  FL   BH  BL   CK
+        ///                  │   │   │    └─┴─response speed u16 BE
+        ///                  │   │   │              └─┴─forward offset u16 BE
+        ///                  │   │   │                         └─┴─backward offset u16 BE
+        ///                  │   │   └ enable (0 = off, 1 = on)
+        ///                  │   └ effect type (6 = G-Force)
+        ///                  └ cmd id (0xb1)
+        /// 
+ /// Exactly one of forward/backward is non-zero at a time in every + /// observed capture (the other is 0x0000) — Pit House's own "Test" + /// alternates between the two on a fixed cadence to demonstrate both + /// directions; a live effect instead holds enable=1 continuously and + /// updates whichever slot matches the sign of live longitudinal G + /// every tick (see MBoosterEffectWorker.ProcessGForceEffect). Both + /// value fields share 's exact + /// "round(frac01*65535)" formula, verified against 4 data points + /// each: response speed 100%/50%/15% -> 0xFFFF/0x7FFF/0x2666 (exact); + /// travel 15mm/10mm/2.5mm (against the wire's fixed 15mm full-scale + /// range — see MBoosterUiConstants.GForceMaxTravelMaxMm) -> + /// 0xFFFF/0xAAAA/0x2AAA (exact). + ///
+ public static byte[] BuildGForceFrame(bool enable, ushort responseSpeedRaw, ushort forwardRaw, ushort backwardRaw, byte device = DeviceMotor) + { + var frame = new byte[14]; + frame[0] = MozaProtocol.MessageStart; + frame[1] = MotorPayloadLen; + frame[2] = GroupMotorWrite; + frame[3] = device; + frame[4] = CmdMotorWrite; + frame[5] = (byte)MBoosterEffectId.GForce; + frame[6] = enable ? (byte)1 : (byte)0; + frame[7] = (byte)(responseSpeedRaw >> 8); + frame[8] = (byte)(responseSpeedRaw & 0xFF); + frame[9] = (byte)(forwardRaw >> 8); + frame[10] = (byte)(forwardRaw & 0xFF); + frame[11] = (byte)(backwardRaw >> 8); + frame[12] = (byte)(backwardRaw & 0xFF); + frame[13] = MozaProtocol.CalculateWireChecksum(frame, 13); + return frame; + } + /// /// Degenerate 0-payload frame targeting the motor — 7e 00 00 12 9d. /// Per protocol note § 3 "Keepalive": send every ~500 ms whenever the port diff --git a/Resources/Strings.Designer.cs b/Resources/Strings.Designer.cs index 5dd463a1..7bda0bdf 100644 --- a/Resources/Strings.Designer.cs +++ b/Resources/Strings.Designer.cs @@ -243,6 +243,10 @@ private static string Get(string key) public static string SliderLabel_VibrationDecay => Get("SliderLabel_VibrationDecay"); public static string SliderLabel_OnsetTempC => Get("SliderLabel_OnsetTempC"); public static string Hint_BrakeFadeExperimental => Get("Hint_BrakeFadeExperimental"); + public static string Section_GForce => Get("Section_GForce"); + public static string Hint_GForceExperimental => Get("Hint_GForceExperimental"); + public static string SliderLabel_MaxTravelMm => Get("SliderLabel_MaxTravelMm"); + public static string SliderLabel_ResponseSpeedPct => Get("SliderLabel_ResponseSpeedPct"); public static string Section_CustomEffects => Get("Section_CustomEffects"); public static string Subtitle_CustomEffectsExperimental => Get("Subtitle_CustomEffectsExperimental"); public static string Hint_CustomEffectsExperimental => Get("Hint_CustomEffectsExperimental"); diff --git a/Resources/Strings.resx b/Resources/Strings.resx index 12941dae..1162f686 100644 --- a/Resources/Strings.resx +++ b/Resources/Strings.resx @@ -183,6 +183,10 @@ Vibration Decay Onset Temperature (°C) Experimental — writes the real Travel End and Max Threshold hardware calibrations while active (more travel and more force needed), then restores your configured values as brakes cool. Requires those already set in Pedal Feel / Sim Input Mapping — has no effect otherwise. + G-Force (Inertial Pedal Feel) (Experimental) + Experimental — pushes the pedal itself under your foot in proportion to live longitudinal G (accelerating pushes forward, braking pushes back), rather than vibrating. Max Pedal Travel sets how far it moves at full G; Response Speed sets how quickly the pedal ramps to the new position. + Max Pedal Travel (mm) + Response Speed (%) Custom Effects (Experimental) // experimental — user-defined vibration driven by SimHub/NCalc formulas Experimental — each custom effect vibrates the motor using a SimHub property or NCalc formula you supply. All custom effects (and the built-in Engine effect) share one wire channel, so only one can drive the motor at a time; the most recently processed active effect wins. Frequency/Intensity behave like Engine's own sliders. diff --git a/Telemetry/TestMode/TestSignalOverrides.cs b/Telemetry/TestMode/TestSignalOverrides.cs index 87e4bffa..460be81f 100644 --- a/Telemetry/TestMode/TestSignalOverrides.cs +++ b/Telemetry/TestMode/TestSignalOverrides.cs @@ -279,6 +279,13 @@ static TestSignalOverrides() // multi-second sweep like the other orientation signals above // wouldn't exercise that in any recognizable way. Add("AccelerationHeave", TestSignal.Sweep(-0.6, 0.6, periodMs: 700)); + // Slow sweep through +/-1G — mBooster's G-Force (Inertial Pedal + // Feel) effect scales its travel offset by AccelerationSurge + // (see docs/protocol/devices/mbooster.md "G-Force"); a multi- + // second period lets the pedal's forward/backward push be felt + // distinctly rather than blurring together like Road Texture's + // fast bump signal. + Add("AccelerationSurge", TestSignal.Sweep(-1.0, 1.0, periodMs: 3000)); // --- Spotter / radar / coordinates --- Add("SpotterCarLeft", TestSignal.Toggle(stepMs: 5000)); diff --git a/UI/SettingsControl.xaml b/UI/SettingsControl.xaml index c356a264..c49f3665 100644 --- a/UI/SettingsControl.xaml +++ b/UI/SettingsControl.xaml @@ -2371,6 +2371,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs index 0321f6ac..2a6561ac 100644 --- a/UI/SettingsControl.xaml.cs +++ b/UI/SettingsControl.xaml.cs @@ -183,6 +183,8 @@ private void OnUnloadedStopTimers(object sender, RoutedEventArgs e) CurrentMBoosterController()?.SetThresholdTestActive(false, _mboosterEffectPedalIndex); if (MBoosterBrakeFadeTestToggle?.IsChecked == true) CurrentMBoosterController()?.SetBrakeFadeTestActive(false); + if (MBoosterGForceTestToggle?.IsChecked == true) + CurrentMBoosterController()?.SetGForceTestActive(false, _mboosterEffectPedalIndex); StopAllCustomEffectTests(); // SDK CoAP server fires RecentRequestAppended on its receive // thread; unsubscribe so a torn-down SettingsControl can be GC'd @@ -2890,6 +2892,8 @@ private void OnMBoosterDeviceRowSelected(string identity, int axisIndex) CurrentMBoosterController()?.SetThresholdTestActive(false, _mboosterEffectPedalIndex); if (MBoosterBrakeFadeTestToggle.IsChecked == true) CurrentMBoosterController()?.SetBrakeFadeTestActive(false); + if (MBoosterGForceTestToggle.IsChecked == true) + CurrentMBoosterController()?.SetGForceTestActive(false, _mboosterEffectPedalIndex); StopAllCustomEffectTests(); _mboosterSelectedIdentity = identity; _mboosterEffectPedalIndex = axisIndex; @@ -3102,6 +3106,12 @@ private void SeedMBoosterEffectControls(IMBoosterEffects? fx) MBoosterRoadTextureSmoothness.Value = fx?.RoadTexture?.SmoothnessPct ?? 50; SetValueText(MBoosterRoadTextureSmoothnessValue, (fx?.RoadTexture?.SmoothnessPct ?? 50).ToString()); MBoosterRoadTextureTestToggle.IsChecked = false; + MBoosterGForceEnable.IsChecked = fx?.GForce?.Enabled ?? false; + MBoosterGForceMaxTravel.Value = fx?.GForce?.MaxTravelMm ?? 10; + SetValueText(MBoosterGForceMaxTravelValue, MBoosterGForceMaxTravel.Value.ToString("0.#")); + MBoosterGForceResponseSpeed.Value = fx?.GForce?.ResponseSpeedPct ?? 50; + SetValueText(MBoosterGForceResponseSpeedValue, (fx?.GForce?.ResponseSpeedPct ?? 50).ToString()); + MBoosterGForceTestToggle.IsChecked = false; } /// Seed the Calibration, Sim Input Mapping and Pedal Feel controls @@ -3850,6 +3860,50 @@ private void MBoosterBrakeFadeTestToggle_Changed(object sender, RoutedEventArgs CurrentMBoosterController()?.SetBrakeFadeTestActive(MBoosterBrakeFadeTestToggle.IsChecked == true); } + private void MBoosterGForceEnable_Changed(object sender, RoutedEventArgs e) + { + if (_suppressEvents) return; + var s = CurrentMBoosterEffectTarget(); + if (s == null) return; + (s.GForce ??= new MBoosterEffectSettings()).Enabled = MBoosterGForceEnable.IsChecked == true; + _plugin.SaveSettings(); + } + // 0-15mm, half-mm steps (matches Pit House's own "Max Pedal + // Travelment" slider) — see MBoosterEffectSettings.MaxTravelMm. + private void MBoosterGForceMaxTravel_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (_suppressEvents) return; + double v = Math.Round(e.NewValue * 2) / 2.0; + v = Math.Max(MBoosterUiConstants.GForceMaxTravelMinMm, Math.Min(MBoosterUiConstants.GForceMaxTravelMaxMm, v)); + MBoosterGForceMaxTravelValue.Text = v.ToString("0.#"); + var s = CurrentMBoosterEffectTarget(); + if (s == null) return; + (s.GForce ??= new MBoosterEffectSettings()).MaxTravelMm = (float)v; + _plugin.SaveSettings(); + } + // 0-100% — sent to the firmware unshaped every frame (it does the + // actual ramping, not the plugin) — see + // MBoosterEffectSettings.ResponseSpeedPct. + private void MBoosterGForceResponseSpeed_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (_suppressEvents) return; + int v = Math.Max(0, Math.Min(100, (int)Math.Round(e.NewValue))); + MBoosterGForceResponseSpeedValue.Text = v.ToString(); + var s = CurrentMBoosterEffectTarget(); + if (s == null) return; + (s.GForce ??= new MBoosterEffectSettings()).ResponseSpeedPct = v; + _plugin.SaveSettings(); + } + // Sustained test toggle — alternates the commanded travel offset + // forward/backward, mirroring Pit House's own "Test" demo (bypasses + // Enabled and the game-running gate). See + // MBoosterDeviceController.SetGForceTestActive. + private void MBoosterGForceTestToggle_Changed(object sender, RoutedEventArgs e) + { + if (_suppressEvents) return; + CurrentMBoosterController()?.SetGForceTestActive(MBoosterGForceTestToggle.IsChecked == true, _mboosterEffectPedalIndex); + } + // ===== Calibration (experimental) =================================== private void MBoosterDirCheck_Changed(object sender, RoutedEventArgs e) From 4eb7041476f2de0cdcc11b5a1cde043124637c1c Mon Sep 17 00:00:00 2001 From: tacodevhaydz Date: Fri, 31 Jul 2026 11:16:47 +1200 Subject: [PATCH 02/13] added friction in pedal feel settings --- Devices/MBoosterTypes.cs | 18 +++++++++++++++++ MozaPlugin.cs | 7 +++++++ Protocol/MozaCommandDatabase.cs | 13 +++++++++++++ Protocol/MozaMBoosterProtocol.cs | 30 +++++++++++++++++++++++++++++ Resources/Strings.Designer.cs | 1 + Resources/Strings.de.resx | 1 + Resources/Strings.el.resx | 1 + Resources/Strings.es.resx | 1 + Resources/Strings.fr.resx | 1 + Resources/Strings.it.resx | 1 + Resources/Strings.ko.resx | 1 + Resources/Strings.nb.resx | 1 + Resources/Strings.resx | 1 + Resources/Strings.ru.resx | 1 + Resources/Strings.vi.resx | 1 + Resources/Strings.zh-Hans.resx | 1 + UI/SettingsControl.xaml | 13 +++++++++++++ UI/SettingsControl.xaml.cs | 32 +++++++++++++++++++++++++++++++ docs/protocol/devices/mbooster.md | 24 +++++++++++++++++++++++ 19 files changed, 149 insertions(+) diff --git a/Devices/MBoosterTypes.cs b/Devices/MBoosterTypes.cs index 739c881b..44b3365a 100644 --- a/Devices/MBoosterTypes.cs +++ b/Devices/MBoosterTypes.cs @@ -352,6 +352,7 @@ public interface IMBoosterPedalConfig : IMBoosterEffects float TravelEndMm { get; set; } float EndstopFrontStiffness { get; set; } float EndstopEndStiffness { get; set; } + float NaturalFrictionPct { get; set; } } /// @@ -385,6 +386,7 @@ public sealed class MBoosterPedalSettings : IMBoosterPedalConfig public float TravelEndMm { get; set; } = -1; public float EndstopFrontStiffness { get; set; } = -1; public float EndstopEndStiffness { get; set; } = -1; + public float NaturalFrictionPct { get; set; } = -1; // Per-pedal vibration effects (same defaults as the master's flat fields). public MBoosterEffectSettings Abs { get; set; } = new MBoosterEffectSettings { FrequencyHz = 22 }; @@ -415,6 +417,7 @@ public MBoosterPedalSettings Clone() => TravelEndMm = TravelEndMm, EndstopFrontStiffness = EndstopFrontStiffness, EndstopEndStiffness = EndstopEndStiffness, + NaturalFrictionPct = NaturalFrictionPct, Abs = Abs?.Clone() ?? new MBoosterEffectSettings(), Lockup = Lockup?.Clone() ?? new MBoosterEffectSettings(), Threshold = Threshold?.Clone() ?? new MBoosterEffectSettings(), @@ -654,6 +657,20 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig public float EndstopFrontStiffness { get; set; } = -1; 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 + // 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 + // 0-100% scale over 0-65535 — see + // MozaMBoosterProtocol.EncodeFrictionPct/DecodeFrictionPct and + // docs/protocol/devices/mbooster.md "Pedal Feel". -1 = "not yet set + // / no override", same sentinel convention as + // TravelStartMm/EndstopFrontStiffness, so a fresh profile never + // overwrites whatever value is already on the device. + public float NaturalFrictionPct { get; set; } = -1; + // Friendly display label the user can edit (defaults to "mBooster" // with a serial-tail fallback). Survives reconnects with the dict key. public string DisplayName { get; set; } = ""; @@ -692,6 +709,7 @@ public MBoosterDeviceSettings Clone() TravelEndMm = TravelEndMm, EndstopFrontStiffness = EndstopFrontStiffness, EndstopEndStiffness = EndstopEndStiffness, + NaturalFrictionPct = NaturalFrictionPct, DisplayName = DisplayName, }; } diff --git a/MozaPlugin.cs b/MozaPlugin.cs index fa347f10..82c2a62c 100644 --- a/MozaPlugin.cs +++ b/MozaPlugin.cs @@ -2345,6 +2345,13 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeEndstopStiffness(cfg.EndstopEndStiffness), dev); wroteAnyCalibration = true; } + if (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; + } if (role == global::MozaPlugin.Devices.MBoosterRole.Brake) { if (cfg.SensorOutputRatioPct >= 0) diff --git a/Protocol/MozaCommandDatabase.cs b/Protocol/MozaCommandDatabase.cs index 407ef535..d97ba381 100644 --- a/Protocol/MozaCommandDatabase.cs +++ b/Protocol/MozaCommandDatabase.cs @@ -603,6 +603,19 @@ static MozaCommandDatabase() // docs/protocol/devices/mbooster.md "Pedal Feel". AddCommand("mbooster-brake-endstop-front", "mbooster", 35, 36, new byte[] { 0xB2, 0x00, 0x00 }, 2, "int"); AddCommand("mbooster-brake-endstop-end", "mbooster", 35, 36, new byte[] { 0xB2, 0x00, 0x01 }, 2, "int"); + // Natural Friction (0-100%) — reverse-engineered from two real + // Pit House USB captures (a toggle on/off, and a 0/25/50/75/100% + // slider sweep). Same "prefix bytes + selector" shape as End + // Stop Stiffness above: ONE cmdId (0xAE) with a fixed 0x00 byte + // and a selector byte (0x00/0x01) before the 2-byte value — but + // unlike Endstop's independent front/end values, every capture + // write sent BOTH selectors with the IDENTICAL value in the same + // burst, so these two are always written together, never + // independently. raw = round(pct * 65535 / 100), matching + // MozaMBoosterProtocol.EncodeFrictionPct. See + // 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"); diff --git a/Protocol/MozaMBoosterProtocol.cs b/Protocol/MozaMBoosterProtocol.cs index a0f0580e..cd7acedf 100644 --- a/Protocol/MozaMBoosterProtocol.cs +++ b/Protocol/MozaMBoosterProtocol.cs @@ -400,6 +400,36 @@ public static double DecodeEndstopStiffness(int raw) return raw * 10.0 / 65535.0; } + /// + /// Pit House "Natural Friction" encoding — reverse-engineered from + /// two real Pit House USB captures (wire commands + /// mbooster-brake-friction-0/-1, cmdId 0xAE with a + /// selector byte; see docs/protocol/devices/mbooster.md "Pedal + /// Feel"). Fixed 0-100% scale over the 0-65535 range: raw = + /// round(pct * 65535 / 100). Verified against a 0/25/50/75/100% + /// sweep (0x0000/0x4000/0x8000/0xbfff/0xffff, all exact) and cross- + /// checked against the firmware's own debug log in a second capture, + /// which echoed the disabled write as fixed-point 0.0 and the + /// enabled write (slider at 100%) as fixed-point 1.0 — confirming + /// there is no separate wire enable bit; turning the feature off + /// just writes raw 0. + /// + public static int EncodeFrictionPct(double pct) + { + if (double.IsNaN(pct) || pct <= 0) return 0; + double raw = Math.Round(pct * 65535.0 / 100.0); + if (raw <= 0) return 0; + if (raw >= 0xFFFF) return 0xFFFF; + return (int)raw; + } + + /// Inverse of . + public static double DecodeFrictionPct(int raw) + { + if (raw <= 0) return 0; + return raw * 100.0 / 65535.0; + } + /// /// Pit House Road Texture Intensity/Smoothness encoding — reverse- /// engineered from two real Pit House USB captures, one per diff --git a/Resources/Strings.Designer.cs b/Resources/Strings.Designer.cs index 7bda0bdf..f9aa4eac 100644 --- a/Resources/Strings.Designer.cs +++ b/Resources/Strings.Designer.cs @@ -220,6 +220,7 @@ private static string Get(string key) public static string SliderLabel_TravelRangeMm => Get("SliderLabel_TravelRangeMm"); public static string SliderLabel_EndstopFrontStiffness => Get("SliderLabel_EndstopFrontStiffness"); public static string SliderLabel_EndstopEndStiffness => Get("SliderLabel_EndstopEndStiffness"); + public static string Hint_NaturalFrictionPedalFeel => Get("Hint_NaturalFrictionPedalFeel"); public static string Section_SimInputMapping => Get("Section_SimInputMapping"); public static string Subtitle_SimInputMapping => Get("Subtitle_SimInputMapping"); public static string SliderLabel_MaxThresholdKg => Get("SliderLabel_MaxThresholdKg"); diff --git a/Resources/Strings.de.resx b/Resources/Strings.de.resx index 57c6f030..6212b018 100644 --- a/Resources/Strings.de.resx +++ b/Resources/Strings.de.resx @@ -148,6 +148,7 @@ Start / Ende des Pedalwegs (mm) Steifigkeit vorderer Anschlag Steifigkeit hinterer Anschlag + Simuliert eine Reibungskraft, die unabhängig von der Spielausgabe ist. SIM-EINGABEZUORDNUNG // Lastzellen-/Pedalsignal wie in Pit House auf das Spiel abbilden Max. Schwellenwert (kg) diff --git a/Resources/Strings.el.resx b/Resources/Strings.el.resx index c1c21057..0ff09bcf 100644 --- a/Resources/Strings.el.resx +++ b/Resources/Strings.el.resx @@ -148,6 +148,7 @@ Αρχή / Τέλος Διαδρομής (mm) Σκληρότητα εμπρός ορίου Σκληρότητα τελικού ορίου + Προσομοιώνει μια δύναμη τριβής ανεξάρτητη από την έξοδο του παιχνιδιού. ΑΝΤΙΣΤΟΙΧΙΣΗ ΕΙΣΟΔΟΥ ΠΡΟΣΟΜΟΙΩΣΗΣ // αντιστοίχιση σήματος κυψέλης φόρτισης/πεντάλ στο παιχνίδι, στυλ Pit House Μέγιστο κατώφλι (kg) diff --git a/Resources/Strings.es.resx b/Resources/Strings.es.resx index b495da68..4a5d23da 100644 --- a/Resources/Strings.es.resx +++ b/Resources/Strings.es.resx @@ -153,6 +153,7 @@ Inicio / Fin del Recorrido (mm) Rigidez del límite frontal Rigidez del límite final + Simula una fuerza de fricción independiente de la salida del juego. MAPEO DE ENTRADA DE SIMULACIÓN // mapea la señal de la celda de carga/pedal al juego, al estilo Pit House Umbral máximo (kg) diff --git a/Resources/Strings.fr.resx b/Resources/Strings.fr.resx index a8484838..1dc1fcb1 100644 --- a/Resources/Strings.fr.resx +++ b/Resources/Strings.fr.resx @@ -148,6 +148,7 @@ Début / Fin de Course (mm) Rigidité de butée avant Rigidité de butée de fin + Simule une force de friction indépendante de la sortie du jeu. MAPPAGE D'ENTRÉE SIM // mappe le signal de la cellule de charge/pédale vers le jeu, à la manière de Pit House Seuil maximal (kg) diff --git a/Resources/Strings.it.resx b/Resources/Strings.it.resx index 2311de67..ad8e060e 100644 --- a/Resources/Strings.it.resx +++ b/Resources/Strings.it.resx @@ -151,6 +151,7 @@ Inizio / Fine Corsa (mm) Rigidità limite anteriore Rigidità limite finale + Simula una forza d'attrito indipendente dall'output di gioco. MAPPATURA INPUT SIM // mappa il segnale della cella di carico/pedale al gioco, in stile Pit House Soglia massima (kg) diff --git a/Resources/Strings.ko.resx b/Resources/Strings.ko.resx index 6060716b..17c99ddd 100644 --- a/Resources/Strings.ko.resx +++ b/Resources/Strings.ko.resx @@ -148,6 +148,7 @@ 이동 시작/종료 (mm) 프론트 리밋 강성 엔드 리밋 강성 + 게임 출력과 무관한 마찰력을 시뮬레이션합니다. 시뮬레이션 입력 매핑 // 로드셀/페달 신호를 게임에 매핑 (핏하우스 방식) 최대 임계값 (kg) diff --git a/Resources/Strings.nb.resx b/Resources/Strings.nb.resx index 8bc9abc8..f78a1aea 100644 --- a/Resources/Strings.nb.resx +++ b/Resources/Strings.nb.resx @@ -153,6 +153,7 @@ Start / Slutt på Bevegelse (mm) Frontgrense-stivhet Sluttgrense-stivhet + Simulerer en friksjonskraft som er uavhengig av spillutgangen. SIM-INNGANGSKARTLEGGING // kartlegger lastcelle-/pedalsignalet til spillet, Pit House-stil Maks terskel (kg) diff --git a/Resources/Strings.resx b/Resources/Strings.resx index 1162f686..f89bc779 100644 --- a/Resources/Strings.resx +++ b/Resources/Strings.resx @@ -160,6 +160,7 @@ Start / End of Travel (mm) Front Limit Stiffness End Limit Stiffness + Simulate a frictional force that is independent of the game output. SIM INPUT MAPPING Max Threshold (kg) diff --git a/Resources/Strings.ru.resx b/Resources/Strings.ru.resx index 8c4459a4..13ab54c3 100644 --- a/Resources/Strings.ru.resx +++ b/Resources/Strings.ru.resx @@ -148,6 +148,7 @@ Начало / Конец хода (мм) Жёсткость переднего упора Жёсткость заднего упора + Имитирует силу трения, не зависящую от игрового вывода. СОПОСТАВЛЕНИЕ ВВОДА СИМУЛЯТОРА // сопоставление сигнала тензодатчика/педали с игрой, в стиле Pit House Макс. порог (кг) diff --git a/Resources/Strings.vi.resx b/Resources/Strings.vi.resx index 5d2a22dd..cabdb9b6 100644 --- a/Resources/Strings.vi.resx +++ b/Resources/Strings.vi.resx @@ -148,6 +148,7 @@ Bắt đầu / Kết thúc Hành trình (mm) Độ cứng giới hạn đầu Độ cứng giới hạn cuối + Mô phỏng một lực ma sát độc lập với đầu ra của game. ÁNH XẠ ĐẦU VÀO SIM // ánh xạ tín hiệu loadcell/bàn đạp vào game, theo kiểu Pit House Ngưỡng tối đa (kg) diff --git a/Resources/Strings.zh-Hans.resx b/Resources/Strings.zh-Hans.resx index 2943665a..56d1cd92 100644 --- a/Resources/Strings.zh-Hans.resx +++ b/Resources/Strings.zh-Hans.resx @@ -148,6 +148,7 @@ 行程起点 / 终点(mm) 前限位硬度 后限位硬度 + 模拟一种独立于游戏输出的摩擦力。 模拟输入映射 // 将传感器/踏板信号映射到游戏,Pit House 风格 最大阈值(kg) diff --git a/UI/SettingsControl.xaml b/UI/SettingsControl.xaml index c49f3665..cf405a8f 100644 --- a/UI/SettingsControl.xaml +++ b/UI/SettingsControl.xaml @@ -1895,6 +1895,19 @@ KeyDown="SliderValueBox_KeyDown" LostFocus="SliderValueBox_LostFocus" Text="200"/> + + + + + + + + + + diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs index 2a6561ac..94aa8bcc 100644 --- a/UI/SettingsControl.xaml.cs +++ b/UI/SettingsControl.xaml.cs @@ -3167,6 +3167,9 @@ private void SeedMBoosterConfigControls(IMBoosterPedalConfig? fx) SetValueText(MBoosterDeadzoneValue, (fx?.DeadzoneKg ?? 0).ToString("F1")); MBoosterMaxForceSlider.Value = fx?.MaxForceKg ?? 200; SetValueText(MBoosterMaxForceValue, (fx?.MaxForceKg ?? 200).ToString("F0")); + float nf = fx?.NaturalFrictionPct ?? -1; + MBoosterNaturalFrictionSlider.Value = nf >= 0 ? nf : 0; + SetValueText(MBoosterNaturalFrictionValue, MBoosterNaturalFrictionSlider.Value.ToString("F0")); } private MBoosterDeviceController? CurrentMBoosterController() @@ -4253,6 +4256,35 @@ private void MBoosterEndstopEndSlider_ValueChanged(object sender, RoutedProperty controller?.PushCurve7Resync(s.CurveX, s.CurveY, dev); }); + // Natural Friction (0-100%) — simulates a frictional force + // independent of game output. Reverse-engineered from two real Pit + // House USB captures (a toggle on/off, and a 0/25/50/75/100% slider + // sweep — see docs/protocol/devices/mbooster.md "Pedal Feel"): wire + // cmdId 0xAE, sharing the same "prefix bytes + selector" shape as + // End Stop Stiffness (0xB2). Every capture write sent BOTH + // selectors with the IDENTICAL value in the same burst, so this + // control always writes mbooster-brake-friction-0 and -1 together + // rather than exposing them as separate sliders. There is no + // separate wire enable bit — the capture's toggle-off write simply + // sent raw 0 (confirmed via the firmware's own debug log echoing + // it as fixed-point 0.0). + private void MBoosterNaturalFrictionSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) => + OnIntSliderChanged(e.NewValue, MBoosterNaturalFrictionValue, "", v => + { + var s = CurrentMBoosterEffectTarget(); + if (s == null) return; + s.NaturalFrictionPct = v; + var controller = CurrentMBoosterController(); + byte dev = MBoosterCalibDevice(controller, _mboosterEffectPedalIndex); + int raw = global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeFrictionPct(v); + controller?.SendIntWrite("mbooster-brake-friction-0", raw, dev); + controller?.SendIntWrite("mbooster-brake-friction-1", raw, dev); + // EXPERIMENTAL / unverified — see MBoosterTravelRangeSlider_RangeChanged + // and MBoosterDeviceController.PushCurve7Resync; untested for this + // control specifically, applied on the same-root-cause theory. + controller?.PushCurve7Resync(s.CurveX, s.CurveY, dev); + }); + 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 e0588bf0..a23d5725 100644 --- a/docs/protocol/devices/mbooster.md +++ b/docs/protocol/devices/mbooster.md @@ -848,6 +848,30 @@ which is why `EncodeEndstopStiffness` explicitly uses that every other `Encode*` helper here implicitly relies on. Same `-1` sentinel convention as `TravelStartMm`/`TravelEndMm`. +**Natural Friction** (`NaturalFrictionPct`, 0–100%, labeled "Natural +Friction" — simulates a frictional force independent of game output) is +another genuine hardware write, reverse-engineered from two real Pit +House USB captures: one toggling the setting off/on, and one dragging the +slider through 0/25/50/75/100%. Same "prefix bytes + selector" shape as +End Stop Stiffness above — ONE cmdId (`0xAE`) with a fixed `0x00` byte and +a selector byte (`0x00`/`0x01`) before the 2-byte value +(`mbooster-brake-friction-0`/`-1` in the command database) — but unlike +Endstop's independent front/end values, every capture write sent **both** +selectors with the identical value in the same burst, so the UI always +writes them together rather than exposing two sliders. Fixed 0–100% scale +over 0–65535: `raw = round(pct * 65535 / 100)` — the 0/25/50/75/100% +sweep matched exactly (`0x0000`/`0x4000`/`0x8000`/`0xbfff`/`0xffff`). The +toggle capture cross-checks this: the mBooster's own firmware debug log +(carried in the response stream as ASCII, `param_manage.c` write-confirm +lines) echoed the disabled write as `Table 2, Param 32 Written: 0 +0.00000` and the enabled write (slider left at 100%) as `Param 32 +Written: 1073741824` (`2^30`, i.e. fixed-point `1.0`) — confirming there +is **no separate wire enable bit**; Pit House's toggle just writes raw 0 +when off and restores the last slider value when on. See +`MozaMBoosterProtocol.EncodeFrictionPct`/`DecodeFrictionPct`. Same `-1` +"not yet set / no override" sentinel convention as +`EndstopFrontStiffness`/`EndstopEndStiffness`. + The same card also has two force-based sliders, both host-side only and both applied in `MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce`, which runs *before* `EvaluateInputCurve`: From 9afba65ac97f83429fc44808e6fc3f13933c37bd Mon Sep 17 00:00:00 2001 From: tacodevhaydz Date: Fri, 31 Jul 2026 11:52:20 +1200 Subject: [PATCH 03/13] segmented damping controls added to pedal feel --- Devices/MBoosterTypes.cs | 93 ++++++ MozaPlugin.cs | 30 ++ Protocol/MozaMBoosterProtocol.cs | 99 ++++++ Resources/Strings.Designer.cs | 5 + Resources/Strings.resx | 5 + Themes/Generic.xaml | 14 + Themes/MozaTheme.xaml | 104 ++++++ UI/Controls/MozaSegmentedBarEditor.cs | 441 ++++++++++++++++++++++++++ UI/SettingsControl.xaml | 26 ++ UI/SettingsControl.xaml.cs | 89 ++++++ docs/protocol/devices/mbooster.md | 76 +++++ 11 files changed, 982 insertions(+) create mode 100644 UI/Controls/MozaSegmentedBarEditor.cs diff --git a/Devices/MBoosterTypes.cs b/Devices/MBoosterTypes.cs index 44b3365a..3dee524b 100644 --- a/Devices/MBoosterTypes.cs +++ b/Devices/MBoosterTypes.cs @@ -108,6 +108,32 @@ public static class MBoosterUiConstants // commanded offset at this rate. public const float GForceResponseSpeedMinPct = 0f; public const float GForceResponseSpeedMaxPct = 100f; + + // Segmented Damping — divider bounds per Pit House's own UI (each + // divider has its OWN independent min/max, unlike a plain dual- + // thumb range): Divider1 stays within [10, 80], Divider2 within + // [20, 90], and the two may never be adjusted within 10% of each + // other. Shared by both "When Pressed" and "When Released" (each + // has its own independent pair of dividers, same bounds). See + // MozaControls.MozaSegmentedBarEditor and + // docs/protocol/devices/mbooster.md "Segmented Damping". + public const float SegDampDivider1MinPct = 10f; + public const float SegDampDivider1MaxPct = 80f; + public const float SegDampDivider2MinPct = 20f; + public const float SegDampDivider2MaxPct = 90f; + public const float SegDampDividerMinGapPct = 10f; + + // Factory defaults, reverse-engineered from a recurring untouched + // baseline across multiple independent captures (5+ sessions each + // for Pressed and Released) — used as the fallback whenever a + // MBoosterSegmentedDampingSettings field is still the -1 "not set" + // sentinel, so a fresh profile displays a sensible starting layout + // without writing anything until the user actually drags a control. + public const float SegDampDivider1PressedDefaultPct = 33f; + public const float SegDampDivider2PressedDefaultPct = 67f; + public const float SegDampDivider1ReleasedDefaultPct = 20f; + public const float SegDampDivider2ReleasedDefaultPct = 70f; + public const float SegDampSegDefaultPct = 0f; } /// @@ -246,6 +272,64 @@ public MBoosterEffectSettings Clone() => }; } + /// + /// Segmented Damping (Pedal Feel) — Pit House's "simulate a damping + /// force independent of in-game output, dividing pedal travel into + /// multiple segments with adjustable range and its own natural + /// damping" feature. Reverse-engineered from real Pit House USB + /// captures (see docs/protocol/devices/mbooster.md "Segmented + /// Damping"): ONE wire command (cmdId 0xB7) carries the ENTIRE + /// feature's state — both the "When Pressed" and "When Released" + /// curves — as 10 fields in a fixed order, sent as a whole snapshot + /// on every edit to any one of them (see + /// MozaMBoosterProtocol.BuildSegmentedDampingFrame). Only "When + /// Pressed" has a UI so far; the "*Released" fields are placeholders + /// (Pit House's own factory defaults, reverse-engineered from a + /// recurring untouched baseline across multiple captures) sent as + /// part of every write until "When Released" gets its own UI. + /// -1 = "not yet set / no override", same sentinel convention as + /// EndstopFrontStiffness/NaturalFrictionPct — a fresh profile writes + /// nothing until the user actually drags a divider or segment. + /// + public sealed class MBoosterSegmentedDampingSettings + { + // Two dividers split 0-100% pedal travel into 3 segments. Bounds + // and the 10% minimum gap between them are Pit House's own + // (MBoosterUiConstants.SegDampDivider1Min/Max etc.) — Divider1 and + // Divider2 are independent from their *Released counterparts + // below (confirmed from capture: dragging one pair's dividers + // never changed the other pair's wire field). + public float Divider1Pressed { get; set; } = -1; + public float Divider2Pressed { get; set; } = -1; + // Damping amount (0-100%) applied within each of the 3 segments + // while the pedal is being pressed. + public float Seg1Pressed { get; set; } = -1; + public float Seg2Pressed { get; set; } = -1; + public float Seg3Pressed { get; set; } = -1; + + // "When Released" — same shape, no UI yet (see class summary). + public float Divider1Released { get; set; } = -1; + public float Divider2Released { get; set; } = -1; + public float Seg1Released { get; set; } = -1; + public float Seg2Released { get; set; } = -1; + public float Seg3Released { get; set; } = -1; + + public MBoosterSegmentedDampingSettings Clone() => + new MBoosterSegmentedDampingSettings + { + Divider1Pressed = Divider1Pressed, + Divider2Pressed = Divider2Pressed, + Seg1Pressed = Seg1Pressed, + Seg2Pressed = Seg2Pressed, + Seg3Pressed = Seg3Pressed, + Divider1Released = Divider1Released, + Divider2Released = Divider2Released, + Seg1Released = Seg1Released, + Seg2Released = Seg2Released, + Seg3Released = Seg3Released, + }; + } + /// /// One user-created, formula-driven vibration effect (Experimental — /// see docs/protocol/devices/mbooster.md "Custom Effects"). Unlike the @@ -353,6 +437,7 @@ public interface IMBoosterPedalConfig : IMBoosterEffects float EndstopFrontStiffness { get; set; } float EndstopEndStiffness { get; set; } float NaturalFrictionPct { get; set; } + MBoosterSegmentedDampingSettings SegmentedDamping { get; set; } } /// @@ -387,6 +472,7 @@ public sealed class MBoosterPedalSettings : IMBoosterPedalConfig public float EndstopFrontStiffness { get; set; } = -1; public float EndstopEndStiffness { get; set; } = -1; public float NaturalFrictionPct { get; set; } = -1; + public MBoosterSegmentedDampingSettings SegmentedDamping { get; set; } = new MBoosterSegmentedDampingSettings(); // Per-pedal vibration effects (same defaults as the master's flat fields). public MBoosterEffectSettings Abs { get; set; } = new MBoosterEffectSettings { FrequencyHz = 22 }; @@ -418,6 +504,7 @@ public MBoosterPedalSettings Clone() => EndstopFrontStiffness = EndstopFrontStiffness, EndstopEndStiffness = EndstopEndStiffness, NaturalFrictionPct = NaturalFrictionPct, + SegmentedDamping = SegmentedDamping?.Clone() ?? new MBoosterSegmentedDampingSettings(), Abs = Abs?.Clone() ?? new MBoosterEffectSettings(), Lockup = Lockup?.Clone() ?? new MBoosterEffectSettings(), Threshold = Threshold?.Clone() ?? new MBoosterEffectSettings(), @@ -671,6 +758,11 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig // overwrites whatever value is already on the device. public float NaturalFrictionPct { get; set; } = -1; + // Segmented Damping (Pit House-style) — see + // MBoosterSegmentedDampingSettings and + // docs/protocol/devices/mbooster.md "Segmented Damping". + public MBoosterSegmentedDampingSettings SegmentedDamping { get; set; } = new MBoosterSegmentedDampingSettings(); + // Friendly display label the user can edit (defaults to "mBooster" // with a serial-tail fallback). Survives reconnects with the dict key. public string DisplayName { get; set; } = ""; @@ -710,6 +802,7 @@ public MBoosterDeviceSettings Clone() EndstopFrontStiffness = EndstopFrontStiffness, EndstopEndStiffness = EndstopEndStiffness, NaturalFrictionPct = NaturalFrictionPct, + SegmentedDamping = SegmentedDamping?.Clone() ?? new MBoosterSegmentedDampingSettings(), DisplayName = DisplayName, }; } diff --git a/MozaPlugin.cs b/MozaPlugin.cs index 82c2a62c..5121ed4d 100644 --- a/MozaPlugin.cs +++ b/MozaPlugin.cs @@ -2352,6 +2352,36 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos controller.SendIntWrite("mbooster-brake-friction-1", frictionRaw, dev); wroteAnyCalibration = true; } + // Segmented Damping (both "When Pressed" and "When + // Released" — see cfg.SegmentedDamping). One wire command + // carries the whole feature's state at once, so a fresh + // profile with no override on EITHER side still sends + // nothing here (guarded like every other calibration write + // above); once ANY field on either side is set, the frame + // is filled out using factory defaults for whichever side + // still has no override. + var sd = cfg.SegmentedDamping; + if (sd != null && (sd.Divider1Pressed >= 0 || sd.Divider2Pressed >= 0 + || sd.Seg1Pressed >= 0 || sd.Seg2Pressed >= 0 || sd.Seg3Pressed >= 0 + || sd.Divider1Released >= 0 || sd.Divider2Released >= 0 + || sd.Seg1Released >= 0 || sd.Seg2Released >= 0 || sd.Seg3Released >= 0)) + { + var c = global::MozaPlugin.Devices.MBoosterUiConstants.SegDampSegDefaultPct; + var frame = global::MozaPlugin.Protocol.MozaMBoosterProtocol.BuildSegmentedDampingFrame( + sd.Divider1Pressed >= 0 ? sd.Divider1Pressed : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider1PressedDefaultPct, + sd.Divider2Pressed >= 0 ? sd.Divider2Pressed : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider2PressedDefaultPct, + sd.Divider1Released >= 0 ? sd.Divider1Released : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider1ReleasedDefaultPct, + sd.Divider2Released >= 0 ? sd.Divider2Released : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider2ReleasedDefaultPct, + sd.Seg1Pressed >= 0 ? sd.Seg1Pressed : c, + sd.Seg1Released >= 0 ? sd.Seg1Released : c, + sd.Seg2Pressed >= 0 ? sd.Seg2Pressed : c, + sd.Seg2Released >= 0 ? sd.Seg2Released : c, + sd.Seg3Pressed >= 0 ? sd.Seg3Pressed : c, + sd.Seg3Released >= 0 ? sd.Seg3Released : c, + dev); + controller.SendOneShot(frame); + wroteAnyCalibration = true; + } if (role == global::MozaPlugin.Devices.MBoosterRole.Brake) { if (cfg.SensorOutputRatioPct >= 0) diff --git a/Protocol/MozaMBoosterProtocol.cs b/Protocol/MozaMBoosterProtocol.cs index cd7acedf..4a212303 100644 --- a/Protocol/MozaMBoosterProtocol.cs +++ b/Protocol/MozaMBoosterProtocol.cs @@ -430,6 +430,105 @@ public static double DecodeFrictionPct(int raw) return raw * 100.0 / 65535.0; } + /// Segmented Damping cmdId (0xB7). See . + public const byte CmdSegmentedDamping = 0xb7; + /// Segmented Damping payload length: cmd byte + 10 x 2-byte fields = 21 (0x15). + public const byte SegmentedDampingPayloadLen = 0x15; + + /// + /// Same 0-100% encoding as + /// (raw = round(pct * 65535 / 100)) — kept as its own named + /// pair since it serves a structurally different command + /// (Segmented Damping's fixed 10-field frame vs Natural Friction's + /// prefix+selector commands), matching this file's convention of a + /// dedicated Encode/Decode pair per reverse-engineered feature. + /// + public static int EncodeSegmentedDampingPct(double pct) + { + if (double.IsNaN(pct) || pct <= 0) return 0; + double raw = Math.Round(pct * 65535.0 / 100.0); + if (raw <= 0) return 0; + if (raw >= 0xFFFF) return 0xFFFF; + return (int)raw; + } + + /// Inverse of . + public static double DecodeSegmentedDampingPct(int raw) + { + if (raw <= 0) return 0; + return raw * 100.0 / 65535.0; + } + + /// + /// Build the write frame for Segmented Damping — cmdId 0xB7, + /// reverse-engineered from real Pit House USB captures (see + /// docs/protocol/devices/mbooster.md "Segmented Damping"). ONE + /// fixed 21-byte payload carries the ENTIRE feature's state — + /// both "When Pressed" and "When Released" — as 10 big-endian + /// u16 fields in this exact order: + ///
+        /// 7e  15  24  12   b7  D1PH D1PL D2PH D2PL  D1RH D1RL D2RH D2RL
+        ///                  │   └──┴─Div1Pressed  └──┴─Div2Pressed
+        ///                  │        └──┴─Div1Released    └──┴─Div2Released
+        ///                  └ cmd id (0xb7)
+        ///     S1PH S1PL S1RH S1RL  S2PH S2PL S2RH S2RL  S3PH S3PL S3RH S3RL  CK
+        ///     └──┴─Seg1Pressed └──┴─Seg1Released
+        ///               └──┴─Seg2Pressed └──┴─Seg2Released
+        ///                         └──┴─Seg3Pressed └──┴─Seg3Released
+        /// 
+ /// Every capture write resent the WHOLE frame — including fields + /// unrelated to whatever the user was actually dragging in that + /// capture — confirming this is always a full snapshot, never a + /// partial update. Each field's IDENTITY is independently verified + /// against its own isolated capture's 0/25/50/.../100%-style sweep + /// (e.g. Seg2Pressed's raw values track its capture's 0/22/57/100% + /// points closely — 0x0000/0x3852/0x91ec/0xffff). The two DIVIDER + /// fields per pair (typed values) landed exactly on + /// round(pct*65535/100) every time; the SEGMENT (Y-axis, mouse- + /// dragged) values are consistently within ~1 raw unit of that + /// formula rather than exact — expected, since a drag lands on + /// whatever pixel row the mouse happened to stop at (e.g. ~57.002%), + /// not a clean typed percentage; the filename's round numbers are + /// approximate labels, not exact wire values. All 10 fields share + /// . + ///
+ public static byte[] BuildSegmentedDampingFrame( + double div1Pressed, double div2Pressed, double div1Released, double div2Released, + double seg1Pressed, double seg1Released, + double seg2Pressed, double seg2Released, + double seg3Pressed, double seg3Released, + byte device = DeviceMotor) + { + var frame = new byte[26]; // 7e + len + group + device + 21 payload + checksum + frame[0] = MozaProtocol.MessageStart; + frame[1] = SegmentedDampingPayloadLen; + frame[2] = GroupMotorWrite; + frame[3] = device; + frame[4] = CmdSegmentedDamping; + + ushort[] fields = + { + (ushort)EncodeSegmentedDampingPct(div1Pressed), + (ushort)EncodeSegmentedDampingPct(div2Pressed), + (ushort)EncodeSegmentedDampingPct(div1Released), + (ushort)EncodeSegmentedDampingPct(div2Released), + (ushort)EncodeSegmentedDampingPct(seg1Pressed), + (ushort)EncodeSegmentedDampingPct(seg1Released), + (ushort)EncodeSegmentedDampingPct(seg2Pressed), + (ushort)EncodeSegmentedDampingPct(seg2Released), + (ushort)EncodeSegmentedDampingPct(seg3Pressed), + (ushort)EncodeSegmentedDampingPct(seg3Released), + }; + int off = 5; + foreach (var f in fields) + { + frame[off++] = (byte)(f >> 8); + frame[off++] = (byte)(f & 0xFF); + } + frame[25] = MozaProtocol.CalculateWireChecksum(frame, 25); + return frame; + } + /// /// Pit House Road Texture Intensity/Smoothness encoding — reverse- /// engineered from two real Pit House USB captures, one per diff --git a/Resources/Strings.Designer.cs b/Resources/Strings.Designer.cs index f9aa4eac..b078fb63 100644 --- a/Resources/Strings.Designer.cs +++ b/Resources/Strings.Designer.cs @@ -221,6 +221,11 @@ private static string Get(string key) public static string SliderLabel_EndstopFrontStiffness => Get("SliderLabel_EndstopFrontStiffness"); public static string SliderLabel_EndstopEndStiffness => Get("SliderLabel_EndstopEndStiffness"); public static string Hint_NaturalFrictionPedalFeel => Get("Hint_NaturalFrictionPedalFeel"); + public static string Section_SegmentedDamping => Get("Section_SegmentedDamping"); + public static string Subtitle_SegmentedDamping => Get("Subtitle_SegmentedDamping"); + public static string Hint_SegmentedDampingExperimental => Get("Hint_SegmentedDampingExperimental"); + public static string Section_SegmentedDampingPressed => Get("Section_SegmentedDampingPressed"); + public static string Section_SegmentedDampingReleased => Get("Section_SegmentedDampingReleased"); public static string Section_SimInputMapping => Get("Section_SimInputMapping"); public static string Subtitle_SimInputMapping => Get("Subtitle_SimInputMapping"); public static string SliderLabel_MaxThresholdKg => Get("SliderLabel_MaxThresholdKg"); diff --git a/Resources/Strings.resx b/Resources/Strings.resx index f89bc779..bc216808 100644 --- a/Resources/Strings.resx +++ b/Resources/Strings.resx @@ -161,6 +161,11 @@ Front Limit Stiffness End Limit Stiffness Simulate a frictional force that is independent of the game output. + SEGMENTED DAMPING + Damping force independent of game output, per pedal-travel segment + Simulate a damping force independent of in-game output. Pedal travel is divided into multiple segments, each with an adjustable range and its own natural damping. Drag a divider to resize a segment; drag inside a segment to set its damping amount. + When Pressed + When Released SIM INPUT MAPPING Max Threshold (kg) diff --git a/Themes/Generic.xaml b/Themes/Generic.xaml index ca26e5ee..51dc4559 100644 --- a/Themes/Generic.xaml +++ b/Themes/Generic.xaml @@ -208,6 +208,20 @@ + + + + + + + diff --git a/Themes/MozaTheme.xaml b/Themes/MozaTheme.xaml index d87a7396..26763765 100644 --- a/Themes/MozaTheme.xaml +++ b/Themes/MozaTheme.xaml @@ -1031,6 +1031,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UI/Controls/MozaSegmentedBarEditor.cs b/UI/Controls/MozaSegmentedBarEditor.cs new file mode 100644 index 00000000..4d536918 --- /dev/null +++ b/UI/Controls/MozaSegmentedBarEditor.cs @@ -0,0 +1,441 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace MozaControls +{ + /// + /// X/Y plot for Segmented Damping (mBooster Pedal Feel): two draggable + /// vertical dividers split 0-100% pedal travel (X axis) into 3 segments, + /// each with its own independently draggable damping amount (Y axis, + /// 0-100%). Unlike — a plain dual-thumb + /// slider sharing ONE range — each divider here has its OWN independent + /// [Min,Max] bound (Pit House's own asymmetric bounds: Divider1 + /// 10-80%, Divider2 20-90%), plus a minimum gap between them. See + /// docs/protocol/devices/mbooster.md "Segmented Damping". + /// + public class MozaSegmentedBarEditor : Control + { + static MozaSegmentedBarEditor() + { + DefaultStyleKeyProperty.OverrideMetadata( + typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(typeof(MozaSegmentedBarEditor))); + } + + /// Fires whenever any divider or segment value changes (drag or programmatic set). + public event EventHandler? ValuesChanged; + + // -------- Divider positions (X axis, 0-100%, two-way bindable) -------- + + public static readonly DependencyProperty Divider1Property = + DependencyProperty.Register(nameof(Divider1), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(33.0, + FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).OnValueChanged())); + public double Divider1 { get => (double)GetValue(Divider1Property); set => SetValue(Divider1Property, value); } + + public static readonly DependencyProperty Divider2Property = + DependencyProperty.Register(nameof(Divider2), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(67.0, + FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).OnValueChanged())); + public double Divider2 { get => (double)GetValue(Divider2Property); set => SetValue(Divider2Property, value); } + + // -------- Per-divider independent bounds + minimum gap -------- + + public static readonly DependencyProperty Divider1MinProperty = + DependencyProperty.Register(nameof(Divider1Min), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(10.0, FrameworkPropertyMetadataOptions.AffectsRender)); + public double Divider1Min { get => (double)GetValue(Divider1MinProperty); set => SetValue(Divider1MinProperty, value); } + + public static readonly DependencyProperty Divider1MaxProperty = + DependencyProperty.Register(nameof(Divider1Max), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(80.0, FrameworkPropertyMetadataOptions.AffectsRender)); + public double Divider1Max { get => (double)GetValue(Divider1MaxProperty); set => SetValue(Divider1MaxProperty, value); } + + public static readonly DependencyProperty Divider2MinProperty = + DependencyProperty.Register(nameof(Divider2Min), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(20.0, FrameworkPropertyMetadataOptions.AffectsRender)); + public double Divider2Min { get => (double)GetValue(Divider2MinProperty); set => SetValue(Divider2MinProperty, value); } + + public static readonly DependencyProperty Divider2MaxProperty = + DependencyProperty.Register(nameof(Divider2Max), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(90.0, FrameworkPropertyMetadataOptions.AffectsRender)); + public double Divider2Max { get => (double)GetValue(Divider2MaxProperty); set => SetValue(Divider2MaxProperty, value); } + + /// Minimum allowed gap between Divider1 and Divider2 (the two may never be dragged closer than this). + public static readonly DependencyProperty MinGapProperty = + DependencyProperty.Register(nameof(MinGap), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(10.0, FrameworkPropertyMetadataOptions.AffectsRender)); + public double MinGap { get => (double)GetValue(MinGapProperty); set => SetValue(MinGapProperty, value); } + + // -------- Segment damping values (Y axis, 0-100%, two-way bindable) -------- + + public static readonly DependencyProperty Seg1ValueProperty = + DependencyProperty.Register(nameof(Seg1Value), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(0.0, + FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).OnValueChanged())); + public double Seg1Value { get => (double)GetValue(Seg1ValueProperty); set => SetValue(Seg1ValueProperty, value); } + + public static readonly DependencyProperty Seg2ValueProperty = + DependencyProperty.Register(nameof(Seg2Value), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(0.0, + FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).OnValueChanged())); + public double Seg2Value { get => (double)GetValue(Seg2ValueProperty); set => SetValue(Seg2ValueProperty, value); } + + public static readonly DependencyProperty Seg3ValueProperty = + DependencyProperty.Register(nameof(Seg3Value), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(0.0, + FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).OnValueChanged())); + public double Seg3Value { get => (double)GetValue(Seg3ValueProperty); set => SetValue(Seg3ValueProperty, value); } + + private void OnValueChanged() + { + Recompute(); + ValuesChanged?.Invoke(this, EventArgs.Empty); + } + + // -------- Appearance -------- + + public static readonly DependencyProperty AccentBrushProperty = + DependencyProperty.Register(nameof(AccentBrush), typeof(Brush), typeof(MozaSegmentedBarEditor), + new PropertyMetadata(null)); + public Brush? AccentBrush { get => (Brush?)GetValue(AccentBrushProperty); set => SetValue(AccentBrushProperty, value); } + + /// Horizontal inset (px) on each end of the plot, room for divider handles at the extremes. + public static readonly DependencyProperty EdgePadProperty = + DependencyProperty.Register(nameof(EdgePad), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(22.0, FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).Recompute())); + public double EdgePad { get => (double)GetValue(EdgePadProperty); set => SetValue(EdgePadProperty, value); } + + /// Vertical inset (px) above the plot, room for the divider handle row + 100% bar. + public static readonly DependencyProperty TopPadProperty = + DependencyProperty.Register(nameof(TopPad), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(28.0, FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).Recompute())); + public double TopPad { get => (double)GetValue(TopPadProperty); set => SetValue(TopPadProperty, value); } + + /// Vertical inset (px) below the plot, room for the 0%/100% X-axis labels. + public static readonly DependencyProperty BottomPadProperty = + DependencyProperty.Register(nameof(BottomPad), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(20.0, FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).Recompute())); + public double BottomPad { get => (double)GetValue(BottomPadProperty); set => SetValue(BottomPadProperty, value); } + + /// Divider handle hit-test radius (px) — a click within this X distance of a divider line grabs it instead of the segment underneath. + public static readonly DependencyProperty DividerHitRadiusProperty = + DependencyProperty.Register(nameof(DividerHitRadius), typeof(double), typeof(MozaSegmentedBarEditor), + new PropertyMetadata(12.0)); + public double DividerHitRadius { get => (double)GetValue(DividerHitRadiusProperty); set => SetValue(DividerHitRadiusProperty, value); } + + // -------- Read-only geometry / labels surfaced to the template -------- + + private static readonly DependencyPropertyKey Seg1RectKey = + DependencyProperty.RegisterReadOnly(nameof(Seg1Rect), typeof(Geometry), typeof(MozaSegmentedBarEditor), new PropertyMetadata(null)); + public static readonly DependencyProperty Seg1RectProperty = Seg1RectKey.DependencyProperty; + public Geometry? Seg1Rect => (Geometry?)GetValue(Seg1RectProperty); + + private static readonly DependencyPropertyKey Seg2RectKey = + DependencyProperty.RegisterReadOnly(nameof(Seg2Rect), typeof(Geometry), typeof(MozaSegmentedBarEditor), new PropertyMetadata(null)); + public static readonly DependencyProperty Seg2RectProperty = Seg2RectKey.DependencyProperty; + public Geometry? Seg2Rect => (Geometry?)GetValue(Seg2RectProperty); + + private static readonly DependencyPropertyKey Seg3RectKey = + DependencyProperty.RegisterReadOnly(nameof(Seg3Rect), typeof(Geometry), typeof(MozaSegmentedBarEditor), new PropertyMetadata(null)); + public static readonly DependencyProperty Seg3RectProperty = Seg3RectKey.DependencyProperty; + public Geometry? Seg3Rect => (Geometry?)GetValue(Seg3RectProperty); + + private static readonly DependencyPropertyKey PlotBackgroundRectKey = + DependencyProperty.RegisterReadOnly(nameof(PlotBackgroundRect), typeof(Geometry), typeof(MozaSegmentedBarEditor), new PropertyMetadata(null)); + public static readonly DependencyProperty PlotBackgroundRectProperty = PlotBackgroundRectKey.DependencyProperty; + public Geometry? PlotBackgroundRect => (Geometry?)GetValue(PlotBackgroundRectProperty); + + private static readonly DependencyPropertyKey Divider1XKey = + DependencyProperty.RegisterReadOnly(nameof(Divider1X), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Divider1XProperty = Divider1XKey.DependencyProperty; + public double Divider1X => (double)GetValue(Divider1XProperty); + + private static readonly DependencyPropertyKey Divider2XKey = + DependencyProperty.RegisterReadOnly(nameof(Divider2X), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Divider2XProperty = Divider2XKey.DependencyProperty; + public double Divider2X => (double)GetValue(Divider2XProperty); + + private static readonly DependencyPropertyKey DividerTopKey = + DependencyProperty.RegisterReadOnly(nameof(DividerTop), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty DividerTopProperty = DividerTopKey.DependencyProperty; + public double DividerTop => (double)GetValue(DividerTopProperty); + + private static readonly DependencyPropertyKey DividerBottomKey = + DependencyProperty.RegisterReadOnly(nameof(DividerBottom), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty DividerBottomProperty = DividerBottomKey.DependencyProperty; + public double DividerBottom => (double)GetValue(DividerBottomProperty); + + private static readonly DependencyPropertyKey Divider1LabelKey = + DependencyProperty.RegisterReadOnly(nameof(Divider1Label), typeof(string), typeof(MozaSegmentedBarEditor), new PropertyMetadata("")); + public static readonly DependencyProperty Divider1LabelProperty = Divider1LabelKey.DependencyProperty; + public string Divider1Label => (string)GetValue(Divider1LabelProperty); + + private static readonly DependencyPropertyKey Divider2LabelKey = + DependencyProperty.RegisterReadOnly(nameof(Divider2Label), typeof(string), typeof(MozaSegmentedBarEditor), new PropertyMetadata("")); + public static readonly DependencyProperty Divider2LabelProperty = Divider2LabelKey.DependencyProperty; + public string Divider2Label => (string)GetValue(Divider2LabelProperty); + + private static readonly DependencyPropertyKey Seg1LabelKey = + DependencyProperty.RegisterReadOnly(nameof(Seg1Label), typeof(string), typeof(MozaSegmentedBarEditor), new PropertyMetadata("")); + public static readonly DependencyProperty Seg1LabelProperty = Seg1LabelKey.DependencyProperty; + public string Seg1Label => (string)GetValue(Seg1LabelProperty); + + private static readonly DependencyPropertyKey Seg2LabelKey = + DependencyProperty.RegisterReadOnly(nameof(Seg2Label), typeof(string), typeof(MozaSegmentedBarEditor), new PropertyMetadata("")); + public static readonly DependencyProperty Seg2LabelProperty = Seg2LabelKey.DependencyProperty; + public string Seg2Label => (string)GetValue(Seg2LabelProperty); + + private static readonly DependencyPropertyKey Seg3LabelKey = + DependencyProperty.RegisterReadOnly(nameof(Seg3Label), typeof(string), typeof(MozaSegmentedBarEditor), new PropertyMetadata("")); + public static readonly DependencyProperty Seg3LabelProperty = Seg3LabelKey.DependencyProperty; + public string Seg3Label => (string)GetValue(Seg3LabelProperty); + + // Label/handle sizes — fixed so their Canvas.Left/Top can be pre- + // computed as already-centered top-left positions (same technique + // MozaRangeSlider uses for its thumbs: LowThumbX = centerX - half), + // rather than relying on a WPF RenderTransform to center a + // variable-width TextBlock after the fact. + private const double HandleWidth = 30, HandleHeight = 20; + private const double LabelWidth = 34, LabelHeight = 18; + + private static readonly DependencyPropertyKey Divider1HandleLeftKey = + DependencyProperty.RegisterReadOnly(nameof(Divider1HandleLeft), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Divider1HandleLeftProperty = Divider1HandleLeftKey.DependencyProperty; + public double Divider1HandleLeft => (double)GetValue(Divider1HandleLeftProperty); + + private static readonly DependencyPropertyKey Divider2HandleLeftKey = + DependencyProperty.RegisterReadOnly(nameof(Divider2HandleLeft), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Divider2HandleLeftProperty = Divider2HandleLeftKey.DependencyProperty; + public double Divider2HandleLeft => (double)GetValue(Divider2HandleLeftProperty); + + private static readonly DependencyPropertyKey Seg1LabelLeftKey = + DependencyProperty.RegisterReadOnly(nameof(Seg1LabelLeft), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Seg1LabelLeftProperty = Seg1LabelLeftKey.DependencyProperty; + public double Seg1LabelLeft => (double)GetValue(Seg1LabelLeftProperty); + + private static readonly DependencyPropertyKey Seg2LabelLeftKey = + DependencyProperty.RegisterReadOnly(nameof(Seg2LabelLeft), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Seg2LabelLeftProperty = Seg2LabelLeftKey.DependencyProperty; + public double Seg2LabelLeft => (double)GetValue(Seg2LabelLeftProperty); + + private static readonly DependencyPropertyKey Seg3LabelLeftKey = + DependencyProperty.RegisterReadOnly(nameof(Seg3LabelLeft), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Seg3LabelLeftProperty = Seg3LabelLeftKey.DependencyProperty; + public double Seg3LabelLeft => (double)GetValue(Seg3LabelLeftProperty); + + // Each label floats just above its own bar's current height (like a + // tooltip pinned to the bar top), clamped so it never rises above + // the plot's own top inset. + private static readonly DependencyPropertyKey Seg1LabelTopKey = + DependencyProperty.RegisterReadOnly(nameof(Seg1LabelTop), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Seg1LabelTopProperty = Seg1LabelTopKey.DependencyProperty; + public double Seg1LabelTop => (double)GetValue(Seg1LabelTopProperty); + + private static readonly DependencyPropertyKey Seg2LabelTopKey = + DependencyProperty.RegisterReadOnly(nameof(Seg2LabelTop), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Seg2LabelTopProperty = Seg2LabelTopKey.DependencyProperty; + public double Seg2LabelTop => (double)GetValue(Seg2LabelTopProperty); + + private static readonly DependencyPropertyKey Seg3LabelTopKey = + DependencyProperty.RegisterReadOnly(nameof(Seg3LabelTop), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Seg3LabelTopProperty = Seg3LabelTopKey.DependencyProperty; + public double Seg3LabelTop => (double)GetValue(Seg3LabelTopProperty); + + public override void OnApplyTemplate() + { + base.OnApplyTemplate(); + HookCanvas(); + Recompute(); + } + + protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo) + { + base.OnRenderSizeChanged(sizeInfo); + Recompute(); + } + + // -------- Drag state -------- + // -1 = none, 0 = Divider1, 1 = Divider2, 2/3/4 = Segment 1/2/3. + private int _dragTarget = -1; + private Canvas? _canvas; + + private void HookCanvas() + { + _canvas = GetTemplateChild("PART_Canvas") as Canvas; + if (_canvas != null) + { + _canvas.MouseLeftButtonDown += OnMouseDown; + _canvas.MouseMove += OnMouseMove; + _canvas.MouseLeftButtonUp += OnMouseUp; + _canvas.LostMouseCapture += (_, __) => _dragTarget = -1; + } + } + + private void OnMouseDown(object sender, MouseButtonEventArgs e) + { + if (_canvas == null) return; + var p = e.GetPosition(_canvas); + + double d1x = Divider1X, d2x = Divider2X; + if (Math.Abs(p.X - d1x) <= DividerHitRadius) + { + _dragTarget = 0; + _canvas.CaptureMouse(); + ApplyDividerDrag(0, p.X); + e.Handled = true; + return; + } + if (Math.Abs(p.X - d2x) <= DividerHitRadius) + { + _dragTarget = 1; + _canvas.CaptureMouse(); + ApplyDividerDrag(1, p.X); + e.Handled = true; + return; + } + + double plotLeft = EdgePad, plotRight = Math.Max(plotLeft, ActualWidth - EdgePad); + double plotTop = TopPad, plotBottom = Math.Max(plotTop, ActualHeight - BottomPad); + if (p.X < plotLeft || p.X > plotRight || p.Y < plotTop || p.Y > plotBottom) return; + + int seg = p.X < d1x ? 2 : (p.X < d2x ? 3 : 4); + // Segments only respond once an actual drag starts (no jump on a + // plain click) — capture the mouse here but don't apply a value + // until OnMouseMove sees real movement. + _dragTarget = seg; + _canvas.CaptureMouse(); + e.Handled = true; + } + + private void OnMouseMove(object sender, MouseEventArgs e) + { + if (_dragTarget < 0 || _canvas == null) return; + if (e.LeftButton != MouseButtonState.Pressed) { _dragTarget = -1; _canvas.ReleaseMouseCapture(); return; } + var p = e.GetPosition(_canvas); + if (_dragTarget <= 1) ApplyDividerDrag(_dragTarget, p.X); + else ApplySegmentDrag(_dragTarget - 2, p.Y); + } + + private void OnMouseUp(object sender, MouseButtonEventArgs e) + { + if (_canvas != null && _canvas.IsMouseCaptured) _canvas.ReleaseMouseCapture(); + _dragTarget = -1; + } + + private void ApplyDividerDrag(int which, double x) + { + double plotW = Math.Max(1, ActualWidth - EdgePad - EdgePad); + double frac = (x - EdgePad) / plotW; + frac = Math.Max(0, Math.Min(1, frac)); + double val = frac * 100.0; + + if (which == 0) + { + double lo = Divider1Min; + double hi = Math.Min(Divider1Max, Divider2 - MinGap); + if (hi < lo) hi = lo; + Divider1 = Math.Round(Math.Max(lo, Math.Min(hi, val)), 0); + } + else + { + double lo = Math.Max(Divider2Min, Divider1 + MinGap); + double hi = Divider2Max; + if (hi < lo) hi = lo; + Divider2 = Math.Round(Math.Max(lo, Math.Min(hi, val)), 0); + } + } + + private void ApplySegmentDrag(int segIndex, double y) + { + double plotH = Math.Max(1, ActualHeight - TopPad - BottomPad); + double plotBottom = TopPad + plotH; + double frac = (plotBottom - y) / plotH; + frac = Math.Max(0, Math.Min(1, frac)); + double val = Math.Round(frac * 100.0, 0); + + switch (segIndex) + { + case 0: Seg1Value = val; break; + case 1: Seg2Value = val; break; + case 2: Seg3Value = val; break; + } + } + + private void Recompute() + { + double w = ActualWidth, h = ActualHeight; + if (w <= 0 || h <= 0) return; + + double plotW = Math.Max(1, w - EdgePad - EdgePad); + double plotH = Math.Max(1, h - TopPad - BottomPad); + double plotBottom = TopPad + plotH; + + double d1 = Math.Max(Divider1Min, Math.Min(Divider1Max, Divider1)); + double d2 = Math.Max(Divider2Min, Math.Min(Divider2Max, Divider2)); + double d1x = EdgePad + d1 / 100.0 * plotW; + double d2x = EdgePad + d2 / 100.0 * plotW; + + SetValue(Divider1XKey, d1x); + SetValue(Divider2XKey, d2x); + double handleTop = TopPad - HandleHeight - 2; + SetValue(DividerTopKey, handleTop); + SetValue(DividerBottomKey, plotBottom); + SetValue(Divider1LabelKey, Math.Round(d1) + "%"); + SetValue(Divider2LabelKey, Math.Round(d2) + "%"); + SetValue(Divider1HandleLeftKey, d1x - HandleWidth / 2.0); + SetValue(Divider2HandleLeftKey, d2x - HandleWidth / 2.0); + + double YOf(double pct) + { + double clamped = Math.Max(0, Math.Min(100, pct)); + return plotBottom - clamped / 100.0 * plotH; + } + + double s1v = Math.Max(0, Math.Min(100, Seg1Value)); + double s2v = Math.Max(0, Math.Min(100, Seg2Value)); + double s3v = Math.Max(0, Math.Min(100, Seg3Value)); + + var seg1 = new RectangleGeometry(new Rect(EdgePad, YOf(s1v), Math.Max(0, d1x - EdgePad), Math.Max(0, plotBottom - YOf(s1v)))); + var seg2 = new RectangleGeometry(new Rect(d1x, YOf(s2v), Math.Max(0, d2x - d1x), Math.Max(0, plotBottom - YOf(s2v)))); + var seg3 = new RectangleGeometry(new Rect(d2x, YOf(s3v), Math.Max(0, EdgePad + plotW - d2x), Math.Max(0, plotBottom - YOf(s3v)))); + seg1.Freeze(); seg2.Freeze(); seg3.Freeze(); + SetValue(Seg1RectKey, seg1); + SetValue(Seg2RectKey, seg2); + SetValue(Seg3RectKey, seg3); + + var bg = new RectangleGeometry(new Rect(EdgePad, TopPad, plotW, plotH)); + bg.Freeze(); + SetValue(PlotBackgroundRectKey, bg); + + string fmt = "F0"; + SetValue(Seg1LabelKey, s1v.ToString(fmt, CultureInfo.InvariantCulture) + "%"); + SetValue(Seg2LabelKey, s2v.ToString(fmt, CultureInfo.InvariantCulture) + "%"); + SetValue(Seg3LabelKey, s3v.ToString(fmt, CultureInfo.InvariantCulture) + "%"); + + double seg1CenterX = (EdgePad + d1x) / 2.0; + double seg2CenterX = (d1x + d2x) / 2.0; + double seg3CenterX = (d2x + EdgePad + plotW) / 2.0; + SetValue(Seg1LabelLeftKey, seg1CenterX - LabelWidth / 2.0); + SetValue(Seg2LabelLeftKey, seg2CenterX - LabelWidth / 2.0); + SetValue(Seg3LabelLeftKey, seg3CenterX - LabelWidth / 2.0); + + double LabelTopFor(double barTopY) => Math.Max(TopPad, barTopY - LabelHeight - 4); + SetValue(Seg1LabelTopKey, LabelTopFor(YOf(s1v))); + SetValue(Seg2LabelTopKey, LabelTopFor(YOf(s2v))); + SetValue(Seg3LabelTopKey, LabelTopFor(YOf(s3v))); + } + } +} diff --git a/UI/SettingsControl.xaml b/UI/SettingsControl.xaml index cf405a8f..4112a364 100644 --- a/UI/SettingsControl.xaml +++ b/UI/SettingsControl.xaml @@ -3,6 +3,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:profilescommon="clr-namespace:SimHub.Plugins.ProfilesCommon;assembly=SimHub.Plugins" xmlns:ctrl="clr-namespace:MozaControls" + xmlns:devices="clr-namespace:MozaPlugin.Devices" xmlns:ui="clr-namespace:MozaPlugin.UI" xmlns:res="clr-namespace:MozaPlugin.Resources" Padding="0" @@ -2007,6 +2008,31 @@ + + + + + + + + = 0 ? nf : 0; SetValueText(MBoosterNaturalFrictionValue, MBoosterNaturalFrictionSlider.Value.ToString("F0")); + + var sd = fx?.SegmentedDamping; + MBoosterSegDampPressedPlot.Divider1 = (sd?.Divider1Pressed ?? -1) >= 0 ? sd!.Divider1Pressed : MBoosterUiConstants.SegDampDivider1PressedDefaultPct; + MBoosterSegDampPressedPlot.Divider2 = (sd?.Divider2Pressed ?? -1) >= 0 ? sd!.Divider2Pressed : MBoosterUiConstants.SegDampDivider2PressedDefaultPct; + MBoosterSegDampPressedPlot.Seg1Value = (sd?.Seg1Pressed ?? -1) >= 0 ? sd!.Seg1Pressed : MBoosterUiConstants.SegDampSegDefaultPct; + MBoosterSegDampPressedPlot.Seg2Value = (sd?.Seg2Pressed ?? -1) >= 0 ? sd!.Seg2Pressed : MBoosterUiConstants.SegDampSegDefaultPct; + MBoosterSegDampPressedPlot.Seg3Value = (sd?.Seg3Pressed ?? -1) >= 0 ? sd!.Seg3Pressed : MBoosterUiConstants.SegDampSegDefaultPct; + + MBoosterSegDampReleasedPlot.Divider1 = (sd?.Divider1Released ?? -1) >= 0 ? sd!.Divider1Released : MBoosterUiConstants.SegDampDivider1ReleasedDefaultPct; + MBoosterSegDampReleasedPlot.Divider2 = (sd?.Divider2Released ?? -1) >= 0 ? sd!.Divider2Released : MBoosterUiConstants.SegDampDivider2ReleasedDefaultPct; + MBoosterSegDampReleasedPlot.Seg1Value = (sd?.Seg1Released ?? -1) >= 0 ? sd!.Seg1Released : MBoosterUiConstants.SegDampSegDefaultPct; + MBoosterSegDampReleasedPlot.Seg2Value = (sd?.Seg2Released ?? -1) >= 0 ? sd!.Seg2Released : MBoosterUiConstants.SegDampSegDefaultPct; + MBoosterSegDampReleasedPlot.Seg3Value = (sd?.Seg3Released ?? -1) >= 0 ? sd!.Seg3Released : MBoosterUiConstants.SegDampSegDefaultPct; } private MBoosterDeviceController? CurrentMBoosterController() @@ -4285,6 +4298,82 @@ private void MBoosterNaturalFrictionSlider_ValueChanged(object sender, RoutedPro controller?.PushCurve7Resync(s.CurveX, s.CurveY, dev); }); + // Segmented Damping — "When Pressed". Reverse-engineered from real + // Pit House USB captures (see docs/protocol/devices/mbooster.md + // "Segmented Damping"): a SINGLE wire command (cmdId 0xB7) carries + // the entire feature's state — both "When Pressed" and "When + // Released" — as one 10-field snapshot, so every edit here must + // resend all 10 fields, not just the ones this plot owns. The + // "*Released" fields have no UI yet; they're sent using Pit + // House's own factory defaults (or whatever was last saved) until + // "When Released" gets its own plot. + private void MBoosterSegDampPressedPlot_ValuesChanged(object sender, EventArgs e) + { + if (_suppressEvents) return; + var s = CurrentMBoosterEffectTarget(); + if (s == null) return; + var sd = s.SegmentedDamping ??= new MBoosterSegmentedDampingSettings(); + sd.Divider1Pressed = (float)MBoosterSegDampPressedPlot.Divider1; + sd.Divider2Pressed = (float)MBoosterSegDampPressedPlot.Divider2; + sd.Seg1Pressed = (float)MBoosterSegDampPressedPlot.Seg1Value; + sd.Seg2Pressed = (float)MBoosterSegDampPressedPlot.Seg2Value; + sd.Seg3Pressed = (float)MBoosterSegDampPressedPlot.Seg3Value; + PushSegmentedDamping(s, sd); + } + + // Segmented Damping — "When Released". Same shared wire command as + // "When Pressed" (see that handler and docs/protocol/devices/ + // mbooster.md "Segmented Damping") — every edit here ALSO resends + // the current Pressed fields alongside the updated Released ones, + // since the frame is always a whole-feature snapshot. + private void MBoosterSegDampReleasedPlot_ValuesChanged(object sender, EventArgs e) + { + if (_suppressEvents) return; + var s = CurrentMBoosterEffectTarget(); + if (s == null) return; + var sd = s.SegmentedDamping ??= new MBoosterSegmentedDampingSettings(); + sd.Divider1Released = (float)MBoosterSegDampReleasedPlot.Divider1; + sd.Divider2Released = (float)MBoosterSegDampReleasedPlot.Divider2; + sd.Seg1Released = (float)MBoosterSegDampReleasedPlot.Seg1Value; + sd.Seg2Released = (float)MBoosterSegDampReleasedPlot.Seg2Value; + sd.Seg3Released = (float)MBoosterSegDampReleasedPlot.Seg3Value; + PushSegmentedDamping(s, sd); + } + + /// + /// Save + send the ONE Segmented Damping wire frame (cmdId 0xB7) + /// covering both "When Pressed" and "When Released" — shared by + /// both plots' change handlers since either one touching its own + /// half still has to resend the other half's current values (the + /// wire command has no partial-update form). Not-yet-set fields + /// (-1 sentinel) fall back to Pit House's own factory defaults, same + /// as does on connect. + /// + private void PushSegmentedDamping(IMBoosterPedalConfig s, MBoosterSegmentedDampingSettings sd) + { + _plugin.SaveSettings(); + + var controller = CurrentMBoosterController(); + byte dev = MBoosterCalibDevice(controller, _mboosterEffectPedalIndex); + var frame = global::MozaPlugin.Protocol.MozaMBoosterProtocol.BuildSegmentedDampingFrame( + sd.Divider1Pressed >= 0 ? sd.Divider1Pressed : MBoosterUiConstants.SegDampDivider1PressedDefaultPct, + 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, + dev); + controller?.SendOneShot(frame); + // EXPERIMENTAL / unverified — see MBoosterTravelRangeSlider_RangeChanged + // and MBoosterDeviceController.PushCurve7Resync; untested for this + // control specifically, applied on the same-root-cause theory. + controller?.PushCurve7Resync(s.CurveX, s.CurveY, dev); + } + 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 a23d5725..81f0a70a 100644 --- a/docs/protocol/devices/mbooster.md +++ b/docs/protocol/devices/mbooster.md @@ -872,6 +872,82 @@ when off and restores the last slider value when on. See "not yet set / no override" sentinel convention as `EndstopFrontStiffness`/`EndstopEndStiffness`. +**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 +multiple segments with adjustable range and its own natural damping": an +X/Y plot (`MozaControls.MozaSegmentedBarEditor`, a new control — no +bar-chart control existed anywhere in this app before) where the X axis +is 0-100% pedal travel and the Y axis is 0-100% damping. Two draggable +vertical dividers split the plot into 3 segments, each with its own +independently draggable damping bar. Reverse-engineered from 11 real +Pit House USB captures — 6 for "When Pressed" (2 isolating one divider +drag each, 3 isolating one segment's Y-drag each, 1 toggling the feature +off/on) and 5 for "When Released" (2 divider, 3 segment) — cross-checked +against each other to decode the wire shape (see below). + +All 6 "When Pressed" captures write the **same single command** — cmdId +`0xB7`, group 36 (write, same `GroupMotorWrite` group the vibration +effects use)/35 (read, unused by any capture — this command isn't part +of `RequestCalibrationReads`' fixed read-burst list) — with a **fixed +21-byte payload**: the cmd byte followed by 10 big-endian `u16` fields, +each `raw = round(pct * 65535 / 100)` (`MozaMBoosterProtocol +.EncodeSegmentedDampingPct`/`DecodeSegmentedDampingPct`). Cross-checking +the "When Pressed" captures against the "When Released" ones (which +exist on disk as `pedal-feel-damping-released-*.pcapng`) revealed the +full field order — critically, **every write resends all 10 fields**, +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: + +``` +cmd=0xB7 Div1Pressed Div2Pressed Div1Released Div2Released + Seg1Pressed Seg1Released Seg2Pressed Seg2Released Seg3Pressed Seg3Released +``` + +Each field's identity is proven by which one varies in lockstep with its +own isolated capture's filename sweep — e.g. `pedal-feel-damping- +pressed-segment2-0-22-57-100.pcapng` is the only capture where the +Seg2Pressed field moves, tracking 0/22/57/100% closely. The two DIVIDER +fields per pair land exactly on `round(pct*65535/100)` (typed/exact +values); the SEGMENT (Y-axis, mouse-dragged) fields are only ever within +about 1 raw unit of that formula — expected, since a drag lands on +whatever pixel row the mouse stopped at (e.g. ~57.002%, not a clean +57%), not the filename's rounded label. See +`MozaMBoosterProtocol.BuildSegmentedDampingFrame`. + +This also confirms "When Pressed" and "When Released" are genuinely +**independent** — separate divider pairs, not a shared X axis with two +Y curves — since each side's divider/segment captures never moved the +other side's fields. A recurring, untouched baseline across 5+ +independent capture sessions gives confident factory defaults: Divider1/ +2 Pressed = 33%/67%, Divider1/2 Released = 20%/70% +(`MBoosterUiConstants.SegDampDivider*DefaultPct`); the very first +capture (the toggle test) shows all-zero segment values, so 0% (no +extra damping) is the default there too +(`MBoosterUiConstants.SegDampSegDefaultPct`). + +Divider bounds are Pit House's own and asymmetric per divider — Divider1 +∈ [10%, 80%], Divider2 ∈ [20%, 90%] — with a 10% minimum gap enforced +between them (`MBoosterUiConstants.SegDampDivider1MinPct`/`MaxPct`, +`SegDampDivider2MinPct`/`MaxPct`, `SegDampDividerMinGapPct`), confirmed +directly from the two divider-sweep captures' filenames (e.g. +`divider-one-10-34-60-80` sweeps from its 10% floor up to 80%, its +ceiling). Both "When Pressed" and "When Released" have their own plot +(`MBoosterSegmentedDampingSettings.Divider1Pressed`/`Divider2Pressed`/ +`Seg1Pressed`/`Seg2Pressed`/`Seg3Pressed` wired to +`MBoosterSegDampPressedPlot_ValuesChanged`; the `*Released` counterparts +wired to `MBoosterSegDampReleasedPlot_ValuesChanged`). Since the wire +command has no partial-update form, both handlers funnel through one +shared `SettingsControl.PushSegmentedDamping` that always resends all 10 +fields — editing a divider on the Released plot still re-sends whatever +the Pressed plot currently holds, and vice versa. `-1` = "not yet set / +no override", same sentinel convention as every other Pedal Feel +calibration; a fresh profile writes nothing until the user drags a +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`: From e96eab090c71ccf9f47f04b316fcff69cad31326 Mon Sep 17 00:00:00 2001 From: tacodevhaydz Date: Fri, 31 Jul 2026 11:56:21 +1200 Subject: [PATCH 04/13] locaisation updates --- 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.ru.resx | 5 +++++ Resources/Strings.vi.resx | 5 +++++ Resources/Strings.zh-Hans.resx | 5 +++++ 10 files changed, 50 insertions(+) diff --git a/Resources/Strings.de.resx b/Resources/Strings.de.resx index 6212b018..55dcde4a 100644 --- a/Resources/Strings.de.resx +++ b/Resources/Strings.de.resx @@ -149,6 +149,11 @@ Steifigkeit vorderer Anschlag Steifigkeit hinterer Anschlag Simuliert eine Reibungskraft, die unabhängig von der Spielausgabe ist. + SEGMENTIERTE DÄMPFUNG + Dämpfungskraft unabhängig von der Spielausgabe, pro Pedalweg-Segment + Simuliert eine Dämpfungskraft, die unabhängig von der Spielausgabe ist. Der Pedalweg wird in mehrere Segmente unterteilt, jedes mit einstellbarem Bereich und eigener natürlicher Dämpfung. Ziehen Sie einen Teiler, um ein Segment in der Größe zu ändern; ziehen Sie innerhalb eines Segments, um dessen Dämpfungsstärke einzustellen. + Beim Drücken + Beim Loslassen SIM-EINGABEZUORDNUNG // Lastzellen-/Pedalsignal wie in Pit House auf das Spiel abbilden Max. Schwellenwert (kg) diff --git a/Resources/Strings.el.resx b/Resources/Strings.el.resx index 0ff09bcf..946fea09 100644 --- a/Resources/Strings.el.resx +++ b/Resources/Strings.el.resx @@ -149,6 +149,11 @@ Σκληρότητα εμπρός ορίου Σκληρότητα τελικού ορίου Προσομοιώνει μια δύναμη τριβής ανεξάρτητη από την έξοδο του παιχνιδιού. + ΤΜΗΜΑΤΟΠΟΙΗΜΕΝΗ ΑΠΟΣΒΕΣΗ + Δύναμη απόσβεσης ανεξάρτητη από την έξοδο του παιχνιδιού, ανά τμήμα διαδρομής πεντάλ + Προσομοιώνει μια δύναμη απόσβεσης ανεξάρτητη από την έξοδο του παιχνιδιού εντός του παιχνιδιού. Η διαδρομή του πεντάλ χωρίζεται σε πολλά τμήματα, το καθένα με ρυθμιζόμενο εύρος και τη δική του φυσική απόσβεση. Σύρετε ένα διαχωριστικό για να αλλάξετε το μέγεθος ενός τμήματος· σύρετε μέσα σε ένα τμήμα για να ορίσετε την ποσότητα απόσβεσής του. + Κατά το Πάτημα + Κατά την Απελευθέρωση ΑΝΤΙΣΤΟΙΧΙΣΗ ΕΙΣΟΔΟΥ ΠΡΟΣΟΜΟΙΩΣΗΣ // αντιστοίχιση σήματος κυψέλης φόρτισης/πεντάλ στο παιχνίδι, στυλ Pit House Μέγιστο κατώφλι (kg) diff --git a/Resources/Strings.es.resx b/Resources/Strings.es.resx index 4a5d23da..cccff068 100644 --- a/Resources/Strings.es.resx +++ b/Resources/Strings.es.resx @@ -154,6 +154,11 @@ Rigidez del límite frontal Rigidez del límite final Simula una fuerza de fricción independiente de la salida del juego. + AMORTIGUACIÓN SEGMENTADA + Fuerza de amortiguación independiente de la salida del juego, por segmento de recorrido del pedal + Simula una fuerza de amortiguación independiente de la salida del juego. El recorrido del pedal se divide en varios segmentos, cada uno con un rango ajustable y su propia amortiguación natural. Arrastra un divisor para redimensionar un segmento; arrastra dentro de un segmento para ajustar su cantidad de amortiguación. + Al Presionar + Al Soltar MAPEO DE ENTRADA DE SIMULACIÓN // mapea la señal de la celda de carga/pedal al juego, al estilo Pit House Umbral máximo (kg) diff --git a/Resources/Strings.fr.resx b/Resources/Strings.fr.resx index 1dc1fcb1..0e45d6e0 100644 --- a/Resources/Strings.fr.resx +++ b/Resources/Strings.fr.resx @@ -149,6 +149,11 @@ Rigidité de butée avant Rigidité de butée de fin Simule une force de friction indépendante de la sortie du jeu. + AMORTISSEMENT SEGMENTÉ + Force d'amortissement indépendante de la sortie du jeu, par segment de course de pédale + Simule une force d'amortissement indépendante de la sortie du jeu. La course de la pédale est divisée en plusieurs segments, chacun avec une plage réglable et son propre amortissement naturel. Faites glisser un diviseur pour redimensionner un segment ; faites glisser à l'intérieur d'un segment pour régler sa quantité d'amortissement. + À l'Appui + Au Relâchement MAPPAGE D'ENTRÉE SIM // mappe le signal de la cellule de charge/pédale vers le jeu, à la manière de Pit House Seuil maximal (kg) diff --git a/Resources/Strings.it.resx b/Resources/Strings.it.resx index ad8e060e..ad68027b 100644 --- a/Resources/Strings.it.resx +++ b/Resources/Strings.it.resx @@ -152,6 +152,11 @@ Rigidità limite anteriore Rigidità limite finale Simula una forza d'attrito indipendente dall'output di gioco. + SMORZAMENTO SEGMENTATO + Forza di smorzamento indipendente dall'output di gioco, per segmento di corsa del pedale + Simula una forza di smorzamento indipendente dall'output di gioco. La corsa del pedale è divisa in più segmenti, ciascuno con un intervallo regolabile e il proprio smorzamento naturale. Trascina un divisore per ridimensionare un segmento; trascina all'interno di un segmento per impostarne la quantità di smorzamento. + Alla Pressione + Al Rilascio MAPPATURA INPUT SIM // mappa il segnale della cella di carico/pedale al gioco, in stile Pit House Soglia massima (kg) diff --git a/Resources/Strings.ko.resx b/Resources/Strings.ko.resx index 17c99ddd..3b09db91 100644 --- a/Resources/Strings.ko.resx +++ b/Resources/Strings.ko.resx @@ -149,6 +149,11 @@ 프론트 리밋 강성 엔드 리밋 강성 게임 출력과 무관한 마찰력을 시뮬레이션합니다. + 구간별 댐핑 + 게임 출력과 무관한 댐핑력, 페달 이동 구간별 적용 + 게임 내 출력과 무관한 댐핑력을 시뮬레이션합니다. 페달 이동 구간이 여러 구간으로 나뉘며, 각 구간마다 조절 가능한 범위와 고유한 자연 댐핑이 적용됩니다. 구분선을 드래그하여 구간 크기를 조정하고, 구간 내부를 드래그하여 댐핑량을 설정하세요. + 누를 때 + 뗄 때 시뮬레이션 입력 매핑 // 로드셀/페달 신호를 게임에 매핑 (핏하우스 방식) 최대 임계값 (kg) diff --git a/Resources/Strings.nb.resx b/Resources/Strings.nb.resx index f78a1aea..9e7b4583 100644 --- a/Resources/Strings.nb.resx +++ b/Resources/Strings.nb.resx @@ -154,6 +154,11 @@ Frontgrense-stivhet Sluttgrense-stivhet Simulerer en friksjonskraft som er uavhengig av spillutgangen. + SEGMENTERT DEMPING + Dempekraft uavhengig av spillutgangen, per pedalbevegelse-segment + Simulerer en dempekraft som er uavhengig av spillutgangen. Pedalbevegelsen deles inn i flere segmenter, hver med justerbart område og sin egen naturlige demping. Dra en delelinje for å endre størrelsen på et segment; dra inne i et segment for å angi dempemengden. + Ved Nedtrykking + Ved Utløsning SIM-INNGANGSKARTLEGGING // kartlegger lastcelle-/pedalsignalet til spillet, Pit House-stil Maks terskel (kg) diff --git a/Resources/Strings.ru.resx b/Resources/Strings.ru.resx index 13ab54c3..a154e3a7 100644 --- a/Resources/Strings.ru.resx +++ b/Resources/Strings.ru.resx @@ -149,6 +149,11 @@ Жёсткость переднего упора Жёсткость заднего упора Имитирует силу трения, не зависящую от игрового вывода. + СЕГМЕНТИРОВАННОЕ ДЕМПФИРОВАНИЕ + Сила демпфирования, не зависящая от игрового вывода, по сегментам хода педали + Имитирует силу демпфирования, не зависящую от игрового вывода. Ход педали делится на несколько сегментов, каждый с регулируемым диапазоном и собственным естественным демпфированием. Перетащите разделитель, чтобы изменить размер сегмента; перетащите внутри сегмента, чтобы задать величину демпфирования. + При Нажатии + При Отпускании СОПОСТАВЛЕНИЕ ВВОДА СИМУЛЯТОРА // сопоставление сигнала тензодатчика/педали с игрой, в стиле Pit House Макс. порог (кг) diff --git a/Resources/Strings.vi.resx b/Resources/Strings.vi.resx index cabdb9b6..8f62b944 100644 --- a/Resources/Strings.vi.resx +++ b/Resources/Strings.vi.resx @@ -149,6 +149,11 @@ Độ cứng giới hạn đầu Độ cứng giới hạn cuối Mô phỏng một lực ma sát độc lập với đầu ra của game. + GIẢM CHẤN PHÂN ĐOẠN + Lực giảm chấn độc lập với đầu ra của game, theo từng phân đoạn hành trình bàn đạp + Mô phỏng một lực giảm chấn độc lập với đầu ra trong game. Hành trình bàn đạp được chia thành nhiều phân đoạn, mỗi phân đoạn có phạm vi điều chỉnh riêng và độ giảm chấn tự nhiên riêng. Kéo đường chia để thay đổi kích thước phân đoạn; kéo bên trong phân đoạn để đặt mức giảm chấn. + Khi Đạp + Khi Nhả ÁNH XẠ ĐẦU VÀO SIM // ánh xạ tín hiệu loadcell/bàn đạp vào game, theo kiểu Pit House Ngưỡng tối đa (kg) diff --git a/Resources/Strings.zh-Hans.resx b/Resources/Strings.zh-Hans.resx index 56d1cd92..3e5110e8 100644 --- a/Resources/Strings.zh-Hans.resx +++ b/Resources/Strings.zh-Hans.resx @@ -149,6 +149,11 @@ 前限位硬度 后限位硬度 模拟一种独立于游戏输出的摩擦力。 + 分段阻尼 + 独立于游戏输出的阻尼力,按踏板行程分段设置 + 模拟一种独立于游戏内输出的阻尼力。踏板行程被划分为多个分段,每段都有可调范围和各自的自然阻尼。拖动分隔线可调整分段大小;在分段内拖动可设置该分段的阻尼量。 + 踩下时 + 松开时 模拟输入映射 // 将传感器/踏板信号映射到游戏,Pit House 风格 最大阈值(kg) From 783e3db2a63ed1fa33abb528b80bd0bc85178194 Mon Sep 17 00:00:00 2001 From: tacodevhaydz Date: Mon, 3 Aug 2026 11:49:45 +1200 Subject: [PATCH 05/13] ui tweaks for readability, fixing pedal trace --- Devices/MBoosterDeviceController.cs | 26 +++++++++ Devices/MozaMBoosterRegistry.cs | 21 ++++++-- UI/Controls/MozaCurveEditor.cs | 84 +++++++++++++++++++++-------- UI/SettingsControl.xaml.cs | 32 +++++------ 4 files changed, 121 insertions(+), 42 deletions(-) diff --git a/Devices/MBoosterDeviceController.cs b/Devices/MBoosterDeviceController.cs index c2c7d4d3..6ab5574e 100644 --- a/Devices/MBoosterDeviceController.cs +++ b/Devices/MBoosterDeviceController.cs @@ -251,6 +251,32 @@ public byte MotorDeviceForCurrentAxis(int axisIndex) return isChain ? MotorDeviceForAxis(axisIndex) : MozaProtocol.DeviceMain; } + /// + /// Whether HID axis is a genuinely wired + /// pedal rather than an unused GenericDesktop usage a chain-capable + /// hub's report descriptor always exposes (Rx/Ry/Rz) regardless of how + /// many pedals are actually plugged in. Trusts the parsed "PD Linked" + /// diagnostic () once it arrives; before + /// that, treats every axis as real if + /// already confirmed a multi-motor chain at connect, else assumes only + /// axis 0 is wired. Same convention + /// uses to gate its own per-pedal tick — callers that resolve a HID + /// axis's role (see ) + /// need this too: raw alone can't tell a real + /// chain from a single connected pedal on a chain-capable hub, so + /// using it directly silently overrides that pedal's own configured + /// Role with the axis-order default (Throttle/Brake/Clutch by index). + /// + public bool IsAxisConnected(int axisIndex) + { + var connected = _connectedAxes; + if (connected != null) + return axisIndex < connected.Length && connected[axisIndex]; + if (SubDeviceCount > 1) + return axisIndex < Math.Max(1, AxisCount); + return axisIndex == 0; + } + /// /// The motor device id for a pedal ROLE (0=Throttle,1=Brake,2=Clutch), /// using the calibration-derived chain map (see diff --git a/Devices/MozaMBoosterRegistry.cs b/Devices/MozaMBoosterRegistry.cs index 8b82a17e..cf5a1a4f 100644 --- a/Devices/MozaMBoosterRegistry.cs +++ b/Devices/MozaMBoosterRegistry.cs @@ -695,11 +695,24 @@ private void MergePositions() { var c = _order[i]; var s = _settingsLookup(c.Identity); - int axisCount = c.AxisCount > 0 ? c.AxisCount : 1; - if (axisCount > MBoosterDeviceController.MaxAxes) axisCount = MBoosterDeviceController.MaxAxes; - for (int a = 0; a < axisCount; a++) + int rawAxisCount = c.AxisCount > 0 ? c.AxisCount : 1; + if (rawAxisCount > MBoosterDeviceController.MaxAxes) rawAxisCount = MBoosterDeviceController.MaxAxes; + + // Resolve roles against how many axes are ACTUALLY wired, + // not the raw HID axis count — a chain-capable hub's report + // descriptor exposes all 3 GenericDesktop axes even when + // only one pedal is physically plugged in, so raw AxisCount + // can't tell a real chain from a single connected pedal. + // Getting this wrong silently overrides that pedal's own + // Role with the axis-order default (see IsAxisConnected). + int connectedAxisCount = 0; + for (int a = 0; a < rawAxisCount; a++) + if (c.IsAxisConnected(a)) connectedAxisCount++; + + for (int a = 0; a < rawAxisCount; a++) { - var role = ResolveAxisRole(s, a, axisCount); + if (!c.IsAxisConnected(a)) continue; + var role = ResolveAxisRole(s, a, connectedAxisCount); if (role == MBoosterRole.Disabled) continue; // MozaData position fields are int (0..100, the same scale // the existing HID reader writes). Round explicitly. diff --git a/UI/Controls/MozaCurveEditor.cs b/UI/Controls/MozaCurveEditor.cs index 2435daa7..29753412 100644 --- a/UI/Controls/MozaCurveEditor.cs +++ b/UI/Controls/MozaCurveEditor.cs @@ -689,7 +689,7 @@ private void Recompute() geom.Freeze(); SetValue(CurveGeometryKey, geom); - UpdateLiveMarker(segments, plotW, PadTop + plotH); + UpdateLiveMarker(segments, pts, plotW, PadTop + plotH); // ---- Background grid (4 interior horizontal + 4 vertical lines) ---- // Vertical lines scale with the rightmost node fraction so they @@ -782,41 +782,79 @@ private void Recompute() /// /// Position the live indicator (see ) exactly ON - /// the already-built spline: map the data-space X to a pixel X via - /// the same XAxisLabels/XLabelFractions correspondence used for tick - /// labels, find which segment contains it, then invert that - /// segment's Bezier X(t) via bisection (same approach as + /// 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 - /// pixel X and Y at that point. + /// pixel X and Y at that point — i.e. the dot always sits ON the + /// curve as currently configured, not just sliding horizontally. /// - private void UpdateLiveMarker((Point p1, Point c1, Point c2, Point p2)[] segments, double plotW, double axisBottomY) + private void UpdateLiveMarker((Point p1, Point c1, Point c2, Point p2)[] segments, Point[] nodePts, double plotW, double axisBottomY) { double liveX = LiveX; bool placed = false; if (!double.IsNaN(liveX) && segments.Length > 0) { - double[] fracs = ParseFractions(XLabelFractions, new[] { 0.0, 0.2, 0.4, 0.6, 0.8, 1.0 }); - string[] rawLabels = ParseLabels(XAxisLabels); - int n = Math.Min(fracs.Length, rawLabels.Length); - var values = new double[n]; - bool parsedOk = n >= 2; - for (int i = 0; parsedOk && i < n; i++) - parsedOk = double.TryParse(rawLabels[i], NumberStyles.Float, CultureInfo.InvariantCulture, out values[i]); - - if (parsedOk) + bool haveTarget; + double targetPixelX = 0; + + if (AllowHorizontalDrag && nodePts.Length > 0) { - double clampedX = Math.Max(values[0], Math.Min(values[n - 1], liveX)); - int lo = 0; + // Nodes are user-draggable in X (see ApplyDrag) — the + // fixed XAxisLabels/XLabelFractions correspondence below + // only matches the DEFAULT (undragged) breakpoints, so + // once the user configures a node's X, that mapping no + // longer reflects the actual plotted curve. Map liveX to + // a pixel X from the node's OWN current (dataX, pixelX) + // pairs instead — both axes are affine in a node's own + // 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 }; + 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; for (int i = 0; i < n - 1; i++) { - if (clampedX >= values[i] && clampedX <= values[i + 1]) { lo = i; break; } + if (clampedX >= dataXs[i] && clampedX <= dataXs[i + 1]) + { + x0 = dataXs[i]; px0 = nodePts[i].X; + x1 = dataXs[i + 1]; px1 = nodePts[i + 1].X; + break; + } + } + targetPixelX = x1 > x0 ? px0 + (clampedX - x0) / (x1 - x0) * (px1 - px0) : px0; + haveTarget = true; + } + else + { + double[] fracs = ParseFractions(XLabelFractions, new[] { 0.0, 0.2, 0.4, 0.6, 0.8, 1.0 }); + string[] rawLabels = ParseLabels(XAxisLabels); + int n = Math.Min(fracs.Length, rawLabels.Length); + var values = new double[n]; + bool parsedOk = n >= 2; + for (int i = 0; parsedOk && i < n; i++) + parsedOk = double.TryParse(rawLabels[i], NumberStyles.Float, CultureInfo.InvariantCulture, out values[i]); + + if (parsedOk) + { + double clampedX = Math.Max(values[0], Math.Min(values[n - 1], liveX)); + int lo = 0; + for (int i = 0; i < n - 1; i++) + { + if (clampedX >= values[i] && clampedX <= values[i + 1]) { lo = i; break; } + } + double t0 = values[lo], t1 = values[lo + 1]; + double f0 = fracs[lo], f1 = fracs[lo + 1]; + double frac = t1 > t0 ? f0 + (clampedX - t0) / (t1 - t0) * (f1 - f0) : f0; + targetPixelX = PadLeft + Math.Max(0, Math.Min(1, frac)) * plotW; } - double t0 = values[lo], t1 = values[lo + 1]; - double f0 = fracs[lo], f1 = fracs[lo + 1]; - double frac = t1 > t0 ? f0 + (clampedX - t0) / (t1 - t0) * (f1 - f0) : f0; - double targetPixelX = PadLeft + Math.Max(0, Math.Min(1, frac)) * plotW; + haveTarget = parsedOk; + } + if (haveTarget) + { int segIdx = segments.Length - 1; for (int i = 0; i < segments.Length; i++) { diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs index 770ee32b..7e1b02b4 100644 --- a/UI/SettingsControl.xaml.cs +++ b/UI/SettingsControl.xaml.cs @@ -2731,26 +2731,18 @@ private void RefreshMBoosterTab() { var rowSettings = _plugin.GetOrCreateMBoosterSettings(c.Identity); int axisCount = c.AxisCount > 0 ? c.AxisCount : 1; - var connected = c.ConnectedAxes; string deviceLabel = BuildMBoosterComboLabel(c); // Which axes are ACTUALLY wired. The HID interface // commonly reports 3 axes (Rx/Ry/Rz) regardless of how - // many pedals are physically connected — ConnectedAxes - // (from the "PD Linked" firmware diagnostic) is the - // only way to tell which are real. Until that - // diagnostic arrives (null), assume only axis 0 is - // real: the common case is a standalone single pedal, - // and a genuine chain's extra axes appear as soon as - // the diagnostic confirms them, instead of showing - // phantom pedals from the very first refresh. + // many pedals are physically connected — IsAxisConnected + // (from the "PD Linked" firmware diagnostic, or the + // SubDeviceCount/axis-0 fallback before it arrives) is + // the only way to tell which are real. var connectedAxes = new List(); for (int axis = 0; axis < axisCount && axis < MBoosterDeviceController.MaxAxes; axis++) { - bool axisKnownConnected = connected != null && axis < connected.Length - ? connected[axis] - : axis == 0; - if (axisKnownConnected) connectedAxes.Add(axis); + if (c.IsAxisConnected(axis)) connectedAxes.Add(axis); } // Only label rows "— Pedal N" when this device genuinely @@ -2764,7 +2756,12 @@ private void RefreshMBoosterTab() string label = multiplePedals ? $"{deviceLabel} — {string.Format(Strings.Label_PedalAxis, shown)}" : deviceLabel; - var role = global::MozaPlugin.Devices.MozaMBoosterRegistry.ResolveAxisRole(rowSettings, axis, axisCount); + // Resolve against the CONNECTED axis count, not the + // raw HID axis count — a chain-capable hub exposes + // all 3 GenericDesktop axes even with only one pedal + // plugged in, so raw axisCount would silently override + // that pedal's own Role with the axis-order default. + var role = global::MozaPlugin.Devices.MozaMBoosterRegistry.ResolveAxisRole(rowSettings, axis, connectedAxes.Count); bool isSelected = string.Equals(c.Identity, _mboosterSelectedIdentity, StringComparison.OrdinalIgnoreCase) && axis == _mboosterEffectPedalIndex; _mboosterDeviceRows.Add(new MBoosterDeviceRow(c.Identity, axis, label, isSelected, role, @@ -2780,7 +2777,12 @@ private void RefreshMBoosterTab() var rowController = registry.FindByIdentity(row.Identity); var rowSettings = _plugin.GetOrCreateMBoosterSettings(row.Identity); int axisCount = rowController != null && rowController.AxisCount > 0 ? rowController.AxisCount : 1; - row.RoleIndex = (int)global::MozaPlugin.Devices.MozaMBoosterRegistry.ResolveAxisRole(rowSettings, row.AxisIndex, axisCount); + if (axisCount > MBoosterDeviceController.MaxAxes) axisCount = MBoosterDeviceController.MaxAxes; + int connectedAxisCount = 0; + if (rowController != null) + for (int axis = 0; axis < axisCount; axis++) + if (rowController.IsAxisConnected(axis)) connectedAxisCount++; + row.RoleIndex = (int)global::MozaPlugin.Devices.MozaMBoosterRegistry.ResolveAxisRole(rowSettings, row.AxisIndex, connectedAxisCount); row.IsSelected = string.Equals(row.Identity, _mboosterSelectedIdentity, StringComparison.OrdinalIgnoreCase) && row.AxisIndex == _mboosterEffectPedalIndex; // DisplayName is per-profile like every other mBooster From 50a05677721812fbd679cf500759e79ba5799d6a Mon Sep 17 00:00:00 2001 From: tacodevhaydz Date: Fri, 7 Aug 2026 13:30:02 +1200 Subject: [PATCH 06/13] fixing wrong comment --- Devices/MBoosterTypes.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Devices/MBoosterTypes.cs b/Devices/MBoosterTypes.cs index 3dee524b..4c6f8b1e 100644 --- a/Devices/MBoosterTypes.cs +++ b/Devices/MBoosterTypes.cs @@ -282,11 +282,11 @@ public MBoosterEffectSettings Clone() => /// feature's state — both the "When Pressed" and "When Released" /// curves — as 10 fields in a fixed order, sent as a whole snapshot /// on every edit to any one of them (see - /// MozaMBoosterProtocol.BuildSegmentedDampingFrame). Only "When - /// Pressed" has a UI so far; the "*Released" fields are placeholders - /// (Pit House's own factory defaults, reverse-engineered from a - /// recurring untouched baseline across multiple captures) sent as - /// part of every write until "When Released" gets its own UI. + /// MozaMBoosterProtocol.BuildSegmentedDampingFrame). Both "When + /// Pressed" and "When Released" have their own UI plot; unset fields + /// fall back to Pit House's own factory defaults (reverse-engineered + /// from a recurring untouched baseline across multiple captures) + /// until the user actually edits them. /// -1 = "not yet set / no override", same sentinel convention as /// EndstopFrontStiffness/NaturalFrictionPct — a fresh profile writes /// nothing until the user actually drags a divider or segment. @@ -307,7 +307,7 @@ public sealed class MBoosterSegmentedDampingSettings public float Seg2Pressed { get; set; } = -1; public float Seg3Pressed { get; set; } = -1; - // "When Released" — same shape, no UI yet (see class summary). + // "When Released" — same shape, own UI plot (see class summary). public float Divider1Released { get; set; } = -1; public float Divider2Released { get; set; } = -1; public float Seg1Released { get; set; } = -1; From d113d415b5f5c146b3d80e66dbd696b123638022 Mon Sep 17 00:00:00 2001 From: tacodevhaydz Date: Sun, 9 Aug 2026 13:00:27 +1200 Subject: [PATCH 07/13] fix ui bug --- Devices/MBoosterTypes.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Devices/MBoosterTypes.cs b/Devices/MBoosterTypes.cs index 4c6f8b1e..3320edee 100644 --- a/Devices/MBoosterTypes.cs +++ b/Devices/MBoosterTypes.cs @@ -117,11 +117,11 @@ public static class MBoosterUiConstants // has its own independent pair of dividers, same bounds). See // MozaControls.MozaSegmentedBarEditor and // docs/protocol/devices/mbooster.md "Segmented Damping". - public const float SegDampDivider1MinPct = 10f; - public const float SegDampDivider1MaxPct = 80f; - public const float SegDampDivider2MinPct = 20f; - public const float SegDampDivider2MaxPct = 90f; - public const float SegDampDividerMinGapPct = 10f; + public const double SegDampDivider1MinPct = 10.0; + public const double SegDampDivider1MaxPct = 80.0; + public const double SegDampDivider2MinPct = 20.0; + public const double SegDampDivider2MaxPct = 90.0; + public const double SegDampDividerMinGapPct = 10.0; // Factory defaults, reverse-engineered from a recurring untouched // baseline across multiple independent captures (5+ sessions each From fa88411538de1ce965829f5aff3d5f93629b6191 Mon Sep 17 00:00:00 2001 From: tacodevhaydz Date: Mon, 10 Aug 2026 10:12:37 +1200 Subject: [PATCH 08/13] added trend line to UI --- Themes/MozaTheme.xaml | 9 +++++++++ UI/Controls/MozaSegmentedBarEditor.cs | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/Themes/MozaTheme.xaml b/Themes/MozaTheme.xaml index 26763765..7836d444 100644 --- a/Themes/MozaTheme.xaml +++ b/Themes/MozaTheme.xaml @@ -1058,6 +1058,15 @@ Data="{Binding Seg2Rect, RelativeSource={RelativeSource TemplatedParent}}"/> + + + + + + + diff --git a/UI/Import/PitHouseImportControl.xaml.cs b/UI/Import/PitHouseImportControl.xaml.cs index 8a638ab5..f863e74d 100644 --- a/UI/Import/PitHouseImportControl.xaml.cs +++ b/UI/Import/PitHouseImportControl.xaml.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Input; @@ -22,6 +23,12 @@ public partial class PitHouseImportControl : UserControl private MozaPlugin? _plugin; private string? _customPathOverride; + // Pedals path only: the attached pedals the preset can be applied to, + // snapshotted when the preset is loaded so a device arriving mid-confirm + // can't shift the combo out from under the user's selection. + private IReadOnlyList _pedalControllers = Array.Empty(); + private bool _suppressTargetChange; + // Selected preset + built plan, populated when Next is clicked. public PitHousePreset? SelectedPreset { get; private set; } public ImportPlan? Plan { get; private set; } @@ -172,7 +179,6 @@ private void LoadPresetAndConfirm(string path) return; } - ImportPlan plan; if (string.Equals(preset.DeviceType, "Motor", StringComparison.OrdinalIgnoreCase)) { var profile = _plugin?.Settings?.ProfileStore?.CurrentProfile; @@ -184,14 +190,21 @@ private void LoadPresetAndConfirm(string path) MessageBoxButton.OK, MessageBoxImage.Error); return; } - plan = PitHouseMotorMapper.BuildPlan(preset, profile); + SelectedPreset = preset; + Plan = PitHouseMotorMapper.BuildPlan(preset, profile); + _pedalControllers = Array.Empty(); + PopulatePedalTargets(null); } else if (string.Equals(preset.DeviceType, "Pedals", StringComparison.OrdinalIgnoreCase)) { - var registry = _plugin?.MBoosterRegistry; - IReadOnlyList controllers = - registry?.Devices ?? Array.Empty(); - plan = PitHousePedalsMapper.BuildPlan(preset, controllers); + SelectedPreset = preset; + _pedalControllers = _plugin?.MBoosterRegistry?.Devices + ?? (IReadOnlyList)Array.Empty(); + // First build with no override so the mapper picks the pedal + // carrying the preset's subject role; the combo then preselects + // whatever it resolved to. + Plan = PitHousePedalsMapper.BuildPlan(preset, _pedalControllers); + PopulatePedalTargets(Plan); } else { @@ -202,9 +215,12 @@ private void LoadPresetAndConfirm(string path) return; } - SelectedPreset = preset; - Plan = plan; + LogPlan(preset, Plan); + ShowConfirmPanel(); + } + private static void LogPlan(PitHousePreset preset, ImportPlan plan) + { // Debug — surface what BuildPlan produced so we can diagnose the // "empty Changes container" case from logs. Logs to SimHub.txt. int changedCount = 0; @@ -212,6 +228,7 @@ private void LoadPresetAndConfirm(string path) MozaLog.Info( $"[AZOM/Import] BuildPlan '{preset.Name}' type={preset.DeviceType}: " + $"dp.Count={preset.DeviceParams.Count} " + + $"subject='{plan.SubjectRoleDisplay ?? "-"}' target='{plan.ResolvedTarget?.Label ?? "-"}' " + $"diffs={plan.Diffs.Count} changed={changedCount} " + $"notImported={plan.NotImported.Count} " + $"fatal='{plan.FatalError ?? ""}'"); @@ -220,7 +237,49 @@ private void LoadPresetAndConfirm(string path) var d = plan.Diffs[i]; MozaLog.Info($"[AZOM/Import] diff[{i}] {d.Label}: '{d.OldDisplay}' -> '{d.NewDisplay}' changed={d.Changed}"); } + } + /// + /// Fill the "Apply to" combo with every attached pedal and preselect the + /// one resolved to. Passing null (Motor path) + /// clears and hides the row. Runs under + /// so repopulating never re-enters the rebuild. + /// + private void PopulatePedalTargets(ImportPlan? plan) + { + _suppressTargetChange = true; + try + { + if (plan == null) + { + TargetPedalCombo.ItemsSource = null; + return; + } + + var targets = PitHousePedalsMapper.EnumerateTargets(_pedalControllers); + TargetPedalCombo.ItemsSource = targets; + // BuildPlan enumerated its own targets, so the plan's instance + // is never one of these — match on (controller, axis) instead. + var resolved = plan.ResolvedTarget; + if (resolved != null) + { + TargetPedalCombo.SelectedItem = targets.FirstOrDefault(t => + ReferenceEquals(t.Controller, resolved.Controller) && t.AxisIndex == resolved.AxisIndex); + } + // No resolved target (no attached pedal carries the subject + // role) leaves the combo unselected so the user picks one. + } + finally { _suppressTargetChange = false; } + } + + private void TargetPedalCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressTargetChange) return; + if (SelectedPreset == null) return; + if (!(TargetPedalCombo.SelectedItem is MBoosterImportTarget target)) return; + + Plan = PitHousePedalsMapper.BuildPlan(SelectedPreset, _pedalControllers, target); + LogPlan(SelectedPreset, Plan); ShowConfirmPanel(); } @@ -235,6 +294,22 @@ private void ShowConfirmPanel() ConfirmPresetText.Text = SelectedPreset.Name; ConfirmProfileText.Text = profileName; + // Pedals only: which role the preset configures, and where it lands. + // A PitHouse preset carries all three role sections but fills in + // only its own — the other two are the device-wide snapshot, so the + // header has to say which one is actually being imported. + bool hasSubject = !string.IsNullOrEmpty(Plan.SubjectRoleDisplay); + SubjectRoleLabel.Visibility = hasSubject ? Visibility.Visible : Visibility.Collapsed; + SubjectRoleText.Visibility = hasSubject ? Visibility.Visible : Visibility.Collapsed; + SubjectRoleText.Text = Plan.SubjectRoleDisplay ?? ""; + + // Retargeting only means something when one section drives one + // pedal — a calibration-only preset already covers every role. + bool canRetarget = hasSubject && !Plan.AutoMatchedPerRole + && TargetPedalCombo.Items.Count > 0; + ApplyToLabel.Visibility = canRetarget ? Visibility.Visible : Visibility.Collapsed; + TargetPedalCombo.Visibility = canRetarget ? Visibility.Visible : Visibility.Collapsed; + // Show the full diff list (changed AND unchanged) so the user can // see the complete mapping. The DataTemplate dims unchanged rows // so the actual changes still stand out. Counts feed the footer @@ -296,6 +371,8 @@ private void Back_Click(object sender, RoutedEventArgs e) { SelectedPreset = null; Plan = null; + _pedalControllers = Array.Empty(); + PopulatePedalTargets(null); ConfirmPanel.Visibility = Visibility.Collapsed; PickerPanel.Visibility = Visibility.Visible; diff --git a/UI/Import/PitHousePedalsMapper.cs b/UI/Import/PitHousePedalsMapper.cs index b9771f63..5ffd1d28 100644 --- a/UI/Import/PitHousePedalsMapper.cs +++ b/UI/Import/PitHousePedalsMapper.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using Newtonsoft.Json.Linq; using MozaPlugin.Devices; @@ -7,88 +8,329 @@ namespace MozaPlugin.UI.Import { /// - /// Maps a PitHouse Pedals preset (mBooster-only — non-mBooster pedal - /// presets have no calibration surface the plugin exposes) onto the - /// attached mBooster controllers' . + /// One pedal a Pedals preset can be imported into — a (controller, HID axis) + /// pair, since a chained mBooster hosts several pedals on one lane. Doubles + /// as the import wizard's "Apply to" combo item ( is + /// the display text). + /// + public sealed class MBoosterImportTarget + { + public MBoosterDeviceController Controller { get; } + public int AxisIndex { get; } + public MBoosterRole Role { get; } + /// Device+pedal text for the combo, e.g. "front-brake — a1b2c3d4 (COM7) — Pedal 2 · Brake". + public string Label { get; } + /// Short "Brake — front-brake" form used to prefix diff rows. + public string RowPrefix { get; } + /// Passive pedal (no motor, e.g. a CRP2) — vibration effects don't apply. + public bool IsPassive { get; } + + public MBoosterImportTarget(MBoosterDeviceController controller, int axisIndex, + MBoosterRole role, string label, string rowPrefix, bool isPassive) + { + Controller = controller; + AxisIndex = axisIndex; + Role = role; + Label = label ?? ""; + RowPrefix = rowPrefix ?? ""; + IsPassive = isPassive; + } + + public override string ToString() => Label; + } + + /// + /// Maps a PitHouse Pedals preset (mBooster-only — non-mBooster pedal presets + /// have no calibration surface the plugin exposes) onto ONE attached pedal's + /// . /// - /// A single PitHouse preset file can carry calibration sections for any - /// of throttle/brake/clutch — sometimes one role is "real" (the preset's - /// theme) and the others are just defaults, sometimes the preset is a - /// full three-pedal reset. We treat each role independently: if its - /// section is populated (any of outdir/min/max/nonlinear1..5 present) - /// AND there is at least one attached mBooster carrying that role, we - /// emit diffs for that pairing. Sections without a matching attached - /// device are surfaced under "Not imported" so the user knows what was - /// dropped. + /// PitHouse writes a preset per pedal role, but the file still carries all + /// three throttle_/brake_/clutch_ sections: only the + /// preset's own role gets the extended block (effects, travel limits, force + /// curves), the other two hold just the device-wide + /// _channlRoleType/_min/_max/_nonlinear1..5/_outdir snapshot. Importing + /// all three rewrote the other two pedals' calibration from what is really + /// filler, so the mapper picks the SUBJECT role — the section carrying any + /// non-generic key — and imports only that, into the one pedal the user + /// targets. See docs/protocol/devices/mbooster.md "PitHouse Pedals preset + /// format". /// public static class PitHousePedalsMapper { + // PitHouse role prefixes, in throttle/brake/clutch order. + private static readonly (string Prefix, MBoosterRole Role, string Label)[] Roles = + { + ("throttle", MBoosterRole.Throttle, "Throttle"), + ("brake", MBoosterRole.Brake, "Brake"), + ("clutch", MBoosterRole.Clutch, "Clutch"), + }; + + // Keys every section carries regardless of which pedal the preset is + // for — presence of these alone does NOT make a section the subject. + private static readonly HashSet GenericSuffixes = new HashSet(StringComparer.Ordinal) + { + "channlRoleType", "outdir", "min", "max", + "nonlinear1", "nonlinear2", "nonlinear3", "nonlinear4", "nonlinear5", + "press_combine", + }; + + // ------------------------------------------------------------ + // Target enumeration (also feeds the wizard's "Apply to" combo) + // ------------------------------------------------------------ + /// - /// Build the apply plan. should be the - /// live list of detected mBoosters from - /// MozaPlugin.MBoosterRegistry.Devices. Each controller's - /// is read for - /// the "before" half of each diff. + /// Every pedal an import could land on: one entry per wired HID axis of + /// every attached mBooster. Uses the same + /// + + /// pair the mBooster + /// tab's row list uses, so both show the same pedals with the same roles + /// — including chained lanes, whose per-axis roles live in + /// rather than the legacy + /// flat Role. + /// + public static List EnumerateTargets( + IReadOnlyList? controllers) + { + var targets = new List(); + if (controllers == null) return targets; + + foreach (var c in controllers) + { + if (c == null) continue; + var s = c.CurrentSettings; + if (s == null) continue; + + int axisCount = c.AxisCount > 0 ? c.AxisCount : 1; + var axes = c.ConnectedAxisIndices(); + var types = c.AxisTypes; + + string deviceLabel = string.IsNullOrWhiteSpace(s.DisplayName) + ? $"{MBoosterDeviceController.ShortIdentity(c.Identity)} ({c.PortName})" + : $"{s.DisplayName} — {MBoosterDeviceController.ShortIdentity(c.Identity)} ({c.PortName})"; + + bool multiplePedals = axes.Count > 1; + int shown = 0; + foreach (int axis in axes) + { + ++shown; + var role = MozaMBoosterRegistry.ResolveAxisRole(s, axis, axisCount); + string pedalPart = multiplePedals + ? $"{deviceLabel} — {string.Format(global::MozaPlugin.Resources.Strings.Label_PedalAxis, shown)}" + : deviceLabel; + bool passive = types != null && axis < types.Length && types[axis] == 2; + + // Row prefix leads with the role so a diff list reads + // "Brake · ABS"; the device name disambiguates two pedals + // that somehow share a role. + string rowPrefix = RoleName(role); + if (!string.IsNullOrWhiteSpace(s.DisplayName)) rowPrefix += " — " + s.DisplayName; + else if (multiplePedals) rowPrefix += $" — {string.Format(global::MozaPlugin.Resources.Strings.Label_PedalAxis, shown)}"; + + targets.Add(new MBoosterImportTarget( + c, axis, role, $"{pedalPart} · {RoleName(role)}", rowPrefix, passive)); + } + } + return targets; + } + + private static string RoleName(MBoosterRole role) + { + switch (role) + { + case MBoosterRole.Throttle: return "Throttle"; + case MBoosterRole.Brake: return "Brake"; + case MBoosterRole.Clutch: return "Clutch"; + default: return "Disabled"; + } + } + + // ------------------------------------------------------------ + // Subject-role detection + // ------------------------------------------------------------ + + /// + /// The role prefixes this preset actually configures. Normally exactly + /// one — the section carrying any key outside + /// . A preset with no extended block + /// anywhere (a plain calibration snapshot) has no discernible subject; + /// every populated section is returned instead, and + /// is set so the caller can auto-match + /// each section to its own pedal rather than asking the user to pick one. + /// + public static List DetectSubjectPrefixes(JObject? dp, out bool isCalibrationOnly) + { + isCalibrationOnly = false; + var subjects = new List(); + if (dp == null) return subjects; + + foreach (var (prefix, _, _) in Roles) + if (HasNonGenericKey(dp, prefix)) subjects.Add(prefix); + + if (subjects.Count > 0) return subjects; + + isCalibrationOnly = true; + foreach (var (prefix, _, _) in Roles) + if (HasAnyPopulatedKey(dp, prefix)) subjects.Add(prefix); + return subjects; + } + + private static bool HasNonGenericKey(JObject dp, string prefix) + { + string p = prefix + "_"; + foreach (var prop in dp.Properties()) + { + if (!prop.Name.StartsWith(p, StringComparison.Ordinal)) continue; + if (prop.Value == null || prop.Value.Type == JTokenType.Null) continue; + if (!GenericSuffixes.Contains(prop.Name.Substring(p.Length))) return true; + } + return false; + } + + private static bool HasAnyPopulatedKey(JObject dp, string prefix) + { + string p = prefix + "_"; + foreach (var prop in dp.Properties()) + { + if (!prop.Name.StartsWith(p, StringComparison.Ordinal)) continue; + if (prop.Value == null || prop.Value.Type == JTokenType.Null) continue; + if (string.Equals(prop.Name, p + "channlRoleType", StringComparison.Ordinal)) continue; + return true; + } + return false; + } + + // ------------------------------------------------------------ + // Plan building + // ------------------------------------------------------------ + + /// + /// Build the apply plan. retargets the + /// import onto a pedal the user picked in the wizard; when null the + /// subject role's own pedal is used (the first attached pedal whose + /// resolved role matches). A calibration-only preset ignores the override + /// and auto-matches each populated section to its own pedal. /// public static ImportPlan BuildPlan( PitHousePreset preset, - IReadOnlyList controllers) + IReadOnlyList? controllers, + MBoosterImportTarget? targetOverride = null) { var plan = new ImportPlan(); if (preset == null) { plan.FatalError = "internal: null preset"; return plan; } - controllers ??= Array.Empty(); var dp = preset.DeviceParams; + if (dp == null) { plan.FatalError = "preset has no deviceParams block"; return plan; } + + var targets = EnumerateTargets(controllers); + var subjects = DetectSubjectPrefixes(dp, out bool calibrationOnly); - // Map PitHouse role prefix to MBoosterRole. The trailing "" entry - // doubles as a label for the diff rows. - var roles = new (string Prefix, MBoosterRole Role, string Label)[] + plan.ConsideredKeys.Add("version"); + + if (subjects.Count == 0) { - ("throttle", MBoosterRole.Throttle, "Throttle"), - ("brake", MBoosterRole.Brake, "Brake"), - ("clutch", MBoosterRole.Clutch, "Clutch"), - }; + plan.SubjectRoleDisplay = "(none)"; + PitHouseMotorMapper.SweepUnhandled(plan, dp); + return plan; + } - foreach (var (prefix, role, label) in roles) + string subjectLabels = string.Join(" + ", + subjects.Select(p => Roles.First(r => r.Prefix == p).Label)); + + // Retargeting only makes sense when one section drives one pedal. + // A calibration-only preset (no subject at all) and the rare preset + // with extended blocks under two roles both auto-match each section + // to the pedal carrying that role instead. + bool autoMatchPerRole = calibrationOnly || subjects.Count > 1; + plan.AutoMatchedPerRole = autoMatchPerRole; + plan.SubjectRoleDisplay = calibrationOnly + ? "all pedals (per role)" + : subjectLabels + (autoMatchPerRole ? " (per role)" : ""); + + // Pair each subject section with the pedal it writes to. + var pairs = new List<(string Prefix, string RoleLabel, MBoosterImportTarget Target)>(); + foreach (var (prefix, role, roleLabel) in Roles) { - // _channlRoleType is the role marker — register it considered - // whether or not the section has calibration data. plan.ConsideredKeys.Add(prefix + "_channlRoleType"); - if (!IsRoleSectionPopulated(dp, prefix)) continue; - - var matching = controllers.Where(c => + if (!subjects.Contains(prefix)) { - var s = c.CurrentSettings; - return s != null && s.Role == role; - }).ToList(); + // Not this preset's subject — the section is the device-wide + // snapshot PitHouse writes into every preset. Importing it + // would rewrite a pedal the user isn't configuring. + if (HasAnyPopulatedKey(dp, prefix)) + { + MarkRoleSectionConsidered(plan, dp, prefix); + plan.NotImported.Add($"{prefix}_* (not this preset's role)"); + } + continue; + } + + var target = autoMatchPerRole + ? targets.FirstOrDefault(t => t.Role == role) + : targetOverride ?? targets.FirstOrDefault(t => t.Role == role); - if (matching.Count == 0) + if (target == null) { - plan.NotImported.Add($"{label}: no mBooster currently attached with this role"); - // Still mark this role's keys considered so the sweep - // doesn't double-list them per-key below. MarkRoleSectionConsidered(plan, dp, prefix); + plan.NotImported.Add(targets.Count == 0 + ? $"{roleLabel}: no mBooster detected" + : $"{roleLabel}: no pedal with this role — pick one above"); continue; } - foreach (var controller in matching) - AddPerControllerDiffs(plan, dp, prefix, label, controller); + pairs.Add((prefix, roleLabel, target)); + if (!autoMatchPerRole) plan.ResolvedTarget = target; } + foreach (var (prefix, _, target) in pairs) + AddSectionDiffs(plan, dp, prefix, target); + + // Un-prefixed device-wide duplicates of the per-pedal keys. PitHouse + // writes both; the per-pedal ones win because they say which pedal + // they belong to. + foreach (var key in DeviceWideDuplicates) + PitHouseMotorMapper.AddSkipped(plan, dp, key, "device-wide copy"); + foreach (var (key, reason) in UnsupportedGlobals) + PitHouseMotorMapper.AddSkipped(plan, dp, key, reason); + // Catch-all: every deviceParams key the mapper hasn't touched gets // surfaced in Not Imported with its value, so no PitHouse setting // silently disappears. Reuses the motor mapper's sweep helper. PitHouseMotorMapper.SweepUnhandled(plan, dp); + // One hardware push per touched device, after the whole plan is + // built — adding per-diff would re-push a device whose every row is + // a no-op. + if (plan.HasChanges) + foreach (var (_, _, target) in pairs) + plan.TouchedMBoosters.Add(target.Controller); + return plan; } + // Un-prefixed keys that repeat a per-pedal value. + private static readonly string[] DeviceWideDuplicates = + { + "machinelimit_min", "machinelimit_max", + "softlimit_hardness_press", "softlimit_hardness_release", + "damping_press", "damping_release", + "friction_press", "friction_release", + "forcelimit_min", + }; + + // Un-prefixed keys with no plugin surface at all. + private static readonly (string Key, string Reason)[] UnsupportedGlobals = + { + ("force_max_coef", "no wire command"), + ("pressure_weight", "no wire command"), + ("enter_sleep_time", "no wire command"), + ("game_mode", "PitHouse-only"), + }; + /// /// Mark every <prefix>_* key in as /// considered, so the catch-all sweep doesn't surface them individually - /// when an entire role section was skipped (e.g. no attached mBooster - /// with that role). + /// when an entire role section was skipped. /// private static void MarkRoleSectionConsidered(ImportPlan plan, JObject dp, string prefix) { @@ -98,170 +340,331 @@ private static void MarkRoleSectionConsidered(ImportPlan plan, JObject dp, strin plan.ConsideredKeys.Add(prop.Name); } - // ----- Helpers ----- + // ------------------------------------------------------------ + // Section mapping + // ------------------------------------------------------------ - private static bool IsRoleSectionPopulated(JObject dp, string prefix) + private static void AddSectionDiffs( + ImportPlan plan, JObject dp, string prefix, MBoosterImportTarget target) { - // Any of the core calibration fields present (and not null) → - // treat the section as real. - string[] keys = { - prefix + "_outdir", - prefix + "_min", - prefix + "_max", - prefix + "_nonlinear1", - prefix + "_nonlinear2", - prefix + "_nonlinear3", - prefix + "_nonlinear4", - prefix + "_nonlinear5", - }; - foreach (var k in keys) + var settings = target.Controller.CurrentSettings; + if (settings == null) return; + + var m = new SectionWriter(plan, dp, prefix, target, settings); + + // ----- Calibration (group 35/36 dir + output curve) ----- + m.Int("outdir", "Direction", 0, 1, + c => c.Direction, (c, v) => c.Direction = v, unsetBelowZero: true); + m.OutputCurve(); + + // min/max are deliberately NOT imported: PitHouse states them as + // percentages (0/3/16/99/100 across the sample presets) while + // MBoosterDeviceSettings.Min/Max are the device's own RAW counts — + // the "Min (raw)"/"Max (raw)" sliders run 0..65535 and are seeded + // from the device read-back. Writing 99 into a raw field caps the + // pedal at ~0.15 % of full scale. The scale factor between the two + // is unverified, so the values are surfaced instead of guessed. + m.SkipKey("min", "percent vs raw counts — unverified"); + m.SkipKey("max", "percent vs raw counts — unverified"); + + // ----- Vibration effects ----- + // PitHouse only writes ABS/Lockup/Threshold under the brake role and + // TC/WheelSpin/GearShift/RoadTexture under any role; absent keys are + // skipped, so no per-role gating is needed here. + m.Effect("ABS", c => c.Abs, "abs", "amp", + freqSuffix: "freq", MBoosterUiConstants.AbsFreqMinHz, MBoosterUiConstants.AbsFreqMaxHz, + smoothSuffix: "smoothness"); + m.Effect("Lockup", c => c.Lockup, "lockup", "amp", + freqSuffix: "freq", MBoosterUiConstants.LockupFreqMinHz, MBoosterUiConstants.LockupFreqMaxHz); + m.Effect("Threshold", c => c.Threshold, "brakethreshold", "amp", + freqSuffix: "freq", MBoosterUiConstants.ThresholdFreqMinHz, MBoosterUiConstants.ThresholdFreqMaxHz, + triggerSuffix: "trigger_input", decaySuffix: "fade_amount"); + m.Effect("Traction Control", c => c.TractionControl, "tc", "amp", + freqSuffix: "freq", MBoosterUiConstants.TractionControlFreqMinHz, MBoosterUiConstants.TractionControlFreqMaxHz); + m.Effect("Wheel Spin", c => c.WheelSpin, "wheel_slip", "amp", + freqSuffix: "freq", MBoosterUiConstants.WheelSpinFreqMinHz, MBoosterUiConstants.WheelSpinFreqMaxHz); + m.Effect("Gear Shift", c => c.GearShift, "gear_shift_vibration", "amp", + freqSuffix: "freq", MBoosterUiConstants.GearShiftFreqMinHz, MBoosterUiConstants.GearShiftFreqMaxHz); + m.Effect("Road Texture", c => c.RoadTexture, "road_texture", "intensity", + smoothSuffix: "smoothness"); + + // ----- Pedal Feel / Sim Input Mapping hardware calibration ----- + // Unit mapping inferred from value range, not from a wire capture — + // see docs/protocol/devices/mbooster.md. Rows carry a * so the + // confirm list shows which ones rest on that inference. + m.TravelRange(); + m.Float("softlimit_hardness_press", "Endstop front stiffness *", 1f, 10f, "F0", + c => c.EndstopFrontStiffness, (c, v) => c.EndstopFrontStiffness = v); + m.Float("softlimit_hardness_release", "Endstop end stiffness *", 1f, 10f, "F0", + c => c.EndstopEndStiffness, (c, v) => c.EndstopEndStiffness = v); + + if (target.Role == MBoosterRole.Brake) { - var t = dp[k]; - if (t != null && t.Type != JTokenType.Null) return true; + m.Float("press_combine", "Sensor output ratio (%) *", 0f, 100f, "F0", + c => c.SensorOutputRatioPct, (c, v) => c.SensorOutputRatioPct = v); + } + else + { + // mbooster-brake-angle-ratio is written only for the brake role + // (MozaPlugin.ApplyMBoosterToHardware), so importing it onto a + // throttle/clutch pedal would never reach the device. + PitHouseMotorMapper.AddSkipped(plan, dp, prefix + "_press_combine", "brake-only"); } - return false; - } - private static int? IntOrNull(JObject dp, string key) - { - var t = dp[key]; - if (t == null || t.Type == JTokenType.Null) return null; - try { return Convert.ToInt32((double)t); } catch { return null; } - } + // ----- Explicitly unsupported families in this section ----- + m.SkipFamily("damping", "no wire command"); + m.SkipFamily("friction", "no wire command"); + m.SkipFamily("forcelimit", "no wire command"); + m.SkipFamily("gforce", "not implemented"); + m.SkipFamily("motor_vibration", "PitHouse motor test"); + m.SkipKey("forces_curve", "no plugin field"); + m.SkipKey("stroke_curve", "no plugin field"); - private static bool? BoolOrNull(JObject dp, string key) - { - var t = dp[key]; - if (t == null || t.Type == JTokenType.Null) return null; - try { return (bool)t; } catch { return null; } + if (target.IsPassive) + plan.NotImported.Add($"{target.RowPrefix}: passive pedal — effects won't play"); } - private static void AddPerControllerDiffs( - ImportPlan plan, JObject dp, string prefix, string roleLabel, - MBoosterDeviceController controller) + /// + /// Per-section diff emitter. Reads "before" values from a non-creating + /// peek (so previewing an import never persists an empty per-pedal + /// entry) and defers the create-on-demand to each diff's apply closure. + /// + private sealed class SectionWriter { - var settings = controller.CurrentSettings; - if (settings == null) return; + private readonly ImportPlan _plan; + private readonly JObject _dp; + private readonly string _prefix; + private readonly string _rowPrefix; + private readonly IMBoosterPedalConfig _read; + private readonly MBoosterDeviceSettings _settings; + private readonly int _axis; + private readonly int _soleAxis; + + public SectionWriter(ImportPlan plan, JObject dp, string prefix, + MBoosterImportTarget target, MBoosterDeviceSettings settings) + { + _plan = plan; + _dp = dp; + _prefix = prefix; + _rowPrefix = target.RowPrefix; + _settings = settings; + _axis = target.AxisIndex; + _soleAxis = target.Controller.SoleConnectedAxis(); + // Defaults stand in when a chained pedal has no entry yet — + // that IS the config it currently runs with. + _read = MozaMBoosterRegistry.PeekPedalConfig(settings, _axis, _soleAxis) + ?? new MBoosterPedalSettings(); + } - // Device label combines role + user-facing display name so a user - // with two brake-role mBoosters can tell rows apart. - string deviceLabel = roleLabel; - if (!string.IsNullOrEmpty(settings.DisplayName)) - deviceLabel = roleLabel + " — " + settings.DisplayName; + private IMBoosterPedalConfig? Write() => + MozaMBoosterRegistry.GetOrCreatePedalConfig(_settings, _axis, _soleAxis); - // Direction - plan.ConsideredKeys.Add(prefix + "_outdir"); - var dir = IntOrNull(dp, prefix + "_outdir"); - if (dir.HasValue) + private string Key(string suffix) => _prefix + "_" + suffix; + + private JToken? Token(string suffix) { - int oldVal = settings.Direction; - plan.Diffs.Add(new FieldDiff( - deviceLabel + " · Direction", - oldVal < 0 ? "(unset)" : oldVal.ToString(), - dir.Value.ToString(), - () => settings.Direction = dir.Value)); - plan.TouchedMBoosters.Add(controller); + _plan.ConsideredKeys.Add(Key(suffix)); + var t = _dp[Key(suffix)]; + return t == null || t.Type == JTokenType.Null ? null : t; } - // Min - plan.ConsideredKeys.Add(prefix + "_min"); - var min = IntOrNull(dp, prefix + "_min"); - if (min.HasValue) + private double? Num(string suffix) { - int oldVal = settings.Min; - plan.Diffs.Add(new FieldDiff( - deviceLabel + " · Min", - oldVal < 0 ? "(unset)" : oldVal.ToString(), - min.Value.ToString(), - () => settings.Min = min.Value)); - plan.TouchedMBoosters.Add(controller); + var t = Token(suffix); + if (t == null) return null; + try { return (double)t; } catch { return null; } } - // Max - plan.ConsideredKeys.Add(prefix + "_max"); - var max = IntOrNull(dp, prefix + "_max"); - if (max.HasValue) + private bool? Bool(string suffix) { - int oldVal = settings.Max; - plan.Diffs.Add(new FieldDiff( - deviceLabel + " · Max", - oldVal < 0 ? "(unset)" : oldVal.ToString(), - max.Value.ToString(), - () => settings.Max = max.Value)); - plan.TouchedMBoosters.Add(controller); + var t = Token(suffix); + if (t == null) return null; + try { return (bool)t; } catch { return null; } } - // Curve Y1..Y5 — emit one combined diff that writes all five - var y = new float?[5]; - bool anyCurvePoint = false; - for (int i = 0; i < 5; i++) + private void Add(string label, string oldDisplay, string newDisplay, Action apply) { - string ck = prefix + "_nonlinear" + (i + 1); - plan.ConsideredKeys.Add(ck); - var v = IntOrNull(dp, ck); - if (v.HasValue) { y[i] = v.Value; anyCurvePoint = true; } + _plan.Diffs.Add(new FieldDiff($"{_rowPrefix} · {label}", oldDisplay, newDisplay, + () => { var cfg = Write(); if (cfg != null) apply(cfg); })); } - if (anyCurvePoint) + + // ----- scalar helpers ----- + + public void Int(string suffix, string label, int lo, int hi, + Func get, Action set, + bool unsetBelowZero = false) { - // Default missing points to 0 so the array is always length-5 - // (matches what ApplyMBoosterToHardware expects). - var newCurve = new float[5]; - for (int i = 0; i < 5; i++) newCurve[i] = y[i] ?? 0f; + var v = Num(suffix); + if (v == null) return; + int nv = (int)Math.Round(Clamp(v.Value, lo, hi)); + int ov = get(_read); + Add(label, + unsetBelowZero && ov < 0 ? "(unset)" : ov.ToString(CultureInfo.InvariantCulture), + nv.ToString(CultureInfo.InvariantCulture), + c => set(c, nv)); + } - var oldCurve = settings.CurveY; + public void Float(string suffix, string label, float lo, float hi, string fmt, + Func get, Action set) + { + var v = Num(suffix); + if (v == null) return; + float nv = (float)Clamp(v.Value, lo, hi); + float ov = get(_read); + Add(label, + ov < 0 ? "(unset)" : ov.ToString(fmt, CultureInfo.InvariantCulture), + nv.ToString(fmt, CultureInfo.InvariantCulture), + c => set(c, nv)); + } + + /// Output curve: nonlinear1..5 → CurveY, one combined row. + public void OutputCurve() + { + var y = 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); + any = true; + } + if (!any) return; + + var oldCurve = _read.CurveY; string oldDisplay = oldCurve == null || oldCurve.Length < 5 ? "(unset)" - : string.Join("/", oldCurve.Take(5).Select(v => ((int)Math.Round(v)).ToString())); - string newDisplay = string.Join("/", newCurve.Select(v => ((int)Math.Round(v)).ToString())); - - plan.Diffs.Add(new FieldDiff( - deviceLabel + " · Curve (Y at 20/40/60/80/100%)", - oldDisplay, newDisplay, - () => settings.CurveY = newCurve)); - plan.TouchedMBoosters.Add(controller); + : string.Join("/", oldCurve.Take(5).Select(FormatCurvePoint)); + string newDisplay = string.Join("/", y.Select(FormatCurvePoint)); + + Add("Output curve (Y at 20/40/60/80/100%)", oldDisplay, newDisplay, + c => c.CurveY = (float[])y.Clone()); } - // Brake-only effects: ABS / Lockup / Threshold — only emit when - // the preset is for the brake role. - if (prefix == "brake") + private static string FormatCurvePoint(float v) => + ((int)Math.Round(v)).ToString(CultureInfo.InvariantCulture); + + /// + /// machinelimit_min/max → TravelStartMm/TravelEndMm as one row, so + /// the pair stays inside the range slider's own min/max gap. Unit + /// mapping (raw value = mm) is inferred, not captured. + /// + public void TravelRange() { - AddEffect(plan, dp, "brake_abs_switch", "brake_abs_amp", - deviceLabel + " · ABS", settings.Abs, controller); - AddEffect(plan, dp, "brake_lockup_switch", "brake_lockup_amp", - deviceLabel + " · Lockup", settings.Lockup, controller); - AddEffect(plan, dp, "brake_brakethreshold_switch", "brake_brakethreshold_amp", - deviceLabel + " · Threshold", settings.Threshold, controller); + var lo = Num("machinelimit_min"); + var hi = Num("machinelimit_max"); + if (lo == null && hi == null) return; + + // Half a pair is only usable when the other end already has a + // real value to pin against — otherwise the clamp below would + // invent one out of the -1 "unset" sentinel. + if (lo == null && _read.TravelStartMm < 0) return; + if (hi == null && _read.TravelEndMm < 0) return; + + float start = (float)Clamp(lo ?? _read.TravelStartMm, + MBoosterUiConstants.TravelMinMm, MBoosterUiConstants.TravelMaxMm); + float end = (float)Clamp(hi ?? _read.TravelEndMm, + MBoosterUiConstants.TravelMinMm, MBoosterUiConstants.TravelMaxMm); + + // Honour the slider's own gap constraints so an imported pair + // can never land somewhere the UI couldn't produce. + if (end - start < MBoosterUiConstants.TravelMinGapMm) + end = Math.Min(MBoosterUiConstants.TravelMaxMm, start + MBoosterUiConstants.TravelMinGapMm); + if (end - start > MBoosterUiConstants.TravelMaxGapMm) + end = start + MBoosterUiConstants.TravelMaxGapMm; + + string oldDisplay = _read.TravelStartMm < 0 || _read.TravelEndMm < 0 + ? "(unset)" + : $"{_read.TravelStartMm.ToString("F1", CultureInfo.InvariantCulture)}–{_read.TravelEndMm.ToString("F1", CultureInfo.InvariantCulture)} mm"; + string newDisplay = $"{start.ToString("F1", CultureInfo.InvariantCulture)}–{end.ToString("F1", CultureInfo.InvariantCulture)} mm"; + + float s = start, e = end; + Add("Travel start–end (mm) *", oldDisplay, newDisplay, + c => { c.TravelStartMm = s; c.TravelEndMm = e; }); } - } - private static void AddEffect( - ImportPlan plan, JObject dp, - string switchKey, string ampKey, - string label, MBoosterEffectSettings target, - MBoosterDeviceController touched) - { - plan.ConsideredKeys.Add(switchKey); - plan.ConsideredKeys.Add(ampKey); - var sw = BoolOrNull(dp, switchKey); - var amp = IntOrNull(dp, ampKey); - if (sw == null && amp == null) return; - if (target == null) return; - - int newAmp = amp.HasValue - ? Math.Max(0, Math.Min(100, amp.Value)) - : target.IntensityPct; - bool newEnabled = sw ?? target.Enabled; - - string oldDisplay = (target.Enabled ? "On" : "Off") + " @ " + target.IntensityPct + "%"; - string newDisplay = (newEnabled ? "On" : "Off") + " @ " + newAmp + "%"; - - plan.Diffs.Add(new FieldDiff(label, oldDisplay, newDisplay, - () => + /// + /// One row per vibration effect covering enable + intensity and + /// whichever of frequency / smoothness / trigger / decay PitHouse + /// carries for it. Absent sub-keys keep the current value. + /// + public void Effect( + string label, + Func pick, + string effectKey, + string ampSuffix, + string? freqSuffix = null, float freqLo = 0f, float freqHi = 0f, + string? smoothSuffix = null, + string? triggerSuffix = null, + string? decaySuffix = null) + { + var current = pick(_read); + if (current == null) return; + + var sw = Bool(effectKey + "_switch"); + var amp = Num(effectKey + "_" + ampSuffix); + var freq = freqSuffix == null ? null : Num(effectKey + "_" + freqSuffix); + var smooth = smoothSuffix == null ? null : Num(effectKey + "_" + smoothSuffix); + var trigger = triggerSuffix == null ? null : Num(effectKey + "_" + triggerSuffix); + var decay = decaySuffix == null ? null : Num(effectKey + "_" + decaySuffix); + + if (sw == null && amp == null && freq == null && smooth == null && trigger == null && decay == null) + return; + + bool newEnabled = sw ?? current.Enabled; + int newAmp = amp.HasValue ? (int)Math.Round(Clamp(amp.Value, 0, 100)) : current.IntensityPct; + float newFreq = freq.HasValue ? (float)Clamp(freq.Value, freqLo, freqHi) : current.FrequencyHz; + int newSmooth = smooth.HasValue ? (int)Math.Round(Clamp(smooth.Value, 0, 100)) : current.SmoothnessPct; + int newTrigger = trigger.HasValue + ? (int)Math.Round(Clamp(trigger.Value, + MBoosterUiConstants.ThresholdTriggerMinPct, MBoosterUiConstants.ThresholdTriggerMaxPct)) + : current.TriggerLevelPct; + int newDecay = decay.HasValue ? (int)Math.Round(Clamp(decay.Value, 0, 100)) : current.DecayPct; + + string Describe(bool en, int a, float f, int sm, int tr, int dc) + { + var parts = new List { en ? "On" : "Off", a.ToString(CultureInfo.InvariantCulture) + "%" }; + if (freqSuffix != null) parts.Add(f.ToString("F0", CultureInfo.InvariantCulture) + "Hz"); + if (smoothSuffix != null) parts.Add("smooth " + sm.ToString(CultureInfo.InvariantCulture)); + if (triggerSuffix != null) parts.Add("trigger " + tr.ToString(CultureInfo.InvariantCulture) + "%"); + if (decaySuffix != null) parts.Add("decay " + dc.ToString(CultureInfo.InvariantCulture)); + return string.Join(" · ", parts); + } + + string oldDisplay = Describe(current.Enabled, current.IntensityPct, current.FrequencyHz, + current.SmoothnessPct, current.TriggerLevelPct, current.DecayPct); + string newDisplay = Describe(newEnabled, newAmp, newFreq, newSmooth, newTrigger, newDecay); + + Add(label, oldDisplay, newDisplay, c => { - target.Enabled = newEnabled; - target.IntensityPct = newAmp; - })); - plan.TouchedMBoosters.Add(touched); + var t = pick(c); + if (t == null) return; + t.Enabled = newEnabled; + t.IntensityPct = newAmp; + if (freqSuffix != null) t.FrequencyHz = newFreq; + if (smoothSuffix != null) t.SmoothnessPct = newSmooth; + if (triggerSuffix != null) t.TriggerLevelPct = newTrigger; + if (decaySuffix != null) t.DecayPct = newDecay; + }); + } + + /// Note every <prefix>_<family>* key as skipped, with a reason. + public void SkipFamily(string family, string reason) + { + string p = _prefix + "_" + family; + var keys = _dp.Properties() + .Where(x => x.Name.StartsWith(p, StringComparison.Ordinal)) + .Select(x => x.Name) + .ToList(); + foreach (var k in keys) + PitHouseMotorMapper.AddSkipped(_plan, _dp, k, reason); + } + + public void SkipKey(string suffix, string reason) => + PitHouseMotorMapper.AddSkipped(_plan, _dp, Key(suffix), reason); + + private static double Clamp(double v, double lo, double hi) => + v < lo ? lo : (v > hi ? hi : v); } } } diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs index 8d1b9409..a49f2386 100644 --- a/UI/SettingsControl.xaml.cs +++ b/UI/SettingsControl.xaml.cs @@ -2839,27 +2839,8 @@ private void RefreshMBoosterTab() { var rowSettings = _plugin.GetOrCreateMBoosterSettings(c.Identity); int axisCount = c.AxisCount > 0 ? c.AxisCount : 1; - var connected = c.ConnectedAxes; string deviceLabel = BuildMBoosterComboLabel(c); - - // Which axes are ACTUALLY wired. The HID interface - // commonly reports 3 axes (Rx/Ry/Rz) regardless of how - // many pedals are physically connected — ConnectedAxes - // (from the "PD Linked" firmware diagnostic) is the - // only way to tell which are real. Until that - // diagnostic arrives (null), assume only axis 0 is - // real: the common case is a standalone single pedal, - // and a genuine chain's extra axes appear as soon as - // the diagnostic confirms them, instead of showing - // phantom pedals from the very first refresh. - var connectedAxes = new List(); - for (int axis = 0; axis < axisCount && axis < MBoosterDeviceController.MaxAxes; axis++) - { - bool axisKnownConnected = connected != null && axis < connected.Length - ? connected[axis] - : axis == 0; - if (axisKnownConnected) connectedAxes.Add(axis); - } + var connectedAxes = c.ConnectedAxisIndices(); // Only label rows "— Pedal N" when this device genuinely // hosts more than one wired pedal — not just because its @@ -3128,23 +3109,11 @@ private void OnMBoosterDeviceRowDisplayNameChanged(string identity, string newDi /// and creating an empty entry here would orphan it — see /// MBoosterDeviceController.SoleConnectedAxis. Null if no device /// selected. Covers effects + calibration + sim input + pedal feel. - private IMBoosterPedalConfig? CurrentMBoosterEffectTarget() - { - var s = CurrentMBoosterSettings(); - if (s == null) return null; - if (_mboosterEffectPedalIndex <= 0) return s; - if (!s.Pedals.TryGetValue(_mboosterEffectPedalIndex, out var p)) - { - if (CurrentMBoosterController()?.SoleConnectedAxis() == _mboosterEffectPedalIndex) - return s; - // Copy-on-write: publish a NEW dictionary via atomic reference - // swap rather than mutating in place, so the 50 Hz effect worker - // threads reading s.Pedals never see a dictionary mid-resize. - p = new MBoosterPedalSettings(); - s.Pedals = new Dictionary(s.Pedals) { [_mboosterEffectPedalIndex] = p }; - } - return p; - } + private IMBoosterPedalConfig? CurrentMBoosterEffectTarget() => + MozaMBoosterRegistry.GetOrCreatePedalConfig( + CurrentMBoosterSettings(), + _mboosterEffectPedalIndex, + CurrentMBoosterController()?.SoleConnectedAxis() ?? -1); /// The per-pedal config for the selected pedal WITHOUT creating a /// missing entry — used when seeding controls so merely viewing a chained @@ -3152,16 +3121,11 @@ private void OnMBoosterDeviceRowDisplayNameChanged(string identity, string newDi /// Same sole-connected-pedal flat-fields fallback as /// so seeding shows the config /// that pedal actually runs with. - private IMBoosterPedalConfig? PeekMBoosterEffectTarget() - { - var s = CurrentMBoosterSettings(); - if (s == null) return null; - if (_mboosterEffectPedalIndex <= 0) return s; - if (s.Pedals.TryGetValue(_mboosterEffectPedalIndex, out var p)) return p; - if (CurrentMBoosterController()?.SoleConnectedAxis() == _mboosterEffectPedalIndex) - return s; - return null; - } + private IMBoosterPedalConfig? PeekMBoosterEffectTarget() => + MozaMBoosterRegistry.PeekPedalConfig( + CurrentMBoosterSettings(), + _mboosterEffectPedalIndex, + CurrentMBoosterController()?.SoleConnectedAxis() ?? -1); /// Seed the eight vibration-effect cards' controls from one /// pedal's effect settings. Assumes the event suppressor is active. Brake diff --git a/docs/protocol/devices/mbooster.md b/docs/protocol/devices/mbooster.md index 3ca9c907..80c64900 100644 --- a/docs/protocol/devices/mbooster.md +++ b/docs/protocol/devices/mbooster.md @@ -1299,6 +1299,144 @@ existing theme pairs (same red/green the temperature graph's MCU/Motor series use). `BlueBrush`/`BwThirdFillBrush` are new — no prior accent color in the theme was a true blue distinct from Cyan. +## PitHouse Pedals preset format + +A PitHouse preset is a JSON object (or a `.mzpreset` zip holding `preset.json` +— see `UI/Import/PitHousePresetArchive.cs`) whose `deviceParams` object holds +every setting as a flat key. mBooster presets carry `"deviceType": "Pedals"` +and `"devices": ["mBooster"]`. + +Sample files these notes were derived from: two real user presets, `Brake` +(100 `deviceParams` keys, saved 2026-07-14) and `Throttle` (88 keys, +2026-07-18), from the same rig. + +### Per-role prefixes, and the subject role + +Every key except a handful of device-wide ones is prefixed `throttle_`, +`brake_` or `clutch_`. **A preset written for one pedal still carries all +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: + +``` +channlRoleType, outdir, min, max, nonlinear1..5, press_combine +``` + +So the section carrying **any key outside that generic set** identifies the +role the preset is really for — its *subject role*. In `Brake.json` only +`brake_*` qualifies; in `Throttle.json` only `throttle_*` does. + +This matters because the other sections are *not* settings for those pedals in +any meaningful sense — they are whatever the device happened to report when +the preset was saved. `PitHousePedalsMapper` therefore imports only the subject +section, into one pedal (`ImportPlan.ResolvedTarget`, retargetable in the +wizard). Importing all three overwrote the untouched pedals' calibration. + +A preset with no extended block in any section has no discernible subject; the +mapper treats it as a plain calibration snapshot and matches each populated +section to the pedal carrying that role (`ImportPlan.IsCalibrationOnlyPreset`). + +### Key → plugin field + +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) | + +Values are clamped to the plugin's own slider bounds (`MBoosterUiConstants`) +on import, and the travel pair additionally honours `TravelMinGapMm` / +`TravelMaxGapMm` so an imported range can't land somewhere the UI could not +produce. + +### `

_min` / `

_max` — percent vs raw counts + +PitHouse states these as **percentages**: every observed value across both +sample presets is in 0–100 (`clutch_min: 0` / `clutch_max: 100` is the +full-range default; `brake_min: 16`, `brake_max: 99`, `throttle_min: 3`), and +they sit alongside `nonlinear1..5`, which are unambiguously percentages. + +`MBoosterDeviceSettings.Min`/`Max` are the device's own **raw counts** — the +Calibration card's sliders are labelled "Min (raw)"/"Max (raw)", run 0–65535 +(the 2-byte field's range), and are seeded from the device read-back, unlike +every other min/max slider in the app, which clamps 0–100. + +The importer therefore does **not** map them. Writing PitHouse's `max: 99` +straight into the raw field caps the pedal's output at ~0.15 % of full scale. +The percent→raw factor is not established by any capture (the raw full-scale a +given unit actually reports is not necessarily 65535), so the values are listed +under "Not imported" with their reason rather than guessed at. A capture of +PitHouse writing `mbooster-

-min`/`-max` after a known slider value would +settle it. + +### The three inferred mappings + +These are read from value range, **not** from a wire capture, and are marked +with `*` in the import wizard's change list: + +- `machinelimit_min/max` → travel in **mm**. Samples are 34.97/45.0 and + 35.99/46.69, sitting inside the plugin's own 3.8–49.7 mm Start/End of Travel + slider (itself reverse-engineered from PitHouse captures of that control). +- `softlimit_hardness_press/release` → End Stop Stiffness. Samples are `3`, + inside the confirmed 1–10 range; press↔front / release↔end is the natural + pairing. +- `press_combine` → sensor blend ratio. Present only under `brake_` (70 in the + Brake preset, 0 in the Throttle preset's brake snapshot), matching + `SensorOutputRatioPct`'s own brake-only scope. Weakest of the three. + +### Not imported — no plugin surface + +`

_damping_*` (including the 3-segment `_segment{1,2,3}_{position,value}` +curve), `

_friction_*`, `

_forcelimit_min/max`, `

_gforce_*`, +`

_motor_vibration_*` (PitHouse's own motor test; `_balance` has no +counterpart at all), and the device-wide `force_max_coef`, `pressure_weight`, +`enter_sleep_time`, `game_mode`. The un-prefixed `machinelimit_*`, +`softlimit_hardness_*`, `damping_*`, `friction_*`, `forcelimit_min` are +device-wide copies of the per-pedal keys — the importer reads the prefixed ones +because those say which pedal they belong to. + +Every one of these is listed with its value and a reason in the wizard's "Not +imported" card; `PitHouseMotorMapper.SweepUnhandled` is the backstop, so a key +PitHouse adds later still surfaces rather than vanishing. + +### Open questions + +- **`_channlRoleType` semantics are unresolved.** In both samples the + *populated* section reads `2` (`brake_channlRoleType: 2` in `Brake.json`, + `throttle_channlRoleType: 2` in `Throttle.json`) while the other two read 1 + and 3. That is inconsistent with the plugin's own `MBoosterRole` enum + (1=Throttle, 2=Brake, 3=Clutch), so the field is *not* simply the section's + role. Two files can't settle it — the importer marks the key considered and + ignores it, using the extended-key test above instead. More samples (a clutch + preset, or presets from a differently-wired rig) would resolve this. +- **`stroke_curve` (6 floats) + `forces_curve` (7 floats) look like one + force-vs-travel curve.** `brake_stroke_curve` spans 36.4–43.6, the same range + as `brake_machinelimit_min/max` (⇒ likely **mm**); `brake_forces_curve` spans + 16.1–47.0, the same range as `brake_forcelimit_min/max` (11/47, ⇒ likely + **kg**). The throttle preset's equivalents are lighter throughout + (4.3–12.0 kg vs the brake's 16–47), which is what a throttle-vs-brake pedal + pair should look like. The plugin's `mbooster-brake-curve7-1..6` family + (`0xAB`, 6 selectors, fed by `ResampleCurveAtSevenths`) is a shape candidate + for `stroke_curve`, but curve7 is always *derived* from `(CurveX, CurveY)` + and has no settings field of its own, so this stays unmapped until a capture + confirms it. + ## Source-of-truth files in this repo - Protocol primitives — [`Protocol/MozaMBoosterProtocol.cs`](../../../Protocol/MozaMBoosterProtocol.cs) @@ -1310,3 +1448,4 @@ color in the theme was a true blue distinct from Cyan. - HID extension — [`Protocol/MozaHidReader.cs`](../../../Protocol/MozaHidReader.cs) (`MozaHidClass.MBooster` path) - Profile storage — [`UI/MozaProfile.cs`](../../../UI/MozaProfile.cs) (`MBoosterSettings` dict) - UI tab — [`UI/SettingsControl.xaml`](../../../UI/SettingsControl.xaml) (`MBoosterTab`) + handlers in `SettingsControl.xaml.cs` under "mBooster tab — multi-device" +- PitHouse preset import — [`UI/Import/PitHousePedalsMapper.cs`](../../../UI/Import/PitHousePedalsMapper.cs) + wizard [`UI/Import/PitHouseImportControl.xaml.cs`](../../../UI/Import/PitHouseImportControl.xaml.cs) From 5f43a576f0fdd1864e7378c7a4bfd335eda2bc86 Mon Sep 17 00:00:00 2001 From: tacodevhaydz Date: Fri, 14 Aug 2026 09:51:57 +1200 Subject: [PATCH 13/13] 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*