// Copyright 2018 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include "DolphinQt/FIFO/FIFOAnalyzer.h" #include #include #include #include #include #include #include #include #include #include #include #include #include "Common/Assert.h" #include "Common/Swap.h" #include "Core/FifoPlayer/FifoPlayer.h" #include "DolphinQt/QtUtils/NonDefaultQPushButton.h" #include "DolphinQt/Settings.h" #include "VideoCommon/BPMemory.h" #include "VideoCommon/CPMemory.h" #include "VideoCommon/OpcodeDecoding.h" #include "VideoCommon/VertexLoaderBase.h" #include "VideoCommon/XFStructs.h" // Values range from 0 to number of frames - 1 constexpr int FRAME_ROLE = Qt::UserRole; // Values range from 0 to number of parts - 1 constexpr int PART_START_ROLE = Qt::UserRole + 1; // Values range from 1 to number of parts constexpr int PART_END_ROLE = Qt::UserRole + 2; FIFOAnalyzer::FIFOAnalyzer() { CreateWidgets(); ConnectWidgets(); UpdateTree(); auto& settings = Settings::GetQSettings(); m_object_splitter->restoreState( settings.value(QStringLiteral("fifoanalyzer/objectsplitter")).toByteArray()); m_search_splitter->restoreState( settings.value(QStringLiteral("fifoanalyzer/searchsplitter")).toByteArray()); m_detail_list->setFont(Settings::Instance().GetDebugFont()); m_entry_detail_browser->setFont(Settings::Instance().GetDebugFont()); connect(&Settings::Instance(), &Settings::DebugFontChanged, this, [this] { m_detail_list->setFont(Settings::Instance().GetDebugFont()); m_entry_detail_browser->setFont(Settings::Instance().GetDebugFont()); }); } FIFOAnalyzer::~FIFOAnalyzer() { auto& settings = Settings::GetQSettings(); settings.setValue(QStringLiteral("fifoanalyzer/objectsplitter"), m_object_splitter->saveState()); settings.setValue(QStringLiteral("fifoanalyzer/searchsplitter"), m_search_splitter->saveState()); } void FIFOAnalyzer::CreateWidgets() { m_tree_widget = new QTreeWidget; m_detail_list = new QListWidget; m_entry_detail_browser = new QTextBrowser; m_object_splitter = new QSplitter(Qt::Horizontal); m_object_splitter->addWidget(m_tree_widget); m_object_splitter->addWidget(m_detail_list); m_tree_widget->header()->hide(); m_search_box = new QGroupBox(tr("Search Current Object")); m_search_edit = new QLineEdit; m_search_new = new NonDefaultQPushButton(tr("Search")); m_search_next = new NonDefaultQPushButton(tr("Next Match")); m_search_previous = new NonDefaultQPushButton(tr("Previous Match")); m_search_label = new QLabel; m_search_next->setEnabled(false); m_search_previous->setEnabled(false); auto* box_layout = new QHBoxLayout; box_layout->addWidget(m_search_edit); box_layout->addWidget(m_search_new); box_layout->addWidget(m_search_next); box_layout->addWidget(m_search_previous); box_layout->addWidget(m_search_label); m_search_box->setLayout(box_layout); m_search_box->setMaximumHeight(m_search_box->minimumSizeHint().height()); m_search_splitter = new QSplitter(Qt::Vertical); m_search_splitter->addWidget(m_object_splitter); m_search_splitter->addWidget(m_entry_detail_browser); m_search_splitter->addWidget(m_search_box); auto* layout = new QHBoxLayout; layout->addWidget(m_search_splitter); setLayout(layout); } void FIFOAnalyzer::ConnectWidgets() { connect(m_tree_widget, &QTreeWidget::itemSelectionChanged, this, &FIFOAnalyzer::UpdateDetails); connect(m_detail_list, &QListWidget::itemSelectionChanged, this, &FIFOAnalyzer::UpdateDescription); connect(m_search_edit, &QLineEdit::returnPressed, this, &FIFOAnalyzer::BeginSearch); connect(m_search_new, &QPushButton::clicked, this, &FIFOAnalyzer::BeginSearch); connect(m_search_next, &QPushButton::clicked, this, &FIFOAnalyzer::FindNext); connect(m_search_previous, &QPushButton::clicked, this, &FIFOAnalyzer::FindPrevious); } void FIFOAnalyzer::Update() { UpdateTree(); UpdateDetails(); UpdateDescription(); } void FIFOAnalyzer::UpdateTree() { m_tree_widget->clear(); if (!FifoPlayer::GetInstance().IsPlaying()) { m_tree_widget->addTopLevelItem(new QTreeWidgetItem({tr("No recording loaded.")})); return; } auto* recording_item = new QTreeWidgetItem({tr("Recording")}); m_tree_widget->addTopLevelItem(recording_item); auto* file = FifoPlayer::GetInstance().GetFile(); const u32 frame_count = file->GetFrameCount(); for (u32 frame = 0; frame < frame_count; frame++) { auto* frame_item = new QTreeWidgetItem({tr("Frame %1").arg(frame)}); recording_item->addChild(frame_item); const AnalyzedFrameInfo& frame_info = FifoPlayer::GetInstance().GetAnalyzedFrameInfo(frame); ASSERT(frame_info.parts.size() != 0); Common::EnumMap part_counts; u32 part_start = 0; for (u32 part_nr = 0; part_nr < frame_info.parts.size(); part_nr++) { const auto& part = frame_info.parts[part_nr]; const u32 part_type_nr = part_counts[part.m_type]; part_counts[part.m_type]++; QTreeWidgetItem* object_item = nullptr; if (part.m_type == FramePartType::PrimitiveData) object_item = new QTreeWidgetItem({tr("Object %1").arg(part_type_nr)}); else if (part.m_type == FramePartType::EFBCopy) object_item = new QTreeWidgetItem({tr("EFB copy %1").arg(part_type_nr)}); // We don't create dedicated labels for FramePartType::Command; // those are grouped with the primitive if (object_item != nullptr) { frame_item->addChild(object_item); object_item->setData(0, FRAME_ROLE, frame); object_item->setData(0, PART_START_ROLE, part_start); object_item->setData(0, PART_END_ROLE, part_nr); part_start = part_nr + 1; } } // We shouldn't end on a Command (it should end with an EFB copy) ASSERT(part_start == frame_info.parts.size()); // The counts we computed should match the frame's counts ASSERT(std::equal(frame_info.part_type_counts.begin(), frame_info.part_type_counts.end(), part_counts.begin())); } } namespace { class DetailCallback : public OpcodeDecoder::Callback { public: explicit DetailCallback(CPState cpmem) : m_cpmem(cpmem) {} OPCODE_CALLBACK(void OnCP(u8 command, u32 value)) { // Note: No need to update m_cpmem as it already has the final value for this object const auto [name, desc] = GetCPRegInfo(command, value); ASSERT(!name.empty()); text = QStringLiteral("CP %1 %2 %3") .arg(command, 2, 16, QLatin1Char('0')) .arg(value, 8, 16, QLatin1Char('0')) .arg(QString::fromStdString(name)); } OPCODE_CALLBACK(void OnXF(u16 address, u8 count, const u8* data)) { const auto [name, desc] = GetXFTransferInfo(address, count, data); ASSERT(!name.empty()); const u32 command = address | ((count - 1) << 16); text = QStringLiteral("XF %1 ").arg(command, 8, 16, QLatin1Char('0')); for (u8 i = 0; i < count; i++) { const u32 value = Common::swap32(&data[i * 4]); text += QStringLiteral("%1 ").arg(value, 8, 16, QLatin1Char('0')); } text += QStringLiteral(" ") + QString::fromStdString(name); } OPCODE_CALLBACK(void OnBP(u8 command, u32 value)) { const auto [name, desc] = GetBPRegInfo(command, value); ASSERT(!name.empty()); text = QStringLiteral("BP %1 %2 %3") .arg(command, 2, 16, QLatin1Char('0')) .arg(value, 6, 16, QLatin1Char('0')) .arg(QString::fromStdString(name)); } OPCODE_CALLBACK(void OnIndexedLoad(CPArray array, u32 index, u16 address, u8 size)) { const auto [desc, written] = GetXFIndexedLoadInfo(array, index, address, size); text = QStringLiteral("LOAD INDX %1 %2") .arg(QString::fromStdString(fmt::to_string(array))) .arg(QString::fromStdString(desc)); } OPCODE_CALLBACK(void OnPrimitiveCommand(OpcodeDecoder::Primitive primitive, u8 vat, u32 vertex_size, u16 num_vertices, const u8* vertex_data)) { const auto name = fmt::to_string(primitive); // Note that vertex_count is allowed to be 0, with no special treatment // (another command just comes right after the current command, with no vertices in between) const u32 object_prim_size = num_vertices * vertex_size; const u8 opcode = 0x80 | (static_cast(primitive) << OpcodeDecoder::GX_PRIMITIVE_SHIFT) | vat; text = QStringLiteral("PRIMITIVE %1 (%2) %3 vertices %4 bytes/vertex %5 total bytes") .arg(QString::fromStdString(name)) .arg(opcode, 2, 16, QLatin1Char('0')) .arg(num_vertices) .arg(vertex_size) .arg(object_prim_size); // It's not really useful to have a massive unreadable hex string for the object primitives. // Put it in the description instead. // #define INCLUDE_HEX_IN_PRIMITIVES #ifdef INCLUDE_HEX_IN_PRIMITIVES text += QStringLiteral(" "); for (u32 i = 0; i < object_prim_size; i++) { text += QStringLiteral("%1").arg(vertex_data[i], 2, 16, QLatin1Char('0')); } #endif } OPCODE_CALLBACK(void OnDisplayList(u32 address, u32 size)) { text = QObject::tr("Call display list at %1 with size %2") .arg(address, 8, 16, QLatin1Char('0')) .arg(size, 8, 16, QLatin1Char('0')); } OPCODE_CALLBACK(void OnNop(u32 count)) { if (count > 1) text = QStringLiteral("NOP (%1x)").arg(count); else text = QStringLiteral("NOP"); } OPCODE_CALLBACK(void OnUnknown(u8 opcode, const u8* data)) { using OpcodeDecoder::Opcode; if (static_cast(opcode) == Opcode::GX_CMD_UNKNOWN_METRICS) text = QStringLiteral("GX_CMD_UNKNOWN_METRICS"); else if (static_cast(opcode) == Opcode::GX_CMD_INVL_VC) text = QStringLiteral("GX_CMD_INVL_VC"); else text = QStringLiteral("Unknown opcode %1").arg(opcode, 2, 16); } OPCODE_CALLBACK(void OnCommand(const u8* data, u32 size)) {} OPCODE_CALLBACK(CPState& GetCPState()) { return m_cpmem; } OPCODE_CALLBACK(u32 GetVertexSize(u8 vat)) { return VertexLoaderBase::GetVertexSize(GetCPState().vtx_desc, GetCPState().vtx_attr[vat]); } QString text; CPState m_cpmem; }; } // namespace void FIFOAnalyzer::UpdateDetails() { // Clearing the detail list can update the selection, which causes UpdateDescription to be called // immediately. However, the object data offsets have not been recalculated yet, which can cause // the wrong data to be used, potentially leading to out of bounds data or other bad things. // Clear m_object_data_offsets first, so that UpdateDescription exits immediately. m_object_data_offsets.clear(); m_detail_list->clear(); m_search_results.clear(); m_search_next->setEnabled(false); m_search_previous->setEnabled(false); m_search_label->clear(); if (!FifoPlayer::GetInstance().IsPlaying()) return; const auto items = m_tree_widget->selectedItems(); if (items.isEmpty() || items[0]->data(0, PART_START_ROLE).isNull()) return; const u32 frame_nr = items[0]->data(0, FRAME_ROLE).toUInt(); const u32 start_part_nr = items[0]->data(0, PART_START_ROLE).toUInt(); const u32 end_part_nr = items[0]->data(0, PART_END_ROLE).toUInt(); const AnalyzedFrameInfo& frame_info = FifoPlayer::GetInstance().GetAnalyzedFrameInfo(frame_nr); const auto& fifo_frame = FifoPlayer::GetInstance().GetFile()->GetFrame(frame_nr); const u32 object_start = frame_info.parts[start_part_nr].m_start; const u32 object_end = frame_info.parts[end_part_nr].m_end; const u32 object_size = object_end - object_start; u32 object_offset = 0; // NOTE: object_info.m_cpmem is the state of cpmem _after_ all of the commands in this object. // However, it doesn't matter that it doesn't match the start, since it will match by the time // primitives are reached. auto callback = DetailCallback(frame_info.parts[end_part_nr].m_cpmem); while (object_offset < object_size) { const u32 start_offset = object_offset; m_object_data_offsets.push_back(start_offset); object_offset += OpcodeDecoder::RunCommand(&fifo_frame.fifoData[object_start + start_offset], object_size - start_offset, callback); QString new_label = QStringLiteral("%1: ").arg(object_start + start_offset, 8, 16, QLatin1Char('0')) + callback.text; m_detail_list->addItem(new_label); } // Needed to ensure the description updates when changing objects m_detail_list->setCurrentRow(0); } void FIFOAnalyzer::BeginSearch() { const QString search_str = m_search_edit->text(); if (!FifoPlayer::GetInstance().IsPlaying()) return; const auto items = m_tree_widget->selectedItems(); if (items.isEmpty() || items[0]->data(0, FRAME_ROLE).isNull() || items[0]->data(0, PART_START_ROLE).isNull()) { m_search_label->setText(tr("Invalid search parameters (no object selected)")); return; } // Having PART_START_ROLE indicates that this is valid const int object_idx = items[0]->parent()->indexOfChild(items[0]); // TODO: Remove even string length limit if (search_str.length() % 2) { m_search_label->setText(tr("Invalid search string (only even string lengths supported)")); return; } const size_t length = search_str.length() / 2; std::vector search_val; for (size_t i = 0; i < length; i++) { const QString byte_str = search_str.mid(static_cast(i * 2), 2); bool good; u8 value = byte_str.toUInt(&good, 16); if (!good) { m_search_label->setText(tr("Invalid search string (couldn't convert to number)")); return; } search_val.push_back(value); } m_search_results.clear(); const u32 frame_nr = items[0]->data(0, FRAME_ROLE).toUInt(); const u32 start_part_nr = items[0]->data(0, PART_START_ROLE).toUInt(); const u32 end_part_nr = items[0]->data(0, PART_END_ROLE).toUInt(); const AnalyzedFrameInfo& frame_info = FifoPlayer::GetInstance().GetAnalyzedFrameInfo(frame_nr); const FifoFrameInfo& fifo_frame = FifoPlayer::GetInstance().GetFile()->GetFrame(frame_nr); const u32 object_start = frame_info.parts[start_part_nr].m_start; const u32 object_end = frame_info.parts[end_part_nr].m_end; const u32 object_size = object_end - object_start; const u8* const object = &fifo_frame.fifoData[object_start]; // TODO: Support searching for bit patterns for (u32 cmd_nr = 0; cmd_nr < m_object_data_offsets.size(); cmd_nr++) { const u32 cmd_start = m_object_data_offsets[cmd_nr]; const u32 cmd_end = (cmd_nr + 1 == m_object_data_offsets.size()) ? object_size : m_object_data_offsets[cmd_nr + 1]; const u8* const cmd_start_ptr = &object[cmd_start]; const u8* const cmd_end_ptr = &object[cmd_end]; for (const u8* ptr = cmd_start_ptr; ptr < cmd_end_ptr - length + 1; ptr++) { if (std::equal(search_val.begin(), search_val.end(), ptr)) { m_search_results.emplace_back(frame_nr, object_idx, cmd_nr); break; } } } ShowSearchResult(0); m_search_label->setText( tr("Found %1 results for \"%2\"").arg(m_search_results.size()).arg(search_str)); } void FIFOAnalyzer::FindNext() { const int index = m_detail_list->currentRow(); ASSERT(index >= 0); auto next_result = std::find_if(m_search_results.begin(), m_search_results.end(), [index](auto& result) { return result.m_cmd > static_cast(index); }); if (next_result != m_search_results.end()) { ShowSearchResult(next_result - m_search_results.begin()); } } void FIFOAnalyzer::FindPrevious() { const int index = m_detail_list->currentRow(); ASSERT(index >= 0); auto prev_result = std::find_if(m_search_results.rbegin(), m_search_results.rend(), [index](auto& result) { return result.m_cmd < static_cast(index); }); if (prev_result != m_search_results.rend()) { ShowSearchResult((m_search_results.rend() - prev_result) - 1); } } void FIFOAnalyzer::ShowSearchResult(size_t index) { if (m_search_results.empty()) return; if (index >= m_search_results.size()) { ShowSearchResult(m_search_results.size() - 1); return; } const auto& result = m_search_results[index]; QTreeWidgetItem* object_item = m_tree_widget->topLevelItem(0)->child(result.m_frame)->child(result.m_object_idx); m_tree_widget->setCurrentItem(object_item); m_detail_list->setCurrentRow(result.m_cmd); m_search_next->setEnabled(index + 1 < m_search_results.size()); m_search_previous->setEnabled(index > 0); } namespace { // TODO: Not sure whether we should bother translating the descriptions class DescriptionCallback : public OpcodeDecoder::Callback { public: explicit DescriptionCallback(const CPState& cpmem) : m_cpmem(cpmem) {} OPCODE_CALLBACK(void OnBP(u8 command, u32 value)) { const auto [name, desc] = GetBPRegInfo(command, value); ASSERT(!name.empty()); text = QObject::tr("BP register "); text += QString::fromStdString(name); text += QLatin1Char{'\n'}; if (desc.empty()) text += QObject::tr("No description available"); else text += QString::fromStdString(desc); } OPCODE_CALLBACK(void OnCP(u8 command, u32 value)) { // Note: No need to update m_cpmem as it already has the final value for this object const auto [name, desc] = GetCPRegInfo(command, value); ASSERT(!name.empty()); text = QObject::tr("CP register "); text += QString::fromStdString(name); text += QLatin1Char{'\n'}; if (desc.empty()) text += QObject::tr("No description available"); else text += QString::fromStdString(desc); } OPCODE_CALLBACK(void OnXF(u16 address, u8 count, const u8* data)) { const auto [name, desc] = GetXFTransferInfo(address, count, data); ASSERT(!name.empty()); text = QObject::tr("XF register "); text += QString::fromStdString(name); text += QLatin1Char{'\n'}; if (desc.empty()) text += QObject::tr("No description available"); else text += QString::fromStdString(desc); } OPCODE_CALLBACK(void OnIndexedLoad(CPArray array, u32 index, u16 address, u8 size)) { const auto [desc, written] = GetXFIndexedLoadInfo(array, index, address, size); text = QString::fromStdString(desc); text += QLatin1Char{'\n'}; switch (array) { case CPArray::XF_A: text += QObject::tr("Usually used for position matrices"); break; case CPArray::XF_B: // i18n: A normal matrix is a matrix used for transforming normal vectors. The word "normal" // does not have its usual meaning here, but rather the meaning of "perpendicular to a // surface". text += QObject::tr("Usually used for normal matrices"); break; case CPArray::XF_C: // i18n: Tex coord is short for texture coordinate text += QObject::tr("Usually used for tex coord matrices"); break; case CPArray::XF_D: text += QObject::tr("Usually used for light objects"); break; default: break; } text += QLatin1Char{'\n'}; text += QString::fromStdString(written); } OPCODE_CALLBACK(void OnPrimitiveCommand(OpcodeDecoder::Primitive primitive, u8 vat, u32 vertex_size, u16 num_vertices, const u8* vertex_data)) { const auto name = fmt::format("{} VAT {}", primitive, vat); // i18n: In this context, a primitive means a point, line, triangle or rectangle. // Do not translate the word primitive as if it was an adjective. text = QObject::tr("Primitive %1").arg(QString::fromStdString(name)); text += QLatin1Char{'\n'}; const auto& vtx_desc = m_cpmem.vtx_desc; const auto& vtx_attr = m_cpmem.vtx_attr[vat]; u32 i = 0; const auto process_component = [&](VertexComponentFormat cformat, ComponentFormat format, u32 non_indexed_count, u32 indexed_count = 1) { u32 count; if (cformat == VertexComponentFormat::NotPresent) return; else if (cformat == VertexComponentFormat::Index8) { format = ComponentFormat::UByte; count = indexed_count; } else if (cformat == VertexComponentFormat::Index16) { format = ComponentFormat::UShort; count = indexed_count; } else { count = non_indexed_count; } const u32 component_size = GetElementSize(format); for (u32 j = 0; j < count; j++) { for (u32 component_off = 0; component_off < component_size; component_off++) { text += QStringLiteral("%1").arg(vertex_data[i + component_off], 2, 16, QLatin1Char('0')); } if (format == ComponentFormat::Float) { const float value = Common::BitCast(Common::swap32(&vertex_data[i])); text += QStringLiteral(" (%1)").arg(value); } i += component_size; text += QLatin1Char{' '}; } text += QLatin1Char{' '}; }; const auto process_simple_component = [&](u32 size) { for (u32 component_off = 0; component_off < size; component_off++) { text += QStringLiteral("%1").arg(vertex_data[i + component_off], 2, 16, QLatin1Char('0')); } i += size; text += QLatin1Char{' '}; text += QLatin1Char{' '}; }; for (u32 vertex_num = 0; vertex_num < num_vertices; vertex_num++) { ASSERT(i == vertex_num * vertex_size); text += QLatin1Char{'\n'}; if (vtx_desc.low.PosMatIdx) process_simple_component(1); for (auto texmtxidx : vtx_desc.low.TexMatIdx) { if (texmtxidx) process_simple_component(1); } process_component(vtx_desc.low.Position, vtx_attr.g0.PosFormat, vtx_attr.g0.PosElements == CoordComponentCount::XY ? 2 : 3); const u32 normal_component_count = vtx_desc.low.Normal == VertexComponentFormat::Direct ? 3 : 1; const u32 normal_elements = vtx_attr.g0.NormalElements == NormalComponentCount::NTB ? 3 : 1; process_component(vtx_desc.low.Normal, vtx_attr.g0.NormalFormat, normal_component_count * normal_elements, vtx_attr.g0.NormalIndex3 ? normal_elements : 1); for (u32 c = 0; c < vtx_desc.low.Color.Size(); c++) { static constexpr Common::EnumMap component_sizes = { 2, // RGB565 3, // RGB888 4, // RGB888x 2, // RGBA4444 3, // RGBA6666 4, // RGBA8888 }; switch (vtx_desc.low.Color[c]) { case VertexComponentFormat::Index8: process_simple_component(1); break; case VertexComponentFormat::Index16: process_simple_component(2); break; case VertexComponentFormat::Direct: process_simple_component(component_sizes[vtx_attr.GetColorFormat(c)]); break; case VertexComponentFormat::NotPresent: break; } } for (u32 t = 0; t < vtx_desc.high.TexCoord.Size(); t++) { process_component(vtx_desc.high.TexCoord[t], vtx_attr.GetTexFormat(t), vtx_attr.GetTexElements(t) == TexComponentCount::ST ? 2 : 1); } } } OPCODE_CALLBACK(void OnDisplayList(u32 address, u32 size)) { text = QObject::tr("No description available"); } OPCODE_CALLBACK(void OnNop(u32 count)) { text = QObject::tr("No description available"); } OPCODE_CALLBACK(void OnUnknown(u8 opcode, const u8* data)) { text = QObject::tr("No description available"); } OPCODE_CALLBACK(void OnCommand(const u8* data, u32 size)) {} OPCODE_CALLBACK(CPState& GetCPState()) { return m_cpmem; } OPCODE_CALLBACK(u32 GetVertexSize(u8 vat)) { return VertexLoaderBase::GetVertexSize(GetCPState().vtx_desc, GetCPState().vtx_attr[vat]); } QString text; CPState m_cpmem; }; } // namespace void FIFOAnalyzer::UpdateDescription() { m_entry_detail_browser->clear(); if (!FifoPlayer::GetInstance().IsPlaying()) return; const auto items = m_tree_widget->selectedItems(); if (items.isEmpty() || m_object_data_offsets.empty()) return; if (items[0]->data(0, FRAME_ROLE).isNull() || items[0]->data(0, PART_START_ROLE).isNull()) return; const u32 frame_nr = items[0]->data(0, FRAME_ROLE).toUInt(); const u32 start_part_nr = items[0]->data(0, PART_START_ROLE).toUInt(); const u32 end_part_nr = items[0]->data(0, PART_END_ROLE).toUInt(); const u32 entry_nr = m_detail_list->currentRow(); const AnalyzedFrameInfo& frame_info = FifoPlayer::GetInstance().GetAnalyzedFrameInfo(frame_nr); const FifoFrameInfo& fifo_frame = FifoPlayer::GetInstance().GetFile()->GetFrame(frame_nr); const u32 object_start = frame_info.parts[start_part_nr].m_start; const u32 object_end = frame_info.parts[end_part_nr].m_end; const u32 object_size = object_end - object_start; const u32 entry_start = m_object_data_offsets[entry_nr]; auto callback = DescriptionCallback(frame_info.parts[end_part_nr].m_cpmem); OpcodeDecoder::RunCommand(&fifo_frame.fifoData[object_start + entry_start], object_size - entry_start, callback); m_entry_detail_browser->setText(callback.text); }