Creating an UI Plugin

An OpenSR UI plugin is a dedicated UI DLL loaded by the dashboard or plugin manager.
Unlike Game (IN) or OUT plugins, a UI plugin does not own its own top-level window.

The host provides a child HWND, and the plugin must build and render its entire interface inside that window.

The UI plugin lifecycle is fully managed by the host:

  • Initialize() is called when the UI page becomes active
  • Shutdown() is called when the page is closed or switched
  • Re-entering the page recreates the UI and reloads settings automatically

Because of this lifecycle, no special callback is required to notify settings changes.
If the user edits settings and returns to the UI page, Initialize() runs again and the plugin reloads its configuration naturally.

UI plugins may use:

  • Win32 + GDI
  • GDI+
  • Direct2D
  • DirectWrite
  • Any rendering system compatible with a child HWND

UI Plugin Architecture

A UI plugin implements IPluginUI.

Example export functions:

extern "C" __declspec(dllexport) IPluginUI* CreatePluginUI()
{
    return new MonitorUIPlugin();
}

extern "C" __declspec(dllexport) void DestroyPluginUI(IPluginUI* pUI)
{
    if (pUI)
        delete static_cast<MonitorUIPlugin*>(pUI);
}

Initialize()

The host passes:

BOOL Initialize(
    HWND hWindow,
    OpenSRContext* context,
    void* outBuf,
    const wchar_t* pluginFolderPath,
    const uint64_t sharedBuffer
) override;

Parameters

ParameterDescription
hWindowChild HWND owned by the host
contextOpenSR shared context
outBufOUT telemetry buffers
pluginFolderPathPlugin folder path
sharedBufferOptional shared pointer from OUT plugin

Host-Owned HWND

The most important concept:

The HWND passed to Initialize() belongs to the host application.

The UI plugin:

  • MUST NOT destroy it
  • MUST NOT subclass permanently without restoring
  • MUST render only inside this window
  • MUST cleanup all resources before Shutdown() returns

Typical usage:

m_hWnd = hWindow;

The plugin can then:

  • Create child controls
  • Subclass the window
  • Render using GDI/GDI+/D2D
  • Process messages

Creating Controls

Controls are created as children of the provided host window.

Example:

m_hDumpButton = CreateWindowExW(
    0,
    L"BUTTON",
    L"Dump Lap",
    WS_CHILD | WS_VISIBLE | BS_OWNERDRAW,
    25, 5, 80, 28,
    m_hWnd,
    (HMENU)IDOK,
    hInstance,
    NULL
);

All controls must use m_hWnd as parent.


Window Subclassing

UI plugins typically subclass the host window to intercept:

  • WM_PAINT
  • WM_COMMAND
  • WM_SIZE
  • WM_DRAWITEM
  • Mouse handling
  • Keyboard handling

Example:

SetWindowSubclass(
    m_hWnd,
    MonitorUIPlugin::SubclassWndProc,
    1,
    (DWORD_PTR)this
);

The subclass procedure can then dispatch messages to the plugin instance.


Mandatory Cleanup

Before Shutdown() returns, the plugin MUST restore the original window procedure.

Failure to un-subclass the host window will cause host instability and crashes.

Correct cleanup:

if (m_hWnd) {
    RemoveWindowSubclass(
        m_hWnd,
        MonitorUIPlugin::SubclassWndProc,
        1
    );
}

This is mandatory.


WantsDialogMessages()

BOOL WantsDialogMessages() const override
{
    return TRUE;
}

This tells the host whether the plugin wants to receive dialog-style keyboard messages.

Typical reasons:

  • TAB navigation
  • ENTER handling
  • ESC handling
  • Keyboard shortcuts
  • Input controls

If your UI does not need keyboard/dialog interaction, returning FALSE is acceptable.


Shared Communication With OUT Plugin

UI plugins may optionally communicate with their associated OUT plugin.

The OUT plugin can expose a shared pointer:

virtual uint64_t GetSharedPointer()
{
    return 0;
}

The host forwards this pointer to the UI plugin during Initialize():

const uint64_t sharedBuffer

The UI plugin may reinterpret the pointer:

SharedData* data =
    reinterpret_cast<SharedData*>(sharedBuffer);

This mechanism is entirely plugin-defined.

The host does not inspect or manage the memory.

Typical uses:

  • Shared runtime statistics
  • Device state
  • Internal queues
  • UI interaction state
  • Live monitoring data
  • Shared caches

The developer is fully responsible for:

  • Thread safety
  • Synchronization
  • Lifetime management
  • Memory ownership

The host only forwards the raw pointer.


Settings Lifecycle

UI plugins are recreated whenever the user reopens the UI page.

Example flow:

  1. User opens UI page
  2. Initialize() called
  3. Plugin loads settings
  4. User switches to Settings page
  5. UI plugin is destroyed
  6. User edits settings
  7. User returns to UI page
  8. Initialize() called again
  9. Settings reloaded automatically

Because of this lifecycle:

  • No settings callback is needed
  • No hot reload mechanism is required
  • Reinitialization naturally reloads configuration

Typical loading:

std::wstring spath =
    StringUtils::createWString(
        m_pContext->osrDocFolder,
        L"\\",
        m_pluginPath,
        L"\\settings.xml"
    );

LoadPluginSettings(spath, m_pluginSettings);

Rendering

The example implementation uses:

  • GDI
  • GDI+
  • Double buffering
  • Owner-drawn controls

However Direct2D is fully supported as well.

The only requirement is rendering into the provided child HWND.


Double Buffered Rendering

The sample uses a memory DC to avoid flickering:

HDC memDC = CreateCompatibleDC(hdc);
HBITMAP memBitmap =
    CreateCompatibleBitmap(hdc, width, height);

SelectObject(memDC, memBitmap);

After rendering:

BitBlt(
    hdc,
    0, 0,
    width, height,
    memDC,
    0, 0,
    SRCCOPY
);

This is strongly recommended for smooth UI rendering.


Render Thread

The example uses a dedicated render/update thread.

The thread periodically invalidates the window:

InvalidateRect(m_hWnd, &rc, FALSE);

This allows:

  • Controlled refresh rate
  • Background telemetry updates
  • Smooth rendering
  • Reduced CPU usage

The plugin itself owns and manages the thread lifecycle.


Thread Ownership

UI plugins are responsible for their own worker threads.

Before shutdown:

  • Signal stop
  • Wake sleeping threads
  • Join all threads
  • Release synchronization objects

Example:

m_stopRequested = true;

m_pauseCv.notify_all();
m_sleepCv.notify_all();

if (renderThread_.joinable())
    renderThread_.join();

Never leave worker threads running after Shutdown().


WM_PAINT Handling

Typical implementation:

case WM_PAINT:
{
    PAINTSTRUCT ps;
    HDC hdc = BeginPaint(hWnd, &ps);

    pThis->RenderTelemetry(hdc);

    EndPaint(hWnd, &ps);
    return 0;
}

The UI plugin completely owns rendering inside the provided client area.


WM_COMMAND Handling

Child controls send commands through the host HWND.

Example:

case WM_COMMAND:
{
    const int id = LOWORD(wParam);

    if (id == IDOK)
    {
        pThis->OnDump((HWND)lParam);
    }

    return 0;
}

Owner Draw Controls

The sample uses:

BS_OWNERDRAW

Combined with:

WM_DRAWITEM

This allows:

  • Custom styling
  • Hover states
  • Modern buttons
  • Full GDI+ rendering

GDI+ Initialization

The plugin initializes GDI+ in the constructor:

GdiplusStartupInput gdiplusStartupInput;

GdiplusStartup(
    &gdiplusToken_,
    &gdiplusStartupInput,
    NULL
);

And shuts it down in the destructor:

GdiplusShutdown(gdiplusToken_);

Important Rules

DO

  • Render only inside the provided HWND
  • Cleanup everything during shutdown
  • Remove all subclasses
  • Join all threads
  • Destroy all GDI objects
  • Handle repainting properly
  • Use double buffering

DO NOT

  • Destroy the host HWND
  • Leak threads
  • Keep dangling subclass procedures
  • Assume the UI lives forever
  • Store invalid pointers after shutdown

Minimal Lifecycle Summary

Creation

Host creates child HWND
        ↓
Load UI DLL
        ↓
CreatePluginUI()
        ↓
Initialize()
        ↓
Plugin creates controls/rendering

Destruction

Shutdown()
        ↓
Stop threads
        ↓
Remove subclasses
        ↓
Destroy resources
        ↓
DestroyPluginUI()

Recommended Structure

A typical UI plugin contains:

PluginUI/
 ├── PluginUI.cpp
 ├── PluginUI.h
 ├── Renderer.cpp
 ├── Renderer.h
 ├── Controls.cpp
 ├── Controls.h
 ├── SharedData.h
 └── settings.xml (shared)

Separating rendering, controls, and shared communication logic is strongly recommended for maintainability.

// MonitorUIPlugin.h
#pragma once

// UI DLL version of OpenSRMonitorPlugin OUT plugin (complete source code available in OpenSR SDK)
// Implements IPluginUI and renders inside the HWND provided by the host.

#include "IPluginUI.h"
#include "TrackRenderer.h"
#include "OpenSRBuffers.h"
#include "OpenSRHelper.h"
#include "StringUtils.h"
#include "TelemetryDumper.h"

#include <thread>
#include <mutex>
#include <atomic>
#include <condition_variable>
#include <gdiplus.h>

using namespace Gdiplus;

class MonitorUIPlugin : public IPluginUI {
public:
    struct PluginSettings {
        int deviceRate = 200;
    };

    MonitorUIPlugin();
    virtual ~MonitorUIPlugin();

    // IPluginSettings
    BOOL Initialize(HWND hWindow, OpenSRContext* context, void* outBuf, const wchar_t* pluginFolderPath, const uint64_t sharedBuffer) override;
    BOOL Shutdown() override;
    BOOL WantsDialogMessages() const override { return TRUE; }
   

    HWND GetHwnd() const { return m_hWnd; }

    // Button handlers (same names as original)
    void OnDump(HWND ctrl);
    void OnSelectDump(HWND ctrl);

private:
    // UI and rendering
    void CreateControls();
    void PopulateInitialState();
    void RenderTelemetry(HDC hdc);
    std::vector<std::pair<std::wstring, std::wstring>> telemetry;
    // Thread loop for periodic invalidation & pause handling
    void RenderThreadFunc();

    PluginSettings m_pluginSettings;
    bool LoadPluginSettings(const std::wstring& filepath, PluginSettings& s);

    // Subclass proc (to intercept WM_PAINT/WM_COMMAND/etc.)
    static LRESULT CALLBACK SubclassWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
        UINT_PTR uIdSubclass, DWORD_PTR dwRefData);
    static LRESULT CALLBACK ButtonSubclassWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
        UINT_PTR uIdSubclass, DWORD_PTR dwRefData);
    // Member data
    HWND m_hWnd = nullptr; // provided by host in Initialize
    OpenSRContext* m_pContext = nullptr;
    OpenSRBuffersOUT* m_pBuffersOut = nullptr;

    // Child controls
    HWND m_hDumpButton = nullptr;
    HWND m_hSelectDumpButton = nullptr;

    bool m_isDumpButtonHovered = false;
    bool m_isSelectDumpButtonHovered = false;

    // Telemetry & rendering
    TrackRenderer m_trackRenderer;
    TelemetryDumper dumper;

    // Threading
    std::thread renderThread_;
    std::atomic<bool> m_isRunning{ false };
    std::atomic<bool> m_stopRequested{ false };
    std::atomic<bool> m_isPaused{ false };
    std::mutex m_pauseMutex;
    std::condition_variable m_pauseCv;

    // GDI+
    ULONG_PTR gdiplusToken_;

    std::wstring m_pluginPath;
    // plugin folder path (if needed)
    std::wstring m_pluginFolderPath;
    std::wstring m_mapPath;

    std::condition_variable m_sleepCv;
    std::mutex m_sleepMutex;
};
// MonitorUIPlugin.cpp
#include <windows.h>
#include "MonitorUIPlugin.h"
#include <windowsx.h>
#include <shlwapi.h>
#include <string>
#include <vector>
#include <chrono>
#include <iostream>
#include <CommCtrl.h>
#include "tinyxml2.h"

#pragma comment(lib, "Comctl32.lib")
#pragma comment(lib, "shlwapi.lib")

using namespace tinyxml2;


// Exports

extern "C" __declspec(dllexport) IPluginUI * CreatePluginUI()
{
    return new MonitorUIPlugin();
}

extern "C" __declspec(dllexport) void DestroyPluginUI(IPluginUI * pUI)
{
    if (pUI) delete static_cast<MonitorUIPlugin*>(pUI);
}

// Implementation

MonitorUIPlugin::MonitorUIPlugin()
    : m_pContext(nullptr), m_pBuffersOut(nullptr), m_hWnd(nullptr)
{
    // Initialize GDI+
    GdiplusStartupInput gdiplusStartupInput;
    Gdiplus::GdiplusStartup(&gdiplusToken_, &gdiplusStartupInput, NULL);
}

MonitorUIPlugin::~MonitorUIPlugin()
{
    // Ensure thread is stopped and cleaned
    Shutdown();

    // Shutdown GDI+
    Gdiplus::GdiplusShutdown(gdiplusToken_);
}

BOOL MonitorUIPlugin::Initialize(HWND hWindow, OpenSRContext* context, void* outBuf, const wchar_t* pluginPath, const uint64_t sharedbuffer)
{
    m_hWnd = hWindow;
    m_pContext = context;
    m_pBuffersOut = static_cast<OpenSRBuffersOUT*>(outBuf);
    
    if (m_pContext) {
        // create Map folder inside plugin directory, 
        // reminder a global Map folder exists already in parent directory /OpenSR/Map/

        // get the path
        m_pluginFolderPath = StringUtils::createWString(context->osrDocFolder, L"\\", pluginPath);
        m_mapPath = StringUtils::createWString(m_pluginFolderPath, "\\Map");
        // create if not exist
        _wmkdir(m_mapPath.c_str());

        // settings stuff
        m_pluginPath = pluginPath;
        std::wstring spath = StringUtils::createWString(m_pContext->osrDocFolder, L"\\", m_pluginPath, L"\\settings.xml");
        LoadPluginSettings(spath, m_pluginSettings);
    
        // Create controls inside provided host HWND
        CreateControls();

        // Populate initial values (if any)
        PopulateInitialState();

        // Subclass host window to intercept WM_PAINT, WM_COMMAND, WM_SIZE, etc.
        // Use 'this' as refData so subclass proc can access instance.
        SetWindowSubclass(m_hWnd, MonitorUIPlugin::SubclassWndProc, 1, (DWORD_PTR)this);

        // Start the render/update thread
        m_stopRequested = false;
        m_isRunning = true;
        renderThread_ = std::thread(&MonitorUIPlugin::RenderThreadFunc, this);
    }
    return TRUE;
}

bool MonitorUIPlugin::LoadPluginSettings(const std::wstring& filepath, PluginSettings& s) {
    tinyxml2::XMLDocument doc;

    FILE* file = _wfsopen(filepath.c_str(), L"rb", _SH_DENYNO);
    if (!file) {
        std::wcerr << L"Failed to open file: " << filepath << std::endl;
        return false;
    }

    tinyxml2::XMLError error = doc.LoadFile(file);
    fclose(file);
    if (error != XML_SUCCESS)
        return false; // File missing or error, defaults remain

    XMLElement* root = doc.FirstChildElement("settings");
    if (!root) return false;

    for (XMLElement* opt = root->FirstChildElement("option"); opt; opt = opt->NextSiblingElement("option")) {
        const char* name = opt->Attribute("name");
        const char* value = opt->GetText();
        if (!name || !value) continue;

        if (strcmp(name, "plugin update delay (ms)") == 0) {
            int idd = atoi(value);
            if (idd >= 10 && idd <= 1000) {
                s.deviceRate = idd;
            }
            else
                s.deviceRate = 200;
        }
    }
    return true;
}

// The implementation of Shutdown must restore the original Window Procedure for the HWND provided by the host.
// Failure to un subclass the window before this function returns will result in host application instability and crashes.
//
BOOL MonitorUIPlugin::Shutdown()
{
    // Stop thread
    if (m_isRunning) {
        m_stopRequested = true;
        {
            std::lock_guard<std::mutex> lock(m_pauseMutex);
            m_isPaused = false;
        }
        m_pauseCv.notify_all();
        m_sleepCv.notify_all();

        if (renderThread_.joinable()) renderThread_.join();
        m_isRunning = false;
    }

    // Remove subclass
    if (m_hWnd) {
        RemoveWindowSubclass(m_hWnd, MonitorUIPlugin::SubclassWndProc, 1);
    }

    // Return TRUE if dumper or other state indicates changes (here follow original dumper flag)
    return dumper.m_dumpReady;
}

// Create child controls similar to OpenSRMonitorPlugin's CreateWindow controls
void MonitorUIPlugin::CreateControls()
{
    if (!m_hWnd) return;

    HINSTANCE hInstance = (HINSTANCE)GetWindowLongPtr(m_hWnd, GWLP_HINSTANCE);

    // Using layout similar to the original monitor plugin but placed inside provided HWND
    const int buttonWidth = 80;
    const int buttonHeight = 28;

    const int buttonMargin = 2;

    m_hDumpButton = CreateWindowExW(0, L"BUTTON", L"Dump Lap",
        WS_CHILD | WS_VISIBLE | BS_OWNERDRAW,
        25, 5, buttonWidth - buttonMargin, buttonHeight - buttonMargin, m_hWnd,
        (HMENU)IDOK, hInstance, NULL);

    m_hSelectDumpButton = CreateWindowExW(0, L"BUTTON", L"Select Dump Lap",
        WS_CHILD | WS_VISIBLE | BS_OWNERDRAW,
        250, 5, buttonWidth + 45 - buttonMargin, buttonHeight - buttonMargin, m_hWnd,
        (HMENU)IDCANCEL, hInstance, NULL);

    if (m_hDumpButton) {
        SetWindowSubclass(m_hDumpButton, MonitorUIPlugin::ButtonSubclassWndProc, 2, (DWORD_PTR)this);
    }
    if (m_hSelectDumpButton) {
        SetWindowSubclass(m_hSelectDumpButton, MonitorUIPlugin::ButtonSubclassWndProc, 3, (DWORD_PTR)this);
    }
    // Note: Further UI (if you want) could be added, but original monitor only had these two buttons plus painted content.
    // Force initial paint
    InvalidateRect(m_hWnd, NULL, TRUE);
}

// Populate initial UI state if needed
void MonitorUIPlugin::PopulateInitialState()
{
    if (m_hDumpButton) {
        if (dumper.m_dumpReady) {
            SetWindowText(m_hDumpButton, L"Stop");
        }
        else {
            SetWindowText(m_hDumpButton, L"Dump Lap");
        }
    }
}

// Dump handlers (copied/adapted from OpenSRMonitorPlugin)
void MonitorUIPlugin::OnSelectDump(HWND /*ctrl*/)
{
    if (m_pContext) {
        OPENFILENAME ofn;
        TCHAR szFile[260] = { 0 };

        ZeroMemory(&ofn, sizeof(ofn));
        ofn.lStructSize = sizeof(ofn);
        ofn.hwndOwner = m_hWnd;
        ofn.lpstrFile = szFile;
        ofn.nMaxFile = sizeof(szFile);
        ofn.lpstrFilter = L".lap Files\0*.lap\0All Files\0*.*\0";
        ofn.nFilterIndex = 1;
        ofn.lpstrInitialDir = m_mapPath.c_str();
        ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST;

        if (GetOpenFileName(&ofn) == TRUE) {
            std::wstring fullPath(ofn.lpstrFile);
            size_t lastSlash = fullPath.find_last_of(L"\\/");
            std::wstring pathOnly = fullPath.substr(0, lastSlash);
            std::wstring filename = L"\\" + fullPath.substr(lastSlash + 1);

            if (filename.size() < 4 || filename.substr(filename.size() - 4) != L".lap") {
                std::wcout << L"Selected file is not a .lap file." << std::endl;
                return;
            }

            m_trackRenderer.m_fullPath = fullPath;
            m_trackRenderer.init(pathOnly.c_str(), filename.c_str());
        }
    }
}

void MonitorUIPlugin::OnDump(HWND ctrl)
{
    if (dumper.m_dumpReady) {
        dumper.Stop();
    }
    else if (m_pContext && !dumper.m_dumpReady) {
        OPENFILENAME ofn;
        TCHAR szFile[260] = { 0 };

        ZeroMemory(&ofn, sizeof(ofn));
        ofn.lStructSize = sizeof(ofn);
        ofn.hwndOwner = m_hWnd;
        ofn.lpstrFile = szFile;
        ofn.nMaxFile = sizeof(szFile);
        ofn.lpstrFilter = L".lap Files\0*.lap\0All Files\0*.*\0";
        ofn.nFilterIndex = 1;
        ofn.lpstrInitialDir = m_mapPath.c_str();
        ofn.Flags = OFN_PATHMUSTEXIST | OFN_OVERWRITEPROMPT;

        if (GetSaveFileName(&ofn) == TRUE) {
            std::wstring fullPath(ofn.lpstrFile);
            size_t lastSlash = fullPath.find_last_of(L"\\/");
            std::wstring pathOnly = fullPath.substr(0, lastSlash);
            std::wstring filename = L"\\" + fullPath.substr(lastSlash + 1);
            if (filename.size() < 4 || filename.substr(filename.size() - 4) != L".lap") {
                filename += L".lap";
                fullPath = pathOnly + L"\\" + filename;
            }
            dumper.Init(pathOnly.c_str(), filename.c_str());
        }
        else {
            return;
        }
    }

    // Toggle button text
    if (ctrl) {
        if (dumper.m_dumpReady) {
            SetWindowText(ctrl, L"Stop");
        }
        else {
            SetWindowText(ctrl, L"Dump Lap");
        }
        InvalidateRect(ctrl, NULL, FALSE);
    }
}
template<typename... Args>
std::wstring wfmt(const wchar_t* fmt, Args... args)
{
    wchar_t buf[256];
    swprintf_s(buf, fmt, args...);   // safe formatting into buffer
    return std::wstring(buf);        // return it as std::wstring
}

void MonitorUIPlugin::RenderTelemetry(HDC hdc)
{
    if (!m_pContext || !m_pBuffersOut) return;

    RECT clientRect;
    GetClientRect(m_hWnd, &clientRect);
    int width = clientRect.right;
    int height = clientRect.bottom;

    // 1. Double Buffering & Save State
    HDC memDC = CreateCompatibleDC(hdc);
    HBITMAP memBitmap = CreateCompatibleBitmap(hdc, width, height);
    HGDIOBJ oldBitmap = SelectObject(memDC, memBitmap);
    int savedDC = SaveDC(memDC);

    // 2. High-Speed Fill
    // PatBlt is often faster than FillRect for solid colors
    BitBlt(memDC, 0, 0, width, height, NULL, 0, 0, WHITENESS);

    OutSimData* outSim = m_pBuffersOut->outSimDataOUT;
    if (!outSim || !outSim->mPacketHeader.reportAvailable) {
        DrawText(memDC, L"Telemetry Not Available", -1, &clientRect, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
    }
    else {
        SelectObject(memDC, GetStockObject(DEFAULT_GUI_FONT));
        SetBkMode(memDC, TRANSPARENT); // Vital for performance with TextOut

        TEXTMETRIC tm;
        GetTextMetrics(memDC, &tm);
        const int lineHeight = tm.tmHeight + 2;
        const int margin = 10;
        const int colWidth = (width - (margin * 2)) / ((width > 1000) ? 3 : (width > 600 ? 2 : 1));
        const int labelValueGap = 135;

        int count = 0;
        wchar_t vB[128]; // Value Buffer
        wchar_t lB[128]; // Label Buffer

        // Ultra-fast Drawing Lambda using ExtTextOut
        auto drawRow = [&](const wchar_t* lbl, const wchar_t* val) {
            int col = count / (110 / ((width > 1000) ? 3 : (width > 600 ? 2 : 1))); // Approximate rows
            // Simplified positioning logic for speed
            int x = margin + (count % ((width > 1000) ? 3 : (width > 600 ? 2 : 1))) * colWidth;
            int y = margin + 40 + (count / ((width > 1000) ? 3 : (width > 600 ? 2 : 1))) * lineHeight;

            SetTextColor(memDC, RGB(60, 60, 60));
            ExtTextOutW(memDC, x, y, 0, NULL, lbl, (UINT)wcslen(lbl), NULL);

            SetTextColor(memDC, RGB(0, 0, 0));
            ExtTextOutW(memDC, x + labelValueGap, y, 0, NULL, val, (UINT)wcslen(val), NULL);
            count++;
        };

        // --- PACKET HEADER ---
        swprintf_s(vB, L"%d", outSim->mPacketHeader.paused); drawRow(L"Paused", vB);
        swprintf_s(vB, L"%d", outSim->mPacketHeader.reportAvailable); drawRow(L"Report Available", vB);
        swprintf_s(vB, L"%d", outSim->mPacketHeader.frameId); drawRow(L"Frame Id", vB);
        swprintf_s(vB, L"%d", outSim->mPacketHeader.playerSlotIndex); drawRow(L"Player Slot", vB);

        // --- PLAYER DATA ---
        int slot = outSim->mPacketHeader.playerSlotIndex;
        Point3D car_pos = { 0,0,0 };
        if (slot > -1) {
            auto& p = outSim->mPlayers.player[slot];
            car_pos = { p.worldPositionX, p.worldPositionY, p.worldPositionZ };
            wchar_t wD[64]; MultiByteToWideChar(CP_UTF8, 0, p.driverName, -1, wD, 64);
            drawRow(L"Driver", wD);
            swprintf_s(vB, L"%.3f s", p.currentLapTime); drawRow(L"Lap Time", vB);
            swprintf_s(vB, L"%.2f", p.bestLapTime); drawRow(L"Best Lap Time", vB);
            swprintf_s(vB, L"%.2f m", p.lapDist); drawRow(L"Lap Dist", vB);
            swprintf_s(vB, L"%d", p.currentLap); drawRow(L"Lap", vB);
            swprintf_s(vB, L"P%d", p.position); drawRow(L"Position", vB);
            swprintf_s(vB, L"%d", p.sector); drawRow(L"Sector", vB);
            swprintf_s(vB, L"%.2f", car_pos.x); drawRow(L"World Pos X", vB);
            swprintf_s(vB, L"%.2f", car_pos.y); drawRow(L"World Pos Y", vB);
            swprintf_s(vB, L"%.2f", car_pos.z); drawRow(L"World Pos Z", vB);
        }

        // --- VEHICLE DATA ---
        auto& v = outSim->mVehicleData;
        swprintf_s(vB, L"%.1f km/h", v.speed); drawRow(L"Speed", vB);
        swprintf_s(vB, L"%d", v.gear); drawRow(L"Gear", vB);
        swprintf_s(vB, L"%.0f", v.rpm); drawRow(L"RPM", vB);
        swprintf_s(vB, L"%.0f", v.maxRpm); drawRow(L"Max RPM", vB);
        swprintf_s(vB, L"%.0f %%", v.throttle); drawRow(L"Throttle", vB);
        swprintf_s(vB, L"%.0f %%", v.brake); drawRow(L"Brake", vB);
        swprintf_s(vB, L"%.0f %%", v.clutch); drawRow(L"Clutch", vB);
        swprintf_s(vB, L"%.0f %%", v.handbrake); drawRow(L"Handbrake", vB);
        swprintf_s(vB, L"%.1f°", v.steer); drawRow(L"Steer Angle", vB);
        swprintf_s(vB, L"%.1f %%", v.brakeBias); drawRow(L"Brake Bias", vB);
        swprintf_s(vB, L"%.1f °C", v.engineTemp); drawRow(L"Engine Temp", vB);
        swprintf_s(vB, L"%.1f °C", v.oilTemp); drawRow(L"Oil Temp", vB);
        swprintf_s(vB, L"%.1f bar", v.oilPress); drawRow(L"Oil Pressure", vB);
        swprintf_s(vB, L"%.1f °C", v.waterTemp); drawRow(L"Water Temp", vB);
        swprintf_s(vB, L"%.1f bar", v.waterLevel); drawRow(L"Water Level", vB);
        swprintf_s(vB, L"%.1f bar", v.fuelPressure); drawRow(L"Fuel Pressure", vB);
        swprintf_s(vB, L"%.1f bar", v.boost); drawRow(L"Boost", vB);
        swprintf_s(vB, L"%.1f L", v.fuelLevel); drawRow(L"Fuel Level", vB);
        swprintf_s(vB, L"%.1f L", v.fuelCapacity); drawRow(L"Fuel Capacity", vB);
        swprintf_s(vB, L"%.1f L/h", v.fuelUsePerHour); drawRow(L"Fuel Use/Hour", vB);

        // --- WHEELS / BRAKES / SUSPENSION ---
        const wchar_t* wPos[4] = { L"FL", L"FR", L"RL", L"RR" };
        for (int i = 0; i < 4; i++) {
            auto& wh = outSim->mWheelData.wheels[i];
            swprintf_s(lB, L"Tire Temp %s", wPos[i]); swprintf_s(vB, L"%.1f °C", wh.tyreTempCore); drawRow(lB, vB);
            swprintf_s(lB, L"Tire Pres %s", wPos[i]); swprintf_s(vB, L"%.1f psi", wh.tyrePressure); drawRow(lB, vB);
            swprintf_s(lB, L"Tire Wear %s", wPos[i]); swprintf_s(vB, L"%.2f %%", wh.tyreWearOverall); drawRow(lB, vB);
            swprintf_s(lB, L"Tire Comp %s", wPos[i]); swprintf_s(vB, L"%d", wh.tyreVisualCompound); drawRow(lB, vB);
            swprintf_s(lB, L"Tire Dirt %s", wPos[i]); swprintf_s(vB, L"%.1f %%", wh.tyreDirtyLevel); drawRow(lB, vB);
            swprintf_s(lB, L"Slip Ang %s", wPos[i]); swprintf_s(vB, L"%.1f°", wh.wheelSlipAngle); drawRow(lB, vB);
            swprintf_s(lB, L"Wheel Spd %s", wPos[i]); swprintf_s(vB, L"%.2f m/s", wh.wheelSpeed); drawRow(lB, vB);
            swprintf_s(lB, L"Tire Grip %s", wPos[i]); swprintf_s(vB, L"%.2f m", wh.tyreGrip); drawRow(lB, vB);
            swprintf_s(lB, L"Surface %s", wPos[i]); swprintf_s(vB, L"%d", wh.wheelSurfaceId); drawRow(lB, vB);

            swprintf_s(lB, L"Brake Temp %s", wPos[i]); swprintf_s(vB, L"%.1f °C", v.brakes[i].brakeDiscTemp); drawRow(lB, vB);
            swprintf_s(lB, L"Brake Wear %s", wPos[i]); swprintf_s(vB, L"%.1f %%", v.brakes[i].brakePadWear); drawRow(lB, vB);

            swprintf_s(lB, L"Susp Defl %s", wPos[i]); swprintf_s(vB, L"%.2f m", v.suspensions[i].suspDeflection); drawRow(lB, vB);
            swprintf_s(lB, L"Susp Vel %s", wPos[i]); swprintf_s(vB, L"%.2f m/s", v.suspensions[i].suspVelocity); drawRow(lB, vB);
        }

        // --- ENVIRONMENT ---
        auto& e = outSim->mSessionData.environment;
        swprintf_s(vB, L"%.1f °C", e.trackTemp); drawRow(L"Track Temp", vB);
        swprintf_s(vB, L"%.1f °C", e.airTemp); drawRow(L"Air Temp", vB);
        swprintf_s(vB, L"%.0f %%", e.humidity); drawRow(L"Humidity", vB);
        swprintf_s(vB, L"%.1f km/h", e.windSpeed); drawRow(L"Wind Speed", vB);
        swprintf_s(vB, L"%.0f°", e.windDirection); drawRow(L"Wind Dir", vB);
        swprintf_s(vB, L"%.1f %%", e.rainLevel); drawRow(L"Rain Density", vB);

        // --- MOTION ---
        auto& m = outSim->mMotionData;
        swprintf_s(vB, L"%.2f", m.carCGLocX); drawRow(L"CarGCLoc X", vB);
        swprintf_s(vB, L"%.2f", m.carCGLocY); drawRow(L"CarGCLoc Y", vB);
        swprintf_s(vB, L"%.2f", m.carCGLocZ); drawRow(L"CarGCLoc Z", vB);
        swprintf_s(vB, L"%.2f°", m.pitch); drawRow(L"Pitch", vB);
        swprintf_s(vB, L"%.2f°", m.roll); drawRow(L"Roll", vB);
        swprintf_s(vB, L"%.2f°", m.yaw); drawRow(L"Yaw", vB);
        swprintf_s(vB, L"%.2f m/s²", m.localAccelX); drawRow(L"Accel X", vB);
        swprintf_s(vB, L"%.2f m/s²", m.localAccelY); drawRow(L"Accel Y", vB);
        swprintf_s(vB, L"%.2f m/s²", m.localAccelZ); drawRow(L"Accel Z", vB);

        if (dumper.m_dumpReady && slot > -1) {
            dumper.dumpPoint(outSim->mPlayers.player[slot].currentLapTime * 1000, v.speed, (int)v.gear, v.rpm, car_pos);
        }

        RECT mapRect = { 10, 410, 652, 820 };
        m_trackRenderer.Draw(memDC, mapRect, car_pos);
    }

    // 3. Blit and cleanup
    BitBlt(hdc, 0, 0, width, height, memDC, 0, 0, SRCCOPY);
    RestoreDC(memDC, savedDC);
    SelectObject(memDC, oldBitmap);
    DeleteObject(memBitmap);
    DeleteDC(memDC);
}


// thread that invalidates the host window at ~30 FPS, respecting pause
void MonitorUIPlugin::RenderThreadFunc()
{
    bool firstPass = true;
    OutSimData* outSim = m_pBuffersOut->outSimDataOUT;
    int ms = 200;
    while (!m_stopRequested) {
        {
            std::unique_lock<std::mutex> lock(m_pauseMutex);
            m_pauseCv.wait(lock, [this]() { return !m_isPaused || m_stopRequested; });
            if (m_stopRequested) break;
        }
        if (!outSim || !outSim->mPacketHeader.reportAvailable || outSim->mPacketHeader.paused)
            ms = m_pluginSettings.deviceRate * 12;
        else
            ms = m_pluginSettings.deviceRate;

        // Ask host window to repaint
        if (m_hWnd) {
            RECT rc;
            GetClientRect(m_hWnd, &rc);

            // exclude top area where buttons live (adjust if layout changes)
            if (firstPass) {
                firstPass = false;
                rc.top = 0;
            } else
                rc.top = 52;

            InvalidateRect(m_hWnd, &rc, FALSE);
        }

        // Sleep to target ~30 FPS
      //  std::this_thread::sleep_for(std::chrono::milliseconds(ms));
        {
            std::unique_lock<std::mutex> lock(m_sleepMutex);
            m_sleepCv.wait_for(lock, std::chrono::milliseconds(ms), [this]() {
                return m_stopRequested.load();
                });
        }

        // If host app stopped running (if context communicates), we can exit early
        if (m_pContext && !m_pContext->isAppRunning) break;
    }
}

LRESULT CALLBACK MonitorUIPlugin::ButtonSubclassWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData)
{
    MonitorUIPlugin* pThis = reinterpret_cast<MonitorUIPlugin*>(dwRefData);
    if (!pThis) {
        return DefSubclassProc(hWnd, uMsg, wParam, lParam);
    }

    // Determine which button this is and get a pointer to its hover flag
    bool* pHoverFlag = nullptr;
    if (hWnd == pThis->m_hDumpButton) {
        pHoverFlag = &pThis->m_isDumpButtonHovered;
    }
    else if (hWnd == pThis->m_hSelectDumpButton) {
        pHoverFlag = &pThis->m_isSelectDumpButtonHovered;
    }
    else {
        return DefSubclassProc(hWnd, uMsg, wParam, lParam);
    }

    switch (uMsg)
    {
    case WM_MOUSEMOVE:
        // When the mouse moves over the button, if we are not already
        // tracking, set the hover flag and request a WM_MOUSELEAVE message.
        if (!(*pHoverFlag))
        {
            *pHoverFlag = true;
            InvalidateRect(hWnd, NULL, FALSE); // Force a repaint to show hover state

            TRACKMOUSEEVENT tme;
            tme.cbSize = sizeof(TRACKMOUSEEVENT);
            tme.dwFlags = TME_LEAVE; // We only need to know when the mouse leaves
            tme.hwndTrack = hWnd;
            TrackMouseEvent(&tme);
        }
        break;

    case WM_MOUSELEAVE:
        // The mouse has left the button. Reset the hover flag and force
        // a repaint to return to the normal state.
        *pHoverFlag = false;
        InvalidateRect(hWnd, NULL, FALSE);
        return 0; // Handled
    }

    return DefSubclassProc(hWnd, uMsg, wParam, lParam);
}
// Window subclass to intercept messages
LRESULT CALLBACK MonitorUIPlugin::SubclassWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
    UINT_PTR /*uIdSubclass*/, DWORD_PTR dwRefData)
{
    MonitorUIPlugin* pThis = reinterpret_cast<MonitorUIPlugin*>(dwRefData);
    if (!pThis) return DefSubclassProc(hWnd, uMsg, wParam, lParam);

    switch (uMsg) {
    case WM_PAINT:
    {
        PAINTSTRUCT ps;
        HDC hdc = BeginPaint(hWnd, &ps);

        // Double-buffered painting
        RECT clientRect;
        GetClientRect(hWnd, &clientRect);
        int width = clientRect.right - clientRect.left;
        int height = clientRect.bottom - clientRect.top;

        HDC hdcMem = CreateCompatibleDC(hdc);
        HBITMAP hbmBack = CreateCompatibleBitmap(hdc, std::max<>(1, width), std::max<>(1, height));
        HBITMAP hbmOld = (HBITMAP)SelectObject(hdcMem, hbmBack);

        // call render
        pThis->RenderTelemetry(hdcMem);

        // blit
        BitBlt(hdc, 0, 0, width, height, hdcMem, 0, 0, SRCCOPY);

        // cleanup
        SelectObject(hdcMem, hbmOld);
        DeleteObject(hbmBack);
        DeleteDC(hdcMem);

        EndPaint(hWnd, &ps);
        return 0;
    }
    case WM_SETCURSOR:
    {
        // Ensure the normal arrow cursor when the mouse is inside this window
        if (LOWORD(lParam) == HTCLIENT)
        {
            SetCursor(LoadCursor(NULL, IDC_ARROW));
            return TRUE; // handled
        }
        break;
    }
    case WM_COMMAND:
    {
        const int id = LOWORD(wParam);
        const int code = HIWORD(wParam);
        HWND hCtl = (HWND)lParam;

        if (id == IDOK && code == BN_CLICKED) {
            // Dump button pressed
            pThis->OnDump(hCtl);
        }
        else if (id == IDCANCEL && code == BN_CLICKED) {
            // Select dump
            pThis->OnSelectDump(hCtl);
        }
        return 0;
    }
    case WM_DESTROY:
        // host might destroy; ensure thread stops
        pThis->m_stopRequested = true;
        pThis->m_pauseCv.notify_all();
        return 0; // DefSubclassProc(hWnd, uMsg, wParam, lParam);
    case WM_ERASEBKGND:
        // we'll handle background in WM_PAINT
        return 1;
    case WM_DRAWITEM:
    {
        LPDRAWITEMSTRUCT dis = (LPDRAWITEMSTRUCT)lParam;
        if (dis->hwndItem == pThis->m_hDumpButton || dis->hwndItem == pThis->m_hSelectDumpButton)
        {
            Graphics g(dis->hDC);
            // No anti-aliasing needed for sharp rectangles, but we keep it for the text.
            g.SetTextRenderingHint(TextRenderingHintAntiAlias);

            // Get the full client rect of the button
            RectF rc((REAL)dis->rcItem.left, (REAL)dis->rcItem.top,
                (REAL)(dis->rcItem.right - dis->rcItem.left),
                (REAL)(dis->rcItem.bottom - dis->rcItem.top));

            // 1. Determine button state and colors
            bool pressed = (dis->itemState & ODS_SELECTED) != 0;
            bool focused = (dis->itemState & ODS_FOCUS) != 0;
            bool hot = (dis->hwndItem == pThis->m_hDumpButton)
                ? pThis->m_isDumpButtonHovered
                : pThis->m_isSelectDumpButtonHovered;

            Color bgColor = pressed ? Color(204, 232, 255) // Pressed blue
                : (hot ? Color(229, 243, 255)   // Hover blue
                    : Color(250, 250, 250)); // Normal off-white


            // Use a slightly darker blue for the border when focused, otherwise a standard gray.
            Color borderColor = focused ? Color(0, 120, 215) : Color(173, 173, 173);

            SolidBrush bgBrush(bgColor);
            SolidBrush borderBrush(borderColor);
            const int borderWidth = 1;

            // 2. Draw the border by filling the entire rectangle with the border color.
            g.FillRectangle(&borderBrush, rc);

            // 3. Draw the background by filling an inner rectangle.
            //    This leaves a 1px "frame" which acts as our border.
            RectF innerRc(rc.X + borderWidth, rc.Y + borderWidth,
                rc.Width - (borderWidth * 2), rc.Height - (borderWidth * 2));
            g.FillRectangle(&bgBrush, innerRc);



            // 4. Draw the caption (text) centered in the button
            WCHAR text[128];
            GetWindowText(dis->hwndItem, text, 128);
            FontFamily ff(L"Segoe UI");
            Font font(&ff, 14.0f, FontStyleRegular, UnitPixel);
            StringFormat sf;
            sf.SetAlignment(StringAlignmentCenter);
            sf.SetLineAlignment(StringAlignmentCenter);
            SolidBrush txtBrush(Color(30, 30, 30));

            // Draw string in the original full rectangle for perfect centering
            g.DrawString(text, -1, &font, rc, &sf, &txtBrush);

            return TRUE;
        }
        break;
    }
    default:
        break;
    }

    return DefSubclassProc(hWnd, uMsg, wParam, lParam);
}