Phase 5: tray app + profiles + runtime wiring

Wire the Core pieces into a runnable tray app with per-game profiles and the
three-state serial-yield auto-switch:

- Profiles/: RioProfile + AppConfig model; ConfigStore (System.Text.Json,
  round-tripped); RioIniImporter ports the legacy RIO.ini (button table, invert
  flags, plasma greeting); AutoSwitchResolver + AutoSwitchWatcher resolve the
  foreground executable into Yield (native game) / Activate (profile) / Idle, with
  native always winning and change-only notifications. IForegroundProcessProvider
  abstracts the OS.
- RioRuntime assembles a profile's live pipeline: serial ButtonPressed/Released +
  KeyPressed/Released → InputRouter (via RioAddress); AnalogReply → AxisCalibrator
  → the six joystick axes; RIO commands → calibration resets + version/check
  requests + lamp re-init. SerialLampSink sends lamp feedback over the link;
  NullJoystickSink is a placeholder until the Phase 1 HID feeder exists.
- RioJoy.Tray: NotifyIcon menu mirroring the legacy console menu (axis resets,
  version/status, raw-axes & poll-rate toggles, quit) + profile selection
  (auto vs. manual) + "start with Windows"; RioCoordinator owns the serial
  acquire/release tied to the watcher (native-game COM-port yield). OS adapters:
  ForegroundProcessProvider (Win32 foreground PID→exe) and AutoStartManager (HKCU
  Run key).
- tests: 18 new xUnit tests (123 total) for config round-trip, ini import,
  the three-state resolver + watcher, and RioRuntime end-to-end over the fake
  transport (button→joystick, keypad-offset→keyboard, analog→six axes).

The joystick output stays a no-op until the Phase 1 driver; on-cabinet
verification of the acquire/release lifecycle remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-26 15:36:58 -05:00
co-authored by Claude Opus 4.8
parent 24a1b64519
commit 1348040e1c
21 changed files with 1382 additions and 33 deletions
+6 -4
View File
@@ -35,7 +35,9 @@ dotnet test RioJoy.sln
## Status
Phases 24 (serial + RIO protocol core, input mapping + output routing, axis
calibration + plasma display) are code-complete and unit-tested (105 tests); the
virtual-HID feeder and hardware verification are pending on the Phase 1 driver.
See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.
Phases 25 are code-complete and unit-tested (123 tests): serial + RIO protocol
core, input mapping + output routing, axis calibration + plasma display, and the
tray app + profiles (JSON config, `RIO.ini` importer, the three-state auto-switch
watcher, and the `RioRuntime` that wires it together). The virtual-HID feeder and
on-cabinet verification are pending on the Phase 1 driver. See
[`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.
+20 -6
View File
@@ -164,12 +164,26 @@ Implemented in `src/RioJoy.Core/Calibration` + `Plasma` (105 xUnit tests total):
-**Remaining:** hardware verification of axis feel + plasma output; the
game-specific `PlasmaScoreDraw` layout is profile content (Phase 5/7).
### Phase 5 — Tray app + profiles
- NotifyIcon + menu mirroring the legacy console menu (reset/recalibrate axes,
version/status, toggle raw-axis & poll-rate readouts, quit) + status/log window.
- **Profile library**, manual selection, and the **three-state auto-switch
watcher** (incl. native-game yield).
- Config persistence; auto-start.
### Phase 5 — Tray app + profiles — code-complete ✅
Core logic in `src/RioJoy.Core/Profiles` + `RioRuntime`; UI/OS in `src/RioJoy.Tray`
(123 xUnit tests total):
- `RioProfile` + `AppConfig` model; `ConfigStore` JSON persistence (round-trip
tested); `RioIniImporter` ports the legacy `RIO.ini` (buttons/inverts/greeting).
- `AutoSwitchResolver` + `AutoSwitchWatcher`: the three-state decision
(Yield native / Activate profile / Idle) from the foreground executable, native
always winning; raises only on change. Pure + tested.
- `RioRuntime` assembles a profile's live pipeline: serial button/keypad packets →
`InputRouter`; analog replies → `AxisCalibrator` → the six joystick axes; RIO
commands → calibration resets + serial requests + lamp re-init. End-to-end tested
over the fake transport.
- Tray: `NotifyIcon` menu mirroring the legacy console menu (axis resets,
version/status, diagnostic toggles, quit) + profile selection (auto vs. manual)
+ "start with Windows"; `RioCoordinator` owns the serial acquire/release tied to
the watcher (native-game COM-port yield); OS adapters
(`ForegroundProcessProvider`, `AutoStartManager`).
-**Remaining:** the joystick output is a `NullJoystickSink` placeholder until
the Phase 1 HID feeder exists; full on-cabinet verification of the auto-switch +
acquire/release lifecycle.
### Phase 6 — Packaging / signing / deploy
- Driver install via `pnputil`; app installer; test-signing script.
@@ -0,0 +1,17 @@
using RioJoy.Core.Calibration;
namespace RioJoy.Core.Mapping;
/// <summary>
/// A no-op <see cref="IJoystickSink"/> placeholder used until the real HID feeder
/// (→ RioGamepad driver via DeviceIoControl, Phase 1) exists. Lets the runtime be
/// assembled and exercised end-to-end without a virtual joystick present.
/// </summary>
public sealed class NullJoystickSink : IJoystickSink
{
public void SetButton(int button, bool pressed) { }
public void SetHat(RioHat position) { }
public void SetAxis(JoyAxis axis, int value) { }
}
+40
View File
@@ -0,0 +1,40 @@
namespace RioJoy.Core.Profiles;
/// <summary>
/// Top-level RIOJoy configuration: the profile library plus the global settings
/// that drive the three-state auto-switch (see docs/PLAN.md §Profiles). Persisted
/// as JSON by <see cref="ConfigStore"/>.
/// </summary>
public sealed class AppConfig
{
/// <summary>Default RIO COM port when a profile doesn't specify one.</summary>
public string DefaultRioComPort { get; set; } = "COM1";
/// <summary>Default plasma COM port when a profile doesn't specify one.</summary>
public string? DefaultPlasmaComPort { get; set; } = "COM2";
/// <summary>
/// Executable names of the native games to yield to. When one of these is the
/// foreground app, RIOJoy releases the COM port and goes dormant. Matched
/// case-insensitively, with or without ".exe".
/// </summary>
public List<string> NativeGameExecutables { get; set; } = new();
/// <summary>The per-game profile library.</summary>
public List<RioProfile> Profiles { get; set; } = new();
/// <summary>
/// Optional profile to activate when nothing else matches (desktop/idle). Null
/// means stay idle / release the port.
/// </summary>
public string? NeutralProfileName { get; set; }
/// <summary>Start RIOJoy with Windows.</summary>
public bool AutoStart { get; set; }
/// <summary>Find a profile by name (case-insensitive), or null.</summary>
public RioProfile? FindProfile(string? name) =>
name is null
? null
: Profiles.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
}
+90
View File
@@ -0,0 +1,90 @@
namespace RioJoy.Core.Profiles;
/// <summary>The three states of the serial-port yield / profile auto-switch.</summary>
public enum SwitchMode
{
/// <summary>A native game is foreground — release the COM port and go dormant.</summary>
Yield,
/// <summary>A supported non-native game is foreground — run its profile.</summary>
Activate,
/// <summary>Nothing matched — idle (port released or a neutral default).</summary>
Idle,
}
/// <summary>The decision produced by <see cref="AutoSwitchResolver"/>.</summary>
public readonly struct SwitchDecision : IEquatable<SwitchDecision>
{
public SwitchMode Mode { get; }
/// <summary>The profile to run (set when <see cref="Mode"/> is <see cref="SwitchMode.Activate"/>).</summary>
public RioProfile? Profile { get; }
private SwitchDecision(SwitchMode mode, RioProfile? profile)
{
Mode = mode;
Profile = profile;
}
public static SwitchDecision Yield() => new(SwitchMode.Yield, null);
public static SwitchDecision Idle() => new(SwitchMode.Idle, null);
public static SwitchDecision Activate(RioProfile profile) => new(SwitchMode.Activate, profile);
public bool Equals(SwitchDecision other) => Mode == other.Mode && ReferenceEquals(Profile, other.Profile);
public override bool Equals(object? obj) => obj is SwitchDecision d && Equals(d);
public override int GetHashCode() => HashCode.Combine(Mode, Profile);
public override string ToString() => Mode == SwitchMode.Activate ? $"Activate({Profile?.Name})" : Mode.ToString();
}
/// <summary>
/// Resolves the foreground executable into a <see cref="SwitchDecision"/>, the
/// pure core of the auto-switch state machine (see docs/PLAN.md §Profiles).
/// Precedence: a native game always wins (yield); otherwise the first profile that
/// lists the executable activates; otherwise the neutral profile (if any) or idle.
/// </summary>
public static class AutoSwitchResolver
{
/// <summary>Normalize an executable name for matching: basename, no ".exe", lower-case.</summary>
public static string Normalize(string executable)
{
ArgumentNullException.ThrowIfNull(executable);
string name = executable.Trim();
// Strip any path (handle both separators regardless of OS).
int slash = name.LastIndexOfAny(new[] { '/', '\\' });
if (slash >= 0)
name = name[(slash + 1)..];
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
name = name[..^4];
return name.ToLowerInvariant();
}
private static bool Matches(IEnumerable<string> patterns, string normalizedExe) =>
patterns.Any(p => Normalize(p) == normalizedExe);
/// <summary>
/// Decide what RIOJoy should do given the current foreground executable
/// (<paramref name="foregroundExecutable"/> may be null when unknown/desktop).
/// </summary>
public static SwitchDecision Resolve(AppConfig config, string? foregroundExecutable)
{
ArgumentNullException.ThrowIfNull(config);
if (!string.IsNullOrWhiteSpace(foregroundExecutable))
{
string exe = Normalize(foregroundExecutable);
if (Matches(config.NativeGameExecutables, exe))
return SwitchDecision.Yield();
foreach (RioProfile profile in config.Profiles)
{
if (Matches(profile.MatchExecutables, exe))
return SwitchDecision.Activate(profile);
}
}
RioProfile? neutral = config.FindProfile(config.NeutralProfileName);
return neutral is not null ? SwitchDecision.Activate(neutral) : SwitchDecision.Idle();
}
}
@@ -0,0 +1,57 @@
namespace RioJoy.Core.Profiles;
/// <summary>Supplies the current foreground application's executable name.</summary>
public interface IForegroundProcessProvider
{
/// <summary>The foreground executable name (e.g. "game.exe"), or null if unknown.</summary>
string? GetForegroundExecutable();
}
/// <summary>
/// Polls the foreground process and resolves the three-state auto-switch decision,
/// raising <see cref="DecisionChanged"/> only when the decision actually changes.
/// The polling cadence is driven by the host; <see cref="Poll"/> performs one
/// resolution so the logic is deterministic and unit-testable.
/// </summary>
public sealed class AutoSwitchWatcher
{
private readonly IForegroundProcessProvider _provider;
private readonly Func<AppConfig> _configAccessor;
private SwitchDecision? _last;
public AutoSwitchWatcher(IForegroundProcessProvider provider, Func<AppConfig> configAccessor)
{
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
_configAccessor = configAccessor ?? throw new ArgumentNullException(nameof(configAccessor));
}
/// <summary>Raised when the resolved decision differs from the previous one.</summary>
public event Action<SwitchDecision>? DecisionChanged;
/// <summary>The most recently resolved decision (null until the first <see cref="Poll"/>).</summary>
public SwitchDecision? Current => _last;
/// <summary>Resolve once; raise <see cref="DecisionChanged"/> if it changed. Returns the decision.</summary>
public SwitchDecision Poll()
{
string? exe = _provider.GetForegroundExecutable();
SwitchDecision decision = AutoSwitchResolver.Resolve(_configAccessor(), exe);
if (_last is null || !_last.Value.Equals(decision))
{
_last = decision;
DecisionChanged?.Invoke(decision);
}
return decision;
}
/// <summary>Poll on <paramref name="interval"/> until cancelled.</summary>
public async Task RunAsync(TimeSpan interval, CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(interval);
Poll();
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
Poll();
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace RioJoy.Core.Profiles;
/// <summary>
/// Loads and saves <see cref="AppConfig"/> as JSON. Replaces the legacy
/// SimpleIni single-file config with a profile library (see docs/PLAN.md).
/// </summary>
public static class ConfigStore
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
public static string Serialize(AppConfig config)
{
ArgumentNullException.ThrowIfNull(config);
return JsonSerializer.Serialize(config, Options);
}
public static AppConfig Deserialize(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
return JsonSerializer.Deserialize<AppConfig>(json, Options)
?? throw new JsonException("Config JSON deserialized to null.");
}
/// <summary>Save the config to <paramref name="path"/> (creating directories).</summary>
public static void Save(AppConfig config, string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
string? dir = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(path, Serialize(config));
}
/// <summary>
/// Load the config from <paramref name="path"/>, or return a fresh default
/// <see cref="AppConfig"/> if the file does not exist.
/// </summary>
public static AppConfig Load(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
return File.Exists(path) ? Deserialize(File.ReadAllText(path)) : new AppConfig();
}
}
+82
View File
@@ -0,0 +1,82 @@
namespace RioJoy.Core.Profiles;
/// <summary>
/// Minimal INI reader (sections, <c>key = value</c>, <c>;</c>/<c>#</c> comments)
/// sufficient to parse the legacy <c>RIO.ini</c>. Keys are case-insensitive.
/// </summary>
public sealed class IniFile
{
private readonly Dictionary<string, Dictionary<string, string>> _sections =
new(StringComparer.OrdinalIgnoreCase);
public static IniFile Parse(string text)
{
ArgumentNullException.ThrowIfNull(text);
var ini = new IniFile();
var current = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
ini._sections[string.Empty] = current;
foreach (string rawLine in text.Split('\n'))
{
string line = rawLine.Trim();
if (line.Length == 0 || line[0] is ';' or '#')
continue;
if (line[0] == '[' && line[^1] == ']')
{
string section = line[1..^1].Trim();
if (!ini._sections.TryGetValue(section, out current!))
ini._sections[section] = current = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
continue;
}
int eq = line.IndexOf('=');
if (eq < 0)
continue;
string key = line[..eq].Trim();
string value = line[(eq + 1)..].Trim();
if (key.Length > 0)
current[key] = value;
}
return ini;
}
/// <summary>All key/value pairs in a section (empty if the section is absent).</summary>
public IReadOnlyDictionary<string, string> Section(string name) =>
_sections.TryGetValue(name, out Dictionary<string, string>? s)
? s
: new Dictionary<string, string>();
public string? GetString(string section, string key) =>
Section(section).TryGetValue(key, out string? v) ? v : null;
public bool GetBool(string section, string key, bool fallback)
{
string? v = GetString(section, key);
if (v is null)
return fallback;
return v.Trim().ToLowerInvariant() switch
{
"1" or "true" or "yes" or "on" => true,
"0" or "false" or "no" or "off" => false,
_ => fallback,
};
}
/// <summary>Parse an integer value that may be hex (<c>0x…</c>) or decimal.</summary>
public static int ParseInt(string value)
{
ArgumentNullException.ThrowIfNull(value);
string v = value.Trim();
bool neg = v.StartsWith('-');
if (neg) v = v[1..];
int magnitude = v.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? Convert.ToInt32(v[2..], 16)
: int.Parse(v);
return neg ? -magnitude : magnitude;
}
}
@@ -0,0 +1,64 @@
using System.Globalization;
using RioJoy.Core.Calibration;
using RioJoy.Core.Mapping;
namespace RioJoy.Core.Profiles;
/// <summary>
/// Imports a legacy <c>RIO.ini</c> into a <see cref="RioProfile"/> (see the key
/// layout in <c>riovjoy2.cpp</c> around line 343). The <c>[Buttons]</c> keys are
/// <c>RIO&lt;hex addr&gt;</c> with hex/decimal map words; <c>[JoyStick]</c> holds
/// the invert/enableZR flags; <c>[Plasma] Greeting</c> is the greeting text.
/// </summary>
public static class RioIniImporter
{
/// <summary>Parse <paramref name="iniText"/> into a profile named <paramref name="name"/>.</summary>
public static RioProfile Import(string iniText, string name = "Imported")
{
ArgumentNullException.ThrowIfNull(iniText);
IniFile ini = IniFile.Parse(iniText);
var profile = new RioProfile
{
Name = name,
PlasmaGreeting = ini.GetString("Plasma", "Greeting"),
WallpaperPath = ini.GetString("Desktop", "File"),
Calibration = new AxisCalibrationConfig
{
InvertX = ini.GetBool("JoyStick", "invertX", false),
InvertY = ini.GetBool("JoyStick", "invertY", false),
InvertZ = ini.GetBool("JoyStick", "invertZ", false),
InvertXR = ini.GetBool("JoyStick", "invertXR", false),
InvertYR = ini.GetBool("JoyStick", "invertYR", false),
InvertZR = ini.GetBool("JoyStick", "invertZR", false),
EnableZR = ini.GetBool("JoyStick", "enableZR", true),
},
};
foreach ((string key, string value) in ini.Section("Buttons"))
{
if (!TryParseButtonAddress(key, out int address))
continue;
int raw = IniFile.ParseInt(value);
if (raw != 0) // skip "do nothing" entries
profile.Buttons[address] = raw & 0xFFFF;
}
return profile;
}
// Keys look like "RIO00".."RIO6F": "RIO" + a hex address into the iRIO table.
private static bool TryParseButtonAddress(string key, out int address)
{
address = 0;
if (!key.StartsWith("RIO", StringComparison.OrdinalIgnoreCase))
return false;
string hex = key[3..];
if (!int.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out address))
return false;
return address >= 0 && address < RioAddress.TableSize;
}
}
+52
View File
@@ -0,0 +1,52 @@
using RioJoy.Core.Calibration;
using RioJoy.Core.Mapping;
namespace RioJoy.Core.Profiles;
/// <summary>
/// A per-game profile: everything about how the cockpit behaves for one
/// non-native game (see docs/PLAN.md §Profiles). The unified profile supersedes
/// the legacy single-file <c>RIO.ini</c>. Profiles never describe the native
/// games — those are the hands-off/yield case.
/// </summary>
public sealed class RioProfile
{
/// <summary>Display name (unique within a library).</summary>
public string Name { get; set; } = "Unnamed";
/// <summary>RIO serial port (e.g. "COM3"); null = use the app default.</summary>
public string? RioComPort { get; set; }
/// <summary>Plasma/VFD serial port; null = use the app default or none.</summary>
public string? PlasmaComPort { get; set; }
/// <summary>
/// The <c>iRIO</c> map: RIO address (0x00..0x6F) → 16-bit map word. Only
/// mapped addresses need entries.
/// </summary>
public Dictionary<int, int> Buttons { get; set; } = new();
/// <summary>Axis calibration / invert options.</summary>
public AxisCalibrationConfig Calibration { get; set; } = new();
/// <summary>Plasma greeting text shown on load (null = leave display as-is).</summary>
public string? PlasmaGreeting { get; set; }
/// <summary>Cockpit wallpaper image path (generated in Phase 7).</summary>
public string? WallpaperPath { get; set; }
/// <summary>
/// Executable names that select this profile when they are the foreground app
/// (matched case-insensitively, with or without ".exe").
/// </summary>
public List<string> MatchExecutables { get; set; } = new();
/// <summary>Build a <see cref="RioInputMap"/> from <see cref="Buttons"/>.</summary>
public RioInputMap ToInputMap()
{
var map = new RioInputMap();
foreach ((int address, int raw) in Buttons)
map[address] = new RioMapEntry((ushort)raw);
return map;
}
}
+138
View File
@@ -0,0 +1,138 @@
using RioJoy.Core.Calibration;
using RioJoy.Core.Mapping;
using RioJoy.Core.Protocol;
using RioJoy.Core.Serial;
namespace RioJoy.Core;
/// <summary>
/// Ties the live RIO link to the input/output pipeline for one active profile:
/// serial button/keypad packets drive the <see cref="InputRouter"/>; analog
/// replies drive the <see cref="AxisCalibrator"/> and the six joystick axes; RIO
/// commands trigger calibration resets and serial requests. This is the runtime
/// assembled per-profile by the tray app (Phase 5).
/// </summary>
public sealed class RioRuntime : IRioCommandSink, IDisposable
{
private readonly RioSerialLink _link;
private readonly IJoystickSink _joystick;
private readonly AxisCalibrator _calibrator;
private readonly InputRouter _router;
private bool _started;
public RioRuntime(
RioSerialLink link,
RioInputMap map,
IInputSink input,
IJoystickSink joystick,
AxisCalibrator? calibrator = null)
{
_link = link ?? throw new ArgumentNullException(nameof(link));
ArgumentNullException.ThrowIfNull(map);
ArgumentNullException.ThrowIfNull(input);
_joystick = joystick ?? throw new ArgumentNullException(nameof(joystick));
_calibrator = calibrator ?? new AxisCalibrator();
var lamp = new SerialLampSink(link);
_router = new InputRouter(map, input, joystick, lamp, this);
}
/// <summary>Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate).</summary>
public event Action<RioCommandCode>? DiagnosticToggle;
/// <summary>Subscribe to the link and set all lamps to their idle state.</summary>
public void Start()
{
if (_started)
return;
_started = true;
_link.PacketReceived += OnPacket;
_link.AnalogReceived += OnAnalog;
_router.InitializeLamps();
}
/// <summary>Unsubscribe from the link.</summary>
public void Stop()
{
if (!_started)
return;
_started = false;
_link.PacketReceived -= OnPacket;
_link.AnalogReceived -= OnAnalog;
}
public void Dispose() => Stop();
/// <summary>Invoke a RIO command directly (e.g. from the tray menu).</summary>
public void Trigger(RioCommandCode command) => ((IRioCommandSink)this).Execute(command);
private void OnAnalog(AnalogReport report)
{
AxisOutputs axes = _calibrator.Update(report);
_joystick.SetAxis(JoyAxis.X, axes.X);
_joystick.SetAxis(JoyAxis.Y, axes.Y);
_joystick.SetAxis(JoyAxis.Z, axes.Z);
_joystick.SetAxis(JoyAxis.Rx, axes.Rx);
_joystick.SetAxis(JoyAxis.Ry, axes.Ry);
_joystick.SetAxis(JoyAxis.Rz, axes.Rz);
}
private void OnPacket(RioPacket packet)
{
ReadOnlySpan<byte> p = packet.Payload.Span;
switch (packet.Command)
{
case RioCommand.ButtonPressed when p[0] < RioAddress.ButtonCount:
_router.Press(RioAddress.FromButton(p[0]));
break;
case RioCommand.ButtonReleased when p[0] < RioAddress.ButtonCount:
_router.Release(RioAddress.FromButton(p[0]));
break;
case RioCommand.KeyPressed when IsKeypad(p):
_router.Press(RioAddress.FromKeypad(p[0], p[1]));
break;
case RioCommand.KeyReleased when IsKeypad(p):
_router.Release(RioAddress.FromKeypad(p[0], p[1]));
break;
}
}
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).
void IRioCommandSink.Execute(RioCommandCode command)
{
switch (command)
{
case RioCommandCode.ResetAllAxes:
case RioCommandCode.ResetThrottle:
case RioCommandCode.ResetLeftPedal:
case RioCommandCode.ResetRightPedal:
case RioCommandCode.ResetVerticalJoystick:
case RioCommandCode.ResetHorizontalJoystick:
_calibrator.Reset(command);
FireAndForget(_link.ResetAsync((RioResetTarget)(byte)command));
break;
case RioCommandCode.GeneralReset:
FireAndForget(_link.ResetAsync(RioResetTarget.All));
_router.InitializeLamps();
break;
case RioCommandCode.RequestVersionAndCheck:
FireAndForget(_link.RequestVersionAsync());
FireAndForget(_link.RequestCheckAsync());
break;
case RioCommandCode.ToggleRawAxesReadout:
case RioCommandCode.TogglePollRateReadout:
DiagnosticToggle?.Invoke(command);
break;
}
}
private static void FireAndForget(Task task) =>
task.ContinueWith(static t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
}
+35
View File
@@ -0,0 +1,35 @@
using RioJoy.Core.Mapping;
namespace RioJoy.Core.Serial;
/// <summary>
/// <see cref="ILampSink"/> that sends lamp feedback back to the RIO as
/// <c>LampRequest</c> packets over the serial link. Writes are fire-and-forget
/// (lamp feedback is best-effort and must not block input routing).
/// </summary>
public sealed class SerialLampSink : ILampSink
{
private readonly RioSerialLink _link;
public SerialLampSink(RioSerialLink link)
{
_link = link ?? throw new ArgumentNullException(nameof(link));
}
public void SetLamp(int address, byte lampState)
{
_ = SendAsync((byte)address, lampState);
}
private async Task SendAsync(byte address, byte state)
{
try
{
await _link.SetLampAsync(address, state).ConfigureAwait(false);
}
catch
{
// Best-effort: a dropped lamp update must not crash the input path.
}
}
}
+31
View File
@@ -0,0 +1,31 @@
using System.Runtime.Versioning;
using Microsoft.Win32;
namespace RioJoy.Tray.Os;
/// <summary>
/// Manages the Windows "run at login" registration via the per-user
/// <c>Run</c> registry key. Per-user (HKCU) needs no elevation and matches the
/// tray app running in the interactive session.
/// </summary>
[SupportedOSPlatform("windows")]
public static class AutoStartManager
{
private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string ValueName = "RIOJoy";
public static bool IsEnabled()
{
using RegistryKey? key = Registry.CurrentUser.OpenSubKey(RunKey);
return key?.GetValue(ValueName) is not null;
}
public static void SetEnabled(bool enabled)
{
using RegistryKey key = Registry.CurrentUser.CreateSubKey(RunKey);
if (enabled)
key.SetValue(ValueName, $"\"{Environment.ProcessPath}\"");
else
key.DeleteValue(ValueName, throwOnMissingValue: false);
}
}
@@ -0,0 +1,43 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using RioJoy.Core.Profiles;
namespace RioJoy.Tray.Os;
/// <summary>
/// <see cref="IForegroundProcessProvider"/> backed by Win32: resolves the
/// foreground window's owning process and returns its executable file name. Used
/// by the auto-switch watcher to detect native games vs. supported titles.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class ForegroundProcessProvider : IForegroundProcessProvider
{
public string? GetForegroundExecutable()
{
nint hwnd = GetForegroundWindow();
if (hwnd == 0)
return null;
_ = GetWindowThreadProcessId(hwnd, out uint pid);
if (pid == 0)
return null;
try
{
using Process process = Process.GetProcessById((int)pid);
// MainModule can throw for protected/elevated processes; ProcessName is a safe fallback.
return process.MainModule?.ModuleName ?? process.ProcessName;
}
catch
{
return null;
}
}
[DllImport("user32.dll")]
private static extern nint GetForegroundWindow();
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(nint hWnd, out uint lpdwProcessId);
}
+159
View File
@@ -0,0 +1,159 @@
using System.Runtime.Versioning;
using RioJoy.Core;
using RioJoy.Core.Calibration;
using RioJoy.Core.Mapping;
using RioJoy.Core.Output;
using RioJoy.Core.Profiles;
using RioJoy.Core.Serial;
namespace RioJoy.Tray;
/// <summary>
/// Owns the active RIO connection and drives the three-state auto-switch: it
/// opens/closes the serial link and assembles a <see cref="RioRuntime"/> for the
/// 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.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class RioCoordinator : IDisposable
{
private readonly Func<AppConfig> _config;
private readonly Func<string, IRioTransport> _transportFactory;
private bool _auto = true;
// The active connection (null when dormant).
private IRioTransport? _transport;
private RioSerialLink? _link;
private RioRuntime? _runtime;
private CancellationTokenSource? _cts;
private string? _activeProfileName;
public RioCoordinator(Func<AppConfig> config, Func<string, IRioTransport>? transportFactory = null)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_transportFactory = transportFactory ?? (port => new SerialPortTransport(port));
}
/// <summary>Raised (with a short status string) whenever the active state changes.</summary>
public event Action<string>? StatusChanged;
/// <summary>Current status line for the tray.</summary>
public string Status { get; private set; } = "Starting…";
/// <summary>The name of the active profile, or null when dormant.</summary>
public string? ActiveProfileName => _activeProfileName;
/// <summary>Whether the auto-switch watcher controls the active profile.</summary>
public bool IsAuto => _auto;
/// <summary>The currently running runtime, for manual RIO-command triggers.</summary>
public RioRuntime? Runtime => _runtime;
/// <summary>Apply a watcher decision (ignored while a manual override is in effect).</summary>
public void ApplyDecision(SwitchDecision decision)
{
if (!_auto)
return;
switch (decision.Mode)
{
case SwitchMode.Activate when decision.Profile is not null:
Activate(decision.Profile);
break;
case SwitchMode.Yield:
GoDormant("Dormant — native game has the port");
break;
default:
GoDormant("Idle");
break;
}
}
/// <summary>Hand control back to the auto-switch watcher.</summary>
public void SetAuto()
{
_auto = true;
SetStatus("Auto");
}
/// <summary>Manually activate a profile; disables auto until <see cref="SetAuto"/>.</summary>
public void SetManualProfile(RioProfile profile)
{
_auto = false;
Activate(profile);
}
/// <summary>Manually go dormant (release the port); disables auto.</summary>
public void SetManualDormant()
{
_auto = false;
GoDormant("Dormant (manual)");
}
private void Activate(RioProfile profile)
{
if (_activeProfileName == profile.Name && _runtime is not null)
return; // already running this profile
Teardown();
AppConfig config = _config();
string port = profile.RioComPort ?? config.DefaultRioComPort;
try
{
_transport = _transportFactory(port);
_link = new RioSerialLink(_transport);
_runtime = new RioRuntime(
_link,
profile.ToInputMap(),
new SendInputSink(),
new NullJoystickSink(),
new AxisCalibrator(profile.Calibration));
_cts = new CancellationTokenSource();
_ = _link.RunAsync(_cts.Token);
_runtime.Start();
_activeProfileName = profile.Name;
SetStatus($"Active: {profile.Name} ({_link.Description})");
}
catch (Exception ex)
{
Teardown();
SetStatus($"Error opening {port}: {ex.Message}");
}
}
private void GoDormant(string status)
{
Teardown();
SetStatus(status);
}
private void Teardown()
{
_runtime?.Dispose();
_runtime = null;
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
_link = null;
_transport?.Dispose(); // releases the COM port
_transport = null;
_activeProfileName = null;
}
private void SetStatus(string status)
{
Status = status;
StatusChanged?.Invoke(status);
}
public void Dispose() => Teardown();
}
+148 -13
View File
@@ -1,39 +1,174 @@
using System.Runtime.Versioning;
using RioJoy.Core.Mapping;
using RioJoy.Core.Profiles;
using RioJoy.Tray.Os;
namespace RioJoy.Tray;
/// <summary>
/// Owns the tray icon and (eventually) the RIOJoy runtime: the serial link to
/// the RIO, the active profile, the virtual-HID feeder, and the profile
/// auto-switch watcher.
///
/// Phase 0 scaffold: just the tray icon + a minimal menu. Wiring the runtime is
/// Phase 5 work; the menu items below are placeholders that mirror the legacy
/// console menu (reset/recalibrate axes, version/status, quit).
/// Owns the tray icon, menu, and the RIOJoy runtime. The menu mirrors the legacy
/// console menu (axis resets, version/status, diagnostic toggles, quit) and adds
/// profile selection (auto vs. manual) and a "start with Windows" toggle. The
/// auto-switch watcher is polled on a UI timer so menu/status updates stay on the
/// UI thread.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class TrayApplicationContext : ApplicationContext
{
private static readonly string ConfigPath =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RIOJoy", "config.json");
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
private readonly AppConfig _config;
private readonly RioCoordinator _coordinator;
private readonly AutoSwitchWatcher _watcher;
private readonly System.Windows.Forms.Timer _pollTimer;
private readonly NotifyIcon _trayIcon;
private readonly ToolStripMenuItem _statusItem;
public TrayApplicationContext()
{
var menu = new ContextMenuStrip();
menu.Items.Add("RIOJoy (scaffold)").Enabled = false;
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("Exit", null, (_, _) => ExitThread());
_config = ConfigStore.Load(ConfigPath);
_coordinator = new RioCoordinator(() => _config);
_coordinator.StatusChanged += _ => RefreshOnUiThread();
_watcher = new AutoSwitchWatcher(new ForegroundProcessProvider(), () => _config);
_watcher.DecisionChanged += d => _coordinator.ApplyDecision(d);
_statusItem = new ToolStripMenuItem("Status: starting…") { Enabled = false };
_trayIcon = new NotifyIcon
{
Icon = SystemIcons.Application,
Text = "RIOJoy",
Visible = true,
ContextMenuStrip = menu,
ContextMenuStrip = BuildMenu(),
};
// Poll the foreground app on the UI thread.
_pollTimer = new System.Windows.Forms.Timer { Interval = (int)PollInterval.TotalMilliseconds };
_pollTimer.Tick += (_, _) => _watcher.Poll();
_pollTimer.Start();
_watcher.Poll();
}
private ContextMenuStrip BuildMenu()
{
var menu = new ContextMenuStrip();
menu.Items.Add(_statusItem);
menu.Items.Add(new ToolStripSeparator());
// Profile selection: Auto + each profile + manual dormant.
var profileMenu = new ToolStripMenuItem("Profile");
profileMenu.DropDownOpening += (_, _) => RebuildProfileMenu(profileMenu);
RebuildProfileMenu(profileMenu);
menu.Items.Add(profileMenu);
// RIO commands mirroring the legacy console menu.
var commands = new ToolStripMenuItem("RIO commands");
AddCommand(commands, "Reset all analog axes", RioCommandCode.ResetAllAxes);
AddCommand(commands, "Reset throttle", RioCommandCode.ResetThrottle);
AddCommand(commands, "Reset left pedal", RioCommandCode.ResetLeftPedal);
AddCommand(commands, "Reset right pedal", RioCommandCode.ResetRightPedal);
AddCommand(commands, "Reset vertical joystick", RioCommandCode.ResetVerticalJoystick);
AddCommand(commands, "Reset horizontal joystick", RioCommandCode.ResetHorizontalJoystick);
commands.DropDownItems.Add(new ToolStripSeparator());
AddCommand(commands, "Reset digital (general)", RioCommandCode.GeneralReset);
AddCommand(commands, "Request version && status", RioCommandCode.RequestVersionAndCheck);
AddCommand(commands, "Toggle raw-axes readout", RioCommandCode.ToggleRawAxesReadout);
AddCommand(commands, "Toggle poll-rate readout", RioCommandCode.TogglePollRateReadout);
menu.Items.Add(commands);
menu.Items.Add(new ToolStripSeparator());
var autoStart = new ToolStripMenuItem("Start with Windows")
{
CheckOnClick = true,
Checked = AutoStartManager.IsEnabled(),
};
autoStart.Click += (_, _) => AutoStartManager.SetEnabled(autoStart.Checked);
menu.Items.Add(autoStart);
menu.Items.Add("Exit", null, (_, _) => Quit());
return menu;
}
private void RebuildProfileMenu(ToolStripMenuItem profileMenu)
{
profileMenu.DropDownItems.Clear();
var auto = new ToolStripMenuItem("Auto (foreground app)") { Checked = _coordinator.IsAuto };
auto.Click += (_, _) => _coordinator.SetAuto();
profileMenu.DropDownItems.Add(auto);
profileMenu.DropDownItems.Add(new ToolStripSeparator());
foreach (RioProfile profile in _config.Profiles)
{
RioProfile captured = profile;
var item = new ToolStripMenuItem(profile.Name)
{
Checked = !_coordinator.IsAuto && _coordinator.ActiveProfileName == profile.Name,
};
item.Click += (_, _) => _coordinator.SetManualProfile(captured);
profileMenu.DropDownItems.Add(item);
}
if (_config.Profiles.Count == 0)
profileMenu.DropDownItems.Add(new ToolStripMenuItem("(no profiles configured)") { Enabled = false });
profileMenu.DropDownItems.Add(new ToolStripSeparator());
var dormant = new ToolStripMenuItem("Dormant (release port)")
{
Checked = !_coordinator.IsAuto && _coordinator.ActiveProfileName is null,
};
dormant.Click += (_, _) => _coordinator.SetManualDormant();
profileMenu.DropDownItems.Add(dormant);
}
private void AddCommand(ToolStripMenuItem parent, string text, RioCommandCode code)
{
var item = new ToolStripMenuItem(text);
item.Click += (_, _) => _coordinator.Runtime?.Trigger(code);
parent.DropDownItems.Add(item);
}
private void RefreshOnUiThread()
{
if (_trayIcon.ContextMenuStrip?.InvokeRequired == true)
{
_trayIcon.ContextMenuStrip.BeginInvoke(RefreshStatus);
return;
}
RefreshStatus();
}
private void RefreshStatus()
{
_statusItem.Text = $"Status: {_coordinator.Status}";
_trayIcon.Text = $"RIOJoy — {_coordinator.Status}".Length <= 63
? $"RIOJoy — {_coordinator.Status}"
: "RIOJoy";
}
private void Quit()
{
_pollTimer.Stop();
_coordinator.Dispose();
_trayIcon.Visible = false;
ExitThread();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_trayIcon.Visible = false;
_pollTimer.Dispose();
_coordinator.Dispose();
_trayIcon.Dispose();
}
@@ -8,23 +8,40 @@ namespace RioJoy.Core.Tests.Mapping;
/// </summary>
internal sealed class RecordingSink : IInputSink, IJoystickSink, ILampSink, IRioCommandSink
{
public List<string> Log { get; } = new();
private readonly List<string> _log = new();
public void KeyDown(byte virtualKey, bool extended) => Log.Add($"KeyDown(0x{virtualKey:X2},ext={extended})");
public void KeyUp(byte virtualKey, bool extended) => Log.Add($"KeyUp(0x{virtualKey:X2},ext={extended})");
public void MouseMove(int dx, int dy) => Log.Add($"MouseMove({dx},{dy})");
public void MouseButton(MouseButton button, bool down) => Log.Add($"MouseButton({button},{down})");
public void SetButton(int button, bool pressed) => Log.Add($"Joy({button},{pressed})");
public void SetHat(RioHat position) => Log.Add($"Hat({position})");
public void SetAxis(RioJoy.Core.Calibration.JoyAxis axis, int value) => Log.Add($"Axis({axis},{value})");
public void SetLamp(int address, byte lampState) => Log.Add($"Lamp(0x{address:X2},0x{lampState:X2})");
public void Execute(RioCommandCode command) => Log.Add($"Cmd({command})");
/// <summary>Direct access for single-threaded tests.</summary>
public List<string> Log
{
get { lock (_log) return _log; }
}
/// <summary>Thread-safe copy for assertions while the link thread writes.</summary>
public string[] Snapshot()
{
lock (_log) return _log.ToArray();
}
private void Add(string entry)
{
lock (_log) _log.Add(entry);
}
public void KeyDown(byte virtualKey, bool extended) => Add($"KeyDown(0x{virtualKey:X2},ext={extended})");
public void KeyUp(byte virtualKey, bool extended) => Add($"KeyUp(0x{virtualKey:X2},ext={extended})");
public void MouseMove(int dx, int dy) => Add($"MouseMove({dx},{dy})");
public void MouseButton(MouseButton button, bool down) => Add($"MouseButton({button},{down})");
public void SetButton(int button, bool pressed) => Add($"Joy({button},{pressed})");
public void SetHat(RioHat position) => Add($"Hat({position})");
public void SetAxis(RioJoy.Core.Calibration.JoyAxis axis, int value) => Add($"Axis({axis},{value})");
public void SetLamp(int address, byte lampState) => Add($"Lamp(0x{address:X2},0x{lampState:X2})");
public void Execute(RioCommandCode command) => Add($"Cmd({command})");
}
@@ -0,0 +1,90 @@
using RioJoy.Core.Profiles;
using Xunit;
namespace RioJoy.Core.Tests.Profiles;
public class AutoSwitchTests
{
private static AppConfig Config() => new()
{
NativeGameExecutables = { "firestorm.exe", "RedPlanet" },
Profiles =
{
new RioProfile { Name = "Doom", MatchExecutables = { "doom.exe", "doom2" } },
new RioProfile { Name = "Quake", MatchExecutables = { "quake.exe" } },
},
};
[Theory]
[InlineData("firestorm.exe")]
[InlineData("FIRESTORM.EXE")]
[InlineData(@"C:\games\Firestorm\firestorm.exe")]
[InlineData("redplanet.exe")] // configured without extension, matched with one
public void NativeGame_Yields(string exe)
{
Assert.Equal(SwitchMode.Yield, AutoSwitchResolver.Resolve(Config(), exe).Mode);
}
[Fact]
public void SupportedGame_Activates_ItsProfile()
{
SwitchDecision d = AutoSwitchResolver.Resolve(Config(), @"D:\Games\doom.exe");
Assert.Equal(SwitchMode.Activate, d.Mode);
Assert.Equal("Doom", d.Profile!.Name);
}
[Fact]
public void Unknown_IsIdle_WhenNoNeutral()
{
Assert.Equal(SwitchMode.Idle, AutoSwitchResolver.Resolve(Config(), "explorer.exe").Mode);
Assert.Equal(SwitchMode.Idle, AutoSwitchResolver.Resolve(Config(), null).Mode);
}
[Fact]
public void Unknown_ActivatesNeutral_WhenConfigured()
{
AppConfig config = Config();
config.NeutralProfileName = "Doom";
SwitchDecision d = AutoSwitchResolver.Resolve(config, "explorer.exe");
Assert.Equal(SwitchMode.Activate, d.Mode);
Assert.Equal("Doom", d.Profile!.Name);
}
[Fact]
public void Native_Wins_OverProfileMatch()
{
AppConfig config = Config();
// A profile also claims the native exe; native yield must still win.
config.Profiles[0].MatchExecutables.Add("firestorm.exe");
Assert.Equal(SwitchMode.Yield, AutoSwitchResolver.Resolve(config, "firestorm.exe").Mode);
}
[Fact]
public void Watcher_RaisesOnlyOnChange()
{
var config = Config();
string? exe = "doom.exe";
var provider = new FakeForeground(() => exe);
var watcher = new AutoSwitchWatcher(provider, () => config);
var changes = new List<SwitchDecision>();
watcher.DecisionChanged += changes.Add;
watcher.Poll(); // doom → Activate
watcher.Poll(); // doom again → no change
exe = "firestorm.exe";
watcher.Poll(); // native → Yield (change)
exe = "firestorm.exe";
watcher.Poll(); // same → no change
Assert.Equal(2, changes.Count);
Assert.Equal(SwitchMode.Activate, changes[0].Mode);
Assert.Equal(SwitchMode.Yield, changes[1].Mode);
}
private sealed class FakeForeground(Func<string?> get) : IForegroundProcessProvider
{
public string? GetForegroundExecutable() => get();
}
}
@@ -0,0 +1,77 @@
using RioJoy.Core.Calibration;
using RioJoy.Core.Profiles;
using Xunit;
namespace RioJoy.Core.Tests.Profiles;
public class ConfigStoreTests
{
[Fact]
public void RoundTrips_FullConfig()
{
var config = new AppConfig
{
DefaultRioComPort = "COM3",
DefaultPlasmaComPort = "COM4",
NativeGameExecutables = { "firestorm.exe", "redplanet.exe" },
NeutralProfileName = "Desktop",
AutoStart = true,
Profiles =
{
new RioProfile
{
Name = "Doom",
RioComPort = "COM5",
PlasmaGreeting = "DOOM",
Buttons = { [0] = 0x8049, [0x50] = 0x1009 },
Calibration = new AxisCalibrationConfig { InvertY = true, EnableZR = false },
MatchExecutables = { "doom.exe" },
},
},
};
string json = ConfigStore.Serialize(config);
AppConfig back = ConfigStore.Deserialize(json);
Assert.Equal("COM3", back.DefaultRioComPort);
Assert.Equal(new[] { "firestorm.exe", "redplanet.exe" }, back.NativeGameExecutables);
Assert.True(back.AutoStart);
RioProfile p = Assert.Single(back.Profiles);
Assert.Equal("Doom", p.Name);
Assert.Equal("COM5", p.RioComPort);
Assert.Equal(0x8049, p.Buttons[0]);
Assert.Equal(0x1009, p.Buttons[0x50]);
Assert.True(p.Calibration.InvertY);
Assert.False(p.Calibration.EnableZR);
Assert.Equal(new[] { "doom.exe" }, p.MatchExecutables);
}
[Fact]
public void Load_MissingFile_ReturnsDefaults()
{
string path = Path.Combine(Path.GetTempPath(), $"riojoy-missing-{Guid.NewGuid():N}.json");
AppConfig config = ConfigStore.Load(path);
Assert.Empty(config.Profiles);
Assert.Equal("COM1", config.DefaultRioComPort);
}
[Fact]
public void Save_ThenLoad_File()
{
string path = Path.Combine(Path.GetTempPath(), $"riojoy-{Guid.NewGuid():N}", "config.json");
try
{
var config = new AppConfig { DefaultRioComPort = "COM9" };
ConfigStore.Save(config, path);
Assert.True(File.Exists(path));
Assert.Equal("COM9", ConfigStore.Load(path).DefaultRioComPort);
}
finally
{
string? dir = Path.GetDirectoryName(path);
if (dir is not null && Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,58 @@
using RioJoy.Core.Profiles;
using Xunit;
namespace RioJoy.Core.Tests.Profiles;
public class RioIniImporterTests
{
private const string Sample = """
[Plasma]
Greeting = RIOvJoy2 v.03
[Desktop]
File = C:\games\RIO\VWE2.bmp
[JoyStick]
invertX = false
invertY = true
enableZR = false
; comment line
[Buttons]
RIO00 = 0x8049
RIO01 = 0x805A
RIO16 = 0x0
RIO50 = 33353
""";
[Fact]
public void Imports_ButtonsAsAddresses()
{
RioProfile p = RioIniImporter.Import(Sample, "VWE2");
Assert.Equal("VWE2", p.Name);
Assert.Equal(0x8049, p.Buttons[0x00]);
Assert.Equal(0x805A, p.Buttons[0x01]);
Assert.Equal(33353, p.Buttons[0x50]); // keypad-0 address, decimal value
Assert.False(p.Buttons.ContainsKey(0x16)); // 0x0 entries are skipped
}
[Fact]
public void Imports_PlasmaAndCalibration()
{
RioProfile p = RioIniImporter.Import(Sample);
Assert.Equal("RIOvJoy2 v.03", p.PlasmaGreeting);
Assert.Equal(@"C:\games\RIO\VWE2.bmp", p.WallpaperPath);
Assert.False(p.Calibration.InvertX);
Assert.True(p.Calibration.InvertY);
Assert.False(p.Calibration.EnableZR);
}
[Fact]
public void EnableZR_DefaultsTrue_WhenAbsent()
{
RioProfile p = RioIniImporter.Import("[JoyStick]\ninvertX = true");
Assert.True(p.Calibration.EnableZR);
}
}
@@ -0,0 +1,97 @@
using System.Diagnostics;
using RioJoy.Core;
using RioJoy.Core.Mapping;
using RioJoy.Core.Protocol;
using RioJoy.Core.Serial;
using RioJoy.Core.Tests.Mapping;
using RioJoy.Core.Tests.Serial;
using Xunit;
namespace RioJoy.Core.Tests;
public class RioRuntimeTests
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
private static async Task WaitUntilAsync(Func<bool> condition)
{
var sw = Stopwatch.StartNew();
while (!condition())
{
if (sw.Elapsed > Timeout)
throw new TimeoutException("Condition not met in time.");
await Task.Delay(10);
}
}
[Fact]
public async Task ButtonPressed_RoutesToJoystick()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
var recorder = new RecordingSink();
var map = new RioInputMap { [0x05] = new RioMapEntry(0x1009) }; // joystick button 9
using var runtime = new RioRuntime(link, map, recorder, recorder);
runtime.Start();
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
await WaitUntilAsync(() => recorder.Snapshot().Contains("Joy(9,True)"));
cts.Cancel();
await run;
}
[Fact]
public async Task AnalogReply_DrivesAllSixAxes()
{
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);
runtime.Start();
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// All axes at zero → joystick X/Y centered.
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
await WaitUntilAsync(() => recorder.Snapshot().Count(e => e.StartsWith("Axis(")) == 6);
string[] log = recorder.Snapshot();
Assert.Contains("Axis(X,16383)", log);
Assert.Contains("Axis(Y,16383)", log);
cts.Cancel();
await run;
}
[Fact]
public async Task KeypadKey_RoutesThroughOffsetAddress()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
var recorder = new RecordingSink();
// Keypad 1, index 2 → address 0x62; map a keyboard key there (VK 0x41 = 'A').
var map = new RioInputMap { [0x62] = new RioMapEntry(0x0041) };
using var runtime = new RioRuntime(link, map, recorder, recorder);
runtime.Start();
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
fake.Enqueue(PacketBuilder.Build(RioCommand.KeyPressed, new byte[] { 1, 2 }));
await WaitUntilAsync(() => recorder.Snapshot().Contains("KeyDown(0x41,ext=False)"));
cts.Cancel();
await run;
}
}