diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52d6896..96cca1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
Notable changes to Wisp are recorded here.
+## 2.6.5 - 2026-10-07
+
+- Add an optional 3D lap-review map with recorded elevation and mouse controls for rotation, pan and zoom. Keep the existing 2D view.
+- Add Shared space to compare two lap maps with the same color scale, focus either lap and move their cursors together or independently.
+- Add power and torque channels, a color legend, contact markers and PNG export. Distinguish recorded object contacts from estimated possible contacts.
+- Move lap-position controls above the map and add a resizable map area with a camera reset button.
+
## 2.6.4 - 2026-10-06
- Improve HUD and Tune compatibility with Forza updates on Steam and Xbox PC.
diff --git a/LICENSES/library-sources-distribution.json b/LICENSES/library-sources-distribution.json
index 500f072..6b8823f 100644
--- a/LICENSES/library-sources-distribution.json
+++ b/LICENSES/library-sources-distribution.json
@@ -1,9 +1,9 @@
{
"schemaVersion": 1,
- "version": "2.6.4",
+ "version": "2.6.5",
"archive": {
- "filename": "Wisp-2.6.4-library-sources.zip",
- "url": "https://github.com/Views2k/Wisp/releases/download/library-sources-2.6.4/Wisp-2.6.4-library-sources.zip",
+ "filename": "Wisp-2.6.5-library-sources.zip",
+ "url": "https://github.com/Views2k/Wisp/releases/download/library-sources-2.6.5/Wisp-2.6.5-library-sources.zip",
"bytes": 879043478,
"sha256": "6ee69a19f4b28c198c79f5f48b11a639bd7d6372b36f333c3ee4873aaf6d5ccc"
}
diff --git a/README.md b/README.md
index 062d371..8241960 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
Supports Steam and Xbox app / Microsoft Store editions on Windows PC.
-## Wisp 2.6.4
+## Wisp 2.6.5
-2.6.4 improves HUD and Tune compatibility with Forza updates on Steam and Xbox PC, restores Drift Zone angle-bonus guidance on the latest builds, and adds a dismissible note from Views.
+2.6.5 adds 3D lap review with recorded elevation, shared-space comparison, channel legends, contact markers and PNG export.
-[Clips](#clips) · [Tune](#tune) · [Lap review](#lap-review) · [Live map and lap delta](#live-map-and-lap-delta) · [Install](#install) · [2.6.4 release notes](docs/releases/Wisp-2.6.4-release-notes.md)
+[Clips](#clips) · [Tune](#tune) · [Lap review](#lap-review) · [Live map and lap delta](#live-map-and-lap-delta) · [Install](#install) · [2.6.5 release notes](docs/releases/Wisp-2.6.5-release-notes.md)
### Clips
@@ -59,7 +59,9 @@ Current-car reading supports **Steam and Xbox app / Microsoft Store FH6 with imp
### Lap review
-Open a saved run in **Runs → Lap review** to inspect the line you drove. Move one cursor across the map and graphs to review speed, inputs, G-force, RPM, gear and wheel readings. Choose a section and compare its time, distance and inputs with another lap or a pinned benchmark. Events mark braking, throttle pickup and shifts.
+Open a saved run in **Runs → Lap review** to inspect the line you drove in **2D or 3D**. The 3D view preserves recorded elevation at the same scale as horizontal distance. Rotate, pan and zoom the map, color it by speed, inputs, G-force, RPM, gear, wheel readings, horsepower, torque or elevation, and follow the selected point in the graph. Choose a section and compare its time, distance and inputs with another lap or a pinned benchmark. Events mark braking, throttle pickup and shifts.
+
+Optional **Shared space** places two laps beside each other with one camera and color scale. Focus either track, move both cursors together or inspect A and B independently, then return to the overview. Resize the map and use **Save PNG** to share the current view and legend. Contact markers distinguish recorded object evidence and saved annotations from possible-contact estimates; they do not identify every collision. [Read the lap-review guide](docs/LAP-REVIEW.md).
Turn on **Save completed laps automatically** to keep laps for later review, grouped by day and car. It uses the timing mode selected in HUD settings while Wisp is running. Choosing Run B takes priority over a compatible pinned benchmark.
@@ -244,8 +246,8 @@ An optional quick tour introduces Clips, Tune, live map, lap delta and lap
review. Start from the welcome banner or choose **Replay the quick tour**
in Release Notes.
-[Download Wisp 2.6.4](https://github.com/Views2k/Wisp/releases/download/v2.6.4/Wisp-Setup-2.6.4.exe) ·
-[2.6.4 release notes](docs/releases/Wisp-2.6.4-release-notes.md) ·
+[Download Wisp 2.6.5](https://github.com/Views2k/Wisp/releases/download/v2.6.5/Wisp-Setup-2.6.5.exe) ·
+[2.6.5 release notes](docs/releases/Wisp-2.6.5-release-notes.md) ·
[Changelog](CHANGELOG.md)
Older interface screenshots retain their original version labels. The gameplay
@@ -372,11 +374,11 @@ require administrator access or a separate .NET runtime.
## Install
-1. Download [Wisp-Setup-2.6.4.exe](https://github.com/Views2k/Wisp/releases/download/v2.6.4/Wisp-Setup-2.6.4.exe).
+1. Download [Wisp-Setup-2.6.5.exe](https://github.com/Views2k/Wisp/releases/download/v2.6.5/Wisp-Setup-2.6.5.exe).
2. Run the installer.
3. Complete the required setup wizard on first launch.
-The [release page](https://github.com/Views2k/Wisp/releases/tag/v2.6.4) also provides the installer checksum and a ZIP containing the same installer. To verify the download, compare its SHA-256 with the `.sha256` file:
+The [release page](https://github.com/Views2k/Wisp/releases/tag/v2.6.5) also provides the installer checksum and a ZIP containing the same installer. To verify the download, compare its SHA-256 with the `.sha256` file:
**[WINDOWS POWERSHELL]**
@@ -496,6 +498,7 @@ To build the self-contained installer:
## Documentation
+- [Wisp 2.6.5 release notes](docs/releases/Wisp-2.6.5-release-notes.md)
- [Wisp 2.6.4 release notes](docs/releases/Wisp-2.6.4-release-notes.md)
- [Wisp 2.6.3 release notes](docs/releases/Wisp-2.6.3-release-notes.md)
- [Wisp 2.6.2 release notes](docs/releases/Wisp-2.6.2-release-notes.md)
diff --git a/docs/LAP-REVIEW-3D-PRIVATE.md b/docs/LAP-REVIEW-3D-PRIVATE.md
new file mode 100644
index 0000000..e70ea08
--- /dev/null
+++ b/docs/LAP-REVIEW-3D-PRIVATE.md
@@ -0,0 +1,88 @@
+# Lap review 3D private test
+
+Historical engineering notes for the private test builds that became Wisp 2.6.5.
+For current controls and usage, see the [lap-review guide](LAP-REVIEW.md).
+
+The 2D review stays the default. 3D uses saved XYZ positions with the same scale
+on every axis; the ground projection is a depth cue, not a terrain reconstruction.
+Camera and cursor changes reuse frozen geometry. Scene preparation is cancellable
+and runs off the UI thread. Dense display paths retain height and selected-channel
+extrema, while cursor and analysis retain the original full-resolution samples.
+
+All fourteen existing channels, wheel selection, Run B / benchmark comparison,
+shared cursor, selected sections, readouts and graph handoff are retained.
+Horsepower, torque and elevation are additional channels. PNG export includes
+the current camera, channel legend and selected-point value, uses a bounded image,
+and saves atomically without overwriting existing files.
+
+## Private 07 audit fixes
+
+Verified defects and fixes from the private 06 audit and follow-up checks:
+
+- Unmatched Both mode updated B only through the slider. All A selection paths
+ now update paired progress; selecting B works symmetrically. Independent A/B
+ keeps the other cursor fixed. Relative progress does not enable timing matches.
+- Matched B keyboard steps could map back to the same A sample. Directional
+ navigation now advances through valid matches and preserves missing-match gaps.
+- Default tooltips used a light system background with Wisp's light text. Tooltips
+ now use themed colors, wrapping and bounded width; the full-map tooltip is removed.
+- Shared-space geometry could be reused after switching to a single track.
+ Preparation now includes layout mode in its identity before reusing a scene.
+- Track picking ignored depth. Segment picking prefers the visible overpass;
+ foreground annotations retain hit priority. Empty space focuses without selection.
+- Distant world positions distorted shared-space scale. Each lap is horizontally
+ centered with one physical scale and unchanged recorded elevation.
+- Map height was lost on reopen; it is now bounded and saved when resizing ends.
+ Compact upward resizing no longer jumps to a larger minimum. Home restores auto size.
+- Elevation values and legends use feet with mph, meters otherwise. Geometry keeps
+ equal physical scale on all axes.
+- Filled and hollow contact stars distinguish reported/saved evidence from estimates.
+ Unused manual-editor commands, storage method and advice have been removed.
+
+Wheel zoom still requires map focus so page scrolling works naturally. Right-click
+or click empty map space to focus without changing the lap point; this is explained
+beside the controls. Contact-format corrections and evidence limits are below.
+
+## Contact evidence
+
+The official FH6 Data Out layout documents SmashableVelDiff and SmashableMass:
+https://support.forza.net/hc/en-us/articles/51744149102611-Forza-Horizon-6-Data-Out-Documentation
+
+These fields report breakable-object velocity loss and mass, not a general
+wall/car collision flag. Object contact requires a continuous observed
+zero-to-positive impulse. A repeated game tick is accepted only with unchanged
+sample time, identity and position (within 1 cm), without a gap or clock rewind;
+repeated lap-clock values do not discard an otherwise continuous object impulse.
+Possible contact is a conservative movement estimate. Existing Contact annotations
+in saved or imported runs remain visible; the manual contact editor has been removed.
+No universal collision accuracy is claimed.
+Historical runs discarded these fields and cannot retrospectively recover them.
+
+New journals declare run schema 3 before recording samples, preventing older
+readers from recovering a partial prefix and discarding newer contact data.
+Finalized recordings and recovered journals with no positive object field omit
+the optional fields and use schema 1 or 2, preserving Wisp 2.6.4 readability.
+If either field is positive, schema 3 retains every object sample, including zero
+baselines, and requires this private build or newer. Existing finalized files and
+imports retain their format and data during metadata edits; no migration is performed.
+
+## Platform assessment
+
+Steam: saved-run visualization uses no game memory or executable-specific data.
+Object telemetry uses FH6's documented 324-byte Data Out packet contract.
+Xbox app / Microsoft Store: the same visualization and packet contract applies;
+no platform-specific native reader was changed. No new live-game collision capture
+was performed on either edition. Packet fixtures establish parsing, not real-world
+coverage of walls, other cars, repeated impacts or dropped packets.
+
+## Verification boundary
+
+Focused checks cover existing channels and interactions, 3D projection/elevation,
+color pixels, stale asynchronous scene protection, retained contact annotations,
+repeated-clock object impulses, schema recovery/import/export and PNG output. The private
+installer retains the standard full packaging gates. Detailed results and source
+revision are retained with the delivered installer.
+
+The stored-lap performance check uses one existing run read-only and a synthetic
+180,000-point size-limit case. CPU preparation and software-render timing do not
+establish gameplay FPS or guarantee every machine's interaction performance.
diff --git a/docs/LAP-REVIEW.md b/docs/LAP-REVIEW.md
index 3746a8e..052d213 100644
--- a/docs/LAP-REVIEW.md
+++ b/docs/LAP-REVIEW.md
@@ -1,19 +1,62 @@
# Lap review
-This describes the unreleased lap-review candidate. The public 2.5.2 installer does not include it.
+Review the line you drove, inspect its telemetry and compare selected sections with another lap. The map uses saved positions from your recording.
+
+## Record and open laps
Open **Runs → Lap review**. Enable **Save completed laps automatically** to retain detailed laps while Wisp is running. Capture uses the timing mode in the HUD's lap settings: Race / Rivals laps or Time Attack. It works with the lap overlays disabled. Manual run recording remains available; older recordings need position and lap telemetry to show a driven map.
Automatic capture uses the existing lap tracker's accepted completion boundary. Joining halfway through a lap, missing telemetry, rewinding, changing car or exceeding the recording limit prevents that interrupted lap from being saved as a complete capture. Completed saves are written to the existing local run library, with its storage limits and export/removal tools. Turn the option off to stop future captures. Pending completed saves finish when Wisp closes.
-Choose a saved run and a lap. The map is the car's recorded path, not a supplied circuit outline or a claim about track limits. The start ring and shared cursor identify locations. Click the map or graph, use arrow keys, or move the sample slider. Choose speed, time delta, throttle, brake, steering input, G-force, RPM or gear as the channel. Tire temperature, slip ratio, raw slip angle and normalized suspension travel also have a wheel selector for the map and graph. The sample readout shows these values for all four wheels together.
+## Map, channels and cursor
+
+Choose a saved run and a lap. The map is the car's recorded path, not a supplied circuit outline or a claim about track limits. The start ring and shared cursor identify locations. Click the map or graph, use arrow keys, or move the **Lap position** slider above the map.
+
+**2D** is the default. Switch to **3D** to view the saved XYZ positions with the same physical scale on every axis, preserving the recorded elevation changes. The ground projection helps show depth; it does not reconstruct terrain or track boundaries. Elevation values use feet when speed is set to mph and meters otherwise.
+
+Use **Color** to choose speed, time delta, throttle, brake, steering input, lateral/longitudinal/combined G-force, RPM, gear, horsepower, torque or elevation. Tire temperature, slip ratio, raw slip angle and normalized suspension travel also have a wheel selector for the map and graph. The legend shows the channel, units and numeric range; the sample readout shows wheel values for all four wheels together. Unavailable data stays unavailable.
+
+### 3D controls
+
+| Input | Action |
+| --- | --- |
+| Click a track point | Select that location; in Shared space, move closer to that lap |
+| Left-drag | Rotate |
+| Shift-drag, right-drag or middle-drag | Pan |
+| Ctrl-drag | Roll |
+| Mouse wheel while the map has focus | Zoom |
+| Arrow keys while the map has focus | Step through recorded points |
+| Home / End while the map has focus | Select the first / last point |
+| Double-click, R, or **Reset camera** | Reset the camera |
+
+Right-click or click empty map space to focus it without changing the selected point. Before the map has focus, the wheel scrolls the page. **Zoom in** and **Zoom out** are also available as buttons. Picking another point on an already focused lap keeps your adjusted camera view.
+
+Drag **Resize map** to change the map height; Wisp remembers the height when resizing ends. With the resize handle focused, Up/Down adjusts the height and Home restores automatic sizing.
+
+**Save PNG** saves the current 2D or 3D map with its channel legend and selected-point values. It includes the current camera and Shared space view when enabled. Resize and frame the map before saving, and choose an unused filename to keep existing images.
+
+## Sections and reference laps
Use **Start section here** and **End section here** around a corner or straight. Compare entry, minimum and exit speed, time, distance, throttle/brake/coasting fractions, G-force, RPM, gear changes and tire values. **Open section in graphs** connects the same interval to the existing Runs graph workspace. Whole-lap timing uses the recorded timing boundary; section statistics describe the actual recorded samples and their coverage.
-Select Run B using the existing comparison controls, then choose its reference lap. Without Run B, the reference picker uses laps from the current run. **Pin this lap as benchmark** keeps a selected run/lap reference between Wisp sessions; rename the run and add its tune label in the normal run details. Pinning does not copy the recording, prevent its removal, or change the live HUD's session-best/previous-lap reference. If the pinned run is unavailable, choose another or unpin it.
+Select Run B using the existing comparison controls, then choose its reference lap. Run B takes priority over a compatible pinned benchmark. Without Run B or a compatible pinned benchmark, the reference picker uses laps from the current run. **Pin this lap as benchmark** keeps a selected run/lap reference between Wisp sessions; rename the run and add its tune label in the normal run details. Pinning does not copy the recording, prevent its removal, or change the live HUD's session-best/previous-lap reference. If the pinned run is unavailable, choose another or unpin it.
Time delta is this lap's time minus the reference's time at matching world positions. A negative value is ahead. Route direction, continuity, start location, car and timing mode must agree, and most of both routes must match. Ambiguous or missing positions get no delta. Comparison does not establish identical tune, conditions or an officially clean lap. Existing telemetry does not provide a reliable official lap-validity flag or full track boundary.
+### Shared space and independent cursors
+
+In 3D, enable **Shared space** when two distinct laps with recorded positions are selected. It is optional and starts off. The laps are arranged beside each other in one scene, using a common physical scale and channel color range while retaining their recorded elevation. Click either track to focus it. Zoom out, choose **Show both laps**, or press Escape while the map has focus to return to the overview.
+
+Choose **Both** to move the cursors together. Compatible comparisons follow matching track positions. When the laps cannot be matched, Both follows relative progress by recorded distance; this does not enable time delta or matched section statistics. **A** and **B** let you inspect the laps independently while keeping the other cursor fixed. Moving B independently leaves A and its graph at their selected point.
+
+Shared space can display different cars or routes when both laps have usable positions. That visual comparison does not relax the timing and benchmark requirements above.
+
+## Contact markers
+
+**Show contacts** displays filled stars for recorded breakable-object evidence or existing saved Contact annotations, and hollow stars for **Possible contact (estimate)**. Object evidence comes from the game's reported breakable-object velocity loss and mass. Possible contacts are inferred from movement; they can be wrong. These markers do not establish every wall/car collision, an officially clean lap, or the absence of contact when no marker appears.
+
+Saved or imported Contact annotations remain visible. There is no manual contact editor. Older recordings that did not retain object telemetry cannot recover that evidence retrospectively. See the [run-file format guide](run-library-format.md) for sharing and older-version compatibility.
+
## Why these measurements
RACELOGIC recommends locating time losses in the delta trace, then examining braking location, apex speed and the line through corner entry and exit. Wisp uses the corresponding telemetry and selected sections; it does not infer where a real apex or legal kerb lies from a bare path. [Circuit Tools guidance](https://en.racelogic.support/motorsport/discontinued-legacy/software/circuit-tools-2/kb/how-to-use-circuit-tools-to-become-faster/).
diff --git a/docs/releases/Wisp-2.6.5-release-notes.md b/docs/releases/Wisp-2.6.5-release-notes.md
new file mode 100644
index 0000000..966b786
--- /dev/null
+++ b/docs/releases/Wisp-2.6.5-release-notes.md
@@ -0,0 +1,12 @@
+# Wisp 2.6.5
+
+[Download Wisp 2.6.5 for Windows](https://github.com/Views2k/Wisp/releases/download/v2.6.5/Wisp-Setup-2.6.5.exe)
+
+## Lap review
+
+- Added an optional **3D track map** with recorded elevation and mouse controls for rotation, pan and zoom. The 2D view remains available.
+- Added **Shared space** to compare two lap maps with the same color scale. Focus either lap or view both, and move their cursors together or independently.
+- Added power and torque channels, a color legend, contact markers and **Save PNG**. Recorded object contacts and estimated possible contacts use distinct markers.
+- Moved lap-position controls above the map and added a resizable map area with a camera reset button.
+
+[Lap-review guide](https://github.com/Views2k/Wisp/blob/v2.6.5/docs/LAP-REVIEW.md)
diff --git a/docs/run-library-format.md b/docs/run-library-format.md
index 5b3b4e5..f775092 100644
--- a/docs/run-library-format.md
+++ b/docs/run-library-format.md
@@ -4,13 +4,19 @@ Wisp run files and library archives preserve recorded telemetry and its metadata
## Single run: `.wisprun`
-The existing single-run format remains unchanged: a gzip-compressed UTF-8 JSON Lines file.
+The single-run container is a gzip-compressed UTF-8 JSON Lines file.
-1. The first line is a `RecordedRun` object. `schemaVersion` is currently `1`; `samples` must be an empty array. The header carries `id`, `name`, `tune`, `notes`, `startedAtUtc`, `finishReason`, `isIncomplete`, `rejectedDatagrams`, `droppedDatagrams`, and `markers`.
+1. The first line is a `RecordedRun` object; `samples` must be an empty array. The header carries `schemaVersion`, `id`, `name`, `tune`, optional `tuneAttachment`, `notes`, `startedAtUtc`, `finishReason`, `isIncomplete`, `rejectedDatagrams`, `droppedDatagrams`, `lapTimingMode`, and `markers`.
2. Every subsequent line is one `RunSample`, in recording order. Its fields are `elapsedSeconds`, `segment`, `isDriving`, `state`, `wheelSpeedMetersPerSecond`, `frontRadiusMeters`, and `rearRadiusMeters`. `state` contains the serialized `VehicleState` fields; values are not rounded for export.
JSON uses camel-case property names and string enum names. Unknown properties and unsupported schema versions are rejected rather than silently discarded. Existing Wisp validation also checks finite/bounded numeric data, chronological samples and markers, consistent car/drivetrain identity, plausible calibration, and metadata lengths. Current definitions live in `src/Wisp.Core/Runs/RunModels.cs`, `src/Wisp.Core/VehicleState.cs`, and `src/Wisp.App/Runs/RunStore.cs`.
+Schema 1 stores telemetry without a structured tune attachment. Schema 2 adds that attachment. Schema 3 permits optional breakable-object velocity-loss and mass fields, with or without a tune attachment. Missing object fields mean unavailable evidence, not a measured zero. These fields are validated as a pair; invalid or partial values are rejected.
+
+When finalizing a new recording or automatic lap, Wisp retains schema 3 if either object field is positive in any sample. All surrounding zero baselines remain intact, so an observed zero-to-positive contact can still be identified. If neither field is ever positive, Wisp omits these optional fields and saves schema 1 or 2 as appropriate. Those completed files remain readable in Wisp 2.6.4; files retaining schema 3 require Wisp 2.6.5 or newer. Existing finalized files and imports are not downgraded, and metadata edits preserve their schema and telemetry.
+
+New recording journals declare schema 3 from the start, so older readers cannot recover a prefix and silently discard later object evidence. Recovery validates its retained samples before choosing the finalized format by the same rule and marks the run incomplete. Unsupported or unrecoverable journals remain on disk.
+
## Library archive: `.zip`, version 1
This is a standard ZIP containing one `manifest.json` and one existing `.wisprun` file per run. Tools that understand ZIP can extract the individual run files without implementing a new telemetry serialization format.
diff --git a/installer/PrivateShiftCaptureGuide.txt b/installer/PrivateShiftCaptureGuide.txt
index a66ec89..0277db2 100644
--- a/installer/PrivateShiftCaptureGuide.txt
+++ b/installer/PrivateShiftCaptureGuide.txt
@@ -1,38 +1,94 @@
-Wisp 2.6.3 Compatibility and drift test
-Private build ID: adaptive-drift-20261006-01
-
-This is a private test build, not a published release. It updates your existing
-Wisp installation and keeps settings, profiles, calibrations, saved runs and
-the Clips library. It is not a separate installation.
-
-Changes in this build
-- Restores Drift Zone angle-bonus guidance for the current Steam and Xbox PC
- builds. If bonus guidance is unavailable, the gauge still shows your angle.
-- A dismissible note from Views appears when opening Wisp, at most once per
- 24 hours. It stays open until you press its X. Its GitHub repo button opens
- Views2k/Wisp so you can leave a star without dismissing the note.
-- Wisp can recognize unchanged HUD and Tune readers after a Forza update on
- both Steam and Xbox PC. A changed version number alone no longer blocks them.
-- Game identity, reader structure, current-car ownership and value checks remain
- required. If an optional reader changes, the verified features stay available.
-- Temporary read failures can be retried without restarting Wisp.
+Wisp 2.6.4 Lap review 3D test
+Private build ID: lap-review-3d-20261006-07
+
+This is a private test build, not a public release. It updates your existing
+Wisp installation and keeps your settings, profiles, calibrations, runs and
+clips. It is not a separate installation.
+
+Choose Skip setup to test this private build without opening Forza. The skip
+lasts for the current session and does not change your saved setup evidence.
+Public releases keep the normal setup flow.
+
+Changes
+- Switch between the existing 2D lap map and a 3D view of recorded elevation.
+- Rotate, tilt, roll, pan and zoom the 3D map; reset the camera at any time.
+- Keep every existing map/graph channel, wheel selector, comparison, cursor,
+ section analysis and graph shortcut. Add horsepower, torque and elevation.
+- Read the color legend and the selected point's value next to the map.
+- Show reported object contacts, possible impacts and saved manual markers.
+- Save the current 2D or 3D map, including its legend, as a PNG image.
+- Optionally compare two 3D laps in Shared space, using the same channel and
+ color scale. Click a track to move closer; zoom back out to see both.
+- Keep the lap-position slider above the map and batch drag updates to one
+ per UI render cycle, preserving the final position when released.
+- Use one page scrollbar for the comparison controls and lap review.
+- Compare recorded tracks from different cars in Shared space. Timing and
+ matched-section checks remain separate from visual comparison.
+- Give the track more room with a taller automatic view and a draggable lower edge.
+- Keep camera zoom steady while rotating either the shared overview or a focused lap.
+- Choose Both, A or B beside Lap position to move paired or independent cursors.
+- Use zoom, reset, Show both laps and Show contacts directly from the top toolbar.
+- View the channel graph directly below the legend, with Lap settings and
+ benchmark beneath it. Drag the clearly marked divider handle to resize the map.
+- Use mouse camera gestures without manual angle sliders or a contact editor.
+- Center the close icon in the A note from Views popup.
+- Move both lap cursors with track/graph clicks, keyboard navigation and the slider.
+- Step through slower comparison laps without sticking on the same sample.
+- Keep the map height when reopening the view, and show imperial elevation in feet.
+- Distinguish reported/saved contacts from estimated contacts in the legend.
+- Select the visible track at overpasses and restore the single-map layout correctly.
+- Read styled, wrapping tooltips without a blank white bar over the track.
+
+Controls
+Lap position: Both moves paired cursors; A or B moves only that lap. Both uses
+matched positions where available, otherwise relative recorded lap distance.
+Relative progress does not establish a valid time delta or matched section.
+Drag: rotate. Shift-drag or right-drag: pan. Ctrl-drag: roll.
+Right-click or click empty map space to enable scroll zoom without selecting a
+lap point. Otherwise the wheel scrolls the page.
+Double-click or Reset camera: restore the workspace camera.
+Drag Resize map on the divider to resize it. Focus the grip and use Up/Down to
+adjust the height, or Home to restore automatic sizing.
+Click the line: select a point. Arrow keys: move along the lap.
+Home/End: first/last point.
+Choose Run B using Compare above, or choose a reference in Lap settings and
+benchmark, then enable Shared space in 3D. Both tracks remain in the same scene,
+laid out side by side. With Shared space off, the original single-lap view remains.
+Click a track to bring the camera toward it. Scroll back out, choose Show
+both laps or press Escape to frame both again. Rotation and pan
+gestures affect the shared camera. PNG saves the current camera view.
+Each map shows its recorded and saved contact markers. Show contacts hides
+or shows those markers; this view does not add or remove saved contacts.
Suggested checks
-1. Start your usual Steam or Xbox PC version of Forza, then Wisp. Drive into the
- open world and check the HUD against the car and the game's displayed values.
-2. Open Tune and refresh. Compare the displayed values with your current tune.
-3. Switch cars and confirm the HUD and Tune show the new car's values.
-4. Close and reopen Forza with Wisp still running. Confirm Wisp reconnects.
-5. Save and play a short clip with your existing recording settings.
-6. Check Drift Zone angle-bonus guidance and Custom target. The selected mode,
- target, gauge position and appearance should stay as you set them.
-
-If a read fails, use Copy details on Tune or export a debug ZIP from Diagnostics.
-Include the private build ID above with your report. This build does not enable
-per-frame diagnostic logging or change your recording settings.
-
-Steam and Xbox read-only comparisons passed on the October 5 game builds.
-Unknown future builds still need to pass the same validation; changed game
-readers, inaccessible memory or ambiguous matches must be rejected.
-Drift angle readings use Data Out. Automatic HUD/Tune validation does not verify
-new Drift Zone scoring rules; unverified rules leave the gauge in Angle only.
+1. Open an existing saved lap in Runs and expand Lap review.
+2. Switch between 2D and 3D. Rotate and zoom, then reset the view.
+3. Change channels and wheels. Select a point, compare Run B, select a section
+ and open it in graphs. The selected point/section should stay consistent.
+4. Toggle Show contacts and check the recorded markers on each lap.
+5. Save a PNG in both modes. Check the current view, colors and legend.
+6. Record a new lap on your usual Steam or Xbox PC installation. Object
+ contacts use the same Data Out fields on both editions.
+
+Contact coverage
+Forza reports velocity loss and mass for breakable-object impacts. Those
+appear as Object contact in new recordings. Possible contact is an estimate
+from movement; it is not a verified wall/car collision. Old recordings did
+not retain the object-impact fields. Previously saved markers remain visible.
+
+Saved-run compatibility
+Existing saved runs retain their original format. New finalized runs without
+positive object-impact telemetry omit those optional fields and remain readable
+in public 2.6.4. Runs with positive object-impact telemetry use run format 3 and
+require this build or newer. Public 2.6.4 keeps those files but cannot open them.
+Recording journals declare format 3 from the start so older Wisp cannot recover
+an incomplete prefix and lose contact data. This build safely recovers them.
+Existing format-3 files are preserved rather than rewritten during metadata edits.
+
+The 3D line uses recorded world positions at equal scale on all axes. Its floor
+projection is a visual reference, not a terrain model. Dense laps are simplified
+for display while keeping original samples for analysis and cursor selection.
+
+Include the private build ID above with feedback. Use Diagnostics to export
+redacted debug details if needed. No game-memory readers or recording settings
+are changed by this build.
diff --git a/installer/Wisp.iss b/installer/Wisp.iss
index c97c2dd..8dd7463 100644
--- a/installer/Wisp.iss
+++ b/installer/Wisp.iss
@@ -1,5 +1,5 @@
#define MyAppName "Wisp"
-#define MyAppVersion "2.6.4"
+#define MyAppVersion "2.6.5"
#define MyAppDisplayVersion MyAppVersion
#define MyAppOutputVersion MyAppVersion
#define MyAppPublisher "Wisp"
diff --git a/src/Wisp.App/App.xaml b/src/Wisp.App/App.xaml
index 161f084..a01b85b 100644
--- a/src/Wisp.App/App.xaml
+++ b/src/Wisp.App/App.xaml
@@ -98,6 +98,31 @@
+
+
+
+
+
+
+
+
+
@@ -22,8 +53,144 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -68,15 +235,10 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml.cs b/src/Wisp.App/Runs/LapReviewView.xaml.cs
index a0261d1..0feaf59 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml.cs
+++ b/src/Wisp.App/Runs/LapReviewView.xaml.cs
@@ -1,6 +1,10 @@
using System.Globalization;
+using System.IO;
+using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
namespace Wisp.App.Runs;
@@ -10,14 +14,113 @@ public LapReviewView()
{
InitializeComponent();
Track.PointChosen += Pick;
+ Track3D.PointChosen += Pick;
Trace.PointChosen += Pick;
+ InitializeMapWorkspace();
+ InitializeScrubbing();
}
private void Pick(int index) { if (DataContext is LapReviewViewModel model) model.Cursor = index; }
+ private void ResetMapView_Click(object sender, RoutedEventArgs e) => Track3D.ResetView();
+ private void ZoomMapIn_Click(object sender, RoutedEventArgs e) => Track3D.ZoomBy(1.25);
+ private void ZoomMapOut_Click(object sender, RoutedEventArgs e) => Track3D.ZoomBy(.8);
+ private bool _exporting;
+
+ private async void SaveMapPng_Click(object sender, RoutedEventArgs e)
+ {
+ if (_exporting || DataContext is not LapReviewViewModel { HasLap: true } model) return;
+ if (model.Is3D && !Track3D.IsReady) { model.MapStatus = "Wait for the 3D map to finish preparing, then save the image."; return; }
+ var dialog = new Microsoft.Win32.SaveFileDialog
+ {
+ Title = "Save the current lap map",
+ Filter = "PNG image (*.png)|*.png",
+ DefaultExt = ".png",
+ FileName = $"wisp-lap-{(model.Is3D ? "3d" : "2d")}-{DateTime.Now:yyyyMMdd-HHmmss}",
+ AddExtension = true,
+ OverwritePrompt = false
+ };
+ dialog.FileOk += (_, cancel) =>
+ {
+ if (Path.GetExtension(dialog.FileName).Equals(".png", StringComparison.OrdinalIgnoreCase) && !File.Exists(dialog.FileName)) return;
+ cancel.Cancel = true;
+ MessageBox.Show(Window.GetWindow(this), "Choose a new filename ending in .png to keep existing files.", "Choose a PNG filename", MessageBoxButton.OK, MessageBoxImage.Information);
+ };
+ if (dialog.ShowDialog(Window.GetWindow(this)) != true) return;
+ _exporting = true;
+ SaveMapPng.IsEnabled = false;
+ model.MapStatus = "Saving map image…";
+ try
+ {
+ var bitmap = CaptureMapImage();
+ await RunImageExporter.WriteAsync(bitmap, dialog.FileName);
+ model.MapStatus = "Map image saved.";
+ }
+ catch (Exception error) when (error is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
+ {
+ model.MapStatus = "The map image could not be saved. Check the destination and try again.";
+ DebugLogging.HealthContextRecorder.Current.RecordBreadcrumb(DebugLogging.HealthEventCode.LapReviewFailed, error.HResult);
+ }
+ finally { _exporting = false; SaveMapPng.ClearValue(IsEnabledProperty); }
+ }
+
+ internal BitmapSource CaptureMapImage()
+ {
+ if (DataContext is not LapReviewViewModel { HasLap: true } model || model.Is3D && !Track3D.IsReady)
+ throw new InvalidOperationException("The lap map is not ready.");
+ MapExportSurface.UpdateLayout();
+ var size = MapExportSurface.RenderSize;
+ if (size.Width <= 0 || size.Height <= 0 || !double.IsFinite(size.Width) || !double.IsFinite(size.Height))
+ throw new InvalidOperationException("The lap map has no layout.");
+ // Bound export memory while keeping small windows readable in shared images.
+ var scale = Math.Min(2, Math.Min(3840 / size.Width, 2160 / size.Height));
+ var bitmap = new RenderTargetBitmap((int)Math.Ceiling(size.Width * scale), (int)Math.Ceiling(size.Height * scale), 96 * scale, 96 * scale, PixelFormats.Pbgra32);
+ // Render in local coordinates; directly rendering a scrolled child also
+ // applies its layout offset and can crop the legend off the saved image.
+ var visual = new DrawingVisual();
+ using (var drawing = visual.RenderOpen())
+ {
+ drawing.DrawRectangle(MapExportSurface.Background, null,
+ new Rect(0, 0, bitmap.PixelWidth / scale, bitmap.PixelHeight / scale));
+ var brush = new VisualBrush(MapExportSurface)
+ {
+ Stretch = Stretch.Fill,
+ ViewboxUnits = BrushMappingMode.Absolute,
+ Viewbox = new Rect((Point)VisualTreeHelper.GetOffset(MapExportSurface), size)
+ };
+ drawing.DrawRectangle(brush, null, new Rect(size));
+ }
+ bitmap.Render(visual); bitmap.Freeze();
+ return bitmap;
+ }
+}
+
+public sealed class LapReviewResizeGripMarginConverter : IValueConverter
+{
+ // Align the sibling handle with the legend divider while excluding it from PNG exports.
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
+ new Thickness(16, 0, 16, 16 + (value is double height && double.IsFinite(height) ? Math.Max(0, height - 28) : 0));
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing;
}
-public sealed class LapReviewMapHeightConverter : IValueConverter
+public sealed class LapReviewCompactViewportConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
- value is double height && double.IsFinite(height) && height > 0 ? Math.Clamp(height - 8, 180, 360) : 360d;
+ value is double height && double.IsFinite(height) && height > 0 && height < 420;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing;
}
+
+public sealed class LapReviewMapHeightConverter : IMultiValueConverter
+{
+ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (values.Length > 1 && values[1] is double requested && double.IsFinite(requested))
+ return Math.Clamp(requested, 70, 1400);
+ var overhead = 235d;
+ if (values.Length >= 6 && values[2] is double toolbar && values[3] is double scrub &&
+ values[4] is double readout && values[5] is bool compact)
+ overhead = 110 + toolbar + scrub + (compact ? 0 : readout + 16);
+ return values.Length > 0 && values[0] is double height && double.IsFinite(height) && height > 0
+ ? Math.Clamp(height - overhead, 70, 640) : 480d;
+ }
+ public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) =>
+ targetTypes.Select(_ => Binding.DoNothing).ToArray();
+}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.ContactAnalysis.cs b/src/Wisp.App/Runs/LapReviewViewModel.ContactAnalysis.cs
new file mode 100644
index 0000000..7a47e9e
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewViewModel.ContactAnalysis.cs
@@ -0,0 +1,29 @@
+using Wisp.Core.Runs;
+
+namespace Wisp.App.Runs;
+
+public sealed partial class LapReviewViewModel
+{
+ private LapReviewLap? _telemetryContactLap;
+ private LapReviewContact[] _telemetryContacts = [];
+
+ private LapReviewContact[]? PreparedTelemetryContacts(LapReviewLap lap) =>
+ ReferenceEquals(_telemetryContactLap, lap) ? _telemetryContacts : null;
+
+ private LapReviewContact[] CachedTelemetryContacts(LapReviewLap lap) =>
+ PreparedTelemetryContacts(lap) ?? [];
+
+ private void AdoptTelemetryContacts(LapReviewLap lap, LapReviewContact[] contacts)
+ {
+ if (ReferenceEquals(_telemetryContactLap, lap) && ReferenceEquals(_telemetryContacts, contacts)) return;
+ _telemetryContactLap = lap;
+ _telemetryContacts = contacts;
+ _contactLap = null;
+ }
+
+ private void ClearTelemetryContacts()
+ {
+ _telemetryContactLap = null;
+ _telemetryContacts = [];
+ }
+}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.Map.cs b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
new file mode 100644
index 0000000..9efa755
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
@@ -0,0 +1,87 @@
+using Wisp.Core;
+using Wisp.Core.Runs;
+
+namespace Wisp.App.Runs;
+
+public sealed partial class LapReviewViewModel
+{
+ private bool _is3D, _showContacts = true;
+ private LapReviewLap? _contactLap;
+ private RunMarker[]? _contactMarkers;
+ private LapReviewContact[] _contacts = [];
+ private LapReviewPlotData? _legendData;
+ private string _mapStatus = "";
+ public bool Is3D { get => _is3D; set { if (_is3D != value) ChangeScrubContext(() => { Set(ref _is3D, value); Changed(nameof(Is2D)); RefreshMapMode(); }); } }
+ public bool Is2D { get => !Is3D; set { if (value) Is3D = false; } }
+ public bool ShowContacts { get => _showContacts; set { if (Set(ref _showContacts, value)) Changed(nameof(Plot)); } }
+ public string MapStatus { get => _mapStatus; set => Set(ref _mapStatus, value); }
+ public string LegendMinimum { get; private set; } = "—";
+ public string LegendMaximum { get; private set; } = "—";
+ public string LegendTitle { get; private set; } = "Speed";
+ public string MapTitle => Lap is { } lap ? $"{lap.RunName} · {lap.Label}" : "Recorded lap";
+ public string MapCursorValue => Lap is { Points.Length: > 0 } lap ?
+ $"{Channel.Label}: {RunPresentation.Number(LapReviewPlot.Value(lap.Points[Cursor], Plot, Cursor), " " + LapReviewPlot.Unit(Plot))}" : "";
+ public string ElevationSummary { get; private set; } = "";
+ public bool HasMapReference => !IsMapComparison && Plot.HasDistinctReference;
+ public double MapHeight
+ {
+ get => _settings.RunWorkspace.LapMapHeight > 0 ? _settings.RunWorkspace.LapMapHeight : double.NaN;
+ set
+ {
+ var height = double.IsFinite(value) ? Math.Clamp(value, 70, 1400) : 0;
+ if (_settings.RunWorkspace.LapMapHeight == height) return;
+ _settings.RunWorkspace.LapMapHeight = height;
+ Changed(nameof(MapHeight));
+ _save();
+ }
+ }
+
+ private IReadOnlyList VisibleContacts
+ {
+ get
+ {
+ EnsureContacts();
+ return ShowContacts ? _contacts : Array.Empty();
+ }
+ }
+
+ private void EnsureContacts()
+ {
+ var markers = _run?.Markers;
+ if (ReferenceEquals(_contactLap, Lap) && ReferenceEquals(_contactMarkers, markers)) return;
+ _contactLap = Lap; _contactMarkers = markers;
+ _contacts = Lap is null ? [] : CachedTelemetryContacts(Lap).Concat(
+ LapReviewContacts.FromMarkers(Lap, markers ?? [])).OrderBy(c => c.PointIndex).ToArray();
+ }
+
+ private void RefreshMapDetails()
+ {
+ var data = Plot;
+ EnsureContacts();
+ if (_legendData is not { } old || !ReferenceEquals(old.Lap, data.Lap) || !ReferenceEquals(old.Reference, data.Reference) ||
+ !ReferenceEquals(old.Comparison, data.Comparison) ||
+ old.Channel != data.Channel || old.Wheel != data.Wheel || old.SpeedUnit != data.SpeedUnit ||
+ old.TemperatureUnit != data.TemperatureUnit || old.TorqueUnit != data.TorqueUnit)
+ {
+ _legendData = data;
+ var range = SharedMapRange(data);
+ _sharedMapRange = range;
+ LegendTitle = Channel.Label + (IsWheelChannel ? " · " + Wheels[SelectedWheel] : "");
+ LegendMinimum = range.HasValues ? $"{range.Minimum:0.##} {LapReviewPlot.Unit(data)}" : "Unavailable";
+ LegendMaximum = range.HasValues ? $"{range.Maximum:0.##} {LapReviewPlot.Unit(data)}" : "Unavailable";
+ var positions = IsMapComparison && Reference is { } reference
+ ? data.Lap!.Points.Concat(reference.Points) : data.Lap?.Points ?? [];
+ var elevationFactor = LapReviewPlot.ElevationFactor(_settings.SpeedUnit);
+ var elevationUnit = _settings.SpeedUnit == SpeedUnit.MilesPerHour ? "ft" : "m";
+ ElevationSummary = data.Lap is { Points.Length: > 0 } ?
+ $"{(IsMapComparison ? "Both laps’ recorded elevation" : "Recorded elevation")}: {positions.Min(p => p.Position.Y) * elevationFactor:0.0}–{positions.Max(p => p.Position.Y) * elevationFactor:0.0} {elevationUnit} · true scale" : "";
+ foreach (var name in new[] { nameof(LegendMinimum), nameof(LegendMaximum), nameof(LegendTitle), nameof(ElevationSummary), nameof(MapTitle), nameof(MapATitle), nameof(MapBTitle) }) Changed(name);
+ }
+ Changed(nameof(MapCursorValue)); Changed(nameof(MapBCursorValue)); Changed(nameof(MapPlotA)); Changed(nameof(MapPlotB));
+ Changed(nameof(ComparisonMapPlot));
+ Changed(nameof(IsMapComparison));
+ Changed(nameof(CanUseSharedSpace));
+ Changed(nameof(HasMapComparison)); Changed(nameof(MapComparisonHint)); Changed(nameof(HasMapReference));
+ NotifyScrubState();
+ }
+}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs b/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
new file mode 100644
index 0000000..4f046e5
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
@@ -0,0 +1,152 @@
+using Wisp.Core.Runs;
+
+namespace Wisp.App.Runs;
+
+public sealed partial class LapReviewViewModel
+{
+ private LapReviewColorRange? _sharedMapRange;
+ private LapReviewContact[] _referenceMapContacts = [];
+ private bool _sharedSpace;
+ private int _referenceCursor;
+ public bool HasMapComparison => Plot.HasDistinctMapReference;
+ private bool HasMatchedMapComparison => Plot.HasDistinctReference && _reverseComparison?.CanCompare == true;
+ public bool CanUseSharedSpace => Is3D && HasMapComparison;
+ public bool SharedSpace { get => _sharedSpace; set { if (_sharedSpace != value) ChangeScrubContext(() => { Set(ref _sharedSpace, value); RefreshMapMode(); }); } }
+ public bool IsMapComparison => CanUseSharedSpace && SharedSpace;
+ public string MapATitle => IsMapComparison ? Lap is { } lap ? $"A · {lap.RunName} · {lap.Label}" : "Run A" : MapTitle;
+ public string MapBTitle => Reference is { } lap ? $"B · {lap.RunName} · {lap.Label}" : "Run B";
+ public string MapComparisonHint => IsMapComparison
+ ? HasMatchedMapComparison ? "Both laps use the same color scale. Click a track to focus it; zoom out to see both."
+ : "Both laps use the same color scale. Matched sections and time delta are unavailable."
+ : HasMapComparison ? "Enable Shared space to view both laps beside each other."
+ : _comparisonRun is not null ? "Run B has no second lap with recorded positions. Choose another run or lap."
+ : "Choose Run B using Compare to enable Shared space.";
+ public string MapBCursorValue
+ {
+ get
+ {
+ if (!HasMapComparison) return "";
+ var data = ReferencePlot();
+ var value = !UsesMatchedReferenceCursor
+ ? data.Lap is { } lap && (uint)data.Cursor < (uint)lap.Points.Length
+ ? LapReviewPlot.Value(lap.Points[data.Cursor], data, data.Cursor) : null
+ : LapReviewPlot.ReferencePosition(Plot, Cursor) is null ? null :
+ Channel.Channel == LapReviewChannel.Delta
+ ? -_comparison?.Points.ElementAtOrDefault(Cursor)?.DeltaSeconds : LapReviewPlot.ReferenceValue(Plot, Cursor);
+ return $"{Channel.Label}: {RunPresentation.Number(value, " " + LapReviewPlot.Unit(data))}";
+ }
+ }
+ public LapReviewPlotData MapPlotA => IsMapComparison
+ ? Plot with { ShowReferencePath = false, ColorRangeOverride = _sharedMapRange } : Plot;
+ public LapReviewPlotData? ComparisonMapPlot => IsMapComparison ? MapPlotB : null;
+ public LapReviewPlotData MapPlotB
+ {
+ get
+ {
+ var data = ReferencePlot();
+ var position = UsesMatchedReferenceCursor ? LapReviewPlot.ReferencePosition(Plot, Cursor) : null;
+ return data with
+ {
+ ShowReferencePath = false,
+ ColorRangeOverride = _sharedMapRange,
+ Cursor = !UsesMatchedReferenceCursor || position.HasValue ? data.Cursor : -1,
+ CursorPositionOverride = position
+ };
+ }
+ }
+
+ private LapReviewPlotData ReferencePlot()
+ {
+ if (!HasMatchedMapComparison)
+ return new(Reference, Lap, null, Channel.Channel, _settings.SpeedUnit,
+ Reference is { Points.Length: > 0 } reference ? Math.Clamp(_referenceCursor, 0, reference.Points.Length - 1) : -1,
+ -1, -1, SelectedWheel, _settings.TireTemperatureUnit, _settings.TorqueUnit, ShowContacts ? _referenceMapContacts : null);
+ int Match(int index) => _comparison?.Points.ElementAtOrDefault(index)?.ReferencePointIndex ?? -1;
+ var start = Match(_sectionStart); var end = Match(_sectionEnd);
+ var matchedSection = start >= 0 && end >= start;
+ return new(Reference, Lap, _reverseComparison, Channel.Channel, _settings.SpeedUnit,
+ UsesMatchedReferenceCursor ? Match(Cursor) : Math.Clamp(_referenceCursor, 0, Math.Max(0, (Reference?.Points.Length ?? 1) - 1)),
+ matchedSection ? start : -1, matchedSection ? end : -1,
+ SelectedWheel, _settings.TireTemperatureUnit, _settings.TorqueUnit, ShowContacts ? _referenceMapContacts : null);
+ }
+
+ public void PickReferencePoint(int index)
+ {
+ if (_disposed || !HasMapComparison || Reference is not { } reference || (uint)index >= (uint)reference.Points.Length) return;
+ if (UsesRelativeReferenceCursor) { Cursor = RelativePrimaryCursor(index); return; }
+ if (!UsesMatchedReferenceCursor)
+ {
+ SetReferenceCursor(index);
+ return;
+ }
+ var matched = NearestPrimaryIndexForReference(index);
+ if (matched >= 0) Cursor = matched;
+ }
+
+ public void NavigateReferenceCursor(LapReviewCursorNavigation navigation)
+ {
+ if (_disposed || !HasMapComparison || Reference is not { Points.Length: > 0 } reference ||
+ !Enum.IsDefined(navigation)) return;
+ var forward = navigation is LapReviewCursorNavigation.Next or LapReviewCursorNavigation.Start;
+ var endpoint = navigation is LapReviewCursorNavigation.Start or LapReviewCursorNavigation.End;
+ var current = MapPlotB.Cursor;
+ var requested = navigation switch
+ {
+ LapReviewCursorNavigation.Start => 0,
+ LapReviewCursorNavigation.End => reference.Points.Length - 1,
+ _ => Math.Clamp(current + (forward ? 1 : -1), 0, reference.Points.Length - 1)
+ };
+ if (UsesRelativeReferenceCursor) { NavigateRelativeReference(requested, forward, endpoint); return; }
+ if (!UsesMatchedReferenceCursor) { SetReferenceCursor(requested); return; }
+
+ // B can have more samples than A. Mapping its next sample to the lower A
+ // sample repeatedly would stick, so require progress in the requested direction.
+ var candidate = forward ? 0 : MaximumCursor;
+ if (!endpoint)
+ {
+ var nearest = current >= 0 ? NearestPrimaryIndexForReference(requested) : -1;
+ candidate = forward ? Math.Max(Cursor + 1, nearest) : Math.Min(Cursor - 1, nearest < 0 ? Cursor - 1 : nearest);
+ }
+ var previousSeconds = _comparison?.Points.ElementAtOrDefault(Cursor)?.ReferenceLapSeconds;
+ var plot = Plot;
+ for (var index = candidate; (uint)index <= (uint)MaximumCursor; index += forward ? 1 : -1)
+ {
+ if (LapReviewPlot.ReferencePosition(plot, index) is null ||
+ _comparison?.Points.ElementAtOrDefault(index)?.ReferenceLapSeconds is not { } seconds) continue;
+ if (!endpoint && previousSeconds is { } previous && (forward ? seconds <= previous : seconds >= previous)) continue;
+ Cursor = index;
+ return;
+ }
+ }
+
+ private int NearestPrimaryIndexForReference(int referenceIndex)
+ {
+ if (Lap is not { Points.Length: > 0 } lap ||
+ _reverseComparison?.Points.ElementAtOrDefault(referenceIndex) is not { ReferencePointIndex: { } lower, ReferenceLapSeconds: { } seconds } ||
+ (uint)lower >= (uint)lap.Points.Length || !double.IsFinite(seconds)) return -1;
+ var upper = Math.Min(lower + 1, lap.Points.Length - 1);
+ var nearest = Math.Abs(lap.Points[upper].LapSeconds - seconds) < Math.Abs(seconds - lap.Points[lower].LapSeconds) ? upper : lower;
+ var alternate = nearest == lower ? upper : lower;
+ // Keep A on an actual recorded sample and retain only genuine forward matches.
+ if (LapReviewPlot.ReferencePosition(Plot, nearest) is not null) return nearest;
+ return LapReviewPlot.ReferencePosition(Plot, alternate) is not null ? alternate : -1;
+ }
+
+ private void RefreshMapMode()
+ {
+ SynchronizeRelativeReferenceCursor(Cursor);
+ _legendData = null;
+ RefreshMapDetails();
+ Changed(nameof(CanUseSharedSpace));
+ }
+
+ private LapReviewColorRange SharedMapRange(LapReviewPlotData data)
+ {
+ var a = LapReviewColorRange.From(data);
+ if (!IsMapComparison) return a;
+ var b = LapReviewColorRange.From(ReferencePlot());
+ if (!a.HasValues) return b;
+ if (!b.HasValues) return a;
+ return new(Math.Min(a.Minimum, b.Minimum), Math.Max(a.Maximum, b.Maximum), true);
+ }
+}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.Scrubbing.cs b/src/Wisp.App/Runs/LapReviewViewModel.Scrubbing.cs
new file mode 100644
index 0000000..0f2b8c9
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewViewModel.Scrubbing.cs
@@ -0,0 +1,167 @@
+using Wisp.Core.Runs;
+
+namespace Wisp.App.Runs;
+
+public enum LapReviewCursorNavigation { Previous, Next, Start, End }
+
+public sealed partial class LapReviewViewModel
+{
+ private enum ScrubTarget { Both, A, B }
+ private ScrubTarget _scrubTarget;
+ internal event Action? ScrubContextChanging;
+ internal event Action? ScrubContextChanged;
+ public bool ScrubBoth { get => _scrubTarget == ScrubTarget.Both; set { if (value) SetScrubTarget(ScrubTarget.Both); } }
+ public bool ScrubA { get => _scrubTarget == ScrubTarget.A; set { if (value) SetScrubTarget(ScrubTarget.A); } }
+ public bool ScrubB { get => _scrubTarget == ScrubTarget.B; set { if (value) SetScrubTarget(ScrubTarget.B); } }
+ private bool ScrubsReferenceOnly => IsMapComparison && ScrubB;
+ private bool UsesMatchedReferenceCursor => HasMatchedMapComparison && (!IsMapComparison || ScrubBoth);
+ private bool UsesRelativeReferenceCursor => IsMapComparison && ScrubBoth && !HasMatchedMapComparison;
+ public int MaximumScrubCursor => ScrubsReferenceOnly ? Math.Max(0, (Reference?.Points.Length ?? 1) - 1) : MaximumCursor;
+ public int ScrubCursor
+ {
+ get => ScrubsReferenceOnly ? Math.Clamp(_referenceCursor, 0, MaximumScrubCursor) : Cursor;
+ set
+ {
+ if (_disposed) return;
+ value = Math.Clamp(value, 0, MaximumScrubCursor);
+ if (ScrubsReferenceOnly) { SetReferenceCursor(value); return; }
+ Cursor = value;
+ }
+ }
+ public string ScrubPosition => !IsMapComparison ? CursorDetails?.Position ?? "" : ScrubA
+ ? "A · " + (CursorDetails?.Position ?? "Unavailable") : ScrubB ? "B · " + ReferenceCursorPosition()
+ : "A · " + (CursorDetails?.Position ?? "Unavailable") + " B · " + ReferenceCursorPosition();
+ public string ScrubHint => !IsMapComparison ? "" : ScrubA ? "Move A only; B stays at its selected position."
+ : ScrubB ? "Move B only; A and the graph stay at their selected position."
+ : HasMatchedMapComparison ? "Move both along matching track positions."
+ : "Move both by relative lap progress (recorded distance); matched timing is unavailable.";
+
+ private void SetScrubTarget(ScrubTarget target)
+ {
+ if (_disposed || _scrubTarget == target) return;
+ ChangeScrubContext(() =>
+ {
+ // Keep the visible B sample when leaving the spatially matched mode.
+ var visibleReference = MapPlotB.Cursor;
+ if (visibleReference >= 0) _referenceCursor = visibleReference;
+ _scrubTarget = target;
+ if (IsMapComparison && target == ScrubTarget.Both && !HasMatchedMapComparison)
+ _referenceCursor = RelativeReferenceCursor(Cursor);
+ Changed(nameof(ScrubBoth)); Changed(nameof(ScrubA)); Changed(nameof(ScrubB));
+ NotifyReferenceCursor();
+ });
+ }
+
+ private void ChangeScrubContext(Action change)
+ {
+ ScrubContextChanging?.Invoke();
+ try { change(); }
+ finally { NotifyScrubState(); ScrubContextChanged?.Invoke(); }
+ }
+
+ private void NotifyScrubState()
+ {
+ Changed(nameof(MaximumScrubCursor)); Changed(nameof(ScrubCursor));
+ Changed(nameof(ScrubPosition)); Changed(nameof(ScrubHint));
+ }
+
+ private void SetReferenceCursor(int value)
+ {
+ if (Reference is not { Points.Length: > 0 } reference) return;
+ value = Math.Clamp(value, 0, reference.Points.Length - 1);
+ if (_referenceCursor == value) return;
+ _referenceCursor = value;
+ NotifyReferenceCursor();
+ }
+
+ private void NotifyReferenceCursor()
+ {
+ Changed(nameof(MapPlotB)); Changed(nameof(ComparisonMapPlot)); Changed(nameof(MapBCursorValue));
+ NotifyScrubState();
+ }
+
+ private bool SynchronizeRelativeReferenceCursor(int primaryIndex)
+ {
+ if (!UsesRelativeReferenceCursor) return false;
+ var referenceIndex = RelativeReferenceCursor(primaryIndex);
+ if (_referenceCursor == referenceIndex) return false;
+ _referenceCursor = referenceIndex;
+ return true;
+ }
+
+ private int RelativeReferenceCursor(int index) => RelativeCursor(Lap, Reference, index);
+ private int RelativePrimaryCursor(int index) => RelativeCursor(Reference, Lap, index);
+
+ private static int RelativeCursor(LapReviewLap? source, LapReviewLap? target, int index)
+ {
+ if (source is not { Points.Length: > 1 } || target is not { Points.Length: > 1 }) return 0;
+ index = Math.Clamp(index, 0, source.Points.Length - 1);
+ if (index == 0) return 0;
+ if (index == source.Points.Length - 1) return target.Points.Length - 1;
+ var first = source.Points[0].DistanceMeters;
+ var span = source.Points[^1].DistanceMeters - first;
+ var targetFirst = target.Points[0].DistanceMeters;
+ var targetSpan = target.Points[^1].DistanceMeters - targetFirst;
+ if (!double.IsFinite(first) || !double.IsFinite(span) || span <= 0 ||
+ !double.IsFinite(source.Points[index].DistanceMeters) || !double.IsFinite(targetFirst) ||
+ !double.IsFinite(targetSpan) || targetSpan < 0) return 0;
+ var progress = Math.Clamp((source.Points[index].DistanceMeters - first) / span, 0, 1);
+ // Gaps and a stationary finish can share the same cumulative distance.
+ if (progress >= 1) return target.Points.Length - 1;
+ var distance = targetFirst + progress * targetSpan;
+ // Recorded cumulative distance is monotonic; avoid scanning either lap while dragging.
+ var low = 0; var high = target.Points.Length - 1;
+ while (low < high)
+ {
+ var middle = low + (high - low) / 2;
+ if (target.Points[middle].DistanceMeters < distance) low = middle + 1;
+ else high = middle;
+ }
+ return low > 0 && distance - target.Points[low - 1].DistanceMeters < target.Points[low].DistanceMeters - distance ? low - 1 : low;
+ }
+
+ private void NavigateRelativeReference(int requested, bool forward, bool endpoint)
+ {
+ if (endpoint) { Cursor = forward ? 0 : MaximumCursor; return; }
+ var currentReference = RelativeReferenceCursor(Cursor);
+ var nearest = RelativePrimaryCursor(requested);
+ if (forward ? nearest > Cursor && RelativeReferenceCursor(nearest) > currentReference
+ : nearest < Cursor && RelativeReferenceCursor(nearest) < currentReference)
+ { Cursor = nearest; return; }
+
+ // Unequal densities or stationary spans can round back to the same A
+ // sample. Find the next representable shared progress without a full scan.
+ var low = forward ? Cursor + 1 : 0;
+ var high = forward ? MaximumCursor : Cursor - 1;
+ var selected = -1;
+ while (low <= high)
+ {
+ var middle = low + (high - low) / 2;
+ var value = RelativeReferenceCursor(middle);
+ var advances = forward ? value > currentReference : value < currentReference;
+ if (advances) selected = middle;
+ if (forward ? advances : !advances) high = middle - 1;
+ else low = middle + 1;
+ }
+ if (selected >= 0) Cursor = selected;
+ }
+
+ private string ReferenceCursorPosition()
+ {
+ var data = MapPlotB;
+ if (data.Lap is not { } lap || (uint)data.Cursor >= (uint)lap.Points.Length) return "Unavailable";
+ var point = lap.Points[data.Cursor];
+ var seconds = point.LapSeconds; var distance = point.DistanceMeters;
+ if (UsesMatchedReferenceCursor && _comparison?.Points.ElementAtOrDefault(Cursor)?.ReferenceLapSeconds is { } matchedSeconds)
+ {
+ seconds = matchedSeconds;
+ if (data.Cursor + 1 < lap.Points.Length)
+ {
+ var next = lap.Points[data.Cursor + 1];
+ if (next.LapSeconds > point.LapSeconds)
+ distance += (next.DistanceMeters - distance) * Math.Clamp((seconds - point.LapSeconds) / (next.LapSeconds - point.LapSeconds), 0, 1);
+ }
+ }
+ return $"{seconds:0.000} s · {distance:0} m";
+ }
+}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.cs b/src/Wisp.App/Runs/LapReviewViewModel.cs
index b8b3b13..e6d8166 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.cs
@@ -15,18 +15,18 @@ public sealed record LapReviewWheelReadout(string Label, string FrontLeft, strin
public sealed record LapReviewCursorReadout(string Position, string Speed, string Powertrain, string Throttle, string Brake,
string Steering, string LateralG, string LongitudinalG, string Delta, LapReviewWheelReadout Temperatures,
LapReviewWheelReadout SlipRatio, LapReviewWheelReadout SlipAngle, LapReviewWheelReadout Suspension);
-public enum LapReviewChannel { Speed, Delta, Throttle, Brake, Steering, LateralG, LongitudinalG, CombinedG, Rpm, Gear, TireTemperature, SlipRatio, SlipAngle, Suspension }
+public enum LapReviewChannel { Speed, Delta, Throttle, Brake, Steering, LateralG, LongitudinalG, CombinedG, Rpm, Gear, TireTemperature, SlipRatio, SlipAngle, Suspension, Power, Torque, Elevation }
-public sealed class LapReviewViewModel : INotifyPropertyChanged, IDisposable
+public sealed partial class LapReviewViewModel : INotifyPropertyChanged, IDisposable
{
private readonly AppSettings _settings;
private readonly RunStore _store;
private readonly Action _save;
- private RecordedRun? _run, _comparisonRun;
+ private RecordedRun? _run, _comparisonRun, _referenceRun;
private CancellationTokenSource? _loadCancellation, _analysisCancellation;
private readonly List _commands = [];
private LapReviewLap? _lap, _reference;
- private LapReviewComparison? _comparison;
+ private LapReviewComparison? _comparison, _reverseComparison;
private LapTimingChoice _timing;
private LapChannelChoice _channel;
private int _cursor, _sectionStart, _sectionEnd;
@@ -46,7 +46,7 @@ public sealed class LapReviewViewModel : INotifyPropertyChanged, IDisposable
public ObservableCollection Metrics { get; } = [];
public ObservableCollection Events { get; } = [];
public LapTimingChoice[] TimingChoices { get; } = [new("Race / Rivals laps", LapTimingMode.GameLaps), new("Time Attack", LapTimingMode.TimeAttack)];
- public LapChannelChoice[] Channels { get; } = [new("Speed", LapReviewChannel.Speed), new("Time delta", LapReviewChannel.Delta), new("Throttle", LapReviewChannel.Throttle), new("Brake", LapReviewChannel.Brake), new("Steering input", LapReviewChannel.Steering), new("Lateral G", LapReviewChannel.LateralG), new("Longitudinal G", LapReviewChannel.LongitudinalG), new("Combined G", LapReviewChannel.CombinedG), new("Engine RPM", LapReviewChannel.Rpm), new("Gear", LapReviewChannel.Gear), new("Tire temperature", LapReviewChannel.TireTemperature), new("Tire slip ratio", LapReviewChannel.SlipRatio), new("Tire slip angle (raw)", LapReviewChannel.SlipAngle), new("Suspension travel", LapReviewChannel.Suspension)];
+ public LapChannelChoice[] Channels { get; } = [new("Speed", LapReviewChannel.Speed), new("Time delta", LapReviewChannel.Delta), new("Throttle", LapReviewChannel.Throttle), new("Brake", LapReviewChannel.Brake), new("Steering input", LapReviewChannel.Steering), new("Lateral G", LapReviewChannel.LateralG), new("Longitudinal G", LapReviewChannel.LongitudinalG), new("Combined G", LapReviewChannel.CombinedG), new("Engine RPM", LapReviewChannel.Rpm), new("Gear", LapReviewChannel.Gear), new("Tire temperature", LapReviewChannel.TireTemperature), new("Tire slip ratio", LapReviewChannel.SlipRatio), new("Tire slip angle (raw)", LapReviewChannel.SlipAngle), new("Suspension travel", LapReviewChannel.Suspension), new("Horsepower", LapReviewChannel.Power), new("Torque", LapReviewChannel.Torque), new("Elevation", LapReviewChannel.Elevation)];
public ICommand SectionStartCommand { get; }
public ICommand SectionEndCommand { get; }
public ICommand WholeLapCommand { get; }
@@ -86,16 +86,34 @@ public LapReviewLap? Lap
get => _lap;
set
{
- if (_disposed || !Set(ref _lap, value)) return;
- _cursor = _sectionStart = 0; _sectionEnd = Math.Max(0, MaximumCursor);
- Changed(nameof(MaximumCursor)); Changed(nameof(Cursor)); Changed(nameof(HasLap)); Changed(nameof(CanPin));
- RaiseCommands();
- if (_applying) RefreshCursor();
- else if (_comparisonRun is null && _settings.LapReviewBenchmarkRunId is not null) _ = LoadAsync();
- else RefreshComparison();
+ if (_disposed || Equals(_lap, value)) return;
+ ChangeScrubContext(() =>
+ {
+ Set(ref _lap, value);
+ _cursor = _sectionStart = 0; _sectionEnd = Math.Max(0, MaximumCursor);
+ Changed(nameof(MaximumCursor)); Changed(nameof(Cursor)); Changed(nameof(HasLap)); Changed(nameof(CanPin));
+ RaiseCommands();
+ if (_applying) RefreshCursor();
+ else if (_comparisonRun is null && _settings.LapReviewBenchmarkRunId is not null) _ = LoadAsync();
+ else RefreshComparison();
+ });
+ }
+ }
+ public LapReviewLap? Reference
+ {
+ get => _reference;
+ set
+ {
+ if (_disposed || Equals(_reference, value)) return;
+ ChangeScrubContext(() =>
+ {
+ Set(ref _reference, value);
+ _referenceCursor = 0;
+ _referenceMapContacts = [];
+ if (!_applying) RefreshComparison();
+ });
}
}
- public LapReviewLap? Reference { get => _reference; set { if (!_disposed && Set(ref _reference, value) && !_applying) RefreshComparison(); } }
public string Status { get => _status; private set => Set(ref _status, value); }
public string ReferenceStatus { get => _referenceStatus; private set => Set(ref _referenceStatus, value); }
public string CaptureStatus { get => _captureStatus; set => Set(ref _captureStatus, value); }
@@ -104,11 +122,28 @@ public LapReviewLap? Lap
public bool CanPin => !_disposed && !_busy && UsableBenchmark(Lap);
public bool IsBusy => _busy;
public int MaximumCursor => Math.Max(0, (Lap?.Points.Length ?? 1) - 1);
- public int Cursor { get => _cursor; set { if (!_disposed && Set(ref _cursor, Math.Clamp(value, 0, Math.Max(0, MaximumCursor)))) { RefreshCursor(); Changed(nameof(Plot)); if (Lap is { Points.Length: > 0 }) CursorMoved?.Invoke(Lap.Points[Cursor].RunSeconds); } } }
+ public int Cursor
+ {
+ get => _cursor;
+ set
+ {
+ if (_disposed) return;
+ value = Math.Clamp(value, 0, MaximumCursor);
+ // Every primary cursor entry point shares Both-mode synchronization:
+ // map/graph picking, keyboard navigation and the lap-position slider.
+ var referenceChanged = SynchronizeRelativeReferenceCursor(value);
+ if (Set(ref _cursor, value))
+ {
+ RefreshCursor(); Changed(nameof(Plot));
+ if (Lap is { Points.Length: > 0 }) CursorMoved?.Invoke(Lap.Points[Cursor].RunSeconds);
+ }
+ else if (referenceChanged) NotifyReferenceCursor();
+ }
+ }
public LapReviewCursorReadout? CursorDetails { get; private set; }
public string CursorText => CursorDetails is { } details ? $"{details.Position} · {details.Speed} · {details.Powertrain}" : "";
public string SectionText { get; private set; } = "";
- public LapReviewPlotData Plot => new(Lap, Reference, _comparison, Channel.Channel, _settings.SpeedUnit, Cursor, _sectionStart, _sectionEnd, SelectedWheel, _settings.TireTemperatureUnit);
+ public LapReviewPlotData Plot => new(Lap, Reference, _comparison, Channel.Channel, _settings.SpeedUnit, Cursor, _sectionStart, _sectionEnd, SelectedWheel, _settings.TireTemperatureUnit, _settings.TorqueUnit, VisibleContacts);
public void RefreshSettings() { if (_disposed) return; Changed(nameof(AutomaticRecording)); RefreshCursor(); FormatSection(); Changed(nameof(Plot)); RaiseCommands(); }
public void SetRuns(RecordedRun? run, RecordedRun? comparison)
{
@@ -154,12 +189,15 @@ private async Task LoadAsync()
return (a, b, current, usePin, pinMatch);
}, cancel.Token);
if (_disposed || cancel.IsCancellationRequested) return;
+ _referenceRun = result.usePin ? pinned : comparisonRun ?? run;
_applying = true;
try
{
Laps.Clear(); foreach (var lap in result.a.Laps) Laps.Add(lap);
ReferenceLaps.Clear(); foreach (var lap in result.b.Laps) ReferenceLaps.Add(lap);
- Reference = ReferenceLaps.Where(UsableBenchmark).MinBy(lap => lap.DurationSeconds);
+ Reference = ReferenceLaps.Where(UsableBenchmark).MinBy(lap => lap.DurationSeconds) ??
+ (comparisonRun is null ? null : ReferenceLaps.LastOrDefault(lap => lap.IsComplete && lap.Points.Length > 1) ??
+ ReferenceLaps.LastOrDefault(lap => lap.Points.Length > 1));
Lap = result.current;
if (Lap is not null && selected is not null && Lap.RunId == selected.RunId &&
Lap.TimingMode == selected.TimingMode && Lap.Points.FirstOrDefault()?.SampleIndex == selected.Points.FirstOrDefault()?.SampleIndex)
@@ -189,7 +227,8 @@ private async Task LoadAsync()
}
private void RefreshComparison()
{
- _comparison = null;
+ _comparison = _reverseComparison = null;
+ SynchronizeRelativeReferenceCursor(Cursor);
RefreshCursor(); Changed(nameof(Plot));
_ = AnalyzeAsync(compare: true);
}
@@ -210,11 +249,14 @@ private void RefreshCursor()
}
private void ClearReview()
{
+ ScrubContextChanging?.Invoke();
_applying = true;
try
{
- _lap = _reference = null; _comparison = null;
- _cursor = _sectionStart = _sectionEnd = 0;
+ _lap = _reference = null; _comparison = _reverseComparison = null;
+ _referenceRun = null; _referenceMapContacts = [];
+ ClearTelemetryContacts();
+ _cursor = _referenceCursor = _sectionStart = _sectionEnd = 0;
Laps.Clear(); ReferenceLaps.Clear();
_sectionStatistics = _referenceStatistics = null; _referenceSectionSeconds = null;
Metrics.Clear(); Events.Clear(); CursorDetails = null; SectionText = "";
@@ -222,7 +264,7 @@ private void ClearReview()
nameof(CanPin), nameof(CursorDetails), nameof(CursorText), nameof(SectionText), nameof(Plot) }) Changed(property);
RaiseCommands();
}
- finally { _applying = false; }
+ finally { _applying = false; NotifyScrubState(); ScrubContextChanged?.Invoke(); }
}
private void RefreshSection() => _ = AnalyzeAsync(compare: false);
@@ -232,7 +274,7 @@ private async Task AnalyzeAsync(bool compare)
if (_disposed || _applying) return;
_analysisCancellation?.Cancel();
var cancel = new CancellationTokenSource(); _analysisCancellation = cancel;
- var lap = Lap; var reference = Reference; var comparison = _comparison;
+ var lap = Lap; var reference = Reference; var comparison = _comparison; var reverse = _reverseComparison;
_sectionStatistics = _referenceStatistics = null; _referenceSectionSeconds = null;
Metrics.Clear(); Events.Clear();
SectionText = lap is { Points.Length: > 1 } ? "Preparing the selected section…" : "";
@@ -242,9 +284,22 @@ private async Task AnalyzeAsync(bool compare)
if (lap is not { Points.Length: > 1 }) return;
_sectionStart = Math.Clamp(_sectionStart, 0, MaximumCursor); _sectionEnd = Math.Clamp(_sectionEnd, _sectionStart, MaximumCursor);
var first = _sectionStart; var last = _sectionEnd;
+ var cachedContacts = PreparedTelemetryContacts(lap);
+ var referenceMarkers = _referenceRun?.Markers ?? [];
+ var cachedReferenceContacts = _referenceMapContacts;
var result = await Task.Run(() =>
{
- if (compare) comparison = reference is null ? null : LapReviewAnalysis.Compare(lap, reference, cancel.Token);
+ var contacts = cachedContacts ?? LapReviewContacts.Find(lap, cancel.Token);
+ var referenceContacts = compare && reference is not null
+ ? LapReviewContacts.Find(reference, cancel.Token).Concat(LapReviewContacts.FromMarkers(reference, referenceMarkers)).OrderBy(contact => contact.PointIndex).ToArray()
+ : cachedReferenceContacts;
+ if (compare)
+ {
+ comparison = reference is null ? null : LapReviewAnalysis.Compare(lap, reference, cancel.Token);
+ reverse = comparison?.CanCompare == true && reference is not null &&
+ new LapReviewPlotData(lap, reference, comparison, LapReviewChannel.Speed, default, 0, 0, 0).HasDistinctReference
+ ? LapReviewAnalysis.Compare(reference, lap, cancel.Token) : null;
+ }
var stats = LapReviewAnalysis.AnalyzeSection(lap, first, last, cancel.Token);
LapSectionStatistics? other = null;
double? referenceSeconds = null;
@@ -257,12 +312,14 @@ private async Task AnalyzeAsync(bool compare)
if (from.ReferenceLapSeconds is { } start && to.ReferenceLapSeconds is { } end && end >= start)
referenceSeconds = end - start;
}
- return (comparison, stats, other, referenceSeconds);
+ return (comparison, reverse, stats, other, referenceSeconds, contacts, referenceContacts);
}, cancel.Token);
if (_disposed || cancel.IsCancellationRequested || !ReferenceEquals(_analysisCancellation, cancel) ||
!ReferenceEquals(lap, Lap) || !ReferenceEquals(reference, Reference)) return;
- _comparison = result.comparison; _sectionStatistics = result.stats;
+ _comparison = result.comparison; _reverseComparison = result.reverse; _sectionStatistics = result.stats;
_referenceStatistics = result.other; _referenceSectionSeconds = result.referenceSeconds;
+ AdoptTelemetryContacts(lap, result.contacts);
+ _referenceMapContacts = result.referenceContacts;
FormatSection(); RefreshCursor(); Changed(nameof(Plot));
}
catch (OperationCanceledException) { }
@@ -270,7 +327,7 @@ private async Task AnalyzeAsync(bool compare)
{
if (!_disposed && !cancel.IsCancellationRequested)
{
- _comparison = null;
+ _comparison = _reverseComparison = null;
SectionText = "This section could not be prepared. Select the lap again to retry.";
Changed(nameof(SectionText)); RefreshCursor(); Changed(nameof(Plot));
}
@@ -338,6 +395,10 @@ private static LapReviewWheelReadout WheelReadout(string label, WheelValues whee
}
private static string Summaries(LapWheelSummaries wheels, Func format) => string.Join(" / ", new[] { wheels.FrontLeft, wheels.FrontRight, wheels.RearLeft, wheels.RearRight }.Select(n => format(n.Mean)));
private bool Set(ref T field, T value, [CallerMemberName] string? name = null) { if (EqualityComparer.Default.Equals(field, value)) return false; field = value; Changed(name); return true; }
- private void Changed([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new(name));
+ private void Changed([CallerMemberName] string? name = null)
+ {
+ PropertyChanged?.Invoke(this, new(name));
+ if (name == nameof(Plot)) RefreshMapDetails();
+ }
public void Dispose() { _disposed = true; _loadCancellation?.Cancel(); _analysisCancellation?.Cancel(); RaiseCommands(); }
}
diff --git a/src/Wisp.App/Runs/RunRecordingService.cs b/src/Wisp.App/Runs/RunRecordingService.cs
index 7a01c13..bbc8d3e 100644
--- a/src/Wisp.App/Runs/RunRecordingService.cs
+++ b/src/Wisp.App/Runs/RunRecordingService.cs
@@ -227,7 +227,7 @@ public async ValueTask DisposeAsync()
{
StartedAtUtc = session.StartedAtUtc,
Name = $"Run {session.StartedAtUtc.ToLocalTime():MMM d, h:mm tt}",
- SchemaVersion = session.TuneAttachment is null ? RecordedRun.BaseSchemaVersion : RecordedRun.CurrentSchemaVersion,
+ SchemaVersion = RecordedRun.CurrentSchemaVersion,
TuneAttachment = session.TuneAttachment
};
long rejected = 0;
diff --git a/src/Wisp.App/Runs/RunStore.cs b/src/Wisp.App/Runs/RunStore.cs
index cf03986..504d0f2 100644
--- a/src/Wisp.App/Runs/RunStore.cs
+++ b/src/Wisp.App/Runs/RunStore.cs
@@ -86,7 +86,7 @@ public Task> ListAsync() => InBackground SaveAsync(RecordedRun run) => InBackground(async () =>
{
- await WriteAsync(run, overwrite: false).ConfigureAwait(false);
+ await WriteAsync(run, overwrite: false, finalizeContactFormat: true).ConfigureAwait(false);
return Summarize(run);
});
@@ -189,7 +189,14 @@ internal RunJournal CreateJournal(RecordedRun header, RunJournalStartReservation
EnsureDirectory();
EnsureCapacity(header.Id);
if (!_activeJournals.TryAdd(header.Id, 0)) throw new InvalidOperationException("This run is already being recorded.");
- try { return new RunJournal(Path.Combine(_directory, $"{header.Id:N}.partial"), header, () => _activeJournals.TryRemove(header.Id, out _)); }
+ try
+ {
+ if (!ValidSchema(header)) throw new InvalidDataException("The run format is unsupported.");
+ // Older readers must reject the journal before recovering a prefix and
+ // discarding later samples whose contact fields they cannot deserialize.
+ return new RunJournal(Path.Combine(_directory, $"{header.Id:N}.partial"),
+ header with { SchemaVersion = RecordedRun.CurrentSchemaVersion }, () => _activeJournals.TryRemove(header.Id, out _));
+ }
catch { _activeJournals.TryRemove(header.Id, out _); throw; }
}
finally { reservation.Dispose(); }
@@ -272,9 +279,25 @@ private static async Task ReadAsync(string path, Guid? expectedId =
return run;
}
- private async Task WriteAsync(RecordedRun run, bool overwrite)
+ private async Task WriteAsync(RecordedRun run, bool overwrite, bool finalizeContactFormat = false)
{
+ if ((run.SchemaVersion is RecordedRun.BaseSchemaVersion or RecordedRun.TuneAttachmentSchemaVersion) &&
+ ValidSchema(run) && run.Samples is { } samples && samples.Any(sample => sample?.State is { } state && HasContactData(state)))
+ run = run with { SchemaVersion = RecordedRun.CurrentSchemaVersion };
Validate(run);
+ // Journals retain v3 until every sample has been read. Only finalized new
+ // recordings without any positive object evidence can omit the optional
+ // fields; imports and edits of existing files retain their declared format.
+ if (finalizeContactFormat && run.SchemaVersion == RecordedRun.CurrentSchemaVersion &&
+ !run.Samples.Any(sample => sample.State.SmashableVelocityLossMetersPerSecond is > 0 || sample.State.SmashableMassKilograms is > 0))
+ run = run with
+ {
+ SchemaVersion = run.TuneAttachment is null ? RecordedRun.BaseSchemaVersion : RecordedRun.TuneAttachmentSchemaVersion,
+ Samples = run.Samples.Select(sample => HasContactData(sample.State) ? sample with
+ {
+ State = sample.State with { SmashableVelocityLossMetersPerSecond = null, SmashableMassKilograms = null }
+ } : sample).ToArray()
+ };
if (!overwrite) EnsureCapacity(run.Id);
var destination = RunPath(run.Id);
var temporary = Path.Combine(_directory, $".{run.Id:N}-{Guid.NewGuid():N}.tmp");
@@ -370,7 +393,7 @@ await WriteAsync(header with
IsIncomplete = true,
TuneAttachment = header.TuneAttachment is { } attachment ? attachment with { DrivingContinuityInterrupted = true } : null,
FinishReason = "Recovered after Wisp closed before the run finished"
- }, false).ConfigureAwait(false);
+ }, overwrite: false, finalizeContactFormat: true).ConfigureAwait(false);
reader.Dispose();
file.Dispose();
File.Delete(path);
@@ -407,6 +430,8 @@ run.Markers is null || run.Markers.Length > MaximumMarkers ||
foreach (var sample in run.Samples)
{
ValidateSample(sample, previous);
+ if (run.SchemaVersion < RecordedRun.CurrentSchemaVersion && HasContactData(sample.State))
+ throw new InvalidDataException("Object-contact telemetry requires run format version 3.");
if (sample.State.CarOrdinal != run.Samples[0].State.CarOrdinal || sample.State.Drivetrain != run.Samples[0].State.Drivetrain)
throw new InvalidDataException("A run cannot combine different cars or drivetrains.");
previous = sample;
@@ -429,10 +454,14 @@ run.Markers is null || run.Markers.Length > MaximumMarkers ||
private static bool ValidSchema(RecordedRun run) => run.SchemaVersion switch
{
RecordedRun.BaseSchemaVersion => run.TuneAttachment is null,
- RecordedRun.CurrentSchemaVersion => run.TuneAttachment is { IsValid: true },
+ RecordedRun.TuneAttachmentSchemaVersion => run.TuneAttachment is { IsValid: true },
+ RecordedRun.CurrentSchemaVersion => run.TuneAttachment is null or { IsValid: true },
_ => false
};
+ private static bool HasContactData(VehicleState state) =>
+ state.SmashableVelocityLossMetersPerSecond.HasValue || state.SmashableMassKilograms.HasValue;
+
private static void ValidateMarker(RunMarker marker, RunMarker? previous, double duration)
{
if (marker is null || !double.IsFinite(marker.ElapsedSeconds) || marker.ElapsedSeconds < 0 || marker.ElapsedSeconds > duration ||
@@ -451,6 +480,9 @@ internal static void ValidateSample(RunSample sample, RunSample? previous)
(state.LocalVelocityXMetersPerSecond is { } localX && !FiniteBound(localX, 500)) ||
(state.LocalVelocityYMetersPerSecond is { } localY && !FiniteBound(localY, 500)) ||
(state.LocalVelocityZMetersPerSecond is { } localZ && !FiniteBound(localZ, 500)) ||
+ state.SmashableVelocityLossMetersPerSecond.HasValue != state.SmashableMassKilograms.HasValue ||
+ (state.SmashableVelocityLossMetersPerSecond is { } impactLoss && (!FiniteBound(impactLoss, 500) || impactLoss < 0)) ||
+ (state.SmashableMassKilograms is { } impactMass && (!FiniteBound(impactMass, 10_000_000) || impactMass < 0)) ||
!FiniteBound(state.EngineMaximumRpm, 30_000) || state.EngineMaximumRpm < 0 ||
!FiniteBound(state.PowerWatts, 100_000_000) || !FiniteBound(state.TorqueNm, 10_000_000) || !FiniteBound(state.BoostPressurePsi, 200) ||
!FiniteBound(state.LateralAccelerationMetersPerSecondSquared, 10_000) || !FiniteBound(state.LongitudinalAccelerationMetersPerSecondSquared, 10_000) ||
diff --git a/src/Wisp.App/Runs/RunWorkspaceSettings.cs b/src/Wisp.App/Runs/RunWorkspaceSettings.cs
index 9537632..65ee647 100644
--- a/src/Wisp.App/Runs/RunWorkspaceSettings.cs
+++ b/src/Wisp.App/Runs/RunWorkspaceSettings.cs
@@ -66,12 +66,13 @@ public sealed class RunWorkspaceSettings
{
public RunWorkspacePreset Preset { get; set; } = RunWorkspacePreset.Overview;
public RunWorkspaceComparisonMode ComparisonMode { get; set; }
+ public double LapMapHeight { get; set; }
public List Panels { get; set; } = RunWorkspaceCatalog.CreatePanels(RunWorkspacePreset.Overview);
public RunWorkspaceSettings Clone()
{
var copy = new RunWorkspaceSettings
- { Preset = Preset, ComparisonMode = ComparisonMode, Panels = Panels?.Where(panel => panel is not null).Select(panel => panel.Clone()).ToList()! };
+ { Preset = Preset, ComparisonMode = ComparisonMode, LapMapHeight = LapMapHeight, Panels = Panels?.Where(panel => panel is not null).Select(panel => panel.Clone()).ToList()! };
copy.Normalize();
return copy;
}
@@ -82,6 +83,7 @@ public void Normalize()
if (!Enum.IsDefined(Preset) || Preset is RunWorkspacePreset.Acceleration or RunWorkspacePreset.Drifting)
Preset = RunWorkspacePreset.Overview;
if (!Enum.IsDefined(ComparisonMode)) ComparisonMode = RunWorkspaceComparisonMode.Overlay;
+ LapMapHeight = double.IsFinite(LapMapHeight) && LapMapHeight > 0 ? Math.Clamp(LapMapHeight, 70, 1400) : 0;
if (Panels is null) Panels = RunWorkspaceCatalog.CreatePanels(Preset);
var clean = new List(RunWorkspaceCatalog.MaximumModules);
var ids = new HashSet(StringComparer.Ordinal);
diff --git a/src/Wisp.App/Runs/RunsPage.xaml b/src/Wisp.App/Runs/RunsPage.xaml
index ecaeb08..88e0169 100644
--- a/src/Wisp.App/Runs/RunsPage.xaml
+++ b/src/Wisp.App/Runs/RunsPage.xaml
@@ -341,42 +341,40 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+