From eee153b2209c2aa2cd90aee7fa22a7bec1075022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Wed, 21 Oct 2026 14:17:51 +0200 Subject: [PATCH] VSH: An empty UMD drive, and inserting a disc over the WebSocket debugger The XMB, booted without a game, now starts with an empty drive: when vsh_module loads with nothing at disc0:, the drive is marked empty, and activating it tells the shell's drive callback (SceVshMediaDetectUMD) there's no disc. Activation used to report a readable disc regardless, so the shell asked for its type and showed "The disc could not be read." The disc type devctl (0x01F20001) answers 0 without a disc, as JPCSP does. game.umd.insert (path) mounts a disc image into the empty drive and calls the drive callback as inserting one does; game.umd.eject takes it out. UmdMount() is UmdReplace() without its need for a disc already in. Inserted into the 6.61 XMB, Daxter shows in the Game column with its icon, background and animated preview, and launching it boots the game to its title menu. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/Debugger/WebSocket/GameSubscriber.cpp | 52 ++++++++++++++++++++++ Core/Debugger/WebSocket/GameSubscriber.h | 2 + Core/HLE/sceIo.cpp | 3 +- Core/HLE/sceKernelModule.cpp | 5 +++ Core/HLE/sceUmd.cpp | 42 ++++++++++++++++- Core/HLE/sceUmd.h | 4 ++ Core/Loaders.cpp | 5 ++- Core/Loaders.h | 2 + docs/VSHBootInvestigation.md | 17 ++++--- docs/WebSocketDebugger.md | 2 +- 10 files changed, 124 insertions(+), 10 deletions(-) diff --git a/Core/Debugger/WebSocket/GameSubscriber.cpp b/Core/Debugger/WebSocket/GameSubscriber.cpp index 5a84b1524b..b430264df8 100644 --- a/Core/Debugger/WebSocket/GameSubscriber.cpp +++ b/Core/Debugger/WebSocket/GameSubscriber.cpp @@ -40,6 +40,7 @@ #include "Core/Debugger/WebSocket/WebSocketUtils.h" #include "Core/ELF/ParamSFO.h" #include "Core/HLE/sceDisplay.h" +#include "Core/HLE/sceUmd.h" #include "Core/RetroAchievements.h" #include "Core/SaveState.h" #include "Core/System.h" @@ -245,6 +246,8 @@ DebuggerSubscriber *WebSocketGameInit(DebuggerEventHandlerMap &map) { map["game.state.verify"] = [p](DebuggerRequest &req) { p->StateOperation(req, StateOp::VERIFY); }; map["game.speed.get"] = &WebSocketGameSpeedGet; map["game.speed.set"] = &WebSocketGameSpeedSet; + map["game.umd.insert"] = &WebSocketGameUmdInsert; + map["game.umd.eject"] = &WebSocketGameUmdEject; map["version"] = &WebSocketVersion; return p; @@ -468,3 +471,52 @@ void WebSocketVersion(DebuggerRequest &req) { else json.writeString("path", path); } + +// Put a disc in the drive (game.umd.insert) +// +// For the XMB, booted with --vsh and no game, whose drive starts out empty. The shell gets the +// drive's callback, as on a PSP. To swap discs in a running game, use the Switch UMD menu. +// +// Parameters: +// - path: string, the disc image (ISO, CSO, ...) or extracted disc directory to insert. +// +// Response (same event name) with no extra data, or error if a disc is already in the drive or the +// image can't be mounted. +void WebSocketGameUmdInsert(DebuggerRequest &req) { + std::string path; + if (!req.ParamString("path", &path)) + return; + + bool running = false; + bool inserted = false; + std::string error; + Core_RunOnCPUThread([&] { + running = PSP_GetBootState() == BootState::Complete; + if (running) + inserted = __UmdInsert(Path(path), &error); + }); + + if (!running) + return req.Fail("Game not running"); + if (!inserted) + return req.Fail("Couldn't insert the disc: " + error); + req.Respond(); +} + +// Take the disc out of the drive (game.umd.eject) +// +// No parameters. +// +// Response (same event name) with no extra data. Ejecting an empty drive does nothing. +void WebSocketGameUmdEject(DebuggerRequest &req) { + bool running = false; + Core_RunOnCPUThread([&] { + running = PSP_GetBootState() == BootState::Complete; + if (running) + __UmdEject(); + }); + + if (!running) + return req.Fail("Game not running"); + req.Respond(); +} diff --git a/Core/Debugger/WebSocket/GameSubscriber.h b/Core/Debugger/WebSocket/GameSubscriber.h index 68943c1af7..d5807d1b6b 100644 --- a/Core/Debugger/WebSocket/GameSubscriber.h +++ b/Core/Debugger/WebSocket/GameSubscriber.h @@ -25,4 +25,6 @@ void WebSocketGameReset(DebuggerRequest &req); void WebSocketGameStatus(DebuggerRequest &req); void WebSocketGameSpeedGet(DebuggerRequest &req); void WebSocketGameSpeedSet(DebuggerRequest &req); +void WebSocketGameUmdInsert(DebuggerRequest &req); +void WebSocketGameUmdEject(DebuggerRequest &req); void WebSocketVersion(DebuggerRequest &req); diff --git a/Core/HLE/sceIo.cpp b/Core/HLE/sceIo.cpp index 74b523ae0f..2c34e5fecf 100644 --- a/Core/HLE/sceIo.cpp +++ b/Core/HLE/sceIo.cpp @@ -1766,7 +1766,8 @@ static u32 sceIoDevctl(const char *name, int cmd, u32 argAddr, int argLen, u32 o case 0x01F20001: // Get UMD disc type if (Memory::IsValid4AlignedRange(outPtr, 8) && outLen >= 8) { - Memory::WriteUnchecked_U32(0x10, outPtr + 4); // Always return game disc (if present) + // A game disc, or none in an empty drive (as JPCSP answers without a disc). + Memory::WriteUnchecked_U32(__UmdIsInserted() ? PSP_UMD_TYPE_GAME : 0, outPtr + 4); return hleLogDebug(Log::sceIo, 0); } else { return hleLogError(Log::sceIo, SCE_ERROR_MEMSTICK_DEVCTL_BAD_PARAMS); diff --git a/Core/HLE/sceKernelModule.cpp b/Core/HLE/sceKernelModule.cpp index c55a59e229..c580468901 100644 --- a/Core/HLE/sceKernelModule.cpp +++ b/Core/HLE/sceKernelModule.cpp @@ -73,6 +73,7 @@ #include "Core/HLE/scePsmf.h" #include "Core/HLE/sceAtrac.h" #include "Core/HLE/sceIo.h" +#include "Core/HLE/sceUmd.h" #include "Core/HLE/KernelWaitHelpers.h" #include "Core/ELF/ParamSFO.h" @@ -1897,6 +1898,10 @@ static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 load if ((module->nm.attribute & PSP_MODULE_VSH_MODE) != 0 || equals(modinfo->name, "vsh_module")) { g_runningVSH = true; INFO_LOG(Log::sceModule, "VSH mode module detected: %s", modinfo->name); + // The shell booted without a game: its drive starts out empty, for a disc to go in later. + if (!pspFileSystem.GetSystem("disc0:")) { + __UmdEject(); + } } // OK, even if it's an ELF module, it might be one we shouldn't fully load and execute! diff --git a/Core/HLE/sceUmd.cpp b/Core/HLE/sceUmd.cpp index fec369bbc0..8da4ad0579 100644 --- a/Core/HLE/sceUmd.cpp +++ b/Core/HLE/sceUmd.cpp @@ -177,6 +177,10 @@ static void __KernelUmdActivate() if (sceKernelGetCompiledSdkVersion() != 0) { notifyArg |= PSP_UMD_READY; } + // An empty drive (the XMB's) says so: the shell otherwise asks for the disc and reports it unreadable. + if (!UMDInserted) { + notifyArg = PSP_UMD_NOT_PRESENT; + } if (driveCBId != 0) __KernelNotifyCallback(driveCBId, notifyArg); @@ -187,7 +191,7 @@ static void __KernelUmdActivate() static void __KernelUmdDeactivate() { - u32 notifyArg = PSP_UMD_PRESENT | PSP_UMD_READY; + u32 notifyArg = UMDInserted ? PSP_UMD_PRESENT | PSP_UMD_READY : PSP_UMD_NOT_PRESENT; if (driveCBId != 0) __KernelNotifyCallback(driveCBId, notifyArg); @@ -506,6 +510,42 @@ void __UmdReplace(const Path &filepath) { __KernelNotifyCallback(driveCBId, notifyArg); } +bool __UmdInsert(const Path &filepath, std::string *error) { + if (UMDInserted) { + *error = "a disc is already in the drive"; + return false; + } + FileLoader *fileLoader; + if (!UmdMount(filepath, &fileLoader, *error)) { + return false; + } + Achievements::ChangeUMD(filepath, fileLoader); + + UMDInserted = true; + UmdWakeThreads(); + if (driveCBId != 0) + __KernelNotifyCallback(driveCBId, PSP_UMD_PRESENT | PSP_UMD_READABLE | PSP_UMD_CHANGED); + return true; +} + +void __UmdEject() { + if (!UMDInserted) { + return; + } + UMDInserted = false; + pspFileSystem.Unmount("umd0:"); + pspFileSystem.Unmount("umd1:"); + pspFileSystem.Unmount("umd:"); + pspFileSystem.Unmount("disc0:"); + UmdWakeThreads(); + if (driveCBId != 0) + __KernelNotifyCallback(driveCBId, PSP_UMD_NOT_PRESENT | PSP_UMD_CHANGED); +} + +bool __UmdIsInserted() { + return UMDInserted; +} + bool getUMDReplacePermit() { return g_UMDReplacePermit; } diff --git a/Core/HLE/sceUmd.h b/Core/HLE/sceUmd.h index 7d45925d8e..165f7e3153 100644 --- a/Core/HLE/sceUmd.h +++ b/Core/HLE/sceUmd.h @@ -40,6 +40,10 @@ void __UmdInit(); void __UmdDoState(PointerWrap &p); void __UmdReplace(const Path &filepath); +// A drive that can be empty: the XMB's, booted without a game. Insert fails if a disc is in. +bool __UmdInsert(const Path &filepath, std::string *error); +void __UmdEject(); +bool __UmdIsInserted(); bool getUMDReplacePermit(); void Register_sceUmdUser(); diff --git a/Core/Loaders.cpp b/Core/Loaders.cpp index 6b13248cc1..095dd3b321 100644 --- a/Core/Loaders.cpp +++ b/Core/Loaders.cpp @@ -353,7 +353,10 @@ bool UmdReplace(const Path &filepath, FileLoader **fileLoader, std::string &erro error = "has no disc"; return false; } + return UmdMount(filepath, fileLoader, error); +} +bool UmdMount(const Path &filepath, FileLoader **fileLoader, std::string &error) { FileLoader *loadedFile = ConstructFileLoader(filepath); if (!loadedFile || !loadedFile->Exists()) { @@ -374,7 +377,7 @@ bool UmdReplace(const Path &filepath, FileLoader **fileLoader, std::string &erro case IdentifiedFileType::PSP_ISO_NP: case IdentifiedFileType::PSP_DISC_DIRECTORY: if (!MountGameISO(loadedFile, &error)) { - error = "mounting the replaced ISO failed: " + error; + error = "mounting the ISO failed: " + error; return false; } break; diff --git a/Core/Loaders.h b/Core/Loaders.h index 13108bef46..acd696f6b3 100644 --- a/Core/Loaders.h +++ b/Core/Loaders.h @@ -163,6 +163,8 @@ Path ResolvePBPFile(const Path &filename); IdentifiedFileType Identify_File(FileLoader *fileLoader, std::string *errorString); bool UmdReplace(const Path &filepath, FileLoader **fileLoader, std::string &error); +// Mounts a disc image at umd0:, umd1:, umd: and disc0:, whether or not one was there. +bool UmdMount(const Path &filepath, FileLoader **fileLoader, std::string &error); enum class ArchiveType { ZIP, diff --git a/docs/VSHBootInvestigation.md b/docs/VSHBootInvestigation.md index 76d4a882ca..1db9dbfedb 100644 --- a/docs/VSHBootInvestigation.md +++ b/docs/VSHBootInvestigation.md @@ -35,12 +35,17 @@ code is not correct." Cancel it with circle and the menu is there and usable. Two separate things: -- **It thinks a disc is inserted**, because nothing in PPSSPP models an empty drive. - `sceUmd.cpp` reports `PSP_UMD_PRESENT | PSP_UMD_READY` unconditionally, and `sceIoDevctl` - `0x01F20001` (get disc type) always answers `0x10`, "game disc". JPCSP answers `0` when no ISO is - loaded, which is why it never asks the next question. Fixing this properly means giving those two - a notion of "no disc" - low risk for games, which always have one, but it is on a path every game - uses. +- **It thought a disc was inserted**, because nothing in PPSSPP modelled an empty drive. Now a shell + booted without a game starts with an empty drive: `sceUmdActivate` tells the shell's drive callback + (`SceVshMediaDetectUMD`) there's no disc, and `sceIoDevctl` `0x01F20001` (get disc type) answers `0`, + as JPCSP does. Activation used to report a readable disc regardless, so the shell asked for its type, + got nothing it knew, and showed "The disc could not be read." + + A disc goes in with the WebSocket debugger's `game.umd.insert` (`path`), which mounts the image + and calls the drive callback the way a PSP does when a disc is inserted, and comes out with + `game.umd.eject`. The shell then lists the disc in the Game column with its icon, background and + animated preview, and launching it boots the game through `sceKernelLoadExecVSHDisc` (Daxter on + 6.61, to its title menu). - **It then asked whether the region matched and we didn't answer.** `sceIoDevctl` `0x01E18030` is the region check, and unusually it answers through the *return value* (1 matches, 0 doesn't) rather than an output buffer. It was unimplemented, so the VSH read the failure as a mismatch and diff --git a/docs/WebSocketDebugger.md b/docs/WebSocketDebugger.md index 9d38871071..2258345e4a 100644 --- a/docs/WebSocketDebugger.md +++ b/docs/WebSocketDebugger.md @@ -271,7 +271,7 @@ file - this is just an index. | Category | Events | File | |---|---|---| -| Game/version | `game.reset`, `game.status`, `game.state.load` (queue loading a `.ppst` file given as `path`, or a `slot` of the running game, applied at the end of the next frame the CPU runs; with `wait: true` the response comes once it's done, or as an error if it failed), `game.state.save` (the same for saving one), `game.state.rewind` (the same for the newest rewind snapshot, which it keeps, so only a rewind within 0.8 seconds of the last steps further back; needs `RewindSnapshotInterval` set, and the `wait` response's `metadata` says how far back that is in emulated time), `game.state.verify` (the same for checking that saving the current state and reading it back agree), `game.speed.get/set` (emulation speed - unlimited fast-forward, or a percentage of 60 FPS; see below), `version` | `GameSubscriber.cpp` | +| Game/version | `game.reset`, `game.status`, `game.state.load` (queue loading a `.ppst` file given as `path`, or a `slot` of the running game, applied at the end of the next frame the CPU runs; with `wait: true` the response comes once it's done, or as an error if it failed), `game.state.save` (the same for saving one), `game.state.rewind` (the same for the newest rewind snapshot, which it keeps, so only a rewind within 0.8 seconds of the last steps further back; needs `RewindSnapshotInterval` set, and the `wait` response's `metadata` says how far back that is in emulated time), `game.state.verify` (the same for checking that saving the current state and reading it back agree), `game.speed.get/set` (emulation speed - unlimited fast-forward, or a percentage of 60 FPS; see below), `game.umd.insert` / `game.umd.eject` (put a disc image, given as `path`, in an empty drive, or take it out: for the XMB booted with `--vsh` and no game, which then lists the disc and can launch it), `version` | `GameSubscriber.cpp` | | CPU core | `cpu.stepping`, `cpu.resume`, `cpu.status` (reports `ticks` plus `us`, emulated microseconds, and `clockHz` - use `us` to line up with wall-clock timings, since games change the clock frequency and the ticks-per-second ratio isn't fixed), `cpu.getAllRegs`, `cpu.getReg`, `cpu.setReg`, `cpu.evaluate` | `CPUCoreSubscriber.cpp` | | Stepping | `cpu.stepInto`, `cpu.stepOver`, `cpu.stepOut`, `cpu.runUntil`, `cpu.runUntilTime` (run until a point in emulated time - `us` absolute or `relativeUs` from now - and break there; this is how to get a scripted repro reproducibly "N seconds into the game" instead of polling `cpu.status` in a loop), `cpu.nextHLE` | `SteppingSubscriber.cpp` | | Breakpoints | `cpu.breakpoint.add/update/remove/list`, `memory.breakpoint.add/update/remove/list`, `cpu.regBreakpoint.add/update/remove/list` (break when a register is written to, by any instruction anywhere - currently GPRs only; interpreter-only, no effect under a JIT backend) | `BreakpointSubscriber.cpp` |