diff --git a/.gitignore b/.gitignore index e0cd892d..cd1437e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ _todo.md +.vscode/* ## Claude stuff .claude* diff --git a/Devices/MBoosterDeviceController.cs b/Devices/MBoosterDeviceController.cs index 14c1a45c..70b26245 100644 --- a/Devices/MBoosterDeviceController.cs +++ b/Devices/MBoosterDeviceController.cs @@ -94,10 +94,10 @@ public sealed class MBoosterDeviceController : IDisposable // (mbooster-brake-threshold); -1 until it answers. Live only, never // persisted and never copied into MBoosterDeviceSettings.MaxThresholdKg: // that field's -1 means "user set no override", and seeding it would make - // the plugin start writing the value back on every connect. Used purely as - // ApplyDeadzoneAndMaxForce's fullScaleKg reference in place of the old - // hardcoded 200kg guess. Volatile: written on the serial read thread, read - // by the HID thread and the UI. + // the plugin start writing the value back on every connect. Surfaced in + // Diagnostics (see DiagnosticsTextBuilder) as a read-back sanity check. + // Volatile: written on the serial read thread, read by the HID thread + // and the UI. private volatile float _deviceReportedMaxThresholdKg = -1; public float DeviceReportedMaxThresholdKg { @@ -1188,9 +1188,16 @@ public bool SendFloatWrite(string commandName, float value, byte? device = null) /// omitting it is what made Travel Start/End silently no-op on /// hardware despite the raw register write reading back fine — see /// MozaCommandDatabase.cs's mbooster-brake-curve7-* comment. Callers - /// use this after any of Travel/Endstop/Ratio/Threshold's own writes - /// on the theory that the same firmware requirement applies to all of - /// them, not just Travel — unconfirmed for the others. + /// use this after Direction/Min/Max/CurveY/Endstop/Friction/ + /// SegmentedDamping/Ratio's own writes too, on the theory that the + /// same firmware requirement applies to all of them, not just + /// Travel — unconfirmed for those. CONFIRMED NOT required for Max + /// Threshold or Deadzone/Max Force specifically: isolated captures + /// for both (max-threshold-4-41-105-153-200.pcapng, + /// max-force-24-75-128-166-200.pcapng, deadzone-0-5-11-14.pcapng) + /// show zero curve7-1..6 traffic alongside their real writes — see + /// MozaPlugin.ApplyMBoosterToHardware's needsCurve7Resync and + /// MBoosterDeviceController.PushFeelCurveResync. /// public void PushCurve7Resync(float[]? curveX, float[]? curveY, byte device) { @@ -1199,6 +1206,27 @@ public void PushCurve7Resync(float[]? curveX, float[]? curveY, byte device) SendIntWrite($"mbooster-brake-curve7-{i + 1}", MozaMBoosterProtocol.EncodeCurve7Point(curve7[i]), device); } + /// + /// Write Deadzone, Max Force, and the 6 interpolated points between + /// them (cmdId 0xAB selectors 0x07-0x0E) as one atomic burst — CONFIRMED + /// real hardware calibration, reverse-engineered from + /// max-force-24-75-128-166-200.pcapng and deadzone-0-5-11-14.pcapng + /// (bug bundle 5VR5AQ8Y): every Deadzone or Max Force change in both + /// captures resent the whole 8-value family together, not just the + /// field that moved — same "no partial update" shape as Segmented + /// Damping. Both values use the identical kg encoding as Max + /// Threshold (). + /// See . + /// + public void PushFeelCurveResync(double deadzoneKg, double maxForceKg, byte device) + { + SendIntWrite("mbooster-brake-deadzone", MozaMBoosterProtocol.EncodeThresholdKg(deadzoneKg), device); + var mid = MozaMBoosterRegistry.ComputeFeelCurve(deadzoneKg, maxForceKg); + for (int i = 0; i < mid.Length; i++) + SendIntWrite($"mbooster-brake-feelcurve-{i + 1}", MozaMBoosterProtocol.EncodeThresholdKg(mid[i]), device); + SendIntWrite("mbooster-brake-maxforce", MozaMBoosterProtocol.EncodeThresholdKg(maxForceKg), device); + } + // ── Coalescing gate for UI-driven calibration writes ── // A slider raises ValueChanged per tick, and every one of these commands // is a flash-backed calibration register that additionally drags a diff --git a/Devices/MBoosterTypes.cs b/Devices/MBoosterTypes.cs index 3320edee..04b11a94 100644 --- a/Devices/MBoosterTypes.cs +++ b/Devices/MBoosterTypes.cs @@ -463,10 +463,12 @@ public sealed class MBoosterPedalSettings : IMBoosterPedalConfig public float SensorOutputRatioPct { get; set; } = -1; public float MaxThresholdKg { get; set; } = -1; - // Pedal Feel (host-side shaping + brake-only wire calibration). + // Pedal Feel (InputCurveY is host-side shaping; Deadzone/MaxForce are + // real brake-only wire calibration — see MBoosterDeviceSettings for + // the field semantics). public float[]? InputCurveY { get; set; } = null; - public float DeadzoneKg { get; set; } = 0; - public float MaxForceKg { get; set; } = 200; + public float DeadzoneKg { get; set; } = -1; + public float MaxForceKg { get; set; } = -1; public float TravelStartMm { get; set; } = -1; public float TravelEndMm { get; set; } = -1; public float EndstopFrontStiffness { get; set; } = -1; @@ -621,8 +623,13 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig // load-cell force to reach 100%, capped at // MBoosterUiConstants.BrakeFadeMaxThresholdKg — this is what // actually makes the pedal feel "softer" (more effort needed for - // the same signal), unlike the host-side-only MaxForceKg, which - // has no wire command and wouldn't affect what the game receives. + // the same signal). MaxForceKg is now also a real wire calibration + // (see docs/protocol/devices/mbooster.md "Pedal Feel"), but Brake + // Fade deliberately still ramps MaxThresholdKg, not MaxForceKg: + // Threshold rescales the sensor's own 0-100% span, while Max Force + // only lowers the effort needed below whatever that span already + // is — ramping it couldn't demand MORE force than Threshold + // already caps at, so it can't reproduce "harder to press." // Both restore to their configured values as brake temp cools. If // the user has never configured a given base value, that ONE // calibration stays fully inert (the other can still ramp @@ -694,28 +701,29 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig // docs/protocol/devices/mbooster.md "Pedal Feel". public float[]? InputCurveY { get; set; } = null; - // Deadzone at the start of pedal travel, in kg of force (0..40). - // Host-side only, applied before InputCurveY (a physical/sensor - // characteristic — the resting force before the load cell means - // anything — should shape the signal before the user's "feel" - // curve does). See MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce. - // 0 = off (default). - public float DeadzoneKg { get; set; } = 0; - - // Force (kg, 0..200) at which the Pedal Feel input curve's X-axis - // reaches 100%. Host-side only. Raw 0-100% pedal travel isn't a - // fixed 0-200kg scale — 100% raw is whatever MaxThresholdKg (Sim - // Input Mapping) currently calibrates the device itself to reach - // 100% at (200kg is only a fallback guess when MaxThresholdKg is - // still -1/unset — see MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce). - // 200 = off IF the device's real threshold is also 200kg; if it's - // lower (real Pit House captures commonly show ~100-125kg), 200 - // has no additional effect beyond whatever the device already - // saturates at, since there's no headroom above the device's own - // calibrated max for software to require more force. Lower it if - // you never press hard enough to reach the curve's right edge - // otherwise. - public float MaxForceKg { get; set; } = 200; + // Deadzone at the start of pedal travel, in kg of force (0..40) — + // REAL hardware calibration (wire command mbooster-brake-deadzone, + // cmdId 0xAB selector 0x07), reverse-engineered from + // deadzone-0-5-11-14.pcapng (bug bundle 5VR5AQ8Y). Same kg encoding + // as MaxThresholdKg — see MozaMBoosterProtocol.EncodeThresholdKg and + // MBoosterDeviceController.PushFeelCurveResync. -1 = "not yet set / + // no override", same sentinel convention as every other real + // calibration field, so a fresh profile never overwrites whatever + // the device already has. Previously host-side-only (0 = off + // default); see docs/protocol/devices/mbooster.md "Pedal Feel". + public float DeadzoneKg { get; set; } = -1; + + // Force (kg, 0..200) at which the pedal's raw HID axis reaches + // 100% travel — REAL hardware calibration (wire command + // mbooster-brake-maxforce, cmdId 0xAB selector 0x0E), reverse- + // engineered from max-force-24-75-128-166-200.pcapng (bug bundle + // 5VR5AQ8Y). Same kg encoding as MaxThresholdKg. Confirmed NOT + // clamped to MaxThresholdKg on the wire (128/166kg sent while + // Threshold read back 125kg) — it's an independent parameter, not + // a rescale of Threshold's own ceiling. -1 = "not yet set / no + // override". Previously host-side-only (200 = off default); see + // docs/protocol/devices/mbooster.md "Pedal Feel". + public float MaxForceKg { get; set; } = -1; // Start/End of pedal travel, in mm (Pit House's own calibration // control, not a host-side shim). Reverse-engineered from two real @@ -745,8 +753,8 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig public float EndstopEndStiffness { get; set; } = -1; // Natural Friction (Pit House-style), 0-100%. Real hardware write - // (not host-side-only like Deadzone/MaxForce) — reverse-engineered - // from two real Pit House USB captures (a toggle on/off, and a + // (like Deadzone/MaxForce above) — reverse-engineered from two + // real Pit House USB captures (a toggle on/off, and a // 0/25/50/75/100% slider sweep): wire commands // mbooster-brake-friction-0/-1 (cmdId 0xAE with a selector byte, // always written together with the same value), 2-byte int, fixed diff --git a/Devices/MozaMBoosterRegistry.cs b/Devices/MozaMBoosterRegistry.cs index 7262f1eb..b37e6a29 100644 --- a/Devices/MozaMBoosterRegistry.cs +++ b/Devices/MozaMBoosterRegistry.cs @@ -425,26 +425,20 @@ public void OnHidAxisUpdate(string identity, string containerId, int axisIndex, } if (c == null) return; - // Pedal Feel — host-side shaping of the raw HID position, - // applied here so every downstream consumer (position bar, - // MergePositions -> game telemetry, the effect worker's - // brake-position fallback) sees the same shaped value. Does not - // touch CurveY (still written to the device's own output-curve - // command unchanged) — see docs/protocol/devices/mbooster.md - // "Pedal Feel". Start/End of Travel (mm) is NOT shaped here — - // it's a real hardware calibration write (mbooster-brake-travel- - // start/end); the device's own firmware already clips/rescales - // the raw signal before this HID read ever sees it. - // Axis 0 (the master unit's pedal) carries the host-side Pedal Feel - // shaping (deadzone / max force / input curve) exactly as the - // single-axis path always has — those controls are calibrated - // against the master pedal. Chained axes (1+) route raw for now; - // per-axis Pedal Feel is a follow-up (Stage 3). - // Per-axis Pedal Feel: shape EACH pedal's HID by ITS OWN config — - // the master (axis 0) from the lane's flat fields, each chained pedal - // from its per-pedal entry. Host-side only (deadzone / max force / - // input curve); the wire calibration is applied separately in - // ApplyMBoosterToHardware. + // Pedal Feel — Deadzone and Max Force are now REAL hardware + // calibration (mbooster-brake-deadzone / -maxforce, cmdId 0xAB + // selectors 0x07/0x0E — see MBoosterDeviceController + // .PushFeelCurveResync and docs/protocol/devices/mbooster.md + // "Pedal Feel"): the device reshapes the raw HID axis itself + // before this read ever sees it, so there is nothing left to + // reshape here. Only InputCurveY remains a host-side-only + // shaping step (there is no wire command for it) — applied so + // every downstream consumer (position bar, MergePositions -> + // game telemetry, the effect worker's brake-position fallback) + // sees the same shaped value. Does not touch CurveY (still + // written to the device's own output-curve command unchanged). + // Per-axis: the master (axis 0) uses the lane's flat fields, + // each chained pedal uses its own per-pedal entry. var laneSettings = _settingsLookup(c.Identity); IMBoosterPedalConfig? cfg = laneSettings; if (axisIndex > 0) @@ -460,12 +454,6 @@ public void OnHidAxisUpdate(string identity, string containerId, int axisIndex, double posPct = pos01 * 100.0; if (cfg != null) { - // Raw 0-100% HID travel isn't a fixed 0-200kg scale — it's - // whatever this pedal's OWN Max Threshold calibration (Sim Input - // Mapping) currently says 100% is. - double fullScaleKg = ResolveFullScaleKg(cfg, c); - if (cfg.DeadzoneKg > 0 || cfg.MaxForceKg < fullScaleKg) - posPct = ApplyDeadzoneAndMaxForce(posPct, cfg.DeadzoneKg, cfg.MaxForceKg, fullScaleKg); // Store the pre-input-curve percent for EVERY axis so the UI's // live curve markers follow whichever pedal is selected (axis 0 // also mirrored to LastRawPercentPreCurve for legacy callers). @@ -491,81 +479,6 @@ public void OnHidAxisUpdate(string identity, string containerId, int axisIndex, MergePositions(); } - /// - /// The force (kg) at which this pedal's raw HID axis reaches 100% — the - /// reference scale and the Max - /// Force slider's own ceiling are both expressed against. Three rungs: - /// - /// The user's own Max Threshold override, when set (they calibrated - /// the device to it from this plugin, so it IS the device's scale). - /// Otherwise the value the DEVICE reported for - /// mbooster-brake-threshold — a real read-back, not a guess. - /// Only if neither exists (routed lane / firmware never answered), - /// the historical 200kg fallback. - /// - /// See docs/protocol/devices/mbooster.md "Sim Input Mapping". - /// - internal static double ResolveFullScaleKg(IMBoosterPedalConfig? cfg, MBoosterDeviceController? c) - { - if (cfg != null && cfg.MaxThresholdKg >= 0) return cfg.MaxThresholdKg; - float reported = c?.DeviceReportedMaxThresholdKg ?? -1; - if (reported > 0) return reported; - return 200.0; - } - - /// - /// Deadzone + Max Force, in kg of force — both host-side only. - /// is the force at which raw 0-100% - /// HID travel reaches 100% — resolve it with - /// , never inline: getting it wrong - /// makes Max Threshold read as INVERTED, since it enters here only as - /// this denominator (bundle KY3HK4QP). Combined into one kg-space remap - /// rather than two independent percent-space steps: - /// - /// Deadzone (0..40kg): force below this clamps to 0. - /// Max Force (0..200kg, default 200 = off): the force at - /// which the input curve's X-axis reaches 100% — lets a - /// user who never presses past, say, 100kg (out of the device's - /// real ) use the curve's full - /// 0-100% range instead of only ever reaching its midpoint. Values - /// at or above are a no-op: the raw - /// axis is already pegged at 100% by the device itself at that - /// point, so there's no more resolution above it for software to - /// require. - /// - /// Everything between the two rescales linearly. See - /// docs/protocol/devices/mbooster.md "Pedal Feel". - /// - internal static double ApplyDeadzoneAndMaxForce(double xPercent, double deadzoneKg, double maxForceKg, double fullScaleKg) - { - if (fullScaleKg <= 0) fullScaleKg = 200.0; - double loPercent = Math.Max(0, Math.Min(fullScaleKg, deadzoneKg)) / fullScaleKg * 100.0; - double hiPercent = Math.Max(0, Math.Min(fullScaleKg, maxForceKg)) / fullScaleKg * 100.0; - return ClipAndRescale(xPercent, loPercent, hiPercent); - } - - /// - /// Shared clip-and-rescale: positions at or below - /// clip to 0, positions at or above - /// clip to 100, everything between - /// rescales linearly to the full 0-100 range. - /// - private static double ClipAndRescale(double xPercent, double loPercent, double hiPercent) - { - xPercent = Math.Max(0, Math.Min(100, xPercent)); - loPercent = Math.Max(0, Math.Min(100, loPercent)); - hiPercent = Math.Max(0, Math.Min(100, hiPercent)); - - double effective = Math.Max(0, xPercent - loPercent); - double range = hiPercent - loPercent; - if (range <= 0) return effective > 0 ? 100 : 0; - - double result = effective / range * 100.0; - if (result < 0) return 0; - if (result > 100) return 100; - return result; - } - /// /// Evaluate a 5-point Pedal Feel curve at a given X (0..100), /// reproducing 's @@ -706,6 +619,34 @@ internal static float[] ResampleCurveAtSevenths(float[]? curveX, float[]? curveY return result; } + // Fixed fractions of the way from Deadzone to Max Force for the 6 + // interpolated points the device holds between those two anchors + // (mbooster-brake-feelcurve-1..6, cmdId 0xAB selectors 0x08-0x0D) — + // empirically measured across both max-force-24-75-128-166-200.pcapng + // (Deadzone fixed, Max Force swept 75/128/166kg) and + // deadzone-0-5-11-14.pcapng (Max Force fixed, Deadzone swept + // 5/11/14kg): (value - deadzone) / (maxForce - deadzone) landed on + // the identical constant per selector in all 6 write bursts (std-dev + // < 0.0001), cross-validating the same fixed shape regardless of + // which endpoint moved. See docs/protocol/devices/mbooster.md + // "Pedal Feel" and bug bundle 5VR5AQ8Y. + private static readonly double[] FeelCurveFractions = + { 0.08049, 0.19495, 0.44245, 0.72433, 0.90040, 0.97910 }; + + /// + /// The 6 points the device's own Deadzone-to-Max-Force curve holds + /// between its two anchors, in kg — see + /// and . + /// + internal static double[] ComputeFeelCurve(double deadzoneKg, double maxForceKg) + { + double range = maxForceKg - deadzoneKg; + var result = new double[FeelCurveFractions.Length]; + for (int i = 0; i < result.Length; i++) + result[i] = deadzoneKg + FeelCurveFractions[i] * range; + return result; + } + /// /// Fallback pairing for when the HID /// identity doesn't exactly match a known CDC identity. Per diff --git a/MozaPlugin.cs b/MozaPlugin.cs index ae4f5925..bb19b730 100644 --- a/MozaPlugin.cs +++ b/MozaPlugin.cs @@ -2672,7 +2672,15 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos else if (axis == soleAxis) cfg = s; else continue; - bool wroteAnyCalibration = false; + // Named for what it gates below, NOT "wrote anything" — Max + // Threshold and Deadzone/Max Force are deliberately excluded + // (see their own write blocks below) because isolated capture + // evidence now DISCONFIRMS the curve7-1..6 resync for them + // specifically (bug bundle 5VR5AQ8Y's max-threshold-4-41-105- + // 153-200.pcapng shows zero 0xAB traffic of any kind + // alongside 4 clean Threshold writes) — unlike Travel, which + // pedal_travel.pcapng directly confirmed DOES need it. + bool needsCurve7Resync = false; // Every per-pedal calibration here is a PHYSICAL setting stored // on that pedal's own mBooster unit (confirmed on hardware: each @@ -2689,12 +2697,12 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos : role == global::MozaPlugin.Devices.MBoosterRole.Clutch ? 2 : -1; byte dev = controller.MotorDeviceForRole(roleIdx, axis); - if (cfg.Direction >= 0) { controller.SendIntWrite($"mbooster-{prefix}-dir", cfg.Direction, dev); wroteAnyCalibration = true; } - if (cfg.Min >= 0) { controller.SendIntWrite($"mbooster-{prefix}-min", cfg.Min, dev); wroteAnyCalibration = true; } - if (cfg.Max >= 0) { controller.SendIntWrite($"mbooster-{prefix}-max", cfg.Max, dev); wroteAnyCalibration = true; } + if (cfg.Direction >= 0) { controller.SendIntWrite($"mbooster-{prefix}-dir", cfg.Direction, dev); needsCurve7Resync = true; } + if (cfg.Min >= 0) { controller.SendIntWrite($"mbooster-{prefix}-min", cfg.Min, dev); needsCurve7Resync = true; } + if (cfg.Max >= 0) { controller.SendIntWrite($"mbooster-{prefix}-max", cfg.Max, dev); needsCurve7Resync = true; } if (cfg.CurveY != null && cfg.CurveY.Length == 5) { - wroteAnyCalibration = true; + needsCurve7Resync = true; // Resample at the fixed 20/40/60/80/100 breakpoints in case // CurveX has been horizontally dragged (see // MozaMBoosterRegistry.ResampleCurveAtFixedBreakpoints) — @@ -2719,32 +2727,32 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos { controller.SendIntWrite("mbooster-brake-travel-start", global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeTravelMm(cfg.TravelStartMm), dev); - wroteAnyCalibration = true; + needsCurve7Resync = true; } if (ownsPedalFeelHardware && cfg.TravelEndMm >= 0) { controller.SendIntWrite("mbooster-brake-travel-end", global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeTravelMm(cfg.TravelEndMm), dev); - wroteAnyCalibration = true; + needsCurve7Resync = true; } if (ownsPedalFeelHardware && cfg.EndstopFrontStiffness >= 0) { controller.SendIntWrite("mbooster-brake-endstop-front", global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeEndstopStiffness(cfg.EndstopFrontStiffness), dev); - wroteAnyCalibration = true; + needsCurve7Resync = true; } if (ownsPedalFeelHardware && cfg.EndstopEndStiffness >= 0) { controller.SendIntWrite("mbooster-brake-endstop-end", global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeEndstopStiffness(cfg.EndstopEndStiffness), dev); - wroteAnyCalibration = true; + needsCurve7Resync = true; } if (ownsPedalFeelHardware && cfg.NaturalFrictionPct >= 0) { int frictionRaw = global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeFrictionPct(cfg.NaturalFrictionPct); controller.SendIntWrite("mbooster-brake-friction-0", frictionRaw, dev); controller.SendIntWrite("mbooster-brake-friction-1", frictionRaw, dev); - wroteAnyCalibration = true; + needsCurve7Resync = true; } // Segmented Damping (both "When Pressed" and "When // Released" — see cfg.SegmentedDamping). One wire command @@ -2774,32 +2782,60 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos sd.Seg3Released >= 0 ? sd.Seg3Released : c, dev); controller.SendOneShot(frame); - wroteAnyCalibration = true; + needsCurve7Resync = true; } if (role == global::MozaPlugin.Devices.MBoosterRole.Brake) { if (cfg.SensorOutputRatioPct >= 0) { controller.SendFloatWrite("mbooster-brake-angle-ratio", cfg.SensorOutputRatioPct, dev); - wroteAnyCalibration = true; + needsCurve7Resync = true; } + // Max Threshold does NOT set needsCurve7Resync — see the + // variable's own doc comment above. Confirmed by an + // isolated capture (max-threshold-4-41-105-153-200.pcapng, + // Max Force held static): zero 0xAB traffic of any kind + // alongside 4 clean Threshold writes. if (cfg.MaxThresholdKg >= 0) { controller.SendIntWrite("mbooster-brake-threshold", global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeThresholdKg(cfg.MaxThresholdKg), dev); - wroteAnyCalibration = true; } } + // Deadzone / Max Force — CONFIRMED real hardware calibration + // (see MBoosterDeviceController.PushFeelCurveResync). Fresh + // profile with neither set (-1) sends nothing, same guarantee + // as every other calibration write here. Once EITHER is set, + // the whole 8-value family is pushed together (the device has + // no partial-update form for it), using the pedal's own sane + // "off" default for whichever side has no override — 0kg + // deadzone, 200kg max force (an out-of-range pedal never + // presses hard enough for Max Force to matter). Does NOT set + // needsCurve7Resync: both max-force-24-75-128-166-200.pcapng + // and deadzone-0-5-11-14.pcapng show this family's own + // resync (selectors 0x07-0x0E) is everything the device + // needs — neither ever included a curve7-1..6 (selectors + // 0x01-0x06) frame. + if (ownsPedalFeelHardware && (cfg.DeadzoneKg >= 0 || cfg.MaxForceKg >= 0)) + { + double dz = cfg.DeadzoneKg >= 0 ? cfg.DeadzoneKg : 0; + double mf = cfg.MaxForceKg >= 0 ? cfg.MaxForceKg : 200; + controller.PushFeelCurveResync(dz, mf, dev); + } + // EXPERIMENTAL / unverified — confirmed on hardware to be // required for a Travel edit to actually take effect; applied // here too on the theory the same firmware requirement covers - // every write above, not just Travel. See - // MBoosterDeviceController.PushCurve7Resync. Guarded like the - // writes above (not unconditional) to preserve this method's - // "fresh profile with no overrides produces zero hardware - // writes" guarantee. - if (wroteAnyCalibration) + // Direction/Min/Max/CurveY/Endstop/Friction/SegmentedDamping/ + // Ratio as well — unconfirmed for those specifically, unlike + // Threshold and Deadzone/MaxForce (see needsCurve7Resync's own + // comment), which now have direct capture evidence against + // it. See MBoosterDeviceController.PushCurve7Resync. Guarded + // like the writes above (not unconditional) to preserve this + // method's "fresh profile with no overrides produces zero + // hardware writes" guarantee. + if (needsCurve7Resync) controller.PushCurve7Resync(cfg.CurveX, cfg.CurveY, dev); } } diff --git a/Protocol/MozaCommandDatabase.cs b/Protocol/MozaCommandDatabase.cs index 8fb74a16..0dc5ef72 100644 --- a/Protocol/MozaCommandDatabase.cs +++ b/Protocol/MozaCommandDatabase.cs @@ -618,6 +618,31 @@ static MozaCommandDatabase() AddCommand("mbooster-brake-curve7-4", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x04 }, 2, "int"); AddCommand("mbooster-brake-curve7-5", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x05 }, 2, "int"); AddCommand("mbooster-brake-curve7-6", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x06 }, 2, "int"); + // Pit House "Deadzone" and "Max Force" (Pedal Feel) — CONFIRMED real + // hardware calibration, reverse-engineered from two real Pit House + // captures (max-force-24-75-128-166-200.pcapng, + // deadzone-0-5-11-14.pcapng — see bug bundle 5VR5AQ8Y). Same cmdId + // 0xAB indexed-register family as curve7-1..6 above, but a + // DIFFERENT selector range (0x07-0x0E) carrying a genuinely separate + // 8-point curve: selector 0x07 = Deadzone, selector 0x0E = Max + // Force, both in kg using the identical encoding as Max Threshold + // (raw = round(kg * 65536 / 200) — see + // MozaMBoosterProtocol.EncodeThresholdKg). Selectors 0x08-0x0D are + // 6 interpolated points between the two anchors — see + // MozaMBoosterRegistry.ComputeFeelCurve. Unlike curve7-1..6 (never + // confirmed as a real requirement), every Max Force / Deadzone + // sweep in both captures resent this whole 8-value family as one + // atomic burst, and real Pit House does NOT clamp Max Force to Max + // Threshold (128kg/166kg were sent while Threshold read back as + // 125kg) — see docs/protocol/devices/mbooster.md "Pedal Feel". + AddCommand("mbooster-brake-deadzone", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x07 }, 2, "int"); + AddCommand("mbooster-brake-feelcurve-1", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x08 }, 2, "int"); + AddCommand("mbooster-brake-feelcurve-2", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x09 }, 2, "int"); + AddCommand("mbooster-brake-feelcurve-3", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x0A }, 2, "int"); + AddCommand("mbooster-brake-feelcurve-4", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x0B }, 2, "int"); + AddCommand("mbooster-brake-feelcurve-5", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x0C }, 2, "int"); + AddCommand("mbooster-brake-feelcurve-6", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x0D }, 2, "int"); + AddCommand("mbooster-brake-maxforce", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x0E }, 2, "int"); // Pit House "End Stop Stiffness" (Front Limit / End Limit) — // reverse-engineered from two real Pit House USB captures, each // sweeping one slider through all 10 values (1-10). Both share diff --git a/UI/SettingsControl.xaml b/UI/SettingsControl.xaml index c5b5a9eb..0c39000c 100644 --- a/UI/SettingsControl.xaml +++ b/UI/SettingsControl.xaml @@ -1893,6 +1893,11 @@ Text="1"/> + + @@ -1921,14 +1926,13 @@ - + diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs index 14ebffb2..8edb06af 100644 --- a/UI/SettingsControl.xaml.cs +++ b/UI/SettingsControl.xaml.cs @@ -3263,10 +3263,11 @@ private void SeedMBoosterConfigControls(IMBoosterPedalConfig? fx) float ee = fx?.EndstopEndStiffness ?? -1; MBoosterEndstopEndSlider.Value = ee >= 0 ? ee : 1; SetValueText(MBoosterEndstopEndValue, MBoosterEndstopEndSlider.Value.ToString("F0")); - MBoosterDeadzoneSlider.Value = fx?.DeadzoneKg ?? 0; - SetValueText(MBoosterDeadzoneValue, (fx?.DeadzoneKg ?? 0).ToString("F1")); - ApplyMBoosterMaxForceCeiling(fx); - MBoosterMaxForceSlider.Value = Math.Min(fx?.MaxForceKg ?? 200, MBoosterMaxForceSlider.Maximum); + float dz = fx?.DeadzoneKg ?? -1; + MBoosterDeadzoneSlider.Value = dz >= 0 ? dz : 0; + SetValueText(MBoosterDeadzoneValue, MBoosterDeadzoneSlider.Value.ToString("F1")); + float mf = fx?.MaxForceKg ?? -1; + MBoosterMaxForceSlider.Value = mf >= 0 ? mf : 200; SetValueText(MBoosterMaxForceValue, MBoosterMaxForceSlider.Value.ToString("F0")); float nf = fx?.NaturalFrictionPct ?? -1; MBoosterNaturalFrictionSlider.Value = nf >= 0 ? nf : 0; @@ -3286,29 +3287,6 @@ private void SeedMBoosterConfigControls(IMBoosterPedalConfig? fx) MBoosterSegDampReleasedPlot.Seg3Value = (sd?.Seg3Released ?? -1) >= 0 ? sd!.Seg3Released : MBoosterUiConstants.SegDampSegDefaultPct; } - /// - /// Cap the Max Force slider at the force the pedal's raw HID axis - /// actually reaches 100% at (). - /// Above that point the device has already pegged its own output, so - /// there is no resolution left for software to require more force — - /// every position past it was silently inert, which with Max Threshold - /// at 140kg left the whole top 30% of a 0-200 slider doing nothing - /// (bundle KY3HK4QP). The XAML's static "200" end label would then be - /// wrong, so it is rewritten to match. - /// - private void ApplyMBoosterMaxForceCeiling(IMBoosterPedalConfig? fx) - { - double ceiling = MozaMBoosterRegistry.ResolveFullScaleKg(fx, CurrentMBoosterController()); - if (ceiling <= 0) ceiling = 200; - MBoosterMaxForceSlider.Maximum = ceiling; - MBoosterMaxForceRangeEndLabel.Text = ceiling.ToString("F0"); - if (MBoosterMaxForceSlider.Value > ceiling) - { - MBoosterMaxForceSlider.Value = ceiling; - SetValueText(MBoosterMaxForceValue, ceiling.ToString("F0")); - } - } - private MBoosterDeviceController? CurrentMBoosterController() { return _plugin?.MBoosterRegistry?.FindByIdentity(_mboosterSelectedIdentity ?? ""); @@ -3497,15 +3475,16 @@ private async void MBoosterAdvancedEditFormula_Click(object sender, RoutedEventA /// /// The same gate hides the Pedal Feel controls that are real hardware /// writes on brake-named SINGLETON cmdIds — Travel (0x84/0x85), End Stop - /// (0xB2), Natural Friction (0xAE) and Segmented Damping (0xB7). None of - /// them carries a per-pedal selector, so editing them from a passive - /// pedal's page didn't configure that pedal — it silently overwrote the - /// ACTIVE pedal's registers (bundle KY3HK4QP: the passive throttle page's + /// (0xB2), Deadzone/Max Force (0xAB selectors 0x07/0x0E), Natural + /// Friction (0xAE) and Segmented Damping (0xB7). None of them carries a + /// per-pedal selector, so editing them from a passive pedal's page + /// didn't configure that pedal — it silently overwrote the ACTIVE + /// pedal's registers (bundle KY3HK4QP: the passive throttle page's /// 3.8/35.9mm travel is what the brake unit committed as Params 48/49). /// Inferred from the wire shape rather than from a Pit House capture of a /// passive-pedal edit — see docs/protocol/devices/mbooster.md. - /// Host-side-only controls (Deadzone, Max Force, the input curve) and the - /// per-role output curve stay visible for every pedal. + /// The input curve (host-side-only) and the per-role output curve stay + /// visible for every pedal. /// private void UpdateMBoosterEffectPassiveState() { @@ -3517,6 +3496,7 @@ private void UpdateMBoosterEffectPassiveState() MBoosterEffectsPassiveNote.Visibility = passive ? Visibility.Visible : Visibility.Collapsed; var hwVisibility = passive ? Visibility.Collapsed : Visibility.Visible; MBoosterTravelEndstopPanel.Visibility = hwVisibility; + MBoosterDeadzoneMaxForcePanel.Visibility = hwVisibility; MBoosterNaturalFrictionPanel.Visibility = hwVisibility; MBoosterSegDampCard.Visibility = hwVisibility; } @@ -4284,9 +4264,14 @@ private static byte MBoosterCalibDevice(global::MozaPlugin.Devices.MBoosterDevic /// one per tick (see MBoosterDeviceController.QueueCalibWrite). The /// EXPERIMENTAL curve7 resync every one of these writes needs to /// actually commit rides inside the same parked action, so it can never - /// be reordered ahead of the write it is committing. + /// be reordered ahead of the write it is committing — unless + /// is false, which + /// MBoosterMaxThresholdSlider_ValueChanged passes: an isolated capture + /// (max-threshold-4-41-105-153-200.pcapng) shows zero 0xAB traffic of + /// any kind alongside 4 clean Threshold writes, directly disconfirming + /// the resync for Threshold specifically. /// - private void QueueMBoosterCalibPush(string key, Action push) + private void QueueMBoosterCalibPush(string key, Action push, bool includeCurve7Resync = true) { var controller = CurrentMBoosterController(); if (controller == null) return; @@ -4296,10 +4281,33 @@ private void QueueMBoosterCalibPush(string key, Action { push(controller, dev); - controller.PushCurve7Resync(curveX, curveY, dev); + if (includeCurve7Resync) controller.PushCurve7Resync(curveX, curveY, dev); }); } + /// + /// Park a Deadzone/Max Force write (cmdId 0xAB selectors 0x07-0x0E) — + /// separate from because neither + /// capture that confirmed this family (max-force-24-75-128-166-200 + /// .pcapng, deadzone-0-5-11-14.pcapng) included the curve7-1..6 + /// resync that helper tacks on for every other calibration write, so + /// reusing it here would send frames Pit House itself never sends + /// for this field. Uses the CURRENT value of whichever of the two + /// fields didn't just change, since the device has no partial-update + /// form for this 8-value family — see + /// MBoosterDeviceController.PushFeelCurveResync. + /// + private void PushMBoosterFeelCurve(IMBoosterPedalConfig s) + { + var controller = CurrentMBoosterController(); + if (controller == null) return; + byte dev = MBoosterCalibDevice(controller, _mboosterEffectPedalIndex); + double dz = s.DeadzoneKg >= 0 ? s.DeadzoneKg : 0; + double mf = s.MaxForceKg >= 0 ? s.MaxForceKg : 200; + controller.QueueCalibWrite($"{dev:x2}:feel-curve", () => + controller.PushFeelCurveResync(dz, mf, dev)); + } + private void MBoosterTravelRangeSlider_RangeChanged(object sender, EventArgs e) { if (_suppressEvents) return; @@ -4320,10 +4328,13 @@ private void MBoosterTravelRangeSlider_RangeChanged(object sender, EventArgs e) _plugin.SaveSettings(); } - // Deadzone at the start of pedal travel (0..40kg, host-side only — - // see MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce). Decimal - // precision (0.1kg ticks), so this doesn't reuse OnIntSliderChanged - // (which rounds to whole numbers like the other mBooster sliders). + // Deadzone at the start of pedal travel (0..40kg) — CONFIRMED real + // hardware calibration (mbooster-brake-deadzone, cmdId 0xAB selector + // 0x07), reverse-engineered from deadzone-0-5-11-14.pcapng (bug + // bundle 5VR5AQ8Y). See MBoosterDeviceController.PushFeelCurveResync + // and PushMBoosterFeelCurve. Decimal precision (0.1kg ticks), so this + // doesn't reuse OnIntSliderChanged (which rounds to whole numbers + // like the other mBooster sliders). private void MBoosterDeadzoneSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) { if (_suppressEvents) return; @@ -4332,18 +4343,24 @@ private void MBoosterDeadzoneSlider_ValueChanged(object sender, RoutedPropertyCh var s = CurrentMBoosterEffectTarget(); if (s == null) return; s.DeadzoneKg = (float)v; + PushMBoosterFeelCurve(s); _plugin.SaveSettings(); } - // Max Force (0..200kg, host-side only, default 200 = off) — the - // force at which the Pedal Feel input curve's X-axis reaches 100%. - // See MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce. + // Max Force (0..200kg) — the force at which the pedal's raw HID axis + // reaches 100% travel. CONFIRMED real hardware calibration + // (mbooster-brake-maxforce, cmdId 0xAB selector 0x0E), reverse- + // engineered from max-force-24-75-128-166-200.pcapng (bug bundle + // 5VR5AQ8Y) — not clamped to Max Threshold on the wire. See + // MBoosterDeviceController.PushFeelCurveResync and + // PushMBoosterFeelCurve. private void MBoosterMaxForceSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterMaxForceValue, "", v => { var s = CurrentMBoosterEffectTarget(); if (s == null) return; s.MaxForceKg = v; + PushMBoosterFeelCurve(s); }); // Sensor Output Ratio — blend between the mBooster's angle sensor @@ -4368,7 +4385,11 @@ private void MBoosterRatioSlider_ValueChanged(object sender, RoutedPropertyChang // mbooster-brake-threshold (cmdId 0xB3), a 4-byte big-endian raw // uint (NOT a float) on a fixed 0-200kg scale — see // MozaMBoosterProtocol.EncodeThresholdKg and - // docs/protocol/devices/mbooster.md "Sim Input Mapping". + // docs/protocol/devices/mbooster.md "Sim Input Mapping". No curve7 + // resync (unlike every other QueueMBoosterCalibPush caller): an + // isolated capture (max-threshold-4-41-105-153-200.pcapng, Max Force + // held static) shows zero 0xAB traffic of any kind alongside 4 + // clean Threshold writes. private void MBoosterMaxThresholdSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, MBoosterMaxThresholdValue, "", v => { @@ -4377,11 +4398,8 @@ private void MBoosterMaxThresholdSlider_ValueChanged(object sender, RoutedProper s.MaxThresholdKg = v; QueueMBoosterCalibPush("brake-threshold", (c, dev) => c.SendIntWrite("mbooster-brake-threshold", - global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeThresholdKg(v), dev)); - // This IS the raw axis's full scale, so it is also the ceiling - // Max Force is expressed against — re-scale that slider now - // rather than leaving its top span silently inert. - ApplyMBoosterMaxForceCeiling(s); + global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeThresholdKg(v), dev), + includeCurve7Resync: false); }); // End Stop Stiffness (Front Limit / End Limit), 1-10 — Pit House's diff --git a/docs/protocol/devices/mbooster.md b/docs/protocol/devices/mbooster.md index 37c53f97..7a8c8f4e 100644 --- a/docs/protocol/devices/mbooster.md +++ b/docs/protocol/devices/mbooster.md @@ -33,13 +33,13 @@ two ways: ## USB identification -| Field | Value | -|-----------------|---------------------------------------------| -| Vendor ID | `0x346E` (Gudsen / Moza) | -| Product ID | `0x0008` | -| Category | `MozaDeviceCategory.MBooster` | -| HID match | VID+PID (no name regex — see [`MozaHidReader.cs`](../../../Protocol/MozaHidReader.cs)) | -| Baud rate | 115200 | +| Field | Value | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Vendor ID | `0x346E` (Gudsen / Moza) | +| Product ID | `0x0008` | +| Category | `MozaDeviceCategory.MBooster` | +| HID match | VID+PID (no name regex — see [`MozaHidReader.cs`](../../../Protocol/MozaHidReader.cs)) | +| Baud rate | 115200 | | Stable identity | USB device instance segment from the registry walk; fallback to the device instance ID surfaced by `HidDevice.DevicePath` — see `MBoosterDeviceController.Identity` | ## Chain topology & connectivity diagnostics @@ -62,7 +62,7 @@ line separates them. Support bundle KY3HK4QP (W17, 1.5.5) is the full failure. The device reported: -``` +```text Throttle pedal is connected, type: passive pedal Brake pedal is connected, type: active pedal Clutch pedal is connected, type: passive pedal @@ -74,9 +74,9 @@ plugin read `PD Linked`'s count of 3 as a chain and addressed the brake at `0x1d`. The capture's response tally is unambiguous: | Target | Writes sent | Responses / write-echoes (`a4 21`) | -|---|---|---| -| `0x12` | 16 | **16** | -| `0x1d` | 1212 | **0** | +| ------ | ----------- | ---------------------------------- | +| `0x12` | 16 | **16** | +| `0x1d` | 1212 | **0** | Reads: 52 to `0x12` all answered; 16 to `0x1d`/`0x1e` answered **none**. Every RX frame in both capture files carries source byte `21` (= `0x12` @@ -146,13 +146,13 @@ The only reliable connectivity source is the group-`0x0E` firmware diagnostic stream, which the device re-emits about once a minute in one of two dialects: -| | Short form (device-type `01-02-00-08`, 3 HID axes) | Long form (device-type `01-02-07-05`, 4 HID axes) | -|---|---|---| -| Connectivity | `PD Linked:[T 0 B 1 C 0]` | `Pedals connected state: [throttle 0 brake 1 clutch 0]` | -| Per-pedal type | `Brake pedal is connected, type: active pedal` / `Throttle pedal is not connected !` | same | -| Sensor dir | `Sensor Dir:[T 1 B -1 C -1]` | `Sensor direction: [throttle 1 brake -1 clutch -1]` | -| Output dir | `OP Dir:[T 0 B 0 C 0]` | `Output direction: [throttle 0 brake 0 clutch 0]` | -| Pot angles | `T-PD:[min … max … angle …]` | `Throttle calibrate theta:[min … max … angle …]` | +| | Short form (device-type `01-02-00-08`, 3 HID axes) | Long form (device-type `01-02-07-05`, 4 HID axes) | +| -------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------- | +| Connectivity | `PD Linked:[T 0 B 1 C 0]` | `Pedals connected state: [throttle 0 brake 1 clutch 0]` | +| Per-pedal type | `Brake pedal is connected, type: active pedal` / `Throttle pedal is not connected !` | same | +| Sensor dir | `Sensor Dir:[T 1 B -1 C -1]` | `Sensor direction: [throttle 1 brake -1 clutch -1]` | +| Output dir | `OP Dir:[T 0 B 0 C 0]` | `Output direction: [throttle 0 brake 0 clutch 0]` | +| Pot angles | `T-PD:[min … max … angle …]` | `Throttle calibrate theta:[min … max … angle …]` | `MBoosterDeviceController.LogPedalDiagnosticIfRelevant` parses both connectivity forms into `ConnectedAxes` (which pedal slots exist — drives @@ -224,7 +224,7 @@ stuffing routines handle all framing. Built inline by [`MozaMBoosterProtocol.BuildMotorFrame`](../../../Protocol/MozaMBoosterProtocol.cs). 14 bytes pre-stuffing. -``` +```text 7e 09 24 12 b1 EF EN 00 P1 FH FL AH AL CK │ │ │ │ │ └─┴─freq u16 BE │ │ │ │ └ param1 (1..255) @@ -236,18 +236,18 @@ Built inline by [`MozaMBoosterProtocol.BuildMotorFrame`](../../../Protocol/MozaM **Effect IDs** (enum [`MBoosterEffectId`](../../../Protocol/MozaMBoosterProtocol.cs)): -| ID | Name | ParamK | Trigger condition (host-side, doc § 4) | -|-----|-----------|--------|----------------------------------------| -| `1` | ABS | 2000 | `absActive > 0.1` from SimHub | +| ID | Name | ParamK | Trigger condition (host-side, doc § 4) | +| --- | --------- | ------ | -------------------------------------------------------------------------------------------------------- | +| `1` | ABS | 2000 | `absActive > 0.1` from SimHub | | `2` | Lockup | 2640 | Heavy brake (>0.8) + wheels < 30 % of vehicle speed (fallback: brake > 0.9 when wheel speed unavailable) | -| `3` | Threshold | 3080 | Rising edge on brake > 0.6; release at < 0.3 (hysteresis) | -| `4` | Engine | 1000 | `rpm > 0.8 × idleRpm` — runs continuously | +| `3` | Threshold | 3080 | Rising edge on brake > 0.6; release at < 0.3 (hysteresis) | +| `4` | Engine | 1000 | `rpm > 0.8 × idleRpm` — runs continuously | **Known-good frames** (verified against the protocol note's hardware captures — diff against these in a `SerialTrafficCapture` export to confirm wire correctness): -``` +```text ABS on, 22Hz, amp=0x08e8: 7e 09 24 12 b1 01 01 00 5a 1c 28 08 e8 0b ABS off: 7e 09 24 12 b1 01 00 00 00 00 00 00 00 7c Lockup on, 55Hz, ramp 0: 7e 09 24 12 b1 02 01 00 30 46 66 00 00 5a @@ -286,7 +286,7 @@ the motor after the port closes. All motor frames go through a single per-connection lane: -``` +```text StreamKind.MBoosterEffect = 17 ``` @@ -304,11 +304,11 @@ they aren't coalesced. reproduces protocol note § 4 verbatim: | Effect | Waveform | -|-----------|-------------------------------------------------------------| -| ABS | `wave = 0.9 + 0.1 * sin(phase); amp = wave * intensity` | -| Lockup | `ramp = clamp(elapsed / 0.5, 0, 1); amp = ramp * intensity` | -| Threshold | 5 Hz envelope: 20 ms full + 120 ms 80 % + 60 ms gap | -| Engine | `wave = 0.5 + 0.5 * sin(phase); amp = wave * intensity` | +| --------- | ----------------------------------------------------------- | +| ABS | `wave = 0.9 + 0.1 * sin(phase); amp = wave * intensity` | +| Lockup | `ramp = clamp(elapsed / 0.5, 0, 1); amp = ramp * intensity` | +| Threshold | 5 Hz envelope: 20 ms full + 120 ms 80 % + 60 ms gap | +| Engine | `wave = 0.5 + 0.5 * sin(phase); amp = wave * intensity` | Engine intensity is clamped to 10 % at apply time (doc § 4 default `engineScale = 0.01`, clamped to `[0, 0.1]`) — engine runs @@ -464,7 +464,7 @@ firmware — sustained valid frames, not silently dropped. **The wire payload shape is materially different from the other four**, reverse-engineered from the stepped capture: -``` +```text 7e 09 24 12 b1 09 EN SH SL NH NL IH IL CK │ │ │ └─┴─smoothness u16 BE └─┴─intensity u16 BE │ │ └ enable (0 = off, 1 = on) @@ -938,16 +938,24 @@ Calibration card (Direction / Min Raw / Max Raw / Read from device / Apply) surfaces this as experimental with a yellow warning. Every one of these registers is **flash-backed**, and each write -additionally drags the 6-frame [curve7 resync](#pedal-feel-host-side-only) +additionally drags the 6-frame [curve7 resync](#pedal-feel) behind it, so the slider handlers must not write per tick. Bundle KY3HK4QP -shows the cost unthrottled: a ~2 s Max Threshold drag emitted 77 threshold -+ 462 curve7 frames — ~40 writes/second into flash. UI writes are therefore -parked latest-wins per (device, command) and flushed once the drag settles +(AZOM's own traffic, not a Pit House capture — the plugin unconditionally +tacked the resync onto every calibration write at the time) shows the cost +unthrottled: a ~2 s Max Threshold drag emitted 77 threshold and 462 curve7 +frames — ~40 writes/second into flash. UI writes are therefore parked +latest-wins per (device, command) and flushed once the drag settles (`MBoosterDeviceController.QueueCalibWrite`, ~400 ms quiet window — the same shape `HardwareApplier.QueueWheelCfgWrite` uses for the wheel's own flash-backed writes). The resync rides *inside* the parked action so it can never be reordered ahead of the write it commits. The connect-time apply (`MozaPlugin.ApplyMBoosterToHardware`) fires immediately and is not parked. +**Update (bug bundle 5VR5AQ8Y):** an isolated real Pit House capture of +Max Threshold alone (`max-threshold-4-41-105-153-200.pcapng`) shows zero +curve7 traffic — Pit House itself never sent the 462 frames the KY3HK4QP +number implied were needed. The resync is no longer sent for Max +Threshold (nor for Deadzone/Max Force — see below); this quiet-window +parking still applies to whichever calibrations still carry it. ## Sim Input Mapping @@ -994,7 +1002,7 @@ The device read-back is now consumed (it is deliberately *not* copied into `MaxThresholdKg`, whose `-1` means "user set no override" — seeding it would make the plugin write the value back on every connect). -## Pedal Feel (host-side only) +## Pedal Feel A card above Sim Input Mapping holds a second 5-point curve, `InputCurveY` on `MBoosterDeviceSettings`. Unlike `CurveY`, this one has @@ -1124,7 +1132,7 @@ including ones unrelated to whatever the user was actually dragging in that particular capture, proving this is always a whole-feature snapshot, never a partial update: -``` +```text cmd=0xB7 Div1Pressed Div2Pressed Div1Released Div2Released Seg1Pressed Seg1Released Seg2Pressed Seg2Released Seg3Pressed Seg3Released ``` @@ -1172,66 +1180,84 @@ divider or a segment on EITHER plot, at which point any still-unset field on the OTHER plot is filled from the factory defaults above rather than left blank (the wire frame has no concept of "not sent" per field). -The same card also has two force-based sliders, both host-side only and -both applied in `MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce`, which -runs *before* `EvaluateInputCurve`: - -- **Deadzone** (`DeadzoneKg`, 0–40kg, default 0 = off) — force below - this clamps to 0. -- **Max Force** (`MaxForceKg`, 0–200kg, default 200 = off) — the force - at which the *input curve's* X-axis reaches 100%. Lets a user who - never presses past, say, 100kg use the curve's full 0–100% range - instead of only ever reaching its midpoint. - -**Update**: originally both were combined into a kg-space remap that -treated raw 0–100% travel as a fixed 0–200kg full scale. That's wrong -whenever the device's real calibration isn't 200kg — and real Pit -House captures already on file for `MaxThresholdKg` show ~100-125kg, -not 200kg. Raw 100% travel is only ever as many kg as -`MaxThresholdKg` (Sim Input Mapping, a genuine hardware calibration — -see above) currently says it is; past that point the device itself -has already pegged its own output at 100%, so there is no more -resolution left for software to detect additional force. Concretely, -the bug this caused: setting Max Force to 200kg (its slider max) was -silently a no-op whenever `MaxThresholdKg` was lower, because -`hiPercent` degenerated to the same 100% raw-travel point the axis -already saturates at — pressing anywhere near the device's real max -already read as 100% input, never requiring the full 200kg the slider -implied. - -Fixed by threading the pedal's actual full scale through as -`ApplyDeadzoneAndMaxForce`'s `fullScaleKg` reference, resolved by -`MozaMBoosterRegistry.ResolveFullScaleKg` — the user's own -`MaxThresholdKg` override, else the value the DEVICE reported for -`mbooster-brake-threshold` (`MBoosterDeviceController -.DeviceReportedMaxThresholdKg` — a real read-back, live only and never -persisted), else the historical 200 kg last resort. Force below the -deadzone clamps to 0, and everything between the deadzone and Max Force -rescales linearly to 0–100%, same as before — just against the real -reference scale instead of a hardcoded one. (This used to be spelled out -in a UI hint, `Hint_DeadzoneScaleAssumption`, but that string was never -actually filled in for English — only other locales had it translated — -so the English UI showed a blank line; the hint has since been removed -entirely rather than backfilled.) - -Practical implication for users: Max Force can only ever *lower* the -effort needed to reach 100% below the pedal's real full scale — it can't -demand *more* force than the device's own calibration already saturates -at, so getting a genuine "200kg to reach 100%" feel requires setting Max -Threshold to 200kg first (Sim Input Mapping), not just Max Force. Every -Max Force position above the full scale was therefore silently inert, -which with Max Threshold at 140 kg left the top 30% of a 0–200 slider -doing nothing — reported as "Max force seems to be doing absolutely -nothing" in bundle KY3HK4QP. The slider's `Maximum` (and its end label) -are now clamped to the resolved full scale -(`SettingsControl.ApplyMBoosterMaxForceCeiling`), re-applied whenever Max -Threshold changes. - -Both remain host-side only: they shape -`MozaData.{Throttle,Brake,Clutch}Position`, which feeds the `AZOM.*` -properties, the pedal traces and the live curve markers — **not** the -game, which reads the pedal's HID directly, and not the pedal's own feel. -There is no wire command for either. +### Deadzone / Max Force — REVISED: real hardware calibration, not host-side (bug bundle 5VR5AQ8Y) + +The same card also has two force-based sliders, **Deadzone** (`DeadzoneKg`, +0–40kg) and **Max Force** (`MaxForceKg`, 0–200kg). These were originally +implemented as a purely host-side kg-space remap +(`MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce`, applied to the raw HID +axis position before `EvaluateInputCurve`, and clamped to whatever +`MaxThresholdKg` currently resolved to — see git history for that design +and the ceiling bug it needed, `SettingsControl.ApplyMBoosterMaxForceCeiling`). + +Two more bug reports for "Max Force does nothing" (5VR5AQ8Y, following +KY3HK4QP) prompted two fresh Pit House USB captures made specifically to +settle the question: `max-force-24-75-128-166-200.pcapng` (Threshold held +fixed, Max Force dragged through 75/128/166kg) and +`deadzone-0-5-11-14.pcapng` (Max Force held fixed at 24kg, Deadzone +dragged through 5/11/14kg). Both confirm the user's own description of +Pit House's real behavior: Deadzone and Max Force **are** real hardware +calibration, not a host-side shim — the previous design's core assumption +was wrong. + +Every drag stop in both captures wrote the same family: cmdId `0xAB` +(the same command `mbooster-brake-curve7-*` above uses, but a +**different, previously-undiscovered selector range**), fixed `0x00` +byte, one of 8 selectors, 2-byte big-endian value using the *identical* +kg encoding as `MaxThresholdKg` (`raw = round(kg * 65536 / 200)` — see +`MozaMBoosterProtocol.EncodeThresholdKg`, reused as-is): + +- **selector `0x07` = Deadzone** (`mbooster-brake-deadzone`) — confirmed + exact: 5/11/14kg encoded and decoded back to 5.0/11.0/14.0kg. +- **selector `0x0E` = Max Force** (`mbooster-brake-maxforce`) — confirmed + exact: 75/128/166kg encoded and decoded back to 75.0/128.0/166.0kg. +- **selectors `0x08`–`0x0D`** = 6 interpolated points between the two + anchors. Computing `(value - deadzone) / (maxForce - deadzone)` for + each of the 6 across all 6 write bursts (3 per capture) landed on the + same constant per selector every time (std-dev < 0.0001), confirming a + fixed shape independent of which endpoint moved: + `{0.08049, 0.19495, 0.44245, 0.72433, 0.90040, 0.97910}` for selectors + `0x08`.. `0x0D` respectively — see + `MozaMBoosterRegistry.ComputeFeelCurve`/`FeelCurveFractions` and + `MBoosterDeviceController.PushFeelCurveResync`, which pushes all 8 + values as one burst (same "no partial update" shape as Segmented + Damping — the device has no way to change one point in isolation). + Why these specific fractions, rather than an evenly-spaced ramp: not + determined — treated as an empirically-measured constant, same + epistemic status as the Segmented Damping factory defaults above. + +Selector `0x04` also rides along unchanged in every single burst across +both captures (raw `0x9126`, every time) — it doesn't correlate with +either Deadzone or Max Force, so it's presumably some other Pedal Feel +field Pit House's UI flushes as part of the same batch. Not needed for +Deadzone/Max Force to work and not written by AZOM's own push. + +**Max Force is confirmed NOT clamped to Max Threshold on the wire** — +128kg and 166kg were sent as Max Force while Max Threshold read back as +125kg (`mbooster-brake-threshold` readback, same read-all poll that +confirmed selector `0x07`). This directly contradicts the original +design's ceiling logic (`ApplyMBoosterMaxForceCeiling`, +`ResolveFullScaleKg` — both removed): Max Force is an independent +parameter, not a rescale of Threshold's own span. The slider's range is +simply the fixed 0–200kg the XAML always declared. + +Like every other real calibration field, both use the shared `-1` "not +yet set / no override" sentinel (previously 0/200 = "off"), so a fresh +profile never overwrites whatever the device already has; once either is +set, the missing one falls back to a sane "off" default (0kg deadzone, +200kg max force) so the write is always a complete, valid curve. Same +brake-named-singleton passive-pedal gating as Travel/End Stop/Friction/ +Segmented Damping applies (`MBoosterDeadzoneMaxForcePanel` in +`SettingsControl.xaml`) — cmdId `0xAB`'s selectors carry no per-pedal +address either, so editing them from a passive pedal's page would +overwrite the active pedal's registers the same way KY3HK4QP found for +Travel. + +`InputCurveY` (the 5-point curve above) remains genuinely host-side — +no wire command was found for it in either capture, and it still shapes +`MozaData.{Throttle,Brake,Clutch}Position` (the `AZOM.*` properties, the +pedal traces, the live curve markers) *after* whatever the device now +delivers already-shaped on the raw HID axis. ### Traction Control — new effect, no verified wire type @@ -1278,7 +1304,7 @@ raw wheel-slip physics heuristic in `UpdateWheelSpinRequest` — the acceleration-side counterpart to Lockup's braking-side heuristic (see "Lockup rebuild" above): -``` +```text isSpinning = throttle > 0.8 && vehicleSpeed < 40 && avgWheelSpeed > vehicleSpeed * 1.3 ``` @@ -1463,14 +1489,14 @@ mapping") holds the Pit House-parity controls, all still under identity; dragging the last node from X=100 to X=60 (Y unchanged) makes breakpoints 60/80/100 all resample to that node's Y. -| Command | Group (R/W) | CmdId | Bytes | Type | -|-------------------------------|-------------|-------|-------|-------| -| `mbooster-throttle-dir/min/max` | 35 / 36 | 1/2/3 | 2 | int | -| `mbooster-brake-dir/min/max` | 35 / 36 | 4/5/6 | 2 | int | -| `mbooster-clutch-dir/min/max` | 35 / 36 | 7/8/9 | 2 | int | -| `mbooster-{throttle,brake,clutch}-y1..y5` | 35 / 36 | 14-29 | 4 | float | -| `mbooster-{throttle,brake,clutch}-output` | 37 / — | 1/2/3 | 2 | int | -| `mbooster-brake-angle-ratio` | 35 / 36 | 26 | 4 | float | +| Command | Group (R/W) | CmdId | Bytes | Type | +| ----------------------------------------- | ----------- | ----- | ----- | ----- | +| `mbooster-throttle-dir/min/max` | 35 / 36 | 1/2/3 | 2 | int | +| `mbooster-brake-dir/min/max` | 35 / 36 | 4/5/6 | 2 | int | +| `mbooster-clutch-dir/min/max` | 35 / 36 | 7/8/9 | 2 | int | +| `mbooster-{throttle,brake,clutch}-y1..y5` | 35 / 36 | 14-29 | 4 | float | +| `mbooster-{throttle,brake,clutch}-output` | 37 / — | 1/2/3 | 2 | int | +| `mbooster-brake-angle-ratio` | 35 / 36 | 26 | 4 | float | All targeted at device id `0x12` on the mBooster's own CDC port. The plugin's [`MozaResponseParser`](../../../Protocol/MozaResponseParser.cs) @@ -1492,7 +1518,7 @@ position bar stuck at 0 despite the device showing "Connected"): the "shared prefix, differing only in trailing interface index" theory above is **wrong**. A real capture showed: -``` +```text HID: 9&1bd82a3a&0&0000 CDC: 8&1709245b&0&0000 ``` @@ -1575,7 +1601,7 @@ three sections** — but only its own role gets the extended block (effects, travel limits, damping/friction, force curves). The other two hold just the device-wide snapshot: -``` +```text channlRoleType, outdir, min, max, nonlinear1..5, press_combine ``` @@ -1599,23 +1625,23 @@ Prefixed `

` = `throttle` / `brake` / `clutch`. Every effect field is host-rendered (see [Effect synthesis](#effect-synthesis)); the calibration rows reach the device through `MozaPlugin.ApplyMBoosterToHardware`. -| PitHouse key | Plugin field | Notes | -|---|---|---| -| `

_outdir` | `Direction` | `mbooster-

-dir` | -| `

_min` / `

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

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

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

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

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

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

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

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

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

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

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

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

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

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

_outdir` | `Direction` | `mbooster-

-dir` | +| `

_min` / `

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

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

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

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

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

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

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

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

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

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

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

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

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

_softlimit_hardness_press` / `_release` | `EndstopFrontStiffness` / `EndstopEndStiffness` | **inferred**, see below | +| `brake_press_combine` | `SensorOutputRatioPct` | **inferred**; brake role only (`mbooster-brake-angle-ratio` is written only for Brake) | Values are clamped to the plugin's own slider bounds (`MBoosterUiConstants`) on import, and the travel pair additionally honours `TravelMinGapMm` / @@ -1693,13 +1719,13 @@ name the mBooster goes to the CRP surface whenever CRP-family pedals are detected; with none detected it stays on the mBooster path so the "no mBooster pedal attached" note is what the user sees. -| PitHouse key | Plugin field | Wire command | -|---|---|---| -| `

_outdir` | `MozaProfile.Pedals

Dir` | `pedals-

-dir` | -| `

_min` / `

_max` | `Pedals

Min` / `Pedals

Max` | `pedals-

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

_nonlinear1..5` | `Pedals

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

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

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

_outdir` | `MozaProfile.Pedals

Dir` | `pedals-

-dir` | +| `

_min` / `

_max` | `Pedals

Min` / `Pedals

Max` | `pedals-

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

_nonlinear1..5` | `Pedals

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

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

_channlRoleType` | *(not imported)* | — (CRP roles are fixed) | `min`/`max` **are** imported here, unlike on the mBooster path: the CRP fields are percent on both sides (`MozaProfile.PedalsThrottleMin` is documented 0-100,