Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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/<n>/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/<n>/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:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
_todo.md
.vscode/*

## Claude stuff
.claude*
Expand Down
378 changes: 378 additions & 0 deletions BaseSettingCatalog.cs

Large diffs are not rendered by default.

14 changes: 5 additions & 9 deletions Devices/Ab9EngineVibrationWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 2 additions & 5 deletions Devices/BaseLfeEffectWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]";
Expand Down Expand Up @@ -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);
}
Expand Down
52 changes: 52 additions & 0 deletions Devices/EngineVibrationMath.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System;

namespace MozaPlugin.Devices
{
/// <summary>
/// Shared math for the "engine vibration" effect across the three FFB
/// hardware types that each render it over a different wire protocol —
/// <see cref="BaseLfeEffectWorker"/> (wheelbase LFE engine/ABS/gearshift
/// streams), <see cref="MBoosterEffectWorker"/> (mBooster vibration
/// motor), and <see cref="Ab9EngineVibrationWorker"/> (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.
/// </summary>
internal static class EngineVibrationMath
{
/// <summary>Redline fallback when the game doesn't report MaxRpm — the
/// convention <see cref="Ab9EngineVibrationWorker"/> and
/// <see cref="MBoosterEffectWorker"/>'s Engine effect both use.</summary>
public const double DefaultRedlineRpm = 8000.0;

/// <summary>
/// RPM as a fraction of redline, clamped to at most 1 so an over-rev
/// can't exceed the redline pitch/period. <paramref name="maxRpm"/>
/// below 100 (the game not reporting it) falls back to
/// <paramref name="defaultRedlineRpm"/>. Assumes
/// <paramref name="rpm"/> is non-negative (both callers already gate
/// on rpm > 0 before reaching here).
/// </summary>
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;
}

/// <summary>
/// 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.
/// </summary>
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;
}
}
}
65 changes: 65 additions & 0 deletions Devices/MBoosterDeviceController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,32 @@ public byte MotorDeviceForCurrentAxis(int axisIndex)
return isChain ? MotorDeviceForAxis(axisIndex) : MozaProtocol.DeviceMain;
}

/// <summary>
/// Whether HID axis <paramref name="axisIndex"/> 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 (<see cref="ConnectedAxes"/>) once it arrives; before
/// that, treats every axis as real if <see cref="SubDeviceCount"/>
/// already confirmed a multi-motor chain at connect, else assumes only
/// axis 0 is wired. Same convention <see cref="MBoosterEffectWorker"/>
/// uses to gate its own per-pedal tick — callers that resolve a HID
/// axis's role (see <see cref="MozaMBoosterRegistry.ResolveAxisRole"/>)
/// need this too: raw <see cref="AxisCount"/> 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).
/// </summary>
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;
}

/// <summary>
/// The motor device id for a pedal ROLE (0=Throttle,1=Brake,2=Clutch),
/// using the calibration-derived chain map (see
Expand Down Expand Up @@ -780,6 +806,30 @@ public int SoleConnectedAxis()
return count == 1 ? sole : -1;
}

/// <summary>
/// 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 — <see cref="ConnectedAxes"/> (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.
/// </summary>
public List<int> ConnectedAxisIndices()
{
int axisCount = AxisCount > 0 ? AxisCount : 1;
var connected = _connectedAxes;
var axes = new List<int>();
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;
}

/// <summary>Short identity slug for capture labels / log lines — last 8 chars of instance id.</summary>
public static string ShortIdentity(string identity)
{
Expand Down Expand Up @@ -1144,6 +1194,21 @@ public void SetRoadTextureTestActive(bool on, int pedalIndex = 0)
WorkerFor(pedalIndex)?.SetRoadTextureTestSustained(on);
}

/// <summary>
/// Continuously alternates G-Force's commanded travel offset
/// forward/backward at the currently configured Max Travel/Response
/// Speed while <paramref name="on"/> is true, bypassing Enabled and
/// the game-running gate — mirrors Pit House's own "Test" demo. See
/// <see cref="SetEngineTestActive"/> for the analogous Engine
/// toggle; same live-tracking and always-allow-off semantics apply
/// here.
/// </summary>
public void SetGForceTestActive(bool on, int pedalIndex = 0)
{
if (on && !_connection.IsConnected) return;
WorkerFor(pedalIndex)?.SetGForceTestSustained(on);
}

/// <summary>
/// Continuously runs Lockup — substituting live brake position for
/// the wheel-slip detection heuristic (which needs vehicle speed),
Expand Down
Loading