From 68f44f5938e3401e9fdcd375387ed813bcde5164 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Fri, 12 Jun 2026 12:08:36 -0400 Subject: [PATCH 1/2] GUI: D3D12 web view backend and disk-served UI schemes Adds a D3D12 path for the CEF web UI (previously D3D11-only) and a way to serve mod-bundled HTML UIs from disk. Graphics: - Renderer reports the configured backend type; it previously always returned the D3D11 default, so D3D12 consumers silently built no-op views - D3D12Backend grows an SRV descriptor slot allocator (64 slots after the ImGui font descriptors) and a fenced WaitForGpu GUI: - ViewD3D12 (CPU OSR): CEF OnPaint buffers upload through per-frame-in-flight, row-pitch-aligned upload buffers and CopyTextureRegion on the backend command list, drawn through the ImGui background draw list (SRV handles double as ImTextureID since ImGui initializes against the same heap). GPU-accelerated OSR stays D3D11-only; ViewD3D12 falls back to the CPU path. - Threading: Manager::Render (uploads) runs on the render thread between backend Begin and the ImGui render; SubmitImGuiDraws runs on the game thread inside the ImGui frame. The CEF pump runs outside the render mutex: it dispatches the game window's messages mid-tick, and a handler blocking on the render thread (the exit flow's RHI flush) deadlocked against Render's lock on the render thread - observed as a minutes-long exit stall. All _views readers lock the (mutable) recursive mutex. - RenderHandler owns its pixel-buffer synchronization: OnPaint (running inside the pump) locks while writing, and both view backends hold the same lock for the whole read - previously the manager mutex around the pump was incidentally the only thing serializing these. - Destroyed views retire into a dying list for 8 ticks (draw data built on the game thread can reference a texture for a couple of frames after removal) and drain the GPU before freeing; the resize path drains likewise before recreating texture, upload buffers and the in-place SRV. - CreateDiskResourceHandler serves files from a directory tree through RegisterSchemeHandlerFactory: the URL path resolves against a root dir via Utils::ResolvePathUnderRoot ("/" maps to index.html, root-escape and drive-injection rejected, unit-tested), then streams via CefStreamResourceHandler with Chromium's own mime table (CefGetMimeType) and Cache-Control no-store (local files change while iterating on UIs). Misses yield a 404 handler, never nullptr - that would fall through to the network stack. File paths hand CEF the wide string: path::string() narrows to the ANSI codepage on Windows while CefString decodes std::string as UTF-8, so non-ASCII install paths would 404. - The scheme handler registry is guarded (Create runs per request on the CEF IO thread, registration on the game thread) and factories are cleared before CefShutdown - outstanding references can stall it. - Shutdown pumps the message loop until every browser has passed OnBeforeClose (3s cap) plus a settle pass before CefShutdown, skips CefShutdown during process teardown (CEF's threads are already gone and it would deadlock), and writes an unbuffered stage trace to logs/web_shutdown_trace.log - shutdown is exactly where the regular log can no longer be read. - Manager::Init resolves the CEF subprocess path from the module owning the framework code (a game-injected DLL ships its own CEF binaries next to itself) and fails loudly when that resolution fails; the CEF cache is scoped per process id so two clients on one machine don't fight over the process-singleton lock. Utils: - ResolvePathUnderRoot: lexical URL-path-under-root resolution with traversal/injection rejection, extracted CEF-free so it unit-tests (code/tests/modules/path_resolve_ut.h - 7 cases incl. dot-dot, backslash and drive injection) - IsProcessShutdownInProgress (RtlDllShutdownInProgress wrapper) Verified in HogwartsMP (D3D12): interactive fullscreen views, transparent HTML chat overlay served from a local scheme, clean ~1s CEF teardown on game exit. FrameworkTests: 100 tests, the single js_features failure is pre-existing on the base revision. Known limitations: CPU OSR only on D3D12, full-buffer uploads (no dirty-rect optimization), no HTTP Range support in the disk handler (CefStreamResourceHandler serves whole files), swapchain resize remains a passthrough. --- code/framework/CMakeLists.txt | 2 + code/framework/src/graphics/backend/d3d12.cpp | 113 ++++++- code/framework/src/graphics/backend/d3d12.h | 32 ++ code/framework/src/graphics/renderer.cpp | 3 +- code/framework/src/gui/backend/view_d3d11.cpp | 42 +-- code/framework/src/gui/backend/view_d3d12.cpp | 277 ++++++++++++++++++ code/framework/src/gui/backend/view_d3d12.h | 66 +++++ code/framework/src/gui/cef/app.cpp | 18 +- code/framework/src/gui/cef/app.h | 4 + .../src/gui/cef/disk_resource_handler.cpp | 67 +++++ .../src/gui/cef/disk_resource_handler.h | 23 ++ .../src/gui/cef/life_span_handler.cpp | 8 + .../framework/src/gui/cef/life_span_handler.h | 10 + code/framework/src/gui/cef/render_handler.cpp | 4 + code/framework/src/gui/cef/render_handler.h | 9 +- code/framework/src/gui/manager.cpp | 189 +++++++++--- code/framework/src/gui/manager.h | 23 +- code/framework/src/gui/view.h | 9 + code/framework/src/utils/path.cpp | 27 ++ code/framework/src/utils/path.h | 7 + code/framework/src/utils/process_shutdown.h | 27 ++ code/tests/framework_ut.cpp | 2 + code/tests/modules/path_resolve_ut.h | 66 +++++ 23 files changed, 966 insertions(+), 62 deletions(-) create mode 100644 code/framework/src/gui/backend/view_d3d12.cpp create mode 100644 code/framework/src/gui/backend/view_d3d12.h create mode 100644 code/framework/src/gui/cef/disk_resource_handler.cpp create mode 100644 code/framework/src/gui/cef/disk_resource_handler.h create mode 100644 code/framework/src/utils/process_shutdown.h create mode 100644 code/tests/modules/path_resolve_ut.h diff --git a/code/framework/CMakeLists.txt b/code/framework/CMakeLists.txt index 24eef1109..90e380f31 100644 --- a/code/framework/CMakeLists.txt +++ b/code/framework/CMakeLists.txt @@ -91,9 +91,11 @@ list(APPEND FRAMEWORK_CLIENT_SRC src/gui/view.cpp src/gui/sdk.cpp src/gui/backend/view_d3d11.cpp + src/gui/backend/view_d3d12.cpp src/gui/cef/app.cpp src/gui/cef/renderer_app.cpp src/gui/cef/client.cpp + src/gui/cef/disk_resource_handler.cpp src/gui/cef/render_handler.cpp src/gui/cef/life_span_handler.cpp src/gui/cef/load_handler.cpp diff --git a/code/framework/src/graphics/backend/d3d12.cpp b/code/framework/src/graphics/backend/d3d12.cpp index 743abdfe1..70de38983 100644 --- a/code/framework/src/graphics/backend/d3d12.cpp +++ b/code/framework/src/graphics/backend/d3d12.cpp @@ -9,18 +9,25 @@ #include "d3d12.h" #include +#include + +#include namespace Framework::Graphics { bool D3D12Backend::Init(const Framework::Graphics::RendererConfiguration &opts) { const auto swapChain = opts.d3d12.swapchain; const auto commandQueue = opts.d3d12.commandQueue; - _device = opts.d3d12.device; _context = opts.d3d12.deviceContext; - // #1 get device from swapchain (maybe different device) + // #1 get device from swapchain — and use it as THE device everywhere: + // descriptor heaps/RTVs below are created from it, so SRVs, textures + // and fences created later through GetDevice() must match or D3D12 + // rejects the mixed-device usage. opts.d3d12.device is ignored if it + // differs. ID3D12Device *pD3DDevice; if (FAILED(swapChain->GetDevice(__uuidof(ID3D12Device), (void **)&pD3DDevice))) { return false; } + _device = pD3DDevice; _swapChain = swapChain; _commandQueue = commandQueue; @@ -39,12 +46,21 @@ namespace Framework::Graphics { { D3D12_DESCRIPTOR_HEAP_DESC desc {}; desc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; - desc.NumDescriptors = _frameBufferCount; + desc.NumDescriptors = _frameBufferCount + kExtraSrvSlots; desc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE; if (pD3DDevice->CreateDescriptorHeap(&desc, IID_PPV_ARGS(&_srvHeap)) != S_OK) { return false; } + + _srvDescriptorSize = pD3DDevice->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV); + + // Slots [0, _frameBufferCount) stay reserved for ImGui (its font SRV + // sits at the heap start); the rest are up for allocation + _freeSrvSlots.clear(); + for (UINT i = 0; i < kExtraSrvSlots; i++) { + _freeSrvSlots.push_back(_frameBufferCount + i); + } } // #3 create rtv heap @@ -132,4 +148,95 @@ namespace Framework::Graphics { int D3D12Backend::NumFramesInFlight() const { return _frameBufferCount; } + + int D3D12Backend::AllocateSRVSlot() { + if (!_srvHeap) { + Framework::Logging::GetLogger(FRAMEWORK_INNER_GRAPHICS)->error("D3D12Backend::AllocateSRVSlot, no descriptor heap"); + return -1; + } + if (_freeSrvSlots.empty()) { + Framework::Logging::GetLogger(FRAMEWORK_INNER_GRAPHICS)->error("D3D12Backend::AllocateSRVSlot, heap exhausted"); + return -1; + } + const auto slot = _freeSrvSlots.back(); + _freeSrvSlots.pop_back(); + return static_cast(slot); + } + + void D3D12Backend::FreeSRVSlot(int slot) { + if (slot < 0) { + return; + } + _freeSrvSlots.push_back(static_cast(slot)); + } + + D3D12_CPU_DESCRIPTOR_HANDLE D3D12Backend::GetSRVSlotCPUHandle(int slot) const { + if (!_srvHeap) { + return {}; + } + auto handle = _srvHeap->GetCPUDescriptorHandleForHeapStart(); + handle.ptr += static_cast(slot) * _srvDescriptorSize; + return handle; + } + + D3D12_GPU_DESCRIPTOR_HANDLE D3D12Backend::GetSRVSlotGPUHandle(int slot) const { + if (!_srvHeap) { + return {}; + } + auto handle = _srvHeap->GetGPUDescriptorHandleForHeapStart(); + handle.ptr += static_cast(slot) * _srvDescriptorSize; + return handle; + } + + bool D3D12Backend::WaitForGpu() { + if (!_commandQueue || !_device) { + return true; // nothing to drain + } + + // During process teardown the game has already destroyed its device and + // queue; touching them is UB and the fence would never signal anyway. + // Freeing without the drain is safe here - in-flight work died with + // the queue's threads + if (Framework::Utils::IsProcessShutdownInProgress()) { + return true; + } + + Microsoft::WRL::ComPtr fence; + if (FAILED(_device->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&fence)))) { + Framework::Logging::GetLogger(FRAMEWORK_INNER_GRAPHICS)->error("D3D12Backend::WaitForGpu, CreateFence failed"); + return false; + } + + const HANDLE event = CreateEventW(nullptr, FALSE, FALSE, nullptr); + if (!event) { + Framework::Logging::GetLogger(FRAMEWORK_INNER_GRAPHICS)->error("D3D12Backend::WaitForGpu, CreateEvent failed"); + return false; + } + + bool drained = false; + if (FAILED(_commandQueue->Signal(fence.Get(), 1))) { + // Generally means the device was removed; in-flight work is dead, + // but we can't prove it, so report failure and let callers decide + Framework::Logging::GetLogger(FRAMEWORK_INNER_GRAPHICS)->error("D3D12Backend::WaitForGpu, queue Signal failed (device removed?)"); + } + else if (fence->GetCompletedValue() >= 1) { + drained = true; + } + else if (SUCCEEDED(fence->SetEventOnCompletion(1, event))) { + // A live GPU signals in single-digit ms; the timeout only bites + // when the queue is wedged - which is precisely when freeing + // GPU-referenced resources is unsafe, hence the failure return + if (WaitForSingleObject(event, 500) == WAIT_OBJECT_0 || fence->GetCompletedValue() >= 1) { + drained = true; + } + else { + Framework::Logging::GetLogger(FRAMEWORK_INNER_GRAPHICS)->error("D3D12Backend::WaitForGpu, fence wait timed out"); + } + } + else { + Framework::Logging::GetLogger(FRAMEWORK_INNER_GRAPHICS)->error("D3D12Backend::WaitForGpu, SetEventOnCompletion failed"); + } + CloseHandle(event); + return drained; + } } // namespace Framework::Graphics diff --git a/code/framework/src/graphics/backend/d3d12.h b/code/framework/src/graphics/backend/d3d12.h index a885fd3e4..7504432f9 100644 --- a/code/framework/src/graphics/backend/d3d12.h +++ b/code/framework/src/graphics/backend/d3d12.h @@ -15,6 +15,11 @@ namespace Framework::Graphics { class D3D12Backend: public Backend { + public: + // Extra shader-visible SRV slots reserved after the ImGui font slot(s), + // handed out to web views via AllocateSRVSlot + static constexpr UINT kExtraSrvSlots = 64; + private: IDXGISwapChain3 *_swapChain = nullptr; UINT _frameBufferCount = 0; @@ -23,6 +28,9 @@ namespace Framework::Graphics { ID3D12GraphicsCommandList *_commandList = nullptr; ID3D12CommandQueue *_commandQueue = nullptr; + UINT _srvDescriptorSize = 0; + std::vector _freeSrvSlots; + struct FrameContext { ID3D12CommandAllocator *_commandAllocator = nullptr; ID3D12Resource *_mainRenderTargetResource = nullptr; @@ -66,5 +74,29 @@ namespace Framework::Graphics { ID3D12GraphicsCommandList *GetGraphicsCommandList() const { return _commandList; } + + UINT GetCurrentFrameIndex() const { + return _swapChain ? _swapChain->GetCurrentBackBufferIndex() : 0; + } + + // Allocates a shader-visible SRV slot from the backend heap (the one ImGui + // is initialized against, so handles are usable as ImTextureID). Returns -1 + // when exhausted. + int AllocateSRVSlot(); + void FreeSRVSlot(int slot); + D3D12_CPU_DESCRIPTOR_HANDLE GetSRVSlotCPUHandle(int slot) const; + D3D12_GPU_DESCRIPTOR_HANDLE GetSRVSlotGPUHandle(int slot) const; + + // Blocks until the GPU has drained the queue. Used before destroying + // resources that in-flight command lists may still reference. Returns + // false when the drain could not be confirmed (fence timeout/failure) + // so callers can refuse to free still-referenced resources; returns + // true during process teardown, where waiting is impossible and + // freeing is safe (the queue's threads are already gone). + [[nodiscard]] bool WaitForGpu(); + + size_t GetFreeSRVSlotCount() const { + return _freeSrvSlots.size(); + } }; } // namespace Framework::Graphics diff --git a/code/framework/src/graphics/renderer.cpp b/code/framework/src/graphics/renderer.cpp index 8228ddeef..adbd60f7e 100644 --- a/code/framework/src/graphics/renderer.cpp +++ b/code/framework/src/graphics/renderer.cpp @@ -21,7 +21,8 @@ namespace Framework::Graphics { return RendererError::RENDERER_ALREADY_INITIALIZED; } - _config = config; + _config = config; + _backend = config.backend; if (_config.backend == RendererBackend::BACKEND_D3D_11) { _d3d11Backend = std::make_unique(); diff --git a/code/framework/src/gui/backend/view_d3d11.cpp b/code/framework/src/gui/backend/view_d3d11.cpp index 3d9e793df..2624f8360 100644 --- a/code/framework/src/gui/backend/view_d3d11.cpp +++ b/code/framework/src/gui/backend/view_d3d11.cpp @@ -175,25 +175,31 @@ namespace Framework::GUI { else { // CPU path: use pixel data from CEF's OnPaint auto *renderHandler = GetRenderHandler(); - if (renderHandler && !renderHandler->GetPixelData().empty()) { - Graphics::Bitmap bmp; - bmp.format = Graphics::BitmapFormat::BGRA8; - bmp.width = _width; - bmp.height = _height; - bmp.pitch = _width * 4; - bmp.size = static_cast(renderHandler->GetPixelData().size()); - bmp.pixels = const_cast(renderHandler->GetPixelData().data()); - - if (_cpuTextureID == 0) { - _cpuTextureID = backend->NextTextureId(); - backend->CreateTexture(_cpuTextureID, bmp); - } - else if (renderHandler->IsPixelDataDirty()) { - backend->UpdateTexture(_cpuTextureID, bmp); - renderHandler->ClearPixelDataDirty(); - } + if (renderHandler) { + // Held for the whole read — OnPaint reallocates the buffer on + // resize + const auto pixelLock = renderHandler->LockPixels(); + + if (!renderHandler->GetPixelData().empty()) { + Graphics::Bitmap bmp; + bmp.format = Graphics::BitmapFormat::BGRA8; + bmp.width = _width; + bmp.height = _height; + bmp.pitch = _width * 4; + bmp.size = static_cast(renderHandler->GetPixelData().size()); + bmp.pixels = const_cast(renderHandler->GetPixelData().data()); + + if (_cpuTextureID == 0) { + _cpuTextureID = backend->NextTextureId(); + backend->CreateTexture(_cpuTextureID, bmp); + } + else if (renderHandler->IsPixelDataDirty()) { + backend->UpdateTexture(_cpuTextureID, bmp); + renderHandler->ClearPixelDataDirty(); + } - gpuState.texture_1_id = _cpuTextureID; + gpuState.texture_1_id = _cpuTextureID; + } } } diff --git a/code/framework/src/gui/backend/view_d3d12.cpp b/code/framework/src/gui/backend/view_d3d12.cpp new file mode 100644 index 000000000..e0b7c0d48 --- /dev/null +++ b/code/framework/src/gui/backend/view_d3d12.cpp @@ -0,0 +1,277 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2024, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#include "view_d3d12.h" +#include "logging/logger.h" + +#include "graphics/backend/d3d12.h" + +#include +#include + +namespace Framework::GUI { + ViewD3D12::ViewD3D12(int id, Graphics::Renderer *graphicsRenderer, Manager *manager): View(id, graphicsRenderer, manager) {} + + ViewD3D12::~ViewD3D12() { + ReleaseResources(); + } + + GUIError ViewD3D12::Init(const std::string &url, int width, int height, int offsetX, int offsetY, bool gpuAccelerated) { + if (gpuAccelerated) { + // CEF shares D3D11 textures on the accelerated path; consuming them + // here would require D3D11-on-12 interop, so fall back to OnPaint + Framework::Logging::GetLogger("Web")->warn("ViewD3D12: GPU-accelerated OSR not supported, falling back to CPU path"); + } + return View::Init(url, width, height, offsetX, offsetY, false); + } + + void ViewD3D12::Update() { + if (!_browser || !_shouldDisplay) { + return; + } + + std::scoped_lock lock(_renderMutex); + View::Update(); + } + + bool ViewD3D12::CreateResources() { + auto *backend = _graphicsRenderer ? _graphicsRenderer->GetD3D12Backend() : nullptr; + auto *device = backend ? backend->GetDevice() : nullptr; + if (!device) { + return false; + } + + // Keep the SRV slot across resizes; the descriptor is rewritten in place + if (_srvSlot < 0) { + _srvSlot = backend->AllocateSRVSlot(); + if (_srvSlot < 0) { + return false; + } + } + + // Resize path: the previous texture/upload buffers (and the SRV being + // rewritten below) may still be referenced by the in-flight command + // list — the render thread submits one frame ahead. Drain first; if + // the drain can't be confirmed, keep the old resources and retry on a + // later frame rather than freeing something the GPU still reads. + if (_texture && !backend->WaitForGpu()) { + Framework::Logging::GetLogger("Web")->error("ViewD3D12: GPU drain failed, deferring resource recreation"); + return false; + } + + _texture.Reset(); + _uploadBuffers.clear(); + + D3D12_HEAP_PROPERTIES defaultHeap {}; + defaultHeap.Type = D3D12_HEAP_TYPE_DEFAULT; + + D3D12_RESOURCE_DESC texDesc {}; + texDesc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D; + texDesc.Width = static_cast(_width); + texDesc.Height = static_cast(_height); + texDesc.DepthOrArraySize = 1; + texDesc.MipLevels = 1; + texDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + texDesc.SampleDesc.Count = 1; + texDesc.Layout = D3D12_TEXTURE_LAYOUT_UNKNOWN; + + if (FAILED(device->CreateCommittedResource(&defaultHeap, D3D12_HEAP_FLAG_NONE, &texDesc, D3D12_RESOURCE_STATE_COPY_DEST, nullptr, IID_PPV_ARGS(&_texture)))) { + Framework::Logging::GetLogger("Web")->error("ViewD3D12: failed to create texture {}x{}", _width, _height); + return false; + } + _textureState = D3D12_RESOURCE_STATE_COPY_DEST; + + _uploadPitch = (static_cast(_width) * 4 + D3D12_TEXTURE_DATA_PITCH_ALIGNMENT - 1) & ~(D3D12_TEXTURE_DATA_PITCH_ALIGNMENT - 1); + + D3D12_HEAP_PROPERTIES uploadHeap {}; + uploadHeap.Type = D3D12_HEAP_TYPE_UPLOAD; + + D3D12_RESOURCE_DESC bufDesc {}; + bufDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER; + bufDesc.Width = static_cast(_uploadPitch) * _height; + bufDesc.Height = 1; + bufDesc.DepthOrArraySize = 1; + bufDesc.MipLevels = 1; + bufDesc.SampleDesc.Count = 1; + bufDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR; + + // Clamped: a zero would leave _uploadBuffers empty and UploadPixels + // does a modulo by its size + const auto framesInFlight = static_cast(std::max(1, backend->NumFramesInFlight())); + _uploadBuffers.resize(framesInFlight); + + for (auto &upload : _uploadBuffers) { + if (FAILED(device->CreateCommittedResource(&uploadHeap, D3D12_HEAP_FLAG_NONE, &bufDesc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&upload.resource)))) { + Framework::Logging::GetLogger("Web")->error("ViewD3D12: failed to create upload buffer"); + ReleaseResources(); + return false; + } + + const D3D12_RANGE noRead {0, 0}; + if (FAILED(upload.resource->Map(0, &noRead, reinterpret_cast(&upload.mapped)))) { + Framework::Logging::GetLogger("Web")->error("ViewD3D12: failed to map upload buffer"); + ReleaseResources(); + return false; + } + } + + D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc {}; + srvDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + srvDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D; + srvDesc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING; + srvDesc.Texture2D.MipLevels = 1; + device->CreateShaderResourceView(_texture.Get(), &srvDesc, backend->GetSRVSlotCPUHandle(_srvSlot)); + _srvGpuHandle = backend->GetSRVSlotGPUHandle(_srvSlot); + + _texWidth = _width; + _texHeight = _height; + _textureReady = false; + + Framework::Logging::GetLogger("Web")->debug("ViewD3D12: resources created {}x{}, srv slot {}", _width, _height, _srvSlot); + return true; + } + + void ViewD3D12::ReleaseResources() { + // In-flight command lists may still reference the texture/upload buffers + // (the render thread submits one frame ahead) — drain the GPU first. + // This is the destructor path, so an unconfirmed drain can only be + // logged: the resources are freed regardless + if (_texture) { + if (auto *backend = _graphicsRenderer ? _graphicsRenderer->GetD3D12Backend() : nullptr) { + if (!backend->WaitForGpu()) { + Framework::Logging::GetLogger("Web")->warn("ViewD3D12: GPU drain unconfirmed, freeing resources anyway (teardown)"); + } + } + } + + _uploadBuffers.clear(); + _texture.Reset(); + _textureReady = false; + + if (_srvSlot >= 0) { + if (auto *backend = _graphicsRenderer ? _graphicsRenderer->GetD3D12Backend() : nullptr) { + backend->FreeSRVSlot(_srvSlot); + } + _srvSlot = -1; + } + } + + void ViewD3D12::UploadPixels(const std::vector &pixels) { + auto *backend = _graphicsRenderer ? _graphicsRenderer->GetD3D12Backend() : nullptr; + auto *cmdList = backend ? backend->GetGraphicsCommandList() : nullptr; + if (!cmdList || !_texture || _uploadBuffers.empty()) { + return; + } + + const auto frameIdx = backend->GetCurrentFrameIndex() % _uploadBuffers.size(); + uint8_t *dst = _uploadBuffers[frameIdx].mapped; + + const uint32_t srcPitch = static_cast(_width) * 4; + for (int y = 0; y < _height; y++) { + memcpy(dst + static_cast(y) * _uploadPitch, pixels.data() + static_cast(y) * srcPitch, srcPitch); + } + + D3D12_RESOURCE_BARRIER barrier {}; + barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; + barrier.Transition.pResource = _texture.Get(); + barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; + + if (_textureState != D3D12_RESOURCE_STATE_COPY_DEST) { + barrier.Transition.StateBefore = _textureState; + barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_COPY_DEST; + cmdList->ResourceBarrier(1, &barrier); + } + + D3D12_TEXTURE_COPY_LOCATION dstLoc {}; + dstLoc.pResource = _texture.Get(); + dstLoc.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX; + dstLoc.SubresourceIndex = 0; + + D3D12_TEXTURE_COPY_LOCATION srcLoc {}; + srcLoc.pResource = _uploadBuffers[frameIdx].resource.Get(); + srcLoc.Type = D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT; + srcLoc.PlacedFootprint.Footprint.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + srcLoc.PlacedFootprint.Footprint.Width = static_cast(_width); + srcLoc.PlacedFootprint.Footprint.Height = static_cast(_height); + srcLoc.PlacedFootprint.Footprint.Depth = 1; + srcLoc.PlacedFootprint.Footprint.RowPitch = _uploadPitch; + + cmdList->CopyTextureRegion(&dstLoc, 0, 0, 0, &srcLoc, nullptr); + + barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_COPY_DEST; + barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; + cmdList->ResourceBarrier(1, &barrier); + _textureState = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; + + _textureReady = true; + } + + void ViewD3D12::Render() { + if (!_browser || !_shouldDisplay) { + return; + } + + std::scoped_lock lock(_renderMutex); + + auto *backend = _graphicsRenderer ? _graphicsRenderer->GetD3D12Backend() : nullptr; + if (!backend) { + return; + } + + auto *renderHandler = GetRenderHandler(); + if (!renderHandler) { + return; + } + + // Held for the whole read: OnPaint (game thread, inside the CEF pump) + // reallocates the buffer on resize + const auto pixelLock = renderHandler->LockPixels(); + + const auto &pixels = renderHandler->GetPixelData(); + if (pixels.empty()) { + return; + } + + if (!_texture || _texWidth != _width || _texHeight != _height) { + if (!CreateResources()) { + return; + } + } + + if (renderHandler->IsPixelDataDirty() || !_textureReady) { + // The pixel buffer can briefly lag the view size during a resize + if (pixels.size() >= static_cast(_width) * _height * 4) { + UploadPixels(pixels); + renderHandler->ClearPixelDataDirty(); + } + } + } + + void ViewD3D12::SubmitImGuiDraw() { + std::scoped_lock lock(_renderMutex); + + if (!_shouldDisplay || !_textureReady || _srvSlot < 0) { + return; + } + + auto *drawList = ImGui::GetBackgroundDrawList(); + drawList->AddImage(static_cast(_srvGpuHandle.ptr), ImVec2(static_cast(_x), static_cast(_y)), + ImVec2(static_cast(_x + _width), static_cast(_y + _height))); + } + + std::string ViewD3D12::GetDebugString() const { + auto *renderHandler = _renderHandler.get(); + size_t pixelBytes = 0; + if (renderHandler) { + const auto pixelLock = renderHandler->LockPixels(); + pixelBytes = renderHandler->GetPixelData().size(); + } + return fmt::format("d3d12 display={} texReady={} srv={} tex={}x{} pixels={} dirty={}", _shouldDisplay, _textureReady, _srvSlot, _texWidth, _texHeight, pixelBytes, + renderHandler ? renderHandler->IsPixelDataDirty() : false); + } +} // namespace Framework::GUI diff --git a/code/framework/src/gui/backend/view_d3d12.h b/code/framework/src/gui/backend/view_d3d12.h new file mode 100644 index 000000000..a570da822 --- /dev/null +++ b/code/framework/src/gui/backend/view_d3d12.h @@ -0,0 +1,66 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2024, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include + +#include +#include +#include +#include + +#include "graphics/renderer.h" +#include "gui/view.h" + +namespace Framework::GUI { + // CPU-path D3D12 view: uploads CEF's OnPaint pixel buffer into a texture on + // the backend command list (render thread, between D3D12Backend::Begin and + // the ImGui draw) and blits it via the ImGui background draw list (game + // thread, inside the ImGui frame). The GPU-accelerated CEF path is not + // supported here — CEF shares D3D11 textures, which would require interop. + class ViewD3D12 final: public View { + private: + Microsoft::WRL::ComPtr _texture; + + // One upload buffer per frame in flight so we never write a buffer the + // GPU may still be copying from + struct UploadBuffer { + Microsoft::WRL::ComPtr resource; + uint8_t *mapped = nullptr; + }; + std::vector _uploadBuffers; + + int _texWidth = 0; + int _texHeight = 0; + uint32_t _uploadPitch = 0; + D3D12_RESOURCE_STATES _textureState = D3D12_RESOURCE_STATE_COPY_DEST; + + int _srvSlot = -1; + D3D12_GPU_DESCRIPTOR_HANDLE _srvGpuHandle {}; + + // Set once the texture holds at least one full frame of pixels + bool _textureReady = false; + + public: + ViewD3D12(int id, Graphics::Renderer *graphicsRenderer, Manager *manager); + ~ViewD3D12() override; + + [[nodiscard]] GUIError Init(const std::string &url, int width, int height, int offsetX, int offsetY, bool gpuAccelerated = false) override; + + void Update() override; + void Render() override; + void SubmitImGuiDraw() override; + std::string GetDebugString() const override; + + private: + bool CreateResources(); + void ReleaseResources(); + void UploadPixels(const std::vector &pixels); + }; +} // namespace Framework::GUI diff --git a/code/framework/src/gui/cef/app.cpp b/code/framework/src/gui/cef/app.cpp index fd943f494..37be564b2 100644 --- a/code/framework/src/gui/cef/app.cpp +++ b/code/framework/src/gui/cef/app.cpp @@ -22,11 +22,18 @@ namespace Framework::GUI::CEF { std::string scheme = CefString(&urlParts.scheme).ToString(); std::string domain = CefString(&urlParts.host).ToString(); - auto it = _handlers.find({scheme, domain}); - if (it == _handlers.end()) - return nullptr; + SchemaHandlerFactoryCallback handler; + { + std::scoped_lock lock(_handlersMutex); + auto it = _handlers.find({scheme, domain}); + if (it == _handlers.end()) + return nullptr; + handler = it->second; + } - return it->second(browser, frame, scheme_name, request); + // Invoked on a copy outside the lock: the callback may be arbitrarily + // slow and a re-registration must neither wait on it nor race it + return handler(browser, frame, scheme_name, request); } void App::OnBeforeCommandLineProcessing(const CefString &processType, CefRefPtr commandLine) { @@ -53,6 +60,7 @@ namespace Framework::GUI::CEF { } void App::RegisterSchemeHandlerFactory(const std::string &scheme, const std::string &domain, SchemaHandlerFactoryCallback callback) { - _handlers[{scheme, domain}] = callback; + std::scoped_lock lock(_handlersMutex); + _handlers[{scheme, domain}] = std::move(callback); } } // namespace Framework::GUI::CEF diff --git a/code/framework/src/gui/cef/app.h b/code/framework/src/gui/cef/app.h index d19c6d87a..095df95e1 100644 --- a/code/framework/src/gui/cef/app.h +++ b/code/framework/src/gui/cef/app.h @@ -14,6 +14,7 @@ #include "include/cef_browser_process_handler.h" #include +#include #include namespace Framework::GUI::CEF { @@ -68,6 +69,9 @@ namespace Framework::GUI::CEF { IMPLEMENT_REFCOUNTING(App); private: + // Create() runs on the CEF IO thread per request; registration happens + // on the game thread + std::mutex _handlersMutex; std::unordered_map _handlers; }; } // namespace Framework::GUI::CEF diff --git a/code/framework/src/gui/cef/disk_resource_handler.cpp b/code/framework/src/gui/cef/disk_resource_handler.cpp new file mode 100644 index 000000000..1a85642e6 --- /dev/null +++ b/code/framework/src/gui/cef/disk_resource_handler.cpp @@ -0,0 +1,67 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2024, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#include "disk_resource_handler.h" + +#include "include/cef_parser.h" +#include "include/cef_stream.h" +#include "include/wrapper/cef_stream_resource_handler.h" + +#include + +#include + +namespace Framework::GUI::CEF { + namespace { + CefRefPtr NotFound() { + static const char kBody[] = "Not Found"; + // CreateForData does not copy — the static literal outlives the handler + return new CefStreamResourceHandler(404, "Not Found", "text/plain", {}, CefStreamReader::CreateForData(const_cast(kBody), sizeof(kBody) - 1)); + } + } // namespace + + CefRefPtr CreateDiskResourceHandler(const std::filesystem::path &root, const CefRefPtr &request) { + CefURLParts urlParts; + if (!request || !CefParseURL(request->GetURL(), urlParts)) { + return NotFound(); + } + + const std::string urlPath = CefURIDecode(CefString(&urlParts.path), false, UU_SPACES).ToString(); + + // Empty = rejected: the path escaped the root (e.g. via "..") + const auto file = Framework::Utils::ResolvePathUnderRoot(root, urlPath); + if (file.empty()) { + return NotFound(); + } + + // wstring(): path::string() narrows to the ANSI codepage on Windows but + // CefString decodes std::string as UTF-8 — non-ASCII paths would 404 + CefRefPtr stream = CefStreamReader::CreateForFile(file.wstring()); + if (!stream) { + return NotFound(); + } + + std::string ext = file.extension().string(); + if (!ext.empty() && ext.front() == '.') { + ext.erase(0, 1); + } + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + std::string mime = CefGetMimeType(ext).ToString(); + if (mime.empty()) { + mime = "application/octet-stream"; + } + + // Local files change while iterating on UIs — never serve a stale copy + CefResponse::HeaderMap headers; + headers.emplace("Cache-Control", "no-store"); + + return new CefStreamResourceHandler(200, "OK", mime, headers, stream); + } +} // namespace Framework::GUI::CEF diff --git a/code/framework/src/gui/cef/disk_resource_handler.h b/code/framework/src/gui/cef/disk_resource_handler.h new file mode 100644 index 000000000..bb69eedc2 --- /dev/null +++ b/code/framework/src/gui/cef/disk_resource_handler.h @@ -0,0 +1,23 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2024, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "include/cef_request.h" +#include "include/cef_resource_handler.h" + +#include + +namespace Framework::GUI::CEF { + // Resolves the request URL against a directory tree ("/" maps to + // index.html) and returns a streaming handler for the file, for use with + // RegisterSchemeHandlerFactory. Anything missing or escaping the root + // yields a 404 handler (never nullptr — that would fall through to the + // network stack for standard schemes). + CefRefPtr CreateDiskResourceHandler(const std::filesystem::path &root, const CefRefPtr &request); +} // namespace Framework::GUI::CEF diff --git a/code/framework/src/gui/cef/life_span_handler.cpp b/code/framework/src/gui/cef/life_span_handler.cpp index 0553c32ff..2c8696e69 100644 --- a/code/framework/src/gui/cef/life_span_handler.cpp +++ b/code/framework/src/gui/cef/life_span_handler.cpp @@ -10,8 +10,15 @@ #include "include/cef_parser.h" namespace Framework::GUI::CEF { + std::atomic LifeSpanHandler::s_liveBrowserCount {0}; + void LifeSpanHandler::OnAfterCreated(CefRefPtr browser) { _browser = browser; + ++s_liveBrowserCount; + + if (_onAfterCreated) { + _onAfterCreated(browser); + } } bool LifeSpanHandler::OnBeforePopup(CefRefPtr browser, CefRefPtr frame, int popupId, const CefString &targetUrl, const CefString &targetFrameName, CefLifeSpanHandler::WindowOpenDisposition targetDisposition, bool userGesture, const CefPopupFeatures &popupFeatures, CefWindowInfo &windowInfo, CefRefPtr &client, CefBrowserSettings &settings, CefRefPtr &extraInfo, bool *noJavascriptAccess) { @@ -21,6 +28,7 @@ namespace Framework::GUI::CEF { void LifeSpanHandler::OnBeforeClose(CefRefPtr browser) { _browser = nullptr; + --s_liveBrowserCount; } bool LifeSpanHandler::OnBeforeBrowse(CefRefPtr browser, CefRefPtr frame, CefRefPtr request, bool userGesture, bool isRedirect) { diff --git a/code/framework/src/gui/cef/life_span_handler.h b/code/framework/src/gui/cef/life_span_handler.h index f7745e2cd..6b1689f88 100644 --- a/code/framework/src/gui/cef/life_span_handler.h +++ b/code/framework/src/gui/cef/life_span_handler.h @@ -10,6 +10,7 @@ #include "include/cef_life_span_handler.h" #include "include/cef_request_handler.h" +#include #include namespace Framework::GUI::CEF { @@ -24,7 +25,16 @@ namespace Framework::GUI::CEF { std::function)> _onAfterCreated; OnBeforeBrowseCallback _onBeforeBrowse; + // Browsers created but not yet through OnBeforeClose, across all views. + // Manager::Shutdown pumps the message loop until this drains before + // calling CefShutdown + static std::atomic s_liveBrowserCount; + public: + static int GetLiveBrowserCount() { + return s_liveBrowserCount.load(); + } + void SetOnAfterCreatedCallback(std::function)> cb) { _onAfterCreated = std::move(cb); } diff --git a/code/framework/src/gui/cef/render_handler.cpp b/code/framework/src/gui/cef/render_handler.cpp index 005582a7c..f2d781795 100644 --- a/code/framework/src/gui/cef/render_handler.cpp +++ b/code/framework/src/gui/cef/render_handler.cpp @@ -43,6 +43,10 @@ namespace Framework::GUI::CEF { return; } + // Views read _pixelData from their render path; the resize below can + // reallocate the buffer out from under them without this + std::lock_guard lock(_pixelMutex); + const size_t size = width * height * 4; if (_pixelData.size() != size) { _pixelData.resize(size); diff --git a/code/framework/src/gui/cef/render_handler.h b/code/framework/src/gui/cef/render_handler.h index 21a6b4dd3..7135411ee 100644 --- a/code/framework/src/gui/cef/render_handler.h +++ b/code/framework/src/gui/cef/render_handler.h @@ -27,7 +27,10 @@ namespace Framework::GUI::CEF { Microsoft::WRL::ComPtr _sharedTexture; HANDLE _sharedHandle = nullptr; - // CPU fallback + // CPU fallback. OnPaint writes from the thread pumping CEF (the game + // thread); views read from the render thread — hold LockPixels() for + // the whole read, GetPixelData() returns a reference + std::mutex _pixelMutex; std::vector _pixelData; bool _pixelDataDirty = false; @@ -51,6 +54,10 @@ namespace Framework::GUI::CEF { return std::lock_guard(_textureMutex); } + [[nodiscard]] std::lock_guard LockPixels() { + return std::lock_guard(_pixelMutex); + } + ID3D11Texture2D *GetSharedTexture() const { return _sharedTexture.Get(); } diff --git a/code/framework/src/gui/manager.cpp b/code/framework/src/gui/manager.cpp index 36d02292c..50194f38a 100644 --- a/code/framework/src/gui/manager.cpp +++ b/code/framework/src/gui/manager.cpp @@ -9,12 +9,34 @@ #include "manager.h" #include +#include #include "gui/backend/view_d3d11.h" +#include "gui/backend/view_d3d12.h" +#include "include/cef_scheme.h" + +#include #include namespace Framework::GUI { + namespace { + // Unbuffered shutdown trace: the spdlog file sink is unreliable + // (per-logger sink duplication overwrites the file) and shutdown is + // exactly where the in-game console can no longer be read. Open/append/ + // close per line so every entry survives even a hard process kill. + void ShutdownTrace(const std::string &rootDir, const std::string &msg) { + if (rootDir.empty()) { + return; + } + FILE *f = nullptr; + if (fopen_s(&f, (rootDir + "/logs/web_shutdown_trace.log").c_str(), "a") != 0 || !f) { + return; + } + fprintf(f, "[%llu ms] %s\n", static_cast(GetTickCount64()), msg.c_str()); + fclose(f); + } + } // namespace Manager::Manager() { _clipboard = std::make_unique(); } @@ -26,22 +48,63 @@ namespace Framework::GUI { } void Manager::Shutdown() { - for (auto &view : _views) { - view.reset(); + { + std::scoped_lock lock(_renderMutex); + ShutdownTrace(_rootDir, fmt::format("Shutdown begin (views={}, dying={}, processTeardown={})", _views.size(), _dyingViews.size(), Framework::Utils::IsProcessShutdownInProgress())); + for (auto &view : _views) { + view.reset(); + } + _views.clear(); + _dyingViews.clear(); } - _views.clear(); + ShutdownTrace(_rootDir, "views released"); if (_cefInitialized) { - CefShutdown(); + if (Framework::Utils::IsProcessShutdownInProgress()) { + // Too late for a clean CefShutdown: its threads are gone and it + // would deadlock. Let the OS reap; subprocesses watch the parent. + Framework::Logging::GetLogger("Web")->debug("Process teardown in progress, skipping CefShutdown"); + ShutdownTrace(_rootDir, "process teardown in progress, skipping CefShutdown"); + } + else { + // The global request context holds references to registered + // scheme handler factories (our CefApp); outstanding references + // at CefShutdown time can stall it + CefClearSchemeHandlerFactories(); + ShutdownTrace(_rootDir, "scheme handler factories cleared"); + + // CloseBrowser is async — pump until every browser has been + // through OnBeforeClose (hard cap so a wedged renderer can't + // hold game exit hostage), then a settle pass for the teardown + // tasks queued behind the close. The count can't miss a + // browser: views create via CreateBrowserSync, so + // OnAfterCreated has fired before a view can exist + int drainedMs = 0; + while (CEF::LifeSpanHandler::GetLiveBrowserCount() > 0 && drainedMs < 3000) { + CefDoMessageLoopWork(); + Sleep(10); + drainedMs += 10; + } + for (int i = 0; i < 50; i++) { + CefDoMessageLoopWork(); + Sleep(10); + } + ShutdownTrace(_rootDir, fmt::format("message loop drained (close wait {} ms, {} browsers left)", drainedMs, CEF::LifeSpanHandler::GetLiveBrowserCount())); + + CefShutdown(); + ShutdownTrace(_rootDir, "CefShutdown complete"); + } _cefInitialized = false; } Lifecycle::Shutdown(); + ShutdownTrace(_rootDir, "Shutdown end"); } GUIError Manager::Init(const std::string &rootDir, ViewportConfiguration initialViewport, Graphics::Renderer *renderer, bool gpuAccelerated) { _graphicsRenderer = renderer; _gpuAccelerated = gpuAccelerated; + _rootDir = rootDir; SetViewportConfiguration(initialViewport); @@ -62,9 +125,20 @@ namespace Framework::GUI { CefString(&settings.cache_path) = cacheRoot.wstring(); CefString(&settings.log_file) = rootDir + "/logs/cef.log"; - // CEF requires an absolute path for the subprocess executable + // CEF requires an absolute path for the subprocess executable. Resolve it + // relative to the module containing this code rather than the process exe: + // when the framework lives in a DLL injected into a game, the game's + // install dir doesn't (and shouldn't) carry our CEF binaries. + static const int s_moduleAnchor = 0; + HMODULE selfModule = nullptr; + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast(&s_moduleAnchor), &selfModule) || !selfModule) { + // Without it GetModuleFileNameW(nullptr) would silently point the + // subprocess path at the game EXE's dir, which doesn't carry CEF + Framework::Logging::GetLogger("Web")->error("Failed to resolve owning module for the CEF subprocess path"); + return GUIError::GUI_CEF_INIT_FAILED; + } wchar_t exePath[MAX_PATH] = {}; - GetModuleFileNameW(nullptr, exePath, MAX_PATH); + GetModuleFileNameW(selfModule, exePath, MAX_PATH); std::filesystem::path subprocessPath = std::filesystem::path(exePath).parent_path() / "cef_subprocess.exe"; CefString(&settings.browser_subprocess_path) = subprocessPath.wstring(); @@ -89,46 +163,78 @@ namespace Framework::GUI { return; } - std::scoped_lock lock(_renderMutex); - - // Pump the CEF message loop + // Pump OUTSIDE _renderMutex. The pump dispatches the game window's + // messages mid-tick; a handler that blocks on the render thread (the + // exit flow does an RHI flush) deadlocks against Manager::Render, + // which takes this mutex on the render thread every frame. Seen as a + // minutes-long exit stall crawling forward on the game's wait + // timeouts. The pump itself never touches _views. CefDoMessageLoopWork(); + std::scoped_lock lock(_renderMutex); + // Update the views for (auto &view : _views) { view->Update(); } - } - void Manager::Render() { - if (!_cefInitialized) { - return; + // Destroy retired views once any in-flight frames referencing their + // textures have long drained + constexpr int kDyingViewTicks = 8; + for (auto it = _dyingViews.begin(); it != _dyingViews.end();) { + if (++it->second > kDyingViewTicks) { + it = _dyingViews.erase(it); + } + else { + ++it; + } } + } - // Sort views by z-index + std::vector Manager::GetViewsByZIndex() const { std::vector views; - for (auto &view : _views) { + for (const auto &view : _views) { views.push_back(view.get()); } std::sort(views.begin(), views.end(), [](GUI::View *a, GUI::View *b) { return a->GetZIndex() < b->GetZIndex(); }); + return views; + } + + void Manager::SubmitImGuiDraws() { + if (!_cefInitialized) { + return; + } std::scoped_lock lock(_renderMutex); - // Render the views - for (auto &view : views) { + for (auto *view : GetViewsByZIndex()) { + view->SubmitImGuiDraw(); + } + } + + void Manager::Render() { + if (!_cefInitialized) { + return; + } + + std::scoped_lock lock(_renderMutex); + + for (auto *view : GetViewsByZIndex()) { view->Render(); } } void Manager::ProcessMouseEvent(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) const { + std::scoped_lock lock(_renderMutex); for (auto &view : _views) { view->ProcessMouseEvent(hWnd, msg, wParam, lParam); } } void Manager::ProcessKeyboardEvent(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) const { + std::scoped_lock lock(_renderMutex); for (auto &view : _views) { view->ProcessKeyboardEvent(hWnd, msg, wParam, lParam); } @@ -154,6 +260,9 @@ namespace Framework::GUI { case Graphics::RendererBackend::BACKEND_D3D_11: view = std::make_unique(++_id, _graphicsRenderer, this); break; + case Graphics::RendererBackend::BACKEND_D3D_12: + view = std::make_unique(++_id, _graphicsRenderer, this); + break; default: Framework::Logging::GetLogger("Web")->error("Failed to create view: Unsupported renderer backend"); return -1; @@ -168,43 +277,52 @@ namespace Framework::GUI { return -1; } - _views.push_back(std::move(view)); + { + std::scoped_lock lock(_renderMutex); + _views.push_back(std::move(view)); + } Framework::Logging::GetLogger("Web")->debug("Created view with id {}", _id); return _id; } + void Manager::RetireView(std::unique_ptr view) { + // Caller must hold _renderMutex. Hide the view so no further draws are + // submitted, then let it age out (destroyed in Update once in-flight + // frames that may reference its texture have drained). + view->Display(false); + view->Focus(false); + _dyingViews.emplace_back(std::move(view), 0); + } + bool Manager::DestroyView(int id) { if (!_cefInitialized) { Framework::Logging::GetLogger("Web")->error("Failed to destroy view: CEF is not initialized"); return false; } - int index = -1; - int i = 0; + std::scoped_lock lock(_renderMutex); - for (auto it = _views.begin(); it != _views.end(); ++it, ++i) { + for (auto it = _views.begin(); it != _views.end(); ++it) { if ((*it)->GetId() == id) { - index = i; - break; - } - } + RetireView(std::move(*it)); + _views.erase(it); - if (index == -1) { - Framework::Logging::GetLogger("Web")->error("Failed to destroy view: View does not exist"); - return false; + Framework::Logging::GetLogger("Web")->debug("Destroyed view with id {}", id); + return true; + } } - _views[index].reset(); - _views.erase(_views.begin() + index); - - Framework::Logging::GetLogger("Web")->debug("Destroyed view with id {}", id); - return true; + Framework::Logging::GetLogger("Web")->error("Failed to destroy view: View does not exist"); + return false; } void Manager::CleanupViews() { + std::scoped_lock lock(_renderMutex); + for (auto it = _views.begin(); it != _views.end();) { if ((*it)->IsGarbageCollected()) { + RetireView(std::move(*it)); it = _views.erase(it); } else { @@ -214,6 +332,7 @@ namespace Framework::GUI { } bool Manager::IsAnyViewFocused() const { + std::scoped_lock lock(_renderMutex); for (const auto &view : _views) { if (view->HasFocus()) { return true; @@ -223,6 +342,7 @@ namespace Framework::GUI { } bool Manager::IsAnyGCViewFocused() const { + std::scoped_lock lock(_renderMutex); for (const auto &view : _views) { if (view->HasFocus() && view->IsGarbageCollected()) { return true; @@ -232,6 +352,7 @@ namespace Framework::GUI { } std::vector Manager::GetAllViews() const { + std::scoped_lock lock(_renderMutex); std::vector views; for (const auto &view : _views) { views.push_back(view.get()); @@ -240,6 +361,7 @@ namespace Framework::GUI { } std::vector Manager::GetGCViews() const { + std::scoped_lock lock(_renderMutex); std::vector views; for (const auto &view : _views) { if (view->IsGarbageCollected()) { @@ -250,6 +372,7 @@ namespace Framework::GUI { } View *Manager::GetView(int id) const { + std::scoped_lock lock(_renderMutex); for (auto it = _views.begin(); it != _views.end(); ++it) { if ((*it)->GetId() == id) { return it->get(); diff --git a/code/framework/src/gui/manager.h b/code/framework/src/gui/manager.h index 46e97ebb5..d3af71952 100644 --- a/code/framework/src/gui/manager.h +++ b/code/framework/src/gui/manager.h @@ -36,14 +36,27 @@ namespace Framework::GUI { CefRefPtr _cefApp; bool _cefInitialized = false; - std::recursive_mutex _renderMutex; + // Guards _views/_dyingViews across the game thread, the render thread + // and reentrant message dispatch; mutable so const readers can lock + mutable std::recursive_mutex _renderMutex; std::vector> _views; + + // Destroyed views are parked here for a few ticks before their GPU + // resources are actually freed: draw data built on the game thread can + // reference a view's texture for a couple of frames after removal + std::vector, int>> _dyingViews; + std::unique_ptr _clipboard; + std::string _rootDir; Graphics::Renderer *_graphicsRenderer {}; bool _gpuAccelerated = false; int _id = 0; + // Callers must hold _renderMutex + std::vector GetViewsByZIndex() const; + void RetireView(std::unique_ptr view); + public: Manager(); ~Manager(); @@ -61,9 +74,17 @@ namespace Framework::GUI { std::vector GetAllViews() const; std::vector GetGCViews() const; + size_t GetDyingViewCount() const { + return _dyingViews.size(); + } + void Update() override; void Render(); + // Game-thread companion to Render(): must be called inside an active + // ImGui frame so D3D12 views can blit through the background draw list + void SubmitImGuiDraws(); + void ProcessMouseEvent(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) const; void ProcessKeyboardEvent(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) const; diff --git a/code/framework/src/gui/view.h b/code/framework/src/gui/view.h index 07ada0c65..e05db02f1 100644 --- a/code/framework/src/gui/view.h +++ b/code/framework/src/gui/view.h @@ -85,6 +85,15 @@ namespace Framework::GUI { virtual void Update(); virtual void Render() = 0; + // Called on the game thread inside an active ImGui frame; backends that + // draw through ImGui (D3D12) submit their textured quad here + virtual void SubmitImGuiDraw() {} + + // One-line backend state dump for debug overlays + virtual std::string GetDebugString() const { + return ""; + } + void ProcessMouseEvent(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam); void ProcessKeyboardEvent(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam); diff --git a/code/framework/src/utils/path.cpp b/code/framework/src/utils/path.cpp index f5a9eba6d..2348acde9 100644 --- a/code/framework/src/utils/path.cpp +++ b/code/framework/src/utils/path.cpp @@ -192,4 +192,31 @@ namespace Framework::Utils { return std::string(path.substr(pos)); // Include the dot in the extension } + + std::filesystem::path ResolvePathUnderRoot(const std::filesystem::path &root, std::string urlPath) { + if (urlPath.empty() || urlPath == "/") { + urlPath = "/index.html"; + } + if (urlPath.front() == '/') { + urlPath.erase(0, 1); + } + + std::error_code ec; + const auto file = std::filesystem::weakly_canonical(root / urlPath, ec); + if (ec) { + return {}; + } + const auto rootCanonical = std::filesystem::weakly_canonical(root, ec); + if (ec) { + return {}; + } + + // lexically_relative is empty when the paths have different roots + // (drive injection) and ".."-prefixed when the input escaped upward + const auto relative = file.lexically_relative(rootCanonical); + if (relative.empty() || *relative.begin() == "..") { + return {}; + } + return file; + } } // namespace Framework::Utils diff --git a/code/framework/src/utils/path.h b/code/framework/src/utils/path.h index 48fd52a73..544f3c361 100644 --- a/code/framework/src/utils/path.h +++ b/code/framework/src/utils/path.h @@ -8,6 +8,7 @@ #pragma once +#include #include #include @@ -18,4 +19,10 @@ namespace Framework::Utils { std::string GetAppDataPathA(); std::wstring GetFileExtensionW(const std::wstring &path); std::string GetFileExtensionA(std::string_view path); + + // Resolves a URL-style path ("" and "/" map to "/index.html") against a + // root directory, for safely serving files from a directory tree. Returns + // an empty path when the input escapes the root (".." traversal, drive or + // absolute-path injection). Purely lexical — existence is not checked. + std::filesystem::path ResolvePathUnderRoot(const std::filesystem::path &root, std::string urlPath); } // namespace Framework::Utils diff --git a/code/framework/src/utils/process_shutdown.h b/code/framework/src/utils/process_shutdown.h new file mode 100644 index 000000000..126e01cc3 --- /dev/null +++ b/code/framework/src/utils/process_shutdown.h @@ -0,0 +1,27 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2024, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "safe_win32.h" + +namespace Framework::Utils { + // True once the OS has begun tearing the process down (CRT/DLL detach). + // Blocking work (thread joins, GPU fences, CefShutdown) must be skipped + // then: the threads it would wait on are already dead and the wait only + // stalls process exit. + inline bool IsProcessShutdownInProgress() { +#ifdef WIN32 + using RtlDllShutdownInProgress_t = BOOLEAN(NTAPI *)(); + static const auto fn = reinterpret_cast(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "RtlDllShutdownInProgress")); + return fn && fn(); +#else + return false; +#endif + } +} // namespace Framework::Utils diff --git a/code/tests/framework_ut.cpp b/code/tests/framework_ut.cpp index 8830aa7a7..fac0afd97 100644 --- a/code/tests/framework_ut.cpp +++ b/code/tests/framework_ut.cpp @@ -12,6 +12,7 @@ /* TEST CATEGORIES */ #include "modules/interpolator_ut.h" +#include "modules/path_resolve_ut.h" #include "modules/state_machine_ut.h" // Scripting tests @@ -27,6 +28,7 @@ int main() { Framework::Logging::GetInstance()->PauseLogging(true); UNIT_MODULE(interpolator); + UNIT_MODULE(path_resolve); UNIT_MODULE(state_machine); // Scripting tests diff --git a/code/tests/modules/path_resolve_ut.h b/code/tests/modules/path_resolve_ut.h new file mode 100644 index 000000000..fe9724e5c --- /dev/null +++ b/code/tests/modules/path_resolve_ut.h @@ -0,0 +1,66 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2024, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "utils/path.h" + +#include + +// Backslash separators and drive prefixes are only meaningful on Windows; on +// POSIX they are ordinary filename characters and the same inputs resolve to +// (harmless) files inside the root +#ifdef _WIN32 +inline constexpr bool kPathSepIsWindows = true; +#else +inline constexpr bool kPathSepIsWindows = false; +#endif + +MODULE(path_resolve, { + using Framework::Utils::ResolvePathUnderRoot; + namespace fs = std::filesystem; + + const fs::path root = fs::temp_directory_path() / "fw_path_resolve_ut" / "ui"; + fs::create_directories(root / "sub"); + const fs::path rootCanonical = fs::weakly_canonical(root); + + IT("maps empty and / to index.html", { + EQUALS(ResolvePathUnderRoot(root, "/") == rootCanonical / "index.html", true); + EQUALS(ResolvePathUnderRoot(root, "") == rootCanonical / "index.html", true); + }); + + IT("resolves nested paths under the root", { + EQUALS(ResolvePathUnderRoot(root, "/sub/style.css") == rootCanonical / "sub" / "style.css", true); + }); + + IT("resolves lexically without requiring the file to exist", { + EQUALS(ResolvePathUnderRoot(root, "/missing/deep/file.js") == rootCanonical / "missing" / "deep" / "file.js", true); + }); + + IT("allows dot-dot that stays inside the root", { + EQUALS(ResolvePathUnderRoot(root, "/sub/../index.html") == rootCanonical / "index.html", true); + }); + + IT("rejects dot-dot traversal escaping the root", { + EQUALS(ResolvePathUnderRoot(root, "/../secret.txt").empty(), true); + EQUALS(ResolvePathUnderRoot(root, "/sub/../../secret.txt").empty(), true); + EQUALS(ResolvePathUnderRoot(root, "/sub/../../../../../etc/passwd").empty(), true); + }); + + IT("rejects backslash traversal where backslash separates", { + EQUALS(ResolvePathUnderRoot(root, "/..\\secret.txt").empty(), kPathSepIsWindows); + EQUALS(ResolvePathUnderRoot(root, "/sub\\..\\..\\secret.txt").empty(), kPathSepIsWindows); + }); + + IT("rejects drive injection where drives exist", { + EQUALS(ResolvePathUnderRoot(root, "/C:/Windows/win.ini").empty(), kPathSepIsWindows); + EQUALS(ResolvePathUnderRoot(root, "/C:\\Windows\\win.ini").empty(), kPathSepIsWindows); + }); + + fs::remove_all(fs::temp_directory_path() / "fw_path_resolve_ut"); +}); From 4513f7afd637dc527a7f848cf32ca4ab29b78641 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Sun, 14 Jun 2026 12:29:52 -0400 Subject: [PATCH 2/2] Graphics: acquire D3D12 back buffer per-present (F11 fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D3D12 backend cached the swapchain back buffers for the whole process lifetime and rendered the overlay to them through its own command list. Holding those references made the game's IDXGISwapChain::ResizeBuffers fail with DXGI_ERROR_INVALID_CALL, which UE4 treats as fatal — crashing on any fullscreen toggle or resolution change (F11, Alt+Enter, the Settings > Display menu). Acquire the current back buffer fresh in Begin() and release it in End(), so no swapchain reference is held between frames; the game owns the resize entirely and we no longer touch ResizeBuffers. CEF views still follow the viewport via Manager::Resize. --- code/framework/src/graphics/backend/d3d12.cpp | 35 ++++++++++++++++--- code/framework/src/graphics/backend/d3d12.h | 17 ++++++++- code/framework/src/gui/manager.cpp | 22 ++++++++++++ code/framework/src/gui/manager.h | 4 +++ code/framework/src/gui/view.cpp | 21 +++++++++++ code/framework/src/gui/view.h | 16 +++++++++ 6 files changed, 109 insertions(+), 6 deletions(-) diff --git a/code/framework/src/graphics/backend/d3d12.cpp b/code/framework/src/graphics/backend/d3d12.cpp index 70de38983..0ee9862c1 100644 --- a/code/framework/src/graphics/backend/d3d12.cpp +++ b/code/framework/src/graphics/backend/d3d12.cpp @@ -78,10 +78,11 @@ namespace Framework::Graphics { const auto rtvDescriptorSize = pD3DDevice->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV); D3D12_CPU_DESCRIPTOR_HANDLE rtvHandle = _rtvHeap->GetCPUDescriptorHandleForHeapStart(); + // Reserve one RTV descriptor slot per frame index; the back-buffer + // resource (and its RTV) is bound fresh each frame in Begin(), so we + // don't grab the buffers here. for (UINT i = 0; i < _frameBufferCount; i++) { _frameContext[i]._mainRenderTargetDescriptor = rtvHandle; - swapChain->GetBuffer(i, IID_PPV_ARGS(&_frameContext[i]._mainRenderTargetResource)); - pD3DDevice->CreateRenderTargetView(_frameContext[i]._mainRenderTargetResource, nullptr, rtvHandle); rtvHandle.ptr += rtvDescriptorSize; } } @@ -109,22 +110,37 @@ namespace Framework::Graphics { void D3D12Backend::Shutdown() { // release objects + if (_currentBackBuffer) { + _currentBackBuffer->Release(); + _currentBackBuffer = nullptr; + } _rtvHeap->Release(); _srvHeap->Release(); _commandList->Release(); for (const auto &frameContext : _frameContext) { frameContext._commandAllocator->Release(); - frameContext._mainRenderTargetResource->Release(); } } void D3D12Backend::Begin() { - const auto ¤tFrameContext = _frameContext[_swapChain->GetCurrentBackBufferIndex()]; + const UINT idx = _swapChain->GetCurrentBackBufferIndex(); + const auto ¤tFrameContext = _frameContext[idx]; currentFrameContext._commandAllocator->Reset(); + // Acquire the current back buffer fresh (AddRef'd) and bind its RTV. It is + // released in End() — we never hold it across frames. The swapchain owns + // the buffer, so dropping our reference doesn't free it; the GPU keeps + // reading it for this frame via the swapchain's own hold. + _currentBackBuffer = nullptr; + if (FAILED(_swapChain->GetBuffer(idx, IID_PPV_ARGS(&_currentBackBuffer)))) { + _currentBackBuffer = nullptr; + return; + } + _device->CreateRenderTargetView(_currentBackBuffer, nullptr, currentFrameContext._mainRenderTargetDescriptor); + _barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; _barrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE; - _barrier.Transition.pResource = currentFrameContext._mainRenderTargetResource; + _barrier.Transition.pResource = _currentBackBuffer; _barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; _barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT; _barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET; @@ -136,11 +152,20 @@ namespace Framework::Graphics { } void D3D12Backend::End() { + if (!_currentBackBuffer) { + return; // Begin() failed to acquire this frame + } + _barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET; _barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PRESENT; _commandList->ResourceBarrier(1, &_barrier); _commandList->Close(); _commandQueue->ExecuteCommandLists(1, (ID3D12CommandList **)&_commandList); + + // Drop our per-frame reference now that the work is submitted. Safe: the + // swapchain still owns the buffer, so it stays alive for the GPU. + _currentBackBuffer->Release(); + _currentBackBuffer = nullptr; } void D3D12Backend::Update() {} diff --git a/code/framework/src/graphics/backend/d3d12.h b/code/framework/src/graphics/backend/d3d12.h index 7504432f9..ba8906a07 100644 --- a/code/framework/src/graphics/backend/d3d12.h +++ b/code/framework/src/graphics/backend/d3d12.h @@ -33,13 +33,18 @@ namespace Framework::Graphics { struct FrameContext { ID3D12CommandAllocator *_commandAllocator = nullptr; - ID3D12Resource *_mainRenderTargetResource = nullptr; D3D12_CPU_DESCRIPTOR_HANDLE _mainRenderTargetDescriptor; }; std::vector _frameContext; D3D12_RESOURCE_BARRIER _barrier {}; + // The current frame's back buffer, acquired fresh in Begin() and released + // in End(). We deliberately never hold a swapchain reference across + // frames so the game can resize/recreate the swapchain (which happens + // between presents) without our reference blocking DXGI's ResizeBuffers. + ID3D12Resource *_currentBackBuffer = nullptr; + public: bool Init(const Framework::Graphics::RendererConfiguration &opts) override; void Shutdown() override; @@ -48,6 +53,16 @@ namespace Framework::Graphics { void End(); int NumFramesInFlight() const; + // Since we no longer cache the back buffers, a swapchain resize or even a + // full swapchain recreation needs nothing from us beyond pointing at the + // current swapchain — Begin() re-acquires the back buffer each frame. + IDXGISwapChain3 *GetSwapChain() const { + return _swapChain; + } + void SetSwapChain(IDXGISwapChain3 *swapChain) { + _swapChain = swapChain; + } + // TODO: Backend not implemented yet void BeginDrawing() {} void EndDrawing() {} diff --git a/code/framework/src/gui/manager.cpp b/code/framework/src/gui/manager.cpp index 50194f38a..94ba35a31 100644 --- a/code/framework/src/gui/manager.cpp +++ b/code/framework/src/gui/manager.cpp @@ -246,6 +246,10 @@ namespace Framework::GUI { return -1; } + // A 0x0 request means "fill the viewport" — such views follow the + // viewport across resizes (see Manager::Resize) + const bool autoResize = (width == 0 && height == 0); + if (width == 0) { width = _viewportConfiguration.width; } @@ -277,6 +281,8 @@ namespace Framework::GUI { return -1; } + view->SetAutoResize(autoResize); + { std::scoped_lock lock(_renderMutex); _views.push_back(std::move(view)); @@ -286,6 +292,22 @@ namespace Framework::GUI { return _id; } + void Manager::Resize(int width, int height) { + if (width <= 0 || height <= 0) { + return; + } + + std::scoped_lock lock(_renderMutex); + _viewportConfiguration.width = width; + _viewportConfiguration.height = height; + + for (auto &view : _views) { + if (view && view->IsAutoResize()) { + view->Resize(width, height); + } + } + } + void Manager::RetireView(std::unique_ptr view) { // Caller must hold _renderMutex. Hide the view so no further draws are // submitted, then let it age out (destroyed in Update once in-flight diff --git a/code/framework/src/gui/manager.h b/code/framework/src/gui/manager.h index d3af71952..f7df660d9 100644 --- a/code/framework/src/gui/manager.h +++ b/code/framework/src/gui/manager.h @@ -81,6 +81,10 @@ namespace Framework::GUI { void Update() override; void Render(); + // Match the viewport (and every fullscreen view) to a new client size, + // e.g. after a swapchain resize / fullscreen toggle. + void Resize(int width, int height); + // Game-thread companion to Render(): must be called inside an active // ImGui frame so D3D12 views can blit through the background draw list void SubmitImGuiDraws(); diff --git a/code/framework/src/gui/view.cpp b/code/framework/src/gui/view.cpp index 9e9d2cbae..ac2821467 100644 --- a/code/framework/src/gui/view.cpp +++ b/code/framework/src/gui/view.cpp @@ -109,6 +109,27 @@ namespace Framework::GUI { // Nothing to update at the base level for CEF; message loop is driven by Manager } + void View::Resize(int width, int height) { + if (width <= 0 || height <= 0) { + return; + } + + // Synchronize with the render thread (ViewD3D12::Render reads _width to + // decide whether to recreate its texture, also under _renderMutex) + std::scoped_lock lock(_renderMutex); + _width = width; + _height = height; + if (_renderHandler) { + _renderHandler->SetDimensions(width, height); + } + if (_browser) { + // Make CEF re-query GetViewRect and repaint at the new size; the + // next OnPaint delivers a full-size buffer and the backend recreates + // its texture (ViewD3D12 detects _texWidth/_texHeight != _width) + _browser->GetHost()->WasResized(); + } + } + void View::ProcessMouseEvent(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { if (!_browser || !_shouldDisplay || !_hasFocus) { return; diff --git a/code/framework/src/gui/view.h b/code/framework/src/gui/view.h index e05db02f1..524b2cb8d 100644 --- a/code/framework/src/gui/view.h +++ b/code/framework/src/gui/view.h @@ -71,6 +71,10 @@ namespace Framework::GUI { bool _shouldDisplay = false; bool _garbageCollected = false; + // Fullscreen overlays (created at 0x0 = viewport size) track the + // viewport across swapchain resizes; explicitly-sized views don't. + bool _autoResize = false; + std::recursive_mutex _renderMutex; glm::vec2 _cursorPos {}; bool _isMouseDown = false; @@ -125,6 +129,18 @@ namespace Framework::GUI { _y = y; } + // Resize the view (and the underlying CEF surface) to new dimensions. + // The backend recreates its GPU texture lazily on the next Render. + void Resize(int width, int height); + + void SetAutoResize(bool enable) { + _autoResize = enable; + } + + bool IsAutoResize() const { + return _autoResize; + } + glm::vec2 GetPosition() const { return {_x, _y}; }