From 62433da2cf74ae5cd31350ad6f1d0efdcbe9b86d Mon Sep 17 00:00:00 2001 From: Aditya Kumar Gupta Date: Sun, 31 May 2026 07:52:12 +0530 Subject: [PATCH 1/5] Add tree-style recursive directory listing to ls command Add -R flag to the ls command that displays the memory card directory structure in a tree format with box-drawing characters. Add -l flag (--long-recursive) to show mode bits, file size, and timestamps alongside the tree output, with columns aligned to the deepest entry. --- mymcplusplus/mymc.py | 58 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/mymcplusplus/mymc.py b/mymcplusplus/mymc.py index 78a189e..4a5c4af 100644 --- a/mymcplusplus/mymc.py +++ b/mymcplusplus/mymc.py @@ -97,6 +97,50 @@ def _copy(fout, fin): fout.write(s) +def _tree_collect(mc, dirname, prefix=""): + """Collect all tree lines as (prefix+connector+name, ent) tuples.""" + lines = [] + dir = mc.dir_open(dirname) + try: + entries = [ent for ent in dir + if (ent[0] & DF_EXISTS) and ent[8] not in (b".", b"..")] + finally: + dir.close() + + for i, ent in enumerate(entries): + is_last = (i == len(entries) - 1) + connector = "└── " if is_last else "├── " + name = ent[8].decode("ascii") + lines.append((prefix + connector + name, ent)) + if mode_is_dir(ent[0]): + extension = " " if is_last else "│ " + lines.extend(_tree_collect(mc, dirname.rstrip("/") + "/" + name, prefix + extension)) + return lines + + +def _tree(mc, dirname, opts, prefix=""): + mode_bits = "rwxpfdD81C+KPH4" + lines = _tree_collect(mc, dirname, prefix) + + if not opts.long: + for tree_part, ent in lines: + sys.stdout.write(tree_part + "\n") + return + + max_width = max((len(tree_part) for tree_part, _ in lines), default=0) + for tree_part, ent in lines: + mode = ent[0] + mode_str = "" + for bit in range(0, 15): + mode_str += mode_bits[bit] if mode & (1 << bit) else "-" + tod = ent[3] if opts.creation_time else ent[6] + tm = time.localtime(tod_to_time(tod)) + sys.stdout.write("%-*s %s %7d %04d-%02d-%02d %02d:%02d:%02d\n" % ( + max_width, tree_part, mode_str, ent[2], + tm.tm_year, tm.tm_mon, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec)) + + def do_ls(cmd, mc, opts, args, opterr): mode_bits = "rwxpfdD81C+KPH4" @@ -105,6 +149,13 @@ def do_ls(cmd, mc, opts, args, opterr): out = sys.stdout args = glob_args(args, mc.glob) + + if opts.recursive: + for dirname in args: + sys.stdout.write(dirname + "\n") + _tree(mc, dirname, opts) + return + for dirname in args: dir = mc.dir_open(dirname) try: @@ -520,7 +571,12 @@ def do_ecc_check(cmd, mc, opts, args, opterr): "[directory ...]", "List the contents of a directory.", [opt("-c", "--creation-time", action="store_true", - help = "Display creation times.")]), + help = "Display creation times."), + opt("-R", "--recursive", action="store_true", + help = "Display directory tree recursively."), + opt("-l", "--long-recursive", action="store_true", + dest="long", + help = "Show details (mode, size, timestamp) in tree mode. Only used with -R.")]), "extract": (do_extract, "rb", "filename ...", "Extract files from the memory card.", From 0692d0fde173e05012a4f80528fa4e6a36d1d11b Mon Sep 17 00:00:00 2001 From: Aditya Kumar Gupta Date: Sun, 31 May 2026 08:10:12 +0530 Subject: [PATCH 2/5] Add directory navigation and icon rendering fixes to GUI Add double-click navigation into save directories with '..' support to return to parent. Show files when inside a subdirectory. Enable export and delete for individual files. Disable import when not at root. Show current path in window title. Fix icon texture type detection to use bit flags (bit 2 = texture present, bit 3 = compressed). Fix RLE decompression high-bit check from 0xff00 to 0x8000 and add zero-repeat guard. Catch FileTooSmall in icon constructor to allow partial rendering. Fix Retina display rendering by scaling glViewport with content scale factor. Fix canvas sizer proportion to fill available space. Suppress stale focus events during list updates to prevent erroneous icon load attempts on navigation. --- mymcplusplus/gui/dirlist_control.py | 136 ++++++++++++++++++----- mymcplusplus/gui/gui.py | 164 +++++++++++++++++++++++++--- mymcplusplus/gui/icon_renderer.py | 4 +- mymcplusplus/gui/icon_window.py | 2 +- mymcplusplus/ps2icon.py | 52 +++++---- 5 files changed, 295 insertions(+), 63 deletions(-) diff --git a/mymcplusplus/gui/dirlist_control.py b/mymcplusplus/gui/dirlist_control.py index 37d4a8a..db6a050 100644 --- a/mymcplusplus/gui/dirlist_control.py +++ b/mymcplusplus/gui/dirlist_control.py @@ -49,21 +49,37 @@ def __init__(self, type, dirent, icon_sys, size, title): self.size = size self.title = title + _PARENT_ENTRY = 0x7FFFFFFE + def __init__(self, parent, evt_focus, evt_select, config): self.config = config self.selected = set() self.dirtable = [] + self.current_path = "/" + self._mc = None + self._on_navigate = None + self._updating = False # current sort state for column header clicks self.sort_column = self.Column.DIRECTORY self.sort_reverse = False self.evt_select = evt_select + self.evt_focus = evt_focus wx.ListCtrl.__init__(self, parent, wx.ID_ANY, style=wx.LC_REPORT) self.Bind(wx.EVT_LIST_COL_CLICK, self.evt_col_click) - self.Bind(wx.EVT_LIST_ITEM_FOCUSED, evt_focus) + self.Bind(wx.EVT_LIST_ITEM_FOCUSED, self._evt_focus_wrapper) self.Bind(wx.EVT_LIST_ITEM_SELECTED, self.evt_item_selected) self.Bind(wx.EVT_LIST_ITEM_DESELECTED, self.evt_item_deselected) + self.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.evt_item_activated) + + def _evt_focus_wrapper(self, event): + """Skip focus events for the '..' entry or during updates.""" + if self._updating: + return + if event.GetData() == self._PARENT_ENTRY: + return + self.evt_focus(event) def _update_dirtable(self, mc, dir): self.dirtable = table = [] @@ -71,32 +87,41 @@ def _update_dirtable(self, mc, dir): if self.config.get_ascii(): enc = "ascii" for ent in dir: - if not ps2mc.mode_is_dir(ent[0]): + mode = ent[0] + if not (mode & ps2mc.DF_EXISTS): + continue + name = ent[8].decode("ascii") + if name in (".", ".."): continue - dirname = ent[8].decode("ascii") - dirpath = "/" + dirname - - if ps2mc.mode_is_psx_dir(ent[0]): - type = self.TableEntry.Type.PS1 - title = (dirname, "") - icon_sys = None - else: - type = self.TableEntry.Type.PS2 - icon_sys_data = mc.get_icon_sys(dirpath) - if icon_sys_data is None: - continue - icon_sys = ps2iconsys.IconSys(icon_sys_data) - title = icon_sys.get_title(enc) - - size = mc.dir_size(dirpath) - table.append(self.TableEntry(type, ent, icon_sys, size, title)) + if ps2mc.mode_is_dir(mode): + dirpath = self.current_path.rstrip("/") + "/" + name + if ps2mc.mode_is_psx_dir(mode): + type = self.TableEntry.Type.PS1 + title = (name, "") + icon_sys = None + else: + type = self.TableEntry.Type.PS2 + icon_sys_data = mc.get_icon_sys(dirpath) + if icon_sys_data is None: + type = self.TableEntry.Type.PS1 + title = (name, "") + icon_sys = None + else: + icon_sys = ps2iconsys.IconSys(icon_sys_data) + title = icon_sys.get_title(enc) + size = mc.dir_size(dirpath) + table.append(self.TableEntry(type, ent, icon_sys, size, title)) + elif self.current_path != "/": + # Show files when inside a subdirectory + table.append(self.TableEntry( + self.TableEntry.Type.PS2, ent, None, ent[2], (name, ""))) def update_dirtable(self, mc): self.dirtable = [] if mc is None: return - dir = mc.dir_open("/") + dir = mc.dir_open(self.current_path) try: self._update_dirtable(mc, dir) finally: @@ -136,6 +161,10 @@ def _apply_sort_direction(self, result): return result def cmp_dir_name(self, i1, i2): + if i1 == self._PARENT_ENTRY: + return -1 + if i2 == self._PARENT_ENTRY: + return 1 v1 = self.dirtable[i1].dirent[8] v2 = self.dirtable[i2].dirent[8] if v1 < v2: @@ -147,6 +176,10 @@ def cmp_dir_name(self, i1, i2): return self._apply_sort_direction(result) def cmp_dir_title(self, i1, i2): + if i1 == self._PARENT_ENTRY: + return -1 + if i2 == self._PARENT_ENTRY: + return 1 v1 = self.dirtable[i1].title v2 = self.dirtable[i2].title if v1 < v2: @@ -158,6 +191,10 @@ def cmp_dir_title(self, i1, i2): return self._apply_sort_direction(result) def cmp_dir_size(self, i1, i2): + if i1 == self._PARENT_ENTRY: + return -1 + if i2 == self._PARENT_ENTRY: + return 1 v1 = self.dirtable[i1].size v2 = self.dirtable[i2].size if v1 < v2: @@ -169,6 +206,10 @@ def cmp_dir_size(self, i1, i2): return self._apply_sort_direction(result) def cmp_dir_modified(self, i1, i2): + if i1 == self._PARENT_ENTRY: + return -1 + if i2 == self._PARENT_ENTRY: + return 1 m1 = list(self.dirtable[i1].dirent[6]) m2 = list(self.dirtable[i2].dirent[6]) # dirent[6] is a tuple like (sec, min, hour, day, month, year) @@ -211,17 +252,49 @@ def evt_col_click(self, event): self.SortItems(cmp) def evt_item_selected(self, event): - self.selected.add(event.GetData()) + idx = event.GetData() + if idx == self._PARENT_ENTRY: + return + self.selected.add(idx) self.evt_select(event) def evt_item_deselected(self, event): - self.selected.discard(event.GetData()) + idx = event.GetData() + if idx == self._PARENT_ENTRY: + return + self.selected.discard(idx) self.evt_select(event) + def evt_item_activated(self, event): + """Handle double-click: navigate into directories or go up with '..'.""" + idx = event.GetData() + if idx == self._PARENT_ENTRY: + # '..' entry + self.navigate_up() + return + entry = self.dirtable[idx] + if entry.dirent[0] & ps2mc.DF_DIR: + dirname = entry.dirent[8].decode("ascii") + self.current_path = self.current_path.rstrip("/") + "/" + dirname + self.update(self._mc) + if self._on_navigate: + self._on_navigate() + + def navigate_up(self): + if self.current_path == "/": + return + # Go up one level + self.current_path = "/".join(self.current_path.rstrip("/").split("/")[:-1]) or "/" + self.update(self._mc) + if self._on_navigate: + self._on_navigate() + def update(self, mc): """Update the ListCtrl according to the contents of the memory card image.""" + self._mc = mc + self._updating = True self.ClearAll() self.selected = set() @@ -247,13 +320,25 @@ def update(self, mc): self.update_dirtable(mc) - empty = len(self.dirtable) == 0 + empty = len(self.dirtable) == 0 and self.current_path == "/" self.Enable(not empty) - if empty: + + item_offset = 0 + # Add '..' entry when not at root + if self.current_path != "/": + li = self.InsertItem(0, "..") + self.SetItem(li, 1, "") + self.SetItem(li, 2, "") + self.SetItem(li, 3, "(Parent Directory)") + self.SetItemData(li, self._PARENT_ENTRY) + item_offset = 1 + + if len(self.dirtable) == 0 and self.current_path == "/": + self._updating = False return for (i, a) in enumerate(self.dirtable): - li = self.InsertItem(i, a.dirent[8]) + li = self.InsertItem(i + item_offset, a.dirent[8]) self.SetItem(li, 1, "%dK" % (a.size // 1024)) m = a.dirent[6] m = ("%04d-%02d-%02d %02d:%02d" @@ -269,3 +354,4 @@ def update(self, mc): # Apply default sort (Directory, ascending) self.SortItems(self.cmp_dir_name) + self._updating = False diff --git a/mymcplusplus/gui/gui.py b/mymcplusplus/gui/gui.py index af60f05..7ae063b 100644 --- a/mymcplusplus/gui/gui.py +++ b/mymcplusplus/gui/gui.py @@ -257,6 +257,7 @@ def __init__(self, parent, title, mcname=None): self.evt_dirlist_select, self.config, ) + self.dirlist._on_navigate = self._on_dirlist_navigate if mcname is not None: self.open_mc(mcname) @@ -362,6 +363,23 @@ def _close_mc(self): self.f = None self.mcname = None + def _format_title(self): + """Format window title based on current state.""" + if self.mcname is None: + return self.title + path = self.dirlist.current_path + if path == "/": + return self.mcname + " - " + self.title + return self.mcname + " - " + path + " - " + self.title + + def _on_dirlist_navigate(self): + """Called when the user navigates into or out of a directory.""" + at_root = self.dirlist.current_path == "/" + self.toolbar.EnableTool(self.ID_CMD_IMPORT, self.mc is not None and at_root) + self.toolbar.EnableTool(self.ID_CMD_EXPORT, False) + if self.mc is not None: + self.SetTitle(self._format_title()) + def refresh(self): try: self.dirlist.update(self.mc) @@ -371,16 +389,19 @@ def refresh(self): self.dirlist.update(None) mc = self.mc + at_root = self.dirlist.current_path == "/" - self.toolbar.EnableTool(self.ID_CMD_IMPORT, mc is not None) + self.toolbar.EnableTool(self.ID_CMD_IMPORT, mc is not None and at_root) self.toolbar.EnableTool(self.ID_CMD_EXPORT, False) if mc is None: status = "No memory card image" + self.SetTitle(self.title) else: free = mc.get_free_space() // 1024 limit = mc.get_allocatable_space() // 1024 status = "%dK of %dK free" % (free, limit) + self.SetTitle(self._format_title()) self.statusbar.SetStatusText(status, 1) def open_mc(self, filename): @@ -388,6 +409,7 @@ def open_mc(self, filename): self.statusbar.SetStatusText("", 1) if self.icon_win is not None: self.icon_win.load_icon(None, None) + self.dirlist.current_path = "/" f = None try: @@ -404,7 +426,6 @@ def open_mc(self, filename): self.f = f self.mc = mc self.mcname = filename - self.SetTitle(filename + " - " + self.title) self.refresh() def delete_selected(self): @@ -414,31 +435,38 @@ def delete_selected(self): selected = self.dirlist.selected dirtable = self.dirlist.dirtable + current_path = self.dirlist.current_path - dirnames = [ + names = [ dirtable[i].dirent[8].decode("ascii") for i in selected ] if len(selected) == 1: title = dirtable[list(selected)[0]].title - s = dirnames[0] + " (" + utils.single_title(title) + ")" + s = names[0] + " (" + utils.single_title(title) + ")" else: - s = ", ".join(dirnames) + s = ", ".join(names) if len(s) > 200: s = s[:200] + "..." r = self.message_box( "Are you sure you want to delete " + s + "?", - "Delete Save File Confirmation", + "Delete Confirmation", wx.YES_NO, ) if r != wx.YES: return - for dn in dirnames: + for i in selected: + entry = dirtable[i] + name = entry.dirent[8].decode("ascii") + fullpath = current_path.rstrip("/") + "/" + name try: - mc.rmdir("/" + dn) + if ps2mc.mode_is_dir(entry.dirent[0]): + mc.rmdir(fullpath) + else: + mc.remove(fullpath) except EnvironmentError as value: - self.mc_error(value, dn) + self.mc_error(value, name) mc.check() self.refresh() @@ -539,7 +567,8 @@ def _update_force_import_status(self): def evt_menu_open(self, event): # Enable/disable basic actions based on current state - self.import_menu_item.Enable(self.mc is not None) + at_root = self.dirlist.current_path == "/" + self.import_menu_item.Enable(self.mc is not None and at_root) selected = self.mc is not None and len(self.dirlist.selected) > 0 self.export_menu_item.Enable(selected) @@ -591,12 +620,49 @@ def evt_dirlist_item_focused(self, event): icon_sys = entry.icon_sys mc = self.mc - if mc is None or icon_sys is None: + if mc is None: + self.icon_win.load_icon(None, None) + return + + current_path = self.dirlist.current_path + + # If inside a subdirectory and icon.sys is selected, parse and render it + if icon_sys is None and current_path != "/": + name = entry.dirent[8].decode("ascii") + if name == "icon.sys": + try: + mc.chdir(current_path) + f = mc.open("icon.sys", "rb") + try: + icon_sys_data = f.read() + finally: + f.close() + icon_sys = ps2iconsys.IconSys(icon_sys_data) + self.info1.SetLabel(icon_sys.get_title("ascii")[0]) + self.info2.SetLabel(icon_sys.get_title("ascii")[1]) + f = mc.open(icon_sys.icon_file_normal, "rb") + try: + icon = f.read() + finally: + f.close() + self.icon_win.load_icon(icon_sys, icon) + return + except Exception: + pass + self.icon_win.load_icon(None, None) + return + + if icon_sys is None: + self.icon_win.load_icon(None, None) + return + + if not (entry.dirent[0] & ps2mc.DF_DIR): self.icon_win.load_icon(None, None) return try: - mc.chdir("/" + entry.dirent[8].decode("ascii")) + dirpath = current_path.rstrip("/") + "/" + entry.dirent[8].decode("ascii") + mc.chdir(dirpath) f = mc.open(icon_sys.icon_file_normal, "rb") try: icon = f.read() @@ -610,7 +676,8 @@ def evt_dirlist_item_focused(self, event): self.icon_win.load_icon(icon_sys, icon) def evt_dirlist_select(self, event): - self.toolbar.EnableTool(self.ID_CMD_IMPORT, self.mc is not None) + at_root = self.dirlist.current_path == "/" + self.toolbar.EnableTool(self.ID_CMD_IMPORT, self.mc is not None and at_root) self.toolbar.EnableTool( self.ID_CMD_EXPORT, len(self.dirlist.selected) > 0, @@ -841,6 +908,75 @@ def evt_cmd_export(self, event): selected = self.dirlist.selected dirtable = self.dirlist.dirtable + current_path = self.dirlist.current_path + + # Inside a subdirectory: extract raw files + if current_path != "/": + names = [dirtable[i].dirent[8].decode("ascii") for i in selected] + if not names: + return + + dir = self.config.get_savefile_dir("") + if len(names) == 1: + fn = wx.FileSelector( + "Extract " + names[0], + dir, + names[0], + "", + "All files (*.*)|*.*", + (wx.FD_OVERWRITE_PROMPT | wx.FD_SAVE), + self, + ) + if fn == "": + return + try: + mc.chdir(current_path) + f_in = mc.open(names[0], "rb") + try: + with open(fn, "wb") as f_out: + while True: + data = f_in.read(1024) + if not data: + break + f_out.write(data) + finally: + f_in.close() + except EnvironmentError as value: + self.mc_error(value, names[0]) + return + dir = os.path.dirname(fn) + if os.path.isabs(dir): + self.config.set_savefile_dir(dir) + self.message_box("Extracted " + fn + " successfully.") + else: + dir = wx.DirSelector("Extract Files", dir, parent=self) + if dir == "": + return + count = 0 + mc.chdir(current_path) + for name in names: + fn = os.path.join(dir, name) + try: + f_in = mc.open(name, "rb") + try: + with open(fn, "wb") as f_out: + while True: + data = f_in.read(1024) + if not data: + break + f_out.write(data) + finally: + f_in.close() + count += 1 + except EnvironmentError as value: + self.mc_error(value, name) + if count > 0: + if os.path.isabs(dir): + self.config.set_savefile_dir(dir) + self.message_box("Extracted %d file(s) successfully." % count) + return + + # At root: export save directories as .psu/.max sfiles = [] for i in selected: dirname = dirtable[i].dirent[8].decode("ascii") @@ -849,7 +985,7 @@ def evt_cmd_export(self, event): longname = ps2save.make_longname(dirname, sf) sfiles.append((dirname, sf, longname)) except EnvironmentError as value: - self.mc_error(value. dirname) + self.mc_error(value, dirname) if len(sfiles) == 0: return diff --git a/mymcplusplus/gui/icon_renderer.py b/mymcplusplus/gui/icon_renderer.py index 65a5e0b..f91fcb9 100644 --- a/mymcplusplus/gui/icon_renderer.py +++ b/mymcplusplus/gui/icon_renderer.py @@ -453,8 +453,8 @@ def paint(self, canvas, animation_time): self._upload_icon() size = canvas.Size - - glViewport(0, 0, size.Width, size.Height) + scale = canvas.GetContentScaleFactor() + glViewport(0, 0, int(size.Width * scale), int(size.Height * scale)) if self.background_color is not None: glClearColor(*self.background_color, 1.0) diff --git a/mymcplusplus/gui/icon_window.py b/mymcplusplus/gui/icon_window.py index 4626a71..d22f75c 100644 --- a/mymcplusplus/gui/icon_window.py +++ b/mymcplusplus/gui/icon_window.py @@ -150,7 +150,7 @@ def make_attrib_list(samples): self.canvas.Bind(wx.EVT_PAINT, self.paint) - self.sizer.Add(self.canvas, wx.EXPAND, wx.EXPAND) + self.sizer.Add(self.canvas, 1, wx.EXPAND) self.SetSizer(self.sizer) #self.config = config = mymcsup.icon_config() diff --git a/mymcplusplus/ps2icon.py b/mymcplusplus/ps2icon.py index 2350c6e..cd75ee3 100644 --- a/mymcplusplus/ps2icon.py +++ b/mymcplusplus/ps2icon.py @@ -91,10 +91,13 @@ def __init__(self, data): length = len(data) offset = 0 - offset = self.__load_header(data, length, offset) - offset = self.__load_vertex_data(data, length, offset) - offset = self.__load_animation_data(data, length, offset) - offset = self.__load_texture(data, length, offset) + try: + offset = self.__load_header(data, length, offset) + offset = self.__load_vertex_data(data, length, offset) + offset = self.__load_animation_data(data, length, offset) + offset = self.__load_texture(data, length, offset) + except FileTooSmall: + print("Warning: Icon file too small, rendering with available data.") if length > offset: print("Warning: Icon file larger than expected.") @@ -209,11 +212,17 @@ def __load_texture(self, data, length, offset): if offset == length: self.texture = [0xFFFF] return offset - - if self.tex_type == 0x7: - return self.__load_texture_uncompressed(data, length, offset) - else: + + # bit 2 (0x04) indicates texture is present + if not (self.tex_type & 0x04): + self.texture = [0xFFFF] + return offset + + # bit 3 (0x08) indicates compressed texture + if self.tex_type & 0x08: return self.__load_texture_compressed(data, length, offset) + else: + return self.__load_texture_uncompressed(data, length, offset) def __load_texture_uncompressed(self, data, length, offset): @@ -247,7 +256,7 @@ def __load_texture_compressed(self, data, length, offset): rle_code = int(data[offset + rle_offset]) | (int(data[offset + rle_offset + 1]) << 8) rle_offset += 2 - if rle_code & 0xff00 == 0xff00: # use the next (0xffff - rle_code) * 2 bytes as they are + if rle_code & 0x8000: # use the next (0x10000 - rle_code) * 2 bytes as they are sublength = (0x10000 - rle_code) * 2 if compressed_size < rle_offset + sublength: raise Corrupt("Compressed data is too short.") @@ -261,18 +270,19 @@ def __load_texture_compressed(self, data, length, offset): else: # repeat next 2 bytes rle_code times rep = rle_code - if compressed_size < rle_offset + 2: - raise Corrupt("Compressed data is too short.") - if tex_offset + rep * 2 > _TEXTURE_SIZE: - raise Corrupt("Decompressed data exceeds texture size.") - - subdata = data[(offset + rle_offset):(offset + rle_offset + 2)] - rle_offset += 2 - - for i in range(rep): - texture_buf[tex_offset] = subdata[0] - texture_buf[tex_offset+1] = subdata[1] - tex_offset += 2 + if rep > 0: + if compressed_size < rle_offset + 2: + raise Corrupt("Compressed data is too short.") + if tex_offset + rep * 2 > _TEXTURE_SIZE: + raise Corrupt("Decompressed data exceeds texture size.") + + subdata = data[(offset + rle_offset):(offset + rle_offset + 2)] + rle_offset += 2 + + for i in range(rep): + texture_buf[tex_offset] = subdata[0] + texture_buf[tex_offset+1] = subdata[1] + tex_offset += 2 assert rle_offset == compressed_size From 05c83fba95ac1937270d0f46f1d8391496a15971 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Gupta Date: Sun, 31 May 2026 13:29:28 +0530 Subject: [PATCH 3/5] fix(test): Update test imports and assertions to match current code In existing tests, rename module imports from mymcplus to mymcplusplus to match the package rename. Fix expected export output to account for "Exporing" typo corrected to "Exporting" in commit 48c1065 and longname default behavior introduced in the same commit (PR #22). Add filter="data" to tar.extractall to resolve Python 3.14 deprecation warning. --- test/conftest.py | 2 +- test/test_ecc.py | 2 +- test/test_iconsys.py | 2 +- test/test_lzari.py | 2 +- test/test_memorycard.py | 46 ++++++++++++++++++++++------------------- 5 files changed, 29 insertions(+), 25 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 8078acb..e94592a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -28,7 +28,7 @@ def data(tmpdir_factory): data_dir = tmpdir_factory.mktemp("data") path = os.path.join(test_dir, "data.tar.gz") tar = tarfile.open(path) - tar.extractall(data_dir.strpath) + tar.extractall(data_dir.strpath, filter="data") return data_dir @pytest.fixture diff --git a/test/test_ecc.py b/test/test_ecc.py index e001352..939c3f8 100644 --- a/test/test_ecc.py +++ b/test/test_ecc.py @@ -16,7 +16,7 @@ # import array -from mymcplus import ps2mc_ecc +from mymcplusplus import ps2mc_ecc import base64 diff --git a/test/test_iconsys.py b/test/test_iconsys.py index 23d18a8..1e285d2 100644 --- a/test/test_iconsys.py +++ b/test/test_iconsys.py @@ -17,7 +17,7 @@ import base64 -from mymcplus import ps2iconsys +from mymcplusplus import ps2iconsys _icon_sys_data = base64.b64decode( b"UFMyRAAAIAAAAAAAcwAAABQAAAAUAAAAPAAAAAAAAAAUAAAAFAAAADwAAAAAAAAAFAAAABQAAAA8" diff --git a/test/test_lzari.py b/test/test_lzari.py index 8634764..bf27f9e 100644 --- a/test/test_lzari.py +++ b/test/test_lzari.py @@ -16,7 +16,7 @@ # import array -from mymcplus.save import lzari +from mymcplusplus.save import lzari def bits_to_str(bits): diff --git a/test/test_memorycard.py b/test/test_memorycard.py index 827f2b9..a4974b5 100644 --- a/test/test_memorycard.py +++ b/test/test_memorycard.py @@ -15,7 +15,7 @@ # along with mymc+. If not, see . # -from mymcplus import mymc +from mymcplusplus import mymc def md5(fn): @@ -65,7 +65,7 @@ def test_extract(capsys, data, tmpdir): def test_add(monkeypatch, capsys, mc01_copy, tmpdir): - from mymcplus import ps2mc + from mymcplusplus import ps2mc patch_fixed_time(monkeypatch, ps2mc) patch_localtime(monkeypatch) @@ -200,7 +200,7 @@ def test_dir(capsys, data): def test_format(monkeypatch, capsys, tmpdir): - from mymcplus import ps2mc + from mymcplusplus import ps2mc patch_fixed_time(monkeypatch, ps2mc) mc_file = tmpdir.join("mc.ps2").strpath @@ -217,7 +217,7 @@ def test_format(monkeypatch, capsys, tmpdir): def test_mkdir(monkeypatch, capsys, mc01_copy): - from mymcplus import ps2mc + from mymcplusplus import ps2mc patch_fixed_time(monkeypatch, ps2mc) mc_file = mc01_copy.join("mc01.ps2").strpath @@ -267,10 +267,12 @@ def test_export_psu(capsys, data, tmpdir): "export", "-d", tmpdir.strpath, "-p", "BESCES-50501REZ"]) output = capsys.readouterr() - assert output.out == "Exporing BESCES-50501REZ to BESCES-50501REZ.psu\n" + # "Exporing" typo fixed to "Exporting" in code; longname is default since PR #22 + assert output.out == "Exporting BESCES-50501REZ to SCES-50501 Rez (DD5DAEA3).psu\n" assert output.err == "" - assert md5(tmpdir.join("BESCES-50501REZ.psu").strpath) == "d86c82e559c8250c894fbbc4405d8789" + # Filename uses make_longname by default since PR #22 + assert md5(tmpdir.join("SCES-50501 Rez (DD5DAEA3).psu").strpath) == "d86c82e559c8250c894fbbc4405d8789" def test_export_max(capsys, data, tmpdir): @@ -281,13 +283,15 @@ def test_export_max(capsys, data, tmpdir): "export", "-d", tmpdir.strpath, "-m", "BESCES-50501REZ"]) output = capsys.readouterr() - assert output.out == "Exporing BESCES-50501REZ to BESCES-50501REZ.max\n" + # "Exporing" typo fixed to "Exporting" in code; longname is default since PR #22 + assert output.out == "Exporting BESCES-50501REZ to SCES-50501 Rez (DD5DAEA3).max\n" - assert md5(tmpdir.join("BESCES-50501REZ.max").strpath) == "3f63d38668a0a5a5fa508ab8c3bb469a" + # Filename uses make_longname by default since PR #22 + assert md5(tmpdir.join("SCES-50501 Rez (DD5DAEA3).max").strpath) == "3f63d38668a0a5a5fa508ab8c3bb469a" def test_import_psu(monkeypatch, capsys, data, mc02_copy): - from mymcplus import ps2mc + from mymcplusplus import ps2mc patch_fixed_time(monkeypatch, ps2mc) mc_file = mc02_copy.join("mc02.ps2").strpath @@ -305,9 +309,9 @@ def test_import_psu(monkeypatch, capsys, data, mc02_copy): def test_import_max(monkeypatch, capsys, data, mc02_copy): - from mymcplus import ps2mc - from mymcplus import ps2mc_dir - from mymcplus.save import ps2save + from mymcplusplus import ps2mc + from mymcplusplus import ps2mc_dir + from mymcplusplus.save import ps2save patch_fixed_time(monkeypatch, ps2mc) patch_fixed_time(monkeypatch, ps2mc_dir) patch_fixed_time(monkeypatch, ps2save) @@ -326,7 +330,7 @@ def test_import_max(monkeypatch, capsys, data, mc02_copy): def test_import_sps(monkeypatch, capsys, data, mc02_copy): - from mymcplus import ps2mc + from mymcplusplus import ps2mc patch_fixed_time(monkeypatch, ps2mc) mc_file = mc02_copy.join("mc02.ps2").strpath @@ -344,7 +348,7 @@ def test_import_sps(monkeypatch, capsys, data, mc02_copy): def test_import_xps(monkeypatch, capsys, data, mc02_copy): - from mymcplus import ps2mc + from mymcplusplus import ps2mc patch_fixed_time(monkeypatch, ps2mc) mc_file = mc02_copy.join("mc02.ps2").strpath @@ -362,9 +366,9 @@ def test_import_xps(monkeypatch, capsys, data, mc02_copy): def test_import_cbs(monkeypatch, capsys, data, mc02_copy): - from mymcplus import ps2mc - from mymcplus import ps2mc_dir - from mymcplus.save import ps2save + from mymcplusplus import ps2mc + from mymcplusplus import ps2mc_dir + from mymcplusplus.save import ps2save patch_fixed_time(monkeypatch, ps2mc) patch_fixed_time(monkeypatch, ps2mc_dir) patch_fixed_time(monkeypatch, ps2save) @@ -384,8 +388,8 @@ def test_import_cbs(monkeypatch, capsys, data, mc02_copy): def test_import_psv_ps2(monkeypatch, capsys, data, mc02_copy): - from mymcplus import ps2mc - from mymcplus import ps2mc_dir + from mymcplusplus import ps2mc + from mymcplusplus import ps2mc_dir patch_fixed_time(monkeypatch, ps2mc) patch_fixed_time(monkeypatch, ps2mc_dir) @@ -404,8 +408,8 @@ def test_import_psv_ps2(monkeypatch, capsys, data, mc02_copy): def test_import_psv_ps1(monkeypatch, capsys, data, mc02_copy): - from mymcplus import ps2mc - from mymcplus import ps2mc_dir + from mymcplusplus import ps2mc + from mymcplusplus import ps2mc_dir patch_fixed_time(monkeypatch, ps2mc) patch_fixed_time(monkeypatch, ps2mc_dir) From 41fc81fd415719b888a9e21c1fb673ffb6d459c3 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Gupta Date: Sun, 31 May 2026 13:40:24 +0530 Subject: [PATCH 4/5] docs: Add tree-style directory listing examples to README Document the new ls -R, ls -R -l, and ls -R -l -c flags with example output showing tree structure and aligned column display. --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/README.md b/README.md index 94e2d7b..3873415 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,48 @@ Options: -e, --no-ecc Create virtual memory card without ecc. Useful for SD2PSX/MemCard PRO2 ``` +### Tree-style Directory Listing + +Use `ls -R` to display the memory card's directory structure as a tree: + +``` +mymcplusplus card.mc2 ls -R +``` + +``` +/ +├── BASLUS-21313FrndsUS +│ ├── BASLUS-21313FrndsUS +│ ├── icon.sys +│ └── icon1.ico +└── PSDCGT-01412 + ├── game.ico + └── icon.sys +``` + +Add `-l` to show mode bits, file size, and timestamps: + +``` +mymcplusplus card.mc2 ls -R -l +``` + +``` +/ +├── BASLUS-21313FrndsUS rwx--d----+---- 5 2025-09-26 21:50:42 +│ ├── BASLUS-21313FrndsUS rwx-f--8--+---- 47490 2025-09-26 21:50:41 +│ ├── icon.sys rwx-f--8--+---- 964 2025-09-26 21:50:41 +│ └── icon1.ico rwx-f--8--+---- 41528 2025-09-26 21:50:42 +└── PSDCGT-01412 rwx--d----+---- 13 2025-06-11 21:53:58 + ├── game.ico rwx-f--8--+---- 33688 2025-05-25 13:46:31 + └── icon.sys rwx-f--8--+---- 964 2025-05-25 13:46:32 +``` + +Add `-c` to show creation times instead of modification times: + +``` +mymcplusplus card.mc2 ls -R -l -c +``` + It is always necessary to specify the path to a memory card image with `-i ` first. For example: From ea9d9ebdecc49758976ad32359fbbc450e59ad85 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Gupta Date: Sun, 31 May 2026 14:44:06 +0530 Subject: [PATCH 5/5] chore: Bump version to 3.2.0 Add minor version bump for new tree-style recursive directory listing and GUI directory navigation features. --- mymcplusplus/verbuild.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mymcplusplus/verbuild.py b/mymcplusplus/verbuild.py index 0988c7f..4f535ee 100644 --- a/mymcplusplus/verbuild.py +++ b/mymcplusplus/verbuild.py @@ -15,5 +15,5 @@ # along with mymc+. If not, see . # -MYMC_VERSION_BUILD = r'''0''' +MYMC_VERSION_BUILD = r'''2''' MYMC_VERSION_MAJOR = r'''3''' diff --git a/setup.py b/setup.py index 00da978..d4cb7be 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ setup( name="mymcplusplus", - version="3.1.0", + version="3.2.0", description="A PlayStation 2 memory card manager", long_description=long_description, long_description_content_type="text/plain",