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..59944f3d 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 { @@ -246,9 +246,23 @@ public int SoleActiveAxis() // Per-axis pre-input-curve percent (0..100) — the same signal as // LastRawPercentPreCurve (after deadzone/max-force, before the input // curve) but for EVERY pedal, so the settings tab's live curve markers - // track whichever pedal is selected, not just the master. + // track whichever pedal is selected, not just the master. NOTE: since + // MozaMBoosterRegistry.OnHidAxisUpdate added the host-side Max + // Threshold rescale, this is "% of Threshold's span" (the Sim Input + // Mapping curve's own input domain) — see LastAxisRawPercentPreThreshold + // below for the true raw reading (% of Max Force's span) instead. public readonly double[] LastAxisRawPercentPreCurve = new double[MaxAxes]; + // Per-axis TRUE raw HID percent (0..100), captured BEFORE the host-side + // Max Threshold rescale (see MozaMBoosterRegistry.OnHidAxisUpdate) — + // i.e. genuinely "% of Max Force's own hardware ceiling", the physical + // force the user is actually applying to the pedal. This is the + // Pedal Feel curve's real input domain (Deadzone-Max Force span), and + // what the "Input Force" live label/marker should show — unlike + // LastAxisRawPercentPreCurve, which is now post-Threshold-rescale and + // represents the Sim Input Mapping curve's own (different) domain. + public readonly double[] LastAxisRawPercentPreThreshold = new double[MaxAxes]; + // Highest axis index + 1 the HID has reported for this lane: 1 for a // lone pedal, up to 3 for a full chain. 0 until the first axis update. public int AxisCount { get; internal set; } @@ -1181,34 +1195,54 @@ public bool SendFloatWrite(string commandName, float value, byte? device = null) } /// - /// EXPERIMENTAL / unverified — resend the output curve at 7 - /// breakpoints (mbooster-brake-curve7-*, cmdId 0xAB) after a - /// real hardware calibration write. pedal_travel.pcapng showed Pit - /// House doing exactly this alongside a Travel Start write, and - /// omitting it is what made Travel Start/End silently no-op on - /// hardware despite the raw register write reading back fine — see - /// MozaCommandDatabase.cs's mbooster-brake-curve7-* comment. Callers - /// use this after 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. + /// Write Deadzone, Max Force, and the Pedal Feel curve's 6 nodes on + /// BOTH axes between them (cmdId 0xAB selectors 0x01-0x0E) as one + /// atomic burst — CONFIRMED real hardware calibration. The Y half + /// (selectors 0x07-0x0E: Deadzone, 6 nodes, Max Force) is 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. The X half (selectors + /// 0x01-0x06, one per node) is reverse-engineered from + /// pedal-feel-node{2,5}-{x,y}-adjust.pcapng: every isolated single- + /// node drag (on EITHER axis) wrote that node's X selector and its Y + /// selector together, X first — sent here in the same order for + /// consistency, though a full resync's exact intra-burst ordering is + /// unconfirmed to matter. All fields use the identical kg encoding + /// as Max Threshold (). + /// / are + /// the Pedal Feel curve's own 6 user-adjustable nodes per axis + /// (0-100%, null/wrong-length = use the default Linear shape) — see + /// . /// - public void PushCurve7Resync(float[]? curveX, float[]? curveY, byte device) + public void PushFeelCurveResync(double deadzoneKg, double maxForceKg, float[]? inputCurveY, float[]? inputCurveX, byte device) { - var curve7 = MozaMBoosterRegistry.ResampleCurveAtSevenths(curveX, curveY); - for (int i = 0; i < curve7.Length; i++) - SendIntWrite($"mbooster-brake-curve7-{i + 1}", MozaMBoosterProtocol.EncodeCurve7Point(curve7[i]), device); + SendIntWrite("mbooster-brake-deadzone", MozaMBoosterProtocol.EncodeThresholdKg(deadzoneKg), device); + var midX = MozaMBoosterRegistry.ComputeFeelCurve(deadzoneKg, maxForceKg, inputCurveX); + var midY = MozaMBoosterRegistry.ComputeFeelCurve(deadzoneKg, maxForceKg, inputCurveY); + for (int i = 0; i < midY.Length; i++) + { + SendIntWrite($"mbooster-brake-feelcurve-x-{i + 1}", MozaMBoosterProtocol.EncodeThresholdKg(midX[i]), device); + SendIntWrite($"mbooster-brake-feelcurve-{i + 1}", MozaMBoosterProtocol.EncodeThresholdKg(midY[i]), device); + } + SendIntWrite("mbooster-brake-maxforce", MozaMBoosterProtocol.EncodeThresholdKg(maxForceKg), device); } // ── Coalescing gate for UI-driven calibration writes ── - // A slider raises ValueChanged per tick, and every one of these commands - // is a flash-backed calibration register that additionally drags a - // 6-frame PushCurve7Resync burst behind it. Bundle KY3HK4QP shows what - // that costs unthrottled: a ~2 s Max Threshold drag emitted 77 threshold - // + 462 curve7 frames, ~40 writes/second into flash. So UI writes are - // parked in a latest-wins slot per (device, command) and flushed once the - // user stops moving, collapsing a whole drag into one write set. Same - // pending+coalesce+throttle shape HardwareApplier.QueueWheelCfgWrite uses - // for the wheel's own flash-backed writes, minus its change cache. + // A slider raises ValueChanged per tick, and every one of these + // commands is a flash-backed calibration register — writing it on + // every tick of a drag would hammer flash unnecessarily. (An + // earlier design also dragged a 6-frame curve7 resync behind every + // write here, motivated by bundle KY3HK4QP's "~2s Max Threshold + // drag emitted 77 threshold + 462 curve7 frames" cost — that resync + // was later removed as unconfirmed/unneeded, but the coalescing + // below is still worth it purely for the primary writes.) UI writes + // are parked in a latest-wins slot per (device, command) and + // flushed once the user stops moving, collapsing a whole drag into + // one write set. Same pending+coalesce+throttle shape + // HardwareApplier.QueueWheelCfgWrite uses for the wheel's own + // flash-backed writes, minus its change cache. // // The connect-time apply (MozaPlugin.ApplyMBoosterToHardware) deliberately // does NOT go through this — it fires once and must not be deferred. diff --git a/Devices/MBoosterTypes.cs b/Devices/MBoosterTypes.cs index 3320edee..2a5c6ecf 100644 --- a/Devices/MBoosterTypes.cs +++ b/Devices/MBoosterTypes.cs @@ -134,6 +134,17 @@ public static class MBoosterUiConstants public const float SegDampDivider1ReleasedDefaultPct = 20f; public const float SegDampDivider2ReleasedDefaultPct = 70f; public const float SegDampSegDefaultPct = 0f; + + // Node counts for the two mBooster curve editors (both were 5-point + // originally). Sim Input Mapping (CurveY/CurveX) is purely host-side, + // no wire command — see MozaMBoosterRegistry.EvaluateCurveArbitraryX. + // Pedal Feel (InputCurveY) is a REAL hardware write, populating + // mbooster-brake-feelcurve-1..6 (cmdId 0xAB selectors 0x08-0x0D) — + // see MozaMBoosterRegistry.ComputeFeelCurve and + // MBoosterDeviceController.PushFeelCurveResync. See + // docs/protocol/devices/mbooster.md "Sim Input Mapping" / "Pedal Feel". + public const int SimInputMappingNodeCount = 6; + public const int PedalFeelNodeCount = 6; } /// @@ -314,6 +325,14 @@ public sealed class MBoosterSegmentedDampingSettings public float Seg2Released { get; set; } = -1; public float Seg3Released { get; set; } = -1; + // Master on/off for the whole feature. Off is a software-side + // convention, not a separate wire command: it sends the same + // BuildSegmentedDampingFrame with all six segment-damping fields + // forced to 0% (dividers left as-is — they're inert once every + // segment damps at 0%). Defaults true so untouched profiles match + // Pit House's own factory-enabled state. + public bool DampingEnabled { get; set; } = true; + public MBoosterSegmentedDampingSettings Clone() => new MBoosterSegmentedDampingSettings { @@ -327,6 +346,7 @@ public MBoosterSegmentedDampingSettings Clone() => Seg1Released = Seg1Released, Seg2Released = Seg2Released, Seg3Released = Seg3Released, + DampingEnabled = DampingEnabled, }; } @@ -430,6 +450,7 @@ public interface IMBoosterPedalConfig : IMBoosterEffects float MaxThresholdKg { get; set; } // Pedal Feel float[]? InputCurveY { get; set; } + float[]? InputCurveX { get; set; } float DeadzoneKg { get; set; } float MaxForceKg { get; set; } float TravelStartMm { get; set; } @@ -437,6 +458,7 @@ public interface IMBoosterPedalConfig : IMBoosterEffects float EndstopFrontStiffness { get; set; } float EndstopEndStiffness { get; set; } float NaturalFrictionPct { get; set; } + bool NaturalFrictionEnabled { get; set; } MBoosterSegmentedDampingSettings SegmentedDamping { get; set; } } @@ -456,22 +478,29 @@ public sealed class MBoosterPedalSettings : IMBoosterPedalConfig public int Direction { get; set; } = -1; public int Min { get; set; } = -1; public int Max { get; set; } = -1; - public float[]? CurveY { get; set; } = null; // 5-point output curve + public float[]? CurveY { get; set; } = null; // 6-point output curve (host-side only) public float[]? CurveX { get; set; } = null; // draggable node X (null = fixed breakpoints) // Sim Input Mapping (see MBoosterDeviceSettings for the field semantics). public float SensorOutputRatioPct { get; set; } = -1; public float MaxThresholdKg { get; set; } = -1; - // Pedal Feel (host-side shaping + brake-only wire calibration). + // Pedal Feel — InputCurveY (6-point), Deadzone, and MaxForce are ALL + // 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; + // X position (0-100% of the Deadzone-Max Force span) of each Pedal + // Feel node — see MBoosterDeviceSettings.InputCurveX. + public float[]? InputCurveX { get; set; } = null; + 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; public float EndstopEndStiffness { get; set; } = -1; public float NaturalFrictionPct { get; set; } = -1; + // Master on/off — see MBoosterDeviceSettings.NaturalFrictionEnabled. + public bool NaturalFrictionEnabled { get; set; } = true; public MBoosterSegmentedDampingSettings SegmentedDamping { get; set; } = new MBoosterSegmentedDampingSettings(); // Per-pedal vibration effects (same defaults as the master's flat fields). @@ -497,6 +526,7 @@ public MBoosterPedalSettings Clone() => SensorOutputRatioPct = SensorOutputRatioPct, MaxThresholdKg = MaxThresholdKg, InputCurveY = InputCurveY == null ? null : (float[])InputCurveY.Clone(), + InputCurveX = InputCurveX == null ? null : (float[])InputCurveX.Clone(), DeadzoneKg = DeadzoneKg, MaxForceKg = MaxForceKg, TravelStartMm = TravelStartMm, @@ -504,6 +534,7 @@ public MBoosterPedalSettings Clone() => EndstopFrontStiffness = EndstopFrontStiffness, EndstopEndStiffness = EndstopEndStiffness, NaturalFrictionPct = NaturalFrictionPct, + NaturalFrictionEnabled = NaturalFrictionEnabled, SegmentedDamping = SegmentedDamping?.Clone() ?? new MBoosterSegmentedDampingSettings(), Abs = Abs?.Clone() ?? new MBoosterEffectSettings(), Lockup = Lockup?.Clone() ?? new MBoosterEffectSettings(), @@ -621,8 +652,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 @@ -641,19 +677,24 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig public int Direction { get; set; } = -1; public int Min { get; set; } = -1; public int Max { get; set; } = -1; - public float[]? CurveY { get; set; } = null; // 5-point output curve + + // Sim Input Mapping output curve (6-point) — PURELY host-side, no + // wire command at all. Remaps the pedal's raw HID position (which + // by this point already reflects Deadzone/Max Force/the Pedal Feel + // curve's hardware shaping) into what AZOM reports as game + // telemetry (MozaData.{Throttle,Brake,Clutch}Position) — see + // MozaMBoosterRegistry.OnHidAxisUpdate/EvaluateCurveArbitraryX and + // docs/protocol/devices/mbooster.md "Sim Input Mapping". CurveY + // holds the 6 node Y-values; CurveX (below) holds their X + // positions, draggable in the curve editor. Null = identity / no + // remapping — existing profiles are unaffected until the user + // opens this section. + public float[]? CurveY { get; set; } = null; // X position (0..100) of each output-curve node, draggable in the // Sim Input Mapping curve editor. Null = default fixed breakpoints - // (20/40/60/80/100 — identical to every other curve in the app). - // There is no hardware command for this (unlike the wheelbase's own - // FFB curve, which has base-ffb-curve-x1..x4) — moving a node here - // instead RESAMPLES the (CurveX, CurveY) shape at the fixed - // 20/40/60/80/100 breakpoints and pushes those 5 values through the - // existing mbooster-throttle-y1..y5 commands, so "100% output before - // 100% input" works using only the wire commands that actually - // exist. See MozaMBoosterRegistry.EvaluateCurveArbitraryX and - // docs/protocol/devices/mbooster.md "Sim Input Mapping". + // (100/6 * k for k=1..6, last node at 100% — see + // MozaMBoosterRegistry.DefaultCurveX). public float[]? CurveX { get; set; } = null; // Per-pedal calibration for the ADDITIONAL pedals on a chained mBooster @@ -683,39 +724,55 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig // value is already on the device. public float MaxThresholdKg { get; set; } = -1; - // Pedal Feel (Pit House-style). Host-side only — there is no wire - // command for this; it shapes the raw HID axis position BEFORE it - // becomes MozaData.{Throttle,Brake,Clutch}Position (and before the - // effect worker's brake-position fallback), independent of CurveY - // (which still writes to the device's own output-curve command - // unchanged). Null = identity / no shaping — existing profiles are - // unaffected until the user opens the new Pedal Feel section. See - // MozaMBoosterRegistry.EvaluateInputCurve and + // Pedal Feel input curve (6-point, Pit House-style) — REAL hardware + // calibration: its nodes (0-100% of the Deadzone-Max Force span) + // populate mbooster-brake-feelcurve-1..6 directly (cmdId 0xAB + // selectors 0x08-0x0D). Null = use the default Linear shape + // (MozaMBoosterRegistry.FeelCurveFractions) — existing profiles are + // unaffected until the user opens this section. See + // MozaMBoosterRegistry.ComputeFeelCurve, + // MBoosterDeviceController.PushFeelCurveResync, and // docs/protocol/devices/mbooster.md "Pedal Feel". public float[]? InputCurveY { get; set; } = null; - // 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; + // X position (0-100% of the Deadzone-Max Force span) of each Pedal + // Feel node, draggable in the curve editor exactly like Sim Input + // Mapping's CurveX — ALSO real hardware calibration though, unlike + // CurveX: reverse-engineered from pedal-feel-node{2,5}-{x,y}-adjust + // .pcapng (four isolated single-node-drag captures), which showed a + // second, previously-undocumented cmdId 0xAB selector family + // (0x01-0x06, one per node, distinct from feelcurve-1..6's + // 0x08-0x0D) always written alongside the node's own feelcurve-N + // write — sent first, same kg-relative-to-span encoding. Null = + // default fixed breakpoints (MozaMBoosterRegistry.FeelCurveFractions + // — the same identity shape InputCurveY defaults to). See + // MozaMBoosterRegistry.ComputeFeelCurve and + // MBoosterDeviceController.PushFeelCurveResync. + public float[]? InputCurveX { get; set; } = null; + + // 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 +802,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 @@ -758,6 +815,15 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig // overwrites whatever value is already on the device. public float NaturalFrictionPct { get; set; } = -1; + // Master on/off for Natural Friction. Not a separate wire concept — + // Pit House's own toggle-off capture simply sent raw 0 (see the doc + // comment above), so AZOM's toggle reproduces that in software: off + // forces the pushed value to 0% regardless of NaturalFrictionPct, + // same pattern as MBoosterSegmentedDampingSettings.DampingEnabled. + // Defaults true so untouched profiles behave as before this toggle + // existed. + public bool NaturalFrictionEnabled { get; set; } = true; + // Segmented Damping (Pit House-style) — see // MBoosterSegmentedDampingSettings and // docs/protocol/devices/mbooster.md "Segmented Damping". @@ -795,6 +861,7 @@ public MBoosterDeviceSettings Clone() SensorOutputRatioPct = SensorOutputRatioPct, MaxThresholdKg = MaxThresholdKg, InputCurveY = InputCurveY == null ? null : (float[])InputCurveY.Clone(), + InputCurveX = InputCurveX == null ? null : (float[])InputCurveX.Clone(), DeadzoneKg = DeadzoneKg, MaxForceKg = MaxForceKg, TravelStartMm = TravelStartMm, @@ -802,6 +869,7 @@ public MBoosterDeviceSettings Clone() EndstopFrontStiffness = EndstopFrontStiffness, EndstopEndStiffness = EndstopEndStiffness, NaturalFrictionPct = NaturalFrictionPct, + NaturalFrictionEnabled = NaturalFrictionEnabled, SegmentedDamping = SegmentedDamping?.Clone() ?? new MBoosterSegmentedDampingSettings(), DisplayName = DisplayName, }; diff --git a/Devices/MozaMBoosterRegistry.cs b/Devices/MozaMBoosterRegistry.cs index 7262f1eb..8018dc47 100644 --- a/Devices/MozaMBoosterRegistry.cs +++ b/Devices/MozaMBoosterRegistry.cs @@ -425,26 +425,23 @@ 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, Max Force, and the Pedal Feel curve + // (InputCurveY) are all now REAL hardware calibration + // (mbooster-brake-deadzone/-maxforce/-feelcurve-1..6, cmdId + // 0xAB selectors 0x07-0x0E — see + // MBoosterDeviceController.PushFeelCurveResync and + // docs/protocol/devices/mbooster.md "Pedal Feel"): the device + // reshapes the raw HID axis itself before this read ever sees + // it, so there is nothing left to reshape here for those. + // Sim Input Mapping (CurveY/CurveX) is the opposite: it has NO + // wire command at all (see docs "Sim Input Mapping") — it + // remaps THIS already-hardware-shaped value into what AZOM + // reports to the sim, applied here so every downstream + // consumer (position bar, MergePositions -> game telemetry, + // the effect worker's brake-position fallback) sees the same + // remapped value. Per-axis: the master (axis 0) uses the + // lane's flat fields, each chained pedal uses its own per-pedal + // entry. var laneSettings = _settingsLookup(c.Identity); IMBoosterPedalConfig? cfg = laneSettings; if (axisIndex > 0) @@ -460,19 +457,39 @@ 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 + // Capture the TRUE raw reading — % of Max Force's own + // hardware ceiling, i.e. the physical force the user is + // actually applying — BEFORE the Threshold rescale below + // changes posPct's meaning. Powers the "Input Force" live + // label/marker; see LastAxisRawPercentPreThreshold's doc. + if (axisIndex < c.LastAxisRawPercentPreThreshold.Length) c.LastAxisRawPercentPreThreshold[axisIndex] = posPct; + + // Max Threshold — HOST-SIDE rescale. Raw HID 100% is the + // Pedal Feel curve's own hardware ceiling (Max Force's kg + // value — see MBoosterDeviceController.PushFeelCurveResync), + // NOT Max Threshold: the mbooster-brake-threshold wire write + // (cmdId 0xB3) does not reliably change that on-device, per + // hardware testing (bug bundle — "Max Threshold does + // nothing" investigation). Max Threshold is meant to be a + // purely host-side remap of the ALREADY-Max-Force-scaled raw + // position into "100% at Threshold's kg" for the sim, same + // category as Sim Input Mapping's CurveY/CurveX below (no + // wire command actually does the real work). Unset (-1) or + // non-positive Threshold is a no-op (ratio 1, same as + // Threshold == Max Force) so an uncustomized profile keeps + // its previous raw-passthrough behavior unchanged. + double maxForceKg = cfg.MaxForceKg >= 0 ? cfg.MaxForceKg : 200.0; + double thresholdKg = cfg.MaxThresholdKg > 0 ? cfg.MaxThresholdKg : maxForceKg; + if (Math.Abs(thresholdKg - maxForceKg) > 0.0001) + posPct = Math.Min(100.0, posPct * (maxForceKg / thresholdKg)); + + // Store the pre-remap percent for EVERY axis so the UI's // live curve markers follow whichever pedal is selected (axis 0 // also mirrored to LastRawPercentPreCurve for legacy callers). if (axisIndex < c.LastAxisRawPercentPreCurve.Length) c.LastAxisRawPercentPreCurve[axisIndex] = posPct; if (axisIndex == 0) c.LastRawPercentPreCurve = posPct; - if (cfg.InputCurveY != null && cfg.InputCurveY.Length == 5) - posPct = EvaluateInputCurve(cfg.InputCurveY, posPct); + if (cfg.CurveY != null && cfg.CurveY.Length == MBoosterUiConstants.SimInputMappingNodeCount) + posPct = EvaluateCurveArbitraryX(cfg.CurveX ?? DefaultCurveX, cfg.CurveY, posPct); } else { @@ -491,126 +508,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 - /// Catmull-Rom rendering exactly (same 1/6-tangent formula, anchored - /// at the origin) so the applied shaping matches what the user sees - /// drawn. holds the 5 node Y-values for - /// X=20,40,60,80,100; X=0 is an implicit (0,0) anchor. The control - /// points this formula produces always fall between their segment's - /// endpoints in X, so the segment's X(t) is monotonic — bisection - /// reliably inverts it to find t for the requested X. - /// - internal static double EvaluateInputCurve(float[] y, double x) - { - if (y == null || y.Length != 5) return x; - x = Math.Max(0, Math.Min(100, x)); - - var xs = new double[] { 0, 20, 40, 60, 80, 100, 100 }; - var ys = new double[] { 0, y[0], y[1], y[2], y[3], y[4], y[4] }; - - int i = (int)Math.Min(4, Math.Floor(x / 20.0)); - int p0i = i == 0 ? 0 : i - 1; - int p2i = i + 1; - int p3i = (i + 2 >= xs.Length) ? i + 1 : i + 2; - - double p0x = xs[p0i], p0y = ys[p0i]; - double p1x = xs[i], p1y = ys[i]; - double p2x = xs[p2i], p2y = ys[p2i]; - double p3x = xs[p3i], p3y = ys[p3i]; - - double c1x = p1x + (p2x - p0x) / 6.0, c1y = p1y + (p2y - p0y) / 6.0; - double c2x = p2x - (p3x - p1x) / 6.0, c2y = p2y - (p3y - p1y) / 6.0; - - double lo = 0, hi = 1; - for (int iter = 0; iter < 24; iter++) - { - double t = (lo + hi) / 2.0; - double bx = CubicBezier(p1x, c1x, c2x, p2x, t); - if (bx < x) lo = t; else hi = t; - } - double result = CubicBezier(p1y, c1y, c2y, p2y, (lo + hi) / 2.0); - if (result < 0) result = 0; - if (result > 100) result = 100; - return result; - } - private static double CubicBezier(double p0, double c1, double c2, double p1, double t) { double mt = 1 - t; @@ -618,26 +515,39 @@ private static double CubicBezier(double p0, double c1, double c2, double p1, do } /// - /// Same Catmull-Rom evaluation as , - /// generalized to arbitrary (draggable) node X positions instead of - /// the fixed 20/40/60/80/100 — used for the Sim Input Mapping output - /// curve's horizontal node drag (MBoosterDeviceSettings.CurveX). - /// Beyond the last node's X, returns that node's Y (flat plateau) — - /// this is what makes "100% output before 100% input" work: drag the - /// last node left and everything past it just stays at that Y. + /// Catmull-Rom evaluation generalized to arbitrary (draggable) node + /// X positions instead of a fixed spacing — used for the Sim Input + /// Mapping output curve's horizontal node drag + /// (MBoosterDeviceSettings.CurveX/CurveY). Purely + /// host-side (see docs/protocol/devices/mbooster.md "Sim Input + /// Mapping") — this remaps the pedal's already-hardware-shaped raw + /// HID position into what AZOM reports as game telemetry; there is + /// no wire command for it. Beyond the last node's X, returns that + /// node's Y (flat plateau) — this is what makes "100% output + /// before 100% input" work: drag the last node left and everything + /// past it just stays at that Y. Node count is derived from + /// 's own length (not hardcoded to the current + /// ) so + /// this same evaluator can also resample an OLDER saved curve (e.g. + /// a legacy 5-node one) at a NEW breakpoint set during migration — + /// see MozaPlugin's curve-array migration. /// internal static double EvaluateCurveArbitraryX(float[] xs, float[] ys, double x) { - if (xs == null || ys == null || xs.Length != 5 || ys.Length != 5) return x; + if (xs == null || ys == null || xs.Length < 2 || xs.Length != ys.Length) return x; + int n = xs.Length; - var px = new double[] { 0, xs[0], xs[1], xs[2], xs[3], xs[4], xs[4] }; - var py = new double[] { 0, ys[0], ys[1], ys[2], ys[3], ys[4], ys[4] }; + var px = new double[n + 2]; + var py = new double[n + 2]; + px[0] = 0; py[0] = 0; + for (int k = 0; k < n; k++) { px[k + 1] = xs[k]; py[k + 1] = ys[k]; } + px[n + 1] = xs[n - 1]; py[n + 1] = ys[n - 1]; if (x <= 0) return 0; - if (x >= px[5]) return py[5]; + if (x >= px[n + 1]) return py[n + 1]; int i = 0; - for (int k = 0; k < 5; k++) + for (int k = 0; k <= n; k++) { if (x >= px[k] && x <= px[k + 1]) { i = k; break; } } @@ -664,45 +574,66 @@ internal static double EvaluateCurveArbitraryX(float[] xs, float[] ys, double x) return CubicBezier(p1y, c1y, c2y, p2y, (lo + hi) / 2.0); } - private static readonly float[] DefaultCurveX = { 20, 40, 60, 80, 100 }; - - /// - /// Resample a (possibly horizontally-dragged) output curve at the - /// fixed 20/40/60/80/100 breakpoints the wire protocol actually - /// supports. When is null (node never - /// dragged), this is the identity — sampling - /// exactly at a node's own X - /// returns that node's own Y — so callers can always resample - /// unconditionally without a "has the user customized X" branch. - /// - internal static float[] ResampleCurveAtFixedBreakpoints(float[]? curveX, float[] curveY) - { - var xs = (curveX != null && curveX.Length == 5) ? curveX : DefaultCurveX; - var result = new float[5]; - for (int i = 0; i < 5; i++) - result[i] = (float)EvaluateCurveArbitraryX(xs, curveY, DefaultCurveX[i]); - return result; - } + // Default (un-dragged) node X breakpoints for the Sim Input Mapping + // output curve, 100/6 * k for k=1..6 — evenly spaced, last node at + // exactly 100% so an untouched curve maps full input to full output. + // (Previously 100/7 * k, inherited from the disproven/removed + // curve7 mechanism's selectors purely for cosmetic continuity — see + // docs/protocol/devices/mbooster.md "Sim Input Mapping" — which left + // the last node short at ~85.7%, so "100% output before 100% input" + // via EvaluateCurveArbitraryX's plateau only needs a user's explicit + // drag now, not an already-shortened default.) + private static readonly float[] DefaultCurveX = + { 100f / 6f, 200f / 6f, 300f / 6f, 400f / 6f, 500f / 6f, 600f / 6f }; + + // Default/un-dragged shape of the Pedal Feel curve's 6 nodes on + // EITHER axis (mbooster-brake-feelcurve-1..6 for Y, cmdId 0xAB + // selectors 0x08-0x0D; mbooster-brake-feelcurve-x-1..6 for X, + // selectors 0x01-0x06), as a fraction (0-1) of the way from Deadzone + // to Max Force — empirically measured across both + // max-force-24-75-128-166-200.pcapng (Deadzone fixed, Max Force + // swept 75/128/166kg) and deadzone-0-5-11-14.pcapng (Max Force + // fixed, Deadzone swept 5/11/14kg): (value - deadzone) / (maxForce - + // deadzone) landed on the identical constant per selector in all 6 + // write bursts (std-dev < 0.0001). This is Pit House's own + // un-dragged default shape (a Linear/identity curve — Y=X trivially + // holds for any untouched curve regardless of its real X-breakpoint + // spacing), NOT a fixed rule: the 6 points on EACH axis are + // genuinely user-adjustable, Y via + // MBoosterDeviceSettings.InputCurveY and X via InputCurveX (see + // ComputeFeelCurve below, used for both) — confirmed by isolated + // single-node-drag captures (pedal-feel-node{2,5}-{x,y}-adjust + // .pcapng) that independently exercised the X selector family, which + // an earlier, less rigorous investigation had spotted once and + // dismissed as an unconfirmed guess for a different curve (see + // docs/protocol/devices/mbooster.md "Removed: y1..y5 and curve7"). + // See docs/protocol/devices/mbooster.md "Pedal Feel" and bug bundle + // 5VR5AQ8Y. + internal static readonly double[] FeelCurveFractions = + { 0.08049, 0.19495, 0.44245, 0.72433, 0.90040, 0.97910 }; /// - /// EXPERIMENTAL / unverified — resample the output curve at 6 evenly - /// spaced breakpoints (100/7, 200/7, ..., 600/7 percent) instead of - /// the wire protocol's usual 20/40/60/80/100, for the - /// mbooster-brake-curve7-* commands (cmdId 0xAB — see - /// MozaCommandDatabase.cs and MozaMBoosterProtocol.EncodeCurve7Point). - /// Spotted once in pedal_travel.pcapng sent alongside a Travel Start - /// write; not confirmed as an actual protocol requirement. Same - /// null-curveY-is-identity fallback as - /// (via 's own null guard). - /// Returned array is indexed 0..5 for wire selectors 1..6 - /// (result[i] is selector i + 1's value). + /// The 6 points of the Pedal Feel curve on ONE axis, in kg, ready to + /// write to mbooster-brake-feelcurve-1..6 (Y) or + /// mbooster-brake-feelcurve-x-1..6 (X) — see + /// . Each + /// node in is a percentage (0-100) of + /// the Deadzone-Max Force span; falls back to + /// (the Linear default, shared by + /// both axes) for any node the user hasn't customized (null or + /// wrong-length array). /// - internal static float[] ResampleCurveAtSevenths(float[]? curveX, float[]? curveY) + internal static double[] ComputeFeelCurve(double deadzoneKg, double maxForceKg, float[]? inputCurve = null) { - var xs = (curveX != null && curveX.Length == 5) ? curveX : DefaultCurveX; - var result = new float[6]; - for (int i = 1; i <= 6; i++) - result[i - 1] = (float)EvaluateCurveArbitraryX(xs, curveY!, i * 100.0 / 7.0); + double range = maxForceKg - deadzoneKg; + int n = FeelCurveFractions.Length; + bool haveCurve = inputCurve != null && inputCurve.Length == n; + var result = new double[n]; + for (int i = 0; i < n; i++) + { + double frac01 = haveCurve ? inputCurve![i] / 100.0 : FeelCurveFractions[i]; + result[i] = deadzoneKg + frac01 * range; + } return result; } @@ -916,13 +847,18 @@ private void MergePositions() /// (, set by the UI when /// the user remaps) always wins. Otherwise: a single-axis device uses /// the legacy (exact backward - /// compat); a multi-pedal chain defaults by axis order to - /// [Throttle, Brake, Clutch]. That order is the standard Moza pedal - /// usage convention — real hardware (support bundle 2026-07-07) exposes - /// the chain's pedals as GenericDesktop axes Rx(0x33)/Ry(0x34)/Rz(0x35), - /// which ascending-sorted give index 0/1/2, and Moza maps Rx→throttle, - /// Ry→brake, Rz→clutch (see MozaHidClass.Pedals). The user remaps via - /// the UI if a given unit's wiring differs. + /// compat); axis 0 of a grown-but-not-yet-remapped chain ALSO honors an + /// already-explicit Role rather than the position default, so a pedal + /// the user set to Brake while solo doesn't silently become "Throttle" + /// the moment a second pedal gets chained onto the same lane. Any other + /// axis (or axis 0 with Role still at its Disabled default) falls back + /// to axis order: [Throttle, Brake, Clutch]. That order is the standard + /// Moza pedal usage convention — real hardware (support bundle + /// 2026-07-07) exposes the chain's pedals as GenericDesktop axes + /// Rx(0x33)/Ry(0x34)/Rz(0x35), which ascending-sorted give index 0/1/2, + /// and Moza maps Rx→throttle, Ry→brake, Rz→clutch (see + /// MozaHidClass.Pedals). The user remaps via the UI if a given unit's + /// wiring differs. /// internal static MBoosterRole ResolveAxisRole(MBoosterDeviceSettings? s, int axisIndex, int axisCount) { @@ -931,6 +867,16 @@ internal static MBoosterRole ResolveAxisRole(MBoosterDeviceSettings? s, int axis return roles[axisIndex]; if (axisCount <= 1) return s?.Role ?? MBoosterRole.Disabled; + // Axis 0 IS the legacy Role field's slot for a single-axis device + // (see the axisCount<=1 branch above) — if the chain then grows to + // a multi-pedal lane before AxisRoles is ever explicitly seeded, + // honor whatever the user already set there instead of silently + // reverting axis 0 to the position-based Throttle default below. + // Losing an explicitly-set Brake there used to hide the brake-only + // Sensor Output Ratio/Max Threshold sliders (and mis-route + // calibration writes) for a pedal the user never touched. + if (axisIndex == 0 && s?.Role is MBoosterRole role0 && role0 != MBoosterRole.Disabled) + return role0; switch (axisIndex) { case 0: return MBoosterRole.Throttle; // Rx (0x33) diff --git a/MozaPlugin.cs b/MozaPlugin.cs index ae4f5925..d41ac422 100644 --- a/MozaPlugin.cs +++ b/MozaPlugin.cs @@ -1056,6 +1056,29 @@ public void Init(PluginManager pluginManager) _settings.VerboseWireDebugLog = false; } + // The mBooster CurveY/CurveX (Sim Input Mapping) and + // InputCurveY (Pedal Feel) arrays moved from 5 to 6 nodes. + // Every other call site treats a wrong-length array as + // "unset" and falls back to a default shape — fine for new + // profiles, but it would silently discard an existing + // user's tuned curve the first time this version runs. + // Resample once instead, preserving each curve's shape. + if (!_settings.MBoosterCurveArraysMigratedTo6) + { + _settings.MBoosterCurveArraysMigratedTo6 = true; + MigrateMBoosterCurveArraysTo6(); + } + + // Follow-up fix for the 100/7-breakpoint bug (see + // FixMBoosterCurveArraysSeventhsBug) — separate flag/pass so + // it also catches profiles that only clicked a preset button + // and never went through the 5->6 migration above. + if (!_settings.MBoosterCurveArraysFixedSeventhsBug) + { + _settings.MBoosterCurveArraysFixedSeventhsBug = true; + FixMBoosterCurveArraysSeventhsBug(); + } + // Initialise the GUID↔model registry up front — page-GUID // resolution (current-wheel page lookup, per-page settings dicts) // depends on it throughout runtime. @@ -2327,18 +2350,59 @@ internal MBoosterDeviceSettings GetOrCreateMBoosterSettings(string identity) var dict = profile.MBoosterSettings; // Lazily migrate a transient transport-keyed entry to the serial - // key in the current profile. A serial-keyed entry, if one - // already exists (the user's saved config from a prior session), - // wins — the transport entry is a just-created placeholder. + // key in the current profile. + // + // A brand-new transport-keyed placeholder gets created (below) + // the instant the device is first detected, BEFORE its serial + // has been read back — this is normal and happens every single + // session. If the user starts editing (dragging a curve node, + // say) in the brief window before OnMBoosterSerialResolved + // fires and migrates it, those edits land on THIS placeholder. + // The old version of this migration always kept whichever + // object was ALREADY under the serial key and silently deleted + // the transport-keyed one — meaning a live edit made in that + // window was discarded outright, with no warning, the moment + // the serial resolved (bug: a real drag-tested curve edit + // vanished, reverting to whatever stale data pre-dated it, even + // though the whole session shut down cleanly afterwards). + // + // Fix: an untouched placeholder (see IsUntouchedMBoosterPlaceholder) + // still loses to whatever's already at the serial key, same as + // before. But once the transport-keyed entry holds real, + // user-visible data, it wins — it can only have gotten that data + // via a live edit moments ago (it started as an empty placeholder + // THIS session), so it's the freshest thing we know about. Only + // log (not silently overwrite) when the serial-keyed side ALSO + // already holds real data — a genuine two-real-datasets conflict + // this heuristic can't perfectly resolve, but at least it's now + // visible instead of an invisible, permanent data loss. if (!string.Equals(original, key, StringComparison.OrdinalIgnoreCase) && dict.TryGetValue(original, out var stale)) { - if (!dict.ContainsKey(key)) dict[key] = stale; + bool staleUntouched = IsUntouchedMBoosterPlaceholder(stale); + bool keyHasEntry = dict.TryGetValue(key, out var existing); + if (!keyHasEntry) + { + dict[key] = stale; + } + else if (!staleUntouched) + { + if (!IsUntouchedMBoosterPlaceholder(existing)) + MozaLog.Warn($"[AZOM\\mBooster] GetOrCreateMBoosterSettings: BOTH the transport-keyed entry ('{original}') and the serial-keyed entry ('{key}') hold real data in profile '{profile.Name}' — keeping the transport-keyed (more recently touched) one; the serial-keyed one's prior values are discarded."); + dict[key] = stale; + } dict.Remove(original); } if (!dict.TryGetValue(key, out var s) || s == null) { + // Diagnostic trail for the "curve values wrong until profile + // reload" class of bug — this is the moment a caller gets + // handed a brand-new, all-defaults placeholder instead of + // the real saved entry, e.g. because `key` is still the raw + // transport identity (serial not resolved/re-keyed yet) at + // the moment the settings UI first seeds from it. + MozaLog.Info($"[AZOM\\mBooster] GetOrCreateMBoosterSettings: NEW placeholder for key='{key}' (original='{original}', resolvedSerial={!string.Equals(original, key, StringComparison.OrdinalIgnoreCase)}) in profile '{profile.Name}'"); s = new MBoosterDeviceSettings(); dict[key] = s; } @@ -2346,6 +2410,40 @@ internal MBoosterDeviceSettings GetOrCreateMBoosterSettings(string identity) } } + /// + /// True if every field GetOrCreateMBoosterSettings's re-key migration + /// cares about is still at its untouched sentinel/default — i.e. this + /// looks exactly like the placeholder GetOrCreateMBoosterSettings + /// itself creates for a just-detected device, not something a user + /// (or an import/migration) has actually written real values into. + /// Used to decide which of two colliding entries (transport-keyed vs + /// serial-keyed) is safe to discard during migration — see the caller. + /// Deliberately does NOT check the effect settings (Abs/Lockup/etc.) + /// or CustomEffects: those aren't part of the bug this guards against, + /// and their own field-level defaults are less clear-cut, so skipping + /// them only makes this check slightly less strict, never wrong in a + /// way that would newly discard real data it didn't already discard. + /// + private static bool IsUntouchedMBoosterPlaceholder(MBoosterDeviceSettings s) + { + return s.Role == global::MozaPlugin.Devices.MBoosterRole.Disabled + && s.AxisRoles == null + && s.Direction < 0 && s.Min < 0 && s.Max < 0 + && s.CurveY == null && s.CurveX == null + && s.SensorOutputRatioPct < 0 && s.MaxThresholdKg < 0 + && s.InputCurveY == null && s.InputCurveX == null + && s.DeadzoneKg < 0 && s.MaxForceKg < 0 + && s.TravelStartMm < 0 && s.TravelEndMm < 0 + && s.EndstopFrontStiffness < 0 && s.EndstopEndStiffness < 0 + && s.NaturalFrictionPct < 0 + && string.IsNullOrEmpty(s.DisplayName) + && (s.Pedals == null || s.Pedals.Count == 0) + && s.SegmentedDamping.Divider1Pressed < 0 && s.SegmentedDamping.Divider2Pressed < 0 + && s.SegmentedDamping.Seg1Pressed < 0 && s.SegmentedDamping.Seg2Pressed < 0 && s.SegmentedDamping.Seg3Pressed < 0 + && s.SegmentedDamping.Divider1Released < 0 && s.SegmentedDamping.Divider2Released < 0 + && s.SegmentedDamping.Seg1Released < 0 && s.SegmentedDamping.Seg2Released < 0 && s.SegmentedDamping.Seg3Released < 0; + } + /// /// A lane's 32-char Moza serial has been interrogated. Record the /// identity→serial mapping (so settings lookups re-key to it), migrate @@ -2357,6 +2455,12 @@ internal MBoosterDeviceSettings GetOrCreateMBoosterSettings(string identity) private void OnMBoosterSerialResolved(string identity, string serial) { if (IsShuttingDown || string.IsNullOrEmpty(identity) || string.IsNullOrEmpty(serial)) return; + // Diagnostic trail alongside GetOrCreateMBoosterSettings's own + // placeholder-creation log — if this fires well AFTER the settings + // UI has already seeded from a transport-keyed placeholder for the + // same identity, that's the race: the UI showed defaults/stale data + // before this re-key ever ran, and nothing told it to reseed. + MozaLog.Info($"[AZOM\\mBooster] OnMBoosterSerialResolved: identity={MBoosterDeviceController.ShortIdentity(identity)} serial={serial}"); _mboosterSerialByIdentity[identity] = "mbooster:" + serial; try { @@ -2595,6 +2699,143 @@ private static bool HealMBoosterAxisRoles(MBoosterDeviceSettings s, bool[] conne return changed; } + // Old 5-node output curve's fixed X breakpoints — what CurveX + // defaulted to (and what InputCurveY was always implicitly fixed + // at) before the redesign to 6 nodes. Used only by the one-shot + // migration below. + private static readonly float[] LegacyMBoosterCurveDefaultX = { 20, 40, 60, 80, 100 }; + + /// + /// One-shot migration (see + /// ): + /// resamples every saved mBooster CurveY/CurveX (Sim Input Mapping) + /// and InputCurveY (Pedal Feel) array from its old 5-node shape to + /// the current 6-node one, across every profile's master settings + /// and every chained pedal — preserving each curve's visual shape + /// instead of letting the ordinary "wrong length = unset" guards + /// elsewhere silently discard it to a default. CurveX itself does + /// not carry over (the old dragged X positions don't map cleanly + /// onto the new node count) — only the resulting Y-shape does; a + /// fresh CurveX default takes over on the next edit. + /// + private void MigrateMBoosterCurveArraysTo6() + { + var profiles = _settings?.ProfileStore?.Profiles; + if (profiles == null) return; + foreach (var profile in profiles) + { + if (profile?.MBoosterSettings == null) continue; + foreach (var device in profile.MBoosterSettings.Values) + { + if (device == null) continue; + MigrateOneMBoosterCurveSet(device); + if (device.Pedals != null) + foreach (var pedal in device.Pedals.Values) + if (pedal != null) MigrateOneMBoosterCurveSet(pedal); + } + } + } + + private static void MigrateOneMBoosterCurveSet(global::MozaPlugin.Devices.IMBoosterPedalConfig cfg) + { + const int oldNodeCount = 5; + if (cfg.CurveY != null && cfg.CurveY.Length == oldNodeCount) + { + var oldXs = (cfg.CurveX != null && cfg.CurveX.Length == oldNodeCount) + ? cfg.CurveX : LegacyMBoosterCurveDefaultX; + var newY = new float[global::MozaPlugin.Devices.MBoosterUiConstants.SimInputMappingNodeCount]; + for (int i = 0; i < newY.Length; i++) + { + double x = (i + 1) * 100.0 / 6.0; + newY[i] = (float)global::MozaPlugin.Devices.MozaMBoosterRegistry.EvaluateCurveArbitraryX(oldXs, cfg.CurveY, x); + } + cfg.CurveY = newY; + cfg.CurveX = null; + } + if (cfg.InputCurveY != null && cfg.InputCurveY.Length == oldNodeCount) + { + var newInput = new float[global::MozaPlugin.Devices.MBoosterUiConstants.PedalFeelNodeCount]; + for (int i = 0; i < newInput.Length; i++) + { + double x = global::MozaPlugin.Devices.MozaMBoosterRegistry.FeelCurveFractions[i] * 100.0; + newInput[i] = (float)global::MozaPlugin.Devices.MozaMBoosterRegistry.EvaluateCurveArbitraryX(LegacyMBoosterCurveDefaultX, cfg.InputCurveY, x); + } + cfg.InputCurveY = newInput; + } + } + + // The Sim Input Mapping curve's default X breakpoints used to be + // 100/7 * k (last node ~85.7%, not 100% — see DefaultCurveX's + // history in Devices/MozaMBoosterRegistry.cs). Any profile that hit + // MBoosterCurveArraysMigratedTo6, or simply clicked a preset button, + // under that bug got a CurveY baked to one of these too-low shapes. + // Matched against UI.SettingsControl's MBoosterCurvePresets (old → + // new) so the follow-up migration below can restore the exact + // preset shape a user actually clicked, not just the default. + private static readonly float[][] OldMBoosterCurvePresetsSeventhsBug = + { + new float[] { 14, 29, 43, 57, 71, 86 }, // Linear + new float[] { 5, 12, 30, 70, 88, 95 }, // S Curve + new float[] { 4, 9, 16, 25, 41, 66 }, // Exponential + new float[] { 34, 59, 75, 84, 91, 96 }, // Parabolic + }; + private static readonly float[][] NewMBoosterCurvePresetsSeventhsBug = + { + new float[] { 17, 33, 50, 67, 83, 100 }, // Linear + new float[] { 6, 16, 50, 84, 94, 100 }, // S Curve + new float[] { 5, 11, 20, 35, 61, 100 }, // Exponential + new float[] { 39, 65, 80, 89, 95, 100 }, // Parabolic + }; + + /// + /// One-shot follow-up migration (see + /// ): + /// a saved Sim Input Mapping curve that exactly matches one of the + /// old, too-low preset shapes (baked in by the 100/7 breakpoint bug, + /// either directly via a preset button or via + /// before this fix) is + /// swapped for the corresponding corrected shape. A curve the user + /// has since custom-dragged away from any preset is left alone — + /// the original 5-node source is long gone, so there's nothing + /// reliable to re-derive it from; a fresh Linear/S-Curve/etc. click + /// or a small manual touch-up fixes it going forward. + /// + private void FixMBoosterCurveArraysSeventhsBug() + { + var profiles = _settings?.ProfileStore?.Profiles; + if (profiles == null) return; + foreach (var profile in profiles) + { + if (profile?.MBoosterSettings == null) continue; + foreach (var device in profile.MBoosterSettings.Values) + { + if (device == null) continue; + FixOneMBoosterCurveSeventhsBug(device); + if (device.Pedals != null) + foreach (var pedal in device.Pedals.Values) + if (pedal != null) FixOneMBoosterCurveSeventhsBug(pedal); + } + } + } + + private static void FixOneMBoosterCurveSeventhsBug(global::MozaPlugin.Devices.IMBoosterPedalConfig cfg) + { + if (cfg.CurveX != null) return; // user has dragged X — not a stock preset shape + if (cfg.CurveY == null || cfg.CurveY.Length != global::MozaPlugin.Devices.MBoosterUiConstants.SimInputMappingNodeCount) return; + for (int p = 0; p < OldMBoosterCurvePresetsSeventhsBug.Length; p++) + { + var old = OldMBoosterCurvePresetsSeventhsBug[p]; + bool match = true; + for (int i = 0; i < old.Length; i++) + if (Math.Abs(cfg.CurveY[i] - old[i]) > 0.01f) { match = false; break; } + if (match) + { + cfg.CurveY = (float[])NewMBoosterCurvePresetsSeventhsBug[p].Clone(); + return; + } + } + } + /// /// Called once per detection rising edge by the registry. Pushes any /// saved calibration values to the device and kicks off a read-back @@ -2672,8 +2913,6 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos else if (axis == soleAxis) cfg = s; else continue; - bool wroteAnyCalibration = false; - // Every per-pedal calibration here is a PHYSICAL setting stored // on that pedal's own mBooster unit (confirmed on hardware: each // unit reports only its own pedal's calibration, under its own @@ -2689,20 +2928,13 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos : role == global::MozaPlugin.Devices.MBoosterRole.Clutch ? 2 : -1; byte dev = controller.MotorDeviceForRole(roleIdx, axis); - if (cfg.Direction >= 0) { controller.SendIntWrite($"mbooster-{prefix}-dir", cfg.Direction, dev); 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.CurveY != null && cfg.CurveY.Length == 5) - { - wroteAnyCalibration = true; - // Resample at the fixed 20/40/60/80/100 breakpoints in case - // CurveX has been horizontally dragged (see - // MozaMBoosterRegistry.ResampleCurveAtFixedBreakpoints) — - // identity when it hasn't. - var resampled = global::MozaPlugin.Devices.MozaMBoosterRegistry.ResampleCurveAtFixedBreakpoints(cfg.CurveX, cfg.CurveY); - for (int k = 0; k < 5; k++) - controller.SendFloatWrite($"mbooster-{prefix}-y{k + 1}", resampled[k], dev); - } + if (cfg.Direction >= 0) controller.SendIntWrite($"mbooster-{prefix}-dir", cfg.Direction, dev); + if (cfg.Min >= 0) controller.SendIntWrite($"mbooster-{prefix}-min", cfg.Min, dev); + if (cfg.Max >= 0) controller.SendIntWrite($"mbooster-{prefix}-max", cfg.Max, dev); + // CurveY/CurveX (Sim Input Mapping output curve) are NOT + // pushed here — purely host-side now, no wire command at + // all (see MozaMBoosterRegistry.EvaluateCurveArbitraryX and + // docs/protocol/devices/mbooster.md "Sim Input Mapping"). // Travel / End Stop / Natural Friction / Segmented Damping are // load-cell + motor Pedal Feel features living on brake-named // SINGLETON cmdIds (0x84/0x85, 0xB2, 0xAE, 0xB7) with no @@ -2719,88 +2951,101 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos { controller.SendIntWrite("mbooster-brake-travel-start", global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeTravelMm(cfg.TravelStartMm), dev); - wroteAnyCalibration = true; } if (ownsPedalFeelHardware && cfg.TravelEndMm >= 0) { controller.SendIntWrite("mbooster-brake-travel-end", global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeTravelMm(cfg.TravelEndMm), dev); - wroteAnyCalibration = true; } if (ownsPedalFeelHardware && cfg.EndstopFrontStiffness >= 0) { controller.SendIntWrite("mbooster-brake-endstop-front", global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeEndstopStiffness(cfg.EndstopFrontStiffness), dev); - wroteAnyCalibration = true; } if (ownsPedalFeelHardware && cfg.EndstopEndStiffness >= 0) { controller.SendIntWrite("mbooster-brake-endstop-end", global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeEndstopStiffness(cfg.EndstopEndStiffness), dev); - wroteAnyCalibration = true; } - if (ownsPedalFeelHardware && cfg.NaturalFrictionPct >= 0) + // NaturalFrictionEnabled == false forces the pushed value to + // 0% regardless of NaturalFrictionPct — same convention as + // SegmentedDampingSettings.DampingEnabled below — so it also + // has to fire on an otherwise-untouched profile once the + // feature has been explicitly switched off. + if (ownsPedalFeelHardware && (cfg.NaturalFrictionPct >= 0 || !cfg.NaturalFrictionEnabled)) { - int frictionRaw = global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeFrictionPct(cfg.NaturalFrictionPct); + float frictionPct = cfg.NaturalFrictionEnabled ? cfg.NaturalFrictionPct : 0f; + int frictionRaw = global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeFrictionPct(frictionPct); controller.SendIntWrite("mbooster-brake-friction-0", frictionRaw, dev); controller.SendIntWrite("mbooster-brake-friction-1", frictionRaw, dev); - 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. + // above); once ANY field on either side is set — or the + // feature has been switched off via DampingEnabled — the + // frame is filled out using factory defaults for whichever + // side still has no override. DampingEnabled == false forces + // every segment field to 0%, same as PushSegmentedDamping in + // UI/SettingsControl.xaml.cs. var sd = ownsPedalFeelHardware ? cfg.SegmentedDamping : null; - if (sd != null && (sd.Divider1Pressed >= 0 || sd.Divider2Pressed >= 0 + if (sd != null && (!sd.DampingEnabled || sd.Divider1Pressed >= 0 || sd.Divider2Pressed >= 0 || sd.Seg1Pressed >= 0 || sd.Seg2Pressed >= 0 || sd.Seg3Pressed >= 0 || sd.Divider1Released >= 0 || sd.Divider2Released >= 0 || sd.Seg1Released >= 0 || sd.Seg2Released >= 0 || sd.Seg3Released >= 0)) { + bool sdEnabled = sd.DampingEnabled; var c = global::MozaPlugin.Devices.MBoosterUiConstants.SegDampSegDefaultPct; var frame = global::MozaPlugin.Protocol.MozaMBoosterProtocol.BuildSegmentedDampingFrame( sd.Divider1Pressed >= 0 ? sd.Divider1Pressed : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider1PressedDefaultPct, sd.Divider2Pressed >= 0 ? sd.Divider2Pressed : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider2PressedDefaultPct, sd.Divider1Released >= 0 ? sd.Divider1Released : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider1ReleasedDefaultPct, sd.Divider2Released >= 0 ? sd.Divider2Released : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider2ReleasedDefaultPct, - sd.Seg1Pressed >= 0 ? sd.Seg1Pressed : c, - sd.Seg1Released >= 0 ? sd.Seg1Released : c, - sd.Seg2Pressed >= 0 ? sd.Seg2Pressed : c, - sd.Seg2Released >= 0 ? sd.Seg2Released : c, - sd.Seg3Pressed >= 0 ? sd.Seg3Pressed : c, - sd.Seg3Released >= 0 ? sd.Seg3Released : c, + !sdEnabled ? 0 : sd.Seg1Pressed >= 0 ? sd.Seg1Pressed : c, + !sdEnabled ? 0 : sd.Seg1Released >= 0 ? sd.Seg1Released : c, + !sdEnabled ? 0 : sd.Seg2Pressed >= 0 ? sd.Seg2Pressed : c, + !sdEnabled ? 0 : sd.Seg2Released >= 0 ? sd.Seg2Released : c, + !sdEnabled ? 0 : sd.Seg3Pressed >= 0 ? sd.Seg3Pressed : c, + !sdEnabled ? 0 : sd.Seg3Released >= 0 ? sd.Seg3Released : c, dev); controller.SendOneShot(frame); - wroteAnyCalibration = true; } if (role == global::MozaPlugin.Devices.MBoosterRole.Brake) { if (cfg.SensorOutputRatioPct >= 0) { controller.SendFloatWrite("mbooster-brake-angle-ratio", cfg.SensorOutputRatioPct, dev); - wroteAnyCalibration = true; } if (cfg.MaxThresholdKg >= 0) { controller.SendIntWrite("mbooster-brake-threshold", global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeThresholdKg(cfg.MaxThresholdKg), dev); - wroteAnyCalibration = true; } } - // 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) - controller.PushCurve7Resync(cfg.CurveX, cfg.CurveY, dev); + // Deadzone / Max Force / Pedal Feel curve — CONFIRMED real + // hardware calibration (see + // MBoosterDeviceController.PushFeelCurveResync). Fresh + // profile with none set sends nothing, same guarantee as + // every other calibration write here. Once ANY of the three + // is set, the whole 8-value family is pushed together (the + // device has no partial-update form for it), using the + // pedal's own sane "off" default for whichever side has no + // override — 0kg deadzone, 200kg max force, and the curve's + // own default Linear shape (MozaMBoosterRegistry + // .FeelCurveFractions) for an uncustomized curve. + bool curveCustomized = (cfg.InputCurveY != null + && cfg.InputCurveY.Length == global::MozaPlugin.Devices.MBoosterUiConstants.PedalFeelNodeCount) + || (cfg.InputCurveX != null + && cfg.InputCurveX.Length == global::MozaPlugin.Devices.MBoosterUiConstants.PedalFeelNodeCount); + if (ownsPedalFeelHardware && (cfg.DeadzoneKg >= 0 || cfg.MaxForceKg >= 0 || curveCustomized)) + { + double dz = cfg.DeadzoneKg >= 0 ? cfg.DeadzoneKg : 0; + double mf = cfg.MaxForceKg >= 0 ? cfg.MaxForceKg : 200; + controller.PushFeelCurveResync(dz, mf, cfg.InputCurveY, cfg.InputCurveX, dev); + } } } diff --git a/Protocol/MozaCommandDatabase.cs b/Protocol/MozaCommandDatabase.cs index 8fb74a16..4357e822 100644 --- a/Protocol/MozaCommandDatabase.cs +++ b/Protocol/MozaCommandDatabase.cs @@ -596,28 +596,58 @@ static MozaCommandDatabase() // docs/protocol/devices/mbooster.md "Pedal Feel". AddCommand("mbooster-brake-travel-start", "mbooster", 35, 36, new byte[] { 0x84 }, 2, "int"); AddCommand("mbooster-brake-travel-end", "mbooster", 35, 36, new byte[] { 0x85 }, 2, "int"); - // EXPERIMENTAL / unverified — spotted in pedal_travel.pcapng: Pit - // House sent these 6 alongside a single Travel Start write, never - // in isolation, so this is a correlation from one capture, not a - // confirmed protocol requirement. cmdId 0xAB, same "prefix bytes - // then payload" shape as endstop above: a fixed 0x00 byte + a 1-6 - // selector before the 2-byte value. Values decoded near a linear - // ramp (selector/7 * 65535) with two outliers, consistent with - // this being the 5-point output curve - // (CurveX/CurveY) re-expressed at 7 evenly-spaced breakpoints - // instead of the usual 20/40/60/80/100 — see - // MozaMBoosterRegistry.ResampleCurveAtSevenths and - // MozaMBoosterProtocol.EncodeCurve7Point. Hypothesis: the - // firmware needs this resent for a Travel/Pedal-Feel write to - // actually take effect, even though the raw Travel register - // itself reads back correctly without it. Needs on-hardware - // confirmation — see docs/protocol/devices/mbooster.md "Pedal Feel". - AddCommand("mbooster-brake-curve7-1", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x01 }, 2, "int"); - AddCommand("mbooster-brake-curve7-2", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x02 }, 2, "int"); - AddCommand("mbooster-brake-curve7-3", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x03 }, 2, "int"); - AddCommand("mbooster-brake-curve7-4", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x04 }, 2, "int"); - AddCommand("mbooster-brake-curve7-5", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x05 }, 2, "int"); - AddCommand("mbooster-brake-curve7-6", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x06 }, 2, "int"); + // Pit House "Deadzone" and "Max Force" (Pedal Feel) — CONFIRMED real + // hardware calibration, reverse-engineered from two real Pit House + // captures (max-force-24-75-128-166-200.pcapng, + // deadzone-0-5-11-14.pcapng — see bug bundle 5VR5AQ8Y). cmdId 0xAB + // indexed-register family, selectors 0x07-0x0E carrying a genuinely + // separate 8-point curve: selector 0x07 = Deadzone, selector 0x0E = + // Max Force, both in kg using the identical encoding as Max + // Threshold (raw = round(kg * 65536 / 200) — see + // MozaMBoosterProtocol.EncodeThresholdKg). Selectors 0x08-0x0D are + // the Pedal Feel curve's own 6 user-adjustable nodes (0-100% of the + // Deadzone-Max Force span) — see MozaMBoosterRegistry + // .ComputeFeelCurve. Every Max Force / Deadzone sweep in both + // captures resent this whole 8-value family as one atomic burst, + // and real Pit House does NOT clamp Max Force to Max Threshold + // (128kg/166kg were sent while Threshold read back as 125kg) — see + // docs/protocol/devices/mbooster.md "Pedal Feel". (An earlier, + // separate 0xAB selector range 0x01-0x06, "curve7", was removed as + // an experimental/unconfirmed resync guess for an unrelated + // curve — see docs for that writeup — but isolated single-node + // drag captures later confirmed that SAME selector range is real + // after all, just for a different purpose: each Pedal Feel + // node's own X position, added back below as + // mbooster-brake-feelcurve-x-1..6.) + 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"); + // Pedal Feel node X position (0-100% of the Deadzone-Max Force + // span, one per node) — CONFIRMED real hardware calibration, + // reverse-engineered from pedal-feel-node{2,5}-{x,y}-adjust.pcapng + // (four isolated single-node drags): every drag wrote this + // selector AND the node's own feelcurve-N selector above + // together, this one first. Same cmdId 0xAB, selectors 0x01-0x06 + // (node K -> selector K) — the SAME selector range an earlier, + // less rigorous investigation spotted once (alongside a Travel + // Start write, not a node drag) and removed as unconfirmed/ + // guessed-wrong (see docs/protocol/devices/mbooster.md "Removed: + // y1..y5 and curve7" and "Pedal Feel"): these isolated captures + // resolve that mystery — it's Pedal Feel's own node X, not a + // universal resync, and not tied to Sim Input Mapping either. + // New command names (not the old removed mbooster-brake-curve7-N) + // to avoid conflating with that disproven theory. + AddCommand("mbooster-brake-feelcurve-x-1", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x01 }, 2, "int"); + AddCommand("mbooster-brake-feelcurve-x-2", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x02 }, 2, "int"); + AddCommand("mbooster-brake-feelcurve-x-3", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x03 }, 2, "int"); + AddCommand("mbooster-brake-feelcurve-x-4", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x04 }, 2, "int"); + AddCommand("mbooster-brake-feelcurve-x-5", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x05 }, 2, "int"); + AddCommand("mbooster-brake-feelcurve-x-6", "mbooster", 35, 36, new byte[] { 0xAB, 0x00, 0x06 }, 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 @@ -644,22 +674,11 @@ static MozaCommandDatabase() // docs/protocol/devices/mbooster.md "Pedal Feel". AddCommand("mbooster-brake-friction-0", "mbooster", 35, 36, new byte[] { 0xAE, 0x00, 0x00 }, 2, "int"); AddCommand("mbooster-brake-friction-1", "mbooster", 35, 36, new byte[] { 0xAE, 0x00, 0x01 }, 2, "int"); - // 5-point output curves per pedal (4-byte float, read 35 / write 36) - AddCommand("mbooster-throttle-y1", "mbooster", 35, 36, new byte[] { 14 }, 4, "float"); - AddCommand("mbooster-throttle-y2", "mbooster", 35, 36, new byte[] { 15 }, 4, "float"); - AddCommand("mbooster-throttle-y3", "mbooster", 35, 36, new byte[] { 16 }, 4, "float"); - AddCommand("mbooster-throttle-y4", "mbooster", 35, 36, new byte[] { 17 }, 4, "float"); - AddCommand("mbooster-throttle-y5", "mbooster", 35, 36, new byte[] { 27 }, 4, "float"); - AddCommand("mbooster-brake-y1", "mbooster", 35, 36, new byte[] { 18 }, 4, "float"); - AddCommand("mbooster-brake-y2", "mbooster", 35, 36, new byte[] { 19 }, 4, "float"); - AddCommand("mbooster-brake-y3", "mbooster", 35, 36, new byte[] { 20 }, 4, "float"); - AddCommand("mbooster-brake-y4", "mbooster", 35, 36, new byte[] { 21 }, 4, "float"); - AddCommand("mbooster-brake-y5", "mbooster", 35, 36, new byte[] { 28 }, 4, "float"); - AddCommand("mbooster-clutch-y1", "mbooster", 35, 36, new byte[] { 22 }, 4, "float"); - AddCommand("mbooster-clutch-y2", "mbooster", 35, 36, new byte[] { 23 }, 4, "float"); - AddCommand("mbooster-clutch-y3", "mbooster", 35, 36, new byte[] { 24 }, 4, "float"); - AddCommand("mbooster-clutch-y4", "mbooster", 35, 36, new byte[] { 25 }, 4, "float"); - AddCommand("mbooster-clutch-y5", "mbooster", 35, 36, new byte[] { 29 }, 4, "float"); + // (The per-role 5-point output curve commands (cmdIds 14-29, + // mbooster-{throttle,brake,clutch}-y1..y5) were removed — the Sim + // Input Mapping output curve is now purely host-side, with no + // wire encoding at all; see docs/protocol/devices/mbooster.md + // "Sim Input Mapping" for the historical writeup.) // Live outputs (read-only group 37) — fallback live-position source // if HID identity pairing fails on a particular unit. AddCommand("mbooster-throttle-output", "mbooster", 37, 0xFF, new byte[] { 1 }, 2, "int"); diff --git a/Protocol/MozaMBoosterProtocol.cs b/Protocol/MozaMBoosterProtocol.cs index 4a212303..419783dc 100644 --- a/Protocol/MozaMBoosterProtocol.cs +++ b/Protocol/MozaMBoosterProtocol.cs @@ -302,13 +302,26 @@ public static byte ComputeParam1(double paramK, double freqHz) /// data points: 4 kg → 1311 exactly, and an unlabeled capture whose /// raw value decoded to ~126 kg, matching an independently-reported /// real Pit House setting of ~125 kg. + /// BUG (fixed): at kg=200 exactly, this formula rounds to 65536 + /// (0x10000) — one bit past the 16-bit range every consumer of this + /// encoding actually uses on the wire (Max Force/Deadzone/Feel Curve + /// nodes are 2-byte fields; even Max Threshold's 4-byte field only + /// ever carries a 16-bit-range value). The old bounds check compared + /// against int.MaxValue, which never caught this, so BuildWriteInt's + /// byte-packing silently truncated 0x10000 to 0x0000 for any 2-byte + /// command — sending 0kg instead of ~200kg for Max Force's own + /// slider maximum (confirmed via azom-max-force-sweep.pcapng: the + /// wire write for Max Force=200 was literally raw=0). Clamping to + /// 65535 instead matches Pit House's own observed encoding — its + /// max-force-140-threshold-4-200sweep.pcapng capture sent Threshold + /// =200kg as raw 0xFFFF (65535), not 0x10000. /// public static int EncodeThresholdKg(double kg) { if (double.IsNaN(kg) || kg <= 0) return 0; double raw = Math.Round(kg * 65536.0 / 200.0); if (raw <= 0) return 0; - if (raw >= int.MaxValue) return int.MaxValue; + if (raw >= 65535.0) return 65535; return (int)raw; } @@ -348,29 +361,6 @@ public static double DecodeTravelMm(int raw) return raw * 53.5 / 65536.0; } - /// - /// EXPERIMENTAL / unverified — encoding for the mbooster-brake- - /// curve7-* commands (cmdId 0xAB) spotted alongside a Travel - /// Start write in pedal_travel.pcapng (see - /// MozaCommandDatabase.cs and MozaMBoosterRegistry.ResampleCurveAtSevenths). - /// Values decoded near selector/7 * 65535, i.e. a plain - /// fraction-of-full-scale over the 0-65535 range — same - /// "value * 65535 / fullscale" family as , - /// with fullscale = 100 (a curve-node percentage, 0-100 like - /// MBoosterDeviceSettings.CurveY): raw = round(pct * 65535 / 100). - /// Not cross-checked against a second capture — treat with more - /// suspicion than this file's other Encode/Decode pairs. - /// - public static int EncodeCurve7Point(double pct) - { - if (double.IsNaN(pct)) pct = 0; - pct = Math.Max(0, Math.Min(100, pct)); - double raw = Math.Round(pct * 65535.0 / 100.0); - if (raw <= 0) return 0; - if (raw >= 0xFFFF) return 0xFFFF; - return (int)raw; - } - /// /// Pit House "End Stop Stiffness" (Front Limit / End Limit) encoding /// — reverse-engineered from two real Pit House USB captures (wire diff --git a/Resources/Strings.Designer.cs b/Resources/Strings.Designer.cs index 5cd38fe2..a4def4ff 100644 --- a/Resources/Strings.Designer.cs +++ b/Resources/Strings.Designer.cs @@ -151,6 +151,7 @@ private static string Get(string key) public static string Section_Position => Get("Section_Position"); public static string Subtitle_LiveHandbrakeInput => Get("Subtitle_LiveHandbrakeInput"); public static string Label_Position => Get("Label_Position"); + public static string Label_InputForce => Get("Label_InputForce"); public static string Section_Calibration => Get("Section_Calibration"); public static string Subtitle_PullHandbrakeFully => Get("Subtitle_PullHandbrakeFully"); public static string Button_StartCalibration => Get("Button_StartCalibration"); diff --git a/Resources/Strings.de.resx b/Resources/Strings.de.resx index ae49d06a..939ad380 100644 --- a/Resources/Strings.de.resx +++ b/Resources/Strings.de.resx @@ -765,4 +765,9 @@ Id: {2} PR geschlossen — auf Stabil umgestellt // Verstärkung pro Band · 100 % = neutral · 500 % = max. Boost EMPFINDLICHKEIT + Eingabekraft + G-Kraft (Trägheits-Pedalgefühl) (Experimentell) + Max. Pedalweg (mm) + Reaktionsgeschwindigkeit (%) + Experimentell — bewegt das Pedal selbst unter Ihrem Fuß proportional zur aktuellen Längs-G-Kraft (Beschleunigen drückt nach vorne, Bremsen drückt zurück), anstatt zu vibrieren. Max. Pedalweg legt fest, wie weit es sich bei voller G-Kraft bewegt; Reaktionsgeschwindigkeit legt fest, wie schnell das Pedal zur neuen Position übergeht. diff --git a/Resources/Strings.el.resx b/Resources/Strings.el.resx index 65fb66d0..ee499da0 100644 --- a/Resources/Strings.el.resx +++ b/Resources/Strings.el.resx @@ -764,4 +764,9 @@ Id: {2} Το PR έκλεισε — μετάβαση σε Σταθερό // κέρδος ανά band · 100% = ουδέτερο · 500% = μέγιστη ενίσχυση ΕΥΑΙΣΘΗΣΙΑ + Δύναμη εισόδου + Δύναμη G (Αδρανειακή Αίσθηση Πεντάλ) (Πειραματικό) + Μέγιστη διαδρομή πεντάλ (mm) + Ταχύτητα απόκρισης (%) + Πειραματικό — μετακινεί το ίδιο το πεντάλ κάτω από το πόδι σας ανάλογα με την τρέχουσα διαμήκη δύναμη G (η επιτάχυνση το σπρώχνει προς τα εμπρός, το φρενάρισμα προς τα πίσω), αντί να δονείται. Η Μέγιστη διαδρομή πεντάλ ορίζει πόσο μετακινείται στη μέγιστη δύναμη G· η Ταχύτητα απόκρισης ορίζει πόσο γρήγορα το πεντάλ μεταβαίνει στη νέα θέση. diff --git a/Resources/Strings.es.resx b/Resources/Strings.es.resx index 2de47e8b..053f7fa3 100644 --- a/Resources/Strings.es.resx +++ b/Resources/Strings.es.resx @@ -770,4 +770,9 @@ Id: {2} PR cerrado — se cambió a Estable // ganancia por banda · 100% = neutro · 500% = realce máximo SENSIBILIDAD + Fuerza de entrada + Fuerza G (sensación inercial del pedal) (experimental) + Recorrido máximo del pedal (mm) + Velocidad de respuesta (%) + Experimental — mueve el propio pedal bajo tu pie en proporción a la G longitudinal en tiempo real (acelerar lo empuja hacia adelante, frenar lo empuja hacia atrás), en lugar de vibrar. El Recorrido máximo del pedal define hasta dónde se mueve con G máxima; la Velocidad de respuesta define con qué rapidez el pedal avanza hacia la nueva posición. diff --git a/Resources/Strings.fr.resx b/Resources/Strings.fr.resx index e6199703..5d78a6b4 100644 --- a/Resources/Strings.fr.resx +++ b/Resources/Strings.fr.resx @@ -765,4 +765,9 @@ Id : {2} PR fermée — retour à Stable // gain par bande · 100 % = neutre · 500 % = boost max SENSIBILITÉ + Force d'entrée + Force G (ressenti inertiel de la pédale) (expérimental) + Course maximale de la pédale (mm) + Vitesse de réponse (%) + Expérimental — pousse la pédale elle-même sous votre pied en proportion de la G longitudinale en temps réel (accélérer la pousse vers l'avant, freiner la pousse vers l'arrière), plutôt que de vibrer. La Course maximale de la pédale définit jusqu'où elle se déplace à G maximale ; la Vitesse de réponse définit la rapidité avec laquelle la pédale rejoint la nouvelle position. diff --git a/Resources/Strings.it.resx b/Resources/Strings.it.resx index 270d8aa3..da25ff8d 100644 --- a/Resources/Strings.it.resx +++ b/Resources/Strings.it.resx @@ -768,4 +768,9 @@ Id: {2} PR chiusa — passato a Stabile // guadagno per banda · 100% = neutro · 500% = amplificazione massima SENSIBILITÀ + Forza in ingresso + Forza G (sensazione inerziale del pedale) (sperimentale) + Corsa massima del pedale (mm) + Velocità di risposta (%) + Sperimentale — spinge il pedale stesso sotto il tuo piede in proporzione alla G longitudinale in tempo reale (l'accelerazione lo spinge in avanti, la frenata lo spinge indietro), invece di vibrare. La Corsa massima del pedale imposta quanto si muove a G massima; la Velocità di risposta imposta quanto velocemente il pedale raggiunge la nuova posizione. diff --git a/Resources/Strings.ko.resx b/Resources/Strings.ko.resx index 98a43e9b..bbc98a2d 100644 --- a/Resources/Strings.ko.resx +++ b/Resources/Strings.ko.resx @@ -764,4 +764,9 @@ Id: {2} PR 종료됨 — 안정 채널로 전환됨 // 대역별 게인 · 100% = 중립 · 500% = 최대 부스트 감도 + 입력 힘 + G포스 (관성 페달 느낌) (실험적) + 최대 페달 이동 거리 (mm) + 반응 속도 (%) + 실험적 — 진동 대신, 실시간 종방향 G에 비례하여 발밑의 페달 자체를 밀어냅니다(가속 시 앞으로, 제동 시 뒤로 밀림). 최대 페달 이동 거리는 최대 G에서 얼마나 움직이는지를 설정하고, 반응 속도는 페달이 새 위치로 얼마나 빠르게 이동하는지를 설정합니다. diff --git a/Resources/Strings.nb.resx b/Resources/Strings.nb.resx index f2cd4556..08de5b48 100644 --- a/Resources/Strings.nb.resx +++ b/Resources/Strings.nb.resx @@ -770,4 +770,9 @@ Id: {2} PR lukket — byttet til Stabil // forsterkning per bånd · 100 % = nøytral · 500 % = maks boost FØLSOMHET + Inngangskraft + G-kraft (treghets-pedalfølelse) (eksperimentell) + Maks pedalbevegelse (mm) + Responshastighet (%) + Eksperimentell — skyver selve pedalen under foten din proporsjonalt med sanntids langsgående G-kraft (akselerasjon skyver fremover, bremsing skyver bakover), i stedet for å vibrere. Maks pedalbevegelse angir hvor langt den beveger seg ved full G; Responshastighet angir hvor raskt pedalen beveger seg til den nye posisjonen. diff --git a/Resources/Strings.pt.resx b/Resources/Strings.pt.resx index 081884a1..35ceff1d 100644 --- a/Resources/Strings.pt.resx +++ b/Resources/Strings.pt.resx @@ -758,4 +758,15 @@ Id: {2} PR fechado — alterado para Estável // ganho por banda · 100% = neutro · 500% = reforço máx. SENSIBILIDADE + Força de entrada + Força G (sensação inercial do pedal) (experimental) + Curso máx. do pedal (mm) + Velocidade de resposta (%) + Experimental — empurra o próprio pedal sob o seu pé em proporção à força G longitudinal em tempo real (acelerar empurra para frente, frear empurra para trás), em vez de vibrar. O Curso máx. do pedal define até onde ele se move na G máxima; a Velocidade de resposta define com que rapidez o pedal avança até a nova posição. + Simula uma força de fricção independente da saída do jogo. + AMORTECIMENTO SEGMENTADO + Força de amortecimento independente da saída do jogo, por segmento de curso do pedal + Simula uma força de amortecimento independente da saída do jogo. O curso do pedal é dividido em vários segmentos, cada um com um intervalo ajustável e seu próprio amortecimento natural. Arraste um divisor para redimensionar um segmento; arraste dentro de um segmento para definir sua quantidade de amortecimento. + Ao pressionar + Ao soltar diff --git a/Resources/Strings.qps-ploc.resx b/Resources/Strings.qps-ploc.resx index 7436a4ef..6adeefa8 100644 --- a/Resources/Strings.qps-ploc.resx +++ b/Resources/Strings.qps-ploc.resx @@ -19,7 +19,7 @@ Arf! ARF!! Boof! - Wuff! + GOD DAMMIT FRANK Bark! GRRR!! Wroof! @@ -749,4 +749,15 @@ Bark Arf! Ruff Bark Grrowl Wuff! AWOO!! + Wroof! + Yowl! + Boof! + Yap! + Grrowl Yip Wuff Bork Howl Arf! + Snarf Wan Boof! + GRRR!! + Yowl Bork! + Ruff Yowl Grr Wuff Yip Bark! + Arf! + Grr! diff --git a/Resources/Strings.resx b/Resources/Strings.resx index 481063af..a42ce326 100644 --- a/Resources/Strings.resx +++ b/Resources/Strings.resx @@ -89,6 +89,7 @@ POSITION // live handbrake input Position + Input Force CALIBRATION // pull the handbrake fully once START CALIBRATION diff --git a/Resources/Strings.ru.resx b/Resources/Strings.ru.resx index 675e0cf7..ae8fd636 100644 --- a/Resources/Strings.ru.resx +++ b/Resources/Strings.ru.resx @@ -765,4 +765,9 @@ Id: {2} PR закрыт — переключено на Стабильный // усиление по полосам · 100% = нейтраль · 500% = максимум ЧУВСТВИТЕЛЬНОСТЬ + Входное усилие + G-сила (инерционное ощущение педали) (экспериментально) + Макс. ход педали (мм) + Скорость отклика (%) + Экспериментально — толкает саму педаль под вашей ногой пропорционально текущей продольной перегрузке G (ускорение толкает вперёд, торможение — назад), вместо вибрации. Макс. ход педали задаёт, насколько далеко она движется при максимальной G; Скорость отклика задаёт, как быстро педаль переходит в новое положение. diff --git a/Resources/Strings.vi.resx b/Resources/Strings.vi.resx index 4279abd6..60848da4 100644 --- a/Resources/Strings.vi.resx +++ b/Resources/Strings.vi.resx @@ -765,4 +765,9 @@ Id: {2} PR đã đóng — đã chuyển về Ổn định // khuếch đại từng dải · 100% = trung tính · 500% = tăng tối đa ĐỘ NHẠY + Lực đầu vào + Lực G (cảm giác bàn đạp quán tính) (thử nghiệm) + Hành trình bàn đạp tối đa (mm) + Tốc độ phản hồi (%) + Thử nghiệm — đẩy chính bàn đạp dưới chân bạn tỷ lệ với lực G dọc theo thời gian thực (tăng tốc đẩy về phía trước, phanh đẩy về phía sau), thay vì rung. Hành trình bàn đạp tối đa quy định nó di chuyển bao xa ở G tối đa; Tốc độ phản hồi quy định bàn đạp chuyển đến vị trí mới nhanh như thế nào. diff --git a/Resources/Strings.zh-Hans.resx b/Resources/Strings.zh-Hans.resx index 630ac762..8663a9bd 100644 --- a/Resources/Strings.zh-Hans.resx +++ b/Resources/Strings.zh-Hans.resx @@ -765,4 +765,9 @@ Id: {2} PR 已关闭 — 已切换到稳定版 // 各频段增益 · 100% = 中性 · 500% = 最大增益 灵敏度 + 输入力值 + G力(惯性踏板手感)(实验性) + 最大踏板行程 (mm) + 响应速度 (%) + 实验性 — 根据实时纵向 G 力推动脚下的踏板本身(加速时向前推,刹车时向后推),而不是震动。最大踏板行程设置在最大 G 力下移动的距离;响应速度设置踏板过渡到新位置的快慢。 diff --git a/Themes/MozaTheme.xaml b/Themes/MozaTheme.xaml index aa8cb9be..04c5790d 100644 --- a/Themes/MozaTheme.xaml +++ b/Themes/MozaTheme.xaml @@ -405,9 +405,73 @@ - - @@ -1145,11 +1209,12 @@ Data="{Binding Seg2Rect, RelativeSource={RelativeSource TemplatedParent}}"/> - + overlay), giving the damping profile one continuous, + rounded shape instead of three separate bars (or a sharp + step) to compare by eye. --> (double)GetValue(Y10Property); set => SetValue(Y10Property, value); } // -------- X values (data-space 0..100, only meaningful when - // AllowHorizontalDrag is true — 5-node curves only, no X6). Defaults - // match the fixed 20/40/60/80/100 breakpoints every other curve in - // this app uses, so a fresh instance renders identically to one - // driven by NodeXFractions until the user actually drags a node - // sideways. -------- + // AllowHorizontalDrag is true — 5-node curves default to the fixed + // 20/40/60/80/100 breakpoints every other curve in this app uses; + // the 6-node Sim Input Mapping curve overwrites X1-X6 from its own + // seeding code (100/6 * k for k=1..6) immediately on load, so X6's + // own DP default below is cosmetic. A fresh instance renders + // identically to one driven by NodeXFractions until the user + // actually drags a node sideways. -------- public static readonly DependencyProperty X1Property = RegisterX(nameof(X1), 20); public static readonly DependencyProperty X2Property = RegisterX(nameof(X2), 40); public static readonly DependencyProperty X3Property = RegisterX(nameof(X3), 60); public static readonly DependencyProperty X4Property = RegisterX(nameof(X4), 80); public static readonly DependencyProperty X5Property = RegisterX(nameof(X5), 100); + public static readonly DependencyProperty X6Property = RegisterX(nameof(X6), 600.0 / 6.0); private static DependencyProperty RegisterX(string name, double dflt) => DependencyProperty.Register(name, typeof(double), typeof(MozaCurveEditor), @@ -87,6 +90,7 @@ private static DependencyProperty RegisterX(string name, double dflt) public double X3 { get => (double)GetValue(X3Property); set => SetValue(X3Property, value); } public double X4 { get => (double)GetValue(X4Property); set => SetValue(X4Property, value); } public double X5 { get => (double)GetValue(X5Property); set => SetValue(X5Property, value); } + public double X6 { get => (double)GetValue(X6Property); set => SetValue(X6Property, value); } // When true, nodes can be dragged horizontally (within their // neighbours' bounds) as well as vertically — used only by the @@ -112,6 +116,37 @@ private static DependencyProperty RegisterX(string name, double dflt) (d, e) => ((MozaCurveEditor)d).Recompute())); public bool LockLastNodeX { get => (bool)GetValue(LockLastNodeXProperty); set => SetValue(LockLastNodeXProperty, value); } + // When true (with AllowHorizontalDrag), only the FIRST and LAST nodes + // may move horizontally — every node in between is Y-only. The first + // node is additionally locked in Y (X-only movement), since its sole + // role is to mark where the curve's usable input range begins; the + // last node keeps moving on both axes. Dragging either endpoint + // horizontally rescales all the in-between nodes' X in proportion to + // their old position between the two (old) endpoints, so the curve's + // shape (relative node spacing) is preserved rather than left behind. + // Used only by the Sim Input Mapping curve (MBoosterCurveEditor) — + // every other curve using AllowHorizontalDrag (e.g. the wheelbase FFB + // output curve) keeps its existing per-node drag behaviour unchanged. + public static readonly DependencyProperty EndpointsOnlyDraggableInXProperty = + DependencyProperty.Register(nameof(EndpointsOnlyDraggableInX), typeof(bool), typeof(MozaCurveEditor), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaCurveEditor)d).Recompute())); + public bool EndpointsOnlyDraggableInX { get => (bool)GetValue(EndpointsOnlyDraggableInXProperty); set => SetValue(EndpointsOnlyDraggableInXProperty, value); } + + // When true, a node's Y is ALSO clamped between its immediate + // neighbours' current Y (index-adjacent, same convention as the + // existing X neighbour-clamp below) — the first/last node clamp + // against YMin/YMax instead. Used by the Pedal Feel curve, where + // both axes are freely draggable (unlike Sim Input Mapping's + // endpoint-only-X nodes) so nothing else stops a node from being + // dragged past its neighbour's Y. Off by default so every other + // curve keeps its existing unconstrained Y-drag behaviour. + public static readonly DependencyProperty ClampYToAdjacentNodesProperty = + DependencyProperty.Register(nameof(ClampYToAdjacentNodes), typeof(bool), typeof(MozaCurveEditor), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaCurveEditor)d).Recompute())); + public bool ClampYToAdjacentNodes { get => (bool)GetValue(ClampYToAdjacentNodesProperty); set => SetValue(ClampYToAdjacentNodesProperty, value); } + // Per-node Y cap for the LAST node only (NaN = disabled) — the 10-band // EQ's 100 Hz band stays 0-100% while every other band runs to YMax=500. public static readonly DependencyProperty LastNodeYMaxProperty = @@ -166,6 +201,21 @@ private static DependencyProperty RegisterX(string name, double dflt) (d, e) => ((MozaCurveEditor)d).Recompute())); public bool AnchorAtOrigin { get => (bool)GetValue(AnchorAtOriginProperty); set => SetValue(AnchorAtOriginProperty, value); } + // When true, the spline is ALSO anchored at the plot's upper-right + // corner (data-space 100,100) as a real drawn point, symmetric to + // AnchorAtOrigin's lower-left corner — so the visible line reaches + // (100,100) even when the last draggable node doesn't sit exactly + // there. Used by the Pedal Feel curve (MBoosterInputCurveEditor, + // both axes now draggable): its domain is 0-100% of the Deadzone→Max + // Force span on BOTH axes, so the curve should visually span that + // whole square. Off by default — every other curve already ends at + // its own last node/point with no separate corner anchor. + public static readonly DependencyProperty AnchorAtTopRightProperty = + DependencyProperty.Register(nameof(AnchorAtTopRight), typeof(bool), typeof(MozaCurveEditor), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaCurveEditor)d).Recompute())); + public bool AnchorAtTopRight { get => (bool)GetValue(AnchorAtTopRightProperty); set => SetValue(AnchorAtTopRightProperty, value); } + // Diagonal y=x reference line from plot lower-left to upper-right — // the "nominal" / linear response. Shown on output curves to make it // easy to read deviation. Off on the EQ where a y=x line is @@ -459,6 +509,19 @@ protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo) private int _dragNode = -1; private Canvas? _canvas; + // Endpoint-drag rescale baseline (see EndpointsOnlyDraggableInX) — + // captured ONCE at the start of an endpoint drag, not re-derived + // every tick from the current (already-rescaled, already-rounded) + // positions. Re-deriving it every tick let a middle node's fraction + // collapse to exactly 0 or 1 once heavy compression rounded its X + // onto an endpoint's own X: every later tick read frac=0 (or 1) + // again from that same now-stuck position, so the curve could + // compress but never re-expand — this fixes that by keeping the + // reference fractions stable for the whole drag gesture. + private double[]? _dragBaseFracs; + private double _dragBaseFirstX; + private double _dragBaseSpan; + private void HookCanvas() { _canvas = GetTemplateChild("PART_Canvas") as Canvas; @@ -478,12 +541,25 @@ private void OnMouseDown(object sender, MouseButtonEventArgs e) _dragNode = FindClosestNode(p); if (_dragNode >= 0) { + int lastNode = ClampedNodeCount() - 1; + if (EndpointsOnlyDraggableInX && (_dragNode == 0 || _dragNode == lastNode)) + CaptureEndpointDragBaseline(lastNode); _canvas.CaptureMouse(); ApplyDrag(p); e.Handled = true; } } + private void CaptureEndpointDragBaseline(int lastNode) + { + _dragBaseFirstX = GetX(0); + _dragBaseSpan = GetX(lastNode) - _dragBaseFirstX; + _dragBaseFracs = new double[lastNode + 1]; + if (_dragBaseSpan > 0.0001) + for (int m = 1; m < lastNode; m++) + _dragBaseFracs[m] = (GetX(m) - _dragBaseFirstX) / _dragBaseSpan; + } + private void OnMouseMove(object sender, MouseEventArgs e) { if (_dragNode < 0 || _canvas == null) return; @@ -522,23 +598,41 @@ private int FindClosestNode(Point p) private void ApplyDrag(Point p) { - double h = _canvas?.ActualHeight ?? ActualHeight; - double plotH = Math.Max(1, h - PadTop - PadBottom); - double y01 = (h - PadBottom - p.Y) / plotH; - double range = Math.Max(1, YMax - YMin); - double v = Math.Max(YMin, Math.Min(YMax, Math.Round(YMin + y01 * range))); - if (!double.IsNaN(LastNodeYMax) && _dragNode == ClampedNodeCount() - 1) - v = Math.Min(v, LastNodeYMax); - SetY(_dragNode, v); + int lastNode = ClampedNodeCount() - 1; + bool isEndpoint = _dragNode == 0 || _dragNode == lastNode; + + // Vertical drag — locked for the first node when + // EndpointsOnlyDraggableInX is set (see its doc comment): that + // node moves horizontally only. + if (!(EndpointsOnlyDraggableInX && _dragNode == 0)) + { + double h = _canvas?.ActualHeight ?? ActualHeight; + double plotH = Math.Max(1, h - PadTop - PadBottom); + double y01 = (h - PadBottom - p.Y) / plotH; + double range = Math.Max(1, YMax - YMin); + double v = Math.Max(YMin, Math.Min(YMax, Math.Round(YMin + y01 * range))); + if (!double.IsNaN(LastNodeYMax) && _dragNode == lastNode) + v = Math.Min(v, LastNodeYMax); + // Clamp to the neighbours' Y too — same crossing-prevention + // reasoning as the X neighbour-clamp below, just per-axis. + if (ClampYToAdjacentNodes) + { + double loY = _dragNode == 0 ? YMin : GetY(_dragNode - 1); + double hiY = _dragNode == lastNode ? YMax : GetY(_dragNode + 1); + if (hiY < loY) hiY = loY; + v = Math.Max(loY, Math.Min(hiY, v)); + } + SetY(_dragNode, v); + } // Horizontal drag (output curve only — see AllowHorizontalDrag). // Clamped between immediate neighbours (min 1-unit gap) so nodes // can never cross, which would make the curve's X non-monotonic - // and the Bezier-inversion evaluators (EvaluateInputCurve-style) - // ill-defined. - int lastNode = ClampedNodeCount() - 1; - if (AllowHorizontalDrag && _dragNode >= 0 && _dragNode < 5 - && !(LockLastNodeX && _dragNode == lastNode)) + // and the Bezier-inversion evaluator + // (MozaMBoosterRegistry.EvaluateCurveArbitraryX) ill-defined. + if (AllowHorizontalDrag && _dragNode >= 0 && _dragNode < 6 + && !(LockLastNodeX && _dragNode == lastNode) + && !(EndpointsOnlyDraggableInX && !isEndpoint)) { double w = _canvas?.ActualWidth ?? ActualWidth; double plotW = Math.Max(1, w - PadLeft - PadRight); @@ -546,10 +640,32 @@ private void ApplyDrag(Point p) double dataX = x01 * 100.0; double lo = _dragNode == 0 ? 1.0 : GetX(_dragNode - 1) + 1.0; - double hi = _dragNode == 4 ? 100.0 : GetX(_dragNode + 1) - 1.0; + double hi = _dragNode == lastNode ? 100.0 : GetX(_dragNode + 1) - 1.0; if (hi < lo) hi = lo; - dataX = Math.Max(lo, Math.Min(hi, dataX)); - SetX(_dragNode, Math.Round(dataX)); + dataX = Math.Round(Math.Max(lo, Math.Min(hi, dataX))); + + if (EndpointsOnlyDraggableInX && isEndpoint) + { + // Rescale every in-between node's X to keep its + // fractional position — captured once at drag start in + // _dragBaseFracs, see CaptureEndpointDragBaseline — between + // the two endpoints, so the curve's shape follows the + // endpoint being dragged instead of being left bunched up + // behind it. + SetX(_dragNode, dataX); + if (_dragBaseFracs != null && _dragBaseSpan > 0.0001) + { + double newFirstX = GetX(0); + double newLastX = GetX(lastNode); + double newSpan = newLastX - newFirstX; + for (int m = 1; m < lastNode; m++) + SetX(m, Math.Round(newFirstX + _dragBaseFracs[m] * newSpan)); + } + } + else + { + SetX(_dragNode, dataX); + } } } @@ -570,6 +686,24 @@ private void SetY(int i, double v) } } + private double GetY(int i) + { + switch (i) + { + case 0: return Y1; + case 1: return Y2; + case 2: return Y3; + case 3: return Y4; + case 4: return Y5; + case 5: return Y6; + case 6: return Y7; + case 7: return Y8; + case 8: return Y9; + case 9: return Y10; + default: return 0; + } + } + private double GetX(int i) { switch (i) @@ -579,6 +713,7 @@ private double GetX(int i) case 2: return X3; case 3: return X4; case 4: return X5; + case 5: return X6; default: return 0; } } @@ -592,6 +727,7 @@ private void SetX(int i, double v) case 2: X3 = v; break; case 3: X4 = v; break; case 4: X5 = v; break; + case 5: X6 = v; break; } } @@ -638,13 +774,13 @@ private void Recompute() // ---- Node X fractions / Y values ---- double[] nodeFracs; - if (AllowHorizontalDrag && nodeCount <= 5) + if (AllowHorizontalDrag && nodeCount <= 6) { // Nodes are user-draggable in X (see ApplyDrag) — derive - // fractions from X1..X5 instead of the fixed NodeXFractions + // fractions from X1..X6 instead of the fixed NodeXFractions // string. Same 0.98 compression as Default5NodeFractions so // a never-dragged node lands exactly where it always has. - double[] xs = { X1, X2, X3, X4, X5 }; + double[] xs = { X1, X2, X3, X4, X5, X6 }; nodeFracs = new double[nodeCount]; for (int i = 0; i < nodeCount; i++) nodeFracs[i] = Math.Max(0, Math.Min(1, (xs[i] / 100.0) * 0.98)); @@ -690,48 +826,53 @@ private void Recompute() } // ---- Catmull-Rom spline ---- - // Two endpoint regimes: - // • AnchorAtOrigin=true (output curves): prepend the plot's - // lower-left corner so the visible line starts at (0,0). - // • AnchorAtOrigin=false (EQ): duplicate the first node as its - // own virtual "previous" neighbour and skip the first segment, - // so the line starts AT the first node with a smooth tangent. - // The last node is always duplicated for the same tangent reason - // — the curve ends AT the last node, not at the right edge. - bool anchor = AnchorAtOrigin; - var allPts = new Point[nodeCount + 2]; - allPts[0] = anchor ? new Point(PadLeft, PadTop + plotH) : pts[0]; - for (int i = 0; i < nodeCount; i++) allPts[i + 1] = pts[i]; - allPts[nodeCount + 1] = pts[nodeCount - 1]; + // `real` holds every point the visible curve actually passes + // through, in order — the draggable nodes, optionally prefixed + // with the plot's lower-left corner (AnchorAtOrigin) and/or + // suffixed with its upper-right corner (AnchorAtTopRight), both + // as genuine drawn points rather than mere tangent helpers. + // Standard Catmull-Rom flat-tangent endpoints: `allPts` pads + // `real` with a duplicate of its own first/last point on each + // side purely so the p0/p3 tangent terms have something to read + // — this reproduces the pre-existing "flat tangent at the first/ + // last drawn point" behaviour for every current curve (anchored + // or not) and extends the same rule to the new top-right anchor. + bool anchorStart = AnchorAtOrigin; + bool anchorEnd = AnchorAtTopRight; + int realCount = nodeCount + (anchorStart ? 1 : 0) + (anchorEnd ? 1 : 0); + var real = new Point[realCount]; + int wi = 0; + if (anchorStart) real[wi++] = new Point(PadLeft, PadTop + plotH); + for (int i = 0; i < nodeCount; i++) real[wi++] = pts[i]; + if (anchorEnd) real[wi++] = new Point(PadLeft + 0.98 * plotW, PadTop); + + var allPts = new Point[realCount + 2]; + allPts[0] = real[0]; + for (int i = 0; i < realCount; i++) allPts[i + 1] = real[i]; + allPts[realCount + 1] = real[realCount - 1]; var fig = new PathFigure { - StartPoint = anchor ? allPts[0] : pts[0], + StartPoint = real[0], IsClosed = false, IsFilled = false, }; - // Stop the loop one short of the duplicated endpoint: the final - // iteration that ran before added a zero-length last_node→last_node - // segment whose tangent control point sticks out past the final - // node, rendering as a tiny tail. The duplicate is still used as - // p3 for the LAST visible segment's tangent computation. - int firstSeg = anchor ? 0 : 1; - int lastSeg = nodeCount; // Cached alongside geometry construction so the live-position // marker (below) can locate the exact pixel point ON the spline // for a given data-space X, without re-deriving the Catmull-Rom // tangents a second time. - var segments = new (Point p1, Point c1, Point c2, Point p2)[lastSeg - firstSeg]; - for (int i = firstSeg; i < lastSeg; i++) + int segCount = realCount - 1; + var segments = new (Point p1, Point c1, Point c2, Point p2)[segCount]; + for (int i = 0; i < segCount; i++) { - Point p0 = i == 0 ? allPts[0] : allPts[i - 1]; - Point p1 = allPts[i]; - Point p2 = allPts[i + 1]; - Point p3 = i + 2 >= allPts.Length ? allPts[i + 1] : allPts[i + 2]; + Point p0 = allPts[i]; + Point p1 = allPts[i + 1]; + Point p2 = allPts[i + 2]; + Point p3 = allPts[i + 3]; Point c1 = new Point(p1.X + (p2.X - p0.X) / 6.0, p1.Y + (p2.Y - p0.Y) / 6.0); Point c2 = new Point(p2.X - (p3.X - p1.X) / 6.0, p2.Y - (p3.Y - p1.Y) / 6.0); fig.Segments.Add(new BezierSegment(c1, c2, p2, true)); - segments[i - firstSeg] = (p1, c1, c2, p2); + segments[i] = (p1, c1, c2, p2); } var geom = new PathGeometry(); geom.Figures.Add(fig); @@ -744,7 +885,15 @@ private void Recompute() // Vertical lines scale with the rightmost node fraction so they // stay under the dots/labels when the X axis is compressed; the // horizontal lines stay evenly spaced (Y axis is always linear). - double xScale = Math.Max(0, Math.Min(1, nodeFracs[nodeCount - 1])); + // Exception: AllowHorizontalDrag curves (Sim Input Mapping, + // Pedal Feel, the FFB output curve) always use the full 0-100% + // span instead — their nodes can end up anywhere along X, so + // tying the grid to wherever the LAST node currently happens to + // sit would shrink/shift the whole grid as it's dragged, right + // when a stable position reference matters most. Every + // fixed-X curve (EQ, Handbrake, Throttle, Brake, Clutch) keeps + // its existing behaviour unchanged. + double xScale = AllowHorizontalDrag ? 0.98 : Math.Max(0, Math.Min(1, nodeFracs[nodeCount - 1])); var grid = new GeometryGroup(); for (int i = 1; i <= 4; i++) { @@ -777,16 +926,27 @@ private void Recompute() } // ---- Optional y=x identity / nominal line (output curves only) ---- - // The line ends at the rightmost node's X (not the plot's right - // pixel edge) so the LINEAR preset's last dot lands exactly on - // the diagonal — the dots are pulled slightly inside the plot to - // avoid clipping, and the reference must follow them. + // Always the TRUE diagonal from data (0,0) to (100,100) — same + // 0.98 inset every node/anchor pixel position uses (so the line + // doesn't get clipped at the plot's true right edge), but that's + // a rendering-only margin, not a stand-in for data-space X. + // BUG (fixed): this used to end at the rightmost NODE's own + // fraction instead of a fixed 0.98 — harmless for curves whose + // last node always sits at data (100,100) by construction (FFB, + // Sim Input Mapping, Throttle/Brake/Clutch), but for a curve + // like Pedal Feel — AnchorAtTopRight, whose last DRAGGABLE node + // legitimately defaults short of 100% (e.g. ~98%) while the + // curve itself still runs on to a separate fixed (100,100) + // corner anchor — the old logic stopped the dashed reference + // short of that corner AND pinned its Y to YMax at the node's + // (too-far-left) X, producing a line that wasn't really y=x at + // all and visibly diverged from the actual plotted curve near + // the top-right (see linear.png). if (ShowIdentityLine) { - double rightFrac = Math.Max(0, Math.Min(1, nodeFracs[nodeCount - 1])); var ident = new LineGeometry( new Point(PadLeft, PadTop + plotH), - new Point(PadLeft + rightFrac * plotW, PadTop)); + new Point(PadLeft + 0.98 * plotW, PadTop)); ident.Freeze(); SetValue(IdentityLineGeometryKey, ident); } @@ -844,7 +1004,7 @@ private void Recompute() /// the already-built spline: map the data-space X to a pixel X, find /// which segment contains it, then invert that segment's Bezier X(t) /// via bisection (same approach as - /// MozaMBoosterRegistry.EvaluateInputCurve) to read off both the + /// MozaMBoosterRegistry.EvaluateCurveArbitraryX) to read off both the /// pixel X and Y at that point — i.e. the dot always sits ON the /// curve as currently configured, not just sliding horizontally. /// @@ -870,7 +1030,7 @@ private void UpdateLiveMarker((Point p1, Point c1, Point c2, Point p2)[] segment // fraction, so linear interpolation between two known // node pairs reproduces the true mapping exactly whether // or not it's been dragged from its default. - double[] dataXs = { X1, X2, X3, X4, X5 }; + double[] dataXs = { X1, X2, X3, X4, X5, X6 }; int n = Math.Min(nodePts.Length, dataXs.Length); double clampedX = Math.Max(0, Math.Min(dataXs[n - 1], liveX)); double x0 = 0, px0 = PadLeft, x1 = dataXs[0], px1 = nodePts[0].X; diff --git a/UI/Controls/MozaSegmentedBarEditor.cs b/UI/Controls/MozaSegmentedBarEditor.cs index fa0070f9..419c2f51 100644 --- a/UI/Controls/MozaSegmentedBarEditor.cs +++ b/UI/Controls/MozaSegmentedBarEditor.cs @@ -158,10 +158,13 @@ private void OnValueChanged() public static readonly DependencyProperty PlotBackgroundRectProperty = PlotBackgroundRectKey.DependencyProperty; public Geometry? PlotBackgroundRect => (Geometry?)GetValue(PlotBackgroundRectProperty); - /// Step line tracing the three segments' current values — - /// flat across each segment's travel range, jumping vertically at - /// each divider — so the damping profile reads as one continuous - /// shape instead of three disconnected bars. + /// Smoothed line tracing the three segments' current values — + /// flat across the middle of each segment's travel range, easing + /// through a short Catmull-Rom-style curve around each divider + /// instead of jumping vertically — so the damping profile reads as + /// one continuous shape instead of three disconnected bars. See + /// (same 1/6-tangent Bezier + /// conversion MozaCurveEditor uses for its own curves). private static readonly DependencyPropertyKey StepLineGeometryKey = DependencyProperty.RegisterReadOnly(nameof(StepLineGeometry), typeof(Geometry), typeof(MozaSegmentedBarEditor), new PropertyMetadata(null)); public static readonly DependencyProperty StepLineGeometryProperty = StepLineGeometryKey.DependencyProperty; @@ -429,16 +432,38 @@ double YOf(double pct) bg.Freeze(); SetValue(PlotBackgroundRectKey, bg); - // Step line ON TOP of the bars, at each segment's own height — - // flat across its travel range, a vertical jump at each divider — - // the same shape the three bars already imply, just traced as one - // line so the overall profile is easier to read at a glance. - var stepFig = new PathFigure { StartPoint = new Point(EdgePad, YOf(s1v)), IsClosed = false, IsFilled = false }; - stepFig.Segments.Add(new LineSegment(new Point(d1x, YOf(s1v)), true)); - stepFig.Segments.Add(new LineSegment(new Point(d1x, YOf(s2v)), true)); - stepFig.Segments.Add(new LineSegment(new Point(d2x, YOf(s2v)), true)); - stepFig.Segments.Add(new LineSegment(new Point(d2x, YOf(s3v)), true)); - stepFig.Segments.Add(new LineSegment(new Point(EdgePad + plotW, YOf(s3v)), true)); + // Smoothed line ON TOP of the bars, at each segment's own height — + // flat across the middle of its travel range, easing through a + // short, tight curve right at each divider instead of jumping + // vertically — the same shape the three bars already imply, just + // traced as one continuous line so the overall profile is easier + // to read at a glance. Sized to match Pit House's own rendering + // (a quick S right at the divider, flat everywhere else): the + // half-width is ~1/4 of the SMALLEST segment's width, measured + // against a real Pit House screenshot's proportions (its + // transition-to-segment-width ratio came out close to that, + // vs. the previous /2.2 divisor here which was visibly wider/ + // more "curvy" than the reference — a long, gradual bow reaching + // deep into each segment instead of a quick kink at the + // divider). The 60px ceiling is just a backstop for an unusually + // wide segment, not the normal-case constraint; the /4.0 term + // does the real work and scales with the control's actual + // rendered size. Still shrinks for narrow segments/gaps so the + // six control points below can never cross each other or the + // plot edges. + double transitionHalfWidth = Math.Max(2.0, Math.Min(60.0, + Math.Min(d1x - EdgePad, Math.Min(d2x - d1x, EdgePad + plotW - d2x)) / 4.0)); + var stepPts = new[] + { + new Point(EdgePad, YOf(s1v)), + new Point(d1x - transitionHalfWidth, YOf(s1v)), + new Point(d1x + transitionHalfWidth, YOf(s2v)), + new Point(d2x - transitionHalfWidth, YOf(s2v)), + new Point(d2x + transitionHalfWidth, YOf(s3v)), + new Point(EdgePad + plotW, YOf(s3v)), + }; + var stepFig = new PathFigure { StartPoint = stepPts[0], IsClosed = false, IsFilled = false }; + AddSmoothPolyline(stepFig, stepPts); var stepGeom = new PathGeometry(); stepGeom.Figures.Add(stepFig); stepGeom.Freeze(); @@ -461,5 +486,53 @@ double YOf(double pct) SetValue(Seg2LabelTopKey, LabelTopFor(YOf(s2v))); SetValue(Seg3LabelTopKey, LabelTopFor(YOf(s3v))); } + + /// + /// Append a smooth Catmull-Rom-style curve through + /// to as a chain of cubic Bezier segments — same + /// 1/6-tangent conversion MozaCurveEditor.Recompute uses for its + /// own curves, so this reads as the same "smooth line" visual language + /// elsewhere in the app. .StartPoint must already + /// be set to pts[0]. The first/last points are their own + /// duplicated neighbour (rather than wrapping or extrapolating), so the + /// curve starts/ends exactly AT pts[0]/pts[^1] with a + /// sensible (non-overshooting) tangent instead of curving past them. + /// + private static void AddSmoothPolyline(PathFigure fig, Point[] pts) + { + int n = pts.Length; + for (int i = 0; i < n - 1; i++) + { + Point p0 = i == 0 ? pts[0] : pts[i - 1]; + Point p1 = pts[i]; + Point p2 = pts[i + 1]; + Point p3 = (i + 2 < n) ? pts[i + 2] : pts[n - 1]; + Point c1, c2; + if (p1.Y == p2.Y) + { + // A flat run (both endpoints at the same height — the + // plateau segments in Recompute's 6-point layout) must + // stay flat. Reaching past it to p0/p3 at a DIFFERENT + // height (the neighbouring divider transition) leaks a + // phantom slope into this segment's own tangent, + // drawing a small dip/bump right at the plateau's edge + // instead of a straight line — exactly the artifact + // visible right before/after each divider. Force a + // zero-slope tangent instead; the transition segment on + // the other side of the divider still computes its own + // tangent correctly, since IT reaches back into a flat + // neighbour that's at the SAME height as its own near + // endpoint. + c1 = new Point(p1.X + (p2.X - p0.X) / 6.0, p1.Y); + c2 = new Point(p2.X - (p3.X - p1.X) / 6.0, p2.Y); + } + else + { + c1 = new Point(p1.X + (p2.X - p0.X) / 6.0, p1.Y + (p2.Y - p0.Y) / 6.0); + c2 = new Point(p2.X - (p3.X - p1.X) / 6.0, p2.Y - (p3.Y - p1.Y) / 6.0); + } + fig.Segments.Add(new BezierSegment(c1, c2, p2, true)); + } + } } } diff --git a/UI/DiagnosticsTextBuilder.cs b/UI/DiagnosticsTextBuilder.cs index ccb63873..ed96f513 100644 --- a/UI/DiagnosticsTextBuilder.cs +++ b/UI/DiagnosticsTextBuilder.cs @@ -317,7 +317,8 @@ private static void AppendMBoosterPedalConfig( $"travel={FmtMm(cfg.TravelStartMm)}..{FmtMm(cfg.TravelEndMm)} " + $"endstop={FmtRaw(cfg.EndstopFrontStiffness)}/{FmtRaw(cfg.EndstopEndStiffness)} " + $"friction={FmtPct(cfg.NaturalFrictionPct)} " + - $"inCurve={(cfg.InputCurveY != null ? "set" : "—")}"); + $"inCurveY={(cfg.InputCurveY != null ? "set" : "—")} " + + $"inCurveX={(cfg.InputCurveX != null ? "set" : "—")}"); } } diff --git a/UI/Import/PitHousePedalsMapper.cs b/UI/Import/PitHousePedalsMapper.cs index 12a54e3e..b405d8b7 100644 --- a/UI/Import/PitHousePedalsMapper.cs +++ b/UI/Import/PitHousePedalsMapper.cs @@ -517,27 +517,44 @@ public void Float(string suffix, string label, float lo, float hi, string fmt, c => set(c, nv)); } - /// Output curve: nonlinear1..5 → CurveY, one combined row. + // PitHouse's own preset file format is fixed at 5 points + // (nonlinear1..5) at 20/40/60/80/100% — that's external and + // won't change. AZOM's CurveY is now 6 points at 100/6 * k + // (see MozaMBoosterRegistry.EvaluateCurveArbitraryX / + // MBoosterUiConstants.SimInputMappingNodeCount), so the + // imported 5-point shape is resampled at the 6 new breakpoints + // rather than mapped 1:1 onto the first 5 of 6 slots. + private static readonly float[] PitHouseOutputCurveX = { 20, 40, 60, 80, 100 }; + + /// Output curve: nonlinear1..5 → CurveY (resampled to 6 nodes). public void OutputCurve() { - var y = new float[5]; + var y5 = new float[5]; bool any = false; for (int i = 0; i < 5; i++) { var v = Num("nonlinear" + (i + 1)); if (v == null) continue; - y[i] = (float)Clamp(v.Value, 0, 100); + y5[i] = (float)Clamp(v.Value, 0, 100); any = true; } if (!any) return; + int n = global::MozaPlugin.Devices.MBoosterUiConstants.SimInputMappingNodeCount; + var y = new float[n]; + for (int i = 0; i < n; i++) + { + double x = (i + 1) * 100.0 / 6.0; + y[i] = (float)global::MozaPlugin.Devices.MozaMBoosterRegistry.EvaluateCurveArbitraryX(PitHouseOutputCurveX, y5, x); + } + var oldCurve = _read.CurveY; - string oldDisplay = oldCurve == null || oldCurve.Length < 5 + string oldDisplay = oldCurve == null || oldCurve.Length < n ? "(unset)" - : string.Join("/", oldCurve.Take(5).Select(FormatCurvePoint)); + : string.Join("/", oldCurve.Take(n).Select(FormatCurvePoint)); string newDisplay = string.Join("/", y.Select(FormatCurvePoint)); - Add("Output curve (Y at 20/40/60/80/100%)", oldDisplay, newDisplay, + Add("Output curve (Y at 100/6% breakpoints)", oldDisplay, newDisplay, c => c.CurveY = (float[])y.Clone()); } diff --git a/UI/MozaPluginSettings.cs b/UI/MozaPluginSettings.cs index aaddbf37..055c7ee5 100644 --- a/UI/MozaPluginSettings.cs +++ b/UI/MozaPluginSettings.cs @@ -269,6 +269,23 @@ public class MozaPluginSettings // VerboseWireDebugLog=true. See MozaPlugin.Init. public bool VerboseWireDebugLogDefaultMigrated { get; set; } + // One-shot marker for the migration that resamples every saved + // mBooster CurveY/CurveX/InputCurveY array from its old 5-node + // shape to the current 6-node one, preserving each curve's visual + // shape instead of silently discarding it to a default. See + // MozaPlugin.Init and MozaMBoosterRegistry.MigrateCurveArraysTo6. + public bool MBoosterCurveArraysMigratedTo6 { get; set; } + + // One-shot marker for the follow-up migration that fixes the Sim + // Input Mapping curve's default X breakpoints — they were 100/7 * k + // (last node ~85.7%, inherited from the disproven/removed curve7 + // mechanism), capping Linear/preset/migrated curves at ~86% output + // instead of reaching 100%. Any profile already run through + // MBoosterCurveArraysMigratedTo6, or that clicked a preset button, + // baked in the too-low shape. See MozaPlugin.Init and + // FixMBoosterCurveArraysSeventhsBug. + public bool MBoosterCurveArraysFixedSeventhsBug { get; set; } + // ~1/min pull of the wheel display's own log via session FF kind=14, // acked with kind=15 (which clears those lines on the device). No UI — // flip to false in MozaPluginSettings.json to stop the pull entirely. diff --git a/UI/SettingsControl.Redesign.cs b/UI/SettingsControl.Redesign.cs index 28e5a384..f1d29c18 100644 --- a/UI/SettingsControl.Redesign.cs +++ b/UI/SettingsControl.Redesign.cs @@ -107,17 +107,22 @@ private void InitRedesignControls() BindEditorToSliders(MBoosterCurveEditor, new[] { MBoosterY1Slider, MBoosterY2Slider, MBoosterY3Slider, - MBoosterY4Slider, MBoosterY5Slider + MBoosterY4Slider, MBoosterY5Slider, MBoosterY6Slider }); BindEditorXToSliders(MBoosterCurveEditor, new[] { MBoosterX1Slider, MBoosterX2Slider, MBoosterX3Slider, - MBoosterX4Slider, MBoosterX5Slider + MBoosterX4Slider, MBoosterX5Slider, MBoosterX6Slider }); BindEditorToSliders(MBoosterInputCurveEditor, new[] { MBoosterInputY1Slider, MBoosterInputY2Slider, MBoosterInputY3Slider, - MBoosterInputY4Slider, MBoosterInputY5Slider + MBoosterInputY4Slider, MBoosterInputY5Slider, MBoosterInputY6Slider + }); + BindEditorXToSliders(MBoosterInputCurveEditor, new[] + { + MBoosterInputX1Slider, MBoosterInputX2Slider, MBoosterInputX3Slider, + MBoosterInputX4Slider, MBoosterInputX5Slider, MBoosterInputX6Slider }); // Two-way bindings: CurveEditor.YN ↔ EqNSlider.Value (FFB EQ @@ -257,17 +262,17 @@ private void BindEditorToSliders(MozaControls.MozaCurveEditor editor, Slider[] s // Two-way bind a MozaCurveEditor's X dependency properties to sliders — // only meaningful when the editor has AllowHorizontalDrag="True". Accepts - // 5 sliders (mBooster Sim Input Mapping — all nodes draggable) or 4 (the - // wheelbase FFB curve, whose last node is pinned at input=100 via - // LockLastNodeX so X5 keeps its DP default). No X6 — horizontal drag - // isn't offered on the 6-band EQ. + // 6 sliders (mBooster Sim Input Mapping and Pedal Feel — all nodes + // draggable) or 4 (the wheelbase FFB curve, whose last node is pinned + // at input=100 via LockLastNodeX so X5 keeps its DP default). + // Horizontal drag isn't offered on the 6-band EQ. private void BindEditorXToSliders(MozaControls.MozaCurveEditor editor, Slider[] sliders) { if (editor == null || sliders == null || sliders.Length < 4) return; var xs = new[] { MozaControls.MozaCurveEditor.X1Property, MozaControls.MozaCurveEditor.X2Property, MozaControls.MozaCurveEditor.X3Property, MozaControls.MozaCurveEditor.X4Property, - MozaControls.MozaCurveEditor.X5Property }; + MozaControls.MozaCurveEditor.X5Property, MozaControls.MozaCurveEditor.X6Property }; int n = Math.Min(sliders.Length, xs.Length); for (int i = 0; i < n; i++) { diff --git a/UI/SettingsControl.xaml b/UI/SettingsControl.xaml index c5b5a9eb..9c85f680 100644 --- a/UI/SettingsControl.xaml +++ b/UI/SettingsControl.xaml @@ -1845,13 +1845,75 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +