diff --git a/.gitignore b/.gitignore index 98b15e5..0745f08 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,7 @@ *.suo # Test results -TestResults/ \ No newline at end of file +TestResults/ + +# Distributable archives (built locally, not tracked) +dist/ \ No newline at end of file diff --git a/README.md b/README.md index 3e8ddbe..5295538 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,11 @@ controls: or `KeyPressed`/`Released` on the wire). **Right-click** — latch it down. - **Drag** the X/Y box and the Z / L / R gauges — the five analog axes, returned by the next `AnalogReply` (14-bit signed, 7-bit-pair packed). + Gauges span each axis' realistic hardware travel, not the full 14-bit + range: Z/L/R rest at raw 0 at the gauge bottom and travel to −800 + (throttle — forward travel runs negative, matching RIOJoy's ratchet) or + +500 (spring-loaded pedals), and the stick covers ±80 around center — + the windows RIOJoy's calibrator expects. - Cells shade to the **lamp state the host commands** (`LampRequest`: off / dim / bright, with slow/med/fast flash), so RIOJoy's press-feedback lights the on-screen panel just like the real buttons. diff --git a/src/VRio.App/MainForm.cs b/src/VRio.App/MainForm.cs index e2100c7..014f036 100644 --- a/src/VRio.App/MainForm.cs +++ b/src/VRio.App/MainForm.cs @@ -90,7 +90,9 @@ internal sealed class MainForm : Form public MainForm() { Text = "vRIO — Virtual RIO cockpit device"; - ClientSize = new Size(1150, 800); + // Fit the window to its content: the cockpit canvas plus the 330px + // control strip, with just enough height for the strip's log area. + ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640)); MinimumSize = new Size(1000, 620); StartPosition = FormStartPosition.CenterScreen; @@ -113,11 +115,11 @@ internal sealed class MainForm : Form // Device / service events arrive on worker threads; marshal to the UI. _device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate); _device.AxesChanged += () => RunOnUi(_canvas.Invalidate); - _device.Logged += line => RunOnUi(() => AppendLog(line)); - _service.Logged += line => RunOnUi(() => AppendLog(line)); + _device.Logged += line => RunOnUi(() => PrependLog(line)); + _service.Logged += line => RunOnUi(() => PrependLog(line)); _service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open)); _service.HostHandshake += high => RunOnUi(() => - AppendLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR")); + PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR")); _rescan.Click += (_, _) => RefreshPorts(); _openClose.Click += (_, _) => ToggleOpen(); @@ -155,7 +157,7 @@ internal sealed class MainForm : Form RefreshPorts(); UpdateStatus(); - AppendLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair."); + PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair."); } private Panel BuildControlStrip() @@ -164,6 +166,11 @@ internal sealed class MainForm : Form { Dock = DockStyle.Right, Width = 330, + // Height must be set before the anchored children are added: + // Anchor offsets are captured against the parent's current size, + // and the 100px default would pin the log box's bottom far below + // the panel, making it grow past the window instead of scrolling. + Height = ClientSize.Height, Padding = new Padding(8), BorderStyle = BorderStyle.FixedSingle, }; @@ -265,13 +272,23 @@ internal sealed class MainForm : Form : "PORT CLOSED\nOpen a COM port to go live."; } - private void AppendLog(string line) + private void PrependLog(string line) { - // Bound the log so an all-day session doesn't grow without limit. - if (_logBox.TextLength > 120_000) - _logBox.Text = _logBox.Text.Substring(_logBox.TextLength - 60_000); + // Newest entry on top; older lines flow downward and eventually fall + // off the bounded end, so the visible top is always current. + string text = $"[{_clock.Elapsed.TotalSeconds,8:F2}s] {line}{Environment.NewLine}" + _logBox.Text; - _logBox.AppendText($"[{_clock.Elapsed.TotalSeconds,8:F2}s] {line}{Environment.NewLine}"); + // Bound the log so an all-day session doesn't grow without limit; + // cut at a line boundary so the tail isn't half an entry. + if (text.Length > 120_000) + { + int cut = text.LastIndexOf(Environment.NewLine, 60_000, StringComparison.Ordinal); + text = text.Substring(0, cut < 0 ? 60_000 : cut + Environment.NewLine.Length); + } + + _logBox.Text = text; + _logBox.SelectionStart = 0; + _logBox.ScrollToCaret(); } private void RunOnUi(Action action) diff --git a/src/VRio.App/PanelCanvas.cs b/src/VRio.App/PanelCanvas.cs index 19ffa23..aabae64 100644 --- a/src/VRio.App/PanelCanvas.cs +++ b/src/VRio.App/PanelCanvas.cs @@ -10,7 +10,11 @@ namespace VRio.App; /// here the cells are the physical controls: pressing one emits a /// button/keypad packet on the wire, and the fill shade tracks the lamp state /// the host last commanded (including flash modes). The encoder strip is live -/// too — drag the X/Y box and the Z/L/R gauges to move the analog axes. +/// too — drag the X/Y box and the Z/L/R gauges to move the analog axes. The +/// gauges span each axis' realistic hardware travel (), +/// not the full 14-bit wire range: Z/L/R rest at raw 0 at the gauge bottom and +/// fill toward full travel (negative on the wire for the throttle, positive +/// for the pedals), and the stick covers ±80 around its center. /// /// Left-click = momentary press (release on mouse-up). Right-click = /// latch the button down / release it (handy for testing holds). @@ -20,12 +24,12 @@ 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 = 108; + 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 pedals' "U" (L / R arms + Rz bar), and the X/Y stick box. + // the pedal gauges L / R, and the X/Y stick box. private const int StripTop = 6; private const int StripH = 68; private const int Bar = 30; @@ -33,7 +37,6 @@ internal sealed class PanelCanvas : Control 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); - private static readonly Rectangle BoxRz = new(BaseX + 34, StripTop + StripH, 82, Bar); private static readonly Rectangle BoxXY = new(BaseX + 120, StripTop, 80, StripH); private readonly System.Windows.Forms.Timer _flashTimer = new() { Interval = 100 }; @@ -247,19 +250,11 @@ internal sealed class PanelCanvas : Control short Axis(RioAxis a) => AxisProvider?.Invoke(a) ?? (short)0; - // Vertical gauges: Z (throttle) and the pedal arms L / R. - DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxZ, "Z", Axis(RioAxis.Throttle)); - DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxL, "L", Axis(RioAxis.LeftPedal)); - DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxR, "R", Axis(RioAxis.RightPedal)); - - // Rz bar: the PC-side rudder mix ((R − L) / 2) — display only. - g.FillRectangle(bg, BoxRz); - g.DrawRectangle(pen, BoxRz); - int mix = (Axis(RioAxis.RightPedal) - Axis(RioAxis.LeftPedal)) / 2; - int mx = BoxRz.X + (int)((mix - (float)AnalogCodec.Min) / (AnalogCodec.Max - AnalogCodec.Min) * BoxRz.Width); - g.FillRectangle(fillBrush, Math.Max(BoxRz.X + 1, Math.Min(mx - 2, BoxRz.Right - 4)), BoxRz.Y + 1, 3, BoxRz.Height - 2); - TextRenderer.DrawText(g, "Rz", labelFont, BoxRz, Color.Gainsboro, - TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); + // Vertical gauges: Z (throttle) and the pedals L / R. Each rests at + // raw 0 (gauge bottom) and fills toward full travel at the top. + DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxZ, "Z", Axis(RioAxis.Throttle), RioAxisRange.ThrottleFull); + DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxL, "L", Axis(RioAxis.LeftPedal), RioAxisRange.PedalFull); + DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxR, "R", Axis(RioAxis.RightPedal), RioAxisRange.PedalFull); // X/Y stick box with a crosshair at the current position. g.FillRectangle(bg, BoxXY); @@ -275,31 +270,39 @@ 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, with a - // live axis readout on its last line (painted per frame, so drags track). + // 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); if (statusRect.Width > 60) { using var statusFont = new Font("Consolas", 8f); var green = Color.FromArgb(120, 220, 120); + int readoutTop = statusRect.Y; if (_statusText.Length > 0) + { TextRenderer.DrawText(g, _statusText, statusFont, statusRect, green, TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak); + readoutTop += TextRenderer.MeasureText(g, _statusText, statusFont, + new Size(statusRect.Width, 0), TextFormatFlags.WordBreak).Height + 2; + } string readout = - $"Z {Axis(RioAxis.Throttle),6} L {Axis(RioAxis.LeftPedal),6} R {Axis(RioAxis.RightPedal),6} " + - $"X {Axis(RioAxis.JoystickX),6} Y {Axis(RioAxis.JoystickY),6}"; - TextRenderer.DrawText(g, readout, statusFont, statusRect, green, - TextFormatFlags.Left | TextFormatFlags.Bottom | TextFormatFlags.SingleLine); + $"Z {Axis(RioAxis.Throttle),4} L {Axis(RioAxis.LeftPedal),3} R {Axis(RioAxis.RightPedal),3} " + + $"X {Axis(RioAxis.JoystickX),3} Y {Axis(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); } } private static void DrawVGauge(Graphics g, Pen pen, Brush bg, Brush fill, Font labelFont, - Rectangle box, string label, short value) + Rectangle box, string label, short value, int full) { g.FillRectangle(bg, box); - // Fill upward from the bottom, proportional to the normalized value. - float norm = (value - (float)AnalogCodec.Min) / (AnalogCodec.Max - AnalogCodec.Min); + // Fill upward from the raw-0 rest position at the bottom, proportional + // to how far the control has travelled toward `full` (either sign). + float norm = Math.Max(0f, Math.Min(1f, value / (float)full)); int h = (int)(norm * (box.Height - 2)); if (h > 0) g.FillRectangle(fill, box.X + 1, box.Bottom - 1 - h, box.Width - 2, h); @@ -310,13 +313,20 @@ internal sealed class PanelCanvas : Control private static PointF StickToPoint(short x, short y) { - float nx = (x - (float)AnalogCodec.Min) / (AnalogCodec.Max - AnalogCodec.Min); - float ny = (y - (float)AnalogCodec.Min) / (AnalogCodec.Max - AnalogCodec.Min); + // Raw X runs opposite the screen direction: stick right = negative on + // the wire (RIOJoy's calibrator maps negative samples to the high half + // of its output axis), so mirror the value for display. + float nx = Norm(-x, RioAxisRange.JoystickExtent); + float ny = Norm(y, RioAxisRange.JoystickExtent); return new PointF( BoxXY.X + 1 + nx * (BoxXY.Width - 2), BoxXY.Bottom - 1 - ny * (BoxXY.Height - 2)); // up = positive Y } + /// Raw axis value → 0..1 across its ±extent travel, clamped. + private static float Norm(int value, int extent) => + Math.Max(0f, Math.Min(1f, (value + extent) / (2f * extent))); + // ---- Interaction ------------------------------------------------------- protected override void OnMouseDown(MouseEventArgs e) @@ -444,8 +454,10 @@ internal sealed class PanelCanvas : Control private void UpdateStick(Point p) { - short x = FromNorm((p.X - BoxXY.X) / (float)BoxXY.Width); - short y = FromNorm((BoxXY.Bottom - p.Y) / (float)BoxXY.Height); + // Dragging right lowers raw X — the wire sign is opposite the screen + // direction (see StickToPoint). + short x = FromNorm((BoxXY.Right - p.X) / (float)BoxXY.Width, RioAxisRange.JoystickExtent); + short y = FromNorm((BoxXY.Bottom - p.Y) / (float)BoxXY.Height, RioAxisRange.JoystickExtent); AxisMoved?.Invoke(RioAxis.JoystickX, x); AxisMoved?.Invoke(RioAxis.JoystickY, y); } @@ -458,13 +470,14 @@ internal sealed class PanelCanvas : Control RioAxis.LeftPedal => BoxL, _ => BoxR, }; - short v = FromNorm((box.Bottom - p.Y) / (float)box.Height); - AxisMoved?.Invoke(axis, v); + // Gauge bottom = rest (raw 0), top = full travel (sign per axis). + float norm = Math.Max(0f, Math.Min(1f, (box.Bottom - p.Y) / (float)box.Height)); + AxisMoved?.Invoke(axis, (short)(norm * RioAxisRange.Full(axis))); } - private static short FromNorm(float norm) + private static short FromNorm(float norm, int extent) { norm = Math.Max(0f, Math.Min(1f, norm)); - return (short)(AnalogCodec.Min + norm * (AnalogCodec.Max - AnalogCodec.Min)); + return (short)(-extent + norm * 2 * extent); } } diff --git a/src/VRio.Core/Device/RioAddressSpace.cs b/src/VRio.Core/Device/RioAddressSpace.cs index cf801a4..f43bb31 100644 --- a/src/VRio.Core/Device/RioAddressSpace.cs +++ b/src/VRio.Core/Device/RioAddressSpace.cs @@ -78,3 +78,42 @@ public enum RioAxis JoystickY = 3, JoystickX = 4, } + +/// +/// Realistic travel of each axis on the physical cockpit, measured from the +/// rest position (raw 0). The wire format can carry 14-bit signed values, but +/// the hardware only uses a small slice of it, and each control has a +/// direction — from RIOJoy's calibrator (RioJoy.Core AxisCalibrator, +/// ported from riovjoy2.cpp): +/// +/// The throttle lever's startup position is unknown, so RIOJoy +/// ratchets its start from the highest sample seen and measures forward +/// travel as start − sample, up to 800 counts — forward travel runs +/// negative on the wire. +/// The pedals are spring-loaded with a predictable rest; RIOJoy takes +/// the lowest sample as rest and a press raises the value, up to 500. +/// The joystick is self-centering; RIOJoy auto-ranges from observed +/// min/max, seeded at ±80 either side of center. +/// +/// Values a vRIO front-end produces should stay inside these windows to look +/// like real hardware. +/// +public static class RioAxisRange +{ + /// Throttle raw value at full forward travel (rest 0; travel runs negative). + public const int ThrottleFull = -800; + + /// Pedal raw value at a full press (rest 0; spring-loaded). + public const int PedalFull = 500; + + /// Joystick full deflection either side of its 0 center. + public const int JoystickExtent = 80; + + /// Raw value at full travel from rest — the sign is the wire direction. + public static int Full(RioAxis axis) => axis switch + { + RioAxis.Throttle => ThrottleFull, + RioAxis.LeftPedal or RioAxis.RightPedal => PedalFull, + _ => JoystickExtent, + }; +}