diff --git a/README.md b/README.md index afb21fc..e337ed1 100644 --- a/README.md +++ b/README.md @@ -107,11 +107,15 @@ a null-modem cable work the same way. The cockpit's second serial device is the **plasma display**: a 128×32 dot-matrix panel on COM2 (9600 8N1, no flow control) that the game draws -mission text and status graphics on. `VPlasma.App` is its software replica: -a bare-glass window that opens **COM12** (the device end of the plasma's -null-modem pair) on startup — retrying while the port is missing or busy, -port status in the title bar — decodes the display's command stream, and -renders the dot matrix in plasma orange, text mode included. +mission text and status graphics on. The software replica comes in two +forms. **Built into vRIO**: the panel's encoder strip hosts the glass at +top left, with its own port row in the control strip (label colour = port +status; auto-opens **COM12** at startup when present). And standalone, +`VPlasma.App`: a bare-glass window that opens **COM12** (the device end of +the plasma's null-modem pair) on startup — retrying while the port is +missing or busy, port status in the title bar. Both decode the display's +command stream and render the dot matrix in plasma orange, text mode +included; only one can hold COM12 at a time. The command set was recovered from two Tesla 4.10 artifacts: `CODE\RP\MUNGA_L4\L4PLASMA.CPP` (the game's driver — it renders everything diff --git a/src/VPlasma.App/PlasmaCanvas.cs b/src/VPlasma.App/PlasmaCanvas.cs index 613a30f..f42b393 100644 --- a/src/VPlasma.App/PlasmaCanvas.cs +++ b/src/VPlasma.App/PlasmaCanvas.cs @@ -11,10 +11,14 @@ namespace VPlasma.App; /// internal sealed class PlasmaCanvas : Control { - private const int DotPitch = 5; // 4 px dot + 1 px gap → a 640×160 dot field - private const int DotSize = 4; private const int Bezel = 4; + // Dot geometry: an N px pitch renders (N−1) px dots with a 1 px gap. The + // standalone glass uses the default pitch 5 (4 px dots, a 640×160 dot + // field); vRIO embeds the same control at pitch 3 to fit its encoder strip. + private readonly int _dotPitch; + private readonly int _dotSize; + private static readonly Color BezelColor = Color.FromArgb(20, 18, 16); private static readonly Color GlassColor = Color.FromArgb(26, 14, 6); private static readonly Color UnlitDot = Color.FromArgb(46, 24, 10); @@ -30,14 +34,14 @@ internal sealed class PlasmaCanvas : Control private PlasmaCursorMode _cursorMode; private int _cellWidth = 6, _cellHeight = 8; - public PlasmaCanvas() + public PlasmaCanvas(int dotPitch = 5) { + _dotPitch = dotPitch; + _dotSize = dotPitch - 1; SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.FixedWidth | ControlStyles.FixedHeight, true); - Size = new Size( - VPlasmaDevice.Width * DotPitch + 2 * Bezel, - VPlasmaDevice.Height * DotPitch + 2 * Bezel); + Size = SizeFor(dotPitch); BackColor = BezelColor; _blinkTimer.Tick += (_, _) => @@ -49,6 +53,11 @@ internal sealed class PlasmaCanvas : Control _blinkTimer.Start(); } + /// Control size at a given dot pitch: the dot field plus bezel. + public static Size SizeFor(int dotPitch) => new( + VPlasmaDevice.Width * dotPitch + 2 * Bezel, + VPlasmaDevice.Height * dotPitch + 2 * Bezel); + /// Snapshot the device state and repaint. Call on the UI thread. public void UpdateFrom(VPlasmaDevice device) { @@ -77,7 +86,7 @@ internal sealed class PlasmaCanvas : Control using (var glass = new SolidBrush(GlassColor)) g.FillRectangle(glass, Bezel - 4, Bezel - 4, - VPlasmaDevice.Width * DotPitch + 7, VPlasmaDevice.Height * DotPitch + 7); + VPlasmaDevice.Width * _dotPitch + 7, VPlasmaDevice.Height * _dotPitch + 7); using var unlit = new SolidBrush(UnlitDot); using var lit = new SolidBrush(LitDot); @@ -86,7 +95,7 @@ internal sealed class PlasmaCanvas : Control for (int y = 0; y < VPlasmaDevice.Height; ++y) { int rowOffset = y * VPlasmaDevice.Width; - int py = Bezel + y * DotPitch; + int py = Bezel + y * _dotPitch; for (int x = 0; x < VPlasmaDevice.Width; ++x) { byte dot = _frame[rowOffset + x]; @@ -96,7 +105,7 @@ internal sealed class PlasmaCanvas : Control { brush = (dot & VPlasmaDevice.PixelHalf) != 0 ? half : lit; } - g.FillRectangle(brush, Bezel + x * DotPitch, py, DotSize, DotSize); + g.FillRectangle(brush, Bezel + x * _dotPitch, py, _dotSize, _dotSize); } } @@ -108,7 +117,7 @@ internal sealed class PlasmaCanvas : Control int cy = _cursor.Row * _cellHeight + _cellHeight - 1; if (cy < VPlasmaDevice.Height) for (int i = 0; i < _cellWidth && cx + i < VPlasmaDevice.Width; ++i) - g.FillRectangle(lit, Bezel + (cx + i) * DotPitch, Bezel + cy * DotPitch, DotSize, DotSize); + g.FillRectangle(lit, Bezel + (cx + i) * _dotPitch, Bezel + cy * _dotPitch, _dotSize, _dotSize); } } diff --git a/src/VRio.App/MainForm.cs b/src/VRio.App/MainForm.cs index 857cfd8..9733d8b 100644 --- a/src/VRio.App/MainForm.cs +++ b/src/VRio.App/MainForm.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.IO.Ports; +using VPlasma.App; +using VPlasma.Core.Device; using VRio.Core.Device; using VRio.Core.Input; @@ -8,11 +10,14 @@ namespace VRio.App; /// /// vRIO main window: the interactive cockpit panel on the left (the same /// functional map RIOJoy's profile editor shows) and a control strip on the -/// right — COM port, device settings, and a live wire log. At startup the -/// usual port () is opened automatically when it's -/// available; otherwise open a COM port by hand. Point RIOJoy at the other -/// end of the null-modem pair, and every click here arrives at RIOJoy exactly -/// as if the physical cockpit sent it. +/// right — COM ports, device settings, and a live wire log. The panel's +/// encoder strip also hosts the built-in plasma glass (the vPLASMA display +/// emulator) on its own COM port. At startup the usual ports +/// (, ) are +/// opened automatically when available; otherwise open them by hand. Point +/// RIOJoy at the other end of the null-modem pair, and every click here +/// arrives at RIOJoy exactly as if the physical cockpit sent it; point the +/// game's COM2 passthrough at the plasma pair and the glass lights up. /// internal sealed class MainForm : Form { @@ -23,9 +28,22 @@ internal sealed class MainForm : Form /// private const string PreferredPort = "COM11"; + /// + /// The built-in plasma's usual port: the device end of the COM2⇄COM12 + /// com0com pair (the game's COM2 passthrough opens the other end). + /// + private const string PreferredPlasmaPort = "COM12"; + private readonly VRioDevice _device = new(); private readonly VRioSerialService _service; + private readonly VPlasmaDevice _plasmaDevice = new(); + private readonly VPlasmaSerialService _plasmaService; private readonly PanelCanvas _canvas = new(); + private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch) + { + Location = PanelCanvas.PlasmaSlot, + }; + private int _selfTestPage; private readonly InputRouter _router; private readonly XInputGamepad _gamepad = new(); private readonly KeyboardLampMirror _lampMirror; @@ -34,6 +52,16 @@ internal sealed class MainForm : Form private readonly string _bindingsPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt"); + // Port rows: one line per built-in device — a name label whose colour is + // the port status (green = open, gray = closed), the port picker, and an + // Open/Close button. The one Rescan button refreshes both pickers. + private readonly Label _rioLabel = new() + { + Text = "vRIO", + Location = new Point(12, 15), + AutoSize = true, + ForeColor = Color.Gray, + }; private readonly ComboBox _portBox = new() { Location = new Point(80, 12), @@ -42,13 +70,20 @@ internal sealed class MainForm : Form }; private readonly Button _rescan = new() { Text = "Rescan", Location = new Point(214, 11), Width = 52 }; private readonly Button _openClose = new() { Text = "Open", Location = new Point(272, 11), Width = 46 }; - private readonly Label _linkStatus = new() + private readonly Label _plasmaLabel = new() { - Text = "Port closed.", - Location = new Point(12, 42), + Text = "vPLASMA", + Location = new Point(12, 45), AutoSize = true, ForeColor = Color.Gray, }; + private readonly ComboBox _plasmaPortBox = new() + { + Location = new Point(80, 42), + Width = 128, + DropDownStyle = ComboBoxStyle.DropDownList, + }; + private readonly Button _plasmaOpenClose = new() { Text = "Open", Location = new Point(272, 41), Width = 46 }; private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 24), AutoSize = true, Checked = true }; private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 52), Width = 140, Height = 26 }; @@ -112,7 +147,8 @@ internal sealed class MainForm : Form AutoSize = true, ForeColor = Color.Gray, Text = "Left-click a cell: momentary press. Right-click: latch it down. " + - "Drag the X/Y box and the Z / L / R gauges to move the axes.", + "Drag the X/Y box and the Z / L / R gauges to move the axes. " + + "Double-click the plasma glass to cycle its self-test pages; right-click it to reset.", }; private readonly TextBox _logBox = new() @@ -153,11 +189,14 @@ internal sealed class MainForm : Form KeyPreview = true; // form-level key routing for the input bindings _service = new VRioSerialService(_device); + _plasmaService = new VPlasmaSerialService(_plasmaDevice); _router = new InputRouter(_device); _lampMirror = new KeyboardLampMirror(_device); - // Panel canvas, scrolled if the window is smaller than the grid. + // Panel canvas, scrolled if the window is smaller than the grid. The + // plasma glass rides along as a child in the encoder strip's left slot. var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) }; + _canvas.Controls.Add(_plasmaCanvas); scroller.Controls.Add(_canvas); Controls.Add(scroller); @@ -186,8 +225,22 @@ internal sealed class MainForm : Form _service.HostHandshake += high => RunOnUi(() => PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR")); + // Built-in plasma glass: a pure listener on its own port, same gestures + // as the standalone vPLASMA — double-click cycles the self-test pages, + // right-click resets the display to its power-on state. + _plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice)); + _plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}")); + _plasmaService.ConnectionChanged += open => RunOnUi(() => OnPlasmaConnectionChanged(open)); + _plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest(); + _plasmaCanvas.MouseClick += (_, e) => + { + if (e.Button == MouseButtons.Right) + _plasmaDevice.Reset(); + }; + _rescan.Click += (_, _) => RefreshPorts(); _openClose.Click += (_, _) => ToggleOpen(); + _plasmaOpenClose.Click += (_, _) => TogglePlasmaOpen(); _spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked; _centerAxes.Click += (_, _) => { @@ -251,13 +304,15 @@ internal sealed class MainForm : Form _lampMirror.Dispose(); _rawKeyboard.Dispose(); _service.Dispose(); + _plasmaService.Dispose(); }; RefreshPorts(); UpdateStatus(); PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair."); LoadBindings(); - AutoOpenPreferredPort(); + _plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame + AutoOpenPreferredPorts(); } private Panel BuildControlStrip() @@ -275,11 +330,13 @@ internal sealed class MainForm : Form BorderStyle = BorderStyle.FixedSingle, }; - panel.Controls.Add(new Label { Text = "COM port:", Location = new Point(12, 15), AutoSize = true }); + panel.Controls.Add(_rioLabel); panel.Controls.Add(_portBox); panel.Controls.Add(_rescan); panel.Controls.Add(_openClose); - panel.Controls.Add(_linkStatus); + panel.Controls.Add(_plasmaLabel); + panel.Controls.Add(_plasmaPortBox); + panel.Controls.Add(_plasmaOpenClose); var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) }; device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff }); @@ -306,52 +363,80 @@ internal sealed class MainForm : Form private void RefreshPorts() { - string? current = _portBox.SelectedItem?.ToString(); - _portBox.Items.Clear(); - foreach (string name in SerialPort.GetPortNames().Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase)) - _portBox.Items.Add(name); + string[] names = SerialPort.GetPortNames().Distinct() + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray(); + // An open service's picker is disabled and shows the served port; + // leave it alone so the display can't drift from reality. + if (!_service.IsOpen) + RefreshPicker(_portBox, names); + if (!_plasmaService.IsOpen) + RefreshPicker(_plasmaPortBox, names); + } - if (_portBox.Items.Count == 0) + private static void RefreshPicker(ComboBox box, string[] portNames) + { + string? current = box.SelectedItem?.ToString(); + box.Items.Clear(); + foreach (string name in portNames) + box.Items.Add(name); + + if (box.Items.Count == 0) return; - int idx = current is null ? -1 : _portBox.Items.IndexOf(current); - _portBox.SelectedIndex = idx >= 0 ? idx : 0; + int idx = current is null ? -1 : box.Items.IndexOf(current); + box.SelectedIndex = idx >= 0 ? idx : 0; } /// - /// Startup convenience: if exists, select it - /// and try to open it. Failures (port missing, or busy because another - /// vRIO/app holds it) just log — no modal box at launch — and leave the - /// manual picker in charge. + /// Startup convenience: if a device's usual port exists, select it and try + /// to open it. Failures (port missing, or busy because another vRIO/app + /// holds it) just log — no modal box at launch — and leave the manual + /// pickers in charge. /// - private void AutoOpenPreferredPort() + private void AutoOpenPreferredPorts() { - int idx = _portBox.Items.IndexOf(PreferredPort); + AutoOpen(_portBox, PreferredPort, _service.Open); + AutoOpen(_plasmaPortBox, PreferredPlasmaPort, _plasmaService.Open); + } + + private void AutoOpen(ComboBox box, string port, Action open) + { + int idx = box.Items.IndexOf(port); if (idx < 0) { - PrependLog($"{PreferredPort} not present — pick a port and open it manually."); + PrependLog($"{port} not present — pick a port and open it manually."); return; } - _portBox.SelectedIndex = idx; + box.SelectedIndex = idx; try { - _service.Open(PreferredPort); + open(port); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) { - PrependLog($"{PreferredPort} is present but could not be opened ({ex.Message.TrimEnd('.')}) — open it manually once it frees up."); + PrependLog($"{port} is present but could not be opened ({ex.Message.TrimEnd('.')}) — open it manually once it frees up."); } } private void ToggleOpen() { if (_service.IsOpen) - { _service.Close(); - return; - } + else + OpenFromPicker(_portBox, _service.Open); + } - if (_portBox.SelectedItem?.ToString() is not { } port) + private void TogglePlasmaOpen() + { + if (_plasmaService.IsOpen) + _plasmaService.Close(); + else + OpenFromPicker(_plasmaPortBox, _plasmaService.Open); + } + + private void OpenFromPicker(ComboBox box, Action open) + { + if (box.SelectedItem?.ToString() is not { } port) { MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " + "null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information); @@ -360,7 +445,7 @@ internal sealed class MainForm : Form try { - _service.Open(port); + open(port); } catch (Exception ex) { @@ -372,12 +457,29 @@ internal sealed class MainForm : Form private void OnConnectionChanged(bool open) { _openClose.Text = open ? "Close" : "Open"; - _portBox.Enabled = _rescan.Enabled = !open; - _linkStatus.Text = open ? $"Serving {_service.PortName} @ 9600 8N1." : "Port closed."; - _linkStatus.ForeColor = open ? Color.ForestGreen : Color.Gray; + _portBox.Enabled = !open; + _rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray; UpdateStatus(); } + private void OnPlasmaConnectionChanged(bool open) + { + _plasmaOpenClose.Text = open ? "Close" : "Open"; + _plasmaPortBox.Enabled = !open; + _plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray; + } + + /// + /// Feed the next canned self-test page through the plasma's wire parser — + /// the same end-to-end exercise the standalone vPLASMA runs on double-click. + /// + private void RunPlasmaSelfTest() + { + byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage); + _plasmaDevice.OnReceived(bytes, bytes.Length); + _selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount; + } + // ---- Keyboard / gamepad input ------------------------------------------- /// diff --git a/src/VRio.App/PanelCanvas.cs b/src/VRio.App/PanelCanvas.cs index b17a99b..6be1582 100644 --- a/src/VRio.App/PanelCanvas.cs +++ b/src/VRio.App/PanelCanvas.cs @@ -1,3 +1,4 @@ +using VPlasma.App; using VRio.Core.Device; using VRio.Core.Panel; using VRio.Core.Protocol; @@ -16,6 +17,10 @@ namespace VRio.App; /// fill toward full travel (negative on the wire for the throttle, positive /// for the pedals), and the stick covers ±80 around its center. /// +/// The strip's left slot hosts the built-in plasma glass: MainForm parks +/// its child at , and the +/// strip height and gauge positions derive from the glass size. +/// /// Left-click = momentary press (release on mouse-up). Right-click = /// latch the button down / release it (handy for testing holds). /// @@ -24,16 +29,27 @@ internal sealed class PanelCanvas : Control // Cell geometry — identical to RIOJoy's PanelView so the two panels align. private const int CellW = 66; private const int CellH = 34; - private const int TopStrip = 78; // encoder strip: gauges end at 74, small gap to the grid private static readonly IReadOnlyList AllButtons = CockpitLayout.Buttons(); - // Encoder-strip geometry (same placement as RIOJoy's PanelView): Z gauge, - // the pedal gauges L / R, and the X/Y stick box. + // First grid row any group occupies (the shared layout leaves row 0 + // empty). Rendering shifts all rows up by this, so the button grid starts + // right under the encoder strip instead of a blank cell row below it. + private static readonly int FirstRow = CockpitLayout.Groups.Min(g => g.OriginRow); + + // Encoder-strip geometry. The built-in plasma glass parks at the strip's + // left edge (MainForm adds its PlasmaCanvas as a child at PlasmaSlot), + // the axis gauges — Z, the pedal gauges L / R, and the X/Y stick box — + // hug the grid's right edge, and the green status text fills the gap + // between them. The strip is exactly as tall as the glass. + internal const int PlasmaDotPitch = 3; // 2 px dots: the 128×32 glass fits the strip + internal static readonly Size PlasmaSize = PlasmaCanvas.SizeFor(PlasmaDotPitch); private const int StripTop = 6; - private const int StripH = 68; + internal static readonly Point PlasmaSlot = new(6, StripTop); + private static readonly int StripH = PlasmaSize.Height; + private static readonly int TopStrip = StripTop + StripH + 6; // button grid starts under the strip private const int Bar = 30; - private static readonly int BaseX = 6 * CellW - 100; + private static readonly int BaseX = GridWidth() - 6 - 200; // the gauge cluster spans 200 px, right-aligned private static readonly Rectangle BoxZ = new(BaseX, StripTop, Bar, StripH); private static readonly Rectangle BoxL = new(BaseX + 34, StripTop, Bar, StripH); private static readonly Rectangle BoxR = new(BaseX + 34 + 82 - Bar, StripTop, Bar, StripH); @@ -109,19 +125,27 @@ internal sealed class PanelCanvas : Control base.Dispose(disposing); } + // Split from GridSize so the static gauge layout can use the width + // without touching TopStrip (which initializes after AllButtons). + private static int GridWidth() + { + int maxCol = 0; + foreach (PanelButton b in AllButtons) + if (b.Col > maxCol) maxCol = b.Col; + return (maxCol + 1) * CellW + 6; + } + private static Size GridSize() { - int maxCol = 0, maxRow = 0; + int maxRow = 0; foreach (PanelButton b in AllButtons) - { - if (b.Col > maxCol) maxCol = b.Col; if (b.Row > maxRow) maxRow = b.Row; - } - return new Size((maxCol + 1) * CellW + 6, TopStrip + (maxRow + 2) * CellH); + // The last used row plus a small margin under the lowest group frame. + return new Size(GridWidth(), TopStrip + (maxRow - FirstRow + 1) * CellH + 6); } private static Rectangle Cell(int col, int row) => - new(col * CellW + 2, TopStrip + row * CellH + 2, CellW - 4, CellH - 4); + new(col * CellW + 2, TopStrip + (row - FirstRow) * CellH + 2, CellW - 4, CellH - 4); // ---- Painting ---------------------------------------------------------- @@ -142,7 +166,7 @@ internal sealed class PanelCanvas : Control { var frame = new Rectangle( grp.OriginCol * CellW + 1, - TopStrip + grp.OriginRow * CellH + 1, + TopStrip + (grp.OriginRow - FirstRow) * CellH + 1, grp.Cols * CellW, (grp.Rows + 1) * CellH); g.DrawRectangle(groupPen, frame); @@ -288,10 +312,12 @@ internal sealed class PanelCanvas : Control new Rectangle(BoxXY.X, BoxXY.Bottom - 16, BoxXY.Width, 14), Color.Gainsboro, TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom); - // The mockup's green status/instruction area, right of the gauges; the - // live axis readout sits directly under the status lines (painted per - // frame, so drags track). - var statusRect = new Rectangle(BoxXY.Right + 16, StripTop, Width - BoxXY.Right - 24, TopStrip - StripTop - 6); + // The mockup's green status/instruction area, between the plasma glass + // and the gauges; the live axis readout sits directly under the status + // lines (painted per frame, so drags track). + var statusRect = new Rectangle( + PlasmaSlot.X + PlasmaSize.Width + 12, StripTop, + BoxZ.Left - PlasmaSlot.X - PlasmaSize.Width - 24, StripH); if (statusRect.Width > 60) { using var statusFont = new Font("Consolas", 8f); @@ -307,11 +333,11 @@ internal sealed class PanelCanvas : Control short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a); string readout = - $"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3} " + + $"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3}\n" + $"X {WireAxis(RioAxis.JoystickX),3} Y {WireAxis(RioAxis.JoystickY),3}"; TextRenderer.DrawText(g, readout, statusFont, new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green, - TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.SingleLine); + TextFormatFlags.Left | TextFormatFlags.Top); } } diff --git a/src/VRio.App/VRio.App.csproj b/src/VRio.App/VRio.App.csproj index e9c0da0..c6325f1 100644 --- a/src/VRio.App/VRio.App.csproj +++ b/src/VRio.App/VRio.App.csproj @@ -17,6 +17,11 @@ + + +