Editor cockpit rework + dormant-by-default serial port use

Profile editor:
- Cockpit-style encoder gauges centered over the Upper Middle MFD: Z left,
  X/Y right, and L/R/Rz arranged as a big "U".
- [JoyStick] section surfaced as a vertical "Axis" toggle column (6 inverts
  + ZR mix), written back to the profile's calibration.
- Tighten the panel layout (close gaps, trim empty border columns); colour the
  Secondary/Screen columns yellow; rename the "Joystick / Hat" board to
  "Joystick"; add an "Import .ini..." tray entry.
- "RIO commands (live)" button group (all RioCommandCodes) fired at the live RIO.

Serial-port handling:
- Start dormant; only take the COM port for a profiled game (auto-switch) or
  while editing, so the native games can open the port without clashing.
- Editor sessions hold the port but route keyboard/mouse/joystick to no-op
  sinks (NullInputSink), so button function can be checked without injecting
  input; RioRuntime.ButtonActivity drives a live cyan press indicator and
  lamp feedback still applies.
- AutoSwitchWatcher.Reset() re-syncs the watcher after an editor session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-27 22:55:53 -05:00
co-authored by Claude Opus 4.8
parent a4a3b58f7e
commit 4994ab699f
12 changed files with 481 additions and 45 deletions
+8 -8
View File
@@ -70,23 +70,23 @@ public static class CockpitPanel
{
// Upper MFD row.
new PanelGroup("Upper Left MFD", PanelGroupKind.Mfd, 4, 2, true, 0, 1, Mfd(0x2F)),
new PanelGroup("Upper Middle MFD", PanelGroupKind.Mfd, 4, 2, true, 5, 1, Mfd(0x27)),
new PanelGroup("Upper Right MFD", PanelGroupKind.Mfd, 4, 2, true, 10, 1, Mfd(0x37)),
new PanelGroup("Upper Middle MFD", PanelGroupKind.Mfd, 4, 2, true, 4, 1, Mfd(0x27)),
new PanelGroup("Upper Right MFD", PanelGroupKind.Mfd, 4, 2, true, 8, 1, Mfd(0x37)),
// Lower MFD row.
new PanelGroup("Lower Left MFD", PanelGroupKind.Mfd, 4, 2, true, 0, 5, Mfd(0x0F)),
new PanelGroup("Lower Right MFD", PanelGroupKind.Mfd, 4, 2, true, 10, 5, Mfd(0x07)),
new PanelGroup("Lower Right MFD", PanelGroupKind.Mfd, 4, 2, true, 8, 5, Mfd(0x07)),
// Board columns.
new PanelGroup("Throttle", PanelGroupKind.Column, 1, 8, true, 0, 9, Col8(0x38)),
new PanelGroup("Secondary", PanelGroupKind.Column, 1, 8, true, 5, 9, Col8(0x10)),
new PanelGroup("Screen", PanelGroupKind.Column, 1, 8, true, 7, 9, Col8(0x18)),
new PanelGroup("Joystick / Hat", PanelGroupKind.Column, 1, 8, true, 13, 9, Col8(0x40)),
new PanelGroup("Secondary", PanelGroupKind.Column, 1, 8, true, 4, 9, Col8(0x10)),
new PanelGroup("Screen", PanelGroupKind.Column, 1, 8, true, 6, 9, Col8(0x18)),
new PanelGroup("Joystick", PanelGroupKind.Column, 1, 8, true, 11, 9, Col8(0x40)),
// Keypads (no lamps): internal in the middle (below Upper Middle MFD, above
// the Secondary/Screen columns); external between those columns and Joystick.
new PanelGroup("Internal Keypad", PanelGroupKind.Keypad, 4, 4, false, 5, 4, Keypad(0x50)),
new PanelGroup("External Keypad", PanelGroupKind.Keypad, 4, 4, false, 9, 9, Keypad(0x60)),
new PanelGroup("Internal Keypad", PanelGroupKind.Keypad, 4, 4, false, 4, 4, Keypad(0x50)),
new PanelGroup("External Keypad", PanelGroupKind.Keypad, 4, 4, false, 7, 9, Keypad(0x60)),
};
/// <summary>Flatten the groups to positioned <see cref="PanelButton"/>s (absolute cells).</summary>
+17
View File
@@ -0,0 +1,17 @@
namespace RioJoy.Core.Mapping;
/// <summary>
/// A no-op <see cref="IInputSink"/>: swallows all keyboard/mouse output. Used while
/// a profile is being edited so the RIO can be exercised to check button function
/// without injecting keystrokes/clicks into the OS (lamp feedback still applies).
/// </summary>
public sealed class NullInputSink : IInputSink
{
public void KeyDown(byte virtualKey, bool extended) { }
public void KeyUp(byte virtualKey, bool extended) { }
public void MouseMove(int dx, int dy) { }
public void MouseButton(MouseButton button, bool down) { }
}
@@ -31,6 +31,13 @@ public sealed class AutoSwitchWatcher
/// <summary>The most recently resolved decision (null until the first <see cref="Poll"/>).</summary>
public SwitchDecision? Current => _last;
/// <summary>
/// Forget the last decision so the next <see cref="Poll"/> re-raises
/// <see cref="DecisionChanged"/> even if the resolved decision is unchanged. Used
/// after a manual/editor override to re-sync the coordinator with the foreground app.
/// </summary>
public void Reset() => _last = null;
/// <summary>Resolve once; raise <see cref="DecisionChanged"/> if it changed. Returns the decision.</summary>
public SwitchDecision Poll()
{
+22 -4
View File
@@ -40,6 +40,13 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
/// <summary>Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate).</summary>
public event Action<RioCommandCode>? DiagnosticToggle;
/// <summary>
/// Raised for every decoded button/keypad event (RIO address, <c>true</c> on press
/// and <c>false</c> on release) — independent of how the entry is routed, so the
/// editor can show live button activity even with no-op output sinks.
/// </summary>
public event Action<int, bool>? ButtonActivity;
/// <summary>Subscribe to the link and set all lamps to their idle state.</summary>
public void Start()
{
@@ -85,20 +92,31 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
switch (packet.Command)
{
case RioCommand.ButtonPressed when p[0] < RioAddress.ButtonCount:
_router.Press(RioAddress.FromButton(p[0]));
Activity(RioAddress.FromButton(p[0]), pressed: true);
break;
case RioCommand.ButtonReleased when p[0] < RioAddress.ButtonCount:
_router.Release(RioAddress.FromButton(p[0]));
Activity(RioAddress.FromButton(p[0]), pressed: false);
break;
case RioCommand.KeyPressed when IsKeypad(p):
_router.Press(RioAddress.FromKeypad(p[0], p[1]));
Activity(RioAddress.FromKeypad(p[0], p[1]), pressed: true);
break;
case RioCommand.KeyReleased when IsKeypad(p):
_router.Release(RioAddress.FromKeypad(p[0], p[1]));
Activity(RioAddress.FromKeypad(p[0], p[1]), pressed: false);
break;
}
}
// Route the event through the input pipeline and notify any live-activity listener.
private void Activity(int address, bool pressed)
{
if (pressed)
_router.Press(address);
else
_router.Release(address);
ButtonActivity?.Invoke(address, pressed);
}
private static bool IsKeypad(ReadOnlySpan<byte> p) => p[0] is 0 or 1 && p[1] <= 0x0F;
// IRioCommandSink: RIO commands routed from a button (RIOcmd port).
+28 -1
View File
@@ -29,10 +29,25 @@ internal sealed class PanelCanvas : Control
/// <summary>Whether an address's lamp is lit (the binding's IsLit flag) — drives the lamp shade.</summary>
public Func<int, bool>? LitProvider { get; set; }
/// <summary>Resolves a <c>[JoyStick]</c> setting's current on/off value.</summary>
public Func<JoyStickSetting, bool>? CalibrationProvider { get; set; }
private readonly HashSet<int> _livePressed = new();
public int? SelectedAddress { get; private set; }
public event Action<int>? AddressSelected;
/// <summary>Mark a RIO address as live-pressed (or released) for the on-panel indicator.</summary>
public void SetLivePressed(int address, bool pressed)
{
if (pressed ? _livePressed.Add(address) : _livePressed.Remove(address))
Invalidate();
}
/// <summary>Raised when a <c>[JoyStick]</c> toggle cell is clicked.</summary>
public event Action<JoyStickSetting>? CalibrationToggled;
public void Select(int? address)
{
SelectedAddress = address;
@@ -45,17 +60,29 @@ internal sealed class PanelCanvas : Control
Invalidate();
}
protected override void OnPaint(PaintEventArgs e) =>
protected override void OnPaint(PaintEventArgs e)
{
PanelView.Paint(
e.Graphics, AllButtons, CockpitPanel.Groups,
a => LabelProvider?.Invoke(a),
a => FunctionProvider?.Invoke(a),
a => LitProvider?.Invoke(a) ?? false,
SelectedAddress);
PanelView.PaintCalibration(e.Graphics, s => CalibrationProvider?.Invoke(s) ?? false);
PanelView.PaintLivePressed(e.Graphics, AllButtons, _livePressed);
}
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
if (PanelView.HitTestCalibration(e.Location) is { } cal)
{
CalibrationToggled?.Invoke(cal.Setting);
Invalidate();
return;
}
PanelButton? hit = PanelView.HitTest(AllButtons, e.Location);
if (hit is not null)
{
+122 -14
View File
@@ -3,6 +3,24 @@ using RioJoy.Core.Editing;
namespace RioJoy.Tray.Editor;
/// <summary>
/// A boolean from the legacy <c>[JoyStick]</c> ini section, surfaced as a clickable
/// cell on the panel (mapped to <see cref="RioJoy.Core.Calibration.AxisCalibrationConfig"/>).
/// </summary>
public enum JoyStickSetting
{
InvertX,
InvertY,
InvertZ,
InvertRx,
InvertRy,
InvertRz,
EnableZR,
}
/// <summary>One <c>[JoyStick]</c> toggle cell placed on the panel (absolute cells).</summary>
public sealed record CalibrationCell(JoyStickSetting Setting, string Label, int Col, int Row);
/// <summary>
/// Draws the cockpit control panel — the functional groups from
/// <see cref="CockpitPanel"/> (MFD clusters, board columns, keypads) plus the
@@ -15,7 +33,24 @@ public static class PanelView
{
public const int CellW = 66;
public const int CellH = 34;
public const int TopStrip = 92; // encoder-gauge band height
public const int TopStrip = 108; // encoder-gauge band height (fits the U + Rz below it)
// The [JoyStick] ini section as cells, stacked vertically in the gap between the
// Throttle column (col 0) and the Secondary column (col 4): a single column at
// col 2, rows 1016, under an "Axis" title (row 9).
public const int CalOriginCol = 2;
public const int CalOriginRow = 9;
public static IReadOnlyList<CalibrationCell> CalibrationCells { get; } = new[]
{
new CalibrationCell(JoyStickSetting.InvertX, "Inv X", 2, 10),
new CalibrationCell(JoyStickSetting.InvertY, "Inv Y", 2, 11),
new CalibrationCell(JoyStickSetting.InvertZ, "Inv Z", 2, 12),
new CalibrationCell(JoyStickSetting.InvertRx, "Inv Rx", 2, 13),
new CalibrationCell(JoyStickSetting.InvertRy, "Inv Ry", 2, 14),
new CalibrationCell(JoyStickSetting.InvertRz, "Inv Rz", 2, 15),
new CalibrationCell(JoyStickSetting.EnableZR, "ZR mix", 2, 16),
};
public static Size GridSize(IReadOnlyList<PanelButton> buttons)
{
@@ -25,7 +60,9 @@ public static class PanelView
if (b.Col > maxCol) maxCol = b.Col;
if (b.Row > maxRow) maxRow = b.Row;
}
return new Size((maxCol + 2) * CellW, TopStrip + (maxRow + 2) * CellH);
// Fit snugly: the rightmost group frame ends just past (maxCol + 1) * CellW,
// so a few px of border avoids a full empty trailing column.
return new Size((maxCol + 1) * CellW + 6, TopStrip + (maxRow + 2) * CellH);
}
private static Rectangle Cell(int col, int row) =>
@@ -72,14 +109,18 @@ public static class PanelView
string? fn = function(b.Address);
bool assigned = !string.IsNullOrEmpty(fn);
bool yellow = b.Group.Title is "Secondary" or "Screen";
Color fill = !b.LampCapable
? Color.FromArgb(150, 182, 226) // keypad — neutral blue
: yellow
? (lit(b.Address) ? Color.FromArgb(240, 205, 50) // Secondary/Screen — yellow lamp
: Color.FromArgb(120, 102, 30)) // Secondary/Screen — dim yellow
: lit(b.Address)
? Color.FromArgb(215, 60, 60) // lamp ON (IsLit checked)
: Color.FromArgb(86, 48, 48); // lamp OFF
using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r);
Color fg = b.LampCapable ? Color.White : Color.Black;
Color fg = (b.LampCapable && !yellow) ? Color.White : Color.Black;
bool hasLab = !string.IsNullOrEmpty(lab);
if (hasLab && assigned)
@@ -108,20 +149,29 @@ public static class PanelView
using var pen = new Pen(Color.FromArgb(120, 160, 120));
using var gauge = new SolidBrush(Color.FromArgb(20, 40, 20));
(string Label, int X, int W)[] boxes =
// Centered over the Upper Middle MFD block and arranged to match the physical
// cockpit: Z on the left and the X/Y stick graph on the right (both full
// height). The L & R pedals and the Rz (mixed-rudder) gauge form a big "U" in
// the center — L and R the full-height arms, Rz the bar joining them.
const int top = 6;
const int fullH = 68; // Z, X/Y and the U's arms (full height)
const int bar = 30; // stroke thickness, matching Z's width / Rz bar
int baseX = 6 * CellW - 100; // cluster is 200 wide; 6*CellW centers the MFD
int uX = baseX + 34, uW = 82;
(string Label, Rectangle Box)[] boxes =
{
("X / Y", 4, 80),
("Z", 92, 26),
("L", 122, 26),
("R", 152, 26),
("Z", new Rectangle(baseX, top, bar, fullH)),
("L", new Rectangle(uX, top, bar, fullH)), // left arm of the U
("R", new Rectangle(uX + uW - bar, top, bar, fullH)), // right arm of the U
("Rz", new Rectangle(uX, top + fullH, uW, bar)), // bottom bar, below L & R (no overlap)
("X / Y", new Rectangle(baseX + 120, top, 80, fullH)),
};
foreach (var (lbl, x, w) in boxes)
foreach (var (lbl, box) in boxes)
{
var r = new Rectangle(x, 6, w, TopStrip - 24);
g.FillRectangle(gauge, r);
g.DrawRectangle(pen, r);
TextRenderer.DrawText(g, lbl, font, new Rectangle(x, TopStrip - 18, w, 16), Color.Gainsboro,
TextFormatFlags.HorizontalCenter);
g.FillRectangle(gauge, box);
g.DrawRectangle(pen, box);
TextRenderer.DrawText(g, lbl, font, box, Color.Gainsboro,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
}
@@ -132,4 +182,62 @@ public static class PanelView
return b;
return null;
}
/// <summary>
/// Draw the <c>[JoyStick]</c> toggle block. <paramref name="state"/> reports each
/// setting's current value; "on" cells render green (active invert / ZR mix).
/// </summary>
public static void PaintCalibration(Graphics g, Func<JoyStickSetting, bool> state)
{
using var titleFont = new Font("Segoe UI", 8f, FontStyle.Bold);
using var cellFont = new Font("Segoe UI", 7.5f);
using var framePen = new Pen(Color.FromArgb(80, 80, 80));
var frame = new Rectangle(
CalOriginCol * CellW + 1,
TopStrip + CalOriginRow * CellH + 1,
CellW,
(CalibrationCells.Count + 1) * CellH); // title row + one cell row each
g.DrawRectangle(framePen, frame);
TextRenderer.DrawText(g, "Axis", titleFont,
new Rectangle(frame.X + 2, frame.Y, frame.Width - 2, CellH), Color.Gainsboro,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
const TextFormatFlags fmt = TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix;
foreach (CalibrationCell c in CalibrationCells)
{
Rectangle r = Cell(c.Col, c.Row);
bool on = state(c.Setting);
Color fill = on ? Color.FromArgb(60, 150, 80) : Color.FromArgb(58, 64, 72); // green on / slate off
using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r);
TextRenderer.DrawText(g, $"{c.Label}\n{(on ? "ON" : "off")}", cellFont, r, Color.White, fmt);
}
}
/// <summary>
/// Outline the buttons currently held down on the live RIO (the editor's
/// check-button-function feedback), drawn over the normal cells.
/// </summary>
public static void PaintLivePressed(Graphics g, IReadOnlyList<PanelButton> buttons, ISet<int> pressed)
{
if (pressed.Count == 0)
return;
using var pen = new Pen(Color.FromArgb(90, 220, 255), 3f); // cyan = live press
foreach (PanelButton b in buttons)
if (pressed.Contains(b.Address))
{
Rectangle r = Cell(b.Col, b.Row);
g.DrawRectangle(pen, r.X, r.Y, r.Width - 1, r.Height - 1);
}
}
/// <summary>Hit-test the <c>[JoyStick]</c> toggle cells; null if the point misses.</summary>
public static CalibrationCell? HitTestCalibration(Point p)
{
foreach (CalibrationCell c in CalibrationCells)
if (Cell(c.Col, c.Row).Contains(p))
return c;
return null;
}
}
+90 -1
View File
@@ -45,6 +45,22 @@ public sealed class ProfileEditorForm : Form
private int? _address;
private bool _loading;
// The RIO device commands, mirroring the tray menu, surfaced as live buttons so
// they can be fired against the RIO while editing (the editor holds the port).
private static readonly (string Label, RioCommandCode Code)[] RioCommands =
{
("Reset all analog axes", RioCommandCode.ResetAllAxes),
("Reset throttle", RioCommandCode.ResetThrottle),
("Reset left pedal", RioCommandCode.ResetLeftPedal),
("Reset right pedal", RioCommandCode.ResetRightPedal),
("Reset vertical joystick", RioCommandCode.ResetVerticalJoystick),
("Reset horizontal joystick", RioCommandCode.ResetHorizontalJoystick),
("Reset digital (general)", RioCommandCode.GeneralReset),
("Request version & status", RioCommandCode.RequestVersionAndCheck),
("Toggle raw-axes readout", RioCommandCode.ToggleRawAxesReadout),
("Toggle poll-rate readout", RioCommandCode.TogglePollRateReadout),
};
/// <summary>A pick-list entry: what the user sees and the byte it maps to.</summary>
private sealed record ValueItem(string Display, byte Value)
{
@@ -54,6 +70,9 @@ public sealed class ProfileEditorForm : Form
/// <summary>Raised when the user saves; the argument is the edited profile.</summary>
public event Action<RioProfile>? Saved;
/// <summary>Raised when the user clicks one of the live RIO-command buttons.</summary>
public event Action<RioCommandCode>? CommandRequested;
public ProfileEditorForm(RioProfile profile)
{
_profile = profile ?? throw new ArgumentNullException(nameof(profile));
@@ -69,6 +88,8 @@ public sealed class ProfileEditorForm : Form
_canvas.LabelProvider = a => _profile.OverlayLabels.TryGetValue(RegionFor(a), out string? t) ? t : null;
_canvas.FunctionProvider = a => _profile.Buttons.TryGetValue(a, out int w) ? ButtonBinding.FromWord((ushort)w).Describe() : null;
_canvas.LitProvider = a => _profile.Buttons.TryGetValue(a, out int w) && ButtonBinding.FromWord((ushort)w).IsLit;
_canvas.CalibrationProvider = ReadCalibration;
_canvas.CalibrationToggled += ToggleCalibration;
_canvas.AddressSelected += OnAddressSelected;
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
@@ -93,7 +114,7 @@ public sealed class ProfileEditorForm : Form
private Panel BuildEditPanel()
{
var panel = new Panel { Dock = DockStyle.Right, Width = 330, Padding = new Padding(8), BorderStyle = BorderStyle.FixedSingle };
var panel = new Panel { Dock = DockStyle.Right, Width = 330, Padding = new Padding(8), BorderStyle = BorderStyle.FixedSingle, AutoScroll = true };
panel.Controls.Add(_info);
panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 47), AutoSize = true });
@@ -103,10 +124,29 @@ public sealed class ProfileEditorForm : Form
panel.Controls.Add(_valueLabel);
panel.Controls.Add(_valueCombo);
panel.Controls.AddRange(new Control[] { _shift, _ctrl, _alt, _ext, _lit, _apply, _unassign, _save, _close });
panel.Controls.Add(BuildCommandGroup());
return panel;
}
// A button per RIO device command, fired against the live RIO via CommandRequested.
private GroupBox BuildCommandGroup()
{
var group = new GroupBox { Text = "RIO commands (live)", Location = new Point(12, 278), Size = new Size(306, 330) };
int y = 24;
foreach ((string label, RioCommandCode code) in RioCommands)
{
RioCommandCode captured = code;
var button = new Button { Text = label, Location = new Point(10, y), Width = 286, Height = 26, UseMnemonic = false };
button.Click += (_, _) => CommandRequested?.Invoke(captured);
group.Controls.Add(button);
y += 29;
}
return group;
}
private void OnAddressSelected(int addr)
{
_address = addr;
@@ -263,6 +303,36 @@ public sealed class ProfileEditorForm : Form
if (enabled) UpdateModifierAvailability();
}
// Read a [JoyStick] setting from the profile's calibration (drives the cell shade).
private bool ReadCalibration(JoyStickSetting s) => s switch
{
JoyStickSetting.InvertX => _profile.Calibration.InvertX,
JoyStickSetting.InvertY => _profile.Calibration.InvertY,
JoyStickSetting.InvertZ => _profile.Calibration.InvertZ,
JoyStickSetting.InvertRx => _profile.Calibration.InvertXR,
JoyStickSetting.InvertRy => _profile.Calibration.InvertYR,
JoyStickSetting.InvertRz => _profile.Calibration.InvertZR,
JoyStickSetting.EnableZR => _profile.Calibration.EnableZR,
_ => false,
};
// Flip a [JoyStick] setting in place; persisted with the profile on Save.
private void ToggleCalibration(JoyStickSetting s)
{
var c = _profile.Calibration;
_profile.Calibration = s switch
{
JoyStickSetting.InvertX => c with { InvertX = !c.InvertX },
JoyStickSetting.InvertY => c with { InvertY = !c.InvertY },
JoyStickSetting.InvertZ => c with { InvertZ = !c.InvertZ },
JoyStickSetting.InvertRx => c with { InvertXR = !c.InvertXR },
JoyStickSetting.InvertRy => c with { InvertYR = !c.InvertYR },
JoyStickSetting.InvertRz => c with { InvertZR = !c.InvertZR },
JoyStickSetting.EnableZR => c with { EnableZR = !c.EnableZR },
_ => c,
};
}
private static string RegionFor(int address) => $"b-{address:X2}";
/// <summary>Select an address programmatically (for tests / offline preview).</summary>
@@ -271,4 +341,23 @@ public sealed class ProfileEditorForm : Form
_canvas.Select(address);
OnAddressSelected(address);
}
/// <summary>
/// Show live RIO button activity on the panel (called from the serial read thread).
/// Lets the user press a physical button and see which cell it is without any
/// keystrokes being injected.
/// </summary>
public void ShowLiveActivity(int address, bool pressed)
{
if (IsDisposed)
return;
if (InvokeRequired)
{
try { BeginInvoke(() => ShowLiveActivity(address, pressed)); }
catch (ObjectDisposedException) { }
catch (InvalidOperationException) { } // handle not created / form closing
return;
}
_canvas.SetLivePressed(address, pressed);
}
}
+56 -12
View File
@@ -16,6 +16,11 @@ namespace RioJoy.Tray;
/// active profile, releasing the COM port when a native game runs or nothing
/// matches (see docs/PLAN.md §Profiles). Manual override from the tray takes
/// precedence over the watcher.
///
/// The coordinator starts <b>dormant</b> and only takes the serial port when a
/// profiled game is in the foreground (via the watcher) or while a profile is being
/// edited (<see cref="BeginEditorSession"/>). This keeps the COM port free the rest
/// of the time so the native games can open it without clashing.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class RioCoordinator : IDisposable
@@ -24,6 +29,7 @@ public sealed class RioCoordinator : IDisposable
private readonly Func<string, IRioTransport> _transportFactory;
private bool _auto = true;
private bool _editorSession;
// The active connection (null when dormant).
private IRioTransport? _transport;
@@ -43,7 +49,10 @@ public sealed class RioCoordinator : IDisposable
public event Action<string>? StatusChanged;
/// <summary>Current status line for the tray.</summary>
public string Status { get; private set; } = "Starting…";
public string Status { get; private set; } = "Dormant";
/// <summary>Whether a profile is currently being edited (port held for the editor).</summary>
public bool IsEditing => _editorSession;
/// <summary>The name of the active profile, or null when dormant.</summary>
public string? ActiveProfileName => _activeProfileName;
@@ -95,10 +104,32 @@ public sealed class RioCoordinator : IDisposable
GoDormant("Dormant (manual)");
}
private void Activate(RioProfile profile)
/// <summary>
/// Acquire the RIO for <paramref name="profile"/> while its editor is open. The
/// editor takes precedence over the watcher (so the port stays put while editing);
/// call <see cref="EndEditorSession"/> when the editor closes.
/// </summary>
public void BeginEditorSession(RioProfile profile)
{
if (_activeProfileName == profile.Name && _runtime is not null)
return; // already running this profile
_editorSession = true;
_auto = false; // editor override beats the auto-switch watcher
Activate(profile, routeInput: false); // hold the port but don't inject keystrokes/joystick
}
/// <summary>Release the editor's port hold and hand control back to the watcher.</summary>
public void EndEditorSession()
{
_editorSession = false;
_auto = true;
GoDormant("Dormant"); // the next watcher poll re-activates if a profiled game is foreground
}
// routeInput=false holds the RIO link (and lamp feedback) but sends keyboard/
// mouse/joystick output to no-op sinks — used for the editor's live button check.
private void Activate(RioProfile profile, bool routeInput = true)
{
if (_activeProfileName == profile.Name && _runtime is not null && !_editorSession)
return; // already running this profile (editor sessions always re-arm)
Teardown();
@@ -107,21 +138,33 @@ public sealed class RioCoordinator : IDisposable
try
{
// Use the real virtual-joystick driver if installed; otherwise fall back
// to a no-op sink (keyboard/mouse still work) and note it in the status.
// Keyboard/mouse + joystick are suppressed while editing so the RIO can be
// exercised without injecting input; lamp feedback over serial still applies.
IInputSink input;
IJoystickSink joystick;
string joystickNote;
if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? feeder))
string note;
if (!routeInput)
{
input = new NullInputSink();
joystick = new NullJoystickSink();
_joystick = null;
note = " [editing — input suppressed]";
}
else if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? feeder))
{
// Use the real virtual-joystick driver if installed.
input = new SendInputSink();
joystick = feeder!;
_joystick = feeder;
joystickNote = string.Empty;
note = string.Empty;
}
else
{
// Otherwise fall back to a no-op joystick (keyboard/mouse still work).
input = new SendInputSink();
joystick = new NullJoystickSink();
_joystick = null;
joystickNote = " [no joystick driver]";
note = " [no joystick driver]";
}
_transport = _transportFactory(port);
@@ -129,7 +172,7 @@ public sealed class RioCoordinator : IDisposable
_runtime = new RioRuntime(
_link,
profile.ToInputMap(),
new SendInputSink(),
input,
joystick,
new AxisCalibrator(profile.Calibration));
@@ -137,7 +180,7 @@ public sealed class RioCoordinator : IDisposable
_ = _link.RunAsync(_cts.Token);
_runtime.Start();
_activeProfileName = profile.Name;
SetStatus($"Active: {profile.Name} ({_link.Description}){joystickNote}");
SetStatus($"{(routeInput ? "Active" : "Editing")}: {profile.Name} ({_link.Description}){note}");
}
catch (Exception ex)
{
@@ -146,6 +189,7 @@ public sealed class RioCoordinator : IDisposable
return;
}
if (routeInput)
ApplyWallpaper(profile);
}
+79
View File
@@ -1,4 +1,5 @@
using System.Runtime.Versioning;
using RioJoy.Core;
using RioJoy.Core.Mapping;
using RioJoy.Core.Profiles;
using RioJoy.Tray.Editor;
@@ -69,6 +70,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
menu.Items.Add(profileMenu);
menu.Items.Add("Edit profile…", null, (_, _) => OpenEditor());
menu.Items.Add("Import .ini…", null, (_, _) => ImportIniProfiles());
// RIO commands mirroring the legacy console menu.
var commands = new ToolStripMenuItem("RIO commands");
@@ -138,8 +140,31 @@ internal sealed class TrayApplicationContext : ApplicationContext
// a profile to edit.
RioProfile profile = _config.Profiles.FirstOrDefault() ?? AddBlankProfile();
// Acquiring the RIO is one of the two reasons we take the serial port (the
// other being a profiled game) — so the editor can check button function. Input
// is suppressed (no keystrokes); the editor only shows which button is pressed.
_coordinator.BeginEditorSession(profile);
var editor = new ProfileEditorForm(profile);
editor.Saved += _ => ConfigStore.Save(_config, ConfigPath);
editor.CommandRequested += cmd => _coordinator.Runtime?.Trigger(cmd);
RioRuntime? runtime = _coordinator.Runtime;
Action<int, bool>? activity = null;
if (runtime is not null)
{
activity = editor.ShowLiveActivity;
runtime.ButtonActivity += activity;
}
editor.FormClosed += (_, _) =>
{
if (runtime is not null && activity is not null)
runtime.ButtonActivity -= activity;
_coordinator.EndEditorSession();
_watcher.Reset(); // re-sync with the foreground app (may re-activate a game)
_watcher.Poll();
};
editor.Show();
}
@@ -150,6 +175,60 @@ internal sealed class TrayApplicationContext : ApplicationContext
return profile;
}
// Import one or more legacy RIO.ini files (the gsheet "INI_V2" format) as
// profiles, naming each after its filename and de-duping collisions.
private void ImportIniProfiles()
{
using var dialog = new OpenFileDialog
{
Title = "Import RIO .ini profile(s)",
Filter = "RIO INI files (*.ini)|*.ini|All files (*.*)|*.*",
Multiselect = true,
};
if (dialog.ShowDialog() != DialogResult.OK)
return;
var imported = new List<string>();
var failures = new List<string>();
foreach (string path in dialog.FileNames)
{
try
{
string name = UniqueProfileName(Path.GetFileNameWithoutExtension(path));
RioProfile profile = RioIniImporter.Import(File.ReadAllText(path), name);
_config.Profiles.Add(profile);
imported.Add($"{name} ({profile.Buttons.Count} buttons mapped)");
}
catch (Exception ex)
{
failures.Add($"{Path.GetFileName(path)}: {ex.Message}");
}
}
if (imported.Count > 0)
ConfigStore.Save(_config, ConfigPath);
string summary = imported.Count > 0
? "Imported:\n " + string.Join("\n ", imported)
: "No profiles imported.";
if (failures.Count > 0)
summary += "\n\nFailed:\n " + string.Join("\n ", failures);
MessageBox.Show(summary, "RIOJoy — Import .ini",
MessageBoxButtons.OK,
failures.Count > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information);
}
// Ensure the imported profile name is unique within the library.
private string UniqueProfileName(string desired)
{
string baseName = string.IsNullOrWhiteSpace(desired) ? "Imported" : desired.Trim();
string candidate = baseName;
for (int i = 2; _config.FindProfile(candidate) is not null; i++)
candidate = $"{baseName} ({i})";
return candidate;
}
private void AddCommand(ToolStripMenuItem parent, string text, RioCommandCode code)
{
var item = new ToolStripMenuItem(text);
@@ -27,7 +27,7 @@ public class CockpitPanelTests
[InlineData(0x38, "Throttle")]
[InlineData(0x10, "Secondary")]
[InlineData(0x18, "Screen")]
[InlineData(0x40, "Joystick / Hat")]
[InlineData(0x40, "Joystick")]
[InlineData(0x50, "Internal Keypad")]
[InlineData(0x6F, "External Keypad")]
public void Address_LandsInExpectedGroup(int address, string group)
@@ -83,6 +83,25 @@ public class AutoSwitchTests
Assert.Equal(SwitchMode.Yield, changes[1].Mode);
}
[Fact]
public void Reset_ReRaises_SameDecision_OnNextPoll()
{
var config = Config();
var provider = new FakeForeground(() => "doom.exe");
var watcher = new AutoSwitchWatcher(provider, () => config);
var changes = new List<SwitchDecision>();
watcher.DecisionChanged += changes.Add;
watcher.Poll(); // doom → Activate (raise)
watcher.Poll(); // same → no raise
watcher.Reset(); // forget last decision (e.g. after an editor session)
watcher.Poll(); // same decision, but re-raised because of Reset
Assert.Equal(2, changes.Count);
Assert.All(changes, c => Assert.Equal(SwitchMode.Activate, c.Mode));
}
private sealed class FakeForeground(Func<string?> get) : IForegroundProcessProvider
{
public string? GetForegroundExecutable() => get();
@@ -72,6 +72,34 @@ public class RioRuntimeTests
await run;
}
[Fact]
public async Task ButtonActivity_FiresForPressAndRelease_EvenWhenUnmapped()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
var recorder = new RecordingSink();
using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder); // empty map
var activity = new List<(int Address, bool Pressed)>();
runtime.ButtonActivity += (addr, pressed) => activity.Add((addr, pressed));
runtime.Start();
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonReleased, new byte[] { 0x05 }));
await WaitUntilAsync(() => activity.Count == 2);
Assert.Equal((0x05, true), activity[0]);
Assert.Equal((0x05, false), activity[1]);
Assert.Empty(recorder.Snapshot()); // unmapped → no output routed, but activity still fired
cts.Cancel();
await run;
}
[Fact]
public async Task KeypadKey_RoutesThroughOffsetAddress()
{