POPStarter DOCS

Architecture

← POPSLoader docs · view on GitHub ↗

Last updated: 2026-07-27 (Release 1.1.0; rolling v1.1.1-dev / experimental v1.1.1-dev-EXP73; frame-count nav + Timer.getTime-is-microseconds + resident cover worker + cover_default/cover_missing art layer + OPL-style overscan + Pads.getMode binding + profile preset removal). Active development and rolling branch is dev (BETA-13-PLAY was renamed to dev post-1.0.0; BETA-12-PLAY is archival). For current Settings behavior, Known Issues, Preservation Contracts, Behavioral Invariants, and Hardware Status, see STATE.md (canonical). Where dated historical sections below differ from STATE.md, STATE.md wins.

ARCHITECTURE

POPSLoader is a PS1-game launcher for the PlayStation 2, built on the Enceladus
runtime. The shipped binary is a single packed EE ELF (bin/POPSLOADER.ELF)
that contains:

Nothing in the runtime is read from loose files beside the ELF: Lua scripts,
PNGs, and IRX modules are all compiled into the ELF as byte arrays and resolved
by name at runtime (src/embed_assets.cpp).

This document describes the system top-to-bottom: boot/IOP -> Lua VM ->
UI/scenes -> device backends -> game launch/handoff -> build/embed pipeline.
For the load-bearing hardware-regression contracts that must not be changed
without hardware re-verification, see PRESERVATION_CONTRACTS.md.


Subsystem diagram

                          POWER-ON / parent launcher (wLaunchELF, PSBBN, OSDSYS, MC autoboot, NHDDL)
                                                |
                                                v
+----------------------------------------------------------------------------------------------+
| PHASE 0  (BEFORE main)   src/main.cpp:619 _ps2sdk_memory_init()  [compiled iff -DRESET_IOP]   |
|   SifExitRpc -> SifInitRpc(0) -> fileXioExit -> SifIopReset(loop) -> SifIopSync -> SifInitRpc |
|   Survives "polluted parents" that leave fileXio alive (ps2sdk #425).                          |
+----------------------------------------------------------------------------------------------+
                                                |
                                                v
+----------------------------------------------------------------------------------------------+
| PHASE 1  EE entry   src/main.cpp:439 main()                                                   |
|   parseLaunchArgs (-page/-mode/-game/-debug)  |  detectBootDeviceHintFromArgv0 (advisory)     |
|   SBV patches  |  IRX bring-up (SifExecModuleBuffer from bin2c'd buffers)  |  gsKit+pad init   |
|   runScript("boot.lua")                                                                        |
+----------------------------------------------------------------------------------------------+
                                                |
                       IRX stack (boot order, fixed)        Layer C (lazy, on demand)
              iomanX -> fileXio -> sio2man                  bdm -> bdmfs_fatfs -> usbmass_bd
              -> [mmceman iff hint==MMCE] -> mcman          mx4sio_bd  (needs usbmass first)
              -> mcserv -> initMC -> padman -> libsd         mmceman   (luasystem EnsureMmceman)
              -> usbd -> ds34usb -> ds34bt -> audsrv         dev9->atad->ps2hdd-osd->ps2fs (luaHDD)
                                                |
                                                v
+----------------------------------------------------------------------------------------------+
| LUA VM   src/luaplayer.cpp:254 runScript()                                                     |
|   custom embedded require() searcher; dofile/loadfile NIL'd; package.path/cpath cleared        |
|   bindings: System.* (luasystem.cpp), HDD.* (luaHDD.cpp), Graphics/Pad/etc.                    |
+----------------------------------------------------------------------------------------------+
                                                |
                                                v
+----------------------------------------------------------------------------------------------+
| EMBEDDED LUA APPLICATION                                                                       |
|   etc/boot.lua         mount HDD boot part -> pfs1: ; MX4SIO slot xlate ; fonts ; require()    |
|   images.lua           IMG_REGISTRATIONS (UI glyph/icon atlas, lazy from embedded PNGs)        |
|   system.lua  <----- controller: device resolution, settings, game lists, LAUNCH ENGINE       |
|        |                  require()s ui + images, owns the main loop                           |
|        v                                                                                       |
|   ui.lua               one big UI table: scenes, transitions, modals, carousel, cover cache    |
+----------------------------------------------------------------------------------------------+
                                                |
                            game launch  (PLDR.RunPOPStarterGame -> LaunchEngine)
                                                v
+----------------------------------------------------------------------------------------------+
| ELF HANDOFF                                                                                    |
|   luasystem.cpp  System.loadELF / loadELFWithPartition / loadELFRebootIOP                      |
|   elf.c          THREE teardown contracts:                                                     |
|       (1) HDD-backed   -> ExecuteHddBackedViaEmbeddedLoader -> ExecuteViaEmbeddedLoader        |
|       (2) BOOT.ELF / DKWDRV-on-HDD (reboot_iop=0) -> embedded loader special-cases             |
|       (3) non-HDD       -> SifLoadElf -> unmount pfs -> SifIopReset -> reload MC IRX -> ExecPS2 |
|   loader.c       BRAM child: reads metadata @0x00083C00 'POPL' -> ExecPS2(final target)        |
+----------------------------------------------------------------------------------------------+
                                                |
                                                v
                          POPSTARTER.ELF / DKWDRV.ELF / game / mc?:/BOOT/BOOT.ELF

Layer 1 — Boot and IOP bring-up (src/main.cpp)

Boot has two distinct phases, and the order is non-obvious: the IOP is reset
before main() runs.

Phase 0: pre-main() IOP hygiene

newlib calls _ps2sdk_memory_init() during EE process startup, before main()
(src/main.cpp:619). When built with -DRESET_IOP it performs a defensive IOP
reset: SifExitRpc() -> SifInitRpc(0) -> fileXioExit() ->
while(!SifIopReset("",0)){} -> while(!SifIopSync()){} -> SifInitRpc(0)
(src/main.cpp:658-663). This recovers from "polluted parent" launchers that
hand off with fileXio still loaded on the IOP — notably wLaunchELF, which only
resets the IOP for HDD targets. A live fileXio holds IOP threads/semaphores
that block a plain SifIopReset (ps2sdk #425), causing a silent hang ->
black screen. RESET_IOP = 1 is set in the shipped build (Makefile:34), and
the flag is wired to the compiler at Makefile:53-55. Because this runs before
main(), a top-down read of main() misses the reset entirely.

Phase 1: main() (src/main.cpp:439)

  1. parseLaunchArgs(argc, argv) (src/main.cpp:198, called at :450) parses
    NHDDL-style args into static buffers. -page= and -mode= both write
    launch_arg_page (-mode is an alias); -game= -> launch_arg_game;
    -debug -> launch_arg_debug (src/main.cpp:220-231).
  2. detectBootDeviceHintFromArgv0() (src/main.cpp:134) derives an advisory
    pre-Lua boot-device hint from argv[0]. All HDD-shaped prefixes
    (hdd/pfs/ata/apa) classify as "HDD" (src/main.cpp:155-160);
    mass and usb both map to "USB" (src/main.cpp:139-144). This hint is
    for pre-Lua/pre-IRX decisions only — the authoritative device is resolved
    later in Lua (see Layer 4). Exposed to Lua as System.getBootDeviceHint()
    (src/luasystem.cpp:1227).
  3. SBV patches: sbv_patch_disable_prefix_check() + sbv_patch_fileio()
    (src/main.cpp:463-464).
  4. IRX bring-up via SifExecModuleBuffer from bin2c'd buffers (no module
    reload from rom0/disk during normal boot). The fixed order is:
    iomanX -> fileXio (+fileXioInit) -> sio2man ->
    [mmceman only if hint==MMCE] -> mcman -> mcserv -> initMC ->
    padman -> libsd -> usbd -> ds34usb -> ds34bt (+inits) -> audsrv
    (src/main.cpp:470-563). Each stage degrades gracefully: fileXio is gated
    on iomanX (if (ioman_ok), src/main.cpp:474), and the mmceman eager
    load is gated on the MMCE boot hint (src/main.cpp:503).
  5. mmceman.irx is the only device-specific module eagerly loaded at boot,
    and only when boot_device_hint == "MMCE"
    (src/main.cpp:503-520), then MarkMmcemanLoaded() syncs the lazy tracker
    (src/main.cpp:520). Every other backend (BDM/USB/MX4SIO/HDD) defers to
    Layer C.
  6. Set boot path / app dir (src/main.cpp:565-570), init gsKit graphics + pad,
    chdir(boot_path), then enter the runScript("boot.lua") loop.

MC is not MMCE. Standard PS2 memory cards (mc0:/mc1:) use the
unconditionally-loaded mcman/mcserv stack (src/main.cpp:545-546).
MMCE (third-party adapters exposing mmce0:/mmce1:) uses mmceman.irx,
which is conditional/lazy. The argv0 classifier deliberately excludes mcm
so mcman is not mistaken for an MC boot.


Layer 2 — Embedded Lua VM (src/luaplayer.cpp)

runScript() (src/luaplayer.cpp:254) creates the Lua state, registers all
native bindings, installs the embedded asset machinery, and runs boot.lua.

This means every .lua under bin/POPSLDR/ is compiled into the ELF at
build time
; editing them requires a rebuild, and the on-card copies are never
read at runtime.


Layer 3 — Boot script (etc/boot.lua)

boot.lua is the first Lua to run and sets up the environment before handing
control to the real application:

etc/boot.lua must end with a 0x0A newline or CI hard-fails (its runner
is newline-sensitive). The bin/POPSLDR/*.lua files are exempt because they
are embedded as raw bytes.


Layer 4 — UI and scene system (bin/POPSLDR/ui.lua + the controller in system.lua)

The embedded Lua application is split into a controller (system.lua, ~8500
lines) and a view (ui.lua, ~6000 lines), plus a data module (images.lua).

Control flow lives in system.lua, not ui.lua

ui.lua defines one giant UI table literal (ui.lua:446 ... return UI at
ui.lua:4825) holding every scene, the transition state machine, the
notification queue, busy overlays, the cover cache, the path-editor keyboard,
modals, and input — but no main loop. The controller system.lua loads the
modules (ui -> images), runs the
init sequence, and owns the single render loop at the very bottom of the file.
Device bring-up now runs inside do_boot_init under the welcome splash
(splash-first, commit 6b65b18), while LoadSettingsNonFatal + the video-mode
apply happen pre-splash:

system.lua:6260  PLDR.AutoInitStartupBackends()        -- inside do_boot_init (runs UNDER the splash)
system.lua:6270  PLDR.AutoLaunchFromLaunchArgs()        -- gated by `if not boot_start_held` (6269)
system.lua:6272  PLDR.SurfaceLaunchArgsDebug()
system.lua:6333  PLDR.LoadSettingsNonFatal()            -- moved later: loads settings + applies video mode BEFORE the splash
system.lua:6354  UI.WelcomeDraw.Play(initial_scene, show_boot_credits, do_boot_init)  -- splash-first paint; do_boot_init runs under it
system.lua:6364  while true do  -- dispatch per-scene Play(), then UI.flip()

The loop maps MMAIN -> UI.MainMenu.Play, MPROFILE -> UI.ProfileQuery.Play,
any game scene (UI.IsGameScene) -> UI.GameList.Play, CREDITS -> UI.Credits.Play (system.lua:6364-6376).

Scenes

UI.SCENES is a numeric enum: GUSBFAT=1, GSMB=3, GMX4SIO=4, GHDD=5
(GAPAHDD aliases 5), GBDMHDD=6, GSMBNET=7, MMAIN=8, MPROFILE=9,
CREDITS=10.
GSMB=3 is the destination for the MMCE list. SMB (v1) network browsing has
its own dedicated scene GSMBNET=7 (see "Main menu and per-device entry").
Scene changes go through UI.SceneChange ->
UI.RequestScene -> UI.Transition.Start, a two-phase out/in EaseInOutCubic
crossfade that swaps UI.CURSCENE at the midpoint.

The metatable write-guard gotcha (confirmed)

Metatables are installed so that:
- writes to UI.MainMenu.OPT are silently dropped unless
Carousel.allowOptWrite is true (ui.lua:3588-3591), and
- writes to UI.CURSCENE are silently dropped unless
UI.Transition.allowSceneWrite is true (ui.lua:3610-3612).

The main loop itself respects this: it sets allowSceneWrite = true around its
own UI.CURSCENE assignment and clears it again (system.lua:6186-6194). Any
code outside the carousel/transition machinery that assigns these directly is a
no-op. Launch-arg page routing only works because it writes the Carousel
fields directly at module-init time (system.lua:2479-2509).

Main menu and per-device entry

UI.MainMenu.opts is an 8-entry horizontal animated carousel (ui.lua:3900):
MMCE, MX4SIO, HDD (exFAT), HDD (PFS), USB, i.Link, SMB (v1), Disc (DKWDRV).
CONFIRM dispatches by OPT index inside the CONFIRM handler (ui.lua:4148-4322):

OPT Entry Action
1 MMCE DetectMMCESlot + GetPS1GameLists -> scene GSMB
2 MX4SIO InitMX4SIOPopsRoot + GetPS1GameLists -> GMX4SIO
3 HDD (exFAT) InitATAPopsRoot + GetPS1GameLists (BDMA ata) -> scene GBDMHDD (ui.lua:4349) — implemented, validating on hardware
4 HDD (PFS) LoadHDDModules + deps + BuildGameList -> GHDD
5 USB ensureUsbMass + BuildMassGameListByType -> GUSBFAT
6 i.Link stub (ui.lua:4318)
7 SMB (v1) InitSMBPopsRoot (lazy connect + share scan) + GetPS1GameLists -> scene GSMBNET (ui.lua:4320) — implemented, CI+Rolling green; browse + launch hardware-confirmed on a static IP config, DHCP path fixed in bb62f2be and unconfirmed
8 Disc (DKWDRV) open DKWDRV modal

UI.RunBusyTask (ui.lua:715) wraps every device-load worker in pcall
behind a saving/loading overlay; progress flows through
MakeBusyProgressReporter (ui.lua:734). The toast stack is Notif_queue
(MAX 2, severity colors, ui.lua:772-841).

Input and navigation timing (frame-counted, NOT wall-clock)

All input flows through UI.Pad.Listen (ui.lua:4440), which reads the pad once
per vblank-paced frame, folds the d-pad bits, then resolves nav events.

Timer.getTime() returns MICROSECONDS on the PS2. The binding lua_time
(src/luatimer.cpp:33, registered as getTime at :126) returns raw
clock() - tick ticks with no division by CLOCKS_PER_SEC, and the EE
toolchain's CLOCKS_PER_SEC is 1e6. The UI historically treated this value as
milliseconds, so every _ms-named gate ran ~1000× too fast. The canonical
Enceladus-ecosystem idiom is therefore frame-counting — the sibling launchers
(OSDMenu-Configurator, RETROLauncher) never read the wall clock for nav. Stock
Lua's os.clock() (seconds) is the only pre-converted time source and is
currently unused. The µs-as-ms timer sweep is complete (9c3f64f + a8e61f3):
the unit-correct gates divide by 1000 (PathEditor key-flash / caret blink, the
launch-watchdog label), and the animation timers were converted to frame-counting /
frame-pacing (nav repeat, description scroll, cover idle, and the scene/boot-fade +
carousel transition). The old per-press action debounce MIN_ACTION_MS was
removed entirely; edge-triggering (pressed = GPAD & ~OLDPAD) is the only action gate.

Analog-stick → d-pad fold is gated on real analog mode (ui.lua:4469-4511).
The left stick is OR'd into the d-pad direction bits only when
Pads.getMode() reports PAD_ANALOG or PAD_DUALSHOCK, plus a per-axis
hysteresis latch (assert at |v| > 64, release below 40) so a deadzone-parked
stick can't dither and edge-spam nav. Pads.getMode() (lua_getmode,
src/luacontrols.cpp:70) returns padInfoMode(port, 0, PAD_MODECURID, 0) — the
live negotiated mode. This is distinct from the pre-existing
Pads.getType() (lua_gettype, src/luacontrols.cpp:9), which reads
PAD_MODETABLE (a capability-table entry) and is unusable for the fold gate; the
fold mirrors OPL's pad->buttons.mode >> 4 check (OPL src/pad.c:201). Without
the gate a digital pad's stale analog bytes (getLeftStick ≈ −127) injected a
phantom PAD_UP|PAD_LEFT every frame and broke up/down nav. lua_getleft/
lua_getright (src/luacontrols.cpp:82/:119) are also hardened (zero-init,
neutral (0,0) default, gated on padRead's return) so an unread/failed frame
never yields garbage.

Game lists

Three builders, with non-uniform entry encoding:
- PLDR.GetPS1GameLists (system.lua:4529) — bare .vcd basenames
(MMCE/MX4SIO).
- PLDR.BuildMassGameListByType (system.lua:4593) — "POPSroot|name" (USB).
- PLDR.HDD.BuildGameList (system.lua:4720) — "partition|relpath", mounting
each __.POPS/__.POPS0..9 partition read-only (system.lua:4729-4742).

UI.GameList.Play (ui.lua:2029) strips the "X|" prefix for display and
launches via PLDR.RunPOPStarterGame on CONFIRM. On GHDD, R2 selects an
"HDD Alt" (full_hdd_pfs0) mode that requires POPSTARTER itself to live on HDD
(ui.lua:2261-2262).

HDD cache (opt-in)

The per-device game-list cache (USB/MMCE/MX4SIO and HDD) is an opt-in feature gated by PLDR.GAMELIST_CACHE (default false, system.lua:3319; persisted as the GAMELIST_CACHE setting key). PLDR.HDD.USECACHE (system.lua:2048) is a dead legacy flag. PLDR.HDD.EnsureGameList (system.lua:5017) orchestrates the always-on in-session memo (LIST_BUILT) plus, when GAMELIST_CACHE is on, a plain-text cache file hdd_gamecache.txt via CreateCache/ReadCache/WipeCache (system.lua:4880/4927/4961), read with a loadfile-free parser (the old .lua cache used loadfile, which is nil in the embedded runtime). When GAMELIST_CACHE is OFF, every device does a fresh live scan.

Cover art (separate from the icon atlas)

UI.CoverCache (ui.lua:257, max = 3) is a 3-entry LRU of loaded box-art
images, refreshed after a navigation settles via CoverCache:UpdateSelection
(ui.lua:326). The refresh is frame-counted: the cover decodes only after the cursor has been
stable for ~250 ms (COVER_IDLE_FRAMES = ceil(cover_fps * 0.25), cover_fps = 50
PAL / 60 NTSC, ui.lua); a per-frame counter resets while the selection keeps
changing, so the cover loads only once navigation settles. Cover-art seeking is
device-aware (BuildCoverCandidates, ui.lua:190). On removable devices the
lookup is one path, exact filename (EXP71): BuildCoverCandidates returns a
SINGLE candidate, <device-root>/ART/<gamefilename>_COV.png (ui.lua:236-245).
The Cover/details folder setting was removed in EXP35 — PLDR.ART_LOCATION
still parses for back-compat but is INERT — and there is no POPS/ART variant,
no beside-the-.vcd fallback, and no disc-marker-stripped family name, so a
multi-disc game needs one art file per disc. For HDD/PFS the single path is
hdd0:__common/POPS/ART/<gamefilename>_COV.png, existence-confirmed through
PLDR.ResolveHddPartitionReadablePath (ui.lua:211-221). The <name>.txt
details sidecar does NOT ride the cover candidate list: on removable devices it
is a separately-computed <device-root>/ART/<gamefilename>.txt (no _COV)
handed to the resident cover worker via Graphics.coverLoadTextPath
(ui.lua:466-480), and on HDD it is resolved independently (ui.lua:486-507).
When a
<base>.png cover loads it gets its own COVER_W inset (ui.lua:624, value 232).
CoverCache:GetOrLoad (ui.lua:344) drops the doesFileExist/open() existence
pre-probe and lets Graphics.loadImage (fopen) open the file directly. In ps2sdk
open() and fopen() share the same libcglue _open, so this is a redundant-syscall
cleanup, NOT a fix: nested subfolder reads work on the BDM/FAT drivers (OPL reads
mass:/ART/ the same way), so a missing POPS/ART cover is a filename/location issue.
The "No cover. Looked for: " caption that used to print here was REMOVED (EXP42)
together with its last_cover_probe state: a missing cover now just draws the
placeholder. Whether covers draw at all is the persisted COVER_ART setting
(Settings > Game List > Cover art, default ON), which replaced the Square toggle.

Layered placeholder (no MISSING.png). When there is no live cover the box
draws two embedded assets instead of a single combined image (the old "Cover
disabled" text label is gone): cover_default.png is the base jewel-case, and
cover_missing.png is overlaid only when the preview is enabled but the game
has no art (ui.lua:2577-2590). The default art, the missing overlay, and the
decorative frame.png border all share the frame's aspect-corrected,
right-anchored rect so they register with the jewel-case window on both NTSC
(Y=448) and PAL (Y=512). MISSING.png was removed entirely (−62 KB ELF); its
bin2c rule, EMBEDDED_RSC entry, embed_assets.cpp externs/ASSET_ENTRYs, and
the images.lua registration are all gone, and there is no longer any
default.png → MISSING.png fallback. This cover machinery is distinct from
the UI chrome/glyph atlas in images.lua.


Layer 5 — Native bindings (src/luasystem.cpp, src/luaHDD.cpp)

Layer C lazy IRX loaders

Backends that are not eagerly loaded at boot are pulled in on demand:
- BDM chain: EnsureBDM -> EnsureBDMFatFs -> EnsureUsbMass
(src/luasystem.cpp:80-120), each idempotent.
- MX4SIO: lua_mx4sio_init calls EnsureUsbMass() before loading
mx4sio_bd.irx (src/luasystem.cpp:1298-1330) — maintainer rule
(2026-05-28): MX4SIO needs the USB drivers first; USB never needs MX4SIO.
- MMCE: EnsureMmceman (src/luasystem.cpp:142) loads mmceman.irx on demand
and MarkMmcemanLoaded() syncs the tracker.
- HDD: Load_HDD_IRX (src/luaHDD.cpp) calls EnsureAtaBdm() — dev9 -> bdm ->
bdmfs_fatfs -> ata_bd (which IS ps2atad built with ATA_ENABLE_BDM=1, so ONE
atad instance serves both APA/PFS and exFAT) — then ps2hdd_osd (args
-o 4 -n 20) -> ps2fs (args -m 4 -o 10 -n 40), aborting on any
id<0/ret==1. Plain ps2atad is no longer loaded: two atad copies re-initing
the live ATA bus was the 42% scan-freeze class. EnsureAtaBdm is serialised by a
binary semaphore because the boot worker and the main thread can both enter it.


Device backends and the dev9-vs-SIO2 bus model

The architectural reason the storage code is split across two files is the IOP
bus topology:

   IOP buses
   ---------
   dev9  (PC-card / expansion bus)          SIO2 + USB host (shared bus)
   --------------------------------         -----------------------------------------
   ps2dev9                                  sio2man
     -> ps2atad   (ATA disk)               padman  (pads)
       -> ps2hdd-osd (APA partitions)      mcman/mcserv (mc0:/mc1: standard memcards)
         -> ps2fs   (PFS -> pfsN:/)        mmceman      (mmce0:/mmce1: 3rd-party)
                                           bdm -> bdmfs_fatfs -> usbmass_bd  (USB mass)
                                                                -> mx4sio_bd (MX4SIO)
   ISOLATED in src/luaHDD.cpp,             WIRED via src/luasystem.cpp,
   loaded only when an HDD path is touched any backend on demand

mass:/ disambiguation (USB vs MX4SIO)

mass:/ is ambiguous. It is resolved not by the argv0 hint (which maps any
mass* to "USB") but by a runtime BDM driver-name lookup. An in-tree IOP RPC
helper, bdm_query (RPC id 0xB0D10B00, iop/bdm_query/bdm_query.c:11-13),
enumerates live block devices via bdm_get_bd(). The EE side (FetchBdmList +
ClassifyMassBackend, src/luasystem.cpp) classifies by driver name: substring
usb -> USB, sdc/mx4 -> MX4SIO, mmce -> MMCE, and an exact "ata" match
(strcmp==0 && strlen==3, mirroring OPL bdmsupport.c) -> exFAT internal HDD (BDMA-ATA). This lets the
launcher classify mass:/ without speculatively loading mx4sio_bd just to
probe.

Authoritative boot-device classification

The C hint is advisory; the authoritative classifier is Lua
DetectBootDevice / ResolveBootContext (system.lua:1983 / :1849), with
precedence (system.lua:1907-1929): mmce > mx4sio > mass (classified via BDM
driver) > pfs|hdd > smb > host > usb > ata > apa, falling back to the C hint.

HDD readiness and status

HDD.GetHDDStatus issues fileXioDevctl("hdd0:", HDIOC_STATUS)
(src/luaHDD.cpp:72-78): 0 = connected+formatted, 1 = not formatted, 2 = not
usable, 3 = not connected. PLDR.LoadHDDModules (system.lua:4754) maps these
to user notifications. Partitions mount on demand via
MountHddPartitionTracked (default FIO_MT_RDONLY, system.lua:789-815).

Startup backend auto-init

PLDR.AutoInitStartupBackends (system.lua:3920, called at :6260) collects
configured-path targets, classifies them, and fires only the needed warm-ups:
EnsureUsbMassReadyOnce/RefreshMassBackends (USB), InitMX4SIOPopsRoot
(MX4SIO), DetectMMCESlot (MMCE), LoadHDDModules + EnsureBootHddMountReady
(HDD).

Settles now exist (this superseded an older "no cold-dev9 settle" note).
Load_HDD_IRX (src/luaHDD.cpp) routes through EnsureAtaBdm(), whose
EnsureAtaBdmModulesInner (src/luasystem.cpp) applies a 1-second settle
before loading ata_bd and another after it, before ps2hdd_osd/ps2fs touch
the bus. The HDD-boot branch also still sleeps 2s (etc/boot.lua:47).


Layer 6 — Game launch and ELF handoff

Launch is a 3-stage chain:

Lua orchestration            C bindings + parent loader         BRAM child loader
(system.lua / ui.lua)   -->  (luasystem.cpp + elf.c)       -->  (loader.c, embedded loader_elf[])

Lua dispatch

PLDR.RunPOPStarterGame (system.lua:5653) builds policy, partition context,
keep-slots, and reboot_iop, then calls LaunchEngine (system.lua:5436).
BuildPopstarterLaunchCommand (system.lua:5631) sets per-device reboot_iop:
default 0 (PLDR.REBOOT_IOP_WHILE_LOADING_POPSTARTER, system.lua:2032);
POPSTARTER-on-HDD -> 1 (so the partition API + HDD routing fire); HDD game with
non-HDD POPSTARTER -> 0; USB/MC/MMCE/MX4SIO POPSTARTER keep 0.

LaunchEngine picks the C API: use_partition_api = exec_partition_context present AND reboot_iop != 0 AND System.loadELFWithPartition exists
(system.lua:5526); otherwise plain loadELF. A cold_external_launch flag
(true when a partition context is present) routes prep through
PrepareForColdExternalELFLaunch (unmount ALL pfs, mask=0) instead of the
selective-keep PrepareForExternalELFLaunch.

C bindings (src/luasystem.cpp)

Three launch bindings — lua_loadELF (src/luasystem.cpp:974) /
lua_loadELFWithPartition (:1020) / lua_loadELFRebootIOP (:1068):
- System.loadELF dispatches on rebootIOP.
- System.loadELFWithPartition hard-requires reboot_iop != 0 and a
partition_context shaped like hdd?:PART: (src/luasystem.cpp:1067-1072);
the partition is passed out-of-band and must not be copied into target argv.
- All three call ClearExecKeepPfsMask() after the launch returns — i.e. only
on failure, since a successful ExecPS2 never returns. The keep-mask is
effectively single-shot per successful launch.

The three teardown contracts (src/elf_loader/src/elf.c)

LoadELFFromFileExecPS2RebootIOPWithPartition (src/elf_loader/src/elf.c:618)
is the central fork:

  1. HDD-backed (partition is hdd/pfs AND filename is hdd/pfs, OR resolved
    path/partition is hdd/pfs) -> ExecuteHddBackedViaEmbeddedLoader
    (defined elf.c:336; dispatched at elf.c:648/:656). DKWDRV-on-HDD
    inherits this same path; the previous V3
    logic that excluded DKWDRV and used a direct
    SifLoadElf -> SifIopReset -> ExecPS2 route black-screened on hardware
    (the documented regression, elf.c:628-644).
  2. BOOT.ELF / DKWDRV-on-HDD via reboot_iop=0 -> embedded-loader
    special-cases in LoadELFFromFileWithPartition: mc?:/BOOT/BOOT.ELF
    routes through ExecuteViaEmbeddedLoader (elf.c:499-502), and
    is_dkwdrv_elf_path does the same (elf.c:516-519).
  3. Non-HDD (USB/MC/MMCE/MX4SIO POPSTARTER, MC DKWDRV) -> the direct path:
    SifLoadElf -> unmount_pfs_slots_for_exec(build_exec_keep_mask(...)) ->
    FlushCache -> SifIopReset (loop) -> SifIopSync -> reload
    rom0:SIO2MAN/MCMAN/MCSERV -> SifExitRpc -> (DKWDRV argv0 synthesis,
    elf.c:691-697) -> ExecPS2 (elf.c:645-700).

The embedded loader handoff (ExecuteViaEmbeddedLoader, elf.c:397)

Validates the loader_elf ELF magic, wipes BRAM (0x84000-0x100000), writes the
EmbeddedLoaderMetadata struct to the fixed address 0x00083C00 with magic
'POPL' (0x504F504C) and version 1 (elf.c:159-170), copies the child's
PT_LOAD segments into BRAM, then tears down
(SifExitIopHeap/SifExitRpc/SifExitCmd/FlushCache(0)/FlushCache(2)) and
ExecPS2's the child entry. The metadata carries partition_context[128] +
load_path[256].

The BRAM child loader (src/elf_loader/src/loader/src/loader.c:280)

Reads the metadata from 0x00083C00, reconstructs/synthesizes target argv, then
branches three ways before ExecPS2 (loader.c:373-427):
- (a) filexio-direct-load (non-hdd-context pfs/hdd load_path):
SifExitRpc only, then ExecPS2 (loader.c:373-379).
- (b) hdd-partition-context: unmount the pfs prefix, SifExitRpc +
SifExitCmd, ExecPS2 (loader.c:381-403; SifExitRpc :396,
SifExitCmd :397, ExecPS2 :401).
- (c) generic: SifExitRpc only and intentionally NO SifExitCmd
(loader.c:404-427).

The absence of SifExitCmd on branch (c) is the single remaining pre-ExecPS2
difference between the hardware-pass and hardware-fail states. The comment at
loader.c:405-413 documents that an "align with reference loaders" change
which added SifExitCmd here caused a black-screen regression. See
PRESERVATION_CONTRACTS.md.

Keep-PFS mask

A 4-bit mask (slots 0-3) controls which pfs mounts survive the pre-exec
unmount. PrepareForExternalELFLaunch (system.lua:1120-1148) computes the
keep set from the exec path's pfs slot plus the boot pfs slots and calls
System.setExecKeepPfsMask. The C side (elf.c:33-45, :97-112) masks
&0x0F and unmount_pfs_slots_for_exec preserves masked slots. HDD-booted
POPSLoader's pfs1: (BOOT_HDD_MOUNT_SLOT=1) is the slot that must survive
BOOT.ELF/exit; forgetting it (or using the cold-prep path that forces mask=0)
leaves fileXio holding the pfs1: RPC server thread and SifIopReset hangs.

Pre-exec validation gate

ValidateHddPopstarterExecGate (system.lua:1655-1725) resolves and mounts the
target partition and probes that the exec-path file exists before allowing the
launch; failure routes to BlockLaunchFailure with a diagnostic screen. A
non-strict fallback (ResolveFallbackMountedPfsExecPath, system.lua:1727) can
reconstruct a partition-aware exec path from a bare pfsN:/ path when the
partition context could not be derived.

Auto-launch (NHDDL -page/-game)

PLDR.AutoLaunchFromLaunchArgs (system.lua:6139, called at :6270) requires
both -page and -game, maps the page to a scene + game-location root,
runs that backend's lazy init, then calls PLDR.RunPOPStarterGame (same engine).
On success ExecPS2 never returns, short-circuiting the normal UI boot.


Data and config layer

POPSTARTER path (one value; the profile presets are gone)

2026-07-13 (R3Z3N review round 2): the profile-preset system was removed
pops_profiles.lua (16 preset POPSTARTER.ELF locations), PLDR.PROFILES,
SELECTED_PROFILE, and the PROFILE_DEFAULT/CUSTOM selection mode are gone.
One value remains: PLDR.POPSTARTER_PATH. Empty = Automatic (the launch
ladder below; the Settings row shows "Automatic"); a set path = step-1 custom
with silent ladder fallback when it doesn't resolve. Legacy .pldrs migrate
on load: an empty path + PROFILE=N (N>=2) materializes that old preset's
absolute path into POPSTARTER_PATH (the Automatic ladder doesn't probe
every preset location); PROFILE=1 lands on Automatic.

Launch-time POPSTARTER.ELF resolution (per device)

At launch, PLDR.ResolveLaunchPopstarterPath (system.lua) picks the actual
POPSTARTER.ELF per device, in order. For removable devices (USB / internal
exFAT-ATA / MX4SIO / MMCE): 1. the explicit user-configured absolute path
("POPSTARTER Path") when it resolves; 2. the game's own
<device>:/POPS/POPSTARTER.ELF when it exists; 3.
POPSTARTER.ELF in the folder POPSLOADER.ELF launched from (cwd); 4. the existing
mc0:/mc1: fallback net. Both the device and cwd steps are
existence-gated, so a device with no POPSTARTER falls straight through — this lets a
per-device build be used without forcing it (e.g. a USB-delay POPSTARTER dropped
in a USB drive's POPS/ folder is used for that drive's games, a faster build
elsewhere). For the internal-PFS HDD (APA) the order is the same shape with
hdd0:__common/POPS/POPSTARTER.ELF as step 2, resolved through the partition
machinery that preserves the D-10/D-15 partition-context + embedded-loader path.
The profiles removal did not touch this ladder (UI/config layer only). The
only user-facing POPSTARTER warning is the launch-time "no POPSTARTER.ELF found
anywhere" toast; a set-but-stale custom path falls through silently by design.
The ui.lua launch preflight (the gate that shows that toast) resolves with the
game entry's own device root on removable pages — NOT PLDR.GAMEPATH, which
is deliberately empty on the multi-drive USB page (its entries encode their root
as "<root>POPS/|name.vcd") — so the gate probes exactly what
RunPOPStarterGame(root, rel) will resolve at launch. GHDD keeps GAMEPATH
(its entries encode a partition name, and the partition machinery is separate).

Image atlas (bin/POPSLDR/images.lua)

IMG_REGISTRATIONS (images.lua:11-36) is 25 {key, filename} pairs (device
icons, backgrounds, splash layers, button/d-pad glyphs, the jewel-case frame,
plus default, cover_default, and cover_missing). The missing key was
removed alongside MISSING.png. The IMG table lazy-loads each PNG from an
embedded blob via System.getEmbeddedAsset -> Graphics.loadImageEmbedded,
caches it, and records permanent failures in IMG_FAILED
(__index at images.lua:50-79). IMG_FALLBACKS (images.lua:46) is now an
empty table — there are no key-to-key fallback edges anymore. This is the UI
chrome/glyph atlas, not per-game box art (covers are UI.CoverCache, Layer 4).

Launch arguments

Parsed C-side (parseLaunchArgs, src/main.cpp:198), normalized in Lua: NormalizeLaunchPage
(system.lua) folds page kinds: ata/ata0/ataN -> EXFAT, hdd/apa/pfs -> HDD,
usb/mass -> USB, mmce -> MMCE, mx4sio/mx4/sdc -> MX4SIO, etc., into
PLDR.LAUNCH_ARGS. -page (and the -mode alias) auto-navigate the carousel via
page_to_opt = {MMCE=1, MX4SIO=2, EXFAT=3, ATA=3, HDD=4, USB=5, SMB=7}: EXFAT/ATA
map to the exFAT page (opt 3, GBDMHDD), HDD to the PFS page (opt 4), and SMB
to the SMB (v1) network page (opt 7, GSMBNET). The bare bdma token and i.Link
remain unrouted no-ops.

On-disk settings (.pldrs)

Plain KEY=VALUE text with 24 keys (EncodeSettings, system.lua; the
loader normalizes CRLF before parsing, and usb:/smb:-prefixed boots fall back
to the MC settings path since those filesystems aren't live in-app),
then the SMB connection block (SMB_*) appended by SmbAppendLines:
POPSTARTER_PATH ("" = Automatic), BDMA, BDMA_ADAPTIVE
(per-launch BDMA variant staging, default off; see STATE.md), DKWDRV_PATH,
STRICT_HDD_PREEXEC_GATE, VIDEO_STANDARD, HIDE_TEXT, KEYBOARD_LAYOUT,
LANGUAGE, BOOT_PAGE, MULTIDISC_COLLAPSE, GLOBAL_HIDE, POPSTARTER_MC_FOLDER,
HIDDEN_DEVICES, SHOW_DETAILS, DETAILS_ALIGN, ART_LOCATION (pops /
pops_art / art, default art; its Settings row was removed in EXP35 and
since EXP71 the cover path is hard-locked, so the key is now INERT and kept only
so older sidecars still parse), HDD_FS (PFS / EXFAT / BOTH, default
BOTH
), COVER_ART (default on; whether covers draw at all, replaced the
session-only Square toggle in EXP42), GAMELIST_CACHE, BOOT_SOUND
(default on; gates the splash ADPCM chime), OVERSCAN (CRT inset permille,
default 0; see the overscan note below), SMB_MODULES. The legacy
PROFILE=/POPSTARTER_MODE= keys are no longer written; on load a legacy
PROFILE=N pick migrates into POPSTARTER_PATH (2026-07-13). STATE.md is canonical for what each key means and its UI
surface.
Location is the per-device sidecar APP_DIR/.pldrs, preferred for every
device — including HDD installs, which now persist on the HDD boot partition
itself via the PLDR.HDD.EnsureBootPartitionWritable RW mount take-over
(system.lua:2159): the launcher's boot pfs slot is unmounted and remounted
read-write in place ("own your mount", the OPL pattern), so the sidecar is
written on-HDD. There is no mc0: fallback for an HDD-cwd install
single-device parity; mc0:/POPSTARTER/.pldrs remains only as a legacy fallback
when no per-device sidecar can be computed. (This supersedes the old PR #466
carve-out — "HDD saves to MC because the bundled ps2hdd-osd.irx can't reliably
write PFS"; see STATE.md > Settings (single-device parity) and
STATE.md > Preservation Contracts.)
LoadSettingsNonFatal (system.lua:3301) resolves the path and performs the
legacy MC->sidecar migration; SaveSettingsAtomic (system.lua:3262) writes via
WriteAtomic (tmp + rename, system.lua:2663-2693). Edits are staged as UI
drafts and committed transactionally by PLDR.CommitSettingsChanges
(system.lua:3540), which snapshots prior state and rolls back on save or
BDMA-apply failure.

Overscan (OPL-style render-inset)

A single render-coordinate inset adapts OPL's rmSetOverscan to this UI at the
one graphics chokepoint. src/graphics.cpp keeps a g_overscan permille
(graphics.cpp:1135); recompute_overscan (:1140) derives a uniform
center-scaling transform exposed as the inline OVX(x)/OVY(y)
(graphics.cpp:1165-1166), and every gsKit_prim_* draw site is wrapped in
OVX()/OVY() so the whole UI scales toward screen center by the overscan
amount. The math is identical to OPL (margin = W*permille/2000 per edge,
scale = 1 - permille/1000); at permille 0 the transform is the identity,
so the feature is inert by default. set_overscan/get_overscan
(graphics.cpp:1155/:1163) are bound to Lua as Screen.setOverscan /
Screen.getOverscan (src/luaScreen.cpp:64/:71, registered at :162-163).
The Settings entry "Overscan (CRT inset)" (ui.lua:3632) is a live ±5-step
adjuster (clamped 0..100) that previews immediately and discards on cancel;
ResetDefaults restores 0 (ui.lua:3356-3358); persistence is the OVERSCAN
.pldrs key.


Layer 7 — Build / embed / CI pipeline

Asset embedding

The top-level Makefile builds a single packed EE ELF inside the pinned
ps2dev/ps2dev:v2.0.0 Docker toolchain. Every Lua/PNG/IRX/icon/ADP blob is
turned into a .c file by ps2sdk's bin2c (BIN2S = $(PS2SDK)/bin/bin2c,
Makefile:67), exposing <symbol>[] + size_<symbol>; those compile to .o
and link into the ELF (EMBEDDED_RSC, Makefile:96-105). The embedded IRX
object list is at Makefile:89-94 (usbd, audsrv, bdm, bdmfs_fatfs, usbmass_bd,
cdfs, ds34bt, ds34usb, ps2dev9, ps2atad, ps2hdd-osd, ps2fs, mmceman, mx4sio_bd, ata_bd,
bdm_query). At runtime, embed_assets.cpp::embedded_get (src/embed_assets.cpp:195)
resolves blobs by normalizing the request (embed:/ / ./ / leading-slash
stripping, IMG/ -> POPSLDR/IMG/) and looking the key up in the single
g_embedded_assets[] table (embed_assets.cpp:93).

The embed table is NOT auto-globbed — adding/removing an embedded asset is
3 explicit, hand-coordinated places
(a 4th if you count the extern decl).
Miss one and it silently fails to resolve (or fails to link):
1. Makefile — a bin2c rule producing asset_<name>.c, plus the matching
asset_<name>.o in EMBEDDED_RSC (Makefile:96-105).
2. src/embed_assets.cpp — an extern decl for the symbol, then an
ASSET_ENTRY in both halves of g_embedded_assets[]: the bare-name row
(e.g. "frame.png") and the POPSLDR/IMG/-prefixed row (e.g.
"POPSLDR/IMG/frame.png").
3. bin/POPSLDR/images.lua — an IMG_REGISTRATIONS {key, filename} pair;
the lookup uses the bare filename.

This is exactly the path the MISSING.png removal (−62 KB ELF) and the
cover_default.png / cover_missing.png additions exercised: each touched all
three places. There is no machine-checked manifest tying them together.

Two-stage child loader build

Final packaging

all (Makefile:115) builds bin/enceladus.elf (EE_BIN), then the
$(EE_BIN_PKD): $(EE_BIN) rule strips it and runs ps2-packer to produce
bin/POPSLOADER.ELF (Makefile:118-120).

CI/CD

Two build workflows duplicate most logic inline:
- compilation.yml — runs on all branches/tags/PRs/dispatch; runs the now-LIVE
embedded-Lua syntax gate (luac5.4 -p, see below), builds, and packages
POPSLOADER.zip as an artifact (no GitHub release).
- rolling-release.yml — runs on push to dev (the active rolling
branch; repiped BETA-12-PLAY -> BETA-13-PLAY -> dev) and on PR events.
On a push to dev it bundles the ELF + full git-tracked source and
force-updates the single rolling-release prerelease via the GitHub API. PR
runs build and harness-gate only — they do NOT publish
(the publish step is
gated github.event_name == 'push', rolling-release.yml:287, since PR #511);
the old "any same-repo PR republishes rolling" hazard is closed.
- opencode.yml — an /oc comment bot (DeepSeek), outside the build/launch
path.

Both build workflows enforce embed-identity gates:
- String markersExec path:, PrepareForColdExternalELFLaunch,
BOOT.ELF launch failed must be grep-found in bin/enceladus.elf. They are
guaranteed present by __attribute__((used)) CI marker constants
(src/main.cpp:39-41).
- Loader staleness/parityloader.c must exist; if its timestamp is not
newer than the loader source, CI re-runs bin2c on the freshly built
loader.elf and cmp -s requires byte-for-byte equality.
- Lua validation — required .lua files must exist, etc/boot.lua must end
with 0x0A, and the embedded-Lua syntax gate is now LIVE: the workflows
apk add lua5.4 and run luac5.4 -p on bin/POPSLDR/*.lua + etc/boot.lua,
hard-failing on a syntax error. It previously silently no-op'd because the
pinned ps2dev image shipped no luac. It catches SYNTAX only — runtime
nil-global / type / load-order errors stay invisible to CI (the d4b04be
load-order boot brick was exactly such a case). See STATE.md > CI / release.

BUILD_INFO.txt is generated fresh in CI (7-char commit SHA + UTC stamp), not
committed; the runtime UI reads it (ui.lua:3413-3465) and shows the stamp on
screen so hardware testers can confirm the exact artifact.


Preservation contracts

Several teardown details across elf.c and loader.c are load-bearing and each
maps to a specific hardware regression (D-10, D-15, U-10). Do not change them
without a hardware-verified replacement. The HDD boot-partition RW take-over
(PLDR.HDD.EnsureBootPartitionWritable, system.lua:2159) is also now
load-bearing — it owns the boot pfs slot for HDD settings save and HDD in-app
.hide, so any launch-path / mount change must not break it.

The authoritative list — including the "no SifExitCmd on the generic child
branch", the partition-aware HDD route, the keep-PFS-mask handling for pfs1:,
the BRAM metadata address/magic coupling, and the EnsureBootPartitionWritable
take-over — lives in PRESERVATION_CONTRACTS.md; see also
STATE.md > Preservation Contracts and STATE.md > Behavioral Invariants.