fix(app): free the replaced gradient texture and defer logo frees

reloadThemeImages() replaced gradient_tex_ in each of its three branches
(image loaded, load failed, no background) without freeing the old
texture, so every skin activation, including the one at startup, leaked
one texture.

The logo and coin textures were freed immediately instead, although the
current frame's draw lists can still reference them: the window backdrop
records its images at the start of the frame and skin switches happen
mid-frame (Settings, Ctrl+arrow keys), and the screenshot sweep switches
skins after RenderDrawData() but before the secondary viewports are
rendered. That hands the renderer a freed texture.

Textures replaced mid-frame are now retired to a queue (retireTexture()
also zeroes the member) that the next preFrame() frees first, after the
previous frame's draw data has been rendered. The old gradient is
retired at the top of reloadThemeImages(), and the logo and coin where
they used to be freed. ensureLogoTexture() still frees directly, since
it runs at the top of render(), before this frame draws a logo.
Shutdown does not drain the queue.

Measured with a temporary live-texture counter over a screenshot sweep
of all 10 skins: before, 6, 7, ... 15 live textures (one more per skin);
now a constant 5. The first screenshot after each skin switch is
pixel-identical to before on all 10 skins, and the full sweep matches
within noise.

Not verifiable here: DX11 at runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 06:05:07 -05:00
co-authored by Claude Opus 5.5
parent 261883ca21
commit 1bf803f107
2 changed files with 16 additions and 3 deletions
+12 -3
View File
@@ -623,6 +623,10 @@ void App::applyVisualSettings()
void App::preFrame()
{
// Free textures retired during the previous frame (its draw data has been rendered by now).
for (ImTextureID t : pending_texture_destroy_) util::DestroyTexture(t);
pending_texture_destroy_.clear();
ImGuiIO& io = ImGui::GetIO();
// Hot-reload unified UI schema
@@ -3300,6 +3304,10 @@ void App::renderStatusBar()
void App::reloadThemeImages(const std::string& bgPath, const std::string& logoPath)
{
// Every branch below replaces gradient_tex_; the background draw list may already reference the
// old one this frame (skin switches happen mid-frame), so free it at the next preFrame().
retireTexture(gradient_tex_);
// Reload background image from the path resolved by the skin system.
// Each theme specifies its image via [theme] images.background_image in its .toml.
if (!bgPath.empty()) {
@@ -3337,14 +3345,15 @@ void App::reloadThemeImages(const std::string& bgPath, const std::string& logoPa
}
// Reset logo loaded flags — will reload on next render frame (ensureLogoTexture re-rasterizes the
// SVG for the new theme). Free the current texture first so a theme switch doesn't leak it.
if (logo_tex_) util::DestroyTexture(logo_tex_);
// SVG for the new theme). Retire the current texture (freed at the next preFrame(), after this
// frame's draw lists are rendered) so a theme switch doesn't leak it.
retireTexture(logo_tex_);
logo_loaded_ = false;
logo_tex_ = 0;
logo_w_ = 0;
logo_h_ = 0;
coin_logo_loaded_ = false;
if (coin_logo_tex_) util::DestroyTexture(coin_logo_tex_);
retireTexture(coin_logo_tex_);
coin_logo_tex_ = 0;
coin_logo_w_ = 0;
coin_logo_h_ = 0;
+4
View File
@@ -1619,6 +1619,10 @@ private:
// Background gradient overlay
ImTextureID gradient_tex_ = 0;
// Textures retired mid-frame (draw lists may still reference them); freed at the next preFrame().
std::vector<ImTextureID> pending_texture_destroy_;
void retireTexture(ImTextureID& t) { if (t) { pending_texture_destroy_.push_back(t); t = 0; } }
// Logo texture (reload on skin change / dark↔light switch)
ImTextureID logo_tex_ = 0;
int logo_w_ = 0;