From 1348040e1c99ea196261f3989e743ff3fbf832a8 Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 26 Jun 2026 15:36:58 -0500 Subject: [PATCH] Phase 5: tray app + profiles + runtime wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 10 +- docs/PLAN.md | 26 ++- src/RioJoy.Core/Mapping/NullJoystickSink.cs | 17 ++ src/RioJoy.Core/Profiles/AppConfig.cs | 40 +++++ src/RioJoy.Core/Profiles/AutoSwitch.cs | 90 ++++++++++ src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs | 57 +++++++ src/RioJoy.Core/Profiles/ConfigStore.cs | 51 ++++++ src/RioJoy.Core/Profiles/IniFile.cs | 82 +++++++++ src/RioJoy.Core/Profiles/RioIniImporter.cs | 64 +++++++ src/RioJoy.Core/Profiles/RioProfile.cs | 52 ++++++ src/RioJoy.Core/RioRuntime.cs | 138 +++++++++++++++ src/RioJoy.Core/Serial/SerialLampSink.cs | 35 ++++ src/RioJoy.Tray/Os/AutoStartManager.cs | 31 ++++ .../Os/ForegroundProcessProvider.cs | 43 +++++ src/RioJoy.Tray/RioCoordinator.cs | 159 +++++++++++++++++ src/RioJoy.Tray/TrayApplicationContext.cs | 161 ++++++++++++++++-- .../Mapping/RecordingSink.cs | 37 ++-- .../Profiles/AutoSwitchTests.cs | 90 ++++++++++ .../Profiles/ConfigStoreTests.cs | 77 +++++++++ .../Profiles/RioIniImporterTests.cs | 58 +++++++ tests/RioJoy.Core.Tests/RioRuntimeTests.cs | 97 +++++++++++ 21 files changed, 1382 insertions(+), 33 deletions(-) create mode 100644 src/RioJoy.Core/Mapping/NullJoystickSink.cs create mode 100644 src/RioJoy.Core/Profiles/AppConfig.cs create mode 100644 src/RioJoy.Core/Profiles/AutoSwitch.cs create mode 100644 src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs create mode 100644 src/RioJoy.Core/Profiles/ConfigStore.cs create mode 100644 src/RioJoy.Core/Profiles/IniFile.cs create mode 100644 src/RioJoy.Core/Profiles/RioIniImporter.cs create mode 100644 src/RioJoy.Core/Profiles/RioProfile.cs create mode 100644 src/RioJoy.Core/RioRuntime.cs create mode 100644 src/RioJoy.Core/Serial/SerialLampSink.cs create mode 100644 src/RioJoy.Tray/Os/AutoStartManager.cs create mode 100644 src/RioJoy.Tray/Os/ForegroundProcessProvider.cs create mode 100644 src/RioJoy.Tray/RioCoordinator.cs create mode 100644 tests/RioJoy.Core.Tests/Profiles/AutoSwitchTests.cs create mode 100644 tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs create mode 100644 tests/RioJoy.Core.Tests/Profiles/RioIniImporterTests.cs create mode 100644 tests/RioJoy.Core.Tests/RioRuntimeTests.cs 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; + } +}