diff --git a/README.md b/README.md
index 90cb622..ec19761 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,9 @@ dotnet test RioJoy.sln
## Status
-Phases 2–4 (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 2–5 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.
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 1f2de5f..191493d 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -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.
diff --git a/src/RioJoy.Core/Mapping/NullJoystickSink.cs b/src/RioJoy.Core/Mapping/NullJoystickSink.cs
new file mode 100644
index 0000000..adfe467
--- /dev/null
+++ b/src/RioJoy.Core/Mapping/NullJoystickSink.cs
@@ -0,0 +1,17 @@
+using RioJoy.Core.Calibration;
+
+namespace RioJoy.Core.Mapping;
+
+///
+/// A no-op 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.
+///
+public sealed class NullJoystickSink : IJoystickSink
+{
+ public void SetButton(int button, bool pressed) { }
+
+ public void SetHat(RioHat position) { }
+
+ public void SetAxis(JoyAxis axis, int value) { }
+}
diff --git a/src/RioJoy.Core/Profiles/AppConfig.cs b/src/RioJoy.Core/Profiles/AppConfig.cs
new file mode 100644
index 0000000..2c6ec5d
--- /dev/null
+++ b/src/RioJoy.Core/Profiles/AppConfig.cs
@@ -0,0 +1,40 @@
+namespace RioJoy.Core.Profiles;
+
+///
+/// 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 .
+///
+public sealed class AppConfig
+{
+ /// Default RIO COM port when a profile doesn't specify one.
+ public string DefaultRioComPort { get; set; } = "COM1";
+
+ /// Default plasma COM port when a profile doesn't specify one.
+ public string? DefaultPlasmaComPort { get; set; } = "COM2";
+
+ ///
+ /// 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".
+ ///
+ public List NativeGameExecutables { get; set; } = new();
+
+ /// The per-game profile library.
+ public List Profiles { get; set; } = new();
+
+ ///
+ /// Optional profile to activate when nothing else matches (desktop/idle). Null
+ /// means stay idle / release the port.
+ ///
+ public string? NeutralProfileName { get; set; }
+
+ /// Start RIOJoy with Windows.
+ public bool AutoStart { get; set; }
+
+ /// Find a profile by name (case-insensitive), or null.
+ public RioProfile? FindProfile(string? name) =>
+ name is null
+ ? null
+ : Profiles.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
+}
diff --git a/src/RioJoy.Core/Profiles/AutoSwitch.cs b/src/RioJoy.Core/Profiles/AutoSwitch.cs
new file mode 100644
index 0000000..e5b7da3
--- /dev/null
+++ b/src/RioJoy.Core/Profiles/AutoSwitch.cs
@@ -0,0 +1,90 @@
+namespace RioJoy.Core.Profiles;
+
+/// The three states of the serial-port yield / profile auto-switch.
+public enum SwitchMode
+{
+ /// A native game is foreground — release the COM port and go dormant.
+ Yield,
+
+ /// A supported non-native game is foreground — run its profile.
+ Activate,
+
+ /// Nothing matched — idle (port released or a neutral default).
+ Idle,
+}
+
+/// The decision produced by .
+public readonly struct SwitchDecision : IEquatable
+{
+ public SwitchMode Mode { get; }
+
+ /// The profile to run (set when is ).
+ 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();
+}
+
+///
+/// Resolves the foreground executable into a , 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.
+///
+public static class AutoSwitchResolver
+{
+ /// Normalize an executable name for matching: basename, no ".exe", lower-case.
+ 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 patterns, string normalizedExe) =>
+ patterns.Any(p => Normalize(p) == normalizedExe);
+
+ ///
+ /// Decide what RIOJoy should do given the current foreground executable
+ /// ( may be null when unknown/desktop).
+ ///
+ 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();
+ }
+}
diff --git a/src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs b/src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs
new file mode 100644
index 0000000..9287516
--- /dev/null
+++ b/src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs
@@ -0,0 +1,57 @@
+namespace RioJoy.Core.Profiles;
+
+/// Supplies the current foreground application's executable name.
+public interface IForegroundProcessProvider
+{
+ /// The foreground executable name (e.g. "game.exe"), or null if unknown.
+ string? GetForegroundExecutable();
+}
+
+///
+/// Polls the foreground process and resolves the three-state auto-switch decision,
+/// raising only when the decision actually changes.
+/// The polling cadence is driven by the host; performs one
+/// resolution so the logic is deterministic and unit-testable.
+///
+public sealed class AutoSwitchWatcher
+{
+ private readonly IForegroundProcessProvider _provider;
+ private readonly Func _configAccessor;
+ private SwitchDecision? _last;
+
+ public AutoSwitchWatcher(IForegroundProcessProvider provider, Func configAccessor)
+ {
+ _provider = provider ?? throw new ArgumentNullException(nameof(provider));
+ _configAccessor = configAccessor ?? throw new ArgumentNullException(nameof(configAccessor));
+ }
+
+ /// Raised when the resolved decision differs from the previous one.
+ public event Action? DecisionChanged;
+
+ /// The most recently resolved decision (null until the first ).
+ public SwitchDecision? Current => _last;
+
+ /// Resolve once; raise if it changed. Returns the decision.
+ 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;
+ }
+
+ /// Poll on until cancelled.
+ public async Task RunAsync(TimeSpan interval, CancellationToken cancellationToken)
+ {
+ using var timer = new PeriodicTimer(interval);
+ Poll();
+ while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
+ Poll();
+ }
+}
diff --git a/src/RioJoy.Core/Profiles/ConfigStore.cs b/src/RioJoy.Core/Profiles/ConfigStore.cs
new file mode 100644
index 0000000..99052cb
--- /dev/null
+++ b/src/RioJoy.Core/Profiles/ConfigStore.cs
@@ -0,0 +1,51 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace RioJoy.Core.Profiles;
+
+///
+/// Loads and saves as JSON. Replaces the legacy
+/// SimpleIni single-file config with a profile library (see docs/PLAN.md).
+///
+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(json, Options)
+ ?? throw new JsonException("Config JSON deserialized to null.");
+ }
+
+ /// Save the config to (creating directories).
+ 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));
+ }
+
+ ///
+ /// Load the config from , or return a fresh default
+ /// if the file does not exist.
+ ///
+ public static AppConfig Load(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ return File.Exists(path) ? Deserialize(File.ReadAllText(path)) : new AppConfig();
+ }
+}
diff --git a/src/RioJoy.Core/Profiles/IniFile.cs b/src/RioJoy.Core/Profiles/IniFile.cs
new file mode 100644
index 0000000..49b38c0
--- /dev/null
+++ b/src/RioJoy.Core/Profiles/IniFile.cs
@@ -0,0 +1,82 @@
+namespace RioJoy.Core.Profiles;
+
+///
+/// Minimal INI reader (sections, key = value, ;/# comments)
+/// sufficient to parse the legacy RIO.ini. Keys are case-insensitive.
+///
+public sealed class IniFile
+{
+ private readonly Dictionary> _sections =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ public static IniFile Parse(string text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+ var ini = new IniFile();
+ var current = new Dictionary(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(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;
+ }
+
+ /// All key/value pairs in a section (empty if the section is absent).
+ public IReadOnlyDictionary Section(string name) =>
+ _sections.TryGetValue(name, out Dictionary? s)
+ ? s
+ : new Dictionary();
+
+ 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,
+ };
+ }
+
+ /// Parse an integer value that may be hex (0x…) or decimal.
+ 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;
+ }
+}
diff --git a/src/RioJoy.Core/Profiles/RioIniImporter.cs b/src/RioJoy.Core/Profiles/RioIniImporter.cs
new file mode 100644
index 0000000..c11a5ae
--- /dev/null
+++ b/src/RioJoy.Core/Profiles/RioIniImporter.cs
@@ -0,0 +1,64 @@
+using System.Globalization;
+using RioJoy.Core.Calibration;
+using RioJoy.Core.Mapping;
+
+namespace RioJoy.Core.Profiles;
+
+///
+/// Imports a legacy RIO.ini into a (see the key
+/// layout in riovjoy2.cpp around line 343). The [Buttons] keys are
+/// RIO<hex addr> with hex/decimal map words; [JoyStick] holds
+/// the invert/enableZR flags; [Plasma] Greeting is the greeting text.
+///
+public static class RioIniImporter
+{
+ /// Parse into a profile named .
+ 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;
+ }
+}
diff --git a/src/RioJoy.Core/Profiles/RioProfile.cs b/src/RioJoy.Core/Profiles/RioProfile.cs
new file mode 100644
index 0000000..728ff10
--- /dev/null
+++ b/src/RioJoy.Core/Profiles/RioProfile.cs
@@ -0,0 +1,52 @@
+using RioJoy.Core.Calibration;
+using RioJoy.Core.Mapping;
+
+namespace RioJoy.Core.Profiles;
+
+///
+/// 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 RIO.ini. Profiles never describe the native
+/// games — those are the hands-off/yield case.
+///
+public sealed class RioProfile
+{
+ /// Display name (unique within a library).
+ public string Name { get; set; } = "Unnamed";
+
+ /// RIO serial port (e.g. "COM3"); null = use the app default.
+ public string? RioComPort { get; set; }
+
+ /// Plasma/VFD serial port; null = use the app default or none.
+ public string? PlasmaComPort { get; set; }
+
+ ///
+ /// The iRIO map: RIO address (0x00..0x6F) → 16-bit map word. Only
+ /// mapped addresses need entries.
+ ///
+ public Dictionary Buttons { get; set; } = new();
+
+ /// Axis calibration / invert options.
+ public AxisCalibrationConfig Calibration { get; set; } = new();
+
+ /// Plasma greeting text shown on load (null = leave display as-is).
+ public string? PlasmaGreeting { get; set; }
+
+ /// Cockpit wallpaper image path (generated in Phase 7).
+ public string? WallpaperPath { get; set; }
+
+ ///
+ /// Executable names that select this profile when they are the foreground app
+ /// (matched case-insensitively, with or without ".exe").
+ ///
+ public List MatchExecutables { get; set; } = new();
+
+ /// Build a from .
+ public RioInputMap ToInputMap()
+ {
+ var map = new RioInputMap();
+ foreach ((int address, int raw) in Buttons)
+ map[address] = new RioMapEntry((ushort)raw);
+ return map;
+ }
+}
diff --git a/src/RioJoy.Core/RioRuntime.cs b/src/RioJoy.Core/RioRuntime.cs
new file mode 100644
index 0000000..61055ce
--- /dev/null
+++ b/src/RioJoy.Core/RioRuntime.cs
@@ -0,0 +1,138 @@
+using RioJoy.Core.Calibration;
+using RioJoy.Core.Mapping;
+using RioJoy.Core.Protocol;
+using RioJoy.Core.Serial;
+
+namespace RioJoy.Core;
+
+///
+/// Ties the live RIO link to the input/output pipeline for one active profile:
+/// serial button/keypad packets drive the ; analog
+/// replies drive the 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).
+///
+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);
+ }
+
+ /// Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate).
+ public event Action? DiagnosticToggle;
+
+ /// Subscribe to the link and set all lamps to their idle state.
+ public void Start()
+ {
+ if (_started)
+ return;
+ _started = true;
+
+ _link.PacketReceived += OnPacket;
+ _link.AnalogReceived += OnAnalog;
+ _router.InitializeLamps();
+ }
+
+ /// Unsubscribe from the link.
+ public void Stop()
+ {
+ if (!_started)
+ return;
+ _started = false;
+
+ _link.PacketReceived -= OnPacket;
+ _link.AnalogReceived -= OnAnalog;
+ }
+
+ public void Dispose() => Stop();
+
+ /// Invoke a RIO command directly (e.g. from the tray menu).
+ 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 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 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);
+}
diff --git a/src/RioJoy.Core/Serial/SerialLampSink.cs b/src/RioJoy.Core/Serial/SerialLampSink.cs
new file mode 100644
index 0000000..127e971
--- /dev/null
+++ b/src/RioJoy.Core/Serial/SerialLampSink.cs
@@ -0,0 +1,35 @@
+using RioJoy.Core.Mapping;
+
+namespace RioJoy.Core.Serial;
+
+///
+/// that sends lamp feedback back to the RIO as
+/// LampRequest packets over the serial link. Writes are fire-and-forget
+/// (lamp feedback is best-effort and must not block input routing).
+///
+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.
+ }
+ }
+}
diff --git a/src/RioJoy.Tray/Os/AutoStartManager.cs b/src/RioJoy.Tray/Os/AutoStartManager.cs
new file mode 100644
index 0000000..eb9ee65
--- /dev/null
+++ b/src/RioJoy.Tray/Os/AutoStartManager.cs
@@ -0,0 +1,31 @@
+using System.Runtime.Versioning;
+using Microsoft.Win32;
+
+namespace RioJoy.Tray.Os;
+
+///
+/// Manages the Windows "run at login" registration via the per-user
+/// Run registry key. Per-user (HKCU) needs no elevation and matches the
+/// tray app running in the interactive session.
+///
+[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);
+ }
+}
diff --git a/src/RioJoy.Tray/Os/ForegroundProcessProvider.cs b/src/RioJoy.Tray/Os/ForegroundProcessProvider.cs
new file mode 100644
index 0000000..3f04e65
--- /dev/null
+++ b/src/RioJoy.Tray/Os/ForegroundProcessProvider.cs
@@ -0,0 +1,43 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using RioJoy.Core.Profiles;
+
+namespace RioJoy.Tray.Os;
+
+///
+/// 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.
+///
+[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);
+}
diff --git a/src/RioJoy.Tray/RioCoordinator.cs b/src/RioJoy.Tray/RioCoordinator.cs
new file mode 100644
index 0000000..c9a57be
--- /dev/null
+++ b/src/RioJoy.Tray/RioCoordinator.cs
@@ -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;
+
+///
+/// Owns the active RIO connection and drives the three-state auto-switch: it
+/// opens/closes the serial link and assembles a 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.
+///
+[SupportedOSPlatform("windows")]
+public sealed class RioCoordinator : IDisposable
+{
+ private readonly Func _config;
+ private readonly Func _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 config, Func? transportFactory = null)
+ {
+ _config = config ?? throw new ArgumentNullException(nameof(config));
+ _transportFactory = transportFactory ?? (port => new SerialPortTransport(port));
+ }
+
+ /// Raised (with a short status string) whenever the active state changes.
+ public event Action? StatusChanged;
+
+ /// Current status line for the tray.
+ public string Status { get; private set; } = "Starting…";
+
+ /// The name of the active profile, or null when dormant.
+ public string? ActiveProfileName => _activeProfileName;
+
+ /// Whether the auto-switch watcher controls the active profile.
+ public bool IsAuto => _auto;
+
+ /// The currently running runtime, for manual RIO-command triggers.
+ public RioRuntime? Runtime => _runtime;
+
+ /// Apply a watcher decision (ignored while a manual override is in effect).
+ 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;
+ }
+ }
+
+ /// Hand control back to the auto-switch watcher.
+ public void SetAuto()
+ {
+ _auto = true;
+ SetStatus("Auto");
+ }
+
+ /// Manually activate a profile; disables auto until .
+ public void SetManualProfile(RioProfile profile)
+ {
+ _auto = false;
+ Activate(profile);
+ }
+
+ /// Manually go dormant (release the port); disables auto.
+ 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();
+}
diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs
index 6ff67d8..bf526bc 100644
--- a/src/RioJoy.Tray/TrayApplicationContext.cs
+++ b/src/RioJoy.Tray/TrayApplicationContext.cs
@@ -1,39 +1,174 @@
+using System.Runtime.Versioning;
+using RioJoy.Core.Mapping;
+using RioJoy.Core.Profiles;
+using RioJoy.Tray.Os;
+
namespace RioJoy.Tray;
///
-/// 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.
///
+[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();
}
diff --git a/tests/RioJoy.Core.Tests/Mapping/RecordingSink.cs b/tests/RioJoy.Core.Tests/Mapping/RecordingSink.cs
index 3d921b5..ba6ec3e 100644
--- a/tests/RioJoy.Core.Tests/Mapping/RecordingSink.cs
+++ b/tests/RioJoy.Core.Tests/Mapping/RecordingSink.cs
@@ -8,23 +8,40 @@ namespace RioJoy.Core.Tests.Mapping;
///
internal sealed class RecordingSink : IInputSink, IJoystickSink, ILampSink, IRioCommandSink
{
- public List Log { get; } = new();
+ private readonly List _log = new();
- public void KeyDown(byte virtualKey, bool extended) => Log.Add($"KeyDown(0x{virtualKey:X2},ext={extended})");
+ /// Direct access for single-threaded tests.
+ public List Log
+ {
+ get { lock (_log) return _log; }
+ }
- public void KeyUp(byte virtualKey, bool extended) => Log.Add($"KeyUp(0x{virtualKey:X2},ext={extended})");
+ /// Thread-safe copy for assertions while the link thread writes.
+ public string[] Snapshot()
+ {
+ lock (_log) return _log.ToArray();
+ }
- public void MouseMove(int dx, int dy) => Log.Add($"MouseMove({dx},{dy})");
+ private void Add(string entry)
+ {
+ lock (_log) _log.Add(entry);
+ }
- public void MouseButton(MouseButton button, bool down) => Log.Add($"MouseButton({button},{down})");
+ public void KeyDown(byte virtualKey, bool extended) => Add($"KeyDown(0x{virtualKey:X2},ext={extended})");
- public void SetButton(int button, bool pressed) => Log.Add($"Joy({button},{pressed})");
+ public void KeyUp(byte virtualKey, bool extended) => Add($"KeyUp(0x{virtualKey:X2},ext={extended})");
- public void SetHat(RioHat position) => Log.Add($"Hat({position})");
+ public void MouseMove(int dx, int dy) => Add($"MouseMove({dx},{dy})");
- public void SetAxis(RioJoy.Core.Calibration.JoyAxis axis, int value) => Log.Add($"Axis({axis},{value})");
+ public void MouseButton(MouseButton button, bool down) => Add($"MouseButton({button},{down})");
- public void SetLamp(int address, byte lampState) => Log.Add($"Lamp(0x{address:X2},0x{lampState:X2})");
+ public void SetButton(int button, bool pressed) => Add($"Joy({button},{pressed})");
- public void Execute(RioCommandCode command) => Log.Add($"Cmd({command})");
+ 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})");
}
diff --git a/tests/RioJoy.Core.Tests/Profiles/AutoSwitchTests.cs b/tests/RioJoy.Core.Tests/Profiles/AutoSwitchTests.cs
new file mode 100644
index 0000000..1308be5
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Profiles/AutoSwitchTests.cs
@@ -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();
+ 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 get) : IForegroundProcessProvider
+ {
+ public string? GetForegroundExecutable() => get();
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs b/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs
new file mode 100644
index 0000000..8ce2782
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs
@@ -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);
+ }
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Profiles/RioIniImporterTests.cs b/tests/RioJoy.Core.Tests/Profiles/RioIniImporterTests.cs
new file mode 100644
index 0000000..22c6255
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Profiles/RioIniImporterTests.cs
@@ -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);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/RioRuntimeTests.cs b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs
new file mode 100644
index 0000000..00f4e4d
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs
@@ -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 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;
+ }
+}