Phase 5: tray app + profiles + runtime wiring

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-26 15:36:58 -05:00
co-authored by Claude Opus 4.8
parent 24a1b64519
commit 1348040e1c
21 changed files with 1382 additions and 33 deletions
+82
View File
@@ -0,0 +1,82 @@
namespace RioJoy.Core.Profiles;
/// <summary>
/// Minimal INI reader (sections, <c>key = value</c>, <c>;</c>/<c>#</c> comments)
/// sufficient to parse the legacy <c>RIO.ini</c>. Keys are case-insensitive.
/// </summary>
public sealed class IniFile
{
private readonly Dictionary<string, Dictionary<string, string>> _sections =
new(StringComparer.OrdinalIgnoreCase);
public static IniFile Parse(string text)
{
ArgumentNullException.ThrowIfNull(text);
var ini = new IniFile();
var current = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
ini._sections[string.Empty] = current;
foreach (string rawLine in text.Split('\n'))
{
string line = rawLine.Trim();
if (line.Length == 0 || line[0] is ';' or '#')
continue;
if (line[0] == '[' && line[^1] == ']')
{
string section = line[1..^1].Trim();
if (!ini._sections.TryGetValue(section, out current!))
ini._sections[section] = current = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
continue;
}
int eq = line.IndexOf('=');
if (eq < 0)
continue;
string key = line[..eq].Trim();
string value = line[(eq + 1)..].Trim();
if (key.Length > 0)
current[key] = value;
}
return ini;
}
/// <summary>All key/value pairs in a section (empty if the section is absent).</summary>
public IReadOnlyDictionary<string, string> Section(string name) =>
_sections.TryGetValue(name, out Dictionary<string, string>? s)
? s
: new Dictionary<string, string>();
public string? GetString(string section, string key) =>
Section(section).TryGetValue(key, out string? v) ? v : null;
public bool GetBool(string section, string key, bool fallback)
{
string? v = GetString(section, key);
if (v is null)
return fallback;
return v.Trim().ToLowerInvariant() switch
{
"1" or "true" or "yes" or "on" => true,
"0" or "false" or "no" or "off" => false,
_ => fallback,
};
}
/// <summary>Parse an integer value that may be hex (<c>0x…</c>) or decimal.</summary>
public static int ParseInt(string value)
{
ArgumentNullException.ThrowIfNull(value);
string v = value.Trim();
bool neg = v.StartsWith('-');
if (neg) v = v[1..];
int magnitude = v.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? Convert.ToInt32(v[2..], 16)
: int.Parse(v);
return neg ? -magnitude : magnitude;
}
}