diff --git a/.claude/skills/f1-add-game/SKILL.md b/.claude/skills/f1-add-game/SKILL.md new file mode 100644 index 0000000..45a731b --- /dev/null +++ b/.claude/skills/f1-add-game/SKILL.md @@ -0,0 +1,409 @@ +--- +name: f1-add-game +description: Adds the required telemetry structs for a specific game year of the F1 game series made by EA/Codemasters, based on their C++ specification files. +--- + +# F1 UDP Telemetry Implementation Skill + +You are an expert F1 UDP telemetry implementation specialist for the GamesDat project. Your role is to convert C++ specification files from EA/Codemasters into C# structs that can deserialize UDP packets. + +## Invocation + +When invoked with `/f1-implementation ` (e.g., `/f1-implementation 2026`), follow this workflow to implement F1 telemetry packet structures. + +## Step 0: Locate the C++ Specification + +**Before starting implementation, you MUST locate the C++ specification file using these strategies:** + +### Strategy 1: Check Convention-Based Location +Look for specification files in these locations: +- `Telemetry/Sources/Formula1/Specifications/F1__spec.h` +- `Telemetry/Sources/Formula1/Specifications/F1_spec.h` +- `Telemetry/Sources/Formula1/Specifications/_udp_spec.h` +- `Telemetry/Sources/Formula1/Docs/` directory + +Use Glob tool to search: +``` +pattern: "Telemetry/Sources/Formula1/**/*.h" +pattern: "Telemetry/Sources/Formula1/**/*.cpp" +pattern: "Telemetry/Sources/Formula1/**/*spec*" +pattern: "Telemetry/Sources/Formula1/**/**" +``` + +### Strategy 2: Search Repository-Wide +If not found in Formula1 directory, search the entire repository: +``` +pattern: "**/*F1**.h" +pattern: "**/*udp**.h" +``` + +### Strategy 3: Ask User for Specification +If specification is not found, ask the user: + +**Use AskUserQuestion tool with these options:** +- **Question**: "I couldn't find the C++ specification file for F1 . How would you like to provide it?" +- **Header**: "Spec Location" +- **Options**: + 1. **Label**: "File path on disk", **Description**: "I'll provide the path to the .h or .cpp file" + 2. **Label**: "Paste content", **Description**: "I'll paste the C++ specification content directly" + 3. **Label**: "Download from URL", **Description**: "I'll provide a URL to download the spec" + 4. **Label**: "Use previous year as template", **Description**: "Copy from F1 and I'll modify manually" + +### Strategy 4: Handle User Response +- **If file path provided**: Read the file using Read tool +- **If content pasted**: Proceed with the pasted content +- **If URL provided**: Use WebFetch or Bash (curl) to download +- **If template requested**: Copy previous year's implementation and note differences for manual review + +**IMPORTANT**: Do NOT proceed with implementation until you have the specification content in hand. + +--- + +## Implementation Workflow + +Once you have the C++ specification, follow these steps: + +### 1. Analyze the C++ Specification + +Review the spec to understand: +- **Packet format version** (e.g., 2024, 2025) +- **Packet sizes** (documented in comments) +- **Constants** (e.g., `cs_maxNumCarsInUDPData = 22`) +- **Struct hierarchy** (which structs contain others) +- **Array sizes** (fixed-size arrays in structs) + +### 2. Create the Namespace Folder + +Create: `Telemetry/Sources/Formula1/F1/` + +Example: `Telemetry/Sources/Formula1/F12026/` + +### 3. Implement the PacketHeader + +Start with the `PacketHeader` struct as it's used by all packet types: + +```csharp +using System.Runtime.InteropServices; + +namespace GamesDat.Core.Telemetry.Sources.Formula1.F1 +{ + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct PacketHeader + { + public ushort m_packetFormat; // + public byte m_gameYear; // Last two digits e.g. 26 + public byte m_gameMajorVersion; // Game major version + public byte m_gameMinorVersion; // Game minor version + public byte m_packetVersion; // Version of this packet type + public byte m_packetId; // Identifier for the packet type + public ulong m_sessionUID; // Unique identifier for the session + public float m_sessionTime; // Session timestamp + public uint m_frameIdentifier; // Identifier for the frame + public uint m_overallFrameIdentifier; // Overall identifier + public byte m_playerCarIndex; // Index of player's car + public byte m_secondaryPlayerCarIndex; // Index of secondary player's car + } +} +``` + +**Key points:** +- Always use `[StructLayout(LayoutKind.Sequential, Pack = 1)]` +- Keep field names exactly as in C++ spec (helps with debugging) +- Add inline comments from the C++ spec + +### 4. Implement Support Structures + +Create the smaller, reusable structs before the main packet types. + +Examples: `MarshalZone`, `WeatherForecastSample`, `CarMotionData`, etc. + +**Pattern:** +```csharp +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct StructName +{ + public type m_fieldName; // comment from spec + // ... more fields +} +``` + +### 5. Implement Main Packet Structures + +For each packet type from the spec, create: + +1. **Component struct** (e.g., `LapData`) - data for one car/item +2. **Packet struct** (e.g., `PacketLapData`) - complete packet with header and array + +**Pattern:** +```csharp +// Component for one car +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct ComponentData +{ + public type m_field1; + public type m_field2; +} + +// Full packet +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct PacketComponentData +{ + public PacketHeader m_header; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 22)] + public ComponentData[] m_dataArray; + + // ... any additional single fields +} +``` + +### 6. Handle Special Cases + +#### Fixed-Size Arrays +```csharp +[MarshalAs(UnmanagedType.ByValArray, SizeConst = SIZE)] +public Type[] m_arrayField; +``` + +#### Strings/Character Arrays +```csharp +[MarshalAs(UnmanagedType.ByValArray, SizeConst = LENGTH)] +public byte[] m_name; // UTF-8 null-terminated string +``` + +#### Unions (EventDataDetails) +Use `StructLayout(LayoutKind.Explicit)` with `FieldOffset`: + +```csharp +[StructLayout(LayoutKind.Explicit)] +public struct EventDataDetails +{ + [FieldOffset(0)] public FastestLapData FastestLap; + [FieldOffset(0)] public RetirementData Retirement; + // ... all union members at offset 0 +} +``` + +Then create a wrapper with helper methods: +```csharp +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct PacketEventData +{ + public PacketHeader m_header; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] + public byte[] m_eventStringCode; + + public EventDataDetails m_eventDetails; + + // Helper to get event code as string + public string EventCode => System.Text.Encoding.ASCII.GetString(m_eventStringCode); + + // Helper to safely extract typed event details + public T GetEventDetails() where T : struct + { + // Implementation for safe union access + } +} +``` + +### 7. Type Mapping Reference + +| C++ Type | C# Type | Notes | +|----------|---------|-------| +| `uint8` | `byte` | Unsigned 8-bit | +| `int8` | `sbyte` | Signed 8-bit | +| `uint16` | `ushort` | Unsigned 16-bit | +| `int16` | `short` | Signed 16-bit | +| `uint32` / `uint` | `uint` | Unsigned 32-bit | +| `int32` | `int` | Signed 32-bit | +| `uint64` | `ulong` | Unsigned 64-bit | +| `float` | `float` | 32-bit float | +| `double` | `double` | 64-bit float | +| `char[]` | `byte[]` | UTF-8 strings | + +### 8. Update F1PacketTypeMapper + +Add mappings for all packet types to `Telemetry/Sources/Formula1/F1PacketTypeMapper.cs`: + +```csharp +private static readonly Dictionary<(ushort format, byte id), Type> _packetTypeMap = new() +{ + // F1 + [(, (byte)PacketId.Motion)] = typeof(F1.PacketMotionData), + [(, (byte)PacketId.Session)] = typeof(F1.PacketSessionData), + [(, (byte)PacketId.LapData)] = typeof(F1.PacketLapData), + [(, (byte)PacketId.Event)] = typeof(F1.PacketEventData), + [(, (byte)PacketId.Participants)] = typeof(F1.PacketParticipantsData), + [(, (byte)PacketId.CarSetups)] = typeof(F1.PacketCarSetupData), + [(, (byte)PacketId.CarTelemetry)] = typeof(F1.PacketCarTelemetryData), + [(, (byte)PacketId.CarStatus)] = typeof(F1.PacketCarStatusData), + [(, (byte)PacketId.FinalClassification)] = typeof(F1.PacketFinalClassificationData), + [(, (byte)PacketId.LobbyInfo)] = typeof(F1.PacketLobbyInfoData), + [(, (byte)PacketId.CarDamage)] = typeof(F1.PacketCarDamageData), + [(, (byte)PacketId.SessionHistory)] = typeof(F1.PacketSessionHistoryData), + [(, (byte)PacketId.TyreSets)] = typeof(F1.PacketTyreSetsData), + [(, (byte)PacketId.MotionEx)] = typeof(F1.PacketMotionExData), + [(, (byte)PacketId.TimeTrial)] = typeof(F1.PacketTimeTrialData), + // ... existing previous years ... +}; +``` + +**Important:** Use fully qualified type names (e.g., `F12024.PacketMotionData`) to avoid conflicts between years. + +### 9. Packet Types Checklist + +Ensure you implement all standard packet types: + +- [ ] Motion (`PacketMotionData`) +- [ ] Session (`PacketSessionData`) +- [ ] Lap Data (`PacketLapData`) +- [ ] Event (`PacketEventData`) +- [ ] Participants (`PacketParticipantsData`) +- [ ] Car Setups (`PacketCarSetupData`) +- [ ] Car Telemetry (`PacketCarTelemetryData`) +- [ ] Car Status (`PacketCarStatusData`) +- [ ] Final Classification (`PacketFinalClassificationData`) +- [ ] Lobby Info (`PacketLobbyInfoData`) +- [ ] Car Damage (`PacketCarDamageData`) +- [ ] Session History (`PacketSessionHistoryData`) +- [ ] Tyre Sets (`PacketTyreSetsData`) +- [ ] Motion Ex (`PacketMotionExData`) +- [ ] Time Trial (`PacketTimeTrialData`) + +**Note:** Some years may have additional packet types or missing ones. Follow the specification exactly. + +### 10. Validation + +After implementation: + +1. **Build the project** - `dotnet build` to ensure no compilation errors +2. **Check struct sizes** - if possible, verify binary size matches spec comments +3. **Compare with previous year** - look for differences and ensure they're intentional +4. **Create summary** - list all implemented packet types and any notable changes from previous year + +--- + +## Best Practices + +1. **Maintain exact field order** - C# struct layout must match C++ exactly +2. **Use Pack = 1** - prevents automatic padding/alignment +3. **Keep original naming** - makes comparison with spec easier +4. **Add XML comments** - for complex structs, add summary docs +5. **Preserve spec comments** - inline comments help understand field meaning +6. **Reference existing implementations** - use previous years as templates (F12024, F12025) +7. **Watch for year-specific changes** - array sizes, new fields, removed fields +8. **One file per packet type** - keeps code organized and maintainable + +## File Organization Pattern + +``` +Formula1/ +├── F1/ +│ ├── PacketHeader.cs +│ ├── CarMotionData.cs +│ ├── PacketMotionData.cs +│ ├── MarshalZone.cs +│ ├── WeatherForecastSample.cs +│ ├── PacketSessionData.cs +│ ├── LapData.cs +│ ├── PacketLapData.cs +│ └── ... (all other packet types) +├── F1PacketTypeMapper.cs (update this) +├── PacketId.cs (shared enum) +└── EventCodes.cs (shared constants) +``` + +## Common Patterns Reference + +### Pattern 1: Simple Data Struct +```csharp +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct SimpleData +{ + public byte m_field1; + public float m_field2; +} +``` + +### Pattern 2: Packet with Array +```csharp +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct PacketWithArray +{ + public PacketHeader m_header; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 22)] + public ItemData[] m_items; +} +``` + +### Pattern 3: Nested Arrays +```csharp +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct DataWithArrays +{ + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] + public float[] m_tyresPressure; // 4 tyres +} +``` + +### Pattern 4: Data + Extra Fields +```csharp +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct PacketWithExtras +{ + public PacketHeader m_header; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 22)] + public ItemData[] m_items; + + public byte m_extraField1; + public byte m_extraField2; +} +``` + +## Troubleshooting + +### Issue: Struct size doesn't match spec +- Check for missing `Pack = 1` +- Verify all arrays have correct `SizeConst` +- Ensure no fields were skipped +- Check type mappings (e.g., `int8` vs `uint8`) + +### Issue: Deserialization fails +- Verify packet header format number matches +- Check PacketTypeMapper has correct entries +- Ensure union types use `LayoutKind.Explicit` +- Validate field order exactly matches spec + +### Issue: Data seems corrupted +- Check endianness (should be little-endian) +- Verify `Pack = 1` is set +- Ensure no extra padding between fields +- Check array sizes match spec constants + +--- + +## Execution Approach + +When this skill is invoked: + +1. **Extract the year** from the invocation (e.g., "2026" from `/f1-implementation 2026`) +2. **Locate the specification** using the strategies in Step 0 +3. **Read existing implementations** (F12024, F12025) to understand patterns +4. **Implement all packet types** following the workflow above +5. **Update F1PacketTypeMapper** with new mappings +6. **Build and validate** the implementation +7. **Provide summary** of what was implemented + +## Reference Implementations + +Use these existing implementations as templates: +- `Telemetry/Sources/Formula1/F12024/` - Complete F1 2024 implementation +- `Telemetry/Sources/Formula1/F12025/` - Complete F1 2025 implementation +- `Telemetry/Sources/Formula1/F1_IMPLEMENTATION_WORKFLOW.md` - Original workflow documentation + +--- + +*This skill automates F1 UDP telemetry implementation for GamesDat.* diff --git a/.gitignore b/.gitignore index f2ac66a..4e55a36 100644 --- a/.gitignore +++ b/.gitignore @@ -362,4 +362,6 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd -.claude/settings.local.json \ No newline at end of file +**/.claude/settings.local.json + +DebugTooling/**/* \ No newline at end of file diff --git a/GamesDat.Demo.Wpf/.claude/settings.local.json b/GamesDat.Demo.Wpf/.claude/settings.local.json deleted file mode 100644 index fc85941..0000000 --- a/GamesDat.Demo.Wpf/.claude/settings.local.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(dotnet build:*)", - "WebSearch", - "WebFetch(domain:github.com)" - ] - } -} diff --git a/GamesDat.Demo.Wpf/GamesDat.Demo.Wpf.csproj b/GamesDat.Demo.Wpf/GamesDat.Demo.Wpf.csproj index 566fa83..f4ca665 100644 --- a/GamesDat.Demo.Wpf/GamesDat.Demo.Wpf.csproj +++ b/GamesDat.Demo.Wpf/GamesDat.Demo.Wpf.csproj @@ -1,7 +1,7 @@  - WinExe + Exe net9.0-windows enable enable diff --git a/GamesDat.Demo.Wpf/Properties/launchSettings.json b/GamesDat.Demo.Wpf/Properties/launchSettings.json index 153c610..f796aca 100644 --- a/GamesDat.Demo.Wpf/Properties/launchSettings.json +++ b/GamesDat.Demo.Wpf/Properties/launchSettings.json @@ -1,7 +1,10 @@ { "profiles": { "GamesDat.Demo.Wpf": { - "commandName": "Project" + "commandName": "Project", + "environmentVariables": { + "DEBUG": "1" + } } } } \ No newline at end of file diff --git a/GamesDat.Demo.Wpf/ViewModels/F1RealtimeSourceViewModel.cs b/GamesDat.Demo.Wpf/ViewModels/F1RealtimeSourceViewModel.cs new file mode 100644 index 0000000..76a263d --- /dev/null +++ b/GamesDat.Demo.Wpf/ViewModels/F1RealtimeSourceViewModel.cs @@ -0,0 +1,166 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GamesDat.Core; +using GamesDat.Core.Telemetry.Sources; +using GamesDat.Core.Telemetry.Sources.Formula1; +using GamesDat.Core.Writer; +using System.Windows.Input; + +namespace GamesDat.Demo.Wpf.ViewModels; + +public partial class F1RealtimeSourceViewModel : ViewModelBase, IRealtimeSource +{ + private GameSession? _gameSession; + private CancellationTokenSource? _cts; + + [ObservableProperty] + private string _sourceName = "Formula 1"; + + [ObservableProperty] + private bool _isRunning; + + [ObservableProperty] + private string _statusMessage = "Stopped"; + + [ObservableProperty] + private int _totalPacketsReceived; + + [ObservableProperty] + private string? _sessionOutputPath; + + [ObservableProperty] + private int _port = 20777; + + // Explicit interface implementations for command covariance + ICommand IRealtimeSource.StartCommand => StartCommand; + ICommand IRealtimeSource.StopCommand => StopCommand; + ICommand IRealtimeSource.ClearDataCommand => ClearDataCommand; + + [RelayCommand(CanExecute = nameof(CanStart))] + private async Task StartAsync() + { + if (IsRunning) return; + + try + { + StatusMessage = "Starting..."; + + // Create F1 realtime telemetry source + var options = new UdpSourceOptions + { + Port = Port + }; + + // Set output path for session recording + SessionOutputPath = $"./sessions/f1_session_{DateTime.UtcNow:yyyy-MM-dd_HH-mm-ss}.dat"; + + F1RealtimeTelemetrySource f1Source; + try + { + f1Source = new F1RealtimeTelemetrySource(options); + f1Source.OutputTo(SessionOutputPath).UseWriter(new BinarySessionWriter()); + } + catch (System.Net.Sockets.SocketException ex) + { + StatusMessage = $"Failed to bind to port {Port}: {ex.Message}. Another application may be using this port."; + IsRunning = false; + return; + } + + // Create game session + _gameSession = new GameSession(defaultOutputDirectory: "./sessions") + .AddSource(f1Source); + + // Simple callback to count packets + _gameSession.OnData(frame => + { + Interlocked.Increment(ref _totalPacketsReceived); + + // Update status message periodically + var count = _totalPacketsReceived; + if (count == 1) + { + StatusMessage = $"Receiving data on UDP port {Port}"; + } + else if (count % 100 == 0) + { + StatusMessage = $"Running... ({count} packets)"; + } + }); + + // Start the session + _cts = new CancellationTokenSource(); + IsRunning = true; + + // Start game session in background + _ = Task.Run(async () => + { + try + { + await _gameSession.StartAsync(_cts.Token); + } + catch (OperationCanceledException) + { + // Normal cancellation + } + catch (Exception ex) + { + StatusMessage = $"Session Error: {ex.Message}"; + IsRunning = false; + } + }); + + // Give it a moment to start + await Task.Delay(500); + + StatusMessage = $"Listening on UDP port {Port} (Waiting for F1 game data...)"; + } + catch (Exception ex) + { + StatusMessage = $"Startup Error: {ex.Message}"; + IsRunning = false; + } + } + + private bool CanStart() => !IsRunning; + + [RelayCommand(CanExecute = nameof(CanStop))] + private async Task StopAsync() + { + _cts?.Cancel(); + + if (_gameSession != null) + { + await _gameSession.StopAsync(); + await _gameSession.DisposeAsync(); + _gameSession = null; + } + + IsRunning = false; + StatusMessage = "Stopped"; + } + + private bool CanStop() => IsRunning; + + [RelayCommand] + private void ClearData() + { + TotalPacketsReceived = 0; + } + + partial void OnIsRunningChanged(bool value) + { + StartCommand.NotifyCanExecuteChanged(); + StopCommand.NotifyCanExecuteChanged(); + } + + public void Dispose() + { + _cts?.Cancel(); + _gameSession?.StopAsync().GetAwaiter().GetResult(); + _gameSession?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _gameSession = null; + _cts?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/GamesDat.Demo.Wpf/ViewModels/IRealtimeSource.cs b/GamesDat.Demo.Wpf/ViewModels/IRealtimeSource.cs new file mode 100644 index 0000000..92f40c2 --- /dev/null +++ b/GamesDat.Demo.Wpf/ViewModels/IRealtimeSource.cs @@ -0,0 +1,13 @@ +using System.Windows.Input; + +namespace GamesDat.Demo.Wpf.ViewModels; + +public interface IRealtimeSource : IDisposable +{ + string SourceName { get; } + bool IsRunning { get; } + string StatusMessage { get; } + ICommand StartCommand { get; } + ICommand StopCommand { get; } + ICommand ClearDataCommand { get; } +} diff --git a/GamesDat.Demo.Wpf/ViewModels/RealtimeSourceViewModel.cs b/GamesDat.Demo.Wpf/ViewModels/RealtimeSourceViewModel.cs index 4c77421..1548da1 100644 --- a/GamesDat.Demo.Wpf/ViewModels/RealtimeSourceViewModel.cs +++ b/GamesDat.Demo.Wpf/ViewModels/RealtimeSourceViewModel.cs @@ -7,10 +7,11 @@ using System.Collections.ObjectModel; using System.IO; using System.Text; +using System.Windows.Input; namespace GamesDat.Demo.Wpf.ViewModels; -public partial class RealtimeSourceViewModel : ViewModelBase, IDisposable +public partial class RealtimeSourceViewModel : ViewModelBase, IRealtimeSource { private readonly Func? _sessionFactory; private GameSession? _session; @@ -41,6 +42,11 @@ public partial class RealtimeSourceViewModel : ViewModelBase, IDisposable public ObservableCollection DataPoints { get; } = []; + // Explicit interface implementations for command covariance + ICommand IRealtimeSource.StartCommand => StartCommand; + ICommand IRealtimeSource.StopCommand => StopCommand; + ICommand IRealtimeSource.ClearDataCommand => ClearDataCommand; + public RealtimeSourceViewModel( string sourceName, Func? sessionFactory) diff --git a/GamesDat.Demo.Wpf/ViewModels/RealtimeTabViewModel.cs b/GamesDat.Demo.Wpf/ViewModels/RealtimeTabViewModel.cs index 103383b..216558c 100644 --- a/GamesDat.Demo.Wpf/ViewModels/RealtimeTabViewModel.cs +++ b/GamesDat.Demo.Wpf/ViewModels/RealtimeTabViewModel.cs @@ -9,10 +9,10 @@ namespace GamesDat.Demo.Wpf.ViewModels; public partial class RealtimeTabViewModel : ViewModelBase, IDisposable { - public ObservableCollection Sources { get; } = []; + public ObservableCollection Sources { get; } = []; [ObservableProperty] - private RealtimeSourceViewModel? _selectedSource; + private IRealtimeSource? _selectedSource; public RealtimeTabViewModel() { @@ -72,6 +72,10 @@ private void InitializeBuiltInSources() trackmaniaSourceRef = trackmaniaSource; Sources.Add(trackmaniaSource); + + // Add F1 source + var f1Source = new F1RealtimeSourceViewModel(); + Sources.Add(f1Source); } [RelayCommand] diff --git a/GamesDat.Demo.Wpf/Views/RealtimeTabView.xaml b/GamesDat.Demo.Wpf/Views/RealtimeTabView.xaml index f7de4a5..b74b3f9 100644 --- a/GamesDat.Demo.Wpf/Views/RealtimeTabView.xaml +++ b/GamesDat.Demo.Wpf/Views/RealtimeTabView.xaml @@ -4,6 +4,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:GamesDat.Demo.Wpf.ViewModels" + xmlns:views="clr-namespace:GamesDat.Demo.Wpf.Views" mc:Ignorable="d" d:DataContext="{d:DesignInstance Type=vm:RealtimeTabViewModel}"> @@ -38,7 +39,8 @@ SelectedItem="{Binding SelectedSource}" Margin="10,0" HorizontalContentAlignment="Stretch"> - + + @@ -92,6 +94,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +