aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhachem <im@hachem.wtf>2026-08-28 03:25:56 +0200
committerhachem <im@hachem.wtf>2026-08-28 03:25:56 +0200
commit9a6ce759212d293a96f82678b59148d8a49efc93 (patch)
tree684efea100bc473ce30745e8f0545e70de1f978c
parent801c6a8916a495162b200fff0d14cfbf89ea854c (diff)
add: link llvm
-rw-r--r--.gitignore5
-rw-r--r--premake5.lua195
-rw-r--r--src/debugger/debugger.cpp250
-rw-r--r--src/debugger/debugger.h8
-rw-r--r--src/debugger/types.h1
-rw-r--r--src/ui/source_view.cpp12
-rw-r--r--src/ui/ui.cpp31
-rw-r--r--src/ui/ui.h1
8 files changed, 492 insertions, 11 deletions
diff --git a/.gitignore b/.gitignore
index bf09ad3..1677882 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,8 @@ compile_flags.txt
# imgui layout state
imgui.ini
+
+# llvm pulled down by `premake5 fetch-llvm`
+ext/llvm/
+ext/llvm-staging/
+ext/*.tar.xz
diff --git a/premake5.lua b/premake5.lua
index eff7f87..ffda3a8 100644
--- a/premake5.lua
+++ b/premake5.lua
@@ -1,12 +1,114 @@
output_dir = "%{cfg.buildcfg}-%{cfg.system}"
+llvm_version = "22.1.0"
+llvm_vendored_dir = "ext/llvm"
+
+function llvm_release_asset()
+ local host = os.host()
+ local arm = os.hostarch() == "ARM64" or os.hostarch() == "arm64"
+
+ if host == "macosx" then
+ return "LLVM-" .. llvm_version .. "-macOS-ARM64.tar.xz"
+ elseif host == "linux" then
+ return "LLVM-" .. llvm_version .. (arm and "-Linux-ARM64" or "-Linux-X64") .. ".tar.xz"
+ elseif host == "windows" then
+ return "clang+llvm-" .. llvm_version ..
+ (arm and "-aarch64-pc-windows-msvc" or "-x86_64-pc-windows-msvc") .. ".tar.xz"
+ end
+
+ return nil
+end
+
+function llvm_prefix()
+ local function usable(candidate)
+ if candidate == nil or candidate == "" then
+ return nil
+ end
+
+ local prefix = path.getabsolute(
+ (candidate:gsub("%s+$", "")):gsub("\\", "/"), _MAIN_SCRIPT_DIR)
+
+ return os.isdir(prefix .. "/include/lldb") and prefix or nil
+ end
+
+ local function newest_of(pattern)
+ local matches = os.matchdirs(pattern)
+ table.sort(matches)
+
+ for index = #matches, 1, -1 do
+ local prefix = usable(matches[index])
+
+ if prefix ~= nil then
+ return prefix
+ end
+ end
+
+ return nil
+ end
+
+ local prefix = usable(os.getenv("LLVM_PREFIX"))
+ or usable(llvm_vendored_dir)
+ or usable(os.outputof("llvm-config --prefix"))
+
+ if prefix == nil and os.host() == "windows" then
+ prefix = usable(os.getenv("ProgramFiles") and os.getenv("ProgramFiles") .. "/LLVM")
+ or newest_of("C:/Program Files/LLVM*")
+ end
+
+ if prefix == nil and os.host() == "macosx" then
+ prefix = usable(os.outputof("brew --prefix llvm"))
+ end
+
+ if prefix == nil and os.host() == "linux" then
+ prefix = newest_of("/usr/lib/llvm-*") or newest_of("/usr/local/llvm*")
+ end
+
+ return prefix
+end
+
+llvm_dir = llvm_prefix()
+
+if llvm_dir == nil and _ACTION ~= nil and _ACTION ~= "fetch-llvm" and _ACTION ~= "clean" then
+ error("no llvm with lldb headers found. install one and set LLVM_PREFIX, " ..
+ "or run `premake5 fetch-llvm` to download the pinned " .. llvm_version .. " release")
+end
+
+llvm_dir = llvm_dir or ""
+
include_dir = {
glfw = "ext/glfw/include",
glad = "ext/glad/include",
imgui = "ext/imgui",
imgui_backends = "ext/imgui/backends",
+ lldb = llvm_dir .. "/include",
}
+library_dir = {
+ lldb = llvm_dir .. "/lib",
+}
+
+function lldb_library()
+ if llvm_dir == "" then
+ return ""
+ end
+
+ local names = { "liblldb.dylib", "liblldb.so", "liblldb.lib" }
+
+ for _, name in ipairs(names) do
+ local candidate = library_dir.lldb .. "/" .. name
+
+ if os.isfile(candidate) then
+ return candidate
+ end
+ end
+
+ local versioned = os.matchfiles(library_dir.lldb .. "/liblldb.so.*")
+ table.sort(versioned)
+
+ return versioned[#versioned]
+ or error("llvm at " .. llvm_dir .. " has lldb headers but no liblldb in " .. library_dir.lldb)
+end
+
function setup_target()
targetdir ("bin/" .. output_dir)
objdir ("bin-int/" .. output_dir .. "/%{prj.name}")
@@ -40,16 +142,93 @@ newaction {
execute = function()
os.rmdir("bin")
os.rmdir("bin-int")
+ os.rmdir(".vs")
os.remove("Makefile")
- for _, file in ipairs(os.matchfiles("*.make")) do
- os.remove(file)
+ local patterns = { "*.make", "*.sln", "*.vcxproj", "*.vcxproj.*" }
+
+ for _, pattern in ipairs(patterns) do
+ for _, file in ipairs(os.matchfiles(pattern)) do
+ os.remove(file)
+ end
end
print("Cleaned build output and generated project files")
end
}
+newaction {
+ trigger = "fetch-llvm",
+ description = "Download the pinned llvm release into " .. llvm_vendored_dir,
+
+ execute = function()
+ if os.isdir(llvm_vendored_dir .. "/include/lldb") then
+ print(llvm_vendored_dir .. " already has an llvm with lldb headers")
+ return
+ end
+
+ local asset = llvm_release_asset()
+
+ if asset == nil then
+ error("no pinned llvm release for " .. os.host() .. " " .. os.hostarch() ..
+ ", install llvm yourself and set LLVM_PREFIX")
+ end
+
+ local archive = "ext/" .. asset
+ local staging = "ext/llvm-staging"
+
+ if not os.isfile(archive) then
+ local url = "https://github.com/llvm/llvm-project/releases/download/llvmorg-" ..
+ llvm_version .. "/" .. asset
+
+ print("downloading " .. url)
+
+ local reported = 0
+ local result, code = http.download(url, archive, {
+ progress = function(total, current)
+ if total > 0 and current - reported >= total / 20 then
+ reported = current
+ printf(" %d%% (%.0f mb)", math.floor(current / total * 100), current / 1048576)
+ end
+ end
+ })
+
+ if result ~= "OK" then
+ os.remove(archive)
+ error("download failed with " .. tostring(code) .. ": " .. tostring(result))
+ end
+ end
+
+ os.rmdir(staging)
+ os.mkdir(staging)
+
+ print("extracting " .. asset)
+
+ local extracted = os.execute('tar -xf "' .. archive .. '" -C "' .. staging .. '"')
+
+ if extracted ~= true and extracted ~= 0 then
+ error("could not extract " .. archive .. ", unpack it into " .. llvm_vendored_dir .. " by hand")
+ end
+
+ local unpacked = os.matchdirs(staging .. "/*")[1]
+
+ if unpacked == nil then
+ error(archive .. " did not contain an llvm directory")
+ end
+
+ os.rmdir(llvm_vendored_dir)
+
+ if not os.rename(unpacked, llvm_vendored_dir) then
+ error("could not move " .. unpacked .. " to " .. llvm_vendored_dir)
+ end
+
+ os.rmdir(staging)
+ os.remove(archive)
+
+ print("llvm " .. llvm_version .. " is ready in " .. llvm_vendored_dir)
+ end
+}
+
workspace "hsdbg"
architecture "x64"
@@ -265,6 +444,7 @@ project "hsdbg"
"%{include_dir.glad}",
"%{include_dir.imgui}",
"%{include_dir.imgui_backends}",
+ "%{include_dir.lldb}",
}
links {
@@ -273,9 +453,20 @@ project "hsdbg"
"imgui",
}
+ defines { "HSDBG_LLVM_PREFIX=\"" .. llvm_dir .. "\"" }
+
+ linkoptions { lldb_library() }
+
filter "system:windows"
systemversion "latest"
+
links "opengl32"
+
+ postbuildcommands {
+ '{COPYFILE} "' .. llvm_dir .. '/bin/liblldb.dll" "%{cfg.targetdir}"',
+ }
+ filter "system:not windows"
+ linkoptions { "-Wl,-rpath," .. library_dir.lldb }
filter "system:linux"
links {
"GL",
diff --git a/src/debugger/debugger.cpp b/src/debugger/debugger.cpp
index 7cd2d01..1e1aee7 100644
--- a/src/debugger/debugger.cpp
+++ b/src/debugger/debugger.cpp
@@ -1,14 +1,116 @@
#include "debugger/debugger.h"
+#include "core/assert.h"
#include "core/log.h"
+#include <lldb/API/LLDB.h>
+
#include <algorithm>
#include <source_location>
+// no standard way to read or write the environment
+#include <cstdlib>
+
namespace Hsdbg
{
namespace
{
+#ifndef HSDBG_LLVM_PREFIX
+ #define HSDBG_LLVM_PREFIX ""
+#endif
+
+ auto set_environment(const char* name, const std::string& value) -> void
+ {
+#ifdef HSDBG_WINDOWS
+ _putenv_s(name, value.c_str());
+#else
+ setenv(name, value.c_str(), 1);
+#endif
+ }
+
+ // lldb spawns a helper to control the inferior on unix. windows needs none,
+ // linux ships lldb-server beside liblldb, and macos requires one entitled
+ // with com.apple.private.cs.debugger, which only the xcode copy carries
+ auto debug_server_candidates() -> std::vector<std::filesystem::path>
+ {
+ const std::filesystem::path llvm_prefix(HSDBG_LLVM_PREFIX);
+
+#if defined(HSDBG_MACOS)
+ return {
+ "/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/"
+ "Resources/debugserver",
+ "/Library/Developer/CommandLineTools/Library/PrivateFrameworks/LLDB.framework/"
+ "Resources/debugserver",
+ llvm_prefix / "bin" / "debugserver",
+ };
+#elif defined(HSDBG_LINUX)
+ return { llvm_prefix / "bin" / "lldb-server" };
+#else
+ return {};
+#endif
+ }
+
+ auto adopt_debug_server() -> void
+ {
+ const std::vector<std::filesystem::path> candidates = debug_server_candidates();
+
+ if (candidates.empty())
+ return;
+
+ if (std::getenv("LLDB_DEBUGSERVER_PATH") != nullptr)
+ return;
+
+ for (const std::filesystem::path& candidate : candidates)
+ {
+ std::error_code error;
+
+ if (!std::filesystem::exists(candidate, error))
+ continue;
+
+ set_environment("LLDB_DEBUGSERVER_PATH", candidate.string());
+ Log::debug("debugger: using debug server '{}'", candidate.string());
+
+ return;
+ }
+
+ Log::warn("debugger: no debug server found, launching a target will fail");
+ }
+
+ auto read_back(lldb::SBTarget& target, lldb::SBBreakpoint& source, Breakpoint& breakpoint) -> void
+ {
+ breakpoint.enabled = source.IsEnabled();
+ breakpoint.hit_count = source.GetHitCount();
+
+ // lldb only calls a location resolved once a process has it mapped, but
+ // for the ui the question is whether it found somewhere to put it at all
+ breakpoint.resolved = source.GetNumLocations() > 0;
+
+ if (source.GetNumLocations() == 0)
+ {
+ breakpoint.address = 0;
+ return;
+ }
+
+ lldb::SBBreakpointLocation location = source.GetLocationAtIndex(0);
+ lldb::SBAddress address = location.GetAddress();
+
+ const lldb::addr_t load_address = address.GetLoadAddress(target);
+ breakpoint.address = load_address != LLDB_INVALID_ADDRESS ? load_address
+ : address.GetFileAddress();
+
+ lldb::SBSymbolContext context = address.GetSymbolContext(lldb::eSymbolContextEverything);
+
+ if (const char* name = context.GetFunction().GetName(); name != nullptr)
+ breakpoint.function = name;
+ else if (const char* symbol = context.GetSymbol().GetName(); symbol != nullptr)
+ breakpoint.function = symbol;
+
+ lldb::SBLineEntry line_entry = context.GetLineEntry();
+
+ if (breakpoint.line != 0 && line_entry.IsValid())
+ breakpoint.line = line_entry.GetLine();
+ }
+
// every call site reports itself, so the stubs stay one line each until
// there is an lldb session behind them
auto not_implemented(std::source_location location = std::source_location::current())
@@ -19,14 +121,37 @@ namespace Hsdbg
}
}
+ struct Debugger::Session
+ {
+ lldb::SBDebugger debugger;
+ lldb::SBTarget target;
+ };
+
Debugger::Debugger()
+ : m_session(std::make_unique<Session>())
{
- Log::info("debugger: ready");
+ adopt_debug_server();
+
+ lldb::SBDebugger::Initialize();
+
+ m_session->debugger = lldb::SBDebugger::Create();
+ m_session->debugger.SetAsync(true);
+
+ HSDBG_ASSERT(m_session->debugger.IsValid(), "failed to create the lldb debugger");
+
+ Log::info("debugger: {}", lldb::SBDebugger::GetVersionString());
}
Debugger::~Debugger()
{
unload_target();
+
+ if (m_session->debugger.IsValid())
+ lldb::SBDebugger::Destroy(m_session->debugger);
+
+ m_session.reset();
+
+ lldb::SBDebugger::Terminate();
}
auto Debugger::load_target(const std::filesystem::path& executable) -> Result<void>
@@ -43,11 +168,42 @@ namespace Hsdbg
if (error)
m_target_path = executable;
+ lldb::SBError create_error;
+ lldb::SBTarget target = m_session->debugger.CreateTarget(m_target_path.string().c_str(),
+ nullptr,
+ nullptr,
+ true,
+ create_error);
+
+ if (!target.IsValid())
+ {
+ const char* reason = create_error.GetCString();
+
+ m_target_path.clear();
+
+ return fail("could not load '{}': {}",
+ executable.string(),
+ reason != nullptr ? reason : "unknown error");
+ }
+
+ if (m_session->target.IsValid())
+ m_session->debugger.DeleteTarget(m_session->target);
+
+ m_session->target = target;
+
m_process_id = 0;
m_stop_reason = StopReason::None;
set_state(TargetState::Loaded);
- Log::info("debugger: loaded target '{}'", m_target_path.string());
+ const char* triple = m_session->target.GetTriple();
+
+ Log::info("debugger: loaded target '{}' ({})",
+ m_target_path.string(),
+ triple != nullptr ? triple : "unknown triple");
+
+ // breakpoints set before a target existed are only requests until now
+ for (Breakpoint& breakpoint : m_breakpoints)
+ resolve_breakpoint(breakpoint);
return {};
}
@@ -69,11 +225,19 @@ namespace Hsdbg
for (Breakpoint& breakpoint : m_breakpoints)
{
+ breakpoint.backend_id = 0;
breakpoint.resolved = false;
breakpoint.hit_count = 0;
breakpoint.address = 0;
}
+ if (m_session->target.IsValid())
+ {
+ m_session->target.DeleteAllBreakpoints();
+ m_session->debugger.DeleteTarget(m_session->target);
+ m_session->target = lldb::SBTarget();
+ }
+
set_state(TargetState::NoTarget);
}
@@ -149,6 +313,7 @@ namespace Hsdbg
breakpoint.line = line;
m_breakpoints.push_back(std::move(breakpoint));
+ resolve_breakpoint(m_breakpoints.back());
Log::info("debugger: breakpoint {} at {}:{}",
m_breakpoints.back().id,
@@ -173,6 +338,7 @@ namespace Hsdbg
breakpoint.function = function;
m_breakpoints.push_back(std::move(breakpoint));
+ resolve_breakpoint(m_breakpoints.back());
Log::info("debugger: breakpoint {} at {}()", m_breakpoints.back().id, function);
@@ -185,6 +351,9 @@ namespace Hsdbg
if (entry == m_breakpoints.end())
return false;
+ if (entry->backend_id != 0 && m_session->target.IsValid())
+ m_session->target.BreakpointDelete(entry->backend_id);
+
m_breakpoints.erase(entry);
Log::info("debugger: removed breakpoint {}", id);
@@ -200,11 +369,22 @@ namespace Hsdbg
breakpoint->enabled = enabled;
+ if (breakpoint->backend_id != 0 && m_session->target.IsValid())
+ {
+ lldb::SBBreakpoint source = m_session->target.FindBreakpointByID(breakpoint->backend_id);
+
+ if (source.IsValid())
+ source.SetEnabled(enabled);
+ }
+
return true;
}
auto Debugger::clear_breakpoints() -> void
{
+ if (m_session->target.IsValid())
+ m_session->target.DeleteAllBreakpoints();
+
m_breakpoints.clear();
}
@@ -252,6 +432,72 @@ namespace Hsdbg
auto Debugger::update() -> void
{
+ sync_breakpoints();
+ }
+
+ auto Debugger::resolve_breakpoint(Breakpoint& breakpoint) -> void
+ {
+ if (!m_session->target.IsValid())
+ return;
+
+ lldb::SBBreakpoint created;
+
+ if (breakpoint.line == 0)
+ {
+ // unscoped, a name like main matches every module the target pulls in
+ created = m_session->target.BreakpointCreateByName(
+ breakpoint.function.c_str(), m_session->target.GetExecutable().GetFilename());
+ }
+ else
+ {
+ created = m_session->target.BreakpointCreateByLocation(breakpoint.file.string().c_str(),
+ breakpoint.line);
+
+ // debug info records whatever path the compiler saw, which rarely
+ // matches what the user opened, so fall back to the file name alone
+ if (created.GetNumLocations() == 0 && breakpoint.file.has_parent_path())
+ {
+ m_session->target.BreakpointDelete(created.GetID());
+
+ created = m_session->target.BreakpointCreateByLocation(
+ breakpoint.file.filename().string().c_str(), breakpoint.line);
+ }
+ }
+
+ if (!created.IsValid())
+ {
+ Log::warn("debugger: lldb refused breakpoint {}", breakpoint.id);
+ return;
+ }
+
+ created.SetEnabled(breakpoint.enabled);
+
+ breakpoint.backend_id = created.GetID();
+
+ read_back(m_session->target, created, breakpoint);
+
+ Log::debug("debugger: breakpoint {} {} ({} locations)",
+ breakpoint.id,
+ breakpoint.resolved ? "resolved" : "pending",
+ created.GetNumLocations());
+ }
+
+ auto Debugger::sync_breakpoints() -> void
+ {
+ if (!m_session->target.IsValid())
+ return;
+
+ for (Breakpoint& breakpoint : m_breakpoints)
+ {
+ if (breakpoint.backend_id == 0)
+ continue;
+
+ lldb::SBBreakpoint source = m_session->target.FindBreakpointByID(breakpoint.backend_id);
+ if (!source.IsValid())
+ continue;
+
+ read_back(m_session->target, source, breakpoint);
+ }
}
auto Debugger::set_state(TargetState state) -> void
diff --git a/src/debugger/debugger.h b/src/debugger/debugger.h
index c810417..c4f3431 100644
--- a/src/debugger/debugger.h
+++ b/src/debugger/debugger.h
@@ -5,6 +5,7 @@
#include <cstdint>
#include <filesystem>
+#include <memory>
#include <span>
#include <string>
#include <string_view>
@@ -76,7 +77,14 @@ namespace Hsdbg
auto is_stopped() const -> bool { return m_state == TargetState::Stopped; }
private:
+ // keeps the lldb headers out of everything that talks to the debugger
+ struct Session;
+
auto set_state(TargetState state) -> void;
+ auto resolve_breakpoint(Breakpoint& breakpoint) -> void;
+ auto sync_breakpoints() -> void;
+
+ std::unique_ptr<Session> m_session;
TargetState m_state = TargetState::NoTarget;
StopReason m_stop_reason = StopReason::None;
diff --git a/src/debugger/types.h b/src/debugger/types.h
index 2f9a698..193a1cc 100644
--- a/src/debugger/types.h
+++ b/src/debugger/types.h
@@ -78,6 +78,7 @@ namespace Hsdbg
struct Breakpoint
{
uint32_t id = 0;
+ int32_t backend_id = 0;
std::filesystem::path file;
uint32_t line = 0;
std::string function;
diff --git a/src/ui/source_view.cpp b/src/ui/source_view.cpp
index 37ceffc..7f80aa8 100644
--- a/src/ui/source_view.cpp
+++ b/src/ui/source_view.cpp
@@ -192,10 +192,14 @@ namespace Hsdbg
if (breakpoint != nullptr)
{
- draw_list->AddCircleFilled(marker_center,
- BREAKPOINT_RADIUS,
- breakpoint->enabled ? BREAKPOINT_COLOR
- : BREAKPOINT_DISABLED_COLOR);
+ const ImU32 color = breakpoint->enabled ? BREAKPOINT_COLOR
+ : BREAKPOINT_DISABLED_COLOR;
+
+ // hollow until lldb finds somewhere to actually put it
+ if (breakpoint->resolved)
+ draw_list->AddCircleFilled(marker_center, BREAKPOINT_RADIUS, color);
+ else
+ draw_list->AddCircle(marker_center, BREAKPOINT_RADIUS, color, 0, 1.5f);
}
else if (gutter_hovered)
{
diff --git a/src/ui/ui.cpp b/src/ui/ui.cpp
index 91fab0a..bc7c910 100644
--- a/src/ui/ui.cpp
+++ b/src/ui/ui.cpp
@@ -28,8 +28,8 @@ namespace Hsdbg
constexpr const char* LOAD_TARGET_POPUP = "load target";
- // macos gives us a 4.1 core context, which speaks glsl 1.50
- // #portability
+ // the lowest glsl that every 3.2+ core profile accepts, and the window
+ // asks for a 3.3 core context on all three platforms
constexpr const char* GLSL_VERSION = "#version 150";
constexpr size_t MAX_CONSOLE_LINES = 2048;
@@ -173,6 +173,9 @@ namespace Hsdbg
{
build_default_layout(dockspace_id);
m_layout_built = true;
+
+ // otherwise whichever panel happens to be submitted last wins the tab
+ m_focus_breakpoints = true;
}
draw_status_bar(debugger);
@@ -190,6 +193,14 @@ namespace Hsdbg
if (m_visible.demo)
ImGui::ShowDemoWindow(&m_visible.demo);
+
+ // a window claims its tab when it is first submitted, so this can only be
+ // asked for once every panel in the node exists
+ if (m_focus_breakpoints)
+ {
+ ImGui::SetWindowFocus(PANEL_BREAKPOINTS);
+ m_focus_breakpoints = false;
+ }
}
auto Ui::build_default_layout(uint32_t dockspace_id) -> void
@@ -466,11 +477,12 @@ namespace Hsdbg
ImGuiTableFlags_SizingStretchProp |
ImGuiTableFlags_ScrollY;
- if (ImGui::BeginTable("##breakpoints", 5, flags))
+ if (ImGui::BeginTable("##breakpoints", 6, flags))
{
ImGui::TableSetupColumn("on", ImGuiTableColumnFlags_WidthFixed, 26.0f);
ImGui::TableSetupColumn("id", ImGuiTableColumnFlags_WidthFixed, 30.0f);
ImGui::TableSetupColumn("location");
+ ImGui::TableSetupColumn("address");
ImGui::TableSetupColumn("hits", ImGuiTableColumnFlags_WidthFixed, 40.0f);
ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 26.0f);
ImGui::TableSetupScrollFreeze(0, 1);
@@ -507,6 +519,19 @@ namespace Hsdbg
ImGui::SetTooltip("%s", breakpoint.file.string().c_str());
ImGui::TableNextColumn();
+ if (breakpoint.resolved)
+ {
+ ImGui::Text("0x%llx", static_cast<unsigned long long>(breakpoint.address));
+
+ if (ImGui::IsItemHovered() && !breakpoint.function.empty())
+ ImGui::SetTooltip("%s", breakpoint.function.c_str());
+ }
+ else
+ {
+ ImGui::TextDisabled("pending");
+ }
+
+ ImGui::TableNextColumn();
ImGui::Text("%u", breakpoint.hit_count);
ImGui::TableNextColumn();
diff --git a/src/ui/ui.h b/src/ui/ui.h
index 20d4e1b..90c314d 100644
--- a/src/ui/ui.h
+++ b/src/ui/ui.h
@@ -75,5 +75,6 @@ namespace Hsdbg
bool m_layout_built = false;
bool m_console_scroll_pending = false;
bool m_load_target_pending = false;
+ bool m_focus_breakpoints = false;
};
}