From 6bde05adf98c535ef6510135c0be9022bbb6b4f3 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Tue, 7 Apr 2026 08:54:53 -0700 Subject: [PATCH 01/16] Add filesystem browsing, extraction, and patching to ISO browser The ISO browser widget previously only showed disc-level metadata (tracks, CRCs, game ID). This adds actual filesystem browsing by connecting the existing ISO9660Reader to the widget's UI: - Directory tree with Name/LBA/Size columns - File extraction to host filesystem via save dialog - File replacement through the PPF patching pipeline - PPF management controls (clear/save) The ISO9660Reader API is expanded to expose directory enumeration and root directory access, which were previously private. Signed-off-by: Nicolas 'Pixel' Noble --- src/cdrom/iso9660-reader.h | 6 +- src/gui/widgets/isobrowser.cc | 171 ++++++++++++++++++++++++++++++++++ src/gui/widgets/isobrowser.h | 24 ++++- 3 files changed, 197 insertions(+), 4 deletions(-) diff --git a/src/cdrom/iso9660-reader.h b/src/cdrom/iso9660-reader.h index 87ca9d787..6b76dcc00 100644 --- a/src/cdrom/iso9660-reader.h +++ b/src/cdrom/iso9660-reader.h @@ -40,13 +40,15 @@ class ISO9660Reader { return std::string_view(m_pvd.get()); } + typedef std::pair FullDirEntry; + std::vector listAllEntriesFrom(const ISO9660LowLevel::DirEntry& entry); + const ISO9660LowLevel::DirEntry& getRootDirEntry() { return m_pvd.get(); } + private: std::shared_ptr m_iso; bool m_failed = false; - typedef std::pair FullDirEntry; std::optional findEntry(const std::string_view& filename); - std::vector listAllEntriesFrom(const ISO9660LowLevel::DirEntry& entry); ISO9660LowLevel::PVD m_pvd; }; diff --git a/src/gui/widgets/isobrowser.cc b/src/gui/widgets/isobrowser.cc index 49d7a9fba..076a97a9f 100644 --- a/src/gui/widgets/isobrowser.cc +++ b/src/gui/widgets/isobrowser.cc @@ -23,6 +23,8 @@ #include +#include "cdrom/cdriso.h" +#include "cdrom/ppf.h" #include "core/cdrom.h" #include "fmt/format.h" #include "imgui/imgui.h" @@ -54,6 +56,51 @@ PCSX::Coroutine<> PCSX::Widgets::IsoBrowser::computeCRC(PCSX::CDRIso* iso) { m_fullCRC = fullCRC; }; +void PCSX::Widgets::IsoBrowser::drawFilesystemTree(const ISO9660LowLevel::DirEntry& entry, const std::string& path) { + auto entries = m_reader->listAllEntriesFrom(entry); + + for (auto& [dirEntry, xa] : entries) { + const auto& filename = dirEntry.get().value; + if (filename.size() == 1 && (filename[0] == '\0' || filename[0] == '\1')) continue; + + bool isDir = (dirEntry.get().value & 2) != 0; + uint32_t lba = dirEntry.get(); + uint32_t size = dirEntry.get(); + auto fullPath = path.empty() ? filename : path + "/" + filename; + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + + if (isDir) { + bool open = ImGui::TreeNodeEx(filename.c_str(), ImGuiTreeNodeFlags_SpanFullWidth); + ImGui::TableSetColumnIndex(1); + ImGui::Text("%u", lba); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(_("")); + if (open) { + drawFilesystemTree(dirEntry, fullPath); + ImGui::TreePop(); + } + } else { + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_Bullet | + ImGuiTreeNodeFlags_NoTreePushOnOpen | ImGuiTreeNodeFlags_SpanFullWidth; + if (m_hasSelection && m_selectedPath == fullPath) flags |= ImGuiTreeNodeFlags_Selected; + ImGui::TreeNodeEx(filename.c_str(), flags); + if (ImGui::IsItemClicked()) { + m_selectedPath = fullPath; + m_selectedEntry = dirEntry; + m_hasSelection = true; + m_selectedIsDir = false; + } + ImGui::TableSetColumnIndex(1); + ImGui::Text("%u", lba); + ImGui::TableSetColumnIndex(2); + auto str = fmt::format("{}", size); + ImGui::TextUnformatted(str.c_str()); + } + } +} + void PCSX::Widgets::IsoBrowser::draw(CDRom* cdrom, const char* title) { if (!ImGui::Begin(title, &m_show, ImGuiWindowFlags_MenuBar)) { ImGui::End(); @@ -171,5 +218,129 @@ significantly by caching the files beforehand.)")); ImGui::EndTable(); } + // Filesystem browser + auto currentIso = cdrom->getIso(); + if (m_cachedIso.lock() != currentIso) { + m_cachedIso = currentIso; + m_reader.reset(); + m_hasSelection = false; + m_selectedPath.clear(); + if (currentIso && !currentIso->failed()) { + m_reader = std::make_unique(currentIso); + if (m_reader->failed()) m_reader.reset(); + } + } + + if (m_reader && ImGui::CollapsingHeader(_("Filesystem"), ImGuiTreeNodeFlags_DefaultOpen)) { + bool extracting = !m_extractionCoroutine.done(); + bool showSaveDialog = false; + bool showReplaceDialog = false; + + if (extracting) { + ImGui::ProgressBar(m_extractionProgress); + m_extractionCoroutine.resume(); + } else { + if (!m_hasSelection || m_selectedIsDir) ImGui::BeginDisabled(); + showSaveDialog = ImGui::Button(_("Extract")); + ImGui::SameLine(); + showReplaceDialog = ImGui::Button(_("Replace")); + if (!m_hasSelection || m_selectedIsDir) ImGui::EndDisabled(); + } + + if (showSaveDialog) { + m_saveFileDialog.openDialog(); + } + if (showReplaceDialog) { + m_openReplaceFileDialog.openDialog(); + } + + // Handle extract dialog result + if (m_saveFileDialog.draw()) { + auto selected = m_saveFileDialog.selected(); + if (!selected.empty() && m_hasSelection) { + auto destPath = reinterpret_cast(selected[0].c_str()); + uint32_t lba = m_selectedEntry.get(); + uint32_t size = m_selectedEntry.get(); + auto isoPtr = m_cachedIso.lock(); + if (isoPtr) { + m_extractionProgress = 0.0f; + m_extractionCoroutine = [](IsoBrowser* self, std::shared_ptr iso, uint32_t lba, + uint32_t size, + std::string dest) -> Coroutine<> { + auto time = std::chrono::steady_clock::now(); + IO src(new CDRIsoFile(iso, lba, size)); + IO out(new UvFile(dest, FileOps::TRUNCATE)); + if (out->failed()) co_return; + uint8_t buffer[2048]; + uint32_t remaining = size; + uint32_t written = 0; + while (remaining > 0) { + uint32_t chunk = std::min(remaining, (uint32_t)sizeof(buffer)); + auto read = src->read(buffer, chunk); + if (read <= 0) break; + out->write(buffer, read); + remaining -= read; + written += read; + if (std::chrono::steady_clock::now() - time > std::chrono::milliseconds(50)) { + self->m_extractionProgress = (float)written / (float)size; + co_yield self->m_extractionCoroutine.awaiter(); + time = std::chrono::steady_clock::now(); + } + } + self->m_extractionProgress = 1.0f; + }(this, isoPtr, lba, size, destPath); + } + } + } + + // Handle replace dialog result + if (m_openReplaceFileDialog.draw()) { + auto selected = m_openReplaceFileDialog.selected(); + if (!selected.empty() && m_hasSelection) { + auto srcPath = reinterpret_cast(selected[0].c_str()); + uint32_t lba = m_selectedEntry.get(); + uint32_t originalSize = m_selectedEntry.get(); + auto isoPtr = m_cachedIso.lock(); + if (isoPtr) { + IO replacement(new UvFile(srcPath)); + if (!replacement->failed()) { + IO isoFile(new CDRIsoFile(isoPtr, lba, originalSize)); + uint32_t replaceSize = std::min((uint32_t)replacement->size(), originalSize); + uint8_t buffer[2048]; + uint32_t remaining = replaceSize; + while (remaining > 0) { + uint32_t chunk = std::min(remaining, (uint32_t)sizeof(buffer)); + auto read = replacement->read(buffer, chunk); + if (read <= 0) break; + isoFile->write(buffer, read); + remaining -= read; + } + } + } + } + } + + if (ImGui::BeginTable("Filesystem", 3, + ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY, + ImVec2(0, 300))) { + ImGui::TableSetupColumn(_("Name"), ImGuiTableColumnFlags_NoHide); + ImGui::TableSetupColumn(_("LBA"), ImGuiTableColumnFlags_WidthFixed, 80.0f); + ImGui::TableSetupColumn(_("Size"), ImGuiTableColumnFlags_WidthFixed, 100.0f); + ImGui::TableHeadersRow(); + drawFilesystemTree(m_reader->getRootDirEntry(), ""); + ImGui::EndTable(); + } + + // PPF patch controls + ImGui::Separator(); + if (ImGui::Button(_("Clear Patches"))) { + currentIso->getPPF()->clear(); + } + ImGui::SameLine(); + if (ImGui::Button(_("Save PPF"))) { + currentIso->getPPF()->save(currentIso->getIsoPath()); + } + } + ImGui::End(); } diff --git a/src/gui/widgets/isobrowser.h b/src/gui/widgets/isobrowser.h index cff66374c..4cbe23e36 100644 --- a/src/gui/widgets/isobrowser.h +++ b/src/gui/widgets/isobrowser.h @@ -22,11 +22,14 @@ #include #include +#include #include #include +#include "cdrom/iso9660-reader.h" #include "gui/widgets/filedialog.h" #include "support/coroutine.h" +#include "supportpsx/iso9660-lowlevel.h" namespace PCSX { @@ -38,7 +41,10 @@ namespace Widgets { class IsoBrowser { public: IsoBrowser(bool& show, std::vector& favorites) - : m_show(show), m_openIsoFileDialog(l_("Open Disk Image"), favorites) {} + : m_show(show), + m_openIsoFileDialog(l_("Open Disk Image"), favorites), + m_saveFileDialog(l_("Extract File"), favorites), + m_openReplaceFileDialog(l_("Replace File"), favorites) {} void draw(CDRom* cdrom, const char* title); bool& m_show; @@ -48,9 +54,23 @@ class IsoBrowser { uint32_t m_crcs[100] = {0}; Coroutine<> m_crcCalculator; float m_crcProgress = 0.0f; - Coroutine<> computeCRC(CDRIso*); + + std::unique_ptr m_reader; + std::weak_ptr m_cachedIso; + std::string m_selectedPath; + ISO9660LowLevel::DirEntry m_selectedEntry; + bool m_hasSelection = false; + bool m_selectedIsDir = false; + + Coroutine<> m_extractionCoroutine; + float m_extractionProgress = 0.0f; + + void drawFilesystemTree(const ISO9660LowLevel::DirEntry& entry, const std::string& path); + FileDialog<> m_openIsoFileDialog; + FileDialog m_saveFileDialog; + FileDialog<> m_openReplaceFileDialog; }; } // namespace Widgets From fcfe8d5b1fd720cbe5578bed2bed71deb335f006 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Tue, 7 Apr 2026 08:56:29 -0700 Subject: [PATCH 02/16] Add directory listing to Lua ISO reader API Exposes ISO9660 filesystem enumeration through the Lua FFI so scripts can list directory contents without the GUI. The reader:listDir(path) method returns a table of entries with name, LBA, size, and isDir fields, enabling headless ISO inspection and processing workflows. Signed-off-by: Nicolas 'Pixel' Noble --- src/core/isoffi.lua | 27 ++++++++++++++++++++ src/core/luaiso.cc | 62 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/core/isoffi.lua b/src/core/isoffi.lua index 6e8787740..5dd4962d8 100644 --- a/src/core/isoffi.lua +++ b/src/core/isoffi.lua @@ -43,6 +43,15 @@ bool isReaderFailed(IsoReader* reader); LuaFile* readerOpen(IsoReader* reader, const char* path); LuaFile* fileisoOpen(LuaIso* wrapper, uint32_t lba, uint32_t size, enum SectorMode mode); +typedef struct { char opaque[?]; } DirEntries; +DirEntries* readerListDir(IsoReader* reader, const char* path); +void deleteDirEntries(DirEntries* entries); +uint32_t dirEntriesCount(DirEntries* entries); +const char* dirEntryName(DirEntries* entries, uint32_t index); +uint32_t dirEntryLBA(DirEntries* entries, uint32_t index); +uint32_t dirEntrySize(DirEntries* entries, uint32_t index); +bool dirEntryIsDir(DirEntries* entries, uint32_t index); + typedef struct { char opaque[?]; } ISO9660Builder; ISO9660Builder* createIsoBuilder(LuaFile* out); void deleteIsoBuilder(ISO9660Builder* builder); @@ -58,6 +67,24 @@ local function createIsoReaderWrapper(isoReader) local reader = { _wrapper = ffi.gc(isoReader, C.deleteIsoReader), open = function(self, fname) return Support.File._createFileWrapper(C.readerOpen(self._wrapper, fname)) end, + listDir = function(self, path) + if path == nil then path = '' end + local entries = ffi.gc(C.readerListDir(self._wrapper, path), C.deleteDirEntries) + local count = C.dirEntriesCount(entries) + local result = {} + for i = 0, count - 1 do + local name = ffi.string(C.dirEntryName(entries, i)) + if name ~= '\0' and name ~= '\1' then + table.insert(result, { + name = name, + lba = C.dirEntryLBA(entries, i), + size = C.dirEntrySize(entries, i), + isDir = C.dirEntryIsDir(entries, i), + }) + end + end + return result + end, } return reader end diff --git a/src/core/luaiso.cc b/src/core/luaiso.cc index 54427879f..fd0228f91 100644 --- a/src/core/luaiso.cc +++ b/src/core/luaiso.cc @@ -27,6 +27,7 @@ #include "core/cdrom.h" #include "lua/luafile.h" #include "lua/luawrapper.h" +#include "support/strings-helpers.h" #include "supportpsx/iso9660-builder.h" namespace { @@ -57,6 +58,59 @@ PCSX::LuaFFI::LuaFile* fileisoOpen(LuaIso* wrapper, uint32_t lba, uint32_t size, return new PCSX::LuaFFI::LuaFile(new PCSX::CDRIsoFile(wrapper->iso, lba, size, mode)); } +struct DirEntries { + std::vector entries; +}; + +DirEntries* readerListDir(PCSX::ISO9660Reader* reader, const char* path) { + auto root = reader->getRootDirEntry(); + if (path == nullptr || path[0] == '\0') { + return new DirEntries{reader->listAllEntriesFrom(root)}; + } + auto* file = reader->open(path); + if (file->failed()) { + delete file; + return new DirEntries{}; + } + delete file; + // Need to find the directory entry for the given path to list its contents. + // Walk the path manually using listAllEntriesFrom. + auto parts = PCSX::StringsHelpers::split(std::string_view(path), "/"); + PCSX::ISO9660LowLevel::DirEntry current = root; + for (auto& part : parts) { + auto entries = reader->listAllEntriesFrom(current); + bool found = false; + for (auto& [entry, xa] : entries) { + if (entry.get().value == part) { + current = entry; + found = true; + break; + } + } + if (!found) return new DirEntries{}; + } + return new DirEntries{reader->listAllEntriesFrom(current)}; +} + +void deleteDirEntries(DirEntries* entries) { delete entries; } +uint32_t dirEntriesCount(DirEntries* entries) { return entries->entries.size(); } +const char* dirEntryName(DirEntries* entries, uint32_t index) { + if (index >= entries->entries.size()) return ""; + return entries->entries[index].first.get().value.c_str(); +} +uint32_t dirEntryLBA(DirEntries* entries, uint32_t index) { + if (index >= entries->entries.size()) return 0; + return entries->entries[index].first.get(); +} +uint32_t dirEntrySize(DirEntries* entries, uint32_t index) { + if (index >= entries->entries.size()) return 0; + return entries->entries[index].first.get(); +} +bool dirEntryIsDir(DirEntries* entries, uint32_t index) { + if (index >= entries->entries.size()) return false; + return (entries->entries[index].first.get().value & 2) != 0; +} + PCSX::ISO9660Builder* createIsoBuilder(PCSX::LuaFFI::LuaFile* wrapper) { return new PCSX::ISO9660Builder(wrapper->file); } @@ -98,6 +152,14 @@ static void registerAllSymbols(PCSX::Lua L) { REGISTER(L, readerOpen); REGISTER(L, fileisoOpen); + REGISTER(L, readerListDir); + REGISTER(L, deleteDirEntries); + REGISTER(L, dirEntriesCount); + REGISTER(L, dirEntryName); + REGISTER(L, dirEntryLBA); + REGISTER(L, dirEntrySize); + REGISTER(L, dirEntryIsDir); + REGISTER(L, createIsoBuilder); REGISTER(L, deleteIsoBuilder); REGISTER(L, isoBuilderWriteLicense); From ecd09eed50cba76bea6eaccc5ea93b9a0db2e920 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Tue, 7 Apr 2026 09:03:02 -0700 Subject: [PATCH 03/16] Add flat view mode to ISO browser filesystem display Adds a "Flat view (by sector)" toggle that shows all files and directories in a single table sorted by LBA, giving a disc topology view. Useful for spotting sector layout, gaps between files, and understanding the physical organization of the disc image. Signed-off-by: Nicolas 'Pixel' Noble --- src/gui/widgets/isobrowser.cc | 84 +++++++++++++++++++++++++++++++---- src/gui/widgets/isobrowser.h | 13 ++++++ 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/gui/widgets/isobrowser.cc b/src/gui/widgets/isobrowser.cc index 076a97a9f..02d9af9e7 100644 --- a/src/gui/widgets/isobrowser.cc +++ b/src/gui/widgets/isobrowser.cc @@ -21,6 +21,7 @@ #include +#include #include #include "cdrom/cdriso.h" @@ -101,6 +102,64 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemTree(const ISO9660LowLevel::DirEnt } } +void PCSX::Widgets::IsoBrowser::collectFlatEntries(const ISO9660LowLevel::DirEntry& entry, const std::string& path) { + auto entries = m_reader->listAllEntriesFrom(entry); + for (auto& [dirEntry, xa] : entries) { + const auto& filename = dirEntry.get().value; + if (filename.size() == 1 && (filename[0] == '\0' || filename[0] == '\1')) continue; + + bool isDir = (dirEntry.get().value & 2) != 0; + uint32_t lba = dirEntry.get(); + uint32_t size = dirEntry.get(); + auto fullPath = path.empty() ? filename : path + "/" + filename; + + m_flatEntries.push_back({fullPath, lba, size, isDir, dirEntry}); + if (isDir) collectFlatEntries(dirEntry, fullPath); + } +} + +void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { + if (m_flatEntriesDirty) { + m_flatEntries.clear(); + collectFlatEntries(m_reader->getRootDirEntry(), ""); + std::sort(m_flatEntries.begin(), m_flatEntries.end(), + [](const FlatEntry& a, const FlatEntry& b) { return a.lba < b.lba; }); + m_flatEntriesDirty = false; + } + + if (ImGui::BeginTable("FilesystemFlat", 4, + ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY | + ImGuiTableFlags_Sortable, + ImVec2(0, 300))) { + ImGui::TableSetupColumn(_("Path"), ImGuiTableColumnFlags_NoHide); + ImGui::TableSetupColumn(_("LBA"), ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_DefaultSort, 80.0f); + ImGui::TableSetupColumn(_("Size"), ImGuiTableColumnFlags_WidthFixed, 100.0f); + ImGui::TableSetupColumn(_("Type"), ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableHeadersRow(); + + for (auto& entry : m_flatEntries) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + bool selected = m_hasSelection && m_selectedPath == entry.path; + if (ImGui::Selectable(entry.path.c_str(), selected, + ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { + m_selectedPath = entry.path; + m_selectedEntry = entry.dirEntry; + m_hasSelection = true; + m_selectedIsDir = entry.isDir; + } + ImGui::TableSetColumnIndex(1); + ImGui::Text("%u", entry.lba); + ImGui::TableSetColumnIndex(2); + auto str = fmt::format("{}", entry.size); + ImGui::TextUnformatted(str.c_str()); + ImGui::TableSetColumnIndex(3); + ImGui::TextUnformatted(entry.isDir ? _("") : _("File")); + } + ImGui::EndTable(); + } +} + void PCSX::Widgets::IsoBrowser::draw(CDRom* cdrom, const char* title) { if (!ImGui::Begin(title, &m_show, ImGuiWindowFlags_MenuBar)) { ImGui::End(); @@ -225,6 +284,7 @@ significantly by caching the files beforehand.)")); m_reader.reset(); m_hasSelection = false; m_selectedPath.clear(); + m_flatEntriesDirty = true; if (currentIso && !currentIso->failed()) { m_reader = std::make_unique(currentIso); if (m_reader->failed()) m_reader.reset(); @@ -320,15 +380,21 @@ significantly by caching the files beforehand.)")); } } - if (ImGui::BeginTable("Filesystem", 3, - ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY, - ImVec2(0, 300))) { - ImGui::TableSetupColumn(_("Name"), ImGuiTableColumnFlags_NoHide); - ImGui::TableSetupColumn(_("LBA"), ImGuiTableColumnFlags_WidthFixed, 80.0f); - ImGui::TableSetupColumn(_("Size"), ImGuiTableColumnFlags_WidthFixed, 100.0f); - ImGui::TableHeadersRow(); - drawFilesystemTree(m_reader->getRootDirEntry(), ""); - ImGui::EndTable(); + ImGui::Checkbox(_("Flat view (by sector)"), &m_flatView); + + if (m_flatView) { + drawFilesystemFlat(); + } else { + if (ImGui::BeginTable("Filesystem", 3, + ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY, + ImVec2(0, 300))) { + ImGui::TableSetupColumn(_("Name"), ImGuiTableColumnFlags_NoHide); + ImGui::TableSetupColumn(_("LBA"), ImGuiTableColumnFlags_WidthFixed, 80.0f); + ImGui::TableSetupColumn(_("Size"), ImGuiTableColumnFlags_WidthFixed, 100.0f); + ImGui::TableHeadersRow(); + drawFilesystemTree(m_reader->getRootDirEntry(), ""); + ImGui::EndTable(); + } } // PPF patch controls diff --git a/src/gui/widgets/isobrowser.h b/src/gui/widgets/isobrowser.h index 4cbe23e36..80f01a142 100644 --- a/src/gui/widgets/isobrowser.h +++ b/src/gui/widgets/isobrowser.h @@ -66,7 +66,20 @@ class IsoBrowser { Coroutine<> m_extractionCoroutine; float m_extractionProgress = 0.0f; + bool m_flatView = false; + struct FlatEntry { + std::string path; + uint32_t lba; + uint32_t size; + bool isDir; + ISO9660LowLevel::DirEntry dirEntry; + }; + std::vector m_flatEntries; + bool m_flatEntriesDirty = true; + void drawFilesystemTree(const ISO9660LowLevel::DirEntry& entry, const std::string& path); + void drawFilesystemFlat(); + void collectFlatEntries(const ISO9660LowLevel::DirEntry& entry, const std::string& path); FileDialog<> m_openIsoFileDialog; FileDialog m_saveFileDialog; From 0bf6a3dcee12277af3d327c35940f6d851d1b67d Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Tue, 7 Apr 2026 09:08:54 -0700 Subject: [PATCH 04/16] Display and support import/export of gaps in flat view The flat view now shows gap entries between files, representing unallocated sectors on the disc. Gaps are selectable and can be extracted or replaced just like files, enabling inspection and patching of hidden or unused disc regions. Signed-off-by: Nicolas 'Pixel' Noble --- src/gui/widgets/isobrowser.cc | 66 ++++++++++++++++++++++++++--------- src/gui/widgets/isobrowser.h | 4 +++ 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/gui/widgets/isobrowser.cc b/src/gui/widgets/isobrowser.cc index 02d9af9e7..921f97e89 100644 --- a/src/gui/widgets/isobrowser.cc +++ b/src/gui/widgets/isobrowser.cc @@ -90,8 +90,11 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemTree(const ISO9660LowLevel::DirEnt if (ImGui::IsItemClicked()) { m_selectedPath = fullPath; m_selectedEntry = dirEntry; + m_selectedLBA = lba; + m_selectedSize = size; m_hasSelection = true; m_selectedIsDir = false; + m_selectedIsGap = false; } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", lba); @@ -113,7 +116,7 @@ void PCSX::Widgets::IsoBrowser::collectFlatEntries(const ISO9660LowLevel::DirEnt uint32_t size = dirEntry.get(); auto fullPath = path.empty() ? filename : path + "/" + filename; - m_flatEntries.push_back({fullPath, lba, size, isDir, dirEntry}); + m_flatEntries.push_back({fullPath, lba, size, isDir, false, dirEntry}); if (isDir) collectFlatEntries(dirEntry, fullPath); } } @@ -124,37 +127,67 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { collectFlatEntries(m_reader->getRootDirEntry(), ""); std::sort(m_flatEntries.begin(), m_flatEntries.end(), [](const FlatEntry& a, const FlatEntry& b) { return a.lba < b.lba; }); + + // Insert gap entries between files + std::vector withGaps; + uint32_t nextExpected = 0; + for (auto& entry : m_flatEntries) { + if (entry.lba > nextExpected) { + uint32_t gapSectors = entry.lba - nextExpected; + auto label = fmt::format(f_(""), gapSectors); + withGaps.push_back({label, nextExpected, gapSectors * 2048, false, true, {}}); + } + withGaps.push_back(entry); + uint32_t sectors = (entry.size + 2047) / 2048; + uint32_t end = entry.lba + sectors; + if (end > nextExpected) nextExpected = end; + } + m_flatEntries = std::move(withGaps); m_flatEntriesDirty = false; } if (ImGui::BeginTable("FilesystemFlat", 4, - ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY | - ImGuiTableFlags_Sortable, + ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY, ImVec2(0, 300))) { ImGui::TableSetupColumn(_("Path"), ImGuiTableColumnFlags_NoHide); - ImGui::TableSetupColumn(_("LBA"), ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_DefaultSort, 80.0f); + ImGui::TableSetupColumn(_("LBA"), ImGuiTableColumnFlags_WidthFixed, 80.0f); ImGui::TableSetupColumn(_("Size"), ImGuiTableColumnFlags_WidthFixed, 100.0f); ImGui::TableSetupColumn(_("Type"), ImGuiTableColumnFlags_WidthFixed, 60.0f); ImGui::TableHeadersRow(); - for (auto& entry : m_flatEntries) { + for (size_t i = 0; i < m_flatEntries.size(); i++) { + auto& entry = m_flatEntries[i]; ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); - bool selected = m_hasSelection && m_selectedPath == entry.path; - if (ImGui::Selectable(entry.path.c_str(), selected, + bool selected = m_hasSelection && m_selectedPath == entry.path && + m_selectedLBA == entry.lba && m_selectedIsGap == entry.isGap; + auto id = fmt::format("{}##{}", entry.path, i); + if (ImGui::Selectable(id.c_str(), selected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { m_selectedPath = entry.path; + m_selectedLBA = entry.lba; + m_selectedSize = entry.size; m_selectedEntry = entry.dirEntry; m_hasSelection = true; m_selectedIsDir = entry.isDir; + m_selectedIsGap = entry.isGap; } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", entry.lba); ImGui::TableSetColumnIndex(2); - auto str = fmt::format("{}", entry.size); - ImGui::TextUnformatted(str.c_str()); + if (entry.isGap) { + auto str = fmt::format("{} sectors", entry.size / 2048); + ImGui::TextUnformatted(str.c_str()); + } else { + auto str = fmt::format("{}", entry.size); + ImGui::TextUnformatted(str.c_str()); + } ImGui::TableSetColumnIndex(3); - ImGui::TextUnformatted(entry.isDir ? _("") : _("File")); + if (entry.isGap) { + ImGui::TextUnformatted(_("Gap")); + } else { + ImGui::TextUnformatted(entry.isDir ? _("") : _("File")); + } } ImGui::EndTable(); } @@ -300,11 +333,12 @@ significantly by caching the files beforehand.)")); ImGui::ProgressBar(m_extractionProgress); m_extractionCoroutine.resume(); } else { - if (!m_hasSelection || m_selectedIsDir) ImGui::BeginDisabled(); + bool canExtractReplace = m_hasSelection && (!m_selectedIsDir || m_selectedIsGap); + if (!canExtractReplace) ImGui::BeginDisabled(); showSaveDialog = ImGui::Button(_("Extract")); ImGui::SameLine(); showReplaceDialog = ImGui::Button(_("Replace")); - if (!m_hasSelection || m_selectedIsDir) ImGui::EndDisabled(); + if (!canExtractReplace) ImGui::EndDisabled(); } if (showSaveDialog) { @@ -319,8 +353,8 @@ significantly by caching the files beforehand.)")); auto selected = m_saveFileDialog.selected(); if (!selected.empty() && m_hasSelection) { auto destPath = reinterpret_cast(selected[0].c_str()); - uint32_t lba = m_selectedEntry.get(); - uint32_t size = m_selectedEntry.get(); + uint32_t lba = m_selectedLBA; + uint32_t size = m_selectedSize; auto isoPtr = m_cachedIso.lock(); if (isoPtr) { m_extractionProgress = 0.0f; @@ -358,8 +392,8 @@ significantly by caching the files beforehand.)")); auto selected = m_openReplaceFileDialog.selected(); if (!selected.empty() && m_hasSelection) { auto srcPath = reinterpret_cast(selected[0].c_str()); - uint32_t lba = m_selectedEntry.get(); - uint32_t originalSize = m_selectedEntry.get(); + uint32_t lba = m_selectedLBA; + uint32_t originalSize = m_selectedSize; auto isoPtr = m_cachedIso.lock(); if (isoPtr) { IO replacement(new UvFile(srcPath)); diff --git a/src/gui/widgets/isobrowser.h b/src/gui/widgets/isobrowser.h index 80f01a142..4d1391e02 100644 --- a/src/gui/widgets/isobrowser.h +++ b/src/gui/widgets/isobrowser.h @@ -60,8 +60,11 @@ class IsoBrowser { std::weak_ptr m_cachedIso; std::string m_selectedPath; ISO9660LowLevel::DirEntry m_selectedEntry; + uint32_t m_selectedLBA = 0; + uint32_t m_selectedSize = 0; bool m_hasSelection = false; bool m_selectedIsDir = false; + bool m_selectedIsGap = false; Coroutine<> m_extractionCoroutine; float m_extractionProgress = 0.0f; @@ -72,6 +75,7 @@ class IsoBrowser { uint32_t lba; uint32_t size; bool isDir; + bool isGap; ISO9660LowLevel::DirEntry dirEntry; }; std::vector m_flatEntries; From a7ea25e5d8b153d3e32b2e63900049c22e59cab9 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Tue, 7 Apr 2026 09:38:36 -0700 Subject: [PATCH 05/16] Add hex editor for ISO file and gap contents Integrates the memory editor widget into the ISO browser. Selecting a file or gap and clicking "Hex Edit" opens a hex viewer/editor backed by a CDRIsoFile. Writes go through the PPF patching pipeline, so edits are non-destructive to the original disc image. Signed-off-by: Nicolas 'Pixel' Noble --- src/gui/widgets/isobrowser.cc | 25 +++++++++++++++++++++++++ src/gui/widgets/isobrowser.h | 7 +++++++ 2 files changed, 32 insertions(+) diff --git a/src/gui/widgets/isobrowser.cc b/src/gui/widgets/isobrowser.cc index 921f97e89..b5798c88e 100644 --- a/src/gui/widgets/isobrowser.cc +++ b/src/gui/widgets/isobrowser.cc @@ -338,6 +338,15 @@ significantly by caching the files beforehand.)")); showSaveDialog = ImGui::Button(_("Extract")); ImGui::SameLine(); showReplaceDialog = ImGui::Button(_("Replace")); + ImGui::SameLine(); + if (ImGui::Button(_("Hex Edit"))) { + auto isoPtr = m_cachedIso.lock(); + if (isoPtr) { + m_hexEditFile.setFile(new CDRIsoFile(isoPtr, m_selectedLBA, m_selectedSize)); + m_hexEditorOpen = true; + m_hexEditorOffset = 0; + } + } if (!canExtractReplace) ImGui::EndDisabled(); } @@ -443,4 +452,20 @@ significantly by caching the files beforehand.)")); } ImGui::End(); + + // Hex editor window (rendered outside the main ISO browser window) + if (m_hexEditorOpen && m_hexEditFile) { + auto size = m_hexEditFile->size(); + m_hexEditor.ReadFn = [this](size_t off) -> ImU8 { + ImU8 b; + m_hexEditFile->readAt(&b, 1, off); + return b; + }; + m_hexEditor.WriteFn = [this](size_t off, ImU8 d) { m_hexEditFile->writeAt(&d, 1, off); }; + m_hexEditor.Cache.BulkReadFn = [this](void* dest, size_t off, size_t len) { + m_hexEditFile->readAt(dest, len, off); + }; + auto title = fmt::format(f_("Hex Editor - {}"), m_selectedPath); + m_hexEditor.DrawWindow(title.c_str(), size); + } } diff --git a/src/gui/widgets/isobrowser.h b/src/gui/widgets/isobrowser.h index 4d1391e02..78db3e9a9 100644 --- a/src/gui/widgets/isobrowser.h +++ b/src/gui/widgets/isobrowser.h @@ -28,7 +28,9 @@ #include "cdrom/iso9660-reader.h" #include "gui/widgets/filedialog.h" +#include "imgui_memory_editor/imgui_memory_editor.h" #include "support/coroutine.h" +#include "support/file.h" #include "supportpsx/iso9660-lowlevel.h" namespace PCSX { @@ -88,6 +90,11 @@ class IsoBrowser { FileDialog<> m_openIsoFileDialog; FileDialog m_saveFileDialog; FileDialog<> m_openReplaceFileDialog; + + bool m_hexEditorOpen = false; + size_t m_hexEditorOffset = 0; + MemoryEditor m_hexEditor{m_hexEditorOpen, 0, m_hexEditorOffset}; + IO m_hexEditFile; }; } // namespace Widgets From 4fb0edd26de44d93b0516dfc2eee52f2e7b59943 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Wed, 15 Apr 2026 13:05:24 -0700 Subject: [PATCH 06/16] Scan gap sectors for hidden files using Mode/subheader analysis Adds a "Scan gaps for hidden files" button in the flat view that reads sector headers in gap regions to detect content stripped from the ISO9660 directory. The scanner classifies sectors by mode: - Mode 0: true gap (empty/unused sectors) - Mode 1: hidden data (M1) - Mode 2: uses XA subheader to determine Form 1 vs Form 2, groups sectors into subfiles by file/channel number, and detects file boundaries via the end-of-file submode flag The scan is opt-in to avoid hammering the ISO on every flat view refresh. Results show the sector type (M1/M2F1/M2F2/Gap) and for Mode 2 files, the file number and channel from the subheader. Signed-off-by: Nicolas 'Pixel' Noble --- src/gui/widgets/isobrowser.cc | 146 +++++++++++++++++++++++++++++----- src/gui/widgets/isobrowser.h | 12 ++- 2 files changed, 137 insertions(+), 21 deletions(-) diff --git a/src/gui/widgets/isobrowser.cc b/src/gui/widgets/isobrowser.cc index b5798c88e..cdd39b35b 100644 --- a/src/gui/widgets/isobrowser.cc +++ b/src/gui/widgets/isobrowser.cc @@ -116,11 +116,95 @@ void PCSX::Widgets::IsoBrowser::collectFlatEntries(const ISO9660LowLevel::DirEnt uint32_t size = dirEntry.get(); auto fullPath = path.empty() ? filename : path + "/" + filename; - m_flatEntries.push_back({fullPath, lba, size, isDir, false, dirEntry}); + m_flatEntries.push_back({fullPath, lba, size, isDir ? FlatEntry::Directory : FlatEntry::File, dirEntry}); if (isDir) collectFlatEntries(dirEntry, fullPath); } } +void PCSX::Widgets::IsoBrowser::scanGapSectors(std::vector& out, uint32_t startLBA, uint32_t sectorCount, + std::shared_ptr iso) { + uint32_t lba = startLBA; + uint32_t end = startLBA + sectorCount; + + while (lba < end) { + uint8_t sector[2352]; + if (iso->readSectors(lba, sector, 1) != 1) { + // Read failed, treat rest as gap + uint32_t remaining = end - lba; + auto label = fmt::format(f_(""), remaining); + out.push_back({label, lba, remaining * 2352, FlatEntry::Gap, {}}); + break; + } + + uint8_t mode = sector[15]; + + if (mode == 0) { + // Mode 0: true gap. Accumulate consecutive mode 0 sectors. + uint32_t gapStart = lba; + while (lba < end) { + if (lba != gapStart) { + if (iso->readSectors(lba, sector, 1) != 1) break; + if (sector[15] != 0) break; + } + lba++; + } + uint32_t count = lba - gapStart; + auto label = fmt::format(f_(""), count); + out.push_back({label, gapStart, count * 2352, FlatEntry::Gap, {}}); + continue; + } + + if (mode == 1) { + // Mode 1 hidden data. Accumulate until mode changes or gap ends. + uint32_t fileStart = lba; + lba++; + while (lba < end) { + if (iso->readSectors(lba, sector, 1) != 1) break; + if (sector[15] != 1) break; + lba++; + } + uint32_t count = lba - fileStart; + auto label = fmt::format(f_(""), count); + out.push_back({label, fileStart, count * 2048, FlatEntry::HiddenM1, {}}); + continue; + } + + if (mode == 2) { + // Mode 2: parse subheader for form and file boundaries + uint8_t* sub = sector + 16; + uint8_t fileNum = sub[0]; + uint8_t channelNum = sub[1]; + uint8_t submode = sub[2]; + bool isForm2 = (submode & 0x20) != 0; + auto type = isForm2 ? FlatEntry::HiddenM2F2 : FlatEntry::HiddenM2F1; + + uint32_t fileStart = lba; + bool hitEof = (submode & 0x80) != 0; + lba++; + + while (lba < end && !hitEof) { + if (iso->readSectors(lba, sector, 1) != 1) break; + if (sector[15] != 2) break; + sub = sector + 16; + // Different file/channel = new subfile + if (sub[0] != fileNum || sub[1] != channelNum) break; + hitEof = (sub[2] & 0x80) != 0; + lba++; + } + + uint32_t count = lba - fileStart; + uint32_t dataSize = isForm2 ? count * 2324 : count * 2048; + auto label = fmt::format(f_(""), + isForm2 ? "M2F2" : "M2F1", fileNum, channelNum, count); + out.push_back({label, fileStart, dataSize, type, {}}); + continue; + } + + // Unknown mode, skip sector + lba++; + } +} + void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { if (m_flatEntriesDirty) { m_flatEntries.clear(); @@ -128,14 +212,14 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { std::sort(m_flatEntries.begin(), m_flatEntries.end(), [](const FlatEntry& a, const FlatEntry& b) { return a.lba < b.lba; }); - // Insert gap entries between files + // Insert simple gap placeholders (no sector scanning yet) std::vector withGaps; uint32_t nextExpected = 0; for (auto& entry : m_flatEntries) { if (entry.lba > nextExpected) { uint32_t gapSectors = entry.lba - nextExpected; - auto label = fmt::format(f_(""), gapSectors); - withGaps.push_back({label, nextExpected, gapSectors * 2048, false, true, {}}); + auto label = fmt::format(f_(""), gapSectors); + withGaps.push_back({label, nextExpected, gapSectors * 2352, FlatEntry::Gap, {}}); } withGaps.push_back(entry); uint32_t sectors = (entry.size + 2047) / 2048; @@ -144,6 +228,30 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { } m_flatEntries = std::move(withGaps); m_flatEntriesDirty = false; + m_gapsScanned = false; + } + + if (!m_gapsScanned) { + if (ImGui::Button(_("Scan gaps for hidden files"))) { + auto iso = m_cachedIso.lock(); + if (iso) { + std::vector scanned; + for (auto& entry : m_flatEntries) { + if (entry.isGap()) { + uint32_t sectorCount = entry.size / 2352; + scanGapSectors(scanned, entry.lba, sectorCount, iso); + } else { + scanned.push_back(entry); + } + } + m_flatEntries = std::move(scanned); + m_gapsScanned = true; + } + } + ImGuiHelpers::ShowHelpMarker(_(R"(Reads sector headers in gap regions to detect +hidden files that were removed from the ISO9660 +directory but still have intact Mode 1/2 sector +headers and subheader file boundary markers.)")); } if (ImGui::BeginTable("FilesystemFlat", 4, @@ -159,8 +267,8 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { auto& entry = m_flatEntries[i]; ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); - bool selected = m_hasSelection && m_selectedPath == entry.path && - m_selectedLBA == entry.lba && m_selectedIsGap == entry.isGap; + bool selected = m_hasSelection && m_selectedLBA == entry.lba && + m_selectedIsGap == entry.isGap(); auto id = fmt::format("{}##{}", entry.path, i); if (ImGui::Selectable(id.c_str(), selected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { @@ -169,25 +277,25 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { m_selectedSize = entry.size; m_selectedEntry = entry.dirEntry; m_hasSelection = true; - m_selectedIsDir = entry.isDir; - m_selectedIsGap = entry.isGap; + m_selectedIsDir = entry.isDir(); + m_selectedIsGap = entry.isGap() || entry.isHidden(); } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", entry.lba); ImGui::TableSetColumnIndex(2); - if (entry.isGap) { - auto str = fmt::format("{} sectors", entry.size / 2048); - ImGui::TextUnformatted(str.c_str()); - } else { - auto str = fmt::format("{}", entry.size); - ImGui::TextUnformatted(str.c_str()); - } + auto str = fmt::format("{}", entry.size); + ImGui::TextUnformatted(str.c_str()); ImGui::TableSetColumnIndex(3); - if (entry.isGap) { - ImGui::TextUnformatted(_("Gap")); - } else { - ImGui::TextUnformatted(entry.isDir ? _("") : _("File")); + const char* typeStr; + switch (entry.type) { + case FlatEntry::File: typeStr = _("File"); break; + case FlatEntry::Directory: typeStr = _(""); break; + case FlatEntry::Gap: typeStr = _("Gap"); break; + case FlatEntry::HiddenM1: typeStr = _("M1"); break; + case FlatEntry::HiddenM2F1: typeStr = _("M2F1"); break; + case FlatEntry::HiddenM2F2: typeStr = _("M2F2"); break; } + ImGui::TextUnformatted(typeStr); } ImGui::EndTable(); } diff --git a/src/gui/widgets/isobrowser.h b/src/gui/widgets/isobrowser.h index 78db3e9a9..4caeb37a8 100644 --- a/src/gui/widgets/isobrowser.h +++ b/src/gui/widgets/isobrowser.h @@ -73,19 +73,27 @@ class IsoBrowser { bool m_flatView = false; struct FlatEntry { + enum Type { File, Directory, Gap, HiddenM1, HiddenM2F1, HiddenM2F2 }; std::string path; uint32_t lba; uint32_t size; - bool isDir; - bool isGap; + Type type; ISO9660LowLevel::DirEntry dirEntry; + + bool isGap() const { return type == Gap; } + bool isDir() const { return type == Directory; } + bool isHidden() const { return type == HiddenM1 || type == HiddenM2F1 || type == HiddenM2F2; } + bool isSelectable() const { return type != Directory; } }; std::vector m_flatEntries; bool m_flatEntriesDirty = true; + bool m_gapsScanned = false; void drawFilesystemTree(const ISO9660LowLevel::DirEntry& entry, const std::string& path); void drawFilesystemFlat(); void collectFlatEntries(const ISO9660LowLevel::DirEntry& entry, const std::string& path); + void scanGapSectors(std::vector& out, uint32_t startLBA, uint32_t sectorCount, + std::shared_ptr iso); FileDialog<> m_openIsoFileDialog; FileDialog m_saveFileDialog; From dd5fe0eb15d8e884449e5ae73852e6d36a4f1e9b Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Wed, 15 Apr 2026 13:17:25 -0700 Subject: [PATCH 07/16] Add reader:findGaps() to Lua ISO API Returns an array of {lba, sectors} tables for LBA ranges not covered by any ISO9660 directory entry. Scripts can then use iso:open(lba, -1, 'GUESS') on each gap to auto-detect hidden files via the CDRIsoFile constructor's existing mode/subheader analysis. Signed-off-by: Nicolas 'Pixel' Noble --- src/core/isoffi.lua | 19 +++++++++++++++ src/core/luaiso.cc | 59 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/core/isoffi.lua b/src/core/isoffi.lua index 5dd4962d8..7767d5353 100644 --- a/src/core/isoffi.lua +++ b/src/core/isoffi.lua @@ -52,6 +52,13 @@ uint32_t dirEntryLBA(DirEntries* entries, uint32_t index); uint32_t dirEntrySize(DirEntries* entries, uint32_t index); bool dirEntryIsDir(DirEntries* entries, uint32_t index); +typedef struct { char opaque[?]; } GapList; +GapList* readerFindGaps(IsoReader* reader); +void deleteGapList(GapList* list); +uint32_t gapListCount(GapList* list); +uint32_t gapEntryLBA(GapList* list, uint32_t index); +uint32_t gapEntrySectors(GapList* list, uint32_t index); + typedef struct { char opaque[?]; } ISO9660Builder; ISO9660Builder* createIsoBuilder(LuaFile* out); void deleteIsoBuilder(ISO9660Builder* builder); @@ -67,6 +74,18 @@ local function createIsoReaderWrapper(isoReader) local reader = { _wrapper = ffi.gc(isoReader, C.deleteIsoReader), open = function(self, fname) return Support.File._createFileWrapper(C.readerOpen(self._wrapper, fname)) end, + findGaps = function(self) + local gapList = ffi.gc(C.readerFindGaps(self._wrapper), C.deleteGapList) + local count = C.gapListCount(gapList) + local result = {} + for i = 0, count - 1 do + table.insert(result, { + lba = C.gapEntryLBA(gapList, i), + sectors = C.gapEntrySectors(gapList, i), + }) + end + return result + end, listDir = function(self, path) if path == nil then path = '' end local entries = ffi.gc(C.readerListDir(self._wrapper, path), C.deleteDirEntries) diff --git a/src/core/luaiso.cc b/src/core/luaiso.cc index fd0228f91..4c5ff562b 100644 --- a/src/core/luaiso.cc +++ b/src/core/luaiso.cc @@ -111,6 +111,59 @@ bool dirEntryIsDir(DirEntries* entries, uint32_t index) { return (entries->entries[index].first.get().value & 2) != 0; } +struct GapEntry { + uint32_t lba; + uint32_t sectors; +}; + +struct GapList { + std::vector gaps; +}; + +static void collectAllEntries(PCSX::ISO9660Reader* reader, const PCSX::ISO9660LowLevel::DirEntry& dir, + std::vector>& out) { + auto entries = reader->listAllEntriesFrom(dir); + for (auto& [entry, xa] : entries) { + const auto& name = entry.get().value; + if (name.size() == 1 && (name[0] == '\0' || name[0] == '\1')) continue; + uint32_t lba = entry.get(); + uint32_t size = entry.get(); + uint32_t sectors = (size + 2047) / 2048; + out.push_back({lba, sectors}); + bool isDir = (entry.get().value & 2) != 0; + if (isDir) collectAllEntries(reader, entry, out); + } +} + +GapList* readerFindGaps(PCSX::ISO9660Reader* reader) { + if (reader->failed()) return new GapList{}; + std::vector> allFiles; + collectAllEntries(reader, reader->getRootDirEntry(), allFiles); + std::sort(allFiles.begin(), allFiles.end()); + + auto* result = new GapList{}; + uint32_t nextExpected = 0; + for (auto& [lba, sectors] : allFiles) { + if (lba > nextExpected) { + result->gaps.push_back({nextExpected, lba - nextExpected}); + } + uint32_t end = lba + sectors; + if (end > nextExpected) nextExpected = end; + } + return result; +} + +void deleteGapList(GapList* list) { delete list; } +uint32_t gapListCount(GapList* list) { return list->gaps.size(); } +uint32_t gapEntryLBA(GapList* list, uint32_t index) { + if (index >= list->gaps.size()) return 0; + return list->gaps[index].lba; +} +uint32_t gapEntrySectors(GapList* list, uint32_t index) { + if (index >= list->gaps.size()) return 0; + return list->gaps[index].sectors; +} + PCSX::ISO9660Builder* createIsoBuilder(PCSX::LuaFFI::LuaFile* wrapper) { return new PCSX::ISO9660Builder(wrapper->file); } @@ -160,6 +213,12 @@ static void registerAllSymbols(PCSX::Lua L) { REGISTER(L, dirEntrySize); REGISTER(L, dirEntryIsDir); + REGISTER(L, readerFindGaps); + REGISTER(L, deleteGapList); + REGISTER(L, gapListCount); + REGISTER(L, gapEntryLBA); + REGISTER(L, gapEntrySectors); + REGISTER(L, createIsoBuilder); REGISTER(L, deleteIsoBuilder); REGISTER(L, isoBuilderWriteLicense); From f7e1c1e2ca5a6fa2399d8638e71500b3cbeb6649 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Wed, 15 Apr 2026 22:06:04 -0700 Subject: [PATCH 08/16] Account for ISO9660 system structures in flat view and gap finder The flat view and Lua gap finder now include ISO9660 system area entries (license sectors 0-15, volume descriptors, L/M path tables, root directory record) so these regions are no longer reported as gaps. The PVD is exposed via ISO9660Reader::getPVD() to read path table locations and sizes. Also adds a System entry type to the flat view display. Signed-off-by: Nicolas 'Pixel' Noble --- src/cdrom/iso9660-reader.h | 1 + src/core/luaiso.cc | 20 ++++++++++++++++++++ src/gui/widgets/isobrowser.cc | 29 +++++++++++++++++++++++++++++ src/gui/widgets/isobrowser.h | 2 +- 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/cdrom/iso9660-reader.h b/src/cdrom/iso9660-reader.h index 6b76dcc00..629ce6828 100644 --- a/src/cdrom/iso9660-reader.h +++ b/src/cdrom/iso9660-reader.h @@ -43,6 +43,7 @@ class ISO9660Reader { typedef std::pair FullDirEntry; std::vector listAllEntriesFrom(const ISO9660LowLevel::DirEntry& entry); const ISO9660LowLevel::DirEntry& getRootDirEntry() { return m_pvd.get(); } + const ISO9660LowLevel::PVD& getPVD() { return m_pvd; } private: std::shared_ptr m_iso; diff --git a/src/core/luaiso.cc b/src/core/luaiso.cc index 4c5ff562b..1ccfcd28d 100644 --- a/src/core/luaiso.cc +++ b/src/core/luaiso.cc @@ -138,6 +138,26 @@ static void collectAllEntries(PCSX::ISO9660Reader* reader, const PCSX::ISO9660Lo GapList* readerFindGaps(PCSX::ISO9660Reader* reader) { if (reader->failed()) return new GapList{}; std::vector> allFiles; + + // Account for ISO9660 system structures + allFiles.push_back({0, 16}); // License/system area + auto& pvd = reader->getPVD(); + uint32_t lPathLoc = pvd.get(); + allFiles.push_back({16, lPathLoc > 16 ? lPathLoc - 16 : 1}); // Volume descriptors + uint32_t pathTableSize = pvd.get(); + uint32_t pathTableSectors = (pathTableSize + 2047) / 2048; + allFiles.push_back({lPathLoc, pathTableSectors}); + uint32_t lPathOptLoc = pvd.get(); + if (lPathOptLoc != 0) allFiles.push_back({lPathOptLoc, pathTableSectors}); + uint32_t mPathLoc = pvd.get(); + allFiles.push_back({mPathLoc, pathTableSectors}); + uint32_t mPathOptLoc = pvd.get(); + if (mPathOptLoc != 0) allFiles.push_back({mPathOptLoc, pathTableSectors}); + auto& rootDir = reader->getRootDirEntry(); + uint32_t rootLBA = rootDir.get(); + uint32_t rootSize = rootDir.get(); + allFiles.push_back({rootLBA, (rootSize + 2047) / 2048}); + collectAllEntries(reader, reader->getRootDirEntry(), allFiles); std::sort(allFiles.begin(), allFiles.end()); diff --git a/src/gui/widgets/isobrowser.cc b/src/gui/widgets/isobrowser.cc index cdd39b35b..22ba29df0 100644 --- a/src/gui/widgets/isobrowser.cc +++ b/src/gui/widgets/isobrowser.cc @@ -208,6 +208,34 @@ void PCSX::Widgets::IsoBrowser::scanGapSectors(std::vector& out, uint void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { if (m_flatEntriesDirty) { m_flatEntries.clear(); + + // Add ISO9660 system structures + auto& pvd = m_reader->getPVD(); + m_flatEntries.push_back({_(""), 0, 16 * 2352, FlatEntry::System, {}}); + m_flatEntries.push_back({_(""), 16, 2048, FlatEntry::System, {}}); + // End volume descriptor at sector 17 (minimum), but path table location tells us where it ends + uint32_t lPathLoc = pvd.get(); + if (lPathLoc > 17) { + m_flatEntries.push_back({_(""), 17, (lPathLoc - 17) * 2048, FlatEntry::System, {}}); + } + uint32_t pathTableSize = pvd.get(); + uint32_t pathTableSectors = (pathTableSize + 2047) / 2048; + m_flatEntries.push_back({_(""), lPathLoc, pathTableSize, FlatEntry::System, {}}); + uint32_t lPathOptLoc = pvd.get(); + if (lPathOptLoc != 0) { + m_flatEntries.push_back({_(""), lPathOptLoc, pathTableSize, FlatEntry::System, {}}); + } + uint32_t mPathLoc = pvd.get(); + m_flatEntries.push_back({_(""), mPathLoc, pathTableSize, FlatEntry::System, {}}); + uint32_t mPathOptLoc = pvd.get(); + if (mPathOptLoc != 0) { + m_flatEntries.push_back({_(""), mPathOptLoc, pathTableSize, FlatEntry::System, {}}); + } + auto& rootDir = m_reader->getRootDirEntry(); + uint32_t rootLBA = rootDir.get(); + uint32_t rootSize = rootDir.get(); + m_flatEntries.push_back({_(""), rootLBA, rootSize, FlatEntry::System, {}}); + collectFlatEntries(m_reader->getRootDirEntry(), ""); std::sort(m_flatEntries.begin(), m_flatEntries.end(), [](const FlatEntry& a, const FlatEntry& b) { return a.lba < b.lba; }); @@ -294,6 +322,7 @@ headers and subheader file boundary markers.)")); case FlatEntry::HiddenM1: typeStr = _("M1"); break; case FlatEntry::HiddenM2F1: typeStr = _("M2F1"); break; case FlatEntry::HiddenM2F2: typeStr = _("M2F2"); break; + case FlatEntry::System: typeStr = _("System"); break; } ImGui::TextUnformatted(typeStr); } diff --git a/src/gui/widgets/isobrowser.h b/src/gui/widgets/isobrowser.h index 4caeb37a8..17c58216c 100644 --- a/src/gui/widgets/isobrowser.h +++ b/src/gui/widgets/isobrowser.h @@ -73,7 +73,7 @@ class IsoBrowser { bool m_flatView = false; struct FlatEntry { - enum Type { File, Directory, Gap, HiddenM1, HiddenM2F1, HiddenM2F2 }; + enum Type { File, Directory, Gap, HiddenM1, HiddenM2F1, HiddenM2F2, System }; std::string path; uint32_t lba; uint32_t size; From a093a9e3229e40701f687f4b06869f762406cf41 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Wed, 15 Apr 2026 22:11:05 -0700 Subject: [PATCH 09/16] Make filesystem tables fill available vertical space The tree and flat view tables now use the remaining window height instead of a fixed 300px, reserving space for the PPF buttons below. Signed-off-by: Nicolas 'Pixel' Noble --- src/gui/widgets/isobrowser.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gui/widgets/isobrowser.cc b/src/gui/widgets/isobrowser.cc index 22ba29df0..fc54c2901 100644 --- a/src/gui/widgets/isobrowser.cc +++ b/src/gui/widgets/isobrowser.cc @@ -284,7 +284,7 @@ headers and subheader file boundary markers.)")); if (ImGui::BeginTable("FilesystemFlat", 4, ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY, - ImVec2(0, 300))) { + ImVec2(0, ImGui::GetContentRegionAvail().y - ImGui::GetFrameHeightWithSpacing() - ImGui::GetStyle().ItemSpacing.y))) { ImGui::TableSetupColumn(_("Path"), ImGuiTableColumnFlags_NoHide); ImGui::TableSetupColumn(_("LBA"), ImGuiTableColumnFlags_WidthFixed, 80.0f); ImGui::TableSetupColumn(_("Size"), ImGuiTableColumnFlags_WidthFixed, 100.0f); @@ -567,7 +567,7 @@ significantly by caching the files beforehand.)")); } else { if (ImGui::BeginTable("Filesystem", 3, ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY, - ImVec2(0, 300))) { + ImVec2(0, ImGui::GetContentRegionAvail().y - ImGui::GetFrameHeightWithSpacing() - ImGui::GetStyle().ItemSpacing.y))) { ImGui::TableSetupColumn(_("Name"), ImGuiTableColumnFlags_NoHide); ImGui::TableSetupColumn(_("LBA"), ImGuiTableColumnFlags_WidthFixed, 80.0f); ImGui::TableSetupColumn(_("Size"), ImGuiTableColumnFlags_WidthFixed, 100.0f); From e081cc74c8a043b7b3a39ef06bb1a21665e94db3 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Wed, 15 Apr 2026 22:20:00 -0700 Subject: [PATCH 10/16] Match hex editor options with main memory editors The ISO hex editor now uses mono font, data preview panel, and lowercase hex display, matching the appearance of the main memory editors. Signed-off-by: Nicolas 'Pixel' Noble --- src/gui/gui.cc | 2 +- src/gui/widgets/isobrowser.h | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/gui/gui.cc b/src/gui/gui.cc index aa5cfea7d..73b38b1d4 100644 --- a/src/gui/gui.cc +++ b/src/gui/gui.cc @@ -178,7 +178,7 @@ PCSX::GUI::GUI(std::vector& favorites) m_openArchiveDialog(l_("Open Archive"), favorites), m_selectBiosDialog(l_("Select BIOS"), favorites), m_selectEXP1Dialog(l_("Select EXP1"), favorites), - m_isoBrowser(settings.get().value, favorites), + m_isoBrowser(settings.get().value, favorites, [this]() { useMonoFont(); }), m_pioCart(settings.get().value, favorites) { assert(g_gui == nullptr); g_gui = this; diff --git a/src/gui/widgets/isobrowser.h b/src/gui/widgets/isobrowser.h index 17c58216c..57728699c 100644 --- a/src/gui/widgets/isobrowser.h +++ b/src/gui/widgets/isobrowser.h @@ -42,11 +42,15 @@ namespace Widgets { class IsoBrowser { public: - IsoBrowser(bool& show, std::vector& favorites) + IsoBrowser(bool& show, std::vector& favorites, std::function monoFont = nullptr) : m_show(show), m_openIsoFileDialog(l_("Open Disk Image"), favorites), m_saveFileDialog(l_("Extract File"), favorites), - m_openReplaceFileDialog(l_("Replace File"), favorites) {} + m_openReplaceFileDialog(l_("Replace File"), favorites) { + m_hexEditor.OptShowDataPreview = true; + m_hexEditor.OptUpperCaseHex = false; + m_hexEditor.PushMonoFont = monoFont; + } void draw(CDRom* cdrom, const char* title); bool& m_show; From 77312ff5aaa80fee0ca3eb5b4f550c416cffaab1 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Wed, 15 Apr 2026 22:36:08 -0700 Subject: [PATCH 11/16] Move actions to context menu, support multiple hex editors Extract/Replace/Hex Edit are now in a right-click context menu on file entries in both tree and flat views, replacing the toolbar buttons. Hex editors use an intrusive list so multiple files can be open simultaneously. Each HexEditorInstance holds its own MemoryEditor, IO, and open state. When the user closes a hex editor window, the instance unlinks from the list and deletes itself on the next frame. Signed-off-by: Nicolas 'Pixel' Noble --- src/gui/widgets/isobrowser.cc | 103 ++++++++++++++++++++++------------ src/gui/widgets/isobrowser.h | 30 +++++++--- 2 files changed, 89 insertions(+), 44 deletions(-) diff --git a/src/gui/widgets/isobrowser.cc b/src/gui/widgets/isobrowser.cc index fc54c2901..39363f410 100644 --- a/src/gui/widgets/isobrowser.cc +++ b/src/gui/widgets/isobrowser.cc @@ -96,6 +96,29 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemTree(const ISO9660LowLevel::DirEnt m_selectedIsDir = false; m_selectedIsGap = false; } + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem(_("Extract"))) { + m_selectedPath = fullPath; + m_selectedLBA = lba; + m_selectedSize = size; + m_hasSelection = true; + m_saveFileDialog.openDialog(); + } + if (ImGui::MenuItem(_("Replace"))) { + m_selectedPath = fullPath; + m_selectedLBA = lba; + m_selectedSize = size; + m_hasSelection = true; + m_openReplaceFileDialog.openDialog(); + } + if (ImGui::MenuItem(_("Hex Edit"))) { + auto isoPtr = m_cachedIso.lock(); + if (isoPtr) { + openHexEditor(fullPath, IO(new CDRIsoFile(isoPtr, lba, size))); + } + } + ImGui::EndPopup(); + } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", lba); ImGui::TableSetColumnIndex(2); @@ -308,6 +331,29 @@ headers and subheader file boundary markers.)")); m_selectedIsDir = entry.isDir(); m_selectedIsGap = entry.isGap() || entry.isHidden(); } + if (!entry.isDir() && ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem(_("Extract"))) { + m_selectedPath = entry.path; + m_selectedLBA = entry.lba; + m_selectedSize = entry.size; + m_hasSelection = true; + m_saveFileDialog.openDialog(); + } + if (ImGui::MenuItem(_("Replace"))) { + m_selectedPath = entry.path; + m_selectedLBA = entry.lba; + m_selectedSize = entry.size; + m_hasSelection = true; + m_openReplaceFileDialog.openDialog(); + } + if (ImGui::MenuItem(_("Hex Edit"))) { + auto isoPtr = m_cachedIso.lock(); + if (isoPtr) { + openHexEditor(entry.path, IO(new CDRIsoFile(isoPtr, entry.lba, entry.size))); + } + } + ImGui::EndPopup(); + } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", entry.lba); ImGui::TableSetColumnIndex(2); @@ -463,35 +509,10 @@ significantly by caching the files beforehand.)")); if (m_reader && ImGui::CollapsingHeader(_("Filesystem"), ImGuiTreeNodeFlags_DefaultOpen)) { bool extracting = !m_extractionCoroutine.done(); - bool showSaveDialog = false; - bool showReplaceDialog = false; if (extracting) { ImGui::ProgressBar(m_extractionProgress); m_extractionCoroutine.resume(); - } else { - bool canExtractReplace = m_hasSelection && (!m_selectedIsDir || m_selectedIsGap); - if (!canExtractReplace) ImGui::BeginDisabled(); - showSaveDialog = ImGui::Button(_("Extract")); - ImGui::SameLine(); - showReplaceDialog = ImGui::Button(_("Replace")); - ImGui::SameLine(); - if (ImGui::Button(_("Hex Edit"))) { - auto isoPtr = m_cachedIso.lock(); - if (isoPtr) { - m_hexEditFile.setFile(new CDRIsoFile(isoPtr, m_selectedLBA, m_selectedSize)); - m_hexEditorOpen = true; - m_hexEditorOffset = 0; - } - } - if (!canExtractReplace) ImGui::EndDisabled(); - } - - if (showSaveDialog) { - m_saveFileDialog.openDialog(); - } - if (showReplaceDialog) { - m_openReplaceFileDialog.openDialog(); } // Handle extract dialog result @@ -590,19 +611,31 @@ significantly by caching the files beforehand.)")); ImGui::End(); - // Hex editor window (rendered outside the main ISO browser window) - if (m_hexEditorOpen && m_hexEditFile) { - auto size = m_hexEditFile->size(); - m_hexEditor.ReadFn = [this](size_t off) -> ImU8 { + // Render hex editor windows and clean up closed ones + for (auto it = m_hexEditors.begin(); it != m_hexEditors.end();) { + auto& inst = *it; + if (!inst.m_open) { + it = m_hexEditors.erase(it); + delete &inst; + continue; + } + auto size = inst.m_file->size(); + inst.m_editor.ReadFn = [&inst](size_t off) -> ImU8 { ImU8 b; - m_hexEditFile->readAt(&b, 1, off); + inst.m_file->readAt(&b, 1, off); return b; }; - m_hexEditor.WriteFn = [this](size_t off, ImU8 d) { m_hexEditFile->writeAt(&d, 1, off); }; - m_hexEditor.Cache.BulkReadFn = [this](void* dest, size_t off, size_t len) { - m_hexEditFile->readAt(dest, len, off); + inst.m_editor.WriteFn = [&inst](size_t off, ImU8 d) { inst.m_file->writeAt(&d, 1, off); }; + inst.m_editor.Cache.BulkReadFn = [&inst](void* dest, size_t off, size_t len) { + inst.m_file->readAt(dest, len, off); }; - auto title = fmt::format(f_("Hex Editor - {}"), m_selectedPath); - m_hexEditor.DrawWindow(title.c_str(), size); + inst.m_editor.DrawWindow(inst.m_title.c_str(), size); + ++it; } } + +void PCSX::Widgets::IsoBrowser::openHexEditor(const std::string& title, IO file) { + auto label = fmt::format(f_("Hex Editor - {}"), title); + auto* inst = new HexEditorInstance(label, file, m_monoFont); + m_hexEditors.push_back(inst); +} diff --git a/src/gui/widgets/isobrowser.h b/src/gui/widgets/isobrowser.h index 57728699c..f9c6c5d10 100644 --- a/src/gui/widgets/isobrowser.h +++ b/src/gui/widgets/isobrowser.h @@ -31,6 +31,7 @@ #include "imgui_memory_editor/imgui_memory_editor.h" #include "support/coroutine.h" #include "support/file.h" +#include "support/list.h" #include "supportpsx/iso9660-lowlevel.h" namespace PCSX { @@ -46,11 +47,9 @@ class IsoBrowser { : m_show(show), m_openIsoFileDialog(l_("Open Disk Image"), favorites), m_saveFileDialog(l_("Extract File"), favorites), - m_openReplaceFileDialog(l_("Replace File"), favorites) { - m_hexEditor.OptShowDataPreview = true; - m_hexEditor.OptUpperCaseHex = false; - m_hexEditor.PushMonoFont = monoFont; - } + m_openReplaceFileDialog(l_("Replace File"), favorites), + m_monoFont(monoFont) {} + ~IsoBrowser() { m_hexEditors.destroyAll(); } void draw(CDRom* cdrom, const char* title); bool& m_show; @@ -103,10 +102,23 @@ class IsoBrowser { FileDialog m_saveFileDialog; FileDialog<> m_openReplaceFileDialog; - bool m_hexEditorOpen = false; - size_t m_hexEditorOffset = 0; - MemoryEditor m_hexEditor{m_hexEditorOpen, 0, m_hexEditorOffset}; - IO m_hexEditFile; + struct HexEditorInstance : public Intrusive::List::Node { + HexEditorInstance(const std::string& title, IO file, std::function monoFont) + : m_title(title), m_file(file), m_editor(m_open, 0, m_offset) { + m_editor.OptShowDataPreview = true; + m_editor.OptUpperCaseHex = false; + m_editor.PushMonoFont = monoFont; + } + std::string m_title; + IO m_file; + bool m_open = true; + size_t m_offset = 0; + MemoryEditor m_editor; + }; + Intrusive::List m_hexEditors; + std::function m_monoFont; + + void openHexEditor(const std::string& title, IO file); }; } // namespace Widgets From d45e6835109da7852b3b1bb225ad87a5d9798fd5 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Sun, 19 Apr 2026 21:06:29 -0700 Subject: [PATCH 12/16] Address CodeRabbit PR 2013 review feedback - Lua dir filter now also skips empty names (ffi.string returns "" for a single NUL byte, so the ".","..'," check needed the empty case) - Gap/System/Hidden hex edit opens with explicit SectorMode so CDRIsoFile doesn't misdetect sector size (RAW for gap/system, M1/M2_FORM1/M2_FORM2 for hidden files based on their detected mode) - Sector span now uses XA attribute Form 2 bit (0x1000) to select 2324-byte sector size for Mode 2 Form 2 files, instead of assuming 2048 everywhere - readerListDir drops the redundant reader->open() check, verifies the target is a directory before returning children - File replace is now a coroutine like extract: yields every 50ms to keep UI responsive, warns when replacement is larger than target, zero-pads the tail when smaller so stale bytes don't leak through - ISO9660Reader constructor consolidates the PVD loop to also track the VD set terminator; new getVDEnd() returns the first sector past the terminator for accurate "Volume Descriptors" system region size - FlatEntry gains a sectors field so gap detection uses the actual on-disc footprint rather than recomputing from logical size Signed-off-by: Nicolas 'Pixel' Noble --- src/cdrom/iso9660-reader.cc | 24 +++++--- src/cdrom/iso9660-reader.h | 4 ++ src/core/isoffi.lua | 2 +- src/core/luaiso.cc | 27 +++++---- src/gui/widgets/isobrowser.cc | 102 ++++++++++++++++++++++++++-------- src/gui/widgets/isobrowser.h | 1 + 6 files changed, 116 insertions(+), 44 deletions(-) diff --git a/src/cdrom/iso9660-reader.cc b/src/cdrom/iso9660-reader.cc index 66607f356..7090be85f 100644 --- a/src/cdrom/iso9660-reader.cc +++ b/src/cdrom/iso9660-reader.cc @@ -25,9 +25,12 @@ PCSX::ISO9660Reader::ISO9660Reader(std::shared_ptr iso) : m_iso(iso) { unsigned pvdSector = 16; + bool foundPVD = false; + // Scan the full Volume Descriptor Set (LBA 16..terminator) to both find + // the PVD and record where the set actually ends. while (true) { - IO pvdFile(new CDRIsoFile(iso, pvdSector++, 2048)); + IO pvdFile(new CDRIsoFile(iso, pvdSector, 2048)); if (pvdFile->failed()) { m_failed = true; return; @@ -36,22 +39,25 @@ PCSX::ISO9660Reader::ISO9660Reader(std::shared_ptr iso) : m_iso(iso) { uint8_t vd[7]; pvdFile->readAt(vd, 7, 0); if ((vd[1] != 'C') || (vd[2] != 'D') || (vd[3] != '0') || (vd[4] != '0') || (vd[5] != '1') || (vd[6] != 1)) { - m_failed = true; + if (!foundPVD) m_failed = true; return; } if (vd[0] == 255) { - m_failed = true; + // Terminator: the VD set ends at the sector after this one. + m_vdEnd = pvdSector + 1; + if (!foundPVD) m_failed = true; return; } - if (vd[0] != 1) continue; - - ISO9660LowLevel::PVD pvd; - pvd.deserialize(pvdFile); + if (vd[0] == 1 && !foundPVD) { + ISO9660LowLevel::PVD pvd; + pvd.deserialize(pvdFile); + m_pvd = pvd; + foundPVD = true; + } - m_pvd = pvd; - break; + pvdSector++; } } diff --git a/src/cdrom/iso9660-reader.h b/src/cdrom/iso9660-reader.h index 629ce6828..258473341 100644 --- a/src/cdrom/iso9660-reader.h +++ b/src/cdrom/iso9660-reader.h @@ -44,10 +44,14 @@ class ISO9660Reader { std::vector listAllEntriesFrom(const ISO9660LowLevel::DirEntry& entry); const ISO9660LowLevel::DirEntry& getRootDirEntry() { return m_pvd.get(); } const ISO9660LowLevel::PVD& getPVD() { return m_pvd; } + // Returns the LBA just past the end of the volume descriptor set, i.e. the + // first sector after the VD terminator (type 255). Returns 17 if not found. + uint32_t getVDEnd() { return m_vdEnd; } private: std::shared_ptr m_iso; bool m_failed = false; + uint32_t m_vdEnd = 17; std::optional findEntry(const std::string_view& filename); ISO9660LowLevel::PVD m_pvd; diff --git a/src/core/isoffi.lua b/src/core/isoffi.lua index 7767d5353..298f59c0f 100644 --- a/src/core/isoffi.lua +++ b/src/core/isoffi.lua @@ -93,7 +93,7 @@ local function createIsoReaderWrapper(isoReader) local result = {} for i = 0, count - 1 do local name = ffi.string(C.dirEntryName(entries, i)) - if name ~= '\0' and name ~= '\1' then + if name ~= '' and name ~= '\0' and name ~= '\1' then table.insert(result, { name = name, lba = C.dirEntryLBA(entries, i), diff --git a/src/core/luaiso.cc b/src/core/luaiso.cc index 1ccfcd28d..96114d98f 100644 --- a/src/core/luaiso.cc +++ b/src/core/luaiso.cc @@ -67,14 +67,9 @@ DirEntries* readerListDir(PCSX::ISO9660Reader* reader, const char* path) { if (path == nullptr || path[0] == '\0') { return new DirEntries{reader->listAllEntriesFrom(root)}; } - auto* file = reader->open(path); - if (file->failed()) { - delete file; - return new DirEntries{}; - } - delete file; - // Need to find the directory entry for the given path to list its contents. - // Walk the path manually using listAllEntriesFrom. + // Walk the path using listAllEntriesFrom. ISO9660 directory entries + // don't carry version suffixes (only files do), so exact match on each + // path component is correct for directory listing. auto parts = PCSX::StringsHelpers::split(std::string_view(path), "/"); PCSX::ISO9660LowLevel::DirEntry current = root; for (auto& part : parts) { @@ -89,6 +84,10 @@ DirEntries* readerListDir(PCSX::ISO9660Reader* reader, const char* path) { } if (!found) return new DirEntries{}; } + // Only return children if the target is actually a directory. + if ((current.get().value & 2) == 0) { + return new DirEntries{}; + } return new DirEntries{reader->listAllEntriesFrom(current)}; } @@ -120,6 +119,11 @@ struct GapList { std::vector gaps; }; +// XA attribute bits (CD-XA spec, stored big-endian in directory record). +// Bit 11 (0x0800): Mode 2 Form 1 data file +// Bit 12 (0x1000): Mode 2 Form 2 interleaved audio/video +static constexpr uint16_t XA_ATTR_FORM2 = 0x1000; + static void collectAllEntries(PCSX::ISO9660Reader* reader, const PCSX::ISO9660LowLevel::DirEntry& dir, std::vector>& out) { auto entries = reader->listAllEntriesFrom(dir); @@ -128,7 +132,9 @@ static void collectAllEntries(PCSX::ISO9660Reader* reader, const PCSX::ISO9660Lo if (name.size() == 1 && (name[0] == '\0' || name[0] == '\1')) continue; uint32_t lba = entry.get(); uint32_t size = entry.get(); - uint32_t sectors = (size + 2047) / 2048; + uint16_t attribs = xa.get(); + uint32_t sectorSize = (attribs & XA_ATTR_FORM2) ? 2324 : 2048; + uint32_t sectors = (size + sectorSize - 1) / sectorSize; out.push_back({lba, sectors}); bool isDir = (entry.get().value & 2) != 0; if (isDir) collectAllEntries(reader, entry, out); @@ -142,8 +148,9 @@ GapList* readerFindGaps(PCSX::ISO9660Reader* reader) { // Account for ISO9660 system structures allFiles.push_back({0, 16}); // License/system area auto& pvd = reader->getPVD(); + uint32_t vdEnd = reader->getVDEnd(); + allFiles.push_back({16, vdEnd > 16 ? vdEnd - 16 : 1}); // Volume descriptors including terminator uint32_t lPathLoc = pvd.get(); - allFiles.push_back({16, lPathLoc > 16 ? lPathLoc - 16 : 1}); // Volume descriptors uint32_t pathTableSize = pvd.get(); uint32_t pathTableSectors = (pathTableSize + 2047) / 2048; allFiles.push_back({lPathLoc, pathTableSectors}); diff --git a/src/gui/widgets/isobrowser.cc b/src/gui/widgets/isobrowser.cc index 39363f410..619b31275 100644 --- a/src/gui/widgets/isobrowser.cc +++ b/src/gui/widgets/isobrowser.cc @@ -139,7 +139,12 @@ void PCSX::Widgets::IsoBrowser::collectFlatEntries(const ISO9660LowLevel::DirEnt uint32_t size = dirEntry.get(); auto fullPath = path.empty() ? filename : path + "/" + filename; - m_flatEntries.push_back({fullPath, lba, size, isDir ? FlatEntry::Directory : FlatEntry::File, dirEntry}); + // Form 2 files use 2324-byte data sectors; everything else logical uses 2048. + uint16_t xaAttribs = xa.get(); + uint32_t sectorSize = (xaAttribs & 0x1000) ? 2324 : 2048; + uint32_t sectors = (size + sectorSize - 1) / sectorSize; + m_flatEntries.push_back( + {fullPath, lba, size, sectors, isDir ? FlatEntry::Directory : FlatEntry::File, dirEntry}); if (isDir) collectFlatEntries(dirEntry, fullPath); } } @@ -155,7 +160,7 @@ void PCSX::Widgets::IsoBrowser::scanGapSectors(std::vector& out, uint // Read failed, treat rest as gap uint32_t remaining = end - lba; auto label = fmt::format(f_(""), remaining); - out.push_back({label, lba, remaining * 2352, FlatEntry::Gap, {}}); + out.push_back({label, lba, remaining * 2352, remaining, FlatEntry::Gap, {}}); break; } @@ -173,7 +178,7 @@ void PCSX::Widgets::IsoBrowser::scanGapSectors(std::vector& out, uint } uint32_t count = lba - gapStart; auto label = fmt::format(f_(""), count); - out.push_back({label, gapStart, count * 2352, FlatEntry::Gap, {}}); + out.push_back({label, gapStart, count * 2352, count, FlatEntry::Gap, {}}); continue; } @@ -188,7 +193,7 @@ void PCSX::Widgets::IsoBrowser::scanGapSectors(std::vector& out, uint } uint32_t count = lba - fileStart; auto label = fmt::format(f_(""), count); - out.push_back({label, fileStart, count * 2048, FlatEntry::HiddenM1, {}}); + out.push_back({label, fileStart, count * 2048, count, FlatEntry::HiddenM1, {}}); continue; } @@ -219,7 +224,7 @@ void PCSX::Widgets::IsoBrowser::scanGapSectors(std::vector& out, uint uint32_t dataSize = isForm2 ? count * 2324 : count * 2048; auto label = fmt::format(f_(""), isForm2 ? "M2F2" : "M2F1", fileNum, channelNum, count); - out.push_back({label, fileStart, dataSize, type, {}}); + out.push_back({label, fileStart, dataSize, count, type, {}}); continue; } @@ -234,30 +239,36 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { // Add ISO9660 system structures auto& pvd = m_reader->getPVD(); - m_flatEntries.push_back({_(""), 0, 16 * 2352, FlatEntry::System, {}}); - m_flatEntries.push_back({_(""), 16, 2048, FlatEntry::System, {}}); - // End volume descriptor at sector 17 (minimum), but path table location tells us where it ends + uint32_t vdEnd = m_reader->getVDEnd(); + m_flatEntries.push_back({_(""), 0, 16 * 2352, 16, FlatEntry::System, {}}); + // Volume descriptor set spans from LBA 16 up to (but not including) vdEnd, + // including the PVD, any SVDs, and the terminator. + uint32_t vdSectors = vdEnd > 16 ? vdEnd - 16 : 1; + m_flatEntries.push_back( + {_(""), 16, vdSectors * 2048, vdSectors, FlatEntry::System, {}}); uint32_t lPathLoc = pvd.get(); - if (lPathLoc > 17) { - m_flatEntries.push_back({_(""), 17, (lPathLoc - 17) * 2048, FlatEntry::System, {}}); - } uint32_t pathTableSize = pvd.get(); uint32_t pathTableSectors = (pathTableSize + 2047) / 2048; - m_flatEntries.push_back({_(""), lPathLoc, pathTableSize, FlatEntry::System, {}}); + m_flatEntries.push_back( + {_(""), lPathLoc, pathTableSize, pathTableSectors, FlatEntry::System, {}}); uint32_t lPathOptLoc = pvd.get(); if (lPathOptLoc != 0) { - m_flatEntries.push_back({_(""), lPathOptLoc, pathTableSize, FlatEntry::System, {}}); + m_flatEntries.push_back( + {_(""), lPathOptLoc, pathTableSize, pathTableSectors, FlatEntry::System, {}}); } uint32_t mPathLoc = pvd.get(); - m_flatEntries.push_back({_(""), mPathLoc, pathTableSize, FlatEntry::System, {}}); + m_flatEntries.push_back( + {_(""), mPathLoc, pathTableSize, pathTableSectors, FlatEntry::System, {}}); uint32_t mPathOptLoc = pvd.get(); if (mPathOptLoc != 0) { - m_flatEntries.push_back({_(""), mPathOptLoc, pathTableSize, FlatEntry::System, {}}); + m_flatEntries.push_back( + {_(""), mPathOptLoc, pathTableSize, pathTableSectors, FlatEntry::System, {}}); } auto& rootDir = m_reader->getRootDirEntry(); uint32_t rootLBA = rootDir.get(); uint32_t rootSize = rootDir.get(); - m_flatEntries.push_back({_(""), rootLBA, rootSize, FlatEntry::System, {}}); + uint32_t rootSectors = (rootSize + 2047) / 2048; + m_flatEntries.push_back({_(""), rootLBA, rootSize, rootSectors, FlatEntry::System, {}}); collectFlatEntries(m_reader->getRootDirEntry(), ""); std::sort(m_flatEntries.begin(), m_flatEntries.end(), @@ -270,11 +281,10 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { if (entry.lba > nextExpected) { uint32_t gapSectors = entry.lba - nextExpected; auto label = fmt::format(f_(""), gapSectors); - withGaps.push_back({label, nextExpected, gapSectors * 2352, FlatEntry::Gap, {}}); + withGaps.push_back({label, nextExpected, gapSectors * 2352, gapSectors, FlatEntry::Gap, {}}); } withGaps.push_back(entry); - uint32_t sectors = (entry.size + 2047) / 2048; - uint32_t end = entry.lba + sectors; + uint32_t end = entry.lba + entry.sectors; if (end > nextExpected) nextExpected = end; } m_flatEntries = std::move(withGaps); @@ -349,7 +359,16 @@ headers and subheader file boundary markers.)")); if (ImGui::MenuItem(_("Hex Edit"))) { auto isoPtr = m_cachedIso.lock(); if (isoPtr) { - openHexEditor(entry.path, IO(new CDRIsoFile(isoPtr, entry.lba, entry.size))); + IEC60908b::SectorMode mode = IEC60908b::SectorMode::GUESS; + switch (entry.type) { + case FlatEntry::Gap: + case FlatEntry::System: mode = IEC60908b::SectorMode::RAW; break; + case FlatEntry::HiddenM1: mode = IEC60908b::SectorMode::M1; break; + case FlatEntry::HiddenM2F1: mode = IEC60908b::SectorMode::M2_FORM1; break; + case FlatEntry::HiddenM2F2: mode = IEC60908b::SectorMode::M2_FORM2; break; + default: break; + } + openHexEditor(entry.path, IO(new CDRIsoFile(isoPtr, entry.lba, entry.size, mode))); } } ImGui::EndPopup(); @@ -563,20 +582,55 @@ significantly by caching the files beforehand.)")); uint32_t originalSize = m_selectedSize; auto isoPtr = m_cachedIso.lock(); if (isoPtr) { - IO replacement(new UvFile(srcPath)); - if (!replacement->failed()) { - IO isoFile(new CDRIsoFile(isoPtr, lba, originalSize)); + m_extractionProgress = 0.0f; + m_extractionCoroutine = [](IsoBrowser* self, std::shared_ptr iso, uint32_t lba, + uint32_t originalSize, std::string src) -> Coroutine<> { + auto time = std::chrono::steady_clock::now(); + IO replacement(new UvFile(src)); + if (replacement->failed()) co_return; + IO isoFile(new CDRIsoFile(iso, lba, originalSize)); uint32_t replaceSize = std::min((uint32_t)replacement->size(), originalSize); + if (replacement->size() > originalSize) { + // Replacement too large; truncated to original size. + g_system->printf( + _("ISO replace: replacement file is larger than target (%zu > %u). Truncating.\n"), + replacement->size(), originalSize); + } uint8_t buffer[2048]; uint32_t remaining = replaceSize; + uint32_t written = 0; while (remaining > 0) { uint32_t chunk = std::min(remaining, (uint32_t)sizeof(buffer)); auto read = replacement->read(buffer, chunk); if (read <= 0) break; isoFile->write(buffer, read); remaining -= read; + written += read; + if (std::chrono::steady_clock::now() - time > std::chrono::milliseconds(50)) { + self->m_extractionProgress = (float)written / (float)originalSize; + co_yield self->m_extractionCoroutine.awaiter(); + time = std::chrono::steady_clock::now(); + } } - } + // Zero-pad the tail if the replacement was smaller than the original, + // so stale bytes from the original don't leak through. + if (written < originalSize) { + uint8_t zeros[2048] = {0}; + uint32_t padRemaining = originalSize - written; + while (padRemaining > 0) { + uint32_t chunk = std::min(padRemaining, (uint32_t)sizeof(zeros)); + isoFile->write(zeros, chunk); + padRemaining -= chunk; + written += chunk; + if (std::chrono::steady_clock::now() - time > std::chrono::milliseconds(50)) { + self->m_extractionProgress = (float)written / (float)originalSize; + co_yield self->m_extractionCoroutine.awaiter(); + time = std::chrono::steady_clock::now(); + } + } + } + self->m_extractionProgress = 1.0f; + }(this, isoPtr, lba, originalSize, srcPath); } } } diff --git a/src/gui/widgets/isobrowser.h b/src/gui/widgets/isobrowser.h index f9c6c5d10..8a8cd14ab 100644 --- a/src/gui/widgets/isobrowser.h +++ b/src/gui/widgets/isobrowser.h @@ -80,6 +80,7 @@ class IsoBrowser { std::string path; uint32_t lba; uint32_t size; + uint32_t sectors; // Actual sector span on disc, derived from XA form for Form 2 files. Type type; ISO9660LowLevel::DirEntry dirEntry; From a9c3bee6fd705cf706bbdc72979fc4ec9264cdcb Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Sun, 19 Apr 2026 21:36:14 -0700 Subject: [PATCH 13/16] Address CodeRabbit second review round on PR 2013 - ISO9660Reader: malformed descriptor after the PVD now fails the reader instead of returning early with a stale m_vdEnd default. - readerListDir: guard against failed readers up front, matching readerFindGaps. - readerFindGaps: emit a trailing gap from the last occupied extent to the end of the disc (PVD VolumeSpaceSize) so appended/orphaned content is visible to Lua callers. - Flat view: same trailing-gap addition so the GUI doesn't hide content past the last known file. - Flat view selection highlight: check isGap() || isHidden() to match how m_selectedIsGap is set, so M1/M2F1/M2F2 rows keep their selected state. - Gap scanning: moved to a Coroutine<> with progress bar; yields every 50ms so large images or trailing gaps no longer freeze the UI. The scan coroutine is reset on ISO change. Signed-off-by: Nicolas 'Pixel' Noble --- src/cdrom/iso9660-reader.cc | 4 +- src/core/luaiso.cc | 6 + src/gui/widgets/isobrowser.cc | 209 ++++++++++++++++++++-------------- src/gui/widgets/isobrowser.h | 5 +- 4 files changed, 138 insertions(+), 86 deletions(-) diff --git a/src/cdrom/iso9660-reader.cc b/src/cdrom/iso9660-reader.cc index 7090be85f..18dc5b7c3 100644 --- a/src/cdrom/iso9660-reader.cc +++ b/src/cdrom/iso9660-reader.cc @@ -39,7 +39,9 @@ PCSX::ISO9660Reader::ISO9660Reader(std::shared_ptr iso) : m_iso(iso) { uint8_t vd[7]; pvdFile->readAt(vd, 7, 0); if ((vd[1] != 'C') || (vd[2] != 'D') || (vd[3] != '0') || (vd[4] != '0') || (vd[5] != '1') || (vd[6] != 1)) { - if (!foundPVD) m_failed = true; + // Malformed descriptor set: even if we already have the PVD, the + // set is broken so we can't trust m_vdEnd. + m_failed = true; return; } diff --git a/src/core/luaiso.cc b/src/core/luaiso.cc index 96114d98f..36399cb94 100644 --- a/src/core/luaiso.cc +++ b/src/core/luaiso.cc @@ -63,6 +63,7 @@ struct DirEntries { }; DirEntries* readerListDir(PCSX::ISO9660Reader* reader, const char* path) { + if (reader->failed()) return new DirEntries{}; auto root = reader->getRootDirEntry(); if (path == nullptr || path[0] == '\0') { return new DirEntries{reader->listAllEntriesFrom(root)}; @@ -177,6 +178,11 @@ GapList* readerFindGaps(PCSX::ISO9660Reader* reader) { uint32_t end = lba + sectors; if (end > nextExpected) nextExpected = end; } + // Trailing gap: anything between the last occupied extent and the disc end. + uint32_t volumeSpaceSize = pvd.get(); + if (volumeSpaceSize > nextExpected) { + result->gaps.push_back({nextExpected, volumeSpaceSize - nextExpected}); + } return result; } diff --git a/src/gui/widgets/isobrowser.cc b/src/gui/widgets/isobrowser.cc index 619b31275..36a1f2d9a 100644 --- a/src/gui/widgets/isobrowser.cc +++ b/src/gui/widgets/isobrowser.cc @@ -149,88 +149,127 @@ void PCSX::Widgets::IsoBrowser::collectFlatEntries(const ISO9660LowLevel::DirEnt } } -void PCSX::Widgets::IsoBrowser::scanGapSectors(std::vector& out, uint32_t startLBA, uint32_t sectorCount, - std::shared_ptr iso) { - uint32_t lba = startLBA; - uint32_t end = startLBA + sectorCount; - - while (lba < end) { - uint8_t sector[2352]; - if (iso->readSectors(lba, sector, 1) != 1) { - // Read failed, treat rest as gap - uint32_t remaining = end - lba; - auto label = fmt::format(f_(""), remaining); - out.push_back({label, lba, remaining * 2352, remaining, FlatEntry::Gap, {}}); - break; +PCSX::Coroutine<> PCSX::Widgets::IsoBrowser::scanAllGaps(std::shared_ptr iso) { + auto time = std::chrono::steady_clock::now(); + std::vector scanned; + + // Count total gap sectors to scan for progress reporting. + uint32_t totalGapSectors = 0; + for (auto& entry : m_flatEntries) { + if (entry.isGap()) totalGapSectors += entry.sectors; + } + uint32_t scannedSectors = 0; + + for (auto& entry : m_flatEntries) { + if (!entry.isGap()) { + scanned.push_back(entry); + continue; } - uint8_t mode = sector[15]; + uint32_t lba = entry.lba; + uint32_t end = entry.lba + entry.sectors; + + while (lba < end) { + uint8_t sector[2352]; + if (iso->readSectors(lba, sector, 1) != 1) { + uint32_t remaining = end - lba; + auto label = fmt::format(f_(""), remaining); + scanned.push_back({label, lba, remaining * 2352, remaining, FlatEntry::Gap, {}}); + scannedSectors += remaining; + break; + } - if (mode == 0) { - // Mode 0: true gap. Accumulate consecutive mode 0 sectors. - uint32_t gapStart = lba; - while (lba < end) { - if (lba != gapStart) { - if (iso->readSectors(lba, sector, 1) != 1) break; - if (sector[15] != 0) break; + uint8_t mode = sector[15]; + + if (mode == 0) { + uint32_t gapStart = lba; + while (lba < end) { + if (lba != gapStart) { + if (iso->readSectors(lba, sector, 1) != 1) break; + if (sector[15] != 0) break; + } + lba++; + scannedSectors++; + if (std::chrono::steady_clock::now() - time > std::chrono::milliseconds(50)) { + m_gapScanProgress = + totalGapSectors > 0 ? (float)scannedSectors / (float)totalGapSectors : 1.0f; + co_yield m_gapScanCoroutine.awaiter(); + time = std::chrono::steady_clock::now(); + } } - lba++; + uint32_t count = lba - gapStart; + auto label = fmt::format(f_(""), count); + scanned.push_back({label, gapStart, count * 2352, count, FlatEntry::Gap, {}}); + continue; } - uint32_t count = lba - gapStart; - auto label = fmt::format(f_(""), count); - out.push_back({label, gapStart, count * 2352, count, FlatEntry::Gap, {}}); - continue; - } - if (mode == 1) { - // Mode 1 hidden data. Accumulate until mode changes or gap ends. - uint32_t fileStart = lba; - lba++; - while (lba < end) { - if (iso->readSectors(lba, sector, 1) != 1) break; - if (sector[15] != 1) break; + if (mode == 1) { + uint32_t fileStart = lba; lba++; + scannedSectors++; + while (lba < end) { + if (iso->readSectors(lba, sector, 1) != 1) break; + if (sector[15] != 1) break; + lba++; + scannedSectors++; + if (std::chrono::steady_clock::now() - time > std::chrono::milliseconds(50)) { + m_gapScanProgress = + totalGapSectors > 0 ? (float)scannedSectors / (float)totalGapSectors : 1.0f; + co_yield m_gapScanCoroutine.awaiter(); + time = std::chrono::steady_clock::now(); + } + } + uint32_t count = lba - fileStart; + auto label = fmt::format(f_(""), count); + scanned.push_back({label, fileStart, count * 2048, count, FlatEntry::HiddenM1, {}}); + continue; } - uint32_t count = lba - fileStart; - auto label = fmt::format(f_(""), count); - out.push_back({label, fileStart, count * 2048, count, FlatEntry::HiddenM1, {}}); - continue; - } - if (mode == 2) { - // Mode 2: parse subheader for form and file boundaries - uint8_t* sub = sector + 16; - uint8_t fileNum = sub[0]; - uint8_t channelNum = sub[1]; - uint8_t submode = sub[2]; - bool isForm2 = (submode & 0x20) != 0; - auto type = isForm2 ? FlatEntry::HiddenM2F2 : FlatEntry::HiddenM2F1; - - uint32_t fileStart = lba; - bool hitEof = (submode & 0x80) != 0; - lba++; + if (mode == 2) { + uint8_t* sub = sector + 16; + uint8_t fileNum = sub[0]; + uint8_t channelNum = sub[1]; + uint8_t submode = sub[2]; + bool isForm2 = (submode & 0x20) != 0; + auto type = isForm2 ? FlatEntry::HiddenM2F2 : FlatEntry::HiddenM2F1; - while (lba < end && !hitEof) { - if (iso->readSectors(lba, sector, 1) != 1) break; - if (sector[15] != 2) break; - sub = sector + 16; - // Different file/channel = new subfile - if (sub[0] != fileNum || sub[1] != channelNum) break; - hitEof = (sub[2] & 0x80) != 0; + uint32_t fileStart = lba; + bool hitEof = (submode & 0x80) != 0; lba++; + scannedSectors++; + + while (lba < end && !hitEof) { + if (iso->readSectors(lba, sector, 1) != 1) break; + if (sector[15] != 2) break; + sub = sector + 16; + if (sub[0] != fileNum || sub[1] != channelNum) break; + hitEof = (sub[2] & 0x80) != 0; + lba++; + scannedSectors++; + if (std::chrono::steady_clock::now() - time > std::chrono::milliseconds(50)) { + m_gapScanProgress = + totalGapSectors > 0 ? (float)scannedSectors / (float)totalGapSectors : 1.0f; + co_yield m_gapScanCoroutine.awaiter(); + time = std::chrono::steady_clock::now(); + } + } + + uint32_t count = lba - fileStart; + uint32_t dataSize = isForm2 ? count * 2324 : count * 2048; + auto label = fmt::format(f_(""), + isForm2 ? "M2F2" : "M2F1", fileNum, channelNum, count); + scanned.push_back({label, fileStart, dataSize, count, type, {}}); + continue; } - uint32_t count = lba - fileStart; - uint32_t dataSize = isForm2 ? count * 2324 : count * 2048; - auto label = fmt::format(f_(""), - isForm2 ? "M2F2" : "M2F1", fileNum, channelNum, count); - out.push_back({label, fileStart, dataSize, count, type, {}}); - continue; + lba++; + scannedSectors++; } - - // Unknown mode, skip sector - lba++; } + + m_flatEntries = std::move(scanned); + m_gapsScanned = true; + m_gapScanProgress = 1.0f; } void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { @@ -287,32 +326,35 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { uint32_t end = entry.lba + entry.sectors; if (end > nextExpected) nextExpected = end; } + // Trailing gap: append any unreferenced sectors at the end of the image. + uint32_t discEnd = pvd.get(); + if (discEnd > nextExpected) { + uint32_t gapSectors = discEnd - nextExpected; + auto label = fmt::format(f_(""), gapSectors); + withGaps.push_back({label, nextExpected, gapSectors * 2352, gapSectors, FlatEntry::Gap, {}}); + } m_flatEntries = std::move(withGaps); m_flatEntriesDirty = false; m_gapsScanned = false; } if (!m_gapsScanned) { - if (ImGui::Button(_("Scan gaps for hidden files"))) { - auto iso = m_cachedIso.lock(); - if (iso) { - std::vector scanned; - for (auto& entry : m_flatEntries) { - if (entry.isGap()) { - uint32_t sectorCount = entry.size / 2352; - scanGapSectors(scanned, entry.lba, sectorCount, iso); - } else { - scanned.push_back(entry); - } + if (m_gapScanCoroutine.done()) { + if (ImGui::Button(_("Scan gaps for hidden files"))) { + auto iso = m_cachedIso.lock(); + if (iso) { + m_gapScanProgress = 0.0f; + m_gapScanCoroutine = scanAllGaps(iso); } - m_flatEntries = std::move(scanned); - m_gapsScanned = true; } - } - ImGuiHelpers::ShowHelpMarker(_(R"(Reads sector headers in gap regions to detect + ImGuiHelpers::ShowHelpMarker(_(R"(Reads sector headers in gap regions to detect hidden files that were removed from the ISO9660 directory but still have intact Mode 1/2 sector headers and subheader file boundary markers.)")); + } else { + ImGui::ProgressBar(m_gapScanProgress); + m_gapScanCoroutine.resume(); + } } if (ImGui::BeginTable("FilesystemFlat", 4, @@ -329,7 +371,7 @@ headers and subheader file boundary markers.)")); ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); bool selected = m_hasSelection && m_selectedLBA == entry.lba && - m_selectedIsGap == entry.isGap(); + m_selectedIsGap == (entry.isGap() || entry.isHidden()); auto id = fmt::format("{}##{}", entry.path, i); if (ImGui::Selectable(id.c_str(), selected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { @@ -520,6 +562,7 @@ significantly by caching the files beforehand.)")); m_hasSelection = false; m_selectedPath.clear(); m_flatEntriesDirty = true; + m_gapScanCoroutine = {}; if (currentIso && !currentIso->failed()) { m_reader = std::make_unique(currentIso); if (m_reader->failed()) m_reader.reset(); diff --git a/src/gui/widgets/isobrowser.h b/src/gui/widgets/isobrowser.h index 8a8cd14ab..0bcf03d19 100644 --- a/src/gui/widgets/isobrowser.h +++ b/src/gui/widgets/isobrowser.h @@ -92,12 +92,13 @@ class IsoBrowser { std::vector m_flatEntries; bool m_flatEntriesDirty = true; bool m_gapsScanned = false; + Coroutine<> m_gapScanCoroutine; + float m_gapScanProgress = 0.0f; void drawFilesystemTree(const ISO9660LowLevel::DirEntry& entry, const std::string& path); void drawFilesystemFlat(); void collectFlatEntries(const ISO9660LowLevel::DirEntry& entry, const std::string& path); - void scanGapSectors(std::vector& out, uint32_t startLBA, uint32_t sectorCount, - std::shared_ptr iso); + Coroutine<> scanAllGaps(std::shared_ptr iso); FileDialog<> m_openIsoFileDialog; FileDialog m_saveFileDialog; From e97564b125e8d923ce2ccbaeeba663cfe02d2e5f Mon Sep 17 00:00:00 2001 From: "Nicolas \"Pixel\" Noble" Date: Sun, 19 Apr 2026 21:45:14 -0700 Subject: [PATCH 14/16] Proper include. --- src/core/luaiso.cc | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/luaiso.cc b/src/core/luaiso.cc index 36399cb94..39c73259a 100644 --- a/src/core/luaiso.cc +++ b/src/core/luaiso.cc @@ -19,6 +19,7 @@ #include "core/luaiso.h" +#include #include #include "cdrom/cdriso.h" From 3113760cb88015c61aeee62db650aed089d35f4a Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Sun, 19 Apr 2026 22:03:13 -0700 Subject: [PATCH 15/16] Address CodeRabbit third review round on PR 2013 - Flat view trailing gap: bound by iso->getTD(0).toLBA() (actual loaded image end) instead of PVD_VolumeSpaceSize (logical volume length). - Extract/Replace now persist the sector mode alongside the selection and pass it into CDRIsoFile, matching Hex Edit; gaps/system use RAW, hidden files use their detected M1/M2_FORM1/M2_FORM2 mode. - Replace coroutine: aborts on I/O errors with a printed message instead of silently zero-padding over read/write failures. Also clamps replacement->size() without truncating through uint32_t. - Gap scan: unrecognized sector modes now accumulate into a gap entry (instead of silently dropping bytes) and the scan yields periodically so long runs don't freeze the UI. - readerListDir: the C++ side now strips "." / ".." sentinel entries so Lua callers never see them, regardless of how LuaJIT's ffi.string happens to encode the single-NUL filename. - collectAllEntries (Lua) and collectFlatEntries (GUI) now carry a visited-LBA set to guard against directory cycles in malformed ISOs. Zero-length extents are also skipped in the Lua side so they don't confuse gap aggregation. Signed-off-by: Nicolas 'Pixel' Noble --- src/core/isoffi.lua | 14 ++-- src/core/luaiso.cc | 34 +++++++-- src/gui/widgets/isobrowser.cc | 130 +++++++++++++++++++++++++--------- src/gui/widgets/isobrowser.h | 6 +- 4 files changed, 135 insertions(+), 49 deletions(-) diff --git a/src/core/isoffi.lua b/src/core/isoffi.lua index 298f59c0f..62dbfd673 100644 --- a/src/core/isoffi.lua +++ b/src/core/isoffi.lua @@ -93,14 +93,12 @@ local function createIsoReaderWrapper(isoReader) local result = {} for i = 0, count - 1 do local name = ffi.string(C.dirEntryName(entries, i)) - if name ~= '' and name ~= '\0' and name ~= '\1' then - table.insert(result, { - name = name, - lba = C.dirEntryLBA(entries, i), - size = C.dirEntrySize(entries, i), - isDir = C.dirEntryIsDir(entries, i), - }) - end + table.insert(result, { + name = name, + lba = C.dirEntryLBA(entries, i), + size = C.dirEntrySize(entries, i), + isDir = C.dirEntryIsDir(entries, i), + }) end return result end, diff --git a/src/core/luaiso.cc b/src/core/luaiso.cc index 39c73259a..3e92147d4 100644 --- a/src/core/luaiso.cc +++ b/src/core/luaiso.cc @@ -21,6 +21,7 @@ #include #include +#include #include "cdrom/cdriso.h" #include "cdrom/file.h" @@ -63,11 +64,24 @@ struct DirEntries { std::vector entries; }; +// Drop ISO9660 "." (\0) and ".." (\1) sentinel entries from a listing. +static std::vector stripSelfParent( + std::vector&& entries) { + std::vector out; + out.reserve(entries.size()); + for (auto& e : entries) { + const auto& name = e.first.get().value; + if (name.size() == 1 && (name[0] == '\0' || name[0] == '\1')) continue; + out.push_back(std::move(e)); + } + return out; +} + DirEntries* readerListDir(PCSX::ISO9660Reader* reader, const char* path) { if (reader->failed()) return new DirEntries{}; auto root = reader->getRootDirEntry(); if (path == nullptr || path[0] == '\0') { - return new DirEntries{reader->listAllEntriesFrom(root)}; + return new DirEntries{stripSelfParent(reader->listAllEntriesFrom(root))}; } // Walk the path using listAllEntriesFrom. ISO9660 directory entries // don't carry version suffixes (only files do), so exact match on each @@ -90,7 +104,7 @@ DirEntries* readerListDir(PCSX::ISO9660Reader* reader, const char* path) { if ((current.get().value & 2) == 0) { return new DirEntries{}; } - return new DirEntries{reader->listAllEntriesFrom(current)}; + return new DirEntries{stripSelfParent(reader->listAllEntriesFrom(current))}; } void deleteDirEntries(DirEntries* entries) { delete entries; } @@ -127,7 +141,8 @@ struct GapList { static constexpr uint16_t XA_ATTR_FORM2 = 0x1000; static void collectAllEntries(PCSX::ISO9660Reader* reader, const PCSX::ISO9660LowLevel::DirEntry& dir, - std::vector>& out) { + std::vector>& out, + std::unordered_set& visitedDirs) { auto entries = reader->listAllEntriesFrom(dir); for (auto& [entry, xa] : entries) { const auto& name = entry.get().value; @@ -137,9 +152,14 @@ static void collectAllEntries(PCSX::ISO9660Reader* reader, const PCSX::ISO9660Lo uint16_t attribs = xa.get(); uint32_t sectorSize = (attribs & XA_ATTR_FORM2) ? 2324 : 2048; uint32_t sectors = (size + sectorSize - 1) / sectorSize; - out.push_back({lba, sectors}); + // Skip zero-length extents: they don't consume sectors, and emitting + // them would confuse the gap aggregation pass. + if (sectors != 0) out.push_back({lba, sectors}); bool isDir = (entry.get().value & 2) != 0; - if (isDir) collectAllEntries(reader, entry, out); + // Guard against malformed ISOs with directory cycles. + if (isDir && visitedDirs.insert(lba).second) { + collectAllEntries(reader, entry, out, visitedDirs); + } } } @@ -167,7 +187,9 @@ GapList* readerFindGaps(PCSX::ISO9660Reader* reader) { uint32_t rootSize = rootDir.get(); allFiles.push_back({rootLBA, (rootSize + 2047) / 2048}); - collectAllEntries(reader, reader->getRootDirEntry(), allFiles); + std::unordered_set visitedDirs; + visitedDirs.insert(rootLBA); + collectAllEntries(reader, reader->getRootDirEntry(), allFiles, visitedDirs); std::sort(allFiles.begin(), allFiles.end()); auto* result = new GapList{}; diff --git a/src/gui/widgets/isobrowser.cc b/src/gui/widgets/isobrowser.cc index 36a1f2d9a..ce8e58fbf 100644 --- a/src/gui/widgets/isobrowser.cc +++ b/src/gui/widgets/isobrowser.cc @@ -92,6 +92,7 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemTree(const ISO9660LowLevel::DirEnt m_selectedEntry = dirEntry; m_selectedLBA = lba; m_selectedSize = size; + m_selectedMode = IEC60908b::SectorMode::GUESS; m_hasSelection = true; m_selectedIsDir = false; m_selectedIsGap = false; @@ -101,6 +102,7 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemTree(const ISO9660LowLevel::DirEnt m_selectedPath = fullPath; m_selectedLBA = lba; m_selectedSize = size; + m_selectedMode = IEC60908b::SectorMode::GUESS; m_hasSelection = true; m_saveFileDialog.openDialog(); } @@ -108,6 +110,7 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemTree(const ISO9660LowLevel::DirEnt m_selectedPath = fullPath; m_selectedLBA = lba; m_selectedSize = size; + m_selectedMode = IEC60908b::SectorMode::GUESS; m_hasSelection = true; m_openReplaceFileDialog.openDialog(); } @@ -128,7 +131,8 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemTree(const ISO9660LowLevel::DirEnt } } -void PCSX::Widgets::IsoBrowser::collectFlatEntries(const ISO9660LowLevel::DirEntry& entry, const std::string& path) { +void PCSX::Widgets::IsoBrowser::collectFlatEntries(const ISO9660LowLevel::DirEntry& entry, const std::string& path, + std::unordered_set& visitedDirs) { auto entries = m_reader->listAllEntriesFrom(entry); for (auto& [dirEntry, xa] : entries) { const auto& filename = dirEntry.get().value; @@ -145,7 +149,10 @@ void PCSX::Widgets::IsoBrowser::collectFlatEntries(const ISO9660LowLevel::DirEnt uint32_t sectors = (size + sectorSize - 1) / sectorSize; m_flatEntries.push_back( {fullPath, lba, size, sectors, isDir ? FlatEntry::Directory : FlatEntry::File, dirEntry}); - if (isDir) collectFlatEntries(dirEntry, fullPath); + // Guard against malformed ISOs with directory cycles. + if (isDir && visitedDirs.insert(lba).second) { + collectFlatEntries(dirEntry, fullPath, visitedDirs); + } } } @@ -262,8 +269,26 @@ PCSX::Coroutine<> PCSX::Widgets::IsoBrowser::scanAllGaps(std::shared_ptr continue; } - lba++; - scannedSectors++; + // Unknown sector mode. Accumulate consecutive unknown-mode sectors + // and emit a single gap entry so the bytes stay visible in the flat + // view instead of silently disappearing. + uint32_t unknownStart = lba; + while (lba < end) { + if (iso->readSectors(lba, sector, 1) != 1) break; + uint8_t m = sector[15]; + if (m == 0 || m == 1 || m == 2) break; + lba++; + scannedSectors++; + if (std::chrono::steady_clock::now() - time > std::chrono::milliseconds(50)) { + m_gapScanProgress = + totalGapSectors > 0 ? (float)scannedSectors / (float)totalGapSectors : 1.0f; + co_yield m_gapScanCoroutine.awaiter(); + time = std::chrono::steady_clock::now(); + } + } + uint32_t unknownCount = lba - unknownStart; + auto unknownLabel = fmt::format(f_(""), unknownCount); + scanned.push_back({unknownLabel, unknownStart, unknownCount * 2352, unknownCount, FlatEntry::Gap, {}}); } } @@ -309,7 +334,9 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { uint32_t rootSectors = (rootSize + 2047) / 2048; m_flatEntries.push_back({_(""), rootLBA, rootSize, rootSectors, FlatEntry::System, {}}); - collectFlatEntries(m_reader->getRootDirEntry(), ""); + std::unordered_set visitedDirs; + visitedDirs.insert(rootLBA); + collectFlatEntries(m_reader->getRootDirEntry(), "", visitedDirs); std::sort(m_flatEntries.begin(), m_flatEntries.end(), [](const FlatEntry& a, const FlatEntry& b) { return a.lba < b.lba; }); @@ -326,8 +353,13 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { uint32_t end = entry.lba + entry.sectors; if (end > nextExpected) nextExpected = end; } - // Trailing gap: append any unreferenced sectors at the end of the image. + // Trailing gap: append any unreferenced sectors at the end of the loaded image. + // Prefer the actual loaded disc length over PVD_VolumeSpaceSize (which is the + // logical volume length and can differ from what's actually on disc). uint32_t discEnd = pvd.get(); + if (auto iso = m_cachedIso.lock()) { + discEnd = iso->getTD(0).toLBA(); + } if (discEnd > nextExpected) { uint32_t gapSectors = discEnd - nextExpected; auto label = fmt::format(f_(""), gapSectors); @@ -379,15 +411,33 @@ headers and subheader file boundary markers.)")); m_selectedLBA = entry.lba; m_selectedSize = entry.size; m_selectedEntry = entry.dirEntry; + switch (entry.type) { + case FlatEntry::Gap: + case FlatEntry::System: m_selectedMode = IEC60908b::SectorMode::RAW; break; + case FlatEntry::HiddenM1: m_selectedMode = IEC60908b::SectorMode::M1; break; + case FlatEntry::HiddenM2F1: m_selectedMode = IEC60908b::SectorMode::M2_FORM1; break; + case FlatEntry::HiddenM2F2: m_selectedMode = IEC60908b::SectorMode::M2_FORM2; break; + default: m_selectedMode = IEC60908b::SectorMode::GUESS; break; + } m_hasSelection = true; m_selectedIsDir = entry.isDir(); m_selectedIsGap = entry.isGap() || entry.isHidden(); } if (!entry.isDir() && ImGui::BeginPopupContextItem()) { + IEC60908b::SectorMode entryMode = IEC60908b::SectorMode::GUESS; + switch (entry.type) { + case FlatEntry::Gap: + case FlatEntry::System: entryMode = IEC60908b::SectorMode::RAW; break; + case FlatEntry::HiddenM1: entryMode = IEC60908b::SectorMode::M1; break; + case FlatEntry::HiddenM2F1: entryMode = IEC60908b::SectorMode::M2_FORM1; break; + case FlatEntry::HiddenM2F2: entryMode = IEC60908b::SectorMode::M2_FORM2; break; + default: break; + } if (ImGui::MenuItem(_("Extract"))) { m_selectedPath = entry.path; m_selectedLBA = entry.lba; m_selectedSize = entry.size; + m_selectedMode = entryMode; m_hasSelection = true; m_saveFileDialog.openDialog(); } @@ -395,22 +445,15 @@ headers and subheader file boundary markers.)")); m_selectedPath = entry.path; m_selectedLBA = entry.lba; m_selectedSize = entry.size; + m_selectedMode = entryMode; m_hasSelection = true; m_openReplaceFileDialog.openDialog(); } if (ImGui::MenuItem(_("Hex Edit"))) { auto isoPtr = m_cachedIso.lock(); if (isoPtr) { - IEC60908b::SectorMode mode = IEC60908b::SectorMode::GUESS; - switch (entry.type) { - case FlatEntry::Gap: - case FlatEntry::System: mode = IEC60908b::SectorMode::RAW; break; - case FlatEntry::HiddenM1: mode = IEC60908b::SectorMode::M1; break; - case FlatEntry::HiddenM2F1: mode = IEC60908b::SectorMode::M2_FORM1; break; - case FlatEntry::HiddenM2F2: mode = IEC60908b::SectorMode::M2_FORM2; break; - default: break; - } - openHexEditor(entry.path, IO(new CDRIsoFile(isoPtr, entry.lba, entry.size, mode))); + openHexEditor(entry.path, + IO(new CDRIsoFile(isoPtr, entry.lba, entry.size, entryMode))); } } ImGui::EndPopup(); @@ -584,14 +627,15 @@ significantly by caching the files beforehand.)")); auto destPath = reinterpret_cast(selected[0].c_str()); uint32_t lba = m_selectedLBA; uint32_t size = m_selectedSize; + IEC60908b::SectorMode mode = m_selectedMode; auto isoPtr = m_cachedIso.lock(); if (isoPtr) { m_extractionProgress = 0.0f; m_extractionCoroutine = [](IsoBrowser* self, std::shared_ptr iso, uint32_t lba, - uint32_t size, + uint32_t size, IEC60908b::SectorMode mode, std::string dest) -> Coroutine<> { auto time = std::chrono::steady_clock::now(); - IO src(new CDRIsoFile(iso, lba, size)); + IO src(new CDRIsoFile(iso, lba, size, mode)); IO out(new UvFile(dest, FileOps::TRUNCATE)); if (out->failed()) co_return; uint8_t buffer[2048]; @@ -611,7 +655,7 @@ significantly by caching the files beforehand.)")); } } self->m_extractionProgress = 1.0f; - }(this, isoPtr, lba, size, destPath); + }(this, isoPtr, lba, size, mode, destPath); } } } @@ -623,21 +667,28 @@ significantly by caching the files beforehand.)")); auto srcPath = reinterpret_cast(selected[0].c_str()); uint32_t lba = m_selectedLBA; uint32_t originalSize = m_selectedSize; + IEC60908b::SectorMode mode = m_selectedMode; auto isoPtr = m_cachedIso.lock(); if (isoPtr) { m_extractionProgress = 0.0f; m_extractionCoroutine = [](IsoBrowser* self, std::shared_ptr iso, uint32_t lba, - uint32_t originalSize, std::string src) -> Coroutine<> { + uint32_t originalSize, IEC60908b::SectorMode mode, + std::string src) -> Coroutine<> { auto time = std::chrono::steady_clock::now(); IO replacement(new UvFile(src)); - if (replacement->failed()) co_return; - IO isoFile(new CDRIsoFile(iso, lba, originalSize)); - uint32_t replaceSize = std::min((uint32_t)replacement->size(), originalSize); - if (replacement->size() > originalSize) { - // Replacement too large; truncated to original size. + if (replacement->failed()) { + g_system->printf(_("ISO replace: failed to open replacement file.\n")); + co_return; + } + IO isoFile(new CDRIsoFile(iso, lba, originalSize, mode)); + size_t replacementSize = replacement->size(); + uint32_t replaceSize = replacementSize > originalSize + ? originalSize + : static_cast(replacementSize); + if (replacementSize > originalSize) { g_system->printf( _("ISO replace: replacement file is larger than target (%zu > %u). Truncating.\n"), - replacement->size(), originalSize); + replacementSize, originalSize); } uint8_t buffer[2048]; uint32_t remaining = replaceSize; @@ -645,10 +696,17 @@ significantly by caching the files beforehand.)")); while (remaining > 0) { uint32_t chunk = std::min(remaining, (uint32_t)sizeof(buffer)); auto read = replacement->read(buffer, chunk); - if (read <= 0) break; - isoFile->write(buffer, read); - remaining -= read; - written += read; + if (read <= 0) { + g_system->printf(_("ISO replace: failed while reading replacement file.\n")); + co_return; + } + auto wrote = isoFile->write(buffer, read); + if (wrote != read) { + g_system->printf(_("ISO replace: failed while writing to ISO.\n")); + co_return; + } + remaining -= static_cast(read); + written += static_cast(read); if (std::chrono::steady_clock::now() - time > std::chrono::milliseconds(50)) { self->m_extractionProgress = (float)written / (float)originalSize; co_yield self->m_extractionCoroutine.awaiter(); @@ -662,9 +720,13 @@ significantly by caching the files beforehand.)")); uint32_t padRemaining = originalSize - written; while (padRemaining > 0) { uint32_t chunk = std::min(padRemaining, (uint32_t)sizeof(zeros)); - isoFile->write(zeros, chunk); - padRemaining -= chunk; - written += chunk; + auto wrote = isoFile->write(zeros, chunk); + if (wrote != static_cast(chunk)) { + g_system->printf(_("ISO replace: failed while zero-padding ISO.\n")); + co_return; + } + padRemaining -= static_cast(wrote); + written += static_cast(wrote); if (std::chrono::steady_clock::now() - time > std::chrono::milliseconds(50)) { self->m_extractionProgress = (float)written / (float)originalSize; co_yield self->m_extractionCoroutine.awaiter(); @@ -673,7 +735,7 @@ significantly by caching the files beforehand.)")); } } self->m_extractionProgress = 1.0f; - }(this, isoPtr, lba, originalSize, srcPath); + }(this, isoPtr, lba, originalSize, mode, srcPath); } } } diff --git a/src/gui/widgets/isobrowser.h b/src/gui/widgets/isobrowser.h index 0bcf03d19..d35a28ac1 100644 --- a/src/gui/widgets/isobrowser.h +++ b/src/gui/widgets/isobrowser.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include "cdrom/iso9660-reader.h" @@ -32,6 +33,7 @@ #include "support/coroutine.h" #include "support/file.h" #include "support/list.h" +#include "supportpsx/iec-60908b.h" #include "supportpsx/iso9660-lowlevel.h" namespace PCSX { @@ -67,6 +69,7 @@ class IsoBrowser { ISO9660LowLevel::DirEntry m_selectedEntry; uint32_t m_selectedLBA = 0; uint32_t m_selectedSize = 0; + IEC60908b::SectorMode m_selectedMode = IEC60908b::SectorMode::GUESS; bool m_hasSelection = false; bool m_selectedIsDir = false; bool m_selectedIsGap = false; @@ -97,7 +100,8 @@ class IsoBrowser { void drawFilesystemTree(const ISO9660LowLevel::DirEntry& entry, const std::string& path); void drawFilesystemFlat(); - void collectFlatEntries(const ISO9660LowLevel::DirEntry& entry, const std::string& path); + void collectFlatEntries(const ISO9660LowLevel::DirEntry& entry, const std::string& path, + std::unordered_set& visitedDirs); Coroutine<> scanAllGaps(std::shared_ptr iso); FileDialog<> m_openIsoFileDialog; From 2e05407deda39182f36a2512de6cc1e65ca30b53 Mon Sep 17 00:00:00 2001 From: Nicolas 'Pixel' Noble Date: Sun, 19 Apr 2026 22:19:02 -0700 Subject: [PATCH 16/16] Address CodeRabbit fourth review round on PR 2013 - drawFilesystemTree now carries a visited-LBA set and skips recursing into already-seen directories, guarding against infinite recursion on malformed ISOs with directory cycles. Matches the guard already in place on collectFlatEntries. - Extraction coroutine now checks src->failed() up front and verifies every read/write return, aborting with a printed error instead of silently marking progress as complete on disk-full or short-write. - Hex editor ReadFn defaults to 0 and returns 0 on short reads; the BulkReadFn zero-fills any tail bytes that readAt didn't cover so I/O errors don't expose stale buffer contents in the hex view. Signed-off-by: Nicolas 'Pixel' Noble --- src/gui/widgets/isobrowser.cc | 50 +++++++++++++++++++++++++++-------- src/gui/widgets/isobrowser.h | 3 ++- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/gui/widgets/isobrowser.cc b/src/gui/widgets/isobrowser.cc index ce8e58fbf..8390fd785 100644 --- a/src/gui/widgets/isobrowser.cc +++ b/src/gui/widgets/isobrowser.cc @@ -23,6 +23,7 @@ #include #include +#include #include "cdrom/cdriso.h" #include "cdrom/ppf.h" @@ -57,7 +58,8 @@ PCSX::Coroutine<> PCSX::Widgets::IsoBrowser::computeCRC(PCSX::CDRIso* iso) { m_fullCRC = fullCRC; }; -void PCSX::Widgets::IsoBrowser::drawFilesystemTree(const ISO9660LowLevel::DirEntry& entry, const std::string& path) { +void PCSX::Widgets::IsoBrowser::drawFilesystemTree(const ISO9660LowLevel::DirEntry& entry, const std::string& path, + std::unordered_set& visitedDirs) { auto entries = m_reader->listAllEntriesFrom(entry); for (auto& [dirEntry, xa] : entries) { @@ -79,7 +81,10 @@ void PCSX::Widgets::IsoBrowser::drawFilesystemTree(const ISO9660LowLevel::DirEnt ImGui::TableSetColumnIndex(2); ImGui::TextUnformatted(_("")); if (open) { - drawFilesystemTree(dirEntry, fullPath); + // Guard against malformed ISOs with directory cycles pointing back to an ancestor. + if (visitedDirs.insert(lba).second) { + drawFilesystemTree(dirEntry, fullPath, visitedDirs); + } ImGui::TreePop(); } } else { @@ -636,18 +641,32 @@ significantly by caching the files beforehand.)")); std::string dest) -> Coroutine<> { auto time = std::chrono::steady_clock::now(); IO src(new CDRIsoFile(iso, lba, size, mode)); + if (src->failed()) { + g_system->printf(_("ISO extract: failed to open source region.\n")); + co_return; + } IO out(new UvFile(dest, FileOps::TRUNCATE)); - if (out->failed()) co_return; + if (out->failed()) { + g_system->printf(_("ISO extract: failed to open destination file.\n")); + co_return; + } uint8_t buffer[2048]; uint32_t remaining = size; uint32_t written = 0; while (remaining > 0) { uint32_t chunk = std::min(remaining, (uint32_t)sizeof(buffer)); auto read = src->read(buffer, chunk); - if (read <= 0) break; - out->write(buffer, read); - remaining -= read; - written += read; + if (read <= 0) { + g_system->printf(_("ISO extract: failed while reading ISO data.\n")); + co_return; + } + auto wrote = out->write(buffer, read); + if (wrote != read) { + g_system->printf(_("ISO extract: failed while writing destination file.\n")); + co_return; + } + remaining -= static_cast(read); + written += static_cast(read); if (std::chrono::steady_clock::now() - time > std::chrono::milliseconds(50)) { self->m_extractionProgress = (float)written / (float)size; co_yield self->m_extractionCoroutine.awaiter(); @@ -752,7 +771,9 @@ significantly by caching the files beforehand.)")); ImGui::TableSetupColumn(_("LBA"), ImGuiTableColumnFlags_WidthFixed, 80.0f); ImGui::TableSetupColumn(_("Size"), ImGuiTableColumnFlags_WidthFixed, 100.0f); ImGui::TableHeadersRow(); - drawFilesystemTree(m_reader->getRootDirEntry(), ""); + std::unordered_set visitedDirs; + visitedDirs.insert(m_reader->getRootDirEntry().get()); + drawFilesystemTree(m_reader->getRootDirEntry(), "", visitedDirs); ImGui::EndTable(); } } @@ -780,13 +801,20 @@ significantly by caching the files beforehand.)")); } auto size = inst.m_file->size(); inst.m_editor.ReadFn = [&inst](size_t off) -> ImU8 { - ImU8 b; - inst.m_file->readAt(&b, 1, off); + ImU8 b = 0; + auto r = inst.m_file->readAt(&b, 1, off); + if (r != 1) b = 0; return b; }; inst.m_editor.WriteFn = [&inst](size_t off, ImU8 d) { inst.m_file->writeAt(&d, 1, off); }; inst.m_editor.Cache.BulkReadFn = [&inst](void* dest, size_t off, size_t len) { - inst.m_file->readAt(dest, len, off); + auto r = inst.m_file->readAt(dest, len, off); + // Zero-fill anything we didn't read so stale buffer contents + // never leak into the hex view on short reads or errors. + if (r < 0) r = 0; + if (static_cast(r) < len) { + std::memset(static_cast(dest) + r, 0, len - static_cast(r)); + } }; inst.m_editor.DrawWindow(inst.m_title.c_str(), size); ++it; diff --git a/src/gui/widgets/isobrowser.h b/src/gui/widgets/isobrowser.h index d35a28ac1..c37411e82 100644 --- a/src/gui/widgets/isobrowser.h +++ b/src/gui/widgets/isobrowser.h @@ -98,7 +98,8 @@ class IsoBrowser { Coroutine<> m_gapScanCoroutine; float m_gapScanProgress = 0.0f; - void drawFilesystemTree(const ISO9660LowLevel::DirEntry& entry, const std::string& path); + void drawFilesystemTree(const ISO9660LowLevel::DirEntry& entry, const std::string& path, + std::unordered_set& visitedDirs); void drawFilesystemFlat(); void collectFlatEntries(const ISO9660LowLevel::DirEntry& entry, const std::string& path, std::unordered_set& visitedDirs);