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) <noreply@anthropic.com>
This commit is contained in:
Henrik RydgårdandClaude Opus 5.5 committed 2026-10-21 17:32:43 +02:00
1 parent b225c33639
commit eee153b220
10 files changed
+124 -10

No files matched your search

@@ -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();
}
+2
View File
@@ -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);
+2 -1
View File
@@ -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);
+5
View File
@@ -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!
+41 -1
View File
@@ -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;
}
+4
View File
@@ -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();
+4 -1
View File
@@ -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;
+2
View File
@@ -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,
+11 -6
View File
@@ -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
+1 -1
View File
@@ -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` |