Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Notable changes to Wisp are recorded here.
- Report which check the update helper failed when an update cannot start, and show how to reinstall when this copy of Wisp does not match the registered installation. Automatic update attempts stop for the session after such a rejection; Install update remains available from the dashboard.
- Record game audio on PCs where Windows reports a meaningless buffer size for the game audio stream. Clip recording previously failed to start there with an audio capture error.
- Keep Wisp running when Windows restarts its desktop display, for example after a graphics driver reset; the HUD restores its transparent overlay on its own.
- On a circuit, learn the track from the start line when the first timed lap begins, including from the back of the grid. Before, the run up to the line could freeze the map for that lap, and the delta waited until the lap after next.

## 2.6.13 - 2026-10-17

Expand Down
17 changes: 15 additions & 2 deletions src/Wisp.Core/LapDelta.cs
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,13 @@ public LapDeltaReading Update(VehicleState state, LapDeltaReference reference, L
// In a race the game rewinds its race clock and lap clock together. Straight after a rewind
// it can briefly report a lap clock that disagrees with its race clock; wait for one that
// agrees. (Wisp's Time Attack clocks restart an attempt while the race clock runs on.)
// The first circuit line is not that wait: the lap clock snaps while the race clock continues.
// A restart is not that wait either. After a grid start the race clock is ahead of the lap
// clock, so both returning to zero would look like a disagreement and the new lap would be missed.
var sameLapLine = !continuous && SameLapLine(timing, last, lap, direction, elapsed, state.GroundSpeedMetersPerSecond);
if (!continuous && timing == LapTimingMode.GameLaps && lap.LapNumber == last.LapNumber && last.RaceSeconds > 0 &&
Math.Abs(lap.CurrentLapSeconds - last.CurrentLapSeconds - (lap.RaceSeconds - last.RaceSeconds)) > .005f &&
++_held <= HeldSamples) return _lastReading;
!sameLapLine && !RaceStart(lap) && ++_held <= HeldSamples) return _lastReading;
if (!continuous)
{
// A rewind returns the car to a moment of this recording, with the lap clock at that
Expand All @@ -299,7 +303,7 @@ public LapDeltaReading Update(VehicleState state, LapDeltaReference reference, L
// zero with its clocks, and a new lap arrives by crossing the line. In Time Attack a lap
// clock back at zero is a new attempt.
if ((lap.CurrentLapSeconds > .25f || timing == LapTimingMode.GameLaps && !RaceStart(lap)) && RewindsTo(last, lap)) Rewind(lap);
else if (lap.CurrentLapSeconds <= .25f && (RaceStart(lap) || AtLapStart(lap))) StartLap(lap);
else if (lap.CurrentLapSeconds <= .25f && (RaceStart(lap) || AtLapStart(lap) || sameLapLine)) StartLap(lap);
// Straight after a rewind the game can briefly report a lap clock that fits neither.
// Wait a moment for a consistent sample before giving up on this lap's recording.
else if (lap.CurrentLapSeconds + .01f < last.CurrentLapSeconds && ++_held <= HeldSamples) return _lastReading;
Expand Down Expand Up @@ -513,6 +517,15 @@ private int RecordedIndexAt(float time)
private static bool RaceStart(LapTelemetry lap) =>
lap.LapNumber == 0 && lap.RaceSeconds <= .5f && Math.Abs(lap.RaceSeconds - lap.CurrentLapSeconds) <= .1f;

// On a circuit the first timed lap does not increment the packet lap. The lap clock snaps
// back to the line, the race clock continues, and the car is still driving forward.
private bool SameLapLine(LapTimingMode timing, LapTelemetry last, LapTelemetry lap, Vector3 move, float seconds, float speed) =>
timing == LapTimingMode.GameLaps && lap.LapNumber == last.LapNumber &&
last.LastLapSeconds <= .01f && lap.LastLapSeconds <= .01f &&
last.CurrentLapSeconds > .25f && lap.CurrentLapSeconds <= .25f &&
lap.RaceSeconds + .01f >= last.RaceSeconds && lap.RaceSeconds - last.RaceSeconds <= 1f &&
Drove(move, seconds, speed) && !RewindsTo(last, lap);

// The car is at the line where laps start (the references' first samples, or this recording's
// when it began there), no further from it than its lap clock allows.
private bool AtLapStart(LapTelemetry lap)
Expand Down
28 changes: 25 additions & 3 deletions tests/Wisp.Core.Tests/LapDeltaScenarioTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,12 @@ private void Require(bool condition, string message, double fraction) =>
}

// A lapped race: the game reports lap number, lap time, last lap and race time.
private sealed class RaceSession(int seed, LapDeltaReference mode) : Session(seed, mode)
private sealed class RaceSession : Session
{
private readonly bool _fromGrid;

internal RaceSession(int seed, LapDeltaReference mode) : base(seed, mode) => _fromGrid = seed % 2 == 0;

private readonly record struct Moment(double Game, double Race, double Lap, double Fraction);
private readonly List<Moment> _history = [];
private Moment _now;
Expand All @@ -138,7 +142,10 @@ private static Vector3 Position(double fraction) =>
internal void Run()
{
NextPace();
Send();
// Even seeds leave the grid behind the line. The first crossing keeps packet lap 0.
// Odd seeds, and every restart, still begin on the line.
if (_fromGrid) ApproachFromGrid();
else Send();
for (var step = 0; step < 70; step++)
{
var roll = Random.NextDouble();
Expand All @@ -160,12 +167,27 @@ private void Drive(double seconds)
for (var i = 0; i < Frames(seconds); i++) Step();
}

// About 90 m behind the line, both clocks at zero. Driving to the line happens before any other event.
private void ApproachFromGrid()
{
var back = 90 / (Math.Tau * 150);
_now = new(0, 0, 0, -back);
Send();
while (_now.Fraction < 0) Step();
}

private void Step(bool send = true)
{
var frame = Frame();
Wall += frame;
var next = _now with { Game = _now.Game + frame, Race = _now.Race + frame, Lap = _now.Lap + frame, Fraction = _now.Fraction + frame / Pace };
if (next.Fraction >= 1)
if (_now.Fraction < 0 && next.Fraction >= 0)
{
// The first line from the grid snaps the lap clock and leaves the packet lap at 0.
next = next with { Lap = next.Fraction * Pace };
_history.Clear();
}
else if (next.Fraction >= 1)
{
var past = (next.Fraction - 1) * Pace;
var duration = next.Lap - past;
Expand Down
59 changes: 59 additions & 0 deletions tests/Wisp.Core.Tests/LapDeltaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,17 @@ public void MapProjectionPreservesAspectAndIncludesOffTrackCar()
}
Assert.Equal(50, LapMapBounds.From(new(Array.Empty<LapPosition>(), false)).Size);
}
private const double CircuitLength = 150 * Math.Tau;

private static Vector3 Circle(double progress) => new((float)(150 * Math.Cos(progress * Math.Tau)), 0,
(float)(150 * Math.Sin(progress * Math.Tau)));

// Meters ahead of the line, along the same circle. Negative is still on the way to it.
private static Vector3 OnCircuit(double metersFromLine) => Circle(metersFromLine / CircuitLength);

private static VehicleState Moving(double race, double lap, int number, Vector3 position, double last, float speed) =>
State(race, lap, number, position, last) with { GroundSpeedMetersPerSecond = speed };

internal static VehicleState State(double race, double lap, int number, Vector3 position, double last = 60) => new()
{
IsRaceOn = true,
Expand Down Expand Up @@ -111,6 +119,57 @@ public void PartialFirstLapBecomesReferenceOnlyAfterAFullLap()
Assert.Equal(LapDeltaStatus.Comparing, Drive(tracker, 120, 60, 2, 5).Status);
}

[Theory]
[InlineData(5.884, 5.900, true)]
[InlineData(5.650, 5.667, false)]
public void SameLapLineSnapStartsTheLearningLap(double beforeRace, double snapRace, bool completeLap)
{
var tracker = new LapDeltaTracker();
const float speed = 29;
const double behind = 92;
const double lapLength = 37.036;
var gridStart = OnCircuit(-behind);
for (var i = 0; i <= (int)Math.Floor(beforeRace * 10); i++)
{
var t = i / 10d;
var along = -behind * (1 - t / beforeRace);
tracker.Update(Moving(t, t, 0, OnCircuit(along), 0, speed), LapDeltaReference.SessionBest);
}
tracker.Update(Moving(beforeRace, beforeRace, 0, OnCircuit(0), 0, speed), LapDeltaReference.SessionBest);
var grid = tracker.ReadMap(1)!;
Assert.False(grid.Outline.Complete);
Assert.True(grid.Outline.Points.Count > 2);
Assert.Contains(grid.Outline.Points, point => Vector3.Distance(point.ToVector(), gridStart) < 5);

// The logged line: packet lap stays 0, last-lap time stays 0, the lap clock snaps to
// about 0.017 s, and the race clock keeps running. The car is still at about 29 m/s.
const double snapLap = .017;
LapDeltaReading reading = tracker.Update(Moving(snapRace, snapLap, 0, OnCircuit(0), 0, speed), LapDeltaReference.SessionBest);
Assert.InRange(Vector3.Distance(gridStart, OnCircuit(0)), 90, 92);
for (var lap = snapLap + .016; lap < .517; lap += .016)
reading = tracker.Update(Moving(snapRace + (lap - snapLap), lap, 0, OnCircuit((lap / lapLength) * CircuitLength), 0, speed), LapDeltaReference.SessionBest);
reading = tracker.Update(Moving(snapRace + (.517 - snapLap), .517, 0, OnCircuit((.517 / lapLength) * CircuitLength), 0, speed), LapDeltaReference.SessionBest);
Assert.Equal(LapDeltaStatus.RecordingLap, reading.Status);
var learning = tracker.ReadMap(2)!;
Assert.True(learning.IsRecording);
Assert.False(learning.Outline.Complete);
Assert.NotSame(grid.Outline, learning.Outline);
Assert.Contains(learning.Outline.Points, point => Vector3.Distance(point.ToVector(), OnCircuit(0)) < 15);
Assert.DoesNotContain(learning.Outline.Points, point => Vector3.Distance(point.ToVector(), gridStart) < 50);
if (!completeLap) return;

for (var lap = .6; lap <= 37.020; lap += .1)
tracker.Update(Moving(snapRace + (lap - snapLap), lap, 0, OnCircuit((lap / lapLength) * CircuitLength), 0, speed), LapDeltaReference.SessionBest);
var snap = OnCircuit(0);
var finish = OnCircuit(0);
var opened = tracker.Update(Moving(42.920, 0, 1, finish, lapLength, speed), LapDeltaReference.SessionBest);
Assert.Equal(LapDeltaStatus.Comparing, opened.Status);
Assert.Equal(lapLength, opened.ReferenceSeconds!.Value, 3);
Assert.InRange(opened.Seconds!.Value, -.05, .05);
// The second line in the log was about 5 m from the first snap. On this circle it is the same point.
Assert.InRange(Vector3.Distance(snap, finish), 0, 5);
}

[Fact]
public void BestAndPreviousReferencesCanBeSwitchedMidLap()
{
Expand Down