Skip to content

Customizable Dual Hanging Dice - #5011

Open
alivca wants to merge 14 commits into
ramensoftware:mainfrom
alivca:main
Open

Customizable Dual Hanging Dice#5011
alivca wants to merge 14 commits into
ramensoftware:mainfrom
alivca:main

Conversation

@alivca

@alivca alivca commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Customizable Dual Hanging Dice

cubic

Add interactive hanging dice to your desktop with dynamic physics. They swing and react in real-time when you move, minimize, maximize, or restore windows, and automatically hide during fullscreen games or apps to stay out of your way.

Changelog

  • Forced HWND_TOPMOST z-order positioning to ensure the overlay stays on top of all windows during active state updates.

  • Enhanced fullscreen detection logic to properly re-apply topmost window placement upon restoring visibility.

Mod authorship

If this pull request introduces a new mod, please complete the section below.

This mod was created by:

    • The submitter, without AI assistance
    • The submitter, with AI assistance
    • Claude
    • ChatGPT
    • Gemini
    • Another AI (please specify):
    • Other (please specify):

@windhawk-reviewer windhawk-reviewer Bot added the waiting-for-author The author's turn: request an AI review, or respond to one that was posted. label Aug 4, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Thanks for the pull request! This repository uses a two-stage review: an AI review that you run yourself, followed by a human review.

To get started, comment /ai-review. Once you're happy with the result, comment /ready-for-reviewer to hand it over to a human reviewer.

See the pull request review process for the full details.

@alivca

alivca commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 4, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


Nice idea and the physics look fun, but the mod is currently hosted in the wrong process and has a few stability/performance problems that need fixing first.

1. This should be a tool mod (@include windhawk.exe), not an explorer.exe injection

The mod installs no function hooks at all — it only creates its own layered window and calls SetWinEventHook with WINEVENT_OUTOFPROCESS, which works from any process. Nothing here needs to run inside Explorer's address space. Two concrete consequences of injecting anyway:

  • Duplicate dice. Windhawk injects into every explorer.exe process. With "Launch folder windows in a separate process" enabled (or any additional explorer.exe instance), each one runs StartThread, registers the class and creates its own overlay — you get two or more sets of dice with independent physics.
  • Blast radius. A crash, hang or GDI+ leak in a 60 fps physics/render loop takes down the shell. As a tool mod it can only take down its own windhawk.exe helper process.

The fix is mechanical: switch to @include windhawk.exe, rename Wh_ModInit/Wh_ModSettingsChanged/Wh_ModUninitWhTool_ModInit/WhTool_ModSettingsChanged/WhTool_ModUninit, and paste the launcher boilerplate from the wiki verbatim (don't refactor it): https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process

Your own mac-magnifying-cursor already does exactly this, and so do the closest comparable overlay mods — neko-cat and one-hair.

2. Wh_ModUninit gives up on the worker thread after 1 second

WaitForSingleObject(g_hThread, 1000);
CloseHandle(g_hThread);

If the wait times out, Wh_ModUninit returns anyway and Windhawk unloads the mod DLL while StartThread/WndProc/WinEventProc are still executing from the unmapped image — a crash in the host process. It also means UnregisterClass (the last statement of StartThread) never runs, leaving DiceCustomizableOverlay registered with an lpfnWndProc pointing into freed memory; the next time the mod is enabled, RegisterClass fails and any message dispatched to the stale class jumps into unmapped code.

Nothing in the shutdown path can block indefinitely here, so just wait properly, the way mac-magnifying-cursor does:

WaitForSingleObject(g_hThread, INFINITE);

Related: g_running is a plain bool written from Wh_ModUninit and read from the worker thread's loop condition — make it std::atomic<bool>.

3. The system-wide EVENT_OBJECT_LOCATIONCHANGE hook is very expensive

g_hEventHookPos = SetWinEventHook(
    EVENT_OBJECT_LOCATIONCHANGE, EVENT_OBJECT_LOCATIONCHANGE,
    NULL, WinEventProc, 0, 0, WINEVENT_OUTOFPROCESS);

EVENT_OBJECT_LOCATIONCHANGE with idProcess = idThread = 0 fires for every object that moves in every process on the desktop — window drags, animations, carets, and the mouse cursor itself (OBJID_CURSOR, i.e. on every mouse move). With WINEVENT_OUTOFPROCESS each one is marshalled to your thread's message queue, so this adds a measurable cost to all window movement and cursor motion system-wide.

None of the if (event == ...) branches in WinEventProc handle it — it exists solely to re-run CheckFullscreenState(). EVENT_SYSTEM_FOREGROUND is the event that actually matters for "did the foreground app go fullscreen", and it fires orders of magnitude less often. Also register it only when hideOnFullscreen is on, and unhook it when the setting is turned off.

4. The 16 ms timer runs a full GDI+ repaint forever, even when nothing is moving

SetTimer(g_hWnd, 1, 16, NULL);
...
case WM_TIMER:
    PhysicsStep();
    RedrawOverlay(hWnd);

RedrawOverlay allocates a bitmap, runs GDI+ antialiased drawing and calls UpdateLayeredWindow 60 times a second for the entire session. The dice are at rest the overwhelming majority of the time, so this is continuous CPU/compositor load (and battery drain on laptops) for zero visual change. Please gate it, e.g.:

  • skip RedrawOverlay when neither die is dragged and both velocities and positions are unchanged since the last frame (you already have all the state needed);
  • KillTimer while g_isHiddenByFullscreen is true and SetTimer again when the overlay comes back — right now the timer keeps firing and PhysicsStep() (including two GetCursorPos/ScreenToClient round-trips) keeps running while the overlay is invisible.

5. UpdateLayeredWindow(..., ULW_ALPHA) needs a 32-bpp DIB section, not CreateCompatibleBitmap

HBITMAP hBmp = CreateCompatibleBitmap(hdcScr, g_winW, g_winH);

CreateCompatibleBitmap returns a device-dependent bitmap whose format follows the screen DC, so there is no guarantee it has (or preserves) an alpha channel — GDI+ may select an opaque pixel format for it and the per-pixel alpha ULW_ALPHA relies on is then undefined. It happens to work on a 32-bpp desktop and silently misbehaves elsewhere. Every comparable mod in the repo uses an explicit top-down 32-bpp DIB section — see neko-cat and one-hair:

BITMAPINFO bi = {};
bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bi.bmiHeader.biWidth = g_winW;
bi.bmiHeader.biHeight = -g_winH;  // top-down
bi.bmiHeader.biPlanes = 1;
bi.bmiHeader.biBitCount = 32;
bi.bmiHeader.biCompression = BI_RGB;
void* bits;
HBITMAP hBmp = CreateDIBSection(hdcMem, &bi, DIB_RGB_COLORS, &bits, NULL, 0);

One more nuance while you're here: ULW_ALPHA expects premultiplied BGRA, but GDI+ composites in straight alpha. Your fills use alpha 220–240 (Color(230, 210, 40, 40) etc.), so they will render slightly brighter than intended and antialiased edges will show light halos. Using fully opaque colors (alpha 255) for the cube bodies sidesteps this; if you want real translucency you'd need to premultiply the RGB by the alpha yourself.

Optional improvements

Minor polish — none of this affects users in normal operation, so it's your call.

  • Settings are applied from the wrong thread. Wh_ModSettingsChanged runs LoadSettings() + ApplySettings() on a Windhawk thread while the worker thread is reading the same state in DrawScene/PhysicsStep. Reassigning g_settings.position / g_settings.diceStyle (std::wstring) can free the buffer another thread is comparing against, and g_winW/g_winH/g_anchor/g_d1/g_d2 are mutated mid-frame. Cleanest fix: register a private message with RegisterWindowMessage, post it to the overlay window from Wh_ModSettingsChanged, and do LoadSettings()/ApplySettings() in WndProc — then all of this state stays single-threaded.
  • Wh_GetStringSetting never returns NULL (it returns L"" when unset/on error), so the if (pos) / if (style) checks are dead. Prefer the RAII wrapper:
    g_settings.position = WindhawkUtils::StringSetting::make(L"position").get();
  • CheckFullscreenState uses g_hWnd after checking it for NULL:
    if (!g_settings.hideOnFullscreen || !g_hWnd) {
        if (g_isHiddenByFullscreen) {
            ShowWindow(g_hWnd, SW_SHOWNOACTIVATE);  // g_hWnd may be NULL here
    Harmless today (ShowWindow(NULL, ...) is a no-op) but the logic is clearly not what was intended.
  • The Settings struct defaults contradict the settings block: diceSize 40 vs 50, randomOnClick true vs false, hideOnFullscreen true vs false. They're never used (LoadSettings always runs first) but they're confusing — make them match. On that note, hideOnFullscreen: true would be a friendlier default: a topmost overlay sitting on top of fullscreen games and videos by default is intrusive.
  • wc.hCursor is not set, so the cursor keeps whatever shape it had when it moves over a die. Add wc.hCursor = LoadCursor(NULL, IDC_ARROW); (or IDC_HAND).
  • No error handling or logging. RegisterClass, CreateWindowEx and GdiplusStartup return values are ignored, and there isn't a single Wh_Log call — if any of them fails the mod is just silently dead. A few Wh_Log calls on the failure paths cost nothing (logging is off by default) and make support much easier.
  • GetCursorPos + ScreenToClient run for both dice every frame in UpdateDicePhysics, but the result is only used in the d.isDragged branch. Move them inside it.
  • RedrawOverlay re-sets the window position every frame by passing &ptDst from GetWindowRect. Since the position isn't changing, passing nullptr for pptDst is simpler and avoids fighting with SetWindowPos from ApplySettings.
  • Unnecessary boilerplate: the WINVER/_WIN32_WINNT defines and the #ifndef WINEVENT_OUTOFPROCESS fallback are all redundant — Windhawk's toolchain already targets Windows 10+ and defines WINEVENT_OUTOFPROCESS. Likewise #define PI / <math.h> can be <numbers> + std::numbers::pi_v<float> in C++23.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The hit-test radius is twice the visible die. Both WM_NCHITTEST and WM_LBUTTONDOWN use dist < d.size, but size is the die's full edge length, so the half-extent is size / 2. Clicks up to a full die-width away from a die's centre are swallowed (HTCLIENT) instead of passing through to the desktop, and the two dice's hit circles overlap heavily. dist < d.size * 0.5f (or a proper rotated-rect test) matches what the user sees.
  • WM_CAPTURECHANGED isn't handled. If capture is lost while dragging (Alt+Tab, another window grabbing it, a UAC prompt), no WM_LBUTTONUP arrives, isDragged stays true and the die follows the cursor forever. Handling WM_CAPTURECHANGED by clearing both isDragged flags fixes it. Relatedly, ReleaseCapture() is called on every WM_LBUTTONUP even when the mod never captured.
  • Grabbing a die snaps its centre to the cursor. UpdateDicePhysics does d.x = pt.x; d.y = pt.y;, so the die jumps under the pointer instead of being dragged from where it was grabbed. Storing the grab offset at WM_LBUTTONDOWN and applying it would feel much better.
  • Maximize/restore probably doesn't trigger a reaction. EVENT_SYSTEM_MOVESIZEEND fires when a modal move/size loop ends (dragging the title bar or a border). Clicking the maximize/restore button or double-clicking the title bar doesn't enter that loop, so the IsZoomed(hwnd) branch never runs for the most common way of maximizing — despite the README promising a reaction to "maximize/restore". EVENT_OBJECT_LOCATIONCHANGE (which you already hook) or EVENT_SYSTEM_FOREGROUND combined with an IsZoomed state check would cover it.
  • Primary monitor only, and no DPI scaling. ApplySettings positions the overlay with GetSystemMetrics(SM_CXSCREEN), which is the primary monitor's width, so on a multi-monitor setup the dice always live on the primary display with no way to choose. diceSize is also in raw physical pixels, so the dice appear half the size on a 200%-scaled display compared to a 100% one. Consider a monitor setting (via MonitorFromPoint/EnumDisplayMonitors + GetMonitorInfo) and scaling diceSize by the target monitor's DPI (GetDpiForMonitor).
  • Vertical placement is fixed. position only offers left/center/right at y = 10. A "top/bottom" or free-Y option would be an easy addition given the physics already handle an arbitrary anchor.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 4, 2026
@alivca

alivca commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 4, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


Nice, self-contained little mod — the physics and the layered-window drawing are clean. A few things should be fixed before merge:

1. This should be a tool mod (@include windhawk.exe), not injected into explorer.exe.

The mod installs no function hooks at all — it only uses SetWinEventHook (which works from any process) and its own top-level layered window. It never touches Explorer's state. That's exactly the "mods as tools" case, and injecting into explorer.exe has two concrete costs here:

  • Multiple explorer.exe processes. With "Launch folder windows in a separate process" enabled (or in other situations), there is more than one explorer.exe, so Wh_ModInit runs more than once and you get several overlapping sets of dice — there is no single-instance guard in the code. The tool-mod launcher handles this for you with a mutex.
  • Shell stability. A GDI+/physics/message-loop bug takes the whole shell down instead of a throwaway windhawk.exe.

You already use this pattern in your own macOS magnifying cursor mod, so it should be a small change: switch @include to windhawk.exe, rename Wh_ModInit/Wh_ModSettingsChanged/Wh_ModUninitWhTool_*, and paste the launcher snippet from the wiki verbatim. Desktop Companions is a very close reference — a GDI+ layered-window desktop pet with dragging and window interaction, running as a tool mod.

2. The README preview image is a 404.

https://raw.githubusercontent.com/alivca/hanging-v-dice-customizable/main/cubic.gif

That repo exists, but cubic.gif isn't in it (the branch has a Delete cubic.gif commit), so the image request returns 404 and the mod page will show a broken image. Since this mod is purely visual, a working GIF/screenshot is important — either re-add the file to that repo or point at the one you already host in alivca/windhawk-mods-gif.

3. Changing a setting while the overlay is hidden for fullscreen leaves it visible and frozen on top of the fullscreen app.

ApplySettingsInternal unconditionally re-shows the window:

SetWindowPos(g_hWnd, HWND_TOPMOST, posX, 10, g_winW, g_winH, SWP_NOACTIVATE | SWP_SHOWWINDOW);
UpdateForegroundHookState();
CheckFullscreenState();

but CheckFullscreenState can't undo it: with g_isHiddenByFullscreen == true and the app still fullscreen, fs && !g_isHiddenByFullscreen is false and !fs && g_isHiddenByFullscreen is false, so both branches are skipped. Result: the window is visible again over the fullscreen game, the timer is still killed (from the earlier KillTimer), and RedrawOverlay early-returns on g_isHiddenByFullscreen — so you get a stale/garbage layered bitmap stuck on top until the foreground changes.

The same shape happens at startup: if a fullscreen app is already running, ApplySettingsInternalCheckFullscreenState hides the window, and then ShowWindow(g_hWnd, SW_SHOW) in StartThread un-hides it again.

Simplest fix — don't show from ApplySettingsInternal, and let one function own visibility:

    SetWindowPos(g_hWnd, HWND_TOPMOST, posX, 10, g_winW, g_winH,
                 SWP_NOACTIVATE | (g_isHiddenByFullscreen ? 0 : SWP_SHOWWINDOW));

and drop the ShowWindow(g_hWnd, SW_SHOW) in StartThread (ApplySettingsInternal already shows the window). It would also be worth making CheckFullscreenState idempotent — compute the desired visibility and apply it — instead of driving off edge transitions only.

4. The overlay steals focus.

Two separate problems:

  • ShowWindow(g_hWnd, SW_SHOW) in StartThread activates the window. Injected into explorer.exe, that fires during logon/Explorer start and yanks focus. Use SW_SHOWNA (as Desktop Companions does), or just remove it per item 3.
  • The window has no WS_EX_NOACTIVATE, and WM_NCHITTEST returns HTCLIENT over the dice — so every click on a die activates the overlay and deactivates whatever the user was working in (title bar greys out, etc.). Add WS_EX_NOACTIVATE:
    g_hWnd = CreateWindowEx(
        WS_EX_TOPMOST | WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
        ...

SetCapture and the drag handling keep working with WS_EX_NOACTIVATE.

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • WaitForSingleObject(g_hThread, INFINITE) in Wh_ModUninit — the shutdown path looks correct, but an unbounded wait means any unexpected hang in the worker thread hangs the unload. A finite timeout is cheap insurance; Desktop Companions uses 5000.
  • g_hWnd and g_wmReloadSettings are written on the worker thread and read from Wh_ModUninit / Wh_ModSettingsChanged (arbitrary threads) without synchronization. std::atomic<HWND> / std::atomic<UINT> would make it well-defined, matching what you already do for g_running.
  • if (pos.get()) / if (style.get()) are dead checks — Wh_GetStringSetting never returns NULL; it returns L"" when unset or on error. If you want a fallback, test for an empty string instead.
  • The in-code Settings defaults contradict the settings block: diceSize = 40 vs 50, randomOnClick = true vs false, hideOnFullscreen = true vs false. Harmless (LoadSettingsInternal always overwrites them) but confusing — either drop the initializers or keep them in sync.
  • while (g_running && GetMessage(&msg, NULL, 0, 0))GetMessage returns -1 on error, which is truthy, so an error would spin. GetMessage(...) > 0 is the safe form.
  • if (msg == g_wmReloadSettings) runs before the guard is known-good: if RegisterWindowMessage ever returned 0, every WM_NULL would trigger a settings reload. if (g_wmReloadSettings && msg == g_wmReloadSettings) costs nothing.
  • RedrawOverlay doesn't check CreateDIBSection for failure before using bits/hBmp.
  • (short)LOWORD(lParam) works, but GET_X_LPARAM/GET_Y_LPARAM from <windowsx.h> express the intent better.
  • The 10 px screen margins in ApplySettingsInternal aren't DPI-scaled (see also the DPI note below).

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The 16 ms timer never stops. PhysicsStep() already returns false once the dice have settled, and you correctly skip the redraw — but the timer keeps firing 60×/s forever, which prevents the CPU from idling. Since you have that signal, you could KillTimer when PhysicsStep() returns false and re-SetTimer from WinEventProc / WM_LBUTTONDOWN.
  • RedrawOverlay rebuilds its drawing surface every frameGetDC + CreateCompatibleDC + CreateDIBSection + DeleteObject + DeleteDC at 60 fps. Caching the DC and DIB section and recreating them only when g_winW/g_winH change would cut a lot of GDI churn.
  • Fullscreen auto-hide misses the most common case. Detection only runs on EVENT_SYSTEM_FOREGROUND, minimize start/end, and EVENT_SYSTEM_MOVESIZEEND. Pressing F11 (or a game's fullscreen toggle) in the already-focused window produces none of those, so the dice stay on top of the newly fullscreen window — and stay hidden after leaving fullscreen — until the foreground changes. A low-cadence re-check (e.g. every ~500 ms from the existing timer) would cover it.
  • EVENT_SYSTEM_MOVESIZEEND only fires when a window exits a user-driven move/size loop. Clicking the maximize button, double-clicking the caption, or Win+Up don't generate it, so the advertised "react when you maximize/restore" effectively only works for drag-to-snap. Worth verifying on your end.
  • Fullscreen hiding isn't monitor-aware — a fullscreen app on a secondary monitor hides dice that are on the primary.
  • Single-monitor / no DPI scaling. Position is computed from GetSystemMetrics(SM_CXSCREEN), so the overlay is always on the primary monitor with no way to choose another. diceSize is in raw pixels with no DPI scaling, so the dice come out physically smaller on a high-DPI display. Consider a monitor setting and scaling diceSize by the target monitor's DPI (GetDpiForMonitor).
  • No WM_DISPLAYCHANGE handling — after a resolution or display-arrangement change the overlay stays at its old position until a settings change.
  • Topmost is only re-asserted on settings change / unhide. Other topmost windows can end up covering the dice; Desktop Companions re-applies HWND_TOPMOST on each frame update for this reason.
  • hideOnFullscreen defaults to false even though "auto-hide in fullscreen" is a headline feature in the description — true seems like the friendlier default.
  • Physics integrates per tick, not per elapsed time — under load or if WM_TIMER coalesces, the dice swing in slow motion rather than keeping real-time speed.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 4, 2026
@alivca

alivca commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 4, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


Nice idea and the code is generally tidy, but the mod is injecting into Explorer without needing to, and the WinEvent flags silently disable a large part of the reactions.

1. This should be a tool mod, not an explorer.exe injection.

The mod installs no function hooks at all — it only uses SetWinEventHook (out-of-context, system-wide), creates its own window and draws with GDI+. Nothing here needs to run inside Explorer's address space, and everything that does run there (a 60 Hz physics/render loop, GDI+, a custom WndProc) can destabilize the shell if it faults.

There's also a concrete user-visible consequence: @include explorer.exe loads the mod into every explorer.exe process. With "Launch folder windows in a separate process" enabled (or any other extra Explorer instance), you get a second, third… independent set of hanging dice, each with its own topmost overlay.

Please convert it to a tool mod: @include windhawk.exe, rename Wh_ModInit/Wh_ModSettingsChanged/Wh_ModUninitWhTool_ModInit/WhTool_ModSettingsChanged/WhTool_ModUninit, and paste the launcher boilerplate from the wiki verbatim (don't refactor it):
https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process

mods/neko-cat.wh.cpp is essentially the same architecture as this mod (worker thread + layered overlay window + animation loop) and is already a tool mod; mods/explorer-folder-hover-menu.wh.cpp has the launcher snippet as a byte-for-byte copy of the wiki version.

2. WINEVENT_OUTOFPROCESS is not a real flag — and its value disables the dice reactions for all Explorer windows.

#ifndef WINEVENT_OUTOFPROCESS
#define WINEVENT_OUTOFPROCESS 0x0003
#endif

There is no such constant in the Windows SDK, so the #ifndef always takes effect. 0x0003 is WINEVENT_SKIPOWNTHREAD | WINEVENT_SKIPOWNPROCESS — i.e. "don't deliver events generated by threads in my own process". Since the mod runs inside explorer.exe, every minimize/restore/move-size event coming from a File Explorer window (or the desktop) is dropped, so the dice never react to them. It also means the fullscreen check isn't run when Explorer becomes the foreground window.

Use the documented flag instead:

SetWinEventHook(EVENT_SYSTEM_MINIMIZESTART, EVENT_SYSTEM_MINIMIZESTART,
                NULL, WinEventProc, 0, 0, WINEVENT_OUTOFCONTEXT);

(WINEVENT_OUTOFCONTEXT is 0x0000; the callback is still delivered asynchronously to the installing thread's message loop, which is what you want here.) See mods/taskbar-auto-hide-when-maximized.wh.cpp for the usual form.

3. Changing settings while hidden for fullscreen un-hides a frozen overlay on top of the fullscreen app.

ApplySettingsInternal unconditionally calls:

SetWindowPos(g_hWnd, HWND_TOPMOST, posX, 10, g_winW, g_winH, SWP_NOACTIVATE | SWP_SHOWWINDOW);

SWP_SHOWWINDOW makes the window visible again, but g_isHiddenByFullscreen stays true, so the following CheckFullscreenState() sees fs && g_isHiddenByFullscreen and takes neither branch. The result: the overlay is visible over the fullscreen app, the physics timer is still killed, and RedrawOverlay early-returns on g_isHiddenByFullscreen — so a stale frame sits on top of the game until the foreground changes.

Either skip SWP_SHOWWINDOW when g_isHiddenByFullscreen is set, or clear the flag (and re-arm the timer) before calling CheckFullscreenState() so it re-evaluates from a known state.

4. Fullscreen transitions that don't change the foreground window are never detected.

CheckFullscreenState() only runs from WinEventProc, which is wired to EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_MINIMIZESTART/MINIMIZEEND and EVENT_SYSTEM_MOVESIZEEND. Pressing F11 in the already-focused browser, or a game switching to fullscreen while it's already foreground, produces none of those — so the overlay stays on screen. The setting is literally named "Hide in Fullscreen Mode (F11 / Fullscreen)", so this is the main case it advertises.

Add EVENT_OBJECT_LOCATIONCHANGE (checking hwnd == GetForegroundWindow() && idObject == OBJID_WINDOW) — see mods/taskbar-auto-hide-when-maximized.wh.cpp — or use SHQueryUserNotificationState() for the game case, as mods/monitor-rounded-edges.wh.cpp does.

5. The 16 ms timer runs forever, including when the dice are at rest.

SetTimer(g_hWnd, 1, 16, NULL) is armed at startup and only killed when hiding for fullscreen. Once the dice settle, PhysicsStep() returns false and nothing is drawn — but the thread is still woken ~60 times per second for the lifetime of the session, which keeps the CPU out of deeper idle states for no benefit (noticeable on laptops).

PhysicsStep() already tells you when it's idle, so just stop the timer and re-arm it when something can move again:

case WM_TIMER:
    if (PhysicsStep()) {
        RedrawOverlay(hWnd);
    } else {
        KillTimer(hWnd, 1);   // nothing moving; re-armed on interaction
    }
    break;

…and call SetTimer(g_hWnd, 1, 16, NULL) from WinEventProc (after nudging the velocities), from WM_LBUTTONDOWN, and from the fullscreen-restore path (which already does this).

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • Window class hInstance. RegisterClass/UnregisterClass use GetModuleHandle(NULL), i.e. the host executable's instance, while lpfnWndProc points into the mod DLL. The register/unregister pair is consistent so it works, but it's cleaner to tie the class to the mod's own module — see the GetCurrentModuleHandle() helper in mods/desktop-live-overlay.wh.cpp.

  • Guard the registered-message comparison. if (msg == g_wmReloadSettings) runs before g_wmReloadSettings is validated; if RegisterWindowMessage ever failed it would be 0, and every WM_NULL would trigger a settings reload. if (g_wmReloadSettings && msg == g_wmReloadSettings).

  • The Settings struct's inline defaults contradict the settings block (diceSize 40 vs 50, randomOnClick true vs false, hideOnFullscreen true vs false). They're always overwritten by LoadSettingsInternal(), so they're dead — but misleading. Either drop them or make them match.

  • Wh_GetStringSetting never returns NULL (it returns L"" on error/unset), so if (pos.get()) / if (style.get()) are no-ops. Assign directly, or check for an empty string if you want a fallback.

  • g_hWnd is a plain HWND written by the worker thread and read from Wh_ModSettingsChanged/Wh_ModUninit on other threads. Formally a data race; std::atomic<HWND> would make it explicit (you already do this for g_running).

  • CreateDIBSection's return value isn't checked in RedrawOverlay. On failure hBmp/bits are null and the subsequent SelectObject/UpdateLayeredWindow operate on the DC's 1×1 default bitmap. An early return would be cleaner.

  • KillTimer(g_hWnd, 1) / DestroyWindow(g_hWnd) after the message loop normally act on an already-destroyed window (the dispatched WM_CLOSEDefWindowProcDestroyWindow path). Harmless, but it reads as if it were the primary teardown; a comment or a IsWindow check would clarify the intent.

  • rand()/srand() could be <random> (std::mt19937 + std::uniform_int_distribution) now that mods build as C++23. Purely cosmetic here since it's all single-threaded.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • "React when you move windows" doesn't currently happen. EVENT_SYSTEM_MOVESIZEEND is handled only inside if (IsZoomed(hwnd)), so plain moves/resizes produce no reaction at all. And clicking the maximize button doesn't raise MOVESIZEEND in the first place (that event is for user-driven move/size loops), so only drag-to-snap maximizing triggers the "maximize" swing. If you want both, react to the move regardless of IsZoomed and detect maximize/restore via EVENT_OBJECT_LOCATIONCHANGE + IsZoomed state tracking.

  • Fullscreen detection isn't monitor-aware. IsWindowFullscreen compares the window against its own monitor, so a fullscreen app on monitor 2 hides dice that live on the primary monitor. Comparing against the monitor the overlay is on would avoid that. Also, a maximized window on a monitor with an auto-hiding taskbar covers the full monitor rect and will be treated as fullscreen.

  • No DPI scaling. diceSize and the 10 px margins are raw pixels, and GetSystemMetrics(SM_CXSCREEN) returns physical pixels for Explorer's per-monitor-aware threads. On a 150–200% display the dice come out proportionally half the intended size. Scaling by GetDpiForWindow(g_hWnd) / 96.0 (or GetDpiForMonitor) would keep them consistent.

  • Primary monitor only, and no reaction to display changes. The overlay is always placed via SM_CXSCREEN, so multi-monitor users can't put it elsewhere, and a resolution/layout change (WM_DISPLAYCHANGE) doesn't reposition it. A monitor setting plus a WM_DISPLAYCHANGE handler that re-runs the positioning part of ApplySettingsInternal would cover both. The 10 px top offset is also hardcoded — a setting might be nice.

  • GDI+ alpha vs. UpdateLayeredWindow. You draw with GDI+ into a plain 32-bit BI_RGB DIB and then blit with AC_SRC_ALPHA, which expects premultiplied alpha. Fully opaque pixels are identical either way (which is why it looks fine overall), but antialiased edges — the die outlines, the dots, the strings — get straight alpha and will composite slightly too bright/fringed. Wrapping the DIB bits in a Gdiplus::Bitmap with PixelFormat32bppPARGB and drawing into that avoids it; see mods/fully-customizable-winver.wh.cpp. While you're there, Graphics gr is still alive when UpdateLayeredWindow runs — scope it in a block (or call gr.Flush()) so all drawing is guaranteed to have landed in the DC first.

  • Per-frame DC/DIB churn. RedrawOverlay creates a screen DC, a memory DC and a fresh CreateDIBSection on every frame, i.e. up to 60×/s. Caching them and only recreating on size change (settings/DPI change) would cut most of the render cost.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added the waiting-for-author The author's turn: request an AI review, or respond to one that was posted. label Aug 4, 2026
@windhawk-reviewer

Copy link
Copy Markdown

@alivca /ready-for-reviewer can't be applied here: the most recent AI review covers 65a50c8, but the current head of this pull request is 3b961de. Comment /ai-review to get a review of the current code.

@alivca

alivca commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 5, 2026
@windhawk-reviewer

Copy link
Copy Markdown

This pull request has already had 3 AI reviews in the last 24 hours, which is the limit, so no review was posted this time.

Comment /ai-review again after 2026-08-05 14:13 UTC (in 2 hours) to get another one.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 5, 2026
@alivca

alivca commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 5, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


Nice idea, and the teardown path (joining the thread, UnhookWinEvent, KillTimer, UnregisterClass, GdiplusShutdown) is more careful than most submissions of this kind. There are a few real problems though, the biggest being that this shouldn't be injected into Explorer at all.

1. This should be a tool mod, not an explorer.exe injection.

The mod installs zero function hooks — it only uses SetWinEventHook, GetForegroundWindow and its own layered window, all of which work from any process. It never touches Explorer's state. That's the textbook case for the mods as tools pattern, and it matters here for two concrete reasons:

  • A crash or hang in the physics/render loop takes down the shell. The mod runs a 60 Hz GDI+ render loop inside explorer.exe forever.
  • @include explorer.exe matches every explorer.exe process. With "Launch folder windows in a separate process" enabled (or any other multi-Explorer situation), each one gets its own thread, its own window class and its own pair of dice — duplicate overlays stacked on top of each other.

Switch to @include windhawk.exe, rename Wh_ModInit/Wh_ModSettingsChanged/Wh_ModUninitWhTool_ModInit/WhTool_ModSettingsChanged/WhTool_ModUninit, and paste the launcher boilerplate from the wiki verbatim (don't refactor it). Your own mac-magnifying-cursor already does exactly this, and neko-cat is the closest analogue in the catalog (desktop-overlay pet, also a tool mod). Reference boilerplate: explorer-folder-hover-menu.wh.cpp#L3837.

2. WINEVENT_OUTOFPROCESS is not a Windows constant — the value you defined silently drops every event from the host process.

#ifndef WINEVENT_OUTOFPROCESS
#define WINEVENT_OUTOFPROCESS 0x0003
#endif

There is no such flag in winuser.h (the #ifndef is why this compiled). 0x0003 is WINEVENT_SKIPOWNTHREAD | WINEVENT_SKIPOWNPROCESS, so all four hooks are installed with "skip events generated by my own process". Since the mod lives in explorer.exe, that means minimize / restore / snap-maximize of any File Explorer window, and any foreground change to Explorer or the taskbar, never reach WinEventProc — the dice just don't react to the shell's own windows. The flag you want is WINEVENT_OUTOFCONTEXT (0x0000), which is what every other mod in the repo uses (example):

g_hEventHookMin = SetWinEventHook(
    EVENT_SYSTEM_MINIMIZESTART, EVENT_SYSTEM_MINIMIZESTART,
    nullptr, WinEventProc, 0, 0, WINEVENT_OUTOFCONTEXT);

Delete the #ifndef block entirely.

3. The dice never reach rest, so the mod repaints at ~60 FPS forever.

PhysicsStep() reports "moved" if the velocity exceeds 0.01:

bool moved1 = (fabsf(g_d1.vx) > 0.01f || fabsf(g_d1.vy) > 0.01f || ...);

But the integrator applies gravity (d.vy += 0.45f) before the string constraint and damping, so the equilibrium of that discrete map is a non-zero residual velocity: the position stops changing while vy stays pinned at ≈ -0.45. I ported the physics loop verbatim and ran it — after ~25 000 frames the position is frozen to four decimals (d1 = (190.0000, 143.3117)) and it still reports moved=True at frame 200 000. In other words RedrawOverlay() runs on every 16 ms tick, indefinitely: a fresh CreateCompatibleDC + CreateDIBSection, a full antialiased GDI+ render and an UpdateLayeredWindow ~62 times a second, producing a pixel-identical frame each time. On a laptop that's a permanent wakeup source.

Two fixes, ideally both:

  • Detect rest by position rather than velocity — positions really do converge:
    bool moved1 = fabsf(g_d1.x - prev1X) > 0.01f || fabsf(g_d1.y - prev1Y) > 0.01f;
  • Stop the timer when idle and restart it from WinEventProc / WM_LBUTTONDOWN, instead of running it unconditionally from StartThread:
    case WM_TIMER:
        if (PhysicsStep()) {
            g_idleFrames = 0;
            RedrawOverlay(hWnd);
        } else if (++g_idleFrames > 30) {
            KillTimer(hWnd, 1);
            g_timerActive = false;
        }
        break;

4. The overlay re-appears — frozen — on top of fullscreen apps.

ApplySettingsInternal() unconditionally shows the window:

SetWindowPos(g_hWnd, HWND_TOPMOST, posX, 10, g_winW, g_winH, SWP_NOACTIVATE | SWP_SHOWWINDOW);
UpdateForegroundHookState();
CheckFullscreenState();

If the overlay was hidden by the fullscreen logic, g_isHiddenByFullscreen is still TRUE, so CheckFullscreenState() takes neither branch (it only reacts to transitions) and the window stays visible. And because RedrawOverlay() early-returns while g_isHiddenByFullscreen is set, it is never repainted — so changing any setting while a fullscreen game is running puts a stale, frozen dice image on top of the game until the app leaves fullscreen. The same ordering bug exists at startup: ApplySettingsInternal()CheckFullscreenState() may hide the window, and then ShowWindow(g_hWnd, SW_SHOWNOACTIVATE) on the next line shows it again.

Make visibility derive from state in one place rather than being set from three. E.g. drop SWP_SHOWWINDOW from ApplySettingsInternal(), move the initial ShowWindow before ApplySettingsInternal(), and have CheckFullscreenState() apply the current desired state instead of only edges:

bool shouldHide = g_settings.hideOnFullscreen && IsWindowFullscreen(GetForegroundWindow());
if (shouldHide != g_isHiddenByFullscreen) {
    g_isHiddenByFullscreen = shouldHide;
    if (shouldHide) {
        ShowWindow(g_hWnd, SW_HIDE);
    } else {
        ShowWindow(g_hWnd, SW_SHOWNOACTIVATE);
        SetWindowPos(g_hWnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
    }
}

5. Fullscreen state is polled 62 times a second, even while hidden.

WM_TIMER calls CheckFullscreenState() on every 16 ms tick, and WinEventProc calls it again on every event. Each call does GetForegroundWindow + GetClassNameW + GetWindowRect + MonitorFromWindow + GetMonitorInfo, several of which are cross-process. This runs even while the overlay is hidden behind a fullscreen game — i.e. exactly when you least want the shell doing extra work. You already install an EVENT_SYSTEM_FOREGROUND hook for this; that covers the transitions. For the cases the hook misses (a window toggling fullscreen in place, e.g. alt-enter), use a separate slow timer of 500 ms–1 s rather than checking every frame.

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • Wh_GetStringSetting never returns NULL (it returns L"" when unset or on error), so the if (pos.get()) / if (style.get()) guards in LoadSettingsInternal() are dead. Assign directly, or check for an empty string if you want to keep the fallback.
  • The Settings struct in-code defaults disagree with the settings block: diceSize 40 vs. 50, randomOnClick true vs. false, hideOnFullscreen true vs. false. They're always overwritten by LoadSettingsInternal(), so they're harmless — but they're misleading. Either match them or drop the initializers.
  • -luser32 in @compilerOptions is redundant — user32 is linked by default (plenty of window-creating mods list only -lcomctl32 or -lgdi32).
  • WndProc compares msg == g_wmReloadSettings without checking that the registration succeeded. If RegisterWindowMessage ever returns 0, WM_NULL would trigger a settings reload. Add g_wmReloadSettings && msg == g_wmReloadSettings.
  • g_hWnd isn't cleared when the window is destroyed, so StartThread calls DestroyWindow(g_hWnd) on an already-destroyed handle on the normal shutdown path, and g_hWnd stays dangling after the thread exits. Set g_hWnd = NULL in WM_DESTROY (or right after DestroyWindow).
  • srand/rand could be std::mt19937 + std::uniform_int_distribution from <random> — C++23 is available. Likewise <cmath>/<cstdlib>/<ctime> instead of the C headers.
  • Wh_ModInit doesn't check the CreateThread result; a Wh_Log on failure would make a silent no-op easier to diagnose.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The README promises more reactions than the code implements. "They swing and react in real-time when you move, minimize, maximize, or restore windows" — but EVENT_SYSTEM_MOVESIZEEND is only acted on if (IsZoomed(hwnd)), so plain window moves produce nothing, and maximizing via the maximize button or Win+Up doesn't generate MOVESIZEEND at all (only the drag-snap path does). Consider reacting to EVENT_OBJECT_LOCATIONCHANGE (throttled) or EVENT_SYSTEM_MOVESIZESTART/END regardless of IsZoomed, or soften the README wording.
  • Primary monitor only. ApplySettingsInternal() positions the overlay with GetSystemMetrics(SM_CXSCREEN), so the dice always land on the primary display, and the size is in raw pixels with no DPI scaling — on a 150% primary they'll look smaller than the same setting on a 100% one. A monitor-selection setting (and scaling diceSize by GetDpiForWindow) would help mixed-DPI/multi-monitor setups.
  • Fullscreen hiding isn't monitor-aware. IsWindowFullscreen() checks the foreground window against its own monitor, but the overlay is always on the primary. A fullscreen app on monitor 2 hides dice that aren't covering it. Comparing the app's monitor to MonitorFromWindow(g_hWnd, ...) would fix that.
  • IsWindowFullscreen() also matches a merely maximized window when the taskbar is set to auto-hide, since the window rect then equals the monitor rect. Probably acceptable, just be aware of it.
  • Physics still runs at full rate while the overlay is hidden by fullscreen — RedrawOverlay early-returns but PhysicsStep() doesn't. Once the timer is idle-aware (item 3) this mostly resolves itself, but you could also skip the step entirely while hidden.
  • RedrawOverlay() doesn't check CreateDIBSection/CreateCompatibleDC for failure. It won't crash (GDI+ just draws into the DC's default 1×1 bitmap), but under GDI handle exhaustion you'd get silent garbage rather than a log line.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 5, 2026
@alivca

alivca commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

/ready-for-reviewer

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-reviewer Ready for a human reviewer, and in the queue for one. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 5, 2026
@m417z

m417z commented Aug 5, 2026

Copy link
Copy Markdown
Member

Please address item 1 of the last review.

Also, I see no reason not to address items 3 and 5 to improve the mod's efficiency. Let me know if I missed something.

I haven't verified item 4 but it seems to be insignificant even if it's a real issue.

@m417z m417z added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-reviewer Ready for a human reviewer, and in the queue for one. labels Aug 5, 2026
@alivca

alivca commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 5, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


Nice, self-contained novelty mod — no external content, no function hooks, settings map cleanly onto the code, and the README has a preview GIF on an allowed host. The main problem is the process model: the mod targets windhawk.exe but doesn't use the tool-mod pattern, and there are a few state-machine bugs that can leave the dice stuck invisible.

1. @include windhawk.exe without the tool-mod launcher boilerplate

The mod declares @include windhawk.exe and then implements plain Wh_ModInit / Wh_ModSettingsChanged / Wh_ModUninit. That is not the "mods as tools" pattern — it just injects the mod into whatever windhawk.exe processes happen to exist, which is not what you want:

  • It loads into every windhawk.exe instance, including the session-0 service process and the short-lived helper invocations (-service-start, -service-stop, -exit, -restart, -safe-mode). The wiki snippet exists precisely to filter those out (ProcessIdToSessionId(...) == 0 and the -service* argv checks). In session 0 your overlay window gets created on an invisible desktop — and because your single-instance mutex is Global\, that invisible instance can win the race and suppress the visible one entirely.
  • The mod's lifetime is tied to the Windhawk UI process rather than to the mod being enabled — exit Windhawk from the tray and the dice go away.
  • A crash in the physics/GDI+ code takes down the Windhawk UI instead of an isolated helper process.

The fix is the documented one: rename Wh_ModInitWhTool_ModInit, Wh_ModSettingsChangedWhTool_ModSettingsChanged, Wh_ModUninitWhTool_ModUninit, and paste the launcher snippet from Mods as tools: Running mods in a dedicated process verbatim at the end of the file (keep it byte-for-byte identical to the wiki so it's easy to review). Every one of the 58 mods currently in the repo that targets windhawk.exe does this — e.g. neko-cat.wh.cpp#L1892, one-hair.wh.cpp#L354, explorer-folder-hover-menu.wh.cpp#L3850. neko-cat is the closest structural match to what you're doing (dedicated UI thread + layered GDI+ overlay).

2. Remove the home-grown Global\ single-instance mutex

g_hSingleInstanceMutex = CreateMutex(NULL, TRUE, L"Global\\WindhawkHangingDiceSingleInstanceMutex");

Once you adopt the boilerplate above this is redundant — the snippet already dedupes with a windhawk-tool-mod_<mod id> mutex. It's also actively harmful as written:

  • The Global\ namespace requires SeCreateGlobalPrivilege, which a non-elevated process doesn't have. If CreateMutex fails, the thread returns 0 and the mod silently does nothing at all — no window, no log.
  • Even when it succeeds, Global\ is machine-wide, so with fast user switching only the first logged-in session ever gets dice.

Delete the mutex and all four of its cleanup blocks.

3. The fullscreen hide/show state machine can get stuck with the dice permanently hidden

Two distinct paths:

  • Turning "Hide in Fullscreen Mode" off while hidden. CheckFullscreenState bails out at the top when the setting is off:

    if (!g_hWnd || !g_settings.hideOnFullscreen) return;

    So g_isHiddenByFullscreen is never reset, ShowWindow(SW_SHOWNOACTIVATE) is never called, RedrawOverlay keeps early-returning, and UpdateForegroundHookState has just torn down the EVENT_SYSTEM_FOREGROUND hook that would have re-checked. The dice are gone until the mod is restarted. Handle the "feature just got disabled" transition explicitly:

    void CheckFullscreenState() {
        if (!g_hWnd) return;
    
        if (!g_settings.hideOnFullscreen) {
            if (g_isHiddenByFullscreen) {
                g_isHiddenByFullscreen = false;
                ShowWindow(g_hWnd, SW_SHOWNOACTIVATE);
                SetWindowPos(g_hWnd, HWND_TOPMOST, 0, 0, 0, 0,
                             SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
                RedrawOverlay(g_hWnd);  // needs a forward declaration
            }
            return;
        }
        ...
    }
  • The 150 ms throttle drops the check instead of deferring it.

    if (now - g_lastFullscreenCheck < 150) return;

    While the dice are hidden, the animation timer is killed after 30 idle frames, so EVENT_SYSTEM_FOREGROUND becomes the only thing that can un-hide them. If that one event lands inside the 150 ms shadow of the last timer-driven check, it's discarded with nothing scheduled to retry, and the dice stay hidden until some unrelated foreground change happens. Either drop the throttle (a GetForegroundWindow + a couple of GetWindowLong/GetMonitorInfo calls on a rare event is cheap), or set a short one-shot timer to re-run the check instead of returning.

4. Settings changes have no visible effect while the dice are at rest

ApplySettingsInternal resizes/repositions the window and moves the dice, but never repaints:

SetWindowPos(g_hWnd, HWND_TOPMOST, posX, 10, g_winW, g_winH, SWP_NOACTIVATE);
UpdateForegroundHookState();
CheckFullscreenState();

The layered surface is only ever updated by RedrawOverlay from WM_TIMER, and in steady state that timer has been killed (g_idleFrames > 30). So changing Dice Size / Position / Visual Style / dice values does nothing visible until the user happens to minimize or move a window — and in the size case the window is now a different size than the layered bitmap it's still showing. Repaint (or restart the timer) at the end of ApplySettingsInternal:

    CheckFullscreenState();
    RedrawOverlay(g_hWnd);

5. Wh_ModUninit can return while the overlay thread is still running

void Wh_ModUninit() {
    if (g_hWnd) {
        PostMessage(g_hWnd, WM_CLOSE, 0, 0);
    }
    if (g_hUIThread) {
        WaitForSingleObject(g_hUIThread, 2000);
        ...

The mod image must be unloadable the instant Wh_ModUninit returns — Windhawk FreeLibrarys it right after. A bounded 2 s wait means that on timeout the DLL is unmapped with the overlay thread's instruction pointer and return address still inside it, which crashes the host. There are two ways to reach that timeout:

  • g_hWnd is assigned on the overlay thread, but Wh_ModInit returns as soon as CreateThread succeeds. If the mod is disabled or reloaded before the window exists, g_hWnd is still NULL, no WM_CLOSE is posted, the message loop never quits, and the wait times out. (g_hWnd is also read across threads with no synchronization.)
  • Any slow path in GdiplusStartup/window creation has the same effect.

As a side effect the class registration also survives (UnregisterClass sits after the message loop), so the next load hits ERROR_CLASS_ALREADY_EXISTS, RegisterClass fails, and the thread silently returns — a stale class whose lpfnWndProc points into a freed image.

Publish the window handle before Wh_ModInit returns and then wait unconditionally:

HANDLE g_hWindowReady;  // signaled by the overlay thread once it has a window, or has given up

BOOL WhTool_ModInit() {
    g_hWindowReady = CreateEvent(nullptr, TRUE, FALSE, nullptr);
    if (!g_hWindowReady) return FALSE;
    g_hUIThread = CreateThread(nullptr, 0, OverlayThreadProc, nullptr, 0, &g_uiThreadId);
    if (!g_hUIThread) { CloseHandle(g_hWindowReady); g_hWindowReady = nullptr; return FALSE; }
    WaitForSingleObject(g_hWindowReady, INFINITE);
    return TRUE;
}

void WhTool_ModUninit() {
    if (g_hWnd) PostMessage(g_hWnd, WM_CLOSE, 0, 0);
    if (g_hUIThread) {
        WaitForSingleObject(g_hUIThread, INFINITE);   // not 2000
        CloseHandle(g_hUIThread);
        g_hUIThread = nullptr;
    }
    if (g_hWindowReady) { CloseHandle(g_hWindowReady); g_hWindowReady = nullptr; }
}

with SetEvent(g_hWindowReady) on every exit path of OverlayThreadProc, including the early bail-outs. This also fixes the settings race where Wh_ModSettingsChanged fires before g_wmReloadSettings has been registered and the change is silently dropped.

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • No logging anywhere. Every failure path in OverlayThreadProc (mutex, GdiplusStartup, RegisterClass, CreateWindowEx) does return 0; with no trace, so a user reporting "the dice never showed up" gives you nothing to work with. Add a Wh_Log(L"...") on each — it compiles to a cheap if (g_logsOn) check and is off by default, so there's no cost.

  • RedrawOverlay rebuilds its whole rendering surface every frame. At 16 ms intervals it does GetDC + CreateCompatibleDC + CreateDIBSection (~500 KB at the default size) + teardown, 60 times a second while the dice swing. Create the memory DC and DIB section once (and recreate them only when g_winW/g_winH change), and keep just the GDI+ draw + UpdateLayeredWindow in the per-frame path.

  • Redundant NULL checks on setting strings. Wh_GetStringSetting never returns NULL — it returns L"" when unset or on error — so if (pos.get()) is always true and can't do what it looks like it does. If the intent was "keep the previous value when unset", check for an empty string instead; otherwise just assign.

  • Comments are in Russian. // Инициализация мода — запускаем UI-поток etc. The repo convention is English by default.

  • g_hEventHookMax actually hooks EVENT_SYSTEM_MOVESIZEEND, not a maximize event — worth renaming to match.

  • Document the accepted ranges in the settings. diceSize is silently clamped to 20–120 and leftDiceValue/rightDiceValue to 1–6, but nothing tells the user that. A $description on each would help. Also worth noting on those two that they're ignored while randomOnClick is on.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • Primary monitor only, and no DPI scaling. ApplySettingsInternal positions the window with GetSystemMetrics(SM_CXSCREEN) and a hard-coded y = 10, so the dice always live on the primary monitor and there's no way to put them on a secondary one. It also uses the full screen width rather than the work area, and diceSize is raw pixels — on a 200 % display the dice are physically half the size they'd be at 100 %. Consider a monitor setting plus GetDpiForWindow/MonitorFromPoint + GetMonitorInfo for placement, and scaling diceSize by the target monitor's DPI.

  • Fullscreen detection ignores which monitor. A fullscreen app on a secondary monitor hides dice that are on the primary. Comparing MonitorFromWindow(hForeground, ...) against MonitorFromWindow(g_hWnd, ...) would scope it correctly.

  • Fullscreen entered without a foreground change isn't detected. The only triggers are EVENT_SYSTEM_FOREGROUND and the animation timer, and the timer is dead once the dice settle. Pressing F11 in an already-focused browser or video player doesn't change the foreground window, so the dice stay on top of it. A filtered EVENT_OBJECT_LOCATIONCHANGE on the foreground window would catch that (it's high-frequency, so gate it hard).

  • "maximize, or restore" in the README isn't quite what's hooked. EVENT_SYSTEM_MINIMIZESTART/MINIMIZEEND cover minimize and restore-from-minimized, and EVENT_SYSTEM_MOVESIZEEND covers the end of a user-driven move/resize loop. Clicking the maximize/restore button on a non-minimized window fires none of those, so no swing happens there.

  • Physics is frame-rate dependent. SetTimer(..., 16, ...) can't actually deliver 16 ms — WM_TIMER has ~15.6 ms granularity and is a low-priority message that gets skipped under load — but the integration uses fixed per-frame deltas (d.vy += 0.45f;, d.x += d.vx;). The dice will visibly swing slower on a busy machine. Scaling by an elapsed-time delta would make it consistent.

  • Topmost isn't re-asserted. The window is created WS_EX_TOPMOST and re-set on settings changes and un-hide, but any other topmost window created later will sit above the dice. That may be fine given the README's fullscreen note — just flagging it.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 5, 2026
@alivca

alivca commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@m417z I've been trying to fix this for over an hour, but I couldn't figure it out. I have no idea how to fix it. What should I do?

@alivca

alivca commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

By the way, everything works fine

@m417z

m417z commented Aug 6, 2026

Copy link
Copy Markdown
Member

The review explains it, there's not much to add. If you need community help, you can join the Windhawk Discord server and try your luck:
https://discord.com/servers/windhawk-923944342991818753

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-for-author The author's turn: request an AI review, or respond to one that was posted.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants