Skip to content

Commit ec489fc

Browse files
authored
Add cross-platform port feasibility analysis and Plan 1 (#11)
2 parents fd380c0 + 6c7ac9d commit ec489fc

2 files changed

Lines changed: 509 additions & 0 deletions

File tree

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
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

Comments
 (0)