# VSH Boot Investigation Notes on booting the PSP's Visual Shell (VSH / `vshmain.prx`) in PPSSPP via the direct HLE boot path (`--vsh`), using a real Sony firmware dump (not checked into the repo - placed locally under `assets/flash0/`, with `assets/flash1/` alongside it if you have one). This is exploratory reverse-engineering of Sony's shipped, undocumented System Software, not a supported PPSSPP feature - treat conclusions here as best-effort, not verified against hardware. ## Start here One command boots VSH, runs to a fixed point, and tells you where it got. It starts the emulator, finds the debugger port, connects, and cleans up after itself - no wrapper script needed: ```bash printf 'cpu.runUntilTime us=6000000\ncpu.status\n:quit\n' | \ Tools/wsdbg/target/release/wsdbg.exe --sync --compact --quiet \ --launch Windows/x64/Debug/PPSSPPHeadless.exe --vsh -i --graphics=vulkan --loglevel=3 2>emu.log ``` That takes a few seconds. Protocol replies come back on stdout, the emulator's log on stderr. Put more commands in the script to inspect state once it stops. Three things to know: 1. **Don't add `:wait cpu.stepping` after `cpu.runUntilTime`** (or any resume/step command). `--sync` already waits for it; a second one never arrives and burns the whole timeout. This is the single most likely reason a run seems to take minutes - emulation itself is ~real time. 2. **`-i` (interpreter)** is what this path is known to work with, and breakpoints are most reliable there. 3. Boot needs a real firmware dump in `assets/flash0/` (and optionally `assets/flash1/`), neither of which is in the repo. ## Why it opens on a disc error The shell reaches the interactive XMB, but first shows "This disc cannot be started. The region 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 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 retried 12 times. Implemented now - PPSSPP has no region-locked discs, so anything it can load is something it should run - and the call drops to one. ## Current status `--vsh` boots VSH and renders real XMB frames. Measured on the GE command stream after 25 emulated seconds: 8 draw commands (two `DRAW SPLINE 16x9`, a `DRAW BEZIER 4x4`, four textured `DRAW PRIM TRI_STRIP` - the wave background and icon quads), 41 texture binds, and full PROJECTION/World/VIEW transform setup. `opening_plugin_module`, `sceMpegVsh_library` and `impose_plugin_module` load and start. **It reaches the interactive XMB.** Up to 2026-08-19 it showed the logo and wave animation and then a full-screen red error screen, which turned out to be the XMB index failing to decrypt (below). With that fixed, and with the disc region check answered (above), the shell opens on a dismissable disc error and the menu behind it works. Note that framebuffer readback does not work under headless on either backend - `gpu.buffer.screenshot` and `gpu.buffer.renderColor` both answer "Could not download output" - so checking what is actually on screen means running the app build. From headless, the useful proxy is the per-frame display list signature (see the red error screen section): a screen that is finished changing repeats byte-identically, and a live menu does not. ## The red error screen What is known, all measured from a 40-emulated-second `--vsh` boot: - **Nothing fails to load.** Every `sceIoOpen` in the whole boot succeeds: `flash0:/font/*.pgf`, `opening_plugin.rco`, `system_plugin.rco` / `_bg` / `_fg`, `topmenu_plugin.rco`, `topmenu_icon.rco`, `impose_plugin.rco`, and `flash0:/vsh/etc/index_02g.dat`. The only I/O failure in the entire trace is `flash1:/registry/init.dat`, which is correct (see above). So VSH genuinely gets as far as building the top menu before giving up. - **Where it freezes.** Grouping `sceGeListUpdateStallAddr` by display list id gives a per-frame signature of what is being drawn. It grows 6 -> 10 -> 11 -> 15 -> 19 -> 33 -> 34 -> 38 stall points (the dialog and its text being composed) and then repeats **byte-identically for the remaining ~2016 frames**. That is the error screen locking in; everything before it is animation. - **Nothing logs an error in the run-up.** The window before the freeze is just the startup jingle decoding (`sceAtracDecodeData` + `sceAudioOutputPannedBlocking`), `vshCtrlReadBufferPositive`, `scePowerIsSuspendRequired`, `sceKernelGetUserLevel` and `sceImposeChanges`. So the trigger is a *wrong return value* we hand back, not a missing call - which makes it much harder to spot than the `sceKernelLoadModuleVSH` bug was. - **`sceImposeChanges` is not a symptom of the error.** It runs once a frame regardless; the ~10000 calls per boot are just the indicator refresh, and they start *after* the frame loop is already steady. Don't read into it. ### What actually triggers it: the XMB index can't be decrypted Found by raising log channels for a narrow window around the transition (5.8s to 6.7s emulated, ~12K lines - `log.channel.set`, not a full-boot log). The frame loop's calls before and after the transition differ sharply: before it there is resource loading, module starting and registry access, after it only the clock and battery indicators. Inside that window: ``` sceIoOpen(flash0:/vsh/etc/index_02g.dat) -> fd 8 sceIoRead(8, 092a2d40, 496) -> the whole file sceIoClose(8) unresolved import sceResmgr/9dc14891, called from 'vsh_module' <- traps here sceKernelExitDeleteThread(1) <- the ScePafJob building the menu gives up ``` `index_02g.dat` is the index of what the XMB shows, and it is encrypted (it starts `PSPsysGP`, not the `release:` of an already-plaintext one). `sceResmgr_9DC14891` is what decrypts it. With that import unresolved the call trapped, the index stayed encrypted, and the job thread quit - hence a shell that has loaded every resource successfully and still has nothing to show. **Fixed.** `Core/HLE/sceResmgr.cpp` implements the call, the three tags it needs (`0x0B2B90F0/91F0/92F0`, keys and code `0x5C` from JPCSP) are in `Core/ELF/PrxDecrypter.cpp`, and `pspDecryptType9()` there decrypts it - 496 bytes in, 159 out, starting `release:`, which is the check `sceResmgr` logs on every call. Type 9 turned out to be type 6 with three differences, all because a type 9 file carries a real ECDSA signature at `0x104..0x12C` where type 6 has nothing: - The "this region must be empty" check has to stop at `0x104` rather than `0x10C`, or the first 8 bytes of the signature fail it (this was the original `-2`). - The signature is excluded from the hashed header rather than fed into it - JPCSP zeroes `buf2[0x34..0x5C)`, which is the same range after its header rearrangement, so `PRXType9` simply leaves that field zero. - `ecdsa_hash` in the KIRK CMD1 header stays 0. Type 6/7 set it; the branch type 9 takes writes only the mode word. Setting it was the final `-4`. The SHA1 check inside the decrypt is a good progress signal while porting: reaching `-4` means the header reconstruction and hash inputs were already right and only the KIRK call was wrong. ## The root cause of the long-standing blank screen **`ModuleMgrForKernel/0xD5DDAB1F` (`sceKernelLoadModuleVSH`) was unimplemented.** That is how VSH loads its own plugins: the XMB's UI lives in `flash0:/vsh/module/*_plugin.prx` and `vshmain` pulls those in through this kernel call, not the user-mode `sceKernelLoadModule`. The import went unresolved, vshmain got no module id back, and the `sceKernelStartModule` that followed was called with **id 0** and failed with `0x8002012E UNKNOWN_MODULE`. No plugin ever ran, so the scene had its containers but nothing drawable in them - which is exactly the "render state set up 6 times per frame, zero draws" symptom this investigation chased for a long time. Implemented along with `0xD86DD11B` `sceKernelSearchModuleByName`, the other unresolved `ModuleMgrForKernel` import. Found by scanning a whole boot for *any* HLE call returning an error. Exactly one mattered, and it had been missed on two earlier passes because it logs as `: error 8002012e` rather than as a `8xxxxxxx=sceSomething(` return value. **Grep both shapes.** ## Solved along the way - **Real kernel driver modules running as genuine MIPS code** needed scratchpad RAM kernel-mode address mirrors (`Core/MemMap.cpp`), dummy COP0 instructions (`Core/MIPS/Interpreter.cpp`), and a GPIO + Syscon serial MMIO model (`Core/HW/GpioMMIO.{h,cpp}`) so `kd/syscon.prx`'s real handshake completes instead of spinning. - **`vsh_module` alarm-task SIGSEGV**: a targeted data patch zeroing an "alarm category count" field nothing else initializes (`__KernelLoadELFFromPtr`). Symptom patch, not a principled fix - why real firmware never reaches that path was never established. - **`SCE_KERNEL_ERROR_NO_MEMORY` in the app build**: the 4MB kernel pool was exhausted by PPGe's ~2MB overlay texture (now skipped when booting VSH) plus 11 driver modules each asking for a 256KB thread stack (now given 32KB). - **`sceHprm_driver/sceHprmReadLatch` (0xE9B776BE)** implemented and its output struct actually zeroed, matching JPCSP. Correct, but did not affect the blank screen. - **flash1 is now mounted** when a dump sits next to the flash0 one. `sceReg` serves a compiled-in snapshot of some real PSP's registry (`Core/HLE/sceReg.cpp`) rather than reading the volume, and a genuine dump has only `registry/system.dreg` and `.ireg`. **`flash1:/registry/init.dat` must stay absent** - an earlier note here said creating one "changes nothing", which is wrong. VSH stats it twice during startup, and *finding* it means "reinitialize the registry": it then calls `sceRegRemoveRegistry` and `sceIoDevctl("flashfat1:", 0x5802)`, both `UNIMPL`, and the boot dies without starting a single plugin module. Its absence is the healthy path, and the resulting `FILE NOT FOUND` in the log is expected, not a lead. - **A diagnostic-tooling bug that produced a phantom lead.** The "unresolved import" reporter encoded identity in a 55-slot ring buffer, but a VSH boot registers ~1000 unresolved imports, so the ring wrapped ~18 times and every report named whatever most recently reused that slot. It made `sceMgVideo_driver` look like one massively hot call when it was the misattributed sum of many. Fixed by recovering `stubAddr` via `g_lastSyscallPC` and looking identity up in `module->importedFuncs`, which already tracked exactly this (interpreter-only, matching the original limitation). **Lesson: when identity has to be recovered later by address, check whether something already tracks it before adding a cache.** Breakpoints were never at fault - if one doesn't fire, suspect the address first. ## Imports that must stay unresolved Counterintuitive but repeatedly measured: implementing more of what the VSH imports can make the boot *worse*, because a correct implementation lets a real flash0 driver finish its init and walk on into hardware PPSSPP does not emulate. Leaving the import unresolved makes the driver fail early and the boot continues. All three are commented in the source at the point where they'd be added: - **`ThreadManForKernel` mutex/fpl NIDs** (`0xB7D098C6` `sceKernelCreateMutex`, `0xB011B11F` `sceKernelLockMutex`, `0x6B30100F` `sceKernelUnlockMutex`, `0xD979E9BF` `sceKernelAllocateFpl`) - we implement all four for user mode already. Resolve them and the NAND and ID storage drivers init successfully, then `sceIdStorage_Service` polls the NAND controller at `0xbd101300` forever. No module starts. - **`InterruptManagerForKernel` interrupt registration** (`0x58DD8978`, `0xF987B1F0`, `0x4D6E7305`, `0xD774BA45`) - JPCSP can honour these because it emulates the interrupt controller as MMIO. We dispatch the few interrupts we emulate ourselves (`__RegisterIntrHandler`), so success is a lie the drivers act on. Measured: 31 such calls (interrupts 4, 12, 15-18, 20-24, 31), then a stall in GE list execution with no plugin module started. 73 unresolved import hits over 37 distinct module/NID pairs remain in a boot. Most of the rest are `sceSysEventForKernel`, `sceSuspendForKernel` and `*_driver` modules that would need real hardware behind them. Note the *runtime* ones log as `Unknown syscall (run) at ...: unresolved import ...` (`HLE.cpp:905`) - a different string from the load-time `Unknown syscall from known HLE module`, so grep for both or you will undercount badly. ## How this boot path works Real hardware boots VSH through the kernel's own module bootstrap, launched from `flash0:/reboot.bin`. PPSSPP doesn't emulate that chain. `--vsh` loads `flash0:/vsh/module/vshmain.prx` through the normal PRX loader, and `LoadAndStartVshKernelModules()` (`Core/HLE/sceKernelModule.cpp`) approximates the missing bootstrap by loading and starting `vshbridge.prx`, `paf.prx`, `common_gui.prx`, `common_util.prx` and 11 real `kd/` drivers first, in the order JPCSP's own `--vsh` shortcut uses. JPCSP's `--vsh` turns out to use the same shortcut rather than a real reboot.bin LLE path, so this is a legitimate if approximate route to a working VSH. Known differences from JPCSP's `--vsh` that have **not** been tried: it preloads `PSP_MODULE_AV_VAUDIO`, `AV_ATRAC3PLUS` and `AV_AVCODEC`, and sets the io filepath to `ms0:/PSP/GAME`. Its trick of forcing the root thread to kernel mode at priority 0x7E **was** tried and reverted - it reorders scheduling enough that `sceVshBridge_Driver` runs before a precondition is ready. Firmware version already matches (660 both sides). ## Thread structure There is no `vsh_main` thread and there shouldn't be. `vshmain.prx`'s `module_start` runs on the `root` thread, creates and starts `SCE_VSH_GRAPHICS` (entry `08818d14`) and returns from module - visible in the log as `Context switch: root -> ... (returned from module)` right after the `sceKernelStartThread`. **`SCE_VSH_GRAPHICS` is VSH's main thread**, and its entry is the outermost frame of every render backtrace. Steady state is 8 threads: `SCE_VSH_GRAPHICS`, `ScePafThread`, `SceWaveMain`, `SceWlanMac`, `sceRTC_Service`, `sceSYSCON_Driver`, `idle0`, `idle1`, plus transient `ScePafJob` threads that load the `.rco` resources and exit. The frame loop is healthy: poll `ScePafSyncCall`, `vshCtrlReadBufferPositive`, render, `sceDisplaySetFrameBuf`, wait `SceVblankSync`. ## The render call chain Still worth having if individual widgets misbehave. Traced with a **write memcheck on a display list word** (`memory.breakpoint.add address=... write=true`), which stops with a structured hit and lets `hle.backtrace` name the whole chain - minutes of work, and the technique to reach for first next time: ``` 08818d14 SCE_VSH_GRAPHICS thread entry 08895544 6-iteration loop 08963cfc container: sets base render state, walks children at [obj+0x550], count [obj+0x554] 08950754 per-child render 089a1cdc / 089a36e4 / 089a38c0 ... GE state helpers ``` `08950754` gates on `z_un_089501cc(child+0x450) == 1` and on `[child+0x438] != 0`, then computes the scissor by intersecting `[child+0x43c..+0x448]` with a parent clip rect. ## Tools and methodology - Everything useful works on a live session: `hle.thread.list`, `hle.eventflag.list`, `hle.backtrace`, `memory.disasm`, `memory.readString`, `gpu.displaylist.disasm`, `cpu.evaluate` (expressions like `[s0+0x438]` read guest memory), and memory breakpoints. See `docs/WebSocketDebugger.md` for the catalog. - `cpu.runUntilTime us=N` is what makes a script reproducible - it stops on the requested emulated microsecond, so every run reaches the same instruction. Ask for the time you need and no more; the red screen locks in around 6 emulated seconds. - Raise log channels for a *short* window only - `sceGe`/`G3D` at debug produce ~200K lines in three seconds of boot. `log.channel.set` is session-only, never persisted. `--loglevel=3` is a good default for the whole run: bare `--log` on a VSH boot writes ~2.3GB, level 3 writes ~66KB. - Diffing against JPCSP (`../JPCSP`, Java source, NIDs searchable by hex) repeatedly beat guessing at missing HLE functions. `assets/flash0` has the decrypted PRXs if a NID needs disassembling. - Headless is preferred over the app build - the app's Debug config blocks on a Windows crash dialog with no console output. **Emulation is not the slow part.** A VSH boot runs at roughly real time even in a Debug build (1 emulated second in a 2.5s run that includes process startup). If a scripted run takes minutes, it is waiting, not emulating - and the usual cause is an explicit `:wait cpu.stepping` after a `cpu.runUntil*`/`cpu.resume`/`cpu.step*`, which `--sync` already waits for. The redundant one waits for a second `cpu.stepping` that never comes and burns the whole `--sync-timeout`. See `Tools/wsdbg/README.md`. ## Not included on this branch The separate `--vsh-reboot` experiment (loading `flash0:/reboot.bin` for a real LLE kernel boot, with NAND/KIRK/system-control MMIO emulation) lives on `vsh-experiments`. The direct HLE path here reaches much further without any of it.