diff --git a/src/cdrom/iso9660-reader.cc b/src/cdrom/iso9660-reader.cc index 66607f356..18dc5b7c3 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,27 @@ 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)) { + // 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; } 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 87ca9d787..258473341 100644 --- a/src/cdrom/iso9660-reader.h +++ b/src/cdrom/iso9660-reader.h @@ -40,13 +40,20 @@ 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(); } + 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; - typedef std::pair FullDirEntry; + uint32_t m_vdEnd = 17; std::optional findEntry(const std::string_view& filename); - std::vector listAllEntriesFrom(const ISO9660LowLevel::DirEntry& entry); ISO9660LowLevel::PVD m_pvd; }; diff --git a/src/core/isoffi.lua b/src/core/isoffi.lua index 6e8787740..62dbfd673 100644 --- a/src/core/isoffi.lua +++ b/src/core/isoffi.lua @@ -43,6 +43,22 @@ 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[?]; } 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); @@ -58,6 +74,34 @@ 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) + local count = C.dirEntriesCount(entries) + local result = {} + for i = 0, count - 1 do + local name = ffi.string(C.dirEntryName(entries, i)) + table.insert(result, { + name = name, + lba = C.dirEntryLBA(entries, i), + size = C.dirEntrySize(entries, i), + isDir = C.dirEntryIsDir(entries, i), + }) + end + return result + end, } return reader end diff --git a/src/core/luaiso.cc b/src/core/luaiso.cc index 54427879f..3e92147d4 100644 --- a/src/core/luaiso.cc +++ b/src/core/luaiso.cc @@ -19,7 +19,9 @@ #include "core/luaiso.h" +#include #include +#include #include "cdrom/cdriso.h" #include "cdrom/file.h" @@ -27,6 +29,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 +60,166 @@ 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; +}; + +// 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{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 + // 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) { + 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{}; + } + // Only return children if the target is actually a directory. + if ((current.get().value & 2) == 0) { + return new DirEntries{}; + } + return new DirEntries{stripSelfParent(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; +} + +struct GapEntry { + uint32_t lba; + uint32_t sectors; +}; + +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, + std::unordered_set& visitedDirs) { + 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(); + uint16_t attribs = xa.get(); + uint32_t sectorSize = (attribs & XA_ATTR_FORM2) ? 2324 : 2048; + uint32_t sectors = (size + sectorSize - 1) / sectorSize; + // 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; + // Guard against malformed ISOs with directory cycles. + if (isDir && visitedDirs.insert(lba).second) { + collectAllEntries(reader, entry, out, visitedDirs); + } + } +} + +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 vdEnd = reader->getVDEnd(); + allFiles.push_back({16, vdEnd > 16 ? vdEnd - 16 : 1}); // Volume descriptors including terminator + uint32_t lPathLoc = pvd.get(); + 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}); + + std::unordered_set visitedDirs; + visitedDirs.insert(rootLBA); + collectAllEntries(reader, reader->getRootDirEntry(), allFiles, visitedDirs); + 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; + } + // 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; +} + +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); } @@ -98,6 +261,20 @@ 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, readerFindGaps); + REGISTER(L, deleteGapList); + REGISTER(L, gapListCount); + REGISTER(L, gapEntryLBA); + REGISTER(L, gapEntrySectors); + REGISTER(L, createIsoBuilder); REGISTER(L, deleteIsoBuilder); REGISTER(L, isoBuilderWriteLicense); diff --git a/src/gui/gui.cc b/src/gui/gui.cc index bd2c28376..bf158f6be 100644 --- a/src/gui/gui.cc +++ b/src/gui/gui.cc @@ -179,7 +179,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.cc b/src/gui/widgets/isobrowser.cc index 49d7a9fba..8390fd785 100644 --- a/src/gui/widgets/isobrowser.cc +++ b/src/gui/widgets/isobrowser.cc @@ -21,8 +21,12 @@ #include +#include #include +#include +#include "cdrom/cdriso.h" +#include "cdrom/ppf.h" #include "core/cdrom.h" #include "fmt/format.h" #include "imgui/imgui.h" @@ -54,6 +58,433 @@ 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, + std::unordered_set& visitedDirs) { + 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) { + // Guard against malformed ISOs with directory cycles pointing back to an ancestor. + if (visitedDirs.insert(lba).second) { + drawFilesystemTree(dirEntry, fullPath, visitedDirs); + } + 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_selectedLBA = lba; + m_selectedSize = size; + m_selectedMode = IEC60908b::SectorMode::GUESS; + m_hasSelection = true; + m_selectedIsDir = false; + m_selectedIsGap = false; + } + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem(_("Extract"))) { + m_selectedPath = fullPath; + m_selectedLBA = lba; + m_selectedSize = size; + m_selectedMode = IEC60908b::SectorMode::GUESS; + m_hasSelection = true; + m_saveFileDialog.openDialog(); + } + if (ImGui::MenuItem(_("Replace"))) { + m_selectedPath = fullPath; + m_selectedLBA = lba; + m_selectedSize = size; + m_selectedMode = IEC60908b::SectorMode::GUESS; + 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); + auto str = fmt::format("{}", size); + ImGui::TextUnformatted(str.c_str()); + } + } +} + +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; + 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; + + // 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}); + // Guard against malformed ISOs with directory cycles. + if (isDir && visitedDirs.insert(lba).second) { + collectFlatEntries(dirEntry, fullPath, visitedDirs); + } + } +} + +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; + } + + 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; + } + + 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(); + } + } + uint32_t count = lba - gapStart; + auto label = fmt::format(f_(""), count); + scanned.push_back({label, gapStart, count * 2352, count, FlatEntry::Gap, {}}); + continue; + } + + 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; + } + + 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; + + 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; + } + + // 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, {}}); + } + } + + m_flatEntries = std::move(scanned); + m_gapsScanned = true; + m_gapScanProgress = 1.0f; +} + +void PCSX::Widgets::IsoBrowser::drawFilesystemFlat() { + if (m_flatEntriesDirty) { + m_flatEntries.clear(); + + // Add ISO9660 system structures + auto& pvd = m_reader->getPVD(); + 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(); + uint32_t pathTableSize = pvd.get(); + uint32_t pathTableSectors = (pathTableSize + 2047) / 2048; + m_flatEntries.push_back( + {_(""), lPathLoc, pathTableSize, pathTableSectors, FlatEntry::System, {}}); + uint32_t lPathOptLoc = pvd.get(); + if (lPathOptLoc != 0) { + m_flatEntries.push_back( + {_(""), lPathOptLoc, pathTableSize, pathTableSectors, FlatEntry::System, {}}); + } + uint32_t mPathLoc = pvd.get(); + m_flatEntries.push_back( + {_(""), mPathLoc, pathTableSize, pathTableSectors, FlatEntry::System, {}}); + uint32_t mPathOptLoc = pvd.get(); + if (mPathOptLoc != 0) { + m_flatEntries.push_back( + {_(""), mPathOptLoc, pathTableSize, pathTableSectors, FlatEntry::System, {}}); + } + auto& rootDir = m_reader->getRootDirEntry(); + uint32_t rootLBA = rootDir.get(); + uint32_t rootSize = rootDir.get(); + uint32_t rootSectors = (rootSize + 2047) / 2048; + m_flatEntries.push_back({_(""), rootLBA, rootSize, rootSectors, FlatEntry::System, {}}); + + 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; }); + + // 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 * 2352, gapSectors, FlatEntry::Gap, {}}); + } + withGaps.push_back(entry); + uint32_t end = entry.lba + entry.sectors; + if (end > nextExpected) nextExpected = end; + } + // 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); + 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 (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); + } + } + 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, + ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY, + 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); + ImGui::TableSetupColumn(_("Type"), ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableHeadersRow(); + + 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_selectedLBA == entry.lba && + m_selectedIsGap == (entry.isGap() || entry.isHidden()); + 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; + 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(); + } + if (ImGui::MenuItem(_("Replace"))) { + 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) { + openHexEditor(entry.path, + IO(new CDRIsoFile(isoPtr, entry.lba, entry.size, entryMode))); + } + } + ImGui::EndPopup(); + } + 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); + 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; + case FlatEntry::System: typeStr = _("System"); break; + } + ImGui::TextUnformatted(typeStr); + } + ImGui::EndTable(); + } +} + void PCSX::Widgets::IsoBrowser::draw(CDRom* cdrom, const char* title) { if (!ImGui::Begin(title, &m_show, ImGuiWindowFlags_MenuBar)) { ImGui::End(); @@ -171,5 +602,227 @@ 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(); + m_flatEntriesDirty = true; + m_gapScanCoroutine = {}; + 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(); + + if (extracting) { + ImGui::ProgressBar(m_extractionProgress); + m_extractionCoroutine.resume(); + } + + // 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_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, IEC60908b::SectorMode mode, + 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()) { + 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) { + 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(); + time = std::chrono::steady_clock::now(); + } + } + self->m_extractionProgress = 1.0f; + }(this, isoPtr, lba, size, mode, 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_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, IEC60908b::SectorMode mode, + std::string src) -> Coroutine<> { + auto time = std::chrono::steady_clock::now(); + IO replacement(new UvFile(src)); + 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"), + replacementSize, 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) { + 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(); + 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)); + 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(); + time = std::chrono::steady_clock::now(); + } + } + } + self->m_extractionProgress = 1.0f; + }(this, isoPtr, lba, originalSize, mode, srcPath); + } + } + } + + 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, 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); + ImGui::TableHeadersRow(); + std::unordered_set visitedDirs; + visitedDirs.insert(m_reader->getRootDirEntry().get()); + drawFilesystemTree(m_reader->getRootDirEntry(), "", visitedDirs); + 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(); + + // 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 = 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) { + 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; + } +} + +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 cff66374c..c37411e82 100644 --- a/src/gui/widgets/isobrowser.h +++ b/src/gui/widgets/isobrowser.h @@ -22,11 +22,19 @@ #include #include +#include #include +#include #include +#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 "support/list.h" +#include "supportpsx/iec-60908b.h" +#include "supportpsx/iso9660-lowlevel.h" namespace PCSX { @@ -37,8 +45,13 @@ namespace Widgets { class IsoBrowser { public: - IsoBrowser(bool& show, std::vector& favorites) - : m_show(show), m_openIsoFileDialog(l_("Open Disk Image"), 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_monoFont(monoFont) {} + ~IsoBrowser() { m_hexEditors.destroyAll(); } void draw(CDRom* cdrom, const char* title); bool& m_show; @@ -48,9 +61,71 @@ 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; + 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; + + Coroutine<> m_extractionCoroutine; + float m_extractionProgress = 0.0f; + + bool m_flatView = false; + struct FlatEntry { + enum Type { File, Directory, Gap, HiddenM1, HiddenM2F1, HiddenM2F2, System }; + 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; + + 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; + Coroutine<> m_gapScanCoroutine; + float m_gapScanProgress = 0.0f; + + 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); + Coroutine<> scanAllGaps(std::shared_ptr iso); + FileDialog<> m_openIsoFileDialog; + FileDialog m_saveFileDialog; + FileDialog<> m_openReplaceFileDialog; + + 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