Editor: rework to the original Win32 RIO control-panel layout

Reworked the profile/mapping editor to mirror the original unfinished Win32
RIO driver's control-panel design (docs/Win32RIO, by FASA / Michel Lowrance)
instead of the flat config-sheet grid. The wallpaper region positions are a
VGA chroma-split display artifact, not the cockpit's logical shape.

- RioJoy.Core.Editing.CockpitPanel: the functional layout — five MFD clusters,
  four board columns (Throttle/Secondary/Screen/Joystick-Hat), an encoder-gauge
  strip, and the two later-added 4x4 keypads (Internal/External). Places every
  address 0x00-0x47 / 0x50-0x6F exactly once (unit-tested).
- Tray PanelView/PanelCanvas render the panel; ProfileEditorForm drives it.
  Buttons show label + assigned function (ButtonBinding.Describe -> CTL-N, JOY1,
  POV-U, Mse LB, RIO command names; unit-tested). Lamp shade is driven by the
  IsLit flag (not by whether a function is assigned); keypads are neutral blue
  with no Lit checkbox. Added an Unassign button next to Apply.
- Removed the superseded sheet-grid UI (SheetView/SheetCanvas); SheetLayout +
  config-sheet.csv stay as reference data.

docs/Win32RIO: the original driver (tasgame.sys, a 32-bit kernel HID minidriver
that opened the serial port, set baud/8N1/DTR, and spoke the *identical* RIO
protocol -- AnalogRequest/Reply, Button Pressed/Released, Check/Lamp/Reset/
Version -- validating our protocol port), oemsetup.inf, RemoteDriver.doc, and the
extracted control-panel / game-controllers mockups.

~236 xUnit tests green. PLAN.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-27 19:48:14 -05:00
co-authored by Claude Opus 4.8
parent 8d2d0b71aa
commit 094637b0a4
17 changed files with 625 additions and 212 deletions
+109
View File
@@ -0,0 +1,109 @@
namespace RioJoy.Core.Editing;
/// <summary>Shape of a panel group.</summary>
public enum PanelGroupKind
{
/// <summary>A 4×2 MFD button cluster (lamp buttons).</summary>
Mfd,
/// <summary>A vertical 1×8 board column (lamp buttons).</summary>
Column,
/// <summary>A 4×4 keypad (added later; no lamps).</summary>
Keypad,
}
/// <summary>
/// One functional group on the cockpit control panel: a titled block of address
/// buttons laid out in <see cref="Rows"/> × <see cref="Cols"/> (row-major, null =
/// empty slot). <see cref="OriginCol"/>/<see cref="OriginRow"/> place the block on
/// the panel in button-cell units (the renderer scales cells to pixels).
/// </summary>
public sealed record PanelGroup(
string Title,
PanelGroupKind Kind,
int Cols,
int Rows,
bool LampCapable,
int OriginCol,
int OriginRow,
IReadOnlyList<int?> Addresses);
/// <summary>One address button placed on the panel (absolute cell coordinates).</summary>
public sealed record PanelButton(int Address, PanelGroup Group, int Col, int Row, bool LampCapable);
/// <summary>
/// The cockpit control panel's logical layout — the functional grouping from the
/// original Win32 RIO design (docs/Win32RIO/control-panel-mockup.png): five MFD
/// clusters, four board columns, and the two later-added 4×4 keypads. Every RIO
/// address 0x000x47 / 0x500x6F is placed exactly once. Pure data so the mapping
/// is unit-tested; the WinForms editor renders these groups and binds each button.
/// </summary>
public static class CockpitPanel
{
// Helper: 4×2 MFD address block, top row then bottom row (addresses descend).
private static int?[] Mfd(int hi) => new int?[]
{
hi, hi - 1, hi - 2, hi - 3,
hi - 4, hi - 5, hi - 6, hi - 7,
};
// Helper: vertical 1×8 column from a base address upward.
private static int?[] Col8(int baseAddr) => new int?[]
{
baseAddr, baseAddr + 1, baseAddr + 2, baseAddr + 3,
baseAddr + 4, baseAddr + 5, baseAddr + 6, baseAddr + 7,
};
// Helper: 4×4 keypad. Physical keys 1 2 3 C / 4 5 6 D / 7 8 9 E / A 0 B F map to
// address = base + that hex digit.
private static int?[] Keypad(int baseAddr)
{
int[] digits = { 0x1, 0x2, 0x3, 0xC, 0x4, 0x5, 0x6, 0xD, 0x7, 0x8, 0x9, 0xE, 0xA, 0x0, 0xB, 0xF };
var a = new int?[16];
for (int i = 0; i < 16; i++) a[i] = baseAddr + digits[i];
return a;
}
/// <summary>All panel groups, positioned for rendering.</summary>
public static IReadOnlyList<PanelGroup> Groups { get; } = new[]
{
// 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)),
// 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)),
// 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)),
// 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)),
};
/// <summary>Flatten the groups to positioned <see cref="PanelButton"/>s (absolute cells).</summary>
public static IReadOnlyList<PanelButton> Buttons()
{
var buttons = new List<PanelButton>();
foreach (PanelGroup g in Groups)
{
for (int i = 0; i < g.Addresses.Count; i++)
{
if (g.Addresses[i] is not { } addr) continue;
int localCol = i % g.Cols;
int localRow = i / g.Cols;
// +1 row leaves space under the title.
buttons.Add(new PanelButton(addr, g, g.OriginCol + localCol, g.OriginRow + 1 + localRow, g.LampCapable));
}
}
return buttons;
}
}
+33
View File
@@ -34,6 +34,39 @@ public sealed class ButtonBinding
/// <summary>True when this binding does nothing (an empty slot).</summary>
public bool IsUnmapped => ToWord() == 0;
/// <summary>
/// A short human-readable description of the assigned function — e.g.
/// <c>CTL-N</c>, <c>JOY1</c>, <c>POV-U</c>, <c>Mse LB</c>, or a RIO command name.
/// Empty when unmapped. Used as the editor cell caption.
/// </summary>
public string Describe() => Kind switch
{
_ when IsUnmapped => string.Empty,
RioRouteKind.Keyboard =>
(Ctrl ? "CTL-" : "") + (Shift ? "SFT-" : "") + (Alt ? "ALT-" : "") + KeyCatalog.NameFor(Value),
RioRouteKind.Joystick => $"JOY{Value}",
RioRouteKind.Hat => (RioHat)Value switch
{
RioHat.Up => "POV-U",
RioHat.Right => "POV-R",
RioHat.Down => "POV-D",
RioHat.Left => "POV-L",
_ => "POV",
},
RioRouteKind.Mouse => "Mse " + (MouseAction)Value switch
{
MouseAction.MoveUp => "Up",
MouseAction.MoveDown => "Dn",
MouseAction.MoveLeft => "Lt",
MouseAction.MoveRight => "Rt",
MouseAction.LeftClick => "LB",
MouseAction.RightClick => "RB",
_ => Value.ToString(),
},
RioRouteKind.RioCommand => ((RioCommandCode)Value).ToString(),
_ => string.Empty,
};
/// <summary>Pack to the 16-bit <c>iRIO</c> map word.</summary>
public ushort ToWord() =>
RioMapEntry.Create(Kind, Value, IsLit, Shift, Ctrl, Alt, Extended).Raw;