diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 777b78fb..859a0dfc 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -126,6 +126,8 @@ jobs: npm exec --prefix tools/browser-tests -- playwright install --with-deps chromium - name: Play an exported game without a game server run: npm test --prefix tools/browser-tests + - name: Hosted native website rendering and Python input + run: EGL_PLATFORM=surfaceless cargo test --locked -p rustic-runtime --profile dev-fast native_website_streams_rendered_frames_and_executes_python_input -- --ignored rust-security: name: Rust dependency security and licenses @@ -198,6 +200,8 @@ jobs: - run: rustup show active-toolchain - run: cargo build --locked --manifest-path apps/luau-host/Cargo.toml --target-dir target - run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-scripting external_runtime -- --nocapture + - name: Hosted website sessions across all adapters + run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-play streamed_sessions_execute_available_native_language_adapters -- --nocapture - name: Scene environment scripting across all adapters run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-play environment -- --nocapture - name: Audio, capture and live reads across all adapters diff --git a/Engine/Cargo.lock b/Engine/Cargo.lock index da7b62aa..0b37dc84 100644 --- a/Engine/Cargo.lock +++ b/Engine/Cargo.lock @@ -904,6 +904,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "data-url" version = "0.3.2" @@ -2115,6 +2121,22 @@ dependencies = [ "match_token", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "ident_case" version = "1.0.1" @@ -3290,7 +3312,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.8", ] [[package]] @@ -3443,6 +3465,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -3537,7 +3568,27 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -3546,6 +3597,15 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -3973,9 +4033,13 @@ dependencies = [ "engine-scripting", "engine-world", "glam 0.33.6", + "image", "renderer-wgpu", "resvg", + "serde", + "serde_json", "tempfile", + "tungstenite", "winit", ] @@ -4156,6 +4220,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4812,6 +4887,23 @@ dependencies = [ "core_maths", ] +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.20", + "utf-8", +] + [[package]] name = "type-map" version = "0.5.1" diff --git a/Engine/apps/editor/src/project_build.rs b/Engine/apps/editor/src/project_build.rs index 5f510fb1..a9aead84 100644 --- a/Engine/apps/editor/src/project_build.rs +++ b/Engine/apps/editor/src/project_build.rs @@ -1,4 +1,5 @@ //! Project distribution UI and shell-free packaging jobs. +mod hosted; mod portable; use eframe::egui; use engine_editor::AuthoringDocument; @@ -31,6 +32,13 @@ impl Target { } } +#[derive(Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +enum WebsiteMode { + #[default] + Static, + Hosted, +} + #[derive(Clone, Serialize, Deserialize)] #[serde(default)] struct Settings { @@ -48,6 +56,8 @@ struct Settings { android_package: String, android_version_code: u32, compiler: String, + #[serde(default)] + website_mode: WebsiteMode, } impl Default for Settings { fn default() -> Self { @@ -66,6 +76,7 @@ impl Default for Settings { android_package: "org.rustic.game".into(), android_version_code: 1, compiler: "ISCC.exe".into(), + website_mode: WebsiteMode::Hosted, } } } @@ -174,7 +185,13 @@ impl ProjectBuild { } } Target::Website => { - ui.label("Turns the current game into a playable website: scenes, scripts, assets and the WebAssembly runtime are packaged automatically. Upload the generated website folder to any static host. No web source folder or game server is needed."); + ui.radio_value(&mut self.settings.website_mode, WebsiteMode::Hosted, "Hosted runtime (all scripting languages)"); + ui.radio_value(&mut self.settings.website_mode, WebsiteMode::Static, "Static WebAssembly (Lua 5.4, JavaScript, HTML/CSS)"); + if self.settings.website_mode == WebsiteMode::Hosted { + ui.label("Packages a website and native game server. Run start-website on a matching host with the game’s language toolchains and a compatible GPU. Players use their browser; each connection gets its own game session. Static hosting alone cannot run this mode."); + } else { + ui.label("Packages the WebAssembly game runtime for any static host. No game server is needed. External desktop scripting languages require Hosted runtime mode."); + } } Target::Apk | Target::Aab => { field(ui, "Android package ID", &mut self.settings.android_package); @@ -229,7 +246,7 @@ impl ProjectBuild { .project() .directory(VirtualDirectory::Builds) .map_err(|e| e.to_string())?; - if self.settings.target != Target::Windows { + if uses_browser_runtime(&self.settings) { portable::validate_scripts(&root)?; } let staging = tempfile::Builder::new() @@ -251,7 +268,7 @@ impl ProjectBuild { let settings = self.settings.clone(); let runtime = std::env::current_exe() .map_err(|e| e.to_string())? - .with_file_name("rustic-runtime.exe"); + .with_file_name(native_runtime_name()); let (sender, receiver) = mpsc::channel(); std::thread::Builder::new() .name("project-build".into()) @@ -425,6 +442,19 @@ fn available(program: &str, argument: &str) -> bool { .is_ok_and(|o| o.status.success()) } +fn native_runtime_name() -> &'static str { + if cfg!(windows) { + "rustic-runtime.exe" + } else { + "rustic-runtime" + } +} + +fn uses_browser_runtime(settings: &Settings) -> bool { + matches!(settings.target, Target::Apk | Target::Aab) + || (settings.target == Target::Website && settings.website_mode == WebsiteMode::Static) +} + fn readiness(root: &Path, settings: &Settings) -> Vec { let mut missing = Vec::new(); match settings.target { @@ -444,7 +474,14 @@ fn readiness(root: &Path, settings: &Settings) -> Vec { missing.push("The custom Inno Setup compiler path does not exist. Use ISCC.exe for automatic setup.".into()); } } - Target::Website => {} + Target::Website => { + if settings.website_mode == WebsiteMode::Hosted + && !std::env::current_exe() + .is_ok_and(|exe| exe.with_file_name(native_runtime_name()).is_file()) + { + missing.push(format!("{} beside the editor.", native_runtime_name())); + } + } Target::Apk | Target::Aab => { if java_executable().is_none() { missing.push("JAVA_HOME pointing to a JDK with bin/java.".into()); @@ -463,7 +500,7 @@ fn readiness(root: &Path, settings: &Settings) -> Vec { } } } - if settings.target != Target::Windows { + if uses_browser_runtime(settings) { if !std::env::current_exe() .is_ok_and(|exe| portable::runtime_available(&portable::runtime_directory(&exe))) { @@ -595,6 +632,15 @@ fn build(root: &Path, output: &Path, settings: &Settings, runtime: &Path) -> Res } } } + Target::Website if settings.website_mode == WebsiteMode::Hosted => { + hosted::export( + &output.join("data"), + &output.join("website"), + runtime, + &settings.name, + )?; + fs::remove_dir_all(output.join("data")).map_err(|e| e.to_string())?; + } Target::Website => { portable::export_website( &output.join("data"), @@ -819,6 +865,23 @@ Name: "desktopicon"; Description: "Create a desktop shortcut"; Flags: unchecked mod tests { use super::*; #[test] + fn old_website_settings_stay_static_and_new_builds_offer_all_languages() { + let previous: Settings = + serde_json::from_str(r#"{"target":"Website","name":"Old Game"}"#).unwrap(); + assert!(previous.website_mode == WebsiteMode::Static); + assert!(uses_browser_runtime(&previous)); + let current = Settings { + target: Target::Website, + ..Settings::default() + }; + assert!(current.website_mode == WebsiteMode::Hosted); + assert!(!uses_browser_runtime(¤t)); + assert!(uses_browser_runtime(&Settings { + target: Target::Apk, + ..current + })); + } + #[test] fn rejects_installer_directive_and_path_injection() { for name in [ "../escape", diff --git a/Engine/apps/editor/src/project_build/hosted-readme.md b/Engine/apps/editor/src/project_build/hosted-readme.md new file mode 100644 index 00000000..c52165dc --- /dev/null +++ b/Engine/apps/editor/src/project_build/hosted-readme.md @@ -0,0 +1,42 @@ +# Hosted website game + +This package runs all Rustic gameplay scripting languages through the native game +runtime. Players need only a browser with WebSocket and Canvas support. The host +needs the same OS/architecture as this exported runtime, a compatible GPU/driver, +and the external language toolchains used by the game (Luau, Python, C/C++, .NET, +JDK including javac, and/or PHP). Lua 5.4 and JavaScript are bundled. The Luau host +and sibling runtime libraries are copied when present. No scripts are translated +or silently omitted. + +1. Keep the complete `website/` folder together. +2. On Windows run `start-website.cmd`. On Linux/macOS run `sh start-website.sh`. +3. Open `http://127.0.0.1:8080` in a browser. The game starts automatically. +4. To choose a port, pass `--website-address 127.0.0.1:9090` to the launcher. + +For remote players, run the package on a compatible GPU-capable server and put an +HTTPS reverse proxy in front of the listening address. Forward WebSocket upgrades +for `/play` and preserve the external Host and Origin headers. Host at the origin +root. The listener defaults to loopback; to listen on a server network interface, +pass `--website-address 0.0.0.0:8080`. Static hosts such as GitHub Pages cannot run +this package. Use the engine's Static WebAssembly mode for those hosts, with its +smaller set of supported languages. + +The host renders 640 × 360 frames at up to 30 fps and streams PNG-compressed +frames and stereo 48 kHz audio. Bandwidth depends on scene complexity; latency +and bandwidth affect playability. Click/tap the canvas to enable browser audio. +Keyboard, primary mouse and touch input work. There is a maximum of eight +connections and idle connections time out after 30 seconds. Each player has an +independent simulation and temporary writable game copy; disconnecting removes +that copy. Saves do not persist between connections. Game UI follows the native +runtime's HTML/CSS/PHP rendering rules. Microphone and device selection refer to +the host, not the player's device; browser microphone forwarding is unavailable. + +Only the packaged game's scripts execute. Do not run untrusted game packages: +native scripts have the host account's ordinary privileges. The listener exposes +only the player, game title and play endpoint, never the game data or scripts. +Use a dedicated host account and your reverse proxy's access controls when needed. + +If startup fails, read the terminal output for missing toolchains, snapshot +integrity errors or GPU errors. Runtime initialization errors also appear in the +browser. If the browser disconnects behind a proxy, check WebSocket forwarding, +matching Host/Origin, timeouts and bandwidth. Close the launcher to stop hosting. diff --git a/Engine/apps/editor/src/project_build/hosted.rs b/Engine/apps/editor/src/project_build/hosted.rs new file mode 100644 index 00000000..da712528 --- /dev/null +++ b/Engine/apps/editor/src/project_build/hosted.rs @@ -0,0 +1,116 @@ +//! Native website packages retain the ordinary gameplay adapters for every language. +use super::copy_tree; +use std::{fs, path::Path}; + +pub(super) fn export( + data: &Path, + destination: &Path, + runtime: &Path, + name: &str, +) -> Result<(), String> { + engine_play::PlaySnapshot::open(data).map_err(|e| e.to_string())?; + if !runtime.is_file() { + return Err("The native game runtime is missing beside the editor.".into()); + } + fs::create_dir_all(destination).map_err(|e| e.to_string())?; + fs::copy(runtime, destination.join(super::native_runtime_name())).map_err(|e| e.to_string())?; + if let Some(parent) = runtime.parent() { + for entry in fs::read_dir(parent).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + if entry.file_type().map_err(|e| e.to_string())?.is_file() + && (entry + .path() + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("dll") || e == "so" || e == "dylib") + || entry.file_name() == "rustic-luau-host.exe" + || entry.file_name() == "rustic-luau-host") + { + fs::copy(entry.path(), destination.join(entry.file_name())) + .map_err(|e| e.to_string())?; + } + } + } + copy_tree(data, &destination.join("data"), &[])?; + for (name, contents) in [ + ( + "index.html", + include_str!("../../../runtime/src/website/player.html"), + ), + ( + "player.js", + include_str!("../../../runtime/src/website/player.js"), + ), + ("readme.md", include_str!("hosted-readme.md")), + ( + "start-website.cmd", + "@echo off\r\n\"%~dp0rustic-runtime.exe\" --website %*\r\n", + ), + ( + "start-website.sh", + "#!/bin/sh\nexec \"$(dirname -- \"$0\")/rustic-runtime\" --website \"$@\"\n", + ), + ] { + fs::write(destination.join(name), contents).map_err(|e| e.to_string())?; + } + fs::write( + destination.join("game-info.json"), + serde_json::to_vec(&serde_json::json!({"name":name})).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn export_preserves_all_language_sources_without_browser_resources() { + use engine_scripting::register_script_asset; + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + fs::create_dir_all(source.join("scripts")).unwrap(); + let mut inputs = vec![]; + for extension in [ + "lua", "luau", "js", "py", "c", "cpp", "cs", "java", "php", "html", + ] { + let directory = if matches!(extension, "php" | "html") { + "ui" + } else { + "scripts" + }; + fs::create_dir_all(source.join(directory)).unwrap(); + let relative = std::path::PathBuf::from(format!("{directory}/test.{extension}")); + fs::write(source.join(&relative), b"source").unwrap(); + register_script_asset(&source, relative.clone()).unwrap(); + inputs.push(engine_play::SnapshotInput::new( + relative, + b"source".to_vec(), + )); + } + inputs.push(engine_play::SnapshotInput::new( + "config/scripts.ron", + fs::read(source.join("config/scripts.ron")).unwrap(), + )); + let snapshot = engine_play::SnapshotBuilder::new(temp.path().join("stage")) + .stage( + engine_play::PlayMode::Standalone, + engine_play::SnapshotInput::new( + "scene/main.scene", + engine_world::SceneDocument::new("Test").to_bytes().unwrap(), + ), + &inputs, + ) + .unwrap(); + let runtime = temp.path().join(super::super::native_runtime_name()); + fs::write(&runtime, b"runtime").unwrap(); + let output = temp.path().join("website"); + export(snapshot.root(), &output, &runtime, "").unwrap(); + engine_play::PlaySnapshot::open(output.join("data")).unwrap(); + for input in inputs { + assert!(output.join("data").join(input.relative_path).is_file()); + } + assert!(output.join("start-website.cmd").is_file()); + assert!(output.join("start-website.sh").is_file()); + assert!(!output.join("rustic-browser.wasm").exists()); + } +} diff --git a/Engine/apps/editor/src/project_build/portable.rs b/Engine/apps/editor/src/project_build/portable.rs index 565f7271..e9734740 100644 --- a/Engine/apps/editor/src/project_build/portable.rs +++ b/Engine/apps/editor/src/project_build/portable.rs @@ -32,7 +32,7 @@ pub(super) fn validate_scripts(root: &Path) -> Result<(), String> { ScriptLanguage::Lua54 | ScriptLanguage::JavaScript | ScriptLanguage::Web ) { return Err(format!( - "{} uses {}. Direct website/Android exports currently support Lua 5.4, JavaScript and HTML/CSS behaviors; external desktop toolchains cannot run on these targets.", + "{} uses {}. Static website/Android exports support Lua 5.4, JavaScript and HTML/CSS behaviors; external desktop toolchains cannot run on these targets. Choose Hosted runtime for website builds.", entry.relative_path.display(), entry.language.display_name() )); diff --git a/Engine/apps/runtime/Cargo.toml b/Engine/apps/runtime/Cargo.toml index 2c5d3dc5..dfed1ae7 100644 --- a/Engine/apps/runtime/Cargo.toml +++ b/Engine/apps/runtime/Cargo.toml @@ -16,6 +16,10 @@ glam.workspace = true resvg.workspace = true winit.workspace = true tempfile.workspace = true +image.workspace = true +serde.workspace = true +serde_json.workspace = true +tungstenite = { version = "0.28", default-features = false, features = ["handshake"] } [dev-dependencies] engine-world = { path = "../../crates/engine-world", version = "0.1.0" } diff --git a/Engine/apps/runtime/src/main.rs b/Engine/apps/runtime/src/main.rs index c6dd21f0..5ab4fc48 100644 --- a/Engine/apps/runtime/src/main.rs +++ b/Engine/apps/runtime/src/main.rs @@ -24,6 +24,7 @@ use winit::window::{Window, WindowId}; mod game_ui; mod standalone; +mod website; const TOKEN_ENVIRONMENT_VARIABLE: &str = "RUSTIC_RUNTIME_IPC_TOKEN"; @@ -73,6 +74,22 @@ fn main() -> ExitCode { } fn run(arguments: &[OsString]) -> Result<(), String> { + if arguments.first().is_some_and(|value| value == "--website") { + let address = match arguments { + [_] => "127.0.0.1:8080", + [_, option, address] if option == "--website-address" => address + .to_str() + .ok_or("Website address is not valid UTF-8")?, + _ => { + return Err("Usage: rustic-runtime --website [--website-address ]".into()); + } + }; + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + return website::run( + executable.parent().ok_or("Missing runtime directory")?, + address, + ); + } if arguments.is_empty() { let executable = std::env::current_exe().map_err(|e| e.to_string())?; return standalone::run(&executable); diff --git a/Engine/apps/runtime/src/website.rs b/Engine/apps/runtime/src/website.rs new file mode 100644 index 00000000..2d53c1da --- /dev/null +++ b/Engine/apps/runtime/src/website.rs @@ -0,0 +1,547 @@ +//! A hosted website runs the native language adapters; the browser transports input, +//! pixels and audio. Each WebSocket owns a disposable snapshot and game session. +use engine_play::{game_session::GameSession, pointer_input::PointerEvent}; +use image::ImageEncoder as _; +use renderer_wgpu::{BackendRequest, SceneViewportRenderer}; +use serde::Deserialize; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use std::time::{Duration, Instant}; +use tungstenite::handshake::server::{ErrorResponse, Request, Response}; +use tungstenite::{Message, WebSocket, accept_hdr_with_config, protocol::WebSocketConfig}; + +const MAX_CONNECTIONS: usize = 8; +const WIDTH: u32 = 640; +const HEIGHT: u32 = 360; +const MAX_INPUT: usize = 64 * 1024; + +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct Input { + keys: Vec, + pointers: Vec, + reset: bool, +} + +pub(super) fn run(package: &Path, address: &str) -> Result<(), String> { + let data = package.join("data"); + engine_play::PlaySnapshot::open(&data).map_err(|e| e.to_string())?; + check_toolchains(&data)?; + let listener = + TcpListener::bind(address).map_err(|e| format!("Could not bind website: {e}"))?; + eprintln!( + "Website listening on http://{}", + listener.local_addr().map_err(|e| e.to_string())? + ); + let connections = Arc::new(AtomicUsize::new(0)); + for stream in listener.incoming() { + let mut stream = stream.map_err(|e| e.to_string())?; + if connections.fetch_add(1, Ordering::AcqRel) >= MAX_CONNECTIONS { + connections.fetch_sub(1, Ordering::AcqRel); + let _ = respond( + &mut stream, + "503 Service Unavailable", + "text/plain", + b"All game sessions are busy. Try again later.", + ); + continue; + } + let permit = ConnectionPermit(Arc::clone(&connections)); + let package = package.to_owned(); + std::thread::Builder::new() + .name("website-player".into()) + .spawn(move || { + let _permit = permit; + if let Err(error) = connection(stream, &package) { + eprintln!("Website connection ended: {error}"); + } + }) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +struct ConnectionPermit(Arc); +impl Drop for ConnectionPermit { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + +fn check_toolchains(data: &Path) -> Result<(), String> { + let path = data.join("config/scripts.ron"); + if path.is_file() { + let loaded = + engine_scripting::load_manifest(&std::fs::read(path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let mut missing = std::collections::BTreeSet::new(); + for script in loaded.manifest.scripts { + let availability = engine_scripting::probe_language_toolchain(script.language); + if !availability.available { + missing.insert(format!( + "{}: {}", + script.language.display_name(), + availability.detail + )); + } + } + if !missing.is_empty() { + return Err(format!( + "Install the game's language toolchains on the website host:\n{}", + missing.into_iter().collect::>().join("\n") + )); + } + } + Ok(()) +} + +// Preserve the already bounded HTTP header when handing the socket to tungstenite. +struct Connection { + header: std::io::Cursor>, + stream: TcpStream, +} +impl Read for Connection { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + if self.header.position() < self.header.get_ref().len() as u64 { + self.header.read(buffer) + } else { + self.stream.read(buffer) + } + } +} +impl Write for Connection { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + self.stream.write(buffer) + } + fn flush(&mut self) -> std::io::Result<()> { + self.stream.flush() + } +} +fn read_header(stream: &mut impl Read) -> Result, String> { + let mut header = Vec::new(); + while header.len() < 8192 { + let mut byte = [0]; + stream.read_exact(&mut byte).map_err(|e| e.to_string())?; + header.push(byte[0]); + if header.ends_with(b"\r\n\r\n") { + return Ok(header); + } + } + Err("HTTP header exceeds 8 KiB".into()) +} +fn respond( + stream: &mut impl Write, + status: &str, + content_type: &str, + body: &[u8], +) -> Result<(), String> { + write!(stream, "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n", body.len()).map_err(|e| e.to_string())?; + stream.write_all(body).map_err(|e| e.to_string()) +} +fn connection(mut stream: TcpStream, package: &Path) -> Result<(), String> { + stream + .set_read_timeout(Some(Duration::from_secs(30))) + .map_err(|e| e.to_string())?; + stream + .set_write_timeout(Some(Duration::from_secs(10))) + .map_err(|e| e.to_string())?; + stream.set_nodelay(true).map_err(|e| e.to_string())?; + let header = read_header(&mut stream)?; + let request = std::str::from_utf8(&header).map_err(|e| e.to_string())?; + let Some(path) = request + .lines() + .next() + .and_then(|line| line.strip_prefix("GET ")) + .and_then(|line| line.strip_suffix(" HTTP/1.1")) + else { + return respond( + &mut stream, + "405 Method Not Allowed", + "text/plain", + b"GET required", + ); + }; + match path { + "/" | "/index.html" => { + return respond( + &mut stream, + "200 OK", + "text/html; charset=utf-8", + include_bytes!("website/player.html"), + ); + } + "/player.js" => { + return respond( + &mut stream, + "200 OK", + "text/javascript; charset=utf-8", + include_bytes!("website/player.js"), + ); + } + "/game-info.json" => { + let info = std::fs::read(package.join("game-info.json")) + .unwrap_or_else(|_| b"{\"name\":\"Rustic Game\"}".to_vec()); + return respond(&mut stream, "200 OK", "application/json", &info); + } + "/play" => {} + _ => return respond(&mut stream, "404 Not Found", "text/plain", b"Not found"), + } + let connection = Connection { + header: std::io::Cursor::new(header), + stream, + }; + let config = WebSocketConfig::default() + .max_message_size(Some(MAX_INPUT)) + .max_frame_size(Some(MAX_INPUT)); + let mut socket = accept_hdr_with_config(connection, validate_origin, Some(config)) + .map_err(|e| e.to_string())?; + let result = play(&mut socket, &package.join("data")); + if let Err(error) = &result { + let _ = socket.send(Message::Text( + serde_json::json!({"error": error}).to_string().into(), + )); + } + let _ = socket.close(None); + result +} +#[allow( + clippy::result_large_err, + reason = "tungstenite requires an HTTP error response in its handshake callback" +)] +fn validate_origin(request: &Request, response: Response) -> Result { + let host = request.headers().get("host").and_then(|h| h.to_str().ok()); + let origin = request + .headers() + .get("origin") + .and_then(|h| h.to_str().ok()); + if host.zip(origin).is_some_and(|(host, origin)| { + origin == format!("http://{host}") || origin == format!("https://{host}") + }) { + Ok(response) + } else { + Err(tungstenite::http::Response::builder() + .status(403) + .body(Some("Same-origin game connections required".into())) + .expect("constant response")) + } +} + +fn copy_snapshot(source: &Path, destination: &Path) -> Result<(), String> { + std::fs::create_dir_all(destination).map_err(|e| e.to_string())?; + for entry in std::fs::read_dir(source).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let kind = entry.file_type().map_err(|e| e.to_string())?; + let target = destination.join(entry.file_name()); + if kind.is_symlink() { + return Err("Snapshot contains a symbolic link".into()); + } + if kind.is_dir() { + copy_snapshot(&entry.path(), &target)?; + } else if kind.is_file() { + // Fresh files inherit writable temporary-directory permissions even + // when the installed package is read-only. + let mut source_file = std::fs::File::open(entry.path()).map_err(|e| e.to_string())?; + let mut target_file = std::fs::File::create(target).map_err(|e| e.to_string())?; + std::io::copy(&mut source_file, &mut target_file).map_err(|e| e.to_string())?; + } + } + Ok(()) +} +fn parse_input(text: &str) -> Result { + if text.len() > MAX_INPUT { + return Err("Input exceeds 64 KiB".into()); + } + let input: Input = serde_json::from_str(text).map_err(|e| e.to_string())?; + if input.keys.len() > 128 + || input.keys.iter().any(|key| key.len() > 64) + || input.pointers.len() > 1024 + { + return Err("Too many input events".into()); + } + Ok(input) +} +fn play(socket: &mut WebSocket, data: &Path) -> Result<(), String> { + let temporary = tempfile::tempdir().map_err(|e| e.to_string())?; + let root: PathBuf = temporary.path().join("game"); + copy_snapshot(data, &root)?; + let mut game = GameSession::load_streamed(&root)?; + let models = engine_assets::load_model_library(&root) + .map_err(|e| e.to_string())? + .into_iter() + .map(|(id, (_, model))| (id, model)) + .collect(); + let mut renderer = + SceneViewportRenderer::new(BackendRequest::Auto).map_err(|e| e.to_string())?; + renderer.set_environment_root(&root); + let ui = super::game_ui::GameUi::load(&root)?; + let mut previous = Instant::now(); + loop { + let message = socket.read().map_err(|e| e.to_string())?; + let text = match message { + Message::Text(text) => text, + Message::Close(_) => return Ok(()), + Message::Ping(_) | Message::Pong(_) => { + socket.flush().map_err(|e| e.to_string())?; + continue; + } + _ => return Err("Game input must be JSON text".into()), + }; + let input = parse_input(text.as_str())?; + // The host owns elapsed time. A client cannot accelerate the simulation. + let elapsed = previous.elapsed(); + let interval = Duration::from_secs_f64(1.0 / 30.0); + if let Some(wait) = interval.checked_sub(elapsed) { + std::thread::sleep(wait); + } + let now = Instant::now(); + game.step( + now.duration_since(previous).as_secs_f64(), + input.keys, + input.pointers, + input.reset, + )?; + previous = now; + let scene = game.render_world(|world| { + renderer_wgpu::game_scene_with_models(world, 16.0 / 9.0, &models) + })?; + let mut frame = renderer + .render(WIDTH, HEIGHT, &scene) + .map_err(|e| e.to_string())? + .ok_or("Game renderer did not produce a frame")?; + ui.composite_rgba(WIDTH, HEIGHT, &mut frame.rgba8)?; + let audio = game.take_stream_audio(); + let mut pixels = Vec::new(); + image::codecs::png::PngEncoder::new_with_quality( + &mut pixels, + image::codecs::png::CompressionType::Fast, + image::codecs::png::FilterType::NoFilter, + ) + .write_image(&frame.rgba8, WIDTH, HEIGHT, image::ExtendedColorType::Rgba8) + .map_err(|e| e.to_string())?; + let mut packet = Vec::with_capacity(16 + pixels.len() + audio.len() * 4); + packet.extend_from_slice(&WIDTH.to_le_bytes()); + packet.extend_from_slice(&HEIGHT.to_le_bytes()); + packet.extend_from_slice( + &u32::try_from(pixels.len()) + .map_err(|e| e.to_string())? + .to_le_bytes(), + ); + packet.extend_from_slice( + &u32::try_from(audio.len()) + .map_err(|e| e.to_string())? + .to_le_bytes(), + ); + packet.extend_from_slice(&pixels); + for sample in audio { + packet.extend_from_slice(&sample.to_le_bytes()); + } + socket + .send(Message::Binary(packet.into())) + .map_err(|e| e.to_string())?; + for (level, message) in game.drain_logs() { + eprintln!("{level}: {message}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cross_origin_connections_are_rejected() { + for origin in ["http://evil.example", "null", "https://game.example.evil"] { + let request = Request::builder() + .uri("/play") + .header("host", "game.example") + .header("origin", origin) + .body(()) + .unwrap(); + assert!(validate_origin(&request, Response::new(())).is_err()); + } + for origin in ["https://game.example", "http://game.example"] { + let request = Request::builder() + .uri("/play") + .header("host", "game.example") + .header("origin", origin) + .body(()) + .unwrap(); + assert!(validate_origin(&request, Response::new(())).is_ok()); + } + let request = Request::builder() + .header("host", "game.example") + .body(()) + .unwrap(); + assert!(validate_origin(&request, Response::new(())).is_err()); + } + + #[test] + fn input_rejects_oversized_and_unexpected_commands() { + assert!( + parse_input(r#"{"keys":["KeyD"],"reset":true}"#) + .unwrap() + .reset + ); + assert!(parse_input(r#"{"seconds":10000}"#).is_err()); + assert!(parse_input(&serde_json::json!({"keys": vec!["KeyD"; 129]}).to_string()).is_err()); + assert!(parse_input(&serde_json::json!({"keys": ["x".repeat(65)]}).to_string()).is_err()); + assert!(parse_input(&" ".repeat(MAX_INPUT + 1)).is_err()); + assert!(read_header(&mut std::io::Cursor::new(vec![b'x'; 8192])).is_err()); + } + + #[test] + fn connection_cannot_fetch_game_sources() { + let package = tempfile::tempdir().unwrap(); + std::fs::create_dir(package.path().join("data")).unwrap(); + std::fs::write(package.path().join("data/secret.py"), b"SECRET").unwrap(); + for path in [ + "/data/secret.py", + "/../data/secret.py", + "/%2e%2e/data/secret.py", + "/player.js", + ] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server, _) = listener.accept().unwrap(); + write!(client, "GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap(); + connection(server, package.path()).unwrap(); + let mut response = String::new(); + client.read_to_string(&mut response).unwrap(); + assert!(!response.contains("SECRET")); + assert!(response.starts_with(if path == "/player.js" { + "HTTP/1.1 200" + } else { + "HTTP/1.1 404" + })); + } + } + + #[test] + fn player_file_writes_are_isolated() { + let source = tempfile::tempdir().unwrap(); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + std::fs::write(source.path().join("save.txt"), b"initial").unwrap(); + copy_snapshot(source.path(), first.path()).unwrap(); + copy_snapshot(source.path(), second.path()).unwrap(); + std::fs::write(first.path().join("save.txt"), b"changed").unwrap(); + assert_eq!( + std::fs::read(second.path().join("save.txt")).unwrap(), + b"initial" + ); + assert_eq!( + std::fs::read(source.path().join("save.txt")).unwrap(), + b"initial" + ); + } +} + +#[cfg(test)] +mod smoke_test { + use super::*; + use engine_play::{PlayMode, SnapshotBuilder, SnapshotInput}; + use engine_world::{ + Camera, EntitySnapshot, LocalTransform, PartAttributes, Primitive, SceneDocument, + ScriptComponent, + }; + use tungstenite::client::IntoClientRequest as _; + + #[test] + #[ignore = "requires native graphics and the Python toolchain"] + fn native_website_streams_rendered_frames_and_executes_python_input() { + let package = tempfile::tempdir().unwrap(); + let authoring = package.path().join("authoring"); + std::fs::create_dir_all(authoring.join("scripts")).unwrap(); + let source = b"from rustic import rustic,run\ndef fixed_update(dt):\n if rustic.key('KeyD')['held']:\n x,y,z=rustic.get_translation()\n rustic.set_translation(x+dt*5,y,z)\nrun(globals())\n"; + std::fs::write(authoring.join("scripts/move.py"), source).unwrap(); + let script = + engine_scripting::register_script_asset(&authoring, "scripts/move.py").unwrap(); + let mut scene = SceneDocument::new("Hosted smoke"); + scene.entities = vec![ + EntitySnapshot { + camera: Some(Camera { + active: true, + ..Camera::default() + }), + local_transform: LocalTransform { + translation: glam::Vec3::new(0.0, 0.0, -5.0), + ..LocalTransform::IDENTITY + }, + ..EntitySnapshot::default() + }, + EntitySnapshot { + primitive: Some(Primitive::Cube { size: 2.0 }), + scripts: vec![ScriptComponent::new(script)], + part_attributes: PartAttributes { + color: [1.0, 0.0, 0.0, 1.0], + anchored: true, + ..PartAttributes::default() + }, + ..EntitySnapshot::default() + }, + ]; + let snapshot = SnapshotBuilder::new(package.path().join("stage")) + .stage( + PlayMode::Standalone, + SnapshotInput::new("scene/main.scene", scene.to_bytes().unwrap()), + &[ + SnapshotInput::new("scripts/move.py", source.as_slice()), + SnapshotInput::new( + "config/scripts.ron", + std::fs::read(authoring.join("config/scripts.ron")).unwrap(), + ), + ], + ) + .unwrap(); + copy_snapshot(snapshot.root(), &package.path().join("data")).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let path = package.path().to_owned(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + connection(stream, &path) + }); + let stream = TcpStream::connect(address).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(30))) + .unwrap(); + let mut request = format!("ws://{address}/play") + .into_client_request() + .unwrap(); + request + .headers_mut() + .insert("origin", format!("http://{address}").parse().unwrap()); + let (mut client, _) = tungstenite::client(request, stream).unwrap(); + client.send(Message::Text("{}".into())).unwrap(); + let initial = client.read().unwrap(); + let Message::Binary(initial) = initial else { + panic!("Expected frame, got {initial:?}") + }; + let length = u32::from_le_bytes(initial[8..12].try_into().unwrap()) as usize; + let image = image::load_from_memory(&initial[16..16 + length]).unwrap(); + assert_eq!((image.width(), image.height()), (WIDTH, HEIGHT)); + let mut changed = false; + for _ in 0..5 { + client + .send(Message::Text(r#"{"keys":["KeyD"]}"#.into())) + .unwrap(); + let Message::Binary(frame) = client.read().unwrap() else { + panic!("Expected frame") + }; + let length = u32::from_le_bytes(frame[8..12].try_into().unwrap()) as usize; + let moved = image::load_from_memory(&frame[16..16 + length]).unwrap(); + changed |= image.as_bytes() != moved.as_bytes(); + } + assert!( + changed, + "Python keyboard input must change the rendered game" + ); + client.close(None).unwrap(); + assert!(server.join().unwrap().is_ok()); + } +} diff --git a/Engine/apps/runtime/src/website/player.html b/Engine/apps/runtime/src/website/player.html new file mode 100644 index 00000000..c23238dd --- /dev/null +++ b/Engine/apps/runtime/src/website/player.html @@ -0,0 +1,4 @@ + +Rustic Game + + diff --git a/Engine/apps/runtime/src/website/player.js b/Engine/apps/runtime/src/website/player.js new file mode 100644 index 00000000..8fd1a971 --- /dev/null +++ b/Engine/apps/runtime/src/website/player.js @@ -0,0 +1,110 @@ +"use strict"; +const canvas = document.getElementById("game"); +const status = document.getElementById("status"); +const context = canvas.getContext("2d"); +fetch("game-info.json").then(r => r.json()).then(info => { document.title = info.name; }).catch(() => {}); +const keys = new Set(); +const pointers = []; +let reset = false; +let audio; +let nextAudioTime = 0; +let running = true; +function fail(error) { + running = false; + status.textContent = `Game could not run: ${error?.message || String(error)}`; + status.hidden = false; + socket?.close(); +} +function enableAudio() { + try { + audio ??= new AudioContext(); + if (audio.state === "suspended") audio.resume().catch(() => {}); + } catch { /* Graphics and gameplay remain available without an audio device. */ } +} +// Browsers require a player gesture for sound, but gameplay starts immediately. +canvas.addEventListener("pointerdown", enableAudio); +canvas.addEventListener("keydown", enableAudio); +function playAudio(bytes) { + if (!audio || audio.state !== "running" || bytes.length < 8) return; + if (nextAudioTime > audio.currentTime + 0.3) return; + const samples = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const count = Math.floor(bytes.length / 8); + const buffer = audio.createBuffer(2, count, 48000); + for (let channel = 0; channel < 2; channel++) { + const destination = buffer.getChannelData(channel); + for (let i = 0; i < count; i++) destination[i] = samples.getFloat32((i * 2 + channel) * 4, true); + } + const source = audio.createBufferSource(); + source.buffer = buffer; + source.connect(audio.destination); + const when = Math.max(audio.currentTime + 0.02, nextAudioTime); + source.start(when); + nextAudioTime = when + buffer.duration; +} +function clearInput() { keys.clear(); heldPointers.clear(); pointers.length = 0; reset = true; } +window.addEventListener("blur", clearInput); +document.addEventListener("visibilitychange", () => { clearInput(); }); +canvas.addEventListener("keydown", event => { + event.preventDefault(); + if (event.code) keys.add(event.code); + keys.add(event.key === " " ? "Space" : event.key.length === 1 ? event.key.toUpperCase() : event.key); +}); +canvas.addEventListener("keyup", event => { + event.preventDefault(); + if (event.code) keys.delete(event.code); + keys.delete(event.key === " " ? "Space" : event.key.length === 1 ? event.key.toUpperCase() : event.key); +}); +const heldPointers = new Set(); +for (const [type, phase] of [["pointerdown", "start"], ["pointermove", "move"], ["pointerup", "end"], ["pointercancel", "cancel"]]) { + canvas.addEventListener(type, event => { + if (event.pointerType === "mouse" && event.button !== 0 && phase !== "move") return; + if (phase === "start") { canvas.focus(); canvas.setPointerCapture(event.pointerId); heldPointers.add(event.pointerId); } + if (!heldPointers.has(event.pointerId)) return; + const rect = canvas.getBoundingClientRect(); + // CSS object-fit letterboxes the fixed 16:9 game image inside the canvas box. + const width = Math.min(rect.width, rect.height * 16 / 9); + const height = width * 9 / 16; + const x = (event.clientX - rect.left - (rect.width - width) / 2) / width; + const y = (event.clientY - rect.top - (rect.height - height) / 2) / height; + if (phase === "start" && (x < 0 || x > 1 || y < 0 || y > 1)) { heldPointers.delete(event.pointerId); return; } + if (pointers.length >= 1024) { clearInput(); return; } + pointers.push({ id: String(event.pointerId), device: event.pointerType === "touch" ? "touch" : "mouse", phase, position: [x, y], pressure: event.pressure || 1, aspect: 16 / 9 }); + if (phase === "end" || phase === "cancel") heldPointers.delete(event.pointerId); + event.preventDefault(); + }); +} + +const url = new URL("play", location.href); +url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; +const socket = new WebSocket(url); +socket.binaryType = "arraybuffer"; +let waiting = false; +function sendInput() { + if (!running || waiting || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ keys: [...keys], pointers: pointers.splice(0, 1024), reset })); + reset = false; + waiting = true; +} +socket.addEventListener("open", () => { canvas.focus(); sendInput(); }); +socket.addEventListener("error", () => fail(new Error("Could not connect to the game host."))); +socket.addEventListener("close", () => { if (running) fail(new Error("Connection closed. Reload to start a new game.")); }); +socket.addEventListener("message", async event => { + if (!running) return; + try { + if (typeof event.data === "string") throw new Error(JSON.parse(event.data).error || "Unexpected game response"); + const bytes = new Uint8Array(event.data); + if (bytes.length < 16) throw new Error("Incomplete game frame"); + const header = new DataView(event.data); + const width = header.getUint32(0, true), height = header.getUint32(4, true); + const pixels = header.getUint32(8, true), samples = header.getUint32(12, true); + if (width !== 640 || height !== 360 || samples > 48000 || pixels > 2000000 || bytes.length !== 16 + pixels + samples * 4) throw new Error("Invalid game frame"); + if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } + const bitmap = await createImageBitmap(new Blob([bytes.subarray(16, 16 + pixels)], { type: "image/png" })); + if (running) context.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + playAudio(bytes.subarray(16 + pixels)); + waiting = false; + requestAnimationFrame(sendInput); + } catch (error) { fail(error); } +}); +window.addEventListener("pagehide", () => { running = false; socket.close(); }); diff --git a/Engine/crates/engine-play/src/audio_output.rs b/Engine/crates/engine-play/src/audio_output.rs index 3a4c2576..8c0c8c0f 100644 --- a/Engine/crates/engine-play/src/audio_output.rs +++ b/Engine/crates/engine-play/src/audio_output.rs @@ -1,6 +1,24 @@ //! Native device handles and streams stay on the audio thread, outside scene locks. +use std::sync::{Arc, Mutex}; +pub(crate) type StreamAudio = Arc>>; +thread_local! { + static STREAM_AUDIO: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +// New scene contexts inherit the connection's sink without changing other players. +pub(crate) fn with_stream_audio(sink: &StreamAudio, operation: impl FnOnce() -> T) -> T { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + STREAM_AUDIO.with(|slot| *slot.borrow_mut() = self.0.take()); + } + } + let _restore = Restore(STREAM_AUDIO.with(|slot| slot.replace(Some(Arc::clone(sink))))); + operation() +} #[derive(Default)] pub(crate) struct AudioOutput { + stream: Option, #[cfg(windows)] sender: Option>, } @@ -22,6 +40,13 @@ enum Command { impl AudioOutput { pub fn new() -> Self { + if let Some(stream) = STREAM_AUDIO.with(|slot| slot.borrow().clone()) { + return Self { + stream: Some(stream), + #[cfg(windows)] + sender: None, + }; + } // Tests must not initialize native device drivers for each scene fixture. #[cfg(all(windows, not(test)))] { @@ -33,6 +58,7 @@ impl AudioOutput { { return Self { sender: Some(sender), + stream: None, }; } } @@ -83,6 +109,15 @@ impl AudioOutput { #[allow(clippy::unused_self)] pub fn submit(&self, samples: Vec) { + if let Some(stream) = &self.stream { + if let Ok(mut queue) = stream.lock() { + if queue.len() + samples.len() > 48_000 { + queue.clear(); + } + queue.extend(samples.into_iter().take(48_000)); + } + return; + } #[cfg(windows)] if let Some(sender) = &self.sender { let _ = sender.try_send(Command::Samples(samples)); @@ -226,3 +261,22 @@ thread_local! { static BROWSER_AUDIO: std::cell::RefCell> = const { std pub(crate) fn take_browser_audio() -> Vec { BROWSER_AUDIO.with(|queue| std::mem::take(&mut *queue.borrow_mut())) } + +#[cfg(test)] +mod stream_tests { + use super::*; + #[test] + fn streamed_audio_is_bounded_and_connection_scoped() { + let first = StreamAudio::default(); + let second = StreamAudio::default(); + let output = with_stream_audio(&first, AudioOutput::new); + let other = with_stream_audio(&second, AudioOutput::new); + assert!(AudioOutput::new().stream.is_none()); + output.submit(vec![0.25; 100]); + other.submit(vec![0.5; 200]); + assert_eq!(first.lock().unwrap().len(), 100); + assert_eq!(*second.lock().unwrap(), vec![0.5; 200]); + output.submit(vec![1.0; 96_000]); + assert_eq!(first.lock().unwrap().len(), 48_000); + } +} diff --git a/Engine/crates/engine-play/src/game_session.rs b/Engine/crates/engine-play/src/game_session.rs index c6a9168c..aa366c73 100644 --- a/Engine/crates/engine-play/src/game_session.rs +++ b/Engine/crates/engine-play/src/game_session.rs @@ -8,6 +8,7 @@ use std::path::Path; pub struct GameSession { scripts: RuntimeScripts, accumulator: f64, + stream_audio: Option, } impl GameSession { /// Load verified packaged data and initialize the same gameplay callbacks as local play. @@ -19,9 +20,32 @@ impl GameSession { Ok(Self { scripts: RuntimeScripts::load(root, &source)?, accumulator: 0.0, + stream_audio: None, }) } - /// Advance bounded real time without launching threads or child processes. + /// Load a native session whose mixed audio is returned to a website player. + /// # Errors + /// Returns the same validation and initialization errors as `load`. + pub fn load_streamed(root: &Path) -> Result { + let audio = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut session = crate::audio_output::with_stream_audio(&audio, || Self::load(root))?; + session.stream_audio = Some(audio); + Ok(session) + } + + /// Drain bounded interleaved stereo 48 kHz samples for a hosted player. + pub fn take_stream_audio(&self) -> Vec { + self.stream_audio + .as_ref() + .and_then(|sink| { + sink.lock() + .ok() + .map(|mut queue| std::mem::take(&mut *queue)) + }) + .unwrap_or_default() + } + + /// Advance bounded real time using the configured gameplay language adapters. /// # Errors /// Rejects non-finite or negative frame intervals. pub fn step( @@ -34,6 +58,22 @@ impl GameSession { if !seconds.is_finite() || seconds < 0.0 { return Err("Invalid frame interval".into()); } + if let Some(audio) = self.stream_audio.clone() { + crate::audio_output::with_stream_audio(&audio, || { + self.step_inner(seconds, keys, pointers, reset); + }); + } else { + self.step_inner(seconds, keys, pointers, reset); + } + Ok(()) + } + fn step_inner( + &mut self, + seconds: f64, + keys: Vec, + pointers: Vec, + reset: bool, + ) { self.scripts.set_input_keys(keys); if reset { self.scripts.reset_pointer_input(); @@ -46,7 +86,6 @@ impl GameSession { self.accumulator -= 1.0 / 60.0; } self.scripts.frame_update(seconds); - Ok(()) } /// Extract an immutable view of the live game after script and physics updates. /// # Errors @@ -68,3 +107,92 @@ impl Drop for GameSession { self.scripts.stop(); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{PlayMode, SnapshotBuilder, SnapshotInput}; + use engine_scripting::{ + ScriptApiVersion, ScriptId, ScriptLanguage, ScriptManifest, ScriptManifestEntry, + }; + use engine_world::{EntitySnapshot, SceneDocument, ScriptComponent}; + + #[test] + fn streamed_sessions_execute_available_native_language_adapters() { + let fixtures: &[(ScriptLanguage, &str, &[u8])] = &[ + (ScriptLanguage::Lua54, "scripts/test.lua", br"return {Start=function() rustic.set_translation(1,2,3) end}"), + (ScriptLanguage::Luau, "scripts/test.luau", br"return {Start=function() rustic.set_translation(1,2,3) end}"), + (ScriptLanguage::JavaScript, "scripts/test.js", br"globalThis.behavior={Start(){rustic.set_translation(1,2,3);}};"), + (ScriptLanguage::Web, "ui/test.html", br"Test"), + (ScriptLanguage::Python, "scripts/test.py", b"from rustic import rustic,run\ndef on_start():\n rustic.set_translation(1,2,3)\nrun(globals())\n"), + (ScriptLanguage::C, "scripts/test.c", br#"#include "rustic.h" +void start(void){rustic.set_translation(1,2,3);} int main(void){return rustic_run((RusticBehavior){.on_start=start});}"#), + (ScriptLanguage::Cpp, "scripts/test.cpp", br#"#include "rustic.hpp" +void start(){rustic.set_translation(1,2,3);} int main(){return rustic_run(RusticBehavior{.on_start=start});}"#), + (ScriptLanguage::CSharp, "scripts/test.cs", br#"using static Rustic; +Run((callback,dt)=>{if(callback=="on_start")rustic.set_translation(1,2,3);});"#), + (ScriptLanguage::Java, "scripts/RusticBehavior.java", br#"class RusticBehavior extends Rustic{public static void main(String[]args)throws Exception{run((callback,dt)->{if(callback.equals("on_start"))rustic.set_translation(1,2,3);});}}"#), + (ScriptLanguage::Php, "ui/test.php", br"function(){global $rustic;$rustic->set_translation(1,2,3);}]);"), + ]; + for &(language, path, source) in fixtures { + if !engine_scripting::probe_language_toolchain(language).available { + assert!( + std::env::var_os("RUSTIC_REQUIRE_ALL_API_ADAPTERS").is_none(), + "{} toolchain is required", + language.display_name() + ); + eprintln!( + "SKIPPED hosted session {}: missing toolchain", + language.display_name() + ); + continue; + } + let temp = tempfile::tempdir().unwrap(); + let script = ScriptId::new(); + let entity = EntitySnapshot { + scripts: vec![ScriptComponent::new(script)], + ..EntitySnapshot::default() + }; + let id = entity.id; + let mut scene = SceneDocument::new("Hosted language test"); + scene.entities.push(entity); + let manifest = ScriptManifest { + format_version: 2, + scripts: vec![ScriptManifestEntry { + id: script, + language, + relative_path: path.into(), + api_version: ScriptApiVersion::CURRENT, + public_properties: vec![], + }], + }; + let snapshot = SnapshotBuilder::new(temp.path().join("stage")) + .stage( + PlayMode::Standalone, + SnapshotInput::new("scene/main.scene", scene.to_bytes().unwrap()), + &[ + SnapshotInput::new( + "config/scripts.ron", + ron::ser::to_string(&manifest).unwrap().into_bytes(), + ), + SnapshotInput::new(path, source), + ], + ) + .unwrap(); + let mut session = GameSession::load_streamed(snapshot.root()).unwrap(); + session.step(1.0 / 60.0, vec![], vec![], false).unwrap(); + let position = session + .render_world(|world| world.local_transform(id).unwrap().translation) + .unwrap(); + assert_eq!( + position, + glam::Vec3::new(1.0, 2.0, 3.0), + "{}: {:?}", + language.display_name(), + session.drain_logs() + ); + } + } +} diff --git a/Engine/crates/engine-scripting/src/agent_authoring.md b/Engine/crates/engine-scripting/src/agent_authoring.md index 3f9c8517..f2a34ea1 100644 --- a/Engine/crates/engine-scripting/src/agent_authoring.md +++ b/Engine/crates/engine-scripting/src/agent_authoring.md @@ -117,6 +117,23 @@ These are optional references, not installed dependencies or engine API specific - Broader design/accessibility reference catalog: https://github.com/jeremylongworth-source/AgentSkills/tree/main/skills - Blender workflow reference: https://github.com/Chara-Xh/blender-modeling-skill/tree/main/skills/blender-modeling +## Exporting a game website + +Use File → Build Project… → Website (playable game). Hosted runtime supports all +Rustic scripting languages through the native runtime; it packages launchers and +a verified game snapshot, and requires a matching GPU-capable host with the used +external language toolchains. Players need only a browser. Run start-website.cmd +(Windows) or sh start-website.sh (Unix), then open http://127.0.0.1:8080. Remote +hosting requires an HTTPS reverse proxy with /play WebSocket forwarding and +matching Host/Origin. Each player gets temporary isolated game files; saves do +not persist. PNG frames are streamed at 640 × 360/up to 30 fps; bandwidth varies by scene. +Static WebAssembly mode needs no game server but only supports Lua 5.4, +JavaScript and HTML/CSS; external languages and standalone PHP pages must not be +silently omitted. Existing saved build settings retain static mode. Do not create +a separate web/ project or attach export helpers as gameplay scripts. See the +project builds guide for host requirements and troubleshooting. There is no +agent export tool; use the editor's build dialog. + ## Physics authoring Built-in primitives use oriented box collision proxies and unit mass in Play. Anchor floors and ramps, enable can_collide on both bodies, and author friction (0..10, default 0.5) through object_update.part_attributes. Rotated planes/boxes provide slope normals; curved primitives remain boxes and sheared transforms use enclosing AABBs. Use Physics.force once per FixedUpdate with dt, impulse to add velocity, and launch to replace it. Raycasts/sphere casts return rotated surface normals. Physics.ground(entity, maxAngle) reads the last completed fixed step and returns null or {entity, normal, angle, walkable}; it does not implement a character controller. Register Physics.onCollisionEnter/Stay/Exit explicitly from Start; callbacks arrive during Update with (other ID, world normal), using native array callbacks in C/C++/C#/Java. Both bodies need can_touch and can_collide; at least one must be unanchored. Defining OnCollisionEnter lifecycle functions alone does not register them. Inspect logs and test sliding, landing and leaving contact. diff --git a/Engine/docs/PROJECT_BUILDS.md b/Engine/docs/PROJECT_BUILDS.md index 346a0dea..0c0f8878 100644 --- a/Engine/docs/PROJECT_BUILDS.md +++ b/Engine/docs/PROJECT_BUILDS.md @@ -21,7 +21,59 @@ The game opens its own window and isolated simulation, forwards keyboard, primar ## Playable website -Select **Website (playable game)** and click **Build Project**. The editor exports the current game automatically: its scene snapshot, registered scripts, assets, HTML/CSS game UI, browser player and compiled WebAssembly runtime. **Do not create a `web/` folder or prepare a separate website.** No game server is required. +Select **Website (playable game)** in **File → Build Project…**. Choose the website mode: + +| Mode | Languages | Hosting | +| --- | --- | --- | +| Hosted runtime (default for new build settings) | Lua 5.4, Luau, JavaScript, Python, C, C++, C#, Java, PHP, HTML/CSS | Native runtime on a matching OS/architecture with GPU and required language toolchains | +| Static WebAssembly | Lua 5.4, JavaScript, HTML/CSS | Any static HTTP/HTTPS host | + +Existing saved build settings retain static mode until you select hosted mode. + +### Hosted runtime: all scripting languages + +Choose **Hosted runtime (all scripting languages)** and click **Build Project**. +The generated `website/` folder contains the native runtime, available sibling +runtime libraries and Luau host, a verified game snapshot in `data/`, player files, +launchers, game title and `readme.md`. It requires no browser-runtime resources. +Registered scripts are retained in their original language and use the same +native gameplay adapters as desktop play; no translation is performed. + +1. Keep the complete folder together. Run `start-website.cmd` on Windows, or + `sh start-website.sh` on Linux/macOS. Use a host matching the exported binary. +2. Install the external toolchains used by the game on that host: Luau, Python, + C/C++ compiler, .NET SDK, JDK including `javac`, and/or PHP. The included Luau + host is used when present. Lua 5.4 and JavaScript are bundled. Missing + registered-script toolchains produce a startup error in the host terminal. +3. Open `http://127.0.0.1:8080`. Each connection starts an independent native game + session and temporary writable game copy; disconnecting removes that copy. +4. For another port pass `--website-address 127.0.0.1:9090` to the launcher. + +For remote players, run on a GPU-capable server with a compatible graphics driver. +Use an HTTPS reverse proxy at the origin root; forward WebSocket upgrades for +`/play` and preserve the public Host and Origin headers. The default listener is +loopback. To bind a server interface pass `--website-address 0.0.0.0:8080`. +Static hosting alone cannot run this mode. No player-side language installation +is needed. + +Keyboard, primary mouse, touch, native game UI (including PHP pages), and game +audio use the native simulation. Click/tap or press a key on the canvas to enable +sound. The host streams PNG-compressed 640 × 360 frames at up to 30 fps and +stereo 48 kHz audio. Bandwidth varies with scene complexity; network latency +adds input delay. Up to eight connections are accepted; inactive connections time +out after 30 seconds. Saves are connection-local and not persistent. Browser +microphone forwarding and player-side audio-device selection are unavailable; +native device APIs refer to the host. Run only trusted game packages under a +suitable host account, since native scripts have that account's privileges. + +If the page fails, inspect both the browser error and host terminal. Restore +missing/corrupted game data, install reported toolchains, and check GPU drivers, +WebSocket forwarding, matching Host/Origin, proxy timeouts and bandwidth. Reload +after a disconnect to start a fresh session. Stop the launcher to stop hosting. + +### Static WebAssembly + +Choose **Static WebAssembly (Lua 5.4, JavaScript, HTML/CSS)** and click **Build Project**. The editor exports the current game automatically: its scene snapshot, registered scripts, assets, HTML/CSS game UI, browser player and compiled WebAssembly runtime. **Do not create a `web/` folder or prepare a separate website.** No game server is required. The output's `website/` directory contains `index.html`, `player.js`, `rustic-browser.js`, `rustic-browser.wasm`, the game file manifest, `data/`, and a `readme.md` with local preview and publishing instructions. Publish the complete directory to any static web host, including a subdirectory. For a local preview: @@ -33,7 +85,7 @@ Open `http://localhost:8080`. The game starts automatically with no engine-added Use a recent browser with WebAssembly exception handling and WebGL 2. The generated player reports missing/corrupted files, unsupported graphics and runtime initialization failures. Game files are verified before scripts execute. Browser file writes use the runtime's in-memory filesystem and disappear on reload. Native microphone capture and native audio-device selection are unavailable; ordinary game audio uses Web Audio. -**Script compatibility:** direct web and Android exports currently support Lua 5.4, JavaScript and HTML/CSS behaviors. Scripts that need external desktop processes (including Luau, Python, C/C++, C#, Java and PHP) cause an explicit build error. Standalone PHP pages also cause an error. They are not silently omitted or converted. Use supported behaviors for these targets. +**Static script compatibility:** static web and Android exports currently support Lua 5.4, JavaScript and HTML/CSS behaviors. Scripts that need external desktop processes (including Luau, Python, C/C++, C#, Java and PHP) cause an explicit build error. Standalone PHP pages also cause an error. They are not silently omitted or converted. Use supported behaviors for these targets, or choose the hosted website mode for all scripting languages. Installed engine distributions bundle `browser-runtime/` beside the editor. Game authors do not need Emscripten to export when those resources are installed. Source developers must build that runtime once with the setup below. @@ -57,7 +109,7 @@ RUSTIC_ANDROID_KEY_PASSWORD= The generated build reads signing credentials from the environment rather than writing them into the project. Keep the keystore outside game assets. Without release signing configuration, an AAB can be produced unsigned; store publication still requires the appropriate signing and testing steps. -Install an APK with `adb install /artifacts/app-0.apk`. An AAB is a publishing artifact, not directly installable with adb. The app requires Android 8.0/API 26 or newer and an updated Android System WebView supporting WebGL 2 and WebAssembly exception handling. The same script/API compatibility limits as website builds apply. +Install an APK with `adb install /artifacts/app-0.apk`. An AAB is a publishing artifact, not directly installable with adb. The app requires Android 8.0/API 26 or newer and an updated Android System WebView supporting WebGL 2 and WebAssembly exception handling. The same script/API compatibility limits as static website builds apply. ## Build browser resources from engine source @@ -83,11 +135,11 @@ The test exports a real scene and checks rendering, Lua/JavaScript startup, keyb ## Troubleshooting -- **Build is disabled:** check the Required messages. Read-only editors cannot build; close the other writable editor and reopen the project. Windows packaging requires a Windows host and its bundled runtime. Inno Setup is configured automatically only when creating setup; a missing default compiler does not disable the build. Web and Android exports require the engine's compiled `browser-runtime/` resources. +- **Build is disabled:** check the Required messages. Read-only editors cannot build; close the other writable editor and reopen the project. Windows packaging requires a Windows host and its bundled runtime. Inno Setup is configured automatically only when creating setup; a missing default compiler does not disable the build. Static website and Android exports require the engine's compiled `browser-runtime/` resources; hosted websites require the native runtime. - **Inno Setup configuration failed:** check the displayed error, internet access to GitHub releases, and permission to install in your local Programs folder and update your user PATH, then retry **Build Project**. A failed signature check stops installation. You can also install Inno Setup 6 manually and enter its existing absolute `ISCC.exe` path. Invalid custom paths are reported in **Required**; reset the field to `ISCC.exe` for automatic setup. Inspect compiler failures for invalid version or image resources. - **Browser runtime missing:** install a distribution that includes `browser-runtime/` or build those engine resources from source. There is no project `web/` source folder to configure. - **Website fails to load:** serve the whole generated `website/` directory over HTTP/HTTPS, check browser WebGL 2/WebAssembly support, and inspect the displayed error and browser console. -- **Unsupported script:** replace external-toolchain behaviors with supported Lua 5.4/JavaScript/HTML behaviors for direct web/Android exports. +- **Unsupported script:** select Hosted runtime for a website using external languages; static web/Android builds require Lua 5.4/JavaScript/HTML behaviors. - **Resource path rejected:** use a path inside the project without `..` or absolute components. Remove symbolic links from packaged game inputs. Outputs cannot be nested in their input. - **Gradle fails:** the dialog includes its exit status and output. Check JDK/SDK compatibility, required platform/build-tools packages, SDK licenses, network access for Gradle dependencies, and release signing environment variables. - **Game fails to launch:** distribute the whole `game/` folder or run setup; do not move only the executable. Restore modified/missing data files, check game logs, graphics drivers, and external language toolchains. diff --git a/Engine/tools/browser-tests/hosted-player.test.mjs b/Engine/tools/browser-tests/hosted-player.test.mjs new file mode 100644 index 00000000..4327b843 --- /dev/null +++ b/Engine/tools/browser-tests/hosted-player.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { after, before, test } from "node:test"; +import { chromium } from "playwright"; + +const source = new URL("../../apps/runtime/src/website/", import.meta.url); +let browser, png; +before(async () => { + browser = await chromium.launch({ executablePath: process.env.RUSTIC_CHROMIUM || undefined, headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"] }); + const page = await browser.newPage(); + png = Buffer.from(await page.evaluate(() => { + const canvas = document.createElement("canvas"); + canvas.width = 640; canvas.height = 360; + const context = canvas.getContext("2d"); + context.fillStyle = "red"; context.fillRect(0, 0, 640, 360); + return [...Uint8Array.from(atob(canvas.toDataURL().split(",")[1]), c => c.charCodeAt(0))]; + })); + await page.close(); +}); +after(async () => { await browser?.close(); }); + +async function openPlayer(t, { error, invalidFrame = false } = {}) { + const context = await browser.newContext({ hasTouch: true }); + t.after(() => context.close()); + const page = await context.newPage(); + const inputs = [], errors = []; + page.on("pageerror", error => errors.push(error.message)); + t.after(() => assert.deepEqual(errors, [])); + await page.route("http://game.test/**", async route => { + const path = new URL(route.request().url()).pathname; + if (path === "/" || path === "/player.js") { + await route.fulfill({ contentType: path === "/" ? "text/html" : "text/javascript", body: await readFile(new URL(path === "/" ? "player.html" : "player.js", source)) }); + } else if (path === "/game-info.json") await route.fulfill({ json: { name: "Hosted Game" } }); + else await route.fulfill({ status: 404 }); + }); + await page.routeWebSocket("ws://game.test/play", socket => { + socket.onMessage(message => { + inputs.push(JSON.parse(message)); + if (error) { socket.send(JSON.stringify({ error })); return; } + if (invalidFrame) { socket.send(Buffer.alloc(8)); return; } + const frame = Buffer.alloc(16 + png.length); + frame.writeUInt32LE(640, 0); frame.writeUInt32LE(360, 4); + frame.writeUInt32LE(png.length, 8); frame.writeUInt32LE(0, 12); + png.copy(frame, 16); + socket.send(frame); + }); + }); + await page.goto("http://game.test/"); + return { page, inputs }; +} + +test("hosted game renders PNG frames, forwards input and starts audio on a gesture", async t => { + const { page, inputs } = await openPlayer(t); + await page.waitForFunction(() => document.querySelector("canvas").getContext("2d").getImageData(0, 0, 1, 1).data[0] === 255); + assert.equal(await page.title(), "Hosted Game"); + assert.equal(await page.locator("#status").isVisible(), false); + const canvas = page.locator("#game"); + await canvas.focus(); + await page.keyboard.down("KeyD"); + await page.waitForTimeout(100); + await page.keyboard.up("KeyD"); + assert.ok(inputs.some(input => input.keys.includes("KeyD"))); + await page.touchscreen.tap(300, 200); + await page.waitForTimeout(100); + assert.ok(inputs.some(input => input.pointers.some(pointer => pointer.device === "touch" && pointer.phase === "start"))); + await page.waitForFunction(() => audio?.state === "running"); + await page.evaluate(() => window.dispatchEvent(new Event("blur"))); + await page.waitForTimeout(100); + assert.ok(inputs.some(input => input.reset && input.keys.length === 0)); +}); + +test("native startup failures appear in the player", async t => { + const { page } = await openPlayer(t, { error: "Python toolchain is unavailable" }); + await page.locator("#status").waitFor({ state: "visible" }); + assert.match(await page.locator("#status").textContent(), /Python toolchain is unavailable/); +}); + +test("truncated transport frames fail visibly", async t => { + const { page } = await openPlayer(t, { invalidFrame: true }); + await page.locator("#status").waitFor({ state: "visible" }); + assert.match(await page.locator("#status").textContent(), /Incomplete game frame/); +}); diff --git a/Engine/tools/browser-tests/package.json b/Engine/tools/browser-tests/package.json index 71038f30..8b79edc1 100644 --- a/Engine/tools/browser-tests/package.json +++ b/Engine/tools/browser-tests/package.json @@ -2,6 +2,6 @@ "name": "rustic-browser-export-tests", "private": true, "type": "module", - "scripts": { "test": "npm run test:player && node test.mjs", "test:player": "node --test player.test.mjs" }, + "scripts": { "test": "npm run test:player && node test.mjs", "test:player": "node --test player.test.mjs hosted-player.test.mjs" }, "devDependencies": { "playwright": "1.64.0" } }