diff --git a/.gitignore b/.gitignore
index 8deaa88..2fa9152 100644
--- a/.gitignore
+++ b/.gitignore
@@ -372,3 +372,7 @@ FodyWeavers.xsd
*.pvk
# Local run-time config that shouldn't be versioned
*.local.json
+
+# Generated overlay/editor previews (regenerable, not committed)
+docs/reference/customBackground/riojoy-preview.png
+docs/reference/customBackground/editor-preview.png
diff --git a/README.md b/README.md
index 9000198..3cb9fad 100644
--- a/README.md
+++ b/README.md
@@ -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 |
| [`tests/RioJoy.Core.Tests`](tests/RioJoy.Core.Tests/) | xUnit tests for the protocol core |
| [`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/PROTOCOL.md`](docs/PROTOCOL.md) | RIO wire format + `iRIO` input-map reference |
| [`docs/reference/`](docs/reference/) | Cockpit overlay art & the legacy labeling pipeline |
@@ -35,11 +37,14 @@ dotnet test RioJoy.sln
## Status
-Phases 1–5 are implemented and tested (136 unit tests): the `RioGamepad` virtual
-HID driver compiles to `.sys` against the EWDK (KMDF + VHF), and the C# side
-covers the serial + RIO protocol core, input mapping + output routing, axis
-calibration + plasma display, the tray app + profiles (JSON config, `RIO.ini`
-importer, three-state auto-switch), and the HID report packer that matches the
-driver's wire format. Remaining work is **deploy-side**: test-sign + install the
-driver, wire the real `DeviceIoControl` feeder, and verify on a cabinet. See
-[`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.
+Phases 1–5 are implemented and tested (136 unit tests). The `RioGamepad` virtual
+HID driver is built (KMDF + VHF), **test-signed, installed, and verified**: it
+enumerates in `joy.cpl`, and the C# HID feeder (`DeviceIoControl` →
+`RioGamepad.sys`) drives its axes, buttons, and hat end-to-end (see
+[`tools/RioJoySmokeTest`](tools/RioJoySmokeTest/)). The C# side covers the serial
++ RIO protocol core, input mapping + output routing, axis calibration + plasma
+display, the tray app + profiles (JSON config, `RIO.ini` importer, three-state
+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.
diff --git a/RioJoy.sln b/RioJoy.sln
index d109dc4..334ba6c 100644
--- a/RioJoy.sln
+++ b/RioJoy.sln
@@ -13,6 +13,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9FFCA6FA
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RioJoy.Core.Tests", "tests\RioJoy.Core.Tests\RioJoy.Core.Tests.csproj", "{4A950510-432A-48C2-92E5-B87D075BA7DB}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RioJoy.Overlay", "src\RioJoy.Overlay\RioJoy.Overlay.csproj", "{1A532110-DD43-40FB-87C9-ECFF014D5799}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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}.Release|Any CPU.ActiveCfg = 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
GlobalSection(NestedProjects) = preSolution
{C81FEF57-A33B-4529-BDB7-02787407A545} = {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}
+ {1A532110-DD43-40FB-87C9-ECFF014D5799} = {CDDAA3FE-6A05-40A0-85CC-4DB0B3EEEA9C}
EndGlobalSection
EndGlobal
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 6033d76..d9c5809 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -100,7 +100,7 @@ tray menu is always available.
## Phases
### 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/`.
- Solution `RioJoy.sln` with `src/RioJoy.Core` (lib) + `src/RioJoy.Tray`
(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 ✅
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
tested); `RioIniImporter` ports the legacy `RIO.ini` (buttons/inverts/greeting).
- `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.
### Phase 6 — Packaging / signing / deploy
-- Driver install via `pnputil`; app installer; test-signing script.
-- Cabinet deployment doc. (Production option: attestation signing.)
+- Driver test-signing + `pnputil` install already scripted (`driver/sign.ps1`,
+ `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
(see [`docs/reference/customBackground/`](reference/customBackground/)).
-- **Mapping editor:** per-button UI to set action + label + lamp, no hex
- bit-twiddling; clone-from-existing profile.
-- **Overlay generator:** render labels into named regions over a base cockpit
- image using **SkiaSharp**, porting the auto-fit/justification logic from
- `sg-goobie-MFD.scm`; export the per-profile wallpaper and re-apply via
- `SystemParametersInfo`.
-- **Region authoring:** extract the label rectangles from `riojoy.xcf` into a
- `regions.json`; in-app box editor for future tweaks.
+- **Overlay generator — done ✅, verified on real assets.** `RioJoy.Core/Overlay`
+ is the pure, unit-tested layout engine: `FontFitter` is a faithful port of the
+ `calc-fontsize` auto-fit search (validated against a brute-force oracle),
+ `OverlayLayoutEngine` ports `create-data-layer`'s fit + horizontal/vertical
+ justification, and `OverlayTemplate`/`OverlayRegion` (a `regions.json` via
+ `OverlayTemplateStore`) hold the cell geometry/color. Label text is per-profile
+ (`RioProfile.OverlayLabels`); `GoobieDataImporter` reads the legacy `.data`
+ 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
importers for each.
- To confirm at Phase 7: target wallpaper resolution(s); static wallpaper vs.
diff --git a/docs/reference/customBackground/config-sheet.csv b/docs/reference/customBackground/config-sheet.csv
new file mode 100644
index 0000000..0496ee2
--- /dev/null
+++ b/docs/reference/customBackground/config-sheet.csv
@@ -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","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""
\ No newline at end of file
diff --git a/docs/reference/customBackground/regions.json b/docs/reference/customBackground/regions.json
new file mode 100644
index 0000000..7a1faa3
--- /dev/null
+++ b/docs/reference/customBackground/regions.json
@@ -0,0 +1,1214 @@
+{
+ "BaseImagePath": "riojoy.png",
+ "Width": 2720,
+ "Height": 600,
+ "Regions": [
+ {
+ "Name": "a-00",
+ "X": 810,
+ "Y": 332,
+ "Width": 197,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-00",
+ "X": 2558,
+ "Y": 428,
+ "Width": 153,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-01",
+ "X": 2398,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-02",
+ "X": 2238,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-03",
+ "X": 2078,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-04",
+ "X": 2558,
+ "Y": 5,
+ "Width": 153,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-05",
+ "X": 2399,
+ "Y": 5,
+ "Width": 149,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-06",
+ "X": 2238,
+ "Y": 5,
+ "Width": 151,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-07",
+ "X": 2078,
+ "Y": 5,
+ "Width": 151,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-08",
+ "X": 2558,
+ "Y": 428,
+ "Width": 153,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-09",
+ "X": 2398,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-0A",
+ "X": 2238,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-0B",
+ "X": 2078,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-0C",
+ "X": 2558,
+ "Y": 5,
+ "Width": 153,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-0D",
+ "X": 2398,
+ "Y": 5,
+ "Width": 151,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-0E",
+ "X": 2238,
+ "Y": 5,
+ "Width": 151,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-0F",
+ "X": 2078,
+ "Y": 5,
+ "Width": 151,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-10",
+ "X": 823,
+ "Y": 428,
+ "Width": 53,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-11",
+ "X": 926,
+ "Y": 428,
+ "Width": 53,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-12",
+ "X": 1035,
+ "Y": 428,
+ "Width": 53,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-13",
+ "X": 1143,
+ "Y": 428,
+ "Width": 53,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-14",
+ "X": 1247,
+ "Y": 428,
+ "Width": 53,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-15",
+ "X": 1354,
+ "Y": 428,
+ "Width": 53,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-16",
+ "X": 1329,
+ "Y": 370,
+ "Width": 50,
+ "Height": 50,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-17",
+ "X": 1382,
+ "Y": 370,
+ "Width": 50,
+ "Height": 50,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-18",
+ "X": 820,
+ "Y": 5,
+ "Width": 54,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-19",
+ "X": 927,
+ "Y": 6,
+ "Width": 53,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-1A",
+ "X": 1035,
+ "Y": 5,
+ "Width": 53,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-1B",
+ "X": 1140,
+ "Y": 5,
+ "Width": 53,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-1C",
+ "X": 1246,
+ "Y": 5,
+ "Width": 53,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-1D",
+ "X": 1354,
+ "Y": 6,
+ "Width": 53,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 38,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-1E",
+ "X": 1330,
+ "Y": 67,
+ "Width": 50,
+ "Height": 50,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-1F",
+ "X": 1388,
+ "Y": 67,
+ "Width": 50,
+ "Height": 50,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FFFFFF",
+ "RotationDegrees": 90
+ },
+ {
+ "Name": "b-20",
+ "X": 1918,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-21",
+ "X": 1758,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-22",
+ "X": 1598,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-23",
+ "X": 1438,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-24",
+ "X": 1919,
+ "Y": 6,
+ "Width": 150,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-25",
+ "X": 1759,
+ "Y": 6,
+ "Width": 149,
+ "Height": 52,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-26",
+ "X": 1599,
+ "Y": 6,
+ "Width": 149,
+ "Height": 52,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-27",
+ "X": 1438,
+ "Y": 5,
+ "Width": 150,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-28",
+ "X": 1918,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-29",
+ "X": 1758,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-2A",
+ "X": 1598,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-2B",
+ "X": 1438,
+ "Y": 428,
+ "Width": 150,
+ "Height": 52,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-2C",
+ "X": 1918,
+ "Y": 5,
+ "Width": 151,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-2D",
+ "X": 1758,
+ "Y": 5,
+ "Width": 151,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-2E",
+ "X": 1598,
+ "Y": 5,
+ "Width": 151,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-2F",
+ "X": 1438,
+ "Y": 6,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-30",
+ "X": 1918,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#0000FF"
+ },
+ {
+ "Name": "b-31",
+ "X": 1758,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#0000FF"
+ },
+ {
+ "Name": "b-32",
+ "X": 1598,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#0000FF"
+ },
+ {
+ "Name": "b-33",
+ "X": 1438,
+ "Y": 428,
+ "Width": 151,
+ "Height": 53,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#0000FF"
+ },
+ {
+ "Name": "b-34",
+ "X": 1918,
+ "Y": 5,
+ "Width": 151,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#0000FF"
+ },
+ {
+ "Name": "b-35",
+ "X": 1758,
+ "Y": 5,
+ "Width": 151,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#0000FF"
+ },
+ {
+ "Name": "b-36",
+ "X": 1598,
+ "Y": 5,
+ "Width": 151,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#0000FF"
+ },
+ {
+ "Name": "b-37",
+ "X": 1438,
+ "Y": 5,
+ "Width": 151,
+ "Height": 54,
+ "FontFamily": "Sans",
+ "FontSizePx": 45,
+ "ColorHex": "#0000FF"
+ },
+ {
+ "Name": "b-38",
+ "X": 2349,
+ "Y": 105,
+ "Width": 50,
+ "Height": 31,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-39",
+ "X": 2349,
+ "Y": 139,
+ "Width": 50,
+ "Height": 31,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-3A",
+ "X": 2349,
+ "Y": 173,
+ "Width": 50,
+ "Height": 31,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-3B",
+ "X": 2349,
+ "Y": 207,
+ "Width": 50,
+ "Height": 32,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-3C",
+ "X": 2349,
+ "Y": 242,
+ "Width": 50,
+ "Height": 32,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-3D",
+ "X": 2349,
+ "Y": 277,
+ "Width": 50,
+ "Height": 31,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-3E",
+ "X": 2349,
+ "Y": 311,
+ "Width": 50,
+ "Height": 32,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-3F",
+ "X": 2349,
+ "Y": 346,
+ "Width": 50,
+ "Height": 31,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-40",
+ "X": 2509,
+ "Y": 105,
+ "Width": 50,
+ "Height": 31,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-41",
+ "X": 2509,
+ "Y": 277,
+ "Width": 50,
+ "Height": 31,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-42",
+ "X": 2509,
+ "Y": 242,
+ "Width": 50,
+ "Height": 32,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-43",
+ "X": 2509,
+ "Y": 346,
+ "Width": 50,
+ "Height": 31,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-44",
+ "X": 2509,
+ "Y": 311,
+ "Width": 50,
+ "Height": 32,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-45",
+ "X": 2509,
+ "Y": 207,
+ "Width": 50,
+ "Height": 32,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-46",
+ "X": 2509,
+ "Y": 173,
+ "Width": 50,
+ "Height": 31,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-47",
+ "X": 2509,
+ "Y": 139,
+ "Width": 50,
+ "Height": 31,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-50",
+ "X": 1682,
+ "Y": 311,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-51",
+ "X": 1602,
+ "Y": 105,
+ "Width": 77,
+ "Height": 65,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-52",
+ "X": 1682,
+ "Y": 105,
+ "Width": 77,
+ "Height": 65,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-53",
+ "X": 1762,
+ "Y": 105,
+ "Width": 77,
+ "Height": 65,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-54",
+ "X": 1602,
+ "Y": 173,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-55",
+ "X": 1682,
+ "Y": 173,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-56",
+ "X": 1762,
+ "Y": 173,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-57",
+ "X": 1602,
+ "Y": 242,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-58",
+ "X": 1682,
+ "Y": 242,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-59",
+ "X": 1762,
+ "Y": 242,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-5A",
+ "X": 1602,
+ "Y": 311,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-5B",
+ "X": 1762,
+ "Y": 311,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-5C",
+ "X": 1842,
+ "Y": 105,
+ "Width": 77,
+ "Height": 65,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-5D",
+ "X": 1842,
+ "Y": 173,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-5E",
+ "X": 1842,
+ "Y": 242,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-5F",
+ "X": 1842,
+ "Y": 311,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "b-60",
+ "X": 1682,
+ "Y": 311,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-61",
+ "X": 1602,
+ "Y": 105,
+ "Width": 77,
+ "Height": 65,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-62",
+ "X": 1682,
+ "Y": 105,
+ "Width": 77,
+ "Height": 65,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-63",
+ "X": 1762,
+ "Y": 105,
+ "Width": 77,
+ "Height": 65,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-64",
+ "X": 1602,
+ "Y": 173,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-65",
+ "X": 1682,
+ "Y": 173,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-66",
+ "X": 1762,
+ "Y": 173,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-67",
+ "X": 1602,
+ "Y": 242,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-68",
+ "X": 1682,
+ "Y": 242,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-69",
+ "X": 1762,
+ "Y": 242,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-6A",
+ "X": 1602,
+ "Y": 311,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-6B",
+ "X": 1762,
+ "Y": 311,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-6C",
+ "X": 1842,
+ "Y": 105,
+ "Width": 77,
+ "Height": 65,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-6D",
+ "X": 1842,
+ "Y": 173,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-6E",
+ "X": 1842,
+ "Y": 242,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "b-6F",
+ "X": 1842,
+ "Y": 311,
+ "Width": 77,
+ "Height": 66,
+ "FontFamily": "Sans",
+ "FontSizePx": 40,
+ "ColorHex": "#FF0000"
+ },
+ {
+ "Name": "KeyPad:INT",
+ "X": 1695,
+ "Y": 79,
+ "Width": 132,
+ "Height": 21,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "KeyPad:EXT",
+ "X": 1695,
+ "Y": 79,
+ "Width": 132,
+ "Height": 21,
+ "FontFamily": "Sans",
+ "FontSizePx": 20,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "HatRight",
+ "X": 2402,
+ "Y": 350,
+ "Width": 104,
+ "Height": 27,
+ "FontFamily": "Sans",
+ "FontSizePx": 18,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "HatLeft",
+ "X": 2402,
+ "Y": 315,
+ "Width": 104,
+ "Height": 28,
+ "FontFamily": "Sans",
+ "FontSizePx": 18,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "HatDown",
+ "X": 2402,
+ "Y": 282,
+ "Width": 104,
+ "Height": 26,
+ "FontFamily": "Sans",
+ "FontSizePx": 18,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "HatUp",
+ "X": 2402,
+ "Y": 247,
+ "Width": 104,
+ "Height": 27,
+ "FontFamily": "Sans",
+ "FontSizePx": 18,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "Lower",
+ "X": 2402,
+ "Y": 212,
+ "Width": 104,
+ "Height": 27,
+ "FontFamily": "Sans",
+ "FontSizePx": 18,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "Middle",
+ "X": 2402,
+ "Y": 178,
+ "Width": 104,
+ "Height": 26,
+ "FontFamily": "Sans",
+ "FontSizePx": 18,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "Upper",
+ "X": 2402,
+ "Y": 144,
+ "Width": 104,
+ "Height": 26,
+ "FontFamily": "Sans",
+ "FontSizePx": 18,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "Throttle",
+ "X": 2242,
+ "Y": 351,
+ "Width": 104,
+ "Height": 26,
+ "FontFamily": "Sans",
+ "FontSizePx": 18,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "Panic",
+ "X": 2242,
+ "Y": 282,
+ "Width": 104,
+ "Height": 26,
+ "FontFamily": "Sans",
+ "FontSizePx": 18,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "Trigger",
+ "X": 2402,
+ "Y": 110,
+ "Width": 104,
+ "Height": 26,
+ "FontFamily": "Sans",
+ "FontSizePx": 18,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "Throttle:",
+ "X": 2243,
+ "Y": 81,
+ "Width": 103,
+ "Height": 20,
+ "FontFamily": "Sans",
+ "FontSizePx": 18,
+ "ColorHex": "#00FF00"
+ },
+ {
+ "Name": "JoyStick:",
+ "X": 2402,
+ "Y": 80,
+ "Width": 104,
+ "Height": 21,
+ "FontFamily": "Sans",
+ "FontSizePx": 18,
+ "ColorHex": "#00FF00"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/reference/customBackground/riojoy.png b/docs/reference/customBackground/riojoy.png
new file mode 100644
index 0000000..d551074
Binary files /dev/null and b/docs/reference/customBackground/riojoy.png differ
diff --git a/src/RioJoy.Core/Editing/SheetLayout.cs b/src/RioJoy.Core/Editing/SheetLayout.cs
new file mode 100644
index 0000000..1f70f16
--- /dev/null
+++ b/src/RioJoy.Core/Editing/SheetLayout.cs
@@ -0,0 +1,120 @@
+using System.Globalization;
+using System.Text;
+
+namespace RioJoy.Core.Editing;
+
+/// Section of the authoring sheet a cell belongs to (drives editor colour).
+public enum SheetSection
+{
+ None,
+ Contacts, // 0x00–0x0F
+ Secondary, // 0x10–0x17
+ Screen, // 0x18–0x1F
+ Letters, // 0x20–0x37
+ Throttle, // 0x38–0x3F
+ Joystick, // 0x40–0x47
+ Keypad, // 0x50–0x5F
+ ExtKeypad, // 0x60–0x6F
+}
+
+///
+/// One cell of the cockpit authoring spreadsheet: its grid /,
+/// the cell , and — when the text is a RIO address (two hex
+/// digits, 0x00–0x6F) — the it edits and its
+/// .
+///
+public sealed record SheetCell(int Row, int Col, string Text, int? Address, SheetSection Section);
+
+///
+/// Parses the legacy authoring spreadsheet (exported CSV, see
+/// docs/reference/customBackground/config-sheet.csv) into a grid of
+/// s. This is the cockpit's logical 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.
+///
+public static class SheetLayout
+{
+ public static IReadOnlyList Load(string path) => Parse(File.ReadAllText(path));
+
+ ///
+ /// The RIO address a cell's text encodes (two hex digits in 0x00–0x6F), or null.
+ ///
+ 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;
+ }
+
+ /// Classify an address into a sheet section.
+ 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,
+ };
+
+ ///
+ /// Parse the CSV (one spreadsheet row per line) into non-empty cells. Cell text
+ /// longer than is dropped as a merged-cell
+ /// artifact; the clean export has none.
+ ///
+ public static IReadOnlyList 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();
+
+ for (int row = 0; row < lines.Length; row++)
+ {
+ if (lines[row].Length == 0) continue;
+ List 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 ParseLine(string line)
+ {
+ var fields = new List();
+ 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;
+ }
+}
diff --git a/src/RioJoy.Core/Mapping/ButtonBinding.cs b/src/RioJoy.Core/Mapping/ButtonBinding.cs
new file mode 100644
index 0000000..d47c638
--- /dev/null
+++ b/src/RioJoy.Core/Mapping/ButtonBinding.cs
@@ -0,0 +1,56 @@
+namespace RioJoy.Core.Mapping;
+
+///
+/// A mutable, editor-friendly view of one iRIO map word: the routing
+/// , payload , lamp flag, and keyboard
+/// modifiers. Two-way binds cleanly to UI controls and converts to/from the packed
+/// 16-bit word via / (which round-trip
+/// through ). The button's display label is stored
+/// separately (the profile's overlay labels), not here.
+///
+public sealed class ButtonBinding
+{
+ /// How the input is routed (keyboard / joystick / hat / mouse / RIO command).
+ public RioRouteKind Kind { get; set; } = RioRouteKind.Keyboard;
+
+ /// Payload byte: VK code, joystick button #, hat direction, or mouse action.
+ public byte Value { get; set; }
+
+ /// Lighted button (lamp feedback on press/release).
+ public bool IsLit { get; set; }
+
+ /// Keyboard modifier: SHIFT.
+ public bool Shift { get; set; }
+
+ /// Keyboard modifier: CTRL.
+ public bool Ctrl { get; set; }
+
+ /// Keyboard modifier: ALT.
+ public bool Alt { get; set; }
+
+ /// Apply KEYEVENTF_EXTENDEDKEY to the key.
+ public bool Extended { get; set; }
+
+ /// True when this binding does nothing (an empty slot).
+ public bool IsUnmapped => ToWord() == 0;
+
+ /// Pack to the 16-bit iRIO map word.
+ public ushort ToWord() =>
+ RioMapEntry.Create(Kind, Value, IsLit, Shift, Ctrl, Alt, Extended).Raw;
+
+ /// Build an editable binding from a packed map word.
+ 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,
+ };
+ }
+}
diff --git a/src/RioJoy.Core/Mapping/KeyCatalog.cs b/src/RioJoy.Core/Mapping/KeyCatalog.cs
new file mode 100644
index 0000000..c94c2ed
--- /dev/null
+++ b/src/RioJoy.Core/Mapping/KeyCatalog.cs
@@ -0,0 +1,83 @@
+namespace RioJoy.Core.Mapping;
+
+/// A named keyboard key and its Windows virtual-key code.
+public readonly record struct KeyName(string Name, byte Value);
+
+///
+/// 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 iRIO payload is a VK code (see /
+/// SendInputSink); modifiers are separate flags, so they are not listed here
+/// as keys.
+///
+public static class KeyCatalog
+{
+ /// All catalogued keys, in menu order (letters, digits, F-keys, …).
+ public static IReadOnlyList Entries { get; } = Build();
+
+ private static readonly Dictionary ByValue =
+ Entries.GroupBy(e => e.Value).ToDictionary(g => g.Key, g => g.First().Name);
+
+ private static readonly Dictionary ByName =
+ Entries.ToDictionary(e => e.Name, e => e.Value, StringComparer.OrdinalIgnoreCase);
+
+ /// Friendly name for a VK code, or "0xHH" when uncatalogued.
+ public static string NameFor(byte vk) => ByValue.TryGetValue(vk, out string? n) ? n : $"0x{vk:X2}";
+
+ /// Look up a VK code by friendly name.
+ public static bool TryGetValue(string name, out byte vk) => ByName.TryGetValue(name, out vk);
+
+ private static List Build()
+ {
+ var list = new List();
+ void Add(string name, int vk) => list.Add(new KeyName(name, (byte)vk));
+
+ for (char c = 'A'; c <= 'Z'; c++) Add(c.ToString(), c); // 0x41–0x5A
+ for (int d = 0; d <= 9; d++) Add(d.ToString(), 0x30 + d); // 0x30–0x39
+ for (int f = 1; f <= 12; f++) Add($"F{f}", 0x6F + f); // F1–F12 = 0x70–0x7B
+
+ 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); // 0x60–0x69
+ 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;
+ }
+}
diff --git a/src/RioJoy.Core/Mapping/RioMapEntry.cs b/src/RioJoy.Core/Mapping/RioMapEntry.cs
index d62feeb..d9a7043 100644
--- a/src/RioJoy.Core/Mapping/RioMapEntry.cs
+++ b/src/RioJoy.Core/Mapping/RioMapEntry.cs
@@ -47,6 +47,42 @@ public readonly struct RioMapEntry
public RioMapEntry(ushort raw) => Raw = raw;
+ ///
+ /// Build a map word from decoded components (the inverse of the decode
+ /// properties) — used by the profile editor to write the iRIO table.
+ /// sets the routing flags; modifiers/lamp/extended set
+ /// their high-byte bits; is the low byte.
+ ///
+ 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);
+ }
+
/// This input drives a lighted button (lamp feedback on press/release).
public bool HasLamp => (Raw & FlagHasLamp) != 0;
diff --git a/src/RioJoy.Core/Overlay/FontFitter.cs b/src/RioJoy.Core/Overlay/FontFitter.cs
new file mode 100644
index 0000000..5723ec7
--- /dev/null
+++ b/src/RioJoy.Core/Overlay/FontFitter.cs
@@ -0,0 +1,64 @@
+namespace RioJoy.Core.Overlay;
+
+///
+/// Finds the largest font size whose text fits a cell — a faithful port of the
+/// legacy calc-fontsize Script-Fu routine
+/// (docs/reference/customBackground/sg-goobie-MFD.scm, lines 50–67).
+///
+/// The original is an expand-then-narrow search: grow the size by a factor
+/// (adjust, 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 .
+///
+public static class FontFitter
+{
+ /// Minimum font size the legacy routine will return (px).
+ public const double MinFontSize = 6;
+
+ // Guard against a pathological measurer that never converges.
+ private const int MaxIterations = 1000;
+
+ ///
+ /// Largest font size (px) at which fits within
+ /// × , measured by
+ /// .
+ ///
+ 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);
+ }
+}
diff --git a/src/RioJoy.Core/Overlay/GoobieDataImporter.cs b/src/RioJoy.Core/Overlay/GoobieDataImporter.cs
new file mode 100644
index 0000000..fbad471
--- /dev/null
+++ b/src/RioJoy.Core/Overlay/GoobieDataImporter.cs
@@ -0,0 +1,118 @@
+using System.Text;
+
+namespace RioJoy.Core.Overlay;
+
+///
+/// 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: ; 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.
+///
+/// Each row maps directly to an label set
+/// (region name → text), so this is the bridge from the old sheet to a profile's
+/// .
+///
+public static class GoobieDataImporter
+{
+ /// A parsed data file: the field names and one label map per row.
+ public sealed record Sheet(
+ IReadOnlyList Fields,
+ IReadOnlyList> Rows);
+
+ public static Sheet Load(string path) => Parse(File.ReadAllText(path));
+
+ /// Parse the data file text into fields + rows.
+ public static Sheet Parse(string text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+
+ List> lists = ReadLists(text);
+ if (lists.Count == 0)
+ throw new FormatException("No lists found; expected a header list of field names.");
+
+ List fields = lists[0];
+ var rows = new List>();
+
+ for (int i = 1; i < lists.Count; i++)
+ {
+ List values = lists[i];
+ var map = new Dictionary(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);
+ }
+
+ ///
+ /// Convenience: the label map for a single row (default the first), with empty
+ /// values dropped — ready to hand to .
+ ///
+ public static IReadOnlyDictionary 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> ReadLists(string s)
+ {
+ var lists = new List>();
+ List? 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();
+ 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;
+ }
+}
diff --git a/src/RioJoy.Core/Overlay/ITextMeasurer.cs b/src/RioJoy.Core/Overlay/ITextMeasurer.cs
new file mode 100644
index 0000000..ae25386
--- /dev/null
+++ b/src/RioJoy.Core/Overlay/ITextMeasurer.cs
@@ -0,0 +1,17 @@
+namespace RioJoy.Core.Overlay;
+
+/// The pixel size of a rendered string (its bounding box).
+public readonly record struct TextExtent(double Width, double Height);
+
+///
+/// Measures rendered text. Abstracts the rasterizer (SkiaSharp, GDI, …) so the
+/// overlay layout math (, )
+/// 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
+/// gimp-text-get-extents-fontname (see docs/reference/customBackground/sg-goobie-MFD.scm).
+///
+public interface ITextMeasurer
+{
+ /// Bounding-box size of at the given font/size, in pixels.
+ TextExtent Measure(string text, string fontFamily, double fontSizePx);
+}
diff --git a/src/RioJoy.Core/Overlay/OverlayLayoutEngine.cs b/src/RioJoy.Core/Overlay/OverlayLayoutEngine.cs
new file mode 100644
index 0000000..b2949ae
--- /dev/null
+++ b/src/RioJoy.Core/Overlay/OverlayLayoutEngine.cs
@@ -0,0 +1,97 @@
+namespace RioJoy.Core.Overlay;
+
+///
+/// Lays out per-region label text over a cockpit template into positioned
+/// s ready for a rasterizer. Pure: all measurement goes
+/// through an , so the placement/auto-fit math is
+/// unit-tested without a real font. Ports the per-field logic of
+/// create-data-layer (sg-goobie-MFD.scm, lines 69–113): optional auto-fit
+/// when the text overflows, then vertical (and here also horizontal) justification.
+///
+public sealed class OverlayLayoutEngine
+{
+ private readonly ITextMeasurer _measurer;
+
+ public OverlayLayoutEngine(ITextMeasurer measurer) =>
+ _measurer = measurer ?? throw new ArgumentNullException(nameof(measurer));
+
+ ///
+ /// Lay out every region that has non-empty label text. Regions without an entry
+ /// in (or with empty text) are skipped — matching the
+ /// legacy behavior where a blank field produced no visible text.
+ ///
+ public IReadOnlyList Layout(
+ OverlayTemplate template,
+ IReadOnlyDictionary labels,
+ OverlayLayoutOptions? options = null)
+ {
+ ArgumentNullException.ThrowIfNull(template);
+ ArgumentNullException.ThrowIfNull(labels);
+ options ??= new OverlayLayoutOptions();
+
+ var placed = new List(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;
+ }
+
+ /// Lay out a single label within a region.
+ 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);
+ }
+}
diff --git a/src/RioJoy.Core/Overlay/OverlayModels.cs b/src/RioJoy.Core/Overlay/OverlayModels.cs
new file mode 100644
index 0000000..1790630
--- /dev/null
+++ b/src/RioJoy.Core/Overlay/OverlayModels.cs
@@ -0,0 +1,115 @@
+namespace RioJoy.Core.Overlay;
+
+/// Horizontal placement of a label within its region.
+public enum OverlayHJust { Left, Center, Right }
+
+/// Vertical placement of a label within its region (legacy "Vertical justification").
+public enum OverlayVJust { Top, Center, Bottom }
+
+///
+/// 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): shrinks the font to fit (auto-fit),
+/// keeps the size and clips to the region,
+/// keeps the size and lets the text spill outside the region.
+///
+public enum OverlaySizeMode { Fit, Crop, Overflow }
+
+///
+/// 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 = , e.g. a RIO address tag like b-00); the
+/// layer's offsets/size/font gave the cell geometry. Geometry is shared by all
+/// profiles for a given cockpit image; only the label text is per-profile.
+///
+public sealed class OverlayRegion
+{
+ /// Region key — matches the template layer name and the profile's label key.
+ public string Name { get; set; } = "";
+
+ /// Cell left edge, in template pixels.
+ public double X { get; set; }
+
+ /// Cell top edge, in template pixels.
+ public double Y { get; set; }
+
+ /// Cell width, in template pixels.
+ public double Width { get; set; }
+
+ /// Cell height, in template pixels.
+ public double Height { get; set; }
+
+ /// Font family for this cell's text.
+ public string FontFamily { get; set; } = "Sans";
+
+ /// Base font size (px) from the template; the starting point before any auto-fit.
+ public double FontSizePx { get; set; } = 12;
+
+ /// Text color as #RRGGBB/#AARRGGBB; null = renderer default.
+ public string? ColorHex { get; set; }
+
+ /// Per-region horizontal justification override; null = use the engine default.
+ public OverlayHJust? HJust { get; set; }
+
+ /// Per-region vertical justification override; null = use the engine default.
+ public OverlayVJust? VJust { get; set; }
+
+ ///
+ /// 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).
+ ///
+ public double? RotationDegrees { get; set; }
+}
+
+///
+/// A cockpit overlay template: the base image plus the labelled regions, extracted
+/// once from the source artwork (docs/reference/customBackground/riojoy.xcf) into a
+/// regions.json. Persisted by .
+///
+public sealed class OverlayTemplate
+{
+ /// Path to the base cockpit image the labels are drawn over.
+ public string? BaseImagePath { get; set; }
+
+ /// Base image width in pixels (the output wallpaper size).
+ public int Width { get; set; }
+
+ /// Base image height in pixels.
+ public int Height { get; set; }
+
+ /// The labelled cells.
+ public List Regions { get; set; } = new();
+
+ /// Find a region by name (case-insensitive), or null.
+ public OverlayRegion? FindRegion(string name) =>
+ Regions.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase));
+}
+
+///
+/// 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.
+///
+public readonly record struct PlacedLabel(
+ string RegionName,
+ string Text,
+ double X,
+ double Y,
+ double Width,
+ double Height,
+ string FontFamily,
+ double FontSizePx,
+ double RotationDegrees = 0);
+
+/// Engine-wide defaults for a layout pass; per-region overrides win.
+public sealed class OverlayLayoutOptions
+{
+ /// How to handle text that overflows its region at the base size.
+ public OverlaySizeMode SizeMode { get; set; } = OverlaySizeMode.Fit;
+
+ /// Default horizontal justification (regions may override).
+ public OverlayHJust HJust { get; set; } = OverlayHJust.Center;
+
+ /// Default vertical justification (regions may override).
+ public OverlayVJust VJust { get; set; } = OverlayVJust.Center;
+}
diff --git a/src/RioJoy.Core/Overlay/OverlayTemplateStore.cs b/src/RioJoy.Core/Overlay/OverlayTemplateStore.cs
new file mode 100644
index 0000000..d7ed400
--- /dev/null
+++ b/src/RioJoy.Core/Overlay/OverlayTemplateStore.cs
@@ -0,0 +1,48 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace RioJoy.Core.Overlay;
+
+///
+/// Loads and saves an as a regions.json — the
+/// cockpit cell geometry extracted from the source artwork
+/// (docs/reference/customBackground/riojoy.xcf). Same JSON conventions as
+/// (indented, enums as strings, null-skipping).
+///
+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(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));
+ }
+}
diff --git a/src/RioJoy.Core/Profiles/AppConfig.cs b/src/RioJoy.Core/Profiles/AppConfig.cs
index 2c6ec5d..bad2029 100644
--- a/src/RioJoy.Core/Profiles/AppConfig.cs
+++ b/src/RioJoy.Core/Profiles/AppConfig.cs
@@ -32,6 +32,13 @@ public sealed class AppConfig
/// Start RIOJoy with Windows.
public bool AutoStart { get; set; }
+ ///
+ /// Path to the cockpit overlay template (regions.json) 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.
+ ///
+ public string? OverlayTemplatePath { get; set; }
+
/// Find a profile by name (case-insensitive), or null.
public RioProfile? FindProfile(string? name) =>
name is null
diff --git a/src/RioJoy.Core/Profiles/RioProfile.cs b/src/RioJoy.Core/Profiles/RioProfile.cs
index 728ff10..f2ce1a3 100644
--- a/src/RioJoy.Core/Profiles/RioProfile.cs
+++ b/src/RioJoy.Core/Profiles/RioProfile.cs
@@ -35,6 +35,14 @@ public sealed class RioProfile
/// Cockpit wallpaper image path (generated in Phase 7).
public string? WallpaperPath { get; set; }
+ ///
+ /// Overlay label text per template region (region/layer name → display text,
+ /// e.g. "b-00" → "FIRE"). Drives the Phase 7 wallpaper generator
+ /// (); empty/absent entries render
+ /// nothing. The region geometry itself lives in the shared overlay template.
+ ///
+ public Dictionary OverlayLabels { get; set; } = new();
+
///
/// Executable names that select this profile when they are the foreground app
/// (matched case-insensitively, with or without ".exe").
diff --git a/src/RioJoy.Overlay/ProfileWallpaperGenerator.cs b/src/RioJoy.Overlay/ProfileWallpaperGenerator.cs
new file mode 100644
index 0000000..7491c53
--- /dev/null
+++ b/src/RioJoy.Overlay/ProfileWallpaperGenerator.cs
@@ -0,0 +1,56 @@
+using RioJoy.Core.Overlay;
+using SkiaSharp;
+
+namespace RioJoy.Overlay;
+
+///
+/// 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
+/// relative to the template file's
+/// directory (so a regions.json + sibling riojoy.png work as a unit).
+/// The runtime calls this on profile activation; an editor can call it for preview.
+///
+public sealed class ProfileWallpaperGenerator
+{
+ private readonly SkiaOverlayRenderer _renderer;
+
+ public ProfileWallpaperGenerator(SkiaOverlayRenderer? renderer = null) =>
+ _renderer = renderer ?? new SkiaOverlayRenderer();
+
+ ///
+ /// Render onto the template's base image and write a
+ /// PNG to ; returns the output path.
+ ///
+ ///
+ /// Directory the template's relative
+ /// is resolved against (normally the folder holding the regions.json).
+ ///
+ public string Generate(
+ OverlayTemplate template,
+ string templateDirectory,
+ IReadOnlyDictionary 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;
+ }
+}
diff --git a/src/RioJoy.Overlay/RioJoy.Overlay.csproj b/src/RioJoy.Overlay/RioJoy.Overlay.csproj
new file mode 100644
index 0000000..90aa8c6
--- /dev/null
+++ b/src/RioJoy.Overlay/RioJoy.Overlay.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0-windows
+ x64
+ enable
+ enable
+ latest
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RioJoy.Overlay/SkiaOverlayRenderer.cs b/src/RioJoy.Overlay/SkiaOverlayRenderer.cs
new file mode 100644
index 0000000..0cc07fc
--- /dev/null
+++ b/src/RioJoy.Overlay/SkiaOverlayRenderer.cs
@@ -0,0 +1,132 @@
+using RioJoy.Core.Overlay;
+using SkiaSharp;
+
+namespace RioJoy.Overlay;
+
+///
+/// Renders a cockpit wallpaper: lays out per-region label text with
+/// , 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
+/// with the layout engine, drawn positions match the
+/// measured extents exactly.
+///
+public sealed class SkiaOverlayRenderer
+{
+ private readonly SkiaTextMeasurer _measurer;
+ private readonly OverlayLayoutEngine _engine;
+ private readonly SKColor _defaultColor;
+
+ /// Label color when a region sets none (default white).
+ public SkiaOverlayRenderer(string defaultColorHex = "#FFFFFF")
+ {
+ _measurer = new SkiaTextMeasurer();
+ _engine = new OverlayLayoutEngine(_measurer);
+ _defaultColor = SKColor.Parse(defaultColorHex);
+ }
+
+ ///
+ /// Render labels onto and return the result as a
+ /// new bitmap (the input is not modified). Caller owns the returned bitmap.
+ ///
+ public SKBitmap Render(
+ OverlayTemplate template,
+ IReadOnlyDictionary 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;
+ }
+
+ /// Render and encode to PNG bytes.
+ public byte[] RenderToPng(
+ OverlayTemplate template,
+ IReadOnlyDictionary 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();
+ }
+
+ ///
+ /// Render using the template's and
+ /// write a PNG to .
+ ///
+ public void RenderToFile(
+ OverlayTemplate template,
+ IReadOnlyDictionary 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);
+ }
+}
diff --git a/src/RioJoy.Overlay/SkiaTextMeasurer.cs b/src/RioJoy.Overlay/SkiaTextMeasurer.cs
new file mode 100644
index 0000000..095ba0d
--- /dev/null
+++ b/src/RioJoy.Overlay/SkiaTextMeasurer.cs
@@ -0,0 +1,35 @@
+using System.Collections.Concurrent;
+using RioJoy.Core.Overlay;
+using SkiaSharp;
+
+namespace RioJoy.Overlay;
+
+///
+/// backed by SkiaSharp — the real metrics the
+/// 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.
+///
+public sealed class SkiaTextMeasurer : ITextMeasurer
+{
+ private readonly ConcurrentDictionary _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);
+ }
+}
diff --git a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
new file mode 100644
index 0000000..c0c65e5
--- /dev/null
+++ b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
@@ -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;
+
+///
+/// Profile editor: shows the cockpit authoring sheet (its logical layout, from
+/// ) as a scrollable, clickable grid and edits the
+/// selected address cell's label and action/modifiers/lamp. Writes back to the
+/// 's (keyed by the
+/// wallpaper region b-XX) and (the iRIO
+/// word); Save raises .
+///
+[SupportedOSPlatform("windows")]
+public sealed class ProfileEditorForm : Form
+{
+ private readonly RioProfile _profile;
+ private readonly IReadOnlyList _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;
+
+ /// A pick-list entry: what the user sees and the byte it maps to.
+ private sealed record ValueItem(string Display, byte Value)
+ {
+ public override string ToString() => Display;
+ }
+
+ /// Raised when the user saves; the argument is the edited profile.
+ public event Action? Saved;
+
+ public ProfileEditorForm(IReadOnlyList 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());
+ _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.";
+ }
+
+ /// The selected RIO address (for tests / offline rendering).
+ 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())
+ _valueCombo.Items.Add(new ValueItem(m.ToString(), (byte)m));
+ break;
+ case RioRouteKind.RioCommand:
+ foreach (RioCommandCode c in Enum.GetValues())
+ _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}";
+
+ /// Select an address programmatically (for tests / offline preview).
+ public void SelectAddress(int address)
+ {
+ SheetCell? cell = _cells.FirstOrDefault(c => c.Address == address);
+ if (cell is not null)
+ {
+ _canvas.Select(address);
+ OnAddressSelected(cell);
+ }
+ }
+}
diff --git a/src/RioJoy.Tray/Editor/SheetCanvas.cs b/src/RioJoy.Tray/Editor/SheetCanvas.cs
new file mode 100644
index 0000000..0a070f8
--- /dev/null
+++ b/src/RioJoy.Tray/Editor/SheetCanvas.cs
@@ -0,0 +1,57 @@
+using System.Runtime.Versioning;
+using RioJoy.Core.Editing;
+
+namespace RioJoy.Tray.Editor;
+
+///
+/// Scrollable cockpit-sheet grid: draws the cells (via ) at a
+/// fixed cell size and raises when an address cell is
+/// clicked. Sized to the full grid so the host panel scrolls it.
+///
+[SupportedOSPlatform("windows")]
+internal sealed class SheetCanvas : Control
+{
+ private const int CellW = 62;
+ private const int CellH = 22;
+
+ private IReadOnlyList _cells = Array.Empty();
+
+ public SheetCanvas()
+ {
+ DoubleBuffered = true;
+ BackColor = Color.FromArgb(28, 28, 28);
+ }
+
+ /// The RIO address currently highlighted, or null.
+ public int? SelectedAddress { get; private set; }
+
+ public event Action? AddressSelected;
+
+ public void SetCells(IReadOnlyList 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();
+ }
+ }
+}
diff --git a/src/RioJoy.Tray/Editor/SheetView.cs b/src/RioJoy.Tray/Editor/SheetView.cs
new file mode 100644
index 0000000..5c80ca9
--- /dev/null
+++ b/src/RioJoy.Tray/Editor/SheetView.cs
@@ -0,0 +1,83 @@
+using System.Runtime.Versioning;
+using RioJoy.Core.Editing;
+
+namespace RioJoy.Tray.Editor;
+
+///
+/// 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 and offline bitmap rendering.
+///
+[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 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 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);
+ }
+ }
+ }
+
+ /// The address cell at a pixel point, or null.
+ public static SheetCell? HitTest(IReadOnlyList 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);
+ }
+}
diff --git a/src/RioJoy.Tray/RioCoordinator.cs b/src/RioJoy.Tray/RioCoordinator.cs
index bc3c8fe..a6e4613 100644
--- a/src/RioJoy.Tray/RioCoordinator.cs
+++ b/src/RioJoy.Tray/RioCoordinator.cs
@@ -3,8 +3,10 @@ using RioJoy.Core;
using RioJoy.Core.Calibration;
using RioJoy.Core.Mapping;
using RioJoy.Core.Output;
+using RioJoy.Core.Overlay;
using RioJoy.Core.Profiles;
using RioJoy.Core.Serial;
+using RioJoy.Overlay;
namespace RioJoy.Tray;
@@ -141,7 +143,48 @@ public sealed class RioCoordinator : IDisposable
{
Teardown();
SetStatus($"Error opening {port}: {ex.Message}");
+ return;
}
+
+ ApplyWallpaper(profile);
+ }
+
+ ///
+ /// Generate the profile's cockpit wallpaper from the configured overlay template
+ /// and apply it. Best-effort and opt-in (only when
+ /// is set), so a missing template or render failure never breaks activation.
+ ///
+ 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)
diff --git a/src/RioJoy.Tray/RioJoy.Tray.csproj b/src/RioJoy.Tray/RioJoy.Tray.csproj
index 65b4e9f..a5f1f5c 100644
--- a/src/RioJoy.Tray/RioJoy.Tray.csproj
+++ b/src/RioJoy.Tray/RioJoy.Tray.csproj
@@ -13,6 +13,7 @@
+
diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs
index bf526bc..53d466d 100644
--- a/src/RioJoy.Tray/TrayApplicationContext.cs
+++ b/src/RioJoy.Tray/TrayApplicationContext.cs
@@ -1,6 +1,8 @@
using System.Runtime.Versioning;
+using RioJoy.Core.Editing;
using RioJoy.Core.Mapping;
using RioJoy.Core.Profiles;
+using RioJoy.Tray.Editor;
using RioJoy.Tray.Os;
namespace RioJoy.Tray;
@@ -67,6 +69,8 @@ internal sealed class TrayApplicationContext : ApplicationContext
RebuildProfileMenu(profileMenu);
menu.Items.Add(profileMenu);
+ menu.Items.Add("Edit profile…", null, (_, _) => OpenEditor());
+
// RIO commands mirroring the legacy console menu.
var commands = new ToolStripMenuItem("RIO commands");
AddCommand(commands, "Reset all analog axes", RioCommandCode.ResetAllAxes);
@@ -129,6 +133,48 @@ internal sealed class TrayApplicationContext : ApplicationContext
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 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)
{
var item = new ToolStripMenuItem(text);
diff --git a/src/RioJoy.Tray/WallpaperApplier.cs b/src/RioJoy.Tray/WallpaperApplier.cs
new file mode 100644
index 0000000..024f9fb
--- /dev/null
+++ b/src/RioJoy.Tray/WallpaperApplier.cs
@@ -0,0 +1,42 @@
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+namespace RioJoy.Tray;
+
+///
+/// Sets the Windows desktop wallpaper to a generated cockpit overlay
+/// (Phase 7 — the modern replacement for manually setting the GIMP-exported
+/// background). Wraps SystemParametersInfo(SPI_SETDESKWALLPAPER).
+///
+/// Windows requires a BMP 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.
+///
+[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
+
+ ///
+ /// Apply as the desktop wallpaper. Returns true on
+ /// success. Throws if the file does not exist.
+ ///
+ 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);
+}
diff --git a/tests/RioJoy.Core.Tests/Editing/SheetLayoutTests.cs b/tests/RioJoy.Core.Tests/Editing/SheetLayoutTests.cs
new file mode 100644
index 0000000..a025a27
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Editing/SheetLayoutTests.cs
@@ -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 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 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}");
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Mapping/ButtonBindingTests.cs b/tests/RioJoy.Core.Tests/Mapping/ButtonBindingTests.cs
new file mode 100644
index 0000000..5d67faa
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Mapping/ButtonBindingTests.cs
@@ -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);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Mapping/KeyCatalogTests.cs b/tests/RioJoy.Core.Tests/Mapping/KeyCatalogTests.cs
new file mode 100644
index 0000000..7255904
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Mapping/KeyCatalogTests.cs
@@ -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());
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Overlay/CockpitRegionsTests.cs b/tests/RioJoy.Core.Tests/Overlay/CockpitRegionsTests.cs
new file mode 100644
index 0000000..26d7e5e
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Overlay/CockpitRegionsTests.cs
@@ -0,0 +1,68 @@
+using RioJoy.Core.Overlay;
+using Xunit;
+
+namespace RioJoy.Core.Tests.Overlay;
+
+///
+/// Regression tests over the committed cockpit regions.json extracted from
+/// riojoy.xcf by tools/XcfRegionExtract. Anchors the extracted geometry and proves
+/// the asset loads through .
+///
+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");
+ });
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Overlay/FontFitterTests.cs b/tests/RioJoy.Core.Tests/Overlay/FontFitterTests.cs
new file mode 100644
index 0000000..a3a0d32
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Overlay/FontFitterTests.cs
@@ -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);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Overlay/GoobieDataImporterTests.cs b/tests/RioJoy.Core.Tests/Overlay/GoobieDataImporterTests.cs
new file mode 100644
index 0000000..547f154
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Overlay/GoobieDataImporterTests.cs
@@ -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 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"]);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Overlay/LinearTextMeasurer.cs b/tests/RioJoy.Core.Tests/Overlay/LinearTextMeasurer.cs
new file mode 100644
index 0000000..c154578
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Overlay/LinearTextMeasurer.cs
@@ -0,0 +1,23 @@
+using RioJoy.Core.Overlay;
+
+namespace RioJoy.Core.Tests.Overlay;
+
+///
+/// Deterministic fake 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.
+///
+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);
+}
diff --git a/tests/RioJoy.Core.Tests/Overlay/OverlayLayoutEngineTests.cs b/tests/RioJoy.Core.Tests/Overlay/OverlayLayoutEngineTests.cs
new file mode 100644
index 0000000..0c66422
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Overlay/OverlayLayoutEngineTests.cs
@@ -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
+ {
+ ["b-00"] = "FIRE",
+ ["b-01"] = "", // empty -> skipped
+ ["b-02"] = "GEAR",
+ ["b-99"] = "ORPHAN", // no such region -> ignored
+ };
+
+ IReadOnlyList 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);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Overlay/OverlayRenderIntegrationTests.cs b/tests/RioJoy.Core.Tests/Overlay/OverlayRenderIntegrationTests.cs
new file mode 100644
index 0000000..c43e622
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Overlay/OverlayRenderIntegrationTests.cs
@@ -0,0 +1,50 @@
+using RioJoy.Core.Overlay;
+using RioJoy.Overlay;
+using SkiaSharp;
+using Xunit;
+
+namespace RioJoy.Core.Tests.Overlay;
+
+///
+/// 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).
+///
+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 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");
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Overlay/OverlayTemplateStoreTests.cs b/tests/RioJoy.Core.Tests/Overlay/OverlayTemplateStoreTests.cs
new file mode 100644
index 0000000..d98b02d
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Overlay/OverlayTemplateStoreTests.cs
@@ -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);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Overlay/ProfileWallpaperGeneratorTests.cs b/tests/RioJoy.Core.Tests/Overlay/ProfileWallpaperGeneratorTests.cs
new file mode 100644
index 0000000..96cfa88
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Overlay/ProfileWallpaperGeneratorTests.cs
@@ -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 { ["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(() =>
+ new ProfileWallpaperGenerator().Generate(
+ template, ".", new Dictionary(),
+ Path.Combine(Path.GetTempPath(), $"x-{Guid.NewGuid():N}.png")));
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Overlay/SkiaOverlayRendererTests.cs b/tests/RioJoy.Core.Tests/Overlay/SkiaOverlayRendererTests.cs
new file mode 100644
index 0000000..89db36b
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Overlay/SkiaOverlayRendererTests.cs
@@ -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 { ["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 { ["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(), 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 { ["x"] = "HI" }, baseImg);
+
+ Assert.True(png.Length > 8);
+ Assert.Equal(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }, png[..8]);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Overlay/TestRepo.cs b/tests/RioJoy.Core.Tests/Overlay/TestRepo.cs
new file mode 100644
index 0000000..23ff62e
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Overlay/TestRepo.cs
@@ -0,0 +1,16 @@
+namespace RioJoy.Core.Tests;
+
+/// Locates committed reference assets relative to the repo root.
+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);
+}
diff --git a/tests/RioJoy.Core.Tests/RioJoy.Core.Tests.csproj b/tests/RioJoy.Core.Tests/RioJoy.Core.Tests.csproj
index 58ada06..650566e 100644
--- a/tests/RioJoy.Core.Tests/RioJoy.Core.Tests.csproj
+++ b/tests/RioJoy.Core.Tests/RioJoy.Core.Tests.csproj
@@ -17,6 +17,7 @@
+
diff --git a/tools/XcfRegionExtract/Program.cs b/tools/XcfRegionExtract/Program.cs
new file mode 100644
index 0000000..26b84c3
--- /dev/null
+++ b/tools/XcfRegionExtract/Program.cs
@@ -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 [-- ]
+//
+// 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();
+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(StringComparer.OrdinalIgnoreCase) { "a-00" };
+for (int a = 0x10; a <= 0x1F; a++) rotateCcw90.Add($"b-{a:X2}");
+
+var regions = new List