diff --git a/CHANGELOG.md b/CHANGELOG.md index a49e70cc..59a7cee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,50 @@ cycle-accurate core later replaced. ## [Unreleased] +### Added + +- **Vs. `DualSystem` presentation in the libretro core (v2.1.10 "Web Parity").** + The libretro core (`crates/rustynes-libretro`) now presents Vs. `DualSystem` + arcade cabinets (Balloon Fight / Wrecking Crew / Tennis / Baseball), reaching + parity with the desktop frontend. It detects them with the same `Emu::from_rom` + (NES 2.0 header Vs. type OR the SHA-keyed `vs_db`), steps **both** cross-wired + consoles each `retro_run`, and composes their two 256×240 framebuffers into a + single **512×240** XRGB8888 side-by-side image (MAIN left, SUB right) — presented + within a 512-wide `max_width` geometry so RetroArch draws the variable width with + no geometry renegotiation. Libretro ports 0/1 → MAIN P1/P2, 2/3 → SUB P1/P2; only + MAIN audio plays; save states use `VsDualSystem::snapshot`/`restore`; the RA/cheat + memory maps expose the MAIN console. Previously a `DualSystem` dump booted a single + console that hangs on its absent partner. The deterministic `no_std` core is + untouched and byte-identical — this is a parallel present/serialize branch in the + FFI wrapper. **Code-complete + builds** (`cargo build -p rustynes-libretro`); + a live RetroArch run with a real cabinet dump is the maintainer's manual check. + Docs: `docs/libretro/advanced_features.md`, `docs/frontend.md`. +- **Browser RetroAchievements auth-proxy deploy stack (v2.1.10 "Web Parity", ADR + 0015).** The browser-RA marshalling (`ra_glue.js` + `wasm_cheevos.rs`) has shipped + since v1.7.0; this lands the remaining ADR 0015 carryover's **deployable** half — + the casual-only auth proxy that injects RA's identity `User-Agent` server-side + (browsers forbid scripts from setting it). `deploy/` gains a first-class + `ra-proxy` compose service (`deploy/Dockerfile.raproxy`, running the stdlib-only + reference stub) behind the shared Caddy TLS proxy at `https:///ra/*`, + configured **purely from env** (`RA_USER_AGENT` / `RA_ALLOWED_ORIGINS` / + `RA_UPSTREAM` / `RA_ENFORCE_CASUAL`) — the proxy holds no RA secret. The stub + grew env-var configuration so one script serves both local dev and the container. + Marshalling-contract tests added to `wasm_cheevos.rs` (ACHIEVEMENT_TRIGGERED + filtering + malformed-payload tolerance + the not-configured caveat). + **Code-complete + compose/config validated**; standing the stack on a live host, + the RA-team `User-Agent` coordination, and a real browser RA login + casual unlock + are the un-CI-able acceptance gate (runbook: `deploy/README.md`, + `docs/cheevos-browser.md`). + +### Deferred (documented) + +- **Vs. `DualSystem` on the wasm desktop-style present (v2.1.10 "Web Parity").** + The CPU compositor (`Gfx::compose_dual_into`) and the core (`Emu::Dual`) are + already cross-platform, but the wasm ROM-load detection + un-gating the GPU + present branch (`Gfx::render_dual`, currently `cfg(not(wasm))`) remain deferred. + The libretro dual present (see Added) ships now; mobile stays deferred. See + `docs/frontend.md`. + ## [2.1.9] - 2026-07-12 - "Fathom" (presentation and signal — marquee CRT shader stack (CRT-Royale / guest-advanced / Sony Megatron) + raw NTSC composite signal-decode path + GIF/WAV capture + generated-palette editor — "Aperture") ### Added @@ -257,6 +301,38 @@ cycle-accurate core later replaced. feeds back into synthesis. AccuracyCoin holds **141/141 (100%)**. - **Expansion-audio decibel oracle (v2.1.6 "Expansion Audio").** Upgraded `crates/rustynes-test-harness/tests/audio_expansion.rs` from pure `insta` snapshots into a real accuracy oracle: each bbbradsmith `db_*` comparison ROM now has a machine-verifiable level criterion. The new `level_db_*` tests measure the peak amplitude of the reference-2A03-square and expansion-square segments in the rendered waveform (`common::capture_frame_peaks` over deterministic frame windows) and **assert** the expansion/reference ratio against the Mesen2 / hardware target — APU triangle ÷ square ≈0.524, VRC6 ≈1.506, MMC5 ≈1.000, N163 1-channel ≈6.02. The 19 `insta` snapshots are retained as byte-exact regression guards. - VRC7 instrument-ROM verification: `vrc7_all_15_melodic_patches_match_nuke_ykt_canonical` pins all 15 melodic patches (+ 3 rhythm) to the canonical Nuke.YKT dump (byte-identical across fceux / Mesen2 / nestopia) — the real `patch_vrc7` criterion. Sunsoft 5B log-DAC step-law and Namco 163 long-period (256-sample) wavetable unit tests added. +- **Vs. `DualSystem` presentation in the libretro core (v2.1.10 "Web Parity").** + The libretro core (`crates/rustynes-libretro`) now presents Vs. `DualSystem` + arcade cabinets (Balloon Fight / Wrecking Crew / Tennis / Baseball), reaching + parity with the desktop frontend. It detects them with the same `Emu::from_rom` + (NES 2.0 header Vs. type OR the SHA-keyed `vs_db`), steps **both** cross-wired + consoles each `retro_run`, and composes their two 256×240 framebuffers into a + single **512×240** XRGB8888 side-by-side image (MAIN left, SUB right) — presented + within a 512-wide `max_width` geometry so RetroArch draws the variable width with + no geometry renegotiation. Libretro ports 0/1 → MAIN P1/P2, 2/3 → SUB P1/P2; only + MAIN audio plays; save states use `VsDualSystem::snapshot`/`restore`; the RA/cheat + memory maps expose the MAIN console. Previously a `DualSystem` dump booted a single + console that hangs on its absent partner. The deterministic `no_std` core is + untouched and byte-identical — this is a parallel present/serialize branch in the + FFI wrapper. **Code-complete + builds** (`cargo build -p rustynes-libretro`); + a live RetroArch run with a real cabinet dump is the maintainer's manual check. + Docs: `docs/libretro/advanced_features.md`, `docs/frontend.md`. +- **Browser RetroAchievements auth-proxy deploy stack (v2.1.10 "Web Parity", ADR + 0015).** The browser-RA marshalling (`ra_glue.js` + `wasm_cheevos.rs`) has shipped + since v1.7.0; this lands the remaining ADR 0015 carryover's **deployable** half — + the casual-only auth proxy that injects RA's identity `User-Agent` server-side + (browsers forbid scripts from setting it). `deploy/` gains a first-class + `ra-proxy` compose service (`deploy/Dockerfile.raproxy`, running the stdlib-only + reference stub) behind the shared Caddy TLS proxy at `https:///ra/*`, + configured **purely from env** (`RA_USER_AGENT` / `RA_ALLOWED_ORIGINS` / + `RA_UPSTREAM` / `RA_ENFORCE_CASUAL`) — the proxy holds no RA secret. The stub + grew env-var configuration so one script serves both local dev and the container. + Marshalling-contract tests added to `wasm_cheevos.rs` (ACHIEVEMENT_TRIGGERED + filtering + malformed-payload tolerance + the not-configured caveat). + **Code-complete + compose/config validated**; standing the stack on a live host, + the RA-team `User-Agent` coordination, and a real browser RA login + casual unlock + are the un-CI-able acceptance gate (runbook: `deploy/README.md`, + `docs/cheevos-browser.md`). ### Changed @@ -264,6 +340,13 @@ cycle-accurate core later replaced. ### Deferred (documented) +- **Vs. `DualSystem` on the wasm desktop-style present (v2.1.10 "Web Parity").** + The CPU compositor (`Gfx::compose_dual_into`) and the core (`Emu::Dual`) are + already cross-platform, but the wasm ROM-load detection + un-gating the GPU + present branch (`Gfx::render_dual`, currently `cfg(not(wasm))`) are deferred to + the v2.1.8/v2.1.9 gfx/composite rebase to avoid colliding with that concurrently + rewritten present path. The libretro dual present ships now (see Added); mobile + stays deferred. See `docs/frontend.md`. - **Sunsoft 5B absolute level** and **VRC7 FM level** are honest documented gaps (`docs/accuracy-ledger.md` §Expansion-audio levels): the 5B log-DAC *shape* is hardware-exact but its full vol-15 / 3-simultaneous-tone range overflows the `i16` `mix_audio` contract (needs a wider mix path); the VRC7 OPLL FM synth + patch ROM are correct, but the pseudo-sine absolute level is patch-dependent and has no clean square-vs-square oracle. Both stay snapshot-guarded. ## [2.1.5] - 2026-07-11 - "Fathom" (regression net & residual — Holy Mapperel mapper regression net + PAL APU frame-counter 10/10 + real TURN NAT-retransmit production fix + fat-LTO A/B validation + MMC3 F5.0 A12-phase study; "Vernier") diff --git a/crates/rustynes-frontend/src/wasm_cheevos.rs b/crates/rustynes-frontend/src/wasm_cheevos.rs index 619a37f9..d1b14557 100644 --- a/crates/rustynes-frontend/src/wasm_cheevos.rs +++ b/crates/rustynes-frontend/src/wasm_cheevos.rs @@ -291,4 +291,43 @@ mod tests { assert!(banner.contains("casual-only")); assert!(banner.to_lowercase().contains("experimental")); } + + #[test] + fn not_configured_caveat_flags_login_unavailable() { + // A fresh session has no proxy configured, so the banner must additionally + // tell the user login + unlocks are unavailable (nothing silently pretends + // to work — the load-bearing honesty invariant of ADR 0015). + let s = BrowserRaSession::new(); + assert!(!s.proxy_configured()); + assert!(s.caveat_banner().to_lowercase().contains("not configured")); + } + + #[test] + fn parse_unlock_titles_extracts_only_triggered_events() { + // Marshalling contract: `ra_do_frame` returns a JSON event array where each + // event has a numeric `type` and a `title`. Only ACHIEVEMENT_TRIGGERED + // (type == 1) events are toasted; other event types (progress, leaderboard, + // etc.) must be ignored. This pins the wasm boundary payload shape without a + // browser (the pure-Rust parser runs on the host). + let json = r#"[ + {"type":1,"title":"First Blood"}, + {"type":2,"title":"Some Progress Indicator"}, + {"type":1,"title":"Speedrun"} + ]"#; + let titles = parse_unlock_titles(json); + assert_eq!( + titles, + vec!["First Blood".to_string(), "Speedrun".to_string()] + ); + } + + #[test] + fn parse_unlock_titles_tolerates_malformed_payload() { + // A malformed / empty payload must never panic across the wasm boundary — it + // degrades to "no unlocks this frame". + assert!(parse_unlock_titles("").is_empty()); + assert!(parse_unlock_titles("not json").is_empty()); + assert!(parse_unlock_titles("{}").is_empty()); + assert!(parse_unlock_titles("[]").is_empty()); + } } diff --git a/crates/rustynes-libretro/src/lib.rs b/crates/rustynes-libretro/src/lib.rs index 4b7cfb7c..db82ac04 100644 --- a/crates/rustynes-libretro/src/lib.rs +++ b/crates/rustynes-libretro/src/lib.rs @@ -11,13 +11,29 @@ //! CPU/PPU/APU) and strict determinism, this crate avoids mutating emulation flow. //! //! - **Video**: Native 256x240 framebuffers are handed off directly to `VideoContext`. +//! A Vs. `DualSystem` cabinet (two cross-wired consoles) instead composes its two +//! 256x240 framebuffers into a single 512x240 side-by-side image, presented as a +//! variable-width frame within the 512-wide `max_width` geometry advertised up front. //! - **Audio**: Audio is drained per frame and interleaved (left/right) into a pooled //! buffer before pushing via `batch_audio_samples`. The accumulator relies on a //! pre-allocated array (or sufficient `Vec` capacity) to honor the hot-path allocation bans. //! - **Input**: The Joypad API is polled each frame and bitmasked into `rustynes_core::Buttons`. +//! For a `DualSystem` cabinet libretro ports 0/1 drive the MAIN console's P1/P2 and +//! ports 2/3 drive the SUB console's P1/P2 (matching `VsDualSystem::set_buttons`). //! - **Save States & Memory Maps**: Direct pointers to WRAM, SRAM, and VRAM are provided //! safely by isolating the memory accessors in the core. Save states serialize statically -//! sized binary blobs natively through `Nes::snapshot_core_into`. +//! sized binary blobs natively through `Nes::snapshot_core_into` (single console) or +//! `VsDualSystem::snapshot` (dual cabinet). +//! +//! # Vs. `DualSystem` present path (v2.1.10 "Web Parity") +//! +//! The core already models the four Vs. `DualSystem` arcade boards (`Emu::Dual`, +//! `rustynes_core::VsDualSystem`). This wrapper detects them at load through +//! [`rustynes_core::Emu::from_rom`] — which OR's the NES 2.0 header Vs. type with the +//! SHA-keyed `vs_db` — and, when a cabinet is loaded, steps BOTH consoles each +//! `retro_run`, composes their framebuffers side-by-side, and presents the 512x240 +//! result. The deterministic core is untouched; the dual branch is purely a parallel +//! present/serialize path, exactly mirroring the desktop frontend's `emu.dual` branch. // We allow `unsafe_code` globally for this crate because it is an FFI boundary // wrapper (like `rustynes-frontend` and `rustynes-cheevos`). All raw pointers @@ -36,20 +52,35 @@ use rust_libretro::{ sys::*, types::*, }; -use rustynes_core::Nes; +use rustynes_core::{Emu, Nes, VsDualSystem}; use std::ffi::CString; +/// NES native framebuffer width in pixels (one console). +const NES_W: usize = 256; +/// NES native framebuffer height in pixels. +const NES_H: usize = 240; +/// Composed width of a Vs. `DualSystem` side-by-side present (two consoles). +const DUAL_W: usize = NES_W * 2; + /// The central libretro core structure for RustyNES. /// /// This struct holds the underlying cycle-accurate `Nes` emulator instance alongside /// the operational buffers necessary to interface with libretro's batch APIs. It is /// statically instantiated via the `retro_core!` macro. pub struct RustyNesLibretro { - /// The cycle-accurate RustyNES core instance. + /// The cycle-accurate RustyNES core instance (single-console carts). /// /// Exists as an `Option` because `retro_init` fires before `retro_load_game`. + /// Mutually exclusive with [`Self::dual`]: exactly one is `Some` while a ROM is + /// loaded, matching the desktop frontend's `emu.nes` / `emu.dual` invariant. nes: Option, + /// A loaded Vs. `DualSystem` cabinet (two cross-wired consoles), when the ROM + /// resolves to one of the four dual boards via [`Emu::from_rom`]. Boxed because + /// `VsDualSystem` owns two full `Nes` instances. Mutually exclusive with + /// [`Self::nes`]; drives the 512x240 side-by-side present path in `on_run`. + dual: Option>, + /// Intermediate buffer for left/right interleaved audio samples. /// /// Pre-allocated with capacity to hold multiple frames of audio, ensuring @@ -81,11 +112,16 @@ impl Default for RustyNesLibretro { fn default() -> Self { Self { nes: None, - // 4096 samples comfortably holds ~85ms of audio at 48kHz, - // well beyond the 16.6ms standard 60Hz frame delivery. - audio_buffer: Vec::with_capacity(4096), + dual: None, + // `audio_float_buffer` holds up to 4096 MONO samples (drained per + // frame — comfortably ~85ms at 48kHz, well beyond the 16.6ms 60Hz + // frame); `audio_buffer` holds the STEREO interleave of those, so it + // needs 2x the capacity to stay allocation-free on the hot path. + audio_buffer: Vec::with_capacity(4096 * 2), audio_float_buffer: Vec::with_capacity(4096), - video_buffer: Vec::with_capacity(256 * 240 * 4), + // Sized for the widest present (512x240 Vs. DualSystem side-by-side) + // so the hot path never reallocates even in dual mode. + video_buffer: Vec::with_capacity(DUAL_W * NES_H * 4), serialize_size: 0, serialize_buffer: Vec::new(), } @@ -109,6 +145,174 @@ struct RetroGameInfoExt { persistent_data: bool, } +/// Translate a libretro joypad snapshot for `port` into RustyNES `Buttons`. +/// +/// Factored out of `on_run` so both the single-console and Vs. `DualSystem` present +/// paths share one mapping (ports 0/1 → main P1/P2, ports 2/3 → sub P1/P2 in dual). +fn joypad_to_buttons(ctx: &mut RunContext, port: u32) -> rustynes_core::Buttons { + let jp = ctx.get_joypad_state(port, 0); + let mut bt = rustynes_core::Buttons::empty(); + if jp.contains(JoypadState::A) { + bt |= rustynes_core::Buttons::A; + } + if jp.contains(JoypadState::B) { + bt |= rustynes_core::Buttons::B; + } + if jp.contains(JoypadState::SELECT) { + bt |= rustynes_core::Buttons::SELECT; + } + if jp.contains(JoypadState::START) { + bt |= rustynes_core::Buttons::START; + } + if jp.contains(JoypadState::UP) { + bt |= rustynes_core::Buttons::UP; + } + if jp.contains(JoypadState::DOWN) { + bt |= rustynes_core::Buttons::DOWN; + } + if jp.contains(JoypadState::LEFT) { + bt |= rustynes_core::Buttons::LEFT; + } + if jp.contains(JoypadState::RIGHT) { + bt |= rustynes_core::Buttons::RIGHT; + } + bt +} + +/// Copy one RGBA8 NES scanline into an XRGB8888 destination scanline, swapping the +/// R and B channels (RGBA in memory → B G R X for libretro's XRGB8888). `dst` and +/// `src` must each be at least `NES_W * 4` bytes. +#[inline] +fn blit_scanline_rgba_to_xrgb(dst: &mut [u8], src: &[u8]) { + for x in 0..NES_W { + let d = x * 4; + let s = x * 4; + dst[d] = src[s + 2]; // B + dst[d + 1] = src[s + 1]; // G + dst[d + 2] = src[s]; // R + dst[d + 3] = src[s + 3]; // X (alpha byte, ignored by XRGB8888) + } +} + +impl RustyNesLibretro { + /// Convert `produced` mono `f32` samples from `audio_float_buffer` into + /// interleaved stereo `i16` and push them. Shared by both present paths so the + /// audio scaling / interleave logic lives in exactly one place. The buffer is + /// pre-allocated to `2 * 4096` (the stereo interleave of the widest mono + /// drain), so this stays allocation-free on the hot path; the `reserve` below + /// is a belt-and-suspenders no-op that keeps that guarantee true even if the + /// mono drain buffer is ever grown past 4096. + fn push_audio(&mut self, ctx: &mut RunContext, produced: usize) { + self.audio_buffer.clear(); + // No-op while capacity already covers `2 * produced` (the common case); + // only ever allocates if a future change enlarges `audio_float_buffer`. + self.audio_buffer.reserve(produced * 2); + for &sample in &self.audio_float_buffer[..produced] { + // RustyNES APU outputs bipolar ~[-0.5, 0.5], so we scale by 65535.0. + let s16 = (sample * 65535.0).clamp(-32768.0, 32767.0) as i16; + // Duplicate the sample for stereo interleaving (Left, Right). + self.audio_buffer.push(s16); + self.audio_buffer.push(s16); + } + rust_libretro::contexts::AudioContext::from(&mut *ctx) + .batch_audio_samples(&self.audio_buffer); + } + + /// The classic single-console present path: 256x240 XRGB8888 + one audio stream. + fn run_single(&mut self, ctx: &mut RunContext) { + ctx.poll_input(); + // Port 0 → Player 1, Port 1 → Player 2. + let b0 = joypad_to_buttons(ctx, 0); + let b1 = joypad_to_buttons(ctx, 1); + { + let nes = self.nes.as_mut().expect("run_single: nes present"); + nes.set_buttons(0, b0); + nes.set_buttons(1, b1); + // Advance the emulator clock by precisely one frame (the lockstep routine + // that drives CPU/PPU/APU progression). The returned framebuffer borrow is + // dropped immediately; we re-read it below via `framebuffer()` to keep the + // video copy disjoint from the audio drain. + nes.run_frame(); + } + self.video_buffer.clear(); + self.video_buffer + .extend_from_slice(self.nes.as_ref().expect("nes present").framebuffer()); + for chunk in self.video_buffer.chunks_exact_mut(4) { + chunk.swap(0, 2); // RGBA8 → XRGB8888 (in-memory B G R X). + } + ctx.draw_frame(&self.video_buffer, NES_W as u32, NES_H as u32, NES_W * 4); + + self.audio_float_buffer.resize(4096, 0.0); + let produced = self + .nes + .as_mut() + .expect("nes present") + .drain_audio_into(&mut self.audio_float_buffer); + self.push_audio(ctx, produced); + } + + /// The Vs. `DualSystem` present path: step BOTH consoles, compose their two + /// 256x240 framebuffers side-by-side into a single 512x240 XRGB8888 image, and + /// present it. Only the MAIN console's audio is played (one stream, matching the + /// desktop frontend); the SUB console's APU ring is drained-and-discarded to keep + /// it bounded. The deterministic core is untouched — this is a parallel present. + fn run_dual(&mut self, ctx: &mut RunContext) { + ctx.poll_input(); + // Ports 0/1 drive the MAIN console's P1/P2; ports 2/3 the SUB console's. + let buttons = [ + joypad_to_buttons(ctx, 0), + joypad_to_buttons(ctx, 1), + joypad_to_buttons(ctx, 2), + joypad_to_buttons(ctx, 3), + ]; + { + let dual = self.dual.as_mut().expect("run_dual: dual present"); + for (port, btn) in buttons.into_iter().enumerate() { + dual.set_buttons(port, btn); + } + dual.run_frame(); + } + self.compose_dual(); + ctx.draw_frame(&self.video_buffer, DUAL_W as u32, NES_H as u32, DUAL_W * 4); + + // Main console audio (the presented stream). + self.audio_float_buffer.resize(4096, 0.0); + let produced = self + .dual + .as_mut() + .expect("dual present") + .main_mut() + .drain_audio_into(&mut self.audio_float_buffer); + self.push_audio(ctx, produced); + + // Bound the SUB console's APU buffer even though its audio is not played: + // drain it into a small stack scratch, looping until a partial fill signals + // the ring is empty. Stack-allocated, so no heap traffic on the hot path. + let mut scratch = [0.0f32; 1024]; + let dual = self.dual.as_mut().expect("dual present"); + while dual.sub_mut().drain_audio_into(&mut scratch) == scratch.len() {} + } + + /// Compose the dual cabinet's two framebuffers into `video_buffer` as a 512x240 + /// XRGB8888 image (MAIN on the left, SUB on the right). Pre-sized once so the + /// per-frame `resize` never reallocates. + fn compose_dual(&mut self) { + self.video_buffer.clear(); + self.video_buffer.resize(DUAL_W * NES_H * 4, 0); + let dual = self.dual.as_ref().expect("compose_dual: dual present"); + let main = dual.main_framebuffer(); + let sub = dual.sub_framebuffer(); + for y in 0..NES_H { + let src = y * NES_W * 4; + let dst = y * DUAL_W * 4; + // Left half ← MAIN, right half ← SUB. Each half is one NES scanline. + let (left, right) = self.video_buffer[dst..dst + DUAL_W * 4].split_at_mut(NES_W * 4); + blit_scanline_rgba_to_xrgb(left, &main[src..src + NES_W * 4]); + blit_scanline_rgba_to_xrgb(right, &sub[src..src + NES_W * 4]); + } + } +} + impl Core for RustyNesLibretro { fn get_info(&self) -> SystemInfo { SystemInfo { @@ -127,7 +331,11 @@ impl Core for RustyNesLibretro { geometry: retro_game_geometry { base_width: 256, base_height: 240, - max_width: 256, + // max_width is 512 so a Vs. DualSystem cabinet's 512x240 side-by-side + // present fits without a geometry renegotiation: RetroArch honors a + // per-frame width up to max_width, so a single-console 256x240 frame and + // a dual 512x240 frame both draw correctly against the same AV info. + max_width: DUAL_W as u32, max_height: 240, aspect_ratio: 0.0, }, @@ -209,8 +417,14 @@ impl Core for RustyNesLibretro { slice.to_vec() }; - let nes = match Nes::from_rom(&rom_data) { - Ok(n) => n, + // `Emu::from_rom` picks the right shape for the cart: a `VsDualSystem` for the + // four Vs. DualSystem boards (detected via the NES 2.0 header Vs. type OR the + // SHA-keyed `vs_db`), else a standard single `Nes`. This is the SAME detection + // the desktop frontend uses, so the libretro core presents dual cabinets + // identically (two consoles side-by-side) instead of booting a single console + // that would hang waiting on its cross-wired partner. + let emu = match Emu::from_rom(&rom_data) { + Ok(e) => e, Err(e) => { eprintln!("[RustyNES] Failed to parse ROM: {e:?}"); return Err(format!("Failed to load ROM: {e:?}").into()); @@ -219,84 +433,38 @@ impl Core for RustyNesLibretro { // Save state sizes in RustyNES are strictly deterministic for a given ROM image. // We evaluate the snapshot footprint once during initialization to satisfy - // libretro's serialization size querying contract. - let mut tmp = Vec::new(); - nes.snapshot_core_into(&mut tmp); - self.serialize_size = tmp.len(); - - self.nes = Some(nes); + // libretro's serialization size querying contract. Clear whichever emulator + // shape a prior load left behind so the two Options stay mutually exclusive. + match emu { + Emu::Single(nes) => { + let nes = *nes; + let mut tmp = Vec::new(); + nes.snapshot_core_into(&mut tmp); + self.serialize_size = tmp.len(); + self.nes = Some(nes); + self.dual = None; + eprintln!("[RustyNES] Loaded single-console cart."); + } + Emu::Dual(dual) => { + // The dual snapshot is a self-describing blob of both consoles; size it + // once here (it is deterministic for a given ROM, like the single case). + self.serialize_size = dual.snapshot().len(); + self.dual = Some(dual); + self.nes = None; + eprintln!("[RustyNES] Loaded Vs. DualSystem cabinet (512x240 side-by-side)."); + } + } Ok(()) } fn on_run(&mut self, ctx: &mut RunContext, _delta_us: Option) { - if let Some(nes) = self.nes.as_mut() { - ctx.poll_input(); - - // Map the Libretro joypad state into the native RustyNES controller bitmasks. - // Port 0 maps to Player 1, Port 1 maps to Player 2. - let mut apply_input = |port: usize| { - let jp = ctx.get_joypad_state(port as u32, 0); - let mut bt = rustynes_core::Buttons::empty(); - if jp.contains(JoypadState::A) { - bt |= rustynes_core::Buttons::A; - } - if jp.contains(JoypadState::B) { - bt |= rustynes_core::Buttons::B; - } - if jp.contains(JoypadState::SELECT) { - bt |= rustynes_core::Buttons::SELECT; - } - if jp.contains(JoypadState::START) { - bt |= rustynes_core::Buttons::START; - } - if jp.contains(JoypadState::UP) { - bt |= rustynes_core::Buttons::UP; - } - if jp.contains(JoypadState::DOWN) { - bt |= rustynes_core::Buttons::DOWN; - } - if jp.contains(JoypadState::LEFT) { - bt |= rustynes_core::Buttons::LEFT; - } - if jp.contains(JoypadState::RIGHT) { - bt |= rustynes_core::Buttons::RIGHT; - } - nes.set_buttons(port, bt); - }; - - apply_input(0); - apply_input(1); - - // Advance the emulator clock by precisely one frame (PPU dots). - // This is the core lockstep routine triggering CPU/APU progression. - let framebuffer = nes.run_frame(); - - self.video_buffer.clear(); - self.video_buffer.extend_from_slice(framebuffer); - for chunk in self.video_buffer.chunks_exact_mut(4) { - chunk.swap(0, 2); // Swap R and B to convert RGBA8 to XRGB8888 (in-memory B G R X) - } - - // The generated framebuffer is exactly 256x240 and formatted as XRGB8888. - ctx.draw_frame(&self.video_buffer, 256, 240, 256 * 4); - - // Drain synthesized audio. RustyNES produces `f32` floats which we scale - // to the standard signed 16-bit integer expected by the frontend. - // The audio buffers are pre-allocated; draining and converting will not - // trigger heap allocations on this critical hot path. - self.audio_buffer.clear(); - self.audio_float_buffer.resize(4096, 0.0); - let produced = nes.drain_audio_into(&mut self.audio_float_buffer); - for &sample in &self.audio_float_buffer[..produced] { - // RustyNES APU outputs bipolar ~[-0.5, 0.5], so we scale by 65535.0 - let s16 = (sample * 65535.0).clamp(-32768.0, 32767.0) as i16; - // Duplicate the sample for stereo interleaving (Left, Right) - self.audio_buffer.push(s16); - self.audio_buffer.push(s16); - } - - rust_libretro::contexts::AudioContext::from(&mut *ctx) - .batch_audio_samples(&self.audio_buffer); + // Two mutually-exclusive shapes: a single console, or a Vs. DualSystem + // cabinet. The dual branch steps both consoles and presents a 512x240 + // side-by-side image; otherwise the classic single-console 256x240 path runs. + if self.dual.is_some() { + self.run_dual(ctx); + } else if self.nes.is_some() { + self.run_single(ctx); } } @@ -307,25 +475,27 @@ impl Core for RustyNesLibretro { ) -> *mut std::os::raw::c_void { // Expose zero-cost direct memory maps for RetroAchievements and cheat engines. // Memory boundary enforcement remains safe within the `rustynes_core` design. - self.nes - .as_mut() - .map_or(std::ptr::null_mut(), |nes| match id { - RETRO_MEMORY_SAVE_RAM => { - let sram = nes.sram_mut(); - if sram.is_empty() { - std::ptr::null_mut() - } else { - sram.as_mut_ptr().cast::() - } - } - RETRO_MEMORY_SYSTEM_RAM => { - nes.wram_mut().as_mut_ptr().cast::() - } - RETRO_MEMORY_VIDEO_RAM => { - nes.vram_mut().as_mut_ptr().cast::() + // In dual mode the MAIN console is the one RetroAchievements / cheats target + // (its memory map is where gameplay state lives); expose it, matching the + // single-console mapping. `main_mut()` yields the MAIN `Nes`. + let nes = match (self.nes.as_mut(), self.dual.as_mut()) { + (Some(nes), _) => nes, + (None, Some(dual)) => dual.main_mut(), + (None, None) => return std::ptr::null_mut(), + }; + match id { + RETRO_MEMORY_SAVE_RAM => { + let sram = nes.sram_mut(); + if sram.is_empty() { + std::ptr::null_mut() + } else { + sram.as_mut_ptr().cast::() } - _ => std::ptr::null_mut(), - }) + } + RETRO_MEMORY_SYSTEM_RAM => nes.wram_mut().as_mut_ptr().cast::(), + RETRO_MEMORY_VIDEO_RAM => nes.vram_mut().as_mut_ptr().cast::(), + _ => std::ptr::null_mut(), + } } fn get_memory_size( @@ -333,12 +503,18 @@ impl Core for RustyNesLibretro { id: std::os::raw::c_uint, _ctx: &mut GetMemorySizeContext, ) -> usize { - self.nes.as_ref().map_or(0, |nes| match id { + // Mirror `get_memory_data`: the MAIN console in dual mode. + let nes = match (self.nes.as_ref(), self.dual.as_ref()) { + (Some(nes), _) => nes, + (None, Some(dual)) => dual.main(), + (None, None) => return 0, + }; + match id { RETRO_MEMORY_SAVE_RAM => nes.sram().len(), RETRO_MEMORY_SYSTEM_RAM => nes.wram().len(), RETRO_MEMORY_VIDEO_RAM => nes.vram().len(), _ => 0, - }) + } } fn get_serialize_size(&mut self, _ctx: &mut GetSerializeSizeContext) -> usize { @@ -346,14 +522,20 @@ impl Core for RustyNesLibretro { } fn on_serialize(&mut self, slice: &mut [u8], _ctx: &mut SerializeContext) -> bool { - // Generates the deterministic binary blob representing the console hardware state. + // Generates the deterministic binary blob representing the console hardware + // state. Single console → `snapshot_core_into`; a Vs. DualSystem cabinet → + // `VsDualSystem::snapshot` (a self-describing blob of BOTH consoles). if let Some(nes) = self.nes.as_ref() { self.serialize_buffer.clear(); nes.snapshot_core_into(&mut self.serialize_buffer); - if slice.len() >= self.serialize_buffer.len() { - slice[..self.serialize_buffer.len()].copy_from_slice(&self.serialize_buffer); - return true; - } + } else if let Some(dual) = self.dual.as_ref() { + self.serialize_buffer = dual.snapshot(); + } else { + return false; + } + if slice.len() >= self.serialize_buffer.len() { + slice[..self.serialize_buffer.len()].copy_from_slice(&self.serialize_buffer); + return true; } false } @@ -363,15 +545,19 @@ impl Core for RustyNesLibretro { if let Some(nes) = self.nes.as_mut() { return nes.restore_quiet(slice).is_ok(); } + if let Some(dual) = self.dual.as_mut() { + return dual.restore(slice).is_ok(); + } false } } retro_core!(RustyNesLibretro { nes: None, - audio_buffer: Vec::with_capacity(4096), + dual: None, + audio_buffer: Vec::with_capacity(4096 * 2), audio_float_buffer: Vec::with_capacity(4096), - video_buffer: Vec::with_capacity(256 * 240 * 4), + video_buffer: Vec::with_capacity(DUAL_W * NES_H * 4), serialize_size: 0, serialize_buffer: Vec::new(), }); diff --git a/deploy/.env.example b/deploy/.env.example index ec599726..421ebb3c 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -18,3 +18,26 @@ TURN_SECRET=changeme # Realm coturn advertises to TURN clients — set to your domain for a real deploy. TURN_REALM=signaling.example.com + +# --- Browser RetroAchievements auth proxy (ADR 0015) ------------------------- +# The ra-proxy service injects RA's identity `User-Agent` server-side (browsers +# forbid scripts setting it). It holds NO RA secret; these are identity/CORS +# config, not credentials. The user's own RA login transits at request time. + +# The EXACT identity header the proxy injects on every forwarded request. Keep +# the leading `RustyNES/` token — RA allowlists by it (coordinate the exact +# string + casual-only intent with the RA team before going live). Match the +# `RA_USER_AGENT` const in crates/rustynes-cheevos/src/http.rs for the release. +RA_USER_AGENT=RustyNES/2.1.10 rcheevos/12.3.0 + +# Upstream RetroAchievements origin (rarely changed). +RA_UPSTREAM=https://retroachievements.org + +# CORS allowlist: the page origin(s) that host the wasm frontend (comma-separated). +# The GitHub Pages demo is https://doublegate.github.io; add a localhost origin +# (e.g. http://127.0.0.1:8081) for local browser testing. +RA_ALLOWED_ORIGINS=https://doublegate.github.io + +# Casual-only enforcement — leave "1". The browser cannot support trustworthy +# hardcore (DevTools can patch the wasm), so the proxy refuses hardcore awards. +RA_ENFORCE_CASUAL=1 diff --git a/deploy/Caddyfile b/deploy/Caddyfile index fb8f3656..a1f59740 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -1,12 +1,27 @@ -# RustyNES — Caddy reverse proxy for the netplay signaling server. +# RustyNES — Caddy reverse proxy for the netplay signaling server + the browser +# RetroAchievements auth proxy. # -# Terminates TLS and proxies WebSocket upgrades to the plain-ws signaling relay -# so browsers can connect at wss:///. The {$DOMAIN} placeholder is -# substituted from the compose `DOMAIN` env var (defaults to localhost, which -# uses Caddy's internal self-signed CA for a local test; a real hostname gets an -# automatic Let's Encrypt cert). +# Terminates TLS and: +# - proxies WebSocket upgrades to the plain-ws signaling relay so browsers can +# connect at wss:///; +# - proxies https:///ra/* to the casual-only RA auth proxy (ADR 0015), +# stripping the /ra prefix so the rcheevos request path reaches upstream RA +# verbatim. `RA_PROXY_BASE` in web/cheevos/ra_glue.js points at +# https:///ra. +# The {$DOMAIN} placeholder is substituted from the compose `DOMAIN` env var +# (defaults to localhost, which uses Caddy's internal self-signed CA for a local +# test; a real hostname gets an automatic Let's Encrypt cert). {$DOMAIN} { + # Browser RetroAchievements auth proxy (ADR 0015). `handle_path` strips the + # `/ra` prefix, so a request to https:///ra/dorequest.php arrives at + # the proxy as /dorequest.php and is forwarded verbatim to upstream RA with + # the identity User-Agent injected. The proxy itself answers the CORS + # preflight + refuses hardcore, so Caddy just forwards. + handle_path /ra/* { + reverse_proxy ra-proxy:8092 + } + # The signaling relay (signaling_server.rs) speaks ONLY WebSocket: it runs # `accept_async` on every connection and rejects anything without an # `Upgrade: websocket` header. So proxy *only* real WebSocket upgrades to it; diff --git a/deploy/Dockerfile.raproxy b/deploy/Dockerfile.raproxy new file mode 100644 index 00000000..afc8b08a --- /dev/null +++ b/deploy/Dockerfile.raproxy @@ -0,0 +1,37 @@ +# RustyNES — browser RetroAchievements casual-only auth proxy (ADR 0015). +# +# A tiny, dependency-free (stdlib-only) HTTP proxy whose ONE job is to inject the +# RA identity `User-Agent` header — which browsers forbid scripts from setting — +# on every forwarded rcheevos request, so the WebAssembly frontend can be +# identified/allowlisted by RetroAchievements. It ALSO enforces casual-only +# (refuses to forward a hardcore award) and answers the CORS preflight for the +# page origin that hosts the wasm build. +# +# The proxy is configured PURELY from environment variables (see the +# `ra-proxy` service in docker-compose.yml + deploy/.env.example) — no config +# file is baked in, and the proxy holds NO RA secret (it injects only the +# non-secret identity header; the user's own login credentials transit at +# request time and are never stored here). +# +# Build context is the workspace root (so the source script under scripts/ is +# reachable), matching the signaling image: +# docker build -f deploy/Dockerfile.raproxy -t rustynes-raproxy .. +FROM python:3.12-slim + +# Non-root for defence in depth — the proxy needs no privilege. +RUN useradd --create-home --uid 10001 raproxy +USER raproxy +WORKDIR /home/raproxy + +# The reference stub is stdlib-only; copy it verbatim (single source of truth +# with local development — no fork of the proxy logic). +COPY --chown=raproxy:raproxy scripts/cheevos/auth_proxy_stub.py ./auth_proxy_stub.py + +# Configured entirely via env (RA_PROXY_BIND / RA_USER_AGENT / RA_UPSTREAM / +# RA_ALLOWED_ORIGINS / RA_ENFORCE_CASUAL). Bind on all interfaces inside the +# container network by default; Caddy is the only thing that reaches it. +ENV RA_PROXY_BIND=0.0.0.0:8092 +EXPOSE 8092 + +# No `--config`: pure env-driven configuration. +ENTRYPOINT ["python3", "auth_proxy_stub.py"] diff --git a/deploy/README.md b/deploy/README.md index c19eb634..e8793290 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,9 +1,18 @@ -# RustyNES — netplay deployment bundle +# RustyNES — netplay + RetroAchievements deployment bundle -This directory deploys the pieces an internet **netplay** session needs: a -**signaling server** (brokers the rendezvous), a **TLS reverse proxy** (so an -`https` page / a `wss://` client can reach the relay), and a **STUN/TURN server** -(NAT traversal). +This directory deploys the server-side pieces the **browser** build needs that a +static page cannot provide itself: + +- an internet **netplay** session's **signaling server** (brokers the + rendezvous), a **TLS reverse proxy** (so an `https` page / a `wss://` client can + reach the relay), and a **STUN/TURN server** (NAT traversal); and +- the casual-only **browser RetroAchievements auth proxy** (ADR 0015) — a tiny + service that injects RA's identity `User-Agent` header server-side (browsers + forbid scripts from setting it) so the wasm build can be identified by + RetroAchievements. It holds no RA secret and refuses hardcore awards. + +All four services come up from one `docker compose up`; each is independent, so +you can host only the subset you need (netplay only, RA only, or both). **One stack, two clients.** The exact same signaling + Caddy-TLS + coturn stack serves **both**: @@ -25,22 +34,28 @@ rendezvous) and §3 (browser WebRTC) — and how each frontend plugs in. **Deployment-ready; live verification pending.** Every piece below builds and is unit/loopback-tested, and this bundle is turn-key (`docker compose up` on a host -with a domain brings up signaling + STUN/TURN). A real end-to-end internet -session — browser WebRTC ICE, or the mobile room-code STUN/punch, plus a live -signaling round-trip — **cannot be exercised headlessly** and has **not** been -run here. It is the maintainer's manual step: the copy-pasteable checklists are -the [Mobile room-code checklist](#mobile-room-code-checklist-maintainer-ops) and -the [Manual verification checklist](#manual-verification-checklist) below. +with a domain brings up signaling + STUN/TURN + the RA auth proxy). Two classes of +end-to-end check **cannot be exercised headlessly** and have **not** been run +here — they are the maintainer's manual steps: + +- a real internet **netplay** session (browser WebRTC ICE, or the mobile + room-code STUN/punch, plus a live signaling round-trip) — see the + [Mobile room-code checklist](#mobile-room-code-checklist-maintainer-ops) and the + [Manual verification checklist](#manual-verification-checklist); and +- a live **browser RetroAchievements** login + casual unlock through the deployed + proxy — see the + [Browser RA live-verify checklist](#browser-ra-live-verify-checklist-maintainer-ops). ## What's here | File | Role | |---|---| | `Dockerfile` | Builds + runs the `rustynes-netplay` `signaling_server` example (`--features signaling-server`). | -| `docker-compose.yml` | Wires `signaling` + `caddy` (TLS proxy, `wss://`) + `coturn` (STUN/TURN). | -| `Caddyfile` | Caddy config: terminate TLS, proxy WebSocket upgrades to the relay. | +| `Dockerfile.raproxy` | Builds + runs the casual-only browser RA auth proxy (the stdlib-only `scripts/cheevos/auth_proxy_stub.py`), env-configured (ADR 0015). | +| `docker-compose.yml` | Wires `signaling` + `ra-proxy` + `caddy` (TLS proxy, `wss://` + `/ra/*`) + `coturn` (STUN/TURN). | +| `Caddyfile` | Caddy config: terminate TLS, proxy WebSocket upgrades to the relay, and proxy `/ra/*` to the RA auth proxy. | | `turnserver.conf` | Minimal coturn STUN + TURN config (credential/realm injected from env). | -| `.env.example` | Template for the per-deploy values (`DOMAIN`, `TURN_*`); copy to `.env`. | +| `.env.example` | Template for the per-deploy values (`DOMAIN`, `TURN_*`, `RA_*`); copy to `.env`. | | `.dockerignore` lives at the **workspace root** | Keeps `target/`, ROMs, docs out of the build context. | The signaling relay carries **no gameplay traffic** — for the browser path it @@ -136,6 +151,63 @@ trunk build --release # serve dist/ from your https host, or `trunk serve` for local dev ``` +## Browser RetroAchievements (auth proxy) + +The `ra-proxy` service is the deployable half of ADR 0015's browser +RetroAchievements carryover. RA identifies/allowlists a client by its HTTP +`User-Agent` (`RustyNES/ rcheevos/`), and browsers +**forbid scripts from setting `User-Agent`** — so every rcheevos server call from +the wasm build is routed through this proxy, which injects the header +server-side. The full browser-RA design is in `docs/cheevos-browser.md`; this +section is only the hosting. + +**It holds no RA secret.** The proxy's job is header injection + CORS + refusing +hardcore — not credential storage. The user's own RA login (username/password) +transits at request time inside the rcheevos login body and is never persisted by +the proxy. Everything the proxy needs comes from environment variables (no +committed config file): + +| `.env` var | Role | +|---|---| +| `RA_USER_AGENT` | The exact identity header injected on every forwarded request. Keep the leading `RustyNES/` token — RA allowlists by it. Coordinate the exact string + casual-only intent with the RA team before going live. | +| `RA_ALLOWED_ORIGINS` | CORS allowlist: the page origin(s) that host the wasm build (comma-separated), e.g. `https://doublegate.github.io`. | +| `RA_UPSTREAM` | Upstream RA origin (default `https://retroachievements.org`). | +| `RA_ENFORCE_CASUAL` | `1`/`true` (default) → the proxy refuses to forward a hardcore award. Leave it on: browser hardcore is untrustworthy (DevTools can patch the wasm). | + +Caddy exposes the proxy at `https:///ra/*` (the `handle_path /ra/*` block +strips the `/ra` prefix, so the rcheevos path reaches upstream RA verbatim). Point +`RA_PROXY_BASE` in `crates/rustynes-frontend/web/cheevos/ra_glue.js` at +`https:///ra`, build the frontend with `--features browser-cheevos`, and +build the Emscripten rcheevos side module (`scripts/cheevos/build_rcheevos_wasm.sh`). + +**Local test (no Docker):** run the reference stub directly and point the glue at +it: + +```bash +RA_PROXY_BIND=127.0.0.1:8092 \ +RA_ALLOWED_ORIGINS='http://127.0.0.1:8081' \ +RA_USER_AGENT='RustyNES/2.1.10 rcheevos/12.3.0' \ + python3 scripts/cheevos/auth_proxy_stub.py +# then RA_PROXY_BASE = "http://127.0.0.1:8092" in ra_glue.js +``` + +### Browser RA live-verify checklist (maintainer ops) + +This cannot be done in CI (no headless RA login, no live proxy) — it is the +acceptance gate for flipping ADR 0015 to fully Implemented: + +- [ ] Host this stack (or run the stub) with `RA_USER_AGENT`/`RA_ALLOWED_ORIGINS` + set; confirm `https:///ra/` reaches the proxy (a `curl -X OPTIONS` + with an allowlisted `Origin` returns the CORS headers). +- [ ] Coordinate the exact `User-Agent` string + casual-only intent with the RA + team so the client is allowlisted. +- [ ] Build the side module + the frontend with `--features browser-cheevos`, set + `RA_PROXY_BASE`, and open the page. +- [ ] Log in with a real RA account through the proxy; confirm the caveat banner + shows "configured" and login succeeds. +- [ ] Load a game with achievements and earn one; confirm the casual unlock toast + and that the unlock is recorded on RetroAchievements (casual, not hardcore). + ## Point the mobile (Android) build at it The Android **room-code** netplay (v1.8.7) uses the *same* deployed stack. The @@ -201,8 +273,8 @@ tabs) to fullest (four players across machines). - [ ] `cp deploy/.env.example deploy/.env` and set `DOMAIN`, `TURN_USER`, a strong `TURN_SECRET`, `TURN_REALM`. - [ ] Real domain: removed `tls internal` from `Caddyfile` (Let's Encrypt). -- [ ] `docker compose up --build -d`; `docker compose ps` shows all three - services healthy. +- [ ] `docker compose up --build -d`; `docker compose ps` shows all four + services (`signaling`, `ra-proxy`, `caddy`, `coturn`) healthy. - [ ] Firewall: `443/tcp` open; `3478/udp` + `3478/tcp` open; coturn relay port range reachable. - [ ] coturn behind 1:1 NAT → `--external-ip=` flag added. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 51d40557..24a4b880 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,10 +1,14 @@ -# RustyNES — browser-netplay deployment bundle +# RustyNES — browser-netplay + RetroAchievements deployment bundle # -# Three services wired together: +# Four services wired together: # - signaling : the WebRTC signaling relay (ws://signaling:9000), built from # the rustynes-netplay `signaling_server` example. +# - ra-proxy : the casual-only browser RetroAchievements auth proxy (ADR 0015) +# that injects RA's identity `User-Agent` server-side (browsers +# forbid scripts setting it). Env-driven; holds no RA secret. # - caddy : a TLS-terminating reverse proxy that exposes the relay as -# wss:/// (an https page cannot open a plain ws://). +# wss:/// and the RA proxy as https:///ra/* +# (an https page cannot open a plain ws:// or fetch a plain http://). # - coturn : a STUN + TURN server for NAT traversal (browsers point their # RtcConfiguration iceServers at it via [netplay] stun_servers). # @@ -26,11 +30,38 @@ services: expose: - "9000" + ra-proxy: + build: + # Build context is the workspace root so the Dockerfile can copy the + # reference proxy stub from scripts/cheevos/. + context: .. + dockerfile: deploy/Dockerfile.raproxy + image: rustynes-raproxy:latest + restart: unless-stopped + # Configured purely from env (no committed config file). The proxy holds NO + # RA secret — it injects only the non-secret identity User-Agent and enforces + # casual-only + the CORS allowlist. Substitute these in `.env` (see + # deploy/.env.example): + # RA_USER_AGENT — keep the leading `RustyNES/` token (RA allowlists by it) + # RA_ALLOWED_ORIGINS — the page origin(s) hosting the wasm build (CSV) + # RA_UPSTREAM — upstream RA origin (default https://retroachievements.org) + # RA_ENFORCE_CASUAL — "1"/"true" (default) refuses hardcore awards + environment: + RA_PROXY_BIND: "0.0.0.0:8092" + RA_USER_AGENT: ${RA_USER_AGENT:-RustyNES/0.0.0 rcheevos/0.0.0} + RA_UPSTREAM: ${RA_UPSTREAM:-https://retroachievements.org} + RA_ALLOWED_ORIGINS: ${RA_ALLOWED_ORIGINS:-https://doublegate.github.io} + RA_ENFORCE_CASUAL: ${RA_ENFORCE_CASUAL:-1} + # Not published directly — only Caddy reaches it, on the internal network. + expose: + - "8092" + caddy: image: caddy:2 restart: unless-stopped depends_on: - signaling + - ra-proxy environment: # The public hostname Caddy serves wss:// on. `localhost` uses Caddy's # internal self-signed CA (good for a local two-tab test); a real domain diff --git a/docs/cheevos-browser.md b/docs/cheevos-browser.md index 7d4099bb..f2f28115 100644 --- a/docs/cheevos-browser.md +++ b/docs/cheevos-browser.md @@ -77,12 +77,28 @@ the browser is routed through a small proxy that injects the header server-side. - **Spec / config:** `scripts/cheevos/auth-proxy.example.toml`. - **Reference stub (stdlib-only Python):** `scripts/cheevos/auth_proxy_stub.py` — injects the `User-Agent`, enforces CORS to the page origin, and refuses - hardcore awards. Run it for local development: + hardcore awards. It is configured from a TOML file **or** (as of v2.1.10) + purely from environment variables, so the same script drives both local dev and + the container deploy. Run it for local development: ```bash + # File-driven: python3 scripts/cheevos/auth_proxy_stub.py --config scripts/cheevos/auth-proxy.example.toml + # Env-driven (no config file): + RA_PROXY_BIND=127.0.0.1:8092 RA_ALLOWED_ORIGINS='http://127.0.0.1:8081' \ + RA_USER_AGENT='RustyNES/2.1.10 rcheevos/12.3.0' \ + python3 scripts/cheevos/auth_proxy_stub.py ``` +- **Deployable stack (v2.1.10 "Web Parity"):** `deploy/` now ships the proxy as a + first-class `ra-proxy` compose service (`deploy/Dockerfile.raproxy` runs the + same stub, env-configured) behind the shared Caddy TLS reverse proxy, which + exposes it at `https:///ra/*`. See `deploy/README.md` §"Browser + RetroAchievements (auth proxy)". The proxy holds **no RA secret** — it injects + only the non-secret identity header; the user's own login transits at request + time. Configuration is env-only (`RA_USER_AGENT` / `RA_ALLOWED_ORIGINS` / + `RA_UPSTREAM` / `RA_ENFORCE_CASUAL`), never a committed credential. + - **Request shape** the glue produces: the server-call trampoline forwards each rcheevos request **verbatim** — it takes the path + query of the rcheevos-built URL and re-targets it at the proxy origin (`${RA_PROXY_BASE}`), @@ -124,13 +140,28 @@ build script now also exports `set_event_handler` and the `getValue` / `setValue Off by default, so the shipped native + wasm builds are byte-identical and AccuracyCoin holds 139/141 (the two newest upstream PPU tests are known gaps). +**v2.1.10 "Web Parity" update — the auth proxy is now a deployable service.** +The deploy stack (`deploy/`) gained a first-class `ra-proxy` compose service +(`deploy/Dockerfile.raproxy`) behind the shared Caddy TLS proxy at +`https:///ra/*`, env-configured (no committed config or secret). The +reference stub grew env-var configuration so one script serves both local dev and +the container. `docker compose up` now brings the proxy up alongside signaling + +STUN/TURN. What remains is genuinely un-CI-able: standing the stack on a real host +and a live RA login (below). + **Maintainer-manual (no headless path — ADR 0015) — the only remaining carryovers:** -1. **Deploy the auth proxy** (host + TLS + hardened CORS) and coordinate the exact - `User-Agent` + casual-only intent with the RA team. Then set `RA_PROXY_BASE`. +1. **Stand the proxy up on a real host** — `docker compose up` in `deploy/` with + `RA_USER_AGENT`/`RA_ALLOWED_ORIGINS` set (TLS via Caddy), and coordinate the + exact `User-Agent` + casual-only intent with the RA team. Then set + `RA_PROXY_BASE` in `web/cheevos/ra_glue.js` to `https:///ra`. The + service + config are code-complete (`deploy/README.md` §"Browser + RetroAchievements"); only running it on a live host + the RA-team coordination + remain. 2. **Live-browser verification with a real RA account** — build with `--features browser-cheevos`, add the `