|
| 1 | +# Cross-platform port — Plan 1 (decouple + de-risking spikes) |
| 2 | + |
| 3 | +## Status & scope |
| 4 | + |
| 5 | +**Plan 1** of a multi-plan effort. Deliberately **spike-first**: it details |
| 6 | +the OS-decoupling refactor (Phase 1A/1B) and the two de-risking spikes |
| 7 | +(Phase 2), and sketches everything past the spike gates only **coarsely** |
| 8 | +(Phase 3+). **Plan 2** is written *after* Phase 2 resolves — spike outcomes |
| 9 | +(esp. the overlay fork) materially change the post-spike shape. |
| 10 | + |
| 11 | +Prerequisite reading — settled higher-level context, do not re-litigate: |
| 12 | +[ai-docs/research/preliminary/crossplatform.md](../research/preliminary/crossplatform.md) |
| 13 | +("Decisions", 2026-05-16): Linux-first / macOS design-aware only; single |
| 14 | +codebase; Linux scope = native + Proton/Steam, X11 & Wayland; main UI → |
| 15 | +Avalonia big-bang; overlay gated. Plan 1 **refines** that doc's Decision #2 |
| 16 | +structure (the port now layers through dedicated `Contracts` + |
| 17 | +`Platform.Abstractions` assemblies — see below). |
| 18 | + |
| 19 | +### Decisions baked into this plan (Q&A 2026-05-16) |
| 20 | + |
| 21 | +- **Full OS-decoupling of the engine**, and a **separate |
| 22 | + `Mouse2Joy.Platform.Abstractions`** for the ports. Because the port DTOs |
| 23 | + move out of the engine (below), `Platform.Windows` ends up **not |
| 24 | + referencing `Mouse2Joy.Engine` at all** — the clean layering the earlier |
| 25 | + draft thought impossible is now real, *because* of the full decouple. |
| 26 | +- **New `Mouse2Joy.Contracts`** holds primitives shared across the port |
| 27 | + boundary; both `Persistence` and `Platform.Abstractions` reference it. |
| 28 | +- **`XInputReport`/`XInputButtons` → `GamepadReport`/`GamepadButtons`**; |
| 29 | + button values become engine-internal ordinals; the Windows ViGEm adapter |
| 30 | + does an *explicit* XInput mapping table (no blind cast). |
| 31 | +- **Persisted key identity migrates to USB HID usage IDs**; |
| 32 | + `VirtualKey` → **`PhysicalKey`**. This is a versioned-schema migration on |
| 33 | + **two** documents. |
| 34 | +- **General settings-migration pipeline** built (mirrors |
| 35 | + `ProfileStore`'s peek-and-chain) — `AppSettings` has a `SchemaVersion` |
| 36 | + field but no migration infra exists today; this is the first settings |
| 37 | + schema change ever. |
| 38 | +- **Contracts extraction + key re-representation + both migrations = one |
| 39 | + atomic Phase 1A unit**, before the broader platform split. |
| 40 | +- **TFM retarget + Windows-code relocation = one atomic change** (CA1416 |
| 41 | + would otherwise red `main` mid-refactor). |
| 42 | +- **Supervisor/watchdog boundary is designed in Plan 1** so Plan 2's Linux |
| 43 | + panic-key + capture-grab ownership isn't boxed in. |
| 44 | +- **Generic `ISetupProbe` seam extracted in Phase 1B** (Windows content |
| 45 | + now; Linux content Plan 2). |
| 46 | +- **Single-instance contract = silent-exit** (matches today; focus-existing |
| 47 | + is a deferred enhancement, not Plan 1). |
| 48 | +- **`FakeTickTimer` virtual-clock** added with Phase 1B tests. |
| 49 | + |
| 50 | +--- |
| 51 | + |
| 52 | +## Target project graph (end of Phase 1B) |
| 53 | + |
| 54 | +``` |
| 55 | +Mouse2Joy.Contracts net8.0 — no deps. Primitives shared across |
| 56 | + the port boundary: PhysicalKey |
| 57 | + (HID usage), MouseButton, |
| 58 | + ScrollDirection, KeyModifiers. |
| 59 | + (Persistence-only enums stay in |
| 60 | + Persistence.Models.) |
| 61 | +Mouse2Joy.Persistence net8.0 → Contracts |
| 62 | +Mouse2Joy.Platform.Abstractions net8.0 → Contracts. Ports + port DTOs: |
| 63 | + IVirtualPad, IInputBackend, |
| 64 | + ITickTimer, IGlobalHotkey, |
| 65 | + ISingleInstanceGuard, ITrayIcon, |
| 66 | + IAppPaths, ISetupProbe, IOverlay, |
| 67 | + IEngineStateSource, GamepadReport, |
| 68 | + RawEvent, EngineStateSnapshot, |
| 69 | + EngineMode. |
| 70 | +Mouse2Joy.Engine net8.0 → Abstractions, Contracts. |
| 71 | + Pure mapping/curve/stick logic. |
| 72 | + NO OS code, NO Win32, NO WPF. |
| 73 | +Mouse2Joy.Platform.Windows net8.0-windows → Abstractions, Contracts, |
| 74 | + Persistence. The ONLY assembly |
| 75 | + with [DllImport] / ViGEm / |
| 76 | + Microsoft.Win32 / WPF-tray. |
| 77 | +Mouse2Joy.Platform.Linux net8.0 → Abstractions, Contracts. Phase 1B: |
| 78 | + NotSupported stubs only. |
| 79 | +Mouse2Joy.UI / Mouse2Joy.App net8.0-windows (WPF, replaced in Phase 3) |
| 80 | +``` |
| 81 | + |
| 82 | +Engine references Abstractions+Contracts; nothing references Engine except |
| 83 | +UI/App. Platform projects never see the engine. |
| 84 | + |
| 85 | +**Resolved (2026-05-16):** `EngineStateSnapshot` + `EngineMode` + |
| 86 | +`IEngineStateSource` move into `Platform.Abstractions` as part of the port |
| 87 | +surface (alongside `GamepadReport`/`RawEvent`). `EngineStateSnapshot` is |
| 88 | +already a pure data record and already references `GamepadButtons` (in |
| 89 | +Abstractions), so this introduces no new coupling and no per-tick mapping |
| 90 | +cost; the engine produces the snapshot type directly. No remaining flagged |
| 91 | +sub-decisions in Plan 1. |
| 92 | + |
| 93 | +--- |
| 94 | + |
| 95 | +## Phase 1A — Contracts + HID-usage key migration (atomic, schema-touching) |
| 96 | + |
| 97 | +OS-neutral, fully Windows-testable, no platform split yet. One coordinated |
| 98 | +change (avoids migrating/moving the key type twice). |
| 99 | + |
| 100 | +1. **Create `Mouse2Joy.Contracts` (`net8.0`, no deps).** Move the |
| 101 | + cross-boundary primitives in (`MouseButton`, `ScrollDirection`, |
| 102 | + `KeyModifiers`, and the key type). Persistence-only enums (`Stick`, |
| 103 | + `Trigger`, `DPadDirection`, `GamepadButton`, `MouseAxis`, |
| 104 | + `AxisComponent`) stay in `Persistence.Models`. `Persistence` references |
| 105 | + `Contracts`. **Risk to verify:** moved types are serialized; |
| 106 | + `InputSource` is polymorphic via `$kind`. Plain value types are |
| 107 | + namespace-agnostic in `System.Text.Json`; keep `$kind` discriminator |
| 108 | + strings **unchanged** so only field shapes change. Verify the |
| 109 | + polymorphic resolver path explicitly. |
| 110 | +2. **`VirtualKey` → `PhysicalKey`, re-represented as a USB HID usage** |
| 111 | + (Usage Page 0x07). Rename type + all call sites |
| 112 | + ([HotkeyBinding](../../src/Mouse2Joy.Persistence/Models/HotkeyBinding.cs), |
| 113 | + [KeySource](../../src/Mouse2Joy.Persistence/Models/InputSource.cs), |
| 114 | + `RawEvent`, `HotkeyMatcher`, etc.). |
| 115 | +3. **Scancode(set 1)+E0 ↔ HID Usage bidirectional table** — pure logic. |
| 116 | + **Mandatory unit tests** (repo convention): both directions, round-trip, |
| 117 | + extended/E0 cases, unmapped-code behavior. |
| 118 | +4. **Profile schema migration** (`Profile.CurrentSchemaVersion` bump + |
| 119 | + registered migration per [MIGRATION_CONVENTIONS.md](../MIGRATION_CONVENTIONS.md)): |
| 120 | + JSON-node rewrite of `keySource.key` `{scancode,extended}` → |
| 121 | + `{hidUsage}`. Idempotent, tolerant of missing fields. Dedicated migration |
| 122 | + test per convention (prior-version migrates, no-op profile still loads, |
| 123 | + version stamp updated). Do **not** pin version numbers in tests/docs. |
| 124 | +5. **Build a general settings-migration pipeline**: an `AppSettings` |
| 125 | + analogue of `ProfileStore.DeserializeProfile`'s peek-`schemaVersion`-and- |
| 126 | + chain. Then `AppSettings.CurrentSchemaVersion` bump + migration rewriting |
| 127 | + `hotkeyBinding.key`. Tests mirror the profile-migration test shape. |
| 128 | +6. `dotnet test Mouse2Joy.sln` green on Windows is the 1A gate. |
| 129 | + |
| 130 | +**1A exit:** profiles + settings transparently migrate; key identity is HID |
| 131 | +usage end-to-end; `Contracts` extracted; behavior unchanged on Windows. |
| 132 | + |
| 133 | +## Phase 1B — Platform decoupling (no behavior change on Windows) |
| 134 | + |
| 135 | +Introduce every seam and the project skeleton while still on Windows, app |
| 136 | +fully working, tests green throughout. |
| 137 | + |
| 138 | +1. **`Mouse2Joy.Platform.Abstractions` (`net8.0`)** — ports + port DTOs |
| 139 | + (graph above). `IVirtualPad`/`IInputBackend`/`IEngineStateSource` move |
| 140 | + here from Engine; `GamepadReport`/`RawEvent`/`EngineStateSnapshot`/ |
| 141 | + `EngineMode` move here. Engine references Abstractions and produces the |
| 142 | + snapshot type directly (no parallel type, per-tick path untouched). |
| 143 | +2. **Extract remaining seams** behind interfaces, wrapping today's Windows |
| 144 | + code unchanged: `ITickTimer` (was |
| 145 | + [WaitableTickTimer](../../src/Mouse2Joy.Engine/Threading/WaitableTickTimer.cs)), |
| 146 | + `IGlobalHotkey` (panic — see supervisor note), `ISingleInstanceGuard` |
| 147 | + (**silent-exit contract**, matching |
| 148 | + [App.xaml.cs:42](../../src/Mouse2Joy.App/App.xaml.cs)), `ITrayIcon`, |
| 149 | + `IAppPaths`, `ISetupProbe` (generic `{requirement,status,remediation}`; |
| 150 | + Windows reports ViGEmBus/Interception/admin via the relocated |
| 151 | + `ViGEmHealth`/`DriverHealth`), `IOverlay` (WPF overlay wrapped unchanged; |
| 152 | + see flagged snapshot sub-decision). |
| 153 | +3. **Retarget + relocate (ONE atomic change, O2):** Engine/Input/VirtualPad |
| 154 | + `net8.0-windows` → `net8.0`; simultaneously relocate **all** Win32 / |
| 155 | + ViGEm / `Microsoft.Win32` / WPF-tray code into a new |
| 156 | + `Mouse2Joy.Platform.Windows` (`net8.0-windows`). Splitting these red-CIs |
| 157 | + `main` on CA1416. `Microsoft.Win32.Registry` gets an explicit package |
| 158 | + ref there. UI/App stay WPF for now (replaced in Phase 3). |
| 159 | +4. **Scaffold `Mouse2Joy.Platform.Linux` (`net8.0`)** — every seam a |
| 160 | + `NotSupportedException` stub. Filled in Plan 2. |
| 161 | +5. **Supervisor/watchdog boundary (design, O3):** on Linux *we* own the |
| 162 | + evdev grab; an engine crash while grabbing = user's input captured with |
| 163 | + no escape, and a dead panic key. Plan 1 carves an explicit |
| 164 | + supervisor-process boundary in the seam design so the grab lifetime + |
| 165 | + panic key can live independent of the engine on Linux. Windows keeps its |
| 166 | + OS-level `RegisterHotKey` independence. Implementation is Plan 2; the |
| 167 | + *boundary/contract* is fixed here so Plan 2 isn't boxed in. |
| 168 | +6. **Startup platform selector** picks the impl set via |
| 169 | + `RuntimeInformation.IsOSPlatform`, feeds DI. |
| 170 | +7. **Tests (convention):** all existing tests green post-move on Windows |
| 171 | + (primary gate). Add a **virtual-time `FakeTickTimer`** (O6) and make |
| 172 | + engine tick-loop tests deterministic against it. Unit-test the |
| 173 | + platform-selector branching and `IAppPaths` path computation (pure, |
| 174 | + per-OS table-testable now). P/Invoke/tray/overlay/hotkey stay untested |
| 175 | + (Win32/visual — explicitly called out per convention). |
| 176 | + |
| 177 | +**1B exit:** byte-identical Windows behavior; no OS type reachable except |
| 178 | +through a port; Linux project compiles (stubs); Windows still shippable. |
| 179 | + |
| 180 | +--- |
| 181 | + |
| 182 | +## Phase 2 — De-risking spikes (throwaway, not merged) |
| 183 | + |
| 184 | +Throwaway harnesses; each produces a findings write-up under |
| 185 | +`ai-docs/research/`. Outcomes feed Plan 2. |
| 186 | + |
| 187 | +### Spike S1 — Linux non-UI path (priority 1) |
| 188 | + |
| 189 | +Console harness on Linux: open an evdev device, `EVIOCGRAB` (suppression |
| 190 | +equivalent), translate evdev `KEY_*`/rel axes ↔ `PhysicalKey`(HID)/`RawEvent`, |
| 191 | +feed the **real unmodified** `Mouse2Joy.Engine`, emit an Xbox-style pad via |
| 192 | +`/dev/uinput`. |
| 193 | + |
| 194 | +**Success criteria (all required):** |
| 195 | +- `evtest`/`jstest` (and ideally one real Linux/Proton title) sees pad |
| 196 | + output from the binding; grabbed real input is suppressed. |
| 197 | +- **Real-profile round-trip (O9):** one of the user's *existing* profiles, |
| 198 | + migrated through Phase 1A to HID `PhysicalKey`, drives correctly on Linux |
| 199 | + — exercises scancode→HID→evdev end-to-end on real data, not a synthetic |
| 200 | + binding. |
| 201 | + |
| 202 | +**Proves:** ports sufficient; HID key model genuinely portable; suppression |
| 203 | +holds on Linux; engine needs zero changes. |
| 204 | + |
| 205 | +**Environment:** user-owned bare-metal Linux. *Out of plan scope — the |
| 206 | +user owns provisioning; this plan does not track it.* |
| 207 | + |
| 208 | +### Spike S2 — Windows Avalonia-overlay gating (priority 2, forks Plan 2) |
| 209 | + |
| 210 | +Minimal standalone Avalonia app: borderless, transparent, top-most, |
| 211 | +**click-through**, correct multi-monitor placement, above a real |
| 212 | +**borderless-fullscreen** game on Windows (O8 — borderless only; exclusive- |
| 213 | +fullscreen is out of scope per INITIALWORK and not tested here). Baseline: |
| 214 | +[WindowStyles.cs](../../src/Mouse2Joy.UI/Interop/WindowStyles.cs) + |
| 215 | +[MonitorInfo.cs](../../src/Mouse2Joy.UI/Interop/MonitorInfo.cs). |
| 216 | + |
| 217 | +**Success (all required):** click-through verified (game gets the clicks), |
| 218 | +overlay stays above a real borderless-fullscreen game, correct multi-monitor |
| 219 | +placement, acceptable redraw cost. |
| 220 | + |
| 221 | +**Outcome forks Plan 2's overlay track:** |
| 222 | +- **Pass →** overlay migrates to Avalonia; full unification, WPF dropped. |
| 223 | +- **Fail →** keep WPF overlay on Windows behind `IOverlay` + author a |
| 224 | + separate native Linux overlay; rest of UI is Avalonia, overlay-agnostic. |
| 225 | + |
| 226 | +**2 exit:** both spikes run; pass/fail recorded with evidence; overlay fork |
| 227 | +resolved; no spike code merged. |
| 228 | + |
| 229 | +--- |
| 230 | + |
| 231 | +## Phase 3+ — Coarse roadmap (detailed in Plan 2) |
| 232 | + |
| 233 | +Shape depends on Phase 2 outcomes. |
| 234 | + |
| 235 | +- **Linux platform impls** (harden S1): real evdev backend (mouse+keyboard |
| 236 | + likely unified — the Windows split is Windows-kernel-specific), `/dev/uinput` |
| 237 | + pad, `ITickTimer` via `timerfd`, XDG `IAppPaths`, `IGlobalHotkey` + |
| 238 | + evdev-grab ownership inside the **supervisor process** designed in 1B, |
| 239 | + `ITrayIcon` (StatusNotifierItem/AppIndicator — DE-dependent, risky), |
| 240 | + Linux `ISetupProbe` content (`/dev/uinput` access, `input` group), shipped |
| 241 | + udev rule + first-run onboarding UX, native↔HID translation tables in the |
| 242 | + Linux capture adapter. |
| 243 | +- **Main UI → Avalonia, big-bang cutover:** re-author all 8 `.xaml` + |
| 244 | + code-behind + custom controls against the unchanged engine/VM layer; |
| 245 | + single switch; WPF dropped for the main app. |
| 246 | +- **Overlay track:** per the S2 fork. |
| 247 | +- **Cross-platform CI matrix** (Windows + Linux build/test). |
| 248 | +- **Still deferred (not Plan 2 either, per feasibility Decision #3):** |
| 249 | + anti-cheat full-screen titles; Linux packaging (Flatpak/AppImage/deb); |
| 250 | + focus-existing single-instance UX; all macOS work (design-aware only). |
| 251 | + |
| 252 | +## Risks / watch-items |
| 253 | + |
| 254 | +- **Polymorphic-serialization relocation (1A.1):** keep `$kind` strings |
| 255 | + stable; verify the `InputSource` resolver after the type move. |
| 256 | +- **Two schema migrations in 1A** (Profile + the first-ever settings |
| 257 | + migration) — settings pipeline is net-new infra, the largest hidden cost. |
| 258 | +- **`Nefarius.ViGEm.Client`** is confined to `Platform.Windows`; verify it |
| 259 | + still restores cleanly post-retarget. |
| 260 | +- **Big-bang UI cutover** means a long window where `main` UI is still WPF; |
| 261 | + mitigated by 1B keeping WPF fully working until Phase 3 lands. |
| 262 | +- **Linux tray** is DE-dependent — a Plan 2 risk, not solved here. |
| 263 | + |
| 264 | +## Follow-ups |
| 265 | + |
| 266 | +- This plan changes no code. All flagged sub-decisions are now resolved; |
| 267 | + **Phase 1A is the first executable unit** and Phase 1B is unblocked. |
| 268 | +- Plan 2 (`ai-docs/plans/CROSSPLATFORM_PORT_PLAN_2.md`) authored after Phase |
| 269 | + 2; details Phase 3+ from spike outcomes. |
| 270 | +- Per repo convention, an `ai-docs/implementations/` write-down is produced |
| 271 | + when Phase 1A/1B actually land (not now — nothing implemented yet). |
0 commit comments