Phase 7: cockpit overlay generator, region extraction, and profile editor

Overlay generator (pure, tested) — RioJoy.Core/Overlay:
- FontFitter ports the legacy calc-fontsize auto-fit search (validated against a
  brute-force oracle); OverlayLayoutEngine ports create-data-layer's fit + h/v
  justification and adds per-region 90 deg CCW rotation; OverlayTemplate/Region +
  OverlayTemplateStore hold cell geometry/colour/rotation (regions.json).
- GoobieDataImporter parses the legacy .data label sheet into label rows.
- src/RioJoy.Overlay: SkiaSharp rasterizer (SkiaTextMeasurer shared with the engine
  so measured layout == drawn output; SkiaOverlayRenderer -> PNG;
  ProfileWallpaperGenerator). Verified end-to-end on the real cockpit art
  (regions.json + riojoy.png + TEST.data) by OverlayRenderIntegrationTests.
- RioJoy.Tray/WallpaperApplier applies via SystemParametersInfo; RioCoordinator
  generates+applies on profile activation (opt-in via AppConfig.OverlayTemplatePath).

Region geometry — tools/XcfRegionExtract parses riojoy.xcf (a GIMP-format binary
reader, no GIMP needed) into the 119-cell regions.json: per-layer offsets/size/
font/colour, with 90 deg CCW rotation on a-00 + b-10..b-1F. Base image riojoy.png.
NOTE: the wallpaper positions are a VGA chroma-split display artifact (6 displays),
not the cockpit's logical layout.

Mapping editor (first cut) — RioJoy.Tray/Editor/ProfileEditorForm renders the
config sheet's logical grid (SheetLayout parses the sheet CSV export). The iRIO
word is edited via ButtonBinding <-> RioMapEntry.Create (no hex bit-twiddling), with
a context-sensitive picker: keyboard keys by name (KeyCatalog VK names), joystick
Button N, hat direction, mouse/RIO-command enum names; modifiers only for keyboard.
Opened from the tray ("Edit profile..."). The sheet-grid layout is a starting point
that needs rework from a better-formatted source.

~220 xUnit tests green. Docs (PLAN.md, README) updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-27 17:13:48 -05:00
co-authored by Claude Opus 4.8
parent 2deeb374ae
commit 8d2d0b71aa
47 changed files with 3866 additions and 21 deletions
+4
View File
@@ -372,3 +372,7 @@ FodyWeavers.xsd
*.pvk *.pvk
# Local run-time config that shouldn't be versioned # Local run-time config that shouldn't be versioned
*.local.json *.local.json
# Generated overlay/editor previews (regenerable, not committed)
docs/reference/customBackground/riojoy-preview.png
docs/reference/customBackground/editor-preview.png
+13 -8
View File
@@ -18,6 +18,8 @@ Red Planet — talk to the RIO directly and do not use this app.)
| [`src/RioJoy.Tray`](src/RioJoy.Tray/) | Background tray application | | [`src/RioJoy.Tray`](src/RioJoy.Tray/) | Background tray application |
| [`tests/RioJoy.Core.Tests`](tests/RioJoy.Core.Tests/) | xUnit tests for the protocol core | | [`tests/RioJoy.Core.Tests`](tests/RioJoy.Core.Tests/) | xUnit tests for the protocol core |
| [`driver/`](driver/) | `RioGamepad` virtual HID driver (KMDF + VHF) — replaces vJoy | | [`driver/`](driver/) | `RioGamepad` virtual HID driver (KMDF + VHF) — replaces vJoy |
| [`tools/RioJoySmokeTest`](tools/RioJoySmokeTest/) | On-cabinet end-to-end check of the feeder → driver path |
| [`tools/XcfRegionExtract`](tools/XcfRegionExtract/) | Extracts cockpit label regions from `riojoy.xcf``regions.json` |
| [`docs/PLAN.md`](docs/PLAN.md) | Full modernization plan (7 phases) | | [`docs/PLAN.md`](docs/PLAN.md) | Full modernization plan (7 phases) |
| [`docs/PROTOCOL.md`](docs/PROTOCOL.md) | RIO wire format + `iRIO` input-map reference | | [`docs/PROTOCOL.md`](docs/PROTOCOL.md) | RIO wire format + `iRIO` input-map reference |
| [`docs/reference/`](docs/reference/) | Cockpit overlay art & the legacy labeling pipeline | | [`docs/reference/`](docs/reference/) | Cockpit overlay art & the legacy labeling pipeline |
@@ -35,11 +37,14 @@ dotnet test RioJoy.sln
## Status ## Status
Phases 15 are implemented and tested (136 unit tests): the `RioGamepad` virtual Phases 15 are implemented and tested (136 unit tests). The `RioGamepad` virtual
HID driver compiles to `.sys` against the EWDK (KMDF + VHF), and the C# side HID driver is built (KMDF + VHF), **test-signed, installed, and verified**: it
covers the serial + RIO protocol core, input mapping + output routing, axis enumerates in `joy.cpl`, and the C# HID feeder (`DeviceIoControl`
calibration + plasma display, the tray app + profiles (JSON config, `RIO.ini` `RioGamepad.sys`) drives its axes, buttons, and hat end-to-end (see
importer, three-state auto-switch), and the HID report packer that matches the [`tools/RioJoySmokeTest`](tools/RioJoySmokeTest/)). The C# side covers the serial
driver's wire format. Remaining work is **deploy-side**: test-sign + install the + RIO protocol core, input mapping + output routing, axis calibration + plasma
driver, wire the real `DeviceIoControl` feeder, and verify on a cabinet. See display, the tray app + profiles (JSON config, `RIO.ini` importer, three-state
[`docs/PLAN.md`](docs/PLAN.md) for the full roadmap. auto-switch), and the HID report packer that matches the driver's wire format.
Remaining work is **on-cabinet** (real RIO serial/axis/plasma/auto-switch
verification) plus packaging (Phase 6) and the profile editor + overlay
generator (Phase 7). See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.
+7
View File
@@ -13,6 +13,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9FFCA6FA
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RioJoy.Core.Tests", "tests\RioJoy.Core.Tests\RioJoy.Core.Tests.csproj", "{4A950510-432A-48C2-92E5-B87D075BA7DB}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RioJoy.Core.Tests", "tests\RioJoy.Core.Tests\RioJoy.Core.Tests.csproj", "{4A950510-432A-48C2-92E5-B87D075BA7DB}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RioJoy.Overlay", "src\RioJoy.Overlay\RioJoy.Overlay.csproj", "{1A532110-DD43-40FB-87C9-ECFF014D5799}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -34,10 +36,15 @@ Global
{4A950510-432A-48C2-92E5-B87D075BA7DB}.Debug|Any CPU.Build.0 = Debug|x64 {4A950510-432A-48C2-92E5-B87D075BA7DB}.Debug|Any CPU.Build.0 = Debug|x64
{4A950510-432A-48C2-92E5-B87D075BA7DB}.Release|Any CPU.ActiveCfg = Release|x64 {4A950510-432A-48C2-92E5-B87D075BA7DB}.Release|Any CPU.ActiveCfg = Release|x64
{4A950510-432A-48C2-92E5-B87D075BA7DB}.Release|Any CPU.Build.0 = Release|x64 {4A950510-432A-48C2-92E5-B87D075BA7DB}.Release|Any CPU.Build.0 = Release|x64
{1A532110-DD43-40FB-87C9-ECFF014D5799}.Debug|Any CPU.ActiveCfg = Debug|x64
{1A532110-DD43-40FB-87C9-ECFF014D5799}.Debug|Any CPU.Build.0 = Debug|x64
{1A532110-DD43-40FB-87C9-ECFF014D5799}.Release|Any CPU.ActiveCfg = Release|x64
{1A532110-DD43-40FB-87C9-ECFF014D5799}.Release|Any CPU.Build.0 = Release|x64
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{C81FEF57-A33B-4529-BDB7-02787407A545} = {CDDAA3FE-6A05-40A0-85CC-4DB0B3EEEA9C} {C81FEF57-A33B-4529-BDB7-02787407A545} = {CDDAA3FE-6A05-40A0-85CC-4DB0B3EEEA9C}
{FA2BAEAC-D8D5-47D9-B5EC-C9D10530D351} = {CDDAA3FE-6A05-40A0-85CC-4DB0B3EEEA9C} {FA2BAEAC-D8D5-47D9-B5EC-C9D10530D351} = {CDDAA3FE-6A05-40A0-85CC-4DB0B3EEEA9C}
{4A950510-432A-48C2-92E5-B87D075BA7DB} = {9FFCA6FA-1A95-4492-9A18-39D4F5720519} {4A950510-432A-48C2-92E5-B87D075BA7DB} = {9FFCA6FA-1A95-4492-9A18-39D4F5720519}
{1A532110-DD43-40FB-87C9-ECFF014D5799} = {CDDAA3FE-6A05-40A0-85CC-4DB0B3EEEA9C}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+47 -13
View File
@@ -100,7 +100,7 @@ tray menu is always available.
## Phases ## Phases
### Phase 0 — Repo & scaffold ✅ (this commit) ### Phase 0 — Repo & scaffold ✅ (this commit)
- `git init`, remote `https://gitea.mysticmachines.com/VWE/riovjoy2.git`. - `git init`, remote `https://gitea.mysticmachines.com/VWE/RIOJoy.git`.
- Legacy C++ moved to `legacy/`; cockpit art to `docs/reference/`. - Legacy C++ moved to `legacy/`; cockpit art to `docs/reference/`.
- Solution `RioJoy.sln` with `src/RioJoy.Core` (lib) + `src/RioJoy.Tray` - Solution `RioJoy.sln` with `src/RioJoy.Core` (lib) + `src/RioJoy.Tray`
(tray app); `driver/` placeholder for the WDK project. (tray app); `driver/` placeholder for the WDK project.
@@ -183,7 +183,7 @@ Implemented in `src/RioJoy.Core/Calibration` + `Plasma` (105 xUnit tests total):
### Phase 5 — Tray app + profiles — code-complete ✅ ### Phase 5 — Tray app + profiles — code-complete ✅
Core logic in `src/RioJoy.Core/Profiles` + `RioRuntime`; UI/OS in `src/RioJoy.Tray` Core logic in `src/RioJoy.Core/Profiles` + `RioRuntime`; UI/OS in `src/RioJoy.Tray`
(123 xUnit tests total): (136 xUnit tests total across the suite):
- `RioProfile` + `AppConfig` model; `ConfigStore` JSON persistence (round-trip - `RioProfile` + `AppConfig` model; `ConfigStore` JSON persistence (round-trip
tested); `RioIniImporter` ports the legacy `RIO.ini` (buttons/inverts/greeting). tested); `RioIniImporter` ports the legacy `RIO.ini` (buttons/inverts/greeting).
- `AutoSwitchResolver` + `AutoSwitchWatcher`: the three-state decision - `AutoSwitchResolver` + `AutoSwitchWatcher`: the three-state decision
@@ -205,20 +205,54 @@ Core logic in `src/RioJoy.Core/Profiles` + `RioRuntime`; UI/OS in `src/RioJoy.Tr
acquire/release lifecycle against real RIO hardware. acquire/release lifecycle against real RIO hardware.
### Phase 6 — Packaging / signing / deploy ### Phase 6 — Packaging / signing / deploy
- Driver install via `pnputil`; app installer; test-signing script. - Driver test-signing + `pnputil` install already scripted (`driver/sign.ps1`,
- Cabinet deployment doc. (Production option: attestation signing.) `driver/install.ps1`, `driver/uninstall.ps1`, [`driver/README.md`](../driver/README.md));
proven on the cabinet. ⏳ Still to do: app installer, a single bundled deploy,
and a cabinet deployment doc. (Production option: attestation signing.)
### Phase 7 — Profile/mapping editor + cockpit overlay generator ### Phase 7 — Profile/mapping editor + cockpit overlay generator — in progress
Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
(see [`docs/reference/customBackground/`](reference/customBackground/)). (see [`docs/reference/customBackground/`](reference/customBackground/)).
- **Mapping editor:** per-button UI to set action + label + lamp, no hex - **Overlay generator — done ✅, verified on real assets.** `RioJoy.Core/Overlay`
bit-twiddling; clone-from-existing profile. is the pure, unit-tested layout engine: `FontFitter` is a faithful port of the
- **Overlay generator:** render labels into named regions over a base cockpit `calc-fontsize` auto-fit search (validated against a brute-force oracle),
image using **SkiaSharp**, porting the auto-fit/justification logic from `OverlayLayoutEngine` ports `create-data-layer`'s fit + horizontal/vertical
`sg-goobie-MFD.scm`; export the per-profile wallpaper and re-apply via justification, and `OverlayTemplate`/`OverlayRegion` (a `regions.json` via
`SystemParametersInfo`. `OverlayTemplateStore`) hold the cell geometry/color. Label text is per-profile
- **Region authoring:** extract the label rectangles from `riojoy.xcf` into a (`RioProfile.OverlayLabels`); `GoobieDataImporter` reads the legacy `.data`
`regions.json`; in-app box editor for future tweaks. sheet into label rows. The rasterizer lives in `src/RioJoy.Overlay`
(**SkiaSharp**): `SkiaTextMeasurer` (shared with the engine so measured layout
== drawn output) + `SkiaOverlayRenderer` (draw labels → PNG). The full chain is
exercised end-to-end on the real cockpit art (`regions.json` + `riojoy.png` +
`TEST.data`) by `OverlayRenderIntegrationTests`. `RioJoy.Tray/WallpaperApplier`
applies the result via `SystemParametersInfo`.
- **Region authoring — extraction done ✅.** `tools/XcfRegionExtract` parses the
GIMP source (`riojoy.xcf`) and writes the 119-cell
`docs/reference/customBackground/regions.json` (per-layer offsets/size/font/color,
`BaseImagePath` → exported `riojoy.png`), anchored by `CockpitRegionsTests`.
⏳ Still to do: an in-app box editor for tweaks.
- **Runtime wiring — done ✅ (opt-in).** `RioJoy.Overlay/ProfileWallpaperGenerator`
renders a profile's labels onto the template base image; `RioCoordinator`
generates + applies the wallpaper on profile activation when
`AppConfig.OverlayTemplatePath` is set (best-effort, off by default, never breaks
activation). The live `SystemParametersInfo` apply changes a user setting, so it
is gated behind config and not exercised by tests. ⏳ Optional: restore the prior
wallpaper when going dormant.
- **Mapping editor — first cut done ✅.** `RioJoy.Tray/Editor/ProfileEditorForm`
shows the cockpit as a scrollable, clickable grid that mirrors the legacy
authoring spreadsheet's **logical** layout — parsed from the sheet's CSV export
(`docs/reference/customBackground/config-sheet.csv`) by `SheetLayout`, coloured
by section. (The layout deliberately follows the sheet, **not** the wallpaper
region positions: the wallpaper drives 6 displays via VGA chroma-channel
splitting, so its label positions stack and don't reflect the physical cockpit.)
Clicking an address cell edits its label and action/modifiers/lamp; the `iRIO`
word is read/written through `ButtonBinding``RioMapEntry.Create` (round-trip
tested) — no hex bit-twiddling. The value field is a **context-sensitive picker**:
keyboard actions pick a key by name (`KeyCatalog` VK names), joystick a Button N,
hat a direction, mouse/RIO-command the enum name; modifiers enable only for
keyboard. Opened from the tray ("Edit profile…"); Save persists the profile.
⏳ Still to refine: grouping a button's two bank addresses (edit once → both),
and clone-from-existing.
- The unified profile JSON supersedes both `RIO.ini` and the Google Sheet, with - The unified profile JSON supersedes both `RIO.ini` and the Google Sheet, with
importers for each. importers for each.
- To confirm at Phase 7: target wallpaper resolution(s); static wallpaper vs. - To confirm at Phase 7: target wallpaper resolution(s); static wallpaper vs.
@@ -0,0 +1,49 @@
"","","","","IMAGE NAME","","","","defalt","","","","Plasma greeting","","","","Hello,","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""
"","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY","","S","C","A","KEY","S","","A","KEY","S","C","A","KEY","S","C","A","KEY","","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY",""
"","","","","G_KEY","","","","H_KEY","","","","I_KEY","","","","J_KEY","","","","","K_KEY","","","","L_KEY","","","","M_KEY","","","","N_KEY","","","","","O_KEY","","","","P_KEY","","","","Q_KEY","","","","R_KEY",""
"","","LABEL","","G","","LABEL","","H","","LABEL","","I","","LABEL","","J","","","LABEL","","K","","","","L","","LABEL","","M","","LABEL","","N","","","LABEL","","O","","LABEL","","P","","LABEL","","Q","","LABEL","","R",""
"","IsLit","","","2F","IsLit","","","2E","IsLit","","","2D","IsLit","","","2C","","IsLit","","","27","IsLit","","","26","IsLit","","","25","IsLit","","","24","","IsLit","","","37","IsLit","","","36","IsLit","","","35","IsLit","","","34",""
"","x","","","","x","","","","x","","","","x","","","","","x","","","","x","","","","x","","","","x","","","","","x","","","","x","","","","x","","","","x","","","",""
"","IsLit","","","2B","IsLit","","","2A","IsLit","","","29","IsLit","","","28","","IsLit","","","23","IsLit","","","22","IsLit","","","21","IsLit","","","20","","IsLit","","","33","IsLit","","","32","IsLit","","","31","IsLit","","","30",""
"","x","","","","x","","","","x","","","","x","","","","","x","","","","x","","","","x","","","","x","","","","","x","","","","x","","","","x","","","","x","","","",""
"","","LABEL","","S","","LABEL","","T","","LABEL","","U","","LABEL","","V","","","LABEL","","<","","","","^","","LABEL","","v","","LABEL","",">","","","LABEL","","W","","LABEL","","X","","LABEL","","Y","","LABEL","","Z",""
"","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY","","S","C","A","KEY","S","","A","KEY","S","C","A","KEY","S","C","A","KEY","","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY",""
"","","","","S_KEY","","","","T_KEY","","","","U_KEY","","","","V_KEY","","","","","LEFT","","","","UP","","","","DOWN","","","","RIGHT","","","","","W_KEY","","","","X_KEY","","","","Y_KEY","","","","Z_KEY",""
"","","","","KEYPAD","","","","LABELS","","","","JOYSTICK AIS","INVERT","","","","","S","C","A","KEY","KEY PAD","","","","BUTTONS","","","","S","C","A","KEY","","S","C","A","KEY","IsLit","","","THROTTLE BOARD","S","C","A","KEY","IsLit","","","JOYSTICK BOARD",""
"","","0","","0","","8","","8","","","","X","","","","","","","","","0_KEY","50","","","","58","","","","","","","8_KEY","","","","","MouseU","","","","38","","","","J1","","","","40",""
"","","1","","1","","9","","9","","","","Y","","","","","","","","","1_KEY","51","","","","59","","","","","","","9_KEY","","","","","MouseR","","","","39","","","","HatD","","","","41",""
"","","2","","2","","A","","A","","","","Z","","","","","","","","","2_KEY","52","","","","5A","","","","","","","A_KEY","","","","","MouseD","","","","3A","","","","HatU","","","","42",""
"","","3","","3","","B","","B","","","","R (L PEDDLE)","","","","","","","","","3_KEY","53","","","","5B","","","","","","","B_KEY","","","","","MouseL","","","","3B","","","","HatR","","","","43",""
"","","4","","4","","C","","C","","","","YR (R PEDDLE)","","","","","","","","","4_KEY","54","","","","5C","","","","","","","C_KEY","","","","","MouseLC","","","","3C","","","","HatL","","","","44",""
"","","5","","5","","D","","D","","","","ZR (RUDDER)","","","","","","","","","5_KEY","55","","","","5D","","","","","","","D_KEY","","","","","ESCAPE","x","","","3D","","","","J4","","","","45",""
"","","6","","6","","E","","E","","","","ZR (DISSABLE)","","","","","","","","","6_KEY","56","","","","5E","","","","","","","E_KEY","","","","","MouseRC","","","","3E","","","","J3","","","","46",""
"","","7","","7","","F","","F","","","","","","","","","","","","","7_KEY","57","","","","5F","","","","","","","F_KEY","","","","","J8","","","","3F","","","","J2","","","","47",""
"","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY","","S","C","A","KEY","IsLit","","","SECONDARY","IsLit","","","SCREEN","S","C","A","KEY","","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY",""
"","","","","HOME","","","","COMMA","","","","PERIOD","","","","END","","","","","MouseU","X","","","10","X","","","18","","","","RETURN","","X","","","LEFT","X","","","RIGHT","X","","","DOWN","X","x","x","UP",""
"","","LABEL","","TOP CONTACT","","LABEL","","UP CONTACT","","LABEL","","DOWN CON","","LABEL","","LAST CONTACT","","","","","MouseR","X","","","11","X","","","19","","","","MENU(ALT)","","","LABEL","","OFFENCE","","LABEL","","DEFENCE","","LABEL","","DRIVE","","LABEL","","BALANCE",""
"","IsLit","","","0F","IsLit","","","0E","IsLit","","","0D","IsLit","","","0C","","","","","MouseD","X","","","12","X","","","1A","","","","CONTROL","","IsLit","","","07","IsLit","","","06","IsLit","","","05","IsLit","","","04",""
"","X","","","","X","","","","X","","","","X","","","","","","","","MouseL","X","","","13","X","","","1B","","","","SHIFT","","x","","","","x","","","","x","","","","x","","","",""
"","IsLit","","","0B","IsLit","","","0A","IsLit","","","09","IsLit","","","08","","","","","MouseLC","X","","","14","X","","","1C","","","","VOLUME_UP","","IsLit","","","03","IsLit","","","02","IsLit","","","01","IsLit","","","00",""
"","X","","","","X","","","","X","","","","X","","","","","","","","TAB","X","","","15","X","","","1D","","","","VOLUME_DOWN","","x","","","","x","","","","x","","","","x","","","",""
"","","LABEL","","NEAR ENEMY","","LABEL","","NEXT ENEMY","","LABEL","","SUB TARGET","","LABEL","","LAST AGGESSOR","","","","","NULL","","","","16","","","","1E","","","","NULL","","","LABEL","","LDS","","LABEL","","UNDOCK","","LABEL","","ZOOM","","LABEL","","LSDI",""
"","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY","","","","","NULL","","","","17","","","","1F","","","","NULL","","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY","S","C","A","KEY",""
"","","","","R_KEY","","","","E_KEY","","","","Y_KEY","","","","Q_KEY","","","","","","","","","SECONDARY","","","","LABELS","","","","","","","","","L_KEY","","","","U_KEY","","","","Z_KEY","","","","I_KEY",""
"","","","","","","","","","","","","","","","","","","","","","","","10","","M U","","18","","ENTER","","","","","","","","","THROTTLE LABELS","","","","","","","","JOYSTICK LABELS","","","","",""
"","S","C","A","KEY","EXTERNAL KEY PAD","","","","BUTTONS","","","","S","C","A","KEY","","","","","","","11","","M R","","19","","ALT","","","","","","","38","","M ^","","","","","","40","","FIRE/ACCEPT","","","","",""
"","","","","RIO_AnalogAllReset","60","","","","68","","","","","","","RIO_AxesReadout","","","","","","","12","","M D","","1A","","CTRL","","","","","","","39","","M >","","","","","","41","","H v","","","","",""
"","","","","RIO_ResetThrottle","61","","","","69","","","","","","","RIO_FrequencyReadout","","","","","","","13","","M L","","1B","","SHIFT","","","","","","","3A","","M v","","","","","","42","","H ^","","","","",""
"","","","","RIO_ResetLeftPedal","62","","","","6A","","","","","","","RIO_more1","","","","","","","14","","MLC","","1C","","VL U","","","","","","","3B","","M <","","","","","","43","","H >","","","","",""
"","","","","RIO_ResetRightPedal","63","","","","6B","","","","","","","RIO_more2","","","","","","","15","","TAB","","1D","","VL D","","","","","","","3C","","ML","","","","","","44","","H <","","","","",""
"","","","","RIO_ResetVerticalJoystick","64","","","","6C","","","","","","","RIO_more3","","","","","","","16","","","","1E","","","","","","","","","3D","","ESC","","","","","","45","","NEXT PRIMEARY","","","","",""
"","","","","RIO_ResetHorizontalJoystick","65","","","","6D","","","","","","","RIO_more4","","","","","","","17","","","","1F","","","","","","","","","3E","","MR","","","","","","46","","NEXT SECOND","","","","",""
"","","","","RIO_DigitalReset","66","","","","6E","","","","","","","RIO_more5","","","","","","","","","","","","","","","","","","","","3F","","REVERSE","","","","","","47","","TARGET/BACK","","","","",""
"","","","","RIO_StatusCheck","67","","","","6F","","","","","","","RIO_more6","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","EXT KEYPAD","","","","LABLES","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""
"","","","","","","0","","RIO_0","","8","","RIO_8","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""
"","","","","","","1","","RIO_1","","9","","RIO_9","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""
"","","","","","","2","","RIO_2","","A","","RIO_A","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""
"","","","","","","3","","RIO_3","","B","","RIO_B","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""
"","","","","","","4","","RIO_4","","C","","RIO_C","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""
"","","","","","","5","","RIO_5","","D","","RIO_D","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""
"","","","","","","6","","RIO_6","","E","","RIO_E","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""
"","","","","","","7","","RIO_7","","F","","RIO_F","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""
1 IMAGE NAME defalt Plasma greeting Hello,
2 S C A KEY S C A KEY S C A KEY S C A KEY S C A KEY S A KEY S C A KEY S C A KEY S C A KEY S C A KEY S C A KEY S C A KEY
3 G_KEY H_KEY I_KEY J_KEY K_KEY L_KEY M_KEY N_KEY O_KEY P_KEY Q_KEY R_KEY
4 LABEL G LABEL H LABEL I LABEL J LABEL K L LABEL M LABEL N LABEL O LABEL P LABEL Q LABEL R
5 IsLit 2F IsLit 2E IsLit 2D IsLit 2C IsLit 27 IsLit 26 IsLit 25 IsLit 24 IsLit 37 IsLit 36 IsLit 35 IsLit 34
6 x x x x x x x x x x x x
7 IsLit 2B IsLit 2A IsLit 29 IsLit 28 IsLit 23 IsLit 22 IsLit 21 IsLit 20 IsLit 33 IsLit 32 IsLit 31 IsLit 30
8 x x x x x x x x x x x x
9 LABEL S LABEL T LABEL U LABEL V LABEL < ^ LABEL v LABEL > LABEL W LABEL X LABEL Y LABEL Z
10 S C A KEY S C A KEY S C A KEY S C A KEY S C A KEY S A KEY S C A KEY S C A KEY S C A KEY S C A KEY S C A KEY S C A KEY
11 S_KEY T_KEY U_KEY V_KEY LEFT UP DOWN RIGHT W_KEY X_KEY Y_KEY Z_KEY
12 KEYPAD LABELS JOYSTICK AIS INVERT S C A KEY KEY PAD BUTTONS S C A KEY S C A KEY IsLit THROTTLE BOARD S C A KEY IsLit JOYSTICK BOARD
13 0 0 8 8 X 0_KEY 50 58 8_KEY MouseU 38 J1 40
14 1 1 9 9 Y 1_KEY 51 59 9_KEY MouseR 39 HatD 41
15 2 2 A A Z 2_KEY 52 5A A_KEY MouseD 3A HatU 42
16 3 3 B B R (L PEDDLE) 3_KEY 53 5B B_KEY MouseL 3B HatR 43
17 4 4 C C YR (R PEDDLE) 4_KEY 54 5C C_KEY MouseLC 3C HatL 44
18 5 5 D D ZR (RUDDER) 5_KEY 55 5D D_KEY ESCAPE x 3D J4 45
19 6 6 E E ZR (DISSABLE) 6_KEY 56 5E E_KEY MouseRC 3E J3 46
20 7 7 F F 7_KEY 57 5F F_KEY J8 3F J2 47
21 S C A KEY S C A KEY S C A KEY S C A KEY S C A KEY IsLit SECONDARY IsLit SCREEN S C A KEY S C A KEY S C A KEY S C A KEY S C A KEY
22 HOME COMMA PERIOD END MouseU X 10 X 18 RETURN X LEFT X RIGHT X DOWN X x x UP
23 LABEL TOP CONTACT LABEL UP CONTACT LABEL DOWN CON LABEL LAST CONTACT MouseR X 11 X 19 MENU(ALT) LABEL OFFENCE LABEL DEFENCE LABEL DRIVE LABEL BALANCE
24 IsLit 0F IsLit 0E IsLit 0D IsLit 0C MouseD X 12 X 1A CONTROL IsLit 07 IsLit 06 IsLit 05 IsLit 04
25 X X X X MouseL X 13 X 1B SHIFT x x x x
26 IsLit 0B IsLit 0A IsLit 09 IsLit 08 MouseLC X 14 X 1C VOLUME_UP IsLit 03 IsLit 02 IsLit 01 IsLit 00
27 X X X X TAB X 15 X 1D VOLUME_DOWN x x x x
28 LABEL NEAR ENEMY LABEL NEXT ENEMY LABEL SUB TARGET LABEL LAST AGGESSOR NULL 16 1E NULL LABEL LDS LABEL UNDOCK LABEL ZOOM LABEL LSDI
29 S C A KEY S C A KEY S C A KEY S C A KEY NULL 17 1F NULL S C A KEY S C A KEY S C A KEY S C A KEY
30 R_KEY E_KEY Y_KEY Q_KEY SECONDARY LABELS L_KEY U_KEY Z_KEY I_KEY
31 10 M U 18 ENTER THROTTLE LABELS JOYSTICK LABELS
32 S C A KEY EXTERNAL KEY PAD BUTTONS S C A KEY 11 M R 19 ALT 38 M ^ 40 FIRE/ACCEPT
33 RIO_AnalogAllReset 60 68 RIO_AxesReadout 12 M D 1A CTRL 39 M > 41 H v
34 RIO_ResetThrottle 61 69 RIO_FrequencyReadout 13 M L 1B SHIFT 3A M v 42 H ^
35 RIO_ResetLeftPedal 62 6A RIO_more1 14 MLC 1C VL U 3B M < 43 H >
36 RIO_ResetRightPedal 63 6B RIO_more2 15 TAB 1D VL D 3C ML 44 H <
37 RIO_ResetVerticalJoystick 64 6C RIO_more3 16 1E 3D ESC 45 NEXT PRIMEARY
38 RIO_ResetHorizontalJoystick 65 6D RIO_more4 17 1F 3E MR 46 NEXT SECOND
39 RIO_DigitalReset 66 6E RIO_more5 3F REVERSE 47 TARGET/BACK
40 RIO_StatusCheck 67 6F RIO_more6
41 EXT KEYPAD LABLES
42 0 RIO_0 8 RIO_8
43 1 RIO_1 9 RIO_9
44 2 RIO_2 A RIO_A
45 3 RIO_3 B RIO_B
46 4 RIO_4 C RIO_C
47 5 RIO_5 D RIO_D
48 6 RIO_6 E RIO_E
49 7 RIO_7 F RIO_F
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+120
View File
@@ -0,0 +1,120 @@
using System.Globalization;
using System.Text;
namespace RioJoy.Core.Editing;
/// <summary>Section of the authoring sheet a cell belongs to (drives editor colour).</summary>
public enum SheetSection
{
None,
Contacts, // 0x000x0F
Secondary, // 0x100x17
Screen, // 0x180x1F
Letters, // 0x200x37
Throttle, // 0x380x3F
Joystick, // 0x400x47
Keypad, // 0x500x5F
ExtKeypad, // 0x600x6F
}
/// <summary>
/// One cell of the cockpit authoring spreadsheet: its grid <see cref="Row"/>/<see cref="Col"/>,
/// the cell <see cref="Text"/>, and — when the text is a RIO address (two hex
/// digits, 0x000x6F) — the <see cref="Address"/> it edits and its
/// <see cref="Section"/>.
/// </summary>
public sealed record SheetCell(int Row, int Col, string Text, int? Address, SheetSection Section);
/// <summary>
/// Parses the legacy authoring spreadsheet (exported CSV, see
/// docs/reference/customBackground/config-sheet.csv) into a grid of
/// <see cref="SheetCell"/>s. This is the cockpit's <em>logical</em> layout — the
/// shape the editor mirrors — as opposed to the wallpaper's pixel positions, which
/// are a VGA chroma-split display artifact. Pure, so it is unit-tested.
/// </summary>
public static class SheetLayout
{
public static IReadOnlyList<SheetCell> Load(string path) => Parse(File.ReadAllText(path));
/// <summary>
/// The RIO address a cell's text encodes (two hex digits in 0x000x6F), or null.
/// </summary>
public static int? AddressFromText(string text)
{
string t = text.Trim();
if (t.Length != 2) return null;
if (!int.TryParse(t, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int a)) return null;
return a is >= 0 and <= 0x6F ? a : null;
}
/// <summary>Classify an address into a sheet section.</summary>
public static SheetSection SectionForAddress(int address) => address switch
{
>= 0x00 and <= 0x0F => SheetSection.Contacts,
>= 0x10 and <= 0x17 => SheetSection.Secondary,
>= 0x18 and <= 0x1F => SheetSection.Screen,
>= 0x20 and <= 0x37 => SheetSection.Letters,
>= 0x38 and <= 0x3F => SheetSection.Throttle,
>= 0x40 and <= 0x47 => SheetSection.Joystick,
>= 0x50 and <= 0x5F => SheetSection.Keypad,
>= 0x60 and <= 0x6F => SheetSection.ExtKeypad,
_ => SheetSection.None,
};
/// <summary>
/// Parse the CSV (one spreadsheet row per line) into non-empty cells. Cell text
/// longer than <paramref name="maxTextLength"/> is dropped as a merged-cell
/// artifact; the clean export has none.
/// </summary>
public static IReadOnlyList<SheetCell> Parse(string csv, int maxTextLength = 40)
{
ArgumentNullException.ThrowIfNull(csv);
string[] lines = csv.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var cells = new List<SheetCell>();
for (int row = 0; row < lines.Length; row++)
{
if (lines[row].Length == 0) continue;
List<string> fields = ParseLine(lines[row]);
for (int col = 0; col < fields.Count; col++)
{
string text = fields[col].Trim();
if (text.Length == 0 || text.Length > maxTextLength) continue;
int? address = AddressFromText(text);
SheetSection section = address is { } a ? SectionForAddress(a) : SheetSection.None;
cells.Add(new SheetCell(row, col, text, address, section));
}
}
return cells;
}
// Split one CSV line into fields, honouring "quoted" fields and "" escapes.
private static List<string> ParseLine(string line)
{
var fields = new List<string>();
var sb = new StringBuilder();
bool inQuotes = false;
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (inQuotes)
{
if (c == '"')
{
if (i + 1 < line.Length && line[i + 1] == '"') { sb.Append('"'); i++; }
else inQuotes = false;
}
else sb.Append(c);
}
else if (c == '"') inQuotes = true;
else if (c == ',') { fields.Add(sb.ToString()); sb.Clear(); }
else sb.Append(c);
}
fields.Add(sb.ToString());
return fields;
}
}
+56
View File
@@ -0,0 +1,56 @@
namespace RioJoy.Core.Mapping;
/// <summary>
/// A mutable, editor-friendly view of one <c>iRIO</c> map word: the routing
/// <see cref="Kind"/>, payload <see cref="Value"/>, lamp flag, and keyboard
/// modifiers. Two-way binds cleanly to UI controls and converts to/from the packed
/// 16-bit word via <see cref="ToWord"/> / <see cref="FromWord"/> (which round-trip
/// through <see cref="RioMapEntry"/>). The button's display label is stored
/// separately (the profile's overlay labels), not here.
/// </summary>
public sealed class ButtonBinding
{
/// <summary>How the input is routed (keyboard / joystick / hat / mouse / RIO command).</summary>
public RioRouteKind Kind { get; set; } = RioRouteKind.Keyboard;
/// <summary>Payload byte: VK code, joystick button #, hat direction, or mouse action.</summary>
public byte Value { get; set; }
/// <summary>Lighted button (lamp feedback on press/release).</summary>
public bool IsLit { get; set; }
/// <summary>Keyboard modifier: SHIFT.</summary>
public bool Shift { get; set; }
/// <summary>Keyboard modifier: CTRL.</summary>
public bool Ctrl { get; set; }
/// <summary>Keyboard modifier: ALT.</summary>
public bool Alt { get; set; }
/// <summary>Apply <c>KEYEVENTF_EXTENDEDKEY</c> to the key.</summary>
public bool Extended { get; set; }
/// <summary>True when this binding does nothing (an empty slot).</summary>
public bool IsUnmapped => ToWord() == 0;
/// <summary>Pack to the 16-bit <c>iRIO</c> map word.</summary>
public ushort ToWord() =>
RioMapEntry.Create(Kind, Value, IsLit, Shift, Ctrl, Alt, Extended).Raw;
/// <summary>Build an editable binding from a packed map word.</summary>
public static ButtonBinding FromWord(ushort raw)
{
var e = new RioMapEntry(raw);
return new ButtonBinding
{
Kind = e.Kind,
Value = e.Value,
IsLit = e.HasLamp,
Shift = e.Shift,
Ctrl = e.Ctrl,
Alt = e.Alt,
Extended = e.Extended,
};
}
}
+83
View File
@@ -0,0 +1,83 @@
namespace RioJoy.Core.Mapping;
/// <summary>A named keyboard key and its Windows virtual-key code.</summary>
public readonly record struct KeyName(string Name, byte Value);
/// <summary>
/// Friendly Windows virtual-key names ↔ VK codes, for the profile editor's key
/// picker so a keyboard action is chosen by name instead of a raw hex byte. The
/// keyboard <c>iRIO</c> payload is a VK code (see <see cref="InputRouter"/> /
/// <c>SendInputSink</c>); modifiers are separate flags, so they are not listed here
/// as keys.
/// </summary>
public static class KeyCatalog
{
/// <summary>All catalogued keys, in menu order (letters, digits, F-keys, …).</summary>
public static IReadOnlyList<KeyName> Entries { get; } = Build();
private static readonly Dictionary<byte, string> ByValue =
Entries.GroupBy(e => e.Value).ToDictionary(g => g.Key, g => g.First().Name);
private static readonly Dictionary<string, byte> ByName =
Entries.ToDictionary(e => e.Name, e => e.Value, StringComparer.OrdinalIgnoreCase);
/// <summary>Friendly name for a VK code, or <c>"0xHH"</c> when uncatalogued.</summary>
public static string NameFor(byte vk) => ByValue.TryGetValue(vk, out string? n) ? n : $"0x{vk:X2}";
/// <summary>Look up a VK code by friendly name.</summary>
public static bool TryGetValue(string name, out byte vk) => ByName.TryGetValue(name, out vk);
private static List<KeyName> Build()
{
var list = new List<KeyName>();
void Add(string name, int vk) => list.Add(new KeyName(name, (byte)vk));
for (char c = 'A'; c <= 'Z'; c++) Add(c.ToString(), c); // 0x410x5A
for (int d = 0; d <= 9; d++) Add(d.ToString(), 0x30 + d); // 0x300x39
for (int f = 1; f <= 12; f++) Add($"F{f}", 0x6F + f); // F1F12 = 0x700x7B
Add("Space", 0x20);
Add("Enter", 0x0D);
Add("Escape", 0x1B);
Add("Tab", 0x09);
Add("Backspace", 0x08);
Add("Delete", 0x2E);
Add("Insert", 0x2D);
Add("Home", 0x24);
Add("End", 0x23);
Add("Page Up", 0x21);
Add("Page Down", 0x22);
Add("Left", 0x25);
Add("Up", 0x26);
Add("Right", 0x27);
Add("Down", 0x28);
for (int n = 0; n <= 9; n++) Add($"NumPad {n}", 0x60 + n); // 0x600x69
Add("NumPad *", 0x6A);
Add("NumPad +", 0x6B);
Add("NumPad -", 0x6D);
Add("NumPad .", 0x6E);
Add("NumPad /", 0x6F);
Add("; :", 0xBA);
Add("= +", 0xBB);
Add(", <", 0xBC);
Add("- _", 0xBD);
Add(". >", 0xBE);
Add("/ ?", 0xBF);
Add("` ~", 0xC0);
Add("[ {", 0xDB);
Add("\\ |", 0xDC);
Add("] }", 0xDD);
Add("' \"", 0xDE);
Add("Caps Lock", 0x14);
Add("Print Screen", 0x2C);
Add("Scroll Lock", 0x91);
Add("Pause", 0x13);
Add("Num Lock", 0x90);
return list;
}
}
+36
View File
@@ -47,6 +47,42 @@ public readonly struct RioMapEntry
public RioMapEntry(ushort raw) => Raw = raw; public RioMapEntry(ushort raw) => Raw = raw;
/// <summary>
/// Build a map word from decoded components (the inverse of the decode
/// properties) — used by the profile editor to write the <c>iRIO</c> table.
/// <paramref name="kind"/> sets the routing flags; modifiers/lamp/extended set
/// their high-byte bits; <paramref name="value"/> is the low byte.
/// </summary>
public static RioMapEntry Create(
RioRouteKind kind,
byte value,
bool lit = false,
bool shift = false,
bool ctrl = false,
bool alt = false,
bool extended = false)
{
ushort raw = value;
raw |= kind switch
{
RioRouteKind.Keyboard => 0,
RioRouteKind.Joystick => FlagJoy,
RioRouteKind.Hat => FlagHat,
RioRouteKind.Mouse => FlagMouse,
RioRouteKind.RioCommand => RioCommandMask,
_ => 0,
};
if (lit) raw |= FlagHasLamp;
if (extended) raw |= FlagExtended;
if (alt) raw |= FlagAlt;
if (ctrl) raw |= FlagCtrl;
if (shift) raw |= FlagShift;
return new RioMapEntry(raw);
}
/// <summary>This input drives a lighted button (lamp feedback on press/release).</summary> /// <summary>This input drives a lighted button (lamp feedback on press/release).</summary>
public bool HasLamp => (Raw & FlagHasLamp) != 0; public bool HasLamp => (Raw & FlagHasLamp) != 0;
+64
View File
@@ -0,0 +1,64 @@
namespace RioJoy.Core.Overlay;
/// <summary>
/// Finds the largest font size whose text fits a cell — a faithful port of the
/// legacy <c>calc-fontsize</c> Script-Fu routine
/// (docs/reference/customBackground/sg-goobie-MFD.scm, lines 5067).
///
/// <para>The original is an expand-then-narrow search: grow the size by a factor
/// (<c>adjust</c>, starting at 2) while the text fits; on the first overflow, halve
/// the growth factor's excess-over-1 and retry from the last size that fit. It
/// converges when the (truncated) size stops changing or the measured extents
/// repeat, and never returns below <see cref="MinFontSize"/>.</para>
/// </summary>
public static class FontFitter
{
/// <summary>Minimum font size the legacy routine will return (px).</summary>
public const double MinFontSize = 6;
// Guard against a pathological measurer that never converges.
private const int MaxIterations = 1000;
/// <summary>
/// Largest font size (px) at which <paramref name="text"/> fits within
/// <paramref name="width"/> × <paramref name="height"/>, measured by
/// <paramref name="measurer"/>.
/// </summary>
public static double BestFitSize(
string text, string fontFamily, double width, double height, ITextMeasurer measurer)
{
ArgumentNullException.ThrowIfNull(measurer);
// State mirrors the Scheme named-let: (fontsize last-extents last-fontsize adjust).
double fontsize = 6; // minimum possible fontsize (the loop's seed)
double lastFontsize = 3;
double adjust = 2;
TextExtent? lastExtents = null;
for (int i = 0; i < MaxIterations; i++)
{
TextExtent extents = measurer.Measure(text, fontFamily, fontsize);
// Converged: size stopped moving, or the extents repeated.
if (lastFontsize == fontsize || (lastExtents is { } prev && prev == extents))
return Math.Max(fontsize, MinFontSize);
if (extents.Width > width || extents.Height > height)
{
// Overflow: shrink the growth factor and retry from the last good size.
adjust = (adjust - 1) * 0.5 + 1;
fontsize = Math.Truncate(lastFontsize * adjust);
// lastExtents / lastFontsize unchanged — they hold the last fit.
}
else
{
// Fits: remember it and keep growing by the current factor.
lastExtents = extents;
lastFontsize = fontsize;
fontsize = Math.Truncate(fontsize * adjust);
}
}
return Math.Max(fontsize, MinFontSize);
}
}
@@ -0,0 +1,118 @@
using System.Text;
namespace RioJoy.Core.Overlay;
/// <summary>
/// Imports the legacy "Goobie" label data file — the Google-Sheet export consumed
/// by sg-goobie-MFD.scm (see docs/reference/customBackground/TEST.data). The format
/// is Lisp-ish: <c>;</c> starts a comment to end of line, a parenthesized list of
/// bare field names (matching the template layer/region names) comes first, then
/// one parenthesized list of quoted strings per output image (row). Field 0 is the
/// row's file tag (output name); the rest are the per-region label text.
///
/// <para>Each row maps directly to an <see cref="OverlayLayoutEngine"/> label set
/// (region name → text), so this is the bridge from the old sheet to a profile's
/// <see cref="Profiles.RioProfile.OverlayLabels"/>.</para>
/// </summary>
public static class GoobieDataImporter
{
/// <summary>A parsed data file: the field names and one label map per row.</summary>
public sealed record Sheet(
IReadOnlyList<string> Fields,
IReadOnlyList<IReadOnlyDictionary<string, string>> Rows);
public static Sheet Load(string path) => Parse(File.ReadAllText(path));
/// <summary>Parse the data file text into fields + rows.</summary>
public static Sheet Parse(string text)
{
ArgumentNullException.ThrowIfNull(text);
List<List<string>> lists = ReadLists(text);
if (lists.Count == 0)
throw new FormatException("No lists found; expected a header list of field names.");
List<string> fields = lists[0];
var rows = new List<IReadOnlyDictionary<string, string>>();
for (int i = 1; i < lists.Count; i++)
{
List<string> values = lists[i];
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int f = 0; f < fields.Count && f < values.Count; f++)
map[fields[f]] = values[f];
rows.Add(map);
}
return new Sheet(fields, rows);
}
/// <summary>
/// Convenience: the label map for a single row (default the first), with empty
/// values dropped — ready to hand to <see cref="OverlayLayoutEngine.Layout"/>.
/// </summary>
public static IReadOnlyDictionary<string, string> LabelsForRow(Sheet sheet, int rowIndex = 0)
{
ArgumentNullException.ThrowIfNull(sheet);
if (rowIndex < 0 || rowIndex >= sheet.Rows.Count)
throw new ArgumentOutOfRangeException(nameof(rowIndex));
return sheet.Rows[rowIndex]
.Where(kv => !string.IsNullOrEmpty(kv.Value))
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
}
// Tokenize into parenthesized lists. Barewords and "quoted strings" are both
// returned as their text (quotes stripped); ';' comments run to end of line.
private static List<List<string>> ReadLists(string s)
{
var lists = new List<List<string>>();
List<string>? current = null;
int i = 0;
while (i < s.Length)
{
char c = s[i];
if (c == ';')
{
while (i < s.Length && s[i] != '\n') i++;
}
else if (char.IsWhiteSpace(c))
{
i++;
}
else if (c == '(')
{
current = new List<string>();
i++;
}
else if (c == ')')
{
if (current is not null) { lists.Add(current); current = null; }
i++;
}
else if (c == '"')
{
i++;
var sb = new StringBuilder();
while (i < s.Length && s[i] != '"')
{
if (s[i] == '\\' && i + 1 < s.Length) i++; // unescape \" and \\
sb.Append(s[i++]);
}
i++; // closing quote
current?.Add(sb.ToString());
}
else
{
int start = i;
while (i < s.Length && !char.IsWhiteSpace(s[i]) && s[i] is not ('(' or ')' or '"' or ';'))
i++;
current?.Add(s[start..i]);
}
}
return lists;
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace RioJoy.Core.Overlay;
/// <summary>The pixel size of a rendered string (its bounding box).</summary>
public readonly record struct TextExtent(double Width, double Height);
/// <summary>
/// Measures rendered text. Abstracts the rasterizer (SkiaSharp, GDI, …) so the
/// overlay layout math (<see cref="FontFitter"/>, <see cref="OverlayLayoutEngine"/>)
/// stays pure and unit-testable against a fake measurer — the same split the rest
/// of the codebase uses for output sinks. The legacy pipeline measured with GIMP's
/// <c>gimp-text-get-extents-fontname</c> (see docs/reference/customBackground/sg-goobie-MFD.scm).
/// </summary>
public interface ITextMeasurer
{
/// <summary>Bounding-box size of <paramref name="text"/> at the given font/size, in pixels.</summary>
TextExtent Measure(string text, string fontFamily, double fontSizePx);
}
@@ -0,0 +1,97 @@
namespace RioJoy.Core.Overlay;
/// <summary>
/// Lays out per-region label text over a cockpit template into positioned
/// <see cref="PlacedLabel"/>s ready for a rasterizer. Pure: all measurement goes
/// through an <see cref="ITextMeasurer"/>, so the placement/auto-fit math is
/// unit-tested without a real font. Ports the per-field logic of
/// <c>create-data-layer</c> (sg-goobie-MFD.scm, lines 69113): optional auto-fit
/// when the text overflows, then vertical (and here also horizontal) justification.
/// </summary>
public sealed class OverlayLayoutEngine
{
private readonly ITextMeasurer _measurer;
public OverlayLayoutEngine(ITextMeasurer measurer) =>
_measurer = measurer ?? throw new ArgumentNullException(nameof(measurer));
/// <summary>
/// Lay out every region that has non-empty label text. Regions without an entry
/// in <paramref name="labels"/> (or with empty text) are skipped — matching the
/// legacy behavior where a blank field produced no visible text.
/// </summary>
public IReadOnlyList<PlacedLabel> Layout(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
OverlayLayoutOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentNullException.ThrowIfNull(labels);
options ??= new OverlayLayoutOptions();
var placed = new List<PlacedLabel>(template.Regions.Count);
foreach (OverlayRegion region in template.Regions)
{
if (labels.TryGetValue(region.Name, out string? text) && !string.IsNullOrEmpty(text))
placed.Add(Place(region, text, options));
}
return placed;
}
/// <summary>Lay out a single label within a region.</summary>
public PlacedLabel Place(OverlayRegion region, string text, OverlayLayoutOptions? options = null)
{
ArgumentNullException.ThrowIfNull(region);
ArgumentException.ThrowIfNullOrEmpty(text);
options ??= new OverlayLayoutOptions();
double rotation = region.RotationDegrees ?? 0;
// A quarter turn swaps the axes the text is fitted against: a 90°/270° label
// runs its length along the cell's height and its line-height along the width.
bool quarterTurn = (int)Math.Abs(rotation) % 180 == 90;
double fitWidth = quarterTurn ? region.Height : region.Width;
double fitHeight = quarterTurn ? region.Width : region.Height;
double size = region.FontSizePx;
TextExtent ext = _measurer.Measure(text, region.FontFamily, size);
// Auto-fit only in Fit mode and only when the base size actually overflows
// (legacy: the calc-fontsize call is guarded by size-handling == 0 && overflow).
if (options.SizeMode == OverlaySizeMode.Fit && (ext.Width > fitWidth || ext.Height > fitHeight))
{
size = FontFitter.BestFitSize(text, region.FontFamily, fitWidth, fitHeight, _measurer);
ext = _measurer.Measure(text, region.FontFamily, size);
}
if (rotation != 0)
{
// Center the (unrotated) text box in the cell; the renderer turns it about
// the cell center, so a centered box rotates in place. Justification N/A.
double cxLeft = region.X + (region.Width - ext.Width) / 2;
double cyTop = region.Y + (region.Height - ext.Height) / 2;
return new PlacedLabel(region.Name, text, cxLeft, cyTop, ext.Width, ext.Height, region.FontFamily, size, rotation);
}
OverlayHJust hjust = region.HJust ?? options.HJust;
OverlayVJust vjust = region.VJust ?? options.VJust;
// Vertical placement is a direct port of the script; horizontal is the
// generalization of its left-anchored behavior (its header comment intends
// "centered within the bounds", so Center is the engine default).
double x = hjust switch
{
OverlayHJust.Center => region.X + (region.Width - ext.Width) / 2,
OverlayHJust.Right => region.X + (region.Width - ext.Width),
_ => region.X,
};
double y = vjust switch
{
OverlayVJust.Center => region.Y + (region.Height - ext.Height) / 2,
OverlayVJust.Bottom => region.Y + region.Height - ext.Height,
_ => region.Y,
};
return new PlacedLabel(region.Name, text, x, y, ext.Width, ext.Height, region.FontFamily, size);
}
}
+115
View File
@@ -0,0 +1,115 @@
namespace RioJoy.Core.Overlay;
/// <summary>Horizontal placement of a label within its region.</summary>
public enum OverlayHJust { Left, Center, Right }
/// <summary>Vertical placement of a label within its region (legacy "Vertical justification").</summary>
public enum OverlayVJust { Top, Center, Bottom }
/// <summary>
/// What to do when a label's text does not fit its region at the base font size.
/// Mirrors the legacy script's "Font sizing (if too large)" option
/// (sg-goobie-MFD.scm): <see cref="Fit"/> shrinks the font to fit (auto-fit),
/// <see cref="Crop"/> keeps the size and clips to the region, <see cref="Overflow"/>
/// keeps the size and lets the text spill outside the region.
/// </summary>
public enum OverlaySizeMode { Fit, Crop, Overflow }
/// <summary>
/// One labelled cell on a cockpit template: the rectangle (and font) a label is
/// laid out within. Equivalent to a named text layer in the legacy GIMP template
/// (layer name = <see cref="Name"/>, e.g. a RIO address tag like <c>b-00</c>); the
/// layer's offsets/size/font gave the cell geometry. Geometry is shared by all
/// profiles for a given cockpit image; only the label <em>text</em> is per-profile.
/// </summary>
public sealed class OverlayRegion
{
/// <summary>Region key — matches the template layer name and the profile's label key.</summary>
public string Name { get; set; } = "";
/// <summary>Cell left edge, in template pixels.</summary>
public double X { get; set; }
/// <summary>Cell top edge, in template pixels.</summary>
public double Y { get; set; }
/// <summary>Cell width, in template pixels.</summary>
public double Width { get; set; }
/// <summary>Cell height, in template pixels.</summary>
public double Height { get; set; }
/// <summary>Font family for this cell's text.</summary>
public string FontFamily { get; set; } = "Sans";
/// <summary>Base font size (px) from the template; the starting point before any auto-fit.</summary>
public double FontSizePx { get; set; } = 12;
/// <summary>Text color as <c>#RRGGBB</c>/<c>#AARRGGBB</c>; null = renderer default.</summary>
public string? ColorHex { get; set; }
/// <summary>Per-region horizontal justification override; null = use the engine default.</summary>
public OverlayHJust? HJust { get; set; }
/// <summary>Per-region vertical justification override; null = use the engine default.</summary>
public OverlayVJust? VJust { get; set; }
/// <summary>
/// Counter-clockwise rotation of the label in degrees; null/0 = upright. Used
/// for cockpit cells whose labels read sideways (e.g. the vertically-oriented
/// button column). Rotated labels are centered in the cell (justification N/A).
/// </summary>
public double? RotationDegrees { get; set; }
}
/// <summary>
/// A cockpit overlay template: the base image plus the labelled regions, extracted
/// once from the source artwork (docs/reference/customBackground/riojoy.xcf) into a
/// <c>regions.json</c>. Persisted by <see cref="OverlayTemplateStore"/>.
/// </summary>
public sealed class OverlayTemplate
{
/// <summary>Path to the base cockpit image the labels are drawn over.</summary>
public string? BaseImagePath { get; set; }
/// <summary>Base image width in pixels (the output wallpaper size).</summary>
public int Width { get; set; }
/// <summary>Base image height in pixels.</summary>
public int Height { get; set; }
/// <summary>The labelled cells.</summary>
public List<OverlayRegion> Regions { get; set; } = new();
/// <summary>Find a region by name (case-insensitive), or null.</summary>
public OverlayRegion? FindRegion(string name) =>
Regions.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// A laid-out label ready to rasterize: the resolved text, top-left position,
/// measured size, and the (possibly auto-fitted) font. The renderer just draws it.
/// </summary>
public readonly record struct PlacedLabel(
string RegionName,
string Text,
double X,
double Y,
double Width,
double Height,
string FontFamily,
double FontSizePx,
double RotationDegrees = 0);
/// <summary>Engine-wide defaults for a layout pass; per-region overrides win.</summary>
public sealed class OverlayLayoutOptions
{
/// <summary>How to handle text that overflows its region at the base size.</summary>
public OverlaySizeMode SizeMode { get; set; } = OverlaySizeMode.Fit;
/// <summary>Default horizontal justification (regions may override).</summary>
public OverlayHJust HJust { get; set; } = OverlayHJust.Center;
/// <summary>Default vertical justification (regions may override).</summary>
public OverlayVJust VJust { get; set; } = OverlayVJust.Center;
}
@@ -0,0 +1,48 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace RioJoy.Core.Overlay;
/// <summary>
/// Loads and saves an <see cref="OverlayTemplate"/> as a <c>regions.json</c> — the
/// cockpit cell geometry extracted from the source artwork
/// (docs/reference/customBackground/riojoy.xcf). Same JSON conventions as
/// <see cref="Profiles.ConfigStore"/> (indented, enums as strings, null-skipping).
/// </summary>
public static class OverlayTemplateStore
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
public static string Serialize(OverlayTemplate template)
{
ArgumentNullException.ThrowIfNull(template);
return JsonSerializer.Serialize(template, Options);
}
public static OverlayTemplate Deserialize(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
return JsonSerializer.Deserialize<OverlayTemplate>(json, Options)
?? throw new JsonException("Overlay template JSON deserialized to null.");
}
public static void Save(OverlayTemplate template, string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
string? dir = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(path, Serialize(template));
}
public static OverlayTemplate Load(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
return Deserialize(File.ReadAllText(path));
}
}
+7
View File
@@ -32,6 +32,13 @@ public sealed class AppConfig
/// <summary>Start RIOJoy with Windows.</summary> /// <summary>Start RIOJoy with Windows.</summary>
public bool AutoStart { get; set; } public bool AutoStart { get; set; }
/// <summary>
/// Path to the cockpit overlay template (<c>regions.json</c>) used to generate
/// per-profile wallpapers (Phase 7). Null disables wallpaper generation. The
/// template's base image is resolved relative to this file's folder.
/// </summary>
public string? OverlayTemplatePath { get; set; }
/// <summary>Find a profile by name (case-insensitive), or null.</summary> /// <summary>Find a profile by name (case-insensitive), or null.</summary>
public RioProfile? FindProfile(string? name) => public RioProfile? FindProfile(string? name) =>
name is null name is null
+8
View File
@@ -35,6 +35,14 @@ public sealed class RioProfile
/// <summary>Cockpit wallpaper image path (generated in Phase 7).</summary> /// <summary>Cockpit wallpaper image path (generated in Phase 7).</summary>
public string? WallpaperPath { get; set; } public string? WallpaperPath { get; set; }
/// <summary>
/// Overlay label text per template region (region/layer name → display text,
/// e.g. <c>"b-00" → "FIRE"</c>). Drives the Phase 7 wallpaper generator
/// (<see cref="Overlay.OverlayLayoutEngine"/>); empty/absent entries render
/// nothing. The region geometry itself lives in the shared overlay template.
/// </summary>
public Dictionary<string, string> OverlayLabels { get; set; } = new();
/// <summary> /// <summary>
/// Executable names that select this profile when they are the foreground app /// Executable names that select this profile when they are the foreground app
/// (matched case-insensitively, with or without ".exe"). /// (matched case-insensitively, with or without ".exe").
@@ -0,0 +1,56 @@
using RioJoy.Core.Overlay;
using SkiaSharp;
namespace RioJoy.Overlay;
/// <summary>
/// Generates a profile's cockpit wallpaper: renders a label set onto the overlay
/// template's base image and writes a PNG. Resolves the template's
/// <see cref="OverlayTemplate.BaseImagePath"/> relative to the template file's
/// directory (so a <c>regions.json</c> + sibling <c>riojoy.png</c> work as a unit).
/// The runtime calls this on profile activation; an editor can call it for preview.
/// </summary>
public sealed class ProfileWallpaperGenerator
{
private readonly SkiaOverlayRenderer _renderer;
public ProfileWallpaperGenerator(SkiaOverlayRenderer? renderer = null) =>
_renderer = renderer ?? new SkiaOverlayRenderer();
/// <summary>
/// Render <paramref name="labels"/> onto the template's base image and write a
/// PNG to <paramref name="outputPath"/>; returns the output path.
/// </summary>
/// <param name="templateDirectory">
/// Directory the template's relative <see cref="OverlayTemplate.BaseImagePath"/>
/// is resolved against (normally the folder holding the regions.json).
/// </param>
public string Generate(
OverlayTemplate template,
string templateDirectory,
IReadOnlyDictionary<string, string> labels,
string outputPath,
OverlayLayoutOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentNullException.ThrowIfNull(labels);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
if (string.IsNullOrWhiteSpace(template.BaseImagePath))
throw new InvalidOperationException("OverlayTemplate.BaseImagePath is not set.");
string basePath = Path.IsPathRooted(template.BaseImagePath)
? template.BaseImagePath
: Path.Combine(templateDirectory, template.BaseImagePath);
using SKBitmap baseImage = SKBitmap.Decode(basePath)
?? throw new InvalidOperationException($"Could not decode base image: {basePath}");
byte[] png = _renderer.RenderToPng(template, labels, baseImage, options);
string? dir = Path.GetDirectoryName(Path.GetFullPath(outputPath));
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
File.WriteAllBytes(outputPath, png);
return outputPath;
}
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<Platforms>x64</Platforms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<!-- SkiaSharp: cross-platform 2D rasterizer used to render the wallpaper. -->
<PackageReference Include="SkiaSharp" Version="2.88.8" />
<PackageReference Include="SkiaSharp.NativeAssets.Win32" Version="2.88.8" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RioJoy.Core\RioJoy.Core.csproj" />
</ItemGroup>
</Project>
+132
View File
@@ -0,0 +1,132 @@
using RioJoy.Core.Overlay;
using SkiaSharp;
namespace RioJoy.Overlay;
/// <summary>
/// Renders a cockpit wallpaper: lays out per-region label text with
/// <see cref="OverlayLayoutEngine"/>, then draws each label onto the base image
/// with SkiaSharp. The modern replacement for the legacy GIMP/Script-Fu pipeline
/// (docs/reference/customBackground/sg-goobie-MFD.scm). Because it shares its
/// <see cref="SkiaTextMeasurer"/> with the layout engine, drawn positions match the
/// measured extents exactly.
/// </summary>
public sealed class SkiaOverlayRenderer
{
private readonly SkiaTextMeasurer _measurer;
private readonly OverlayLayoutEngine _engine;
private readonly SKColor _defaultColor;
/// <param name="defaultColorHex">Label color when a region sets none (default white).</param>
public SkiaOverlayRenderer(string defaultColorHex = "#FFFFFF")
{
_measurer = new SkiaTextMeasurer();
_engine = new OverlayLayoutEngine(_measurer);
_defaultColor = SKColor.Parse(defaultColorHex);
}
/// <summary>
/// Render labels onto <paramref name="baseImage"/> and return the result as a
/// new bitmap (the input is not modified). Caller owns the returned bitmap.
/// </summary>
public SKBitmap Render(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
SKBitmap baseImage,
OverlayLayoutOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentNullException.ThrowIfNull(labels);
ArgumentNullException.ThrowIfNull(baseImage);
options ??= new OverlayLayoutOptions();
var output = baseImage.Copy();
using var canvas = new SKCanvas(output);
foreach (PlacedLabel label in _engine.Layout(template, labels, options))
{
OverlayRegion? region = template.FindRegion(label.RegionName);
SKColor color = region?.ColorHex is { } hex ? SKColor.Parse(hex) : _defaultColor;
using var paint = new SKPaint
{
Typeface = _measurer.Typeface(label.FontFamily),
TextSize = (float)label.FontSizePx,
Color = color,
IsAntialias = true,
};
// PlacedLabel.Y is the top of the text box; Skia draws from the baseline.
float baseline = (float)label.Y - paint.FontMetrics.Ascent; // ascent is negative
// Rotated labels: turn the canvas about the cell center (the engine
// centered the text box there). RotationDegrees is counter-clockwise;
// Skia's RotateDegrees is clockwise-positive, so negate.
if (label.RotationDegrees != 0 && region is not null)
{
float cx = (float)(region.X + region.Width / 2);
float cy = (float)(region.Y + region.Height / 2);
canvas.Save();
canvas.RotateDegrees(-(float)label.RotationDegrees, cx, cy);
canvas.DrawText(label.Text, (float)label.X, baseline, paint);
canvas.Restore();
continue;
}
bool clip = options.SizeMode == OverlaySizeMode.Crop && region is not null;
if (clip)
{
canvas.Save();
canvas.ClipRect(new SKRect(
(float)region!.X, (float)region.Y,
(float)(region.X + region.Width), (float)(region.Y + region.Height)));
}
canvas.DrawText(label.Text, (float)label.X, baseline, paint);
if (clip)
canvas.Restore();
}
canvas.Flush();
return output;
}
/// <summary>Render and encode to PNG bytes.</summary>
public byte[] RenderToPng(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
SKBitmap baseImage,
OverlayLayoutOptions? options = null)
{
using SKBitmap bmp = Render(template, labels, baseImage, options);
using SKData data = bmp.Encode(SKEncodedImageFormat.Png, 100);
return data.ToArray();
}
/// <summary>
/// Render using the template's <see cref="OverlayTemplate.BaseImagePath"/> and
/// write a PNG to <paramref name="outputPath"/>.
/// </summary>
public void RenderToFile(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
string outputPath,
OverlayLayoutOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
if (string.IsNullOrWhiteSpace(template.BaseImagePath))
throw new InvalidOperationException("OverlayTemplate.BaseImagePath is not set.");
using SKBitmap baseImage = SKBitmap.Decode(template.BaseImagePath)
?? throw new InvalidOperationException($"Could not decode base image: {template.BaseImagePath}");
byte[] png = RenderToPng(template, labels, baseImage, options);
string? dir = Path.GetDirectoryName(Path.GetFullPath(outputPath));
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
File.WriteAllBytes(outputPath, png);
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using RioJoy.Core.Overlay;
using SkiaSharp;
namespace RioJoy.Overlay;
/// <summary>
/// <see cref="ITextMeasurer"/> backed by SkiaSharp — the real metrics the
/// <see cref="SkiaOverlayRenderer"/> draws with, so measured layout and rendered
/// output agree. Width is the glyph advance; height is the logical line height
/// (descent ascent), which keeps vertical centering stable across labels
/// regardless of which glyphs each one happens to contain.
/// </summary>
public sealed class SkiaTextMeasurer : ITextMeasurer
{
private readonly ConcurrentDictionary<string, SKTypeface> _typefaces = new();
internal SKTypeface Typeface(string fontFamily) =>
_typefaces.GetOrAdd(fontFamily, f => SKTypeface.FromFamilyName(f) ?? SKTypeface.Default);
public TextExtent Measure(string text, string fontFamily, double fontSizePx)
{
using var paint = new SKPaint
{
Typeface = Typeface(fontFamily),
TextSize = (float)fontSizePx,
IsAntialias = true,
};
double width = paint.MeasureText(text);
SKFontMetrics m = paint.FontMetrics;
double height = m.Descent - m.Ascent; // ascent is negative
return new TextExtent(width, height);
}
}
+255
View File
@@ -0,0 +1,255 @@
using System.Runtime.Versioning;
using RioJoy.Core.Editing;
using RioJoy.Core.Hid;
using RioJoy.Core.Mapping;
using RioJoy.Core.Profiles;
namespace RioJoy.Tray.Editor;
/// <summary>
/// Profile editor: shows the cockpit authoring sheet (its logical layout, from
/// <see cref="SheetLayout"/>) as a scrollable, clickable grid and edits the
/// selected address cell's label and action/modifiers/lamp. Writes back to the
/// <see cref="RioProfile"/>'s <see cref="RioProfile.OverlayLabels"/> (keyed by the
/// wallpaper region <c>b-XX</c>) and <see cref="RioProfile.Buttons"/> (the iRIO
/// word); <c>Save</c> raises <see cref="Saved"/>.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class ProfileEditorForm : Form
{
private readonly RioProfile _profile;
private readonly IReadOnlyList<SheetCell> _cells;
private readonly SheetCanvas _canvas = new();
private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 12), MaximumSize = new Size(310, 0) };
private readonly TextBox _labelBox = new() { Location = new Point(70, 44), Width = 230 };
private readonly ComboBox _kindBox = new() { Location = new Point(70, 78), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly Label _valueLabel = new() { Text = "Key:", Location = new Point(12, 115), AutoSize = true };
private readonly ComboBox _valueCombo = new() { Location = new Point(70, 112), Width = 230, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly CheckBox _shift = new() { Text = "Shift", Location = new Point(70, 144), AutoSize = true };
private readonly CheckBox _ctrl = new() { Text = "Ctrl", Location = new Point(140, 144), AutoSize = true };
private readonly CheckBox _alt = new() { Text = "Alt", Location = new Point(200, 144), AutoSize = true };
private readonly CheckBox _ext = new() { Text = "Ext", Location = new Point(250, 144), AutoSize = true };
private readonly CheckBox _lit = new() { Text = "Lit", Location = new Point(70, 172), AutoSize = true };
private readonly Button _apply = new() { Text = "Apply to cell", Location = new Point(70, 206), Width = 110 };
private readonly Button _save = new() { Text = "Save profile", Location = new Point(70, 240), Width = 110 };
private readonly Button _close = new() { Text = "Close", Location = new Point(190, 240), Width = 80 };
private int? _address;
private bool _loading;
/// <summary>A pick-list entry: what the user sees and the byte it maps to.</summary>
private sealed record ValueItem(string Display, byte Value)
{
public override string ToString() => Display;
}
/// <summary>Raised when the user saves; the argument is the edited profile.</summary>
public event Action<RioProfile>? Saved;
public ProfileEditorForm(IReadOnlyList<SheetCell> cells, RioProfile profile)
{
_cells = cells ?? throw new ArgumentNullException(nameof(cells));
_profile = profile ?? throw new ArgumentNullException(nameof(profile));
Text = $"RIOJoy — Edit profile: {profile.Name}";
ClientSize = new Size(1500, 820);
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(900, 500);
_kindBox.Items.AddRange(Enum.GetNames<RioRouteKind>());
_kindBox.SelectedIndexChanged += OnKindChanged;
_canvas.SetCells(cells);
_canvas.AddressSelected += OnAddressSelected;
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
scroller.Controls.Add(_canvas);
Panel editPanel = BuildEditPanel();
Controls.Add(scroller);
Controls.Add(editPanel);
_apply.Click += (_, _) => ApplyToCell();
_save.Click += (_, _) => { ApplyToCell(); Saved?.Invoke(_profile); };
_close.Click += (_, _) => Close();
SetEditingEnabled(false);
_info.Text = "Click a coloured address cell to edit it.";
}
/// <summary>The selected RIO address (for tests / offline rendering).</summary>
public int? SelectedAddress => _address;
private Panel BuildEditPanel()
{
var panel = new Panel { Dock = DockStyle.Right, Width = 330, Padding = new Padding(8), BorderStyle = BorderStyle.FixedSingle };
panel.Controls.Add(_info);
panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 47), AutoSize = true });
panel.Controls.Add(_labelBox);
panel.Controls.Add(new Label { Text = "Action:", Location = new Point(12, 81), AutoSize = true });
panel.Controls.Add(_kindBox);
panel.Controls.Add(_valueLabel);
panel.Controls.Add(_valueCombo);
panel.Controls.AddRange(new Control[] { _shift, _ctrl, _alt, _ext, _lit, _apply, _save, _close });
return panel;
}
private void OnAddressSelected(SheetCell cell)
{
if (cell.Address is not { } addr) { SetEditingEnabled(false); return; }
_address = addr;
string region = RegionFor(addr);
_info.Text = $"Cell {cell.Text} Address 0x{addr:X2} {cell.Section} (wallpaper {region})";
_labelBox.Text = _profile.OverlayLabels.TryGetValue(region, out string? label) ? label : string.Empty;
ButtonBinding b = ButtonBinding.FromWord(
_profile.Buttons.TryGetValue(addr, out int w) ? (ushort)w : (ushort)0);
_loading = true;
_kindBox.SelectedItem = b.Kind.ToString();
PopulateValues(b.Kind);
SelectValue(b.Value);
_shift.Checked = b.Shift;
_ctrl.Checked = b.Ctrl;
_alt.Checked = b.Alt;
_ext.Checked = b.Extended;
_lit.Checked = b.IsLit;
_loading = false;
SetEditingEnabled(true);
}
private void OnKindChanged(object? sender, EventArgs e)
{
if (_loading) return;
PopulateValues(SelectedKind());
if (_valueCombo.Items.Count > 0) _valueCombo.SelectedIndex = 0;
UpdateModifierAvailability();
}
// Fill the value picker with friendly choices for the chosen routing kind.
private void PopulateValues(RioRouteKind kind)
{
_valueLabel.Text = kind switch
{
RioRouteKind.Keyboard => "Key:",
RioRouteKind.Joystick => "Button:",
RioRouteKind.Hat => "Direction:",
RioRouteKind.Mouse => "Action:",
RioRouteKind.RioCommand => "Command:",
_ => "Value:",
};
_valueCombo.BeginUpdate();
_valueCombo.Items.Clear();
switch (kind)
{
case RioRouteKind.Keyboard:
foreach (KeyName k in KeyCatalog.Entries)
_valueCombo.Items.Add(new ValueItem(k.Name, k.Value));
break;
case RioRouteKind.Joystick:
for (int btn = 1; btn <= RioHidReport.ButtonCount; btn++)
_valueCombo.Items.Add(new ValueItem($"Button {btn}", (byte)btn));
break;
case RioRouteKind.Hat:
foreach (RioHat h in new[] { RioHat.Up, RioHat.Right, RioHat.Down, RioHat.Left })
_valueCombo.Items.Add(new ValueItem(h.ToString(), (byte)(int)h));
break;
case RioRouteKind.Mouse:
foreach (MouseAction m in Enum.GetValues<MouseAction>())
_valueCombo.Items.Add(new ValueItem(m.ToString(), (byte)m));
break;
case RioRouteKind.RioCommand:
foreach (RioCommandCode c in Enum.GetValues<RioCommandCode>())
_valueCombo.Items.Add(new ValueItem(c.ToString(), (byte)c));
break;
}
_valueCombo.EndUpdate();
UpdateModifierAvailability();
}
private void SelectValue(byte value)
{
for (int i = 0; i < _valueCombo.Items.Count; i++)
{
if (_valueCombo.Items[i] is ValueItem v && v.Value == value)
{
_valueCombo.SelectedIndex = i;
return;
}
}
// Not in the list (e.g. an uncatalogued VK): add a raw fallback entry.
int idx = _valueCombo.Items.Add(new ValueItem($"0x{value:X2}", value));
_valueCombo.SelectedIndex = idx;
}
// Modifiers/extended only make sense for keyboard actions.
private void UpdateModifierAvailability()
{
bool keyboard = SelectedKind() == RioRouteKind.Keyboard;
foreach (Control c in new Control[] { _shift, _ctrl, _alt, _ext })
c.Enabled = keyboard;
}
private RioRouteKind SelectedKind() =>
Enum.TryParse(_kindBox.SelectedItem?.ToString(), out RioRouteKind k) ? k : RioRouteKind.Keyboard;
private void ApplyToCell()
{
if (_address is not { } addr) return;
string region = RegionFor(addr);
string label = _labelBox.Text.Trim();
if (string.IsNullOrEmpty(label))
_profile.OverlayLabels.Remove(region);
else
_profile.OverlayLabels[region] = label;
RioRouteKind kind = SelectedKind();
bool keyboard = kind == RioRouteKind.Keyboard;
var binding = new ButtonBinding
{
Kind = kind,
Value = _valueCombo.SelectedItem is ValueItem vi ? vi.Value : (byte)0,
Shift = keyboard && _shift.Checked,
Ctrl = keyboard && _ctrl.Checked,
Alt = keyboard && _alt.Checked,
Extended = keyboard && _ext.Checked,
IsLit = _lit.Checked,
};
ushort word = binding.ToWord();
if (word == 0)
_profile.Buttons.Remove(addr);
else
_profile.Buttons[addr] = word;
}
private void SetEditingEnabled(bool enabled)
{
foreach (Control c in new Control[] { _labelBox, _kindBox, _valueCombo, _shift, _ctrl, _alt, _ext, _lit, _apply })
c.Enabled = enabled;
if (enabled) UpdateModifierAvailability();
}
private static string RegionFor(int address) => $"b-{address:X2}";
/// <summary>Select an address programmatically (for tests / offline preview).</summary>
public void SelectAddress(int address)
{
SheetCell? cell = _cells.FirstOrDefault(c => c.Address == address);
if (cell is not null)
{
_canvas.Select(address);
OnAddressSelected(cell);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Runtime.Versioning;
using RioJoy.Core.Editing;
namespace RioJoy.Tray.Editor;
/// <summary>
/// Scrollable cockpit-sheet grid: draws the cells (via <see cref="SheetView"/>) at a
/// fixed cell size and raises <see cref="AddressSelected"/> when an address cell is
/// clicked. Sized to the full grid so the host panel scrolls it.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class SheetCanvas : Control
{
private const int CellW = 62;
private const int CellH = 22;
private IReadOnlyList<SheetCell> _cells = Array.Empty<SheetCell>();
public SheetCanvas()
{
DoubleBuffered = true;
BackColor = Color.FromArgb(28, 28, 28);
}
/// <summary>The RIO address currently highlighted, or null.</summary>
public int? SelectedAddress { get; private set; }
public event Action<SheetCell>? AddressSelected;
public void SetCells(IReadOnlyList<SheetCell> cells)
{
_cells = cells;
Size = SheetView.GridSize(cells, CellW, CellH);
Invalidate();
}
public void Select(int? address)
{
SelectedAddress = address;
Invalidate();
}
protected override void OnPaint(PaintEventArgs e) =>
SheetView.Paint(e.Graphics, _cells, CellW, CellH, SelectedAddress);
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
SheetCell? hit = SheetView.HitTest(_cells, e.Location, CellW, CellH);
if (hit?.Address is { } addr)
{
SelectedAddress = addr;
AddressSelected?.Invoke(hit);
Invalidate();
}
}
}
+83
View File
@@ -0,0 +1,83 @@
using System.Runtime.Versioning;
using RioJoy.Core.Editing;
namespace RioJoy.Tray.Editor;
/// <summary>
/// Draws the cockpit authoring sheet as a grid of cells at their spreadsheet
/// (row, col) positions — the cockpit's logical layout. Address cells are filled by
/// section and clickable; scaffolding/label cells are drawn as context text. Shared
/// by <see cref="SheetCanvas"/> and offline bitmap rendering.
/// </summary>
[SupportedOSPlatform("windows")]
public static class SheetView
{
public static Color SectionColor(SheetSection section) => section switch
{
SheetSection.Contacts or SheetSection.Letters => Color.FromArgb(232, 150, 150), // red
SheetSection.Secondary or SheetSection.Screen => Color.FromArgb(245, 224, 150), // yellow
SheetSection.Throttle or SheetSection.Joystick => Color.FromArgb(150, 182, 226), // blue
SheetSection.Keypad or SheetSection.ExtKeypad => Color.FromArgb(165, 212, 165), // green
_ => Color.Gainsboro,
};
public static Size GridSize(IReadOnlyList<SheetCell> cells, int cellW, int cellH)
{
int maxCol = 0, maxRow = 0;
foreach (SheetCell c in cells)
{
if (c.Col > maxCol) maxCol = c.Col;
if (c.Row > maxRow) maxRow = c.Row;
}
return new Size((maxCol + 1) * cellW, (maxRow + 1) * cellH);
}
public static void Paint(
Graphics g,
IReadOnlyList<SheetCell> cells,
int cellW,
int cellH,
int? selectedAddress)
{
g.Clear(Color.FromArgb(28, 28, 28));
using var addrFont = new Font("Segoe UI", 7.5f, FontStyle.Bold);
using var textFont = new Font("Segoe UI", 7f);
using var border = new Pen(Color.FromArgb(70, 70, 70));
foreach (SheetCell cell in cells)
{
var r = new Rectangle(cell.Col * cellW, cell.Row * cellH, cellW, cellH);
if (cell.Address is { } addr)
{
using var fill = new SolidBrush(SectionColor(cell.Section));
g.FillRectangle(fill, r);
g.DrawRectangle(border, r);
TextRenderer.DrawText(g, cell.Text, addrFont, r, Color.Black,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPrefix);
if (addr == selectedAddress)
{
using var hi = new Pen(Color.Yellow, 2f);
g.DrawRectangle(hi, r.X + 1, r.Y + 1, r.Width - 2, r.Height - 2);
}
}
else
{
// Scaffolding / KEY / LABEL / section headers — context only.
TextRenderer.DrawText(g, cell.Text, textFont, r, Color.Silver,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
}
}
}
/// <summary>The address cell at a pixel point, or null.</summary>
public static SheetCell? HitTest(IReadOnlyList<SheetCell> cells, Point p, int cellW, int cellH)
{
int col = p.X / cellW;
int row = p.Y / cellH;
return cells.FirstOrDefault(c => c.Row == row && c.Col == col && c.Address is not null);
}
}
+43
View File
@@ -3,8 +3,10 @@ using RioJoy.Core;
using RioJoy.Core.Calibration; using RioJoy.Core.Calibration;
using RioJoy.Core.Mapping; using RioJoy.Core.Mapping;
using RioJoy.Core.Output; using RioJoy.Core.Output;
using RioJoy.Core.Overlay;
using RioJoy.Core.Profiles; using RioJoy.Core.Profiles;
using RioJoy.Core.Serial; using RioJoy.Core.Serial;
using RioJoy.Overlay;
namespace RioJoy.Tray; namespace RioJoy.Tray;
@@ -141,7 +143,48 @@ public sealed class RioCoordinator : IDisposable
{ {
Teardown(); Teardown();
SetStatus($"Error opening {port}: {ex.Message}"); SetStatus($"Error opening {port}: {ex.Message}");
return;
} }
ApplyWallpaper(profile);
}
/// <summary>
/// Generate the profile's cockpit wallpaper from the configured overlay template
/// and apply it. Best-effort and opt-in (only when <see cref="AppConfig.OverlayTemplatePath"/>
/// is set), so a missing template or render failure never breaks activation.
/// </summary>
private void ApplyWallpaper(RioProfile profile)
{
string? templatePath = _config().OverlayTemplatePath;
if (string.IsNullOrWhiteSpace(templatePath) || !File.Exists(templatePath))
return;
try
{
OverlayTemplate template = OverlayTemplateStore.Load(templatePath);
string templateDir = Path.GetDirectoryName(Path.GetFullPath(templatePath)) ?? ".";
string outDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"RIOJoy", "wallpapers");
string outPath = Path.Combine(outDir, SafeFileName(profile.Name) + ".png");
new ProfileWallpaperGenerator().Generate(template, templateDir, profile.OverlayLabels, outPath);
profile.WallpaperPath = outPath;
WallpaperApplier.Apply(outPath);
}
catch (Exception ex)
{
SetStatus($"{Status} [wallpaper: {ex.Message}]");
}
}
private static string SafeFileName(string name)
{
foreach (char c in Path.GetInvalidFileNameChars())
name = name.Replace(c, '_');
return name;
} }
private void GoDormant(string status) private void GoDormant(string status)
+1
View File
@@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\RioJoy.Core\RioJoy.Core.csproj" /> <ProjectReference Include="..\RioJoy.Core\RioJoy.Core.csproj" />
<ProjectReference Include="..\RioJoy.Overlay\RioJoy.Overlay.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+46
View File
@@ -1,6 +1,8 @@
using System.Runtime.Versioning; using System.Runtime.Versioning;
using RioJoy.Core.Editing;
using RioJoy.Core.Mapping; using RioJoy.Core.Mapping;
using RioJoy.Core.Profiles; using RioJoy.Core.Profiles;
using RioJoy.Tray.Editor;
using RioJoy.Tray.Os; using RioJoy.Tray.Os;
namespace RioJoy.Tray; namespace RioJoy.Tray;
@@ -67,6 +69,8 @@ internal sealed class TrayApplicationContext : ApplicationContext
RebuildProfileMenu(profileMenu); RebuildProfileMenu(profileMenu);
menu.Items.Add(profileMenu); menu.Items.Add(profileMenu);
menu.Items.Add("Edit profile…", null, (_, _) => OpenEditor());
// RIO commands mirroring the legacy console menu. // RIO commands mirroring the legacy console menu.
var commands = new ToolStripMenuItem("RIO commands"); var commands = new ToolStripMenuItem("RIO commands");
AddCommand(commands, "Reset all analog axes", RioCommandCode.ResetAllAxes); AddCommand(commands, "Reset all analog axes", RioCommandCode.ResetAllAxes);
@@ -129,6 +133,48 @@ internal sealed class TrayApplicationContext : ApplicationContext
profileMenu.DropDownItems.Add(dormant); profileMenu.DropDownItems.Add(dormant);
} }
private void OpenEditor()
{
// The cockpit sheet (config-sheet.csv) sits beside the overlay template.
string? templatePath = _config.OverlayTemplatePath;
string? sheetPath = string.IsNullOrWhiteSpace(templatePath)
? null
: Path.Combine(Path.GetDirectoryName(Path.GetFullPath(templatePath)) ?? ".", "config-sheet.csv");
if (sheetPath is null || !File.Exists(sheetPath))
{
MessageBox.Show(
"Set 'OverlayTemplatePath' in the config and place 'config-sheet.csv' beside it to use the editor.",
"RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
IReadOnlyList<SheetCell> cells;
try
{
cells = SheetLayout.Load(sheetPath);
}
catch (Exception ex)
{
MessageBox.Show($"Could not load the cockpit sheet:\n{ex.Message}", "RIOJoy",
MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
RioProfile profile = _config.Profiles.FirstOrDefault() ?? AddBlankProfile();
var editor = new ProfileEditorForm(cells, profile);
editor.Saved += _ => ConfigStore.Save(_config, ConfigPath);
editor.Show();
}
private RioProfile AddBlankProfile()
{
var profile = new RioProfile { Name = "New profile" };
_config.Profiles.Add(profile);
return profile;
}
private void AddCommand(ToolStripMenuItem parent, string text, RioCommandCode code) private void AddCommand(ToolStripMenuItem parent, string text, RioCommandCode code)
{ {
var item = new ToolStripMenuItem(text); var item = new ToolStripMenuItem(text);
+42
View File
@@ -0,0 +1,42 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace RioJoy.Tray;
/// <summary>
/// Sets the Windows desktop wallpaper to a generated cockpit overlay
/// (Phase 7 — the modern replacement for manually setting the GIMP-exported
/// background). Wraps <c>SystemParametersInfo(SPI_SETDESKWALLPAPER)</c>.
///
/// <para>Windows requires a <strong>BMP</strong> file path for reliable results on
/// all versions; PNGs are accepted on modern Windows but not guaranteed, so callers
/// should hand this a path the overlay renderer wrote. Applying a wallpaper changes
/// a user-visible system setting, so it is only invoked on an explicit profile
/// activation, never speculatively.</para>
/// </summary>
[SupportedOSPlatform("windows")]
public static class WallpaperApplier
{
private const int SPI_SETDESKWALLPAPER = 0x0014;
private const int SPIF_UPDATEINIFILE = 0x01; // persist across logon
private const int SPIF_SENDCHANGE = 0x02; // broadcast WM_SETTINGCHANGE
/// <summary>
/// Apply <paramref name="imagePath"/> as the desktop wallpaper. Returns true on
/// success. Throws if the file does not exist.
/// </summary>
public static bool Apply(string imagePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
string full = Path.GetFullPath(imagePath);
if (!File.Exists(full))
throw new FileNotFoundException("Wallpaper image not found.", full);
return SystemParametersInfo(
SPI_SETDESKWALLPAPER, 0, full, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE);
}
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SystemParametersInfo(int uAction, int uParam, string lpvParam, int fuWinIni);
}
@@ -0,0 +1,78 @@
using RioJoy.Core.Editing;
using Xunit;
namespace RioJoy.Core.Tests.Editing;
public class SheetLayoutTests
{
[Theory]
[InlineData("0F", 0x0F)]
[InlineData("50", 0x50)]
[InlineData("6F", 0x6F)]
[InlineData("00", 0x00)]
public void AddressFromText_ParsesHexCells(string text, int expected)
{
Assert.Equal(expected, SheetLayout.AddressFromText(text));
}
[Theory]
[InlineData("HOME")]
[InlineData("RIO_0")]
[InlineData("70")] // > 0x6F
[InlineData("M U")]
[InlineData("0")] // single digit
public void AddressFromText_RejectsNonAddresses(string text)
{
Assert.Null(SheetLayout.AddressFromText(text));
}
[Theory]
[InlineData(0x00, SheetSection.Contacts)]
[InlineData(0x10, SheetSection.Secondary)]
[InlineData(0x18, SheetSection.Screen)]
[InlineData(0x2F, SheetSection.Letters)]
[InlineData(0x38, SheetSection.Throttle)]
[InlineData(0x40, SheetSection.Joystick)]
[InlineData(0x50, SheetSection.Keypad)]
[InlineData(0x6F, SheetSection.ExtKeypad)]
public void SectionForAddress_Classifies(int address, SheetSection expected)
{
Assert.Equal(expected, SheetLayout.SectionForAddress(address));
}
[Fact]
public void Parse_BuildsCellsWithGridPositions()
{
const string csv =
"\"\",\"IsLit\",\"\",\"\",\"0F\"\n" +
"\"\",\"\",\"\",\"\",\"HOME\"";
IReadOnlyList<SheetCell> cells = SheetLayout.Parse(csv);
SheetCell addr = Assert.Single(cells, c => c.Text == "0F");
Assert.Equal(0x0F, addr.Address);
Assert.Equal(SheetSection.Contacts, addr.Section);
Assert.Equal(0, addr.Row);
Assert.Equal(4, addr.Col);
SheetCell key = Assert.Single(cells, c => c.Text == "HOME");
Assert.Null(key.Address);
Assert.Equal(1, key.Row);
Assert.Equal(4, key.Col);
}
[Fact]
public void Parse_RealSheet_HasFullAddressMap()
{
IReadOnlyList<SheetCell> cells = SheetLayout.Load(
TestRepo.CustomBackground("config-sheet.csv"));
// Every RIO address 0x00..0x6F that exists should appear exactly once as a cell.
var addresses = cells.Where(c => c.Address is not null).Select(c => c.Address!.Value).ToHashSet();
Assert.Contains(0x00, addresses);
Assert.Contains(0x2F, addresses); // a letter button
Assert.Contains(0x50, addresses); // keypad
Assert.Contains(0x6F, addresses); // ext keypad
Assert.True(addresses.Count >= 100, $"expected most of the address map, got {addresses.Count}");
}
}
@@ -0,0 +1,84 @@
using RioJoy.Core.Mapping;
using Xunit;
namespace RioJoy.Core.Tests.Mapping;
public class ButtonBindingTests
{
[Theory]
[InlineData(RioRouteKind.Keyboard, 0x47, 0x0047)]
[InlineData(RioRouteKind.Joystick, 0x58, 0x1058)]
[InlineData(RioRouteKind.Hat, 0x01, 0x2001)]
[InlineData(RioRouteKind.Mouse, 0x10, 0x4010)]
[InlineData(RioRouteKind.RioCommand, 0x60, 0x7060)]
public void Create_PacksRoutingFlagsAndValue(RioRouteKind kind, byte value, ushort expected)
{
RioMapEntry e = RioMapEntry.Create(kind, value);
Assert.Equal(expected, e.Raw);
Assert.Equal(kind, e.Kind);
Assert.Equal(value, e.Value);
}
[Fact]
public void Create_SetsHighByteBits()
{
RioMapEntry e = RioMapEntry.Create(
RioRouteKind.Keyboard, 0x41, lit: true, shift: true, ctrl: true, alt: true, extended: true);
Assert.True(e.HasLamp);
Assert.True(e.Shift);
Assert.True(e.Ctrl);
Assert.True(e.Alt);
Assert.True(e.Extended);
Assert.Equal(0x41, e.Value);
// 0x8000|0x0800|0x0400|0x0200|0x0100|0x41
Assert.Equal(0x8F41, e.Raw);
}
[Theory]
[InlineData(RioRouteKind.Keyboard)]
[InlineData(RioRouteKind.Joystick)]
[InlineData(RioRouteKind.Hat)]
[InlineData(RioRouteKind.Mouse)]
[InlineData(RioRouteKind.RioCommand)]
public void Binding_RoundTripsThroughWord(RioRouteKind kind)
{
var b = new ButtonBinding
{
Kind = kind,
Value = 0x2A,
IsLit = true,
Shift = true,
Alt = true,
};
ushort word = b.ToWord();
ButtonBinding back = ButtonBinding.FromWord(word);
Assert.Equal(b.Kind, back.Kind);
Assert.Equal(b.Value, back.Value);
Assert.Equal(b.IsLit, back.IsLit);
Assert.Equal(b.Shift, back.Shift);
Assert.Equal(b.Ctrl, back.Ctrl);
Assert.Equal(b.Alt, back.Alt);
Assert.Equal(b.Extended, back.Extended);
Assert.Equal(word, back.ToWord());
}
[Fact]
public void Binding_FromWord_DecodesLegacyValue()
{
// 0x8F41 from the encode test -> lit keyboard 'A' with all modifiers.
ButtonBinding b = ButtonBinding.FromWord(0x8F41);
Assert.Equal(RioRouteKind.Keyboard, b.Kind);
Assert.Equal(0x41, b.Value);
Assert.True(b.IsLit && b.Shift && b.Ctrl && b.Alt && b.Extended);
}
[Fact]
public void Binding_Default_IsUnmapped()
{
Assert.True(new ButtonBinding().IsUnmapped);
Assert.False(new ButtonBinding { Value = 0x41 }.IsUnmapped);
}
}
@@ -0,0 +1,42 @@
using RioJoy.Core.Mapping;
using Xunit;
namespace RioJoy.Core.Tests.Mapping;
public class KeyCatalogTests
{
[Theory]
[InlineData("A", 0x41)]
[InlineData("Z", 0x5A)]
[InlineData("0", 0x30)]
[InlineData("F1", 0x70)]
[InlineData("F12", 0x7B)]
[InlineData("Enter", 0x0D)]
[InlineData("Space", 0x20)]
[InlineData("Left", 0x25)]
[InlineData("NumPad 5", 0x65)]
public void NameAndValue_RoundTrip(string name, int vk)
{
Assert.True(KeyCatalog.TryGetValue(name, out byte v));
Assert.Equal((byte)vk, v);
Assert.Equal(name, KeyCatalog.NameFor((byte)vk));
}
[Fact]
public void NameFor_UnknownVk_ReturnsHex()
{
Assert.Equal("0xFF", KeyCatalog.NameFor(0xFF));
}
[Fact]
public void TryGetValue_Unknown_ReturnsFalse()
{
Assert.False(KeyCatalog.TryGetValue("NotAKey", out _));
}
[Fact]
public void Entries_AreUniqueByName()
{
Assert.Equal(KeyCatalog.Entries.Count, KeyCatalog.Entries.Select(e => e.Name).Distinct().Count());
}
}
@@ -0,0 +1,68 @@
using RioJoy.Core.Overlay;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
/// <summary>
/// Regression tests over the committed cockpit <c>regions.json</c> extracted from
/// riojoy.xcf by tools/XcfRegionExtract. Anchors the extracted geometry and proves
/// the asset loads through <see cref="OverlayTemplateStore"/>.
/// </summary>
public class CockpitRegionsTests
{
private static OverlayTemplate Load() =>
OverlayTemplateStore.Load(TestRepo.CustomBackground("regions.json"));
[Fact]
public void Loads_WithExpectedCanvasAndCount()
{
OverlayTemplate t = Load();
Assert.Equal(2720, t.Width);
Assert.Equal(600, t.Height);
Assert.Equal(119, t.Regions.Count);
}
[Fact]
public void HasExpectedButtonCell()
{
OverlayRegion? b00 = Load().FindRegion("b-00");
Assert.NotNull(b00);
Assert.Equal(2558, b00!.X, 6);
Assert.Equal(428, b00.Y, 6);
Assert.Equal(153, b00.Width, 6);
Assert.Equal(53, b00.Height, 6);
Assert.Equal(45, b00.FontSizePx, 6);
Assert.Equal("Sans", b00.FontFamily);
}
[Fact]
public void IncludesNamedControlLabels()
{
OverlayTemplate t = Load();
Assert.NotNull(t.FindRegion("Throttle"));
Assert.NotNull(t.FindRegion("Trigger"));
Assert.NotNull(t.FindRegion("HatUp"));
}
[Fact]
public void VerticalCells_AreTaggedCcw90()
{
OverlayTemplate t = Load();
Assert.Equal(90.0, t.FindRegion("a-00")!.RotationDegrees);
Assert.Equal(90.0, t.FindRegion("b-10")!.RotationDegrees);
Assert.Equal(90.0, t.FindRegion("b-1F")!.RotationDegrees);
Assert.Null(t.FindRegion("b-00")!.RotationDegrees); // a normal upright cell
}
[Fact]
public void AllRegionsLieWithinCanvas()
{
OverlayTemplate t = Load();
Assert.All(t.Regions, r =>
{
Assert.True(r.X >= 0 && r.Y >= 0, $"{r.Name} has negative offset");
Assert.True(r.X + r.Width <= t.Width, $"{r.Name} overflows width");
Assert.True(r.Y + r.Height <= t.Height, $"{r.Name} overflows height");
});
}
}
@@ -0,0 +1,75 @@
using RioJoy.Core.Overlay;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
public class FontFitterTests
{
// Brute-force oracle: the largest integer size whose text fits, mirroring the
// routine's truncate-to-integer behavior and MinFontSize floor.
private static double BruteForceBestFit(
string text, ITextMeasurer m, double width, double height, double cap = 1000)
{
double best = FontFitter.MinFontSize;
for (double s = FontFitter.MinFontSize; s <= cap; s++)
{
TextExtent e = m.Measure(text, "f", s);
if (e.Width <= width && e.Height <= height)
best = s;
}
return best;
}
[Theory]
[InlineData("FIRE", 100, 20)]
[InlineData("GEAR UP", 120, 24)]
[InlineData("X", 50, 50)]
[InlineData("LONGER LABEL TEXT", 80, 16)]
[InlineData("WW", 10, 40)] // width-bound, tiny cell
public void BestFitSize_MatchesBruteForceOracle(string text, double w, double h)
{
var m = new LinearTextMeasurer();
double fit = FontFitter.BestFitSize(text, "f", w, h, m);
double oracle = BruteForceBestFit(text, m, w, h);
Assert.Equal(oracle, fit);
}
[Fact]
public void BestFitSize_ResultActuallyFits()
{
var m = new LinearTextMeasurer();
double size = FontFitter.BestFitSize("THROTTLE", "f", 90, 18, m);
TextExtent e = m.Measure("THROTTLE", "f", size);
Assert.True(e.Width <= 90 && e.Height <= 18, $"size {size} -> {e.Width}x{e.Height} should fit 90x18");
}
[Fact]
public void BestFitSize_NeverBelowMinimum()
{
var m = new LinearTextMeasurer();
// A cell far too small for any text still yields the floor, not 0/negative.
double size = FontFitter.BestFitSize("IMPOSSIBLE", "f", 1, 1, m);
Assert.Equal(FontFitter.MinFontSize, size);
}
[Fact]
public void BestFitSize_EmptyText_Terminates()
{
// Empty text has zero width; the loop must still converge (via repeated
// extents) rather than grow forever.
double size = FontFitter.BestFitSize("", "f", 100, 100, new LinearTextMeasurer());
Assert.True(size >= FontFitter.MinFontSize);
}
[Fact]
public void BestFitSize_LargerCell_AllowsLargerFont()
{
var m = new LinearTextMeasurer();
double small = FontFitter.BestFitSize("ABC", "f", 60, 12, m);
double big = FontFitter.BestFitSize("ABC", "f", 600, 120, m);
Assert.True(big > small);
}
}
@@ -0,0 +1,57 @@
using RioJoy.Core.Overlay;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
public class GoobieDataImporterTests
{
private const string Sample = """
; a comment, ignored
( a-00 b-00 b-01 b-02 )
( "tag" "FIRE" "" "GEAR UP" )
""";
[Fact]
public void Parse_ReadsFieldsAndRow()
{
GoobieDataImporter.Sheet sheet = GoobieDataImporter.Parse(Sample);
Assert.Equal(new[] { "a-00", "b-00", "b-01", "b-02" }, sheet.Fields);
Assert.Single(sheet.Rows);
Assert.Equal("tag", sheet.Rows[0]["a-00"]);
Assert.Equal("FIRE", sheet.Rows[0]["b-00"]);
Assert.Equal("GEAR UP", sheet.Rows[0]["b-02"]); // quoted strings keep spaces
}
[Fact]
public void LabelsForRow_DropsEmptyValues()
{
IReadOnlyDictionary<string, string> labels =
GoobieDataImporter.LabelsForRow(GoobieDataImporter.Parse(Sample));
Assert.True(labels.ContainsKey("b-00"));
Assert.False(labels.ContainsKey("b-01")); // empty string dropped
Assert.Equal(3, labels.Count); // a-00, b-00, b-02
}
[Fact]
public void Parse_Field0_IsTheRowFileTag()
{
GoobieDataImporter.Sheet sheet = GoobieDataImporter.Parse(Sample);
Assert.Equal("a-00", sheet.Fields[0]);
Assert.Equal("tag", sheet.Rows[0][sheet.Fields[0]]);
}
[Fact]
public void Loads_RealTestDataFile()
{
GoobieDataImporter.Sheet sheet = GoobieDataImporter.Load(TestRepo.CustomBackground("TEST.data"));
Assert.Equal("a-00", sheet.Fields[0]);
Assert.NotEmpty(sheet.Rows);
// Header: ( a-00 b-00 b-01 ... ) Row0: ( "defalt" "LSDI" "ZOOM" ... )
Assert.Equal("defalt", sheet.Rows[0]["a-00"]);
Assert.Equal("LSDI", sheet.Rows[0]["b-00"]);
Assert.Equal("ZOOM", sheet.Rows[0]["b-01"]);
}
}
@@ -0,0 +1,23 @@
using RioJoy.Core.Overlay;
namespace RioJoy.Core.Tests.Overlay;
/// <summary>
/// Deterministic fake <see cref="ITextMeasurer"/> for layout tests: width grows
/// linearly with character count and font size, height with font size. Lets the
/// font-fit and placement math be asserted exactly without a real font/rasterizer.
/// </summary>
internal sealed class LinearTextMeasurer : ITextMeasurer
{
private readonly double _charWidthPerPx;
private readonly double _heightPerPx;
public LinearTextMeasurer(double charWidthPerPx = 0.6, double heightPerPx = 1.2)
{
_charWidthPerPx = charWidthPerPx;
_heightPerPx = heightPerPx;
}
public TextExtent Measure(string text, string fontFamily, double fontSizePx) =>
new(text.Length * fontSizePx * _charWidthPerPx, fontSizePx * _heightPerPx);
}
@@ -0,0 +1,143 @@
using RioJoy.Core.Overlay;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
public class OverlayLayoutEngineTests
{
// LinearTextMeasurer(0.6, 1.2): width = len*size*0.6, height = size*1.2.
private static readonly OverlayLayoutEngine Engine = new(new LinearTextMeasurer());
private static OverlayRegion Region(
string name = "b-00", double x = 0, double y = 0, double w = 200, double h = 40, double size = 10) =>
new() { Name = name, X = x, Y = y, Width = w, Height = h, FontSizePx = size, FontFamily = "Sans" };
[Fact]
public void Place_TextFits_KeepsBaseSize_AndCenters()
{
// "FIRE" at size 10 -> 24 x 12, well inside 200 x 40.
PlacedLabel p = Engine.Place(Region(x: 10, y: 20), "FIRE");
Assert.Equal(10, p.FontSizePx, 6);
Assert.Equal(24, p.Width, 6);
Assert.Equal(12, p.Height, 6);
Assert.Equal(10 + (200 - 24) / 2, p.X, 6); // 98
Assert.Equal(20 + (40 - 12) / 2, p.Y, 6); // 34
}
[Fact]
public void Place_Overflow_Fit_ShrinksFont()
{
// Narrow cell: "FIRE" at base 10 is 24 wide > 20 -> auto-fit shrinks it.
PlacedLabel p = Engine.Place(Region(w: 20, h: 40), "FIRE");
Assert.True(p.FontSizePx < 10);
Assert.True(p.Width <= 20, $"fitted width {p.Width} must be <= 20");
}
[Fact]
public void Place_Overflow_Crop_KeepsBaseSize()
{
var opts = new OverlayLayoutOptions { SizeMode = OverlaySizeMode.Crop };
PlacedLabel p = Engine.Place(Region(w: 20, h: 40), "FIRE", opts);
Assert.Equal(10, p.FontSizePx, 6); // not shrunk
Assert.Equal(24, p.Width, 6); // overflows the 20-wide cell
}
[Theory]
[InlineData(OverlayHJust.Left, 0)]
[InlineData(OverlayHJust.Center, 44)] // (100-12)/2
[InlineData(OverlayHJust.Right, 88)] // 100-12
public void Place_HorizontalJustification(OverlayHJust hjust, double expectedX)
{
// "AB" at size 10 -> 12 x 12 inside a 100 x 20 cell at origin.
var opts = new OverlayLayoutOptions { HJust = hjust };
PlacedLabel p = Engine.Place(Region(w: 100, h: 20), "AB", opts);
Assert.Equal(expectedX, p.X, 6);
}
[Theory]
[InlineData(OverlayVJust.Top, 0)]
[InlineData(OverlayVJust.Center, 4)] // (20-12)/2
[InlineData(OverlayVJust.Bottom, 8)] // 20-12
public void Place_VerticalJustification(OverlayVJust vjust, double expectedY)
{
var opts = new OverlayLayoutOptions { VJust = vjust };
PlacedLabel p = Engine.Place(Region(w: 100, h: 20), "AB", opts);
Assert.Equal(expectedY, p.Y, 6);
}
[Fact]
public void Place_RegionOverride_BeatsEngineDefault()
{
OverlayRegion region = Region(w: 100, h: 20);
region.HJust = OverlayHJust.Right;
region.VJust = OverlayVJust.Top;
var opts = new OverlayLayoutOptions { HJust = OverlayHJust.Left, VJust = OverlayVJust.Bottom };
PlacedLabel p = Engine.Place(region, "AB", opts);
Assert.Equal(88, p.X, 6); // Right wins over Left
Assert.Equal(0, p.Y, 6); // Top wins over Bottom
}
[Fact]
public void Place_Rotated_CentersInCell_AndCarriesAngle()
{
// "HELLO" at size 10 -> 30 x 12; fits the swapped 200 x 50 envelope, so size holds.
OverlayRegion region = Region(w: 50, h: 200, size: 10);
region.RotationDegrees = 90;
PlacedLabel p = Engine.Place(region, "HELLO");
Assert.Equal(90, p.RotationDegrees, 6);
Assert.Equal(10, p.FontSizePx, 6);
Assert.Equal((50 - 30) / 2.0, p.X, 6); // centered horizontally -> 10
Assert.Equal((200 - 12) / 2.0, p.Y, 6); // centered vertically -> 94
}
[Fact]
public void Place_Rotated_FitsAgainstSwappedDimensions()
{
// A wide, short cell: upright the text fits height-bound; rotated it must fit
// the (swapped) narrow height, forcing a smaller font.
OverlayRegion upright = Region(w: 200, h: 20, size: 30);
OverlayRegion rotated = Region(w: 200, h: 20, size: 30);
rotated.RotationDegrees = 90;
double uprightSize = Engine.Place(upright, "HELLO").FontSizePx;
double rotatedSize = Engine.Place(rotated, "HELLO").FontSizePx;
Assert.True(rotatedSize < uprightSize,
$"rotated {rotatedSize} should be smaller than upright {uprightSize} (fit uses swapped axes)");
}
[Fact]
public void Layout_SkipsEmptyAndMissingLabels_PreservesRegionOrder()
{
var template = new OverlayTemplate
{
Regions =
{
Region("b-00"),
Region("b-01"),
Region("b-02"),
},
};
var labels = new Dictionary<string, string>
{
["b-00"] = "FIRE",
["b-01"] = "", // empty -> skipped
["b-02"] = "GEAR",
["b-99"] = "ORPHAN", // no such region -> ignored
};
IReadOnlyList<PlacedLabel> placed = Engine.Layout(template, labels);
Assert.Equal(2, placed.Count);
Assert.Equal("b-00", placed[0].RegionName);
Assert.Equal("b-02", placed[1].RegionName);
Assert.Equal("GEAR", placed[1].Text);
}
}
@@ -0,0 +1,50 @@
using RioJoy.Core.Overlay;
using RioJoy.Overlay;
using SkiaSharp;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
/// <summary>
/// Full Phase-7 pipeline on the real cockpit assets: extracted regions.json + the
/// exported riojoy.png + the legacy TEST.data labels → a rendered wallpaper. Proves
/// the extract → import → lay out → rasterize chain end to end (headless).
/// </summary>
public class OverlayRenderIntegrationTests
{
private static bool RegionDiffers(SKBitmap a, SKBitmap b, OverlayRegion r)
{
int x0 = (int)r.X, y0 = (int)r.Y;
int x1 = Math.Min(a.Width, (int)(r.X + r.Width));
int y1 = Math.Min(a.Height, (int)(r.Y + r.Height));
for (int y = y0; y < y1; y++)
for (int x = x0; x < x1; x++)
if (a.GetPixel(x, y) != b.GetPixel(x, y))
return true;
return false;
}
[Fact]
public void RendersCockpitWallpaper_FromRealAssets()
{
OverlayTemplate template = OverlayTemplateStore.Load(TestRepo.CustomBackground("regions.json"));
Assert.False(string.IsNullOrWhiteSpace(template.BaseImagePath));
using SKBitmap baseImg = SKBitmap.Decode(TestRepo.CustomBackground(template.BaseImagePath!));
Assert.NotNull(baseImg);
Assert.Equal(template.Width, baseImg.Width);
Assert.Equal(template.Height, baseImg.Height);
IReadOnlyDictionary<string, string> labels =
GoobieDataImporter.LabelsForRow(GoobieDataImporter.Load(TestRepo.CustomBackground("TEST.data")));
using SKBitmap rendered = new SkiaOverlayRenderer().Render(template, labels, baseImg);
Assert.Equal(2720, rendered.Width);
Assert.Equal(600, rendered.Height);
// A labeled cell ("b-00" -> "LSDI") must have changed from the base.
OverlayRegion b00 = template.FindRegion("b-00")!;
Assert.True(RegionDiffers(baseImg, rendered, b00), "labelled region b-00 should differ from the base image");
}
}
@@ -0,0 +1,53 @@
using RioJoy.Core.Overlay;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
public class OverlayTemplateStoreTests
{
[Fact]
public void RoundTrips_GeometryAndOverrides()
{
var template = new OverlayTemplate
{
BaseImagePath = "cockpit.png",
Width = 1920,
Height = 1080,
Regions =
{
new OverlayRegion { Name = "b-00", X = 10, Y = 20, Width = 80, Height = 24, FontFamily = "Consolas", FontSizePx = 18 },
new OverlayRegion { Name = "b-41", X = 5, Y = 7, Width = 60, Height = 30, HJust = OverlayHJust.Right, VJust = OverlayVJust.Bottom },
},
};
OverlayTemplate back = OverlayTemplateStore.Deserialize(OverlayTemplateStore.Serialize(template));
Assert.Equal(1920, back.Width);
Assert.Equal(2, back.Regions.Count);
OverlayRegion? r0 = back.FindRegion("b-00");
Assert.NotNull(r0);
Assert.Equal(80, r0!.Width, 6);
Assert.Equal("Consolas", r0.FontFamily);
Assert.Null(r0.HJust);
OverlayRegion? r1 = back.FindRegion("B-41"); // case-insensitive lookup
Assert.NotNull(r1);
Assert.Equal(OverlayHJust.Right, r1!.HJust);
Assert.Equal(OverlayVJust.Bottom, r1.VJust);
}
[Fact]
public void Serialize_WritesEnumsAsStrings()
{
var template = new OverlayTemplate
{
Regions = { new OverlayRegion { Name = "x", HJust = OverlayHJust.Center } },
};
string json = OverlayTemplateStore.Serialize(template);
Assert.Contains("\"Center\"", json);
Assert.DoesNotContain("\"HJust\": 1", json);
}
}
@@ -0,0 +1,46 @@
using RioJoy.Core.Overlay;
using RioJoy.Overlay;
using SkiaSharp;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
public class ProfileWallpaperGeneratorTests
{
[Fact]
public void Generate_WritesWallpaperPng_AtCanvasSize()
{
string templatePath = TestRepo.CustomBackground("regions.json");
OverlayTemplate template = OverlayTemplateStore.Load(templatePath);
string templateDir = Path.GetDirectoryName(templatePath)!;
var labels = new Dictionary<string, string> { ["a-00"] = "DEMO", ["b-00"] = "FIRE" };
string outPath = Path.Combine(Path.GetTempPath(), $"riojoy-wp-{Guid.NewGuid():N}.png");
try
{
string written = new ProfileWallpaperGenerator().Generate(template, templateDir, labels, outPath);
Assert.Equal(outPath, written);
Assert.True(File.Exists(outPath));
using SKBitmap bmp = SKBitmap.Decode(outPath);
Assert.Equal(template.Width, bmp.Width);
Assert.Equal(template.Height, bmp.Height);
}
finally
{
if (File.Exists(outPath)) File.Delete(outPath);
}
}
[Fact]
public void Generate_WithoutBaseImagePath_Throws()
{
var template = new OverlayTemplate { Width = 10, Height = 10 }; // BaseImagePath null
Assert.Throws<InvalidOperationException>(() =>
new ProfileWallpaperGenerator().Generate(
template, ".", new Dictionary<string, string>(),
Path.Combine(Path.GetTempPath(), $"x-{Guid.NewGuid():N}.png")));
}
}
@@ -0,0 +1,82 @@
using RioJoy.Core.Overlay;
using RioJoy.Overlay;
using SkiaSharp;
using Xunit;
namespace RioJoy.Core.Tests.Overlay;
public class SkiaOverlayRendererTests
{
private static SKBitmap SolidBase(int w, int h, SKColor color)
{
var bmp = new SKBitmap(w, h);
using var canvas = new SKCanvas(bmp);
canvas.Clear(color);
canvas.Flush();
return bmp;
}
private static bool HasLightPixel(SKBitmap bmp, int x, int y, int w, int h)
{
for (int yy = y; yy < y + h; yy++)
for (int xx = x; xx < x + w; xx++)
{
SKColor p = bmp.GetPixel(xx, yy);
if (p.Red > 40 || p.Green > 40 || p.Blue > 40)
return true;
}
return false;
}
private static OverlayRegion Region(string name, int x, int y, int w, int h) =>
new() { Name = name, X = x, Y = y, Width = w, Height = h, FontFamily = "Arial", FontSizePx = 20, ColorHex = "#FFFFFF" };
[Fact]
public void Render_DrawsLabel_AndPreservesDimensions()
{
using SKBitmap baseImg = SolidBase(200, 100, SKColors.Black);
var template = new OverlayTemplate { Width = 200, Height = 100, Regions = { Region("b-00", 10, 10, 150, 50) } };
var labels = new Dictionary<string, string> { ["b-00"] = "FIRE" };
using SKBitmap output = new SkiaOverlayRenderer().Render(template, labels, baseImg);
Assert.Equal(200, output.Width);
Assert.Equal(100, output.Height);
Assert.True(HasLightPixel(output, 10, 10, 150, 50), "white label text should appear inside the region");
}
[Fact]
public void Render_DoesNotMutateBaseImage()
{
using SKBitmap baseImg = SolidBase(120, 60, SKColors.Black);
var template = new OverlayTemplate { Regions = { Region("x", 5, 5, 100, 40) } };
using SKBitmap _ = new SkiaOverlayRenderer().Render(template, new Dictionary<string, string> { ["x"] = "HI" }, baseImg);
Assert.False(HasLightPixel(baseImg, 0, 0, 120, 60), "the source bitmap must be left untouched");
}
[Fact]
public void Render_RegionWithoutLabel_StaysBackground()
{
using SKBitmap baseImg = SolidBase(120, 60, SKColors.Black);
var template = new OverlayTemplate { Regions = { Region("x", 5, 5, 100, 40) } };
// No entry for "x" -> nothing drawn.
using SKBitmap output = new SkiaOverlayRenderer().Render(template, new Dictionary<string, string>(), baseImg);
Assert.False(HasLightPixel(output, 0, 0, 120, 60));
}
[Fact]
public void RenderToPng_ProducesValidPngSignature()
{
using SKBitmap baseImg = SolidBase(64, 64, SKColors.Black);
var template = new OverlayTemplate { Regions = { Region("x", 2, 2, 60, 30) } };
byte[] png = new SkiaOverlayRenderer().RenderToPng(template, new Dictionary<string, string> { ["x"] = "HI" }, baseImg);
Assert.True(png.Length > 8);
Assert.Equal(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }, png[..8]);
}
}
@@ -0,0 +1,16 @@
namespace RioJoy.Core.Tests;
/// <summary>Locates committed reference assets relative to the repo root.</summary>
internal static class TestRepo
{
public static string Root()
{
DirectoryInfo? dir = new(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "RioJoy.sln")))
dir = dir.Parent;
return dir?.FullName ?? throw new DirectoryNotFoundException("Could not locate repo root (RioJoy.sln).");
}
public static string CustomBackground(string file) =>
Path.Combine(Root(), "docs", "reference", "customBackground", file);
}
@@ -17,6 +17,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\RioJoy.Core\RioJoy.Core.csproj" /> <ProjectReference Include="..\..\src\RioJoy.Core\RioJoy.Core.csproj" />
<ProjectReference Include="..\..\src\RioJoy.Overlay\RioJoy.Overlay.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+133
View File
@@ -0,0 +1,133 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
// Extracts the cockpit label cells from the GIMP source art (riojoy.xcf) into a
// regions.json in OverlayTemplate shape: each named text layer becomes a region
// (Name = layer name, geometry = layer box, font/size from the gimp-text-layer
// parasite). Replaces eyeballing coordinates out of GIMP by hand.
//
// dotnet run --project tools/XcfRegionExtract [-- <in.xcf> <out.json>]
//
// Defaults assume it is run from the repo root. XCF is big-endian; pointers are
// 32-bit for versions < 11. Only layer metadata is read (no tile/pixel data), so
// tile compression does not matter.
string inPath = args.Length > 0 ? args[0] : "docs/reference/customBackground/riojoy.xcf";
string outPath = args.Length > 1 ? args[1] : "docs/reference/customBackground/regions.json";
// Base cockpit image (the wallpaper background), a sibling of regions.json.
string baseImage = args.Length > 2 ? args[2] : "riojoy.png";
byte[] d = File.ReadAllBytes(inPath);
int pos = 0;
uint U32() { uint v = (uint)((d[pos] << 24) | (d[pos + 1] << 16) | (d[pos + 2] << 8) | d[pos + 3]); pos += 4; return v; }
int I32() => (int)U32();
ulong U64() { ulong v = 0; for (int i = 0; i < 8; i++) v = (v << 8) | d[pos++]; return v; }
string magic = Encoding.ASCII.GetString(d, 0, 9); // "gimp xcf "
string ver = Encoding.ASCII.GetString(d, 9, 4); // "file" (=v0) or "vNNN"
if (magic != "gimp xcf ") { Console.Error.WriteLine($"Not an XCF file: '{magic}'"); return 1; }
bool ptr64 = ver[0] == 'v' && int.TryParse(ver.AsSpan(1, 3), out int vn) && vn >= 11;
long Ptr() => ptr64 ? (long)U64() : U32();
pos = 14;
int width = (int)U32(), height = (int)U32();
_ = U32(); // base type
Console.WriteLine($"xcf ver='{ver}' canvas={width}x{height}");
string XcfString()
{
uint len = U32();
if (len == 0) return "";
string s = Encoding.UTF8.GetString(d, pos, (int)len - 1); // length includes trailing NUL
pos += (int)len;
return s;
}
void SkipProps()
{
while (true) { uint t = U32(); uint l = U32(); if (t == 0) break; pos += (int)l; }
}
pos = 26; // header(14) + width(4) + height(4) + base(4)
SkipProps(); // image property list
var layerPtrs = new List<long>();
while (true) { long p = Ptr(); if (p == 0) break; layerPtrs.Add(p); }
var fontRx = new Regex("\\(font \"([^\"]*)\"\\)");
var sizeRx = new Regex("\\(font-size ([0-9.]+)");
var colorRx = new Regex("\\(color \\(color-rgb ([0-9.]+) ([0-9.]+) ([0-9.]+)\\)\\)");
static string? ToHex(Match m)
{
if (!m.Success) return null;
int Byte(int g) => (int)Math.Round(double.Parse(m.Groups[g].Value, CultureInfo.InvariantCulture) * 255);
return $"#{Byte(1):X2}{Byte(2):X2}{Byte(3):X2}";
}
// Cockpit-specific orientation: these cells' labels read 90° counter-clockwise
// (the vertical button column b-10..b-1F and the center title a-00). GIMP doesn't
// record a simple text-layer rotation, so it is applied here by name.
var rotateCcw90 = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "a-00" };
for (int a = 0x10; a <= 0x1F; a++) rotateCcw90.Add($"b-{a:X2}");
var regions = new List<object>();
foreach (long lp in layerPtrs)
{
pos = (int)lp;
uint lw = U32(), lh = U32(); _ = U32(); // width, height, type
string name = XcfString();
int dx = 0, dy = 0; string? parasite = null;
while (true)
{
uint t = U32(); uint l = U32();
if (t == 0) break;
int next = pos + (int)l;
if (t == 15) { dx = I32(); dy = I32(); } // PROP_OFFSETS
else if (t == 21) // PROP_PARASITES
{
while (pos < next)
{
string pname = XcfString();
_ = U32(); // flags
uint psize = U32();
string pdata = Encoding.UTF8.GetString(d, pos, (int)psize); pos += (int)psize;
if (pname == "gimp-text-layer") parasite = pdata;
}
}
pos = next;
}
if (parasite is null) continue; // only text layers are label cells
string font = fontRx.Match(parasite) is { Success: true } fm ? fm.Groups[1].Value : "Sans";
string sizeStr = sizeRx.Match(parasite) is { Success: true } sm ? sm.Groups[1].Value : "12";
double sizePx = double.TryParse(sizeStr, NumberStyles.Float, CultureInfo.InvariantCulture, out double sp) ? sp : 12;
string? colorHex = ToHex(colorRx.Match(parasite));
regions.Add(new
{
Name = name,
X = dx,
Y = dy,
Width = (int)lw,
Height = (int)lh,
FontFamily = string.IsNullOrEmpty(font) ? "Sans" : font,
FontSizePx = sizePx,
ColorHex = colorHex,
RotationDegrees = rotateCcw90.Contains(name) ? 90.0 : (double?)null,
});
}
var template = new { BaseImagePath = baseImage, Width = width, Height = height, Regions = regions };
var opts = new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
File.WriteAllText(outPath, JsonSerializer.Serialize(template, opts));
Console.WriteLine($"Wrote {regions.Count} regions -> {outPath}");
return 0;
+31
View File
@@ -0,0 +1,31 @@
# XcfRegionExtract
Extracts the cockpit label cells from the GIMP source art
([`docs/reference/customBackground/riojoy.xcf`](../../docs/reference/customBackground/riojoy.xcf))
into a `regions.json` in `OverlayTemplate` shape — the geometry the Phase 7
overlay generator (`RioJoy.Core.Overlay`) lays text into.
Each **named text layer** becomes a region: `Name` = layer name (e.g. `b-00`,
`Throttle`), geometry = the layer's box (`PROP_OFFSETS` + width/height), and
`FontFamily`/`FontSizePx` from the layer's `gimp-text-layer` parasite. Only layer
metadata is read (no tile/pixel data), so XCF tile compression is irrelevant.
## Run
```cmd
dotnet run --project tools/XcfRegionExtract
```
Defaults read `docs/reference/customBackground/riojoy.xcf` and overwrite
`docs/reference/customBackground/regions.json` (run from the repo root). Override:
```cmd
dotnet run --project tools/XcfRegionExtract -- path\to\in.xcf path\to\out.json
```
Re-run whenever the cockpit artwork changes. The committed `regions.json` is the
canonical extract; `OverlayTemplate.BaseImagePath` is left null and must point at
the exported base cockpit image used as the wallpaper background.
> Supports XCF version 0 (`gimp xcf file`) through current; pointers are 64-bit at
> version ≥ 11, 32-bit otherwise.
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>XcfRegionExtract</AssemblyName>
<!-- Dev utility; not part of RioJoy.sln. -->
</PropertyGroup>
</Project>