sceIo: Implement the memory stick path devctls the pre-3.80 VSH browsers use

- Fixes #22890

Before 3.80, the XMB's music browser doesn't list a folder with
sceIoDopen/sceIoDread. It asks the FAT driver directly, with the path as a
list of UTF-16 components: devctl 0x02415864 starts a directory search and
returns a dir fd, read entry by entry with ioctl 0x02425069. 0x02435865 and
0x02435860 are getstat and open, with the path given the same way. None of
these were implemented, so firmware 3.71 and older found no tracks.

The argument layouts are from fatmsmod.prx and music_browser.prx in 3.71.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Henrik RydgårdandClaude Opus 5.5 committed 2026-11-03 01:07:12 +01:00
1 parent f416c318f4
commit 103f68f199
1 file changed
+251
+251
View File
@@ -24,6 +24,7 @@
#include "Common/Thread/ThreadUtil.h"
#include "Common/Profiler/Profiler.h"
#include "Common/TimeUtil.h"
#include "Common/Data/Encoding/Utf8.h"
#include "Common/File/FileUtil.h"
#include "Common/Serialize/SerializeFuncs.h"
@@ -1764,6 +1765,10 @@ struct DeviceSize {
u32_le sectorCount;
};
static u32 IoOpenFatSearch(u32 argAddr, int argLen);
static u32 IoGetstatFatComponentPath(u32 argAddr, int argLen);
static u32 IoOpenFatComponentPath(u32 argAddr, int argLen);
static u32 sceIoDevctl(const char *name, int cmd, u32 argAddr, int argLen, u32 outPtr, int outLen) {
if (strcmp(name, "emulator:")) {
DEBUG_LOG(Log::sceIo,"sceIoDevctl(\"%s\", %08x, %08x, %i, %08x, %i)", name, cmd, argAddr, argLen, outPtr, outLen);
@@ -2020,6 +2025,15 @@ static u32 sceIoDevctl(const char *name, int cmd, u32 argAddr, int argLen, u32 o
case 0x02425856:
// Used by VSH, no clue what it should do. Let's just return 0.
return hleLogError(Log::sceIo, 0, "Unknown memstick devctl: %08x", cmd);
case 0x02415864:
// Directory search, used by the VSH media browsers before 3.80.
return IoOpenFatSearch(argAddr, argLen);
case 0x02435865:
// Getstat, with the path given the same way as 0x02415864.
return IoGetstatFatComponentPath(argAddr, argLen);
case 0x02435860:
// Open, with the path given the same way as 0x02415864.
return IoOpenFatComponentPath(argAddr, argLen);
}
}
@@ -2748,6 +2762,232 @@ static u32 sceIoDclose(int id) {
return hleLogDebug(Log::sceIo, kernelObjects.Destroy<DirListing>(id));
}
// Reads a null-terminated UTF-16 string from guest memory.
static bool ReadGuestUTF16(u32 addr, std::string *out) {
std::u16string str;
for (int i = 0; i < 256; i++) {
if (!Memory::IsValidRange(addr + i * 2, 2)) {
return false;
}
const char16_t c = Memory::ReadUnchecked_U16(addr + i * 2);
if (c == 0) {
*out = ConvertUCS2ToUTF8(str);
return true;
}
str.push_back(c);
}
return false;
}
// Reads a null-terminated list of pointers to UTF-16 strings.
static bool ReadGuestUTF16List(u32 addr, std::vector<std::string> *out) {
for (int i = 0; i < 64; i++) {
if (!Memory::IsValid4AlignedAddress(addr + i * 4)) {
return false;
}
const u32 ptr = Memory::ReadUnchecked_U32(addr + i * 4);
if (ptr == 0) {
return true;
}
std::string str;
if (!ReadGuestUTF16(ptr, &str)) {
return false;
}
out->push_back(str);
}
return false;
}
// A path given as a list of UTF-16 components, like "/", "PSP", "MUSIC".
static bool ReadFatComponentPath(u32 addr, std::string *path) {
std::vector<std::string> components;
if (!ReadGuestUTF16List(addr, &components)) {
return false;
}
*path = "ms0:";
for (const std::string &component : components) {
if (component != "/") {
*path += "/" + component;
}
}
if (*path == "ms0:") {
*path += "/";
}
return true;
}
// sceIoDevctl("ms0:", 0x02435865): sceIoGetstat with the path given as UTF-16 components (see
// IoOpenFatSearch). The argument is { path list, SceIoStat *, options }.
static u32 IoGetstatFatComponentPath(u32 argAddr, int argLen) {
if (argLen < 12 || !Memory::IsValid4AlignedAddress(argAddr)) {
return hleLogError(Log::sceIo, SCE_KERNEL_ERROR_ERRNO_INVALID_ARGUMENT, "bad args");
}
const u32 pathListAddr = Memory::ReadUnchecked_U32(argAddr);
const u32 statAddr = Memory::ReadUnchecked_U32(argAddr + 4);
const u32 optionsAddr = Memory::ReadUnchecked_U32(argAddr + 8);
std::string path;
if (!pathListAddr || !optionsAddr || !Memory::IsValidRange(statAddr, sizeof(SceIoStat)) || !ReadFatComponentPath(pathListAddr, &path)) {
return hleLogError(Log::sceIo, SCE_KERNEL_ERROR_ERRNO_INVALID_ARGUMENT, "bad args");
}
const PSPFileInfo info = pspFileSystem.GetFileInfo(path);
if (!info.exists) {
return hleLogWarning(Log::sceIo, SCE_KERNEL_ERROR_ERRNO_FILE_NOT_FOUND, "%s not found", path.c_str());
}
__IoGetStat((SceIoStat *)Memory::GetPointerUnchecked(statAddr), info, true);
return hleLogDebug(Log::sceIo, 0, "%s", path.c_str());
}
// sceIoDevctl("ms0:", 0x02435860): sceIoOpen with the path given as UTF-16 components (see
// IoOpenFatSearch). The argument is { path list, flags, mode, options }, and the result a file fd.
static u32 IoOpenFatComponentPath(u32 argAddr, int argLen) {
if (argLen < 16 || !Memory::IsValid4AlignedAddress(argAddr)) {
return hleLogError(Log::sceIo, SCE_KERNEL_ERROR_ERRNO_INVALID_ARGUMENT, "bad args");
}
const u32 pathListAddr = Memory::ReadUnchecked_U32(argAddr);
const int flags = (int)Memory::ReadUnchecked_U32(argAddr + 4);
const int mode = (int)Memory::ReadUnchecked_U32(argAddr + 8);
const u32 optionsAddr = Memory::ReadUnchecked_U32(argAddr + 12);
std::string path;
if (!pathListAddr || !optionsAddr || !ReadFatComponentPath(pathListAddr, &path)) {
return hleLogError(Log::sceIo, SCE_KERNEL_ERROR_ERRNO_INVALID_ARGUMENT, "bad args");
}
int error = 0;
FileNode *f = __IoOpen(error, path.c_str(), flags, mode);
if (!f) {
return hleLogWarning(Log::sceIo, error, "%s: open failed", path.c_str());
}
const int id = __IoAllocFd(f);
if (id < 0) {
kernelObjects.Destroy<FileNode>(f->GetUID());
return hleLogError(Log::sceIo, id, "out of fds");
}
asyncParams[id].priority = asyncDefaultPriority;
return hleLogDebug(Log::sceIo, id, "%s", path.c_str());
}
// Case-insensitive, like FAT. Only * and ?.
static bool FatPatternMatch(const char *pattern, const char *name) {
if (*pattern == '\0') {
return *name == '\0';
}
if (*pattern == '*') {
for (const char *n = name; ; n++) {
if (FatPatternMatch(pattern + 1, n)) {
return true;
}
if (*n == '\0') {
return false;
}
}
}
if (*name == '\0') {
return false;
}
if (*pattern != '?' && tolower((unsigned char)*pattern) != tolower((unsigned char)*name)) {
return false;
}
return FatPatternMatch(pattern + 1, name + 1);
}
// The memory stick FAT driver's directory search, which the VSH's media browsers used before
// 3.80 switched them to sceIoDopen/sceIoDread. sceIoDevctl("ms0:", 0x02415864) takes three
// pointers: a null-terminated list of UTF-16 path components ("/", "PSP", "MUSIC"), a list of
// UTF-16 name patterns, and an options struct. It returns a directory fd, read with
// sceIoIoctl(0x02425069) and closed with sceIoDclose.
// From fatmsmod.prx (3.71): the devctl opens "ms0:....****DOPEN MAGIC NAME****...." and passes
// the arguments on to the dir with ioctl 0x0243D85F, which starts a FAT find-first.
static u32 IoOpenFatSearch(u32 argAddr, int argLen) {
if (argLen < 12 || !Memory::IsValid4AlignedAddress(argAddr)) {
return hleLogError(Log::sceIo, SCE_KERNEL_ERROR_ERRNO_INVALID_ARGUMENT, "bad args");
}
const u32 pathListAddr = Memory::ReadUnchecked_U32(argAddr);
const u32 patternListAddr = Memory::ReadUnchecked_U32(argAddr + 4);
const u32 optionsAddr = Memory::ReadUnchecked_U32(argAddr + 8);
if (!pathListAddr || !patternListAddr || !optionsAddr) {
return hleLogError(Log::sceIo, SCE_KERNEL_ERROR_ERRNO_INVALID_ARGUMENT, "null arg");
}
std::string path;
std::vector<std::string> patterns;
if (!ReadFatComponentPath(pathListAddr, &path) || !ReadGuestUTF16List(patternListAddr, &patterns)) {
return hleLogError(Log::sceIo, SCE_KERNEL_ERROR_ERRNO_INVALID_ARGUMENT, "bad path or pattern list");
}
bool exists = false;
std::vector<PSPFileInfo> listing = pspFileSystem.GetDirListing(path, &exists);
if (!exists) {
return hleLogWarning(Log::sceIo, SCE_KERNEL_ERROR_ERRNO_FILE_NOT_FOUND, "%s not found", path.c_str());
}
DirListing *dir = new DirListing();
for (const PSPFileInfo &info : listing) {
if (info.name == "." || info.name == "..") {
continue;
}
for (const std::string &pattern : patterns) {
if (FatPatternMatch(pattern.c_str(), info.name.c_str())) {
dir->listing.push_back(info);
break;
}
}
}
dir->index = 0;
dir->name = path;
const SceUID id = kernelObjects.Create(dir);
return hleLogDebug(Log::sceIo, id, "%s: %d matching entries", path.c_str(), (int)dir->listing.size());
}
// Reads the next entry of a search started by IoOpenFatSearch. The 0x224 byte argument holds:
// +0x000 SceIoDirent *, filled as by sceIoDread, d_private in the old layout (short name at
// +0, long name at +13)
// +0x004 the 8.3 name in UTF-16, 13 chars
// +0x01E the long name in UTF-16, 256 chars, empty if the entry has none
// +0x220 int *, gets the status of converting the 8.3 name to UTF-16 (1 = lossy, which the
// music browser skips if there's no long name either)
// Returns 1 per entry, 0 at the end.
static int IoReadFatSearchEntry(DirListing *dir, u32 argAddr, u32 argLen) {
if (argLen != 0x224 || !Memory::IsValidRange(argAddr, 0x224)) {
return SCE_KERNEL_ERROR_ERRNO_INVALID_ARGUMENT;
}
const u32 direntAddr = Memory::ReadUnchecked_U32(argAddr);
if (!Memory::IsValidRange(direntAddr, sizeof(SceIoDirEnt))) {
return SCE_KERNEL_ERROR_ERRNO_INVALID_ARGUMENT;
}
if (dir->index >= (int)dir->listing.size()) {
return 0;
}
const int index = dir->index++;
const PSPFileInfo &info = dir->listing[index];
const std::string &shortName = dir->ShortName(index);
SceIoDirEnt *entry = (SceIoDirEnt *)Memory::GetPointerUnchecked(direntAddr);
__IoGetStat(&entry->d_stat, info, true);
strcpy_limit(entry->d_name, info.name.c_str(), ARRAY_SIZE(entry->d_name));
if (Memory::IsValidRange(entry->d_private, 13 + 256)) {
strcpy_limit((char *)Memory::GetPointerUnchecked(entry->d_private), shortName.c_str(), 13);
strcpy_limit((char *)Memory::GetPointerUnchecked(entry->d_private + 13), entry->d_name, 256);
}
auto writeUTF16 = [](u32 addr, const std::string &str, int maxChars) {
const std::u16string utf16 = ConvertUTF8ToUCS2(str);
int i = 0;
for (; i < (int)utf16.size() && i < maxChars - 1; i++) {
Memory::WriteUnchecked_U16(utf16[i], addr + i * 2);
}
Memory::WriteUnchecked_U16(0, addr + i * 2);
};
writeUTF16(argAddr + 0x4, shortName, 13);
// On FAT, a name that's already a valid 8.3 name has no long name entry.
writeUTF16(argAddr + 0x1E, info.name == shortName ? std::string() : info.name, 256);
const u32 statusAddr = Memory::ReadUnchecked_U32(argAddr + 0x220);
if (Memory::IsValid4AlignedAddress(statusAddr)) {
Memory::WriteUnchecked_U32(0, statusAddr);
}
return 1;
}
int __IoIoctl(u32 id, u32 cmd, u32 indataPtr, u32 inlen, u32 outdataPtr, u32 outlen, int &usec) {
u32 error;
FileNode *f = __IoGetFd(id, error);
@@ -3028,6 +3268,17 @@ int __IoIoctl(u32 id, u32 cmd, u32 indataPtr, u32 inlen, u32 outdataPtr, u32 out
}
u32 sceIoIoctl(u32 id, u32 cmd, u32 indataPtr, u32 inlen, u32 outdataPtr, u32 outlen) {
if (cmd == 0x02425069) {
// Next entry of a memory stick directory search. That works on a directory fd.
u32 error;
if (DirListing *dir = kernelObjects.Get<DirListing>(id, error)) {
const int result = IoReadFatSearchEntry(dir, indataPtr, inlen);
if (result == 1) {
return hleLogDebug(Log::sceIo, result, "%s", dir->listing[dir->index - 1].name.c_str());
}
return hleLogDebugOrError(Log::sceIo, result);
}
}
int usec = 0;
int result = __IoIoctl(id, cmd, indataPtr, inlen, outdataPtr, outlen, usec);
if (usec != 0) {