diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 503e92f5..99388d02 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -68,11 +68,17 @@ jobs: state=$(jq -r '.state' <<<"$info") draft=$(jq -r '.isDraft' <<<"$info") head_repo=$(jq -r '.headRepositoryOwner.login + "/" + .headRepository.name' <<<"$info") - if [ "$state" != "OPEN" ] || [ "$draft" = "true" ] \ - || [ "$head_repo" != "$GITHUB_REPOSITORY" ]; then - echo "::error::PR #${INPUT_PR} is not an open, non-draft, same-repo PR" + if [ "$state" != "OPEN" ] || [ "$draft" = "true" ]; then + echo "::error::PR #${INPUT_PR} is not an open, non-draft PR" exit 1 fi + # Fork heads are buildable ONLY down this path. workflow_dispatch is + # maintainer-triggered, so the fork's code has been reviewed before + # it gets a write token and DISCORD_WEBHOOK_URL. The pull_request + # trigger still refuses forks outright (see the `changed` job's if:). + if [ "$head_repo" != "$GITHUB_REPOSITORY" ]; then + echo "::warning::PR #${INPUT_PR} head is fork ${head_repo} — building fork code with repo secrets" + fi pr_number=$(jq -r '.number' <<<"$info") pr_title=$(jq -r '.title' <<<"$info") head_sha=$(jq -r '.headRefOid' <<<"$info") @@ -106,9 +112,25 @@ jobs: with: # Build the PR head, not the synthetic merge commit — the release # tag must point at a real commit and the artifact must match it. - ref: ${{ steps.pr.outputs.head_sha }} + # Via refs/pull//head, which resolves for a fork head too; a bare + # SHA does not, since that commit lives in the fork, not here. + ref: refs/pull/${{ steps.pr.outputs.pr_number }}/head fetch-depth: 0 + - name: Verify checkout matches PR head + shell: bash + env: + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: | + set -euo pipefail + # refs/pull//head can advance between the resolve step and here. + # Tag and artifact must agree, so fail rather than ship a mismatch. + actual=$(git rev-parse HEAD) + if [ "$actual" != "$HEAD_SHA" ]; then + echo "::error::Checked out ${actual} but PR head is ${HEAD_SHA}" + exit 1 + fi + - name: Setup .NET SDK uses: actions/setup-dotnet@v5 with: 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/BaseSettingCatalog.cs b/BaseSettingCatalog.cs new file mode 100644 index 00000000..9f5c488e --- /dev/null +++ b/BaseSettingCatalog.cs @@ -0,0 +1,378 @@ +using System; + +namespace MozaPlugin +{ + /// + /// Declarative table of every wheelbase setting reachable from a SimHub + /// button binding, consumed by to generate the + /// AZOM.* property delegates and step/toggle actions. One row per + /// setting instead of one hand-written Step method each. + /// + /// Scales and ranges mirror the Base-tab slider handlers in + /// UI/SettingsControl.xaml.cs exactly — display units are what the + /// slider shows, raw is what goes on the wire. Keep the two in sync: a + /// mismatch writes a plausible-looking wrong value to the parameter store. + /// + /// Every command here is a group 0x28/0x29 (or main group 0x1F) parameter + /// slot and hits base flash on write, so callers must not stream these — + /// see the rail guard in . + /// + internal static class BaseSettingCatalog + { + /// One steppable numeric wheelbase setting. + internal sealed class NumericSetting + { + public string Name = ""; // AZOM. + public Func GetRaw = _ => 0; + public Action SetRaw = (_, __) => { }; + public string[] Commands = Array.Empty(); // order is load-bearing (Rotation) + public int Min; + public int Max; + public int Fine; + public int Coarse; + public Func ToDisplay = v => v; + public Func ToRaw = v => v; + /// Per-firmware max override (EQ band mode). Null = use . + public Func? MaxFor; + /// Firmware capability gate. Null = always available. + public Func? Supported; + + public int EffectiveMax(MozaData d) => MaxFor?.Invoke(d) ?? Max; + public bool IsSupported(MozaData d) => Supported?.Invoke(d) ?? true; + } + + /// One on/off wheelbase setting. + internal sealed class ToggleSetting + { + public string Name = ""; // AZOM.{On,Off,Toggle} + public Func Get = _ => 0; + public Action Set = (_, __) => { }; + public string Command = ""; + public int OnValue = 1; + public int OffValue; + /// True when the current raw value counts as "on". + public bool IsOn(MozaData d) => Get(d) == OnValue; + } + + // Percent settings stored as percent x 10 (base-ffb-strength, base-damper, ...). + private static int FromTenths(int raw) => (int)Math.Round(raw / 10.0); + private static int ToTenths(int display) => display * 10; + + // Game effect gains: 0-100 % stored as 0-255. + private static int From255(int raw) => (int)Math.Round(raw / 2.55); + private static int To255(int display) => (int)Math.Round(display * 2.55); + + // Soft-limit stiffness: display 1-10 <-> raw 100-500 (affine, cf. + // SoftLimitStiffnessSlider_ValueChanged / RefreshBaseTab). + private const double SoftLimitStep = 400.0 / 9.0; + private static int FromSoftLimit(int raw) => (int)Math.Round((raw / SoftLimitStep) - 2.25 + 1.0); + private static int ToSoftLimit(int display) => (int)Math.Round(display * SoftLimitStep - SoftLimitStep + 100.0); + + // ===== Road sensitivity + EQ presets ============================== + // Shared with UI/SettingsControl.xaml.cs so the button macro and the + // AZOM step actions drive identical values. + // + // ORDER IS LOAD-BEARING: static field initializers run in textual + // order, and the Numeric table below calls EqBand(), which indexes + // EqRegisterCommands. Declaring these after Numeric leaves them null + // during its initializer — a NullReferenceException inside the static + // constructor, surfacing as a TypeInitializationException that kills + // plugin Init (and, via SettingsControl.EqCommands, the settings pane). + // Keep every static array Numeric depends on above it. + + /// EQ write commands in register order (band 1..10). + internal static readonly string[] EqRegisterCommands = + { + "base-equalizer1", "base-equalizer2", "base-equalizer3", + "base-equalizer4", "base-equalizer5", "base-equalizer6", + "base-equalizer7", "base-equalizer8", "base-equalizer9", + "base-equalizer10" + }; + + /// + /// EQ registers in FREQUENCY order (5/10/15/25/30/40/50/60/80/100 Hz) — + /// the 10-band registers interleave. Preset rows are in this order. + /// + internal static readonly string[] Eq10FreqOrderCommands = + { + "base-equalizer1", "base-equalizer7", "base-equalizer2", + "base-equalizer3", "base-equalizer8", "base-equalizer4", + "base-equalizer9", "base-equalizer5", "base-equalizer10", + "base-equalizer6" + }; + + /// Register index (0-based) for each frequency column, in frequency order. + internal static readonly int[] Eq10FreqOrderRegisters = { 0, 6, 1, 2, 7, 3, 8, 4, 9, 5 }; + + /// + /// Frequency-order columns carried by the legacy registers Eq1..Eq6 + /// (5/15/25/40/60/100 Hz). + /// + internal static readonly int[] Eq6FreqColumns = { 0, 2, 3, 5, 7, 9 }; + + /// + /// PitHouse "sensitivity" presets 0..10 — one-shot macros writing + /// road-sensitivity (0x0C = 10 + 4*N) plus a canned EQ curve; no + /// dedicated sensitivity register exists, so the buttons are momentary. + /// Values in frequency order 5/10/15/25/30/40/50/60/80/100 Hz. On + /// legacy firmware only the six old registers are written (columns via + /// ) — the four new bands are skipped. + /// + internal static readonly int[][] EqSensitivityPresets = + { + new[] { 100, 100, 30, 10, 0, 0, 0, 0, 0, 0 }, + new[] { 100, 100, 60, 20, 10, 0, 0, 0, 0, 0 }, + new[] { 100, 100, 70, 40, 30, 10, 0, 0, 0, 0 }, + new[] { 100, 100, 80, 50, 40, 20, 10, 10, 0, 0 }, + new[] { 100, 100, 90, 60, 50, 30, 20, 20, 10, 0 }, + new[] { 100, 100, 100, 70, 60, 40, 30, 30, 10, 0 }, + new[] { 100, 100, 100, 90, 80, 50, 40, 40, 20, 0 }, + new[] { 100, 100, 100, 100, 90, 60, 60, 60, 40, 0 }, + new[] { 100, 100, 100, 100, 90, 80, 80, 80, 60, 0 }, + new[] { 100, 100, 100, 100, 100, 100, 100, 100, 80, 0 }, + new[] { 100, 100, 100, 100, 100, 100, 100, 100, 100, 100 }, + }; + + /// + /// Steppable settings. Each row generates one AZOM.<Name> + /// property (display units) plus Up/Down/UpCoarse/ + /// DownCoarse actions. + /// + internal static readonly NumericSetting[] Numeric = + { + // ── Base / motor ──────────────────────────────────────────────── + new NumericSetting { + Name = "FfbStrength", Commands = new[] { "base-ffb-strength" }, + GetRaw = d => d.FfbStrength, SetRaw = (d, v) => d.FfbStrength = v, + Min = 0, Max = 100, Fine = 5, Coarse = 10, + ToDisplay = FromTenths, ToRaw = ToTenths }, + + new NumericSetting { + Name = "Torque", Commands = new[] { "base-torque" }, + GetRaw = d => d.Torque, SetRaw = (d, v) => d.Torque = v, + Min = 50, Max = 100, Fine = 5, Coarse = 10 }, + + // Rotation writes both slots; base-limit must precede base-max-angle. + new NumericSetting { + Name = "Rotation", Commands = new[] { "base-limit", "base-max-angle" }, + GetRaw = d => d.Limit, SetRaw = (d, v) => { d.Limit = v; d.MaxAngle = v; }, + Min = 60, Max = 2700, Fine = 90, Coarse = 180, + ToDisplay = raw => raw * 2, ToRaw = deg => deg / 2 }, + + new NumericSetting { + Name = "WheelSpeedLimit", Commands = new[] { "base-speed" }, + GetRaw = d => d.Speed, SetRaw = (d, v) => d.Speed = v, + Min = 0, Max = 200, Fine = 5, Coarse = 10, + ToDisplay = FromTenths, ToRaw = ToTenths }, + + new NumericSetting { + Name = "Interpolation", Commands = new[] { "main-set-interpolation" }, + GetRaw = d => d.Interpolation, SetRaw = (d, v) => d.Interpolation = v, + Min = 0, Max = 10, Fine = 1, Coarse = 2, + ToDisplay = FromTenths, ToRaw = ToTenths }, + + new NumericSetting { + Name = "GearshiftVibration", Commands = new[] { "base-gearshift-vibration" }, + GetRaw = d => d.GearshiftVibration, SetRaw = (d, v) => d.GearshiftVibration = v, + Min = 0, Max = 5, Fine = 1, Coarse = 2 }, + + // ── Wheelbase effects ─────────────────────────────────────────── + new NumericSetting { + Name = "Damper", Commands = new[] { "base-damper" }, + GetRaw = d => d.Damper, SetRaw = (d, v) => d.Damper = v, + Min = 0, Max = 100, Fine = 5, Coarse = 10, + ToDisplay = FromTenths, ToRaw = ToTenths }, + + new NumericSetting { + Name = "Friction", Commands = new[] { "base-friction" }, + GetRaw = d => d.Friction, SetRaw = (d, v) => d.Friction = v, + Min = 0, Max = 100, Fine = 5, Coarse = 10, + ToDisplay = FromTenths, ToRaw = ToTenths }, + + // "Natural Inertia" on the Wheelbase Effects card (cmd 0x04) — not + // to be confused with NaturalInertia below (cmd 0x13). + new NumericSetting { + Name = "Inertia", Commands = new[] { "base-inertia" }, + GetRaw = d => d.Inertia, SetRaw = (d, v) => d.Inertia = v, + Min = 100, Max = 500, Fine = 10, Coarse = 50, + ToDisplay = FromTenths, ToRaw = ToTenths }, + + // "Wheel Spring" — the base's own mechanical centering spring. + new NumericSetting { + Name = "Spring", Commands = new[] { "base-spring" }, + GetRaw = d => d.Spring, SetRaw = (d, v) => d.Spring = v, + Min = 0, Max = 100, Fine = 5, Coarse = 10, + ToDisplay = FromTenths, ToRaw = ToTenths }, + + // ── Game effect gains (DirectInput effect scaling, main group) ─── + new NumericSetting { + Name = "GameDamper", Commands = new[] { "main-set-damper-gain" }, + GetRaw = d => d.GameDamper, SetRaw = (d, v) => d.GameDamper = v, + Min = 0, Max = 100, Fine = 5, Coarse = 10, + ToDisplay = From255, ToRaw = To255 }, + + new NumericSetting { + Name = "GameFriction", Commands = new[] { "main-set-friction-gain" }, + GetRaw = d => d.GameFriction, SetRaw = (d, v) => d.GameFriction = v, + Min = 0, Max = 100, Fine = 5, Coarse = 10, + ToDisplay = From255, ToRaw = To255 }, + + new NumericSetting { + Name = "GameInertia", Commands = new[] { "main-set-inertia-gain" }, + GetRaw = d => d.GameInertia, SetRaw = (d, v) => d.GameInertia = v, + Min = 0, Max = 100, Fine = 5, Coarse = 10, + ToDisplay = From255, ToRaw = To255 }, + + // "Game Spring" — gain on the game's own spring effect. Distinct + // from Spring above; both are centering-ish, neither supersedes it. + new NumericSetting { + Name = "GameSpring", Commands = new[] { "main-set-spring-gain" }, + GetRaw = d => d.GameSpring, SetRaw = (d, v) => d.GameSpring = v, + Min = 0, Max = 100, Fine = 5, Coarse = 10, + ToDisplay = From255, ToRaw = To255 }, + + // ── Protection / soft limit / high-speed damping ──────────────── + // "Steering Wheel Inertia" on the Protection card (cmd 0x13). + new NumericSetting { + Name = "NaturalInertia", Commands = new[] { "base-natural-inertia" }, + GetRaw = d => d.NaturalInertia, SetRaw = (d, v) => d.NaturalInertia = v, + Min = 100, Max = 4000, Fine = 50, Coarse = 200 }, + + new NumericSetting { + Name = "SoftLimitStiffness", Commands = new[] { "base-soft-limit-stiffness" }, + GetRaw = d => d.SoftLimitStiffness, SetRaw = (d, v) => d.SoftLimitStiffness = v, + Min = 1, Max = 10, Fine = 1, Coarse = 2, + ToDisplay = FromSoftLimit, ToRaw = ToSoftLimit }, + + new NumericSetting { + Name = "SpeedDamping", Commands = new[] { "base-speed-damping" }, + GetRaw = d => d.SpeedDamping, SetRaw = (d, v) => d.SpeedDamping = v, + Min = 0, Max = 100, Fine = 5, Coarse = 10 }, + + new NumericSetting { + Name = "SpeedDampingPoint", Commands = new[] { "base-speed-damping-point" }, + GetRaw = d => d.SpeedDampingPoint, SetRaw = (d, v) => d.SpeedDampingPoint = v, + Min = 0, Max = 400, Fine = 10, Coarse = 50 }, + + // ── FFB equalizer (register order; see EqRegisterFrequencies) ──── + // Legacy firmware caps every band at 400 %; 10-band firmware raises + // 1-5 to 500 % and drops band 6 (100 Hz) to 100 % — cf. ApplyEqBandMode. + EqBand(1, d => d.Equalizer1, (d, v) => d.Equalizer1 = v), + EqBand(2, d => d.Equalizer2, (d, v) => d.Equalizer2 = v), + EqBand(3, d => d.Equalizer3, (d, v) => d.Equalizer3 = v), + EqBand(4, d => d.Equalizer4, (d, v) => d.Equalizer4 = v), + EqBand(5, d => d.Equalizer5, (d, v) => d.Equalizer5 = v), + EqBand(6, d => d.Equalizer6, (d, v) => d.Equalizer6 = v), + EqBand(7, d => d.Equalizer7, (d, v) => d.Equalizer7 = v), + EqBand(8, d => d.Equalizer8, (d, v) => d.Equalizer8 = v), + EqBand(9, d => d.Equalizer9, (d, v) => d.Equalizer9 = v), + EqBand(10, d => d.Equalizer10, (d, v) => d.Equalizer10 = v), + + // ── FFB output curve ──────────────────────────────────────────── + // The base has x1..x4 but no x5 and does not persist the X + // breakpoints; the profile-apply path therefore rides all nine + // together. These per-node actions match what the UI curve sliders + // already do (one command per dragged node). + CurveNode("FfbCurveX1", "base-ffb-curve-x1", d => d.FfbCurveX1, (d, v) => d.FfbCurveX1 = v), + CurveNode("FfbCurveX2", "base-ffb-curve-x2", d => d.FfbCurveX2, (d, v) => d.FfbCurveX2 = v), + CurveNode("FfbCurveX3", "base-ffb-curve-x3", d => d.FfbCurveX3, (d, v) => d.FfbCurveX3 = v), + CurveNode("FfbCurveX4", "base-ffb-curve-x4", d => d.FfbCurveX4, (d, v) => d.FfbCurveX4 = v), + CurveNode("FfbCurveY1", "base-ffb-curve-y1", d => d.FfbCurveY1, (d, v) => d.FfbCurveY1 = v), + CurveNode("FfbCurveY2", "base-ffb-curve-y2", d => d.FfbCurveY2, (d, v) => d.FfbCurveY2 = v), + CurveNode("FfbCurveY3", "base-ffb-curve-y3", d => d.FfbCurveY3, (d, v) => d.FfbCurveY3 = v), + CurveNode("FfbCurveY4", "base-ffb-curve-y4", d => d.FfbCurveY4, (d, v) => d.FfbCurveY4 = v), + CurveNode("FfbCurveY5", "base-ffb-curve-y5", d => d.FfbCurveY5, (d, v) => d.FfbCurveY5 = v), + }; + + private static NumericSetting EqBand(int band, Func get, Action set) + => new NumericSetting + { + Name = "Equalizer" + band, + Commands = new[] { EqRegisterCommands[band - 1] }, + GetRaw = get, SetRaw = set, + Min = 0, Max = 400, Fine = 5, Coarse = 25, + // Band 6 is the 100 Hz band, capped at 100 % on 10-band firmware. + MaxFor = band == 6 + ? (Func)(d => d.BaseSupportsEq10 ? 100 : 400) + : d => d.BaseSupportsEq10 ? 500 : 400, + // Bands 7-10 exist only on 10-band firmware — old bases must + // never see cmds 0x32..0x35. + Supported = band >= 7 ? (Func)(d => d.BaseSupportsEq10) : null, + }; + + private static NumericSetting CurveNode(string name, string cmd, Func get, Action set) + => new NumericSetting + { + Name = name, Commands = new[] { cmd }, + GetRaw = get, SetRaw = set, + Min = 0, Max = 100, Fine = 5, Coarse = 10, + }; + + /// + /// On/off settings. Each row generates one AZOM.<Name> + /// bool property plus On/Off/Toggle actions. + /// WorkMode is deliberately absent — its On/Off actions predate + /// this table and are registered by hand so the names don't collide. + /// + internal static readonly ToggleSetting[] Toggles = + { + new ToggleSetting { + Name = "Protection", Command = "base-protection", + Get = d => d.Protection, Set = (d, v) => d.Protection = v }, + + new ToggleSetting { + Name = "FfbReverse", Command = "base-ffb-reverse", + Get = d => d.FfbReverse, Set = (d, v) => d.FfbReverse = v }, + + new ToggleSetting { + Name = "SoftLimitRetain", Command = "base-soft-limit-retain", + Get = d => d.SoftLimitRetain, Set = (d, v) => d.SoftLimitRetain = v }, + + // cmd 0x1E: 0 = Reserved, 1 = Full. "On" = full output. + new ToggleSetting { + Name = "PerformanceOutput", Command = "base-temp-strategy", + Get = d => d.TempStrategy, Set = (d, v) => d.TempStrategy = v }, + + new ToggleSetting { + Name = "BaseStatusLed", Command = "main-set-led-status", + Get = d => d.LedStatus, Set = (d, v) => d.LedStatus = v }, + + // BLE is inverted on the wire: 0 = on, 85 = off. + new ToggleSetting { + Name = "Bluetooth", Command = "main-set-ble-mode", + Get = d => d.BleMode, Set = (d, v) => d.BleMode = v, + OnValue = 0, OffValue = 85 }, + }; + + internal const int RoadSensitivityMinPreset = 0; + internal const int RoadSensitivityMaxPreset = 10; + + /// Preset index 0..10 from the stored register value, or -1 when unread. + internal static int RoadSensitivityPresetFromRaw(int raw) + { + if (raw < 10) return -1; + int n = (int)Math.Round((raw - 10) / 4.0); + return n < 0 ? 0 : (n > 10 ? 10 : n); + } + + internal static int RoadSensitivityRawFromPreset(int preset) => 10 + 4 * preset; + + /// Write one EQ register's value into by 0-based register index. + internal static void SetEqRegister(MozaData d, int index0, int value) + { + switch (index0) + { + case 0: d.Equalizer1 = value; break; + case 1: d.Equalizer2 = value; break; + case 2: d.Equalizer3 = value; break; + case 3: d.Equalizer4 = value; break; + case 4: d.Equalizer5 = value; break; + case 5: d.Equalizer6 = value; break; + case 6: d.Equalizer7 = value; break; + case 7: d.Equalizer8 = value; break; + case 8: d.Equalizer9 = value; break; + case 9: d.Equalizer10 = value; break; + } + } + } +} diff --git a/Devices/Ab9EngineVibrationWorker.cs b/Devices/Ab9EngineVibrationWorker.cs index 9fea37e8..523dd997 100644 --- a/Devices/Ab9EngineVibrationWorker.cs +++ b/Devices/Ab9EngineVibrationWorker.cs @@ -31,10 +31,6 @@ internal sealed class Ab9EngineVibrationWorker : IDisposable // FreqTickHz × maxRpm does (K = FreqTickHz × maxRpm). See // docs/protocol/devices/ab9-shifter.md and tools/ab9-rpm-correlate. private const double FreqTickHz = 6.18e7; //New value from kilarn123, old: 6.366e7; - // Redline fallback when the game doesn't report MaxRpm (matches the - // HardwareApplier 8000-rpm convention) so the slider still maps to a - // sensible redline frequency. - private const double DefaultRedlineRpm = 8000.0; private const int TickPeriodMs = 11; // Sub-stream tick budgets. Scaled by rpm/IdleRpm at runtime where noted. private const int KeepalivePairBaseTicks = 12; @@ -200,12 +196,12 @@ private void Tick() if (rawActive) { // audible = freqSlider × (rpm/maxRpm); slider is the redline - // frequency. period = FreqTickHz / audible. Clamp the fraction + // frequency. period = FreqTickHz / audible. Fraction clamped // to (0,1] so over-rev can't exceed the redline pitch and a - // missing MaxRpm falls back to an 8000-rpm redline. - double redline = maxRpm > 100.0 ? maxRpm : DefaultRedlineRpm; - double fraction = rpm / redline; - if (fraction > 1.0) fraction = 1.0; + // missing MaxRpm falls back to the shared redline convention + // (see EngineVibrationMath.RedlineFraction — the same model + // MBoosterEffectWorker.UpdateEngineRequest uses for Engine). + double fraction = EngineVibrationMath.RedlineFraction(rpm, maxRpm); double p = FreqTickHz / (freqHz * fraction); if (p < MozaAb9DeviceManager.MinPeriodTicks) p = MozaAb9DeviceManager.MinPeriodTicks; if (p > MozaAb9DeviceManager.MaxPeriodTicks) p = MozaAb9DeviceManager.MaxPeriodTicks; diff --git a/Devices/BaseLfeEffectWorker.cs b/Devices/BaseLfeEffectWorker.cs index 77d46b5f..17347773 100644 --- a/Devices/BaseLfeEffectWorker.cs +++ b/Devices/BaseLfeEffectWorker.cs @@ -40,7 +40,7 @@ internal sealed class BaseLfeEffectWorker : IDisposable // synthetic RPM (idle→redline) into the real formulas and evaluates them — // so the combined preset is coherent (every slot at the SAME rpm) and matches // in-game exactly. Non-RPM property refs still read live telemetry. - private const double SynthRedlineRpm = 8000.0; + private const double SynthRedlineRpm = EngineVibrationMath.DefaultRedlineRpm; private const double SynthMaxSpeedKmh = 250.0; // speed at the top of the sweep (for speed-scaled effects) private const string RpmToken = "[DataCorePlugin.GameData.Rpms]"; private const string MaxRpmToken = "[DataCorePlugin.GameData.MaxRpm]"; @@ -457,10 +457,7 @@ private static double Envelope(double intensity01, ref double phase, double freq { double depth = 1.0 - smoothness01; if (depth > 1e-6 && freqHz > 0) - { - phase += 2.0 * Math.PI * freqHz * TickPeriodSec; - if (phase >= 2.0 * Math.PI) phase -= 2.0 * Math.PI * Math.Floor(phase / (2.0 * Math.PI)); - } + phase = EngineVibrationMath.AdvancePhase(phase, freqHz, TickPeriodSec); double env = (1.0 - depth) + depth * (0.5 + 0.5 * Math.Sin(phase)); return Clamp01(intensity01 * env); } diff --git a/Devices/EngineVibrationMath.cs b/Devices/EngineVibrationMath.cs new file mode 100644 index 00000000..85e94f7a --- /dev/null +++ b/Devices/EngineVibrationMath.cs @@ -0,0 +1,52 @@ +using System; + +namespace MozaPlugin.Devices +{ + /// + /// Shared math for the "engine vibration" effect across the three FFB + /// hardware types that each render it over a different wire protocol — + /// (wheelbase LFE engine/ABS/gearshift + /// streams), (mBooster vibration + /// motor), and (AB9 shifter). The + /// wire encoding stays per-device (frame shapes, param tables, and + /// sub-streams are hardware-specific and not interchangeable), but the + /// carrier-phase oscillator and the RPM-to-redline scaling underneath it + /// are the same math each worker used to re-derive independently. + /// + internal static class EngineVibrationMath + { + /// Redline fallback when the game doesn't report MaxRpm — the + /// convention and + /// 's Engine effect both use. + public const double DefaultRedlineRpm = 8000.0; + + /// + /// RPM as a fraction of redline, clamped to at most 1 so an over-rev + /// can't exceed the redline pitch/period. + /// below 100 (the game not reporting it) falls back to + /// . Assumes + /// is non-negative (both callers already gate + /// on rpm > 0 before reaching here). + /// + public static double RedlineFraction(double rpm, double maxRpm, double defaultRedlineRpm = DefaultRedlineRpm) + { + double redline = maxRpm > 100.0 ? maxRpm : defaultRedlineRpm; + double fraction = rpm / redline; + return fraction > 1.0 ? 1.0 : fraction; + } + + /// + /// Advance a phase accumulator by one tick at the given carrier + /// frequency, wrapped to [0, 2π) for numerical stability over a long + /// running session — the oscillator underneath every sine-based + /// vibration waveform in this app. + /// + public static double AdvancePhase(double phase, double freqHz, double dtSec) + { + double p = phase + 2.0 * Math.PI * freqHz * dtSec; + if (p >= 2.0 * Math.PI) + p -= 2.0 * Math.PI * Math.Floor(p / (2.0 * Math.PI)); + return p; + } + } +} diff --git a/Devices/MBoosterDeviceController.cs b/Devices/MBoosterDeviceController.cs index 4c107250..0466c750 100644 --- a/Devices/MBoosterDeviceController.cs +++ b/Devices/MBoosterDeviceController.cs @@ -374,6 +374,32 @@ public byte MotorDeviceForCurrentAxis(int axisIndex) return isChain ? MotorDeviceForAxis(axisIndex) : MozaProtocol.DeviceMain; } + /// + /// Whether HID axis is a genuinely wired + /// pedal rather than an unused GenericDesktop usage a chain-capable + /// hub's report descriptor always exposes (Rx/Ry/Rz) regardless of how + /// many pedals are actually plugged in. Trusts the parsed "PD Linked" + /// diagnostic () once it arrives; before + /// that, treats every axis as real if + /// already confirmed a multi-motor chain at connect, else assumes only + /// axis 0 is wired. Same convention + /// uses to gate its own per-pedal tick — callers that resolve a HID + /// axis's role (see ) + /// need this too: raw alone can't tell a real + /// chain from a single connected pedal on a chain-capable hub, so + /// using it directly silently overrides that pedal's own configured + /// Role with the axis-order default (Throttle/Brake/Clutch by index). + /// + public bool IsAxisConnected(int axisIndex) + { + var connected = _connectedAxes; + if (connected != null) + return axisIndex < connected.Length && connected[axisIndex]; + if (SubDeviceCount > 1) + return axisIndex < Math.Max(1, AxisCount); + return axisIndex == 0; + } + /// /// The motor device id for a pedal ROLE (0=Throttle,1=Brake,2=Clutch), /// using the calibration-derived chain map (see @@ -780,6 +806,30 @@ public int SoleConnectedAxis() return count == 1 ? sole : -1; } + /// + /// HID axis indices of the pedals this lane ACTUALLY hosts. The HID + /// interface commonly reports 3 axes (Rx/Ry/Rz) regardless of how many + /// pedals are physically connected — (from + /// the "PD Linked" firmware diagnostic) is the only way to tell which + /// are real. Until that diagnostic arrives (null), only axis 0 counts: + /// the common case is a standalone single pedal, and a genuine chain's + /// extra axes appear as soon as the diagnostic confirms them instead of + /// showing phantom pedals. Shared by the mBooster tab's row list and the + /// PitHouse import wizard's target list so both show the same pedals. + /// + public List ConnectedAxisIndices() + { + int axisCount = AxisCount > 0 ? AxisCount : 1; + var connected = _connectedAxes; + var axes = new List(); + for (int axis = 0; axis < axisCount && axis < MaxAxes; axis++) + { + bool known = connected != null && axis < connected.Length ? connected[axis] : axis == 0; + if (known) axes.Add(axis); + } + return axes; + } + /// Short identity slug for capture labels / log lines — last 8 chars of instance id. public static string ShortIdentity(string identity) { @@ -1144,6 +1194,21 @@ public void SetRoadTextureTestActive(bool on, int pedalIndex = 0) WorkerFor(pedalIndex)?.SetRoadTextureTestSustained(on); } + /// + /// Continuously alternates G-Force's commanded travel offset + /// forward/backward at the currently configured Max Travel/Response + /// Speed while is true, bypassing Enabled and + /// the game-running gate — mirrors Pit House's own "Test" demo. See + /// for the analogous Engine + /// toggle; same live-tracking and always-allow-off semantics apply + /// here. + /// + public void SetGForceTestActive(bool on, int pedalIndex = 0) + { + if (on && !_connection.IsConnected) return; + WorkerFor(pedalIndex)?.SetGForceTestSustained(on); + } + /// /// Continuously runs Lockup — substituting live brake position for /// the wheel-slip detection heuristic (which needs vehicle speed), diff --git a/Devices/MBoosterEffectWorker.cs b/Devices/MBoosterEffectWorker.cs index 1ef7e13d..18c4b4d9 100644 --- a/Devices/MBoosterEffectWorker.cs +++ b/Devices/MBoosterEffectWorker.cs @@ -88,6 +88,7 @@ internal sealed class MBoosterEffectWorker : IDisposable private EffectState _threshold; private EffectState _engine; private EffectState _roadTexture; + private EffectState _gforce; private bool _thresholdLatched; // hysteresis flag for the Threshold effect (doc § 4) // Debounce countdown for the Gear Shift effect, decremented each // tick by TickPeriodSec — separate from EffectState.ElapsedSec @@ -140,6 +141,7 @@ internal sealed class MBoosterEffectWorker : IDisposable private volatile bool _roadTextureTestSustained; private volatile bool _lockupTestSustained; private volatile bool _thresholdTestSustained; + private volatile bool _gforceTestSustained; // Custom effects' sustained Test toggles — same semantics as the five // built-ins above (runs indefinitely, live-tracks Frequency/Intensity, @@ -272,6 +274,7 @@ private struct EffectState public double SmoothnessRequest01; // 0..1, ABS (user-set); Traction Control/Wheel Spin fix this at 1 public double RoadTextureRoughness01; // 0..1, Road-Texture-only: live suspension-derived intensity scale public double ThresholdDecayRequest01; // 0..1, Threshold-only: sustain-decay depth + public double GForceSigned01; // -1..1, G-Force-only: live longitudinal-G fraction (+ accel, - brake) } public MBoosterEffectWorker( @@ -364,6 +367,9 @@ public void PostFrame(in MBoosterTelemetrySnapshot snap) /// Turn Threshold's sustained test toggle on/off. See . public void SetThresholdTestSustained(bool on) => _thresholdTestSustained = on; + /// Turn G-Force's sustained test toggle on/off. See . + public void SetGForceTestSustained(bool on) => _gforceTestSustained = on; + /// Turn Brake Fade's sustained test toggle on/off. See . public void SetBrakeFadeTestSustained(bool on) => _brakeFadeTestActive = on; @@ -443,6 +449,7 @@ private void Tick() UpdateLockupRequest(effects, brakeSignal, snap, ref _lockup); UpdateThresholdRequest(effects, brakeSignal, snap, ref _threshold); UpdateRoadTextureRequest(effects, snap, ref _roadTexture); + UpdateGForceRequest(effects, snap, ref _gforce); // --- Apply per-effect activation edges + emit motor frame ------ // @@ -469,6 +476,13 @@ private void Tick() // threshold pulse that lands in the same tick as a bump always // wins instead of being masked by it. ProcessRoadTextureEffect(effects, ref _roadTexture); + // G-Force — same ambient tier as Engine/Road Texture (before + // the wheel-slip cues) so a lockup/ABS/TC/wheel-spin/ + // threshold/gear-shift pulse always wins if it lands in the + // same tick, matching every other continuous effect's + // priority. Its own frame shape is unrelated to any of the + // others' (see ProcessGForceEffect). + ProcessGForceEffect(effects, ref _gforce); // Custom (NCalc) effects — Experimental. Placed in the ambient // tier (after Engine/Road Texture, before the wheel-slip cues) so // a user-authored effect can override built-in ambient vibration @@ -536,9 +550,6 @@ private void Tick() // rpm/redline scaling below. Matches the top of the device's // hardware-safe engine range (MBoosterUiConstants.EngineFreqMaxHz). private const double EngineRedlineFreqHz = MBoosterUiConstants.EngineFreqMaxHz; - // Redline fallback when the game doesn't report MaxRpm — same - // 8000-rpm convention Ab9EngineVibrationWorker and HardwareApplier use. - private const double EngineDefaultRedlineRpm = 8000.0; private void UpdateEngineRequest(IMBoosterEffects? effects, in MBoosterTelemetrySnapshot snap, ref EffectState st) { @@ -580,10 +591,10 @@ private void UpdateEngineRequest(IMBoosterEffects? effects, in MBoosterTelemetry } // fraction clamped to (0,1] so over-rev can't exceed the redline - // pitch; a missing MaxRpm falls back to EngineDefaultRedlineRpm — - // same shape as Ab9EngineVibrationWorker.Tick. - double redline = snap.MaxRpm > 100.0 ? snap.MaxRpm : EngineDefaultRedlineRpm; - double fraction = Math.Min(1.0, rpm / redline); + // pitch; a missing MaxRpm falls back to the shared redline + // convention (see EngineVibrationMath.RedlineFraction — the same + // model Ab9EngineVibrationWorker.Tick uses). + double fraction = EngineVibrationMath.RedlineFraction(rpm, snap.MaxRpm); st.FreqHz = ClampEngineFreq(EngineRedlineFreqHz * fraction); // Engine continuous-effect: user 0..100 % maps to output // amplitude 0..EngineScaleMax — see the constants block above @@ -1107,6 +1118,42 @@ private void UpdateRoadTextureRequest(IMBoosterEffects? effects, in MBoosterTele st.IntensityRequest = envelope > 0.01 ? 1 : 0; } + // How many G reads as "100 %" commanded travel. Not a Pit House + // control — its own "Test" demo always commands the full configured + // Max Travel regardless of any G reading, so this mapping is the + // plugin's own choice (Experimental). 1.0G covers hard braking/ + // acceleration in most sim content without the effect maxing out on + // every firm stop. + private const double GForceFullScaleG = 1.0; + // Test-toggle demo cadence — mirrors Pit House's own alternating + // "Test" cycle (~0.6-0.7s per phase in capture) so the user can feel + // both directions, not a wire-protocol requirement. + private const double GForceTestPhaseSec = 0.6; + + private void UpdateGForceRequest(IMBoosterEffects? effects, in MBoosterTelemetrySnapshot snap, ref EffectState st) + { + if (_gforceTestSustained) + { + st.ElapsedSec += TickPeriodSec; + double cycle = st.ElapsedSec % (GForceTestPhaseSec * 2); + st.GForceSigned01 = cycle < GForceTestPhaseSec ? 1.0 : -1.0; + st.IntensityRequest = 1; + return; + } + + bool active = effects?.GForce != null && effects.GForce.Enabled && snap.GameRunning; + if (!active) + { + st.IntensityRequest = 0; + st.GForceSigned01 = 0; + return; + } + + double signed = snap.LongitudinalG / GForceFullScaleG; + st.GForceSigned01 = Math.Max(-1.0, Math.Min(1.0, signed)); + st.IntensityRequest = 1; + } + // Brake Fade — NOT a vibration effect. Dynamically rewrites TWO real // hardware calibrations in lockstep as brake temp climbs past // BrakeFadeOnsetC, using the SAME ramp01 fraction for both so they @@ -1222,7 +1269,39 @@ private void UpdateBrakeFadeThreshold(IMBoosterPedalConfig? pedalConfig, double // ===== Edge handling + frame emission ============================= - private void ProcessEffect(MBoosterEffectId id, ref EffectState st) + /// + /// Wire-native dispatch for the four effects with their OWN + /// protocol-verified (or at least self-consistent) effect type — + /// Abs/Lockup/Threshold/Engine — where the wire id IS the logical + /// effect. See the (id, ref st, synthesize) overload below for + /// the shared activation-edge/phase/frame-emission core; effects that + /// need a DIFFERENT logical waveform than their wire id (Traction + /// Control, Wheel Spin, Gear Shift, Custom Effects — all reuse + /// Engine's wire slot) call that overload directly instead. + /// + private void ProcessEffect(MBoosterEffectId id, ref EffectState st) => + ProcessEffect(id, ref st, s => id switch + { + MBoosterEffectId.Abs => MBoosterEffectSynthesizer.SynthesizeAbs(s.IntensityRequest, s.PhaseRad, s.SmoothnessRequest01), + MBoosterEffectId.Lockup => MBoosterEffectSynthesizer.SynthesizeLockup(s.IntensityRequest, s.ElapsedSec), + MBoosterEffectId.Threshold => MBoosterEffectSynthesizer.SynthesizeThreshold(s.IntensityRequest, s.ElapsedSec, s.ThresholdDecayRequest01), + MBoosterEffectId.Engine => MBoosterEffectSynthesizer.SynthesizeEngine(s.IntensityRequest, s.PhaseRad), + _ => 0.0, + }); + + /// + /// Shared activation-edge + phase-oscillator + frame-emission core + /// for every vibration effect that goes out via + /// (i.e. every + /// effect except Road Texture and G-Force, which have their own + /// differently-shaped wire payloads). is the + /// WIRE effect type the frame is addressed as — for Traction + /// Control/Wheel Spin/Gear Shift/Custom Effects that's always + /// (no verified wire type of + /// their own), while picks the actual + /// waveform for whichever LOGICAL effect this call represents. + /// + private void ProcessEffect(MBoosterEffectId id, ref EffectState st, Func synthesize) { bool wantActive = st.IntensityRequest > 0 && st.FreqHz > 0; @@ -1249,19 +1328,9 @@ private void ProcessEffect(MBoosterEffectId id, ref EffectState st) } st.ElapsedSec += TickPeriodSec; - // phase += 2π * freq * dt; wrap at 2π for numerical stability. - st.PhaseRad += 2.0 * Math.PI * st.FreqHz * TickPeriodSec; - if (st.PhaseRad >= 2.0 * Math.PI) - st.PhaseRad -= 2.0 * Math.PI * Math.Floor(st.PhaseRad / (2.0 * Math.PI)); + st.PhaseRad = EngineVibrationMath.AdvancePhase(st.PhaseRad, st.FreqHz, TickPeriodSec); - double amp01 = id switch - { - MBoosterEffectId.Abs => MBoosterEffectSynthesizer.SynthesizeAbs(st.IntensityRequest, st.PhaseRad, st.SmoothnessRequest01), - MBoosterEffectId.Lockup => MBoosterEffectSynthesizer.SynthesizeLockup(st.IntensityRequest, st.ElapsedSec), - MBoosterEffectId.Threshold => MBoosterEffectSynthesizer.SynthesizeThreshold(st.IntensityRequest, st.ElapsedSec, st.ThresholdDecayRequest01), - MBoosterEffectId.Engine => MBoosterEffectSynthesizer.SynthesizeEngine(st.IntensityRequest, st.PhaseRad), - _ => 0.0, - }; + double amp01 = synthesize(st); byte param1 = MozaMBoosterProtocol.ComputeParam1( MozaMBoosterProtocol.ParamKFor(id), st.FreqHz); @@ -1327,6 +1396,52 @@ private void ProcessRoadTextureEffect(IMBoosterEffects? effects, ref EffectState SendMotor(frame); } + /// + /// G-Force (Inertial Pedal Feel) — Experimental. NOT a vibration + /// effect: unlike every other Process* method here, this holds + /// enable=1 continuously while active and streams a live directional + /// TRAVEL OFFSET target every tick (see + /// MozaMBoosterProtocol.BuildGForceFrame) rather than synthesizing a + /// waveform. (computed in + /// UpdateGForceRequest, -1..1) selects which of the wire's two + /// offset slots carries the magnitude — positive (accelerating) + /// pushes the forward slot, negative (braking) the backward slot — + /// scaled by the user's MaxTravelMm against the wire's fixed 15mm + /// full-scale range. ResponseSpeedPct is sent unshaped every frame; + /// the firmware does the actual ramping, not this worker. + /// + private void ProcessGForceEffect(IMBoosterEffects? effects, ref EffectState st) + { + const MBoosterEffectId id = MBoosterEffectId.GForce; + bool wantActive = st.IntensityRequest > 0; + + if (!wantActive) + { + if (st.Active) + { + _device.SendOneShot(MozaMBoosterProtocol.BuildDisableFrame(id, TargetDevice)); + st.Active = false; + } + return; + } + st.Active = true; + + var gforce = effects?.GForce; + double maxTravelMm = Math.Max(0, gforce?.MaxTravelMm ?? 0); + double responseSpeedPct = Clamp01((gforce?.ResponseSpeedPct ?? 0) / 100.0); + + double travelFraction01 = Clamp01(Math.Abs(st.GForceSigned01)) + * (maxTravelMm / MBoosterUiConstants.GForceMaxTravelMaxMm); + + ushort responseRaw = MozaMBoosterProtocol.EncodeAmp(responseSpeedPct); + ushort magnitudeRaw = MozaMBoosterProtocol.EncodeAmp(travelFraction01); + ushort forwardRaw = st.GForceSigned01 >= 0 ? magnitudeRaw : (ushort)0; + ushort backwardRaw = st.GForceSigned01 < 0 ? magnitudeRaw : (ushort)0; + + var frame = MozaMBoosterProtocol.BuildGForceFrame(true, responseRaw, forwardRaw, backwardRaw, TargetDevice); + SendMotor(frame); + } + /// /// Update + process every user-created custom effect for one tick /// (Experimental — docs/protocol/devices/mbooster.md "Custom @@ -1449,42 +1564,9 @@ private void UpdateCustomEffectRequest(MBoosterCustomEffect effect, ref EffectSt /// wire slot — see the ordering note at this method's call site in /// . /// - private void ProcessCustomEffect(ref EffectState st) - { - const MBoosterEffectId id = MBoosterEffectId.Engine; - bool wantActive = st.IntensityRequest > 0 && st.FreqHz > 0; - - if (!wantActive && st.Active) - { - _device.SendOneShot(MozaMBoosterProtocol.BuildDisableFrame(id, TargetDevice)); - st.Active = false; - st.PhaseRad = 0; - st.ElapsedSec = 0; - return; - } - if (!wantActive) return; - - if (!st.Active) - { - st.Active = true; - st.PhaseRad = 0; - st.ElapsedSec = 0; - } - - st.ElapsedSec += TickPeriodSec; - st.PhaseRad += 2.0 * Math.PI * st.FreqHz * TickPeriodSec; - if (st.PhaseRad >= 2.0 * Math.PI) - st.PhaseRad -= 2.0 * Math.PI * Math.Floor(st.PhaseRad / (2.0 * Math.PI)); - - double amp01 = MBoosterEffectSynthesizer.SynthesizeEngine(st.IntensityRequest, st.PhaseRad); - - byte param1 = MozaMBoosterProtocol.ComputeParam1(MozaMBoosterProtocol.ParamKFor(id), st.FreqHz); - ushort freqU16 = MozaMBoosterProtocol.EncodeFreq(st.FreqHz); - ushort ampU16 = MozaMBoosterProtocol.EncodeAmp(amp01); - - var frame = MozaMBoosterProtocol.BuildMotorFrame(id, enable: true, param1, freqU16, ampU16, TargetDevice); - SendMotor(frame); - } + private void ProcessCustomEffect(ref EffectState st) => + ProcessEffect(MBoosterEffectId.Engine, ref st, + s => MBoosterEffectSynthesizer.SynthesizeEngine(s.IntensityRequest, s.PhaseRad)); /// /// Activation-edge + frame-emission path for Traction Control — @@ -1499,42 +1581,9 @@ private void ProcessCustomEffect(ref EffectState st) /// active custom effects for that one wire slot — see the ordering /// note at this method's call site in . /// - private void ProcessTractionControlEffect(ref EffectState st) - { - const MBoosterEffectId id = MBoosterEffectId.Engine; - bool wantActive = st.IntensityRequest > 0 && st.FreqHz > 0; - - if (!wantActive && st.Active) - { - _device.SendOneShot(MozaMBoosterProtocol.BuildDisableFrame(id, TargetDevice)); - st.Active = false; - st.PhaseRad = 0; - st.ElapsedSec = 0; - return; - } - if (!wantActive) return; - - if (!st.Active) - { - st.Active = true; - st.PhaseRad = 0; - st.ElapsedSec = 0; - } - - st.ElapsedSec += TickPeriodSec; - st.PhaseRad += 2.0 * Math.PI * st.FreqHz * TickPeriodSec; - if (st.PhaseRad >= 2.0 * Math.PI) - st.PhaseRad -= 2.0 * Math.PI * Math.Floor(st.PhaseRad / (2.0 * Math.PI)); - - double amp01 = MBoosterEffectSynthesizer.SynthesizeTractionControl(st.IntensityRequest, st.PhaseRad, st.SmoothnessRequest01); - - byte param1 = MozaMBoosterProtocol.ComputeParam1(MozaMBoosterProtocol.ParamKFor(id), st.FreqHz); - ushort freqU16 = MozaMBoosterProtocol.EncodeFreq(st.FreqHz); - ushort ampU16 = MozaMBoosterProtocol.EncodeAmp(amp01); - - var frame = MozaMBoosterProtocol.BuildMotorFrame(id, enable: true, param1, freqU16, ampU16, TargetDevice); - SendMotor(frame); - } + private void ProcessTractionControlEffect(ref EffectState st) => + ProcessEffect(MBoosterEffectId.Engine, ref st, + s => MBoosterEffectSynthesizer.SynthesizeTractionControl(s.IntensityRequest, s.PhaseRad, s.SmoothnessRequest01)); /// /// Activation-edge + frame-emission path for Wheel Spin — identical @@ -1547,42 +1596,9 @@ private void ProcessTractionControlEffect(ref EffectState st) /// see the ordering note at this method's call site in /// . /// - private void ProcessWheelSpinEffect(ref EffectState st) - { - const MBoosterEffectId id = MBoosterEffectId.Engine; - bool wantActive = st.IntensityRequest > 0 && st.FreqHz > 0; - - if (!wantActive && st.Active) - { - _device.SendOneShot(MozaMBoosterProtocol.BuildDisableFrame(id, TargetDevice)); - st.Active = false; - st.PhaseRad = 0; - st.ElapsedSec = 0; - return; - } - if (!wantActive) return; - - if (!st.Active) - { - st.Active = true; - st.PhaseRad = 0; - st.ElapsedSec = 0; - } - - st.ElapsedSec += TickPeriodSec; - st.PhaseRad += 2.0 * Math.PI * st.FreqHz * TickPeriodSec; - if (st.PhaseRad >= 2.0 * Math.PI) - st.PhaseRad -= 2.0 * Math.PI * Math.Floor(st.PhaseRad / (2.0 * Math.PI)); - - double amp01 = MBoosterEffectSynthesizer.SynthesizeWheelSpin(st.IntensityRequest, st.PhaseRad, st.SmoothnessRequest01); - - byte param1 = MozaMBoosterProtocol.ComputeParam1(MozaMBoosterProtocol.ParamKFor(id), st.FreqHz); - ushort freqU16 = MozaMBoosterProtocol.EncodeFreq(st.FreqHz); - ushort ampU16 = MozaMBoosterProtocol.EncodeAmp(amp01); - - var frame = MozaMBoosterProtocol.BuildMotorFrame(id, enable: true, param1, freqU16, ampU16, TargetDevice); - SendMotor(frame); - } + private void ProcessWheelSpinEffect(ref EffectState st) => + ProcessEffect(MBoosterEffectId.Engine, ref st, + s => MBoosterEffectSynthesizer.SynthesizeWheelSpin(s.IntensityRequest, s.PhaseRad, s.SmoothnessRequest01)); /// /// Activation-edge + frame-emission path for Gear Shift — same @@ -1596,42 +1612,9 @@ private void ProcessWheelSpinEffect(ref EffectState st) /// the waveform — a short oscillating burst that decays to silence /// over . /// - private void ProcessGearShiftEffect(ref EffectState st) - { - const MBoosterEffectId id = MBoosterEffectId.Engine; - bool wantActive = st.IntensityRequest > 0 && st.FreqHz > 0; - - if (!wantActive && st.Active) - { - _device.SendOneShot(MozaMBoosterProtocol.BuildDisableFrame(id, TargetDevice)); - st.Active = false; - st.PhaseRad = 0; - st.ElapsedSec = 0; - return; - } - if (!wantActive) return; - - if (!st.Active) - { - st.Active = true; - st.PhaseRad = 0; - st.ElapsedSec = 0; - } - - st.ElapsedSec += TickPeriodSec; - st.PhaseRad += 2.0 * Math.PI * st.FreqHz * TickPeriodSec; - if (st.PhaseRad >= 2.0 * Math.PI) - st.PhaseRad -= 2.0 * Math.PI * Math.Floor(st.PhaseRad / (2.0 * Math.PI)); - - double amp01 = MBoosterEffectSynthesizer.SynthesizeGearShift(st.IntensityRequest, st.PhaseRad, st.ElapsedSec, GearShiftPulseDurationSec); - - byte param1 = MozaMBoosterProtocol.ComputeParam1(MozaMBoosterProtocol.ParamKFor(id), st.FreqHz); - ushort freqU16 = MozaMBoosterProtocol.EncodeFreq(st.FreqHz); - ushort ampU16 = MozaMBoosterProtocol.EncodeAmp(amp01); - - var frame = MozaMBoosterProtocol.BuildMotorFrame(id, enable: true, param1, freqU16, ampU16, TargetDevice); - SendMotor(frame); - } + private void ProcessGearShiftEffect(ref EffectState st) => + ProcessEffect(MBoosterEffectId.Engine, ref st, + s => MBoosterEffectSynthesizer.SynthesizeGearShift(s.IntensityRequest, s.PhaseRad, s.ElapsedSec, GearShiftPulseDurationSec)); // ===== Helpers ==================================================== diff --git a/Devices/MBoosterTypes.cs b/Devices/MBoosterTypes.cs index 12cfb155..3320edee 100644 --- a/Devices/MBoosterTypes.cs +++ b/Devices/MBoosterTypes.cs @@ -92,6 +92,48 @@ public static class MBoosterUiConstants // .ProcessCustomEffect — so the frequency range matches Engine's. public const float CustomEffectFreqMinHz = 5f; public const float CustomEffectFreqMaxHz = 200f; + + // G-Force (Inertial Pedal Feel) — Max Pedal Travel slider bounds, + // matching Pit House's own "Max Pedal Travelment" control exactly + // (0-15mm — reverse-engineered from capture, see + // MozaMBoosterProtocol.BuildGForceFrame). This is also the wire + // protocol's fixed full-scale denominator: the encoded travel + // fraction is always relative to 15mm, not to some other range. + public const float GForceMaxTravelMinMm = 0f; + public const float GForceMaxTravelMaxMm = 15f; + + // G-Force's Response Speed slider bounds, matching Pit House's own + // control exactly (0-100%). Sent to the firmware every frame — it's + // not host-side smoothing, the device itself ramps toward the + // commanded offset at this rate. + public const float GForceResponseSpeedMinPct = 0f; + public const float GForceResponseSpeedMaxPct = 100f; + + // Segmented Damping — divider bounds per Pit House's own UI (each + // divider has its OWN independent min/max, unlike a plain dual- + // thumb range): Divider1 stays within [10, 80], Divider2 within + // [20, 90], and the two may never be adjusted within 10% of each + // other. Shared by both "When Pressed" and "When Released" (each + // has its own independent pair of dividers, same bounds). See + // MozaControls.MozaSegmentedBarEditor and + // docs/protocol/devices/mbooster.md "Segmented Damping". + public const double SegDampDivider1MinPct = 10.0; + public const double SegDampDivider1MaxPct = 80.0; + public const double SegDampDivider2MinPct = 20.0; + public const double SegDampDivider2MaxPct = 90.0; + public const double SegDampDividerMinGapPct = 10.0; + + // Factory defaults, reverse-engineered from a recurring untouched + // baseline across multiple independent captures (5+ sessions each + // for Pressed and Released) — used as the fallback whenever a + // MBoosterSegmentedDampingSettings field is still the -1 "not set" + // sentinel, so a fresh profile displays a sensible starting layout + // without writing anything until the user actually drags a control. + public const float SegDampDivider1PressedDefaultPct = 33f; + public const float SegDampDivider2PressedDefaultPct = 67f; + public const float SegDampDivider1ReleasedDefaultPct = 20f; + public const float SegDampDivider2ReleasedDefaultPct = 70f; + public const float SegDampSegDefaultPct = 0f; } /// @@ -197,6 +239,22 @@ public sealed class MBoosterEffectSettings // matches the wheelbase's own GearshiftDebounceMs default. public int DebounceMs { get; set; } = 500; + // G-Force-only (Experimental), millimeters (MBoosterUiConstants + // .GForceMaxTravelMinMm/MaxMm) — how far the pedal pushes at 100% + // commanded G, matching Pit House's own "Max Pedal Travelment" + // slider exactly. Sent every frame as a fraction of the wire's + // fixed 15mm full scale — see MBoosterEffectWorker.ProcessGForceEffect + // and MozaMBoosterProtocol.BuildGForceFrame. + public float MaxTravelMm { get; set; } = 10f; + + // G-Force-only (Experimental), 0..100 (MBoosterUiConstants + // .GForceResponseSpeedMinPct/MaxPct) — how fast the firmware ramps + // the pedal toward the newly commanded offset, matching Pit House's + // own "Response Speed" slider exactly. Unlike every other + // Intensity/Frequency knob this isn't host-side shaping — the raw + // percentage is sent straight to the device every frame. + public int ResponseSpeedPct { get; set; } = 50; + public MBoosterEffectSettings Clone() => new MBoosterEffectSettings { @@ -209,6 +267,66 @@ public MBoosterEffectSettings Clone() => BrakeFadeOnsetC = BrakeFadeOnsetC, VibrateOnNeutral = VibrateOnNeutral, DebounceMs = DebounceMs, + MaxTravelMm = MaxTravelMm, + ResponseSpeedPct = ResponseSpeedPct, + }; + } + + /// + /// Segmented Damping (Pedal Feel) — Pit House's "simulate a damping + /// force independent of in-game output, dividing pedal travel into + /// multiple segments with adjustable range and its own natural + /// damping" feature. Reverse-engineered from real Pit House USB + /// captures (see docs/protocol/devices/mbooster.md "Segmented + /// Damping"): ONE wire command (cmdId 0xB7) carries the ENTIRE + /// feature's state — both the "When Pressed" and "When Released" + /// curves — as 10 fields in a fixed order, sent as a whole snapshot + /// on every edit to any one of them (see + /// MozaMBoosterProtocol.BuildSegmentedDampingFrame). Both "When + /// Pressed" and "When Released" have their own UI plot; unset fields + /// fall back to Pit House's own factory defaults (reverse-engineered + /// from a recurring untouched baseline across multiple captures) + /// until the user actually edits them. + /// -1 = "not yet set / no override", same sentinel convention as + /// EndstopFrontStiffness/NaturalFrictionPct — a fresh profile writes + /// nothing until the user actually drags a divider or segment. + /// + public sealed class MBoosterSegmentedDampingSettings + { + // Two dividers split 0-100% pedal travel into 3 segments. Bounds + // and the 10% minimum gap between them are Pit House's own + // (MBoosterUiConstants.SegDampDivider1Min/Max etc.) — Divider1 and + // Divider2 are independent from their *Released counterparts + // below (confirmed from capture: dragging one pair's dividers + // never changed the other pair's wire field). + public float Divider1Pressed { get; set; } = -1; + public float Divider2Pressed { get; set; } = -1; + // Damping amount (0-100%) applied within each of the 3 segments + // while the pedal is being pressed. + public float Seg1Pressed { get; set; } = -1; + public float Seg2Pressed { get; set; } = -1; + public float Seg3Pressed { get; set; } = -1; + + // "When Released" — same shape, own UI plot (see class summary). + public float Divider1Released { get; set; } = -1; + public float Divider2Released { get; set; } = -1; + public float Seg1Released { get; set; } = -1; + public float Seg2Released { get; set; } = -1; + public float Seg3Released { get; set; } = -1; + + public MBoosterSegmentedDampingSettings Clone() => + new MBoosterSegmentedDampingSettings + { + Divider1Pressed = Divider1Pressed, + Divider2Pressed = Divider2Pressed, + Seg1Pressed = Seg1Pressed, + Seg2Pressed = Seg2Pressed, + Seg3Pressed = Seg3Pressed, + Divider1Released = Divider1Released, + Divider2Released = Divider2Released, + Seg1Released = Seg1Released, + Seg2Released = Seg2Released, + Seg3Released = Seg3Released, }; } @@ -286,6 +404,7 @@ public interface IMBoosterEffects MBoosterEffectSettings TractionControl { get; set; } MBoosterEffectSettings WheelSpin { get; set; } MBoosterEffectSettings GearShift { get; set; } + MBoosterEffectSettings GForce { get; set; } System.Collections.Generic.List CustomEffects { get; set; } } @@ -317,6 +436,8 @@ public interface IMBoosterPedalConfig : IMBoosterEffects float TravelEndMm { get; set; } float EndstopFrontStiffness { get; set; } float EndstopEndStiffness { get; set; } + float NaturalFrictionPct { get; set; } + MBoosterSegmentedDampingSettings SegmentedDamping { get; set; } } /// @@ -350,6 +471,8 @@ public sealed class MBoosterPedalSettings : IMBoosterPedalConfig public float TravelEndMm { get; set; } = -1; public float EndstopFrontStiffness { get; set; } = -1; public float EndstopEndStiffness { get; set; } = -1; + public float NaturalFrictionPct { get; set; } = -1; + public MBoosterSegmentedDampingSettings SegmentedDamping { get; set; } = new MBoosterSegmentedDampingSettings(); // Per-pedal vibration effects (same defaults as the master's flat fields). public MBoosterEffectSettings Abs { get; set; } = new MBoosterEffectSettings { FrequencyHz = 22 }; @@ -360,6 +483,7 @@ public sealed class MBoosterPedalSettings : IMBoosterPedalConfig public MBoosterEffectSettings TractionControl { get; set; } = new MBoosterEffectSettings { FrequencyHz = 22 }; public MBoosterEffectSettings WheelSpin { get; set; } = new MBoosterEffectSettings { FrequencyHz = 30 }; public MBoosterEffectSettings GearShift { get; set; } = new MBoosterEffectSettings { FrequencyHz = 22 }; + public MBoosterEffectSettings GForce { get; set; } = new MBoosterEffectSettings { MaxTravelMm = 10, ResponseSpeedPct = 50 }; public List CustomEffects { get; set; } = new List(); public MBoosterPedalSettings Clone() => @@ -379,6 +503,8 @@ public MBoosterPedalSettings Clone() => TravelEndMm = TravelEndMm, EndstopFrontStiffness = EndstopFrontStiffness, EndstopEndStiffness = EndstopEndStiffness, + NaturalFrictionPct = NaturalFrictionPct, + SegmentedDamping = SegmentedDamping?.Clone() ?? new MBoosterSegmentedDampingSettings(), Abs = Abs?.Clone() ?? new MBoosterEffectSettings(), Lockup = Lockup?.Clone() ?? new MBoosterEffectSettings(), Threshold = Threshold?.Clone() ?? new MBoosterEffectSettings(), @@ -387,6 +513,7 @@ public MBoosterPedalSettings Clone() => TractionControl = TractionControl?.Clone() ?? new MBoosterEffectSettings(), WheelSpin = WheelSpin?.Clone() ?? new MBoosterEffectSettings(), GearShift = GearShift?.Clone() ?? new MBoosterEffectSettings(), + GForce = GForce?.Clone() ?? new MBoosterEffectSettings(), CustomEffects = CustomEffects?.Select(c => c.Clone()).ToList() ?? new List(), }; } @@ -452,6 +579,15 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig // Traction Control/Wheel Spin (no verified wire effect type of its // own — see MBoosterEffectWorker.ProcessGearShiftEffect). public MBoosterEffectSettings GearShift { get; set; } = new MBoosterEffectSettings { FrequencyHz = 22 }; + // G-Force (Inertial Pedal Feel) — Experimental. NOT a vibration + // effect: a sustained, directional pedal travel offset scaled by + // live longitudinal G (MBoosterTelemetrySnapshot.LongitudinalG), + // reverse-engineered from real Pit House "Test" captures (see + // docs/protocol/devices/mbooster.md "G-Force"). MaxTravelMm and + // ResponseSpeedPct mirror Pit House's own two sliders exactly; see + // MBoosterEffectWorker.UpdateGForceRequest/ProcessGForceEffect and + // MozaMBoosterProtocol.BuildGForceFrame. + public MBoosterEffectSettings GForce { get; set; } = new MBoosterEffectSettings { MaxTravelMm = 10, ResponseSpeedPct = 50 }; // FrequencyHz defaults to 55 — the exact value from the "known-good" // real Pit House capture (docs/protocol/devices/mbooster.md: // "Lockup on, 55 Hz, start of ramp"). @@ -608,6 +744,25 @@ public sealed class MBoosterDeviceSettings : IMBoosterPedalConfig public float EndstopFrontStiffness { get; set; } = -1; public float EndstopEndStiffness { get; set; } = -1; + // Natural Friction (Pit House-style), 0-100%. Real hardware write + // (not host-side-only like Deadzone/MaxForce) — reverse-engineered + // from two real Pit House USB captures (a toggle on/off, and a + // 0/25/50/75/100% slider sweep): wire commands + // mbooster-brake-friction-0/-1 (cmdId 0xAE with a selector byte, + // always written together with the same value), 2-byte int, fixed + // 0-100% scale over 0-65535 — see + // MozaMBoosterProtocol.EncodeFrictionPct/DecodeFrictionPct and + // docs/protocol/devices/mbooster.md "Pedal Feel". -1 = "not yet set + // / no override", same sentinel convention as + // TravelStartMm/EndstopFrontStiffness, so a fresh profile never + // overwrites whatever value is already on the device. + public float NaturalFrictionPct { get; set; } = -1; + + // Segmented Damping (Pit House-style) — see + // MBoosterSegmentedDampingSettings and + // docs/protocol/devices/mbooster.md "Segmented Damping". + public MBoosterSegmentedDampingSettings SegmentedDamping { get; set; } = new MBoosterSegmentedDampingSettings(); + // Friendly display label the user can edit (defaults to "mBooster" // with a serial-tail fallback). Survives reconnects with the dict key. public string DisplayName { get; set; } = ""; @@ -625,6 +780,7 @@ public MBoosterDeviceSettings Clone() TractionControl = TractionControl?.Clone() ?? new MBoosterEffectSettings(), WheelSpin = WheelSpin?.Clone() ?? new MBoosterEffectSettings(), GearShift = GearShift?.Clone() ?? new MBoosterEffectSettings(), + GForce = GForce?.Clone() ?? new MBoosterEffectSettings(), Lockup = Lockup?.Clone() ?? new MBoosterEffectSettings(), Threshold = Threshold?.Clone() ?? new MBoosterEffectSettings(), Engine = Engine?.Clone() ?? new MBoosterEffectSettings(), @@ -645,6 +801,8 @@ public MBoosterDeviceSettings Clone() TravelEndMm = TravelEndMm, EndstopFrontStiffness = EndstopFrontStiffness, EndstopEndStiffness = EndstopEndStiffness, + NaturalFrictionPct = NaturalFrictionPct, + SegmentedDamping = SegmentedDamping?.Clone() ?? new MBoosterSegmentedDampingSettings(), DisplayName = DisplayName, }; } @@ -679,6 +837,13 @@ public readonly struct MBoosterTelemetrySnapshot // this is a proxy for road-surface roughness used by Road Texture. // See docs/protocol/devices/mbooster.md "Road Texture". public readonly double SuspensionHeaveG; + // Longitudinal chassis acceleration, in G — SimHub's + // StatusDataBase.AccelerationSurge (nullable; 0 when a game doesn't + // report it). Positive = accelerating, negative = braking. Drives + // the G-Force (Inertial Pedal Feel) effect — see + // MBoosterEffectWorker.UpdateGForceRequest and + // docs/protocol/devices/mbooster.md "G-Force". + public readonly double LongitudinalG; // Peak brake temperature across all 4 corners, normalized to // Celsius regardless of the game's reported TemperatureUnit — // sourced from StatusDataBase.BrakesTemperatureMax (nullable; 0 @@ -710,7 +875,7 @@ public readonly struct MBoosterTelemetrySnapshot public MBoosterTelemetrySnapshot( bool gameRunning, double rpm, double maxRpm, double idleRpm, double brake, double throttle, bool absActive, bool tcActive, - double vehicleSpeedMs, double avgWheelSpeedMs, double suspensionHeaveG, + double vehicleSpeedMs, double avgWheelSpeedMs, double suspensionHeaveG, double longitudinalG, double brakeTempC, int gearShiftSeq, bool gearIsNeutral) { GameRunning = gameRunning; @@ -724,12 +889,13 @@ public MBoosterTelemetrySnapshot( VehicleSpeedMs = vehicleSpeedMs; AvgWheelSpeedMs = avgWheelSpeedMs; SuspensionHeaveG = suspensionHeaveG; + LongitudinalG = longitudinalG; BrakeTempC = brakeTempC; GearShiftSeq = gearShiftSeq; GearIsNeutral = gearIsNeutral; } public static readonly MBoosterTelemetrySnapshot Empty = - new MBoosterTelemetrySnapshot(false, 0, 0, 800, 0, 0, false, false, 0, 0, 0, 0, 0, false); + new MBoosterTelemetrySnapshot(false, 0, 0, 800, 0, 0, false, false, 0, 0, 0, 0, 0, 0, false); } } diff --git a/Devices/MozaMBoosterRegistry.cs b/Devices/MozaMBoosterRegistry.cs index 91dbd243..81c8b634 100644 --- a/Devices/MozaMBoosterRegistry.cs +++ b/Devices/MozaMBoosterRegistry.cs @@ -829,13 +829,24 @@ private void MergePositions() // echo (or, before the mirror ticks, zero) the real values. if (c.IsRouted) continue; var s = _settingsLookup(c.Identity); - var connected = c.ConnectedAxes; - int axisCount = c.AxisCount > 0 ? c.AxisCount : 1; - if (axisCount > MBoosterDeviceController.MaxAxes) axisCount = MBoosterDeviceController.MaxAxes; - for (int a = 0; a < axisCount; a++) + int rawAxisCount = c.AxisCount > 0 ? c.AxisCount : 1; + if (rawAxisCount > MBoosterDeviceController.MaxAxes) rawAxisCount = MBoosterDeviceController.MaxAxes; + + // Resolve roles against how many axes are ACTUALLY wired, + // not the raw HID axis count — a chain-capable hub's report + // descriptor exposes all 3 GenericDesktop axes even when + // only one pedal is physically plugged in, so raw AxisCount + // can't tell a real chain from a single connected pedal. + // Getting this wrong silently overrides that pedal's own + // Role with the axis-order default (see IsAxisConnected). + int connectedAxisCount = 0; + for (int a = 0; a < rawAxisCount; a++) + if (c.IsAxisConnected(a)) connectedAxisCount++; + + for (int a = 0; a < rawAxisCount; a++) { - if (connected != null && (a >= connected.Length || !connected[a])) continue; - var role = ResolveAxisRole(s, a, axisCount); + if (!c.IsAxisConnected(a)) continue; + var role = ResolveAxisRole(s, a, connectedAxisCount); if (role == MBoosterRole.Disabled) continue; // MozaData position fields are int (0..100, the same scale // the existing HID reader writes). Round explicitly. @@ -890,6 +901,48 @@ internal static MBoosterRole ResolveAxisRole(MBoosterDeviceSettings? s, int axis } } + /// + /// The full per-pedal config object for one axis of a lane, creating a + /// missing chained-pedal entry on demand: the master's flat fields for + /// axis 0, else [axis]. + /// A lane whose SOLE connected pedal is this (non-zero) axis with no + /// per-pedal entry gets the flat fields instead (and never creates the + /// entry) — that's where the config landed while the UI still showed the + /// axis-0 row, and creating an empty entry here would orphan it. See + /// . + /// + internal static IMBoosterPedalConfig? GetOrCreatePedalConfig( + MBoosterDeviceSettings? s, int axisIndex, int soleConnectedAxis) + { + if (s == null) return null; + if (axisIndex <= 0) return s; + if (!s.Pedals.TryGetValue(axisIndex, out var p)) + { + if (soleConnectedAxis == axisIndex) return s; + // Copy-on-write: publish a NEW dictionary via atomic reference + // swap rather than mutating in place, so the 50 Hz effect worker + // threads reading s.Pedals never see a dictionary mid-resize. + p = new MBoosterPedalSettings(); + s.Pedals = new Dictionary(s.Pedals) { [axisIndex] = p }; + } + return p; + } + + /// + /// Same resolution as but WITHOUT + /// creating a missing chained-pedal entry — for read-only callers + /// (control seeding, import previews) so merely looking at a pedal never + /// persists an empty entry. Null when that pedal has no config yet. + /// + internal static IMBoosterPedalConfig? PeekPedalConfig( + MBoosterDeviceSettings? s, int axisIndex, int soleConnectedAxis) + { + if (s == null) return null; + if (axisIndex <= 0) return s; + if (s.Pedals.TryGetValue(axisIndex, out var p)) return p; + return soleConnectedAxis == axisIndex ? s : null; + } + private void LogCollisionOnce(string role, string identity) { string key = role + ":" + identity; diff --git a/MozaPlugin.cs b/MozaPlugin.cs index a0cb696f..a590fc85 100644 --- a/MozaPlugin.cs +++ b/MozaPlugin.cs @@ -2075,6 +2075,14 @@ public void DataUpdate(PluginManager pluginManager, ref GameData data) // road-surface roughness. Nullable — 0 for games that don't // report it, same fail-soft style as the rest of this block. double suspensionHeaveG = nd?.AccelerationHeave ?? 0.0; + // Longitudinal chassis acceleration, in G — SimHub's + // StatusDataBase.AccelerationSurge (= AccelerationX), same + // family/convention as AccelerationHeave above. Positive = + // accelerating, negative = braking/decelerating. Drives the + // G-Force (Inertial Pedal Feel) effect — see + // MBoosterEffectWorker.UpdateGForceRequest. Nullable — 0 for + // games that don't report it. + double longitudinalG = nd?.AccelerationSurge ?? 0.0; // Brake Fade's temperature signal — peak across all 4 // corners (any one wheel overheating should trigger the // warning, not just the average). BrakesTemperatureMax is @@ -2125,6 +2133,7 @@ public void DataUpdate(PluginManager pluginManager, ref GameData data) vehicleSpeedMs: vehicleMs, avgWheelSpeedMs: avgWheelMs, suspensionHeaveG: suspensionHeaveG, + longitudinalG: longitudinalG, brakeTempC: brakeTempC, gearShiftSeq: _mboosterShiftSeq, gearIsNeutral: gearIsNeutral); @@ -2702,6 +2711,43 @@ internal void ApplyMBoosterToHardware(MBoosterDeviceController controller, MBoos global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeEndstopStiffness(cfg.EndstopEndStiffness), dev); wroteAnyCalibration = true; } + if (cfg.NaturalFrictionPct >= 0) + { + int frictionRaw = global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeFrictionPct(cfg.NaturalFrictionPct); + controller.SendIntWrite("mbooster-brake-friction-0", frictionRaw, dev); + controller.SendIntWrite("mbooster-brake-friction-1", frictionRaw, dev); + wroteAnyCalibration = true; + } + // Segmented Damping (both "When Pressed" and "When + // Released" — see cfg.SegmentedDamping). One wire command + // carries the whole feature's state at once, so a fresh + // profile with no override on EITHER side still sends + // nothing here (guarded like every other calibration write + // above); once ANY field on either side is set, the frame + // is filled out using factory defaults for whichever side + // still has no override. + var sd = cfg.SegmentedDamping; + if (sd != null && (sd.Divider1Pressed >= 0 || sd.Divider2Pressed >= 0 + || sd.Seg1Pressed >= 0 || sd.Seg2Pressed >= 0 || sd.Seg3Pressed >= 0 + || sd.Divider1Released >= 0 || sd.Divider2Released >= 0 + || sd.Seg1Released >= 0 || sd.Seg2Released >= 0 || sd.Seg3Released >= 0)) + { + var c = global::MozaPlugin.Devices.MBoosterUiConstants.SegDampSegDefaultPct; + var frame = global::MozaPlugin.Protocol.MozaMBoosterProtocol.BuildSegmentedDampingFrame( + sd.Divider1Pressed >= 0 ? sd.Divider1Pressed : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider1PressedDefaultPct, + sd.Divider2Pressed >= 0 ? sd.Divider2Pressed : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider2PressedDefaultPct, + sd.Divider1Released >= 0 ? sd.Divider1Released : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider1ReleasedDefaultPct, + sd.Divider2Released >= 0 ? sd.Divider2Released : global::MozaPlugin.Devices.MBoosterUiConstants.SegDampDivider2ReleasedDefaultPct, + sd.Seg1Pressed >= 0 ? sd.Seg1Pressed : c, + sd.Seg1Released >= 0 ? sd.Seg1Released : c, + sd.Seg2Pressed >= 0 ? sd.Seg2Pressed : c, + sd.Seg2Released >= 0 ? sd.Seg2Released : c, + sd.Seg3Pressed >= 0 ? sd.Seg3Pressed : c, + sd.Seg3Released >= 0 ? sd.Seg3Released : c, + dev); + controller.SendOneShot(frame); + wroteAnyCalibration = true; + } if (role == global::MozaPlugin.Devices.MBoosterRole.Brake) { if (cfg.SensorOutputRatioPct >= 0) diff --git a/Protocol/MozaCommandDatabase.cs b/Protocol/MozaCommandDatabase.cs index 8bc4ddda..de17bd18 100644 --- a/Protocol/MozaCommandDatabase.cs +++ b/Protocol/MozaCommandDatabase.cs @@ -608,6 +608,19 @@ static MozaCommandDatabase() // docs/protocol/devices/mbooster.md "Pedal Feel". AddCommand("mbooster-brake-endstop-front", "mbooster", 35, 36, new byte[] { 0xB2, 0x00, 0x00 }, 2, "int"); AddCommand("mbooster-brake-endstop-end", "mbooster", 35, 36, new byte[] { 0xB2, 0x00, 0x01 }, 2, "int"); + // Natural Friction (0-100%) — reverse-engineered from two real + // Pit House USB captures (a toggle on/off, and a 0/25/50/75/100% + // slider sweep). Same "prefix bytes + selector" shape as End + // Stop Stiffness above: ONE cmdId (0xAE) with a fixed 0x00 byte + // and a selector byte (0x00/0x01) before the 2-byte value — but + // unlike Endstop's independent front/end values, every capture + // write sent BOTH selectors with the IDENTICAL value in the same + // burst, so these two are always written together, never + // independently. raw = round(pct * 65535 / 100), matching + // MozaMBoosterProtocol.EncodeFrictionPct. See + // docs/protocol/devices/mbooster.md "Pedal Feel". + AddCommand("mbooster-brake-friction-0", "mbooster", 35, 36, new byte[] { 0xAE, 0x00, 0x00 }, 2, "int"); + AddCommand("mbooster-brake-friction-1", "mbooster", 35, 36, new byte[] { 0xAE, 0x00, 0x01 }, 2, "int"); // 5-point output curves per pedal (4-byte float, read 35 / write 36) AddCommand("mbooster-throttle-y1", "mbooster", 35, 36, new byte[] { 14 }, 4, "float"); AddCommand("mbooster-throttle-y2", "mbooster", 35, 36, new byte[] { 15 }, 4, "float"); diff --git a/Protocol/MozaMBoosterProtocol.cs b/Protocol/MozaMBoosterProtocol.cs index 93e35c63..4a212303 100644 --- a/Protocol/MozaMBoosterProtocol.cs +++ b/Protocol/MozaMBoosterProtocol.cs @@ -20,6 +20,13 @@ public enum MBoosterEffectId : byte // with this effect type. Uses a materially different payload shape // from the other four — see BuildRoadTextureFrame. RoadTexture = 9, + // G-Force (Inertial Pedal Feel) — reverse-engineered from four real + // Pit House "Test" USB captures at different Max Travel/Response + // Speed settings (see docs/protocol/devices/mbooster.md "G-Force"). + // Not a vibration waveform at all: a sustained, directional TRAVEL + // OFFSET target the firmware moves the pedal to and holds — see + // BuildGForceFrame. + GForce = 6, } /// @@ -161,6 +168,57 @@ public static byte[] BuildRoadTextureFrame(bool enable, ushort intensityRaw, ush return frame; } + /// + /// Build the motor-write frame for the G-Force (Inertial Pedal Feel) + /// effect — effect type 6, a genuinely different mechanism from + /// every other mBooster effect: not a vibration waveform, but a + /// sustained, directional TRAVEL OFFSET target the firmware moves + /// the pedal to and holds, at a firmware-side ramp rate set by + /// . Reverse-engineered from four + /// real Pit House "Test" captures at different Max Travel/Response + /// Speed settings (see docs/protocol/devices/mbooster.md "G-Force"): + ///
+        /// 7e  09  24  12   b1  06  EN   RH  RL   FH  FL   BH  BL   CK
+        ///                  │   │   │    └─┴─response speed u16 BE
+        ///                  │   │   │              └─┴─forward offset u16 BE
+        ///                  │   │   │                         └─┴─backward offset u16 BE
+        ///                  │   │   └ enable (0 = off, 1 = on)
+        ///                  │   └ effect type (6 = G-Force)
+        ///                  └ cmd id (0xb1)
+        /// 
+ /// Exactly one of forward/backward is non-zero at a time in every + /// observed capture (the other is 0x0000) — Pit House's own "Test" + /// alternates between the two on a fixed cadence to demonstrate both + /// directions; a live effect instead holds enable=1 continuously and + /// updates whichever slot matches the sign of live longitudinal G + /// every tick (see MBoosterEffectWorker.ProcessGForceEffect). Both + /// value fields share 's exact + /// "round(frac01*65535)" formula, verified against 4 data points + /// each: response speed 100%/50%/15% -> 0xFFFF/0x7FFF/0x2666 (exact); + /// travel 15mm/10mm/2.5mm (against the wire's fixed 15mm full-scale + /// range — see MBoosterUiConstants.GForceMaxTravelMaxMm) -> + /// 0xFFFF/0xAAAA/0x2AAA (exact). + ///
+ public static byte[] BuildGForceFrame(bool enable, ushort responseSpeedRaw, ushort forwardRaw, ushort backwardRaw, byte device = DeviceMotor) + { + var frame = new byte[14]; + frame[0] = MozaProtocol.MessageStart; + frame[1] = MotorPayloadLen; + frame[2] = GroupMotorWrite; + frame[3] = device; + frame[4] = CmdMotorWrite; + frame[5] = (byte)MBoosterEffectId.GForce; + frame[6] = enable ? (byte)1 : (byte)0; + frame[7] = (byte)(responseSpeedRaw >> 8); + frame[8] = (byte)(responseSpeedRaw & 0xFF); + frame[9] = (byte)(forwardRaw >> 8); + frame[10] = (byte)(forwardRaw & 0xFF); + frame[11] = (byte)(backwardRaw >> 8); + frame[12] = (byte)(backwardRaw & 0xFF); + frame[13] = MozaProtocol.CalculateWireChecksum(frame, 13); + return frame; + } + /// /// Degenerate 0-payload frame targeting the motor — 7e 00 00 12 9d. /// Per protocol note § 3 "Keepalive": send every ~500 ms whenever the port @@ -342,6 +400,135 @@ public static double DecodeEndstopStiffness(int raw) return raw * 10.0 / 65535.0; } + /// + /// Pit House "Natural Friction" encoding — reverse-engineered from + /// two real Pit House USB captures (wire commands + /// mbooster-brake-friction-0/-1, cmdId 0xAE with a + /// selector byte; see docs/protocol/devices/mbooster.md "Pedal + /// Feel"). Fixed 0-100% scale over the 0-65535 range: raw = + /// round(pct * 65535 / 100). Verified against a 0/25/50/75/100% + /// sweep (0x0000/0x4000/0x8000/0xbfff/0xffff, all exact) and cross- + /// checked against the firmware's own debug log in a second capture, + /// which echoed the disabled write as fixed-point 0.0 and the + /// enabled write (slider at 100%) as fixed-point 1.0 — confirming + /// there is no separate wire enable bit; turning the feature off + /// just writes raw 0. + /// + public static int EncodeFrictionPct(double pct) + { + if (double.IsNaN(pct) || pct <= 0) return 0; + double raw = Math.Round(pct * 65535.0 / 100.0); + if (raw <= 0) return 0; + if (raw >= 0xFFFF) return 0xFFFF; + return (int)raw; + } + + /// Inverse of . + public static double DecodeFrictionPct(int raw) + { + if (raw <= 0) return 0; + return raw * 100.0 / 65535.0; + } + + /// Segmented Damping cmdId (0xB7). See . + public const byte CmdSegmentedDamping = 0xb7; + /// Segmented Damping payload length: cmd byte + 10 x 2-byte fields = 21 (0x15). + public const byte SegmentedDampingPayloadLen = 0x15; + + /// + /// Same 0-100% encoding as + /// (raw = round(pct * 65535 / 100)) — kept as its own named + /// pair since it serves a structurally different command + /// (Segmented Damping's fixed 10-field frame vs Natural Friction's + /// prefix+selector commands), matching this file's convention of a + /// dedicated Encode/Decode pair per reverse-engineered feature. + /// + public static int EncodeSegmentedDampingPct(double pct) + { + if (double.IsNaN(pct) || pct <= 0) return 0; + double raw = Math.Round(pct * 65535.0 / 100.0); + if (raw <= 0) return 0; + if (raw >= 0xFFFF) return 0xFFFF; + return (int)raw; + } + + /// Inverse of . + public static double DecodeSegmentedDampingPct(int raw) + { + if (raw <= 0) return 0; + return raw * 100.0 / 65535.0; + } + + /// + /// Build the write frame for Segmented Damping — cmdId 0xB7, + /// reverse-engineered from real Pit House USB captures (see + /// docs/protocol/devices/mbooster.md "Segmented Damping"). ONE + /// fixed 21-byte payload carries the ENTIRE feature's state — + /// both "When Pressed" and "When Released" — as 10 big-endian + /// u16 fields in this exact order: + ///
+        /// 7e  15  24  12   b7  D1PH D1PL D2PH D2PL  D1RH D1RL D2RH D2RL
+        ///                  │   └──┴─Div1Pressed  └──┴─Div2Pressed
+        ///                  │        └──┴─Div1Released    └──┴─Div2Released
+        ///                  └ cmd id (0xb7)
+        ///     S1PH S1PL S1RH S1RL  S2PH S2PL S2RH S2RL  S3PH S3PL S3RH S3RL  CK
+        ///     └──┴─Seg1Pressed └──┴─Seg1Released
+        ///               └──┴─Seg2Pressed └──┴─Seg2Released
+        ///                         └──┴─Seg3Pressed └──┴─Seg3Released
+        /// 
+ /// Every capture write resent the WHOLE frame — including fields + /// unrelated to whatever the user was actually dragging in that + /// capture — confirming this is always a full snapshot, never a + /// partial update. Each field's IDENTITY is independently verified + /// against its own isolated capture's 0/25/50/.../100%-style sweep + /// (e.g. Seg2Pressed's raw values track its capture's 0/22/57/100% + /// points closely — 0x0000/0x3852/0x91ec/0xffff). The two DIVIDER + /// fields per pair (typed values) landed exactly on + /// round(pct*65535/100) every time; the SEGMENT (Y-axis, mouse- + /// dragged) values are consistently within ~1 raw unit of that + /// formula rather than exact — expected, since a drag lands on + /// whatever pixel row the mouse happened to stop at (e.g. ~57.002%), + /// not a clean typed percentage; the filename's round numbers are + /// approximate labels, not exact wire values. All 10 fields share + /// . + ///
+ public static byte[] BuildSegmentedDampingFrame( + double div1Pressed, double div2Pressed, double div1Released, double div2Released, + double seg1Pressed, double seg1Released, + double seg2Pressed, double seg2Released, + double seg3Pressed, double seg3Released, + byte device = DeviceMotor) + { + var frame = new byte[26]; // 7e + len + group + device + 21 payload + checksum + frame[0] = MozaProtocol.MessageStart; + frame[1] = SegmentedDampingPayloadLen; + frame[2] = GroupMotorWrite; + frame[3] = device; + frame[4] = CmdSegmentedDamping; + + ushort[] fields = + { + (ushort)EncodeSegmentedDampingPct(div1Pressed), + (ushort)EncodeSegmentedDampingPct(div2Pressed), + (ushort)EncodeSegmentedDampingPct(div1Released), + (ushort)EncodeSegmentedDampingPct(div2Released), + (ushort)EncodeSegmentedDampingPct(seg1Pressed), + (ushort)EncodeSegmentedDampingPct(seg1Released), + (ushort)EncodeSegmentedDampingPct(seg2Pressed), + (ushort)EncodeSegmentedDampingPct(seg2Released), + (ushort)EncodeSegmentedDampingPct(seg3Pressed), + (ushort)EncodeSegmentedDampingPct(seg3Released), + }; + int off = 5; + foreach (var f in fields) + { + frame[off++] = (byte)(f >> 8); + frame[off++] = (byte)(f & 0xFF); + } + frame[25] = MozaProtocol.CalculateWireChecksum(frame, 25); + return frame; + } + /// /// Pit House Road Texture Intensity/Smoothness encoding — reverse- /// engineered from two real Pit House USB captures, one per diff --git a/README.md b/README.md index 32482b35..66d996f8 100644 --- a/README.md +++ b/README.md @@ -227,8 +227,8 @@ The plugin exposes these properties for use in SimHub dashboards and overlays: | `AZOM.MosfetTemp` | double | MOSFET temperature (°C or °F, per the temperature-unit setting) | | `AZOM.MotorTemp` | double | Motor temperature (°C or °F, per the temperature-unit setting) | | `AZOM.BaseState` | int | Wheelbase state | -| `AZOM.FfbStrength` | int | FFB strength (%) | | `AZOM.MaxAngle` | int | Max steering angle (degrees) | +| `AZOM.ClutchSplitPoint` | int | Clutch split point (%) for the current wheel, as shown on the wheel device page (Paddles Mode = Combined) | | `AZOM.HidConnected` | bool | Whether a device HID surface is being read (live input is available) | | `AZOM.SteeringAngle` | double | Live steering angle in degrees (0 = center, ± = each lock direction); 0 until max-angle is known | | `AZOM.SteeringPosition` | double | Live steering as 0–100 (0 = full lock, 50 = center, 100 = full lock); -1 when unknown | @@ -244,6 +244,45 @@ The plugin exposes these properties for use in SimHub dashboards and overlays: These input properties are populated directly from the device HID surface, so they update live even when no game is running. +#### Wheelbase settings + +Every wheelbase setting on the plugin's **Base** tab is also exposed as a property, in the same units the slider shows. Each has a matching set of actions (see below). Values track what the base reported on its last settings read, so they hold their defaults until the base answers after connect. + +The numeric ones read `-1` when the value isn't available — the plugin is still loading, or the setting doesn't exist on this firmware (equalizer bands 7–10 on 6-band bases). + +| Property | Type | Range | Description | +|----------|------|-------|-------------| +| `AZOM.FfbStrength` | int | 0–100 | Game FFB strength (%) | +| `AZOM.Torque` | int | 50–100 | Base torque output (%) | +| `AZOM.Rotation` | int | 60–2700 | Wheel rotation angle (degrees) | +| `AZOM.WheelSpeedLimit` | int | 0–200 | Maximum wheel speed (%) | +| `AZOM.Interpolation` | int | 0–10 | FFB interpolation | +| `AZOM.GearshiftVibration` | int | 0–5 | Base gear-shift vibration intensity | +| `AZOM.Damper` | int | 0–100 | Wheel damper (%) | +| `AZOM.Friction` | int | 0–100 | Wheel friction (%) | +| `AZOM.Inertia` | int | 100–500 | Natural inertia (Wheelbase Effects) | +| `AZOM.Spring` | int | 0–100 | Wheel spring — the base's own centering force (%) | +| `AZOM.GameDamper` | int | 0–100 | Game damper effect gain (%) | +| `AZOM.GameFriction` | int | 0–100 | Game friction effect gain (%) | +| `AZOM.GameInertia` | int | 0–100 | Game inertia effect gain (%) | +| `AZOM.GameSpring` | int | 0–100 | Game spring effect gain (%) | +| `AZOM.NaturalInertia` | int | 100–4000 | Steering wheel inertia (Protection) | +| `AZOM.SoftLimitStiffness` | int | 1–10 | Soft limit stiffness | +| `AZOM.SpeedDamping` | int | 0–100 | High-speed damping level (%) | +| `AZOM.SpeedDampingPoint` | int | 0–400 | High-speed damping trigger speed (kph) | +| `AZOM.RoadSensitivity` | int | 0–10 | Road sensitivity preset index; -1 until the base reports it | +| `AZOM.Equalizer1` … `AZOM.Equalizer10` | int | 0–400/500 | FFB equalizer bands, in **register** order. Bands 7–10 read -1 on 6-band firmware. Band 6 (100 Hz) caps at 100 on 10-band firmware, the rest at 500; all six cap at 400 on legacy firmware | +| `AZOM.FfbCurveX1` … `X4`, `AZOM.FfbCurveY1` … `Y5` | int | 0–100 | FFB output curve node positions | +| `AZOM.Protection` | bool | | Hands-off protection enabled | +| `AZOM.FfbReverse` | bool | | Force feedback reversal enabled | +| `AZOM.SoftLimitRetain` | bool | | Soft limit "retain game FFB" enabled | +| `AZOM.PerformanceOutput` | bool | | Performance output on full (false = reserved) | +| `AZOM.BaseStatusLed` | bool | | Base status LED on | +| `AZOM.Bluetooth` | bool | | Bluetooth on | +| `AZOM.WorkMode` | int | 0/1 | 0 = base running, 1 = standby | + +The equalizer bands are numbered by hardware register, which is **not** frequency order on 10-band firmware. Register order maps to 5/15/25/40/60/100 Hz for bands 1–6 and 10/30/50/80 Hz for bands 7–10. + ### SimHub Actions The plugin registers these actions, bindable to wheel/controller buttons under SimHub's **Controls and events** (or to dashboard controls). They change the same settings as the sliders/toggles in the plugin UI, push to hardware immediately, and persist to the active profile. @@ -254,17 +293,52 @@ Each *step* setting has four actions: `…Up` / `…Down` apply a fine step, and |--------|-------|------|--------|--------| | `AZOM.FfbStrengthUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–100% | ±5 | ±10 | Wheelbase FFB strength | | `AZOM.TorqueUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 50–100% | ±5 | ±10 | Wheelbase torque limit | -| `AZOM.RotationUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 90–2700° | ±90° | ±180° | Steering rotation (max angle) | +| `AZOM.RotationUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 60–2700° | ±90° | ±180° | Steering rotation (max angle) | +| `AZOM.WheelSpeedLimitUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–200% | ±5 | ±10 | Maximum wheel speed | +| `AZOM.InterpolationUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–10 | ±1 | ±2 | FFB interpolation | +| `AZOM.GearshiftVibrationUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–5 | ±1 | ±2 | Base gear-shift vibration intensity | +| `AZOM.DamperUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–100% | ±5 | ±10 | Wheel damper | +| `AZOM.FrictionUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–100% | ±5 | ±10 | Wheel friction | +| `AZOM.InertiaUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 100–500 | ±10 | ±50 | Natural inertia (Wheelbase Effects) | +| `AZOM.SpringUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–100% | ±5 | ±10 | Wheel spring — the base's own centering force | +| `AZOM.GameDamperUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–100% | ±5 | ±10 | Game damper effect gain | +| `AZOM.GameFrictionUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–100% | ±5 | ±10 | Game friction effect gain | +| `AZOM.GameInertiaUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–100% | ±5 | ±10 | Game inertia effect gain | +| `AZOM.GameSpringUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–100% | ±5 | ±10 | Game spring effect gain | +| `AZOM.NaturalInertiaUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 100–4000 | ±50 | ±200 | Steering wheel inertia (Protection) | +| `AZOM.SoftLimitStiffnessUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 1–10 | ±1 | ±2 | Soft limit stiffness | +| `AZOM.SpeedDampingUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–100% | ±5 | ±10 | High-speed damping level | +| `AZOM.SpeedDampingPointUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–400 kph | ±10 | ±50 | High-speed damping trigger speed | +| `AZOM.RoadSensitivityUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–10 | ±1 | ±2 | Road sensitivity preset (also rewrites the FFB equalizer curve, exactly like the Base-tab preset buttons) | +| `AZOM.Equalizer1Up` … `AZOM.Equalizer10…DownCoarse` | 0–400/500% | ±5 | ±25 | FFB equalizer bands, in register order (see the property table) | +| `AZOM.FfbCurveX1Up` … `AZOM.FfbCurveY5…DownCoarse` | 0–100 | ±5 | ±10 | FFB output curve node positions | +| `AZOM.ClutchSplitUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–100% | ±5 | ±10 | Clutch split point — the combined-paddle bite point (Paddles Mode = Combined) | | `AZOM.Ab9EngineIntensityUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–100 | ±5 | ±10 | AB9 engine-vibration intensity | | `AZOM.Ab9EngineFrequencyUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–200 Hz | ±10 | ±20 | AB9 engine-vibration frequency | | `AZOM.Ab9GearShiftIntensityUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–100 | ±5 | ±10 | AB9 gear-shift vibration intensity | | `AZOM.DisplayBrightnessUp` / `…Down` / `…UpCoarse` / `…DownCoarse` | 0–100% | ±5 | ±10 | Wheel screen display brightness | +Wheelbase settings live in the base's parameter store, which is flash. A step action that would leave the value unchanged (already at the top or bottom of its range) writes nothing, so holding a bound button at a limit costs no extra flash writes. + +Each wheelbase *toggle* has three actions — `…On`, `…Off` and `…Toggle`: + +| Action | Effect | +|--------|--------| +| `AZOM.ProtectionOn` / `…Off` / `…Toggle` | Hands-off protection | +| `AZOM.FfbReverseOn` / `…Off` / `…Toggle` | Force feedback reversal | +| `AZOM.SoftLimitRetainOn` / `…Off` / `…Toggle` | Soft limit "retain game FFB" | +| `AZOM.PerformanceOutputOn` / `…Off` / `…Toggle` | Performance output (on = full, off = reserved) | +| `AZOM.BaseStatusLedOn` / `…Off` / `…Toggle` | Base status LED | +| `AZOM.BluetoothOn` / `…Off` / `…Toggle` | Wheelbase Bluetooth | + +`AZOM.BaseStatusLed*` and `AZOM.Bluetooth*` are stored in the base itself and are not part of a per-game profile, so they don't change when you switch games. + | Action | Effect | |--------|--------| | `AZOM.DisplayBrightness0` … `AZOM.DisplayBrightness100` | Set wheel screen display brightness to a fixed level (0–100% in steps of 10) | | `AZOM.WorkModeOff` | Turn off the wheelbase work mode (puts the base into standby) | | `AZOM.WorkModeOn` | Turn on the wheelbase work mode (normal active state) | +| `AZOM.WorkModeToggle` | Flip the wheelbase between standby and its normal active state | | `AZOM.Ab9LayoutNext` | Switch the AB9 shifter to the next mechanical layout (wraps around) | | `AZOM.Ab9LayoutPrev` | Switch the AB9 shifter to the previous mechanical layout (wraps around) | | `AZOM.Ab9Layout5R1` / `…6R1` / `…6R2` / `…7R1` / `…7R2` / `…Sequential` | Set the AB9 mechanical layout directly | diff --git a/Resources/Strings.Designer.cs b/Resources/Strings.Designer.cs index e6bde7c0..5cd38fe2 100644 --- a/Resources/Strings.Designer.cs +++ b/Resources/Strings.Designer.cs @@ -222,6 +222,12 @@ private static string Get(string key) public static string SliderLabel_TravelRangeMm => Get("SliderLabel_TravelRangeMm"); public static string SliderLabel_EndstopFrontStiffness => Get("SliderLabel_EndstopFrontStiffness"); public static string SliderLabel_EndstopEndStiffness => Get("SliderLabel_EndstopEndStiffness"); + public static string Hint_NaturalFrictionPedalFeel => Get("Hint_NaturalFrictionPedalFeel"); + public static string Section_SegmentedDamping => Get("Section_SegmentedDamping"); + public static string Subtitle_SegmentedDamping => Get("Subtitle_SegmentedDamping"); + public static string Hint_SegmentedDampingExperimental => Get("Hint_SegmentedDampingExperimental"); + public static string Section_SegmentedDampingPressed => Get("Section_SegmentedDampingPressed"); + public static string Section_SegmentedDampingReleased => Get("Section_SegmentedDampingReleased"); public static string Section_SimInputMapping => Get("Section_SimInputMapping"); public static string Subtitle_SimInputMapping => Get("Subtitle_SimInputMapping"); public static string SliderLabel_MaxThresholdKg => Get("SliderLabel_MaxThresholdKg"); @@ -245,6 +251,10 @@ private static string Get(string key) public static string SliderLabel_VibrationDecay => Get("SliderLabel_VibrationDecay"); public static string SliderLabel_OnsetTempC => Get("SliderLabel_OnsetTempC"); public static string Hint_BrakeFadeExperimental => Get("Hint_BrakeFadeExperimental"); + public static string Section_GForce => Get("Section_GForce"); + public static string Hint_GForceExperimental => Get("Hint_GForceExperimental"); + public static string SliderLabel_MaxTravelMm => Get("SliderLabel_MaxTravelMm"); + public static string SliderLabel_ResponseSpeedPct => Get("SliderLabel_ResponseSpeedPct"); public static string Section_CustomEffects => Get("Section_CustomEffects"); public static string Subtitle_CustomEffectsExperimental => Get("Subtitle_CustomEffectsExperimental"); public static string Hint_CustomEffectsExperimental => Get("Hint_CustomEffectsExperimental"); @@ -679,6 +689,8 @@ private static string Get(string key) public static string Import_Label_Folder => Get("Import_Label_Folder"); public static string Import_Label_Preset => Get("Import_Label_Preset"); public static string Import_Label_Profile => Get("Import_Label_Profile"); + public static string Import_Label_SubjectRole => Get("Import_Label_SubjectRole"); + public static string Import_Label_ApplyTo => Get("Import_Label_ApplyTo"); public static string Import_Label_Changes => Get("Import_Label_Changes"); public static string Import_NoMotorPresets => Get("Import_NoMotorPresets"); public static string Import_NoPedalsPresets => Get("Import_NoPedalsPresets"); diff --git a/Resources/Strings.de.resx b/Resources/Strings.de.resx index c245a541..ae49d06a 100644 --- a/Resources/Strings.de.resx +++ b/Resources/Strings.de.resx @@ -148,6 +148,12 @@ Start / Ende des Pedalwegs (mm) Steifigkeit vorderer Anschlag Steifigkeit hinterer Anschlag + Simuliert eine Reibungskraft, die unabhängig von der Spielausgabe ist. + SEGMENTIERTE DÄMPFUNG + Dämpfungskraft unabhängig von der Spielausgabe, pro Pedalweg-Segment + Simuliert eine Dämpfungskraft, die unabhängig von der Spielausgabe ist. Der Pedalweg wird in mehrere Segmente unterteilt, jedes mit einstellbarem Bereich und eigener natürlicher Dämpfung. Ziehen Sie einen Teiler, um ein Segment in der Größe zu ändern; ziehen Sie innerhalb eines Segments, um dessen Dämpfungsstärke einzustellen. + Beim Drücken + Beim Loslassen SIM-EINGABEZUORDNUNG // Lastzellen-/Pedalsignal wie in Pit House auf das Spiel abbilden Max. Schwellenwert (kg) @@ -597,6 +603,8 @@ Id: {2} ORDNER VOREINSTELLUNG PROFIL + PEDAL + ANWENDEN AUF ÄNDERUNGEN // keine Motor-Voreinstellungen in diesem Ordner // keine Pedals-Voreinstellungen in diesem Ordner diff --git a/Resources/Strings.el.resx b/Resources/Strings.el.resx index a6a47e40..65fb66d0 100644 --- a/Resources/Strings.el.resx +++ b/Resources/Strings.el.resx @@ -148,6 +148,12 @@ Αρχή / Τέλος Διαδρομής (mm) Σκληρότητα εμπρός ορίου Σκληρότητα τελικού ορίου + Προσομοιώνει μια δύναμη τριβής ανεξάρτητη από την έξοδο του παιχνιδιού. + ΤΜΗΜΑΤΟΠΟΙΗΜΕΝΗ ΑΠΟΣΒΕΣΗ + Δύναμη απόσβεσης ανεξάρτητη από την έξοδο του παιχνιδιού, ανά τμήμα διαδρομής πεντάλ + Προσομοιώνει μια δύναμη απόσβεσης ανεξάρτητη από την έξοδο του παιχνιδιού εντός του παιχνιδιού. Η διαδρομή του πεντάλ χωρίζεται σε πολλά τμήματα, το καθένα με ρυθμιζόμενο εύρος και τη δική του φυσική απόσβεση. Σύρετε ένα διαχωριστικό για να αλλάξετε το μέγεθος ενός τμήματος· σύρετε μέσα σε ένα τμήμα για να ορίσετε την ποσότητα απόσβεσής του. + Κατά το Πάτημα + Κατά την Απελευθέρωση ΑΝΤΙΣΤΟΙΧΙΣΗ ΕΙΣΟΔΟΥ ΠΡΟΣΟΜΟΙΩΣΗΣ // αντιστοίχιση σήματος κυψέλης φόρτισης/πεντάλ στο παιχνίδι, στυλ Pit House Μέγιστο κατώφλι (kg) @@ -596,6 +602,8 @@ Id: {2} ΦΑΚΕΛΟΣ ΠΡΟΕΠΙΛΟΓΗ ΠΡΟΦΙΛ + ΠΕΝΤΑΛ + ΕΦΑΡΜΟΓΗ ΣΕ ΑΛΛΑΓΕΣ // δεν υπάρχουν προεπιλογές Motor σε αυτόν τον φάκελο // δεν υπάρχουν προεπιλογές Pedals σε αυτόν τον φάκελο diff --git a/Resources/Strings.es.resx b/Resources/Strings.es.resx index 27b24ff8..2de47e8b 100644 --- a/Resources/Strings.es.resx +++ b/Resources/Strings.es.resx @@ -153,6 +153,12 @@ Inicio / Fin del Recorrido (mm) Rigidez del límite frontal Rigidez del límite final + Simula una fuerza de fricción independiente de la salida del juego. + AMORTIGUACIÓN SEGMENTADA + Fuerza de amortiguación independiente de la salida del juego, por segmento de recorrido del pedal + Simula una fuerza de amortiguación independiente de la salida del juego. El recorrido del pedal se divide en varios segmentos, cada uno con un rango ajustable y su propia amortiguación natural. Arrastra un divisor para redimensionar un segmento; arrastra dentro de un segmento para ajustar su cantidad de amortiguación. + Al Presionar + Al Soltar MAPEO DE ENTRADA DE SIMULACIÓN // mapea la señal de la celda de carga/pedal al juego, al estilo Pit House Umbral máximo (kg) @@ -602,6 +608,8 @@ Id: {2} CARPETA PRESET PERFIL + PEDAL + APLICAR A CAMBIOS // no hay presets de Motor en esta carpeta // no hay presets de Pedals en esta carpeta diff --git a/Resources/Strings.fr.resx b/Resources/Strings.fr.resx index b243c95d..e6199703 100644 --- a/Resources/Strings.fr.resx +++ b/Resources/Strings.fr.resx @@ -148,6 +148,12 @@ Début / Fin de Course (mm) Rigidité de butée avant Rigidité de butée de fin + Simule une force de friction indépendante de la sortie du jeu. + AMORTISSEMENT SEGMENTÉ + Force d'amortissement indépendante de la sortie du jeu, par segment de course de pédale + Simule une force d'amortissement indépendante de la sortie du jeu. La course de la pédale est divisée en plusieurs segments, chacun avec une plage réglable et son propre amortissement naturel. Faites glisser un diviseur pour redimensionner un segment ; faites glisser à l'intérieur d'un segment pour régler sa quantité d'amortissement. + À l'Appui + Au Relâchement MAPPAGE D'ENTRÉE SIM // mappe le signal de la cellule de charge/pédale vers le jeu, à la manière de Pit House Seuil maximal (kg) @@ -597,6 +603,8 @@ Id : {2} DOSSIER PRÉRÉGLAGE PROFIL + PÉDALE + APPLIQUER À MODIFICATIONS // aucun préréglage Motor dans ce dossier // aucun préréglage Pedals dans ce dossier diff --git a/Resources/Strings.it.resx b/Resources/Strings.it.resx index af9a0f6b..270d8aa3 100644 --- a/Resources/Strings.it.resx +++ b/Resources/Strings.it.resx @@ -151,6 +151,12 @@ Inizio / Fine Corsa (mm) Rigidità limite anteriore Rigidità limite finale + Simula una forza d'attrito indipendente dall'output di gioco. + SMORZAMENTO SEGMENTATO + Forza di smorzamento indipendente dall'output di gioco, per segmento di corsa del pedale + Simula una forza di smorzamento indipendente dall'output di gioco. La corsa del pedale è divisa in più segmenti, ciascuno con un intervallo regolabile e il proprio smorzamento naturale. Trascina un divisore per ridimensionare un segmento; trascina all'interno di un segmento per impostarne la quantità di smorzamento. + Alla Pressione + Al Rilascio MAPPATURA INPUT SIM // mappa il segnale della cella di carico/pedale al gioco, in stile Pit House Soglia massima (kg) @@ -600,6 +606,8 @@ Id: {2} CARTELLA PRESET PROFILO + PEDALE + APPLICA A MODIFICHE // nessun preset Motor in questa cartella // nessun preset Pedals in questa cartella diff --git a/Resources/Strings.ko.resx b/Resources/Strings.ko.resx index 0bf0c3ae..98a43e9b 100644 --- a/Resources/Strings.ko.resx +++ b/Resources/Strings.ko.resx @@ -148,6 +148,12 @@ 이동 시작/종료 (mm) 프론트 리밋 강성 엔드 리밋 강성 + 게임 출력과 무관한 마찰력을 시뮬레이션합니다. + 구간별 댐핑 + 게임 출력과 무관한 댐핑력, 페달 이동 구간별 적용 + 게임 내 출력과 무관한 댐핑력을 시뮬레이션합니다. 페달 이동 구간이 여러 구간으로 나뉘며, 각 구간마다 조절 가능한 범위와 고유한 자연 댐핑이 적용됩니다. 구분선을 드래그하여 구간 크기를 조정하고, 구간 내부를 드래그하여 댐핑량을 설정하세요. + 누를 때 + 뗄 때 시뮬레이션 입력 매핑 // 로드셀/페달 신호를 게임에 매핑 (핏하우스 방식) 최대 임계값 (kg) @@ -596,6 +602,8 @@ Id: {2} 폴더 프리셋 프로필 + 페달 + 적용 대상 변경 사항 // 이 폴더에 Motor 프리셋이 없습니다 // 이 폴더에 Pedals 프리셋이 없습니다 diff --git a/Resources/Strings.nb.resx b/Resources/Strings.nb.resx index 7bacee19..f2cd4556 100644 --- a/Resources/Strings.nb.resx +++ b/Resources/Strings.nb.resx @@ -153,6 +153,12 @@ Start / Slutt på Bevegelse (mm) Frontgrense-stivhet Sluttgrense-stivhet + Simulerer en friksjonskraft som er uavhengig av spillutgangen. + SEGMENTERT DEMPING + Dempekraft uavhengig av spillutgangen, per pedalbevegelse-segment + Simulerer en dempekraft som er uavhengig av spillutgangen. Pedalbevegelsen deles inn i flere segmenter, hver med justerbart område og sin egen naturlige demping. Dra en delelinje for å endre størrelsen på et segment; dra inne i et segment for å angi dempemengden. + Ved Nedtrykking + Ved Utløsning SIM-INNGANGSKARTLEGGING // kartlegger lastcelle-/pedalsignalet til spillet, Pit House-stil Maks terskel (kg) @@ -602,6 +608,8 @@ Id: {2} MAPPE FORHÅNDSINNSTILLING PROFIL + PEDAL + BRUK PÅ ENDRINGER // ingen Motor-forhåndsinnstillinger i denne mappen // ingen Pedals-forhåndsinnstillinger i denne mappen diff --git a/Resources/Strings.pt.resx b/Resources/Strings.pt.resx index fea1a05e..081884a1 100644 --- a/Resources/Strings.pt.resx +++ b/Resources/Strings.pt.resx @@ -615,6 +615,8 @@ Id: {2} PASTA PRESET PERFIL + PEDAL + APLICAR A ALTERAÇÕES // nenhum preset de Motor nesta pasta // nenhum preset de Pedais nesta pasta diff --git a/Resources/Strings.qps-ploc.resx b/Resources/Strings.qps-ploc.resx index a3ac9f02..7436a4ef 100644 --- a/Resources/Strings.qps-ploc.resx +++ b/Resources/Strings.qps-ploc.resx @@ -606,6 +606,8 @@ ARF!! ARF!! RUFF!! + YIP!! + BARK BARK!! WROOF!! Barff Boof! Wroof Yowff! diff --git a/Resources/Strings.resx b/Resources/Strings.resx index 24745867..481063af 100644 --- a/Resources/Strings.resx +++ b/Resources/Strings.resx @@ -160,6 +160,12 @@ Start / End of Travel (mm) Front Limit Stiffness End Limit Stiffness + Simulate a frictional force that is independent of the game output. + SEGMENTED DAMPING + Damping force independent of game output, per pedal-travel segment + Simulate a damping force independent of in-game output. Pedal travel is divided into multiple segments, each with an adjustable range and its own natural damping. Drag a divider to resize a segment; drag inside a segment to set its damping amount. + When Pressed + When Released SIM INPUT MAPPING Max Threshold (kg) @@ -183,6 +189,10 @@ Vibration Decay Onset Temperature (°C) Experimental — writes the real Travel End and Max Threshold hardware calibrations while active (more travel and more force needed), then restores your configured values as brakes cool. Requires those already set in Pedal Feel / Sim Input Mapping — has no effect otherwise. + G-Force (Inertial Pedal Feel) (Experimental) + Experimental — pushes the pedal itself under your foot in proportion to live longitudinal G (accelerating pushes forward, braking pushes back), rather than vibrating. Max Pedal Travel sets how far it moves at full G; Response Speed sets how quickly the pedal ramps to the new position. + Max Pedal Travel (mm) + Response Speed (%) Custom Effects (Experimental) // experimental — user-defined vibration driven by SimHub/NCalc formulas Experimental — each custom effect vibrates the motor using a SimHub property or NCalc formula you supply. All custom effects (and the built-in Engine effect) share one wire channel, so only one can drive the motor at a time; the most recently processed active effect wins. Frequency/Intensity behave like Engine's own sliders. @@ -623,6 +633,8 @@ Id: {2} FOLDER PRESET PROFILE + PEDAL + APPLY TO CHANGES // no Motor presets in this folder // no Pedals presets in this folder diff --git a/Resources/Strings.ru.resx b/Resources/Strings.ru.resx index 281fc527..675e0cf7 100644 --- a/Resources/Strings.ru.resx +++ b/Resources/Strings.ru.resx @@ -148,6 +148,12 @@ Начало / Конец хода (мм) Жёсткость переднего упора Жёсткость заднего упора + Имитирует силу трения, не зависящую от игрового вывода. + СЕГМЕНТИРОВАННОЕ ДЕМПФИРОВАНИЕ + Сила демпфирования, не зависящая от игрового вывода, по сегментам хода педали + Имитирует силу демпфирования, не зависящую от игрового вывода. Ход педали делится на несколько сегментов, каждый с регулируемым диапазоном и собственным естественным демпфированием. Перетащите разделитель, чтобы изменить размер сегмента; перетащите внутри сегмента, чтобы задать величину демпфирования. + При Нажатии + При Отпускании СОПОСТАВЛЕНИЕ ВВОДА СИМУЛЯТОРА // сопоставление сигнала тензодатчика/педали с игрой, в стиле Pit House Макс. порог (кг) @@ -597,6 +603,8 @@ Id: {2} ПАПКА ПРЕСЕТ ПРОФИЛЬ + ПЕДАЛЬ + ПРИМЕНИТЬ К ИЗМЕНЕНИЯ // в этой папке нет пресетов Motor // в этой папке нет пресетов Pedals diff --git a/Resources/Strings.vi.resx b/Resources/Strings.vi.resx index 2d203b7b..4279abd6 100644 --- a/Resources/Strings.vi.resx +++ b/Resources/Strings.vi.resx @@ -148,6 +148,12 @@ Bắt đầu / Kết thúc Hành trình (mm) Độ cứng giới hạn đầu Độ cứng giới hạn cuối + Mô phỏng một lực ma sát độc lập với đầu ra của game. + GIẢM CHẤN PHÂN ĐOẠN + Lực giảm chấn độc lập với đầu ra của game, theo từng phân đoạn hành trình bàn đạp + Mô phỏng một lực giảm chấn độc lập với đầu ra trong game. Hành trình bàn đạp được chia thành nhiều phân đoạn, mỗi phân đoạn có phạm vi điều chỉnh riêng và độ giảm chấn tự nhiên riêng. Kéo đường chia để thay đổi kích thước phân đoạn; kéo bên trong phân đoạn để đặt mức giảm chấn. + Khi Đạp + Khi Nhả ÁNH XẠ ĐẦU VÀO SIM // ánh xạ tín hiệu loadcell/bàn đạp vào game, theo kiểu Pit House Ngưỡng tối đa (kg) @@ -597,6 +603,8 @@ Id: {2} THƯ MỤC CÀI ĐẶT SẴN HỒ SƠ + BÀN ĐẠP + ÁP DỤNG CHO THAY ĐỔI // không có cài đặt sẵn Motor trong thư mục này // không có cài đặt sẵn Pedals trong thư mục này diff --git a/Resources/Strings.zh-Hans.resx b/Resources/Strings.zh-Hans.resx index ae49a358..630ac762 100644 --- a/Resources/Strings.zh-Hans.resx +++ b/Resources/Strings.zh-Hans.resx @@ -148,6 +148,12 @@ 行程起点 / 终点(mm) 前限位硬度 后限位硬度 + 模拟一种独立于游戏输出的摩擦力。 + 分段阻尼 + 独立于游戏输出的阻尼力,按踏板行程分段设置 + 模拟一种独立于游戏内输出的阻尼力。踏板行程被划分为多个分段,每段都有可调范围和各自的自然阻尼。拖动分隔线可调整分段大小;在分段内拖动可设置该分段的阻尼量。 + 踩下时 + 松开时 模拟输入映射 // 将传感器/踏板信号映射到游戏,Pit House 风格 最大阈值(kg) @@ -597,6 +603,8 @@ Id: {2} 文件夹 预设 配置文件 + 踏板 + 应用于 更改 // 此文件夹中没有 Motor 预设 // 此文件夹中没有 Pedals 预设 diff --git a/SimHubRegistrar.cs b/SimHubRegistrar.cs index 33ccd04a..21e68b7e 100644 --- a/SimHubRegistrar.cs +++ b/SimHubRegistrar.cs @@ -35,8 +35,44 @@ internal void RegisterProperties(PluginManager pluginManager) _plugin.AttachDelegate("AZOM.MosfetTemp", () => (_plugin.Data == null || _plugin.PropertyResolver == null) ? 0.0 : _plugin.PropertyResolver.ConvertTemp(_plugin.Data.MosfetTemp)); _plugin.AttachDelegate("AZOM.MotorTemp", () => (_plugin.Data == null || _plugin.PropertyResolver == null) ? 0.0 : _plugin.PropertyResolver.ConvertTemp(_plugin.Data.MotorTemp)); _plugin.AttachDelegate("AZOM.BaseState", () => _plugin.Data?.BaseState ?? 0); - _plugin.AttachDelegate("AZOM.FfbStrength", () => (_plugin.Data?.FfbStrength ?? 0) / 10); _plugin.AttachDelegate("AZOM.MaxAngle", () => (_plugin.Data?.MaxAngle ?? 0) * 2); + + // Every wheelbase setting, in the same display units the Base-tab + // sliders show (AZOM.FfbStrength is one of these — it kept its + // historical percent scaling). Values track the device read-back, + // so they read their _data defaults until the base answers its + // settings sweep. Unsupported settings (EQ bands 7-10 on legacy + // firmware) report -1 rather than a plausible-looking zero. + foreach (var s in BaseSettingCatalog.Numeric) + { + var def = s; // capture per iteration + _plugin.AttachDelegate("AZOM." + def.Name, () => + { + var d = _plugin.Data; + if (d == null || !def.IsSupported(d)) return -1; + return def.ToDisplay(def.GetRaw(d)); + }); + } + foreach (var t in BaseSettingCatalog.Toggles) + { + var def = t; // capture per iteration + _plugin.AttachDelegate("AZOM." + def.Name, () => + { + var d = _plugin.Data; + return d != null && def.IsOn(d); + }); + } + // Standby: raw register value (0 = running, 1 = standby), matching + // the existing AZOM.WorkModeOn/Off actions rather than the UI + // checkbox's inverted "Standby Mode" sense. + _plugin.AttachDelegate("AZOM.WorkMode", () => _plugin.Data?.WorkMode ?? 0); + // Road sensitivity as the 0-10 preset index the Base-tab buttons + // use, not the 10..50 register value. -1 until the base reports it. + _plugin.AttachDelegate("AZOM.RoadSensitivity", + () => BaseSettingCatalog.RoadSensitivityPresetFromRaw(_plugin.Data?.RoadSensitivity ?? -1)); + // Clutch split point (issue #125) — the wheel overlay is the source + // of truth; newer KS-family firmware drops the cmd-9 read-back. + _plugin.AttachDelegate("AZOM.ClutchSplitPoint", () => CurrentClutchSplitPoint()); // Telemetry pipeline health, so users can show a degraded/parked state on // an overlay. TelemetryState = the PipelinePhase name (Idle/SilenceWait/ // Starting/Active/HotSwitchBurst/Recovery/Parked). DashboardBound is a @@ -100,10 +136,27 @@ internal void RegisterActions() // and persists via SaveSettings(). An open settings panel re-reads the // new value on its refresh tick. - // Base feel. - AddStepActions("AZOM.FfbStrength", 5, 10, StepFfbStrength); // 0..100 % - AddStepActions("AZOM.Torque", 5, 10, StepTorque); // 50..100 % - AddStepActions("AZOM.Rotation", 90, 180, StepRotation); // 90..2700 deg + // Every wheelbase setting in BaseSettingCatalog, including the + // long-standing FfbStrength / Torque / Rotation trio. + foreach (var s in BaseSettingCatalog.Numeric) + { + var def = s; // capture per iteration + AddStepActions("AZOM." + def.Name, def.Fine, def.Coarse, d => StepBaseSetting(def, d)); + } + foreach (var t in BaseSettingCatalog.Toggles) + { + var def = t; // capture per iteration + AddToggleActions(def); + } + // Road sensitivity steps the 0..10 preset, not the raw register — + // the preset also rewrites the EQ curve, and moving one without the + // other leaves the base in a state the Base tab can't represent. + AddStepActions("AZOM.RoadSensitivity", 1, 2, StepRoadSensitivity); + + // Clutch split point, 0..100 % (issue #125; cf. + // MozaWheelSettingsControl's WiClutchPointSlider). Only meaningful + // with Paddles Mode = Combined. + AddStepActions("AZOM.ClutchSplit", 5, 10, StepClutchSplit); // AB9 shifter vibration. AddStepActions("AZOM.Ab9EngineIntensity", 5, 10, StepAb9EngineIntensity); // 0..100 @@ -178,6 +231,18 @@ internal void RegisterActions() _plugin.SaveSettings(); MozaLog.Debug("[AZOM] Work mode on via action"); }); + // Flip between the two. Registered by hand rather than through the + // toggle table so it can't clash with the WorkModeOn/Off names above. + _plugin.AddAction("AZOM.WorkModeToggle", (a, b) => + { + var data = _plugin.Data; + if (data == null) return; + int val = data.WorkMode != 0 ? 0 : 1; + data.WorkMode = val; + _plugin.WriteIfBaseConnected("main-set-work-mode", val); + _plugin.SaveSettings(); + MozaLog.Debug($"[AZOM] Work mode {(val == 0 ? "on" : "off (standby)")} via action"); + }); // Toggle the wheel screen on/off, remembering the on-brightness so a // later toggle-on restores it instead of a fixed default. @@ -319,48 +384,137 @@ private void AddStepActions(string name, int fine, int coarse, Action apply _plugin.AddAction(name + "DownCoarse", (a, b) => apply(-coarse)); } - private static int ClampStep(int current, int delta, int min, int max) - => Math.Max(min, Math.Min(max, current + delta)); + /// + /// Registers {name}On / {name}Off / {name}Toggle + /// for a two-state wheelbase setting, mirroring the Base-tab checkbox + /// commit path (mirror to _data, push, persist). + /// + private void AddToggleActions(BaseSettingCatalog.ToggleSetting def) + { + _plugin.AddAction("AZOM." + def.Name + "On", (a, b) => SetToggle(def, true)); + _plugin.AddAction("AZOM." + def.Name + "Off", (a, b) => SetToggle(def, false)); + _plugin.AddAction("AZOM." + def.Name + "Toggle", (a, b) => + { + var data = _plugin.Data; + if (data != null) SetToggle(def, !def.IsOn(data)); + }); + } - // FFB strength: stored raw = percent * 10 (cf. FfbStrengthSlider_ValueChanged). - private void StepFfbStrength(int deltaPct) + private void SetToggle(BaseSettingCatalog.ToggleSetting def, bool on) { var data = _plugin.Data; if (data == null) return; - int pct = ClampStep(data.FfbStrength / 10, deltaPct, 0, 100); - int raw = pct * 10; - data.FfbStrength = raw; - _plugin.WriteIfBaseConnected("base-ffb-strength", raw); + int val = on ? def.OnValue : def.OffValue; + if (def.Get(data) == val) return; // already there — no flash write + def.Set(data, val); + _plugin.WriteIfBaseConnected(def.Command, val); _plugin.SaveSettings(); - MozaLog.Debug($"[AZOM] FFB strength → {pct}% via action"); + MozaLog.Debug($"[AZOM] {def.Name} → {(on ? "on" : "off")} via action"); } - // Torque limit: percent, 50..100 (cf. TorqueSlider_ValueChanged). - private void StepTorque(int deltaPct) + private static int ClampStep(int current, int delta, int min, int max) + => Math.Max(min, Math.Min(max, current + delta)); + + /// + /// Nudge one wheelbase setting by a signed delta in display units, + /// mirroring its Base-tab slider commit path exactly: clamp, mirror to + /// _data, push every command in order, persist via + /// SaveSettings() (which captures _data into the active + /// profile). An open settings panel re-reads the value on its refresh tick. + /// + /// Every one of these commands is a base parameter-store slot and hits + /// flash on write, so a value already saturated at a range rail must NOT + /// be re-written — a held button with key repeat would otherwise burn one + /// flash write per repeat for no change. + /// + private void StepBaseSetting(BaseSettingCatalog.NumericSetting def, int delta) { var data = _plugin.Data; - if (data == null) return; - int v = ClampStep(data.Torque, deltaPct, 50, 100); - data.Torque = v; - _plugin.WriteIfBaseConnected("base-torque", v); + if (data == null || !def.IsSupported(data)) return; + int current = def.ToDisplay(def.GetRaw(data)); + int next = ClampStep(current, delta, def.Min, def.EffectiveMax(data)); + if (next == current) return; // saturated — skip the redundant flash write + int raw = def.ToRaw(next); + def.SetRaw(data, raw); + foreach (var cmd in def.Commands) + _plugin.WriteIfBaseConnected(cmd, raw); _plugin.SaveSettings(); - MozaLog.Debug($"[AZOM] Torque → {v}% via action"); + MozaLog.Debug($"[AZOM] {def.Name} → {next} via action"); } - // Steering rotation: display degrees, stored raw = degrees / 2; both - // base-limit and base-max-angle move together (cf. RotationSlider_ValueChanged). - private void StepRotation(int deltaDeg) + // Road sensitivity: step the 0..10 preset index (cf. the Base tab's + // sensitivity buttons / EqSensitivity_Click). There is no dedicated + // sensitivity register — a preset is the 0x0C value plus a canned EQ + // curve, so both move together or the base ends up in a state the Base + // tab can't represent. Legacy firmware gets only the six old registers. + private void StepRoadSensitivity(int delta) { var data = _plugin.Data; if (data == null) return; - int deg = ClampStep(data.Limit * 2, deltaDeg, 60, 2700); - int raw = deg / 2; - data.Limit = raw; - data.MaxAngle = raw; - _plugin.WriteIfBaseConnected("base-limit", raw); - _plugin.WriteIfBaseConnected("base-max-angle", raw); + int current = BaseSettingCatalog.RoadSensitivityPresetFromRaw(data.RoadSensitivity); + // Unknown (base hasn't reported): step in from the appropriate end. + int next = current < 0 + ? (delta > 0 ? BaseSettingCatalog.RoadSensitivityMinPreset : BaseSettingCatalog.RoadSensitivityMaxPreset) + : ClampStep(current, delta, + BaseSettingCatalog.RoadSensitivityMinPreset, + BaseSettingCatalog.RoadSensitivityMaxPreset); + if (next == current) return; // saturated — skip the redundant flash write + + int sensitivity = BaseSettingCatalog.RoadSensitivityRawFromPreset(next); + data.RoadSensitivity = sensitivity; + _plugin.WriteIfBaseConnected("base-road-sensitivity", sensitivity); + + int[] preset = BaseSettingCatalog.EqSensitivityPresets[next]; + if (data.BaseSupportsEq10) + { + for (int i = 0; i < 10; i++) + { + BaseSettingCatalog.SetEqRegister(data, BaseSettingCatalog.Eq10FreqOrderRegisters[i], preset[i]); + _plugin.WriteIfBaseConnected(BaseSettingCatalog.Eq10FreqOrderCommands[i], preset[i]); + } + } + else + { + for (int i = 0; i < 6; i++) + { + int v = preset[BaseSettingCatalog.Eq6FreqColumns[i]]; + BaseSettingCatalog.SetEqRegister(data, i, v); + _plugin.WriteIfBaseConnected(BaseSettingCatalog.EqRegisterCommands[i], v); + } + } + _plugin.SaveSettings(); + MozaLog.Debug($"[AZOM] Road sensitivity → preset {next} (0x0C={sensitivity}) via action"); + } + + // ===== Clutch split point (issue #125) ===== + + // Current split point using the same source of truth as the wheel device + // page: the per-(profile x wheel-page) overlay first, because newer + // KS-family firmware silently drops the cmd-9 read-back, then the live + // _data mirror, then the slider's 50 % default. Never returns a + // sentinel, so the first nudge always moves from a real value. + private int CurrentClutchSplitPoint() + { + var overlay = _plugin.GetCurrentWheelOverlay(_plugin.Settings?.ProfileStore?.CurrentProfile); + int v = overlay?.WheelClutchPoint ?? -1; + if (v < 0) v = _plugin.Data?.WheelClutchPoint ?? -1; + if (v < 0) v = 50; + return v > 100 ? 100 : v; + } + + // Mirror of WiClutchPointSlider_ValueChanged's commit path: _data + + // wheel overlay + device push + persist. The wheel settings page picks + // the new value up on its next refresh tick. Wire value == display %. + private void StepClutchSplit(int delta) + { + int current = CurrentClutchSplitPoint(); + int val = ClampStep(current, delta, 0, 100); + if (val == current) return; // saturated — skip the redundant flash write + if (_plugin.Data != null) _plugin.Data.WheelClutchPoint = val; + _plugin.UpdateActiveWheelOverlay(o => o.WheelClutchPoint = val); + _plugin.WriteIfWheelDetected("wheel-clutch-point", val); _plugin.SaveSettings(); - MozaLog.Debug($"[AZOM] Rotation → {deg}° via action"); + MozaLog.Debug($"[AZOM] Clutch split point → {val}% via action"); } // AB9 engine vibration is host-rendered: the worker thread picks up the diff --git a/Telemetry/TestMode/TestSignalOverrides.cs b/Telemetry/TestMode/TestSignalOverrides.cs index 87e4bffa..460be81f 100644 --- a/Telemetry/TestMode/TestSignalOverrides.cs +++ b/Telemetry/TestMode/TestSignalOverrides.cs @@ -279,6 +279,13 @@ static TestSignalOverrides() // multi-second sweep like the other orientation signals above // wouldn't exercise that in any recognizable way. Add("AccelerationHeave", TestSignal.Sweep(-0.6, 0.6, periodMs: 700)); + // Slow sweep through +/-1G — mBooster's G-Force (Inertial Pedal + // Feel) effect scales its travel offset by AccelerationSurge + // (see docs/protocol/devices/mbooster.md "G-Force"); a multi- + // second period lets the pedal's forward/backward push be felt + // distinctly rather than blurring together like Road Texture's + // fast bump signal. + Add("AccelerationSurge", TestSignal.Sweep(-1.0, 1.0, periodMs: 3000)); // --- Spotter / radar / coordinates --- Add("SpotterCarLeft", TestSignal.Toggle(stepMs: 5000)); diff --git a/Themes/Generic.xaml b/Themes/Generic.xaml index bd60598d..f79a1241 100644 --- a/Themes/Generic.xaml +++ b/Themes/Generic.xaml @@ -208,6 +208,20 @@ + + + + + + + diff --git a/Themes/MozaTheme.xaml b/Themes/MozaTheme.xaml index 947babd3..aa8cb9be 100644 --- a/Themes/MozaTheme.xaml +++ b/Themes/MozaTheme.xaml @@ -1118,6 +1118,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UI/Controls/MozaCurveEditor.cs b/UI/Controls/MozaCurveEditor.cs index 288a9f1b..46c4e177 100644 --- a/UI/Controls/MozaCurveEditor.cs +++ b/UI/Controls/MozaCurveEditor.cs @@ -738,7 +738,7 @@ private void Recompute() geom.Freeze(); SetValue(CurveGeometryKey, geom); - UpdateLiveMarker(segments, plotW, PadTop + plotH); + UpdateLiveMarker(segments, pts, plotW, PadTop + plotH); // ---- Background grid (4 interior horizontal + 4 vertical lines) ---- // Vertical lines scale with the rightmost node fraction so they @@ -841,41 +841,79 @@ private void Recompute() /// /// Position the live indicator (see ) exactly ON - /// the already-built spline: map the data-space X to a pixel X via - /// the same XAxisLabels/XLabelFractions correspondence used for tick - /// labels, find which segment contains it, then invert that - /// segment's Bezier X(t) via bisection (same approach as + /// the already-built spline: map the data-space X to a pixel X, find + /// which segment contains it, then invert that segment's Bezier X(t) + /// via bisection (same approach as /// MozaMBoosterRegistry.EvaluateInputCurve) to read off both the - /// pixel X and Y at that point. + /// pixel X and Y at that point — i.e. the dot always sits ON the + /// curve as currently configured, not just sliding horizontally. /// - private void UpdateLiveMarker((Point p1, Point c1, Point c2, Point p2)[] segments, double plotW, double axisBottomY) + private void UpdateLiveMarker((Point p1, Point c1, Point c2, Point p2)[] segments, Point[] nodePts, double plotW, double axisBottomY) { double liveX = LiveX; bool placed = false; if (!double.IsNaN(liveX) && segments.Length > 0) { - double[] fracs = ParseFractions(XLabelFractions, new[] { 0.0, 0.2, 0.4, 0.6, 0.8, 1.0 }); - string[] rawLabels = ParseLabels(XAxisLabels); - int n = Math.Min(fracs.Length, rawLabels.Length); - var values = new double[n]; - bool parsedOk = n >= 2; - for (int i = 0; parsedOk && i < n; i++) - parsedOk = double.TryParse(rawLabels[i], NumberStyles.Float, CultureInfo.InvariantCulture, out values[i]); - - if (parsedOk) + bool haveTarget; + double targetPixelX = 0; + + if (AllowHorizontalDrag && nodePts.Length > 0) { - double clampedX = Math.Max(values[0], Math.Min(values[n - 1], liveX)); - int lo = 0; + // Nodes are user-draggable in X (see ApplyDrag) — the + // fixed XAxisLabels/XLabelFractions correspondence below + // only matches the DEFAULT (undragged) breakpoints, so + // once the user configures a node's X, that mapping no + // longer reflects the actual plotted curve. Map liveX to + // a pixel X from the node's OWN current (dataX, pixelX) + // pairs instead — both axes are affine in a node's own + // fraction, so linear interpolation between two known + // node pairs reproduces the true mapping exactly whether + // or not it's been dragged from its default. + double[] dataXs = { X1, X2, X3, X4, X5 }; + int n = Math.Min(nodePts.Length, dataXs.Length); + double clampedX = Math.Max(0, Math.Min(dataXs[n - 1], liveX)); + double x0 = 0, px0 = PadLeft, x1 = dataXs[0], px1 = nodePts[0].X; for (int i = 0; i < n - 1; i++) { - if (clampedX >= values[i] && clampedX <= values[i + 1]) { lo = i; break; } + if (clampedX >= dataXs[i] && clampedX <= dataXs[i + 1]) + { + x0 = dataXs[i]; px0 = nodePts[i].X; + x1 = dataXs[i + 1]; px1 = nodePts[i + 1].X; + break; + } + } + targetPixelX = x1 > x0 ? px0 + (clampedX - x0) / (x1 - x0) * (px1 - px0) : px0; + haveTarget = true; + } + else + { + double[] fracs = ParseFractions(XLabelFractions, new[] { 0.0, 0.2, 0.4, 0.6, 0.8, 1.0 }); + string[] rawLabels = ParseLabels(XAxisLabels); + int n = Math.Min(fracs.Length, rawLabels.Length); + var values = new double[n]; + bool parsedOk = n >= 2; + for (int i = 0; parsedOk && i < n; i++) + parsedOk = double.TryParse(rawLabels[i], NumberStyles.Float, CultureInfo.InvariantCulture, out values[i]); + + if (parsedOk) + { + double clampedX = Math.Max(values[0], Math.Min(values[n - 1], liveX)); + int lo = 0; + for (int i = 0; i < n - 1; i++) + { + if (clampedX >= values[i] && clampedX <= values[i + 1]) { lo = i; break; } + } + double t0 = values[lo], t1 = values[lo + 1]; + double f0 = fracs[lo], f1 = fracs[lo + 1]; + double frac = t1 > t0 ? f0 + (clampedX - t0) / (t1 - t0) * (f1 - f0) : f0; + targetPixelX = PadLeft + Math.Max(0, Math.Min(1, frac)) * plotW; } - double t0 = values[lo], t1 = values[lo + 1]; - double f0 = fracs[lo], f1 = fracs[lo + 1]; - double frac = t1 > t0 ? f0 + (clampedX - t0) / (t1 - t0) * (f1 - f0) : f0; - double targetPixelX = PadLeft + Math.Max(0, Math.Min(1, frac)) * plotW; + haveTarget = parsedOk; + } + if (haveTarget) + { int segIdx = segments.Length - 1; for (int i = 0; i < segments.Length; i++) { diff --git a/UI/Controls/MozaSegmentedBarEditor.cs b/UI/Controls/MozaSegmentedBarEditor.cs new file mode 100644 index 00000000..fa0070f9 --- /dev/null +++ b/UI/Controls/MozaSegmentedBarEditor.cs @@ -0,0 +1,465 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace MozaControls +{ + /// + /// X/Y plot for Segmented Damping (mBooster Pedal Feel): two draggable + /// vertical dividers split 0-100% pedal travel (X axis) into 3 segments, + /// each with its own independently draggable damping amount (Y axis, + /// 0-100%). Unlike — a plain dual-thumb + /// slider sharing ONE range — each divider here has its OWN independent + /// [Min,Max] bound (Pit House's own asymmetric bounds: Divider1 + /// 10-80%, Divider2 20-90%), plus a minimum gap between them. See + /// docs/protocol/devices/mbooster.md "Segmented Damping". + /// + public class MozaSegmentedBarEditor : Control + { + static MozaSegmentedBarEditor() + { + DefaultStyleKeyProperty.OverrideMetadata( + typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(typeof(MozaSegmentedBarEditor))); + } + + /// Fires whenever any divider or segment value changes (drag or programmatic set). + public event EventHandler? ValuesChanged; + + // -------- Divider positions (X axis, 0-100%, two-way bindable) -------- + + public static readonly DependencyProperty Divider1Property = + DependencyProperty.Register(nameof(Divider1), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(33.0, + FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).OnValueChanged())); + public double Divider1 { get => (double)GetValue(Divider1Property); set => SetValue(Divider1Property, value); } + + public static readonly DependencyProperty Divider2Property = + DependencyProperty.Register(nameof(Divider2), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(67.0, + FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).OnValueChanged())); + public double Divider2 { get => (double)GetValue(Divider2Property); set => SetValue(Divider2Property, value); } + + // -------- Per-divider independent bounds + minimum gap -------- + + public static readonly DependencyProperty Divider1MinProperty = + DependencyProperty.Register(nameof(Divider1Min), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(10.0, FrameworkPropertyMetadataOptions.AffectsRender)); + public double Divider1Min { get => (double)GetValue(Divider1MinProperty); set => SetValue(Divider1MinProperty, value); } + + public static readonly DependencyProperty Divider1MaxProperty = + DependencyProperty.Register(nameof(Divider1Max), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(80.0, FrameworkPropertyMetadataOptions.AffectsRender)); + public double Divider1Max { get => (double)GetValue(Divider1MaxProperty); set => SetValue(Divider1MaxProperty, value); } + + public static readonly DependencyProperty Divider2MinProperty = + DependencyProperty.Register(nameof(Divider2Min), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(20.0, FrameworkPropertyMetadataOptions.AffectsRender)); + public double Divider2Min { get => (double)GetValue(Divider2MinProperty); set => SetValue(Divider2MinProperty, value); } + + public static readonly DependencyProperty Divider2MaxProperty = + DependencyProperty.Register(nameof(Divider2Max), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(90.0, FrameworkPropertyMetadataOptions.AffectsRender)); + public double Divider2Max { get => (double)GetValue(Divider2MaxProperty); set => SetValue(Divider2MaxProperty, value); } + + /// Minimum allowed gap between Divider1 and Divider2 (the two may never be dragged closer than this). + public static readonly DependencyProperty MinGapProperty = + DependencyProperty.Register(nameof(MinGap), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(10.0, FrameworkPropertyMetadataOptions.AffectsRender)); + public double MinGap { get => (double)GetValue(MinGapProperty); set => SetValue(MinGapProperty, value); } + + // -------- Segment damping values (Y axis, 0-100%, two-way bindable) -------- + + public static readonly DependencyProperty Seg1ValueProperty = + DependencyProperty.Register(nameof(Seg1Value), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(0.0, + FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).OnValueChanged())); + public double Seg1Value { get => (double)GetValue(Seg1ValueProperty); set => SetValue(Seg1ValueProperty, value); } + + public static readonly DependencyProperty Seg2ValueProperty = + DependencyProperty.Register(nameof(Seg2Value), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(0.0, + FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).OnValueChanged())); + public double Seg2Value { get => (double)GetValue(Seg2ValueProperty); set => SetValue(Seg2ValueProperty, value); } + + public static readonly DependencyProperty Seg3ValueProperty = + DependencyProperty.Register(nameof(Seg3Value), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(0.0, + FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).OnValueChanged())); + public double Seg3Value { get => (double)GetValue(Seg3ValueProperty); set => SetValue(Seg3ValueProperty, value); } + + private void OnValueChanged() + { + Recompute(); + ValuesChanged?.Invoke(this, EventArgs.Empty); + } + + // -------- Appearance -------- + + public static readonly DependencyProperty AccentBrushProperty = + DependencyProperty.Register(nameof(AccentBrush), typeof(Brush), typeof(MozaSegmentedBarEditor), + new PropertyMetadata(null)); + public Brush? AccentBrush { get => (Brush?)GetValue(AccentBrushProperty); set => SetValue(AccentBrushProperty, value); } + + /// Horizontal inset (px) on each end of the plot, room for divider handles at the extremes. + public static readonly DependencyProperty EdgePadProperty = + DependencyProperty.Register(nameof(EdgePad), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(22.0, FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).Recompute())); + public double EdgePad { get => (double)GetValue(EdgePadProperty); set => SetValue(EdgePadProperty, value); } + + /// Vertical inset (px) above the plot, room for the divider handle row + 100% bar. + public static readonly DependencyProperty TopPadProperty = + DependencyProperty.Register(nameof(TopPad), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(28.0, FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).Recompute())); + public double TopPad { get => (double)GetValue(TopPadProperty); set => SetValue(TopPadProperty, value); } + + /// Vertical inset (px) below the plot, room for the 0%/100% X-axis labels. + public static readonly DependencyProperty BottomPadProperty = + DependencyProperty.Register(nameof(BottomPad), typeof(double), typeof(MozaSegmentedBarEditor), + new FrameworkPropertyMetadata(20.0, FrameworkPropertyMetadataOptions.AffectsRender, + (d, e) => ((MozaSegmentedBarEditor)d).Recompute())); + public double BottomPad { get => (double)GetValue(BottomPadProperty); set => SetValue(BottomPadProperty, value); } + + /// Divider handle hit-test radius (px) — a click within this X distance of a divider line grabs it instead of the segment underneath. + public static readonly DependencyProperty DividerHitRadiusProperty = + DependencyProperty.Register(nameof(DividerHitRadius), typeof(double), typeof(MozaSegmentedBarEditor), + new PropertyMetadata(12.0)); + public double DividerHitRadius { get => (double)GetValue(DividerHitRadiusProperty); set => SetValue(DividerHitRadiusProperty, value); } + + // -------- Read-only geometry / labels surfaced to the template -------- + + private static readonly DependencyPropertyKey Seg1RectKey = + DependencyProperty.RegisterReadOnly(nameof(Seg1Rect), typeof(Geometry), typeof(MozaSegmentedBarEditor), new PropertyMetadata(null)); + public static readonly DependencyProperty Seg1RectProperty = Seg1RectKey.DependencyProperty; + public Geometry? Seg1Rect => (Geometry?)GetValue(Seg1RectProperty); + + private static readonly DependencyPropertyKey Seg2RectKey = + DependencyProperty.RegisterReadOnly(nameof(Seg2Rect), typeof(Geometry), typeof(MozaSegmentedBarEditor), new PropertyMetadata(null)); + public static readonly DependencyProperty Seg2RectProperty = Seg2RectKey.DependencyProperty; + public Geometry? Seg2Rect => (Geometry?)GetValue(Seg2RectProperty); + + private static readonly DependencyPropertyKey Seg3RectKey = + DependencyProperty.RegisterReadOnly(nameof(Seg3Rect), typeof(Geometry), typeof(MozaSegmentedBarEditor), new PropertyMetadata(null)); + public static readonly DependencyProperty Seg3RectProperty = Seg3RectKey.DependencyProperty; + public Geometry? Seg3Rect => (Geometry?)GetValue(Seg3RectProperty); + + private static readonly DependencyPropertyKey PlotBackgroundRectKey = + DependencyProperty.RegisterReadOnly(nameof(PlotBackgroundRect), typeof(Geometry), typeof(MozaSegmentedBarEditor), new PropertyMetadata(null)); + public static readonly DependencyProperty PlotBackgroundRectProperty = PlotBackgroundRectKey.DependencyProperty; + public Geometry? PlotBackgroundRect => (Geometry?)GetValue(PlotBackgroundRectProperty); + + /// 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. + private static readonly DependencyPropertyKey StepLineGeometryKey = + DependencyProperty.RegisterReadOnly(nameof(StepLineGeometry), typeof(Geometry), typeof(MozaSegmentedBarEditor), new PropertyMetadata(null)); + public static readonly DependencyProperty StepLineGeometryProperty = StepLineGeometryKey.DependencyProperty; + public Geometry? StepLineGeometry => (Geometry?)GetValue(StepLineGeometryProperty); + + private static readonly DependencyPropertyKey Divider1XKey = + DependencyProperty.RegisterReadOnly(nameof(Divider1X), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Divider1XProperty = Divider1XKey.DependencyProperty; + public double Divider1X => (double)GetValue(Divider1XProperty); + + private static readonly DependencyPropertyKey Divider2XKey = + DependencyProperty.RegisterReadOnly(nameof(Divider2X), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Divider2XProperty = Divider2XKey.DependencyProperty; + public double Divider2X => (double)GetValue(Divider2XProperty); + + private static readonly DependencyPropertyKey DividerTopKey = + DependencyProperty.RegisterReadOnly(nameof(DividerTop), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty DividerTopProperty = DividerTopKey.DependencyProperty; + public double DividerTop => (double)GetValue(DividerTopProperty); + + private static readonly DependencyPropertyKey DividerBottomKey = + DependencyProperty.RegisterReadOnly(nameof(DividerBottom), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty DividerBottomProperty = DividerBottomKey.DependencyProperty; + public double DividerBottom => (double)GetValue(DividerBottomProperty); + + private static readonly DependencyPropertyKey Divider1LabelKey = + DependencyProperty.RegisterReadOnly(nameof(Divider1Label), typeof(string), typeof(MozaSegmentedBarEditor), new PropertyMetadata("")); + public static readonly DependencyProperty Divider1LabelProperty = Divider1LabelKey.DependencyProperty; + public string Divider1Label => (string)GetValue(Divider1LabelProperty); + + private static readonly DependencyPropertyKey Divider2LabelKey = + DependencyProperty.RegisterReadOnly(nameof(Divider2Label), typeof(string), typeof(MozaSegmentedBarEditor), new PropertyMetadata("")); + public static readonly DependencyProperty Divider2LabelProperty = Divider2LabelKey.DependencyProperty; + public string Divider2Label => (string)GetValue(Divider2LabelProperty); + + private static readonly DependencyPropertyKey Seg1LabelKey = + DependencyProperty.RegisterReadOnly(nameof(Seg1Label), typeof(string), typeof(MozaSegmentedBarEditor), new PropertyMetadata("")); + public static readonly DependencyProperty Seg1LabelProperty = Seg1LabelKey.DependencyProperty; + public string Seg1Label => (string)GetValue(Seg1LabelProperty); + + private static readonly DependencyPropertyKey Seg2LabelKey = + DependencyProperty.RegisterReadOnly(nameof(Seg2Label), typeof(string), typeof(MozaSegmentedBarEditor), new PropertyMetadata("")); + public static readonly DependencyProperty Seg2LabelProperty = Seg2LabelKey.DependencyProperty; + public string Seg2Label => (string)GetValue(Seg2LabelProperty); + + private static readonly DependencyPropertyKey Seg3LabelKey = + DependencyProperty.RegisterReadOnly(nameof(Seg3Label), typeof(string), typeof(MozaSegmentedBarEditor), new PropertyMetadata("")); + public static readonly DependencyProperty Seg3LabelProperty = Seg3LabelKey.DependencyProperty; + public string Seg3Label => (string)GetValue(Seg3LabelProperty); + + // Label/handle sizes — fixed so their Canvas.Left/Top can be pre- + // computed as already-centered top-left positions (same technique + // MozaRangeSlider uses for its thumbs: LowThumbX = centerX - half), + // rather than relying on a WPF RenderTransform to center a + // variable-width TextBlock after the fact. + private const double HandleWidth = 30, HandleHeight = 20; + private const double LabelWidth = 34, LabelHeight = 18; + + private static readonly DependencyPropertyKey Divider1HandleLeftKey = + DependencyProperty.RegisterReadOnly(nameof(Divider1HandleLeft), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Divider1HandleLeftProperty = Divider1HandleLeftKey.DependencyProperty; + public double Divider1HandleLeft => (double)GetValue(Divider1HandleLeftProperty); + + private static readonly DependencyPropertyKey Divider2HandleLeftKey = + DependencyProperty.RegisterReadOnly(nameof(Divider2HandleLeft), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Divider2HandleLeftProperty = Divider2HandleLeftKey.DependencyProperty; + public double Divider2HandleLeft => (double)GetValue(Divider2HandleLeftProperty); + + private static readonly DependencyPropertyKey Seg1LabelLeftKey = + DependencyProperty.RegisterReadOnly(nameof(Seg1LabelLeft), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Seg1LabelLeftProperty = Seg1LabelLeftKey.DependencyProperty; + public double Seg1LabelLeft => (double)GetValue(Seg1LabelLeftProperty); + + private static readonly DependencyPropertyKey Seg2LabelLeftKey = + DependencyProperty.RegisterReadOnly(nameof(Seg2LabelLeft), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Seg2LabelLeftProperty = Seg2LabelLeftKey.DependencyProperty; + public double Seg2LabelLeft => (double)GetValue(Seg2LabelLeftProperty); + + private static readonly DependencyPropertyKey Seg3LabelLeftKey = + DependencyProperty.RegisterReadOnly(nameof(Seg3LabelLeft), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Seg3LabelLeftProperty = Seg3LabelLeftKey.DependencyProperty; + public double Seg3LabelLeft => (double)GetValue(Seg3LabelLeftProperty); + + // Each label floats just above its own bar's current height (like a + // tooltip pinned to the bar top), clamped so it never rises above + // the plot's own top inset. + private static readonly DependencyPropertyKey Seg1LabelTopKey = + DependencyProperty.RegisterReadOnly(nameof(Seg1LabelTop), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Seg1LabelTopProperty = Seg1LabelTopKey.DependencyProperty; + public double Seg1LabelTop => (double)GetValue(Seg1LabelTopProperty); + + private static readonly DependencyPropertyKey Seg2LabelTopKey = + DependencyProperty.RegisterReadOnly(nameof(Seg2LabelTop), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Seg2LabelTopProperty = Seg2LabelTopKey.DependencyProperty; + public double Seg2LabelTop => (double)GetValue(Seg2LabelTopProperty); + + private static readonly DependencyPropertyKey Seg3LabelTopKey = + DependencyProperty.RegisterReadOnly(nameof(Seg3LabelTop), typeof(double), typeof(MozaSegmentedBarEditor), new PropertyMetadata(0.0)); + public static readonly DependencyProperty Seg3LabelTopProperty = Seg3LabelTopKey.DependencyProperty; + public double Seg3LabelTop => (double)GetValue(Seg3LabelTopProperty); + + public override void OnApplyTemplate() + { + base.OnApplyTemplate(); + HookCanvas(); + Recompute(); + } + + protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo) + { + base.OnRenderSizeChanged(sizeInfo); + Recompute(); + } + + // -------- Drag state -------- + // -1 = none, 0 = Divider1, 1 = Divider2, 2/3/4 = Segment 1/2/3. + private int _dragTarget = -1; + private Canvas? _canvas; + + private void HookCanvas() + { + _canvas = GetTemplateChild("PART_Canvas") as Canvas; + if (_canvas != null) + { + _canvas.MouseLeftButtonDown += OnMouseDown; + _canvas.MouseMove += OnMouseMove; + _canvas.MouseLeftButtonUp += OnMouseUp; + _canvas.LostMouseCapture += (_, __) => _dragTarget = -1; + } + } + + private void OnMouseDown(object sender, MouseButtonEventArgs e) + { + if (_canvas == null) return; + var p = e.GetPosition(_canvas); + + double d1x = Divider1X, d2x = Divider2X; + if (Math.Abs(p.X - d1x) <= DividerHitRadius) + { + _dragTarget = 0; + _canvas.CaptureMouse(); + ApplyDividerDrag(0, p.X); + e.Handled = true; + return; + } + if (Math.Abs(p.X - d2x) <= DividerHitRadius) + { + _dragTarget = 1; + _canvas.CaptureMouse(); + ApplyDividerDrag(1, p.X); + e.Handled = true; + return; + } + + double plotLeft = EdgePad, plotRight = Math.Max(plotLeft, ActualWidth - EdgePad); + double plotTop = TopPad, plotBottom = Math.Max(plotTop, ActualHeight - BottomPad); + if (p.X < plotLeft || p.X > plotRight || p.Y < plotTop || p.Y > plotBottom) return; + + int seg = p.X < d1x ? 2 : (p.X < d2x ? 3 : 4); + // Segments only respond once an actual drag starts (no jump on a + // plain click) — capture the mouse here but don't apply a value + // until OnMouseMove sees real movement. + _dragTarget = seg; + _canvas.CaptureMouse(); + e.Handled = true; + } + + private void OnMouseMove(object sender, MouseEventArgs e) + { + if (_dragTarget < 0 || _canvas == null) return; + if (e.LeftButton != MouseButtonState.Pressed) { _dragTarget = -1; _canvas.ReleaseMouseCapture(); return; } + var p = e.GetPosition(_canvas); + if (_dragTarget <= 1) ApplyDividerDrag(_dragTarget, p.X); + else ApplySegmentDrag(_dragTarget - 2, p.Y); + } + + private void OnMouseUp(object sender, MouseButtonEventArgs e) + { + if (_canvas != null && _canvas.IsMouseCaptured) _canvas.ReleaseMouseCapture(); + _dragTarget = -1; + } + + private void ApplyDividerDrag(int which, double x) + { + double plotW = Math.Max(1, ActualWidth - EdgePad - EdgePad); + double frac = (x - EdgePad) / plotW; + frac = Math.Max(0, Math.Min(1, frac)); + double val = frac * 100.0; + + if (which == 0) + { + double lo = Divider1Min; + double hi = Math.Min(Divider1Max, Divider2 - MinGap); + if (hi < lo) hi = lo; + Divider1 = Math.Round(Math.Max(lo, Math.Min(hi, val)), 0); + } + else + { + double lo = Math.Max(Divider2Min, Divider1 + MinGap); + double hi = Divider2Max; + if (hi < lo) hi = lo; + Divider2 = Math.Round(Math.Max(lo, Math.Min(hi, val)), 0); + } + } + + private void ApplySegmentDrag(int segIndex, double y) + { + double plotH = Math.Max(1, ActualHeight - TopPad - BottomPad); + double plotBottom = TopPad + plotH; + double frac = (plotBottom - y) / plotH; + frac = Math.Max(0, Math.Min(1, frac)); + double val = Math.Round(frac * 100.0, 0); + + switch (segIndex) + { + case 0: Seg1Value = val; break; + case 1: Seg2Value = val; break; + case 2: Seg3Value = val; break; + } + } + + private void Recompute() + { + double w = ActualWidth, h = ActualHeight; + if (w <= 0 || h <= 0) return; + + double plotW = Math.Max(1, w - EdgePad - EdgePad); + double plotH = Math.Max(1, h - TopPad - BottomPad); + double plotBottom = TopPad + plotH; + + double d1 = Math.Max(Divider1Min, Math.Min(Divider1Max, Divider1)); + double d2 = Math.Max(Divider2Min, Math.Min(Divider2Max, Divider2)); + double d1x = EdgePad + d1 / 100.0 * plotW; + double d2x = EdgePad + d2 / 100.0 * plotW; + + SetValue(Divider1XKey, d1x); + SetValue(Divider2XKey, d2x); + double handleTop = TopPad - HandleHeight - 2; + SetValue(DividerTopKey, handleTop); + SetValue(DividerBottomKey, plotBottom); + SetValue(Divider1LabelKey, Math.Round(d1) + "%"); + SetValue(Divider2LabelKey, Math.Round(d2) + "%"); + SetValue(Divider1HandleLeftKey, d1x - HandleWidth / 2.0); + SetValue(Divider2HandleLeftKey, d2x - HandleWidth / 2.0); + + double YOf(double pct) + { + double clamped = Math.Max(0, Math.Min(100, pct)); + return plotBottom - clamped / 100.0 * plotH; + } + + double s1v = Math.Max(0, Math.Min(100, Seg1Value)); + double s2v = Math.Max(0, Math.Min(100, Seg2Value)); + double s3v = Math.Max(0, Math.Min(100, Seg3Value)); + + var seg1 = new RectangleGeometry(new Rect(EdgePad, YOf(s1v), Math.Max(0, d1x - EdgePad), Math.Max(0, plotBottom - YOf(s1v)))); + var seg2 = new RectangleGeometry(new Rect(d1x, YOf(s2v), Math.Max(0, d2x - d1x), Math.Max(0, plotBottom - YOf(s2v)))); + var seg3 = new RectangleGeometry(new Rect(d2x, YOf(s3v), Math.Max(0, EdgePad + plotW - d2x), Math.Max(0, plotBottom - YOf(s3v)))); + seg1.Freeze(); seg2.Freeze(); seg3.Freeze(); + SetValue(Seg1RectKey, seg1); + SetValue(Seg2RectKey, seg2); + SetValue(Seg3RectKey, seg3); + + var bg = new RectangleGeometry(new Rect(EdgePad, TopPad, plotW, plotH)); + bg.Freeze(); + SetValue(PlotBackgroundRectKey, bg); + + // 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)); + var stepGeom = new PathGeometry(); + stepGeom.Figures.Add(stepFig); + stepGeom.Freeze(); + SetValue(StepLineGeometryKey, stepGeom); + + string fmt = "F0"; + SetValue(Seg1LabelKey, s1v.ToString(fmt, CultureInfo.InvariantCulture) + "%"); + SetValue(Seg2LabelKey, s2v.ToString(fmt, CultureInfo.InvariantCulture) + "%"); + SetValue(Seg3LabelKey, s3v.ToString(fmt, CultureInfo.InvariantCulture) + "%"); + + double seg1CenterX = (EdgePad + d1x) / 2.0; + double seg2CenterX = (d1x + d2x) / 2.0; + double seg3CenterX = (d2x + EdgePad + plotW) / 2.0; + SetValue(Seg1LabelLeftKey, seg1CenterX - LabelWidth / 2.0); + SetValue(Seg2LabelLeftKey, seg2CenterX - LabelWidth / 2.0); + SetValue(Seg3LabelLeftKey, seg3CenterX - LabelWidth / 2.0); + + double LabelTopFor(double barTopY) => Math.Max(TopPad, barTopY - LabelHeight - 4); + SetValue(Seg1LabelTopKey, LabelTopFor(YOf(s1v))); + SetValue(Seg2LabelTopKey, LabelTopFor(YOf(s2v))); + SetValue(Seg3LabelTopKey, LabelTopFor(YOf(s3v))); + } + } +} diff --git a/UI/DiagnosticsTextBuilder.cs b/UI/DiagnosticsTextBuilder.cs index 81ff4859..1513fdc1 100644 --- a/UI/DiagnosticsTextBuilder.cs +++ b/UI/DiagnosticsTextBuilder.cs @@ -694,7 +694,7 @@ public static string BuildDeviceLog(MozaPlugin plugin) // Pull counters first, so "no lines" is diagnosable without a wire // trace: requests=0 means we never asked, requests>0 payloads=0 // means the display isn't answering. - string pull = plugin.TelemetrySender?.DeviceLogPullStatus; + var pull = plugin.TelemetrySender?.DeviceLogPullStatus; if (pull != null) sb.AppendLine($"Pull: {pull}"); var cm2 = plugin._cm2Sender?.DeviceLogPullStatus; if (cm2 != null) sb.AppendLine($"Pull: {cm2}"); diff --git a/UI/Import/FieldDiff.cs b/UI/Import/FieldDiff.cs index 216eb95e..d7788381 100644 --- a/UI/Import/FieldDiff.cs +++ b/UI/Import/FieldDiff.cs @@ -43,6 +43,33 @@ public sealed class ImportPlan public List NotImported { get; } = new List(); public string? FatalError { get; set; } + /// + /// Which pedal role(s) a Pedals preset actually configures, for the + /// confirm header — a PitHouse preset carries all three role sections + /// but only fills in its own. Null on the Motor path. + /// See . + /// + public string? SubjectRoleDisplay { get; set; } + + /// + /// True when each of a Pedals preset's sections was matched to the pedal + /// carrying its own role, rather than one section going to one chosen + /// pedal — either because the preset has no discernible subject role (a + /// plain calibration snapshot) or because it configures more than one. + /// The wizard hides the "Apply to" selector in that case: retargeting + /// has no single meaning when several sections are in play. + /// + public bool AutoMatchedPerRole { get; set; } + + /// + /// The pedal a Pedals preset's subject section was mapped onto — either + /// the caller's override or the auto-picked pedal carrying the subject + /// role. Null on the Motor path, on a calibration-only preset (every + /// section auto-matched to its own pedal), and when no attached pedal + /// carries the role. The wizard uses it to preselect the "Apply to" combo. + /// + public MBoosterImportTarget? ResolvedTarget { get; set; } + /// /// mBooster controllers whose settings were touched by one or more /// diffs in this plan. After all diffs have been applied, the caller diff --git a/UI/Import/PitHouseImportControl.xaml b/UI/Import/PitHouseImportControl.xaml index f8eb658e..710b10bc 100644 --- a/UI/Import/PitHouseImportControl.xaml +++ b/UI/Import/PitHouseImportControl.xaml @@ -174,6 +174,8 @@ + + + + + + + + + diff --git a/UI/Import/PitHouseImportControl.xaml.cs b/UI/Import/PitHouseImportControl.xaml.cs index 8a638ab5..f863e74d 100644 --- a/UI/Import/PitHouseImportControl.xaml.cs +++ b/UI/Import/PitHouseImportControl.xaml.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Input; @@ -22,6 +23,12 @@ public partial class PitHouseImportControl : UserControl private MozaPlugin? _plugin; private string? _customPathOverride; + // Pedals path only: the attached pedals the preset can be applied to, + // snapshotted when the preset is loaded so a device arriving mid-confirm + // can't shift the combo out from under the user's selection. + private IReadOnlyList _pedalControllers = Array.Empty(); + private bool _suppressTargetChange; + // Selected preset + built plan, populated when Next is clicked. public PitHousePreset? SelectedPreset { get; private set; } public ImportPlan? Plan { get; private set; } @@ -172,7 +179,6 @@ private void LoadPresetAndConfirm(string path) return; } - ImportPlan plan; if (string.Equals(preset.DeviceType, "Motor", StringComparison.OrdinalIgnoreCase)) { var profile = _plugin?.Settings?.ProfileStore?.CurrentProfile; @@ -184,14 +190,21 @@ private void LoadPresetAndConfirm(string path) MessageBoxButton.OK, MessageBoxImage.Error); return; } - plan = PitHouseMotorMapper.BuildPlan(preset, profile); + SelectedPreset = preset; + Plan = PitHouseMotorMapper.BuildPlan(preset, profile); + _pedalControllers = Array.Empty(); + PopulatePedalTargets(null); } else if (string.Equals(preset.DeviceType, "Pedals", StringComparison.OrdinalIgnoreCase)) { - var registry = _plugin?.MBoosterRegistry; - IReadOnlyList controllers = - registry?.Devices ?? Array.Empty(); - plan = PitHousePedalsMapper.BuildPlan(preset, controllers); + SelectedPreset = preset; + _pedalControllers = _plugin?.MBoosterRegistry?.Devices + ?? (IReadOnlyList)Array.Empty(); + // First build with no override so the mapper picks the pedal + // carrying the preset's subject role; the combo then preselects + // whatever it resolved to. + Plan = PitHousePedalsMapper.BuildPlan(preset, _pedalControllers); + PopulatePedalTargets(Plan); } else { @@ -202,9 +215,12 @@ private void LoadPresetAndConfirm(string path) return; } - SelectedPreset = preset; - Plan = plan; + LogPlan(preset, Plan); + ShowConfirmPanel(); + } + private static void LogPlan(PitHousePreset preset, ImportPlan plan) + { // Debug — surface what BuildPlan produced so we can diagnose the // "empty Changes container" case from logs. Logs to SimHub.txt. int changedCount = 0; @@ -212,6 +228,7 @@ private void LoadPresetAndConfirm(string path) MozaLog.Info( $"[AZOM/Import] BuildPlan '{preset.Name}' type={preset.DeviceType}: " + $"dp.Count={preset.DeviceParams.Count} " + + $"subject='{plan.SubjectRoleDisplay ?? "-"}' target='{plan.ResolvedTarget?.Label ?? "-"}' " + $"diffs={plan.Diffs.Count} changed={changedCount} " + $"notImported={plan.NotImported.Count} " + $"fatal='{plan.FatalError ?? ""}'"); @@ -220,7 +237,49 @@ private void LoadPresetAndConfirm(string path) var d = plan.Diffs[i]; MozaLog.Info($"[AZOM/Import] diff[{i}] {d.Label}: '{d.OldDisplay}' -> '{d.NewDisplay}' changed={d.Changed}"); } + } + /// + /// Fill the "Apply to" combo with every attached pedal and preselect the + /// one resolved to. Passing null (Motor path) + /// clears and hides the row. Runs under + /// so repopulating never re-enters the rebuild. + /// + private void PopulatePedalTargets(ImportPlan? plan) + { + _suppressTargetChange = true; + try + { + if (plan == null) + { + TargetPedalCombo.ItemsSource = null; + return; + } + + var targets = PitHousePedalsMapper.EnumerateTargets(_pedalControllers); + TargetPedalCombo.ItemsSource = targets; + // BuildPlan enumerated its own targets, so the plan's instance + // is never one of these — match on (controller, axis) instead. + var resolved = plan.ResolvedTarget; + if (resolved != null) + { + TargetPedalCombo.SelectedItem = targets.FirstOrDefault(t => + ReferenceEquals(t.Controller, resolved.Controller) && t.AxisIndex == resolved.AxisIndex); + } + // No resolved target (no attached pedal carries the subject + // role) leaves the combo unselected so the user picks one. + } + finally { _suppressTargetChange = false; } + } + + private void TargetPedalCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressTargetChange) return; + if (SelectedPreset == null) return; + if (!(TargetPedalCombo.SelectedItem is MBoosterImportTarget target)) return; + + Plan = PitHousePedalsMapper.BuildPlan(SelectedPreset, _pedalControllers, target); + LogPlan(SelectedPreset, Plan); ShowConfirmPanel(); } @@ -235,6 +294,22 @@ private void ShowConfirmPanel() ConfirmPresetText.Text = SelectedPreset.Name; ConfirmProfileText.Text = profileName; + // Pedals only: which role the preset configures, and where it lands. + // A PitHouse preset carries all three role sections but fills in + // only its own — the other two are the device-wide snapshot, so the + // header has to say which one is actually being imported. + bool hasSubject = !string.IsNullOrEmpty(Plan.SubjectRoleDisplay); + SubjectRoleLabel.Visibility = hasSubject ? Visibility.Visible : Visibility.Collapsed; + SubjectRoleText.Visibility = hasSubject ? Visibility.Visible : Visibility.Collapsed; + SubjectRoleText.Text = Plan.SubjectRoleDisplay ?? ""; + + // Retargeting only means something when one section drives one + // pedal — a calibration-only preset already covers every role. + bool canRetarget = hasSubject && !Plan.AutoMatchedPerRole + && TargetPedalCombo.Items.Count > 0; + ApplyToLabel.Visibility = canRetarget ? Visibility.Visible : Visibility.Collapsed; + TargetPedalCombo.Visibility = canRetarget ? Visibility.Visible : Visibility.Collapsed; + // Show the full diff list (changed AND unchanged) so the user can // see the complete mapping. The DataTemplate dims unchanged rows // so the actual changes still stand out. Counts feed the footer @@ -296,6 +371,8 @@ private void Back_Click(object sender, RoutedEventArgs e) { SelectedPreset = null; Plan = null; + _pedalControllers = Array.Empty(); + PopulatePedalTargets(null); ConfirmPanel.Visibility = Visibility.Collapsed; PickerPanel.Visibility = Visibility.Visible; diff --git a/UI/Import/PitHousePedalsMapper.cs b/UI/Import/PitHousePedalsMapper.cs index b9771f63..5ffd1d28 100644 --- a/UI/Import/PitHousePedalsMapper.cs +++ b/UI/Import/PitHousePedalsMapper.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using Newtonsoft.Json.Linq; using MozaPlugin.Devices; @@ -7,88 +8,329 @@ namespace MozaPlugin.UI.Import { /// - /// Maps a PitHouse Pedals preset (mBooster-only — non-mBooster pedal - /// presets have no calibration surface the plugin exposes) onto the - /// attached mBooster controllers' . + /// One pedal a Pedals preset can be imported into — a (controller, HID axis) + /// pair, since a chained mBooster hosts several pedals on one lane. Doubles + /// as the import wizard's "Apply to" combo item ( is + /// the display text). + /// + public sealed class MBoosterImportTarget + { + public MBoosterDeviceController Controller { get; } + public int AxisIndex { get; } + public MBoosterRole Role { get; } + /// Device+pedal text for the combo, e.g. "front-brake — a1b2c3d4 (COM7) — Pedal 2 · Brake". + public string Label { get; } + /// Short "Brake — front-brake" form used to prefix diff rows. + public string RowPrefix { get; } + /// Passive pedal (no motor, e.g. a CRP2) — vibration effects don't apply. + public bool IsPassive { get; } + + public MBoosterImportTarget(MBoosterDeviceController controller, int axisIndex, + MBoosterRole role, string label, string rowPrefix, bool isPassive) + { + Controller = controller; + AxisIndex = axisIndex; + Role = role; + Label = label ?? ""; + RowPrefix = rowPrefix ?? ""; + IsPassive = isPassive; + } + + public override string ToString() => Label; + } + + /// + /// Maps a PitHouse Pedals preset (mBooster-only — non-mBooster pedal presets + /// have no calibration surface the plugin exposes) onto ONE attached pedal's + /// . /// - /// A single PitHouse preset file can carry calibration sections for any - /// of throttle/brake/clutch — sometimes one role is "real" (the preset's - /// theme) and the others are just defaults, sometimes the preset is a - /// full three-pedal reset. We treat each role independently: if its - /// section is populated (any of outdir/min/max/nonlinear1..5 present) - /// AND there is at least one attached mBooster carrying that role, we - /// emit diffs for that pairing. Sections without a matching attached - /// device are surfaced under "Not imported" so the user knows what was - /// dropped. + /// PitHouse writes a preset per pedal role, but the file still carries all + /// three throttle_/brake_/clutch_ sections: only the + /// preset's own role gets the extended block (effects, travel limits, force + /// curves), the other two hold just the device-wide + /// _channlRoleType/_min/_max/_nonlinear1..5/_outdir snapshot. Importing + /// all three rewrote the other two pedals' calibration from what is really + /// filler, so the mapper picks the SUBJECT role — the section carrying any + /// non-generic key — and imports only that, into the one pedal the user + /// targets. See docs/protocol/devices/mbooster.md "PitHouse Pedals preset + /// format". /// public static class PitHousePedalsMapper { + // PitHouse role prefixes, in throttle/brake/clutch order. + private static readonly (string Prefix, MBoosterRole Role, string Label)[] Roles = + { + ("throttle", MBoosterRole.Throttle, "Throttle"), + ("brake", MBoosterRole.Brake, "Brake"), + ("clutch", MBoosterRole.Clutch, "Clutch"), + }; + + // Keys every section carries regardless of which pedal the preset is + // for — presence of these alone does NOT make a section the subject. + private static readonly HashSet GenericSuffixes = new HashSet(StringComparer.Ordinal) + { + "channlRoleType", "outdir", "min", "max", + "nonlinear1", "nonlinear2", "nonlinear3", "nonlinear4", "nonlinear5", + "press_combine", + }; + + // ------------------------------------------------------------ + // Target enumeration (also feeds the wizard's "Apply to" combo) + // ------------------------------------------------------------ + /// - /// Build the apply plan. should be the - /// live list of detected mBoosters from - /// MozaPlugin.MBoosterRegistry.Devices. Each controller's - /// is read for - /// the "before" half of each diff. + /// Every pedal an import could land on: one entry per wired HID axis of + /// every attached mBooster. Uses the same + /// + + /// pair the mBooster + /// tab's row list uses, so both show the same pedals with the same roles + /// — including chained lanes, whose per-axis roles live in + /// rather than the legacy + /// flat Role. + /// + public static List EnumerateTargets( + IReadOnlyList? controllers) + { + var targets = new List(); + if (controllers == null) return targets; + + foreach (var c in controllers) + { + if (c == null) continue; + var s = c.CurrentSettings; + if (s == null) continue; + + int axisCount = c.AxisCount > 0 ? c.AxisCount : 1; + var axes = c.ConnectedAxisIndices(); + var types = c.AxisTypes; + + string deviceLabel = string.IsNullOrWhiteSpace(s.DisplayName) + ? $"{MBoosterDeviceController.ShortIdentity(c.Identity)} ({c.PortName})" + : $"{s.DisplayName} — {MBoosterDeviceController.ShortIdentity(c.Identity)} ({c.PortName})"; + + bool multiplePedals = axes.Count > 1; + int shown = 0; + foreach (int axis in axes) + { + ++shown; + var role = MozaMBoosterRegistry.ResolveAxisRole(s, axis, axisCount); + string pedalPart = multiplePedals + ? $"{deviceLabel} — {string.Format(global::MozaPlugin.Resources.Strings.Label_PedalAxis, shown)}" + : deviceLabel; + bool passive = types != null && axis < types.Length && types[axis] == 2; + + // Row prefix leads with the role so a diff list reads + // "Brake · ABS"; the device name disambiguates two pedals + // that somehow share a role. + string rowPrefix = RoleName(role); + if (!string.IsNullOrWhiteSpace(s.DisplayName)) rowPrefix += " — " + s.DisplayName; + else if (multiplePedals) rowPrefix += $" — {string.Format(global::MozaPlugin.Resources.Strings.Label_PedalAxis, shown)}"; + + targets.Add(new MBoosterImportTarget( + c, axis, role, $"{pedalPart} · {RoleName(role)}", rowPrefix, passive)); + } + } + return targets; + } + + private static string RoleName(MBoosterRole role) + { + switch (role) + { + case MBoosterRole.Throttle: return "Throttle"; + case MBoosterRole.Brake: return "Brake"; + case MBoosterRole.Clutch: return "Clutch"; + default: return "Disabled"; + } + } + + // ------------------------------------------------------------ + // Subject-role detection + // ------------------------------------------------------------ + + /// + /// The role prefixes this preset actually configures. Normally exactly + /// one — the section carrying any key outside + /// . A preset with no extended block + /// anywhere (a plain calibration snapshot) has no discernible subject; + /// every populated section is returned instead, and + /// is set so the caller can auto-match + /// each section to its own pedal rather than asking the user to pick one. + /// + public static List DetectSubjectPrefixes(JObject? dp, out bool isCalibrationOnly) + { + isCalibrationOnly = false; + var subjects = new List(); + if (dp == null) return subjects; + + foreach (var (prefix, _, _) in Roles) + if (HasNonGenericKey(dp, prefix)) subjects.Add(prefix); + + if (subjects.Count > 0) return subjects; + + isCalibrationOnly = true; + foreach (var (prefix, _, _) in Roles) + if (HasAnyPopulatedKey(dp, prefix)) subjects.Add(prefix); + return subjects; + } + + private static bool HasNonGenericKey(JObject dp, string prefix) + { + string p = prefix + "_"; + foreach (var prop in dp.Properties()) + { + if (!prop.Name.StartsWith(p, StringComparison.Ordinal)) continue; + if (prop.Value == null || prop.Value.Type == JTokenType.Null) continue; + if (!GenericSuffixes.Contains(prop.Name.Substring(p.Length))) return true; + } + return false; + } + + private static bool HasAnyPopulatedKey(JObject dp, string prefix) + { + string p = prefix + "_"; + foreach (var prop in dp.Properties()) + { + if (!prop.Name.StartsWith(p, StringComparison.Ordinal)) continue; + if (prop.Value == null || prop.Value.Type == JTokenType.Null) continue; + if (string.Equals(prop.Name, p + "channlRoleType", StringComparison.Ordinal)) continue; + return true; + } + return false; + } + + // ------------------------------------------------------------ + // Plan building + // ------------------------------------------------------------ + + /// + /// Build the apply plan. retargets the + /// import onto a pedal the user picked in the wizard; when null the + /// subject role's own pedal is used (the first attached pedal whose + /// resolved role matches). A calibration-only preset ignores the override + /// and auto-matches each populated section to its own pedal. /// public static ImportPlan BuildPlan( PitHousePreset preset, - IReadOnlyList controllers) + IReadOnlyList? controllers, + MBoosterImportTarget? targetOverride = null) { var plan = new ImportPlan(); if (preset == null) { plan.FatalError = "internal: null preset"; return plan; } - controllers ??= Array.Empty(); var dp = preset.DeviceParams; + if (dp == null) { plan.FatalError = "preset has no deviceParams block"; return plan; } + + var targets = EnumerateTargets(controllers); + var subjects = DetectSubjectPrefixes(dp, out bool calibrationOnly); - // Map PitHouse role prefix to MBoosterRole. The trailing "" entry - // doubles as a label for the diff rows. - var roles = new (string Prefix, MBoosterRole Role, string Label)[] + plan.ConsideredKeys.Add("version"); + + if (subjects.Count == 0) { - ("throttle", MBoosterRole.Throttle, "Throttle"), - ("brake", MBoosterRole.Brake, "Brake"), - ("clutch", MBoosterRole.Clutch, "Clutch"), - }; + plan.SubjectRoleDisplay = "(none)"; + PitHouseMotorMapper.SweepUnhandled(plan, dp); + return plan; + } - foreach (var (prefix, role, label) in roles) + string subjectLabels = string.Join(" + ", + subjects.Select(p => Roles.First(r => r.Prefix == p).Label)); + + // Retargeting only makes sense when one section drives one pedal. + // A calibration-only preset (no subject at all) and the rare preset + // with extended blocks under two roles both auto-match each section + // to the pedal carrying that role instead. + bool autoMatchPerRole = calibrationOnly || subjects.Count > 1; + plan.AutoMatchedPerRole = autoMatchPerRole; + plan.SubjectRoleDisplay = calibrationOnly + ? "all pedals (per role)" + : subjectLabels + (autoMatchPerRole ? " (per role)" : ""); + + // Pair each subject section with the pedal it writes to. + var pairs = new List<(string Prefix, string RoleLabel, MBoosterImportTarget Target)>(); + foreach (var (prefix, role, roleLabel) in Roles) { - // _channlRoleType is the role marker — register it considered - // whether or not the section has calibration data. plan.ConsideredKeys.Add(prefix + "_channlRoleType"); - if (!IsRoleSectionPopulated(dp, prefix)) continue; - - var matching = controllers.Where(c => + if (!subjects.Contains(prefix)) { - var s = c.CurrentSettings; - return s != null && s.Role == role; - }).ToList(); + // Not this preset's subject — the section is the device-wide + // snapshot PitHouse writes into every preset. Importing it + // would rewrite a pedal the user isn't configuring. + if (HasAnyPopulatedKey(dp, prefix)) + { + MarkRoleSectionConsidered(plan, dp, prefix); + plan.NotImported.Add($"{prefix}_* (not this preset's role)"); + } + continue; + } + + var target = autoMatchPerRole + ? targets.FirstOrDefault(t => t.Role == role) + : targetOverride ?? targets.FirstOrDefault(t => t.Role == role); - if (matching.Count == 0) + if (target == null) { - plan.NotImported.Add($"{label}: no mBooster currently attached with this role"); - // Still mark this role's keys considered so the sweep - // doesn't double-list them per-key below. MarkRoleSectionConsidered(plan, dp, prefix); + plan.NotImported.Add(targets.Count == 0 + ? $"{roleLabel}: no mBooster detected" + : $"{roleLabel}: no pedal with this role — pick one above"); continue; } - foreach (var controller in matching) - AddPerControllerDiffs(plan, dp, prefix, label, controller); + pairs.Add((prefix, roleLabel, target)); + if (!autoMatchPerRole) plan.ResolvedTarget = target; } + foreach (var (prefix, _, target) in pairs) + AddSectionDiffs(plan, dp, prefix, target); + + // Un-prefixed device-wide duplicates of the per-pedal keys. PitHouse + // writes both; the per-pedal ones win because they say which pedal + // they belong to. + foreach (var key in DeviceWideDuplicates) + PitHouseMotorMapper.AddSkipped(plan, dp, key, "device-wide copy"); + foreach (var (key, reason) in UnsupportedGlobals) + PitHouseMotorMapper.AddSkipped(plan, dp, key, reason); + // Catch-all: every deviceParams key the mapper hasn't touched gets // surfaced in Not Imported with its value, so no PitHouse setting // silently disappears. Reuses the motor mapper's sweep helper. PitHouseMotorMapper.SweepUnhandled(plan, dp); + // One hardware push per touched device, after the whole plan is + // built — adding per-diff would re-push a device whose every row is + // a no-op. + if (plan.HasChanges) + foreach (var (_, _, target) in pairs) + plan.TouchedMBoosters.Add(target.Controller); + return plan; } + // Un-prefixed keys that repeat a per-pedal value. + private static readonly string[] DeviceWideDuplicates = + { + "machinelimit_min", "machinelimit_max", + "softlimit_hardness_press", "softlimit_hardness_release", + "damping_press", "damping_release", + "friction_press", "friction_release", + "forcelimit_min", + }; + + // Un-prefixed keys with no plugin surface at all. + private static readonly (string Key, string Reason)[] UnsupportedGlobals = + { + ("force_max_coef", "no wire command"), + ("pressure_weight", "no wire command"), + ("enter_sleep_time", "no wire command"), + ("game_mode", "PitHouse-only"), + }; + /// /// Mark every <prefix>_* key in as /// considered, so the catch-all sweep doesn't surface them individually - /// when an entire role section was skipped (e.g. no attached mBooster - /// with that role). + /// when an entire role section was skipped. /// private static void MarkRoleSectionConsidered(ImportPlan plan, JObject dp, string prefix) { @@ -98,170 +340,331 @@ private static void MarkRoleSectionConsidered(ImportPlan plan, JObject dp, strin plan.ConsideredKeys.Add(prop.Name); } - // ----- Helpers ----- + // ------------------------------------------------------------ + // Section mapping + // ------------------------------------------------------------ - private static bool IsRoleSectionPopulated(JObject dp, string prefix) + private static void AddSectionDiffs( + ImportPlan plan, JObject dp, string prefix, MBoosterImportTarget target) { - // Any of the core calibration fields present (and not null) → - // treat the section as real. - string[] keys = { - prefix + "_outdir", - prefix + "_min", - prefix + "_max", - prefix + "_nonlinear1", - prefix + "_nonlinear2", - prefix + "_nonlinear3", - prefix + "_nonlinear4", - prefix + "_nonlinear5", - }; - foreach (var k in keys) + var settings = target.Controller.CurrentSettings; + if (settings == null) return; + + var m = new SectionWriter(plan, dp, prefix, target, settings); + + // ----- Calibration (group 35/36 dir + output curve) ----- + m.Int("outdir", "Direction", 0, 1, + c => c.Direction, (c, v) => c.Direction = v, unsetBelowZero: true); + m.OutputCurve(); + + // min/max are deliberately NOT imported: PitHouse states them as + // percentages (0/3/16/99/100 across the sample presets) while + // MBoosterDeviceSettings.Min/Max are the device's own RAW counts — + // the "Min (raw)"/"Max (raw)" sliders run 0..65535 and are seeded + // from the device read-back. Writing 99 into a raw field caps the + // pedal at ~0.15 % of full scale. The scale factor between the two + // is unverified, so the values are surfaced instead of guessed. + m.SkipKey("min", "percent vs raw counts — unverified"); + m.SkipKey("max", "percent vs raw counts — unverified"); + + // ----- Vibration effects ----- + // PitHouse only writes ABS/Lockup/Threshold under the brake role and + // TC/WheelSpin/GearShift/RoadTexture under any role; absent keys are + // skipped, so no per-role gating is needed here. + m.Effect("ABS", c => c.Abs, "abs", "amp", + freqSuffix: "freq", MBoosterUiConstants.AbsFreqMinHz, MBoosterUiConstants.AbsFreqMaxHz, + smoothSuffix: "smoothness"); + m.Effect("Lockup", c => c.Lockup, "lockup", "amp", + freqSuffix: "freq", MBoosterUiConstants.LockupFreqMinHz, MBoosterUiConstants.LockupFreqMaxHz); + m.Effect("Threshold", c => c.Threshold, "brakethreshold", "amp", + freqSuffix: "freq", MBoosterUiConstants.ThresholdFreqMinHz, MBoosterUiConstants.ThresholdFreqMaxHz, + triggerSuffix: "trigger_input", decaySuffix: "fade_amount"); + m.Effect("Traction Control", c => c.TractionControl, "tc", "amp", + freqSuffix: "freq", MBoosterUiConstants.TractionControlFreqMinHz, MBoosterUiConstants.TractionControlFreqMaxHz); + m.Effect("Wheel Spin", c => c.WheelSpin, "wheel_slip", "amp", + freqSuffix: "freq", MBoosterUiConstants.WheelSpinFreqMinHz, MBoosterUiConstants.WheelSpinFreqMaxHz); + m.Effect("Gear Shift", c => c.GearShift, "gear_shift_vibration", "amp", + freqSuffix: "freq", MBoosterUiConstants.GearShiftFreqMinHz, MBoosterUiConstants.GearShiftFreqMaxHz); + m.Effect("Road Texture", c => c.RoadTexture, "road_texture", "intensity", + smoothSuffix: "smoothness"); + + // ----- Pedal Feel / Sim Input Mapping hardware calibration ----- + // Unit mapping inferred from value range, not from a wire capture — + // see docs/protocol/devices/mbooster.md. Rows carry a * so the + // confirm list shows which ones rest on that inference. + m.TravelRange(); + m.Float("softlimit_hardness_press", "Endstop front stiffness *", 1f, 10f, "F0", + c => c.EndstopFrontStiffness, (c, v) => c.EndstopFrontStiffness = v); + m.Float("softlimit_hardness_release", "Endstop end stiffness *", 1f, 10f, "F0", + c => c.EndstopEndStiffness, (c, v) => c.EndstopEndStiffness = v); + + if (target.Role == MBoosterRole.Brake) { - var t = dp[k]; - if (t != null && t.Type != JTokenType.Null) return true; + m.Float("press_combine", "Sensor output ratio (%) *", 0f, 100f, "F0", + c => c.SensorOutputRatioPct, (c, v) => c.SensorOutputRatioPct = v); + } + else + { + // mbooster-brake-angle-ratio is written only for the brake role + // (MozaPlugin.ApplyMBoosterToHardware), so importing it onto a + // throttle/clutch pedal would never reach the device. + PitHouseMotorMapper.AddSkipped(plan, dp, prefix + "_press_combine", "brake-only"); } - return false; - } - private static int? IntOrNull(JObject dp, string key) - { - var t = dp[key]; - if (t == null || t.Type == JTokenType.Null) return null; - try { return Convert.ToInt32((double)t); } catch { return null; } - } + // ----- Explicitly unsupported families in this section ----- + m.SkipFamily("damping", "no wire command"); + m.SkipFamily("friction", "no wire command"); + m.SkipFamily("forcelimit", "no wire command"); + m.SkipFamily("gforce", "not implemented"); + m.SkipFamily("motor_vibration", "PitHouse motor test"); + m.SkipKey("forces_curve", "no plugin field"); + m.SkipKey("stroke_curve", "no plugin field"); - private static bool? BoolOrNull(JObject dp, string key) - { - var t = dp[key]; - if (t == null || t.Type == JTokenType.Null) return null; - try { return (bool)t; } catch { return null; } + if (target.IsPassive) + plan.NotImported.Add($"{target.RowPrefix}: passive pedal — effects won't play"); } - private static void AddPerControllerDiffs( - ImportPlan plan, JObject dp, string prefix, string roleLabel, - MBoosterDeviceController controller) + /// + /// Per-section diff emitter. Reads "before" values from a non-creating + /// peek (so previewing an import never persists an empty per-pedal + /// entry) and defers the create-on-demand to each diff's apply closure. + /// + private sealed class SectionWriter { - var settings = controller.CurrentSettings; - if (settings == null) return; + private readonly ImportPlan _plan; + private readonly JObject _dp; + private readonly string _prefix; + private readonly string _rowPrefix; + private readonly IMBoosterPedalConfig _read; + private readonly MBoosterDeviceSettings _settings; + private readonly int _axis; + private readonly int _soleAxis; + + public SectionWriter(ImportPlan plan, JObject dp, string prefix, + MBoosterImportTarget target, MBoosterDeviceSettings settings) + { + _plan = plan; + _dp = dp; + _prefix = prefix; + _rowPrefix = target.RowPrefix; + _settings = settings; + _axis = target.AxisIndex; + _soleAxis = target.Controller.SoleConnectedAxis(); + // Defaults stand in when a chained pedal has no entry yet — + // that IS the config it currently runs with. + _read = MozaMBoosterRegistry.PeekPedalConfig(settings, _axis, _soleAxis) + ?? new MBoosterPedalSettings(); + } - // Device label combines role + user-facing display name so a user - // with two brake-role mBoosters can tell rows apart. - string deviceLabel = roleLabel; - if (!string.IsNullOrEmpty(settings.DisplayName)) - deviceLabel = roleLabel + " — " + settings.DisplayName; + private IMBoosterPedalConfig? Write() => + MozaMBoosterRegistry.GetOrCreatePedalConfig(_settings, _axis, _soleAxis); - // Direction - plan.ConsideredKeys.Add(prefix + "_outdir"); - var dir = IntOrNull(dp, prefix + "_outdir"); - if (dir.HasValue) + private string Key(string suffix) => _prefix + "_" + suffix; + + private JToken? Token(string suffix) { - int oldVal = settings.Direction; - plan.Diffs.Add(new FieldDiff( - deviceLabel + " · Direction", - oldVal < 0 ? "(unset)" : oldVal.ToString(), - dir.Value.ToString(), - () => settings.Direction = dir.Value)); - plan.TouchedMBoosters.Add(controller); + _plan.ConsideredKeys.Add(Key(suffix)); + var t = _dp[Key(suffix)]; + return t == null || t.Type == JTokenType.Null ? null : t; } - // Min - plan.ConsideredKeys.Add(prefix + "_min"); - var min = IntOrNull(dp, prefix + "_min"); - if (min.HasValue) + private double? Num(string suffix) { - int oldVal = settings.Min; - plan.Diffs.Add(new FieldDiff( - deviceLabel + " · Min", - oldVal < 0 ? "(unset)" : oldVal.ToString(), - min.Value.ToString(), - () => settings.Min = min.Value)); - plan.TouchedMBoosters.Add(controller); + var t = Token(suffix); + if (t == null) return null; + try { return (double)t; } catch { return null; } } - // Max - plan.ConsideredKeys.Add(prefix + "_max"); - var max = IntOrNull(dp, prefix + "_max"); - if (max.HasValue) + private bool? Bool(string suffix) { - int oldVal = settings.Max; - plan.Diffs.Add(new FieldDiff( - deviceLabel + " · Max", - oldVal < 0 ? "(unset)" : oldVal.ToString(), - max.Value.ToString(), - () => settings.Max = max.Value)); - plan.TouchedMBoosters.Add(controller); + var t = Token(suffix); + if (t == null) return null; + try { return (bool)t; } catch { return null; } } - // Curve Y1..Y5 — emit one combined diff that writes all five - var y = new float?[5]; - bool anyCurvePoint = false; - for (int i = 0; i < 5; i++) + private void Add(string label, string oldDisplay, string newDisplay, Action apply) { - string ck = prefix + "_nonlinear" + (i + 1); - plan.ConsideredKeys.Add(ck); - var v = IntOrNull(dp, ck); - if (v.HasValue) { y[i] = v.Value; anyCurvePoint = true; } + _plan.Diffs.Add(new FieldDiff($"{_rowPrefix} · {label}", oldDisplay, newDisplay, + () => { var cfg = Write(); if (cfg != null) apply(cfg); })); } - if (anyCurvePoint) + + // ----- scalar helpers ----- + + public void Int(string suffix, string label, int lo, int hi, + Func get, Action set, + bool unsetBelowZero = false) { - // Default missing points to 0 so the array is always length-5 - // (matches what ApplyMBoosterToHardware expects). - var newCurve = new float[5]; - for (int i = 0; i < 5; i++) newCurve[i] = y[i] ?? 0f; + var v = Num(suffix); + if (v == null) return; + int nv = (int)Math.Round(Clamp(v.Value, lo, hi)); + int ov = get(_read); + Add(label, + unsetBelowZero && ov < 0 ? "(unset)" : ov.ToString(CultureInfo.InvariantCulture), + nv.ToString(CultureInfo.InvariantCulture), + c => set(c, nv)); + } - var oldCurve = settings.CurveY; + public void Float(string suffix, string label, float lo, float hi, string fmt, + Func get, Action set) + { + var v = Num(suffix); + if (v == null) return; + float nv = (float)Clamp(v.Value, lo, hi); + float ov = get(_read); + Add(label, + ov < 0 ? "(unset)" : ov.ToString(fmt, CultureInfo.InvariantCulture), + nv.ToString(fmt, CultureInfo.InvariantCulture), + c => set(c, nv)); + } + + /// Output curve: nonlinear1..5 → CurveY, one combined row. + public void OutputCurve() + { + var y = new float[5]; + bool any = false; + for (int i = 0; i < 5; i++) + { + var v = Num("nonlinear" + (i + 1)); + if (v == null) continue; + y[i] = (float)Clamp(v.Value, 0, 100); + any = true; + } + if (!any) return; + + var oldCurve = _read.CurveY; string oldDisplay = oldCurve == null || oldCurve.Length < 5 ? "(unset)" - : string.Join("/", oldCurve.Take(5).Select(v => ((int)Math.Round(v)).ToString())); - string newDisplay = string.Join("/", newCurve.Select(v => ((int)Math.Round(v)).ToString())); - - plan.Diffs.Add(new FieldDiff( - deviceLabel + " · Curve (Y at 20/40/60/80/100%)", - oldDisplay, newDisplay, - () => settings.CurveY = newCurve)); - plan.TouchedMBoosters.Add(controller); + : string.Join("/", oldCurve.Take(5).Select(FormatCurvePoint)); + string newDisplay = string.Join("/", y.Select(FormatCurvePoint)); + + Add("Output curve (Y at 20/40/60/80/100%)", oldDisplay, newDisplay, + c => c.CurveY = (float[])y.Clone()); } - // Brake-only effects: ABS / Lockup / Threshold — only emit when - // the preset is for the brake role. - if (prefix == "brake") + private static string FormatCurvePoint(float v) => + ((int)Math.Round(v)).ToString(CultureInfo.InvariantCulture); + + /// + /// machinelimit_min/max → TravelStartMm/TravelEndMm as one row, so + /// the pair stays inside the range slider's own min/max gap. Unit + /// mapping (raw value = mm) is inferred, not captured. + /// + public void TravelRange() { - AddEffect(plan, dp, "brake_abs_switch", "brake_abs_amp", - deviceLabel + " · ABS", settings.Abs, controller); - AddEffect(plan, dp, "brake_lockup_switch", "brake_lockup_amp", - deviceLabel + " · Lockup", settings.Lockup, controller); - AddEffect(plan, dp, "brake_brakethreshold_switch", "brake_brakethreshold_amp", - deviceLabel + " · Threshold", settings.Threshold, controller); + var lo = Num("machinelimit_min"); + var hi = Num("machinelimit_max"); + if (lo == null && hi == null) return; + + // Half a pair is only usable when the other end already has a + // real value to pin against — otherwise the clamp below would + // invent one out of the -1 "unset" sentinel. + if (lo == null && _read.TravelStartMm < 0) return; + if (hi == null && _read.TravelEndMm < 0) return; + + float start = (float)Clamp(lo ?? _read.TravelStartMm, + MBoosterUiConstants.TravelMinMm, MBoosterUiConstants.TravelMaxMm); + float end = (float)Clamp(hi ?? _read.TravelEndMm, + MBoosterUiConstants.TravelMinMm, MBoosterUiConstants.TravelMaxMm); + + // Honour the slider's own gap constraints so an imported pair + // can never land somewhere the UI couldn't produce. + if (end - start < MBoosterUiConstants.TravelMinGapMm) + end = Math.Min(MBoosterUiConstants.TravelMaxMm, start + MBoosterUiConstants.TravelMinGapMm); + if (end - start > MBoosterUiConstants.TravelMaxGapMm) + end = start + MBoosterUiConstants.TravelMaxGapMm; + + string oldDisplay = _read.TravelStartMm < 0 || _read.TravelEndMm < 0 + ? "(unset)" + : $"{_read.TravelStartMm.ToString("F1", CultureInfo.InvariantCulture)}–{_read.TravelEndMm.ToString("F1", CultureInfo.InvariantCulture)} mm"; + string newDisplay = $"{start.ToString("F1", CultureInfo.InvariantCulture)}–{end.ToString("F1", CultureInfo.InvariantCulture)} mm"; + + float s = start, e = end; + Add("Travel start–end (mm) *", oldDisplay, newDisplay, + c => { c.TravelStartMm = s; c.TravelEndMm = e; }); } - } - private static void AddEffect( - ImportPlan plan, JObject dp, - string switchKey, string ampKey, - string label, MBoosterEffectSettings target, - MBoosterDeviceController touched) - { - plan.ConsideredKeys.Add(switchKey); - plan.ConsideredKeys.Add(ampKey); - var sw = BoolOrNull(dp, switchKey); - var amp = IntOrNull(dp, ampKey); - if (sw == null && amp == null) return; - if (target == null) return; - - int newAmp = amp.HasValue - ? Math.Max(0, Math.Min(100, amp.Value)) - : target.IntensityPct; - bool newEnabled = sw ?? target.Enabled; - - string oldDisplay = (target.Enabled ? "On" : "Off") + " @ " + target.IntensityPct + "%"; - string newDisplay = (newEnabled ? "On" : "Off") + " @ " + newAmp + "%"; - - plan.Diffs.Add(new FieldDiff(label, oldDisplay, newDisplay, - () => + /// + /// One row per vibration effect covering enable + intensity and + /// whichever of frequency / smoothness / trigger / decay PitHouse + /// carries for it. Absent sub-keys keep the current value. + /// + public void Effect( + string label, + Func pick, + string effectKey, + string ampSuffix, + string? freqSuffix = null, float freqLo = 0f, float freqHi = 0f, + string? smoothSuffix = null, + string? triggerSuffix = null, + string? decaySuffix = null) + { + var current = pick(_read); + if (current == null) return; + + var sw = Bool(effectKey + "_switch"); + var amp = Num(effectKey + "_" + ampSuffix); + var freq = freqSuffix == null ? null : Num(effectKey + "_" + freqSuffix); + var smooth = smoothSuffix == null ? null : Num(effectKey + "_" + smoothSuffix); + var trigger = triggerSuffix == null ? null : Num(effectKey + "_" + triggerSuffix); + var decay = decaySuffix == null ? null : Num(effectKey + "_" + decaySuffix); + + if (sw == null && amp == null && freq == null && smooth == null && trigger == null && decay == null) + return; + + bool newEnabled = sw ?? current.Enabled; + int newAmp = amp.HasValue ? (int)Math.Round(Clamp(amp.Value, 0, 100)) : current.IntensityPct; + float newFreq = freq.HasValue ? (float)Clamp(freq.Value, freqLo, freqHi) : current.FrequencyHz; + int newSmooth = smooth.HasValue ? (int)Math.Round(Clamp(smooth.Value, 0, 100)) : current.SmoothnessPct; + int newTrigger = trigger.HasValue + ? (int)Math.Round(Clamp(trigger.Value, + MBoosterUiConstants.ThresholdTriggerMinPct, MBoosterUiConstants.ThresholdTriggerMaxPct)) + : current.TriggerLevelPct; + int newDecay = decay.HasValue ? (int)Math.Round(Clamp(decay.Value, 0, 100)) : current.DecayPct; + + string Describe(bool en, int a, float f, int sm, int tr, int dc) + { + var parts = new List { en ? "On" : "Off", a.ToString(CultureInfo.InvariantCulture) + "%" }; + if (freqSuffix != null) parts.Add(f.ToString("F0", CultureInfo.InvariantCulture) + "Hz"); + if (smoothSuffix != null) parts.Add("smooth " + sm.ToString(CultureInfo.InvariantCulture)); + if (triggerSuffix != null) parts.Add("trigger " + tr.ToString(CultureInfo.InvariantCulture) + "%"); + if (decaySuffix != null) parts.Add("decay " + dc.ToString(CultureInfo.InvariantCulture)); + return string.Join(" · ", parts); + } + + string oldDisplay = Describe(current.Enabled, current.IntensityPct, current.FrequencyHz, + current.SmoothnessPct, current.TriggerLevelPct, current.DecayPct); + string newDisplay = Describe(newEnabled, newAmp, newFreq, newSmooth, newTrigger, newDecay); + + Add(label, oldDisplay, newDisplay, c => { - target.Enabled = newEnabled; - target.IntensityPct = newAmp; - })); - plan.TouchedMBoosters.Add(touched); + var t = pick(c); + if (t == null) return; + t.Enabled = newEnabled; + t.IntensityPct = newAmp; + if (freqSuffix != null) t.FrequencyHz = newFreq; + if (smoothSuffix != null) t.SmoothnessPct = newSmooth; + if (triggerSuffix != null) t.TriggerLevelPct = newTrigger; + if (decaySuffix != null) t.DecayPct = newDecay; + }); + } + + /// Note every <prefix>_<family>* key as skipped, with a reason. + public void SkipFamily(string family, string reason) + { + string p = _prefix + "_" + family; + var keys = _dp.Properties() + .Where(x => x.Name.StartsWith(p, StringComparison.Ordinal)) + .Select(x => x.Name) + .ToList(); + foreach (var k in keys) + PitHouseMotorMapper.AddSkipped(_plan, _dp, k, reason); + } + + public void SkipKey(string suffix, string reason) => + PitHouseMotorMapper.AddSkipped(_plan, _dp, Key(suffix), reason); + + private static double Clamp(double v, double lo, double hi) => + v < lo ? lo : (v > hi ? hi : v); } } } diff --git a/UI/SettingsControl.xaml b/UI/SettingsControl.xaml index 9c54414f..f14f99fd 100644 --- a/UI/SettingsControl.xaml +++ b/UI/SettingsControl.xaml @@ -3,6 +3,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:profilescommon="clr-namespace:SimHub.Plugins.ProfilesCommon;assembly=SimHub.Plugins" xmlns:ctrl="clr-namespace:MozaControls" + xmlns:devices="clr-namespace:MozaPlugin.Devices" xmlns:ui="clr-namespace:MozaPlugin.UI" xmlns:res="clr-namespace:MozaPlugin.Resources" Padding="0" @@ -1920,6 +1921,19 @@ KeyDown="SliderValueBox_KeyDown" LostFocus="SliderValueBox_LostFocus" Text="200"/> + + + + + + + + + + @@ -2019,6 +2033,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UI/SettingsControl.xaml.cs b/UI/SettingsControl.xaml.cs index 646d6f5d..b2853708 100644 --- a/UI/SettingsControl.xaml.cs +++ b/UI/SettingsControl.xaml.cs @@ -187,6 +187,8 @@ private void OnUnloadedStopTimers(object sender, RoutedEventArgs e) CurrentMBoosterController()?.SetThresholdTestActive(false, _mboosterEffectPedalIndex); if (MBoosterBrakeFadeTestToggle?.IsChecked == true) CurrentMBoosterController()?.SetBrakeFadeTestActive(false); + if (MBoosterGForceTestToggle?.IsChecked == true) + CurrentMBoosterController()?.SetGForceTestActive(false, _mboosterEffectPedalIndex); StopAllCustomEffectTests(); // SDK CoAP server fires RecentRequestAppended on its receive // thread; unsubscribe so a torn-down SettingsControl can be GC'd @@ -1715,12 +1717,9 @@ private static string ExtractNumericPrefix(string raw) // ===== FFB Equalizer handlers ===== - private static readonly string[] EqCommands = { - "base-equalizer1", "base-equalizer2", "base-equalizer3", - "base-equalizer4", "base-equalizer5", "base-equalizer6", - "base-equalizer7", "base-equalizer8", "base-equalizer9", - "base-equalizer10" - }; + // EQ write commands in register order. Shared with the AZOM step + // actions so the button macros and the bindings drive identical values. + private static readonly string[] EqCommands = BaseSettingCatalog.EqRegisterCommands; private void Eq1Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, Eq1Value, "%", v => { _data.Equalizer1 = v; _plugin.WriteIfBaseConnected(EqCommands[0], v); }); private void Eq2Slider_ValueChanged(object s, RoutedPropertyChangedEventArgs e) => OnIntSliderChanged(e.NewValue, Eq2Value, "%", v => { _data.Equalizer2 = v; _plugin.WriteIfBaseConnected(EqCommands[1], v); }); @@ -1736,12 +1735,7 @@ private static string ExtractNumericPrefix(string raw) // 10-band mappings in FREQUENCY order (5/10/15/25/30/40/50/60/80/100 Hz) // — the new registers interleave. Keep in sync with the FfbEqualizer10 // slider binding in SettingsControl.Redesign.cs. - private static readonly string[] Eq10Commands = { - "base-equalizer1", "base-equalizer7", "base-equalizer2", - "base-equalizer3", "base-equalizer8", "base-equalizer4", - "base-equalizer9", "base-equalizer5", "base-equalizer10", - "base-equalizer6" - }; + private static readonly string[] Eq10Commands = BaseSettingCatalog.Eq10FreqOrderCommands; private Slider[] Eq10Sliders() => new[] { Eq1Slider, Eq7Slider, Eq2Slider, Eq3Slider, Eq8Slider, Eq4Slider, Eq9Slider, Eq5Slider, Eq10Slider, Eq6Slider }; @@ -1816,24 +1810,11 @@ private void ApplyFfbEqPreset10(int[] p) // Values in frequency order 5/10/15/25/30/40/50/60/80/100 Hz. On // legacy firmware only the six old registers are written (columns // via Eq6FreqColumns) — the four new bands are skipped. - private static readonly int[][] EqSensitivityPresets = - { - new[] { 100, 100, 30, 10, 0, 0, 0, 0, 0, 0 }, - new[] { 100, 100, 60, 20, 10, 0, 0, 0, 0, 0 }, - new[] { 100, 100, 70, 40, 30, 10, 0, 0, 0, 0 }, - new[] { 100, 100, 80, 50, 40, 20, 10, 10, 0, 0 }, - new[] { 100, 100, 90, 60, 50, 30, 20, 20, 10, 0 }, - new[] { 100, 100, 100, 70, 60, 40, 30, 30, 10, 0 }, - new[] { 100, 100, 100, 90, 80, 50, 40, 40, 20, 0 }, - new[] { 100, 100, 100, 100, 90, 60, 60, 60, 40, 0 }, - new[] { 100, 100, 100, 100, 90, 80, 80, 80, 60, 0 }, - new[] { 100, 100, 100, 100, 100, 100, 100, 100, 80, 0 }, - new[] { 100, 100, 100, 100, 100, 100, 100, 100, 100, 100 }, - }; + private static readonly int[][] EqSensitivityPresets = BaseSettingCatalog.EqSensitivityPresets; // Frequency-order columns carried by the legacy registers Eq1..Eq6 // (5/15/25/40/60/100 Hz). - private static readonly int[] Eq6FreqColumns = { 0, 2, 3, 5, 7, 9 }; + private static readonly int[] Eq6FreqColumns = BaseSettingCatalog.Eq6FreqColumns; private void EqSensitivity_Click(object sender, RoutedEventArgs e) { @@ -2860,27 +2841,8 @@ private void RefreshMBoosterTab() { var rowSettings = _plugin.GetOrCreateMBoosterSettings(c.Identity); int axisCount = c.AxisCount > 0 ? c.AxisCount : 1; - var connected = c.ConnectedAxes; string deviceLabel = BuildMBoosterComboLabel(c); - - // Which axes are ACTUALLY wired. The HID interface - // commonly reports 3 axes (Rx/Ry/Rz) regardless of how - // many pedals are physically connected — ConnectedAxes - // (from the "PD Linked" firmware diagnostic) is the - // only way to tell which are real. Until that - // diagnostic arrives (null), assume only axis 0 is - // real: the common case is a standalone single pedal, - // and a genuine chain's extra axes appear as soon as - // the diagnostic confirms them, instead of showing - // phantom pedals from the very first refresh. - var connectedAxes = new List(); - for (int axis = 0; axis < axisCount && axis < MBoosterDeviceController.MaxAxes; axis++) - { - bool axisKnownConnected = connected != null && axis < connected.Length - ? connected[axis] - : axis == 0; - if (axisKnownConnected) connectedAxes.Add(axis); - } + var connectedAxes = c.ConnectedAxisIndices(); // Only label rows "— Pedal N" when this device genuinely // hosts more than one wired pedal — not just because its @@ -2893,7 +2855,12 @@ private void RefreshMBoosterTab() string label = multiplePedals ? $"{deviceLabel} — {string.Format(Strings.Label_PedalAxis, shown)}" : deviceLabel; - var role = global::MozaPlugin.Devices.MozaMBoosterRegistry.ResolveAxisRole(rowSettings, axis, axisCount); + // Resolve against the CONNECTED axis count, not the + // raw HID axis count — a chain-capable hub exposes + // all 3 GenericDesktop axes even with only one pedal + // plugged in, so raw axisCount would silently override + // that pedal's own Role with the axis-order default. + var role = global::MozaPlugin.Devices.MozaMBoosterRegistry.ResolveAxisRole(rowSettings, axis, connectedAxes.Count); bool isSelected = string.Equals(c.Identity, _mboosterSelectedIdentity, StringComparison.OrdinalIgnoreCase) && axis == _mboosterEffectPedalIndex; _mboosterDeviceRows.Add(new MBoosterDeviceRow(c.Identity, axis, label, isSelected, role, @@ -2909,7 +2876,12 @@ private void RefreshMBoosterTab() var rowController = registry.FindByIdentity(row.Identity); var rowSettings = _plugin.GetOrCreateMBoosterSettings(row.Identity); int axisCount = rowController != null && rowController.AxisCount > 0 ? rowController.AxisCount : 1; - row.RoleIndex = (int)global::MozaPlugin.Devices.MozaMBoosterRegistry.ResolveAxisRole(rowSettings, row.AxisIndex, axisCount); + if (axisCount > MBoosterDeviceController.MaxAxes) axisCount = MBoosterDeviceController.MaxAxes; + int connectedAxisCount = 0; + if (rowController != null) + for (int axis = 0; axis < axisCount; axis++) + if (rowController.IsAxisConnected(axis)) connectedAxisCount++; + row.RoleIndex = (int)global::MozaPlugin.Devices.MozaMBoosterRegistry.ResolveAxisRole(rowSettings, row.AxisIndex, connectedAxisCount); row.IsSelected = string.Equals(row.Identity, _mboosterSelectedIdentity, StringComparison.OrdinalIgnoreCase) && row.AxisIndex == _mboosterEffectPedalIndex; // DisplayName is per-profile like every other mBooster @@ -3021,6 +2993,8 @@ private void OnMBoosterDeviceRowSelected(string identity, int axisIndex) CurrentMBoosterController()?.SetThresholdTestActive(false, _mboosterEffectPedalIndex); if (MBoosterBrakeFadeTestToggle.IsChecked == true) CurrentMBoosterController()?.SetBrakeFadeTestActive(false); + if (MBoosterGForceTestToggle.IsChecked == true) + CurrentMBoosterController()?.SetGForceTestActive(false, _mboosterEffectPedalIndex); StopAllCustomEffectTests(); _mboosterSelectedIdentity = identity; _mboosterEffectPedalIndex = axisIndex; @@ -3149,23 +3123,11 @@ private void OnMBoosterDeviceRowDisplayNameChanged(string identity, string newDi /// and creating an empty entry here would orphan it — see /// MBoosterDeviceController.SoleConnectedAxis. Null if no device /// selected. Covers effects + calibration + sim input + pedal feel. - private IMBoosterPedalConfig? CurrentMBoosterEffectTarget() - { - var s = CurrentMBoosterSettings(); - if (s == null) return null; - if (_mboosterEffectPedalIndex <= 0) return s; - if (!s.Pedals.TryGetValue(_mboosterEffectPedalIndex, out var p)) - { - if (CurrentMBoosterController()?.SoleConnectedAxis() == _mboosterEffectPedalIndex) - return s; - // Copy-on-write: publish a NEW dictionary via atomic reference - // swap rather than mutating in place, so the 50 Hz effect worker - // threads reading s.Pedals never see a dictionary mid-resize. - p = new MBoosterPedalSettings(); - s.Pedals = new Dictionary(s.Pedals) { [_mboosterEffectPedalIndex] = p }; - } - return p; - } + private IMBoosterPedalConfig? CurrentMBoosterEffectTarget() => + MozaMBoosterRegistry.GetOrCreatePedalConfig( + CurrentMBoosterSettings(), + _mboosterEffectPedalIndex, + CurrentMBoosterController()?.SoleConnectedAxis() ?? -1); /// The per-pedal config for the selected pedal WITHOUT creating a /// missing entry — used when seeding controls so merely viewing a chained @@ -3173,16 +3135,11 @@ private void OnMBoosterDeviceRowDisplayNameChanged(string identity, string newDi /// Same sole-connected-pedal flat-fields fallback as /// so seeding shows the config /// that pedal actually runs with. - private IMBoosterPedalConfig? PeekMBoosterEffectTarget() - { - var s = CurrentMBoosterSettings(); - if (s == null) return null; - if (_mboosterEffectPedalIndex <= 0) return s; - if (s.Pedals.TryGetValue(_mboosterEffectPedalIndex, out var p)) return p; - if (CurrentMBoosterController()?.SoleConnectedAxis() == _mboosterEffectPedalIndex) - return s; - return null; - } + private IMBoosterPedalConfig? PeekMBoosterEffectTarget() => + MozaMBoosterRegistry.PeekPedalConfig( + CurrentMBoosterSettings(), + _mboosterEffectPedalIndex, + CurrentMBoosterController()?.SoleConnectedAxis() ?? -1); /// Seed the eight vibration-effect cards' controls from one /// pedal's effect settings. Assumes the event suppressor is active. Brake @@ -3246,6 +3203,12 @@ private void SeedMBoosterEffectControls(IMBoosterEffects? fx) MBoosterRoadTextureSmoothness.Value = fx?.RoadTexture?.SmoothnessPct ?? 50; SetValueText(MBoosterRoadTextureSmoothnessValue, (fx?.RoadTexture?.SmoothnessPct ?? 50).ToString()); MBoosterRoadTextureTestToggle.IsChecked = false; + MBoosterGForceEnable.IsChecked = fx?.GForce?.Enabled ?? false; + MBoosterGForceMaxTravel.Value = fx?.GForce?.MaxTravelMm ?? 10; + SetValueText(MBoosterGForceMaxTravelValue, MBoosterGForceMaxTravel.Value.ToString("0.#")); + MBoosterGForceResponseSpeed.Value = fx?.GForce?.ResponseSpeedPct ?? 50; + SetValueText(MBoosterGForceResponseSpeedValue, (fx?.GForce?.ResponseSpeedPct ?? 50).ToString()); + MBoosterGForceTestToggle.IsChecked = false; } /// Seed the Calibration, Sim Input Mapping and Pedal Feel controls @@ -3301,6 +3264,22 @@ private void SeedMBoosterConfigControls(IMBoosterPedalConfig? fx) SetValueText(MBoosterDeadzoneValue, (fx?.DeadzoneKg ?? 0).ToString("F1")); MBoosterMaxForceSlider.Value = fx?.MaxForceKg ?? 200; SetValueText(MBoosterMaxForceValue, (fx?.MaxForceKg ?? 200).ToString("F0")); + float nf = fx?.NaturalFrictionPct ?? -1; + MBoosterNaturalFrictionSlider.Value = nf >= 0 ? nf : 0; + SetValueText(MBoosterNaturalFrictionValue, MBoosterNaturalFrictionSlider.Value.ToString("F0")); + + var sd = fx?.SegmentedDamping; + MBoosterSegDampPressedPlot.Divider1 = (sd?.Divider1Pressed ?? -1) >= 0 ? sd!.Divider1Pressed : MBoosterUiConstants.SegDampDivider1PressedDefaultPct; + MBoosterSegDampPressedPlot.Divider2 = (sd?.Divider2Pressed ?? -1) >= 0 ? sd!.Divider2Pressed : MBoosterUiConstants.SegDampDivider2PressedDefaultPct; + MBoosterSegDampPressedPlot.Seg1Value = (sd?.Seg1Pressed ?? -1) >= 0 ? sd!.Seg1Pressed : MBoosterUiConstants.SegDampSegDefaultPct; + MBoosterSegDampPressedPlot.Seg2Value = (sd?.Seg2Pressed ?? -1) >= 0 ? sd!.Seg2Pressed : MBoosterUiConstants.SegDampSegDefaultPct; + MBoosterSegDampPressedPlot.Seg3Value = (sd?.Seg3Pressed ?? -1) >= 0 ? sd!.Seg3Pressed : MBoosterUiConstants.SegDampSegDefaultPct; + + MBoosterSegDampReleasedPlot.Divider1 = (sd?.Divider1Released ?? -1) >= 0 ? sd!.Divider1Released : MBoosterUiConstants.SegDampDivider1ReleasedDefaultPct; + MBoosterSegDampReleasedPlot.Divider2 = (sd?.Divider2Released ?? -1) >= 0 ? sd!.Divider2Released : MBoosterUiConstants.SegDampDivider2ReleasedDefaultPct; + MBoosterSegDampReleasedPlot.Seg1Value = (sd?.Seg1Released ?? -1) >= 0 ? sd!.Seg1Released : MBoosterUiConstants.SegDampSegDefaultPct; + MBoosterSegDampReleasedPlot.Seg2Value = (sd?.Seg2Released ?? -1) >= 0 ? sd!.Seg2Released : MBoosterUiConstants.SegDampSegDefaultPct; + MBoosterSegDampReleasedPlot.Seg3Value = (sd?.Seg3Released ?? -1) >= 0 ? sd!.Seg3Released : MBoosterUiConstants.SegDampSegDefaultPct; } private MBoosterDeviceController? CurrentMBoosterController() @@ -3994,6 +3973,50 @@ private void MBoosterBrakeFadeTestToggle_Changed(object sender, RoutedEventArgs CurrentMBoosterController()?.SetBrakeFadeTestActive(MBoosterBrakeFadeTestToggle.IsChecked == true); } + private void MBoosterGForceEnable_Changed(object sender, RoutedEventArgs e) + { + if (_suppressEvents) return; + var s = CurrentMBoosterEffectTarget(); + if (s == null) return; + (s.GForce ??= new MBoosterEffectSettings()).Enabled = MBoosterGForceEnable.IsChecked == true; + _plugin.SaveSettings(); + } + // 0-15mm, half-mm steps (matches Pit House's own "Max Pedal + // Travelment" slider) — see MBoosterEffectSettings.MaxTravelMm. + private void MBoosterGForceMaxTravel_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (_suppressEvents) return; + double v = Math.Round(e.NewValue * 2) / 2.0; + v = Math.Max(MBoosterUiConstants.GForceMaxTravelMinMm, Math.Min(MBoosterUiConstants.GForceMaxTravelMaxMm, v)); + MBoosterGForceMaxTravelValue.Text = v.ToString("0.#"); + var s = CurrentMBoosterEffectTarget(); + if (s == null) return; + (s.GForce ??= new MBoosterEffectSettings()).MaxTravelMm = (float)v; + _plugin.SaveSettings(); + } + // 0-100% — sent to the firmware unshaped every frame (it does the + // actual ramping, not the plugin) — see + // MBoosterEffectSettings.ResponseSpeedPct. + private void MBoosterGForceResponseSpeed_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (_suppressEvents) return; + int v = Math.Max(0, Math.Min(100, (int)Math.Round(e.NewValue))); + MBoosterGForceResponseSpeedValue.Text = v.ToString(); + var s = CurrentMBoosterEffectTarget(); + if (s == null) return; + (s.GForce ??= new MBoosterEffectSettings()).ResponseSpeedPct = v; + _plugin.SaveSettings(); + } + // Sustained test toggle — alternates the commanded travel offset + // forward/backward, mirroring Pit House's own "Test" demo (bypasses + // Enabled and the game-running gate). See + // MBoosterDeviceController.SetGForceTestActive. + private void MBoosterGForceTestToggle_Changed(object sender, RoutedEventArgs e) + { + if (_suppressEvents) return; + CurrentMBoosterController()?.SetGForceTestActive(MBoosterGForceTestToggle.IsChecked == true, _mboosterEffectPedalIndex); + } + // ===== Calibration (experimental) =================================== private void MBoosterDirCheck_Changed(object sender, RoutedEventArgs e) @@ -4343,6 +4366,111 @@ private void MBoosterEndstopEndSlider_ValueChanged(object sender, RoutedProperty controller?.PushCurve7Resync(s.CurveX, s.CurveY, dev); }); + // Natural Friction (0-100%) — simulates a frictional force + // independent of game output. Reverse-engineered from two real Pit + // House USB captures (a toggle on/off, and a 0/25/50/75/100% slider + // sweep — see docs/protocol/devices/mbooster.md "Pedal Feel"): wire + // cmdId 0xAE, sharing the same "prefix bytes + selector" shape as + // End Stop Stiffness (0xB2). Every capture write sent BOTH + // selectors with the IDENTICAL value in the same burst, so this + // control always writes mbooster-brake-friction-0 and -1 together + // rather than exposing them as separate sliders. There is no + // separate wire enable bit — the capture's toggle-off write simply + // sent raw 0 (confirmed via the firmware's own debug log echoing + // it as fixed-point 0.0). + private void MBoosterNaturalFrictionSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) => + OnIntSliderChanged(e.NewValue, MBoosterNaturalFrictionValue, "", v => + { + var s = CurrentMBoosterEffectTarget(); + if (s == null) return; + s.NaturalFrictionPct = v; + var controller = CurrentMBoosterController(); + byte dev = MBoosterCalibDevice(controller, _mboosterEffectPedalIndex); + int raw = global::MozaPlugin.Protocol.MozaMBoosterProtocol.EncodeFrictionPct(v); + controller?.SendIntWrite("mbooster-brake-friction-0", raw, dev); + controller?.SendIntWrite("mbooster-brake-friction-1", raw, dev); + // EXPERIMENTAL / unverified — see MBoosterTravelRangeSlider_RangeChanged + // and MBoosterDeviceController.PushCurve7Resync; untested for this + // control specifically, applied on the same-root-cause theory. + controller?.PushCurve7Resync(s.CurveX, s.CurveY, dev); + }); + + // Segmented Damping — "When Pressed". Reverse-engineered from real + // Pit House USB captures (see docs/protocol/devices/mbooster.md + // "Segmented Damping"): a SINGLE wire command (cmdId 0xB7) carries + // the entire feature's state — both "When Pressed" and "When + // Released" — as one 10-field snapshot, so every edit here must + // resend all 10 fields, not just the ones this plot owns. The + // "*Released" fields have no UI yet; they're sent using Pit + // House's own factory defaults (or whatever was last saved) until + // "When Released" gets its own plot. + private void MBoosterSegDampPressedPlot_ValuesChanged(object sender, EventArgs e) + { + if (_suppressEvents) return; + var s = CurrentMBoosterEffectTarget(); + if (s == null) return; + var sd = s.SegmentedDamping ??= new MBoosterSegmentedDampingSettings(); + sd.Divider1Pressed = (float)MBoosterSegDampPressedPlot.Divider1; + sd.Divider2Pressed = (float)MBoosterSegDampPressedPlot.Divider2; + sd.Seg1Pressed = (float)MBoosterSegDampPressedPlot.Seg1Value; + sd.Seg2Pressed = (float)MBoosterSegDampPressedPlot.Seg2Value; + sd.Seg3Pressed = (float)MBoosterSegDampPressedPlot.Seg3Value; + PushSegmentedDamping(s, sd); + } + + // Segmented Damping — "When Released". Same shared wire command as + // "When Pressed" (see that handler and docs/protocol/devices/ + // mbooster.md "Segmented Damping") — every edit here ALSO resends + // the current Pressed fields alongside the updated Released ones, + // since the frame is always a whole-feature snapshot. + private void MBoosterSegDampReleasedPlot_ValuesChanged(object sender, EventArgs e) + { + if (_suppressEvents) return; + var s = CurrentMBoosterEffectTarget(); + if (s == null) return; + var sd = s.SegmentedDamping ??= new MBoosterSegmentedDampingSettings(); + sd.Divider1Released = (float)MBoosterSegDampReleasedPlot.Divider1; + sd.Divider2Released = (float)MBoosterSegDampReleasedPlot.Divider2; + sd.Seg1Released = (float)MBoosterSegDampReleasedPlot.Seg1Value; + sd.Seg2Released = (float)MBoosterSegDampReleasedPlot.Seg2Value; + sd.Seg3Released = (float)MBoosterSegDampReleasedPlot.Seg3Value; + PushSegmentedDamping(s, sd); + } + + /// + /// Save + send the ONE Segmented Damping wire frame (cmdId 0xB7) + /// covering both "When Pressed" and "When Released" — shared by + /// both plots' change handlers since either one touching its own + /// half still has to resend the other half's current values (the + /// wire command has no partial-update form). Not-yet-set fields + /// (-1 sentinel) fall back to Pit House's own factory defaults, same + /// as does on connect. + /// + private void PushSegmentedDamping(IMBoosterPedalConfig s, MBoosterSegmentedDampingSettings sd) + { + _plugin.SaveSettings(); + + var controller = CurrentMBoosterController(); + byte dev = MBoosterCalibDevice(controller, _mboosterEffectPedalIndex); + var frame = global::MozaPlugin.Protocol.MozaMBoosterProtocol.BuildSegmentedDampingFrame( + sd.Divider1Pressed >= 0 ? sd.Divider1Pressed : MBoosterUiConstants.SegDampDivider1PressedDefaultPct, + sd.Divider2Pressed >= 0 ? sd.Divider2Pressed : MBoosterUiConstants.SegDampDivider2PressedDefaultPct, + sd.Divider1Released >= 0 ? sd.Divider1Released : MBoosterUiConstants.SegDampDivider1ReleasedDefaultPct, + sd.Divider2Released >= 0 ? sd.Divider2Released : MBoosterUiConstants.SegDampDivider2ReleasedDefaultPct, + sd.Seg1Pressed >= 0 ? sd.Seg1Pressed : MBoosterUiConstants.SegDampSegDefaultPct, + sd.Seg1Released >= 0 ? sd.Seg1Released : MBoosterUiConstants.SegDampSegDefaultPct, + sd.Seg2Pressed >= 0 ? sd.Seg2Pressed : MBoosterUiConstants.SegDampSegDefaultPct, + sd.Seg2Released >= 0 ? sd.Seg2Released : MBoosterUiConstants.SegDampSegDefaultPct, + sd.Seg3Pressed >= 0 ? sd.Seg3Pressed : MBoosterUiConstants.SegDampSegDefaultPct, + sd.Seg3Released >= 0 ? sd.Seg3Released : MBoosterUiConstants.SegDampSegDefaultPct, + dev); + controller?.SendOneShot(frame); + // EXPERIMENTAL / unverified — see MBoosterTravelRangeSlider_RangeChanged + // and MBoosterDeviceController.PushCurve7Resync; untested for this + // control specifically, applied on the same-root-cause theory. + controller?.PushCurve7Resync(s.CurveX, s.CurveY, dev); + } + private void MBoosterReadCalButton_Click(object sender, RoutedEventArgs e) { CurrentMBoosterController()?.RequestCalibrationReads(); diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 45614aed..43671b4b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -96,6 +96,7 @@ Notes: | `MozaData.cs` | Thread-safe data model (~80 volatile fields) for every device value + HID input positions; `UpdateFromCommand`/`UpdateFromArray` map parsed responses to fields | | `MozaDeviceManager.cs` | High-level read/write API per connection: wheel ID cycling (23→21→19), paced read batches, presence probes | | `SimHubRegistrar.cs` | SimHub `AZOM.*` property delegates + button-bindable actions (step/cycle/toggle) | +| `BaseSettingCatalog.cs` | Declarative table of every wheelbase setting (command, `MozaData` accessors, display range, scale, step sizes) that `SimHubRegistrar` expands into `AZOM.*` properties and step/toggle actions; also the shared road-sensitivity/EQ preset data | | `Protocol/` | Serial transport: `MozaSerialConnection` (threads, framing, 0x7E stuffing, write lanes), `MozaPortDiscovery` (registry walk), `MozaUsbIds` (PID inventory), `MozaCommandDatabase` (200+ commands), `MozaResponseParser`, `MozaProtocol` (constants/checksums), `MozaHidReader`, `PendingResponseTracker`, `WriteBudget`, `ConnectionFailure`, `SessionPropertyPushBuilder`/`FfRecordReader` (FF-record write/read) | | `Devices/` | Device detection + per-device managers and SimHub device extensions: `DeviceProber`, `DeviceDetectionState`, `ConnectionCoordinator`, `WheelModelInfo`, `MozaDeviceConstants`, AB9 / Hub / Dashboard / Base / mBooster / standalone-peripheral managers, wheel/dash/base extensions + LED managers + device settings controls, `DeviceDefinitionDeployer`, `WheelUi/` helpers | | `Telemetry/` | Dashboard telemetry pipeline: `TelemetrySender` (orchestrator) + collaborators in subdirectories (see [Architecture](#architecture)); FSR1/CM1 display drivers; `DashboardBindingCoordinator`, `DualDisplayCoordinator`, `Fsr1Cm1MappingCoordinator`, `SimHubPropertyResolver`, `ChannelCatalogParser`, `ConfigJsonClient`, `PropertyPushQueue` | @@ -225,7 +226,7 @@ On host **sleep/resume** the wheel firmware power-cycles and silently tears down - `OnMessageReceived` (serial read thread): captures firmware-debug 0x0E lines (also wheel-alive evidence + FSR1/CM1 page-report + rim attach/detach parsing), filters session/control frames the telemetry dispatcher owns, routes presence-probe ACKs to `OnPresenceProbeAck`, then parses via `MozaResponseParser` → `MozaData` → `DeviceProber.DetectDevices`. - `PollStatus` (5 s): hub/base-aux polls, dual-display ticks, wheel hot-swap miss counter + PitHouse-parity wheel maintenance (presence probe, param poll, 0x43 keepalive, model recheck), presence probes for undetected devices, display re-probe + 60 s display-wedge watchdog (one-shot forced reconnect), knob-ring capability read, hub port-power polls. - `CheckGearshiftEvent`/`CheckAb9GearshiftEvent` (per `DataUpdate`): debounced gearshift vibration triggers; neutral transitions suppressed by default. -- Button-bindable actions + `AZOM.*` properties live in `SimHubRegistrar.cs`; the user-facing action list is in [README.md § SimHub Actions](../README.md#simhub-actions). +- Button-bindable actions + `AZOM.*` properties live in `SimHubRegistrar.cs`, most of them generated from the `BaseSettingCatalog.cs` table (one row per wheelbase setting → one property + four step actions, or three toggle actions); the user-facing action list is in [README.md § SimHub Actions](../README.md#simhub-actions). Adding a wheelbase setting to the SimHub surface is a one-row change — the row's display range and scale **must** match the corresponding Base-tab slider handler in `UI/SettingsControl.xaml.cs`, since both write the same parameter-store slot. ### Serial protocol layer (`Protocol/`) diff --git a/docs/protocol/devices/mbooster.md b/docs/protocol/devices/mbooster.md index 3ca9c907..548447f5 100644 --- a/docs/protocol/devices/mbooster.md +++ b/docs/protocol/devices/mbooster.md @@ -928,6 +928,106 @@ which is why `EncodeEndstopStiffness` explicitly uses that every other `Encode*` helper here implicitly relies on. Same `-1` sentinel convention as `TravelStartMm`/`TravelEndMm`. +**Natural Friction** (`NaturalFrictionPct`, 0–100%, labeled "Natural +Friction" — simulates a frictional force independent of game output) is +another genuine hardware write, reverse-engineered from two real Pit +House USB captures: one toggling the setting off/on, and one dragging the +slider through 0/25/50/75/100%. Same "prefix bytes + selector" shape as +End Stop Stiffness above — ONE cmdId (`0xAE`) with a fixed `0x00` byte and +a selector byte (`0x00`/`0x01`) before the 2-byte value +(`mbooster-brake-friction-0`/`-1` in the command database) — but unlike +Endstop's independent front/end values, every capture write sent **both** +selectors with the identical value in the same burst, so the UI always +writes them together rather than exposing two sliders. Fixed 0–100% scale +over 0–65535: `raw = round(pct * 65535 / 100)` — the 0/25/50/75/100% +sweep matched exactly (`0x0000`/`0x4000`/`0x8000`/`0xbfff`/`0xffff`). The +toggle capture cross-checks this: the mBooster's own firmware debug log +(carried in the response stream as ASCII, `param_manage.c` write-confirm +lines) echoed the disabled write as `Table 2, Param 32 Written: 0 +0.00000` and the enabled write (slider left at 100%) as `Param 32 +Written: 1073741824` (`2^30`, i.e. fixed-point `1.0`) — confirming there +is **no separate wire enable bit**; Pit House's toggle just writes raw 0 +when off and restores the last slider value when on. See +`MozaMBoosterProtocol.EncodeFrictionPct`/`DecodeFrictionPct`. Same `-1` +"not yet set / no override" sentinel convention as +`EndstopFrontStiffness`/`EndstopEndStiffness`. + +**Segmented Damping** (labeled "SEGMENTED DAMPING" with its own card, two +plots — "When Pressed" and "When Released") is Pit House's "simulate a +damping force independent of in-game output, dividing pedal travel into +multiple segments with adjustable range and its own natural damping": an +X/Y plot (`MozaControls.MozaSegmentedBarEditor`, a new control — no +bar-chart control existed anywhere in this app before) where the X axis +is 0-100% pedal travel and the Y axis is 0-100% damping. Two draggable +vertical dividers split the plot into 3 segments, each with its own +independently draggable damping bar. Reverse-engineered from 11 real +Pit House USB captures — 6 for "When Pressed" (2 isolating one divider +drag each, 3 isolating one segment's Y-drag each, 1 toggling the feature +off/on) and 5 for "When Released" (2 divider, 3 segment) — cross-checked +against each other to decode the wire shape (see below). + +All 6 "When Pressed" captures write the **same single command** — cmdId +`0xB7`, group 36 (write, same `GroupMotorWrite` group the vibration +effects use)/35 (read, unused by any capture — this command isn't part +of `RequestCalibrationReads`' fixed read-burst list) — with a **fixed +21-byte payload**: the cmd byte followed by 10 big-endian `u16` fields, +each `raw = round(pct * 65535 / 100)` (`MozaMBoosterProtocol +.EncodeSegmentedDampingPct`/`DecodeSegmentedDampingPct`). Cross-checking +the "When Pressed" captures against the "When Released" ones (which +exist on disk as `pedal-feel-damping-released-*.pcapng`) revealed the +full field order — critically, **every write resends all 10 fields**, +including ones unrelated to whatever the user was actually dragging in +that particular capture, proving this is always a whole-feature +snapshot, never a partial update: + +``` +cmd=0xB7 Div1Pressed Div2Pressed Div1Released Div2Released + Seg1Pressed Seg1Released Seg2Pressed Seg2Released Seg3Pressed Seg3Released +``` + +Each field's identity is proven by which one varies in lockstep with its +own isolated capture's filename sweep — e.g. `pedal-feel-damping- +pressed-segment2-0-22-57-100.pcapng` is the only capture where the +Seg2Pressed field moves, tracking 0/22/57/100% closely. The two DIVIDER +fields per pair land exactly on `round(pct*65535/100)` (typed/exact +values); the SEGMENT (Y-axis, mouse-dragged) fields are only ever within +about 1 raw unit of that formula — expected, since a drag lands on +whatever pixel row the mouse stopped at (e.g. ~57.002%, not a clean +57%), not the filename's rounded label. See +`MozaMBoosterProtocol.BuildSegmentedDampingFrame`. + +This also confirms "When Pressed" and "When Released" are genuinely +**independent** — separate divider pairs, not a shared X axis with two +Y curves — since each side's divider/segment captures never moved the +other side's fields. A recurring, untouched baseline across 5+ +independent capture sessions gives confident factory defaults: Divider1/ +2 Pressed = 33%/67%, Divider1/2 Released = 20%/70% +(`MBoosterUiConstants.SegDampDivider*DefaultPct`); the very first +capture (the toggle test) shows all-zero segment values, so 0% (no +extra damping) is the default there too +(`MBoosterUiConstants.SegDampSegDefaultPct`). + +Divider bounds are Pit House's own and asymmetric per divider — Divider1 +∈ [10%, 80%], Divider2 ∈ [20%, 90%] — with a 10% minimum gap enforced +between them (`MBoosterUiConstants.SegDampDivider1MinPct`/`MaxPct`, +`SegDampDivider2MinPct`/`MaxPct`, `SegDampDividerMinGapPct`), confirmed +directly from the two divider-sweep captures' filenames (e.g. +`divider-one-10-34-60-80` sweeps from its 10% floor up to 80%, its +ceiling). Both "When Pressed" and "When Released" have their own plot +(`MBoosterSegmentedDampingSettings.Divider1Pressed`/`Divider2Pressed`/ +`Seg1Pressed`/`Seg2Pressed`/`Seg3Pressed` wired to +`MBoosterSegDampPressedPlot_ValuesChanged`; the `*Released` counterparts +wired to `MBoosterSegDampReleasedPlot_ValuesChanged`). Since the wire +command has no partial-update form, both handlers funnel through one +shared `SettingsControl.PushSegmentedDamping` that always resends all 10 +fields — editing a divider on the Released plot still re-sends whatever +the Pressed plot currently holds, and vice versa. `-1` = "not yet set / +no override", same sentinel convention as every other Pedal Feel +calibration; a fresh profile writes nothing until the user drags a +divider or a segment on EITHER plot, at which point any still-unset +field on the OTHER plot is filled from the factory defaults above rather +than left blank (the wire frame has no concept of "not sent" per field). + The same card also has two force-based sliders, both host-side only and both applied in `MozaMBoosterRegistry.ApplyDeadzoneAndMaxForce`, which runs *before* `EvaluateInputCurve`: @@ -1299,6 +1399,144 @@ existing theme pairs (same red/green the temperature graph's MCU/Motor series use). `BlueBrush`/`BwThirdFillBrush` are new — no prior accent color in the theme was a true blue distinct from Cyan. +## PitHouse Pedals preset format + +A PitHouse preset is a JSON object (or a `.mzpreset` zip holding `preset.json` +— see `UI/Import/PitHousePresetArchive.cs`) whose `deviceParams` object holds +every setting as a flat key. mBooster presets carry `"deviceType": "Pedals"` +and `"devices": ["mBooster"]`. + +Sample files these notes were derived from: two real user presets, `Brake` +(100 `deviceParams` keys, saved 2026-07-14) and `Throttle` (88 keys, +2026-07-18), from the same rig. + +### Per-role prefixes, and the subject role + +Every key except a handful of device-wide ones is prefixed `throttle_`, +`brake_` or `clutch_`. **A preset written for one pedal still carries all +three sections** — but only its own role gets the extended block (effects, +travel limits, damping/friction, force curves). The other two hold just the +device-wide snapshot: + +``` +channlRoleType, outdir, min, max, nonlinear1..5, press_combine +``` + +So the section carrying **any key outside that generic set** identifies the +role the preset is really for — its *subject role*. In `Brake.json` only +`brake_*` qualifies; in `Throttle.json` only `throttle_*` does. + +This matters because the other sections are *not* settings for those pedals in +any meaningful sense — they are whatever the device happened to report when +the preset was saved. `PitHousePedalsMapper` therefore imports only the subject +section, into one pedal (`ImportPlan.ResolvedTarget`, retargetable in the +wizard). Importing all three overwrote the untouched pedals' calibration. + +A preset with no extended block in any section has no discernible subject; the +mapper treats it as a plain calibration snapshot and matches each populated +section to the pedal carrying that role (`ImportPlan.IsCalibrationOnlyPreset`). + +### Key → plugin field + +Prefixed `

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

_outdir` | `Direction` | `mbooster-

-dir` | +| `

_min` / `

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

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

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

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

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

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

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

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

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

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

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

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

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

_softlimit_hardness_press` / `_release` | `EndstopFrontStiffness` / `EndstopEndStiffness` | **inferred**, see below | +| `brake_press_combine` | `SensorOutputRatioPct` | **inferred**; brake role only (`mbooster-brake-angle-ratio` is written only for Brake) | + +Values are clamped to the plugin's own slider bounds (`MBoosterUiConstants`) +on import, and the travel pair additionally honours `TravelMinGapMm` / +`TravelMaxGapMm` so an imported range can't land somewhere the UI could not +produce. + +### `

_min` / `

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

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

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

_friction_*`, `

_forcelimit_min/max`, `

_gforce_*`, +`

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