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(); +foreach (long lp in layerPtrs) +{ + pos = (int)lp; + uint lw = U32(), lh = U32(); _ = U32(); // width, height, type + string name = XcfString(); + int dx = 0, dy = 0; string? parasite = null; + + while (true) + { + uint t = U32(); uint l = U32(); + if (t == 0) break; + int next = pos + (int)l; + if (t == 15) { dx = I32(); dy = I32(); } // PROP_OFFSETS + else if (t == 21) // PROP_PARASITES + { + while (pos < next) + { + string pname = XcfString(); + _ = U32(); // flags + uint psize = U32(); + string pdata = Encoding.UTF8.GetString(d, pos, (int)psize); pos += (int)psize; + if (pname == "gimp-text-layer") parasite = pdata; + } + } + pos = next; + } + + if (parasite is null) continue; // only text layers are label cells + string font = fontRx.Match(parasite) is { Success: true } fm ? fm.Groups[1].Value : "Sans"; + string sizeStr = sizeRx.Match(parasite) is { Success: true } sm ? sm.Groups[1].Value : "12"; + double sizePx = double.TryParse(sizeStr, NumberStyles.Float, CultureInfo.InvariantCulture, out double sp) ? sp : 12; + string? colorHex = ToHex(colorRx.Match(parasite)); + + regions.Add(new + { + Name = name, + X = dx, + Y = dy, + Width = (int)lw, + Height = (int)lh, + FontFamily = string.IsNullOrEmpty(font) ? "Sans" : font, + FontSizePx = sizePx, + ColorHex = colorHex, + RotationDegrees = rotateCcw90.Contains(name) ? 90.0 : (double?)null, + }); +} + +var template = new { BaseImagePath = baseImage, Width = width, Height = height, Regions = regions }; +var opts = new JsonSerializerOptions +{ + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, +}; +File.WriteAllText(outPath, JsonSerializer.Serialize(template, opts)); +Console.WriteLine($"Wrote {regions.Count} regions -> {outPath}"); +return 0; diff --git a/tools/XcfRegionExtract/README.md b/tools/XcfRegionExtract/README.md new file mode 100644 index 0000000..dcee43c --- /dev/null +++ b/tools/XcfRegionExtract/README.md @@ -0,0 +1,31 @@ +# XcfRegionExtract + +Extracts the cockpit label cells from the GIMP source art +([`docs/reference/customBackground/riojoy.xcf`](../../docs/reference/customBackground/riojoy.xcf)) +into a `regions.json` in `OverlayTemplate` shape — the geometry the Phase 7 +overlay generator (`RioJoy.Core.Overlay`) lays text into. + +Each **named text layer** becomes a region: `Name` = layer name (e.g. `b-00`, +`Throttle`), geometry = the layer's box (`PROP_OFFSETS` + width/height), and +`FontFamily`/`FontSizePx` from the layer's `gimp-text-layer` parasite. Only layer +metadata is read (no tile/pixel data), so XCF tile compression is irrelevant. + +## Run + +```cmd +dotnet run --project tools/XcfRegionExtract +``` + +Defaults read `docs/reference/customBackground/riojoy.xcf` and overwrite +`docs/reference/customBackground/regions.json` (run from the repo root). Override: + +```cmd +dotnet run --project tools/XcfRegionExtract -- path\to\in.xcf path\to\out.json +``` + +Re-run whenever the cockpit artwork changes. The committed `regions.json` is the +canonical extract; `OverlayTemplate.BaseImagePath` is left null and must point at +the exported base cockpit image used as the wallpaper background. + +> Supports XCF version 0 (`gimp xcf file`) through current; pointers are 64-bit at +> version ≥ 11, 32-bit otherwise. diff --git a/tools/XcfRegionExtract/XcfRegionExtract.csproj b/tools/XcfRegionExtract/XcfRegionExtract.csproj new file mode 100644 index 0000000..3553741 --- /dev/null +++ b/tools/XcfRegionExtract/XcfRegionExtract.csproj @@ -0,0 +1,10 @@ + + + Exe + net8.0 + enable + enable + XcfRegionExtract + + +