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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Notable changes to Wisp are recorded here.
- Keep a slow drive from holding up clip recording.
- Record game audio in clips on PCs where recording previously stopped 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.
- In circuit races started from the grid, learn the first lap from the start line: the track map follows the car on that lap, the lap delta appears on the next lap, and saved laps include it.
- Stop automatic update attempts when this copy of Wisp cannot update itself automatically, and show how to install the update with the full installer from wispoverlay.com. Install update remains available from the dashboard.
- Make update checks more dependable on shared internet connections and during network outages.
- Make diagnostics tell a Windows power loss or system crash apart from a problem in Wisp.
Expand Down
4 changes: 4 additions & 0 deletions docs/releases/Wisp-2.7.0-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ Wisp 2.7 is a reliability update. Clips keep recording when the graphics card is

- Wisp keeps running when Windows restarts its desktop display, for example after a graphics driver reset, and the HUD restores its transparent overlay on its own.

## Laps

- In circuit races started from the grid, the first lap is learned from the start line: the track map follows the car on that lap, the lap delta appears on the next lap, and saved laps include it.

## Updates

- When this copy of Wisp cannot update itself automatically, Wisp stops retrying in the background and shows how to install the update with the full installer from wispoverlay.com. Install update stays available on the dashboard.
Expand Down
2 changes: 2 additions & 0 deletions src/Wisp.App/ReleaseNotesCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public static class ReleaseNotesCatalog
"Clips record game audio on PCs where recording previously stopped with an audio capture error."),
Group("Reliability",
"Wisp keeps running when Windows restarts its desktop display, for example after a graphics driver reset, and the HUD restores its transparent overlay on its own."),
Group("Laps",
"In circuit races started from the grid, the first lap is learned from the start line: the track map follows the car on that lap, the lap delta appears on the next lap, and saved laps include it."),
Group("Updates",
"When this copy of Wisp cannot update itself automatically, Wisp stops retrying in the background and shows how to install the update with the full installer from wispoverlay.com. Install update stays available on the dashboard.",
"Update checks are more dependable on shared internet connections and during network outages."),
Expand Down
19 changes: 19 additions & 0 deletions src/Wisp.Core/GridStartLine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Wisp.Core;

// A circuit race started from the grid begins its first timed lap at the start line, but FH6 does
// not report that crossing as a new lap: the packet lap stays 0 and no lap time is recorded. Its
// lap clock, which ran from the race start, restarts at the line while the race clock runs on.
// The live lap tracker and saved-run review both treat that one sample as the first lap's start.
internal static class GridStartLine
{
// Lap clock readings at or below this are at the line, as for any other lap start.
internal const float AtLine = .25f;

// The same packet lap with no lap timed yet, the lap clock back at the line, and the race clock
// still running. A rewind or restart takes the race clock back too, so neither matches.
internal static bool IsFirstCrossing(LapTelemetry before, LapTelemetry after) =>
after.LapNumber == before.LapNumber &&
before.LastLapSeconds <= .01f && after.LastLapSeconds <= .01f &&
before.CurrentLapSeconds > AtLine && after.CurrentLapSeconds <= AtLine &&
after.RaceSeconds + .01f >= before.RaceSeconds && after.RaceSeconds - before.RaceSeconds <= 1;
}
13 changes: 10 additions & 3 deletions src/Wisp.Core/LapDelta.cs
Original file line number Diff line number Diff line change
Expand Up @@ -283,12 +283,18 @@ public LapDeltaReading Update(VehicleState state, LapDeltaReference reference, L
lap.RaceSeconds + .01f < last.RaceSeconds || lap.LapNumber < last.LapNumber ||
lap.LapNumber > last.LapNumber + 1 || !resumed && !Drove(direction, elapsed, state.GroundSpeedMetersPerSecond) ||
(!boundary && lap.CurrentLapSeconds + .01f < last.CurrentLapSeconds));
// From the grid, the first timed lap begins at the line without a new packet lap: the lap
// clock restarts while the race clock runs on and the car keeps driving.
var gridLine = !continuous && timing == LapTimingMode.GameLaps && GridStartLine.IsFirstCrossing(last, lap) &&
Drove(direction, elapsed, state.GroundSpeedMetersPerSecond);
// 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.)
if (!continuous && timing == LapTimingMode.GameLaps && lap.LapNumber == last.LapNumber && last.RaceSeconds > 0 &&
// Neither the grid line nor a race (re)start is that disagreement: a restart reports both
// clocks at zero, even though the race clock led the lap clock after a grid start.
if (!continuous && !gridLine && 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;
!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 @@ -298,7 +304,8 @@ public LapDeltaReading Update(VehicleState state, LapDeltaReference reference, L
// In a race the game's lap counter tells a rewind from a new lap: a restart returns to lap
// 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);
if (gridLine) StartLap(lap);
else 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);
// 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.
Expand Down
11 changes: 11 additions & 0 deletions src/Wisp.Core/Runs/LapReviewAnalysis.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ public static LapReviewResult Build(RecordedRun run, LapTimingMode timing,
var jumped = previous is not null && moved > Math.Max(25, dt * 180);
var changedLap = current is not null && lap.LapNumber + 1 != current.Number;
var changedCircuit = clock?.CircuitChanged == true;
// From the grid, the first timed lap begins at the line without a new packet lap, as in
// LapDeltaTracker. Its lap clock restarting there is that lap's start, not a rewind.
var gridLine = timing == LapTimingMode.GameLaps && previous is not null && previousLap is not null && !breakPending &&
ContinuousSamples(previous, sample) && !changedCar && !jumped && GridStartLine.IsFirstCrossing(previousLap, lap);
if (boundary)
{
double? line = null;
Expand All @@ -106,6 +110,13 @@ public static LapReviewResult Build(RecordedRun run, LapTimingMode timing,
current = new(sample.State.CarOrdinal, lap.LapNumber + 1, line ?? sample.ElapsedSeconds,
crossing && !changedCar && !jumped, initialQuality);
}
else if (gridLine)
{
// The run from the grid ends at the line, where the first timed lap starts.
var line = Math.Max(previous!.ElapsedSeconds, sample.ElapsedSeconds - lap.CurrentLapSeconds);
Finish(line, false, LapReviewQuality.None);
current = new(sample.State.CarOrdinal, lap.LapNumber + 1, line, true, initialQuality);
}
else if (changedCar || rewind || jumped || changedLap || changedCircuit)
{
var reason = rewind ? LapReviewQuality.Rewind : LapReviewQuality.Discontinuity;
Expand Down
25 changes: 22 additions & 3 deletions tests/Wisp.Core.Tests/LapDeltaScenarioTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,13 @@ private void Require(bool condition, string message, double fraction) =>
$"Recent events: {string.Join(", ", _events.TakeLast(6))}. Packets: {string.Join(" ", _packets)}");
}

// A lapped race: the game reports lap number, lap time, last lap and race time.
// A lapped race: the game reports lap number, lap time, last lap and race time. Even seeds start
// from a grid slot behind the line, as most circuit races do; restarts begin on the line.
private sealed class RaceSession(int seed, LapDeltaReference mode) : Session(seed, mode)
{
private readonly record struct Moment(double Game, double Race, double Lap, double Fraction);
private readonly List<Moment> _history = [];
private readonly bool _gridStart = seed % 2 == 0;
private Moment _now;
private double _lastLap;
private int _number;
Expand All @@ -138,7 +140,8 @@ private static Vector3 Position(double fraction) =>
internal void Run()
{
NextPace();
Send();
if (_gridStart) LeaveGrid();
else Send();
for (var step = 0; step < 70; step++)
{
var roll = Random.NextDouble();
Expand All @@ -160,12 +163,28 @@ private void Drive(double seconds)
for (var i = 0; i < Frames(seconds); i++) Step();
}

// Both clocks start in a grid slot 60–120 m behind the line. Nothing else happens before the
// car reaches the line.
private void LeaveGrid()
{
_now = new(0, 0, 0, -(60 + Random.NextDouble() * 60) / (Math.Tau * 150));
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 restarts the lap clock without a new lap number or
// lap time. Rewinds stay within the timed laps.
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
49 changes: 49 additions & 0 deletions tests/Wisp.Core.Tests/LapDeltaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,55 @@ public void PartialFirstLapBecomesReferenceOnlyAfterAFullLap()
Assert.Equal(LapDeltaStatus.Comparing, Drive(tracker, 120, 60, 2, 5).Status);
}

// Issue #141 lap logs: the car left its grid slot about 92 m behind the line and crossed it at
// about 29 m/s. The packet lap stayed 0 and the last-lap time 0; the lap clock restarted at
// about 0.017 s while the race clock ran on. The lap from that line took 37.036 s.
[Theory]
[InlineData(5.884, 5.900)]
[InlineData(5.650, 5.667)]
public void FirstCrossingFromTheGridStartsTheFirstTimedLap(double beforeLine, double atLine)
{
const double behindLine = 92, lineClock = .017, lapSeconds = 37.036, step = .05;
const float speed = 29;
const double circuit = 150 * Math.Tau;
var tracker = new LapDeltaTracker();
VehicleState At(double race, double lapClock, int number, double meters, double last = 0) =>
State(race, lapClock, number, Circle(meters / circuit), last) with { GroundSpeedMetersPerSecond = speed };
LapDeltaReading Update(VehicleState state) => tracker.Update(state, LapDeltaReference.SessionBest, capture: true);
double Along(double lapClock) => lapClock / lapSeconds * circuit;

// Both clocks run from the race start while the car drives from the grid to the line.
for (var t = 0d; t < beforeLine; t += step) Update(At(t, t, 0, -behindLine * (1 - t / beforeLine)));
Update(At(beforeLine, beforeLine, 0, -.3));
var gridTraversal = tracker.CaptureFrame!.Traversal;
Assert.Contains(tracker.ReadMap(0)!.Outline.Points, point => Vector3.Distance(point.ToVector(), Circle(-behindLine / circuit)) < 5);

var reading = Update(At(atLine, lineClock, 0, Along(lineClock)));
Assert.Equal(LapDeltaStatus.RecordingLap, reading.Status);
Assert.NotEqual(gridTraversal, tracker.CaptureFrame!.Traversal);
Assert.True(tracker.CaptureFrame.Eligible);
var lapTraversal = tracker.CaptureFrame.Traversal;

for (var t = lineClock + step; t < 3; t += step) reading = Update(At(atLine + t - lineClock, t, 0, Along(t)));
Assert.Equal(LapDeltaStatus.RecordingLap, reading.Status);
// The learning map is redrawn from the line; the run from the grid is not part of it.
var map = tracker.ReadMap(0)!;
Assert.True(map.IsRecording);
Assert.DoesNotContain(map.Outline.Points, point => Vector3.Distance(point.ToVector(), Circle(-behindLine / circuit)) < 40);

for (var t = 3d; t < lapSeconds; t += step) Update(At(atLine + t - lineClock, t, 0, Along(t)));
var finish = atLine + lapSeconds - lineClock;
Update(At(finish + .02, .02, 1, circuit + Along(.02), lapSeconds));
// The lap completes as a game lap, so automatic lap recording saves it.
Assert.Equal(lapTraversal, tracker.CompletedCapture!.Traversal);
Assert.Equal(lapSeconds, tracker.CompletedCapture.DurationSeconds, 3);

for (var t = .02 + step; t < 5; t += step) reading = Update(At(finish + t, t, 1, circuit + Along(t), lapSeconds));
Assert.Equal(LapDeltaStatus.Comparing, reading.Status);
Assert.Equal(lapSeconds, reading.ReferenceSeconds!.Value, 3);
Assert.InRange(reading.Seconds!.Value, -.05, .05);
}

[Fact]
public void BestAndPreviousReferencesCanBeSwitchedMidLap()
{
Expand Down
66 changes: 66 additions & 0 deletions tests/Wisp.Core.Tests/LapReviewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,72 @@ public void RewindSplitsThePathAndCannotBecomeACompletedLap()
Assert.All(laps, lap => Assert.True(lap.Quality.HasFlag(LapReviewQuality.Rewind)));
}

// A circuit race from the grid, as in the issue #141 lap logs: both clocks run from the race
// start; at the line the lap clock restarts while the packet lap stays 0 and no lap time is
// recorded; each later line is a new packet lap with its lap time.
private const double GridToLine = 5.884, GridLap = 37, GridMeters = 92;

private static RunSample GridSample(double elapsed, double race)
{
var sinceLine = race - GridToLine;
var number = sinceLine < 0 ? (ushort)0 : (ushort)Math.Floor(sinceLine / GridLap);
var lapClock = sinceLine < 0 ? race : sinceLine - number * GridLap;
var meters = sinceLine < 0 ? -GridMeters * (1 - race / GridToLine) : sinceLine / GridLap * Math.Tau * 100;
var sample = Sample(elapsed, Circle(meters / 100), (float)lapClock, number, number == 0 ? 0 : (float)GridLap, speed: 17);
return sample with { State = sample.State with { Lap = sample.State.Lap! with { RaceSeconds = (float)race } } };
}

private static RunSample[] GridRace(double until) =>
Enumerable.Range(0, (int)Math.Round(until * 10) + 1).Select(index => GridSample(index / 10d, index / 10d)).ToArray();

[Fact]
public void RaceFromTheGridKeepsItsFirstTimedLap()
{
var run = new RecordedRun { Name = "Grid race", Samples = GridRace(GridToLine + 2 * GridLap + 2) };
var laps = LapReviewAnalysis.Build(run, cancellationToken: TestContext.Current.CancellationToken).Laps;
// The run from the grid stays a partial path; the laps from the line are complete.
Assert.False(laps[0].IsComplete);
Assert.True(laps[0].Quality.HasFlag(LapReviewQuality.PartialEnd));
Assert.DoesNotContain(laps, lap => lap.Quality.HasFlag(LapReviewQuality.Rewind));
var timed = laps.Where(lap => lap.IsComplete).ToArray();
Assert.Equal(new[] { 1, 2 }, timed.Select(lap => lap.Number).ToArray());
Assert.All(timed, lap => Assert.Equal(GridLap, lap.DurationSeconds!.Value, 3));
Assert.Equal(GridToLine, timed[0].StartSeconds, 3);
}

[Fact]
public void AutomaticallySavedFirstLapFromTheGridIsComplete()
{
// Automatic lap recording saves the packet before the line, the lap, and the next line.
var race = GridRace(GridToLine + GridLap + .5);
var first = Array.FindLastIndex(race, sample => sample.State.Lap!.RaceSeconds < GridToLine);
var last = Array.FindIndex(race, sample => sample.State.Lap!.LapNumber == 1);
var start = race[first].ElapsedSeconds;
var run = new RecordedRun
{
Name = "Game lap",
LapTimingMode = LapTimingMode.GameLaps,
Samples = race[first..(last + 1)].Select(sample => sample with { ElapsedSeconds = sample.ElapsedSeconds - start }).ToArray()
};
var lap = Assert.Single(LapReviewAnalysis.Build(run, cancellationToken: TestContext.Current.CancellationToken).Laps);
Assert.True(lap.IsComplete);
Assert.Equal(1, lap.Number);
Assert.Equal(GridLap, lap.DurationSeconds!.Value, 3);
Assert.False(lap.Quality.HasFlag(LapReviewQuality.Rewind));
Assert.False(lap.Quality.HasFlag(LapReviewQuality.PartialStart));
}

[Fact]
public void RewindJustAfterTheGridLineStaysARewind()
{
// The lap clock goes back below the line threshold, but the race clock goes back with it.
var rewound = Enumerable.Range(0, 400).Select(index => GridSample(6.3 + index / 10d, 5.95 + index / 10d));
var run = new RecordedRun { Name = "Grid race with a rewind", Samples = [.. GridRace(6.2), .. rewound] };
var laps = LapReviewAnalysis.Build(run, cancellationToken: TestContext.Current.CancellationToken).Laps;
Assert.DoesNotContain(laps, lap => lap.IsComplete);
Assert.Equal(2, laps.Count(lap => lap.Quality.HasFlag(LapReviewQuality.Rewind)));
}

[Fact]
public void TeleportIsNotDrawnAsAConnectingTrackSegment()
{
Expand Down
Loading