diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e234c0a..81cf502 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1064,5 +1064,8 @@ git diff # Bad - will paginate For additional context about technologies to be used, project structure, -shell commands, and other important information, read the current plan +shell commands, and other important information, read the current plan at +`specs/006-multimon-gpu-efficiency/plan.md` and its supporting research, +data model, contracts (`specs/006-multimon-gpu-efficiency/contracts/`), +and quickstart documents. diff --git a/.specify/feature.json b/.specify/feature.json new file mode 100644 index 0000000..b6d319a --- /dev/null +++ b/.specify/feature.json @@ -0,0 +1 @@ +{"feature_directory":"specs/006-multimon-gpu-efficiency"} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 073d01e..9983908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to MatrixRain are documented in this file. ## [Unreleased] +### Added + +- **User Story 1 (P1) — Runtime topology and device-loss recovery.** MatrixRain now responds to monitors being added or removed while running (WM_DISPLAYCHANGE, coalesced across windows) and to the active GPU becoming unavailable (driver reset, sleep/resume, eGPU unplugged - detected via Present HRESULT). The render context is rebuilt automatically; this fixes the previously-reported "GPU stuck at ~90% after undocking a Surface Book 3" defect where the ghost monitor's render thread continued processing forever. +- **User Story 2 (P1) — Optional multi-monitor spanning.** New "Render on all monitors" checkbox in the configuration dialog (default on). Toggling it live applies within 1 second; Cancel reverts. +- **User Story 3 (P2) — GPU adapter selection.** New "GPU" dropdown in the configuration dialog listing each real adapter name (with "(default)" appended to the system default). Software/WARP adapters are excluded. Selection persists by description string; if the saved adapter is missing at startup, the application silently falls back to the system default. Live device-switch takes effect within 1 second. +- **User Story 4 (P2) — Frame cap on high-refresh monitors.** Per-monitor `FrameLimiter` engages only when the monitor's native refresh exceeds 60 Hz, capping that monitor's rendering to 60 FPS. At ≤60 Hz the existing vsync path is preserved with no measurable per-frame overhead. Substantially reduces GPU work on 144Hz / 165Hz laptop displays. +- **User Story 5 (P3) — Graphics quality preset spectrum.** New "Quality" slider (Low / Medium / High / Custom) in a "Graphics quality" group box. Three discrete tuning sliders are always visible — Glow passes (1-4), Glow resolution (Eighth / Quarter / Half / Full), Glow smoothness (Low / Medium / High). Each quality-related control has an "ⓘ" infotip; hovering or tabbing-then-pressing-Space/Enter reveals a tooltip with a description and standardized GPU-performance-impact phrase. Glow Intensity at 0% now disables the entire bloom pipeline (true off, not just darker). First-run heuristic picks a starting preset based on detected GPU class and total monitor pixel count (discrete → High; integrated + modest load → Medium; integrated + heavy load → Low). Custom-drift behaviour: any direct edit of an advanced control auto-flips the preset to Custom and saves the resulting values for restoration when the user later re-selects Custom. + +### Changed + +- Bloom pipeline is now runtime-parametric: blur passes (1-4), bloom buffer resolution (full/half/quarter/eighth), and blur kernel taps (5 / 9 / 13) are all selectable per-frame from the quality preset / advanced sliders. Three blur shader variants are compiled at startup so the tap-count switch is free at draw time. +- Default settings on a fresh install now pick a quality preset matched to detected hardware rather than always using the maximum. +- Reset button in the configuration dialog now restores every v1.4 setting (multi-monitor, GPU, quality preset, glow passes / resolution / smoothness) in addition to the v1.3 controls it previously covered. + +### Removed + +- The backtick (`) debug hotkey that toggled the per-character fade-timer overlay. (The "Show fade timers" dialog checkbox is unchanged.) + +### Known Issues + +- None known for v1.4 functionality. + ## [1.3.1984] - 2026-06-03 ### Added diff --git a/MatrixRain/ConfigDialog.cpp b/MatrixRain/ConfigDialog.cpp index 38bcf59..cbe9567 100644 --- a/MatrixRain/ConfigDialog.cpp +++ b/MatrixRain/ConfigDialog.cpp @@ -1,10 +1,15 @@ #include "pch.h" +#include +#pragma comment(lib, "comctl32.lib") + #include "ConfigDialog.h" +#include "..\MatrixRainCore\AdapterSelection.h" #include "..\MatrixRainCore\Application.h" #include "..\MatrixRainCore\ApplicationState.h" #include "..\MatrixRainCore\ConfigDialogController.h" #include "..\MatrixRainCore\CommandLine.h" +#include "..\MatrixRainCore\WindowsAdapterProvider.h" #include "resource.h" @@ -18,9 +23,78 @@ struct DialogContext Application * m_pApp = nullptr; bool m_ownsContextMemory = false; bool m_isScreenSaverCPL = false; + + // Parallel to IDC_GPU_COMBO entries: each index holds the underlying + // DXGI adapter description so OnGpuChange can map a selection back to + // the persistence string. The OS default adapter is annotated with + // " (default)" in its display label; no synthetic + // entry is added (the user picks the default adapter by name directly). + std::vector m_gpuAdapterDescriptions; + + // T058/T059 - shared tooltip control + the keyboard-activation TTF_TRACK + // tool whose text we update on each info-button BN_CLICKED. + HWND m_hTooltip = nullptr; + + // Larger font used to render the info-tip ⓘ glyph at 1.5x the dialog's + // default font size in the BS_OWNERDRAW button paint path. Created in + // OnInitDialog from the dialog's WM_GETFONT; destroyed in OnDestroy. + HFONT m_hInfoTipFont = nullptr; }; +// Sentinel uId for the single TTF_TRACK tool used by keyboard activation. +static constexpr UINT_PTR kTrackTipUId = 0xC0C00C0Cu; + +// Subclass id for the info-button mouse-event relay (TTF_SUBCLASS proved +// unreliable on BS_OWNERDRAW buttons; we explicitly forward mouse events +// to the tooltip via TTM_RELAYEVENT instead). +static constexpr UINT_PTR kInfoButtonSubclassId = 0xC0C00C1Bu; + + + + +static LRESULT CALLBACK InfoButtonSubclassProc (HWND hWnd, + UINT msg, + WPARAM wParam, + LPARAM lParam, + UINT_PTR uIdSubclass, + DWORD_PTR dwRefData) +{ + HWND hTooltip = reinterpret_cast (dwRefData); + + + if (hTooltip) + { + switch (msg) + { + case WM_MOUSEMOVE: + case WM_LBUTTONDOWN: + case WM_LBUTTONUP: + case WM_RBUTTONDOWN: + case WM_RBUTTONUP: + case WM_MBUTTONDOWN: + case WM_MBUTTONUP: + { + MSG ttMsg = {}; + ttMsg.hwnd = hWnd; + ttMsg.message = msg; + ttMsg.wParam = wParam; + ttMsg.lParam = lParam; + SendMessageW (hTooltip, TTM_RELAYEVENT, 0, reinterpret_cast (&ttMsg)); + break; + } + } + } + + if (msg == WM_NCDESTROY) + { + RemoveWindowSubclass (hWnd, InfoButtonSubclassProc, uIdSubclass); + } + + return DefSubclassProc (hWnd, msg, wParam, lParam); +} + + @@ -71,6 +145,309 @@ static Application * GetApplicationFromDialog (HWND hDlg) +//////////////////////////////////////////////////////////////////////////////// +// +// GetInfoTipText — locked infotip strings (per the spec contract, +// FR-036: descriptive sentence + standardized perf-impact sentence). +// +//////////////////////////////////////////////////////////////////////////////// + +static const wchar_t * GetInfoTipText (int infoId) +{ + switch (infoId) + { + case IDC_QUALITY_PRESET_INFO: + return L"Picks a graphics quality preset. Higher presets look richer but " + L"use more GPU. Custom lets you tune the individual settings below.\r\n" + L"\r\n" + L"Significant GPU performance impact."; + + case IDC_GLOWINTENSITY_INFO: + return L"Brightness of the glow effect around bright characters. Setting " + L"this to 0% disables the glow effect entirely.\r\n" + L"\r\n" + L"Significant GPU performance impact."; + + case IDC_GLOWSIZE_INFO: + return L"Width of the glow halo around bright characters."; + + case IDC_GLOWPASSES_INFO: + return L"How many times the glow is blurred. Each pass roughly doubles the " + L"glow's width.\r\n" + L"\r\n" + L"Significant GPU performance impact."; + + case IDC_GLOWRES_INFO: + return L"Resolution the glow is computed at. Lower is much cheaper and only " + L"slightly softer; Quarter is about 4x cheaper than Full.\r\n" + L"\r\n" + L"Significant GPU performance impact."; + + case IDC_GLOWSMOOTH_INFO: + return L"Number of samples per blur step. Higher gives smoother gradients " + L"with no banding.\r\n" + L"\r\n" + L"Moderate GPU performance impact."; + + default: + return L""; + } +} + + + + +static bool IsInfoTipControlId (int id) +{ + switch (id) + { + case IDC_QUALITY_PRESET_INFO: + case IDC_GLOWINTENSITY_INFO: + case IDC_GLOWSIZE_INFO: + case IDC_GLOWPASSES_INFO: + case IDC_GLOWRES_INFO: + case IDC_GLOWSMOOTH_INFO: + return true; + default: + return false; + } +} + + + + +static int TickFrequencyForSliderId (int id) +{ + switch (id) + { + case IDC_DENSITY_SLIDER: return 5; + case IDC_ANIMSPEED_SLIDER: return 5; + case IDC_GLOWINTENSITY_SLIDER: return 10; + case IDC_GLOWSIZE_SLIDER: return 25; // 50..200 -> 7 ticks at 50,75,100,125,150,175,200 (midpoint 125 preserved per tick-mark-conventions.md) + case IDC_QUALITY_PRESET_SLIDER: return 1; + case IDC_GLOWPASSES_SLIDER: return 1; + case IDC_GLOWRES_SLIDER: return 1; + case IDC_GLOWSMOOTH_SLIDER: return 1; + default: return 1; + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// CreateAndRegisterTooltip +// +// Creates a shared WC_TOOLTIPS window for the config dialog and registers +// every IDC_*_INFO button as a tool with TTF_IDISHWND | TTF_SUBCLASS, with +// per-tool text supplied via TTN_GETDISPINFO (handled in ConfigDialogProc). +// +//////////////////////////////////////////////////////////////////////////////// + +static HWND CreateAndRegisterTooltip (HWND hDlg) +{ + static const int kInfoIds[] = + { + IDC_QUALITY_PRESET_INFO, + IDC_GLOWINTENSITY_INFO, + IDC_GLOWSIZE_INFO, + IDC_GLOWPASSES_INFO, + IDC_GLOWRES_INFO, + IDC_GLOWSMOOTH_INFO, + }; + + + HWND hTooltip = CreateWindowExW (WS_EX_TOPMOST, + TOOLTIPS_CLASS, + nullptr, + WS_POPUP | TTS_ALWAYSTIP | TTS_NOPREFIX, + CW_USEDEFAULT, CW_USEDEFAULT, + CW_USEDEFAULT, CW_USEDEFAULT, + hDlg, nullptr, nullptr, nullptr); + + if (!hTooltip) + { + return nullptr; + } + + SendMessageW (hTooltip, TTM_SETMAXTIPWIDTH, 0, 300); + SendMessageW (hTooltip, TTM_ACTIVATE, TRUE, 0); + + + for (int infoId : kInfoIds) + { + HWND hCtrl = GetDlgItem (hDlg, infoId); + + if (!hCtrl) + { + continue; + } + + TOOLINFOW ti = { sizeof (TOOLINFOW) }; + ti.uFlags = TTF_IDISHWND; + ti.hwnd = hDlg; + ti.uId = (UINT_PTR) hCtrl; + ti.lpszText = LPSTR_TEXTCALLBACKW; + + SendMessageW (hTooltip, TTM_ADDTOOLW, 0, (LPARAM) &ti); + + // Manually subclass the button to forward mouse events to the + // tooltip — TTF_SUBCLASS proved unreliable on BS_OWNERDRAW buttons. + SetWindowSubclass (hCtrl, + InfoButtonSubclassProc, + kInfoButtonSubclassId, + reinterpret_cast (hTooltip)); + } + + + // Single TTF_TRACK tool used for keyboard-activated tips (T059). Its + // text is updated on each info-button BN_CLICKED before we call + // TTM_TRACKPOSITION + TTM_TRACKACTIVATE. + TOOLINFOW trackTool = { sizeof (TOOLINFOW) }; + trackTool.uFlags = TTF_TRACK | TTF_ABSOLUTE; + trackTool.hwnd = hDlg; + trackTool.uId = kTrackTipUId; + trackTool.lpszText = const_cast (L""); + + SendMessageW (hTooltip, TTM_ADDTOOLW, 0, (LPARAM) &trackTool); + + return hTooltip; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnInfoButtonClick — keyboard-activated tooltip for an IDC_*_INFO button +// (T059). Updates the shared TTF_TRACK tool's text to the matching infotip +// string, positions it just below/right of the button, and activates it. +// Auto-dismisses on a 5-second timer (handled in ConfigDialogProc). +// +//////////////////////////////////////////////////////////////////////////////// + +static constexpr UINT_PTR kInfoTipDismissTimerId = 0xC0C0C0DEu; + +static void OnInfoButtonClick (HWND hDlg, int infoId) +{ + DialogContext * pContext = GetDialogContext (hDlg); + + if (!pContext || !pContext->m_hTooltip) + { + return; + } + + HWND hButton = GetDlgItem (hDlg, infoId); + + if (!hButton) + { + return; + } + + RECT btnRect; + GetWindowRect (hButton, &btnRect); + + + // Update the TTF_TRACK tool's text and position. + TOOLINFOW ti = { sizeof (TOOLINFOW) }; + ti.hwnd = hDlg; + ti.uId = kTrackTipUId; + ti.lpszText = const_cast (GetInfoTipText (infoId)); + + SendMessageW (pContext->m_hTooltip, TTM_UPDATETIPTEXTW, 0, (LPARAM) &ti); + + SendMessageW (pContext->m_hTooltip, + TTM_TRACKPOSITION, + 0, + MAKELPARAM (btnRect.right + 4, btnRect.bottom + 2)); + + SendMessageW (pContext->m_hTooltip, TTM_TRACKACTIVATE, TRUE, (LPARAM) &ti); + + SetTimer (hDlg, kInfoTipDismissTimerId, 5000, nullptr); +} + + + + +static void DismissInfoTip (HWND hDlg) +{ + DialogContext * pContext = GetDialogContext (hDlg); + + if (!pContext || !pContext->m_hTooltip) + { + return; + } + + TOOLINFOW ti = { sizeof (TOOLINFOW) }; + ti.hwnd = hDlg; + ti.uId = kTrackTipUId; + + SendMessageW (pContext->m_hTooltip, TTM_TRACKACTIVATE, FALSE, (LPARAM) &ti); + KillTimer (hDlg, kInfoTipDismissTimerId); +} + + + + +static std::wstring FormatPercentLabel (int sliderId, int value) +{ + // Glow Intensity reads "0% (glow disabled)" at 0 (FR-031). + if (sliderId == IDC_GLOWINTENSITY_SLIDER && value == 0) + { + return std::wstring (L"0% (glow disabled)"); + } + + return std::format (L"{}%", value); +} + + + + +static const wchar_t * FormatResolutionLabel (int divisor) +{ + switch (divisor) + { + case 1: return L"Full"; + case 2: return L"Half"; + case 4: return L"Quarter"; + case 8: return L"Eighth"; + default: return L"Half"; + } +} + + + + +static const wchar_t * FormatSmoothnessLabel (int taps) +{ + switch (taps) + { + case 5: return L"Low"; + case 9: return L"Medium"; + case 13: return L"High"; + default: return L"High"; + } +} + + + + +static const wchar_t * FormatQualityPresetLabel (QualityPreset preset) +{ + switch (preset) + { + case QualityPreset::Low: return L"Low"; + case QualityPreset::Medium: return L"Medium"; + case QualityPreset::High: return L"High"; + case QualityPreset::Custom: return L"Custom"; + default: return L"Custom"; + } +} + + + + //////////////////////////////////////////////////////////////////////////////// // // InitializeSlider @@ -79,9 +456,101 @@ static Application * GetApplicationFromDialog (HWND hDlg) static void InitializeSlider (HWND hDlg, int sliderId, int labelId, int minValue, int maxValue, int currentValue) { - SendDlgItemMessageW (hDlg, sliderId, TBM_SETRANGE, TRUE, MAKELPARAM (minValue, maxValue)); + SendDlgItemMessageW (hDlg, sliderId, TBM_SETRANGE, TRUE, MAKELPARAM (minValue, maxValue)); + SendDlgItemMessageW (hDlg, sliderId, TBM_SETTICFREQ, TickFrequencyForSliderId (sliderId), 0); + + // Speed (1..100) at freq=5 lands the last tick at 96; add an explicit + // tick at 100 for the documented 21-tick total. + if (sliderId == IDC_ANIMSPEED_SLIDER) + { + SendDlgItemMessageW (hDlg, sliderId, TBM_SETTIC, 0, 100); + } + SendDlgItemMessageW (hDlg, sliderId, TBM_SETPOS, TRUE, currentValue); - SetDlgItemTextW (hDlg, labelId, std::format (L"{}%", currentValue).c_str()); + SetDlgItemTextW (hDlg, labelId, FormatPercentLabel (sliderId, currentValue).c_str()); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Discrete-slider initializers (Passes / Resolution / Smoothness). These +// use the same trackbar control but with small integer ranges and mapped +// labels rather than percentages. +// +//////////////////////////////////////////////////////////////////////////////// + +static void InitializePassesSlider (HWND hDlg, int currentPasses) +{ + SendDlgItemMessageW (hDlg, IDC_GLOWPASSES_SLIDER, TBM_SETRANGE, TRUE, MAKELPARAM (1, 4)); + SendDlgItemMessageW (hDlg, IDC_GLOWPASSES_SLIDER, TBM_SETTICFREQ, 1, 0); + SendDlgItemMessageW (hDlg, IDC_GLOWPASSES_SLIDER, TBM_SETPOS, TRUE, currentPasses); + SetDlgItemTextW (hDlg, IDC_GLOWPASSES_LABEL, std::format (L"{}", currentPasses).c_str()); +} + + +static void InitializeResolutionSlider (HWND hDlg, int currentDivisor) +{ + // Slider position 0..2 maps to divisor 4/2/1 (Quarter/Half/Full). + // Eighth (divisor 8) is no longer offered through the UI; if a saved + // configuration still has it, fall back to Quarter on display. + int pos = 1; // default Half + + switch (currentDivisor) + { + case 8: pos = 0; break; // Eighth folds onto Quarter for display + case 4: pos = 0; break; + case 2: pos = 1; break; + case 1: pos = 2; break; + } + + SendDlgItemMessageW (hDlg, IDC_GLOWRES_SLIDER, TBM_SETRANGE, TRUE, MAKELPARAM (0, 2)); + SendDlgItemMessageW (hDlg, IDC_GLOWRES_SLIDER, TBM_SETTICFREQ, 1, 0); + SendDlgItemMessageW (hDlg, IDC_GLOWRES_SLIDER, TBM_SETPOS, TRUE, pos); + SetDlgItemTextW (hDlg, IDC_GLOWRES_LABEL, FormatResolutionLabel (currentDivisor)); +} + + +static void InitializeSmoothnessSlider (HWND hDlg, int currentTaps) +{ + int pos = 2; // default High + + switch (currentTaps) + { + case 5: pos = 0; break; + case 9: pos = 1; break; + case 13: pos = 2; break; + } + + SendDlgItemMessageW (hDlg, IDC_GLOWSMOOTH_SLIDER, TBM_SETRANGE, TRUE, MAKELPARAM (0, 2)); + SendDlgItemMessageW (hDlg, IDC_GLOWSMOOTH_SLIDER, TBM_SETTICFREQ, 1, 0); + SendDlgItemMessageW (hDlg, IDC_GLOWSMOOTH_SLIDER, TBM_SETPOS, TRUE, pos); + SetDlgItemTextW (hDlg, IDC_GLOWSMOOTH_LABEL, FormatSmoothnessLabel (currentTaps)); +} + + +static int ResolutionSliderPosToDivisor (int pos) +{ + switch (pos) + { + case 0: return 4; + case 1: return 2; + case 2: return 1; + default: return 2; + } +} + + +static int SmoothnessSliderPosToTaps (int pos) +{ + switch (pos) + { + case 0: return 5; + case 1: return 9; + case 2: return 13; + default: return 13; + } } @@ -152,6 +621,68 @@ static void InitializeColorSchemeCombo (HWND hDlg, const std::wstring & currentS +//////////////////////////////////////////////////////////////////////////////// +// +// InitializeGpuCombo +// +// Enumerate the system's rendering adapters via WindowsAdapterProvider and +// populate IDC_GPU_COMBO with their real names; the OS default adapter is +// annotated with " (default)" via FormatAdapterLabel. The user picks the +// default adapter by name directly (no synthetic entry). +// +// Selection logic: +// - currentDescription matches an enumerated adapter -> select that entry. +// - currentDescription is empty or doesn't match -> select the (default) +// adapter so the UI still highlights what is actually running. +// +//////////////////////////////////////////////////////////////////////////////// + +static void InitializeGpuCombo (HWND hDlg, DialogContext * pContext, const std::wstring & currentDescription) +{ + WindowsAdapterProvider provider; + std::vector adapters = provider.EnumerateAdapters(); + int selected = -1; + int defaultIndex = -1; + + + pContext->m_gpuAdapterDescriptions.clear(); + + for (const AdapterInfo & adapter : adapters) + { + std::wstring label = FormatAdapterLabel (adapter); + + SendDlgItemMessageW (hDlg, IDC_GPU_COMBO, CB_ADDSTRING, 0, (LPARAM) label.c_str()); + + pContext->m_gpuAdapterDescriptions.push_back (adapter.m_description); + + int idx = static_cast (pContext->m_gpuAdapterDescriptions.size()) - 1; + + if (adapter.m_isDefault) + { + defaultIndex = idx; + } + + if (!currentDescription.empty() && adapter.m_description == currentDescription) + { + selected = idx; + } + } + + if (selected < 0) + { + selected = defaultIndex; + } + + if (selected >= 0) + { + SendDlgItemMessageW (hDlg, IDC_GPU_COMBO, CB_SETCURSEL, selected, 0); + } +} + + + + + //////////////////////////////////////////////////////////////////////////////// // // OnInitDialog @@ -255,9 +786,48 @@ static BOOL OnInitDialog (HWND hDlg, LPARAM initParam) pSettings->m_glowSizePercent); InitializeColorSchemeCombo (hDlg, pSettings->m_colorSchemeKey); + InitializeGpuCombo (hDlg, pContext, pSettings->m_gpuAdapter); + + // Quality preset combo + advanced disclosure. Three named presets + + // Custom; the dialog code selects whichever matches the loaded + // settings. + // Quality preset slider (0=Low, 1=Medium, 2=High, 3=Custom). Replaces + // the v1.4 combobox per UX feedback — discrete trackbar with named + // value label matches the rest of the dialog's slider+label pattern. + SendDlgItemMessageW (hDlg, IDC_QUALITY_PRESET_SLIDER, TBM_SETRANGE, TRUE, MAKELPARAM (0, 3)); + SendDlgItemMessageW (hDlg, IDC_QUALITY_PRESET_SLIDER, TBM_SETTICFREQ, 1, 0); + SendDlgItemMessageW (hDlg, IDC_QUALITY_PRESET_SLIDER, TBM_SETPOS, TRUE, static_cast (pSettings->m_qualityPreset)); + SetDlgItemTextW (hDlg, IDC_QUALITY_PRESET_LABEL, FormatQualityPresetLabel (pSettings->m_qualityPreset)); + + InitializePassesSlider (hDlg, pSettings->m_advancedValues.m_blurPasses); + InitializeResolutionSlider (hDlg, static_cast (pSettings->m_advancedValues.m_bloomResolutionDivisor)); + InitializeSmoothnessSlider (hDlg, static_cast (pSettings->m_advancedValues.m_blurTaps)); + + // Tooltip surface for the IDC_*_INFO indicators (FR-034/FR-035/FR-036). + // The TTN_GETDISPINFO notification is handled in ConfigDialogProc. + pContext->m_hTooltip = CreateAndRegisterTooltip (hDlg); + + // Create the 1.5x-size font used by the owner-drawn ⓘ glyphs. The + // base font comes from the dialog itself (WM_GETFONT). + { + HFONT hDialogFont = reinterpret_cast (SendMessageW (hDlg, WM_GETFONT, 0, 0)); + LOGFONTW lf = {}; + + if (hDialogFont && GetObjectW (hDialogFont, sizeof (lf), &lf)) + { + // lfHeight is negative for character-cell heights; multiply + // absolute value by 1.5 and preserve sign. + lf.lfHeight = (lf.lfHeight < 0) + ? -static_cast ((-lf.lfHeight) * 3 / 2) + : static_cast ( lf.lfHeight * 3 / 2); + + pContext->m_hInfoTipFont = CreateFontIndirectW (&lf); + } + } - CheckDlgButton (hDlg, IDC_STARTFULLSCREEN_CHECK, pSettings->m_startFullscreen ? BST_CHECKED : BST_UNCHECKED); - CheckDlgButton (hDlg, IDC_SHOWDEBUG_CHECK, pSettings->m_showDebugStats ? BST_CHECKED : BST_UNCHECKED); + CheckDlgButton (hDlg, IDC_STARTFULLSCREEN_CHECK, pSettings->m_startFullscreen ? BST_CHECKED : BST_UNCHECKED); + CheckDlgButton (hDlg, IDC_MULTIMONITOR_CHECK, pSettings->m_multiMonitorEnabled ? BST_CHECKED : BST_UNCHECKED); + CheckDlgButton (hDlg, IDC_SHOWDEBUG_CHECK, pSettings->m_showDebugStats ? BST_CHECKED : BST_UNCHECKED); // Hide fullscreen checkbox in screensaver CPL mode — screensaver always forces fullscreen if (pContext->m_isScreenSaverCPL) @@ -286,6 +856,8 @@ static BOOL OnInitDialog (HWND hDlg, LPARAM initParam) // //////////////////////////////////////////////////////////////////////////////// +static void OnQualityPresetChange (HWND hDlg); + static BOOL OnHScroll (HWND hDlg, LPARAM lParam) { HRESULT hr = S_OK; @@ -307,22 +879,84 @@ static BOOL OnHScroll (HWND hDlg, LPARAM lParam) { case IDC_DENSITY_SLIDER: pController->UpdateDensity (pos); - SetDlgItemTextW (hDlg, IDC_DENSITY_LABEL, std::format (L"{}%", pos).c_str()); + SetDlgItemTextW (hDlg, IDC_DENSITY_LABEL, FormatPercentLabel (ctrlId, pos).c_str()); break; case IDC_ANIMSPEED_SLIDER: pController->UpdateAnimationSpeed (pos); - SetDlgItemTextW (hDlg, IDC_ANIMSPEED_LABEL, std::format (L"{}%", pos).c_str()); + SetDlgItemTextW (hDlg, IDC_ANIMSPEED_LABEL, FormatPercentLabel (ctrlId, pos).c_str()); break; case IDC_GLOWINTENSITY_SLIDER: + { pController->UpdateGlowIntensity (pos); - SetDlgItemTextW (hDlg, IDC_GLOWINTENSITY_LABEL, std::format (L"{}%", pos).c_str()); + SetDlgItemTextW (hDlg, IDC_GLOWINTENSITY_LABEL, FormatPercentLabel (ctrlId, pos).c_str()); + + // Glow Intensity is part of the advanced-graphics value set; + // changing it drifts the preset to Custom (FR-023) the same + // way moving Passes / Resolution / Smoothness would. + AdvancedGraphicsValues v = pController->GetSettings().m_advancedValues; + v.m_glowIntensityPercent = pos; + pController->UpdateAdvancedGraphicsValues (v); + { + QualityPreset p = pController->GetSettings().m_qualityPreset; + SendDlgItemMessageW (hDlg, IDC_QUALITY_PRESET_SLIDER, TBM_SETPOS, TRUE, static_cast (p)); + SetDlgItemTextW (hDlg, IDC_QUALITY_PRESET_LABEL, FormatQualityPresetLabel (p)); + } break; + } case IDC_GLOWSIZE_SLIDER: pController->UpdateGlowSize (pos); - SetDlgItemTextW (hDlg, IDC_GLOWSIZE_LABEL, std::format (L"{}%", pos).c_str()); + SetDlgItemTextW (hDlg, IDC_GLOWSIZE_LABEL, FormatPercentLabel (ctrlId, pos).c_str()); + break; + + case IDC_GLOWPASSES_SLIDER: + { + AdvancedGraphicsValues v = pController->GetSettings().m_advancedValues; + v.m_blurPasses = pos; + pController->UpdateAdvancedGraphicsValues (v); + SetDlgItemTextW (hDlg, IDC_GLOWPASSES_LABEL, std::format (L"{}", pos).c_str()); + { + QualityPreset p = pController->GetSettings().m_qualityPreset; + SendDlgItemMessageW (hDlg, IDC_QUALITY_PRESET_SLIDER, TBM_SETPOS, TRUE, static_cast (p)); + SetDlgItemTextW (hDlg, IDC_QUALITY_PRESET_LABEL, FormatQualityPresetLabel (p)); + } + break; + } + + case IDC_GLOWRES_SLIDER: + { + int divisor = ResolutionSliderPosToDivisor (pos); + AdvancedGraphicsValues v = pController->GetSettings().m_advancedValues; + v.m_bloomResolutionDivisor = static_cast (divisor); + pController->UpdateAdvancedGraphicsValues (v); + SetDlgItemTextW (hDlg, IDC_GLOWRES_LABEL, FormatResolutionLabel (divisor)); + { + QualityPreset p = pController->GetSettings().m_qualityPreset; + SendDlgItemMessageW (hDlg, IDC_QUALITY_PRESET_SLIDER, TBM_SETPOS, TRUE, static_cast (p)); + SetDlgItemTextW (hDlg, IDC_QUALITY_PRESET_LABEL, FormatQualityPresetLabel (p)); + } + break; + } + + case IDC_GLOWSMOOTH_SLIDER: + { + int taps = SmoothnessSliderPosToTaps (pos); + AdvancedGraphicsValues v = pController->GetSettings().m_advancedValues; + v.m_blurTaps = static_cast (taps); + pController->UpdateAdvancedGraphicsValues (v); + SetDlgItemTextW (hDlg, IDC_GLOWSMOOTH_LABEL, FormatSmoothnessLabel (taps)); + { + QualityPreset p = pController->GetSettings().m_qualityPreset; + SendDlgItemMessageW (hDlg, IDC_QUALITY_PRESET_SLIDER, TBM_SETPOS, TRUE, static_cast (p)); + SetDlgItemTextW (hDlg, IDC_QUALITY_PRESET_LABEL, FormatQualityPresetLabel (p)); + } + break; + } + + case IDC_QUALITY_PRESET_SLIDER: + OnQualityPresetChange (hDlg); break; } @@ -366,6 +1000,91 @@ static void OnColorSchemeChange (HWND hDlg) +//////////////////////////////////////////////////////////////////////////////// +// +// OnGpuChange +// +//////////////////////////////////////////////////////////////////////////////// + +static void OnGpuChange (HWND hDlg) +{ + HRESULT hr = S_OK; + DialogContext * pContext = GetDialogContext (hDlg); + ConfigDialogController * pController = nullptr; + int index = 0; + + + + CBRAEx (pContext != nullptr && pContext->m_controller != nullptr, E_UNEXPECTED); + + pController = pContext->m_controller.get(); + + index = (int) SendDlgItemMessageW (hDlg, IDC_GPU_COMBO, CB_GETCURSEL, 0, 0); + + CBRAEx (index >= 0 && index < static_cast (pContext->m_gpuAdapterDescriptions.size()), E_UNEXPECTED); + + pController->UpdateGpuAdapter (pContext->m_gpuAdapterDescriptions[index]); + + // Live preview: post a rebuild so the running app recreates its + // contexts on the newly-chosen GPU within 1 second (FR-015). + if (Application * pApp = GetApplicationFromDialog (hDlg)) + { + pApp->ApplyDisplayModeChange(); + } + +Error: + return; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnQualityPresetChange +// +//////////////////////////////////////////////////////////////////////////////// + +static void OnQualityPresetChange (HWND hDlg) +{ + HRESULT hr = S_OK; + ConfigDialogController * pController = GetControllerFromDialog (hDlg); + int index = 0; + + + + CBRAEx (pController != nullptr, E_UNEXPECTED); + + index = (int) SendDlgItemMessageW (hDlg, IDC_QUALITY_PRESET_SLIDER, TBM_GETPOS, 0, 0); + + CBRAEx (index >= 0 && index <= 3, E_UNEXPECTED); + + pController->UpdateQualityPreset (static_cast (index)); + + { + // Reflect the snapped advanced values back into the three sliders and + // the Glow Intensity slider; the controller already updated them. + const ScreenSaverSettings & s = pController->GetSettings(); + + SetDlgItemTextW (hDlg, IDC_QUALITY_PRESET_LABEL, FormatQualityPresetLabel (s.m_qualityPreset)); + + SendDlgItemMessageW (hDlg, IDC_GLOWINTENSITY_SLIDER, TBM_SETPOS, TRUE, s.m_advancedValues.m_glowIntensityPercent); + SetDlgItemTextW (hDlg, IDC_GLOWINTENSITY_LABEL, + FormatPercentLabel (IDC_GLOWINTENSITY_SLIDER, s.m_advancedValues.m_glowIntensityPercent).c_str()); + + InitializePassesSlider (hDlg, s.m_advancedValues.m_blurPasses); + InitializeResolutionSlider (hDlg, static_cast (s.m_advancedValues.m_bloomResolutionDivisor)); + InitializeSmoothnessSlider (hDlg, static_cast (s.m_advancedValues.m_blurTaps)); + } + +Error: + return; +} + + + + //////////////////////////////////////////////////////////////////////////////// // // OnStartFullscreenCheck @@ -400,6 +1119,40 @@ static void OnStartFullscreenCheck (HWND hDlg) +//////////////////////////////////////////////////////////////////////////////// +// +// OnMultiMonitorCheck +// +//////////////////////////////////////////////////////////////////////////////// + +static void OnMultiMonitorCheck (HWND hDlg) +{ + HRESULT hr = S_OK; + ConfigDialogController * pController = GetControllerFromDialog (hDlg); + bool checked = IsDlgButtonChecked (hDlg, IDC_MULTIMONITOR_CHECK) == BST_CHECKED; + + + + CBRAEx (pController != nullptr, E_UNEXPECTED); + + pController->UpdateMultiMonitorEnabled (checked); + + // Live preview: post a rebuild so the running app applies the new + // multimon gate within the next message-loop tick. Reuses the same + // message handler the display-mode toggle relies on. + if (Application * pApp = GetApplicationFromDialog (hDlg)) + { + pApp->ApplyDisplayModeChange(); + } + +Error: + return; +} + + + + + //////////////////////////////////////////////////////////////////////////////// // // OnShowDebugCheck @@ -503,7 +1256,38 @@ static void OnResetButton (HWND hDlg) // Update color scheme combo box schemeIndex = ColorSchemeKeyToIndex (pDefaults->m_colorSchemeKey); SendDlgItemMessageW (hDlg, IDC_COLORSCHEME_COMBO, CB_SETCURSEL, schemeIndex, 0); - + + // v1.4 additions — multimon, GPU, quality preset slider, advanced sliders. + CheckDlgButton (hDlg, IDC_MULTIMONITOR_CHECK, pDefaults->m_multiMonitorEnabled ? BST_CHECKED : BST_UNCHECKED); + + { + DialogContext * pCtx = GetDialogContext (hDlg); + + if (pCtx) + { + // Re-resolve the default-marked entry in the existing combo. + int defaultIdx = 0; + + for (size_t i = 0; i < pCtx->m_gpuAdapterDescriptions.size(); i++) + { + if (pCtx->m_gpuAdapterDescriptions[i] == pDefaults->m_gpuAdapter) + { + defaultIdx = static_cast (i); + break; + } + } + + SendDlgItemMessageW (hDlg, IDC_GPU_COMBO, CB_SETCURSEL, defaultIdx, 0); + } + } + + SendDlgItemMessageW (hDlg, IDC_QUALITY_PRESET_SLIDER, TBM_SETPOS, TRUE, static_cast (pDefaults->m_qualityPreset)); + SetDlgItemTextW (hDlg, IDC_QUALITY_PRESET_LABEL, FormatQualityPresetLabel (pDefaults->m_qualityPreset)); + + InitializePassesSlider (hDlg, pDefaults->m_advancedValues.m_blurPasses); + InitializeResolutionSlider (hDlg, static_cast (pDefaults->m_advancedValues.m_bloomResolutionDivisor)); + InitializeSmoothnessSlider (hDlg, static_cast (pDefaults->m_advancedValues.m_blurTaps)); + CheckDlgButton (hDlg, IDC_STARTFULLSCREEN_CHECK, pDefaults->m_startFullscreen ? BST_CHECKED : BST_UNCHECKED); CheckDlgButton (hDlg, IDC_SHOWDEBUG_CHECK, pDefaults->m_showDebugStats ? BST_CHECKED : BST_UNCHECKED); #ifdef _DEBUG @@ -515,11 +1299,12 @@ static void OnResetButton (HWND hDlg) CBRAEx (pController != nullptr, E_UNEXPECTED); if (pController->IsLiveMode()) { - pController->UpdateDensity (pDefaults->m_densityPercent); - pController->UpdateAnimationSpeed (pDefaults->m_animationSpeedPercent); - pController->UpdateGlowIntensity (pDefaults->m_glowIntensityPercent); - pController->UpdateGlowSize (pDefaults->m_glowSizePercent); - pController->UpdateColorScheme (pDefaults->m_colorSchemeKey.c_str()); + pController->UpdateDensity (pDefaults->m_densityPercent); + pController->UpdateAnimationSpeed (pDefaults->m_animationSpeedPercent); + pController->UpdateGlowIntensity (pDefaults->m_glowIntensityPercent); + pController->UpdateGlowSize (pDefaults->m_glowSizePercent); + pController->UpdateColorScheme (pDefaults->m_colorSchemeKey.c_str()); + pController->UpdateAdvancedGraphicsValues (pDefaults->m_advancedValues); } Error: @@ -588,10 +1373,19 @@ static BOOL OnCancel (HWND hDlg) // For modeless dialogs, revert live preview changes back to snapshot if (pController->IsLiveMode()) { - Application * pApp = GetApplicationFromDialog (hDlg); + Application * pApp = GetApplicationFromDialog (hDlg); + bool needsRebuild = pController->LiveModeRebuildRequired(); pController->CancelLiveMode(); + // Only rebuild contexts when a field that requires destroy/recreate + // (multimon span, GPU adapter) actually changed — otherwise the + // user sees an ugly flicker on every dialog close. + if (pApp && needsRebuild) + { + pApp->ApplyDisplayModeChange(); + } + // Clear the dialog handle before destroying so input handling resumes if (pApp) { @@ -640,10 +1434,33 @@ static BOOL OnCommand (HWND hDlg, WPARAM wParam) OnColorSchemeChange (hDlg); } break; + + case IDC_GPU_COMBO: + if (HIWORD (wParam) == CBN_SELCHANGE) + { + OnGpuChange (hDlg); + } + break; + + case IDC_QUALITY_PRESET_INFO: + case IDC_GLOWINTENSITY_INFO: + case IDC_GLOWSIZE_INFO: + case IDC_GLOWPASSES_INFO: + case IDC_GLOWRES_INFO: + case IDC_GLOWSMOOTH_INFO: + // BN_CLICKED on any info button (mouse click OR Space/Enter on + // a keyboard-focused button) pops the matching infotip via + // TTM_TRACKACTIVATE. Auto-dismisses after 5s. + OnInfoButtonClick (hDlg, LOWORD (wParam)); + break; case IDC_STARTFULLSCREEN_CHECK: OnStartFullscreenCheck (hDlg); break; + + case IDC_MULTIMONITOR_CHECK: + OnMultiMonitorCheck (hDlg); + break; case IDC_SHOWDEBUG_CHECK: OnShowDebugCheck (hDlg); @@ -712,6 +1529,12 @@ static void OnDestroy (HWND hDlg) if (pContext) { SetWindowLongPtr (hDlg, DWLP_USER, 0); + + if (pContext->m_hInfoTipFont) + { + DeleteObject (pContext->m_hInfoTipFont); + pContext->m_hInfoTipFont = nullptr; + } if (pContext->m_ownsContextMemory) { @@ -754,6 +1577,94 @@ static INT_PTR CALLBACK ConfigDialogProc (HWND hDlg, case WM_COMMAND: result = OnCommand (hDlg, wParam); break; + + case WM_DRAWITEM: + { + // Owner-draw paint for the ⓘ info indicators. No button frame: + // we draw only the glyph (transparent background, 1.5x font). + LPDRAWITEMSTRUCT pdis = reinterpret_cast (lParam); + + if (pdis && pdis->CtlType == ODT_BUTTON && IsInfoTipControlId (pdis->CtlID)) + { + DialogContext * pContext = GetDialogContext (hDlg); + HFONT hOldFont = nullptr; + int oldBkMode; + COLORREF oldTextColor; + wchar_t glyph[] = L"\u24D8"; + + // Erase the entire rect with the dialog's face color so any + // previous focus rect (XOR-drawn by the system) is cleared + // before we redraw the glyph + optional new focus rect. + FillRect (pdis->hDC, &pdis->rcItem, GetSysColorBrush (COLOR_3DFACE)); + + if (pContext && pContext->m_hInfoTipFont) + { + hOldFont = static_cast (SelectObject (pdis->hDC, pContext->m_hInfoTipFont)); + } + + oldBkMode = SetBkMode (pdis->hDC, TRANSPARENT); + oldTextColor = SetTextColor (pdis->hDC, GetSysColor (COLOR_WINDOWTEXT)); + + DrawTextW (pdis->hDC, + glyph, + 1, + &pdis->rcItem, + DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP); + + SetTextColor (pdis->hDC, oldTextColor); + SetBkMode (pdis->hDC, oldBkMode); + + if (hOldFont) + { + SelectObject (pdis->hDC, hOldFont); + } + + if (pdis->itemState & ODS_FOCUS) + { + DrawFocusRect (pdis->hDC, &pdis->rcItem); + } + + result = TRUE; + } + break; + } + + case WM_NOTIFY: + { + LPNMHDR pnmhdr = reinterpret_cast (lParam); + + if (pnmhdr && (pnmhdr->code == TTN_GETDISPINFOW || pnmhdr->code == TTN_NEEDTEXTW)) + { + // Resolve the tool's hwnd back to an IDC_*_INFO control id + // and supply the locked infotip text via LPSTR_TEXTCALLBACK. + NMTTDISPINFOW * pdi = reinterpret_cast (lParam); + HWND hToolHwnd = reinterpret_cast (pdi->hdr.idFrom); + int toolId = GetDlgCtrlID (hToolHwnd); + + if (IsInfoTipControlId (toolId)) + { + pdi->lpszText = const_cast (GetInfoTipText (toolId)); + result = TRUE; + } + } + break; + } + + case WM_TIMER: + if (wParam == kInfoTipDismissTimerId) + { + DismissInfoTip (hDlg); + result = TRUE; + } + break; + + case WM_ACTIVATE: + // Lose-focus on the dialog dismisses any active TTF_TRACK tip. + if (LOWORD (wParam) == WA_INACTIVE) + { + DismissInfoTip (hDlg); + } + break; case WM_DESTROY: OnDestroy (hDlg); diff --git a/MatrixRain/MatrixRain.manifest b/MatrixRain/MatrixRain.manifest new file mode 100644 index 0000000..70edac5 --- /dev/null +++ b/MatrixRain/MatrixRain.manifest @@ -0,0 +1,43 @@ + + + + MatrixRain animated screensaver + + + + + + + + + + + + PerMonitorV2,PerMonitor,System + True/PM + + + + + + + + + + diff --git a/MatrixRain/MatrixRain.rc b/MatrixRain/MatrixRain.rc index 485676b..139165d 100644 --- a/MatrixRain/MatrixRain.rc +++ b/MatrixRain/MatrixRain.rc @@ -1,4 +1,4 @@ -// Microsoft Visual C++ generated resource script. +// Microsoft Visual C++ generated resource script. // #include "resource.h" @@ -85,38 +85,83 @@ END // // Dialog // +// Layout column conventions (all DLU, used consistently for every row): +// +// Left margin x = 15 +// Prompt label x = 15 w = 58 (fits "Glow smoothness:") +// Infotip (ⓘ button) x = 74 w = 14 h = 14 +// Slider / combo x = 90 w = 162 (slider) / w = 195 (combo) +// Value label x = 257 w = 28 +// Right margin ends at x = 285 (= 300 - 15, mirrors left margin) +// +// Sliders use msctls_trackbar32 (the standard modern themed trackbar; visual +// styles + DPI scaling are inherited automatically via DS_SETFONT and the +// app manifest). Slider height is 20 DLU to leave room for the tick row +// beneath the channel. +// -IDD_MATRIXRAIN_SAVER_CONFIG DIALOGEX 0, 0, 240, 200 +IDD_MATRIXRAIN_SAVER_CONFIG DIALOGEX 0, 0, 300, 320 STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | WS_POPUP | WS_CAPTION | WS_SYSMENU -CAPTION "MatrixRain Screensaver Configuration" +CAPTION "MatrixRain configuration" FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN - LTEXT "Density:",IDC_STATIC,7,10,40,8 - CONTROL "",IDC_DENSITY_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | WS_TABSTOP,50,7,130,15 - LTEXT "100%",IDC_DENSITY_LABEL,185,10,40,8 + LTEXT "Density:",IDC_STATIC,15,14,58,8 + CONTROL "",IDC_DENSITY_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | WS_TABSTOP,90,12,162,20 + LTEXT "100%",IDC_DENSITY_LABEL,257,14,28,8 + + LTEXT "Speed:",IDC_STATIC,15,36,58,8 + CONTROL "",IDC_ANIMSPEED_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | WS_TABSTOP,90,34,162,20 + LTEXT "75%",IDC_ANIMSPEED_LABEL,257,36,28,8 + + LTEXT "Glow intensity:",IDC_STATIC,15,58,58,8 + CONTROL "ⓘ",IDC_GLOWINTENSITY_INFO,"Button",BS_OWNERDRAW | WS_TABSTOP,74,56,14,14 + CONTROL "",IDC_GLOWINTENSITY_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | WS_TABSTOP,90,56,162,20 + LTEXT "100%",IDC_GLOWINTENSITY_LABEL,257,58,28,8 + + LTEXT "Glow size:",IDC_STATIC,15,80,58,8 + CONTROL "ⓘ",IDC_GLOWSIZE_INFO,"Button",BS_OWNERDRAW | WS_TABSTOP,74,78,14,14 + CONTROL "",IDC_GLOWSIZE_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | WS_TABSTOP,90,78,162,20 + LTEXT "100%",IDC_GLOWSIZE_LABEL,257,80,28,8 + + LTEXT "Color:",IDC_STATIC,15,103,58,8 + COMBOBOX IDC_COLORSCHEME_COMBO,90,100,195,60,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP - LTEXT "Speed:",IDC_STATIC,7,30,40,8 - CONTROL "",IDC_ANIMSPEED_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | WS_TABSTOP,50,27,130,15 - LTEXT "75%",IDC_ANIMSPEED_LABEL,185,30,40,8 + LTEXT "GPU:",IDC_STATIC,15,125,58,8 + COMBOBOX IDC_GPU_COMBO,90,122,195,120,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP - LTEXT "Glow intensity:",IDC_STATIC,7,50,40,8 - CONTROL "",IDC_GLOWINTENSITY_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | WS_TABSTOP,50,47,130,15 - LTEXT "100%",IDC_GLOWINTENSITY_LABEL,185,50,40,8 + GROUPBOX "Graphics performance and quality",IDC_QUALITY_GROUPBOX,7,144,286,100 + LTEXT "Quality:",IDC_STATIC,15,156,58,8 + CONTROL "ⓘ",IDC_QUALITY_PRESET_INFO,"Button",BS_OWNERDRAW | WS_TABSTOP,74,154,14,14 + CONTROL "",IDC_QUALITY_PRESET_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | WS_TABSTOP,90,154,162,20 + LTEXT "High",IDC_QUALITY_PRESET_LABEL,257,156,28,8 - LTEXT "Glow size:",IDC_STATIC,7,70,40,8 - CONTROL "",IDC_GLOWSIZE_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | WS_TABSTOP,50,67,130,15 - LTEXT "100%",IDC_GLOWSIZE_LABEL,185,70,40,8 + LTEXT "Glow passes:",IDC_GLOWPASSES_PROMPT,15,178,58,8 + CONTROL "ⓘ",IDC_GLOWPASSES_INFO,"Button",BS_OWNERDRAW | WS_TABSTOP,74,176,14,14 + CONTROL "",IDC_GLOWPASSES_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | WS_TABSTOP,90,176,162,20 + LTEXT "3",IDC_GLOWPASSES_LABEL,257,178,28,8 - LTEXT "Color:",IDC_STATIC,7,90,50,8 - COMBOBOX IDC_COLORSCHEME_COMBO,60,88,100,60,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP + LTEXT "Glow resolution:",IDC_GLOWRES_PROMPT,15,200,58,8 + CONTROL "ⓘ",IDC_GLOWRES_INFO,"Button",BS_OWNERDRAW | WS_TABSTOP,74,198,14,14 + CONTROL "",IDC_GLOWRES_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | WS_TABSTOP,90,198,162,20 + LTEXT "Half",IDC_GLOWRES_LABEL,257,200,28,8 - CONTROL "Start in fullscreen",IDC_STARTFULLSCREEN_CHECK,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,7,110,120,10 - CONTROL "Show debug statistics",IDC_SHOWDEBUG_CHECK,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,7,125,120,10 - CONTROL "Show fade timers",IDC_SHOWFADETIMERS_CHECK,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,7,140,120,10 + LTEXT "Glow smoothness:",IDC_GLOWSMOOTH_PROMPT,15,222,58,8 + CONTROL "ⓘ",IDC_GLOWSMOOTH_INFO,"Button",BS_OWNERDRAW | WS_TABSTOP,74,220,14,14 + CONTROL "",IDC_GLOWSMOOTH_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | WS_TABSTOP,90,220,162,20 + LTEXT "High",IDC_GLOWSMOOTH_LABEL,257,222,28,8 + + // Bottom checkboxes — two columns. + // Left column (x = 15, w = 130): debug-app / behavior toggles + // Right column (x = 150, w = 135): debug-overlay toggles + CONTROL "Start in fullscreen",IDC_STARTFULLSCREEN_CHECK,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,15,255,130,10 + CONTROL "Render on all monitors",IDC_MULTIMONITOR_CHECK,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,15,270,130,10 + CONTROL "Show debug statistics",IDC_SHOWDEBUG_CHECK,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,150,255,135,10 + CONTROL "Show fade timers",IDC_SHOWFADETIMERS_CHECK,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,150,270,135,10 - DEFPUSHBUTTON "OK",IDOK,40,175,50,14 - PUSHBUTTON "Cancel",IDCANCEL,95,175,50,14 - PUSHBUTTON "Reset",IDC_RESET_BUTTON,150,175,50,14 + // Bottom buttons — Reset at the left margin, OK + Cancel at the right. + PUSHBUTTON "Reset",IDC_RESET_BUTTON,15,295,50,14 + DEFPUSHBUTTON "OK",IDOK,180,295,50,14 + PUSHBUTTON "Cancel",IDCANCEL,235,295,50,14 END #endif // English (United States) resources diff --git a/MatrixRain/MatrixRain.vcxproj b/MatrixRain/MatrixRain.vcxproj index be5a983..d27f224 100644 --- a/MatrixRain/MatrixRain.vcxproj +++ b/MatrixRain/MatrixRain.vcxproj @@ -286,6 +286,9 @@ + + + diff --git a/MatrixRain/main.cpp b/MatrixRain/main.cpp index b3fc6fb..995916e 100644 --- a/MatrixRain/main.cpp +++ b/MatrixRain/main.cpp @@ -37,6 +37,14 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, // Set DPI awareness to per-monitor V2 for consistent physical pixel measurements SetProcessDpiAwarenessContext (DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + // Register comctl32 v6 common controls (trackbars, themed buttons, + // comboboxes, etc.) so the config dialog renders with the modern + // Win11 themed appearance instead of the unthemed XP-classic fallback. + { + INITCOMMONCONTROLSEX iccex = { sizeof (iccex), ICC_STANDARD_CLASSES | ICC_BAR_CLASSES }; + InitCommonControlsEx (&iccex); + } + // Parse command-line arguments { CommandLine cmdLine; diff --git a/MatrixRain/resource.h b/MatrixRain/resource.h index dcb044e..479bf79 100644 --- a/MatrixRain/resource.h +++ b/MatrixRain/resource.h @@ -17,6 +17,30 @@ #define IDC_SHOWDEBUG_CHECK 1011 #define IDC_SHOWFADETIMERS_CHECK 1012 #define IDC_RESET_BUTTON 1013 +#define IDC_MULTIMONITOR_CHECK 1014 +#define IDC_MULTIMONITOR_INFO 1015 +#define IDC_GPU_COMBO 1016 +#define IDC_GPU_INFO 1017 +#define IDC_QUALITY_PRESET_SLIDER 1018 +#define IDC_QUALITY_PRESET_INFO 1019 +#define IDC_GRAPHICS_ADVANCED_CHECK 1020 +#define IDC_GRAPHICS_ADVANCED_INFO 1021 +#define IDC_GLOWPASSES_SLIDER 1022 +#define IDC_GLOWPASSES_LABEL 1023 +#define IDC_GLOWPASSES_INFO 1024 +#define IDC_GLOWRES_SLIDER 1025 +#define IDC_GLOWRES_LABEL 1026 +#define IDC_GLOWRES_INFO 1027 +#define IDC_GLOWSMOOTH_SLIDER 1028 +#define IDC_GLOWSMOOTH_LABEL 1029 +#define IDC_GLOWSMOOTH_INFO 1030 +#define IDC_GLOWINTENSITY_INFO 1031 +#define IDC_GLOWSIZE_INFO 1032 +#define IDC_QUALITY_GROUPBOX 1033 +#define IDC_GLOWPASSES_PROMPT 1034 +#define IDC_GLOWRES_PROMPT 1035 +#define IDC_GLOWSMOOTH_PROMPT 1036 +#define IDC_QUALITY_PRESET_LABEL 1037 // Next default values for new objects // @@ -24,7 +48,7 @@ #ifndef APSTUDIO_READONLY_SYMBOLS #define _APS_NEXT_RESOURCE_VALUE 103 #define _APS_NEXT_COMMAND_VALUE 40001 -#define _APS_NEXT_CONTROL_VALUE 1014 +#define _APS_NEXT_CONTROL_VALUE 1038 #define _APS_NEXT_SYMED_VALUE 101 #endif #endif diff --git a/MatrixRainCore/AdapterSelection.cpp b/MatrixRainCore/AdapterSelection.cpp new file mode 100644 index 0000000..334e1ed --- /dev/null +++ b/MatrixRainCore/AdapterSelection.cpp @@ -0,0 +1,50 @@ +#include "pch.h" + +#include "AdapterSelection.h" + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ResolveAdapter +// +//////////////////////////////////////////////////////////////////////////////// + +std::optional ResolveAdapter (const std::vector & adapters, + const std::wstring & savedDescription) +{ + if (savedDescription.empty()) + { + return std::nullopt; + } + + for (const AdapterInfo & adapter : adapters) + { + if (adapter.m_description == savedDescription) + { + return adapter.m_luid; + } + } + + return std::nullopt; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// FormatAdapterLabel +// +//////////////////////////////////////////////////////////////////////////////// + +std::wstring FormatAdapterLabel (const AdapterInfo & adapter) +{ + if (adapter.m_isDefault) + { + return adapter.m_description + L" (default)"; + } + + return adapter.m_description; +} diff --git a/MatrixRainCore/AdapterSelection.h b/MatrixRainCore/AdapterSelection.h new file mode 100644 index 0000000..62717e7 --- /dev/null +++ b/MatrixRainCore/AdapterSelection.h @@ -0,0 +1,25 @@ +#pragma once + +#include "IAdapterProvider.h" + +#include + + +// Maps a persisted adapter description string to the LUID of the matching +// enumerated adapter, or std::nullopt if no match. +// +// - savedDescription empty -> nullopt (use system default) +// - savedDescription not present in adapters -> nullopt (saved GPU vanished; +// fall back to default) +// - savedDescription matches m_description -> the matching adapter's LUID +// +// The provider is responsible for excluding software adapters; this helper +// does NOT re-filter them. +std::optional ResolveAdapter (const std::vector & adapters, + const std::wstring & savedDescription); + + +// Builds the user-facing combobox label for an adapter: +// - default adapter: " (default)" +// - non-default adapter: "" +std::wstring FormatAdapterLabel (const AdapterInfo & adapter); diff --git a/MatrixRainCore/Application.cpp b/MatrixRainCore/Application.cpp index c1323dc..8300c76 100644 --- a/MatrixRainCore/Application.cpp +++ b/MatrixRainCore/Application.cpp @@ -14,9 +14,14 @@ #include "InputSystem.h" #include "FPSCounter.h" #include "MonitorRenderContext.h" +#include "MonitorInfo.h" #include "MonitorLayout.h" +#include "MultiMonitorGate.h" +#include "QualityPresets.h" #include "RenderThreadInputs.h" +#include "WindowsAdapterProvider.h" #include "WindowsMonitorProvider.h" +#include "AdapterSelection.h" #include "ScreenSaverModeContext.h" #include "UnicodeSymbols.h" #include "Version.h" @@ -252,6 +257,52 @@ void Application::InitializeApplicationState (const ScreenSaverModeContext * pSc m_pScreenSaverContext = pScreenSaverContext; m_appState->Initialize (m_pScreenSaverContext); + + // First-run quality preset heuristic (FR-037). Runs only on a truly + // fresh install (no registry key existed); upgrades from earlier + // versions keep the High preset's defaults so the visual output is + // identical to what they saw before (FR-022). + if (m_appState->IsFirstRun()) + { + std::vector adapters = WindowsAdapterProvider{}.EnumerateAdapters(); + uint64_t totalPixels = 0; + + if (m_monitorProvider) + { + for (const MonitorInfo & monitor : m_monitorProvider->GetMonitors()) + { + totalPixels += static_cast (monitor.Width()) * + static_cast (monitor.Height()); + } + } + + QualityPreset firstRunPreset = PickDefaultQualityPreset (adapters, totalPixels); + (void) m_appState->ApplyFirstRunQualityPreset (firstRunPreset); + + // Also seed SharedState so the first frame renders at the chosen + // preset's values (the snapshot path picks up subsequent changes). + { + std::lock_guard lock (m_sharedState.mutex); + const ScreenSaverSettings & s = m_appState->GetSettings(); + + m_sharedState.glowIntensityPercent = s.m_advancedValues.m_glowIntensityPercent; + m_sharedState.blurPasses = s.m_advancedValues.m_blurPasses; + m_sharedState.bloomResolutionDivisor = s.m_advancedValues.m_bloomResolutionDivisor; + m_sharedState.blurTaps = s.m_advancedValues.m_blurTaps; + } + } + else + { + // Existing install: seed SharedState from the loaded advanced + // values so the render thread renders at whatever preset/custom + // values the user previously saved. + std::lock_guard lock (m_sharedState.mutex); + const ScreenSaverSettings & s = m_appState->GetSettings(); + + m_sharedState.blurPasses = s.m_advancedValues.m_blurPasses; + m_sharedState.bloomResolutionDivisor = s.m_advancedValues.m_bloomResolutionDivisor; + m_sharedState.blurTaps = s.m_advancedValues.m_blurTaps; + } // Register for settings change notifications — write to SharedState m_appState->RegisterDensityChangeCallback ([this](int densityPercent) { @@ -274,6 +325,14 @@ void Application::InitializeApplicationState (const ScreenSaverModeContext * pSc m_sharedState.glowSizePercent = sizePercent; }); + m_appState->RegisterAdvancedGraphicsCallback ([this](const AdvancedGraphicsValues & values) { + std::lock_guard lock (m_sharedState.mutex); + m_sharedState.glowIntensityPercent = values.m_glowIntensityPercent; + m_sharedState.blurPasses = values.m_blurPasses; + m_sharedState.bloomResolutionDivisor = values.m_bloomResolutionDivisor; + m_sharedState.blurTaps = values.m_blurTaps; + }); + m_appState->RegisterColorSchemeCallback ([this](ColorScheme scheme) { std::lock_guard lock (m_sharedState.mutex); m_sharedState.colorScheme = scheme; @@ -340,6 +399,20 @@ HRESULT Application::CreateRenderContexts() HRESULT hr = S_OK; + // Resolve the user's preferred GPU adapter (description -> LUID) once + // per (re)build so every per-monitor context is created on the same + // device. A saved adapter that is no longer present silently falls + // back to the system default (FR-014); the resolved value is consumed + // by AddContext when it constructs each MonitorRenderContext. + { + WindowsAdapterProvider provider; + std::vector adapters = provider.EnumerateAdapters(); + std::wstring saved = m_appState ? m_appState->GetSettings().m_gpuAdapter : std::wstring(); + + m_resolvedAdapter = ResolveAdapter (adapters, saved); + } + + if (ShouldSpanAllMonitors()) { hr = CreateFullscreenContexts(); @@ -373,18 +446,18 @@ HRESULT Application::CreateRenderContexts() bool Application::ShouldSpanAllMonitors() const { + std::optional saverMode; + if (m_pScreenSaverContext) { - ScreenSaverMode mode = m_pScreenSaverContext->m_mode; - - if (mode == ScreenSaverMode::ScreenSaverPreview || - mode == ScreenSaverMode::HelpRequested) - { - return false; - } + saverMode = m_pScreenSaverContext->m_mode; } - return m_appState && m_appState->GetDisplayMode() == DisplayMode::Fullscreen; + + bool multiMonEnabled = m_appState && m_appState->GetSettings().m_multiMonitorEnabled; + DisplayMode displayMode = m_appState ? m_appState->GetDisplayMode() : DisplayMode::Windowed; + + return ::ShouldSpanAllMonitors (multiMonEnabled, displayMode, saverMode); } @@ -511,7 +584,7 @@ HRESULT Application::AddContext (const POINT & position, const SIZE & size, DWOR context = std::make_unique (isPrimary); - hr = context->Initialize (hwnd, static_cast (size.cx), static_cast (size.cy)); + hr = context->Initialize (hwnd, static_cast (size.cx), static_cast (size.cy), m_resolvedAdapter); CHR (hr); if (isPrimary) @@ -1103,9 +1176,37 @@ LRESULT Application::HandleMessage (HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM return 0; case WM_APP_REBUILD_CONTEXTS: + // Coalesced burst handler — clear the latch so any further + // topology / device-loss notifications that arrive while we + // are rebuilding can request a follow-up rebuild. + m_rebuildCoalescer.Consume(); RebuildContextsForCurrentMode(); return 0; + case WM_DISPLAYCHANGE: + // Monitor topology changed (add, remove, resolution, primary + // reassignment). Windows broadcasts WM_DISPLAYCHANGE to every + // top-level window we own, so coalesce to a single rebuild via + // the latch; the rebuild itself is then driven by the + // WM_APP_REBUILD_CONTEXTS case above. + if (m_rebuildCoalescer.RequestRebuild() && m_hwnd) + { + PostMessageW (m_hwnd, WM_APP_REBUILD_CONTEXTS, 0, 0); + } + return 0; + + case WM_APP_DEVICE_LOST: + // Per-monitor render thread reported Present() returned a + // device-lost HRESULT. Device removal usually hits every + // monitor's render thread within milliseconds of each other, so + // funnel through the coalescer the same way WM_DISPLAYCHANGE + // does to avoid N back-to-back full-rebuild cycles. + if (m_rebuildCoalescer.RequestRebuild() && m_hwnd) + { + PostMessageW (m_hwnd, WM_APP_REBUILD_CONTEXTS, 0, 0); + } + return 0; + default: return DefWindowProc (hwnd, uMsg, wParam, lParam); } diff --git a/MatrixRainCore/Application.h b/MatrixRainCore/Application.h index 20d33d1..1184919 100644 --- a/MatrixRainCore/Application.h +++ b/MatrixRainCore/Application.h @@ -1,5 +1,6 @@ #pragma once +#include "RebuildCoalescer.h" #include "RegistrySettingsProvider.h" #include "ScreenSaverModeContext.h" #include "SharedState.h" @@ -75,6 +76,12 @@ class Application // Posted to the primary window to rebuild contexts outside any dialog proc static constexpr UINT WM_APP_REBUILD_CONTEXTS = WM_APP + 1; + // Posted by per-monitor render threads when Present returns a device-lost + // HRESULT. The HandleMessage handler routes the request through + // m_rebuildCoalescer so an N-monitor burst (driver reset, sleep/resume) + // collapses to a single subsequent WM_APP_REBUILD_CONTEXTS. + static constexpr UINT WM_APP_DEVICE_LOST = WM_APP + 2; + private: // Core systems RegistrySettingsProvider m_settingsProvider; @@ -84,6 +91,8 @@ class Application std::unique_ptr m_inputSystem; std::unique_ptr m_appState; OverlayState m_overlays; + RebuildCoalescer m_rebuildCoalescer; + std::optional m_resolvedAdapter; // Win32 window` HWND m_hwnd { nullptr }; diff --git a/MatrixRainCore/ApplicationState.cpp b/MatrixRainCore/ApplicationState.cpp index 96b4141..0cab932 100644 --- a/MatrixRainCore/ApplicationState.cpp +++ b/MatrixRainCore/ApplicationState.cpp @@ -23,9 +23,9 @@ void ApplicationState::Initialize (const ScreenSaverModeContext * pScreenSaverCo // Load settings from provider (falls back to defaults if no data exists) HRESULT hr = m_settingsProvider.Load (m_settings); - // hr == S_FALSE means key didn't exist, used defaults (not an error) - // hr == S_OK means loaded from registry successfully - // Any other HRESULT is an actual error, but we continue with defaults + // hr == S_FALSE means key didn't exist, used defaults (not an error). + // Capture this for first-run heuristics (e.g., FR-037 quality preset). + m_isFirstRun = (hr == S_FALSE); UNREFERENCED_PARAMETER (hr); // Apply settings to runtime state @@ -185,6 +185,15 @@ void ApplicationState::RegisterGlowSizeCallback (std::function callba +void ApplicationState::RegisterAdvancedGraphicsCallback (std::function callback) +{ + m_advancedGraphicsChangeCallback = callback; +} + + + + + void ApplicationState::RegisterColorSchemeCallback (std::function callback) { m_colorSchemeChangeCallback = callback; @@ -264,6 +273,24 @@ void ApplicationState::SetGlowSize (int sizePercent) +void ApplicationState::SetAdvancedGraphics (const AdvancedGraphicsValues & values) +{ + m_settings.m_advancedValues = values; + + if (m_advancedGraphicsChangeCallback) + { + m_advancedGraphicsChangeCallback (values); + } + + // Note: we DON'T SaveSettings() here - the controller already owns + // the persistence path on dialog OK / live cancel. This setter is + // purely the live-preview pump. +} + + + + + void ApplicationState::ApplySettings (const ScreenSaverSettings & settings) { m_settings = settings; @@ -399,3 +426,34 @@ ApplicationState::SaveSettings() return m_settingsProvider.Save (m_settings); } + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ApplicationState::ApplyFirstRunQualityPreset +// +//////////////////////////////////////////////////////////////////////////////// + +HRESULT ApplicationState::ApplyFirstRunQualityPreset (QualityPreset preset) +{ + if (preset != QualityPreset::Custom) + { + m_settings.m_qualityPreset = preset; + m_settings.m_advancedValues = LookupPresetValues (preset); + + // Keep the legacy top-level glow-intensity field in sync with the + // advanced cluster's copy. ScreenSaverSettings carries glow + // intensity in both m_glowIntensityPercent (read by Application's + // unconditional SharedState seed) and m_advancedValues.m_glow- + // IntensityPercent (read by the live-mode advanced-graphics + // callback). RegistrySettingsProvider::Save persists the top- + // level field — without this mirror the preset's intensity (e.g. + // 75 for Low) is silently overwritten by the unchanged default + // (100), and the next launch starts at the wrong value. + m_settings.m_glowIntensityPercent = m_settings.m_advancedValues.m_glowIntensityPercent; + } + + return SaveSettings(); +} + diff --git a/MatrixRainCore/ApplicationState.h b/MatrixRainCore/ApplicationState.h index abe02dd..479c374 100644 --- a/MatrixRainCore/ApplicationState.h +++ b/MatrixRainCore/ApplicationState.h @@ -40,6 +40,11 @@ class ApplicationState /// Screensaver mode context for runtime behavior (nullptr for normal mode) void Initialize (const ScreenSaverModeContext * pScreenSaverContext); + /// Was the most recent settings Load() a brand-new install (no + /// registry key existed)? Used by Application to decide whether to + /// run the first-run quality-preset heuristic (FR-037). + bool IsFirstRun () const { return m_isFirstRun; } + /// /// Toggle between Windowed and Fullscreen display modes. /// @@ -86,6 +91,14 @@ class ApplicationState /// Function to call with new glow size percentage void RegisterGlowSizeCallback (std::function callback); + /// + /// Register a callback to be notified when the advanced graphics + /// values (preset-driven or custom) change. Used by Application to + /// push the new values into SharedState so the render thread picks + /// them up via the snapshot path on the next frame (US5 live preview). + /// + void RegisterAdvancedGraphicsCallback (std::function callback); + /// /// Register a callback to be notified when the color scheme changes /// (via dialog, hotkey, or full settings apply). @@ -125,6 +138,13 @@ class ApplicationState /// Glow size percentage (50-200) void SetGlowSize (int sizePercent); + /// + /// Update the advanced graphics values (passes / resolution / smoothness + /// + glow intensity as a unit). Fires the advanced-graphics callback + /// so SharedState picks up the new values for the render thread. + /// + void SetAdvancedGraphics (const AdvancedGraphicsValues & values); + /// /// Apply full settings to application state (for live dialog revert). /// @@ -146,6 +166,12 @@ class ApplicationState bool GetShowStatistics() const { return m_showStatistics; } const ScreenSaverSettings GetSettings() const { return m_settings; } + /// First-run convenience: apply a heuristically-chosen quality preset + /// to the in-memory settings, populate the advanced values from its + /// lookup row, and persist. Called once at startup by Application + /// when IsFirstRun() is true (FR-037). + HRESULT ApplyFirstRunQualityPreset (QualityPreset preset); + void SetDisplayMode (DisplayMode mode) { m_displayMode = mode; } void SetColorScheme (ColorScheme scheme); void SetShowStatistics (bool show); @@ -161,6 +187,7 @@ class ApplicationState bool m_showStatistics = false; // Show FPS and density statistics float m_elapsedTime = 0.0f; // Elapsed time for color cycling animation ScreenSaverSettings m_settings; // User-configurable settings + bool m_isFirstRun = false; // True iff Load() found no registry key const ScreenSaverModeContext * m_pScreenSaverContext = nullptr; // Screensaver mode context (nullptr = normal mode) std::function m_densityChangeCallback = nullptr; // Callback for density changes std::function m_animationSpeedChangeCallback = nullptr; // Callback for animation speed changes @@ -169,6 +196,7 @@ class ApplicationState std::function m_colorSchemeChangeCallback = nullptr; // Callback for color scheme changes std::function m_showStatisticsChangeCallback = nullptr; // Callback for show-statistics changes std::function m_showDebugFadeTimesChangeCallback = nullptr; // Callback for show-debug-fade-times changes + std::function m_advancedGraphicsChangeCallback = nullptr; // Callback for US5 advanced graphics changes }; diff --git a/MatrixRainCore/ConfigDialogController.cpp b/MatrixRainCore/ConfigDialogController.cpp index 7d5ba18..ba7b9f0 100644 --- a/MatrixRainCore/ConfigDialogController.cpp +++ b/MatrixRainCore/ConfigDialogController.cpp @@ -7,6 +7,12 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::ConfigDialogController +// +//////////////////////////////////////////////////////////////////////////////// + ConfigDialogController::ConfigDialogController (ISettingsProvider & settingsProvider) : m_settingsProvider (settingsProvider) { @@ -15,40 +21,52 @@ ConfigDialogController::ConfigDialogController (ISettingsProvider & settingsProv +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::Initialize +// +// Loads settings from the provider, falling back to defaults if the +// load fails for any reason. Always returns S_OK — worst case we +// proceed with defaults rather than refuse to open the dialog. +// +//////////////////////////////////////////////////////////////////////////////// HRESULT ConfigDialogController::Initialize() { HRESULT hr = S_OK; - - - - // Load settings from provider (falls back to defaults if no data exists) + + hr = m_settingsProvider.Load (m_settings); - + // hr == S_FALSE means key didn't exist, used defaults (not an error) // hr == S_OK means loaded from registry successfully // Any other HRESULT is an actual error, but we continue with defaults if (FAILED (hr)) { - // Reset to defaults on error m_settings = ScreenSaverSettings(); } - - // Save original settings for Cancel operation + m_originalSettings = m_settings; - - return S_OK; // Always succeed - worst case we use defaults + + +// Error: // /WX would reject the unreferenced label; uncomment when the + // first CHR/CBR/CWR call lands here. + return S_OK; } +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::UpdateDensity +// +//////////////////////////////////////////////////////////////////////////////// void ConfigDialogController::UpdateDensity (int densityPercent) { m_settings.m_densityPercent = ScreenSaverSettings::ClampDensityPercent (densityPercent); - - // Propagate to ApplicationState in live mode + if (m_snapshot.isLiveMode && m_snapshot.applicationStateRef) { m_snapshot.applicationStateRef->OnDensityChanged (m_settings.m_densityPercent); @@ -58,18 +76,22 @@ void ConfigDialogController::UpdateDensity (int densityPercent) +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::UpdateColorScheme +// +//////////////////////////////////////////////////////////////////////////////// void ConfigDialogController::UpdateColorScheme (const std::wstring & colorSchemeKey) { - // Only update if valid color scheme if (IsValidColorSchemeKey (colorSchemeKey)) { - // Normalize to lowercase for storage std::wstring normalizedKey = colorSchemeKey; + + std::transform (normalizedKey.begin(), normalizedKey.end(), normalizedKey.begin(), ::towlower); m_settings.m_colorSchemeKey = normalizedKey; - - // Propagate to ApplicationState in live mode + if (m_snapshot.isLiveMode && m_snapshot.applicationStateRef) { m_snapshot.applicationStateRef->SetColorScheme (ParseColorSchemeKey (normalizedKey)); @@ -80,14 +102,18 @@ void ConfigDialogController::UpdateColorScheme (const std::wstring & colorScheme +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::UpdateAnimationSpeed +// +//////////////////////////////////////////////////////////////////////////////// void ConfigDialogController::UpdateAnimationSpeed (int animationSpeedPercent) { - m_settings.m_animationSpeedPercent = ScreenSaverSettings::ClampPercent (animationSpeedPercent, - ScreenSaverSettings::MIN_ANIMATION_SPEED_PERCENT, - ScreenSaverSettings::MAX_ANIMATION_SPEED_PERCENT); - - // Propagate to ApplicationState in live mode + m_settings.m_animationSpeedPercent = ScreenSaverSettings::ClampPercent (animationSpeedPercent, + ScreenSaverSettings::MIN_ANIMATION_SPEED_PERCENT, + ScreenSaverSettings::MAX_ANIMATION_SPEED_PERCENT); + if (m_snapshot.isLiveMode && m_snapshot.applicationStateRef) { m_snapshot.applicationStateRef->SetAnimationSpeed (m_settings.m_animationSpeedPercent); @@ -97,14 +123,18 @@ void ConfigDialogController::UpdateAnimationSpeed (int animationSpeedPercent) +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::UpdateGlowIntensity +// +//////////////////////////////////////////////////////////////////////////////// void ConfigDialogController::UpdateGlowIntensity (int glowIntensityPercent) { - m_settings.m_glowIntensityPercent = ScreenSaverSettings::ClampPercent (glowIntensityPercent, - ScreenSaverSettings::MIN_GLOW_INTENSITY_PERCENT, - ScreenSaverSettings::MAX_GLOW_INTENSITY_PERCENT); - - // Propagate to ApplicationState in live mode + m_settings.m_glowIntensityPercent = ScreenSaverSettings::ClampPercent (glowIntensityPercent, + ScreenSaverSettings::MIN_GLOW_INTENSITY_PERCENT, + ScreenSaverSettings::MAX_GLOW_INTENSITY_PERCENT); + if (m_snapshot.isLiveMode && m_snapshot.applicationStateRef) { m_snapshot.applicationStateRef->SetGlowIntensity (m_settings.m_glowIntensityPercent); @@ -114,14 +144,18 @@ void ConfigDialogController::UpdateGlowIntensity (int glowIntensityPercent) +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::UpdateGlowSize +// +//////////////////////////////////////////////////////////////////////////////// void ConfigDialogController::UpdateGlowSize (int glowSizePercent) { - m_settings.m_glowSizePercent = ScreenSaverSettings::ClampPercent (glowSizePercent, - ScreenSaverSettings::MIN_GLOW_SIZE_PERCENT, + m_settings.m_glowSizePercent = ScreenSaverSettings::ClampPercent (glowSizePercent, + ScreenSaverSettings::MIN_GLOW_SIZE_PERCENT, ScreenSaverSettings::MAX_GLOW_SIZE_PERCENT); - - // Propagate to ApplicationState in live mode + if (m_snapshot.isLiveMode && m_snapshot.applicationStateRef) { m_snapshot.applicationStateRef->SetGlowSize (m_settings.m_glowSizePercent); @@ -131,6 +165,11 @@ void ConfigDialogController::UpdateGlowSize (int glowSizePercent) +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::UpdateStartFullscreen +// +//////////////////////////////////////////////////////////////////////////////// void ConfigDialogController::UpdateStartFullscreen (bool startFullscreen) { @@ -140,6 +179,99 @@ void ConfigDialogController::UpdateStartFullscreen (bool startFullscreen) +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::UpdateMultiMonitorEnabled +// +//////////////////////////////////////////////////////////////////////////////// + +void ConfigDialogController::UpdateMultiMonitorEnabled (bool multiMonitorEnabled) +{ + m_settings.m_multiMonitorEnabled = multiMonitorEnabled; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::UpdateGpuAdapter +// +//////////////////////////////////////////////////////////////////////////////// + +void ConfigDialogController::UpdateGpuAdapter (const std::wstring & description) +{ + m_settings.m_gpuAdapter = description; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::UpdateQualityPreset +// +//////////////////////////////////////////////////////////////////////////////// + +void ConfigDialogController::UpdateQualityPreset (QualityPreset preset) +{ + m_settings.m_qualityPreset = preset; + m_settings.m_advancedValues = ApplyPresetSnap (preset, m_settings.m_advancedValues, m_settings.m_lastCustom); + + // Mirror the snapped glow intensity into the legacy top-level field so + // RegistrySettingsProvider::Save persists the preset value. See + // ApplicationState::ApplyFirstRunQualityPreset for the same rationale. + m_settings.m_glowIntensityPercent = m_settings.m_advancedValues.m_glowIntensityPercent; + + // Live mode: push the snapped advanced values to ApplicationState so + // SharedState picks them up via the registered callback and the render + // thread renders the new preset on the next frame (US5 live preview). + if (m_snapshot.isLiveMode && m_snapshot.applicationStateRef) + { + m_snapshot.applicationStateRef->SetAdvancedGraphics (m_settings.m_advancedValues); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::UpdateAdvancedGraphicsValues +// +//////////////////////////////////////////////////////////////////////////////// + +void ConfigDialogController::UpdateAdvancedGraphicsValues (const AdvancedGraphicsValues & values) +{ + m_settings.m_advancedValues = values; + + // Per the locked custom-drift behavior: any direct advanced edit + // updates LastCustom (always - even if the values happen to coincide + // with a named preset row, the fact that the user touched a knob + // makes their current state the canonical "last custom" set). + m_settings.m_lastCustom = values; + + // Mirror glow intensity into the legacy top-level field (see + // UpdateQualityPreset for rationale). + m_settings.m_glowIntensityPercent = values.m_glowIntensityPercent; + + // Recompute the displayed preset selection (typically flips to Custom). + m_settings.m_qualityPreset = DetectActivePreset (values); + + if (m_snapshot.isLiveMode && m_snapshot.applicationStateRef) + { + m_snapshot.applicationStateRef->SetAdvancedGraphics (values); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::UpdateShowDebugStats +// +//////////////////////////////////////////////////////////////////////////////// void ConfigDialogController::UpdateShowDebugStats (bool showDebugStats) { @@ -149,6 +281,11 @@ void ConfigDialogController::UpdateShowDebugStats (bool showDebugStats) +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::UpdateShowFadeTimers +// +//////////////////////////////////////////////////////////////////////////////// void ConfigDialogController::UpdateShowFadeTimers (bool showFadeTimers) { @@ -158,21 +295,27 @@ void ConfigDialogController::UpdateShowFadeTimers (bool showFadeTimers) +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::ApplyChanges +// +// Persists the working settings to the registry and snapshots them as +// the new originalSettings so a subsequent CancelChanges reverts to +// this state rather than to whatever was on disk at dialog open. +// +//////////////////////////////////////////////////////////////////////////////// HRESULT ConfigDialogController::ApplyChanges() { HRESULT hr = S_OK; - - - - // Persist settings to registry + + hr = m_settingsProvider.Save (m_settings); CHR (hr); - - // Update original settings to match saved state + m_originalSettings = m_settings; - - + + Error: return hr; } @@ -180,20 +323,28 @@ HRESULT ConfigDialogController::ApplyChanges() +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::CancelChanges +// +//////////////////////////////////////////////////////////////////////////////// void ConfigDialogController::CancelChanges() { - // Restore original settings (discard pending changes) m_settings = m_originalSettings; } +//////////////////////////////////////////////////////////////////////////////// +// +// ConfigDialogController::ResetToDefaults +// +//////////////////////////////////////////////////////////////////////////////// void ConfigDialogController::ResetToDefaults() { - // Reset to factory defaults m_settings = ScreenSaverSettings(); } diff --git a/MatrixRainCore/ConfigDialogController.h b/MatrixRainCore/ConfigDialogController.h index 459fb33..4d24343 100644 --- a/MatrixRainCore/ConfigDialogController.h +++ b/MatrixRainCore/ConfigDialogController.h @@ -68,6 +68,49 @@ class ConfigDialogController /// True to start in fullscreen mode void UpdateStartFullscreen (bool startFullscreen); + /// + /// Update multi-monitor enabled flag. When true, MatrixRain creates a + /// render context per connected monitor in fullscreen mode; when false, + /// it uses a single primary-only window. The dialog handler is + /// expected to post WM_APP_REBUILD_CONTEXTS after calling this so the + /// running app picks up the new gate decision (see FR-003). The + /// CancelLiveMode path automatically reverts via the snapshot, but the + /// dialog handler must likewise post a rebuild on Cancel for the + /// reverted setting to take visual effect (see FR-031b). + /// + /// True to span all monitors + void UpdateMultiMonitorEnabled (bool multiMonitorEnabled); + + /// + /// Update the user-selected GPU adapter (by DXGI description string). + /// Empty string means "use the system default adapter". The dialog + /// handler is expected to post WM_APP_REBUILD_CONTEXTS after calling + /// this so the running app rebuilds its contexts on the new device + /// (FR-015). Cancel-revert is automatic via the snapshot, but the + /// dialog handler must post a rebuild on Cancel for the reverted + /// adapter selection to take visual effect (FR-031b). + /// + /// DXGI adapter description (Empty for default) + void UpdateGpuAdapter (const std::wstring & description); + + /// + /// Update the current quality preset. When set to a named preset, + /// also snaps m_advancedValues to that preset's lookup row (so the + /// dialog can reflect them back into the advanced sliders). When + /// set to Custom, restores LastCustom if saved, else leaves the + /// advanced values at their current state. Cancel-revert is + /// automatic via the snapshot. + /// + void UpdateQualityPreset (QualityPreset preset); + + /// + /// Update the four advanced graphics control values as a unit. Always + /// updates LastCustom (FR-023) AND recomputes m_qualityPreset via + /// DetectActivePreset so the dialog can refresh the preset combo + /// (typically flipping it to Custom when knobs drift off the table). + /// + void UpdateAdvancedGraphicsValues (const AdvancedGraphicsValues & values); + /// /// Update show debug stats flag. /// @@ -128,6 +171,22 @@ class ConfigDialogController /// True if live mode active, false for modal mode bool IsLiveMode() const { return m_snapshot.isLiveMode; } + /// + /// True if the currently-pending settings differ from the live-mode + /// snapshot in any field that requires the monitor render contexts to + /// be torn down and rebuilt (multi-monitor span, or selected GPU + /// adapter). Used by the dialog to suppress an unnecessary + /// destroy/recreate flicker on OK / Cancel when nothing rebuild-worthy + /// has changed. + /// + bool LiveModeRebuildRequired() const + { + if (!m_snapshot.isLiveMode) return false; + if (m_settings.m_multiMonitorEnabled != m_snapshot.snapshotSettings.m_multiMonitorEnabled) return true; + if (m_settings.m_gpuAdapter != m_snapshot.snapshotSettings.m_gpuAdapter) return true; + return false; + } + private: ISettingsProvider & m_settingsProvider; // Settings provider (not owned) ScreenSaverSettings m_settings; // Current settings (may include pending changes) diff --git a/MatrixRainCore/DeviceLost.cpp b/MatrixRainCore/DeviceLost.cpp new file mode 100644 index 0000000..e3cea56 --- /dev/null +++ b/MatrixRainCore/DeviceLost.cpp @@ -0,0 +1,36 @@ +#include "pch.h" + +#include "DeviceLost.h" + + +// D3DDDIERR_DEVICEREMOVED lives in d3dukmdt.h (kernel-mode DDI header), +// which we don't include from user-mode TUs. Define inline — the value +// is documented and stable. See Microsoft device-lost recovery samples. +#ifndef D3DDDIERR_DEVICEREMOVED +#define D3DDDIERR_DEVICEREMOVED ((HRESULT) 0x88760870L) +#endif + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// IsDeviceLost +// +//////////////////////////////////////////////////////////////////////////////// + +bool IsDeviceLost (HRESULT hr) +{ + switch (hr) + { + case DXGI_ERROR_DEVICE_REMOVED: + case DXGI_ERROR_DEVICE_RESET: + case DXGI_ERROR_DEVICE_HUNG: + case DXGI_ERROR_DRIVER_INTERNAL_ERROR: + case D3DDDIERR_DEVICEREMOVED: + return true; + + default: + return false; + } +} diff --git a/MatrixRainCore/DeviceLost.h b/MatrixRainCore/DeviceLost.h new file mode 100644 index 0000000..ece8ae2 --- /dev/null +++ b/MatrixRainCore/DeviceLost.h @@ -0,0 +1,24 @@ +#pragma once + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// IsDeviceLost +// +// Classifies an HRESULT returned by IDXGISwapChain::Present (and related +// D3D11/DXGI APIs) as "the device is gone and must be recreated", or not. +// Used by the render-thread loop to trigger the application-level rebuild +// path when the GPU goes away (driver reset, sleep/resume, eGPU unplugged, +// user disabled adapter in Device Manager, etc.). +// +// This helper is intentionally narrow: it covers the HRESULTs that +// IDXGISwapChain::Present is documented to return for a lost device on +// D3D11. D3DDDIERR_* codes that can surface from GetDeviceRemovedReason +// are deliberately NOT included here; that helper deals with Present's +// return surface only. +// +//////////////////////////////////////////////////////////////////////////////// + +bool IsDeviceLost (HRESULT hr); diff --git a/MatrixRainCore/FrameLimiter.cpp b/MatrixRainCore/FrameLimiter.cpp new file mode 100644 index 0000000..fa0e546 --- /dev/null +++ b/MatrixRainCore/FrameLimiter.cpp @@ -0,0 +1,86 @@ +#include "pch.h" + +#include "FrameLimiter.h" + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ShouldEngageFrameLimiter +// +//////////////////////////////////////////////////////////////////////////////// + +bool ShouldEngageFrameLimiter (unsigned monitorRefreshHz) +{ + return monitorRefreshHz > 60; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// FrameLimiter::FrameLimiter +// +//////////////////////////////////////////////////////////////////////////////// + +FrameLimiter::FrameLimiter (unsigned targetFps) +{ + TargetFps (targetFps); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// FrameLimiter::TargetFps +// +//////////////////////////////////////////////////////////////////////////////// + +void FrameLimiter::TargetFps (unsigned targetFps) +{ + if (targetFps == 0) + { + m_frameInterval = std::chrono::steady_clock::duration::zero(); + } + else + { + m_frameInterval = std::chrono::nanoseconds (1'000'000'000ull / targetFps); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// FrameLimiter::WaitForNextFrame +// +//////////////////////////////////////////////////////////////////////////////// + +void FrameLimiter::WaitForNextFrame() +{ + using clock = std::chrono::steady_clock; + + + clock::time_point now = clock::now(); + + if (!m_lastFrameTime.has_value()) + { + // First-ever call: no prior frame to pace against; render now. + m_lastFrameTime = now; + return; + } + + clock::time_point nextDeadline = *m_lastFrameTime + m_frameInterval; + + if (now < nextDeadline) + { + std::this_thread::sleep_until (nextDeadline); + now = clock::now(); + } + + m_lastFrameTime = now; +} diff --git a/MatrixRainCore/FrameLimiter.h b/MatrixRainCore/FrameLimiter.h new file mode 100644 index 0000000..3ef514b --- /dev/null +++ b/MatrixRainCore/FrameLimiter.h @@ -0,0 +1,33 @@ +#pragma once + +#include + + +// Pure predicate: should the frame limiter engage on a monitor whose +// native refresh is the given integer Hz? Engages only when refresh +// exceeds 60 Hz; at <=60 Hz the existing vsync path is preferred (no +// added overhead). +bool ShouldEngageFrameLimiter (unsigned monitorRefreshHz); + + +// Wall-clock-based per-monitor frame pacer used when the monitor's +// native refresh exceeds 60 Hz (see ShouldEngageFrameLimiter). Sleeps +// inside WaitForNextFrame to enforce a target frames-per-second cap. +// The first call returns immediately (no prior frame timestamp); each +// subsequent call sleeps until at least 1/targetFps seconds have elapsed +// since the previous call. +// +// Uses std::chrono::steady_clock for monotonic timing. Safe to be +// owned per-monitor (one instance per MonitorRenderContext). +class FrameLimiter +{ + public: + explicit FrameLimiter (unsigned targetFps); + + void TargetFps (unsigned targetFps); + void WaitForNextFrame (); + + private: + std::chrono::steady_clock::duration m_frameInterval; + std::optional m_lastFrameTime; +}; diff --git a/MatrixRainCore/IAdapterProvider.h b/MatrixRainCore/IAdapterProvider.h new file mode 100644 index 0000000..0e40d8d --- /dev/null +++ b/MatrixRainCore/IAdapterProvider.h @@ -0,0 +1,32 @@ +#pragma once + + +// Describes one rendering-capable GPU adapter discovered at runtime. Mirrors +// the existing MonitorInfo / IMonitorProvider pattern. Software adapters +// (Microsoft Basic Render Driver / WARP) are filtered out by the concrete +// provider before the list reaches consumers, so callers never see them and +// do not need to re-filter. +struct AdapterInfo +{ + std::wstring m_description; // DXGI_ADAPTER_DESC1::Description (UTF-16) + LUID m_luid { 0, 0 }; // DXGI_ADAPTER_DESC1::AdapterLuid + unsigned int m_dedicatedVramMb { 0 }; + bool m_isSoftware { false }; + bool m_isDefault { false }; // System default rendering adapter +}; + + +// Abstract enumeration of GPU adapters. WindowsAdapterProvider drives this +// via DXGI in production; InMemoryAdapterProvider drives it from a +// test-supplied vector for unit tests. +class IAdapterProvider +{ +public: + virtual ~IAdapterProvider() = default; + + // Enumerate all rendering-capable GPU adapters currently present on the + // system. Software adapters MUST be excluded. Returns an empty vector + // if no non-software adapters exist; callers then use the system default- + // adapter path (D3D11CreateDevice(nullptr, HARDWARE)). + virtual std::vector EnumerateAdapters() const = 0; +}; diff --git a/MatrixRainCore/IRenderSystem.h b/MatrixRainCore/IRenderSystem.h index 6632cc0..6376f5c 100644 --- a/MatrixRainCore/IRenderSystem.h +++ b/MatrixRainCore/IRenderSystem.h @@ -35,8 +35,10 @@ class IRenderSystem // synchronously and must not be retained past the call. virtual void Render (const AnimationSystem & animationSystem, const Viewport & viewport, const RenderParams & params) = 0; - // Present the rendered frame; blocks on this monitor's VBlank. - virtual void Present() = 0; + // Present the rendered frame; blocks on this monitor's VBlank. Returns + // the HRESULT from the underlying swap-chain Present so callers can + // detect device-lost (see DeviceLost.h / IsDeviceLost). + virtual HRESULT Present() = 0; // Recreate swap-chain buffers for a new client size. virtual void Resize (UINT width, UINT height) = 0; @@ -48,6 +50,11 @@ class IRenderSystem virtual void SetGlowIntensity (int intensityPercent) = 0; virtual void SetGlowSize (int sizePercent) = 0; + // User Story 5 - advanced graphics quality knobs from shared state. + virtual void SetBlurPasses (int passes) = 0; + virtual void SetBloomResolution (int divisor) = 0; + virtual void SetBlurTaps (int taps) = 0; + // Fixed character scale that bypasses viewport-based scaling. virtual void SetCharacterScaleOverride (float scale) = 0; diff --git a/MatrixRainCore/InMemoryAdapterProvider.h b/MatrixRainCore/InMemoryAdapterProvider.h new file mode 100644 index 0000000..33e7afc --- /dev/null +++ b/MatrixRainCore/InMemoryAdapterProvider.h @@ -0,0 +1,32 @@ +#pragma once + +#include "IAdapterProvider.h" + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// InMemoryAdapterProvider +// +// Test-only IAdapterProvider that returns a copy of the vector it was +// constructed with. No DXGI dependency. +// +//////////////////////////////////////////////////////////////////////////////// + +class InMemoryAdapterProvider : public IAdapterProvider +{ +public: + explicit InMemoryAdapterProvider (std::vector adapters) : + m_adapters (std::move (adapters)) + { + } + + std::vector EnumerateAdapters() const override + { + return m_adapters; + } + +private: + std::vector m_adapters; +}; diff --git a/MatrixRainCore/InputSystem.cpp b/MatrixRainCore/InputSystem.cpp index 0280de1..0a0bc98 100644 --- a/MatrixRainCore/InputSystem.cpp +++ b/MatrixRainCore/InputSystem.cpp @@ -71,16 +71,6 @@ bool InputSystem::ProcessKeyDown (int virtualKey) handled = true; break; -#ifdef _DEBUG - case VK_OEM_3: // Backtick/tilde key (`~) — debug only - if (m_appState) - { - m_appState->ToggleDebugFadeTimes(); - handled = true; - } - break; -#endif - default: break; } diff --git a/MatrixRainCore/MatrixRainCore.vcxproj b/MatrixRainCore/MatrixRainCore.vcxproj index 2328476..ffacc6e 100644 --- a/MatrixRainCore/MatrixRainCore.vcxproj +++ b/MatrixRainCore/MatrixRainCore.vcxproj @@ -289,6 +289,14 @@ + + + + + + + + @@ -309,6 +317,7 @@ + @@ -342,6 +351,13 @@ + + + + + + + diff --git a/MatrixRainCore/MonitorRenderContext.cpp b/MatrixRainCore/MonitorRenderContext.cpp index 486958b..5dc9ea8 100644 --- a/MatrixRainCore/MonitorRenderContext.cpp +++ b/MatrixRainCore/MonitorRenderContext.cpp @@ -7,6 +7,7 @@ #include "ApplicationState.h" #include "ColorScheme.h" #include "DensityController.h" +#include "DeviceLost.h" #include "FPSCounter.h" #include "Overlay.h" #include "RenderParams.h" @@ -61,14 +62,14 @@ MonitorRenderContext::~MonitorRenderContext() // //////////////////////////////////////////////////////////////////////////////// -HRESULT MonitorRenderContext::Initialize (HWND hwnd, UINT width, UINT height) +HRESULT MonitorRenderContext::Initialize (HWND hwnd, UINT width, UINT height, std::optional adapterLuid) { HRESULT hr = S_OK; m_hwnd = hwnd; - hr = m_renderSystem->Initialize (hwnd, width, height); + hr = m_renderSystem->Initialize (hwnd, width, height, adapterLuid); CHR (hr); // Size the viewport to match the swap chain. The WM_SIZE fired during @@ -83,6 +84,31 @@ HRESULT MonitorRenderContext::Initialize (HWND hwnd, UINT width, UINT height) m_densityController->SetDpiScale (dpiScale); } + // Engage the frame limiter when this monitor's native refresh exceeds + // 60 Hz so we do not pay the full bloom pipeline cost 144 times per + // second on a high-refresh display. At <=60 Hz the limiter is left + // unconstructed and the render loop pays zero per-frame overhead for + // the cap check (FR-018). + { + HMONITOR hMon = MonitorFromWindow (hwnd, MONITOR_DEFAULTTONEAREST); + MONITORINFOEXW mi = {}; + DEVMODEW dm = {}; + + mi.cbSize = sizeof (mi); + dm.dmSize = sizeof (dm); + + if (hMon && GetMonitorInfoW (hMon, &mi) && + EnumDisplaySettingsW (mi.szDevice, ENUM_CURRENT_SETTINGS, &dm)) + { + unsigned refreshHz = static_cast (dm.dmDisplayFrequency); + + if (ShouldEngageFrameLimiter (refreshHz)) + { + m_frameLimiter.emplace (60); + } + } + } + Error: return hr; @@ -334,6 +360,14 @@ void MonitorRenderContext::RenderThreadProc() while (!m_shouldStop) { + // High-refresh frame cap: when this monitor's native refresh is + // > 60 Hz the limiter throttles the loop to 60 fps. At <=60 Hz + // m_frameLimiter is empty and this is a single nullopt check. + if (m_frameLimiter) + { + m_frameLimiter->WaitForNextFrame(); + } + auto currentTime = steady_clock::now(); float deltaTime = duration_cast> (currentTime - lastFrameTime).count(); lastFrameTime = currentTime; @@ -383,6 +417,9 @@ void MonitorRenderContext::RenderThreadProc() m_animationSystem->SetAnimationSpeed (snapshot.animationSpeedPercent); m_renderSystem->SetGlowIntensity (snapshot.glowIntensityPercent); m_renderSystem->SetGlowSize (snapshot.glowSizePercent); + m_renderSystem->SetBlurPasses (snapshot.blurPasses); + m_renderSystem->SetBloomResolution (static_cast (snapshot.bloomResolutionDivisor)); + m_renderSystem->SetBlurTaps (static_cast (snapshot.blurTaps)); // Update/Render hold the overlay lock (primary only); Present is kept // OUTSIDE it so the UI thread's Show/Dismiss is never blocked by VSync. @@ -398,7 +435,24 @@ void MonitorRenderContext::RenderThreadProc() Render (snapshot); } - m_renderSystem->Present(); + HRESULT presentHr = m_renderSystem->Present(); + + if (IsDeviceLost (presentHr)) + { + // GPU is gone (driver reset, removal, sleep/resume). Stop this + // render thread immediately and ask the UI thread to rebuild + // every context on whatever adapter is currently available. + // Post WM_APP_DEVICE_LOST (not WM_APP_REBUILD_CONTEXTS directly) + // so Application::HandleMessage routes the request through the + // RebuildCoalescer — an N-monitor burst then collapses to a + // single rebuild instead of N back-to-back rebuilds. + if (m_hwnd) + { + PostMessageW (m_hwnd, Application::WM_APP_DEVICE_LOST, 0, 0); + } + + break; + } } } diff --git a/MatrixRainCore/MonitorRenderContext.h b/MatrixRainCore/MonitorRenderContext.h index 59fd831..5a0a0b9 100644 --- a/MatrixRainCore/MonitorRenderContext.h +++ b/MatrixRainCore/MonitorRenderContext.h @@ -1,5 +1,6 @@ #pragma once +#include "FrameLimiter.h" #include "SharedState.h" @@ -39,7 +40,7 @@ class MonitorRenderContext ~MonitorRenderContext(); // Construction — called on the UI thread before the render thread starts - HRESULT Initialize (HWND hwnd, UINT width, UINT height); + HRESULT Initialize (HWND hwnd, UINT width, UINT height, std::optional adapterLuid = std::nullopt); void InitializeAnimation(); HRESULT BuildGlyphAtlas(); @@ -78,6 +79,7 @@ class MonitorRenderContext std::unique_ptr m_renderSystem; std::unique_ptr m_densityController; std::unique_ptr m_fpsCounter; + std::optional m_frameLimiter; std::mutex m_renderMutex; std::thread m_renderThread; diff --git a/MatrixRainCore/MultiMonitorGate.cpp b/MatrixRainCore/MultiMonitorGate.cpp new file mode 100644 index 0000000..0d033ae --- /dev/null +++ b/MatrixRainCore/MultiMonitorGate.cpp @@ -0,0 +1,28 @@ +#include "pch.h" + +#include "MultiMonitorGate.h" + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ShouldSpanAllMonitors +// +//////////////////////////////////////////////////////////////////////////////// + +bool ShouldSpanAllMonitors (bool multiMonEnabled, + DisplayMode displayMode, + std::optional saverMode) +{ + // Preview and Help views are explicitly single-window paths and the + // user's multimon preference never applies to them. + if (saverMode.has_value() && + (*saverMode == ScreenSaverMode::ScreenSaverPreview || + *saverMode == ScreenSaverMode::HelpRequested)) + { + return false; + } + + return multiMonEnabled && displayMode == DisplayMode::Fullscreen; +} diff --git a/MatrixRainCore/MultiMonitorGate.h b/MatrixRainCore/MultiMonitorGate.h new file mode 100644 index 0000000..b05d15a --- /dev/null +++ b/MatrixRainCore/MultiMonitorGate.h @@ -0,0 +1,34 @@ +#pragma once + +#include "ApplicationState.h" +#include "ScreenSaverMode.h" + +#include + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ShouldSpanAllMonitors +// +// Pure decision: should MatrixRain create a render context per connected +// monitor (true), or use a single primary-only window (false)? +// +// Truth table: +// - Preview (/p) and the /? usage view are always single (forced false, +// regardless of the user's multi-monitor setting). +// - Otherwise: spans all monitors iff the user has multi-monitor enabled +// AND the application is currently in Fullscreen display mode. +// (Windowed mode is always single.) +// +// saverMode may be std::nullopt when no screensaver-mode context is +// attached (the normal /c launch from Explorer-as-screensaver still goes +// through a context, but unit tests can pass nullopt to model the +// "non-screensaver invocation" case). +// +//////////////////////////////////////////////////////////////////////////////// + +bool ShouldSpanAllMonitors (bool multiMonEnabled, + DisplayMode displayMode, + std::optional saverMode); diff --git a/MatrixRainCore/QualityPresets.cpp b/MatrixRainCore/QualityPresets.cpp new file mode 100644 index 0000000..bb645c1 --- /dev/null +++ b/MatrixRainCore/QualityPresets.cpp @@ -0,0 +1,157 @@ +#include "pch.h" + +#include "QualityPresets.h" + + + + +// Heuristic constants for first-run preset selection. Externally visible +// so unit tests can pin them and protect against silent retunes. +const unsigned int kDiscreteVramThresholdMb = 256; +const uint64_t kHeavyTotalPixelsThreshold = 16'000'000ull; + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// AdvancedGraphicsValues equality +// +//////////////////////////////////////////////////////////////////////////////// + +bool operator== (const AdvancedGraphicsValues & a, const AdvancedGraphicsValues & b) +{ + return a.m_glowIntensityPercent == b.m_glowIntensityPercent && + a.m_blurPasses == b.m_blurPasses && + a.m_bloomResolutionDivisor == b.m_bloomResolutionDivisor && + a.m_blurTaps == b.m_blurTaps; +} + + +bool operator!= (const AdvancedGraphicsValues & a, const AdvancedGraphicsValues & b) +{ + return !(a == b); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// LookupPresetValues +// +//////////////////////////////////////////////////////////////////////////////// + +AdvancedGraphicsValues LookupPresetValues (QualityPreset preset) +{ + switch (preset) + { + case QualityPreset::Low: + return AdvancedGraphicsValues { 75, 1, ResolutionDivisor::Quarter, BlurTaps::Low }; + + case QualityPreset::Medium: + return AdvancedGraphicsValues { 100, 2, ResolutionDivisor::Half, BlurTaps::Medium }; + + case QualityPreset::High: + return AdvancedGraphicsValues { 100, 3, ResolutionDivisor::Half, BlurTaps::High }; + + case QualityPreset::Custom: + default: + // Caller precondition violation; return High as a safe default. + ASSERT (false); + return AdvancedGraphicsValues { 100, 3, ResolutionDivisor::Half, BlurTaps::High }; + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DetectActivePreset +// +//////////////////////////////////////////////////////////////////////////////// + +QualityPreset DetectActivePreset (const AdvancedGraphicsValues & current) +{ + if (current == LookupPresetValues (QualityPreset::Low)) + { + return QualityPreset::Low; + } + + if (current == LookupPresetValues (QualityPreset::Medium)) + { + return QualityPreset::Medium; + } + + if (current == LookupPresetValues (QualityPreset::High)) + { + return QualityPreset::High; + } + + return QualityPreset::Custom; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ApplyPresetSnap +// +//////////////////////////////////////////////////////////////////////////////// + +AdvancedGraphicsValues ApplyPresetSnap (QualityPreset preset, + const AdvancedGraphicsValues & current, + const std::optional & lastCustom) +{ + if (preset == QualityPreset::Custom) + { + if (lastCustom.has_value()) + { + return *lastCustom; + } + + return current; + } + + return LookupPresetValues (preset); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// PickDefaultQualityPreset +// +//////////////////////////////////////////////////////////////////////////////// + +QualityPreset PickDefaultQualityPreset (const std::vector & adapters, + uint64_t totalMonitorPixels) +{ + bool hasDiscrete = false; + + + for (const AdapterInfo & adapter : adapters) + { + if (!adapter.m_isSoftware && adapter.m_dedicatedVramMb >= kDiscreteVramThresholdMb) + { + hasDiscrete = true; + break; + } + } + + + if (hasDiscrete) + { + return QualityPreset::High; + } + + if (totalMonitorPixels > kHeavyTotalPixelsThreshold) + { + return QualityPreset::Low; + } + + return QualityPreset::Medium; +} diff --git a/MatrixRainCore/QualityPresets.h b/MatrixRainCore/QualityPresets.h new file mode 100644 index 0000000..ca005c5 --- /dev/null +++ b/MatrixRainCore/QualityPresets.h @@ -0,0 +1,100 @@ +#pragma once + +#include "IAdapterProvider.h" + +#include +#include + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// QualityPresets — preset table + state-machine helpers for the graphics +// quality control surface (User Story 5). +// +// Per the locked design in contracts/quality-preset-mapping.md: +// - Three named presets (Low, Medium, High) and a Custom sentinel. +// - High is calibrated to today's exact rendering so upgrading users +// who never open the dialog see no visible change. +// - Glow on/off is owned by the existing Glow Intensity slider: +// m_glowIntensityPercent == 0 disables the entire bloom pipeline. +// +//////////////////////////////////////////////////////////////////////////////// + + +enum class QualityPreset : int +{ + Low = 0, + Medium = 1, + High = 2, + Custom = 3 +}; + + + + +enum class ResolutionDivisor : int +{ + Full = 1, + Half = 2, + Quarter = 4, + Eighth = 8 +}; + + + + +enum class BlurTaps : int +{ + Low = 5, + Medium = 9, + High = 13 +}; + + + + +struct AdvancedGraphicsValues +{ + int m_glowIntensityPercent { 100 }; // 0..200; 0 = glow disabled + int m_blurPasses { 3 }; // 1..4 + ResolutionDivisor m_bloomResolutionDivisor { ResolutionDivisor::Half }; + BlurTaps m_blurTaps { BlurTaps::High }; +}; + + +bool operator== (const AdvancedGraphicsValues & a, const AdvancedGraphicsValues & b); +bool operator!= (const AdvancedGraphicsValues & a, const AdvancedGraphicsValues & b); + + +// Returns the values for a named preset. Passing Custom is a precondition +// violation (assert in debug, fall back to High row in release). +AdvancedGraphicsValues LookupPresetValues (QualityPreset preset); + + +// Returns the named preset whose row exactly matches the given advanced +// values, or Custom if no named preset matches. +QualityPreset DetectActivePreset (const AdvancedGraphicsValues & current); + + +// Computes new advanced values when the user changes the preset combo: +// - Named preset -> the preset's lookup row. +// - Custom + lastCustom set -> the saved LastCustom values. +// - Custom + no LastCustom -> current unchanged. +AdvancedGraphicsValues ApplyPresetSnap (QualityPreset preset, + const AdvancedGraphicsValues & current, + const std::optional & lastCustom); + + +// First-run heuristic (runs only when no QualityPreset is saved). +// - Any discrete adapter (>=256 MB dedicated VRAM, not software) -> High +// - Integrated only, totalMonitorPixels <= 16M -> Medium +// - Integrated only, totalMonitorPixels > 16M (~ 2x 4K) -> Low +QualityPreset PickDefaultQualityPreset (const std::vector & adapters, + uint64_t totalMonitorPixels); + + +// Heuristic constants — exposed for direct verification in unit tests. +extern const unsigned int kDiscreteVramThresholdMb; +extern const uint64_t kHeavyTotalPixelsThreshold; diff --git a/MatrixRainCore/RebuildCoalescer.cpp b/MatrixRainCore/RebuildCoalescer.cpp new file mode 100644 index 0000000..266d59d --- /dev/null +++ b/MatrixRainCore/RebuildCoalescer.cpp @@ -0,0 +1,34 @@ +#include "pch.h" + +#include "RebuildCoalescer.h" + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// RebuildCoalescer::RequestRebuild +// +//////////////////////////////////////////////////////////////////////////////// + +bool RebuildCoalescer::RequestRebuild() +{ + // test_and_set returns the PREVIOUS flag state. If the flag was clear + // (false) we are the first request since the last Consume() and return + // true; if already set (true) we coalesce by returning false. + return !m_pending.test_and_set (std::memory_order_acq_rel); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// RebuildCoalescer::Consume +// +//////////////////////////////////////////////////////////////////////////////// + +void RebuildCoalescer::Consume() +{ + m_pending.clear (std::memory_order_release); +} diff --git a/MatrixRainCore/RebuildCoalescer.h b/MatrixRainCore/RebuildCoalescer.h new file mode 100644 index 0000000..4ae71fa --- /dev/null +++ b/MatrixRainCore/RebuildCoalescer.h @@ -0,0 +1,49 @@ +#pragma once + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// RebuildCoalescer +// +// Collapses a burst of "I need a context rebuild" requests from multiple +// threads/sources into a single rebuild action. WM_DISPLAYCHANGE is +// broadcast to every top-level window, so on a multi-monitor system one +// topology change can fire N notifications; without coalescing we would +// tear down and recreate every render context N times. +// +// Usage from the message pump: +// case WM_DISPLAYCHANGE: +// if (m_rebuildCoalescer.RequestRebuild()) +// PostMessage (m_mainHwnd, WM_APP_REBUILD_CONTEXTS, 0, 0); +// return 0; +// +// case WM_APP_REBUILD_CONTEXTS: +// m_rebuildCoalescer.Consume(); +// RebuildContextsForCurrentMode(); +// return 0; +// +// Thread-safe: backed by std::atomic_flag. Exactly one concurrent caller +// of RequestRebuild() will receive true between a given pair of Consume() +// calls; all others receive false. +// +//////////////////////////////////////////////////////////////////////////////// + +class RebuildCoalescer +{ + public: + RebuildCoalescer() = default; + + // Returns true iff this is the first request since construction or + // the last Consume() call. Subsequent requests return false until + // Consume() is called. + bool RequestRebuild(); + + // Resets the pending state so a future RequestRebuild() will once + // again return true. Idempotent. + void Consume(); + + private: + std::atomic_flag m_pending = ATOMIC_FLAG_INIT; +}; diff --git a/MatrixRainCore/RegistrySettingsProvider.cpp b/MatrixRainCore/RegistrySettingsProvider.cpp index e535c5b..1ae197f 100644 --- a/MatrixRainCore/RegistrySettingsProvider.cpp +++ b/MatrixRainCore/RegistrySettingsProvider.cpp @@ -40,6 +40,76 @@ HRESULT RegistrySettingsProvider::Load (ScreenSaverSettings & settings) ReadInt (hKey, VALUE_GLOW_SIZE, settings.m_glowSizePercent); ReadBool (hKey, VALUE_START_FULLSCREEN, settings.m_startFullscreen); ReadBool (hKey, VALUE_SHOW_DEBUG_STATS, settings.m_showDebugStats); + ReadBool (hKey, VALUE_MULTIMONITOR, settings.m_multiMonitorEnabled); + ReadString (hKey, VALUE_GPU_ADAPTER, settings.m_gpuAdapter); + + // Quality preset: REG_SZ matching the enum class names. Empty/missing + // value triggers the first-run heuristic in Application::Initialize. + { + std::wstring presetName; + + if (ReadString (hKey, VALUE_QUALITY_PRESET, presetName) == S_OK) + { + if (presetName == L"Low") settings.m_qualityPreset = QualityPreset::Low; + else if (presetName == L"Medium") settings.m_qualityPreset = QualityPreset::Medium; + else if (presetName == L"High") settings.m_qualityPreset = QualityPreset::High; + else if (presetName == L"Custom") settings.m_qualityPreset = QualityPreset::Custom; + } + } + + // Last-custom advanced values: ALL FOUR DWORDs must be present or we + // treat the persisted LastCustom as missing. Partial state is never + // honored (registry-schema contract). + { + int passes = 0; + int resolution = 0; + int smoothness = 0; + int glowIntensity = 0; + + bool havePasses = ReadInt (hKey, VALUE_LASTCUSTOM_PASSES, passes) == S_OK; + bool haveResolution = ReadInt (hKey, VALUE_LASTCUSTOM_RESOLUTION, resolution) == S_OK; + bool haveSmoothness = ReadInt (hKey, VALUE_LASTCUSTOM_SMOOTHNESS, smoothness) == S_OK; + bool haveIntensity = ReadInt (hKey, VALUE_LASTCUSTOM_GLOW_INTENSITY, glowIntensity) == S_OK; + + if (havePasses && haveResolution && haveSmoothness && haveIntensity) + { + AdvancedGraphicsValues v; + + v.m_glowIntensityPercent = std::clamp (glowIntensity, 0, 200); + v.m_blurPasses = std::clamp (passes, 1, 4); + + switch (resolution) + { + case 1: v.m_bloomResolutionDivisor = ResolutionDivisor::Full; break; + case 2: v.m_bloomResolutionDivisor = ResolutionDivisor::Half; break; + case 4: v.m_bloomResolutionDivisor = ResolutionDivisor::Quarter; break; + case 8: v.m_bloomResolutionDivisor = ResolutionDivisor::Eighth; break; + default: v.m_bloomResolutionDivisor = ResolutionDivisor::Half; + } + + switch (smoothness) + { + case 5: v.m_blurTaps = BlurTaps::Low; break; + case 9: v.m_blurTaps = BlurTaps::Medium; break; + case 13: v.m_blurTaps = BlurTaps::High; break; + default: v.m_blurTaps = BlurTaps::High; + } + + settings.m_lastCustom = v; + } + } + + // When QualityPreset == Custom, restore the advanced values from + // LastCustom (if present). Otherwise advanced values follow the + // named preset's lookup row. + if (settings.m_qualityPreset == QualityPreset::Custom && settings.m_lastCustom.has_value()) + { + settings.m_advancedValues = *settings.m_lastCustom; + } + else if (settings.m_qualityPreset != QualityPreset::Custom) + { + settings.m_advancedValues = LookupPresetValues (settings.m_qualityPreset); + } // Clamp all values to valid ranges settings.Clamp(); @@ -109,6 +179,47 @@ HRESULT RegistrySettingsProvider::Save (const ScreenSaverSettings & settings) hr = WriteBool (hKey, VALUE_SHOW_DEBUG_STATS, settings.m_showDebugStats); CHR (hr); + hr = WriteBool (hKey, VALUE_MULTIMONITOR, settings.m_multiMonitorEnabled); + CHR (hr); + + hr = WriteString (hKey, VALUE_GPU_ADAPTER, settings.m_gpuAdapter); + CHR (hr); + + // QualityPreset name as REG_SZ. + { + const wchar_t * name = L"High"; + + switch (settings.m_qualityPreset) + { + case QualityPreset::Low: name = L"Low"; break; + case QualityPreset::Medium: name = L"Medium"; break; + case QualityPreset::High: name = L"High"; break; + case QualityPreset::Custom: name = L"Custom"; break; + } + + hr = WriteString (hKey, VALUE_QUALITY_PRESET, std::wstring (name)); + CHR (hr); + } + + // LastCustom values: always persist when present so a future switch to + // Custom can restore them. When absent, intentionally leave any prior + // values in the registry (no-op write would be a silent migration + // gotcha). The all-or-nothing read enforces consistency. + if (settings.m_lastCustom.has_value()) + { + hr = WriteInt (hKey, VALUE_LASTCUSTOM_GLOW_INTENSITY, settings.m_lastCustom->m_glowIntensityPercent); + CHR (hr); + + hr = WriteInt (hKey, VALUE_LASTCUSTOM_PASSES, settings.m_lastCustom->m_blurPasses); + CHR (hr); + + hr = WriteInt (hKey, VALUE_LASTCUSTOM_RESOLUTION, static_cast (settings.m_lastCustom->m_bloomResolutionDivisor)); + CHR (hr); + + hr = WriteInt (hKey, VALUE_LASTCUSTOM_SMOOTHNESS, static_cast (settings.m_lastCustom->m_blurTaps)); + CHR (hr); + } + Error: if (hKey != nullptr) diff --git a/MatrixRainCore/RegistrySettingsProvider.h b/MatrixRainCore/RegistrySettingsProvider.h index eae2a34..28828c6 100644 --- a/MatrixRainCore/RegistrySettingsProvider.h +++ b/MatrixRainCore/RegistrySettingsProvider.h @@ -29,7 +29,14 @@ class RegistrySettingsProvider : public ISettingsProvider static constexpr LPCWSTR VALUE_GLOW_SIZE = L"GlowSize"; static constexpr LPCWSTR VALUE_START_FULLSCREEN = L"StartFullscreen"; static constexpr LPCWSTR VALUE_SHOW_DEBUG_STATS = L"ShowDebugStats"; - static constexpr LPCWSTR VALUE_LAST_SAVED = L"LastSaved"; + static constexpr LPCWSTR VALUE_MULTIMONITOR = L"MultiMonitor"; + static constexpr LPCWSTR VALUE_GPU_ADAPTER = L"GpuAdapter"; + static constexpr LPCWSTR VALUE_QUALITY_PRESET = L"QualityPreset"; + static constexpr LPCWSTR VALUE_LASTCUSTOM_GLOW_INTENSITY = L"LastCustom_GlowIntensity"; + static constexpr LPCWSTR VALUE_LASTCUSTOM_PASSES = L"LastCustom_Passes"; + static constexpr LPCWSTR VALUE_LASTCUSTOM_RESOLUTION = L"LastCustom_Resolution"; + static constexpr LPCWSTR VALUE_LASTCUSTOM_SMOOTHNESS = L"LastCustom_Smoothness"; + static constexpr LPCWSTR VALUE_LAST_SAVED = L"LastSaved"; static HRESULT ReadInt (HKEY hKey, LPCWSTR valueName, int & outValue); static HRESULT ReadBool (HKEY hKey, LPCWSTR valueName, bool & outValue); diff --git a/MatrixRainCore/RenderSystem.cpp b/MatrixRainCore/RenderSystem.cpp index 8790a06..e31e132 100644 --- a/MatrixRainCore/RenderSystem.cpp +++ b/MatrixRainCore/RenderSystem.cpp @@ -8,11 +8,269 @@ #include "Overlay.h" #include "OverlayColor.h" +#pragma comment(lib, "pdh.lib") + using Microsoft::WRL::ComPtr; +//////////////////////////////////////////////////////////////////////////////// +// +// GPU load via PDH (matches Task Manager's per-process "GPU" column). +// +// Singleton inside this translation unit — one PDH query for the whole +// process, polled at most every 1 s (Task Manager itself samples at +// ~1 Hz). Returns -1.0 until at least one collection has succeeded +// (PDH counters return no data on the very first collection call). +// +// The counter path is the system-wide wildcard `\GPU Engine(*)\Utilization +// Percentage`. PDH expands the wildcard ONCE at AddCounter time and +// returns values only for the instances that existed then — new engine +// instances created later (driver reset, GPU sleep/resume, eGPU hot-plug, +// another D3D process starting) would otherwise be invisible to us. To +// keep the readings honest we close and re-open the query every +// `kRefreshIntervalMs` so the wildcard gets re-expanded against the +// current set of engine instances. Result-loop filters to our own PID +// by matching the `pid_NNNN_` substring of each instance name; the +// per-engine-type sums are then MAX-reduced (same shape Task Manager +// uses for the per-process "GPU" column). +// +//////////////////////////////////////////////////////////////////////////////// + +namespace { + +class GpuLoadMonitor +{ +public: + GpuLoadMonitor() = default; + + ~GpuLoadMonitor() + { + ReleaseQuery(); + } + + GpuLoadMonitor (const GpuLoadMonitor &) = delete; + GpuLoadMonitor & operator= (const GpuLoadMonitor &) = delete; + + // Returns most-recent load percentage in [0, 100], or -1.0 when not + // yet ready (initialization failed, or first collection has not yet + // produced data). Thread-safe. + double GetLoadPercent() + { + std::lock_guard lock (m_mutex); + + ULONGLONG nowTick = GetTickCount64(); + + + if (m_initFailed) + { + return -1.0; + } + + // Throttle PDH polling to ~1 Hz (matches Task Manager's update rate). + if (m_lastPollTick != 0 && (nowTick - m_lastPollTick) < kPollIntervalMs) + { + return m_cachedLoad; + } + + m_lastPollTick = nowTick; + + if (!EnsureQueryReady (nowTick)) + { + return m_cachedLoad; + } + + DWORD itemCount = 0; + PDH_FMT_COUNTERVALUE_ITEM_W * items = nullptr; + + + if (!CollectCounters (itemCount, items)) + { + return m_cachedLoad; + } + + m_cachedLoad = AggregateMaxPerEngineType (items, itemCount); + return m_cachedLoad; + } + +private: + static constexpr ULONGLONG kPollIntervalMs = 1000; // 1 Hz polling + static constexpr ULONGLONG kRefreshIntervalMs = 10000; // re-expand wildcard every 10 s + + // Open the PDH query and add the wildcard counter. Stores the open + // time in m_lastRefreshTick so EnsureQueryReady knows when to recycle + // the query to re-expand the wildcard. + bool Initialize (ULONGLONG nowTick) + { + if (PdhOpenQueryW (nullptr, 0, &m_query) != ERROR_SUCCESS) + { + m_query = nullptr; + return false; + } + + m_ownPid = GetCurrentProcessId(); + + if (PdhAddEnglishCounterW (m_query, + L"\\GPU Engine(*)\\Utilization Percentage", + 0, + &m_counter) != ERROR_SUCCESS) + { + ReleaseQuery(); + return false; + } + + m_lastRefreshTick = nowTick; + + // Prime the query — PDH returns no data on the very first collect. + PdhCollectQueryData (m_query); + return true; + } + + void ReleaseQuery() + { + if (m_query) + { + PdhCloseQuery (m_query); + } + + m_query = nullptr; + m_counter = nullptr; + } + + // Lazily open the query on first call, and periodically recycle it so + // PDH re-expands the wildcard against the current set of GPU engine + // instances (see class comment). Returns false if the query couldn't + // be opened (latches m_initFailed so subsequent calls fast-bail). + bool EnsureQueryReady (ULONGLONG nowTick) + { + if (m_query && (nowTick - m_lastRefreshTick) >= kRefreshIntervalMs) + { + ReleaseQuery(); + } + + if (!m_query) + { + if (!Initialize (nowTick)) + { + m_initFailed = true; + return false; + } + + // The priming Collect inside Initialize produced no data; let + // the next GetLoadPercent tick do the first real read. + return false; + } + + if (PdhCollectQueryData (m_query) != ERROR_SUCCESS) + { + return false; + } + + return true; + } + + // Two-pass PdhGetFormattedCounterArrayW: first call to size the buffer, + // second to fill it. On return outItems points into m_arrayBuf (which + // remains valid until the next call on the same thread under m_mutex). + bool CollectCounters (DWORD & outItemCount, PDH_FMT_COUNTERVALUE_ITEM_W * & outItems) + { + DWORD bufferBytes = 0; + PDH_STATUS s = PDH_CSTATUS_NO_OBJECT; + + + outItemCount = 0; + outItems = nullptr; + + s = PdhGetFormattedCounterArrayW (m_counter, PDH_FMT_DOUBLE, &bufferBytes, &outItemCount, nullptr); + + if (s != PDH_MORE_DATA || outItemCount == 0) + { + return false; + } + + m_arrayBuf.resize (bufferBytes); + outItems = reinterpret_cast (m_arrayBuf.data()); + + if (PdhGetFormattedCounterArrayW (m_counter, PDH_FMT_DOUBLE, &bufferBytes, &outItemCount, outItems) != ERROR_SUCCESS) + { + outItems = nullptr; + outItemCount = 0; + return false; + } + + return true; + } + + // Task Manager's per-process "GPU" column for our process: + // for each engine type (3D / Compute / Copy / Video* / ...): + // sum utilization across every instance of that engine type for + // OUR PID only + // take MAX across engine types + double AggregateMaxPerEngineType (const PDH_FMT_COUNTERVALUE_ITEM_W * items, DWORD count) const + { + wchar_t pidMarker[32]; + std::map sumPerEngineType; + double maxLoad = 0.0; + + + StringCchPrintfW (pidMarker, ARRAYSIZE (pidMarker), L"pid_%lu_", m_ownPid); + + for (DWORD i = 0; i < count; i++) + { + if (items[i].FmtValue.CStatus != ERROR_SUCCESS || !items[i].szName) + { + continue; + } + + std::wstring_view name (items[i].szName); + + if (name.find (pidMarker) == std::wstring_view::npos) + { + continue; + } + + constexpr std::wstring_view kEngTypeMarker = L"engtype_"; + size_t pos = name.find (kEngTypeMarker); + std::wstring engineType = (pos != std::wstring_view::npos) + ? std::wstring (name.substr (pos + kEngTypeMarker.size())) + : std::wstring (L""); + + sumPerEngineType[engineType] += items[i].FmtValue.doubleValue; + } + + for (const auto & [type, sum] : sumPerEngineType) + { + maxLoad = (std::max) (maxLoad, sum); + } + + return (std::min) (maxLoad, 100.0); + } + + + std::mutex m_mutex; + PDH_HQUERY m_query { nullptr }; + PDH_HCOUNTER m_counter { nullptr }; + ULONGLONG m_lastPollTick { 0 }; + ULONGLONG m_lastRefreshTick { 0 }; + DWORD m_ownPid { 0 }; + double m_cachedLoad { -1.0 }; + bool m_initFailed { false }; + std::vector m_arrayBuf; +}; + + +GpuLoadMonitor & SharedGpuLoadMonitor() +{ + static GpuLoadMonitor s_monitor; + return s_monitor; +} + +} // namespace + + + + RenderSystem::~RenderSystem() { @@ -23,7 +281,7 @@ RenderSystem::~RenderSystem() -HRESULT RenderSystem::Initialize (HWND hwnd, UINT width, UINT height) +HRESULT RenderSystem::Initialize (HWND hwnd, UINT width, UINT height, std::optional adapterLuid) { HRESULT hr = S_OK; D3D11_VIEWPORT viewport = {}; @@ -33,6 +291,12 @@ HRESULT RenderSystem::Initialize (HWND hwnd, UINT width, UINT height) m_hwnd = hwnd; m_renderWidth = width; m_renderHeight = height; + + // Remember the user's chosen adapter (if any) so CreateDevice can route + // device creation to the matching DXGI adapter. nullopt = use the + // system default (preserves the existing behaviour for callers that + // do not opt in to GPU selection). + m_requestedAdapterLuid = adapterLuid; hr = CreateDevice(); CHR (hr); @@ -145,10 +409,13 @@ HRESULT RenderSystem::RebuildOverlayAtlas() HRESULT RenderSystem::CreateDevice() { - HRESULT hr = S_OK; - UINT createDeviceFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; // Required for Direct2D interop - D3D_FEATURE_LEVEL featureLevels[] = { D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0 }; - D3D_FEATURE_LEVEL featureLevel; + HRESULT hr = S_OK; + UINT createDeviceFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; // Required for Direct2D interop + D3D_FEATURE_LEVEL featureLevels[] = { D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0 }; + D3D_FEATURE_LEVEL featureLevel; + Microsoft::WRL::ComPtr requestedAdapter; + D3D_DRIVER_TYPE driverType = D3D_DRIVER_TYPE_HARDWARE; + IDXGIAdapter * pAdapterForCreate = nullptr; @@ -156,8 +423,28 @@ HRESULT RenderSystem::CreateDevice() createDeviceFlags |= D3D11_CREATE_DEVICE_DEBUG; #endif - hr = D3D11CreateDevice (nullptr, // Default adapter - D3D_DRIVER_TYPE_HARDWARE, // Hardware acceleration + // If the user picked a specific adapter, look it up by LUID and use it + // here. D3D11CreateDevice REQUIRES driver type UNKNOWN when a non-null + // adapter is passed. On any lookup failure we silently fall back to + // the default-adapter path so a vanished GPU never blocks startup + // (FR-014). + if (m_requestedAdapterLuid.has_value()) + { + Microsoft::WRL::ComPtr pFactory4; + + if (SUCCEEDED (CreateDXGIFactory1 (IID_PPV_ARGS (&pFactory4)))) + { + if (SUCCEEDED (pFactory4->EnumAdapterByLuid (*m_requestedAdapterLuid, + IID_PPV_ARGS (&requestedAdapter)))) + { + pAdapterForCreate = requestedAdapter.Get(); + driverType = D3D_DRIVER_TYPE_UNKNOWN; + } + } + } + + hr = D3D11CreateDevice (pAdapterForCreate, // Explicit adapter or nullptr for default + driverType, // UNKNOWN if explicit adapter, else HARDWARE nullptr, // No software rasterizer createDeviceFlags, featureLevels, @@ -876,7 +1163,9 @@ static const char * s_kszBlurHorizontalShaderSource = R"( float4 color = float4(0, 0, 0, 0); - // 13-tap Gaussian blur (horizontal), spread scaled by glowSize + // 13-tap Gaussian blur (horizontal), spread scaled by glowSize. + // High-smoothness variant; see ..._Tap5 / ..._Tap9 below for the + // cheaper Low/Medium quality variants selected at bind time. float weights[13] = { 0.02, 0.04, 0.06, 0.08, 0.10, 0.12, 0.16, 0.12, 0.10, 0.08, 0.06, 0.04, 0.02 }; for (int i = -6; i <= 6; i++) { @@ -888,6 +1177,86 @@ static const char * s_kszBlurHorizontalShaderSource = R"( } )"; + + + +static const char * s_kszBlurHorizontalShader9TapSource = R"( + cbuffer BloomConstants : register(b0) + { + float bloomIntensity; + float glowSize; + float2 padding; + }; + + Texture2D inputTexture : register(t0); + SamplerState samplerState : register(s0); + + struct PSInput + { + float4 position : SV_POSITION; + float2 uv : TEXCOORD; + }; + + float4 main(PSInput input) : SV_TARGET + { + uint width, height; + inputTexture.GetDimensions(width, height); + float texelX = glowSize / width; + + float4 color = float4(0, 0, 0, 0); + + // 9-tap Gaussian blur (horizontal) - Medium quality variant. + float weights[9] = { 0.05, 0.09, 0.12, 0.15, 0.18, 0.15, 0.12, 0.09, 0.05 }; + for (int i = -4; i <= 4; i++) + { + float2 offset = float2(i * texelX, 0); + color += inputTexture.Sample(samplerState, input.uv + offset) * weights[i + 4]; + } + + return color; + } + )"; + + + + +static const char * s_kszBlurHorizontalShader5TapSource = R"( + cbuffer BloomConstants : register(b0) + { + float bloomIntensity; + float glowSize; + float2 padding; + }; + + Texture2D inputTexture : register(t0); + SamplerState samplerState : register(s0); + + struct PSInput + { + float4 position : SV_POSITION; + float2 uv : TEXCOORD; + }; + + float4 main(PSInput input) : SV_TARGET + { + uint width, height; + inputTexture.GetDimensions(width, height); + float texelX = glowSize / width; + + float4 color = float4(0, 0, 0, 0); + + // 5-tap Gaussian blur (horizontal) - Low quality variant. + float weights[5] = { 0.10, 0.24, 0.32, 0.24, 0.10 }; + for (int i = -2; i <= 2; i++) + { + float2 offset = float2(i * texelX, 0); + color += inputTexture.Sample(samplerState, input.uv + offset) * weights[i + 2]; + } + + return color; + } + )"; + static const char * s_kszBlurVerticalShaderSource = R"( cbuffer BloomConstants : register(b0) { @@ -913,7 +1282,8 @@ static const char * s_kszBlurVerticalShaderSource = R"( float4 color = float4(0, 0, 0, 0); - // 13-tap Gaussian blur (vertical), spread scaled by glowSize + // 13-tap Gaussian blur (vertical), spread scaled by glowSize. + // High-smoothness variant; see ..._Tap5 / ..._Tap9 below. float weights[13] = { 0.02, 0.04, 0.06, 0.08, 0.10, 0.12, 0.16, 0.12, 0.10, 0.08, 0.06, 0.04, 0.02 }; for (int i = -6; i <= 6; i++) { @@ -925,6 +1295,84 @@ static const char * s_kszBlurVerticalShaderSource = R"( } )"; + + + +static const char * s_kszBlurVerticalShader9TapSource = R"( + cbuffer BloomConstants : register(b0) + { + float bloomIntensity; + float glowSize; + float2 padding; + }; + + Texture2D inputTexture : register(t0); + SamplerState samplerState : register(s0); + + struct PSInput + { + float4 position : SV_POSITION; + float2 uv : TEXCOORD; + }; + + float4 main(PSInput input) : SV_TARGET + { + uint width, height; + inputTexture.GetDimensions(width, height); + float texelY = glowSize / height; + + float4 color = float4(0, 0, 0, 0); + + float weights[9] = { 0.05, 0.09, 0.12, 0.15, 0.18, 0.15, 0.12, 0.09, 0.05 }; + for (int i = -4; i <= 4; i++) + { + float2 offset = float2(0, i * texelY); + color += inputTexture.Sample(samplerState, input.uv + offset) * weights[i + 4]; + } + + return color; + } + )"; + + + + +static const char * s_kszBlurVerticalShader5TapSource = R"( + cbuffer BloomConstants : register(b0) + { + float bloomIntensity; + float glowSize; + float2 padding; + }; + + Texture2D inputTexture : register(t0); + SamplerState samplerState : register(s0); + + struct PSInput + { + float4 position : SV_POSITION; + float2 uv : TEXCOORD; + }; + + float4 main(PSInput input) : SV_TARGET + { + uint width, height; + inputTexture.GetDimensions(width, height); + float texelY = glowSize / height; + + float4 color = float4(0, 0, 0, 0); + + float weights[5] = { 0.10, 0.24, 0.32, 0.24, 0.10 }; + for (int i = -2; i <= 2; i++) + { + float2 offset = float2(0, i * texelY); + color += inputTexture.Sample(samplerState, input.uv + offset) * weights[i + 2]; + } + + return color; + } + )"; + static const char * s_kszBloomExtractShaderSource = R"( Texture2D inputTexture : register(t0); SamplerState samplerState : register(s0); @@ -1073,7 +1521,11 @@ HRESULT RenderSystem::CompileBloomShaders() ComPtr quadVSBlob; ComPtr extractPSBlob; ComPtr blurHPSBlob; + ComPtr blurH9PSBlob; + ComPtr blurH5PSBlob; ComPtr blurVPSBlob; + ComPtr blurV9PSBlob; + ComPtr blurV5PSBlob; ComPtr compositePSBlob; ComPtr haloPSBlob; QuadVertex quadVertices[] = { @@ -1087,12 +1539,16 @@ HRESULT RenderSystem::CompileBloomShaders() D3D11_BUFFER_DESC vbDesc = { }; D3D11_SUBRESOURCE_DATA vbData = { }; ShaderCompileEntry bloomShaderTable[] = { - { s_kszQuadVertexShaderSource, "QuadVS", "main", "vs_5_0", L"D3DCompile failed for quad vertex shader", quadVSBlob.GetAddressOf(), nullptr }, - { s_kszBloomExtractShaderSource, "Extract", "main", "ps_5_0", L"D3DCompile failed for bloom extract shader", extractPSBlob.GetAddressOf(), &m_bloomExtractPS }, - { s_kszBlurHorizontalShaderSource, "BlurH", "main", "ps_5_0", L"D3DCompile failed for horizontal blur shader", blurHPSBlob.GetAddressOf(), &m_blurHorizontalPS }, - { s_kszBlurVerticalShaderSource, "BlurV", "main", "ps_5_0", L"D3DCompile failed for vertical blur shader", blurVPSBlob.GetAddressOf(), &m_blurVerticalPS }, - { s_kszBloomCompositeShaderSource, "Composite", "main", "ps_5_0", L"D3DCompile failed for composite shader", compositePSBlob.GetAddressOf(), &m_compositePS }, - { s_kszHaloShaderSource, "Halo", "main", "ps_5_0", L"D3DCompile failed for halo shader", haloPSBlob.GetAddressOf(), &m_haloPS } + { s_kszQuadVertexShaderSource, "QuadVS", "main", "vs_5_0", L"D3DCompile failed for quad vertex shader", quadVSBlob.GetAddressOf(), nullptr }, + { s_kszBloomExtractShaderSource, "Extract", "main", "ps_5_0", L"D3DCompile failed for bloom extract shader", extractPSBlob.GetAddressOf(), &m_bloomExtractPS }, + { s_kszBlurHorizontalShaderSource, "BlurH13", "main", "ps_5_0", L"D3DCompile failed for horizontal blur 13-tap", blurHPSBlob.GetAddressOf(), &m_blurHorizontalPS }, + { s_kszBlurHorizontalShader9TapSource, "BlurH9", "main", "ps_5_0", L"D3DCompile failed for horizontal blur 9-tap", blurH9PSBlob.GetAddressOf(), &m_blurHorizontalPS9 }, + { s_kszBlurHorizontalShader5TapSource, "BlurH5", "main", "ps_5_0", L"D3DCompile failed for horizontal blur 5-tap", blurH5PSBlob.GetAddressOf(), &m_blurHorizontalPS5 }, + { s_kszBlurVerticalShaderSource, "BlurV13", "main", "ps_5_0", L"D3DCompile failed for vertical blur 13-tap", blurVPSBlob.GetAddressOf(), &m_blurVerticalPS }, + { s_kszBlurVerticalShader9TapSource, "BlurV9", "main", "ps_5_0", L"D3DCompile failed for vertical blur 9-tap", blurV9PSBlob.GetAddressOf(), &m_blurVerticalPS9 }, + { s_kszBlurVerticalShader5TapSource, "BlurV5", "main", "ps_5_0", L"D3DCompile failed for vertical blur 5-tap", blurV5PSBlob.GetAddressOf(), &m_blurVerticalPS5 }, + { s_kszBloomCompositeShaderSource, "Composite", "main", "ps_5_0", L"D3DCompile failed for composite shader", compositePSBlob.GetAddressOf(), &m_compositePS }, + { s_kszHaloShaderSource, "Halo", "main", "ps_5_0", L"D3DCompile failed for halo shader", haloPSBlob.GetAddressOf(), &m_haloPS } }; @@ -1144,6 +1600,7 @@ HRESULT RenderSystem::CreateBloomResources (UINT width, UINT height) HRESULT hr = S_OK; UINT bloomWidth; UINT bloomHeight; + int divisor = 0; D3D11_TEXTURE2D_DESC sceneTexDesc = { }; D3D11_TEXTURE2D_DESC texDesc = { }; @@ -1171,9 +1628,12 @@ HRESULT RenderSystem::CreateBloomResources (UINT width, UINT height) hr = m_device->CreateShaderResourceView (m_sceneTexture.Get(), nullptr, &m_sceneSRV); CHRA (hr); - // Create bloom extraction texture (half resolution for performance) - bloomWidth = width / 2; - bloomHeight = height / 2; + // Create bloom extraction texture (size driven by the Resolution slider: + // Full/Half/Quarter/Eighth map to integer divisors 1/2/4/8). + divisor = std::clamp (static_cast (m_bloomResolutionDivisor), 1, 8); + + bloomWidth = std::max (1u, width / static_cast (divisor)); + bloomHeight = std::max (1u, height / static_cast (divisor)); texDesc.Width = bloomWidth; texDesc.Height = bloomHeight; @@ -1321,20 +1781,24 @@ void RenderSystem::SetViewport(UINT width, UINT height) HRESULT RenderSystem::ApplyBloom() { - HRESULT hr = S_OK; + HRESULT hr = S_OK; ID3D11ShaderResourceView * srvs[2]; - ID3D11Buffer * nullCB = nullptr; + ID3D11Buffer * nullCB = nullptr; D3D11_MAPPED_SUBRESOURCE mappedBloomCB; + int viewportDivisor = 0; + ID3D11PixelShader * blurH = nullptr; + ID3D11PixelShader * blurV = nullptr; + int passCount = 0; // Safety check - if render target or bloom resources are being recreated during resize, skip CBREx (m_renderTargetView && m_sceneTexture && m_bloomTexture && m_bloomExtractPS && m_compositePS, E_UNEXPECTED); - // Scene texture already has the rendered characters - no need to copy from backbuffer - - // Set viewport for half-resolution processing - SetViewport (m_renderWidth / 2, m_renderHeight / 2); + // Bloom viewport matches the bloom buffer size (resolution-divisor scaled). + viewportDivisor = std::clamp (static_cast (m_bloomResolutionDivisor), 1, 8); + SetViewport (std::max (1u, m_renderWidth / static_cast (viewportDivisor)), + std::max (1u, m_renderHeight / static_cast (viewportDivisor))); // EXTRACTION PASS: Extract only bright pixels from scene to bloom texture SetRenderPipelineState (m_fullscreenQuadInputLayout.Get(), @@ -1366,16 +1830,35 @@ HRESULT RenderSystem::ApplyBloom() // Bind constant buffer for blur shaders (glowSize controls spread) m_context->PSSetConstantBuffers (0, 1, m_bloomConstantBuffer.GetAddressOf()); + // Select the blur shader variant matching the user's current smoothness + // setting. Low/Medium variants use cheaper 5-tap / 9-tap kernels; High + // uses the canonical 13-tap kernel. + blurH = m_blurHorizontalPS.Get(); + blurV = m_blurVerticalPS.Get(); + + if (m_blurTaps == BlurTaps::Low && m_blurHorizontalPS5 && m_blurVerticalPS5) + { + blurH = m_blurHorizontalPS5.Get(); + blurV = m_blurVerticalPS5.Get(); + } + else if (m_blurTaps == BlurTaps::Medium && m_blurHorizontalPS9 && m_blurVerticalPS9) + { + blurH = m_blurHorizontalPS9.Get(); + blurV = m_blurVerticalPS9.Get(); + } + // Multiple blur passes: each H+V pass blurs the previous result, - // creating an exponentially wider and softer glow. Three passes with - // a 13-tap kernel produce a very wide, cinematic bloom falloff. - for (int pass = 0; pass < 3; ++pass) + // creating an exponentially wider and softer glow. Pass count and + // smoothness are both runtime-configurable via the Quality preset. + passCount = std::clamp (m_blurPasses, 1, 4); + + for (int pass = 0; pass < passCount; ++pass) { // Horizontal blur pass (bloom → temp) - RenderFullscreenPass (m_blurTempRTV.Get(), m_blurHorizontalPS.Get(), m_bloomSRV.GetAddressOf(), 1); + RenderFullscreenPass (m_blurTempRTV.Get(), blurH, m_bloomSRV.GetAddressOf(), 1); // Vertical blur pass (temp → bloom) - RenderFullscreenPass (m_bloomRTV.Get(), m_blurVerticalPS.Get(), m_blurTempSRV.GetAddressOf(), 1); + RenderFullscreenPass (m_bloomRTV.Get(), blurV, m_blurTempSRV.GetAddressOf(), 1); } // Restore full viewport @@ -1723,8 +2206,37 @@ void RenderSystem::Render (const AnimationSystem & animationSystem, const Viewpo renderOverlay (params.pHotkeyOverlay); renderOverlay (params.pUsageOverlay); - // Apply bloom (extracts bright pixels including overlay text, composites to backbuffer) - (void)ApplyBloom(); + // Apply bloom (extracts bright pixels including overlay text, composites + // to backbuffer). When the user has dialed Glow Intensity to 0% the + // entire bloom pipeline is bypassed and the scene texture is composited + // directly to the backbuffer (FR-031: "glow disabled" = true off, not + // just darker). + if (m_glowIntensity > 0.0f) + { + (void)ApplyBloom(); + } + else + { + // Direct scene-to-backbuffer copy: render the scene texture without + // the bloom extract/blur/composite passes. Bind a null SRV at + // slot 1 (bloom) so the composite PS doesn't sample whatever + // texture was left bound there by a previous bloom-on frame — + // PSSetShaderResources with numResources=1 only touches slot 0. + m_context->OMSetRenderTargets (1, m_renderTargetView.GetAddressOf(), nullptr); + m_context->OMSetBlendState (nullptr, nullptr, 0xffffffff); + + ID3D11ShaderResourceView * srvs[] = { m_sceneSRV.Get(), nullptr }; + + SetRenderPipelineState (m_fullscreenQuadInputLayout.Get(), + D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST, + m_fullscreenQuadVB.Get(), + sizeof (float) * 5, + m_fullscreenQuadVS.Get(), + nullptr, + nullptr); + m_context->PSSetSamplers (0, 1, m_samplerState.GetAddressOf()); + RenderFullscreenPass (m_renderTargetView.Get(), m_compositePS.Get(), srvs, 2); + } } else { @@ -1736,7 +2248,10 @@ void RenderSystem::Render (const AnimationSystem & animationSystem, const Viewpo // Render FPS counter overlay if fps > 0 if (params.fps > 0.0f) { - RenderFPSCounter (params.fps, params.rainPercentage, params.streakCount, params.activeHeadCount); + double gpuLoad = SharedGpuLoadMonitor().GetLoadPercent(); + bool gpuLoadValid = gpuLoad >= 0.0; + + RenderFPSCounter (params.fps, params.rainPercentage, params.streakCount, params.activeHeadCount, gpuLoad, gpuLoadValid); } // Debug: Render fade times when enabled and only one streak is visible @@ -1750,19 +2265,21 @@ void RenderSystem::Render (const AnimationSystem & animationSystem, const Viewpo -void RenderSystem::Present() +HRESULT RenderSystem::Present() { - if (m_swapChain) + if (!m_swapChain) { - m_swapChain->Present (1, 0); // VSync enabled + return S_OK; } + + return m_swapChain->Present (1, 0); // VSync enabled } -void RenderSystem::RenderFPSCounter (float fps, int rainPercentage, int streakCount, int activeHeadCount) +void RenderSystem::RenderFPSCounter (float fps, int rainPercentage, int streakCount, int activeHeadCount, double gpuLoadPercent, bool gpuLoadValid) { HRESULT hr = S_OK; bool drawing = false; @@ -1781,8 +2298,18 @@ void RenderSystem::RenderFPSCounter (float fps, int rainPercentage, int streakCo m_d2dContext->BeginDraw(); drawing = true; - // Format FPS text with rain density info: "Rain xxx% (yyy heads / zzz total), ww FPS" - swprintf_s (fpsText, L"Rain %d%% (%d heads / %d total), %.0f FPS", rainPercentage, activeHeadCount, streakCount, fps); + // Format FPS text with rain density info and GPU load: + // "Rain xxx% (yyy heads / zzz total), ww FPS, GPU vv%" + if (gpuLoadValid) + { + swprintf_s (fpsText, L"Rain %d%% (%d heads / %d total), %.0f FPS, GPU %.0f%%", + rainPercentage, activeHeadCount, streakCount, fps, gpuLoadPercent); + } + else + { + swprintf_s (fpsText, L"Rain %d%% (%d heads / %d total), %.0f FPS, GPU --%%", + rainPercentage, activeHeadCount, streakCount, fps); + } // Get render target size for positioning size = m_d2dContext->GetSize(); @@ -2884,6 +3411,64 @@ void RenderSystem::SetGlowSize (int sizePercent) +//////////////////////////////////////////////////////////////////////////////// +// +// RenderSystem::SetBlurPasses / SetBloomResolution / SetBlurTaps +// +//////////////////////////////////////////////////////////////////////////////// + +void RenderSystem::SetBlurPasses (int passes) +{ + m_blurPasses = passes; +} + + +void RenderSystem::SetBloomResolution (int divisor) +{ + // Stored as the divisor integer (1/2/4/8); a bloom-buffer recreate is + // required when this changes. The viewport divisor in ApplyBloom uses + // the same enum value to keep the half/quarter/eighth render consistent. + ResolutionDivisor newDiv; + + switch (divisor) + { + case 1: newDiv = ResolutionDivisor::Full; break; + case 4: newDiv = ResolutionDivisor::Quarter; break; + case 8: newDiv = ResolutionDivisor::Eighth; break; + case 2: + default: + newDiv = ResolutionDivisor::Half; + break; + } + + if (newDiv != m_bloomResolutionDivisor) + { + m_bloomResolutionDivisor = newDiv; + + // Recreate the bloom textures at the new resolution. Recreate is + // a no-op if the device is not yet initialized. + if (m_device && m_renderWidth > 0 && m_renderHeight > 0) + { + (void)CreateBloomResources (m_renderWidth, m_renderHeight); + } + } +} + + +void RenderSystem::SetBlurTaps (int taps) +{ + switch (taps) + { + case 5: m_blurTaps = BlurTaps::Low; break; + case 9: m_blurTaps = BlurTaps::Medium; break; + case 13: + default: m_blurTaps = BlurTaps::High; break; + } +} + + + + //////////////////////////////////////////////////////////////////////////////// // diff --git a/MatrixRainCore/RenderSystem.h b/MatrixRainCore/RenderSystem.h index 1bc2013..1a3e99e 100644 --- a/MatrixRainCore/RenderSystem.h +++ b/MatrixRainCore/RenderSystem.h @@ -9,6 +9,7 @@ #include "GlyphAtlas.h" #include "IRenderSystem.h" #include "Overlay.h" +#include "QualityPresets.h" #include "RenderParams.h" #include "Viewport.h" #include "ColorScheme.h" @@ -40,7 +41,7 @@ class RenderSystem : public IRenderSystem public: ~RenderSystem(); - HRESULT Initialize (HWND hwnd, UINT width, UINT height); + HRESULT Initialize (HWND hwnd, UINT width, UINT height, std::optional adapterLuid = std::nullopt); HRESULT BuildGlyphAtlas(); @@ -48,7 +49,7 @@ class RenderSystem : public IRenderSystem void Render (const AnimationSystem & animationSystem, const Viewport & viewport, const RenderParams & params) override; - void Present() override; + HRESULT Present() override; void Resize (UINT width, UINT height) override; @@ -64,6 +65,10 @@ class RenderSystem : public IRenderSystem void SetGlowSize (int sizePercent) override; + void SetBlurPasses (int passes) override; + void SetBloomResolution (int divisor) override; + void SetBlurTaps (int taps) override; + void SetCharacterScaleOverride (float scale) override; void OnDpiChanged (UINT dpi) override; @@ -143,7 +148,7 @@ class RenderSystem : public IRenderSystem void SortStreaksByDepth (std::vector & streaks); HRESULT UpdateInstanceBuffer (const AnimationSystem & animationSystem, ColorScheme colorScheme, float elapsedTime); void ClearRenderTarget(); - void RenderFPSCounter (float fps, int rainPercentage, int streakCount, int activeHeadCount); + void RenderFPSCounter (float fps, int rainPercentage, int streakCount, int activeHeadCount, double gpuLoadPercent, bool gpuLoadValid); void DrawFeatheredGlow (const wchar_t * fpsText, UINT32 textLength, const D2D1_RECT_F & textRect); void DrawFeatheredBackground (std::span chars, std::span xPositions, float advanceScale, float baseY, float cellHeight, int numRows, float padding, float opacityScale); void ComputeRowRects (std::span chars, std::span xPositions, float advanceScale, float baseY, float cellHeight, int numRows, float hPad, float vPad); @@ -192,7 +197,11 @@ class RenderSystem : public IRenderSystem ComPtr m_blurTempSRV; ComPtr m_bloomExtractPS; ComPtr m_blurHorizontalPS; + ComPtr m_blurHorizontalPS9; + ComPtr m_blurHorizontalPS5; ComPtr m_blurVerticalPS; + ComPtr m_blurVerticalPS9; + ComPtr m_blurVerticalPS5; ComPtr m_compositePS; ComPtr m_haloPS; ComPtr m_fullscreenQuadVB; @@ -241,6 +250,11 @@ class RenderSystem : public IRenderSystem // Window handle (for DPI queries) HWND m_hwnd { nullptr }; + // User-selected adapter LUID, or nullopt for system default. Captured + // in Initialize and consumed by CreateDevice to route D3D device + // creation to the requested GPU on hybrid laptops. + std::optional m_requestedAdapterLuid; + // Render target dimensions UINT m_renderWidth { 0 }; UINT m_renderHeight { 0 }; @@ -252,6 +266,12 @@ class RenderSystem : public IRenderSystem float m_glowIntensity { 2.5f }; // Bloom intensity multiplier (100% = 2.5) float m_glowSize { 1.0f }; // Blur radius multiplier (100% = 1.0) + // User Story 5 - graphics quality runtime parameters. Driven by the + // settings snapshot path the same way m_glowIntensity is. + int m_blurPasses { 3 }; + ResolutionDivisor m_bloomResolutionDivisor { ResolutionDivisor::Half }; + BlurTaps m_blurTaps { BlurTaps::High }; + // Character scale override (bypasses viewport-based scaling when set) std::optional m_characterScaleOverride; diff --git a/MatrixRainCore/ScreenSaverSettings.h b/MatrixRainCore/ScreenSaverSettings.h index d5ff852..88b97ae 100644 --- a/MatrixRainCore/ScreenSaverSettings.h +++ b/MatrixRainCore/ScreenSaverSettings.h @@ -1,5 +1,7 @@ #pragma once +#include "QualityPresets.h" + using SystemClockTimePoint = std::chrono::system_clock::time_point; @@ -32,6 +34,16 @@ struct ScreenSaverSettings bool m_startFullscreen { true }; bool m_showDebugStats { false }; bool m_showFadeTimers { false }; + bool m_multiMonitorEnabled { true }; + std::wstring m_gpuAdapter; // Empty (default) = system default + + // User Story 5 - graphics quality preset + advanced control values. + // The High preset is calibrated to today's exact rendering so users + // who upgrade and never open the dialog see no visible change (FR-022). + QualityPreset m_qualityPreset { QualityPreset::High }; + AdvancedGraphicsValues m_advancedValues; // Defaults to High row + std::optional m_lastCustom; // Last user-customized set + std::optional m_lastSavedTimestamp; void Clamp(); diff --git a/MatrixRainCore/SharedState.h b/MatrixRainCore/SharedState.h index 6879981..0eaac4b 100644 --- a/MatrixRainCore/SharedState.h +++ b/MatrixRainCore/SharedState.h @@ -1,6 +1,7 @@ #pragma once #include "ColorScheme.h" +#include "QualityPresets.h" #include "ScreenSaverSettings.h" @@ -50,6 +51,13 @@ struct SharedState int glowIntensityPercent = ScreenSaverSettings::DEFAULT_GLOW_INTENSITY_PERCENT; int glowSizePercent = ScreenSaverSettings::DEFAULT_GLOW_SIZE_PERCENT; + // Quality-preset-driven advanced graphics knobs. The defaults here + // mirror the High preset (today's hardcoded values) so the snapshot + // produces the same output as v1.3 until the user changes something. + int blurPasses = 3; + ResolutionDivisor bloomResolutionDivisor = ResolutionDivisor::Half; + BlurTaps blurTaps = BlurTaps::High; + // Debug/statistics display bool showStatistics = false; bool showDebugFadeTimes = false; @@ -73,15 +81,18 @@ struct SharedState struct Snapshot { - int densityPercent = ScreenSaverSettings::DEFAULT_DENSITY_PERCENT; - ColorScheme colorScheme = ColorScheme::Green; - int animationSpeedPercent = ScreenSaverSettings::DEFAULT_ANIMATION_SPEED_PERCENT; - int glowIntensityPercent = ScreenSaverSettings::DEFAULT_GLOW_INTENSITY_PERCENT; - int glowSizePercent = ScreenSaverSettings::DEFAULT_GLOW_SIZE_PERCENT; - bool showStatistics = false; - bool showDebugFadeTimes = false; - bool isPaused = false; - float elapsedTime = 0.0f; + int densityPercent = ScreenSaverSettings::DEFAULT_DENSITY_PERCENT; + ColorScheme colorScheme = ColorScheme::Green; + int animationSpeedPercent = ScreenSaverSettings::DEFAULT_ANIMATION_SPEED_PERCENT; + int glowIntensityPercent = ScreenSaverSettings::DEFAULT_GLOW_INTENSITY_PERCENT; + int glowSizePercent = ScreenSaverSettings::DEFAULT_GLOW_SIZE_PERCENT; + int blurPasses = 3; + ResolutionDivisor bloomResolutionDivisor = ResolutionDivisor::Half; + BlurTaps blurTaps = BlurTaps::High; + bool showStatistics = false; + bool showDebugFadeTimes = false; + bool isPaused = false; + float elapsedTime = 0.0f; }; @@ -90,15 +101,18 @@ struct SharedState { return Snapshot { - .densityPercent = densityPercent, - .colorScheme = colorScheme, - .animationSpeedPercent = animationSpeedPercent, - .glowIntensityPercent = glowIntensityPercent, - .glowSizePercent = glowSizePercent, - .showStatistics = showStatistics, - .showDebugFadeTimes = showDebugFadeTimes, - .isPaused = isPaused, - .elapsedTime = elapsedTime, + .densityPercent = densityPercent, + .colorScheme = colorScheme, + .animationSpeedPercent = animationSpeedPercent, + .glowIntensityPercent = glowIntensityPercent, + .glowSizePercent = glowSizePercent, + .blurPasses = blurPasses, + .bloomResolutionDivisor = bloomResolutionDivisor, + .blurTaps = blurTaps, + .showStatistics = showStatistics, + .showDebugFadeTimes = showDebugFadeTimes, + .isPaused = isPaused, + .elapsedTime = elapsedTime, }; } }; diff --git a/MatrixRainCore/Version.h b/MatrixRainCore/Version.h index e1ff31f..6b29d50 100644 --- a/MatrixRainCore/Version.h +++ b/MatrixRainCore/Version.h @@ -5,7 +5,7 @@ #define VERSION_MAJOR 1 #define VERSION_MINOR 3 -#define VERSION_BUILD 1984 +#define VERSION_BUILD 2098 #define VERSION_YEAR 2026 // Helper macros for stringification diff --git a/MatrixRainCore/WindowsAdapterProvider.cpp b/MatrixRainCore/WindowsAdapterProvider.cpp new file mode 100644 index 0000000..6ecabc9 --- /dev/null +++ b/MatrixRainCore/WindowsAdapterProvider.cpp @@ -0,0 +1,122 @@ +#include "pch.h" + +#include "WindowsAdapterProvider.h" + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// GetDefaultAdapterLuid +// +// Returns the LUID of the system default rendering adapter (the one Windows +// picks via the UNSPECIFIED GPU preference), or std::nullopt if it cannot +// be determined. Used to set AdapterInfo::m_isDefault. +// +//////////////////////////////////////////////////////////////////////////////// + +static std::optional GetDefaultAdapterLuid (IDXGIFactory1 * pFactory) +{ + HRESULT hr = S_OK; + Microsoft::WRL::ComPtr pFactory6; + Microsoft::WRL::ComPtr pDefaultAdapter; + DXGI_ADAPTER_DESC1 desc = {}; + std::optional result; + + + hr = pFactory->QueryInterface (IID_PPV_ARGS (&pFactory6)); + CBREx (SUCCEEDED (hr), S_OK); + + hr = pFactory6->EnumAdapterByGpuPreference (0, + DXGI_GPU_PREFERENCE_UNSPECIFIED, + IID_PPV_ARGS (&pDefaultAdapter)); + CBREx (SUCCEEDED (hr), S_OK); + + hr = pDefaultAdapter->GetDesc1 (&desc); + CBREx (SUCCEEDED (hr), S_OK); + + result = desc.AdapterLuid; + + +Error: + return result; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// WindowsAdapterProvider::EnumerateAdapters +// +//////////////////////////////////////////////////////////////////////////////// + +std::vector WindowsAdapterProvider::EnumerateAdapters() const +{ + HRESULT hr = S_OK; + Microsoft::WRL::ComPtr pFactory; + std::vector adapters; + std::optional defaultLuid; + UINT index = 0; + bool dxgiFailure = false; + + + hr = CreateDXGIFactory1 (IID_PPV_ARGS (&pFactory)); + CBREx (SUCCEEDED (hr), S_OK); + + defaultLuid = GetDefaultAdapterLuid (pFactory.Get()); + + + for (;;) + { + Microsoft::WRL::ComPtr pAdapter; + DXGI_ADAPTER_DESC1 desc = {}; + + + hr = pFactory->EnumAdapters1 (index, &pAdapter); + + if (hr == DXGI_ERROR_NOT_FOUND) + { + // End of enumeration is the normal termination, not a failure. + hr = S_OK; + break; + } + + if (FAILED (hr)) + { + // Per contracts/adapter-provider.md: on any DXGI failure return + // an empty vector (callers then use the system default-adapter + // path). Latch the failure and bail out of the loop; the + // cleared `adapters` happens at the Error: label below. + dxgiFailure = true; + break; + } + + hr = pAdapter->GetDesc1 (&desc); + + if (SUCCEEDED (hr) && (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) == 0) + { + AdapterInfo info; + info.m_description = desc.Description; + info.m_luid = desc.AdapterLuid; + info.m_dedicatedVramMb = static_cast (desc.DedicatedVideoMemory / (1024ull * 1024ull)); + info.m_isSoftware = false; + info.m_isDefault = defaultLuid.has_value() && + defaultLuid->LowPart == desc.AdapterLuid.LowPart && + defaultLuid->HighPart == desc.AdapterLuid.HighPart; + + adapters.push_back (std::move (info)); + } + + ++index; + } + + +Error: + if (dxgiFailure) + { + adapters.clear(); + } + + return adapters; +} diff --git a/MatrixRainCore/WindowsAdapterProvider.h b/MatrixRainCore/WindowsAdapterProvider.h new file mode 100644 index 0000000..f0d3e76 --- /dev/null +++ b/MatrixRainCore/WindowsAdapterProvider.h @@ -0,0 +1,31 @@ +#pragma once + +#include "IAdapterProvider.h" + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// WindowsAdapterProvider +// +// Concrete IAdapterProvider that enumerates GPU adapters via DXGI. Drives +// the GPU dropdown in the configuration dialog and the adapter-resolution +// path used by Application::Initialize / RebuildContextsForCurrentMode. +// +// Implementation uses IDXGIFactory1::EnumAdapters1 for the basic list and +// IDXGIFactory6::EnumAdapterByGpuPreference (UNSPECIFIED) to identify the +// system default rendering adapter. Software adapters +// (DXGI_ADAPTER_FLAG_SOFTWARE - Microsoft Basic Render Driver / WARP) are +// filtered out before being returned. +// +// On any DXGI failure the provider returns an empty vector; callers then +// use the default-adapter path (D3D11CreateDevice(nullptr, HARDWARE)). +// +//////////////////////////////////////////////////////////////////////////////// + +class WindowsAdapterProvider : public IAdapterProvider +{ +public: + std::vector EnumerateAdapters() const override; +}; diff --git a/MatrixRainCore/pch.h b/MatrixRainCore/pch.h index 17edcdd..6b2e66f 100644 --- a/MatrixRainCore/pch.h +++ b/MatrixRainCore/pch.h @@ -37,6 +37,8 @@ #include #include +#include +#include #include #include #include @@ -47,6 +49,7 @@ #include #include #include +#include #include diff --git a/MatrixRainTests/MatrixRainTests.vcxproj b/MatrixRainTests/MatrixRainTests.vcxproj index e7540e3..33bb2b6 100644 --- a/MatrixRainTests/MatrixRainTests.vcxproj +++ b/MatrixRainTests/MatrixRainTests.vcxproj @@ -317,6 +317,12 @@ + + + + + + diff --git a/MatrixRainTests/SpyRenderSystem.h b/MatrixRainTests/SpyRenderSystem.h index 8f9f8c6..aacbbe5 100644 --- a/MatrixRainTests/SpyRenderSystem.h +++ b/MatrixRainTests/SpyRenderSystem.h @@ -25,6 +25,9 @@ class SpyRenderSystem : public IRenderSystem int m_dpiChangedCount = 0; int m_glowIntensity = -1; int m_glowSize = -1; + int m_blurPasses = -1; + int m_bloomResolution = -1; + int m_blurTaps = -1; float m_characterScale = -1.0f; float m_dpiScale = 1.0f; @@ -45,12 +48,20 @@ class SpyRenderSystem : public IRenderSystem } - void Present() override + HRESULT Present() override { m_presentCount++; + + return m_presentReturnHr; } + // Test seam: setting this lets a test simulate Present returning a + // device-lost HRESULT (e.g., DXGI_ERROR_DEVICE_REMOVED) so callers + // can exercise their recovery paths without a real D3D device. + HRESULT m_presentReturnHr = S_OK; + + void Resize (UINT width, UINT height) override { m_resizeCount++; @@ -78,6 +89,24 @@ class SpyRenderSystem : public IRenderSystem } + void SetBlurPasses (int passes) override + { + m_blurPasses = passes; + } + + + void SetBloomResolution (int divisor) override + { + m_bloomResolution = divisor; + } + + + void SetBlurTaps (int taps) override + { + m_blurTaps = taps; + } + + void SetCharacterScaleOverride (float scale) override { m_characterScale = scale; diff --git a/MatrixRainTests/unit/AdapterSelectionTests.cpp b/MatrixRainTests/unit/AdapterSelectionTests.cpp new file mode 100644 index 0000000..da704c5 --- /dev/null +++ b/MatrixRainTests/unit/AdapterSelectionTests.cpp @@ -0,0 +1,173 @@ +#include "Pch_MatrixRainTests.h" + +#include "..\..\MatrixRainCore\AdapterSelection.h" +#include "..\..\MatrixRainCore\InMemoryAdapterProvider.h" + + + + +namespace MatrixRainTests +{ + + + static AdapterInfo MakeAdapter (const std::wstring & description, LONG luidLo, bool isDefault, unsigned int vramMb = 1024) + { + AdapterInfo a; + a.m_description = description; + a.m_luid = LUID { static_cast (luidLo), 0 }; + a.m_dedicatedVramMb = vramMb; + a.m_isSoftware = false; + a.m_isDefault = isDefault; + return a; + } + + + + + static bool LuidEquals (LUID a, LUID b) + { + return a.LowPart == b.LowPart && a.HighPart == b.HighPart; + } + + + + + TEST_CLASS (AdapterSelectionTests) + { + public: + + // + // ResolveAdapter + // + + TEST_METHOD (ResolveAdapter_EmptySaved_ReturnsNullopt) + { + std::vector adapters { + MakeAdapter (L"Intel UHD Graphics 620", 100, true), + MakeAdapter (L"NVIDIA GeForce GTX 1650", 200, false) + }; + + auto result = ResolveAdapter (adapters, L""); + + Assert::IsFalse (result.has_value()); + } + + + + + TEST_METHOD (ResolveAdapter_EmptyAdapterList_ReturnsNullopt) + { + std::vector adapters; + + auto result = ResolveAdapter (adapters, L"NVIDIA GeForce GTX 1650"); + + Assert::IsFalse (result.has_value()); + } + + + + + TEST_METHOD (ResolveAdapter_NonMatchingSaved_ReturnsNullopt) + { + std::vector adapters { + MakeAdapter (L"Intel UHD Graphics 620", 100, true) + }; + + auto result = ResolveAdapter (adapters, L"Acme GPU 9000"); + + Assert::IsFalse (result.has_value()); + } + + + + + TEST_METHOD (ResolveAdapter_MatchingSaved_ReturnsLuid) + { + std::vector adapters { + MakeAdapter (L"Intel UHD Graphics 620", 100, true), + MakeAdapter (L"NVIDIA GeForce GTX 1650", 200, false) + }; + + auto result = ResolveAdapter (adapters, L"NVIDIA GeForce GTX 1650"); + + Assert::IsTrue (result.has_value()); + Assert::IsTrue (LuidEquals (*result, LUID { 200, 0 })); + } + + + + + TEST_METHOD (ResolveAdapter_DuplicateDescriptions_ReturnsFirstMatch) + { + std::vector adapters { + MakeAdapter (L"Acme GPU", 100, true), + MakeAdapter (L"Acme GPU", 200, false) + }; + + auto result = ResolveAdapter (adapters, L"Acme GPU"); + + Assert::IsTrue (result.has_value()); + Assert::IsTrue (LuidEquals (*result, LUID { 100, 0 })); + } + + + + + // + // FormatAdapterLabel + // + + TEST_METHOD (FormatAdapterLabel_NonDefault_ReturnsDescriptionUnchanged) + { + AdapterInfo a = MakeAdapter (L"NVIDIA GeForce GTX 1650", 200, false); + + Assert::AreEqual (std::wstring (L"NVIDIA GeForce GTX 1650"), FormatAdapterLabel (a)); + } + + + + + TEST_METHOD (FormatAdapterLabel_Default_AppendsSuffix) + { + AdapterInfo a = MakeAdapter (L"Intel UHD Graphics 620", 100, true); + + Assert::AreEqual (std::wstring (L"Intel UHD Graphics 620 (default)"), FormatAdapterLabel (a)); + } + + + + + TEST_METHOD (FormatAdapterLabel_EmptyDescriptionDefault_ReturnsSuffixOnly) + { + AdapterInfo a = MakeAdapter (L"", 0, true); + + Assert::AreEqual (std::wstring (L" (default)"), FormatAdapterLabel (a)); + } + + + + + // + // InMemoryAdapterProvider + // + + TEST_METHOD (InMemoryAdapterProvider_ReturnsCopyOfInput) + { + std::vector input { + MakeAdapter (L"Intel UHD Graphics 620", 100, true), + MakeAdapter (L"NVIDIA GeForce GTX 1650", 200, false) + }; + + InMemoryAdapterProvider provider (input); + auto enumerated = provider.EnumerateAdapters(); + + Assert::AreEqual (size_t (2), enumerated.size()); + Assert::AreEqual (std::wstring (L"Intel UHD Graphics 620"), enumerated[0].m_description); + Assert::AreEqual (std::wstring (L"NVIDIA GeForce GTX 1650"), enumerated[1].m_description); + Assert::IsTrue (enumerated[0].m_isDefault); + Assert::IsFalse (enumerated[1].m_isDefault); + } + }; + + +} diff --git a/MatrixRainTests/unit/ConfigDialogControllerTests.cpp b/MatrixRainTests/unit/ConfigDialogControllerTests.cpp index 86430cc..478ea3d 100644 --- a/MatrixRainTests/unit/ConfigDialogControllerTests.cpp +++ b/MatrixRainTests/unit/ConfigDialogControllerTests.cpp @@ -329,6 +329,64 @@ namespace MatrixRainTests + // T044 - new US5 controller methods + + TEST_METHOD (TestUpdateQualityPreset_SnapsAdvancedValues) + { + ConfigDialogController controller (m_settingsProvider); + controller.Initialize(); + + controller.UpdateQualityPreset (QualityPreset::Low); + + Assert::IsTrue (controller.GetSettings().m_qualityPreset == QualityPreset::Low); + Assert::IsTrue (controller.GetSettings().m_advancedValues == LookupPresetValues (QualityPreset::Low)); + } + + + + + TEST_METHOD (TestUpdateAdvancedGraphicsValues_DriftsToCustom) + { + ConfigDialogController controller (m_settingsProvider); + controller.Initialize(); + + // Pick values that don't match any named preset. + AdvancedGraphicsValues custom { 137, 4, ResolutionDivisor::Eighth, BlurTaps::Low }; + controller.UpdateAdvancedGraphicsValues (custom); + + Assert::IsTrue (controller.GetSettings().m_qualityPreset == QualityPreset::Custom, + L"Off-table values should auto-flip preset to Custom"); + Assert::IsTrue (controller.GetSettings().m_advancedValues == custom); + Assert::IsTrue (controller.GetSettings().m_lastCustom.has_value()); + Assert::IsTrue (*controller.GetSettings().m_lastCustom == custom, + L"LastCustom should always capture the latest advanced edit"); + } + + + + + TEST_METHOD (TestUpdateQualityPreset_Custom_WithSavedLastCustom_RestoresIt) + { + ConfigDialogController controller (m_settingsProvider); + controller.Initialize(); + + // Save a custom set by editing first. + AdvancedGraphicsValues custom { 50, 2, ResolutionDivisor::Quarter, BlurTaps::Medium }; + controller.UpdateAdvancedGraphicsValues (custom); + + // Navigate to a named preset (snaps advanced away from custom). + controller.UpdateQualityPreset (QualityPreset::High); + Assert::IsTrue (controller.GetSettings().m_advancedValues == LookupPresetValues (QualityPreset::High)); + + // Switch back to Custom -> should restore the saved set. + controller.UpdateQualityPreset (QualityPreset::Custom); + Assert::IsTrue (controller.GetSettings().m_advancedValues == custom); + } + + + + + // T019.10: Test ConfigDialogController saves settings on ApplyChanges TEST_METHOD (TestConfigDialogControllerSavesOnApply) diff --git a/MatrixRainTests/unit/DeviceLostTests.cpp b/MatrixRainTests/unit/DeviceLostTests.cpp new file mode 100644 index 0000000..20ad9b4 --- /dev/null +++ b/MatrixRainTests/unit/DeviceLostTests.cpp @@ -0,0 +1,95 @@ +#include "Pch_MatrixRainTests.h" + +#include "..\..\MatrixRainCore\DeviceLost.h" + + +// Same as DeviceLost.cpp — D3DDDIERR_DEVICEREMOVED lives in d3dukmdt.h +// (kernel-mode DDI header) which user-mode TUs don't pull in. Define +// inline so the test can pin the production classifier's behaviour. +#ifndef D3DDDIERR_DEVICEREMOVED +#define D3DDDIERR_DEVICEREMOVED ((HRESULT) 0x88760870L) +#endif + + + + +namespace MatrixRainTests +{ + + + TEST_CLASS (DeviceLostTests) + { + public: + + TEST_METHOD (IsDeviceLost_DeviceRemoved_ReturnsTrue) + { + Assert::IsTrue (IsDeviceLost (DXGI_ERROR_DEVICE_REMOVED)); + } + + + + + TEST_METHOD (IsDeviceLost_DeviceReset_ReturnsTrue) + { + Assert::IsTrue (IsDeviceLost (DXGI_ERROR_DEVICE_RESET)); + } + + + + + TEST_METHOD (IsDeviceLost_DeviceHung_ReturnsTrue) + { + Assert::IsTrue (IsDeviceLost (DXGI_ERROR_DEVICE_HUNG)); + } + + + + + TEST_METHOD (IsDeviceLost_DriverInternalError_ReturnsTrue) + { + Assert::IsTrue (IsDeviceLost (DXGI_ERROR_DRIVER_INTERNAL_ERROR)); + } + + + + + TEST_METHOD (IsDeviceLost_D3DDDIERR_DEVICEREMOVED_ReturnsTrue) + { + Assert::IsTrue (IsDeviceLost (D3DDDIERR_DEVICEREMOVED)); + } + + + + + TEST_METHOD (IsDeviceLost_Success_ReturnsFalse) + { + Assert::IsFalse (IsDeviceLost (S_OK)); + } + + + + + TEST_METHOD (IsDeviceLost_GenericFailure_ReturnsFalse) + { + Assert::IsFalse (IsDeviceLost (E_FAIL)); + } + + + + + TEST_METHOD (IsDeviceLost_InvalidArg_ReturnsFalse) + { + Assert::IsFalse (IsDeviceLost (E_INVALIDARG)); + } + + + + + TEST_METHOD (IsDeviceLost_Occluded_ReturnsFalse) + { + Assert::IsFalse (IsDeviceLost (DXGI_STATUS_OCCLUDED)); + } + }; + + +} diff --git a/MatrixRainTests/unit/FrameLimiterTests.cpp b/MatrixRainTests/unit/FrameLimiterTests.cpp new file mode 100644 index 0000000..bf645b5 --- /dev/null +++ b/MatrixRainTests/unit/FrameLimiterTests.cpp @@ -0,0 +1,91 @@ +#include "Pch_MatrixRainTests.h" + +#include "..\..\MatrixRainCore\FrameLimiter.h" + +#include + + + + +namespace MatrixRainTests +{ + + + TEST_CLASS (FrameLimiterTests) + { + public: + + // + // ShouldEngageFrameLimiter + // + + TEST_METHOD (ShouldEngageFrameLimiter_AtOrBelow60_ReturnsFalse) + { + Assert::IsFalse (ShouldEngageFrameLimiter (0)); + Assert::IsFalse (ShouldEngageFrameLimiter (30)); + Assert::IsFalse (ShouldEngageFrameLimiter (59)); + Assert::IsFalse (ShouldEngageFrameLimiter (60)); + } + + + + + TEST_METHOD (ShouldEngageFrameLimiter_Above60_ReturnsTrue) + { + Assert::IsTrue (ShouldEngageFrameLimiter (61)); + Assert::IsTrue (ShouldEngageFrameLimiter (75)); + Assert::IsTrue (ShouldEngageFrameLimiter (120)); + Assert::IsTrue (ShouldEngageFrameLimiter (144)); + Assert::IsTrue (ShouldEngageFrameLimiter (240)); + } + + + + + // + // FrameLimiter + // + + TEST_METHOD (WaitForNextFrame_FirstCall_ReturnsImmediately) + { + using clock = std::chrono::steady_clock; + + FrameLimiter limiter (60); + clock::time_point t0 = clock::now(); + + limiter.WaitForNextFrame(); + + clock::time_point t1 = clock::now(); + auto elapsed = std::chrono::duration_cast (t1 - t0).count(); + + Assert::IsTrue (elapsed < 5, L"First WaitForNextFrame should return effectively immediately"); + } + + + + + TEST_METHOD (WaitForNextFrame_SecondCall_SleepsApproxFrameInterval) + { + using clock = std::chrono::steady_clock; + + FrameLimiter limiter (60); + clock::time_point t0; + clock::time_point t1; + + + limiter.WaitForNextFrame(); // primes m_lastFrameTime + t0 = clock::now(); + limiter.WaitForNextFrame(); // should sleep ~16.6ms minus the (tiny) gap above + t1 = clock::now(); + + auto elapsedMs = std::chrono::duration_cast (t1 - t0).count(); + + // Sleep granularity on Windows is usually >=10ms; allow a wide + // tolerance so CI scheduler jitter does not flake the test. + Assert::IsTrue (elapsedMs >= 10, L"Second WaitForNextFrame should sleep at least ~10ms at 60 fps"); + Assert::IsTrue (elapsedMs <= 50, L"Second WaitForNextFrame should not over-sleep beyond ~50ms"); + } + }; + + +} diff --git a/MatrixRainTests/unit/MultiMonitorGateTests.cpp b/MatrixRainTests/unit/MultiMonitorGateTests.cpp new file mode 100644 index 0000000..b7be8f5 --- /dev/null +++ b/MatrixRainTests/unit/MultiMonitorGateTests.cpp @@ -0,0 +1,67 @@ +#include "Pch_MatrixRainTests.h" + +#include "..\..\MatrixRainCore\MultiMonitorGate.h" + + + + +namespace MatrixRainTests +{ + + + TEST_CLASS (MultiMonitorGateTests) + { + public: + + TEST_METHOD (Preview_AlwaysFalse_RegardlessOfOtherInputs) + { + Assert::IsFalse (ShouldSpanAllMonitors (true, DisplayMode::Fullscreen, ScreenSaverMode::ScreenSaverPreview)); + Assert::IsFalse (ShouldSpanAllMonitors (true, DisplayMode::Windowed, ScreenSaverMode::ScreenSaverPreview)); + Assert::IsFalse (ShouldSpanAllMonitors (false, DisplayMode::Fullscreen, ScreenSaverMode::ScreenSaverPreview)); + Assert::IsFalse (ShouldSpanAllMonitors (false, DisplayMode::Windowed, ScreenSaverMode::ScreenSaverPreview)); + } + + + + + TEST_METHOD (HelpRequested_AlwaysFalse_RegardlessOfOtherInputs) + { + Assert::IsFalse (ShouldSpanAllMonitors (true, DisplayMode::Fullscreen, ScreenSaverMode::HelpRequested)); + Assert::IsFalse (ShouldSpanAllMonitors (true, DisplayMode::Windowed, ScreenSaverMode::HelpRequested)); + Assert::IsFalse (ShouldSpanAllMonitors (false, DisplayMode::Fullscreen, ScreenSaverMode::HelpRequested)); + Assert::IsFalse (ShouldSpanAllMonitors (false, DisplayMode::Windowed, ScreenSaverMode::HelpRequested)); + } + + + + + TEST_METHOD (MultiMonEnabled_PlusFullscreen_PlusNormal_ReturnsTrue) + { + Assert::IsTrue (ShouldSpanAllMonitors (true, DisplayMode::Fullscreen, ScreenSaverMode::Normal)); + Assert::IsTrue (ShouldSpanAllMonitors (true, DisplayMode::Fullscreen, ScreenSaverMode::ScreenSaverFull)); + Assert::IsTrue (ShouldSpanAllMonitors (true, DisplayMode::Fullscreen, std::nullopt)); + } + + + + + TEST_METHOD (MultiMonDisabled_AlwaysFalse_OutsidePreviewHelp) + { + Assert::IsFalse (ShouldSpanAllMonitors (false, DisplayMode::Fullscreen, ScreenSaverMode::Normal)); + Assert::IsFalse (ShouldSpanAllMonitors (false, DisplayMode::Fullscreen, ScreenSaverMode::ScreenSaverFull)); + Assert::IsFalse (ShouldSpanAllMonitors (false, DisplayMode::Fullscreen, std::nullopt)); + } + + + + + TEST_METHOD (Windowed_AlwaysFalse_OutsidePreviewHelp) + { + Assert::IsFalse (ShouldSpanAllMonitors (true, DisplayMode::Windowed, ScreenSaverMode::Normal)); + Assert::IsFalse (ShouldSpanAllMonitors (false, DisplayMode::Windowed, ScreenSaverMode::Normal)); + Assert::IsFalse (ShouldSpanAllMonitors (true, DisplayMode::Windowed, std::nullopt)); + } + }; + + +} diff --git a/MatrixRainTests/unit/QualityPresetsTests.cpp b/MatrixRainTests/unit/QualityPresetsTests.cpp new file mode 100644 index 0000000..56e879e --- /dev/null +++ b/MatrixRainTests/unit/QualityPresetsTests.cpp @@ -0,0 +1,211 @@ +#include "Pch_MatrixRainTests.h" + +#include "..\..\MatrixRainCore\QualityPresets.h" + + + + +namespace MatrixRainTests +{ + + + static AdapterInfo MakeAdapter (const std::wstring & description, unsigned int vramMb, bool isSoftware) + { + AdapterInfo a; + a.m_description = description; + a.m_luid = LUID { 0, 0 }; + a.m_dedicatedVramMb = vramMb; + a.m_isSoftware = isSoftware; + a.m_isDefault = false; + return a; + } + + + + + TEST_CLASS (QualityPresetsTests) + { + public: + + // + // LookupPresetValues — table validation + // + + TEST_METHOD (LookupPresetValues_Low) + { + AdvancedGraphicsValues v = LookupPresetValues (QualityPreset::Low); + + Assert::AreEqual (75, v.m_glowIntensityPercent); + Assert::AreEqual (1, v.m_blurPasses); + Assert::IsTrue (v.m_bloomResolutionDivisor == ResolutionDivisor::Quarter); + Assert::IsTrue (v.m_blurTaps == BlurTaps::Low); + } + + + + + TEST_METHOD (LookupPresetValues_Medium) + { + AdvancedGraphicsValues v = LookupPresetValues (QualityPreset::Medium); + + Assert::AreEqual (100, v.m_glowIntensityPercent); + Assert::AreEqual (2, v.m_blurPasses); + Assert::IsTrue (v.m_bloomResolutionDivisor == ResolutionDivisor::Half); + Assert::IsTrue (v.m_blurTaps == BlurTaps::Medium); + } + + + + + TEST_METHOD (LookupPresetValues_High_MatchesCurrentDefault) + { + // FR-022: High preset must be visually identical to today's + // default rendering. These values mirror the existing hardcoded + // constants in RenderSystem before parametrization. + AdvancedGraphicsValues v = LookupPresetValues (QualityPreset::High); + + Assert::AreEqual (100, v.m_glowIntensityPercent); + Assert::AreEqual (3, v.m_blurPasses); + Assert::IsTrue (v.m_bloomResolutionDivisor == ResolutionDivisor::Half); + Assert::IsTrue (v.m_blurTaps == BlurTaps::High); + } + + + + + // + // DetectActivePreset + // + + TEST_METHOD (DetectActivePreset_ExactPresetRow_ReturnsThatPreset) + { + Assert::IsTrue (DetectActivePreset (LookupPresetValues (QualityPreset::Low)) == QualityPreset::Low); + Assert::IsTrue (DetectActivePreset (LookupPresetValues (QualityPreset::Medium)) == QualityPreset::Medium); + Assert::IsTrue (DetectActivePreset (LookupPresetValues (QualityPreset::High)) == QualityPreset::High); + } + + + + + TEST_METHOD (DetectActivePreset_OffTableValues_ReturnsCustom) + { + AdvancedGraphicsValues v; + v.m_glowIntensityPercent = 137; // Not on any preset row + v.m_blurPasses = 4; // No preset uses 4 passes + v.m_bloomResolutionDivisor = ResolutionDivisor::Eighth; // Custom-only + v.m_blurTaps = BlurTaps::High; + + Assert::IsTrue (DetectActivePreset (v) == QualityPreset::Custom); + } + + + + + // + // ApplyPresetSnap + // + + TEST_METHOD (ApplyPresetSnap_NamedPreset_ReturnsLookup) + { + AdvancedGraphicsValues current { 50, 4, ResolutionDivisor::Full, BlurTaps::Low }; + + AdvancedGraphicsValues result = ApplyPresetSnap (QualityPreset::High, current, std::nullopt); + + Assert::IsTrue (result == LookupPresetValues (QualityPreset::High)); + } + + + + + TEST_METHOD (ApplyPresetSnap_Custom_WithSavedLastCustom_RestoresIt) + { + AdvancedGraphicsValues current { 100, 3, ResolutionDivisor::Half, BlurTaps::High }; + AdvancedGraphicsValues lastCustom { 137, 2, ResolutionDivisor::Eighth, BlurTaps::Low }; + + AdvancedGraphicsValues result = ApplyPresetSnap (QualityPreset::Custom, current, lastCustom); + + Assert::IsTrue (result == lastCustom); + } + + + + + TEST_METHOD (ApplyPresetSnap_Custom_NoSavedLastCustom_KeepsCurrent) + { + AdvancedGraphicsValues current { 137, 2, ResolutionDivisor::Eighth, BlurTaps::Low }; + + AdvancedGraphicsValues result = ApplyPresetSnap (QualityPreset::Custom, current, std::nullopt); + + Assert::IsTrue (result == current); + } + + + + + // + // PickDefaultQualityPreset — first-run heuristic + // + + TEST_METHOD (PickDefault_DiscreteAdapter_ReturnsHigh) + { + std::vector adapters { + MakeAdapter (L"Intel UHD Graphics 620", 100, false), // integrated + MakeAdapter (L"NVIDIA GeForce GTX 1650", 4096, false) // discrete + }; + + Assert::IsTrue (PickDefaultQualityPreset (adapters, 1920ull * 1080ull) == QualityPreset::High); + } + + + + + TEST_METHOD (PickDefault_IntegratedOnly_LightLoad_ReturnsMedium) + { + std::vector adapters { + MakeAdapter (L"Intel UHD Graphics 620", 128, false) + }; + + Assert::IsTrue (PickDefaultQualityPreset (adapters, 1920ull * 1080ull) == QualityPreset::Medium); + } + + + + + TEST_METHOD (PickDefault_IntegratedOnly_HeavyLoad_ReturnsLow) + { + std::vector adapters { + MakeAdapter (L"Intel UHD Graphics 620", 128, false) + }; + + // Two 4K monitors -> > kHeavyTotalPixelsThreshold (16M) + uint64_t pixels = 2ull * 3840ull * 2160ull; + + Assert::IsTrue (PickDefaultQualityPreset (adapters, pixels) == QualityPreset::Low); + } + + + + + TEST_METHOD (PickDefault_SoftwareAdaptersIgnored_ReturnsMedium) + { + std::vector adapters { + MakeAdapter (L"Microsoft Basic Render Driver", 1024, true), // software (ignored) + MakeAdapter (L"Intel UHD Graphics 620", 128, false) // integrated + }; + + Assert::IsTrue (PickDefaultQualityPreset (adapters, 1920ull * 1080ull) == QualityPreset::Medium); + } + + + + + TEST_METHOD (PickDefault_HeuristicConstants_Pinned) + { + // Protect against silent retunes of the heuristic. + Assert::AreEqual (256u, kDiscreteVramThresholdMb); + Assert::AreEqual (16'000'000ull, kHeavyTotalPixelsThreshold); + } + }; + + +} diff --git a/MatrixRainTests/unit/RebuildCoalescerTests.cpp b/MatrixRainTests/unit/RebuildCoalescerTests.cpp new file mode 100644 index 0000000..7fb10a9 --- /dev/null +++ b/MatrixRainTests/unit/RebuildCoalescerTests.cpp @@ -0,0 +1,108 @@ +#include "Pch_MatrixRainTests.h" + +#include "..\..\MatrixRainCore\RebuildCoalescer.h" + +#include +#include +#include + + + + +namespace MatrixRainTests +{ + + + TEST_CLASS (RebuildCoalescerTests) + { + public: + + TEST_METHOD (RequestRebuild_FirstCall_ReturnsTrue) + { + RebuildCoalescer coalescer; + + Assert::IsTrue (coalescer.RequestRebuild()); + } + + + + + TEST_METHOD (RequestRebuild_RepeatedCalls_OnlyFirstReturnsTrue) + { + RebuildCoalescer coalescer; + + Assert::IsTrue (coalescer.RequestRebuild()); + Assert::IsFalse (coalescer.RequestRebuild()); + Assert::IsFalse (coalescer.RequestRebuild()); + Assert::IsFalse (coalescer.RequestRebuild()); + } + + + + + TEST_METHOD (Consume_AfterRequest_AllowsNextRequest) + { + RebuildCoalescer coalescer; + + Assert::IsTrue (coalescer.RequestRebuild()); + Assert::IsFalse (coalescer.RequestRebuild()); + coalescer.Consume(); + Assert::IsTrue (coalescer.RequestRebuild()); + } + + + + + TEST_METHOD (Consume_WithoutPriorRequest_DoesNotCrash) + { + RebuildCoalescer coalescer; + + coalescer.Consume(); + Assert::IsTrue (coalescer.RequestRebuild()); + } + + + + + TEST_METHOD (RequestRebuild_FromManyThreads_ExactlyOneReturnsTrue) + { + constexpr int kThreadCount = 32; + RebuildCoalescer coalescer; + std::atomic trueCount {0}; + std::atomic startGate {0}; + + + std::vector threads; + threads.reserve (kThreadCount); + + for (int i = 0; i < kThreadCount; ++i) + { + threads.emplace_back ([&]() + { + // Spin-wait so all threads contend at roughly the same moment. + while (startGate.load (std::memory_order_acquire) == 0) + { + } + + if (coalescer.RequestRebuild()) + { + trueCount.fetch_add (1, std::memory_order_relaxed); + } + }); + } + + + startGate.store (1, std::memory_order_release); + + for (std::thread & t : threads) + { + t.join(); + } + + + Assert::AreEqual (1, trueCount.load (std::memory_order_relaxed)); + } + }; + + +} diff --git a/MatrixRainTests/unit/RegistrySettingsProviderTests.cpp b/MatrixRainTests/unit/RegistrySettingsProviderTests.cpp index 4befc74..58cc1bf 100644 --- a/MatrixRainTests/unit/RegistrySettingsProviderTests.cpp +++ b/MatrixRainTests/unit/RegistrySettingsProviderTests.cpp @@ -149,6 +149,218 @@ namespace MatrixRainTests Assert::IsFalse (loadSettings.m_startFullscreen, L"StartFullscreen should match saved value"); Assert::IsTrue (loadSettings.m_showDebugStats, L"ShowDebugStats should match saved value"); } + + + + + TEST_METHOD (TestLoadSettings_MultiMonitor_DefaultsToTrue_WhenAbsent) + { + DeleteTestRegistryKey(); + + HKEY hKey = nullptr; + LSTATUS status = RegCreateKeyExW (HKEY_CURRENT_USER, TEST_REGISTRY_KEY_PATH, 0, nullptr, + REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hKey, nullptr); + Assert::AreEqual ((LONG)ERROR_SUCCESS, (LONG)status); + + DWORD density = 50; + RegSetValueExW (hKey, L"Density", 0, REG_DWORD, (const BYTE *)&density, sizeof (DWORD)); + RegCloseKey (hKey); + + + ScreenSaverSettings settings; + HRESULT hr = m_provider.Load (settings); + + + Assert::AreEqual (S_OK, hr); + Assert::IsTrue (settings.m_multiMonitorEnabled, L"Absent MultiMonitor value should leave default (true)"); + } + + + + + TEST_METHOD (TestSaveLoadRoundTrip_MultiMonitor_PreservesFalse) + { + DeleteTestRegistryKey(); + + ScreenSaverSettings saveSettings; + saveSettings.m_multiMonitorEnabled = false; + + + HRESULT hr = m_provider.Save (saveSettings); + Assert::AreEqual (S_OK, hr); + + + ScreenSaverSettings loadSettings; + hr = m_provider.Load (loadSettings); + + + Assert::AreEqual (S_OK, hr); + Assert::IsFalse (loadSettings.m_multiMonitorEnabled, L"MultiMonitor=false should round-trip"); + } + + + + + TEST_METHOD (TestSaveLoadRoundTrip_MultiMonitor_PreservesTrue) + { + DeleteTestRegistryKey(); + + ScreenSaverSettings saveSettings; + saveSettings.m_multiMonitorEnabled = true; + + + HRESULT hr = m_provider.Save (saveSettings); + Assert::AreEqual (S_OK, hr); + + + ScreenSaverSettings loadSettings; + loadSettings.m_multiMonitorEnabled = false; // pre-set opposite to prove the load overwrites + hr = m_provider.Load (loadSettings); + + + Assert::AreEqual (S_OK, hr); + Assert::IsTrue (loadSettings.m_multiMonitorEnabled, L"MultiMonitor=true should round-trip"); + } + + + + + TEST_METHOD (TestSaveLoadRoundTrip_GpuAdapter_PreservesDescription) + { + DeleteTestRegistryKey(); + + ScreenSaverSettings saveSettings; + saveSettings.m_gpuAdapter = L"NVIDIA GeForce RTX 3050 Ti Laptop GPU"; + + + HRESULT hr = m_provider.Save (saveSettings); + Assert::AreEqual (S_OK, hr); + + + ScreenSaverSettings loadSettings; + hr = m_provider.Load (loadSettings); + + + Assert::AreEqual (S_OK, hr); + Assert::AreEqual (std::wstring (L"NVIDIA GeForce RTX 3050 Ti Laptop GPU"), loadSettings.m_gpuAdapter); + } + + + + + TEST_METHOD (TestSaveLoadRoundTrip_GpuAdapter_EmptyDescription) + { + DeleteTestRegistryKey(); + + ScreenSaverSettings saveSettings; + saveSettings.m_gpuAdapter = L""; + + + HRESULT hr = m_provider.Save (saveSettings); + Assert::AreEqual (S_OK, hr); + + + ScreenSaverSettings loadSettings; + loadSettings.m_gpuAdapter = L"non-empty"; + hr = m_provider.Load (loadSettings); + + + Assert::AreEqual (S_OK, hr); + Assert::AreEqual (std::wstring (L""), loadSettings.m_gpuAdapter); + } + + + + + TEST_METHOD (TestSaveLoadRoundTrip_QualityPreset_PreservesNamedPreset) + { + DeleteTestRegistryKey(); + + ScreenSaverSettings saveSettings; + saveSettings.m_qualityPreset = QualityPreset::Low; + + + HRESULT hr = m_provider.Save (saveSettings); + Assert::AreEqual (S_OK, hr); + + + ScreenSaverSettings loadSettings; + hr = m_provider.Load (loadSettings); + + + Assert::AreEqual (S_OK, hr); + Assert::IsTrue (loadSettings.m_qualityPreset == QualityPreset::Low); + + // Per the load logic, the named preset's row is automatically + // applied to m_advancedValues. + Assert::IsTrue (loadSettings.m_advancedValues == LookupPresetValues (QualityPreset::Low)); + } + + + + + TEST_METHOD (TestSaveLoadRoundTrip_LastCustom_AllOrNothing) + { + DeleteTestRegistryKey(); + + ScreenSaverSettings saveSettings; + saveSettings.m_qualityPreset = QualityPreset::Custom; + saveSettings.m_lastCustom = AdvancedGraphicsValues { 137, 4, ResolutionDivisor::Eighth, BlurTaps::Low }; + saveSettings.m_advancedValues = *saveSettings.m_lastCustom; + + + HRESULT hr = m_provider.Save (saveSettings); + Assert::AreEqual (S_OK, hr); + + + ScreenSaverSettings loadSettings; + hr = m_provider.Load (loadSettings); + + + Assert::AreEqual (S_OK, hr); + Assert::IsTrue (loadSettings.m_qualityPreset == QualityPreset::Custom); + Assert::IsTrue (loadSettings.m_lastCustom.has_value()); + Assert::AreEqual (137, loadSettings.m_lastCustom->m_glowIntensityPercent); + Assert::AreEqual (4, loadSettings.m_lastCustom->m_blurPasses); + Assert::IsTrue (loadSettings.m_lastCustom->m_bloomResolutionDivisor == ResolutionDivisor::Eighth); + Assert::IsTrue (loadSettings.m_lastCustom->m_blurTaps == BlurTaps::Low); + // Custom + LastCustom present -> advanced values restored from LastCustom. + Assert::IsTrue (loadSettings.m_advancedValues == *loadSettings.m_lastCustom); + } + + + + + TEST_METHOD (TestLoadSettings_LastCustom_MissingOneValue_IgnoresAll) + { + DeleteTestRegistryKey(); + + // Manually set 3 of the 4 LastCustom values; omit the 4th to + // exercise the all-or-nothing read contract. + HKEY hKey = nullptr; + LSTATUS status = RegCreateKeyExW (HKEY_CURRENT_USER, TEST_REGISTRY_KEY_PATH, 0, nullptr, + REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hKey, nullptr); + Assert::AreEqual ((LONG)ERROR_SUCCESS, (LONG)status); + + DWORD intensity = 137; + DWORD passes = 4; + DWORD smoothness = 5; + // VALUE_LASTCUSTOM_RESOLUTION intentionally not written. + + RegSetValueExW (hKey, L"LastCustom_GlowIntensity", 0, REG_DWORD, (const BYTE *)&intensity, sizeof (DWORD)); + RegSetValueExW (hKey, L"LastCustom_Passes", 0, REG_DWORD, (const BYTE *)&passes, sizeof (DWORD)); + RegSetValueExW (hKey, L"LastCustom_Smoothness", 0, REG_DWORD, (const BYTE *)&smoothness, sizeof (DWORD)); + RegCloseKey (hKey); + + + ScreenSaverSettings settings; + HRESULT hr = m_provider.Load (settings); + + + Assert::AreEqual (S_OK, hr); + Assert::IsFalse (settings.m_lastCustom.has_value(), + L"Missing any LastCustom_* value should yield nullopt (all-or-nothing read contract)"); + } diff --git a/MatrixRainTests/unit/ScreenSaverSettingsTests.cpp b/MatrixRainTests/unit/ScreenSaverSettingsTests.cpp index 7e8857e..2cb7ce3 100644 --- a/MatrixRainTests/unit/ScreenSaverSettingsTests.cpp +++ b/MatrixRainTests/unit/ScreenSaverSettingsTests.cpp @@ -26,6 +26,7 @@ namespace MatrixRainTests Assert::IsTrue (settings.m_startFullscreen, L"startFullscreen default"); Assert::IsFalse (settings.m_showDebugStats, L"showDebugStats default"); Assert::IsFalse (settings.m_showFadeTimers, L"showFadeTimers default"); + Assert::IsTrue (settings.m_multiMonitorEnabled, L"multiMonitorEnabled default"); Assert::IsFalse (settings.m_lastSavedTimestamp.has_value(), L"lastSavedTimestamp default"); } diff --git a/README.md b/README.md index 5320a95..f782bd9 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ I built this Win32/DirectX C++ Matrix-rain screensaver/demo as a test project to | Version | Highlights | | :---: | :--- | +| **1.4** | Performance optimization release — pick which GPU to render on, plus Quality presets (Low/Medium/High/Custom with per-knob infotips) to dial back GPU load. Adds live multi-monitor toggle, frame cap on >60 Hz displays, and a themed two-column dialog overhaul | | **1.3** | Multi-monitor support — independent, DPI-aware Matrix rain on every connected display in fullscreen and screensaver modes | | **1.2** | Screensaver install/uninstall (`/install`, `/uninstall`) with UAC elevation and Group Policy detection | | **1.1** | In-app overlays — usage dialog (`/?`), startup help hint, and `?` hotkey reference; multi-pass bloom glow with live glow-size control | @@ -146,10 +147,6 @@ Press S to toggle the statistics display for nerdy debugging information: - Number of rain streaks (including partials still fading off) on screen - FPS -Press ` (backtick) to toggle the per-character fade timer. This gets pretty cluttered with more than just a few streaks, so you probably want to dial down the density first. - -![Fade Timers](assets/FadeTimers.png) - ## Specs & SpecKit - My main goal with this project was to learn about spec-driven development with [SpecKit](https://github.com/github/spec-kit). Starting with a short description of the app's purpose and behavior, SpecKit generated a detailed [spec](specs/001-matrix-rain/spec.md) with five user stories, assigned priorities to them, and generated acceptance criteria for each. It came up with a set of edge cases to be clarified and tested, and 27 functional requirements. diff --git a/specs/006-multimon-gpu-efficiency/checklists/requirements.md b/specs/006-multimon-gpu-efficiency/checklists/requirements.md new file mode 100644 index 0000000..ede8da8 --- /dev/null +++ b/specs/006-multimon-gpu-efficiency/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Multi-Monitor User Control and GPU Efficiency + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-03 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`. +- This spec was developed through an extended pre-spec planning conversation in which all major design decisions (multi-monitor default, GPU adapter persistence by description, runtime topology response in both display and screensaver modes, frame cap engagement only above 60Hz, quality preset count and naming, advanced control disclosure pattern, dialog dynamic resize, information tip control behavior, infotip perf-impact phrasing, first-run heuristic, custom-snap behavior, glow on/off via existing Glow Intensity slider) were settled with the user. Consequently no [NEEDS CLARIFICATION] markers are needed; all open questions from earlier drafts were resolved before the spec was written. +- Implementation-level details (DXGI APIs, D3D11 device creation parameters, shader pass structure, Win32 message handling, .rc layout, file:line citations) have been kept out of this spec and reside in the supporting plan in the session workspace at `files/v14-improvements-plan.md`. They will be the basis for the `/speckit.plan` step. diff --git a/specs/006-multimon-gpu-efficiency/contracts/adapter-provider.md b/specs/006-multimon-gpu-efficiency/contracts/adapter-provider.md new file mode 100644 index 0000000..0973c06 --- /dev/null +++ b/specs/006-multimon-gpu-efficiency/contracts/adapter-provider.md @@ -0,0 +1,92 @@ +# Contract — `IAdapterProvider` and `AdapterInfo` + +**Feature**: `006-multimon-gpu-efficiency` +**Header**: `MatrixRainCore\IAdapterProvider.h` (new) + +This contract defines the new interface and value type used to enumerate GPU adapters for the GPU-selection feature. It mirrors the existing `IMonitorProvider`/`WindowsMonitorProvider`/`InMemoryMonitorProvider` pattern. + +--- + +## `struct AdapterInfo` + +```cpp +struct AdapterInfo +{ + std::wstring m_description; // Human-readable adapter name (DXGI_ADAPTER_DESC1::Description) + LUID m_luid; // Stable-within-session id (DXGI_ADAPTER_DESC1::AdapterLuid) + unsigned int m_dedicatedVramMb; // DedicatedVideoMemory in MB + bool m_isSoftware; // True for Microsoft Basic Render Driver / WARP + bool m_isDefault; // True iff this is the system default rendering adapter +}; +``` + +**Invariants**: +- `m_description.empty()` iff DXGI returned an empty description; consumers MUST treat this as "unknown" and exclude such an adapter from any user-visible list. +- At most one `AdapterInfo` in any returned list has `m_isDefault == true`. Exactly one is required when at least one non-software adapter exists. + +--- + +## `class IAdapterProvider` *(abstract)* + +```cpp +class IAdapterProvider +{ +public: + virtual ~IAdapterProvider() = default; + + // Enumerate all rendering-capable GPU adapters currently present on the + // system. Software adapters (DXGI_ADAPTER_FLAG_SOFTWARE) MUST be excluded. + // Returns an empty vector if no non-software adapters exist (the caller + // is then expected to use the system default-adapter path). + virtual std::vector EnumerateAdapters() const = 0; +}; +``` + +**Contract**: +- `EnumerateAdapters()` is pure and side-effect-free; it MAY be called multiple times during one process lifetime and MUST return current state each call. +- The order of the returned vector is implementation-defined; consumers MUST find the default entry via `m_isDefault`, not by index. + +--- + +## Implementations + +### `WindowsAdapterProvider` + +- Uses `CreateDXGIFactory1` to obtain `IDXGIFactory1` for the basic enumeration. +- Uses `CreateDXGIFactory2` (with `DXGI_CREATE_FACTORY_DEBUG` in `_DEBUG` builds) and `QueryInterface` for `IDXGIFactory6` to identify the system default adapter via `EnumAdapterByGpuPreference(0, DXGI_GPU_PREFERENCE_UNSPECIFIED, IID_PPV_ARGS(&defaultAdapter))`. +- For each `EnumAdapters1` result: call `GetDesc1`, skip if `(desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) != 0`, populate one `AdapterInfo`, mark `m_isDefault = (desc.AdapterLuid == defaultAdapter.GetDesc1().AdapterLuid)`. +- On any DXGI failure HRESULT, log via existing `Console`/EHM macros and return an empty vector (callers then use the default-adapter path). + +### `InMemoryAdapterProvider` *(test seam)* + +- Constructor takes a `std::vector` and stores it. +- `EnumerateAdapters()` returns a copy of the stored vector. +- No DXGI dependency; usable freely in unit tests. + +--- + +## Pure helpers consuming `AdapterInfo` + +### `std::optional ResolveAdapter(const std::vector& adapters, const std::wstring& savedDescription)` + +- Returns `nullopt` iff `savedDescription` is empty, or no adapter in `adapters` has a matching `m_description`. (Caller then creates the device with `nullptr` + `D3D_DRIVER_TYPE_HARDWARE`.) +- Returns the matching adapter's `m_luid` otherwise. (Caller then looks up the adapter via `EnumAdapterByLuid` and creates the device with `D3D_DRIVER_TYPE_UNKNOWN`.) +- Software adapters MUST already be excluded by the provider; this helper does not re-filter them. + +### `std::wstring FormatAdapterLabel(const AdapterInfo& adapter)` + +- Returns `m_description` unchanged if `m_isDefault == false`. +- Returns `m_description + L" (default)"` if `m_isDefault == true`. + +Both helpers are pure; unit-tested via `InMemoryAdapterProvider` seeds. + +--- + +## Device-creation contract change + +`RenderSystem::Initialize(HWND hwnd, int width, int height)` becomes `RenderSystem::Initialize(HWND hwnd, int width, int height, std::optional adapterLuid)`. + +- `adapterLuid == nullopt` → existing behavior: `D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, …)`. +- `adapterLuid` present → look up via `factory6->EnumAdapterByLuid(*adapterLuid, IID_PPV_ARGS(&adapter))`; on success `D3D11CreateDevice(adapter.Get(), D3D_DRIVER_TYPE_UNKNOWN, …)`; on lookup failure (adapter disappeared between enumeration and creation), fall back to `nullptr` + `D3D_DRIVER_TYPE_HARDWARE` and log via EHM. + +The default-adapter fallback path is exercised in production by FR-014 (saved adapter not present at startup). diff --git a/specs/006-multimon-gpu-efficiency/contracts/quality-preset-mapping.md b/specs/006-multimon-gpu-efficiency/contracts/quality-preset-mapping.md new file mode 100644 index 0000000..ee748bd --- /dev/null +++ b/specs/006-multimon-gpu-efficiency/contracts/quality-preset-mapping.md @@ -0,0 +1,111 @@ +# Contract — Quality Preset Mapping + +**Feature**: `006-multimon-gpu-efficiency` +**Header/impl**: `MatrixRainCore\QualityPresets.{h,cpp}` (new) + +This contract pins the exact mapping between named quality presets and the underlying knob values, plus the rules for preset-snap, custom-drift detection, and first-run defaulting. All of these are pure functions and exhaustively unit-tested. + +--- + +## The preset table + +```cpp +enum class QualityPreset : int { Low = 0, Medium = 1, High = 2, Custom = 3 }; +enum class ResolutionDivisor : int { Full = 1, Half = 2, Quarter = 4, Eighth = 8 }; +enum class BlurTaps : int { Low = 5, Medium = 9, High = 13 }; + +struct AdvancedGraphicsValues +{ + int m_glowIntensityPercent; // 0..200; 0 = glow disabled + int m_blurPasses; // 1..4 + ResolutionDivisor m_bloomResolutionDivisor; // Full/Half/Quarter/Eighth + BlurTaps m_blurTaps; // Low/Medium/High +}; +``` + +| QualityPreset | GlowIntensity | Passes | Resolution | BlurTaps | +|---------------|---------------|--------|------------|----------| +| `Low` | 75 | 1 | `Quarter` | `Low` | +| `Medium` | 100 | 2 | `Half` | `Medium` | +| `High` | 100 | 3 | `Half` | `High` | +| `Custom` | *(any)* | *(any)*| *(any)* | *(any)* | + +**`High` is calibrated to today's exact rendering** (3 passes, half resolution, 13-tap blur, glow intensity 100). Existing users who upgrade and never open the dialog see no visible change. + +The `Eighth` resolution divisor is reachable only via `Custom`; no named preset uses it. (Available for the "I really need every GPU cycle back" scenario.) + +`m_glowIntensityPercent == 0` disables the entire glow pipeline (extract + blur + composite all bypassed; direct-to-backbuffer fallback path is taken). This is enforced at the render layer; the value indicator label MUST read `"0% (glow disabled)"` per FR-031. + +--- + +## Pure helpers + +### `AdvancedGraphicsValues LookupPresetValues(QualityPreset preset)` + +- For `Low`/`Medium`/`High`: returns the row from the table above. +- For `Custom`: precondition violated — undefined behavior (assert in debug, return `High` row in release). Callers MUST never pass `Custom`. + +### `QualityPreset DetectActivePreset(const AdvancedGraphicsValues& current)` + +- Returns the named preset whose row exactly matches `current`. +- Returns `Custom` if no named preset matches. +- Used after every advanced-control change to recompute the combo's displayed selection. + +### `AdvancedGraphicsValues ApplyPresetSnap(QualityPreset preset, const AdvancedGraphicsValues& current, std::optional lastCustom)` + +Snap rule when the user changes the preset combo: +- `preset ∈ {Low, Medium, High}` → return `LookupPresetValues(preset)`. +- `preset == Custom`: + - if `lastCustom.has_value()` → return `*lastCustom`. + - else → return `current` unchanged (the advanced controls keep showing the previous preset's values until the user touches one). + +### `QualityPreset PickDefaultQualityPreset(const std::vector& adapters, uint64_t totalMonitorPixels)` + +First-run heuristic; runs only when no `QualityPreset` is saved. + +``` +if any adapter in `adapters` has m_dedicatedVramMb >= kDiscreteVramThresholdMb + and !m_isSoftware: + return High +elif totalMonitorPixels > kHeavyTotalPixelsThreshold: + return Low +else: + return Medium +``` + +Constants: +- `kDiscreteVramThresholdMb` = `256`. +- `kHeavyTotalPixelsThreshold` = `16'000'000` (≈ two 4K displays). + +Both are `static constexpr` in `QualityPresets.cpp` and named in the test suite for explicit verification. + +--- + +## Custom-drift behavior (state machine) + +Inputs to the state machine: +- `activePreset : QualityPreset` (what the dropdown displays). +- `advanced : AdvancedGraphicsValues` (the four knob values). +- `lastCustom : optional` (persisted history). + +Transitions: + +| Event | New `activePreset` | New `advanced` | New `lastCustom` | +|----------------------------------------|-------------------------------------------------|-------------------------------------------------|----------------------------------------| +| User selects named preset P | P | `LookupPresetValues(P)` | unchanged | +| User selects `Custom` | `Custom` | `ApplyPresetSnap(Custom, advanced, lastCustom)` | unchanged | +| User moves any advanced control | `DetectActivePreset(newAdvanced)` | `newAdvanced` | `newAdvanced` (always — even if `activePreset` becomes a named one again by accident, e.g., user moved a control back to a preset's value) | +| Initial load (no saved preset) | `PickDefaultQualityPreset(adapters, pixels)` | `LookupPresetValues(default)` | `nullopt` | +| Initial load (`QualityPreset = "Custom"`, all `LastCustom_*` present) | `Custom` | from `LastCustom_*` values | parsed from `LastCustom_*` | +| Initial load (`QualityPreset = "Custom"`, any `LastCustom_*` missing) | `PickDefaultQualityPreset(...)` | `LookupPresetValues(default)` | `nullopt` | + +The "always update lastCustom on any advanced change" rule is intentional: even if the user happens to nudge a slider back to a named preset's exact value, the *fact that they touched it* makes their current state the canonical "last custom" set. This guarantees they can always recover whatever they were tinkering with by switching back to `Custom`. + +--- + +## Persistence integration + +The state machine above interacts with the registry per the rules in `registry-schema.md`. Specifically: +- `QualityPreset` (REG_SZ) is written on every preset change. +- `LastCustom_*` (DWORDs) are written whenever `lastCustom` is updated (i.e., on every advanced-control change). +- On startup, `LastCustomGraphicsValues` is loaded only when all four DWORDs are present; missing any one yields `nullopt`. diff --git a/specs/006-multimon-gpu-efficiency/contracts/registry-schema.md b/specs/006-multimon-gpu-efficiency/contracts/registry-schema.md new file mode 100644 index 0000000..30267be --- /dev/null +++ b/specs/006-multimon-gpu-efficiency/contracts/registry-schema.md @@ -0,0 +1,34 @@ +# Contract — Registry Schema + +**Feature**: `006-multimon-gpu-efficiency` +**Registry key**: `HKEY_CURRENT_USER\Software\relmer\MatrixRain` (existing). + +This contract documents the new registry values introduced by this feature. Existing values are unchanged. + +--- + +## New values + +| Value name | Type | Domain | Default | Owner | +|-----------------------------|---------|---------------------------------|--------------|----------------------------------------------------------------------------------| +| `MultiMonitor` | DWORD | `0` (disabled), `1` (enabled) | `1` | `RegistrySettingsProvider::ReadBool` / `WriteBool` | +| `GpuAdapter` | REG_SZ | UTF-16 string; `""` = default | `""` | `RegistrySettingsProvider::ReadString` / `WriteString` | +| `QualityPreset` | REG_SZ | `"Low"`, `"Medium"`, `"High"`, `"Custom"`, or `""` (= "not yet set; pick on next launch") | `""` | `RegistrySettingsProvider::ReadString` / `WriteString` | +| `LastCustom_GlowIntensity` | DWORD | `0..200` | absent | `RegistrySettingsProvider::ReadInt` / `WriteInt` | +| `LastCustom_Passes` | DWORD | `1..4` | absent | `RegistrySettingsProvider::ReadInt` / `WriteInt` | +| `LastCustom_Resolution` | DWORD | one of `1`, `2`, `4`, `8` | absent | `RegistrySettingsProvider::ReadInt` / `WriteInt` | +| `LastCustom_Smoothness` | DWORD | one of `5`, `9`, `13` | absent | `RegistrySettingsProvider::ReadInt` / `WriteInt` | +| `ShowAdvancedGraphics` | DWORD | `0`, `1` | `0` (or `1` if `QualityPreset == "Custom"`) | `RegistrySettingsProvider::ReadBool` / `WriteBool` | + +## Read/write rules + +- All `LastCustom_*` values are loaded together: if **any** is absent, `LastCustomGraphicsValues` is treated as empty (`nullopt`) and all four are ignored. Partial state is never honored. +- Reading clamps out-of-domain values to the nearest valid value (same convention as existing `m_glowIntensityPercent`). +- Writing happens via `Save()` on dialog OK and via the live-update controller path (`UpdateMultiMonitorEnabled`, `UpdateGpuAdapter`, `UpdateQualityPreset`, `UpdateAdvancedGraphicsValues`, `UpdateShowAdvancedGraphics`). +- No migration logic — absent values trigger the documented defaults at load time. + +## Backward compatibility + +Existing installations whose registry contains only the v1.3 values continue to work: `MultiMonitor` defaults to `1` (preserves today's behavior); `GpuAdapter` defaults to `""` (uses system default — same as today); `QualityPreset` defaults to `""` which on first non-empty save will be replaced by the first-run heuristic's choice; `LastCustom_*` absent → `LastCustomGraphicsValues = nullopt`; `ShowAdvancedGraphics` defaults to `0`. + +The visible behavior for a user who upgrades and never opens the dialog is **identical** to v1.3, because the default preset (High) is calibrated to today's exact knob values. diff --git a/specs/006-multimon-gpu-efficiency/contracts/tick-mark-conventions.md b/specs/006-multimon-gpu-efficiency/contracts/tick-mark-conventions.md new file mode 100644 index 0000000..2af8e67 --- /dev/null +++ b/specs/006-multimon-gpu-efficiency/contracts/tick-mark-conventions.md @@ -0,0 +1,70 @@ +# Contract — Trackbar Tick-Mark Conventions + +**Feature**: `006-multimon-gpu-efficiency` +**Applies to**: all `msctls_trackbar32` controls in the MatrixRain configuration dialog (`IDD_MATRIXRAIN_SAVER_CONFIG`). + +This contract pins the visual conventions for trackbar ticks. Discrete sliders (the new advanced graphics controls) and percentage sliders (existing + Glow Intensity used as on/off) follow different rules, captured below. + +--- + +## Rule 1 — Discrete sliders + +Sliders whose positions correspond to a small, finite set of named choices. + +- Style: `TBS_AUTOTICKS | TBS_TOP | WS_TABSTOP`. +- Range: from `0` (or `1` for `IDC_GLOWPASSES_SLIDER`) up to `n-1` (or `n`), step 1. +- Each tick position has a label drawn beneath it. Implementation: `LTEXT` statics in the `.rc` aligned at the tick screen-x positions in dialog units, computed once during layout authoring. +- The slider's vertical footprint is taller than a percentage slider to leave room for the labels. + +Controls covered: + +| Control | Range | Default | Labels (left → right) | +|--------------------------|--------|---------|-----------------------------------------------------| +| `IDC_GLOWPASSES_SLIDER` | 1..4 | 3 | `"1"` `"2"` `"3"` `"4"` | +| `IDC_GLOWRES_SLIDER` | 0..3 | 2 | `"Eighth"` `"Quarter"` `"Half"` `"Full"` | +| `IDC_GLOWSMOOTH_SLIDER` | 0..2 | 2 | `"Low"` `"Medium"` `"High"` | + +The right-side value indicator (an existing pattern, `IDC_*_LABEL`) mirrors the currently-selected tick's label text (e.g., `"Quarter"`), not the raw integer position. + +--- + +## Rule 2 — Percentage sliders + +Sliders whose positions represent percentages or quasi-percentage values. + +- Style: `TBS_AUTOTICKS | WS_TABSTOP` (existing). +- Ticks are unlabeled. +- The slider does NOT snap to ticks (free positioning). +- Tick frequency chosen so a tick falls at the midpoint of the range, with a target of ~21 ticks total. Per-slider settings: + +| Control | Range | Mid | `TBM_SETTICFREQ` | Tick count | Notes | +|-------------------------------|----------|-----|------------------|------------|-------| +| `IDC_DENSITY_SLIDER` | 0..100 | 50 | 5 | 21 | Exact midpoint tick at 50. | +| `IDC_ANIMSPEED_SLIDER` | 1..100 | — | 5 | 21 | `TBM_SETTICFREQ = 5` yields ticks 1, 6, …, 96; add one explicit tick at 100 via `TBM_SETTIC` for a total of 21. Midpoint (50.5) is non-integer; closest tick is 51. | +| `IDC_GLOWINTENSITY_SLIDER` | 0..200 | 100 | 10 | 21 | Exact midpoint tick at 100. Value indicator reads `"0% (glow disabled)"` at position 0 (FR-031). | +| `IDC_GLOWSIZE_SLIDER` | 50..200 | 125 | 5 | 31 | freq=10 would land ticks 50, 60, …, 120, 130, …, 200 with no tick at 125 (violating the midpoint rule); freq=5 keeps the midpoint at 125 at the cost of denser ticks. | + +The right-side value indicator continues to show the integer percent value followed by `"%"`, with the documented `"0% (glow disabled)"` special-case for Glow Intensity. + +--- + +## Rule 3 — Initialization + +The existing helper `InitializeSlider(hDlg, sliderId, labelId, min, max, current)` in `ConfigDialog.cpp` is extended to: +1. Send `TBM_SETRANGE` (already done). +2. Send `TBM_SETTICFREQ` per the per-slider table above (new). +3. For `IDC_ANIMSPEED_SLIDER`, additionally send `TBM_SETTIC, 0, 100` after `TBM_SETTICFREQ` to add the explicit tick at 100 (new). +4. Send `TBM_SETPOS` (already done). +5. Format and set the label text (already done) — extended to special-case `IDC_GLOWINTENSITY_SLIDER` value 0 (FR-031) and to use mapped text for discrete sliders (FR-022, FR-029, FR-030). + +The `WM_HSCROLL` handler is similarly extended to update the value indicator using the mapped/special-case text. + +--- + +## Rule 4 — Non-trackbar quality controls + +Out of scope for this contract but listed here for completeness: + +- `IDC_QUALITY_PRESET_COMBO`: combobox (`CBS_DROPDOWNLIST | WS_TABSTOP`). Entries: `"Low"`, `"Medium"`, `"High"`, `"Custom"`. No tick concept. +- `IDC_GRAPHICS_ADVANCED_CHECK`: checkbox (`BS_AUTOCHECKBOX | WS_TABSTOP`). Drives dialog dynamic resize per R-010. +- `IDC_*_INFO`: owner-drawn buttons (`BS_OWNERDRAW | WS_TABSTOP`) for information tips. Hover + Space/Enter trigger the shared tooltip per R-009. diff --git a/specs/006-multimon-gpu-efficiency/data-model.md b/specs/006-multimon-gpu-efficiency/data-model.md new file mode 100644 index 0000000..408f8a1 --- /dev/null +++ b/specs/006-multimon-gpu-efficiency/data-model.md @@ -0,0 +1,210 @@ +# Data Model — Multi-Monitor User Control and GPU Efficiency + +**Feature**: `006-multimon-gpu-efficiency` + +This feature is a desktop application with no database. "Data" here means: +- **Settings entities** persisted in the Windows registry. +- **In-memory configuration entities** that drive rendering and the dialog state. +- **Pure-function lookup tables** mapping quality presets to their concrete knob values. + +No new file formats, no new IPC schemas. All persistence is via the existing `RegistrySettingsProvider`. + +--- + +## Entity 1 — `MultiMonitorSetting` + +**Purpose**: User-controlled flag for whether MatrixRain should span all monitors. + +**Fields**: +| Field | Type | Default | Constraints | +|------------------|------|---------|--------------------------------------| +| `m_enabled` | bool | `true` | — (no constraint beyond bool domain) | + +**Source location**: `MatrixRainCore\ScreenSaverSettings.h` (new field `m_multiMonitorEnabled`). +**Persistence**: registry value `MultiMonitor` (DWORD; 0 = disabled, 1 = enabled). +**State transitions**: free toggle. Effect on running app: live rebuild within 1 second. + +--- + +## Entity 2 — `GpuAdapterSetting` + +**Purpose**: User's preferred rendering adapter. + +**Fields**: +| Field | Type | Default | Constraints | +|--------------------|-----------------|---------|------------------------------------------------------------| +| `m_description` | `std::wstring` | `L""` | UTF-16; empty = "use system default"; up to 128 chars (the DXGI description length cap) | + +**Source location**: `MatrixRainCore\ScreenSaverSettings.h` (new field `m_gpuAdapter`). +**Persistence**: registry value `GpuAdapter` (REG_SZ). +**Validation rules**: at startup, the description is looked up against the currently-enumerated adapter list via `ResolveAdapter`. No match = silently fall back to default (FR-011, FR-014). +**State transitions**: free choice from the enumerated list; persists immediately on dialog OK. + +--- + +## Entity 3 — `AdapterInfo` *(in-memory, enumerated)* + +**Purpose**: Describes one rendering-capable GPU adapter discovered at runtime. + +**Fields**: +| Field | Type | Source | +|--------------------|----------------|-------------------------------------------------| +| `m_description` | `std::wstring` | `DXGI_ADAPTER_DESC1::Description` | +| `m_luid` | `LUID` | `DXGI_ADAPTER_DESC1::AdapterLuid` | +| `m_dedicatedVramMb`| `unsigned` | `DXGI_ADAPTER_DESC1::DedicatedVideoMemory / (1024*1024)` | +| `m_isSoftware` | bool | `(desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) != 0`| +| `m_isDefault` | bool | True if this adapter is the one returned by `IDXGIFactory6::EnumAdapterByGpuPreference(0, DXGI_GPU_PREFERENCE_UNSPECIFIED, …)` | + +**Source location**: `MatrixRainCore\IAdapterProvider.h`. +**Construction**: `WindowsAdapterProvider::EnumerateAdapters()` filters out software adapters before returning. +**Relationship**: `ResolveAdapter(std::vector, std::wstring savedDescription)` returns the matching `LUID` (or `std::nullopt` for "use default"). + +--- + +## Entity 4 — `QualityPreset` *(enum)* + +**Values**: `Low`, `Medium`, `High`, `Custom`. +**Source**: `MatrixRainCore\QualityPresets.h` (new `enum class QualityPreset : int { Low = 0, Medium = 1, High = 2, Custom = 3 }`). +**Persistence**: registry value `QualityPreset` (REG_SZ, exact string `"Low"`, `"Medium"`, `"High"`, or `"Custom"`). +**Default**: chosen on first run by `PickDefaultQualityPreset` (see Entity 8). + +**State transitions**: +- User selects a named preset → all `AdvancedGraphicsValues` snap (Entity 5). +- User changes any `AdvancedGraphicsValues` field → preset auto-flips to `Custom` and `LastCustomGraphicsValues` (Entity 6) is updated. +- User selects `Custom` from the dropdown: + - If `LastCustomGraphicsValues` has been saved before → restore those. + - Else → leave `AdvancedGraphicsValues` at its current values. + +--- + +## Entity 5 — `AdvancedGraphicsValues` + +**Purpose**: The four numeric knobs that determine glow rendering quality. + +**Fields**: +| Field | Type | Range / Domain | Default | +|------------------------|-------------------------------|-----------------------------------------------|-------------------------------| +| `m_glowIntensityPercent` | int | 0..200 (existing; 0 = glow fully disabled) | 100 (existing) | +| `m_blurPasses` | int | 1..4 (4 discrete tick positions) | 3 (the "High" preset) | +| `m_bloomResolutionDivisor` | `enum ResolutionDivisor` | `Full=1`, `Half=2`, `Quarter=4`, `Eighth=8` | `Half` | +| `m_blurTaps` | `enum BlurTaps` | `Low=5`, `Medium=9`, `High=13` | `High` | + +**Source location**: `MatrixRainCore\QualityPresets.h`. + +**Validation rules**: +- `m_glowIntensityPercent` clamped to `[0, 200]` on load. +- `m_blurPasses` clamped to `[1, 4]` on load (legacy 0 values silently become 1). +- `m_bloomResolutionDivisor` invalid → default `Half`. +- `m_blurTaps` invalid → default `High`. + +**Live behavior**: change → `RenderSystem` sees new values on next frame via the existing shared-state snapshot path. + +--- + +## Entity 6 — `LastCustomGraphicsValues` + +**Purpose**: The user's most recent custom set of advanced control values, used to restore Custom after they navigate away and back. + +**Fields**: same shape as Entity 5 (`m_glowIntensityPercent`, `m_blurPasses`, `m_bloomResolutionDivisor`, `m_blurTaps`). + +**Existence semantics**: `std::optional` — does not exist until the user has customized at least once. Once set, it persists across restarts. + +**Persistence**: four registry DWORD values: +- `LastCustom_GlowIntensity` +- `LastCustom_Passes` +- `LastCustom_Resolution` (stored as the divisor integer: 1/2/4/8) +- `LastCustom_Smoothness` (stored as the tap count: 5/9/13) + +If any of these values is absent at load, `LastCustomGraphicsValues` is treated as empty (`nullopt`) and all four are ignored — partial state is intentionally not honored. + +**Update trigger**: written whenever the user moves any advanced control (regardless of whether the active preset is named or `Custom`), so the "switch to Custom restores last custom" UX works correctly even if the user navigated through named presets in between. + +--- + +## Entity 7 — `ShowAdvancedGraphicsSetting` + +**Purpose**: Whether the advanced graphics controls are revealed in the configuration dialog. + +**Fields**: +| Field | Type | Default | Constraints | +|------------------|------|---------|-------------| +| `m_show` | bool | `false` | — | + +**Persistence**: registry value `ShowAdvancedGraphics` (DWORD). +**Initial-show heuristic**: if the user's saved `QualityPreset` is `Custom`, the default is `true` (so they immediately see the controls that define their custom set). Otherwise `false`. +**Live behavior**: dialog resizes within 100ms of toggle; persisted on dialog OK. + +--- + +## Entity 8 — `PickDefaultQualityPreset` *(pure function, not persisted)* + +**Purpose**: First-run heuristic that picks a `QualityPreset` when no preset is saved. + +**Inputs**: +| Input | Type | Source | +|----------------------|-----------------------------------|----------------------------------------------| +| `adapters` | `const std::vector&` | `WindowsAdapterProvider::EnumerateAdapters` | +| `totalMonitorPixels` | `uint64_t` | sum of each connected monitor's width*height | + +**Output**: `QualityPreset`. + +**Algorithm**: +``` +if any adapter in `adapters` is discrete (dedicatedVramMb >= 256 and not software): + return High +elif totalMonitorPixels > 16_000_000: // ~ two 4K displays + return Low +else: + return Medium +``` + +**Heuristic constants**: +| Constant | Value | Justification | +|-----------------------------------|----------------|------------------------------------------------------------------| +| `kDiscreteVramThresholdMb` | 256 | Modern integrated adapters reserve ≪256 MB dedicated; discrete adapters >=256 MB even at the low end | +| `kHeavyTotalPixelsThreshold` | 16,000,000 | Approximately two 4K displays (3840×2160 × 2 = 16,588,800) | + +Both constants are `static constexpr` in `QualityPresets.cpp` so tests can pin them via dependency-of-the-test rather than a build flag. + +--- + +## Entity Relationships + +``` +ScreenSaverSettings (persisted) +├── m_multiMonitorEnabled : bool +├── m_gpuAdapter : wstring +├── m_qualityPreset : QualityPreset +├── m_advancedValues : AdvancedGraphicsValues ← driven by m_qualityPreset OR +├── m_lastCustom : optional custom drift +└── m_showAdvancedGraphics : bool + +QualityPreset (enum) ──maps via──> AdvancedGraphicsValues (via lookup table) + +AdapterProvider ──enumerates──> [AdapterInfo] +ResolveAdapter(adapters, savedDescription) ──> optional + │ +RenderSystem.Initialize(hwnd, w, h, optional) + │ + ▼ + D3D11CreateDevice(adapter|nullptr, type) +``` + +**Lock domains**: +- Settings struct: protected by `SharedState::mutex` for live updates (existing pattern). +- Adapter enumeration: called only at startup and on rebuild from the UI thread; no lock needed. +- Render thread: reads settings via the existing `SharedState::GetSnapshot()` lock-snapshot pattern. + +**Persistence touch points** (registry key `HKCU\Software\relmer\MatrixRain`): + +| Value name | Type | Owner entity | +|-----------------------------|---------|-------------------------------| +| `MultiMonitor` | DWORD | `MultiMonitorSetting` | +| `GpuAdapter` | REG_SZ | `GpuAdapterSetting` | +| `QualityPreset` | REG_SZ | `QualityPreset` enum | +| `LastCustom_GlowIntensity` | DWORD | `LastCustomGraphicsValues` | +| `LastCustom_Passes` | DWORD | `LastCustomGraphicsValues` | +| `LastCustom_Resolution` | DWORD | `LastCustomGraphicsValues` | +| `LastCustom_Smoothness` | DWORD | `LastCustomGraphicsValues` | +| `ShowAdvancedGraphics` | DWORD | `ShowAdvancedGraphicsSetting` | +| *(existing values)* | various | unchanged | diff --git a/specs/006-multimon-gpu-efficiency/plan.md b/specs/006-multimon-gpu-efficiency/plan.md new file mode 100644 index 0000000..0b84bb0 --- /dev/null +++ b/specs/006-multimon-gpu-efficiency/plan.md @@ -0,0 +1,136 @@ +# Implementation Plan: Multi-Monitor User Control and GPU Efficiency + +**Branch**: `006-multimon-gpu-efficiency` | **Date**: 2026-06-03 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/006-multimon-gpu-efficiency/spec.md` + +## Summary + +Five coordinated improvements to MatrixRain v1.3.1984 (already-merged multi-monitor base): + +1. **Multi-monitor optional** — A persisted on/off setting (default on) gates the existing per-monitor fan-out path. +2. **Runtime topology + device-loss response** — `WM_DISPLAYCHANGE` and `Present` HRESULT inspection drive a coalesced rebuild of monitor render contexts, fixing the ghost-monitor render thread that today holds GPU at ~90% after an undock. +3. **GPU adapter selection** — A new `IAdapterProvider`/`AdapterInfo` trio (mirroring the existing monitor-provider pattern) plus a config-dialog combobox lets the user pick the rendering adapter on hybrid laptops. `D3D11CreateDevice` is changed to accept a specific adapter, and persistence is by adapter description string. +4. **Frame-rate cap on high-refresh monitors** — A pure `FrameLimiter` helper engages per monitor only when the monitor's native refresh exceeds 60 Hz, capping to 60 FPS; at ≤60 Hz the existing `Present(1, 0)` vsync path is preserved with no limiter overhead. +5. **Graphics quality preset spectrum** — A new "Quality" combobox (Low / Medium / High / Custom) bundles per-knob quality settings; an advanced disclosure toggle reveals three discrete sliders (Passes / Resolution / Smoothness) plus accessible information tips on every quality control. The existing Glow Intensity slider gains a true "0% = disabled" mode that bypasses the bloom pipeline. First-run defaults are chosen by a pure static GPU-capability heuristic. + +The plan preserves the existing architecture (per-monitor `MonitorRenderContext`, central `Application` coordinator, `RegistrySettingsProvider`, `ConfigDialog`/`ConfigDialogController` split, in-memory provider pattern for tests) and extends it without breaking changes. Net code shape is +1 provider trio, +5 pure helpers, +1 dialog group with disclosure, +5 settings fields. The "High" quality preset is intentionally calibrated to today's exact rendering so existing users see no visible change after upgrade. + +## Technical Context + +**Language/Version**: C++23 (`/std:c++latest`), MSVC v18 (Visual Studio 2026 Enterprise). +**Primary Dependencies**: D3D11 (existing), DXGI — **add `` to `pch.h`** for `IDXGIFactory6::EnumAdapterByGpuPreference`; Win32 (existing trackbars, dialogs; new tooltip control `WC_TOOLTIP` and owner-draw button); existing EHM (Error Handling Macros) and ComPtr. +**Storage**: Windows registry (existing key `HKCU\Software\relmer\MatrixRain`); new values `MultiMonitor` (DWORD), `GpuAdapter` (REG_SZ), `QualityPreset` (REG_SZ), `LastCustom_Passes` / `LastCustom_Resolution` / `LastCustom_Smoothness` / `LastCustom_GlowIntensity` (DWORDs), `ShowAdvancedGraphics` (DWORD). Persisted via the existing `RegistrySettingsProvider` helpers. +**Testing**: Microsoft C++ Native Unit Test Framework (``) via the existing `MatrixRainTests.vcxproj`. Run with `vstest.console.exe` at `…\Common7\IDE\Extensions\TestPlatform\vstest.console.exe`. Existing baseline: **354 tests passing on master**. +**Target Platform**: Windows 11 x64 only. Modern DXGI GPU-preference APIs (`IDXGIFactory6`) are available; legacy `NvOptimusEnablement`/`AmdPowerXpressRequestHighPerformance` exports are intentionally **not** added in v1.4 (see research.md). +**Project Type**: Desktop application (`MatrixRainCore.lib` + `MatrixRain.exe` + `MatrixRainTests.dll`). +**Performance Goals**: (1) On a >60 Hz monitor, render at ≤65 FPS, reducing GPU work by ≥50% relative to v1.3 baseline. (2) On a Surface-class integrated GPU with one Full-HD monitor, GPU utilization ≤70% of v1.3 baseline. (3) Topology change and GPU selection change take effect within 1 second. (4) On undock from a multi-monitor system, GPU utilization within 1 second drops to within 10% of "started in undocked state" baseline. +**Constraints**: Warnings-as-errors clean (`/W4 /WX /sdl`); no `/m` flag for MSBuild (PCH `C3859/C1076` transient memory failures); auto-bumped `Version.h` excluded from commits; existing settings keys/value layout must remain compatible (no migration of pre-existing values); single-monitor users see no visible change; "High" preset must be visually identical to today's default; flicker on dialog resize must be minimized (`SetWindowPos` with `SWP_NOZORDER | SWP_DRAWFRAME` + suppressing `WM_NCCALCSIZE` redraw bursts). +**Scale/Scope**: 5 user stories, 39 functional requirements, 10 success criteria. Code estimate ~2,000 new LOC (~800 core + helpers, ~500 dialog/UI, ~700 tests). One feature branch, multiple atomic commits per constitution. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-evaluated after Phase 1 design (re-evaluation results identical — no design choices conflict with any principle).* + +| Principle | Compliance | Notes | +|-----------|------------|-------| +| **I. TDD (non-negotiable)** | PASS | All pure helpers (`ShouldSpanAllMonitors` core, `ResolveAdapter`, `FormatAdapterLabel`, `IsDeviceLost`, `FrameLimiter`, `ShouldEngageFrameLimiter`, `PickDefaultQualityPreset`, `ApplyPresetSnap`, `DetectCustomDrift`, `LookupQualityPresetValues`, `LookupResolutionDivisor`, `LookupBlurTapCount`, `IsGlowDisabled`, `CoalesceRebuildRequest`) live in `MatrixRainCore` and are TDD'd against `MatrixRainTests`. Win32 dialog wiring, owner-draw of the info indicator, and DXGI device creation are out of TDD scope per the constitution's exemption for entry-point/UI code; they receive manual QA per the success criteria and the existing in-memory provider pattern for any logic they invoke. | +| **II. Performance-First** | PASS | Performance is the primary motivator. Frame cap, idle bloom-pipeline skip (via Glow Intensity 0), preset-driven resolution divisor, kernel-tap variants, and the elimination of the ghost-monitor render thread are all measured against the existing v1.3 baseline as success criteria SC-001/SC-004/SC-005. | +| **III. C++23 + Windows native** | PASS | No new tech stack. New code uses `std::optional`, structured bindings, `std::format`, `std::chrono::steady_clock`, `std::ranges` where idiomatic. Wide-string everywhere (`L"…"`, `wstring`/`wstring_view`). | +| **IV. Modular architecture** | PASS | New code introduces one new interface (`IAdapterProvider`) with two implementations (`WindowsAdapterProvider`, `InMemoryAdapterProvider`) and ≤14 pure free-function helpers grouped by concern (`AdapterSelection.{h,cpp}`, `FrameLimiter.{h,cpp}`, `QualityPresets.{h,cpp}`, `MultiMonitorGate.{h,cpp}`, `DeviceLost.{h,cpp}`, `RebuildCoalescer.{h,cpp}`). Dependencies flow one direction: providers/helpers ← `Application` ← `MonitorRenderContext` ← `RenderSystem`. No circular dependencies. | +| **V. Type safety + modern idioms** | PASS | `enum class QualityPreset`, `enum class ResolutionDivisor`, `enum class BlurTaps`. `std::optional` for "no GPU saved". ComPtr for COM lifetimes. `const`-correct throughout. | +| **VI. Library-first** | PASS | All logic in `MatrixRainCore.lib`. `MatrixRain.exe` gains only `ConfigDialog.cpp` UI glue (already its role today), `.rc` resource changes, and `resource.h` IDs. No business logic added to `.exe`. | +| **VII. PCH** | PASS | One new include (``) added to `pch.h` in the appropriate alphabetical group. No system headers added to other files. Test PCH continues to `#include` core PCH. | +| **VIII. Code formatting and style** | PASS | All new code follows the existing project conventions: 5 blank lines between top-level constructs, column-aligned declarations with `*`/`&` as their own column, function header comment banners (80 char), Win32 trackbar/combo pattern matching `ConfigDialog.cpp` precedent. Hungarian-prefix discipline preserved where the surrounding code uses it; modern names elsewhere. | +| **IX. Commit discipline** | PASS | Tasks.md (next phase) breaks work into atomic units, each producing a single commit after build + full test pass. Sequencing: spec/plan/research/data-model/quickstart/contracts (one commit total — this branch initialization) → then per-task commits. | + +**Verdict**: No violations. Complexity Tracking section omitted (nothing to justify). + +## Project Structure + +### Documentation (this feature) + +```text +specs/006-multimon-gpu-efficiency/ +├── spec.md # Feature spec (already created) +├── plan.md # This file +├── research.md # Phase 0 — decisions/rationale/alternatives +├── data-model.md # Phase 1 — entities, fields, validation +├── quickstart.md # Phase 1 — how to validate the feature +├── contracts/ +│ ├── registry-schema.md # Persistent settings: keys, types, defaults +│ ├── adapter-provider.md # IAdapterProvider/AdapterInfo contract +│ ├── quality-preset-mapping.md # Preset → knob values + custom-snap rules +│ └── tick-mark-conventions.md # Slider tick frequencies and labeling rules +├── checklists/ +│ └── requirements.md # Spec quality checklist (already created) +└── tasks.md # (Phase 2 — created by /speckit.tasks, not by /speckit.plan) +``` + +### Source code (repository root) + +This is a single-product Windows desktop application using the existing three-project Visual Studio solution (per Constitution VI). No new top-level project is added; all new code lands in the existing tree. + +```text +MatrixRain.sln +│ +├── MatrixRainCore/ # Static library (all logic, tested) +│ ├── pch.h # + in DirectX/DXGI block +│ │ +│ ├── ScreenSaverSettings.h # + multimon/gpu/preset/last-custom fields +│ ├── ISettingsProvider.h # (existing — no change needed) +│ ├── RegistrySettingsProvider.{h,cpp} # + read/write new values +│ ├── InMemorySettingsProvider.{h,cpp} # + new fields (test seam) +│ │ +│ ├── IAdapterProvider.h # NEW — pure interface, AdapterInfo struct +│ ├── WindowsAdapterProvider.{h,cpp} # NEW — DXGI enumeration impl +│ ├── InMemoryAdapterProvider.{h,cpp} # NEW — test seam +│ ├── AdapterSelection.{h,cpp} # NEW — pure helpers: ResolveAdapter, +│ │ # FormatAdapterLabel +│ │ +│ ├── FrameLimiter.{h,cpp} # NEW — refresh-gated frame pacing +│ ├── DeviceLost.{h,cpp} # NEW — IsDeviceLost(HRESULT) classifier +│ ├── RebuildCoalescer.{h,cpp} # NEW — atomic-flag coalescing helper +│ ├── MultiMonitorGate.{h,cpp} # NEW — pure ShouldSpanAllMonitors core +│ ├── QualityPresets.{h,cpp} # NEW — preset↔knob map, snap, custom drift +│ │ +│ ├── RenderSystem.{h,cpp} # MOD — accept adapter; capture Present +│ │ # HRESULT; expose IsDeviceLost flag; +│ │ # parametric bloom (passes/res/taps); +│ │ # bypass bloom when intensity == 0 +│ ├── MonitorRenderContext.{h,cpp} # MOD — accept adapter + frame limiter; +│ │ # gate Present on device-lost path +│ ├── Application.{h,cpp} # MOD — multimon gate; WM_DISPLAYCHANGE +│ │ # handler; coalesced rebuild; +│ │ # resolve adapter once at startup +│ ├── ConfigDialogController.{h,cpp} # MOD — new UpdateMultiMonitorEnabled, +│ │ # UpdateGpuAdapter, UpdateQuality*, +│ │ # UpdateShowAdvancedGraphics +│ └── (existing files unchanged) +│ +├── MatrixRain/ # .exe — Win32 entry + dialog UI only +│ ├── MatrixRain.rc # MOD — new controls in dialog template +│ ├── resource.h # MOD — new IDC_* IDs +│ └── ConfigDialog.cpp # MOD — populate combos, wire sliders, +│ # advanced disclosure resize, +│ # infotip control + tooltip wiring +│ +├── MatrixRainTests/ # CppUnitTest DLL +│ └── unit/ +│ ├── AdapterSelectionTests.cpp # NEW — ResolveAdapter, FormatAdapterLabel +│ ├── FrameLimiterTests.cpp # NEW — sleep math, >60Hz gate +│ ├── DeviceLostTests.cpp # NEW — HRESULT classification table +│ ├── MultiMonitorGateTests.cpp # NEW — ShouldSpanAllMonitors truth table +│ ├── QualityPresetsTests.cpp # NEW — preset map, snap, drift, defaults +│ ├── RebuildCoalescerTests.cpp # NEW — flag set/clear/coalesce +│ ├── RegistrySettingsProviderTests.cpp # MOD — round-trip new values +│ ├── ConfigDialogControllerTests.cpp # MOD — new Update*/persistence flow +│ └── (existing tests unchanged) +│ +└── specs/006-multimon-gpu-efficiency/ # This feature's artifacts +``` + +**Structure Decision**: Existing three-project layout. No new projects. New core/test files only; modifications to existing files limited to those listed above. This honors Constitution principles IV (modular), VI (library-first), and VII (PCH discipline). + +## Complexity Tracking + +> Constitution Check passes with no violations. No complexity justification required. diff --git a/specs/006-multimon-gpu-efficiency/quickstart.md b/specs/006-multimon-gpu-efficiency/quickstart.md new file mode 100644 index 0000000..4c6d53f --- /dev/null +++ b/specs/006-multimon-gpu-efficiency/quickstart.md @@ -0,0 +1,129 @@ +# Quickstart — Multi-Monitor User Control and GPU Efficiency + +**Feature**: `006-multimon-gpu-efficiency` +**Audience**: developer or reviewer validating the feature on a workstation. + +This document is the minimal recipe to (1) build the feature, (2) run the automated tests, (3) launch the binary, and (4) walk through each of the five user stories to confirm acceptance. + +--- + +## 1. Build + +```powershell +Set-Location C:\Users\relmer\source\repos\relmer\MatrixRain +& 'C:\Program Files\Microsoft Visual Studio\18\Enterprise\MSBuild\Current\Bin\MSBuild.exe' ` + MatrixRain.sln /p:Configuration=Debug /p:Platform=x64 +``` + +- Do NOT pass `/m`. The PCH triggers transient `C3859`/`C1076` memory failures under parallel build on this machine. +- Warnings-as-errors (`/W4 /WX /sdl`) are enforced; any C4189 (unused variable) or C4100 (unreferenced parameter) breaks the build. + +## 2. Tests + +```powershell +& 'C:\Program Files\Microsoft Visual Studio\18\Enterprise\Common7\IDE\Extensions\TestPlatform\vstest.console.exe' ` + x64\Debug\MatrixRainTests.dll /Platform:x64 /InIsolation | ` + Select-String 'Total tests|Passed:|Failed:' +``` + +Expected output: total count = **354 (master baseline) + new tests added by this feature**. The new test files live in `MatrixRainTests/unit/`: +- `AdapterSelectionTests.cpp` +- `FrameLimiterTests.cpp` +- `DeviceLostTests.cpp` +- `MultiMonitorGateTests.cpp` +- `QualityPresetsTests.cpp` +- `RebuildCoalescerTests.cpp` +- + extensions to `RegistrySettingsProviderTests.cpp` and `ConfigDialogControllerTests.cpp` + +All tests must pass. No skipped tests. + +## 3. Launch + +```powershell +Start-Process .\x64\Debug\MatrixRain.exe +``` + +For screensaver-mode validation: + +```powershell +Start-Process .\x64\Debug\MatrixRain.exe -ArgumentList '/s' +``` + +For configuration-dialog-only: + +```powershell +Start-Process .\x64\Debug\MatrixRain.exe -ArgumentList '/c' +``` + +## 4. Acceptance walkthrough + +### US1 — Runtime topology and device-loss response (P1) + +1. Connect a second monitor. Launch MatrixRain. Confirm it renders on both monitors. +2. Open Task Manager (Performance → GPU) and note the application's GPU utilization. +3. Disconnect the second monitor (physically unplug or disable in Display Settings → "Disconnect this display"). +4. Within 1 second: only the remaining monitor continues rendering; GPU utilization drops to within 10% of "starting MatrixRain fresh with one monitor" (US1 SC-001). +5. Reconnect the second monitor. Within 1 second: MatrixRain begins rendering to it without restart (US1 SC-002). +6. Repeat steps 1-5 after launching MatrixRain in screensaver mode (`MatrixRain.exe /s`); verify identical behaviour (FR-006 requires the same response in both display and `/s` modes). +7. Optional: suspend the laptop, wait a few seconds, resume. MatrixRain must continue running (a sleep/resume often surfaces as a `DXGI_ERROR_DEVICE_REMOVED` and exercises the same device-loss recovery path). +6. Optional: disable the active GPU's driver via Device Manager. MatrixRain recovers automatically; no error dialog appears. + +### US2 — Multi-monitor optional toggle (P1) + +1. Launch MatrixRain on a multi-monitor system. +2. Press Enter (or right-click → Configure) to open the configuration dialog. +3. Find the "Render on all monitors" checkbox; verify it is checked by default. +4. Uncheck it and click OK. +5. Within 1 second: secondary monitors return to their normal desktop content; primary continues showing MatrixRain. +6. Restart MatrixRain. Verify it starts on the primary only (setting persisted). +7. Open the dialog, re-check the checkbox, OK. Verify all monitors resume rendering within 1 second. + +### US3 — GPU adapter selection (P2) + +*Requires a hybrid laptop or system with multiple non-software GPUs.* + +1. Open the configuration dialog. Locate the GPU dropdown. +2. Verify each adapter is listed by its real name. The system default has `" (default)"` appended (e.g., `"NVIDIA GeForce RTX 3050 Ti (default)"`). +3. Verify software adapters (Microsoft Basic Render Driver) are NOT listed. +4. Select a non-default adapter and click OK. +5. Within 1 second: in Task Manager, MatrixRain's GPU usage switches to the selected GPU. +6. Restart MatrixRain. Verify it starts on the selected GPU. +7. To test the "adapter vanished" path: edit `HKCU\Software\relmer\MatrixRain\GpuAdapter` to a fake name (e.g., `"Acme GPU 9000"`). Restart MatrixRain. Verify it starts successfully on the default adapter without any error dialog. + +### US4 — Frame-rate cap on high-refresh monitors (P2) + +*Best validated on a system with a >60Hz monitor.* + +1. Enable debug statistics in MatrixRain (Configuration → Show debug statistics). +2. On a >60Hz monitor: launch MatrixRain. Read the on-screen FPS. Expected: approximately 60. +3. On a 60Hz monitor: launch MatrixRain. Read the on-screen FPS. Expected: approximately 60 (existing behavior). +4. Mixed-refresh multi-monitor system: confirm each monitor independently shows ~60 FPS. +5. Optional: compare GPU utilization on the high-refresh monitor before and after this feature. Expected: ≥50% reduction (US4 SC-004). + +### US5 — Graphics quality presets and advanced controls (P3) + +1. Open the configuration dialog. Locate the "Quality" dropdown. +2. Verify entries: `Low`, `Medium`, `High`, `Custom`. +3. Switch through `Low` → `Medium` → `High`. Verify visible changes to glow appearance. +4. Locate the "Show advanced graphics settings" checkbox. Check it; the dialog grows to reveal three sliders (Passes / Resolution / Smoothness) and four `ⓘ` indicators next to the quality controls. +5. Move any advanced slider; verify the Quality dropdown switches to `Custom` automatically. +6. Switch the Quality back to `High`; verify all advanced sliders snap to High's values. +7. Switch back to `Custom`; verify the previously-customized values are restored. +8. Drag the Glow Intensity slider to 0%; verify the value label reads `"0% (glow disabled)"` and the glow effect is fully gone (not just darker). +9. Hover the mouse over each `ⓘ`; verify a tooltip appears containing descriptive text ending in one of: + - `"Significant GPU performance impact."` + - `"Moderate GPU performance impact."` + - `"Small GPU performance impact."` +10. Tab to each `ⓘ` (verify each is keyboard-focusable). Press Space. Verify the same tooltip appears for the focused indicator. +11. Uncheck "Show advanced graphics settings". Verify the dialog shrinks and the advanced controls are hidden; the OK/Cancel/Reset buttons remain accessible. +12. Restart MatrixRain. Verify all preset/advanced/`ShowAdvancedGraphics` choices persisted. + +## 5. Common-pitfall checklist + +When implementing or reviewing: +- All new system headers (``) go in `pch.h` only. +- `D3D11CreateDevice` MUST use `D3D_DRIVER_TYPE_UNKNOWN` whenever a non-null adapter is passed. +- Render thread MUST NOT call `DestroyWindow`, modify registry, or change the active adapter directly — it MUST POST to the UI thread via `WM_APP_REBUILD_CONTEXTS`. +- All new commits exclude `Version.h` (auto-bumped pre-build). +- All commit messages follow Conventional Commits and include the `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>` trailer. +- One task = one commit (constitution principle IX). diff --git a/specs/006-multimon-gpu-efficiency/research.md b/specs/006-multimon-gpu-efficiency/research.md new file mode 100644 index 0000000..bfdf680 --- /dev/null +++ b/specs/006-multimon-gpu-efficiency/research.md @@ -0,0 +1,186 @@ +# Phase 0 Research — Multi-Monitor User Control and GPU Efficiency + +**Feature**: `006-multimon-gpu-efficiency` +**Status**: Complete. All technical unknowns were resolved during the pre-spec planning discussion with the user; this document consolidates the chosen approaches, the rationale, and the alternatives considered, so subsequent phases can proceed without further investigation. + +--- + +## R-001: Multi-monitor enabled/disabled — runtime apply + +**Decision**: Toggling the setting POSTs `WM_APP_REBUILD_CONTEXTS` to the application's main window; the existing `RebuildContextsForCurrentMode` flow (`Application.cpp:883-940`) tears down all `MonitorRenderContext` threads, re-enumerates monitors, recomputes the layout, and starts the new set. Settings-driven gate is via a pure `ShouldSpanAllMonitors(bool multimonEnabled, DisplayMode displayMode, ScreenSaverMode saverMode)` helper used by `Application::ShouldSpanAllMonitors()` (`Application.cpp:374-388`). + +**Rationale**: This is the same teardown/rebuild path already used today for Alt+Enter (mode toggle), so we get atomicity, lock ordering, and overlay/dialog handling for free. The pure gate is unit-testable in isolation, eliminating logic from the Win32 layer. + +**Alternatives considered**: Apply at next launch only (rejected per user direction — feels broken). Selectively start/stop per-monitor contexts without a full rebuild (rejected — added complexity for a setting that changes infrequently and is already cheap to rebuild). + +--- + +## R-002: Runtime monitor add/remove detection + +**Decision**: Add a `case WM_DISPLAYCHANGE:` handler to `Application::HandleMessage` (`Application.cpp:1081…`). On receipt, set an `std::atomic_flag m_rebuildPending` and POST a single `WM_APP_REBUILD_CONTEXTS`. The `WM_APP_REBUILD_CONTEXTS` handler clears the flag and runs `RebuildContextsForCurrentMode`. This coalesces the burst of `WM_DISPLAYCHANGE` messages the system broadcasts to every top-level window in a multi-window app. + +**Rationale**: `WM_DISPLAYCHANGE` is the standard, kernel-broadcast notification. Windows delivers it to every top-level window (which on multimon = once per `MonitorRenderContext` hwnd). Without coalescing we would trigger N rebuilds for one topology change. The atomic flag is the simplest viable coalescing strategy and is pure-testable as a `CoalesceRebuildRequest(flag)` helper. + +**Alternatives considered**: `WM_DEVICECHANGE` filter for monitor devices (rejected — fires for many unrelated device classes, more complex filter, no advantage over `WM_DISPLAYCHANGE`). Polling `EnumDisplayMonitors` on a timer (rejected — wasteful and laggy). `IDXGIFactory::RegisterStereoStatusWindow` and friends (irrelevant API). + +--- + +## R-003: Device-loss detection and recovery + +**Decision**: Capture the HRESULT returned from `m_swapChain->Present(1, 0)` in `RenderSystem::Present` (`RenderSystem.cpp:1753-1758`). Pure helper `bool IsDeviceLost(HRESULT)` returns true for `DXGI_ERROR_DEVICE_REMOVED`, `DXGI_ERROR_DEVICE_RESET`, `DXGI_ERROR_DEVICE_HUNG`, `DXGI_ERROR_DRIVER_INTERNAL_ERROR`, and `D3DDDIERR_DEVICEREMOVED`. On true, the `MonitorRenderContext` render thread exits its loop and POSTs `WM_APP_REBUILD_CONTEXTS` to its HWND. The main-thread rebuild re-resolves the chosen adapter via `ResolveAdapter` (which falls back to default if the prior selection vanished) and reinitializes. + +**Rationale**: `Present` is the canonical detection point for D3D11. Classifying via a pure helper lets us unit-test the HRESULT mapping without a real device. The render-thread-posts-to-UI-thread pattern reuses the existing rebuild barrier, ensuring lock-correctness. + +**Alternatives considered**: Calling `GetDeviceRemovedReason` only on `DXGI_ERROR_DEVICE_REMOVED` (kept as best-effort logging within the same path). Polling `IDXGIDevice::GetCreationFlags` or similar (no useful liveness signal). Catching the error at `D3D11CreateDevice` rebuild time only (insufficient — first symptom is at Present). + +--- + +## R-004: GPU adapter enumeration and persistence + +**Decision**: +- **Enumeration**: New `IAdapterProvider` interface with `WindowsAdapterProvider` (uses `CreateDXGIFactory1` → `IDXGIFactory1::EnumAdapters1` for the list; uses `CreateDXGIFactory2` → `IDXGIFactory6::EnumAdapterByGpuPreference(0, DXGI_GPU_PREFERENCE_UNSPECIFIED, …)` to identify the system default; filters out adapters with `DXGI_ADAPTER_FLAG_SOFTWARE`). +- **Persistence**: Adapter is stored by `DXGI_ADAPTER_DESC1::Description` (REG_SZ at value name `GpuAdapter` under `HKCU\Software\relmer\MatrixRain`). Empty string = "use system default". +- **Resolution**: Pure helper `ResolveAdapter(const vector& adapters, const wstring& savedDescription) -> std::optional`. Returns the matching adapter's LUID, or `nullopt` if the saved description is empty or not found (caller then calls `D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, …)`). +- **Device creation**: `RenderSystem::Initialize` (`RenderSystem.cpp:146-180`) gains an optional adapter LUID parameter. If supplied, `IDXGIFactory4::EnumAdapterByLuid` looks up the adapter and `D3D11CreateDevice(adapter, D3D_DRIVER_TYPE_UNKNOWN, …)` creates the device on it. If absent, the existing `nullptr` + `D3D_DRIVER_TYPE_HARDWARE` path is preserved unchanged. + +**Rationale**: Description-string persistence trades a tiny risk of driver-update renames for the considerable benefit of human-readable, reboot-stable identification. LUID is not guaranteed stable across reboots; enumeration index is not stable across hardware changes. The mandatory `D3D_DRIVER_TYPE_UNKNOWN` paired with a non-null adapter is a documented D3D11 requirement. Excluding software adapters matches the user's intent ("hybrid laptops, pick integrated vs discrete"). + +**Alternatives considered**: LUID-based persistence (rejected — not stable). Enumeration index (rejected — fragile). Synthetic "Default" + "High performance" preference entries instead of real adapter names (rejected per user — show real names). Legacy `extern "C" __declspec(dllexport) DWORD NvOptimusEnablement = 1;` and `int AmdPowerXpressRequestHighPerformance = 1;` exports (rejected for v1.4 — they are static, evaluated at process load, and would force the discrete GPU always, defeating the integrated-for-power case; on Windows 10 1803+ the DXGI-based path is sufficient). + +--- + +## R-005: Hybrid-laptop discrete-GPU routing + +**Decision**: Rely entirely on `IDXGIFactory6::EnumAdapterByGpuPreference` for advertising the high-performance adapter and on `D3D11CreateDevice(adapter, D3D_DRIVER_TYPE_UNKNOWN, …)` for actually creating the device on the user's chosen physical GPU. Document the cross-adapter present-time copy (DWM runs on the integrated GPU) as expected behavior. + +**Rationale**: On Windows 10 1803+ with current drivers the DXGI path correctly routes the process to the chosen physical GPU on NVIDIA Optimus, AMD PowerXpress, and Surface-class hybrid systems. Adding the legacy magic-export symbols would *force* discrete always (they are evaluated at process-load time before any user setting is read), which is exactly the wrong behavior for a "let the user pick" feature. + +**Alternatives considered**: Adding the legacy magic exports as a belt-and-suspenders fallback (deferred — revisit only if real-world testing on a target hybrid configuration shows the OS-level APIs are insufficient). Modifying the per-app OS GPU preference at `HKCU\…\DirectX\UserGpuPreferences` (rejected — too invasive; that's a user-controlled OS setting). + +--- + +## R-006: Frame-rate cap on high-refresh monitors + +**Decision**: Pure `FrameLimiter` class wrapping a `std::chrono::steady_clock` last-frame timestamp. API: `void TargetFps(unsigned)` to configure, `void WaitForNextFrame()` to block (sleep) until next frame slot. A pure free function `bool ShouldEngageFrameLimiter(unsigned monitorRefreshHz)` returns true iff `monitorRefreshHz > 60`. `MonitorRenderContext::Initialize` accepts the monitor's refresh rate and, when the engage predicate is true, calls `WaitForNextFrame` at the top of each render-loop iteration before the existing `Present(1, 0)`; otherwise the limiter is bypassed entirely and the loop is unchanged. + +**Rationale**: `steady_clock` is monotonic and the natural fit. Per-monitor refresh is already available via `DEVMODE::dmDisplayFrequency` or the existing monitor-enumeration data. Gating on `> 60Hz` keeps the 60Hz case zero-overhead. The pure helpers are trivially unit-tested (elapsed → sleep math; engage truth table). + +**Alternatives considered**: `QueryPerformanceCounter` (no benefit over `steady_clock` for ~60Hz timing). DXGI `WaitableObject` swap chains (more complex; would force a separate code path for the high-refresh case; benefit not justified). Global frame cap across all monitors regardless of refresh (rejected per user direction). + +--- + +## R-007: Bloom-pipeline parametrization + +**Decision**: Replace the hardcoded values in `RenderSystem::ApplyBloom` (`RenderSystem.cpp:1328-1395`) with run-time parameters: +- **Blur iterations**: was hardcoded `for (int pass = 0; pass < 3; ++pass)` at `:1372`; becomes `m_blurPasses` (1..4). +- **Bloom buffer resolution**: was hardcoded `width / 2` / `height / 2` (`:1175-1176`, viewport `:1337`); becomes `width / m_bloomResolutionDivisor` (divisor ∈ {1, 2, 4, 8} mapping Full/Half/Quarter/Eighth). +- **Blur kernel taps**: select one of three precompiled blur shader variants (5-tap, 9-tap, 13-tap) at bind time. Variants are compiled at startup alongside the existing extract/composite shaders. +- **Glow on/off**: when Glow Intensity == 0, the entire bloom pipeline is bypassed (the existing direct-to-backbuffer fallback at `:1731` is taken). + +**Rationale**: Discrete shader variants for kernel taps avoid the dynamic-loop cost of a uniform-driven blur kernel and let HLSL fully unroll each variant. The integer divisor for resolution preserves the existing texture-recreation path with no special-case math. Bypassing the pipeline at intensity == 0 gives the true power savings the user expects from "off" (no extract pass, no blur passes, no composite pass). + +**Alternatives considered**: One blur shader with a `loopCount` uniform (rejected — dynamic loops are slow; lost optimization headroom). Skipping individual passes by conditional `RenderFullscreenPass` calls in the loop (less clean than parametrizing the loop bound). + +--- + +## R-008: Quality preset spectrum and custom-snap behavior + +**Decision**: +- **Presets**: `enum class QualityPreset { Low, Medium, High, Custom }`. +- **Preset → knob map** (in `QualityPresets.cpp`, pure lookup table): + + | Preset | GlowIntensity | Passes | Resolution | Smoothness | + |--------|---------------|--------|------------|------------| + | Low | 75 | 1 | Quarter | Low (5) | + | Medium | 100 | 2 | Half | Med (9) | + | High | 100 | 3 | Half | High (13) | + +- **High == today's exact values** — by design, so existing users see no change after upgrade. +- **First-run heuristic**: `PickDefaultQualityPreset(AdapterClass, dedicatedVramMB, totalPixelCount)`: + - Any discrete adapter → `High`. + - Integrated only, totalPixelCount ≤ ~16M → `Medium`. + - Integrated only, totalPixelCount > ~16M → `Low`. +- **Custom-snap behavior** (pure helpers `ApplyPresetSnap`, `DetectCustomDrift`): + - Preset → named preset: all advanced controls snap to that preset's row. + - Any advanced control change: preset auto-flips to `Custom`; current values become the in-memory `LastCustom`. + - Preset → `Custom`: if a saved `LastCustom` exists in the registry, restore it; else leave the advanced controls at their current values (whatever the previous preset's values were until the user touches one). +- **Persistence**: `QualityPreset` (REG_SZ "Low"/"Medium"/"High"/"Custom"). `LastCustom_Passes` / `LastCustom_Resolution` / `LastCustom_Smoothness` / `LastCustom_GlowIntensity` (DWORDs) — written whenever `LastCustom` is updated, even if the active preset is named (so the user gets their last custom back when they later switch to Custom). + +**Rationale**: Three named presets cover the meaningful design points and avoid the redundancy of the originally proposed Battery (already reachable via Glow Intensity 0) and Ultra (gratuitous for a screensaver). The first-run heuristic is intentionally static and run only when no preset is saved (no runtime adaptive downshift), per the user's explicit preference. The custom-snap behavior makes preset switching predictable and never silently loses user work. + +**Alternatives considered**: Five-preset spectrum (rejected as redundant — see above). Three presets with auto-saved per-preset "last custom" overrides (over-engineered). Continuous quality slider 0..100 (rejected — there's no natural continuum across the three independent knobs). + +--- + +## R-009: Information tip ("ⓘ") control + +**Decision**: Each information tip is an owner-drawn `BS_OWNERDRAW | WS_TABSTOP` push button (~12×12 dialog units) drawn via `WM_DRAWITEM` as a 1-pixel circle outline with a lowercase "i" centered inside. A single shared `WC_TOOLTIP` window (created in `OnInitDialog`) hosts the actual tooltip text. Each info button is registered as a tool with `TTF_IDISHWND | TTF_SUBCLASS` for hover behavior. For keyboard activation, each info button's `BN_CLICKED` handler positions the tooltip below/right of the button and calls `TTM_TRACKACTIVATE` on a parallel `TTF_TRACK` registration; the tooltip dismisses on kill-focus, ESC, or a 5-second timer. Per-button text is supplied via `TTN_GETDISPINFO`, keyed by control ID. + +**Rationale**: Owner-draw guarantees a crisp circle + "i" at any DPI without depending on a specific font glyph being installed. Real `BUTTON` controls are naturally focusable (Tab) and fire `BN_CLICKED` on Space/Enter, so keyboard accessibility comes for free. The single shared tooltip + per-tool text is the standard Win32 idiom and minimizes window count. + +**Alternatives considered**: Static control with the Unicode "info" code point (U+2139 ℹ or U+24D8 ⓘ) (rejected — font-availability fragile, not focusable). Segoe MDL2 Assets "Info" glyph U+E946 in a label (rejected — font/DPI fragility, not focusable). Custom small dialog modal popup instead of a tooltip (over-engineered, breaks Win32 conventions). + +--- + +## R-010: Configuration dialog dynamic resize + +**Decision**: Lay out the `.rc` template at the EXPANDED size (with the advanced controls visible). In `OnInitDialog`, after loading settings: +1. Record each advanced control's rect (via `GetWindowRect` + screen-to-client). +2. Compute `advancedBlockHeight` = (lowest advanced control's bottom) − (highest advanced control's top) + spacing. +3. If the user's last `ShowAdvancedGraphics` setting is off (the default), hide the advanced controls (`ShowWindow(SW_HIDE)`), shrink the dialog by `advancedBlockHeight` (`SetWindowPos`), and move the OK/Cancel/Reset row (and anything below the advanced block) up by the same amount. + +On `BN_CLICKED` of `IDC_GRAPHICS_ADVANCED_CHECK`, run the inverse transform and persist the new setting. Use DPI-aware computation by sourcing the height from the actual control rects (which already scale with the dialog's font), not from a hardcoded pixel value. + +**Rationale**: Working from the expanded layout in the `.rc` keeps the layout authority in the resource editor (and avoids fragile DPI math at runtime). Capturing the block height from actual control rects in `OnInitDialog` automatically picks up the correct DPI-scaled value. Moving the buttons (and any controls below the advanced block) by the same delta keeps the visual layout consistent in both states. + +**Alternatives considered**: Two separate dialog templates (rejected — control IDs would need to differ, doubling event-handling code). Fixed taller dialog with reserved empty space (rejected per user — leaves an empty gap that looks broken). `SetWindowRgn` to clip out the advanced area (visual artifacts, doesn't reflow buttons). + +--- + +## R-011: Tick-mark conventions for trackbars + +**Decision**: Apply the following rules to all percentage trackbars in the dialog (recorded as a contract in `contracts/tick-mark-conventions.md`): +- **Discrete sliders** (Passes, Resolution, Smoothness): `TBS_AUTOTICKS` plus labeled ticks under each tick mark (achieved with `LTEXT` statics aligned at the tick screen-x positions in the dialog template). +- **Percentage sliders, common rule**: tick frequency chosen so a tick falls at the midpoint of the range and ~21 ticks total are produced. Concrete per-slider settings: + + | Slider | Range | Mid | `TBM_SETTICFREQ` | Ticks | Notes | + |-----------------|---------|-----|------------------|-------|------------------------------------| + | Density | 0..100 | 50 | 5 | 21 | Exact midpoint tick | + | Speed | 1..100 | — | 5 | 21 | freq=5 → 20 ticks 1..96; one extra explicit tick at 100 via `TBM_SETTIC` | + | Glow Intensity | 0..200 | 100 | 10 | 21 | Exact midpoint tick | + | Glow Size | 50..200 | 125 | 5 | 31 | freq=10 would miss 125; freq=5 keeps midpoint, accept denser tick count | + +- All percentage sliders are unlabeled and non-snapping. + +**Rationale**: A midpoint tick is a strong visual anchor and the user explicitly requested it. The 21-tick target keeps visual density consistent; Glow Size is the outlier because its range (150) doesn't divide cleanly. Speed needs one explicit tick because its range (1..100) doesn't permit an integer midpoint and `TBM_SETTICFREQ` would otherwise stop at 96. + +**Alternatives considered**: Widening Glow Size range to 50..250 to fit the 21-tick rule (rejected — invents range purely to satisfy tick math; alters user-visible setting space for no functional reason). freq=10 on Glow Size yielding 16 ticks but no midpoint tick (rejected per user requirement). Per-slider TBM_SETTIC arrays everywhere (over-engineered for sliders that already work with `TBS_AUTOTICKS`). + +--- + +## R-012: Test strategy + +**Decision**: Extend the existing pure-helper + InMemoryProvider test pattern (already used by `MonitorEnumeratorTests`, `MonitorLayoutTests`, `IRenderSystemTests`, `RenderThreadInputsTests`). New unit tests target the pure helpers introduced by this feature; Win32 UI and DXGI device-creation paths are covered by manual QA per the success criteria, since they are out of TDD scope per the constitution's exemption. + +**Rationale**: Reuses the proven test architecture (354 tests passing on master). Every meaningful behavior introduced by this feature is decomposable into a pure function that is exhaustively testable without a real device, monitor, or window. The Win32 wiring layer is thin glue; manual QA on real hybrid hardware is the highest-value remaining verification. + +**Alternatives considered**: WTL/CppWinUI integration tests for the dialog (excessive scope). Hooking `D3D11CreateDevice` for adapter-routing tests (fragile, low ROI). + +--- + +## R-013: Build/test environment quirks (carried over from prior work) + +**Decision**: No new tooling. Documented constraints: +- Build: `MSBuild.exe /p:Configuration=Debug /p:Platform=x64 MatrixRain.sln` (no `/m` — transient PCH `C3859`/`C1076` memory failures). +- Test: `vstest.console.exe MatrixRainTests.dll /Platform:x64 /InIsolation`. +- `Version.h` is auto-bumped pre-build; exclude from commits. +- Commit messages: PowerShell has no heredoc — write to a temp file, `git commit -F`, remove. Conventional Commits + `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>` trailer. +- `/W4 /WX /sdl`: C4189 (unused variable) and C4100 (unreferenced parameter) break builds; use `UNREFERENCED_PARAMETER` or remove the binding. + +**Rationale**: Carried forward from the prior multimon work that ships on this base; no change. + +--- + +## Open items + +None. All NEEDS CLARIFICATION items were resolved in the pre-spec planning conversation. diff --git a/specs/006-multimon-gpu-efficiency/spec.md b/specs/006-multimon-gpu-efficiency/spec.md new file mode 100644 index 0000000..bed8521 --- /dev/null +++ b/specs/006-multimon-gpu-efficiency/spec.md @@ -0,0 +1,219 @@ +# Feature Specification: Multi-Monitor User Control and GPU Efficiency + +**Feature Branch**: `006-multimon-gpu-efficiency` +**Created**: 2026-06-03 +**Status**: Implemented (60/63 tasks committed; T060/T061/T063 polish documented in tasks.md) +**Input**: User description: "Improve MatrixRain v1.4: make multi-monitor rendering optional (default on); add a GPU adapter selection so users on hybrid laptops can pick integrated vs discrete; respond appropriately to monitors and GPUs being added or removed while running (current behavior leaves a 90% GPU load after undocking a Surface Book 3); and reduce overall GPU usage on hybrid hardware through a frame-rate cap on high-refresh monitors and a graphics quality preset spectrum (Low / Medium / High / Custom) with advanced controls behind a disclosure toggle and accessible information tips." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Adapt immediately when monitors are added or removed (Priority: P1) + +A Surface Book 3 user is running MatrixRain across both the external monitor and the laptop screen. They undock the laptop, disconnecting the external monitor. MatrixRain continues running on the remaining laptop screen and immediately stops consuming GPU work for the disconnected monitor. When they redock, the external monitor begins displaying MatrixRain again without restarting the application. + +**Why this priority**: This is a correctness defect today. After an undock, MatrixRain continues rendering at ~90% GPU into a non-existent monitor; on this hardware it can never recover until the user restarts the application. Fixing this is a blocking quality issue for hybrid-laptop users, who are also the population most affected by all other items in this feature. + +**Independent Test**: With MatrixRain running on a multi-monitor setup, physically disconnect one monitor (or disable it in Display Settings) and verify that within a few seconds GPU utilization for MatrixRain drops to a level consistent with rendering only to the remaining monitors. Reconnect the monitor and verify MatrixRain resumes rendering to it without manual intervention. + +**Acceptance Scenarios**: + +1. **Given** MatrixRain is running spanning two monitors at high GPU load, **When** the user disconnects one monitor, **Then** within 1 second only the remaining monitor continues to be rendered and GPU utilization drops to a level comparable to having started in the new topology. +2. **Given** MatrixRain is running on a single monitor after a disconnect, **When** the user reconnects an additional monitor, **Then** MatrixRain begins rendering to it within 1 second without requiring restart. +3. **Given** MatrixRain is rendering on a specific GPU and that GPU's driver is reset or it becomes temporarily unavailable, **When** the device-loss event occurs, **Then** MatrixRain recovers automatically and resumes rendering without user intervention or visible error dialogs. + +--- + +### User Story 2 - Choose whether MatrixRain spans all monitors (Priority: P1) + +A multi-monitor user opens MatrixRain's configuration dialog and toggles off the "render on all monitors" option because they only want the screensaver on their primary display. The change takes effect immediately on the running instance and persists across restarts. + +**Why this priority**: Multi-monitor rendering is currently always-on. Some users find this distracting (gaming/working secondary monitor, ambient lighting on a TV in the same room) or simply want lower GPU cost. Today they have no opt-out. Making it a setting (default on) is small but unblocks a real user choice. + +**Independent Test**: Open the configuration dialog on a multi-monitor system, locate the "render on all monitors" control, toggle it off, dismiss the dialog. Verify MatrixRain immediately stops rendering on non-primary monitors. Reopen the dialog and confirm the setting persisted; toggle it back on and verify all monitors resume rendering. + +**Acceptance Scenarios**: + +1. **Given** MatrixRain is running on multiple monitors with the multi-monitor setting on (default), **When** the user opens the configuration dialog and turns the setting off, **Then** within 1 second only the primary monitor continues rendering and the secondary monitors return to their normal desktop content. +2. **Given** MatrixRain was last configured with the multi-monitor setting off, **When** the application is launched, **Then** it renders only on the primary monitor. +3. **Given** a single-monitor system, **When** the user opens the configuration dialog, **Then** the multi-monitor control is present but has no effect on behavior (always renders on the one available monitor). + +--- + +### User Story 3 - Pick which GPU MatrixRain uses on hybrid laptops (Priority: P2) + +A user on a Surface Book 3, Surface Laptop Studio 2, or NVIDIA Optimus / AMD PowerXpress laptop opens MatrixRain's configuration dialog. They see a list of the GPUs available on their machine by their real names (e.g., "Intel Iris Xe Graphics", "NVIDIA GeForce RTX 3050 Ti"), with the system default annotated. They select the integrated GPU to extend battery life. MatrixRain begins running on that GPU and the change persists across restarts. + +**Why this priority**: On hybrid laptops, MatrixRain currently always runs on whichever GPU the operating system chose. There is no way for the user to prefer the lower-power integrated GPU when on battery, or the higher-performance discrete GPU when plugged in. The previous priority items (US1, US2) have higher urgency: US1 fixes a defect that affects this same hardware, US2 is the smallest power lever. With those addressed, this becomes the next-most-impactful user control. + +**Independent Test**: On a hybrid-laptop system with two or more rendering-capable adapters, open the configuration dialog and verify the GPU list shows the real adapter names with "(default)" appended to the system default. Select a non-default adapter, dismiss the dialog. Verify (via Task Manager's GPU view) that MatrixRain switches to using the selected GPU. Restart MatrixRain and verify it starts on the selected GPU. Choose a GPU that doesn't exist (by editing the saved setting to a fake name), restart, and verify MatrixRain falls back to the default adapter without error. + +**Acceptance Scenarios**: + +1. **Given** a system with multiple non-software GPU adapters, **When** the user opens the configuration dialog, **Then** the GPU selection control lists each adapter by its real name and the system default adapter is annotated with "(default)". +2. **Given** software/basic-render adapters exist on the system, **When** the user views the GPU selection list, **Then** those adapters are not included in the list. +3. **Given** the user has selected a specific GPU, **When** the dialog is dismissed, **Then** MatrixRain begins rendering on that GPU within 1 second on the running instance and continues to do so on subsequent launches. +4. **Given** a previously selected GPU is no longer present on the system at launch (removed, disabled, or driver uninstalled), **When** MatrixRain starts, **Then** it falls back to the system default adapter and starts successfully without showing an error dialog. + +--- + +### User Story 4 - Cap frame rate on high-refresh monitors (Priority: P2) + +A user with a 144Hz laptop display starts MatrixRain. Rather than rendering 144 frames per second per monitor (wasteful for a screensaver), MatrixRain limits itself to 60 frames per second on that monitor, substantially reducing GPU work. A user on a 60Hz monitor sees no change from prior behavior. + +**Why this priority**: High-refresh displays are common on modern laptops, and rendering a screensaver at 144 FPS is gratuitously expensive — every frame pays the full rendering and bloom cost. A simple per-monitor cap at 60 FPS for refresh > 60Hz is the largest guaranteed GPU saving available with no visible quality impact for this content. Lower priority than US1/US2 only because it is invisible to users (no UI to surface it) and its impact is felt as cooler hardware rather than as a feature. + +**Independent Test**: On a system with a >60Hz monitor, run MatrixRain with debug statistics enabled and verify the FPS counter on that monitor reports approximately 60. On a system with a 60Hz monitor, verify the FPS counter reports approximately 60 (the existing behavior). Compare GPU utilization on the high-refresh case before and after the change to confirm a substantial reduction. + +**Acceptance Scenarios**: + +1. **Given** a monitor whose native refresh rate is greater than 60Hz, **When** MatrixRain renders to that monitor, **Then** rendering is limited to approximately 60 frames per second on that monitor. +2. **Given** a monitor whose native refresh rate is 60Hz or less, **When** MatrixRain renders to that monitor, **Then** rendering continues at the monitor's native refresh rate as before, without any added overhead from the limiter. +3. **Given** a multi-monitor setup with mixed refresh rates (e.g., 60Hz primary and 144Hz secondary), **When** MatrixRain renders to both, **Then** the 60Hz monitor runs at 60 FPS and the 144Hz monitor also runs at approximately 60 FPS, each evaluated independently. + +--- + +### User Story 5 - Tune graphics quality with presets and an advanced disclosure (Priority: P3) + +A user opens MatrixRain's configuration dialog and sees a "Quality" preset selector offering Low, Medium, and High. They pick Low for battery savings or High for the richest look. A more advanced user enables an "advanced graphics settings" toggle that reveals individual controls for the number of glow passes, the resolution of the glow buffer, and the smoothness of the glow blur. As they adjust any individual control, the preset selector automatically switches to "Custom" and remembers those values for next time they pick Custom. Each quality control has an "ⓘ" indicator next to it; hovering the mouse over it or tabbing to it and pressing Space/Enter shows a short description of the control and its GPU performance impact (Significant / Moderate / Small). + +**Why this priority**: This is the most powerful efficiency lever (resolution divisor alone can be ~4x cheaper) but it is also the largest UI surface in this feature. It is lower priority than US1-US4 because: US1 fixes a defect; US2/US3 are simpler controls that unlock immediate user choice; US4 captures the largest fixed win without any UI work. US5 is the long-tail quality/perf control surface that benefits power-conscious users who want to dial in their own tradeoff. + +**Independent Test**: Open the configuration dialog, locate the Quality preset selector, verify Low/Medium/High options are present. Switch through them and verify visible changes to glow appearance. Enable the advanced toggle and verify three additional controls appear (Passes, Resolution, Smoothness). Move any one of them and verify the preset selector switches to Custom. Switch the preset back to High; verify all advanced controls snap to the High preset's values. Switch to Custom again and verify the previously-customized values are restored. Hover the mouse over each "ⓘ" indicator and verify a tooltip appears with descriptive text ending in one of "Significant GPU performance impact.", "Moderate GPU performance impact.", or "Small GPU performance impact.". Tab to each "ⓘ" indicator and press Space; verify the same tooltip appears. + +**Acceptance Scenarios**: + +1. **Given** the configuration dialog is open, **When** the user views the Graphics section, **Then** a Quality preset selector is visible with at least Low, Medium, and High options. +2. **Given** the user has not enabled the advanced toggle, **When** the dialog is shown, **Then** the advanced graphics controls are not visible and the dialog occupies only the space needed for the always-visible controls. +3. **Given** the user enables the advanced toggle, **When** the toggle is checked, **Then** the dialog grows to reveal the advanced controls and the existing OK/Cancel/Reset buttons reposition appropriately; unchecking the toggle reverses this. +4. **Given** any named preset is selected, **When** the user moves an advanced control, **Then** the preset selector automatically switches to "Custom" and the current advanced control values are remembered as the "last custom" set. +5. **Given** a named preset is selected, **When** the user selects a different named preset, **Then** all advanced controls snap to that preset's defined values. +6. **Given** the user has previously used Custom and saved a custom set of values, **When** the user selects "Custom" from the preset selector after having moved through named presets, **Then** the advanced controls restore to the saved custom values. +7. **Given** the user has never used Custom in this dialog session and there is no saved custom set, **When** the user selects "Custom" from the preset selector, **Then** the advanced controls remain at their current values. +8. **Given** the existing Glow Intensity slider, **When** the user moves it to 0%, **Then** the glow effect is fully disabled (not merely darker) and the slider's value indicator reads "0% (glow disabled)". +9. **Given** any quality-related control with an "ⓘ" indicator, **When** the user hovers the mouse over the indicator OR tabs to it with the keyboard and presses Space or Enter, **Then** a tooltip appears containing descriptive text and ending with exactly one of "Significant GPU performance impact.", "Moderate GPU performance impact.", or "Small GPU performance impact.". +10. **Given** a fresh installation with no saved quality preset, **When** the user launches MatrixRain on a system with a discrete GPU, **Then** the High preset is applied; on a system with only an integrated GPU and a modest pixel load, the Medium preset is applied; on a system with an integrated GPU driving multiple high-resolution monitors, the Low preset is applied. + +--- + +### Edge Cases + +- A user toggles the "render on all monitors" setting off while MatrixRain is currently spanning multiple monitors: the secondary monitor windows must be cleanly removed within 1 second and not leave stale or frozen content on those displays. +- A monitor is added or removed while MatrixRain is running in screensaver mode (`/s`): MatrixRain must adapt and keep running rather than exiting, the same as in normal display mode. +- A user toggles the "render on all monitors" setting off and then immediately back on: MatrixRain must end in the fully-spanning state without leaving any monitor stuck in an in-between state. +- The user's previously selected GPU has been removed, disabled, or replaced between MatrixRain runs: MatrixRain must start successfully on the default adapter without an error dialog and without losing the user's other preferences. +- The user's previously selected GPU is removed *while MatrixRain is running*: MatrixRain must detect the loss and continue running on the default adapter. +- A laptop is suspended and resumed while MatrixRain is running: MatrixRain must recover gracefully (the GPU and monitors may have effectively been "removed and re-added" from MatrixRain's perspective). +- The system reports a monitor whose native refresh rate cannot be determined: MatrixRain must fall back to standard vertical-sync behavior rather than refusing to render or capping incorrectly. +- The user selects "Custom" before ever having moved an advanced control: the advanced controls must stay at their current values (whatever named preset they reflect), and from that point any subsequent adjustment becomes the new "last custom". +- A configuration dialog is open while a monitor is removed: the dialog must remain functional and dismissible; new MatrixRain behavior takes effect after dialog dismissal. +- The user cancels (rather than OKs) the configuration dialog after having adjusted multiple controls in advanced mode: all live previews are reverted and persisted settings are unchanged. + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Multi-monitor user control + +- **FR-001**: System MUST provide a user-visible setting in the configuration dialog to enable or disable multi-monitor spanning. +- **FR-002**: Multi-monitor spanning MUST default to enabled for new installations and for existing users who have never seen this setting. +- **FR-003**: Toggling the multi-monitor setting MUST take effect on the running application within 1 second, without restart. +- **FR-004**: System MUST persist the multi-monitor setting across application restarts. + +#### Runtime topology and device-loss response + +- **FR-005**: System MUST detect monitor connection and disconnection events while running. +- **FR-006**: When monitors are added or removed, system MUST adapt within 1 second so that rendering resources for absent monitors are released and rendering resources for newly-present monitors are created. This MUST happen in both normal display mode and screensaver mode (`/s`). +- **FR-007**: When the active GPU becomes unavailable (driver reset, removal, sleep/resume, or other device-loss event), system MUST detect the loss and re-initialize on an available adapter (falling back to the system default if the previously-chosen adapter is no longer present), without showing an error dialog and without exiting. +- **FR-008**: System MUST collapse a burst of topology-change notifications into a single re-initialization rather than re-initializing once per notification. + +#### GPU adapter selection + +- **FR-009**: System MUST provide a user-visible control in the configuration dialog to select which GPU is used for rendering, listing each available rendering-capable adapter by its real, human-readable name. +- **FR-010**: GPU selection MUST mark the system's default adapter by appending "(default)" to its name in the list. +- **FR-011**: GPU selection MUST exclude software adapters (e.g., Microsoft Basic Render Driver) from the list. +- **FR-012**: GPU selection MUST default to "system default adapter" for new installations. +- **FR-013**: The selected GPU MUST persist across application restarts identified by adapter description. +- **FR-014**: If the previously selected adapter is not present at startup, system MUST silently fall back to the system default adapter and continue starting. +- **FR-015**: Changing the GPU selection MUST take effect on the running application within 1 second, without restart. +- **FR-016**: All monitors rendered by the application MUST use the user's selected GPU; the selection is application-wide, not per-monitor. + +#### Frame-rate efficiency + +- **FR-017**: For any monitor whose native refresh rate exceeds 60Hz, system MUST limit rendering on that monitor to no more than 65 frames per second (target 60). +- **FR-018**: For any monitor whose native refresh rate is 60Hz or less, system MUST continue to render synchronized to vertical refresh as today, with no measurable per-frame overhead from the limiter. +- **FR-019**: The frame-rate limit applies independently per monitor; mixed-refresh-rate multi-monitor setups MUST each follow the rule above. + +#### Graphics quality presets and advanced controls + +- **FR-020**: System MUST provide a Quality preset control with at least the named options "Low", "Medium", and "High", plus a "Custom" option. "Custom" MAY be selected directly by the user, and MUST also be selected automatically by the system whenever the advanced controls' values do not match any named preset. +- **FR-021**: Selecting a named Quality preset MUST set all advanced graphics controls to that preset's predefined values within 1 second of the selection. +- **FR-022**: The "High" preset's values MUST produce the same visual result as the current (pre-feature) default rendering, so existing users who never touch the new controls see no change. +- **FR-023**: Adjusting any individual advanced graphics control MUST automatically switch the Quality preset to "Custom" and update an in-memory "last custom" snapshot of all advanced control values. +- **FR-024**: Selecting "Custom" from the preset control MUST restore the saved "last custom" values if any have been saved previously; otherwise the advanced controls MUST remain at their current values. +- **FR-025**: System MUST persist both the Quality preset name and (when the preset is "Custom" or when a custom set has been used at least once) the "last custom" advanced values across application restarts. + +#### Advanced graphics controls — visibility and individual controls + +- **FR-026**: System MUST provide an "advanced graphics settings" toggle control. When this toggle is off, the advanced graphics controls MUST NOT be visible and the configuration dialog MUST occupy only the space needed for the always-visible controls. On first load, the toggle defaults to off, except when the saved Quality preset is "Custom", in which case it defaults to on. +- **FR-027**: When the advanced toggle is turned on, the configuration dialog MUST dynamically grow to reveal the advanced controls; existing dialog controls below the advanced section (OK, Cancel, Reset) MUST reposition appropriately. Turning the toggle off MUST reverse this. +- **FR-028**: System MUST provide an advanced control for the number of glow passes (integer, 4 discrete positions from minimum to maximum) with labeled tick positions and a value indicator showing the current value. +- **FR-029**: System MUST provide an advanced control for the glow buffer resolution with 4 discrete positions labeled "Eighth", "Quarter", "Half", "Full" from minimum to maximum, defaulting to "Half". +- **FR-030**: System MUST provide an advanced control for the glow blur smoothness with 3 discrete positions labeled "Low", "Medium", "High" from minimum to maximum, defaulting to "High". +- **FR-031**: The existing Glow Intensity slider MUST own the on/off state of the glow effect: when its value is set to 0%, the glow effect MUST be fully disabled (no glow processing performed at all), and the slider's value indicator MUST read "0% (glow disabled)" instead of "0%". + +#### Configuration dialog — common conventions + +- **FR-031b**: The configuration dialog MUST behave such that all live previews of changes made within the dialog session are reverted if the user dismisses the dialog with Cancel; persisted settings MUST remain unchanged from their pre-dialog values in that case. This applies to every setting introduced by this feature (multi-monitor enabled, GPU adapter, Quality preset, advanced graphics control values, Show advanced graphics setting). + +- **FR-032**: All percentage sliders in the configuration dialog MUST display unlabeled tick marks. Tick frequency MUST be chosen so that a tick falls at the midpoint of each slider's range where the range allows an integer midpoint, with a target of approximately 21 ticks across the range. +- **FR-033**: All discrete-position sliders (the three advanced graphics controls) MUST display a label beneath each tick position indicating the value at that tick. +- **FR-034**: Every new quality-related control (Quality preset, advanced toggle, advanced controls, Glow Intensity, Glow Size) MUST have an information indicator (an "ⓘ" / lowercase 'i' in a circle) immediately associated with it. +- **FR-035**: Information indicators MUST be reachable both by mouse hover and by keyboard focus (Tab). Activating an indicator (mouse hover, or Space/Enter while focused) MUST display a tooltip containing the indicator's descriptive text. +- **FR-036**: Every information tip's text MUST consist of one or more descriptive sentences followed by exactly one of the standardized perf-impact sentences: "Significant GPU performance impact.", "Moderate GPU performance impact.", or "Small GPU performance impact.". + +#### First-run defaults + +- **FR-037**: On first run with no saved Quality preset, system MUST select a default preset based on the system's available GPU class and the total resolution of currently-connected monitors: + - Systems with a discrete GPU available: "High". + - Systems with only integrated GPUs and a modest total monitor pixel count: "Medium". + - Systems with only integrated GPUs and a heavy total monitor pixel count (e.g., multiple high-resolution monitors): "Low". +- **FR-038**: On first run with no saved GPU selection, system MUST default to the system default adapter (same behavior as omitting any selection). +- **FR-039**: On first run with no saved multi-monitor setting, system MUST default to multi-monitor spanning enabled. + +### Key Entities *(include if feature involves data)* + +- **Multi-monitor setting**: A single yes/no preference whether MatrixRain renders on all monitors or only the primary. Defaults to yes. +- **Selected GPU adapter**: A persistent identifier (human-readable adapter description) of the user's preferred rendering adapter, or empty meaning "use system default". Used to look up the actual adapter at startup and after device-loss recovery; falls back to system default if not found. +- **Quality preset**: The currently-selected named preset (one of "Low", "Medium", "High", "Custom"). Determines the values of the advanced graphics controls when set to a named preset. +- **Advanced graphics control values**: A group consisting of (passes, resolution, smoothness, glow intensity). Driven by the Quality preset's predefined values when a named preset is selected; freely editable when Custom is selected. +- **Last custom values**: A persisted snapshot of the most recent set of advanced control values the user actually customized. Restored when the user re-selects "Custom" after having used a named preset. Does not exist until the user has customized at least once. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: After disconnecting an external monitor on a hybrid laptop while MatrixRain is running on both, the application's GPU utilization within 1 second drops to a level comparable (within 10%) to launching MatrixRain fresh with only the remaining monitor connected. +- **SC-002**: After reconnecting a previously-disconnected monitor, MatrixRain begins rendering to that monitor within 1 second without requiring the application to be restarted. +- **SC-003**: A user on a hybrid laptop can change the selected GPU through the configuration dialog and observe (via the operating system's GPU view) MatrixRain switch to using the newly-selected GPU within 1 second, without restarting the application. +- **SC-004**: On a 144Hz monitor with default settings, MatrixRain renders at no more than 65 frames per second per monitor and reduces GPU work for that monitor by at least 50% relative to v1.3 baseline behavior on the same hardware. +- **SC-005**: On a Surface-class integrated-GPU laptop running MatrixRain with default settings on its built-in display, GPU utilization is no more than 70% of the v1.3 baseline for the same hardware and content. +- **SC-006**: Every information tip in the configuration dialog can be read both by hovering with a mouse and by tabbing to its indicator and pressing Space or Enter; both methods produce the same text. +- **SC-007**: When the previously-saved GPU adapter is not present at application start, the application starts successfully on the system default adapter without showing any error dialog. +- **SC-008**: Existing users who upgrade and never open the configuration dialog see no visible change in the rendered output of MatrixRain (the "High" quality preset is visually identical to the v1.3 default). +- **SC-009**: A user can switch between named quality presets in the configuration dialog and observe the visual change on all active monitors within 1 second of selection, without restarting the application. +- **SC-010**: After customizing the advanced graphics controls and selecting a different named preset, the user can return to "Custom" and find their previously-customized values restored exactly. + +## Assumptions + +- **Terminology**: this specification uses "multi-monitor spanning", "render on all monitors", and "multimon" interchangeably to mean the same behaviour: MatrixRain creating a render context per connected monitor. The persisted setting that controls this is referred to in implementation contracts as `MultiMonitor` (registry value name) and `m_multiMonitorEnabled` (in-memory field). + +- Target operating systems are Windows 10 version 1803 or later, and Windows 11. On these versions, the operating system's modern GPU-preference APIs are sufficient to route MatrixRain's rendering to the user's chosen adapter on NVIDIA Optimus, AMD PowerXpress, and Surface-class hybrid systems. Legacy driver-export mechanisms for forcing the discrete GPU are not employed in this version of the feature; they may be added later if real-world testing on a target hybrid configuration shows the OS-level APIs are insufficient. +- The DWM compositor runs on the integrated GPU on hybrid laptops, and presenting from the discrete GPU therefore incurs a cross-adapter copy at present time. This is expected, normal, and the discrete GPU's compute headroom is assumed to far exceed that copy cost. +- A user who selects the discrete GPU expects MatrixRain's process to be routed to it; the operating system's per-application GPU preference (if set by the user separately) is assumed to either match or be deliberately overridden. We do not modify the per-application OS preference automatically. +- Single-monitor users retain their current behavior exactly: no visible change, no new dialog footprint that meaningfully shifts the layout for the single-monitor case beyond the additional graphics-quality and information-tip controls. +- Users who have not opened the configuration dialog continue to see MatrixRain's current default visual quality with no perceptible regression (the "High" preset is calibrated to be visually equivalent to the v1.3 default). +- Adapter human-readable descriptions are stable enough across reboots and driver updates to be used as the persistent identifier for the user's GPU selection. A driver update that renames an adapter would be treated as the adapter being absent and would silently fall back to the default; this is acceptable. +- Monitor refresh rates queried from the operating system are correct and stable while a given monitor is connected; if the OS cannot report a refresh rate for a monitor, fallback to standard vertical-sync rendering is acceptable. +- The configuration dialog continues to use the operating system's standard Win32 dialog rendering and accessibility behaviors; no third-party UI framework is introduced. The information-tip indicator may be a small owner-drawn control to achieve the required appearance (a lowercase "i" in a circle) while remaining keyboard-focusable. +- The "heavy" pixel-count threshold used to choose between Medium and Low first-run presets on integrated GPUs is a tunable constant; its initial value (approximately 16 million pixels — e.g., two 4K monitors, or one 8K monitor) is acceptable and can be revisited based on real-world feedback. +- Existing test infrastructure (the project's 354-test unit test suite, pure-helper + in-memory provider pattern) is the appropriate place for behavioral tests of the new pure helpers introduced by this feature. The Win32 dialog, information-tip control, owner-draw rendering, real hot-plug behavior, and real-hardware GPU utilization measurements are validated by manual QA on the user's hybrid-laptop hardware. diff --git a/specs/006-multimon-gpu-efficiency/tasks.md b/specs/006-multimon-gpu-efficiency/tasks.md new file mode 100644 index 0000000..e435ae5 --- /dev/null +++ b/specs/006-multimon-gpu-efficiency/tasks.md @@ -0,0 +1,320 @@ +--- +description: "Task list for feature implementation" +--- + +# Tasks: Multi-Monitor User Control and GPU Efficiency + +**Input**: Design documents from `/specs/006-multimon-gpu-efficiency/` +**Prerequisites**: spec.md, plan.md, research.md, data-model.md, contracts/, quickstart.md (all present) +**Tests**: REQUIRED for all core-library code per constitution principle I (TDD is non-negotiable). UI/dialog code in `MatrixRain.exe` is exempt per the same principle's scope exemption. + +**Organization**: Tasks are grouped by user story to enable independent implementation, testing, and shipping of each story. + +## Format: `[ID] [P?] [Story?] Description` + +- **[P]**: Can run in parallel (different files, no incomplete dependencies) +- **[Story]**: User story this task belongs to (`US1`–`US5`) +- File paths are absolute within the repo root `C:\Users\relmer\source\repos\relmer\MatrixRain\`. + +## Path Conventions + +- Core static library: `MatrixRainCore\` +- Executable (Win32 entry + dialog UI): `MatrixRain\` +- Unit tests: `MatrixRainTests\unit\` +- Single Visual Studio solution: `MatrixRain.sln` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: One-time bootstrap shared across all user stories. + +- [X] T001 [P] Add `#include ` to `MatrixRainCore\pch.h` in the DirectX/DXGI block, alphabetically after the existing ``; rebuild the entire solution once to refresh PCH. +- [X] T002 [P] Reserve new dialog control IDs in `MatrixRain\resource.h` (no .rc changes yet): `IDC_MULTIMONITOR_CHECK`, `IDC_MULTIMONITOR_INFO`, `IDC_GPU_COMBO`, `IDC_GPU_INFO`, `IDC_QUALITY_PRESET_COMBO`, `IDC_QUALITY_PRESET_INFO`, `IDC_GRAPHICS_ADVANCED_CHECK`, `IDC_GRAPHICS_ADVANCED_INFO`, `IDC_GLOWPASSES_SLIDER`, `IDC_GLOWPASSES_LABEL`, `IDC_GLOWPASSES_INFO`, `IDC_GLOWRES_SLIDER`, `IDC_GLOWRES_LABEL`, `IDC_GLOWRES_INFO`, `IDC_GLOWSMOOTH_SLIDER`, `IDC_GLOWSMOOTH_LABEL`, `IDC_GLOWSMOOTH_INFO`, `IDC_GLOWINTENSITY_INFO`, `IDC_GLOWSIZE_INFO`. Bump `_APS_NEXT_CONTROL_VALUE` accordingly. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: None. Each user story is independently implementable with its own settings field; no shared blocking infrastructure exists beyond Phase 1 setup. Existing `RegistrySettingsProvider` already supports DWORD and REG_SZ patterns (the latter via `ColorScheme`), so no foundational provider work is required. + +> **Checkpoint**: Phase 1 complete → user story implementation can now begin in parallel across staffed developers. + +--- + +## Phase 3: User Story 1 — Adapt immediately when monitors are added or removed (Priority: P1) 🎯 MVP + +**Goal**: Fix the ghost-monitor render-thread defect by responding to monitor add/remove and GPU device-loss events at runtime, rebuilding the per-monitor render contexts within 1 second. + +**Independent Test**: With MatrixRain running spanning two monitors, disconnect one monitor; verify within 1 second that GPU utilization drops to a level within 10% of the "started in undocked state" baseline. Reconnect and verify MatrixRain resumes rendering on it without restart. Manually reset the active GPU's driver via Device Manager and verify automatic recovery. + +### Tests for User Story 1 + +- [X] T003 [P] [US1] Create `MatrixRainTests\unit\DeviceLostTests.cpp` with truth-table tests for `IsDeviceLost(HRESULT)`: returns `true` for `DXGI_ERROR_DEVICE_REMOVED`, `DXGI_ERROR_DEVICE_RESET`, `DXGI_ERROR_DEVICE_HUNG`, `DXGI_ERROR_DRIVER_INTERNAL_ERROR`, `D3DDDIERR_DEVICEREMOVED`; returns `false` for `S_OK`, `E_FAIL`, `DXGI_STATUS_OCCLUDED`, `E_INVALIDARG`. Verify tests fail (helper not yet implemented). Add file to `MatrixRainTests.vcxproj`. +- [X] T004 [P] [US1] Create `MatrixRainTests\unit\RebuildCoalescerTests.cpp` with tests for `RebuildCoalescer`: `RequestRebuild()` returns true on first call; subsequent calls return false until `Consume()`; after `Consume()`, next `RequestRebuild()` returns true again; concurrent calls from multiple threads coalesce to exactly one true return (use `std::thread` ×N and an atomic counter). Verify tests fail. Add to vcxproj. + +### Implementation for User Story 1 + +- [X] T005 [P] [US1] Create `MatrixRainCore\DeviceLost.h` and `MatrixRainCore\DeviceLost.cpp` exposing `bool IsDeviceLost(HRESULT hr)` per the contract in research R-003. Pure free function. Add both files to `MatrixRainCore.vcxproj`. T003 must now pass. +- [X] T006 [P] [US1] Create `MatrixRainCore\RebuildCoalescer.h` and `MatrixRainCore\RebuildCoalescer.cpp` with a class wrapping `std::atomic_flag`: `bool RequestRebuild()` returns true iff this is the first request since the last `Consume()`; `void Consume()` clears the flag. Add files to vcxproj. T004 must now pass. +- [X] T007 [US1] Modify `MatrixRainCore\RenderSystem.h` and `MatrixRainCore\RenderSystem.cpp`: change `void Present()` signature to `HRESULT Present()` (currently at `RenderSystem.cpp:1753-1758`); return the HRESULT from `m_swapChain->Present(1, 0)`. Update the single caller in `MonitorRenderContext.cpp:401` to capture the result into a local variable (still unused at this step). +- [X] T008 [US1] Modify `MatrixRainCore\MonitorRenderContext.cpp::RenderThreadProc` (`:327-402`): after the Present call at `:401`, if `IsDeviceLost(hr)` is true, log via existing console/EHM, set an `m_deviceLost` flag, `PostMessage(m_hwnd, WM_APP_REBUILD_CONTEXTS, 0, 0)`, then `break` out of the render loop. Add `m_deviceLost` (`std::atomic`) to `MonitorRenderContext.h` (initialized false; observed by `Application` during rebuild). Include `DeviceLost.h`. +- [X] T009 [US1] Modify `MatrixRainCore\Application.h` to add a private `RebuildCoalescer m_rebuildCoalescer;` member. Modify `MatrixRainCore\Application.cpp::HandleMessage` (`:1081…`) to add `case WM_DISPLAYCHANGE:` that calls `m_rebuildCoalescer.RequestRebuild()`; if it returns true, `PostMessage(m_mainHwnd, WM_APP_REBUILD_CONTEXTS, 0, 0)`; always return 0. +- [X] T010 [US1] Modify `MatrixRainCore\Application.cpp::HandleMessage` `case WM_APP_REBUILD_CONTEXTS` (`:1105`): call `m_rebuildCoalescer.Consume()` at the top of the handler so the next topology change can request again. No other behavior change required here — `RebuildContextsForCurrentMode` (`:883-940`) already does the teardown/re-enumerate/restart cycle. + +> **Checkpoint**: Build (`MSBuild MatrixRain.sln /p:Configuration=Debug /p:Platform=x64`, no `/m`). Run full test suite (`vstest.console MatrixRainTests.dll`). Manual QA: launch MatrixRain on a multi-monitor system, disconnect one monitor; observe within 1 second the secondary window closes and GPU utilization drops; reconnect and observe the secondary window reappears. With Device Manager, disable then re-enable the active GPU's driver; MatrixRain recovers without restart and without an error dialog. **Commit when green.** + +--- + +## Phase 4: User Story 2 — Choose whether MatrixRain spans all monitors (Priority: P1) + +**Goal**: Add a user-visible setting (default on) to enable/disable multi-monitor spanning; persist across restarts; take effect within 1 second when toggled. + +**Independent Test**: Open the configuration dialog on a multi-monitor system, toggle off the "render on all monitors" checkbox, dismiss the dialog; within 1 second only the primary monitor continues rendering. Restart; setting persisted. Toggle back on; all monitors resume within 1 second. + +### Tests for User Story 2 + +- [X] T011 [P] [US2] Create `MatrixRainTests\unit\MultiMonitorGateTests.cpp` with the full truth table for `ShouldSpanAllMonitors(bool enabled, DisplayMode displayMode, ScreenSaverMode saverMode)` per the contract in research R-001 (Preview/help mode forces single regardless of setting; otherwise honors `enabled`). Verify failing. +- [X] T012 [P] [US2] Add tests to `MatrixRainTests\unit\ScreenSaverSettingsTests.cpp` (or create if absent) for the new field `m_multiMonitorEnabled`: default `true` on fresh-construct; survives clamp/validation. Verify failing. +- [X] T013 [P] [US2] Extend `MatrixRainTests\unit\RegistrySettingsProviderTests.cpp` with round-trip tests for the `MultiMonitor` DWORD value: absent → default 1 (true); 0 → false; 1 → true; any other → clamp to true. Verify failing. + +### Implementation for User Story 2 + +- [X] T014 [P] [US2] Create `MatrixRainCore\MultiMonitorGate.h` and `MatrixRainCore\MultiMonitorGate.cpp` with pure free function `bool ShouldSpanAllMonitors(bool enabled, DisplayMode displayMode, ScreenSaverMode saverMode)`. Add to vcxproj. T011 must now pass. +- [X] T015 [US2] Modify `MatrixRainCore\ScreenSaverSettings.h` to add `bool m_multiMonitorEnabled = true;`. Update the load/validation block as needed (no clamping required for bool, but ensure registry-default branch sets it to true). T012 must now pass. +- [X] T016 [US2] Modify `MatrixRainCore\RegistrySettingsProvider.h` to add `static constexpr LPCWSTR VALUE_MULTIMONITOR = L"MultiMonitor";` and the corresponding read/write call in the Load/Save implementations in `.cpp` (use existing `ReadBool`/`WriteBool` helpers). T013 must now pass. +- [X] T017 [US2] Modify `MatrixRainCore\InMemorySettingsProvider.{h,cpp}` to add the same `m_multiMonitorEnabled` field with default `true` (test-seam parity). +- [X] T018 [US2] Modify `MatrixRainCore\Application.cpp::ShouldSpanAllMonitors()` (`:374-388`) to delegate to the pure `ShouldSpanAllMonitors(m_appState->GetSettings().m_multiMonitorEnabled, m_displayMode, m_screenSaverMode)` helper. Add `#include "MultiMonitorGate.h"` to `Application.cpp` (not the header — implementation detail). +- [X] T019 [US2] Modify `MatrixRainCore\ConfigDialogController.h` to declare `void UpdateMultiMonitorEnabled(bool enabled);`. Implement in `ConfigDialogController.cpp` mirroring `UpdateStartFullscreen` (`:135-138`) IN FULL: persist via `m_settingsProvider->Save()`, update in-memory `m_settings.m_multiMonitorEnabled`, AND register the new field in the controller's pre-dialog snapshot consumed by `CancelChanges`/`CancelLiveMode` (so dialog Cancel reverts the live preview per FR-031b). Return so the caller can POST the rebuild message. +- [X] T020 [US2] Modify `MatrixRain\MatrixRain.rc` to add the checkbox `CONTROL "Render on all monitors", IDC_MULTIMONITOR_CHECK, "Button", BS_AUTOCHECKBOX | WS_TABSTOP, …` and the adjacent `IDC_MULTIMONITOR_INFO` owner-draw button (per R-009 placeholder; owner-draw paint comes in US5). Grow the dialog template height as needed to make room. Verify the dialog still loads and lays out correctly. +- [X] T021 [US2] Modify `MatrixRain\ConfigDialog.cpp::OnInitDialog` (~`:259` block) to `CheckDlgButton(hDlg, IDC_MULTIMONITOR_CHECK, settings.m_multiMonitorEnabled ? BST_CHECKED : BST_UNCHECKED)`. Add a new `OnMultiMonitorCheck` handler that calls `pController->UpdateMultiMonitorEnabled(isChecked)` and `PostMessage(g_mainHwnd, WM_APP_REBUILD_CONTEXTS, 0, 0)` for the live rebuild. Wire it into `OnCommand` (`~:644`). + +> **Checkpoint**: Build + full tests + manual QA: open dialog on multimon system, uncheck the new checkbox; secondary monitors stop rendering within 1 second. Restart; setting persisted. Toggle back on; monitors resume. **Commit when green.** + +--- + +## Phase 5: User Story 3 — Pick which GPU MatrixRain uses on hybrid laptops (Priority: P2) + +**Goal**: Add a dropdown listing real adapter names (with "(default)" appended to the system default), persist by description string, exclude software adapters, fall back to default if the saved adapter is missing. + +**Independent Test**: On a hybrid laptop, open the dialog; verify the GPU list shows real adapter names with `" (default)"` on the default; software adapters absent. Select a non-default GPU; within 1 second Task Manager shows MatrixRain on the new GPU. Restart; persisted. Edit `HKCU\…\MatrixRain\GpuAdapter` to a fake name; restart; MatrixRain starts on default without error dialog. + +### Tests for User Story 3 + +- [X] T022 [P] [US3] Create `MatrixRainTests\unit\AdapterSelectionTests.cpp` with tests for `ResolveAdapter(adapters, savedDescription)` per the contract: empty saved → `nullopt`; non-matching saved → `nullopt`; matching by description → the matching `LUID`; multiple adapters with the same description (degenerate) → first match wins. Use `InMemoryAdapterProvider` seeds. Verify failing. +- [X] T023 [P] [US3] Add tests in `AdapterSelectionTests.cpp` for `FormatAdapterLabel(adapter)`: `m_isDefault == true` → `m_description + L" (default)"`; `m_isDefault == false` → `m_description` unchanged; empty description handled gracefully (returns `L" (default)"` if default, else empty — exact behavior documented and tested). +- [X] T024 [P] [US3] Extend `MatrixRainTests\unit\RegistrySettingsProviderTests.cpp` with round-trip tests for the `GpuAdapter` REG_SZ value: absent → default `L""`; `L"NVIDIA Whatever"` → preserved exactly; long descriptions (≥128 chars) preserved. Verify failing. + +### Implementation for User Story 3 + +- [X] T025 [P] [US3] Create `MatrixRainCore\IAdapterProvider.h` with the `struct AdapterInfo` and abstract `class IAdapterProvider` per `contracts/adapter-provider.md`. Add to vcxproj. +- [X] T026 [P] [US3] Create `MatrixRainCore\InMemoryAdapterProvider.h` and `.cpp`: constructor takes `std::vector` (stored by value); `EnumerateAdapters()` returns a copy. Add to vcxproj. +- [X] T027 [P] [US3] Create `MatrixRainCore\AdapterSelection.h` and `.cpp` with pure `std::optional ResolveAdapter(const std::vector&, const std::wstring&)` and `std::wstring FormatAdapterLabel(const AdapterInfo&)`. Add to vcxproj. T022, T023 must now pass. +- [X] T028 [US3] Create `MatrixRainCore\WindowsAdapterProvider.h` and `.cpp`. Use `CreateDXGIFactory1` for enumeration via `IDXGIFactory1::EnumAdapters1` + `GetDesc1`; obtain `IDXGIFactory6` via `QueryInterface` and call `EnumAdapterByGpuPreference(0, DXGI_GPU_PREFERENCE_UNSPECIFIED, IID_PPV_ARGS(&defaultAdapter))` to identify the system default LUID. Skip adapters with `(desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) != 0`. Use ComPtr for COM lifetimes; use EHM (`CHRA` for external APIs). Add to vcxproj. +- [X] T029 [US3] Modify `MatrixRainCore\ScreenSaverSettings.h` to add `std::wstring m_gpuAdapter;` (default `L""`). +- [X] T030 [US3] Modify `MatrixRainCore\RegistrySettingsProvider.{h,cpp}` to add `VALUE_GPU_ADAPTER = L"GpuAdapter"` and load/save via existing `ReadString`/`WriteString` helpers (already used by `ColorScheme`). T024 must now pass. +- [X] T031 [US3] Modify `MatrixRainCore\InMemorySettingsProvider.{h,cpp}` to add `m_gpuAdapter` parity. +- [X] T032 [US3] Modify `MatrixRainCore\RenderSystem.h` to change `HRESULT Initialize(HWND hwnd, int width, int height)` signature to `HRESULT Initialize(HWND hwnd, int width, int height, std::optional adapterLuid)`. Modify `RenderSystem.cpp::Initialize` (`:146-180`): if `adapterLuid.has_value()`, obtain `IDXGIFactory4`+ via `CreateDXGIFactory1`/`QueryInterface`, call `EnumAdapterByLuid(*adapterLuid, IID_PPV_ARGS(&adapter))`; on success call `D3D11CreateDevice(adapter.Get(), D3D_DRIVER_TYPE_UNKNOWN, …)`; on lookup failure log + fall back to `D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, …)`. Default-adapter path (no LUID) preserves existing behavior. +- [X] T033 [US3] Modify `MatrixRainCore\MonitorRenderContext.h` and `.cpp` to add `std::optional` parameter to `Initialize`; forward to `RenderSystem::Initialize`. +- [X] T034 [US3] Modify `MatrixRainCore\Application.cpp`: in `Initialize` (or a new `ResolveAdapterOnce()` helper called from there), construct a `WindowsAdapterProvider`, call `EnumerateAdapters()`, call `ResolveAdapter(adapters, settings.m_gpuAdapter)`, cache the optional `LUID` as `m_resolvedAdapter`. Pass `m_resolvedAdapter` to every `MonitorRenderContext::Initialize` call. In `RebuildContextsForCurrentMode` (`:883-940`), re-resolve the adapter before re-initializing contexts (so a device-loss recovery after a GPU removal picks up the fallback path automatically). +- [X] T035 [US3] Modify `MatrixRainCore\ConfigDialogController.{h,cpp}` to add `void UpdateGpuAdapter(const std::wstring& description)` mirroring `UpdateColorScheme` (`:62-78`) IN FULL: persist, update in-memory state, AND register the new field in the controller's pre-dialog snapshot consumed by `CancelChanges`/`CancelLiveMode` so dialog Cancel reverts the live preview per FR-031b. +- [X] T036 [US3] Modify `MatrixRain\MatrixRain.rc` to add `LTEXT "GPU:"`, `COMBOBOX IDC_GPU_COMBO …, CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP`, and `IDC_GPU_INFO` owner-draw button. Grow dialog height to fit. +- [X] T037 [US3] Modify `MatrixRain\ConfigDialog.cpp`: in `OnInitDialog`, construct a `WindowsAdapterProvider`, enumerate, populate `IDC_GPU_COMBO` via `CB_ADDSTRING` using `FormatAdapterLabel`. Track the underlying descriptions in a `std::vector` indexed by combo position so `OnGpuChange` (a new handler mirroring `OnColorSchemeChange` at `:345-363`) can call `pController->UpdateGpuAdapter(descriptions[CB_GETCURSEL])` and `PostMessage(g_mainHwnd, WM_APP_REBUILD_CONTEXTS, 0, 0)`. + +> **Checkpoint**: Build + full tests + manual QA on a hybrid laptop: dropdown shows real names with "(default)"; pick non-default; restart; persisted; runs on the picked GPU per Task Manager. Edit registry to fake name; restart; falls back silently. **Commit when green.** + +--- + +## Phase 6: User Story 4 — Cap frame rate on high-refresh monitors (Priority: P2) + +**Goal**: On any monitor with native refresh > 60 Hz, limit MatrixRain rendering to ~60 FPS; on ≤60 Hz monitors, leave existing vsync behavior untouched with zero added overhead. + +**Independent Test**: Enable debug statistics. On a >60 Hz monitor, observe FPS ~60. On a 60 Hz monitor, FPS ~60 (unchanged). Mixed-refresh multimon: each monitor independently shows ~60. Compare GPU utilization on the high-refresh path before/after — expect ≥50% reduction. + +### Tests for User Story 4 + +- [X] T038 [P] [US4] Create `MatrixRainTests\unit\FrameLimiterTests.cpp`: `ShouldEngageFrameLimiter(refreshHz)` truth table for `0, 30, 59, 60, 61, 75, 120, 144, 240` (only `> 60` returns true); `FrameLimiter::TargetFps(60)` produces a `WaitForNextFrame` that returns immediately on the first call; the second call returns approximately 16.6ms (±2ms) after the first. Use `std::chrono::steady_clock` measurements with reasonable tolerance for CI flakiness. Verify failing. + +### Implementation for User Story 4 + +- [X] T039 [P] [US4] Create `MatrixRainCore\FrameLimiter.h` and `.cpp` with: pure free function `bool ShouldEngageFrameLimiter(unsigned monitorRefreshHz)` and class `FrameLimiter { void TargetFps(unsigned); void WaitForNextFrame(); }` using `std::chrono::steady_clock` for the last-frame timestamp. Add to vcxproj. T038 must now pass. +- [X] T040 [US4] Modify `MatrixRainCore\MonitorRenderContext.h` to add `std::optional m_frameLimiter;` member. Modify `MonitorRenderContext::Initialize` (`.cpp`) to accept the monitor's `refreshHz` (from `DEVMODE::dmDisplayFrequency` or existing `MonitorInfo`); if `ShouldEngageFrameLimiter(refreshHz)`, construct `m_frameLimiter` with `TargetFps(60)`; else leave `nullopt`. Plumb the refresh rate through `Application.cpp` when constructing each `MonitorRenderContext`. +- [X] T041 [US4] Modify `MatrixRainCore\MonitorRenderContext.cpp::RenderThreadProc` (`:327-402`): at the top of each loop iteration (after the `m_inTransition` skip at `:349-353` and before the `lock_guard` at `:356`), call `if (m_frameLimiter) m_frameLimiter->WaitForNextFrame();`. This sleeps only when the limiter is engaged; on ≤60 Hz monitors the call is skipped entirely, preserving zero overhead. + +> **Checkpoint**: Build + full tests + manual QA: on >60 Hz monitor, debug-stats FPS reads ~60; GPU usage on the high-refresh path measurably lower than baseline. On a 60 Hz monitor, FPS reads ~60 (existing). **Commit when green.** + +--- + +## Phase 7: User Story 5 — Graphics quality presets and advanced controls (Priority: P3) + +**Goal**: Add a Quality preset combobox (Low/Medium/High/Custom) with an advanced disclosure that reveals three discrete sliders (Passes / Resolution / Smoothness) and information tips on each quality control. Glow Intensity owns true on/off via the existing slider. Custom-snap behavior, dynamic dialog resize, first-run heuristic, and tooltip accessibility all per the locked design. + +**Independent Test**: Verify all 10 acceptance scenarios in spec User Story 5: preset combo entries; advanced controls hidden by default; toggle reveals advanced + grows dialog; moving advanced flips to Custom; selecting named preset snaps advanced; switching to Custom restores LastCustom or stays put; Glow Intensity 0 → label `"0% (glow disabled)"` + true bypass; hover on any ⓘ shows tooltip; keyboard Tab + Space on any ⓘ shows same tooltip; first-run heuristic picks correct default by GPU class. + +### Tests for User Story 5 + +- [X] T042 [P] [US5] Create `MatrixRainTests\unit\QualityPresetsTests.cpp` exhaustively covering: + - `LookupPresetValues(QualityPreset::Low/Medium/High)` returns exactly the rows from `contracts/quality-preset-mapping.md`. + - `DetectActivePreset(values)` returns the named preset whose row exactly matches `values`, else `QualityPreset::Custom`. Test each named row plus several off-table combinations. + - `ApplyPresetSnap(preset, current, lastCustom)`: named preset → returns lookup; `Custom` + `lastCustom.has_value()` → returns `*lastCustom`; `Custom` + `!lastCustom.has_value()` → returns `current` unchanged. + - `PickDefaultQualityPreset(adapters, totalPixels)`: discrete adapter (vram ≥ 256 MB, not software) → `High`; integrated-only + totalPixels ≤ 16M → `Medium`; integrated-only + totalPixels > 16M → `Low`. Use `InMemoryAdapterProvider` seeds. + - Verify the constants `kDiscreteVramThresholdMb` and `kHeavyTotalPixelsThreshold` are at their documented values via public-test accessors (or extern constexpr declarations in the header for test visibility). + - Verify failing. +- [X] T043 [P] [US5] Extend `MatrixRainTests\unit\RegistrySettingsProviderTests.cpp` with round-trip tests for: `QualityPreset` REG_SZ accepting `"Low"`/`"Medium"`/`"High"`/`"Custom"`/`""`; all four `LastCustom_*` DWORDs read as a unit (any missing → `LastCustomGraphicsValues = nullopt`); `ShowAdvancedGraphics` DWORD default 0. Verify failing. +- [X] T044 [P] [US5] Extend `MatrixRainTests\unit\ConfigDialogControllerTests.cpp` (or create) with tests for the new controller methods: `UpdateQualityPreset(Low)` snaps advanced values; `UpdateAdvancedGraphicsValues(custom)` drifts preset to Custom and persists `LastCustom_*`; round-trip persists across `Load`. Verify failing. + +### Implementation for User Story 5 + +- [X] T045 [P] [US5] Create `MatrixRainCore\QualityPresets.h` and `MatrixRainCore\QualityPresets.cpp` with: `enum class QualityPreset`; `enum class ResolutionDivisor`; `enum class BlurTaps`; `struct AdvancedGraphicsValues`; `static constexpr` heuristic constants; pure helpers `LookupPresetValues`, `DetectActivePreset`, `ApplyPresetSnap`, `PickDefaultQualityPreset`. Add to vcxproj. T042 must now pass. +- [X] T046 [US5] Modify `MatrixRainCore\ScreenSaverSettings.h` to add `QualityPreset m_qualityPreset = QualityPreset::High;`, `AdvancedGraphicsValues m_advancedValues` (initialized to `LookupPresetValues(QualityPreset::High)`), `std::optional m_lastCustom;`, `bool m_showAdvancedGraphics = false;`. Validate/clamp on load (out-of-range integers clamp; invalid enum strings → default). Include `QualityPresets.h`. +- [X] T047 [US5] Modify `MatrixRainCore\RegistrySettingsProvider.{h,cpp}` to add all new value-name constants and load/save: + - `VALUE_QUALITY_PRESET` REG_SZ + - `VALUE_LASTCUSTOM_GLOW_INTENSITY`, `VALUE_LASTCUSTOM_PASSES`, `VALUE_LASTCUSTOM_RESOLUTION`, `VALUE_LASTCUSTOM_SMOOTHNESS` (DWORD; all-or-nothing read — if any absent, do not populate `m_lastCustom`) + - `VALUE_SHOW_ADVANCED_GRAPHICS` DWORD + T043 must now pass. +- [X] T048 [US5] Modify `MatrixRainCore\InMemorySettingsProvider.{h,cpp}` for field parity. +- [X] T049 [US5] Modify `MatrixRainCore\RenderSystem.h` and `RenderSystem.cpp` to parametrize bloom: + - Add member fields `int m_blurPasses = 3`, `ResolutionDivisor m_bloomResolutionDivisor = Half`, `BlurTaps m_blurTaps = High` with setters used by the existing snapshot path. + - Replace literal `3` in the loop at `:1372` with `m_blurPasses`. + - Replace `width / 2` / `height / 2` in `CreateBloomResources` (`:1175-1176`) and the bloom viewport at `:1337` with division by `static_cast(m_bloomResolutionDivisor)` (the enum's integer values are the divisors). + - Compile and store three blur-shader variants (5-tap, 9-tap, 13-tap) at startup alongside the existing extract/composite shaders (`:1089-1100` area); select the active variant by `m_blurTaps` in `ApplyBloom`. + - When bloom-buffer dimensions change (because the resolution divisor changed), recreate `m_bloomTexture`, `m_bloomRTV`, `m_bloomSRV`, `m_blurTempTexture`, `m_blurTempRTV`, `m_blurTempSRV` — use the existing resize teardown path. +- [X] T050 [US5] Modify `MatrixRainCore\RenderSystem.cpp`: at the top of the existing post-process branch (around `:1726`), if `m_glowIntensityPercent == 0` take the direct-to-backbuffer fallback (`:1731`-style path) and skip `ApplyBloom` entirely. Verify that the existing fallback path correctly renders the scene without the post-process pipeline. +- [X] T051 [US5] Modify `MatrixRainCore\Application.cpp::Initialize` to call `PickDefaultQualityPreset(adapters, totalMonitorPixels)` when `settings.m_qualityPreset` is the "not yet set" sentinel (loaded from an empty `QualityPreset` REG_SZ) AND `settings.m_lastCustom == nullopt`. Persist the chosen preset immediately via the controller/settings-save path so subsequent runs skip the heuristic. +- [X] T052 [US5] Modify `MatrixRainCore\ConfigDialogController.{h,cpp}` to add: + - `void UpdateQualityPreset(QualityPreset)` — calls `ApplyPresetSnap`, writes back `m_settings.m_advancedValues`, persists. + - `void UpdateAdvancedGraphicsValues(const AdvancedGraphicsValues&)` — writes new values; updates `m_lastCustom` (always); recomputes `m_qualityPreset = DetectActivePreset(values)`; persists. + - `void UpdateShowAdvancedGraphics(bool)` — persists. + ALL three new setters MUST also register their corresponding fields in the controller's pre-dialog snapshot consumed by `CancelChanges`/`CancelLiveMode` so dialog Cancel reverts every live preview per FR-031b (including the cascade where moving an advanced slider auto-flips the preset to Custom — that flip must also revert). + T044 must now pass. +- [X] T053 [US5] Modify `MatrixRain\MatrixRain.rc` to: + - Add a `GROUPBOX "Graphics quality"` containing `IDC_QUALITY_PRESET_COMBO`, `IDC_QUALITY_PRESET_INFO`, `IDC_GRAPHICS_ADVANCED_CHECK`, `IDC_GRAPHICS_ADVANCED_INFO`, and the three advanced sliders with their value labels and info buttons. + - Add `IDC_GLOWPASSES_SLIDER` (range 1..4) with `IDC_GLOWPASSES_LABEL` to the right, `IDC_GLOWPASSES_INFO` further right, and four `LTEXT "1" "2" "3" "4"` aligned beneath the tick positions. + - Add `IDC_GLOWRES_SLIDER` (range 0..3) with `IDC_GLOWRES_LABEL` and `IDC_GLOWRES_INFO`, plus `LTEXT "Eighth" "Quarter" "Half" "Full"` beneath ticks. + - Add `IDC_GLOWSMOOTH_SLIDER` (range 0..2) with `IDC_GLOWSMOOTH_LABEL` and `IDC_GLOWSMOOTH_INFO`, plus `LTEXT "Low" "Medium" "High"` beneath ticks. + - Add `IDC_GLOWINTENSITY_INFO` next to the existing Glow Intensity slider; `IDC_GLOWSIZE_INFO` next to Glow Size. + - Lay out the dialog at its EXPANDED size (all advanced controls visible). The `OnInitDialog` code will collapse them if the saved `ShowAdvancedGraphics` is false. +- [X] T054 [US5] Modify `MatrixRain\ConfigDialog.cpp::InitializeSlider` (`:76-85`): + - Send `TBM_SETTICFREQ` per the locked per-slider table (`Density`: 5; `AnimSpeed`: 5; `GlowIntensity`: 10; `GlowSize`: 5; new discrete sliders: 1). + - For `IDC_ANIMSPEED_SLIDER`, additionally send `TBM_SETTIC, 0, 100` to add the 21st tick at 100. + - Special-case label text: `IDC_GLOWINTENSITY_SLIDER` at value 0 → `"0% (glow disabled)"`; the three discrete sliders use mapped strings (`"1".."4"`, `"Eighth"`/`"Quarter"`/`"Half"`/`"Full"`, `"Low"`/`"Medium"`/`"High"`). + - Extend the WM_HSCROLL handler (`:294-330`) to use the same mapping when updating value labels live. +- [X] T055 [US5] Modify `MatrixRain\ConfigDialog.cpp::OnInitDialog`: + - Populate `IDC_QUALITY_PRESET_COMBO` with `"Low"`, `"Medium"`, `"High"`, `"Custom"` via `CB_ADDSTRING`; set selection from `settings.m_qualityPreset`. + - Set initial state of `IDC_GRAPHICS_ADVANCED_CHECK` from `settings.m_showAdvancedGraphics`. + - Record the rects of all advanced controls (`GetWindowRect` + `MapWindowPoints` → client coords). Compute `m_advancedBlockHeight`. + - If `!settings.m_showAdvancedGraphics`: `ShowWindow(SW_HIDE)` each advanced control; `SetWindowPos` the dialog to shrink by `m_advancedBlockHeight`; `MoveWindow` the OK/Cancel/Reset buttons up by the same delta. Implement an inverse transform handler on `IDC_GRAPHICS_ADVANCED_CHECK` `BN_CLICKED` that toggles between collapsed and expanded. +- [X] T056 [US5] Modify `MatrixRain\ConfigDialog.cpp`: implement WM_HSCROLL handlers for `IDC_GLOWPASSES_SLIDER`, `IDC_GLOWRES_SLIDER`, `IDC_GLOWSMOOTH_SLIDER`. Each reads the new value, constructs an `AdvancedGraphicsValues` from all three sliders + glow intensity, calls `pController->UpdateAdvancedGraphicsValues(values)`, then updates `IDC_QUALITY_PRESET_COMBO` selection from `DetectActivePreset(values)` (typically flipping to Custom). +- [X] T057 [US5] Modify `MatrixRain\ConfigDialog.cpp`: implement `CBN_SELCHANGE` handler for `IDC_QUALITY_PRESET_COMBO`. Translate selection → `QualityPreset`. Call `pController->UpdateQualityPreset(preset)`; the controller updates `m_advancedValues` (via `ApplyPresetSnap`). The dialog then reflects the new values back into the three advanced sliders via `TBM_SETPOS` and re-runs `InitializeSlider`-style label updates. +- [X] T058 [US5] Modify `MatrixRain\ConfigDialog.cpp::OnInitDialog`: create a shared `WC_TOOLTIP` window (`CreateWindowEx(..., TOOLTIPS_CLASS, ...)`). For each `IDC_*_INFO` control, register a tool with `TTM_ADDTOOL` using flags `TTF_IDISHWND | TTF_SUBCLASS`, `lpszText = LPSTR_TEXTCALLBACK`. Implement `TTN_GETDISPINFO` handler that switches on `((LPNMHDR)lParam)->idFrom` (the hwnd) to return the matching infotip text per the locked strings in `plan.md`/research R-009 (each ending with `"Significant GPU performance impact."`, `"Moderate GPU performance impact."`, or `"Small GPU performance impact."`). +- [X] T059 [US5] Modify `MatrixRain\ConfigDialog.cpp`: implement `WM_DRAWITEM` for each `IDC_*_INFO` button — draw a 1-pixel circle outline within the button rect, then `DrawText`/`TextOut` a centered lowercase "i" using the dialog's font. Implement `BN_CLICKED` keyboard activation: on click (Space/Enter on focused button), look up the matching `TTF_TRACK` tool registration, position the tip near the button (`TTM_TRACKPOSITION`), and `TTM_TRACKACTIVATE TRUE`. Set a `SetTimer` for 5 seconds (or hook `WM_KILLFOCUS` / `WM_KEYDOWN VK_ESCAPE`) to `TTM_TRACKACTIVATE FALSE`. + +> **Checkpoint**: Build + full tests + manual QA per quickstart US5: all 12 walkthrough steps pass. **Commit when green.** + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +- [ ] T060 [P] Run end-to-end quickstart validation (build → test → manual QA per `specs\006-multimon-gpu-efficiency\quickstart.md` Section 4 for all 5 user stories on a single development workstation). +- [ ] T061 Manual QA on a real hybrid laptop (Surface Book 3 / Surface Laptop Studio 2 / Optimus laptop) covering: undock/redock GPU%, GPU dropdown switch + Task Manager verification, multimon-off GPU%, high-refresh display 60 FPS confirmation, pre/post v1.3-baseline GPU% comparison per SC-001/SC-003/SC-004/SC-005, AND a suspend/resume cycle (close lid or `Start → Sleep`, wait ≥10s, resume) verifying MatrixRain continues running and that the device-loss recovery path tolerates the resulting `DXGI_ERROR_DEVICE_REMOVED`. Capture before/after GPU% screenshots and append to `specs\006-multimon-gpu-efficiency\quickstart.md` or a sibling QA notes file. +- [X] T062 [P] Update `CHANGELOG.md` with the v1.4 release section summarizing the five user-visible improvements and citing this feature spec. +- [X] T063 Update `specs\006-multimon-gpu-efficiency\spec.md` "Status" field from `Draft` to `Implemented`; mark the requirements checklist as fully complete. + +--- + +## Dependencies & Execution Order + +### Phase dependencies + +- **Phase 1 (Setup)**: no dependencies; can start immediately. T001 and T002 are independent and parallelizable. +- **Phase 2 (Foundational)**: empty by design (see rationale at top of Phase 2). +- **Phase 3 (US1, P1, MVP)**: after Phase 1; independent of all other user stories. +- **Phase 4 (US2, P1)**: after Phase 1; independent of US1 and others. (Touches different files than US1 except both edit `Application.cpp`; sequence T021 after T009/T010 if a developer works both stories serially. Otherwise merge resolution is trivial — different functions in the same file.) +- **Phase 5 (US3, P2)**: after Phase 1 (needs T001 for ``); independent of US1/US2/US4/US5. Touches `RenderSystem` and `MonitorRenderContext` signatures. +- **Phase 6 (US4, P2)**: after Phase 1; touches `MonitorRenderContext` — sequence after US3 (T032/T033) when a single developer is doing both (same files), or accept a small merge if parallel. +- **Phase 7 (US5, P3)**: after Phase 1; the largest phase. Touches `RenderSystem` parametric bloom (so sequence after any US3 changes to `RenderSystem.cpp` signature for a clean merge). +- **Phase 8 (Polish)**: after all desired user stories are complete. + +### Within each user story + +- Tests (constitution-mandated for core code) MUST be written and fail before their corresponding implementation task. +- Pure helpers ([P] tasks within a story) can land in parallel. +- Helpers before consumers (e.g., T005/T006 before T008/T009 in US1; T014 before T018 in US2; T027 before T034 in US3; T039 before T041 in US4; T045 before T051/T052 in US5). +- Within US5, the rendering parametrization (T049, T050) can land before any UI changes (T053+) and gives an early visual check by manually editing settings via the registry. + +### Parallel opportunities + +- **Across stories**: Once Phase 1 lands, all five user-story phases can be worked in parallel by separate developers (US1 has the lightest file footprint; US5 is the heaviest). Recommended order if solo: **US1 → US2 → US4 → US3 → US5** (US4 is small and unlocks a meaningful GPU win quickly; US3 sets up the adapter plumbing that US5's first-run heuristic consumes). +- **Within Phase 1**: T001 ∥ T002. +- **Within each user story's test layer**: every test task is `[P]` (different test files). +- **Within each user story's helper layer**: every helper-file task is `[P]` (different source files); consumers that modify shared files (`RenderSystem`, `MonitorRenderContext`, `Application`, `ConfigDialog`, `ScreenSaverSettings`, `RegistrySettingsProvider`) are NOT parallelizable within a story but ARE parallelizable across stories if developers coordinate or accept light merges. + +--- + +## Parallel Example: User Story 1 + +```bash +# Phase 3 — tests, both run in parallel: +Task: "T003 [P] [US1] Create MatrixRainTests\unit\DeviceLostTests.cpp …" +Task: "T004 [P] [US1] Create MatrixRainTests\unit\RebuildCoalescerTests.cpp …" + +# Verify both test files fail to build / fail to pass (helpers don't exist yet). + +# Phase 3 — helpers, both run in parallel: +Task: "T005 [P] [US1] Create MatrixRainCore\DeviceLost.{h,cpp} …" +Task: "T006 [P] [US1] Create MatrixRainCore\RebuildCoalescer.{h,cpp} …" + +# Verify both test files now pass. + +# Phase 3 — sequential consumers (each touches a different existing file): +Task: "T007 [US1] Modify MatrixRainCore\RenderSystem.{h,cpp} …" +Task: "T008 [US1] Modify MatrixRainCore\MonitorRenderContext.cpp …" +Task: "T009 [US1] Modify MatrixRainCore\Application.{h,cpp} …" +Task: "T010 [US1] Modify MatrixRainCore\Application.cpp …" +``` + +--- + +## Implementation Strategy + +### MVP first (User Story 1) + +1. Complete Phase 1: Setup (T001-T002). +2. Skip Phase 2 (intentionally empty). +3. Complete Phase 3: US1 (T003-T010). +4. **STOP and VALIDATE**: undock/redock manual QA + Device Manager GPU-disable test. +5. Deploy / demo. **This single story is the v1.4 defect-fix release if needed.** + +### Incremental delivery + +1. Phase 1 → Foundation ready. +2. + Phase 3 (US1) → MVP defect fix; demo. +3. + Phase 4 (US2) → Multimon opt-out; demo. +4. + Phase 6 (US4) → Frame cap; demo (quietly bigger win than US2). +5. + Phase 5 (US3) → GPU dropdown; demo. +6. + Phase 7 (US5) → Quality presets and advanced UI; demo. +7. + Phase 8 (Polish) → Release. + +### Parallel team strategy + +With three developers after Phase 1 lands: +- Developer A: US1 (Phase 3) → US3 (Phase 5) +- Developer B: US2 (Phase 4) → US4 (Phase 6) +- Developer C: US5 (Phase 7) + +Merge conflicts will be concentrated on `RenderSystem.{h,cpp}` (US3 + US5), `MonitorRenderContext.{h,cpp}` (US3 + US4), and `Application.cpp` (all five). Sequence the merges so the signature changes (US3) land first. + +--- + +## Notes + +- Constitution principle I (TDD non-negotiable for core library): every core-library task in this file is preceded by a `[test]`-equivalent test task. UI/dialog work in `MatrixRain.exe` is exempt per the scope-exemption clause. +- Constitution principle IX (commit discipline): **one task = one commit**. Each commit must build clean and pass the full test suite (`354 baseline + new tests`). Do not bundle tasks. +- Constitution principle VIII (formatting): all new code follows the project's 5-blank-lines-between-top-level-constructs, column-aligned-with-separate-pointer-column conventions. `git diff` each task before committing. +- Constitution principle VII (PCH): the only new system header is `` in T001; no other `#include <…>` directives are added to non-PCH files. +- Conventional Commits + `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>` trailer on every commit. +- `Version.h` is auto-bumped pre-build; exclude from `git add`. +- `MSBuild` invocation MUST NOT use `/m` (transient PCH `C3859`/`C1076` failures). +- Each "Checkpoint" line ends a deployable increment. + +