mirror of
https://github.com/wheremyfoodat/Panda3DS.git
synced 2025-07-08 08:12:57 +12:00
Merge pull request #774 from wheremyfoodat/cpu-debugger
Some checks are pending
Android Build / x64 (release) (push) Waiting to run
Android Build / arm64 (release) (push) Waiting to run
HTTP Server Build / build (push) Waiting to run
Hydra Core Build / Windows (push) Waiting to run
Hydra Core Build / MacOS (push) Waiting to run
Hydra Core Build / Linux (push) Waiting to run
Hydra Core Build / Android-x64 (push) Waiting to run
Hydra Core Build / ARM-Libretro (push) Waiting to run
Linux AppImage Build / build (push) Waiting to run
Linux Build / build (push) Waiting to run
MacOS Build / MacOS-arm64 (push) Waiting to run
MacOS Build / MacOS-x86_64 (push) Waiting to run
MacOS Build / MacOS-Universal (push) Blocked by required conditions
Qt Build / Windows (push) Waiting to run
Qt Build / MacOS-arm64 (push) Waiting to run
Qt Build / MacOS-x86_64 (push) Waiting to run
Qt Build / MacOS-Universal (push) Blocked by required conditions
Qt Build / Linux (push) Waiting to run
Windows Build / build (push) Waiting to run
iOS Simulator Build / build (push) Waiting to run
Some checks are pending
Android Build / x64 (release) (push) Waiting to run
Android Build / arm64 (release) (push) Waiting to run
HTTP Server Build / build (push) Waiting to run
Hydra Core Build / Windows (push) Waiting to run
Hydra Core Build / MacOS (push) Waiting to run
Hydra Core Build / Linux (push) Waiting to run
Hydra Core Build / Android-x64 (push) Waiting to run
Hydra Core Build / ARM-Libretro (push) Waiting to run
Linux AppImage Build / build (push) Waiting to run
Linux Build / build (push) Waiting to run
MacOS Build / MacOS-arm64 (push) Waiting to run
MacOS Build / MacOS-x86_64 (push) Waiting to run
MacOS Build / MacOS-Universal (push) Blocked by required conditions
Qt Build / Windows (push) Waiting to run
Qt Build / MacOS-arm64 (push) Waiting to run
Qt Build / MacOS-x86_64 (push) Waiting to run
Qt Build / MacOS-Universal (push) Blocked by required conditions
Qt Build / Linux (push) Waiting to run
Windows Build / build (push) Waiting to run
iOS Simulator Build / build (push) Waiting to run
Qt: Initial CPU debugger implementation
This commit is contained in:
commit
ce4750e375
8 changed files with 351 additions and 6 deletions
|
@ -733,12 +733,12 @@ if(NOT BUILD_HYDRA_CORE AND NOT BUILD_LIBRETRO_CORE)
|
|||
set(FRONTEND_SOURCE_FILES src/panda_qt/main.cpp src/panda_qt/screen.cpp src/panda_qt/main_window.cpp src/panda_qt/about_window.cpp
|
||||
src/panda_qt/config_window.cpp src/panda_qt/zep.cpp src/panda_qt/text_editor.cpp src/panda_qt/cheats_window.cpp src/panda_qt/mappings.cpp
|
||||
src/panda_qt/patch_window.cpp src/panda_qt/elided_label.cpp src/panda_qt/shader_editor.cpp src/panda_qt/translations.cpp
|
||||
src/panda_qt/thread_debugger.cpp
|
||||
src/panda_qt/thread_debugger.cpp src/panda_qt/cpu_debugger.cpp
|
||||
)
|
||||
set(FRONTEND_HEADER_FILES include/panda_qt/screen.hpp include/panda_qt/main_window.hpp include/panda_qt/about_window.hpp
|
||||
include/panda_qt/config_window.hpp include/panda_qt/text_editor.hpp include/panda_qt/cheats_window.hpp
|
||||
include/panda_qt/patch_window.hpp include/panda_qt/elided_label.hpp include/panda_qt/shader_editor.hpp
|
||||
include/panda_qt/thread_debugger.hpp
|
||||
include/panda_qt/thread_debugger.hpp include/panda_qt/cpu_debugger.hpp include/panda_qt/disabled_widget_overlay.hpp
|
||||
)
|
||||
|
||||
source_group("Source Files\\Qt" FILES ${FRONTEND_SOURCE_FILES})
|
||||
|
|
|
@ -23,7 +23,7 @@ namespace Common {
|
|||
// pc: program counter of the instruction to disassemble
|
||||
// bytes: Byte representation of instruction
|
||||
// buffer: text buffer to output the disassembly too
|
||||
usize disassemble(std::string& buffer, u32 pc, std::span<u8> bytes, u64 offset = 0) {
|
||||
usize disassemble(std::string& buffer, u32 pc, std::span<const u8> bytes, u64 offset = 0) {
|
||||
if (!initialized) {
|
||||
buffer = "Capstone was not properly initialized";
|
||||
return 0;
|
||||
|
|
|
@ -122,14 +122,16 @@ class Emulator {
|
|||
// Reloads some settings that require special handling, such as audio enable
|
||||
void reloadSettings();
|
||||
|
||||
CPU& getCPU() { return cpu; }
|
||||
Memory& getMemory() { return memory; }
|
||||
Kernel& getKernel() { return kernel; }
|
||||
Scheduler& getScheduler() { return scheduler; }
|
||||
|
||||
EmulatorConfig& getConfig() { return config; }
|
||||
Cheats& getCheats() { return cheats; }
|
||||
ServiceManager& getServiceManager() { return kernel.getServiceManager(); }
|
||||
LuaManager& getLua() { return lua; }
|
||||
Scheduler& getScheduler() { return scheduler; }
|
||||
Memory& getMemory() { return memory; }
|
||||
AudioDeviceInterface& getAudioDevice() { return audioDevice; }
|
||||
Kernel& getKernel() { return kernel; }
|
||||
|
||||
RendererType getRendererType() const { return config.rendererType; }
|
||||
Renderer* getRenderer() { return gpu.getRenderer(); }
|
||||
|
|
45
include/panda_qt/cpu_debugger.hpp
Normal file
45
include/panda_qt/cpu_debugger.hpp
Normal file
|
@ -0,0 +1,45 @@
|
|||
#pragma once
|
||||
#include <QLineEdit>
|
||||
#include <QListWidget>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QScrollBar>
|
||||
#include <QWidget>
|
||||
|
||||
#include "capstone.hpp"
|
||||
#include "emulator.hpp"
|
||||
#include "panda_qt/disabled_widget_overlay.hpp"
|
||||
|
||||
class CPUDebugger : public QWidget {
|
||||
Q_OBJECT
|
||||
Emulator* emu;
|
||||
|
||||
QListWidget* disasmListWidget;
|
||||
QScrollBar* verticalScrollBar;
|
||||
QPlainTextEdit* registerTextEdit;
|
||||
QTimer* updateTimer;
|
||||
QLineEdit* addressInput;
|
||||
|
||||
DisabledWidgetOverlay* disabledOverlay;
|
||||
|
||||
bool enabled = false;
|
||||
bool followPC = false;
|
||||
Common::CapstoneDisassembler disassembler;
|
||||
|
||||
public:
|
||||
CPUDebugger(Emulator* emulator, QWidget* parent = nullptr);
|
||||
void enable();
|
||||
void disable();
|
||||
|
||||
private:
|
||||
// Update the state of the disassembler. Qt events should always call update, not updateDisasm/updateRegister
|
||||
// As update properly handles thread safety
|
||||
void update();
|
||||
void updateDisasm();
|
||||
void updateRegisters();
|
||||
void scrollToPC();
|
||||
|
||||
bool eventFilter(QObject* obj, QEvent* event) override;
|
||||
void showEvent(QShowEvent* event) override;
|
||||
void resizeEvent(QResizeEvent* event) override;
|
||||
void keyPressEvent(QKeyEvent* event);
|
||||
};
|
28
include/panda_qt/disabled_widget_overlay.hpp
Normal file
28
include/panda_qt/disabled_widget_overlay.hpp
Normal file
|
@ -0,0 +1,28 @@
|
|||
#pragma once
|
||||
|
||||
#include <QtWidgets>
|
||||
|
||||
class DisabledWidgetOverlay : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
DisabledWidgetOverlay(QWidget *parent = nullptr, QString overlayText = tr("This widget is disabled")) : text(overlayText), QWidget(parent) {
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
private:
|
||||
QString text;
|
||||
|
||||
void paintEvent(QPaintEvent *) override {
|
||||
QPainter painter = QPainter(this);
|
||||
painter.fillRect(rect(), QColor(60, 60, 60, 128));
|
||||
painter.setPen(Qt::gray);
|
||||
|
||||
QFont font = painter.font();
|
||||
font.setBold(true);
|
||||
font.setPointSize(18);
|
||||
|
||||
painter.setFont(font);
|
||||
painter.drawText(rect(), Qt::AlignCenter, text);
|
||||
}
|
||||
};
|
|
@ -17,6 +17,7 @@
|
|||
#include "panda_qt/about_window.hpp"
|
||||
#include "panda_qt/cheats_window.hpp"
|
||||
#include "panda_qt/config_window.hpp"
|
||||
#include "panda_qt/cpu_debugger.hpp"
|
||||
#include "panda_qt/patch_window.hpp"
|
||||
#include "panda_qt/screen.hpp"
|
||||
#include "panda_qt/shader_editor.hpp"
|
||||
|
@ -110,6 +111,7 @@ class MainWindow : public QMainWindow {
|
|||
TextEditorWindow* luaEditor;
|
||||
PatchWindow* patchWindow;
|
||||
ShaderEditorWindow* shaderEditor;
|
||||
CPUDebugger* cpuDebugger;
|
||||
ThreadDebugger* threadDebugger;
|
||||
|
||||
// We use SDL's game controller API since it's the sanest API that supports as many controllers as possible
|
||||
|
|
265
src/panda_qt/cpu_debugger.cpp
Normal file
265
src/panda_qt/cpu_debugger.cpp
Normal file
|
@ -0,0 +1,265 @@
|
|||
#include "panda_qt/cpu_debugger.hpp"
|
||||
|
||||
#include <fmt/format.h>
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QGridLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QListWidget>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QPushButton>
|
||||
#include <limits>
|
||||
#include <span>
|
||||
#include <utility>
|
||||
|
||||
// TODO: Make this actually thread-safe by having it only work when paused
|
||||
static int getLinesInViewport(QListWidget* listWidget) {
|
||||
auto viewportHeight = listWidget->viewport()->height();
|
||||
QFontMetrics fm = QFontMetrics(listWidget->font());
|
||||
auto lineHeight = fm.height();
|
||||
|
||||
return int(viewportHeight / lineHeight);
|
||||
}
|
||||
|
||||
static std::pair<int, int> getVisibleLineRange(QListWidget* listWidget, QScrollBar* scrollBar) {
|
||||
int firstLine = scrollBar->value();
|
||||
int lineCount = getLinesInViewport(listWidget);
|
||||
|
||||
return {firstLine, lineCount};
|
||||
}
|
||||
|
||||
CPUDebugger::CPUDebugger(Emulator* emulator, QWidget* parent) : emu(emulator), disassembler(CS_ARCH_ARM, CS_MODE_ARM), QWidget(parent, Qt::Window) {
|
||||
setWindowTitle(tr("CPU debugger"));
|
||||
resize(1000, 600);
|
||||
|
||||
QGridLayout* gridLayout = new QGridLayout(this);
|
||||
QHBoxLayout* horizontalLayout = new QHBoxLayout();
|
||||
|
||||
// Set up the top line widgets
|
||||
QPushButton* goToAddressButton = new QPushButton(tr("Go to address"), this);
|
||||
QPushButton* goToPCButton = new QPushButton(tr("Go to PC"), this);
|
||||
QCheckBox* followPCCheckBox = new QCheckBox(tr("Follow PC"), this);
|
||||
addressInput = new QLineEdit(this);
|
||||
|
||||
horizontalLayout->addWidget(goToAddressButton);
|
||||
horizontalLayout->addWidget(goToPCButton);
|
||||
horizontalLayout->addWidget(followPCCheckBox);
|
||||
horizontalLayout->addWidget(addressInput);
|
||||
|
||||
followPCCheckBox->setChecked(followPC);
|
||||
connect(followPCCheckBox, &QCheckBox::toggled, this, [&](bool checked) { followPC = checked; });
|
||||
|
||||
addressInput->setPlaceholderText(tr("Address to jump to"));
|
||||
addressInput->setMaximumWidth(150);
|
||||
|
||||
gridLayout->addLayout(horizontalLayout, 0, 0);
|
||||
|
||||
// Disassembly list on the left, scrollbar in the middle, register view on the right
|
||||
disasmListWidget = new QListWidget(this);
|
||||
gridLayout->addWidget(disasmListWidget, 1, 0);
|
||||
|
||||
verticalScrollBar = new QScrollBar(Qt::Vertical, this);
|
||||
gridLayout->addWidget(verticalScrollBar, 1, 1);
|
||||
|
||||
registerTextEdit = new QPlainTextEdit(this);
|
||||
registerTextEdit->setEnabled(true);
|
||||
registerTextEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
registerTextEdit->setMaximumWidth(800);
|
||||
gridLayout->addWidget(registerTextEdit, 1, 2);
|
||||
|
||||
// Setup overlay for when the widget is disabled
|
||||
disabledOverlay = new DisabledWidgetOverlay(this, tr("Pause the emulator to use the CPU Debugger"));
|
||||
disabledOverlay->resize(size()); // Fill the whole screen
|
||||
disabledOverlay->raise();
|
||||
disabledOverlay->hide();
|
||||
|
||||
// Use a monospace font for the disassembly to align it
|
||||
QFont mono_font = QFont("Courier New");
|
||||
mono_font.setStyleHint(QFont::Monospace);
|
||||
disasmListWidget->setFont(mono_font);
|
||||
registerTextEdit->setFont(mono_font);
|
||||
|
||||
// Forward scrolling from the list widget to our scrollbar
|
||||
disasmListWidget->installEventFilter(this);
|
||||
|
||||
// Annoyingly, due to a Qt limitation we can't set it to U32_MAX
|
||||
verticalScrollBar->setRange(0, std::numeric_limits<s32>::max());
|
||||
verticalScrollBar->setSingleStep(8);
|
||||
verticalScrollBar->setPageStep(getLinesInViewport(disasmListWidget));
|
||||
verticalScrollBar->show();
|
||||
connect(verticalScrollBar, &QScrollBar::valueChanged, this, &CPUDebugger::updateDisasm);
|
||||
registerTextEdit->setReadOnly(true);
|
||||
|
||||
connect(goToPCButton, &QPushButton::clicked, this, [&]() { scrollToPC(); });
|
||||
|
||||
// We have a QTimer that triggers every 500ms to update our widget when it's active
|
||||
updateTimer = new QTimer(this);
|
||||
connect(updateTimer, &QTimer::timeout, this, &CPUDebugger::update);
|
||||
|
||||
// Go to address when the "Go to address" button is pressed, or when we press enter inside the address input box
|
||||
connect(goToAddressButton, &QPushButton::clicked, this, [&]() {
|
||||
QString text = addressInput->text().trimmed();
|
||||
|
||||
bool validAddr = false;
|
||||
u32 addr = text.toUInt(&validAddr, 16); // Parse address as hex
|
||||
if (validAddr) {
|
||||
verticalScrollBar->setValue(addr);
|
||||
} else {
|
||||
addressInput->setText(tr("Invalid hexadecimal address"));
|
||||
}
|
||||
});
|
||||
connect(addressInput, &QLineEdit::returnPressed, goToAddressButton, &QPushButton::click);
|
||||
|
||||
disable();
|
||||
hide();
|
||||
}
|
||||
|
||||
void CPUDebugger::enable() {
|
||||
enabled = true;
|
||||
|
||||
disabledOverlay->hide();
|
||||
scrollToPC();
|
||||
|
||||
// Update the widget every 500ms
|
||||
updateTimer->start(500);
|
||||
update();
|
||||
}
|
||||
|
||||
void CPUDebugger::disable() {
|
||||
enabled = false;
|
||||
|
||||
updateTimer->stop();
|
||||
disabledOverlay->show();
|
||||
}
|
||||
|
||||
void CPUDebugger::update() {
|
||||
if (enabled) {
|
||||
if (followPC) {
|
||||
scrollToPC();
|
||||
}
|
||||
|
||||
updateDisasm();
|
||||
updateRegisters();
|
||||
}
|
||||
}
|
||||
|
||||
void CPUDebugger::updateDisasm() {
|
||||
int currentRow = disasmListWidget->currentRow();
|
||||
disasmListWidget->clear();
|
||||
|
||||
auto [firstLine, lineCount] = getVisibleLineRange(disasmListWidget, verticalScrollBar);
|
||||
const u32 startPC = (firstLine + 3) & ~3; // Align PC to 4 bytes
|
||||
const u32 endPC = startPC + lineCount * sizeof(u32);
|
||||
|
||||
auto& cpu = emu->getCPU();
|
||||
auto& mem = emu->getMemory();
|
||||
u32 pc = cpu.getReg(15);
|
||||
|
||||
std::string disassembly;
|
||||
|
||||
for (u32 addr = startPC; addr < endPC; addr += sizeof(u32)) {
|
||||
if (auto pointer = (u32*)mem.getReadPointer(addr)) {
|
||||
const u32 instruction = *pointer;
|
||||
|
||||
// Convert instruction to byte array to pass to Capstone
|
||||
const std::array<u8, 4> bytes = {
|
||||
u8(instruction & 0xff),
|
||||
u8((instruction >> 8) & 0xff),
|
||||
u8((instruction >> 16) & 0xff),
|
||||
u8((instruction >> 24) & 0xff),
|
||||
};
|
||||
|
||||
disassembler.disassemble(disassembly, pc, std::span(bytes));
|
||||
disassembly = fmt::format("{:08X} | {}", addr, disassembly);
|
||||
|
||||
QListWidgetItem* item = new QListWidgetItem(QString::fromStdString(disassembly));
|
||||
if (addr == pc) {
|
||||
item->setBackground(Qt::darkGreen);
|
||||
}
|
||||
disasmListWidget->addItem(item);
|
||||
} else
|
||||
disasmListWidget->addItem(QString::fromStdString(fmt::format("{:08X} | ???", addr)));
|
||||
}
|
||||
|
||||
disasmListWidget->setCurrentRow(currentRow);
|
||||
}
|
||||
|
||||
void CPUDebugger::scrollToPC() {
|
||||
u32 pc = emu->getCPU().getReg(15);
|
||||
verticalScrollBar->setValue(pc);
|
||||
}
|
||||
|
||||
void CPUDebugger::updateRegisters() {
|
||||
auto& cpu = emu->getCPU();
|
||||
const std::span<u32, 16> gprs = cpu.regs();
|
||||
const std::span<u32, 32> fprs = cpu.fprs();
|
||||
const u32 pc = gprs[15];
|
||||
const u32 cpsr = cpu.getCPSR();
|
||||
const u32 fpscr = cpu.getFPSCR();
|
||||
|
||||
std::string text = "";
|
||||
text.reserve(2048);
|
||||
|
||||
text += fmt::format("PC: {:08X}\nCPSR: {:08X}\nFPSCR: {:08X}\n", pc, cpsr, fpscr);
|
||||
|
||||
text += "\nGeneral Purpose Registers\n";
|
||||
for (int i = 0; i < 10; i++) {
|
||||
text += fmt::format("r{:01d}: 0x{:08X}\n", i, gprs[i]);
|
||||
}
|
||||
for (int i = 10; i < 16; i++) {
|
||||
text += fmt::format("r{:02d}: 0x{:08X}\n", i, gprs[i]);
|
||||
}
|
||||
|
||||
text += "\nFloating Point Registers\n";
|
||||
for (int i = 0; i < 10; i++) {
|
||||
text += fmt::format("f{:01d}: {:f}\n", i, Helpers::bit_cast<float, u32>(fprs[i]));
|
||||
}
|
||||
for (int i = 10; i < 32; i++) {
|
||||
text += fmt::format("f{:01d}: {:f}\n", i, Helpers::bit_cast<float, u32>(fprs[i]));
|
||||
}
|
||||
|
||||
registerTextEdit->setPlainText(QString::fromStdString(text));
|
||||
}
|
||||
|
||||
bool CPUDebugger::eventFilter(QObject* obj, QEvent* event) {
|
||||
// Forward scroll events from the list widget to the scrollbar
|
||||
if (obj == disasmListWidget && event->type() == QEvent::Wheel) {
|
||||
QWheelEvent* wheelEvent = (QWheelEvent*)event;
|
||||
|
||||
int wheelSteps = wheelEvent->angleDelta().y() / 60;
|
||||
int newScrollValue = verticalScrollBar->value() - wheelSteps;
|
||||
newScrollValue = qBound(verticalScrollBar->minimum(), newScrollValue, verticalScrollBar->maximum());
|
||||
verticalScrollBar->setValue(newScrollValue);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return QWidget::eventFilter(obj, event);
|
||||
}
|
||||
|
||||
void CPUDebugger::showEvent(QShowEvent* event) {
|
||||
QWidget::showEvent(event);
|
||||
|
||||
enable();
|
||||
}
|
||||
|
||||
// Scroll 1 instruction up or down when the arrow keys are pressed and we're at the edge of the disassembly list
|
||||
void CPUDebugger::keyPressEvent(QKeyEvent* event) {
|
||||
constexpr usize instructionSize = sizeof(u32);
|
||||
|
||||
if (event->key() == Qt::Key_Up) {
|
||||
verticalScrollBar->setValue(verticalScrollBar->value() - instructionSize);
|
||||
} else if (event->key() == Qt::Key_Down) {
|
||||
verticalScrollBar->setValue(verticalScrollBar->value() + instructionSize);
|
||||
} else {
|
||||
QWidget::keyPressEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
void CPUDebugger::resizeEvent(QResizeEvent* event) {
|
||||
QWidget::resizeEvent(event);
|
||||
disabledOverlay->resize(event->size());
|
||||
verticalScrollBar->setPageStep(getLinesInViewport(disasmListWidget));
|
||||
|
||||
update();
|
||||
}
|
|
@ -66,6 +66,7 @@ MainWindow::MainWindow(QApplication* app, QWidget* parent) : QMainWindow(parent)
|
|||
auto cheatsEditorAction = toolsMenu->addAction(tr("Open Cheats Editor"));
|
||||
auto patchWindowAction = toolsMenu->addAction(tr("Open Patch Window"));
|
||||
auto shaderEditorAction = toolsMenu->addAction(tr("Open Shader Editor"));
|
||||
auto cpuDebuggerAction = toolsMenu->addAction(tr("Open CPU Debugger"));
|
||||
auto threadDebuggerAction = toolsMenu->addAction(tr("Open Thread Debugger"));
|
||||
auto dumpDspFirmware = toolsMenu->addAction(tr("Dump loaded DSP firmware"));
|
||||
|
||||
|
@ -74,6 +75,7 @@ MainWindow::MainWindow(QApplication* app, QWidget* parent) : QMainWindow(parent)
|
|||
connect(shaderEditorAction, &QAction::triggered, this, [this]() { shaderEditor->show(); });
|
||||
connect(cheatsEditorAction, &QAction::triggered, this, [this]() { cheatsEditor->show(); });
|
||||
connect(patchWindowAction, &QAction::triggered, this, [this]() { patchWindow->show(); });
|
||||
connect(cpuDebuggerAction, &QAction::triggered, this, [this]() { cpuDebugger->show(); });
|
||||
connect(threadDebuggerAction, &QAction::triggered, this, [this]() { threadDebugger->show(); });
|
||||
connect(dumpDspFirmware, &QAction::triggered, this, &MainWindow::dumpDspFirmware);
|
||||
|
||||
|
@ -94,6 +96,7 @@ MainWindow::MainWindow(QApplication* app, QWidget* parent) : QMainWindow(parent)
|
|||
luaEditor = new TextEditorWindow(this, "script.lua", "");
|
||||
shaderEditor = new ShaderEditorWindow(this, "shader.glsl", "");
|
||||
threadDebugger = new ThreadDebugger(emu, this);
|
||||
cpuDebugger = new CPUDebugger(emu, this);
|
||||
|
||||
shaderEditor->setEnable(emu->getRenderer()->supportsShaderReload());
|
||||
if (shaderEditor->supported) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue