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