# Technical Plan: PS1 (PlayStation 1) Games in the Browser with Multiplayer

**Status:** Draft v1.1
**Scope:** A web app where a user supplies their own PS1 game file (`.bin/.cue`, `.iso`, `.chd`, `.pbp`, …) and plays it in the browser — on desktop **and mobile (touch controls)** — alone or together with other players in the same game session.

---

## 1. Goals & Non-Goals

### Goals
1. **User-supplied game file** — drag-and-drop / file-picker upload of a PS1 disc image; no game content shipped by us.
2. **Playable in-browser emulation** — accurate PS1 emulation (video, audio, controllers, memory cards, save states) with no native install.
3. **Multiple players in the same game**:
   - **Local multiplayer** on one machine (2P keyboard/gamepads, multitap up to 8P where the game supports it).
   - **Online multiplayer (netplay)** — multiple remote players controlling the *same running game session* with low latency.
   - **Co-op / turn-taking ("hot-seat")** — several people share one session by passing control tokens (useful for single-player games).
4. **Mobile playable (touch-first controls)** — a full on-screen control system (virtual gamepad + layout editor) so phones/tablets are first-class devices, plus support for Bluetooth/USB controllers on mobile.
5. **Persistence** — memory-card saves, save states, control layouts, and uploaded game files survive reloads.

### Non-Goals (v1)
- Emulating PS2/PSP or other systems.
- Distributing copyrighted ROMs, BIOS files, or circumventing copy protection.
- Perfect sub-frame online timing for competitive fighting-game standards (target ~60–100 ms added latency; rollback is a v2 option).

### Legal note (must appear in the product UI)
> The application only runs game images that **you own** and dump from your own discs. We ship no game content. The PS1 BIOS is also user-supplied (or replaced by the emulator's open HLE BIOS); BIOS files are Sony copyrighted and cannot be bundled.

---

## 2. Background: Why PS1-in-a-Browser Is Feasible

- The PS1 (R3000A CPU @ 33.87 MHz, 2 MB RAM, 1 MB VRAM, GTE geometry coprocessor, SPU audio) is light enough to emulate at full speed in WebAssembly on mid-range hardware — typically **1–3 cores of overhead**, leaving room for netplay logic.
- Mature **open-source PS1 emulator cores already compile to WASM** via Emscripten.
- Deterministic, fixed-timestep emulation makes **lockstep netplay** practical: both peers run the same core on the same inputs and arrive at identical machine state, so only *inputs* need to be sent over the network.

---

## 3. Emulator Core Selection

| Option | Accuracy | WASM maturity | Multi-disc/m3u | Save states | Determinism for netplay | Verdict |
|---|---|---|---|---|---|---|
| **PCSX-ReARMed** (libretro core) via **RetroArch WASM / EmulatorJS** | Good (HLE BIOS; optional real BIOS) | High — ships as a ready-made WASM core, works today | Yes (`.m3u`) | Yes | Good (fixed-cycle core; disable async SPU) | **MVP choice** |
| **Play!** (jpd002) Emscripten build | High | Medium — community web build, smaller ecosystem | Yes | Yes | Needs verification | **v2 accuracy option** |
| **DuckStation** | Very high | Low — no mature official web build; heavy GL/Vulkan deps | Yes | Yes | Untested | Not for v1 |
| **Beetle PSX HW (SwanStation)** via libretro | High | Low in browser (Vulkan/GL renderer) | Yes | Yes | Untested | Not for v1 |
| **PCSX-Redux** | Dev/debug oriented | Has an experimental web target | Yes | Yes | Untested | Research only |

**Decision:** wrap **PCSX-ReARMed WASM** behind a thin `EmulatorCore` interface so the core can be swapped for Play! later without touching the app.

### 3.1 Core build & integration requirements
- Emscripten build flags: `-s MODULARIZE=1 -s EXPORT_ES6=1 -s ENVIRONMENT=web,worker -s ALLOW_MEMORY_GROWTH=1`.
- Run the core inside a **Web Worker** with an **OffscreenCanvas** (emulation loop never blocks the UI thread).
- Optional `-pthread` build (WASM threads) **only when cross-origin isolated**; always provide a single-threaded fallback binary (iOS Safari is the main consumer of the fallback).
- Export a small C ABI (or use Emscripten `ccall`) for: `coreLoad(gameFiles)`, `coreRunFrame()`, `coreSetInput(player, bits)`, `coreSerialize()/coreDeserialize()`, `coreSetHash()` (for desync detection).
- **BIOS:** prefer the core's **HLE BIOS** path (no file needed); accept a user-dropped `scph5501/5502/5503.bin` when a game requires it (detect by game-SHA blacklist/whitelist).

---

## 4. Game File Ingestion Pipeline

### 4.1 Accepted inputs
| Format | Notes |
|---|---|
| `.bin` + `.cue` | Most common PS1 dump; **multi-BIN cues require selecting all files at once** |
| `.iso` | Single file, common for PS1 ISO dumps |
| `.chd` | **Recommended format** — single file, compressed, contains cue+bin internally; PCSX-ReARMed supports it natively |
| `.pbp` | Single-file PSP-style pack of PS1 discs; supported by ReARMed |
| `.img` / `.raw` (+ `.cue`) | Treated like bin/cue |
| `.exe` / `.ps-exe` | Bare executables (homebrew/scene); supported by ReARMed |
| `.m3u` | Multi-disc playlist (text file referencing other images) |
| `.zip` | Unpacked client-side (fflate); reject archives > ~1 GB after inflate |
| `.7z` | Optional (7z-wasm), lower priority |

### 4.2 Flow
```
[File picker / drag-drop]
        │  (File System Access API: showOpenFilePicker({multiple:true}) with
        │   <input type="file" multiple> fallback for Safari/Firefox)
        ▼
[Extension sniff] ──► .cue → parse text, match referenced .BIN names against the
        │              selected file set; error if a track file is missing
        ▼
[Unpack] zip/7z via fflate / 7z-wasm in a worker
        ▼
[Normalize into a "GamePackage"]
        { id: sha256(file bytes) , disc: {tracks:[{path,bytes,offset,size}]},
          cueText?, region?, title? }
        ▼
[Persist] IndexedDB (metadata) + OPFS or IndexedDB blob store (raw bytes)
        ▼
[Stream to core] — pass File/Blob handles; core reads lazily via
                  Emscripten MEMFS preload or a custom read callback so the
                  ~700 MB disc never has to be fully copied into the WASM heap
```

### 4.3 Key engineering details
- **Hashing:** SHA-256 of the disc image → game identity (used for netplay room matching, save-state compatibility, control-layout per-game lookup, and cheat/compatibility DB lookup). Use `crypto.subtle.digest`.
- **Memory:** do **not** copy the whole disc into wasm linear memory. Prefer CHD (compressed on disk) + lazy decompression, or pass a JS `Blob` through a pread-style FS callback.
- **Cue parsing:** write a small tolerant parser (FILE/TRACK/INDEX modes, MSF addressing). Multi-bin cues must be resolved *before* hashing.
- **Validation:** show track layout, detected region (via `SYSTEM.CNF` `BOOT` string / `LICENSE`), and game title (from `SYSTEM.CNF` or library lookup) before starting.
- **Persistence:** store packages in OPFS (`navigator.storage.getDirectory()`) with an IndexedDB index; show storage quota (`navigator.storage.estimate()`) and request `navigator.storage.persist()` on mobile (browsers evict non-persistent storage under pressure); offer "eject game".

---

## 5. Emulation Runtime (Single-Player Foundation)

```
┌──────────────────────────── Browser main thread ───────────────────────────┐
│  UI (React)  │  File picker  │  Settings  │  Lobby/Chat  │  HUD overlay    │
│      ▲ input events (keyboard / gamepad / TOUCH — see §6)                  │
│      │      ▼ controller state writes                                      │
│  SharedArrayBuffer input ring  (per-player, ControllerState × N players)   │
└──────┬──────────────────────────────────────────────┬───────────────────────┘
       │                          cross-origin isolated │
┌──────▼──────────────────────── Emulation Worker ─────▼─────────────────────┐
│  fixed-timestep loop @ 59.94 Hz (NTSC) / 50 Hz (PAL):                     │
│    read inputs → coreRunFrame() → push video frame → push audio            │
│  Video: OffscreenCanvas / WebGL2 blit of core framebuffer                  │
│  Audio: ring buffer → AudioWorklet (48 kHz, resample from 44.1 kHz)        │
│  Netplay: input exchange + state hashing live here (same thread as core)   │
└────────────────────────────────────────────────────────────────────────────┘
```

- **Timing:** the emulator must run from an *emulated clock* (cycles per frame), never from `performance.now()` deltas — this is a hard prerequisite for determinism/netplay. Audio buffering must not feed back into emulation speed (fixed rate, drop/insert audio instead).
- **Video:** core writes into a WASM framebuffer; worker transfers it as an `ImageBitmap` or draws via WebGL2 texture upload; `requestAnimationFrame` on the main thread presents the latest frame (triple-buffered).
- **Audio:** SPU output → `AudioWorklet` pull with a jitter buffer (~60–100 ms). Mute on `visibilitychange` policy choice. **Mobile:** the `AudioContext` must be resumed from a user gesture — the "Tap to Start" screen doubles as this gesture gate.
- **Input:** sources normalize into a `ControllerState` (see §6) written into the `SharedArrayBuffer` ring; the core samples it once per emulated frame (input latency ≤ 1 frame).
- **Cross-origin isolation:** serve with `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` (+ `Cross-Origin-Resource-Policy` on assets) to enable SAB and wasm threads. Feature-detect `crossOriginIsolated`; fall back to `postMessage` input passing.

---

## 6. Input Control System (Desktop + Mobile)

**Design principle:** every input source — keyboard, physical gamepad, on-screen touch controls, or a remote netplay peer — normalizes into the *same* per-player `ControllerState` and is written to the same SAB ring. The emulator core never knows where a button press came from, so mobile touch is a first-class citizen, not an afterthought.

### 6.1 Sources & priority

| Source | API | Platform | Notes |
|---|---|---|---|
| Virtual gamepad (touch) | Pointer Events overlay (§6.2) | Phones/tablets, touch laptops | **Primary mobile source** |
| Physical gamepad | `Gamepad API`, polled per frame | Desktop, Android, iOS 16+ (Bluetooth/USB) | Auto-detected via `gamepadconnected` |
| Keyboard | `keydown/keyup` by `KeyboardEvent.code` (layout-independent) | Desktop | Rumble ignored |
| Remote peer | `InputPacket` over WebRTC, applied at frame `F + D` | All | §7.2 |

**Merging:** sources are OR-combined per player, so a phone with a paired Bluetooth controller can use either. When a physical pad connects, the UI **auto-hides the virtual pad** (with a manual override toggle in the HUD). Controls are always **per-player**: each player's source binds to one PS1 controller port.

### 6.2 Virtual gamepad (touch controls)

Landscape default layout (normalized coordinates, all positions/sizes user-editable):

```
┌──────────────────────────────────────────────────────────────────────┐
│  [≡ HUD]                                        [L2][R2] (stacked)  │
│                                                     [L1][R1]         │
│  ╭───────╮                                  ┌─────┬─────┐           │
│  │ D-pad │  OR  (analog stick mode)         │  ▲  │  ✕  │           │
│  │ ◀ ▲ ▼ ▶│                                 ├─────┼─────┤           │
│  ╰───────╯                                  │  ◀  │  ○  │  [START]  │
│  (virtual analog stick:                     ├─────┼─────┤  [SELECT] │
│   relative drag from touch origin,          │  ▼  │  □  │           │
│   deadzone + cardinal-snap assist)          └─────┴─────┘           │
│  ┌──┐ [analog R stick, for DualShock games]                         │
│  └──┘                                                                │
└──────────────────────────────────────────────────────────────────────┘
```

**Components:** D-pad (or left analog stick), face buttons ✕○□▲, L1/L2/R1/R2, Start/Select, right analog stick (shown only for games whose compatibility entry declares analog/DualShock support).

**Engineering requirements:**
- **Multi-touch:** Pointer Events with per-`pointerId` tracking and `setPointerCapture` so run+jump+turn (3+ simultaneous contacts) never drops; support up to 10 pointers. Set `touch-action: none` on the overlay and `overscroll-behavior: none` on the page — no scrolling, pinch-zoom, or double-tap-zoom while playing.
- **Latency:** write the new `ControllerState` **directly inside the pointer handler** (no waiting for `requestAnimationFrame`) into the SAB ring; budget **event → SAB write < 4 ms**. Visual feedback (button press highlight) is immediate via CSS state.
- **Analog sticks:** virtual stick = vector from touch-origin to current point, normalized to −127..127, configurable **deadzone** (default ~10 %) and optional **cardinal snap** in D-pad mode (8-way assist for platformers). Right stick uses the same component with its own anchor point.
- **Shoulder buttons:** L2/R2 stacked above L1/R1 (PS1-style), semi-transparent (40 % opacity idle → 85 % pressed), large hit targets (≥ 44 px CSS minimum, recommended 56–64 px for triggers).
- **Ergonomics:** global scale slider (75–150 %), opacity slider, **left-handed mode** (mirror layout), per-button size adjustment; haptic feedback `navigator.vibrate(8)` on press where supported (Android; iOS ignores it harmlessly).
- **Implementation choice:** DOM/CSS overlay elements above the canvas (chosen over canvas-drawn pads for the free layout editor, accessibility labels, and CSS-only press animations). Keep the overlay on its own compositor layer (`transform`/`opacity` only, `will-change: transform`) so presses never repaint the game canvas.

### 6.3 Layout editor
- **Drag-and-drop editor** (from the pause menu): move, resize (corner handle), opacity, per-button scale, snap-to-grid; live preview over a frozen game frame.
- **Presets:** *Phone landscape*, *Phone portrait* (smaller game canvas, compact pad), *Tablet*, *Left-handed*, *2-player split* (§6.8), plus genre presets (fighting: large face buttons + D-pad; racing: triggers emphasized; platformer: D-pad + minimal shoulders).
- **Per-game persistence:** layouts keyed by `gameKey` (SHA-256) falling back to a global default; export/import as JSON; "reset to default".
- **Profile model:** `global → genre → per-game` override chain.

### 6.4 Orientation, viewport & safe areas
- **Landscape recommended:** on portrait entry show a non-blocking "rotate device" hint; the game runs in portrait too (canvas shrinks), except fullscreen where we attempt `screen.orientation.lock('landscape')` (Android Chrome only — iOS Safari has no orientation lock, so rely on the hint).
- **Safe areas:** size/position the overlay with `env(safe-area-inset-left/right/top/bottom)` so notch/hole-punch/home-indicator hardware never covers buttons.
- **Viewport instability:** position against `window.visualViewport` (not `window.innerHeight`) and re-layout on `resize`, `scroll` (iOS URL-bar collapse), and `orientationchange`.
- **Fullscreen:** Fullscreen API button in the HUD (`requestFullscreen` + `orientation.lock` best-effort); fall back to browser chrome when unavailable.
- Page shell: `user-scalable=no` **only on the play route** (menus stay pinch-zoomable for accessibility), `overscroll-behavior: none`, block iOS pinch `gesturestart` during play.

### 6.5 Physical controllers on mobile
- `Gamepad API` on Android Chrome and iOS 16+ Safari supports Bluetooth (and USB via adapter) controllers — **recommended way to play seriously on a phone/tablet**, and the way to do 2P on one mobile device.
- Auto-hide virtual pad on `gamepadconnected`; per-pad remap UI shared with desktop; deadzone/diagonal calibration; rumble via `Gamepad.hapticsActuator` where available.

### 6.6 Mobile performance & browser quirks
- **iOS Safari:** no WASM threads → single-threaded core build; `AudioContext` needs a user gesture → "Tap to Start" gate; storage may be evicted → `storage.persist()` + quota UI; no `OffscreenCanvas` fallback path on very old iOS → main-thread canvas fallback (already planned in §11 risks).
- **Android mid-range:** feature-detect `deviceMemory` / `hardwareConcurrency`; below a threshold default to 1× internal resolution and 0.85× render scale.
- **Thermal throttling:** monitor fps; on sustained < 55 fps show a "reducing resolution" notice rather than silently stuttering.
- **Battery:** pause emulation and release the audio device when backgrounded (`visibilitychange`); in netplay this posts an "away" status to the room (§7.2.6).

### 6.7 Input flow end-to-end (mobile)
```
finger down ─► pointerdown handler ─► update ControllerState (bit set)
      │                                    │
      │ (sync, < 4 ms)                     ▼
      ▼                          SharedArrayBuffer ring
CSS press feedback               (written immediately, no rAF wait)
                                         │
                                         ▼
                     emulation worker samples ONCE per frame (≤16.7 ms)
                                         │
                                         ▼
                        coreSetInput(port, buttons, sticks)
                        (netplay: packetized → frame F+D, §7.2.2)
```

### 6.8 Multiplayer + touch
- **Mode A on one touch device:** a *split overlay* preset puts P1 controls on the left half and P2 on the right half (cramped but functional for puzzle/turn-based); for action 2P we recommend two Bluetooth pads or two devices + netplay.
- **Mode B (netplay) from mobile:** virtual pad latency is inside the normal input budget (event → SAB < 4 ms, plus `D` frames of network delay). Cellular networks are handled by the adaptive delay formula (§7.2.3) and TURN fallback (§7.2.6).
- **Mode C1 (host-streamed):** guests on phones get the virtual pad over the same `ControllerState` path; only the *host* runs the emulator, so guest mobile CPU is irrelevant.
- Backgrounding mid-netplay (app switch, incoming call) → stall watchdog (§7.2.3) shows "player paused"; auto-rejoin with state resync (§7.2.4) on return.

### 6.9 Accessibility
- Every virtual button carries an ARIA label; the *menu* UI is screen-reader navigable (the game canvas is exempt).
- **One-finger mode:** optional "hold-to-toggle" mapping (e.g., tap-to-toggle auto-run) and adjustable press-and-hold delay for motor impairments.
- High-contrast outline option for controls; all layouts fully remappable (any control → any position/size).

---

## 7. Multiplayer Architecture

Three complementary modes; ship in this order.

### 7.1 Mode A — Local multiplayer (same machine, same browser)
- **Simplest and most reliable**; PS1 natively supports 2 controllers; multitap games support up to 8.
- Desktop map: Player 1 = keyboard WASD+left-hand / Gamepad 0; Player 2 = arrow keys / Gamepad 1; etc.
- Mobile map: virtual pad = current player; **Bluetooth gamepads** preferred for 2P; one-device *split overlay* as fallback (§6.8).
- Provide a **control-remapping screen** (shared by keyboard/gamepad/touch) and multitap on/off setting (mirrors the emulator's `Multitap` option).
- Effort: **small** (the SAB input ring is already per-player).

### 7.2 Mode B — Online netplay (deterministic lockstep) ← core of "multiple players play in the same game"
Both peers run the *same* emulation; only inputs are transmitted.

#### 7.2.1 Session model
```
Room (WebSocket signaling / relay)
 ├─ host: authoritative for lobby, settings, and (optionally) state sync
 ├─ members: player slots 0..7, each maps to a PS1 controller port
 ├─ gameKey: sha256(disc) + coreVersion + settingsHash   ← must match on all peers
 └─ frameClock: shared monotonically increasing frame counter
```

#### 7.2.2 Per-frame protocol (WebRTC DataChannel, ordered+reliable is fine; WS fallback)
```ts
type InputPacket = {
  roomId: string;
  frame: number;          // frame this input takes effect on
  playerId: string;
  port: number;           // PS1 controller port 0..7
  buttons: number;        // 16-bit PAD bitmask (active-low, PS1 layout)
  analog?: { lx: number; ly: number; rx: number; ry: number }; // DualShock
  seq: number;
};
```
The packet is built from the same `ControllerState` the touch/keyboard/gamepad sources produce (§6) — source-agnostic by construction.

#### 7.2.3 Synchronization strategy (delay-based lockstep, v1)
1. **Input delay `D` frames** (default `D = 3`, ≈50 ms NTSC): local input for frame `F` is applied at frame `F + D`, giving remote inputs time to arrive. `D` adapts to measured RTT: `D = clamp(ceil(RTT / 2 / 16.7ms) + 1, 2, 8)`.
2. Each peer buffers remote inputs in a **slot array keyed by frame**; the emulation loop for frame `F` **blocks until inputs for `F` from all active players are present** (with a 200 ms watchdog → "connection stalled" overlay, then pause or graceful disconnect).
3. Late input (arrived after frame executed) = **desync risk** → trigger resync (below).

#### 7.2.4 Desync detection & recovery
- Every **60 frames**, each peer computes a **state hash**: CRC32/FNV-1a over `2 MB main RAM + 1 MB VRAM + scratchpad + CPU regs + GTE regs + SPU state + emulated clock`.
- Exchange hashes; on mismatch:
  1. Log divergent subsystem (per-region hashes localize it).
  2. **Resync:** host serializes its savestate (`coreSerialize()` ≈ a few MB, compress with `CompressionStream('gzip')` → typically 300–700 KB) and sends it; peers `coreDeserialize()` and resume at the next keyframe.
  3. If mismatch recurs within 5 s → hard-stop with "incompatible settings/core" message.
- **Prevent desyncs proactively:** pin the exact core WASM binary (content hash) served from our CDN; force identical settings (region, frame limit, disc, BIOS mode, multitap, audio-async OFF, fast-forward OFF); verify `gameKey` before the room starts. **Touch layouts never affect determinism** — they only produce the same `ControllerState` bits any source would.

#### 7.2.5 Latency targets & rollback (v2)
- v1 target: **RTT ≤ 80 ms regions playable at D = 3–5 frames** (50–83 ms added input lag — acceptable for racing/co-op/puzzle).
- v2 **rollback netcode** (GGPO-style): snapshot every frame (PS1 state ≈ 3–4 MB raw → too heavy per-frame over the wire, but *local* snapshots are cheap: ring buffer of 10–20 states in memory). Roll back and re-simulate `RTT/2` frames on late input. Requires: fast save/load (< 2 ms) and re-simulation of the SPU without audio glitches (mute during rollback). Feasible; schedule only after lockstep ships.

#### 7.2.6 Networking infrastructure
- **Signaling/rooms:** WebSocket service (Node `ws` / Cloudflare Durable Objects / Supabase Realtime) — create/join room by code, exchange SDP/ICE, relay `InputPacket`s if P2P fails.
- **P2P:** WebRTC DataChannel (`ordered: true`), STUN (public Google/Cloudflare), **TURN (coturn, required — ~15 % of clients are symmetric NAT)**; mobile cellular clients are the main TURN users, so plan TURN capacity around them.
- **Host migration:** none needed for lockstep (any peer can be lobby host); for savestate resync, designate a "sync master" that everyone can pull from.
- **Anti-cheat:** inputs are the only attack surface; validate button-bit ranges and frame monotonicity server-side; rate-limit. (Full determinism means a modified client can still cheat — acceptable for v1 casual play.)

### 7.3 Mode C — "One session, shared control" (hot-seat / co-op for single-player games)
- The game runs **once** (on the host or on a cloud instance); members send `ControlToken` grants or raw inputs to the running session.
- **C1 — P2P hot-seat:** host runs the emulator; guests watch a WebRTC **video track** (canvas capture → VP8/AV1, ~1080p30–60) and send inputs through the data channel; host injects them into the corresponding pad. One input at a time (token) or simultaneous (for games that read multiple pads). Guests on phones use the virtual pad (§6.8).
  - *Pros:* zero determinism concerns; any game becomes multiplayer-able; **guest device capability is irrelevant** (good for mobile). *Cons:* host must have a good upstream; guests see host's latency + encode latency (~40–80 ms).
- **C2 — Cloud-hosted streaming (premium):** same as C1 but the emulator runs on our server (headless worker + hardware encode via NVENC/AV1), so no peer upload limits. Requires GPU fleet; **defer to a later phase**.

### 7.4 Mode comparison

| | A: Local | B: Lockstep netplay | C1: Host-streamed |
|---|---|---|---|
| Latency added | 0 | ~50–83 ms (delay) | ~80–150 ms (encode+net) |
| Bandwidth | 0 | ~1–5 KB/s | host: 3–8 Mbps up per guest |
| Works for any game | 2P (+multitap) | only if all players are in-room | ✅ any game, incl. single-player |
| Server cost | none | signaling + TURN | none (P2P) / GPU (C2) |
| Desync risk | none | yes (mitigated §7.2.4) | none |
| Mobile as player | 1 virtual pad / BT pads | ✅ (cellular → TURN) | ✅ (guest renders video only) |

---

## 8. System Architecture (Target)

```
                        ┌─────────────────────────────┐
                        │  CDN / Static (Vite build)  │
                        │  COOP+COEP headers on HTML  │
                        │  core.wasm, EmulatorJS, UI  │
                        └──────────────┬──────────────┘
                                       │ HTTPS
┌─────────────── Browser ──────────────▼──────────────────────────────┐
│  React UI  ── File picker ── Settings ── Lobby/Chat ── HUD          │
│  Input layer (§6): virtual pad (Pointer Events) + Gamepad + kbd     │
│     │                                                               │
│  ┌──▼──────────── Emulation Worker ───────────────┐                │
│  │  PCSX-ReARMed WASM  │  input ring (SAB)        │                │
│  │  OffscreenCanvas→WebGL2 │ AudioWorklet ring     │                │
│  │  netplay FSM: delay-lockstep, hash, resync     │                │
│  └──┬──────────────┬──────────────────┬───────────┘                │
│     │ IndexedDB/OPFS│ WebRTC DataChan  │ WebSocket (signaling)      │
└─────┼───────────────┼──────────────────┼────────────────────────────┘
      ▼               ▼                  ▼
  saves/states    peers (P2P inputs)   ┌──────────────────────────┐
  game packages   + video (mode C1)    │  Room/Signaling service  │
  touch layouts                        │  (create/join, SDP relay,│
                                       │   input relay fallback,  │
                                       │   TURN: coturn)          │
                                       └──────────────────────────┘
```

### Suggested stack
| Layer | Choice |
|---|---|
| Frontend | Vite + TypeScript + React, Zustand for state |
| Input | Pointer Events virtual pad + layout editor, `Gamepad API`, keyboard; all → SAB `ControllerState` ring |
| Emulation | PCSX-ReARMed WASM (own Emscripten build), Web Worker + OffscreenCanvas |
| Audio | WebAudio `AudioWorklet` (gesture-gated on mobile) |
| Storage | IndexedDB (idb-keyval/Dexie) + OPFS for disc images; `storage.persist()` on mobile |
| Realtime | WebRTC DataChannel + WebSocket signaling (Node/`ws` or Durable Objects) |
| TURN | coturn (self-hosted or Metered/Twilio) |
| Chat/presence | same WebSocket room channel |
| Hosting | Any static host that can set COOP/COEP (Cloudflare Pages, Netlify, nginx) |

---

## 9. Key Interfaces (sketch)

```ts
// ---- Input (§6) -------------------------------------------------------
type ControllerState = {
  buttons: number;           // 16-bit PS1 PAD bitmask
  lx: number; ly: number;    // analog sticks, -127..127
  rx: number; ry: number;
};

interface InputSource {
  readonly kind: 'keyboard' | 'gamepad' | 'touch' | 'remote';
  attach(sink: InputSink): void;      // writes ControllerState into SAB ring
}

interface TouchLayout {               // persisted JSON: global / genre / per-game
  id: string;
  scope: 'global' | 'genre:fighting' | `game:${string}`; // game = SHA-256
  controls: Array<{
    id: 'dpad' | 'stick-l' | 'stick-r' | 'cross' | 'circle' | 'square'
       | 'triangle' | 'l1' | 'l2' | 'r1' | 'r2' | 'start' | 'select';
    x: number; y: number;     // normalized 0..1 within safe-area box
    w: number; h: number;     // normalized size
    opacity: number;          // 0..1 idle
    mode: 'dpad' | 'analog';
    player: number;           // PS1 port (enables split overlay)
  }>;
}

// ---- Emulator ---------------------------------------------------------
interface EmulatorCore {
  load(pkg: GamePackage, bios?: Blob): Promise<void>;
  runFrame(inputs: readonly ControllerState[]): FrameOutput;
  serialize(): Uint8Array;                 // full savestate (for resync/persist)
  deserialize(state: Uint8Array): void;
  stateHash(region: 'cpu'|'ram'|'vram'|'spu'|'all'): number; // CRC32
  setOption<K extends keyof CoreOptions>(k: K, v: CoreOptions[K]): void;
}

interface NetplaySession {
  readonly role: 'host' | 'peer';
  readonly frame: number;
  sendLocalInput(frame: number, port: number, input: ControllerState): void;
  // resolves when inputs for `frame` of all active players are available
  awaitFrameInputs(frame: number): Promise<void>;
  on(event: 'desync' | 'stall' | 'peerJoin' | 'peerLeave' | 'chat', cb): void;
}
```

---

## 10. Delivery Plan (milestones)

| # | Milestone | Deliverable | Est. |
|---|---|---|---|
| **M0** | Core spike | EmulatorJS/ReARMed WASM plays a `.bin/.cue` in a worker with audio+gamepad at 60 fps; perf numbers on target devices (incl. one mid-range Android + one iPhone) | 1 wk |
| **M1** | File pipeline | Multi-file picker, cue parser, zip, CHD/PBP, SHA-256 identity, OPFS/IndexedDB persistence (`storage.persist()`), validation UI | 1–2 wk |
| **M2** | Single-player product | BIOS handling, save states + memory card, settings (region, resolution, shader, fast-save) | 2 wk |
| **M3** | **Mobile input system** | Virtual gamepad (multi-touch, analog sticks, triggers), layout editor + presets, orientation/safe-area/visualViewport handling, Tap-to-Start audio gate, Bluetooth pad support, left-handed mode | 2 wk |
| **M4** | Local multiplayer | Multi-gamepad + keyboard split, shared remap UI (kbd/gamepad/touch), multitap, mobile split-overlay preset, Mode A docs | 1 wk |
| **M5** | Online netplay (beta) | Rooms, WebRTC/TURN (mobile-cellular tested), delay-lockstep, adaptive D, state-hash desync detection + savestate resync, lobby chat, background/rejoin handling | 3 wk |
| **M6** | Shared-control streaming | Mode C1 host-streamed play (canvas capture → WebRTC video + input token), mobile guest UX | 2 wk |
| **M7** | Hardening | Cross-browser + real-device QA, chaos-network tests, TURN scaling, telemetry, ToS/legal page | 1–2 wk |
| **Post-v1** | Rollback netcode, Play! core option, cloud C2, spectating with delayed keyframes, per-genre layout auto-detect | — |

**Critical path:** M0 → M1 → M2 → **M3** → M5. M4 can overlap M5; M3 depends only on M2's runtime.

---

## 11. Risks & Mitigations

| Risk | Impact | Mitigation |
|---|---|---|
| Emulation desync between peers | Netplay breaks | Pin core binary hash + forced settings; per-region state hashing; savestate resync; extensive cross-browser determinism tests |
| 700 MB disc blows up WASM heap / memory pressure | Crash, OOM | Prefer CHD; lazy `pread` from Blob/OPFS; never preload whole disc into MEMFS |
| Missing COOP/COEP (hosting) kills SAB/threads | Input latency ↑, perf ↓ | Feature-detect `crossOriginIsolated`; `postMessage` fallback; single-threaded wasm build |
| iOS Safari: no wasm threads, audio needs gesture, storage eviction, no orientation lock | Broken mobile UX | Single-threaded core; "Tap to Start" gesture gate; `storage.persist()` + quota warning; rotate-device hint instead of orientation lock |
| Virtual-pad latency or dropped multi-touch | Unplayable feel | Pointer Events + `setPointerCapture`; direct SAB write in the handler (< 4 ms); `touch-action: none`; real-device input-latency tests |
| Notch / URL-bar collapse / rotation shifts the overlay | Controls misaligned or off-screen | `visualViewport`-based positioning, `env(safe-area-inset-*)`, relayout on `orientationchange`, fullscreen best-effort |
| Two players on one phone screen (Mode A) cramped | Poor UX | Split-overlay preset for casual games; steer users to Bluetooth pads or two devices + netplay |
| TURN bandwidth cost | $ | STUN-first; relay only fallback traffic; cap room size (2P default); cellular users are the main TURN consumers |
| BIOS/copyright exposure | Legal | Ship only HLE BIOS; user-provided BIOS path; explicit "bring your own dump" UX |
| Determinism broken by audio/async features | Subtle desyncs | Fixed-clock emulation, async SPU off in netplay, golden-trace CI |
| Mobile CPU can't hold 60 fps | Bad UX | Perf profiler at M0; dynamic resolution; thermal-throttle notice; warn below 55 fps |

---

## 12. Testing Strategy

1. **Determinism CI:** record input traces (JSON of per-frame `ControllerState`) → run on Chromium/Firefox/Safari runners → compare `stateHash` every 60 frames. Any divergence = blocker. Traces recorded from **touch, keyboard, and gamepad** sources alike (they must produce identical states).
2. **Netplay chaos tests:** use `tc netem`/Clumsy-style proxies (or a controllable WebRTC test peer) for 0–5 % loss, 30–200 ms jitter, 2 s blackouts; assert resync succeeds and stall UI appears.
3. **Touch E2E:** Playwright mobile emulation (`hasTouch: true`, iPhone + Android viewport matrices) — tap virtual buttons and assert the `ControllerState` bit is set within one frame; **multi-touch test** (two/three simultaneous `pointerdown`s, e.g., run+jump); layout-editor drag/resize round-trip; portrait↔landscape relayout keeps controls inside the safe area.
4. **Input-latency measurement:** instrument pointer-event `timeStamp` → SAB write (must be < 4 ms) and pointer → core sample (≤ 1 frame); run on real devices, not just emulation.
5. **Real-device matrix:** iPhone Safari (iOS 16/17), mid-range Android Chrome, iPad Safari — fps, touch responsiveness, fullscreen, Bluetooth pad pairing, storage persistence across eviction-prone sessions.
6. **Compatibility matrix:** top-100 PS1 games list; smoke-load each (boot to title) nightly; mark analog-capable titles so the right-stick control auto-shows.
7. **E2E (Playwright, desktop):** upload fixture `.chd` (homebrew test ROM), start game, inject keypress, assert frame hash changes; create two browser contexts in a room and assert lockstep frames advance together.
8. **Perf budgets:** main-thread long tasks < 50 ms; emulation ≥ 59 fps on an i5-8250U / M1 and ≥ 58 fps on a Snapdragon 6-series; audio underruns < 1/min; virtual-pad repaints never block the canvas compositor layer.

---

## 13. Open Questions

1. Target room size: **2 players** (PS1 native) only, or multitap titles up to 8? (Affects TURN capacity.)
2. Do we need **spectator mode** (watch a friend's session with N-frame delay) in v1?
3. Is a **cloud-hosted (C2)** offering required for the business case, or is P2P enough?
4. Mobile-first or desktop-first for the MVP UI? *(Plan assumes touch controls ship in v1 — M3 — regardless.)*
5. Do we self-host signaling/TURN or use a managed service (cost vs. control)?
6. Ship **genre-based default touch layouts** (fighting/racing/platformer) out of the box, or a single universal preset plus the editor?
