diff --git a/crates/horizon-client/assets/rustui/icons/LICENSE.txt b/crates/horizon-client/assets/rustui/icons/LICENSE.txt new file mode 100644 index 0000000..d0c3736 --- /dev/null +++ b/crates/horizon-client/assets/rustui/icons/LICENSE.txt @@ -0,0 +1,24 @@ +The icons in this folder are outline icons from theSVG UI (https://thesvg.org/ui), +rendered at 64 x 64 with cairosvg. + +MIT License + +Copyright (c) theSVG contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/horizon-client/assets/rustui/icons/activity.png b/crates/horizon-client/assets/rustui/icons/activity.png new file mode 100644 index 0000000..2201366 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/activity.png differ diff --git a/crates/horizon-client/assets/rustui/icons/arrow-left.png b/crates/horizon-client/assets/rustui/icons/arrow-left.png new file mode 100644 index 0000000..9c797ad Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/arrow-left.png differ diff --git a/crates/horizon-client/assets/rustui/icons/arrow-right.png b/crates/horizon-client/assets/rustui/icons/arrow-right.png new file mode 100644 index 0000000..dd25521 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/arrow-right.png differ diff --git a/crates/horizon-client/assets/rustui/icons/award.png b/crates/horizon-client/assets/rustui/icons/award.png new file mode 100644 index 0000000..3d72ed5 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/award.png differ diff --git a/crates/horizon-client/assets/rustui/icons/bell.png b/crates/horizon-client/assets/rustui/icons/bell.png new file mode 100644 index 0000000..c9ecce2 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/bell.png differ diff --git a/crates/horizon-client/assets/rustui/icons/bot.png b/crates/horizon-client/assets/rustui/icons/bot.png new file mode 100644 index 0000000..5a23f76 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/bot.png differ diff --git a/crates/horizon-client/assets/rustui/icons/boxes.png b/crates/horizon-client/assets/rustui/icons/boxes.png new file mode 100644 index 0000000..28f7410 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/boxes.png differ diff --git a/crates/horizon-client/assets/rustui/icons/chat-thread.png b/crates/horizon-client/assets/rustui/icons/chat-thread.png new file mode 100644 index 0000000..02280b5 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/chat-thread.png differ diff --git a/crates/horizon-client/assets/rustui/icons/check.png b/crates/horizon-client/assets/rustui/icons/check.png new file mode 100644 index 0000000..b80d935 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/check.png differ diff --git a/crates/horizon-client/assets/rustui/icons/chevron-down.png b/crates/horizon-client/assets/rustui/icons/chevron-down.png new file mode 100644 index 0000000..4bd8340 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/chevron-down.png differ diff --git a/crates/horizon-client/assets/rustui/icons/chevron-right.png b/crates/horizon-client/assets/rustui/icons/chevron-right.png new file mode 100644 index 0000000..31a42a7 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/chevron-right.png differ diff --git a/crates/horizon-client/assets/rustui/icons/circle-alert.png b/crates/horizon-client/assets/rustui/icons/circle-alert.png new file mode 100644 index 0000000..77d6101 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/circle-alert.png differ diff --git a/crates/horizon-client/assets/rustui/icons/external-link.png b/crates/horizon-client/assets/rustui/icons/external-link.png new file mode 100644 index 0000000..a428427 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/external-link.png differ diff --git a/crates/horizon-client/assets/rustui/icons/eye.png b/crates/horizon-client/assets/rustui/icons/eye.png new file mode 100644 index 0000000..831d4e5 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/eye.png differ diff --git a/crates/horizon-client/assets/rustui/icons/globe.png b/crates/horizon-client/assets/rustui/icons/globe.png new file mode 100644 index 0000000..1735c99 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/globe.png differ diff --git a/crates/horizon-client/assets/rustui/icons/hand.png b/crates/horizon-client/assets/rustui/icons/hand.png new file mode 100644 index 0000000..3e00981 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/hand.png differ diff --git a/crates/horizon-client/assets/rustui/icons/headphones.png b/crates/horizon-client/assets/rustui/icons/headphones.png new file mode 100644 index 0000000..35ccbe0 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/headphones.png differ diff --git a/crates/horizon-client/assets/rustui/icons/house.png b/crates/horizon-client/assets/rustui/icons/house.png new file mode 100644 index 0000000..6c1c6e9 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/house.png differ diff --git a/crates/horizon-client/assets/rustui/icons/info.png b/crates/horizon-client/assets/rustui/icons/info.png new file mode 100644 index 0000000..6c26485 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/info.png differ diff --git a/crates/horizon-client/assets/rustui/icons/keyboard.png b/crates/horizon-client/assets/rustui/icons/keyboard.png new file mode 100644 index 0000000..2fab823 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/keyboard.png differ diff --git a/crates/horizon-client/assets/rustui/icons/layers.png b/crates/horizon-client/assets/rustui/icons/layers.png new file mode 100644 index 0000000..b37134c Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/layers.png differ diff --git a/crates/horizon-client/assets/rustui/icons/list.png b/crates/horizon-client/assets/rustui/icons/list.png new file mode 100644 index 0000000..2a9d2d7 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/list.png differ diff --git a/crates/horizon-client/assets/rustui/icons/log-out.png b/crates/horizon-client/assets/rustui/icons/log-out.png new file mode 100644 index 0000000..984165d Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/log-out.png differ diff --git a/crates/horizon-client/assets/rustui/icons/message-square.png b/crates/horizon-client/assets/rustui/icons/message-square.png new file mode 100644 index 0000000..8bc272a Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/message-square.png differ diff --git a/crates/horizon-client/assets/rustui/icons/monitor.png b/crates/horizon-client/assets/rustui/icons/monitor.png new file mode 100644 index 0000000..2f9e064 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/monitor.png differ diff --git a/crates/horizon-client/assets/rustui/icons/mouse-pointer-click.png b/crates/horizon-client/assets/rustui/icons/mouse-pointer-click.png new file mode 100644 index 0000000..9dd2f55 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/mouse-pointer-click.png differ diff --git a/crates/horizon-client/assets/rustui/icons/newspaper.png b/crates/horizon-client/assets/rustui/icons/newspaper.png new file mode 100644 index 0000000..8a58291 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/newspaper.png differ diff --git a/crates/horizon-client/assets/rustui/icons/package.png b/crates/horizon-client/assets/rustui/icons/package.png new file mode 100644 index 0000000..79fdf39 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/package.png differ diff --git a/crates/horizon-client/assets/rustui/icons/pin.png b/crates/horizon-client/assets/rustui/icons/pin.png new file mode 100644 index 0000000..01aca39 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/pin.png differ diff --git a/crates/horizon-client/assets/rustui/icons/play.png b/crates/horizon-client/assets/rustui/icons/play.png new file mode 100644 index 0000000..be8fc51 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/play.png differ diff --git a/crates/horizon-client/assets/rustui/icons/plus.png b/crates/horizon-client/assets/rustui/icons/plus.png new file mode 100644 index 0000000..7ef73bd Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/plus.png differ diff --git a/crates/horizon-client/assets/rustui/icons/refresh-cw.png b/crates/horizon-client/assets/rustui/icons/refresh-cw.png new file mode 100644 index 0000000..6560e0c Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/refresh-cw.png differ diff --git a/crates/horizon-client/assets/rustui/icons/search.png b/crates/horizon-client/assets/rustui/icons/search.png new file mode 100644 index 0000000..63fa1c2 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/search.png differ diff --git a/crates/horizon-client/assets/rustui/icons/server.png b/crates/horizon-client/assets/rustui/icons/server.png new file mode 100644 index 0000000..64b2c0e Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/server.png differ diff --git a/crates/horizon-client/assets/rustui/icons/settings-2.png b/crates/horizon-client/assets/rustui/icons/settings-2.png new file mode 100644 index 0000000..5d1a837 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/settings-2.png differ diff --git a/crates/horizon-client/assets/rustui/icons/settings.png b/crates/horizon-client/assets/rustui/icons/settings.png new file mode 100644 index 0000000..ec51a87 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/settings.png differ diff --git a/crates/horizon-client/assets/rustui/icons/shield.png b/crates/horizon-client/assets/rustui/icons/shield.png new file mode 100644 index 0000000..a956615 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/shield.png differ diff --git a/crates/horizon-client/assets/rustui/icons/shopping-cart.png b/crates/horizon-client/assets/rustui/icons/shopping-cart.png new file mode 100644 index 0000000..32860a1 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/shopping-cart.png differ diff --git a/crates/horizon-client/assets/rustui/icons/sliders-horizontal.png b/crates/horizon-client/assets/rustui/icons/sliders-horizontal.png new file mode 100644 index 0000000..0f1f66c Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/sliders-horizontal.png differ diff --git a/crates/horizon-client/assets/rustui/icons/sparkles.png b/crates/horizon-client/assets/rustui/icons/sparkles.png new file mode 100644 index 0000000..473ec96 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/sparkles.png differ diff --git a/crates/horizon-client/assets/rustui/icons/star.png b/crates/horizon-client/assets/rustui/icons/star.png new file mode 100644 index 0000000..5544bdf Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/star.png differ diff --git a/crates/horizon-client/assets/rustui/icons/trash.png b/crates/horizon-client/assets/rustui/icons/trash.png new file mode 100644 index 0000000..6f03eb9 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/trash.png differ diff --git a/crates/horizon-client/assets/rustui/icons/type.png b/crates/horizon-client/assets/rustui/icons/type.png new file mode 100644 index 0000000..258e6e5 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/type.png differ diff --git a/crates/horizon-client/assets/rustui/icons/user.png b/crates/horizon-client/assets/rustui/icons/user.png new file mode 100644 index 0000000..8deb875 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/user.png differ diff --git a/crates/horizon-client/assets/rustui/icons/users.png b/crates/horizon-client/assets/rustui/icons/users.png new file mode 100644 index 0000000..cc502da Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/users.png differ diff --git a/crates/horizon-client/assets/rustui/icons/wand.png b/crates/horizon-client/assets/rustui/icons/wand.png new file mode 100644 index 0000000..874d16f Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/wand.png differ diff --git a/crates/horizon-client/assets/rustui/icons/wrench.png b/crates/horizon-client/assets/rustui/icons/wrench.png new file mode 100644 index 0000000..679254c Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/wrench.png differ diff --git a/crates/horizon-client/assets/rustui/icons/x.png b/crates/horizon-client/assets/rustui/icons/x.png new file mode 100644 index 0000000..b765f58 Binary files /dev/null and b/crates/horizon-client/assets/rustui/icons/x.png differ diff --git a/crates/horizon-client/src/assets.rs b/crates/horizon-client/src/assets.rs index 63c237c..4d217a8 100644 --- a/crates/horizon-client/src/assets.rs +++ b/crates/horizon-client/src/assets.rs @@ -106,7 +106,7 @@ impl Assets { root.join("horizon-resources.json"), )?))?; if manifest["data_version"].as_i64() != Some(i64::from(DATA_VERSION)) { - return Err(invalid("resources do not match Horizon's pinned data version").into()); + return Err(invalid("resources do not match RustNotMC's pinned data version").into()); } let (registry, flags) = BlockRegistry::from_facts_json_with_flags(File::open(root.join("facts.json"))?)?; diff --git a/crates/horizon-client/src/audio.rs b/crates/horizon-client/src/audio.rs index e45362e..3ded5a8 100644 --- a/crates/horizon-client/src/audio.rs +++ b/crates/horizon-client/src/audio.rs @@ -538,7 +538,7 @@ fn build_stream>( *out = T::from_sample(*s); } }, - |e| log::warn!("Horizon: audio stream error: {e}"), + |e| log::warn!("RustNotMC: audio stream error: {e}"), None, )?; Ok(stream) @@ -556,7 +556,7 @@ impl Audio { if output && registry.is_empty() { // The game then plays nothing on a working output (#1035, #1076). log::warn!( - "Horizon: no sounds in {}: they were not copied from your Minecraft \ + "RustNotMC: no sounds in {}: they were not copied from your Minecraft \ install; prepare the game again in the launcher (or run \ horizon-prepare sounds)", root.join("assets").display() @@ -572,7 +572,7 @@ impl Audio { Ok(v) => (Some(v), true), Err(e) => { if output { - log::warn!("Horizon: no sound: {e}"); + log::warn!("RustNotMC: no sound: {e}"); } (None, false) } @@ -627,7 +627,7 @@ impl Audio { match open_device(&device, mixer.clone()) { Ok((stream, config)) => { log::info!( - "Horizon: audio output {name} ({} Hz, {} channels, {})", + "RustNotMC: audio output {name} ({} Hz, {} channels, {})", config.sample_rate().0, config.channels(), config.sample_format() diff --git a/crates/horizon-client/src/auth.rs b/crates/horizon-client/src/auth.rs index a869927..d3ad24d 100644 --- a/crates/horizon-client/src/auth.rs +++ b/crates/horizon-client/src/auth.rs @@ -174,7 +174,7 @@ pub fn discovery_url() -> String { fn agent() -> ureq::Agent { ureq::AgentBuilder::new() .timeout(Duration::from_secs(15)) - .user_agent(&format!("Horizon/{}", horizon_core::release::VERSION)) + .user_agent(&format!("RustNotMC/{}", horizon_core::release::VERSION)) .build() } diff --git a/crates/horizon-client/src/benchmark.rs b/crates/horizon-client/src/benchmark.rs index bd2e97b..583ea3c 100644 --- a/crates/horizon-client/src/benchmark.rs +++ b/crates/horizon-client/src/benchmark.rs @@ -82,8 +82,8 @@ pub fn start(requests: Sender, setup: Setup) { .spawn(move || { let control = Control(requests); match run(&control, setup) { - Ok(path) => println!("Horizon: benchmark report {}", path.display()), - Err(e) => eprintln!("Horizon: benchmark failed: {e}"), + Ok(path) => println!("RustNotMC: benchmark report {}", path.display()), + Err(e) => eprintln!("RustNotMC: benchmark failed: {e}"), } let _ = control.call("quit"); }) diff --git a/crates/horizon-client/src/crafting.rs b/crates/horizon-client/src/crafting.rs index 899ce90..2bfc27b 100644 --- a/crates/horizon-client/src/crafting.rs +++ b/crates/horizon-client/src/crafting.rs @@ -1212,7 +1212,7 @@ impl Recipes { let v: Value = match serde_json::from_slice(&fs::read(&path)?) { Ok(v) => v, Err(e) => { - log::warn!("Horizon: recipe {name} not loaded: {e}"); + log::warn!("RustNotMC: recipe {name} not loaded: {e}"); continue; } }; diff --git a/crates/horizon-client/src/crash.rs b/crates/horizon-client/src/crash.rs index 5490808..e438114 100644 --- a/crates/horizon-client/src/crash.rs +++ b/crates/horizon-client/src/crash.rs @@ -68,7 +68,7 @@ impl Report { pub fn text(&self) -> String { let comment = COMMENTS[self.time.timestamp().rem_euclid(COMMENTS.len() as i64) as usize]; let mut out = String::new(); - let _ = writeln!(out, "---- Horizon Crash Report ----"); + let _ = writeln!(out, "---- RustNotMC Crash Report ----"); let _ = writeln!(out, "// {comment}\n"); let _ = writeln!(out, "Time: {}", self.time.format("%Y-%m-%d %H:%M:%S")); let _ = writeln!(out, "Description: {}\n", self.description); @@ -253,7 +253,7 @@ fn memory() -> String { /// The report's details: Horizon's and the machine's, then what the game set as it ran. pub fn system_details() -> Vec<(String, String)> { let mut out = vec![ - ("Horizon Version".to_owned(), version()), + ("RustNotMC Version".to_owned(), version()), ( "Minecraft Version".to_owned(), horizon_core::VANILLA_VERSION.to_owned(), @@ -283,7 +283,7 @@ fn deliver(home: &Path, report: Report) -> Option { crate::logging::write_only( log::Level::Error, &format!( - "Horizon crashed: {}\n{}", + "RustNotMC crashed: {}\n{}", report.error, report .stack @@ -297,11 +297,11 @@ fn deliver(home: &Path, report: Report) -> Option { crate::telemetry::keep_crash(home, &report); match report.save(home) { Ok(path) => { - eprintln!("Horizon: crash report saved to {}", path.display()); + eprintln!("RustNotMC: crash report saved to {}", path.display()); Some(path) } Err(e) => { - eprintln!("Horizon: could not save a crash report: {e}"); + eprintln!("RustNotMC: could not save a crash report: {e}"); eprintln!("{}", report.text()); None } @@ -556,7 +556,7 @@ mod tests { thread: "Render thread".into(), side: "client", details: vec![ - ("Horizon Version".into(), "0.1.0".into()), + ("RustNotMC Version".into(), "0.1.0".into()), ("Minecraft Version".into(), "26.3".into()), ("Graphics".into(), "Test GPU (Vulkan), driver 1.0".into()), ], @@ -576,7 +576,7 @@ mod tests { fn the_report_reads_like_vanillas() { let text = report().text(); let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines[0], "---- Horizon Crash Report ----"); + assert_eq!(lines[0], "---- RustNotMC Crash Report ----"); assert!(lines[1].starts_with("// ")); assert_eq!(lines[3], "Time: 2026-10-14 09:05:07"); assert_eq!(lines[4], "Description: Unexpected error"); @@ -588,7 +588,7 @@ mod tests { ); assert!(text.contains("-- Head --\nThread: Render thread\n")); assert!(text.contains( - "-- System Details --\nDetails:\n\tHorizon Version: 0.1.0\n\tMinecraft Version: 26.3\n\tGraphics: Test GPU (Vulkan), driver 1.0\n" + "-- System Details --\nDetails:\n\tRustNotMC Version: 0.1.0\n\tMinecraft Version: 26.3\n\tGraphics: Test GPU (Vulkan), driver 1.0\n" )); assert!(text.ends_with( "-- Last 1 log lines --\n[09:05:06] [Render thread/INFO]: load assets: 1.20s\n" diff --git a/crates/horizon-client/src/discord_presence.rs b/crates/horizon-client/src/discord_presence.rs index 5f8cc58..e006829 100644 --- a/crates/horizon-client/src/discord_presence.rs +++ b/crates/horizon-client/src/discord_presence.rs @@ -16,7 +16,7 @@ use serde_json::{Value, json}; use crate::command::args::GameMode; -/// The "Horizon" application on Discord's developer portal (public: it names the game). Its +/// The "RustNotMC" application on Discord's developer portal (public: it names the game). Its /// Rich Presence assets: `horizon`, the logo. pub const DISCORD_APP_ID: &str = "1563857609738690560"; /// Whether the application has the game-mode images (`survival`, `creative`, `adventure`, @@ -129,7 +129,7 @@ pub fn start(enabled: bool, automated: bool) { Ok(handle) => { let _ = THREAD.set(handle.thread().clone()); } - Err(e) => log::warn!("Horizon: no Discord presence: {e}"), + Err(e) => log::warn!("RustNotMC: no Discord presence: {e}"), } } @@ -216,7 +216,7 @@ fn run() { match c.set_activity(activity(wanted.status, wanted.since)) { Ok(()) => sent = Some((wanted, now)), Err(e) => { - log::info!("Horizon: Discord presence disconnected ({e})"); + log::info!("RustNotMC: Discord presence disconnected ({e})"); client = None; sent = None; } @@ -226,7 +226,7 @@ fn run() { /// The activity Discord shows for `status`; `None` clears it. fn activity(status: Status, since: Option) -> Option { - let mut assets = json!({"large_image": "horizon", "large_text": "Horizon"}); + let mut assets = json!({"large_image": "horizon", "large_text": "RustNotMC"}); let (details, state, party) = match status { Status::Off => return None, Status::Menus => ("In the menus", None, None), @@ -338,11 +338,11 @@ impl Client { }; match client.handshake() { Ok(()) => { - log::info!("Horizon: connected to Discord for Rich Presence"); + log::info!("RustNotMC: connected to Discord for Rich Presence"); Some(client) } Err(e) => { - log::debug!("Horizon: Discord at {} refused: {e}", path.display()); + log::debug!("RustNotMC: Discord at {} refused: {e}", path.display()); None } } @@ -370,7 +370,7 @@ impl Client { match read_frame(&mut self.pipe)? { (FRAME, frame) if ours(&frame) => { if frame["evt"] == "ERROR" { - log::info!("Horizon: Discord presence: {}", frame["data"]["message"]); + log::info!("RustNotMC: Discord presence: {}", frame["data"]["message"]); } return Ok(()); } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index dddd4f5..2673e13 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -390,7 +390,7 @@ impl Options { match arg.as_str() { "--help" | "-h" => { println!( - "Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.", + "RustNotMC — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.", horizon_core::VANILLA_VERSION ); return Ok(None); @@ -495,7 +495,7 @@ impl Options { o.player_name = Some(session.name.clone()); horizon_client::auth::set_current(session); } - Err(e) => eprintln!("Horizon: not signed in: {e}"), + Err(e) => eprintln!("RustNotMC: not signed in: {e}"), } } Ok(Some(o)) @@ -12008,6 +12008,8 @@ impl Game<'_> { || self.title.as_ref().is_some_and(|t| { matches!(self.screen, Screen::Options | Screen::Win) || !matches!(t.mode, horizon_client::title::Mode::Main) + // The Rust menu is translucent over the scene: it blurs it. + || (t.rust_menu && matches!(t.mode, horizon_client::title::Mode::Main)) }) || (self.title.is_none() && self.player.is_some() @@ -12091,6 +12093,7 @@ impl Game<'_> { self.close_container(); self.screen = Screen::Playing; self.pause_menu = true; + self.ui.pause = None; self.capture(true); } Screen::GameModeSwitcher => self.close_gamemode_switcher(false), @@ -12373,7 +12376,7 @@ impl Game<'_> { } fn update_world(&mut self) { if let Err(e) = self.poll_remote() { - log::warn!("Horizon: {e}"); + log::warn!("RustNotMC: {e}"); } self.prepare_lod(); if self.lod.is_some() { @@ -12445,7 +12448,7 @@ impl Game<'_> { .save_later(&modified, self.pack.assets.registry, self.res.biomes()) }) { - log::warn!("Horizon: failed to save unloaded chunks: {e}"); + log::warn!("RustNotMC: failed to save unloaded chunks: {e}"); } let (far_distance, far_reduced) = self.far_terrain_choice(); if let Some(renderer) = self.renderer.as_mut() { @@ -12552,7 +12555,7 @@ impl ApplicationHandler for Game<'_> { let size = self.options.window_size.map(f64::from); self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen); let attributes = Window::default_attributes() - .with_title("Horizon") + .with_title("RustNotMC") .with_window_icon(horizon_client::window::icon()); // Wayland names the window by its app id, which the horizon.desktop entry matches. #[cfg(target_os = "linux")] @@ -13402,7 +13405,7 @@ impl ApplicationHandler for Game<'_> { format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0) }); log::info!( - "Horizon: slow frame {cpu:.1} ms{worked} ({steps} ticks): {}", + "RustNotMC: slow frame {cpu:.1} ms{worked} ({steps} ticks): {}", horizon_client::spans::describe(&spans) ); } @@ -13466,7 +13469,7 @@ impl ApplicationHandler for Game<'_> { if self.frames == 0 { // The launcher times Play to this line (launcher/src/perf.rs). log::info!( - "Horizon: first frame after {} ms", + "RustNotMC: first frame after {} ms", self.started.elapsed().as_millis() ); } @@ -13580,7 +13583,7 @@ impl ApplicationHandler for Game<'_> { fn exiting(&mut self, _event_loop: &ActiveEventLoop) { self.pad_close_output(); if let Err(e) = self.save_for_exit() { - log::warn!("Horizon: failed to save the world on exit: {e}"); + log::warn!("RustNotMC: failed to save the world on exit: {e}"); } self.finish_perf(); } @@ -14121,7 +14124,7 @@ impl Game<'_> { )); } self.pending_entry = Some(entry); - log::info!("Horizon: world entry waiting for world resources"); + log::info!("RustNotMC: world entry waiting for world resources"); Ok(true) } @@ -14195,7 +14198,7 @@ impl Game<'_> { fn join_world_rest(&mut self) -> Result<()> { if self.rest_loader.is_some() { let waited = Instant::now(); - log::info!("Horizon: waiting for world resources"); + log::info!("RustNotMC: waiting for world resources"); while self.rest_loader.is_some() { self.poll_world_rest()?; if self.rest_loader.is_some() { @@ -14203,7 +14206,7 @@ impl Game<'_> { } } log::info!( - "Horizon: world resources joined after {} ms", + "RustNotMC: world resources joined after {} ms", waited.elapsed().as_millis() ); } @@ -14319,7 +14322,7 @@ fn load_resources( // Minecraft.rollbackResourcePacks: the packs' textures don't fit, start // with the game's own resources. Err(e) if root != vanilla_root => { - log::warn!("Horizon: resource packs failed to load ({e}); using the default resources"); + log::warn!("RustNotMC: resource packs failed to load ({e}); using the default resources"); pack_error = Some(e.to_string()); root = vanilla_root.to_path_buf(); None @@ -14330,7 +14333,7 @@ fn load_resources( if menu { horizon_client::telemetry::mark_ui_ready(); log::info!( - "Horizon: UI bundle ready after {} ms", + "RustNotMC: UI bundle ready after {} ms", started.elapsed().as_millis() ); publish(ui, &root); @@ -14348,7 +14351,7 @@ fn load_resources( // Minecraft.rollbackResourcePacks: the packs break loading, start // with the game's own resources. Err(e) if root != vanilla_root => { - log::warn!("Horizon: resource packs failed to load ({e}); using the default resources"); + log::warn!("RustNotMC: resource packs failed to load ({e}); using the default resources"); pack_error = Some(e.to_string()); root = vanilla_root.to_path_buf(); let assets = Assets::load(vanilla_root)?; @@ -14370,7 +14373,7 @@ fn load_resources( if menu { horizon_client::telemetry::mark_ui_ready(); log::info!( - "Horizon: UI bundle ready after {} ms", + "RustNotMC: UI bundle ready after {} ms", started.elapsed().as_millis() ); publish(ui, &root); @@ -14448,7 +14451,7 @@ fn load_resources( if menu { horizon_client::telemetry::mark_core_ready(); log::info!( - "Horizon: core-ready after {} ms", + "RustNotMC: core-ready after {} ms", started.elapsed().as_millis() ); } @@ -14459,7 +14462,7 @@ fn load_resources( let renderer_cpu = menu.then(|| prepare_renderer_textures(&pack)); if menu { log::info!( - "Horizon: handoff CPU resources prepared on worker in {} ms", + "RustNotMC: handoff CPU resources prepared on worker in {} ms", prepared_at.elapsed().as_millis() ); } @@ -14488,7 +14491,7 @@ fn load_resources( progress.set(9); horizon_client::telemetry::mark_rest_ready(); log::info!( - "Horizon: rest-ready after {} ms", + "RustNotMC: rest-ready after {} ms", started.elapsed().as_millis() ); Ok(()) @@ -14548,7 +14551,7 @@ fn load_world_rest( let recipes = Recipes::load(root, &items)?; let transformers = horizon_client::block_transform::Transformers::load(root, assets.registry)?; log::info!( - "Horizon: resources loaded in {:.1}s; {} block states, {} items, {} biomes; {} worker threads", + "RustNotMC: resources loaded in {:.1}s; {} block states, {} items, {} biomes; {} worker threads", started.elapsed().as_secs_f32(), facts.states.len(), items.items.len(), @@ -14984,7 +14987,7 @@ impl Loading { .map(f64::from); self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen); let attributes = Window::default_attributes() - .with_title("Horizon") + .with_title("RustNotMC") .with_window_icon(horizon_client::window::icon()); // Wayland names the window by its app id, which the horizon.desktop entry matches. #[cfg(target_os = "linux")] @@ -15035,7 +15038,7 @@ impl Loading { /// and title panorama. fn set_ui(&mut self, gui: UiResources, root: PathBuf) { log::info!( - "Horizon: loading the title artwork after {} ms", + "RustNotMC: loading the title artwork after {} ms", self.started.elapsed().as_millis() ); self.menu.set_root(root, &self.settings); @@ -15104,13 +15107,13 @@ impl Loading { self.menu.perf.title_shown() } Ok(_) => {} - Err(e) => log::warn!("Horizon: loading screen frame failed: {e}"), + Err(e) => log::warn!("RustNotMC: loading screen frame failed: {e}"), } } if !self.shown { self.shown = true; log::info!( - "Horizon: loading screen shown after {} ms", + "RustNotMC: loading screen shown after {} ms", self.started.elapsed().as_millis() ); } @@ -15139,7 +15142,7 @@ impl Loading { self.settings.horizon.api, )?; log::info!( - "Horizon: world renderer ready after {} ms", + "RustNotMC: world renderer ready after {} ms", self.started.elapsed().as_millis() ); let renderer_ms = renderer_at.elapsed().as_millis(); @@ -15180,7 +15183,7 @@ impl Loading { game.apply_settings(); game.previous = Instant::now(); log::info!( - "Horizon: startup handoff {} ms (renderer {} ms, start_game {} ms)", + "RustNotMC: startup handoff {} ms (renderer {} ms, start_game {} ms)", handoff_at.elapsed().as_millis(), renderer_ms, game_ms @@ -15370,7 +15373,7 @@ pub fn run() -> Result<()> { let mut pack_error = None; options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs, &[]) .unwrap_or_else(|e| { - log::warn!("Horizon: resource packs not applied: {e}"); + log::warn!("RustNotMC: resource packs not applied: {e}"); pack_error = Some(e.to_string()); vanilla_root.clone() }); diff --git a/crates/horizon-client/src/game/agent_settings.rs b/crates/horizon-client/src/game/agent_settings.rs index 5045f1c..219992f 100644 --- a/crates/horizon-client/src/game/agent_settings.rs +++ b/crates/horizon-client/src/game/agent_settings.rs @@ -25,7 +25,7 @@ impl Game<'_> { if self.settings.extras.no_ai { if self.options.control.is_some() { log::warn!( - "Horizon: control socket not opened: {}", + "RustNotMC: control socket not opened: {}", horizon_core::no_ai::REFUSAL ); } @@ -35,7 +35,7 @@ impl Game<'_> { // Asked for on the command line: failing to listen is an error. let server = control::Server::start(path) .map_err(|e| format!("control socket {}: {e}", path.display()))?; - log::info!("Horizon: control socket {}", server.path().display()); + log::info!("RustNotMC: control socket {}", server.path().display()); self.control = Some(server); } else if self.agent_control_on { self.open_agent_socket(); @@ -49,10 +49,10 @@ impl Game<'_> { let path = self.agent_socket_path(); match control::Server::start(&path) { Ok(server) => { - log::info!("Horizon: control socket {}", server.path().display()); + log::info!("RustNotMC: control socket {}", server.path().display()); self.control = Some(server); } - Err(e) => log::warn!("Horizon: control socket {}: {e}", path.display()), + Err(e) => log::warn!("RustNotMC: control socket {}: {e}", path.display()), } } @@ -68,7 +68,7 @@ impl Game<'_> { .is_some_and(|c| !c.path().as_os_str().is_empty()); if self.settings.extras.no_ai || (changed && !wanted) { if open { - log::info!("Horizon: control socket closed"); + log::info!("RustNotMC: control socket closed"); self.control = None; } } else if changed && wanted && self.control.is_none() { @@ -130,7 +130,7 @@ impl Game<'_> { let config = serde_json::json!({ "mcpServers": { "horizon-game": { "command": ctl, "args": ["--socket", socket, "mcp"] } } }); format!( - "Horizon agent control (this computer only)\nSocket: {}\nTry it: \"{}\" --socket \"{}\" state\nMCP config:\n{}\n", + "RustNotMC agent control (this computer only)\nSocket: {}\nTry it: \"{}\" --socket \"{}\" state\nMCP config:\n{}\n", socket.display(), ctl.display(), socket.display(), diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 4ed7a05..536739b 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -99,6 +99,11 @@ pub struct UiState { pub screenshot_message: Option, /// The options screens. pub options: horizon_client::options_ui::OptionsUi, + /// The Rust pause menu over a world (`rustMenu`), whether the preference + /// allows it, and when the menu opened. + pub pause: Option, + pub pause_rust: Option, + pub pause_at: Option, /// The prepared resources before resource packs are layered on. pub vanilla_root: std::path::PathBuf, /// Death screen cause and when the player died (buttons wait 20 ticks). diff --git a/crates/horizon-client/src/game/commands/pregen.rs b/crates/horizon-client/src/game/commands/pregen.rs index eb3c376..276dbca 100644 --- a/crates/horizon-client/src/game/commands/pregen.rs +++ b/crates/horizon-client/src/game/commands/pregen.rs @@ -206,7 +206,7 @@ impl Game<'_> { .count(); let [missing, lighting, unlit] = streaming.unstick_light(&mut self.world, &row); log::warn!( - "Horizon: pregeneration waits on row {:?}: {missing} not loaded, {lighting} being lit, \ + "RustNotMC: pregeneration waits on row {:?}: {missing} not loaded, {lighting} being lit, \ {unlit} unlit with no light job (queued again), {waiting} not post-processed; {:?}", row.first(), streaming.stages() diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index 07f3d6c..51df99a 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -343,7 +343,7 @@ impl Game<'_> { "Worker-Main-1", "WARN", &format!( - "Horizon doesn't generate the {preset} world preset yet: using minecraft:normal" + "RustNotMC doesn't generate the {preset} world preset yet: using minecraft:normal" ), ); } diff --git a/crates/horizon-client/src/game/dialogs.rs b/crates/horizon-client/src/game/dialogs.rs index 4928b0a..9d33cbe 100644 --- a/crates/horizon-client/src/game/dialogs.rs +++ b/crates/horizon-client/src/game/dialogs.rs @@ -58,7 +58,7 @@ impl Game<'_> { /// under the warning screen, it is what Back returns to. pub(super) fn show_dialog(&mut self, tag: &Tag) { let Some(dialog) = Dialog::parse(tag) else { - log::warn!("Horizon: failed to show dialog for data {tag:?}"); + log::warn!("RustNotMC: failed to show dialog for data {tag:?}"); return; }; let values = dialog.inputs.iter().map(|i| i.control.initial()).collect(); @@ -144,7 +144,7 @@ impl Game<'_> { self.send_chat(&format!("/{command}")); } else { log::warn!( - "Horizon: commands are not supported in configuration, trying to run '{command}'" + "RustNotMC: commands are not supported in configuration, trying to run '{command}'" ); } } @@ -153,7 +153,7 @@ impl Game<'_> { use horizon_client::clipboard::Clipboard; self.clipboard.set(&value); } - Some(other) => log::info!("Horizon: dialog action {other:?} is not supported yet"), + Some(other) => log::info!("RustNotMC: dialog action {other:?} is not supported yet"), None => {} } if !self.dialog_open() { @@ -178,7 +178,7 @@ impl Game<'_> { }); match found { Some(tag) => self.show_dialog(&tag), - None => log::warn!("Horizon: unknown dialog {id}"), + None => log::warn!("RustNotMC: unknown dialog {id}"), } } tag => self.show_dialog(tag), @@ -200,7 +200,7 @@ impl Game<'_> { .and_then(|r| r.registries.data("minecraft:dialog", *id).cloned()); match tag { Some(tag) => self.show_dialog(&tag), - None => log::warn!("Horizon: unknown dialog {id}"), + None => log::warn!("RustNotMC: unknown dialog {id}"), } } } diff --git a/crates/horizon-client/src/game/extras.rs b/crates/horizon-client/src/game/extras.rs index 4d21f52..816e601 100644 --- a/crates/horizon-client/src/game/extras.rs +++ b/crates/horizon-client/src/game/extras.rs @@ -111,7 +111,7 @@ impl Game<'_> { &mut title.mode { log::info!( - "Horizon: reconnecting to {} in {} s", + "RustNotMC: reconnecting to {} in {} s", horizon_client::net::shown_address(&retry.address), retry.seconds_left(std::time::Instant::now()) ); diff --git a/crates/horizon-client/src/game/functions.rs b/crates/horizon-client/src/game/functions.rs index 9cb0c5c..c6b9a9b 100644 --- a/crates/horizon-client/src/game/functions.rs +++ b/crates/horizon-client/src/game/functions.rs @@ -108,7 +108,7 @@ impl Game<'_> { .collect(); let store = DataStore::load(&packs); for (id, e) in &store.errors { - log::warn!("Horizon: failed to load function {id}: {e}"); + log::warn!("RustNotMC: failed to load function {id}: {e}"); } self.fns.store = store; self.sync_function_registries(); diff --git a/crates/horizon-client/src/game/growth.rs b/crates/horizon-client/src/game/growth.rs index 1c5de45..c14ffeb 100644 --- a/crates/horizon-client/src/game/growth.rs +++ b/crates/horizon-client/src/game/growth.rs @@ -67,11 +67,11 @@ pub(super) fn install_live_features( match features { Ok(Some(features)) => { res.behaviours().set_live_features(Arc::new(features)); - log::info!("Horizon: live features ready in {:?}", started.elapsed()); + log::info!("RustNotMC: live features ready in {:?}", started.elapsed()); } Ok(None) => { - log::warn!("Horizon: the worldgen bundle has no features; saplings will not grow") + log::warn!("RustNotMC: the worldgen bundle has no features; saplings will not grow") } - Err(e) => log::warn!("Horizon: live features unavailable: {e}"), + Err(e) => log::warn!("RustNotMC: live features unavailable: {e}"), } } diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs index df40ff3..0a30f57 100644 --- a/crates/horizon-client/src/game/integrated.rs +++ b/crates/horizon-client/src/game/integrated.rs @@ -139,7 +139,7 @@ impl Integrated { impl Drop for Integrated { fn drop(&mut self) { if let Err(error) = self.halt() { - log::error!("Horizon: integrated server shutdown: {error}"); + log::error!("RustNotMC: integrated server shutdown: {error}"); } } } @@ -262,7 +262,7 @@ impl Game<'_> { &ready_tx, ); if let Err(e) = result { - log::error!("Horizon: integrated server: {e}"); + log::error!("RustNotMC: integrated server: {e}"); *server_flags.failure.lock().unwrap() = Some(e.to_string()); let _ = ready_tx.send(Err(e.to_string())); } @@ -629,7 +629,7 @@ impl Game<'_> { match pending { Publish::Open(listener, settings) => { if let Err(e) = host.hub.lan.listen_on(listener, true) { - log::error!("Horizon: Open to LAN: {e}"); + log::error!("RustNotMC: Open to LAN: {e}"); } host.settings = settings; host.hub.config.force_game_mode = settings.force_game_mode; diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index ab316c4..30f3f0f 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -377,7 +377,7 @@ impl Game<'_> { format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0) }); log::info!( - "Horizon: slow tick {wall:.1} ms{cpu} ({} mobs): {}", + "RustNotMC: slow tick {wall:.1} ms{cpu} ({} mobs): {}", self.mobs.len(), horizon_client::spans::describe(&spans) ); diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 52dc46f..69322d6 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -234,14 +234,14 @@ pub(super) fn content_root( id: m.id.clone(), content, }), - Err(e) => log::warn!("Horizon: mod {} content not loaded: {e}", m.id), + Err(e) => log::warn!("RustNotMC: mod {} content not loaded: {e}", m.id), } } match mod_content::resource_root(root, &mods, &here.join(".horizon-cache")) { Ok(out) => { if out != root { log::info!( - "Horizon: content from {} mod(s) in {}", + "RustNotMC: content from {} mod(s) in {}", mods.len(), out.display() ); @@ -249,7 +249,7 @@ pub(super) fn content_root( out } Err(e) => { - log::warn!("Horizon: content mods not loaded: {e}"); + log::warn!("RustNotMC: content mods not loaded: {e}"); root.to_owned() } } @@ -264,7 +264,7 @@ impl Game<'_> { let found = horizon_mods::find(&dir).len() + self.mods.dev.len(); if found > 0 { log::warn!( - "Horizon: {found} mod(s) not started: this build has no mod runtime (build with --features mods)" + "RustNotMC: {found} mod(s) not started: this build has no mod runtime (build with --features mods)" ); } } @@ -620,7 +620,7 @@ impl Game<'_> { if self.options.execution_role.is_client() { self.system_message(Text::literal("Mods reloaded").with_color(text::GRAY)); } else { - log::info!("Horizon: mods reloaded"); + log::info!("RustNotMC: mods reloaded"); } } } diff --git a/crates/horizon-client/src/game/moved_extras.rs b/crates/horizon-client/src/game/moved_extras.rs index bd27703..f0e290e 100644 --- a/crates/horizon-client/src/game/moved_extras.rs +++ b/crates/horizon-client/src/game/moved_extras.rs @@ -93,7 +93,7 @@ mod with_mods { installed = true; } Err(e) => { - log::warn!("Horizon: {} not installed, next start retries: {e}", m.id); + log::warn!("RustNotMC: {} not installed, next start retries: {e}", m.id); self.mods.moved.notices.push( Text::literal(format!( "{} moved to the mod browser; it couldn't be installed for you yet, Horizon tries again at the next start", diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index 7ac4ae9..1e8195f 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -101,6 +101,24 @@ impl Game<'_> { self.screen = Screen::Options; self.options_from_title = from_title; let ui = &mut self.ui.options; + // The Rust settings shell is the default; `horizon.txt`'s `rustSettings` + // off restores vanilla's screens (the rollback). + let dir = Self::options_path() + .parent() + .map(std::path::Path::to_path_buf) + .unwrap_or_default(); + ui.rust = horizon_client::ui_theme::preference(&dir, "rustSettings").is_none_or(|v| { + !matches!( + v.trim().to_ascii_lowercase().as_str(), + "false" | "0" | "off" + ) + }); + // The shell's rail is the main page: it opens on the first category. + let page = if ui.rust && page == Page::Main { + Page::Video + } else { + page + }; ui.open_at(!from_title && self.title.is_none(), page); let root = self.options.resources.clone(); ui.languages = lang::available(&[&root]); diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index 013b94d..eb6478a 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -40,7 +40,7 @@ pub fn open_folder(dir: &Path) { "xdg-open" }; if let Err(e) = std::process::Command::new(program).arg(dir).spawn() { - log::warn!("Horizon: could not open {}: {e}", dir.display()); + log::warn!("RustNotMC: could not open {}: {e}", dir.display()); } } /// `Util.getPlatform().openUri`: the system browser on the link. @@ -137,7 +137,7 @@ impl Game<'_> { return; } if let Err(e) = self.reload_selected_packs() { - log::warn!("Horizon: resource reload failed: {e}"); + log::warn!("RustNotMC: resource reload failed: {e}"); if self.screen != super::Screen::Options { self.open_options_at( self.title.is_some(), diff --git a/crates/horizon-client/src/game/pad.rs b/crates/horizon-client/src/game/pad.rs index 9a71bdc..22fa668 100644 --- a/crates/horizon-client/src/game/pad.rs +++ b/crates/horizon-client/src/game/pad.rs @@ -160,7 +160,7 @@ impl Game<'_> { fn pad_button_logged(&mut self, event_loop: &ActiveEventLoop, button: Button, down: bool) { if let Err(e) = self.pad_button(event_loop, button, down) { - log::warn!("Horizon: controller {}: {e}", button.name()); + log::warn!("RustNotMC: controller {}: {e}", button.name()); } } diff --git a/crates/horizon-client/src/game/pad/hardware.rs b/crates/horizon-client/src/game/pad/hardware.rs index b464ef3..42e301a 100644 --- a/crates/horizon-client/src/game/pad/hardware.rs +++ b/crates/horizon-client/src/game/pad/hardware.rs @@ -74,7 +74,7 @@ impl Pads { } Err(gilrs::Error::NotImplemented(_)) => None, Err(e) => { - log::warn!("Horizon: controllers are unavailable: {e}"); + log::warn!("RustNotMC: controllers are unavailable: {e}"); None } }; @@ -380,7 +380,7 @@ impl Game<'_> { let end = Instant::now() + Duration::from_millis(u64::from(pulse.millis) + 50); self.pad.devices.rumbles.push((effect, end)); } - Err(e) => log::debug!("Horizon: controller vibration: {e}"), + Err(e) => log::debug!("RustNotMC: controller vibration: {e}"), } } diff --git a/crates/horizon-client/src/game/pad_menu.rs b/crates/horizon-client/src/game/pad_menu.rs index 30f2e73..3110dad 100644 --- a/crates/horizon-client/src/game/pad_menu.rs +++ b/crates/horizon-client/src/game/pad_menu.rs @@ -947,7 +947,7 @@ impl Game<'_> { None => self.options_key(key.expect("a key"), None), }; if let Err(e) = result { - log::warn!("Horizon: on-screen keyboard: {e}"); + log::warn!("RustNotMC: on-screen keyboard: {e}"); } } else if self.title.is_some() { match key { diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index 8ceea7e..a478b90 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -1020,7 +1020,7 @@ impl Game<'_> { let keys: HashSet<(i32, i32)> = unloaded.iter().map(|c| (c.x, c.z)).collect(); let chunks = self.entity_chunks(&keys, true); if let Err(e) = self.save.save_entities_later(chunks) { - log::warn!("Horizon: failed to save entities: {e}"); + log::warn!("RustNotMC: failed to save entities: {e}"); } } /// Leaving a dimension: its entities are saved with it. diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 22b7c13..a503fc6 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -312,7 +312,7 @@ impl Game<'_> { } self.join_world_rest()?; if let Err(error) = self.join_server(address, name, None, cookies) { - log::warn!("Horizon: could not join server: {error}"); + log::warn!("RustNotMC: could not join server: {error}"); if self.title.is_none() { self.title = Some(horizon_client::title::Title::new( std::env::current_dir()?.join("saves"), @@ -839,7 +839,7 @@ impl Game<'_> { break; } Next::Lost => { - log::warn!("Horizon: the chunk decoder stopped; a column is missing"); + log::warn!("RustNotMC: the chunk decoder stopped; a column is missing"); continue; } }; @@ -912,7 +912,7 @@ impl Game<'_> { if let Some(r) = self.remote.as_mut() { r.decode_errors += 1; if r.decode_errors <= 20 { - log::warn!("Horizon: skipped undecodable packet {e}"); + log::warn!("RustNotMC: skipped undecodable packet {e}"); } } } @@ -920,7 +920,7 @@ impl Game<'_> { reason, from_server, } => { - log::info!("Horizon: disconnected: {reason}"); + log::info!("RustNotMC: disconnected: {reason}"); // ClientboundTransferPacket left a target: connect there // instead of showing the disconnect, the stored cookies // carried over (ClientCommonPacketListenerImpl. diff --git a/crates/horizon-client/src/game/remote_chunks.rs b/crates/horizon-client/src/game/remote_chunks.rs index 47f4746..7bda348 100644 --- a/crates/horizon-client/src/game/remote_chunks.rs +++ b/crates/horizon-client/src/game/remote_chunks.rs @@ -186,7 +186,7 @@ impl ChunkDecoder { } }); if let Err(e) = spawned { - log::warn!("Horizon: no chunk decoder thread ({e}); chunks decode in the frame"); + log::warn!("RustNotMC: no chunk decoder thread ({e}); chunks decode in the frame"); } Self { jobs, done } } @@ -244,7 +244,7 @@ impl Game<'_> { let chunk = match column { Ok(chunk) => chunk, Err(e) => { - log::warn!("Horizon: chunk {} {}: {e}", p.x, p.z); + log::warn!("RustNotMC: chunk {} {}: {e}", p.x, p.z); return; } }; diff --git a/crates/horizon-client/src/game/screens.rs b/crates/horizon-client/src/game/screens.rs index 200a0e6..3d03a25 100644 --- a/crates/horizon-client/src/game/screens.rs +++ b/crates/horizon-client/src/game/screens.rs @@ -133,7 +133,59 @@ impl Game<'_> { pub(super) fn external_server(&self) -> bool { self.remote.is_some() && self.integrated.is_none() } + /// Builds the Rust pause menu on first use, when `rustMenu` is on. + fn ensure_rust_pause(&mut self) { + if self.ui.pause.is_some() { + return; + } + let rust = *self.ui.pause_rust.get_or_insert_with(|| { + let dir = Self::options_path() + .parent() + .map(std::path::Path::to_path_buf) + .unwrap_or_default(); + horizon_client::ui_theme::preference(&dir, "rustMenu").is_none_or(|v| { + !matches!( + v.trim().to_ascii_lowercase().as_str(), + "false" | "0" | "off" + ) + }) + }); + if !rust { + return; + } + let dir = Self::options_path() + .parent() + .map(std::path::Path::to_path_buf) + .unwrap_or_default(); + let mut menu = horizon_client::rustui::RustMenu::new(&dir.join("saves")); + let server = self.remote.is_some(); + let last = self.settings.file.string("lastServer"); + menu.current = Some(horizon_client::rustui::CurrentServer { + name: if server { + last.clone() + } else { + self.level.name.to_string() + }, + address: if server { last } else { String::new() }, + singleplayer: !server, + }); + menu.tab = horizon_client::rustui::Tab::Current; + self.ui.pause = Some(menu); + self.ui.pause_at = Some(std::time::Instant::now()); + } pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) { + self.ensure_rust_pause(); + if let Some(menu) = &self.ui.pause { + let last = self.settings.file.string("lastServer"); + let meta = horizon_client::rustui::Meta { + elapsed: self + .ui + .pause_at + .map_or(0.0, |at| at.elapsed().as_secs_f32()), + }; + menu.draw(&self.gui.atlas, batch, self.cursor, &last, &meta); + return; + } let friends = self.friends_view().shown; let atlas = &self.gui.atlas; let (w, h) = (batch.width, batch.height); @@ -211,14 +263,42 @@ impl Game<'_> { } } pub(super) fn pause_click(&mut self) -> Result<()> { - let (w, h) = { - let size = self - .renderer - .as_ref() - .map_or([1, 1], |r| [r.config.width, r.config.height]); - let b = HudBatch::new(size); - (b.width, b.height) - }; + let size = self + .renderer + .as_ref() + .map_or([1, 1], |r| [r.config.width, r.config.height]); + let b = HudBatch::new(size); + let (w, h) = (b.width, b.height); + if self.ui.pause.is_some() { + let last = self.settings.file.string("lastServer"); + let frame = horizon_client::rustui::Frame::new((w, h), b.scale, self.cursor); + let (outcome, sound) = { + let menu = self.ui.pause.as_mut().expect("checked above"); + menu.click(&frame, &last) + }; + if sound { + self.audio.play_button_click(); + } + use horizon_client::rustui::Outcome as O; + match outcome { + O::Resume => { + self.ui.pause = None; + self.screen = Screen::Playing; + self.pause_menu = false; + self.capture(true); + } + O::Options => self.open_options(false), + O::Stats => self.open_stats(), + O::Advancements => self.open_advancements(true), + O::WorldOptions => self.open_world_options(), + O::Quit => { + self.ui.pause = None; + self.close_world()?; + } + _ => {} + } + return Ok(()); + } let links = !self.server_links().is_empty(); let friends = pause_icons(w, h, links)[FRIENDS_ICON]; let hit = pause_buttons(w, h, self.external_server(), links) diff --git a/crates/horizon-client/src/game/server_links.rs b/crates/horizon-client/src/game/server_links.rs index 85fda71..71eca03 100644 --- a/crates/horizon-client/src/game/server_links.rs +++ b/crates/horizon-client/src/game/server_links.rs @@ -57,7 +57,7 @@ pub(super) fn trusted(links: Vec) -> Vec { .filter(|l| { let ok = valid_untrusted_uri(&l.link); if !ok { - log::warn!("Horizon: server sent an invalid link: {}", l.link); + log::warn!("RustNotMC: server sent an invalid link: {}", l.link); } ok }) diff --git a/crates/horizon-client/src/game/server_mods.rs b/crates/horizon-client/src/game/server_mods.rs index a5a4b0f..ee86716 100644 --- a/crates/horizon-client/src/game/server_mods.rs +++ b/crates/horizon-client/src/game/server_mods.rs @@ -384,13 +384,13 @@ mod run { let state = match crate::game::render_control::parse_state(registry, &b.state) { Ok(state) => state, Err(e) => { - log::warn!("Horizon: a server mod set {}: {e}", b.state); + log::warn!("RustNotMC: a server mod set {}: {e}", b.state); continue; } }; if let Err(e) = self.place_state([b.x, b.y, b.z], state, false) { log::debug!( - "Horizon: a server mod's block at {} {} {}: {e:?}", + "RustNotMC: a server mod's block at {} {} {}: {e:?}", b.x, b.y, b.z @@ -565,7 +565,7 @@ mod run { let over = queued.len() - MAX_QUEUED_WRITES; queued.drain(..over); log::warn!( - "Horizon: mod {name} set more blocks than wait for their level: {over} dropped" + "RustNotMC: mod {name} set more blocks than wait for their level: {over} dropped" ); } } diff --git a/crates/horizon-client/src/game/server_packs.rs b/crates/horizon-client/src/game/server_packs.rs index 5650c58..4db9b8f 100644 --- a/crates/horizon-client/src/game/server_packs.rs +++ b/crates/horizon-client/src/game/server_packs.rs @@ -326,7 +326,7 @@ impl Game<'_> { entry.accept_textures = setting; } if let Err(e) = servers::save(&file, &list) { - log::warn!("Horizon: could not save {}: {e}", file.display()); + log::warn!("RustNotMC: could not save {}: {e}", file.display()); } } Ok(()) @@ -373,7 +373,7 @@ impl Game<'_> { match download::Queue::open(dir) { Ok(queue) => state.queue = Some(queue), Err(e) => { - log::error!("Horizon: failed to open the download queue: {e}"); + log::error!("RustNotMC: failed to open the download queue: {e}"); // Every request failed, as the notifier would hear it. let (progress, events) = std::sync::mpsc::channel(); for _ in &requests { @@ -411,7 +411,7 @@ impl Game<'_> { .map(|(id, path)| packs::server_pack(*id, path)) .collect(); let Some(server) = server else { - log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all"); + log::warn!("RustNotMC: invalid pack metadata in a server pack, ignoring all"); self.finish_pack_reload(false); return; }; @@ -452,7 +452,7 @@ impl Game<'_> { match handle { Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }), Err(e) => { - log::error!("Horizon: could not start the pack reload worker: {e}"); + log::error!("RustNotMC: could not start the pack reload worker: {e}"); self.finish_pack_reload(false); } } @@ -476,7 +476,7 @@ impl Game<'_> { { Ok(()) => true, Err(e) => { - log::warn!("Horizon: server resource pack reload failed: {e}"); + log::warn!("RustNotMC: server resource pack reload failed: {e}"); false } }; diff --git a/crates/horizon-client/src/game/startup_menu.rs b/crates/horizon-client/src/game/startup_menu.rs index fdeb726..fe71bc7 100644 --- a/crates/horizon-client/src/game/startup_menu.rs +++ b/crates/horizon-client/src/game/startup_menu.rs @@ -656,7 +656,7 @@ impl Loading { if let Some(loader) = loaded.rest_loader.take() && let Err(error) = loader.finish() { - log::warn!("Horizon: replaced resource generation failed: {error}"); + log::warn!("RustNotMC: replaced resource generation failed: {error}"); } } self.loaded = None; diff --git a/crates/horizon-client/src/game/world_loading.rs b/crates/horizon-client/src/game/world_loading.rs index d7c4f2b..1a57f4a 100644 --- a/crates/horizon-client/src/game/world_loading.rs +++ b/crates/horizon-client/src/game/world_loading.rs @@ -255,7 +255,7 @@ pub(super) fn preload_world_data( }); match spawned { Ok(thread) => *preload = Preload::Started { thread, left: None }, - Err(e) => log::warn!("Horizon: world data preload: {e}"), + Err(e) => log::warn!("RustNotMC: world data preload: {e}"), } } Preload::Idle => {} @@ -268,7 +268,7 @@ pub(super) fn preload_world_data( super::lan::forget_sync_data(); horizon_threads::release_free_memory(); *preload = Preload::Idle; - log::info!("Horizon: preloaded world data dropped, no world opened"); + log::info!("RustNotMC: preloaded world data dropped, no world opened"); } } } diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index beb8b90..e18c6c3 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -122,7 +122,7 @@ fn world_pass_after_culling<'e>( keep_depth: bool, ) -> wgpu::RenderPass<'e> { encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Horizon world, after culling"), + label: Some("RustNotMC world, after culling"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view, resolve_target: None, @@ -815,7 +815,7 @@ struct Opened { fn describe_adapter(adapter: &wgpu::Adapter) { let info = adapter.get_info(); log::info!( - "Horizon renderer: {} ({:?}, {:?})", + "RustNotMC renderer: {} ({:?}, {:?})", info.name, info.backend, info.device_type @@ -885,7 +885,7 @@ async fn open_device( for (attempt, backends) in order.iter().copied().enumerate() { let name = backends_name(backends); if attempt > 0 { - log::warn!("Horizon renderer: trying {name} ({})", failures.join("; ")); + log::warn!("RustNotMC renderer: trying {name} ({})", failures.join("; ")); } let instance = wgpu::Instance::new(instance_descriptor(backends)); let surface = if offscreen { @@ -946,7 +946,7 @@ async fn open_device( limits.max_texture_array_layers, )); let desc = wgpu::DeviceDescriptor { - label: Some("Horizon device"), + label: Some("RustNotMC device"), required_features: features, required_limits: limits, ..Default::default() @@ -994,7 +994,7 @@ const NO_GPU_DRIVER: &str = "No Vulkan or OpenGL graphics driver found: Horizon /// only sign of where the game stopped. The log file is written unbuffered; /// the flush is for loggers that buffer. fn startup_step(step: std::fmt::Arguments) { - log::info!("Horizon renderer: {step}"); + log::info!("RustNotMC renderer: {step}"); log::logger().flush(); } /// How far the first frame on a new device got, for `startup_step`. @@ -1149,7 +1149,7 @@ impl Renderer { } let gpu_timer = timing::GpuTimer::new(&device, &queue); let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("Horizon terrain"), + label: Some("RustNotMC terrain"), source: wgpu::ShaderSource::Wgsl(include_str!("terrain.wgsl").into()), }); let entry = |binding, visibility, ty| wgpu::BindGroupLayoutEntry { @@ -1166,14 +1166,14 @@ impl Renderer { }; let sprite_arrays = layers.div_ceil(split).max(1); if sprite_arrays > 1 { - log::info!("Horizon: {layers} block textures in {sprite_arrays} arrays"); + log::info!("RustNotMC: {layers} block textures in {sprite_arrays} arrays"); } let sprite_constants = [ ("SPRITE_ARRAYS", f64::from(sprite_arrays)), ("SPRITE_SPLIT", f64::from(split)), ]; let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Horizon terrain bindings"), + label: Some("RustNotMC terrain bindings"), entries: &[ entry( 0, @@ -1222,7 +1222,7 @@ impl Renderer { ], }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("Horizon terrain layout"), + label: Some("RustNotMC terrain layout"), bind_group_layouts: &[Some(&bindings)], ..Default::default() }); @@ -1413,7 +1413,7 @@ impl Renderer { Some(alpha_blend), ); let globals = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Horizon globals"), + label: Some("RustNotMC globals"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, @@ -1532,7 +1532,7 @@ impl Renderer { }); let group = terrain_group( &device, - "Horizon terrain resources", + "RustNotMC terrain resources", &bindings, &globals, &sprite_views, @@ -1668,7 +1668,7 @@ impl Renderer { )?; hud.atlas_texture = Some(atlas.clone()); let entity_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("Horizon entities"), + label: Some("RustNotMC entities"), source: wgpu::ShaderSource::Wgsl(entity_shader().into()), }); let entity_bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { @@ -2266,7 +2266,7 @@ impl Renderer { ..Default::default() }); let border_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("Horizon world border"), + label: Some("RustNotMC world border"), source: wgpu::ShaderSource::Wgsl(include_str!("border.wgsl").into()), }); let border_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2]; @@ -2326,7 +2326,7 @@ impl Renderer { cache: None, }); let screen_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("Horizon screen effects"), + label: Some("RustNotMC screen effects"), source: wgpu::ShaderSource::Wgsl(include_str!("screen.wgsl").into()), }); let screen_attributes = @@ -2757,7 +2757,7 @@ impl Renderer { let read = cull.is_some_and(|c| c.depth_test() == cull::DepthTest::InShader); device .create_texture(&wgpu::TextureDescriptor { - label: Some("Horizon depth"), + label: Some("RustNotMC depth"), size: wgpu::Extent3d { width: config.width, height: config.height, @@ -3406,7 +3406,7 @@ impl Renderer { let views = sprite_views(&self.sprite_textures, &self.sprite_unused, Some(levels + 1)); self.group = terrain_group( &self.device, - "Horizon terrain resources", + "RustNotMC terrain resources", &self.terrain_layout, &self.globals, &views, @@ -3583,7 +3583,7 @@ impl Renderer { let mut encoder = section_copies.unwrap_or_else(|| { self.device .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("Horizon frame"), + label: Some("RustNotMC frame"), }) }); if self.animated_textures { @@ -4602,7 +4602,7 @@ impl Renderer { }, })]; let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Horizon world"), + label: Some("RustNotMC world"), color_attachments: &attachments, depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: world_depth, @@ -5269,7 +5269,7 @@ impl Renderer { texture: &wgpu::Texture, ) -> wgpu::Buffer { let buffer = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Horizon screenshot"), + label: Some("RustNotMC screenshot"), size: u64::from(self.padded_row()) * u64::from(self.config.height), usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false, diff --git a/crates/horizon-client/src/gfx/animate.rs b/crates/horizon-client/src/gfx/animate.rs index 5a70f3b..2f81f0c 100644 --- a/crates/horizon-client/src/gfx/animate.rs +++ b/crates/horizon-client/src/gfx/animate.rs @@ -109,7 +109,7 @@ impl SpriteAnimator { let bytes: u64 = chains.iter().map(|(chain, _)| chain.len() as u64).sum(); if bytes > limit { log::warn!( - "Horizon: layer {}'s animation ({bytes} bytes) does not fit in a storage buffer; it stays still", + "RustNotMC: layer {}'s animation ({bytes} bytes) does not fit in a storage buffer; it stays still", animation.layer ); continue; @@ -386,7 +386,7 @@ fn pipeline(device: &wgpu::Device) -> Option { match internal.or(pollster::block_on(validation.pop())) { None => Some(pipeline), Some(e) => { - log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})"); + log::warn!("RustNotMC renderer: animated sprites drawn on the CPU ({e})"); None } } diff --git a/crates/horizon-client/src/gfx/arena.rs b/crates/horizon-client/src/gfx/arena.rs index 8f320a4..d5cabec 100644 --- a/crates/horizon-client/src/gfx/arena.rs +++ b/crates/horizon-client/src/gfx/arena.rs @@ -162,7 +162,7 @@ impl Arena { )?; if !std::mem::replace(&mut self.spilled, true) { log::warn!( - "Horizon: video memory is full; more terrain is kept in system memory \ + "RustNotMC: video memory is full; more terrain is kept in system memory \ (lower the render distance if frames slow down)" ); } diff --git a/crates/horizon-client/src/gfx/hdr.rs b/crates/horizon-client/src/gfx/hdr.rs index 8b9df7a..ec8beea 100644 --- a/crates/horizon-client/src/gfx/hdr.rs +++ b/crates/horizon-client/src/gfx/hdr.rs @@ -72,7 +72,7 @@ pub struct Hdr { impl Hdr { pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("Horizon HDR"), + label: Some("RustNotMC HDR"), source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()), }); let texture = |binding| wgpu::BindGroupLayoutEntry { @@ -86,7 +86,7 @@ impl Hdr { count: None, }; let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Horizon HDR bindings"), + label: Some("RustNotMC HDR bindings"), entries: &[ texture(0), texture(1), @@ -103,12 +103,12 @@ impl Hdr { ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("Horizon HDR"), + label: Some("RustNotMC HDR"), bind_group_layouts: &[Some(&layout)], ..Default::default() }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("Horizon HDR"), + label: Some("RustNotMC HDR"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, @@ -133,7 +133,7 @@ impl Hdr { cache: None, }); let params = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Horizon HDR parameters"), + label: Some("RustNotMC HDR parameters"), size: 32, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, @@ -171,13 +171,13 @@ impl Hdr { }) }; let frame = texture( - "Horizon HDR frame", + "RustNotMC HDR frame", wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, ); - let world = texture("Horizon HDR world", wgpu::TextureUsages::COPY_DST); + let world = texture("RustNotMC HDR world", wgpu::TextureUsages::COPY_DST); let view = |t: &wgpu::Texture| t.create_view(&Default::default()); let group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Horizon HDR bindings"), + label: Some("RustNotMC HDR bindings"), layout: &self.layout, entries: &[ wgpu::BindGroupEntry { @@ -250,7 +250,7 @@ impl Hdr { }, })]; let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Horizon HDR"), + label: Some("RustNotMC HDR"), color_attachments: &attachments, ..Default::default() }); diff --git a/crates/horizon-client/src/gfx/occlusion.rs b/crates/horizon-client/src/gfx/occlusion.rs index 22e2f3c..fdbe254 100644 --- a/crates/horizon-client/src/gfx/occlusion.rs +++ b/crates/horizon-client/src/gfx/occlusion.rs @@ -602,7 +602,7 @@ impl Occlusion { self.changed_late.clear(); return; } - log::warn!("Horizon: the occlusion worker stopped; culling on the render thread"); + log::warn!("RustNotMC: the occlusion worker stopped; culling on the render thread"); self.worker = None; } self.edits.clear(); diff --git a/crates/horizon-client/src/gfx/panorama.rs b/crates/horizon-client/src/gfx/panorama.rs index 1c17e0b..a60534b 100644 --- a/crates/horizon-client/src/gfx/panorama.rs +++ b/crates/horizon-client/src/gfx/panorama.rs @@ -16,7 +16,7 @@ pub struct Faces { impl Faces { pub fn load(root: &Path) -> Self { Self::try_load(root).unwrap_or_else(|error| { - log::warn!("Horizon: title panorama unavailable ({error})"); + log::warn!("RustNotMC: title panorama unavailable ({error})"); Self { size: 1, pixels: [96, 110, 111, 255].repeat(6), diff --git a/crates/horizon-client/src/gfx/world_post.rs b/crates/horizon-client/src/gfx/world_post.rs index 17609d2..bb65dc7 100644 --- a/crates/horizon-client/src/gfx/world_post.rs +++ b/crates/horizon-client/src/gfx/world_post.rs @@ -25,11 +25,11 @@ pub struct Post { impl Post { pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("Horizon post"), + label: Some("RustNotMC post"), source: wgpu::ShaderSource::Wgsl(include_str!("../world_post.wgsl").into()), }); let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Horizon post bindings"), + label: Some("RustNotMC post bindings"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, @@ -50,13 +50,13 @@ impl Post { ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("Horizon post"), + label: Some("RustNotMC post"), bind_group_layouts: &[Some(&layout)], ..Default::default() }); let pipeline = |entry: &str| { device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("Horizon post"), + label: Some("RustNotMC post"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, @@ -82,7 +82,7 @@ impl Post { }) }; let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("Horizon post"), + label: Some("RustNotMC post"), mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, ..Default::default() @@ -165,7 +165,7 @@ impl Post { /// Bindings that copy (or anti-alias) `view` onto the screen in `finish_with`. pub fn bind(&self, device: &wgpu::Device, view: &wgpu::TextureView) -> wgpu::BindGroup { device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Horizon post"), + label: Some("RustNotMC post"), layout: &self.layout, entries: &[ wgpu::BindGroupEntry { @@ -203,7 +203,7 @@ impl Post { }, })]; let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Horizon post"), + label: Some("RustNotMC post"), color_attachments: &attachments, ..Default::default() }); diff --git a/crates/horizon-client/src/heartbeat.rs b/crates/horizon-client/src/heartbeat.rs index 9f3f3a5..fbfe426 100644 --- a/crates/horizon-client/src/heartbeat.rs +++ b/crates/horizon-client/src/heartbeat.rs @@ -34,7 +34,7 @@ pub fn start() { Ok(handle) => { let _ = PINGER.set(handle.thread().clone()); } - Err(e) => log::warn!("Horizon: no live count ping: {e}"), + Err(e) => log::warn!("RustNotMC: no live count ping: {e}"), } } @@ -62,7 +62,7 @@ fn run(endpoint: &str) { Err(e) => { // Said once, not every minute the server is away. if !failed { - log::info!("Horizon: the live count ping failed ({e}); trying again later"); + log::info!("RustNotMC: the live count ping failed ({e}); trying again later"); } failed = true; wait = (wait * 2).min(MAX_WAIT); diff --git a/crates/horizon-client/src/hud.rs b/crates/horizon-client/src/hud.rs index fda0094..3666ad8 100644 --- a/crates/horizon-client/src/hud.rs +++ b/crates/horizon-client/src/hud.rs @@ -501,6 +501,13 @@ impl HudAtlas { images.push(("horizon/white".into(), 4, 4, vec![255; 64])); if full { Self::slots(&mut images, crate::trims::slot_size(root)); + // Our own icons are part of every atlas: no runtime upload, so a + // rebuild while resources load cannot wipe them. + for (name, bytes) in crate::rustui::icons::ICONS { + if let Ok((w, h, rgba)) = decode_bytes(bytes) { + images.push((format!("horizon/rustui-icons/{name}"), w, h, rgba)); + } + } } // Blurred sprites are sampled linearly in the atlas (screen.wgsl): a // border wrapped from their own edges keeps the filter off their @@ -1152,7 +1159,7 @@ impl HudRenderer { glint_sampler: &wgpu::Sampler, ) -> Result { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("Horizon HUD"), + label: Some("RustNotMC HUD"), source: wgpu::ShaderSource::Wgsl(include_str!("hud.wgsl").into()), }); diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index 545fc26..dd47070 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -140,6 +140,9 @@ pub mod random_tick_sections; pub mod recipe_book; pub mod recipe_book_screen; pub mod reconnect; +pub mod rsx; +pub mod rsx_terrain; +pub mod rustui; pub mod save; pub mod scoreboard_cmd; pub mod screen_draw; diff --git a/crates/horizon-client/src/locator_bar.rs b/crates/horizon-client/src/locator_bar.rs index ecead2c..5644a64 100644 --- a/crates/horizon-client/src/locator_bar.rs +++ b/crates/horizon-client/src/locator_bar.rs @@ -174,7 +174,7 @@ impl WaypointStyles { { out.insert(format!("{namespace}:{name}"), style); } else { - log::warn!("Horizon: invalid waypoint style {}", path.display()); + log::warn!("RustNotMC: invalid waypoint style {}", path.display()); } } } diff --git a/crates/horizon-client/src/logging.rs b/crates/horizon-client/src/logging.rs index 282831f..a6c7b74 100644 --- a/crates/horizon-client/src/logging.rs +++ b/crates/horizon-client/src/logging.rs @@ -243,7 +243,7 @@ impl Writer { { let _ = writeln!( stderr.lock(), - "Horizon: could not roll over the log: {e}" + "RustNotMC: could not roll over the log: {e}" ); } self.date = date; @@ -362,14 +362,14 @@ pub fn init(home: &Path, main_thread: &'static str) { Ok(Some(latest)) => Some(latest), Ok(None) => { eprintln!( - "Horizon: another game is writing {}; this one logs to the terminal only", + "RustNotMC: another game is writing {}; this one logs to the terminal only", dir.join("latest.log").display() ); None } Err(e) => { eprintln!( - "Horizon: could not open {}: {e}", + "RustNotMC: could not open {}: {e}", dir.join("latest.log").display() ); None @@ -386,7 +386,7 @@ pub fn init(home: &Path, main_thread: &'static str) { .name("Log writer".into()) .spawn(move || writer.run(receive)) { - eprintln!("Horizon: could not start the log writer: {e}"); + eprintln!("RustNotMC: could not start the log writer: {e}"); } Logger { out, diff --git a/crates/horizon-client/src/main.rs b/crates/horizon-client/src/main.rs index 8d03455..b34db61 100644 --- a/crates/horizon-client/src/main.rs +++ b/crates/horizon-client/src/main.rs @@ -24,11 +24,11 @@ fn main() { if !help { logging::init(&home, thread); // The log's first line names the build, so a bug report that carries the log says which one it was. - log::info!("Horizon {}", crash::version()); + log::info!("RustNotMC {}", crash::version()); crash::install(home.clone(), side); } if let Err(error) = game::run() { - log::error!("Horizon: {error}"); + log::error!("RustNotMC: {error}"); if !help { crash::report_error(&home, side, &*error); } diff --git a/crates/horizon-client/src/mobs/trial_spawner.rs b/crates/horizon-client/src/mobs/trial_spawner.rs index e789f14..a35f092 100644 --- a/crates/horizon-client/src/mobs/trial_spawner.rs +++ b/crates/horizon-client/src/mobs/trial_spawner.rs @@ -156,7 +156,7 @@ pub fn load_registry(root: &Path) { let id = rel.to_string_lossy().replace('\\', "/"); out.insert(format!("{ns}:{id}"), c); } - None => log::warn!("Horizon: bad trial spawner config {}", path.display()), + None => log::warn!("RustNotMC: bad trial spawner config {}", path.display()), } } } diff --git a/crates/horizon-client/src/mod_content.rs b/crates/horizon-client/src/mod_content.rs index 2d8d2de..c0ec7a3 100644 --- a/crates/horizon-client/src/mod_content.rs +++ b/crates/horizon-client/src/mod_content.rs @@ -1247,11 +1247,11 @@ pub fn resource_root(base: &Path, mods: &[ContentMod], cache: &Path) -> Result

Arc::new(model), Err(e) if or_missing => { - log::warn!("Horizon: missing block model {name}: {e}"); + log::warn!("RustNotMC: missing block model {name}: {e}"); skipped.insert(name); continue; } diff --git a/crates/horizon-client/src/models/catalog.rs b/crates/horizon-client/src/models/catalog.rs index c02e972..c29f0dd 100644 --- a/crates/horizon-client/src/models/catalog.rs +++ b/crates/horizon-client/src/models/catalog.rs @@ -159,7 +159,7 @@ impl StateModels { Ok(loaded) => loaded, Err(e) => { log::warn!( - "Horizon: blockstate definition of {namespace}:{path} not loaded: {e}" + "RustNotMC: blockstate definition of {namespace}:{path} not loaded: {e}" ); continue; } @@ -212,7 +212,7 @@ impl StateModels { let library = match models.block_library(root, names.clone()) { Ok(library) => library, Err(e) => { - log::warn!("Horizon: block models incomplete ({e}); using the missing model"); + log::warn!("RustNotMC: block models incomplete ({e}); using the missing model"); models.block_library_or_missing(root, names)? } }; diff --git a/crates/horizon-client/src/moved_extras.rs b/crates/horizon-client/src/moved_extras.rs index 91f0a9a..504a3bc 100644 --- a/crates/horizon-client/src/moved_extras.rs +++ b/crates/horizon-client/src/moved_extras.rs @@ -1,4 +1,4 @@ -//! Extras that became official mods (docs/mods.md, "Horizon's official +//! Extras that became official mods (docs/mods.md, "RustNotMC's official //! mods"). For a player who had one on, the game installs the mod from //! Horizon's registry once, checked as the launcher checks it, turns it on //! and carries the extra's settings over into the mod's storage. A player diff --git a/crates/horizon-client/src/offline_name.rs b/crates/horizon-client/src/offline_name.rs index 552f155..a2f2d2e 100644 --- a/crates/horizon-client/src/offline_name.rs +++ b/crates/horizon-client/src/offline_name.rs @@ -33,7 +33,7 @@ pub fn load_or_create(dir: &Path) -> String { let name = generate(seed()); if let Err(e) = fs::write(&path, format!("{KEY}{name}\n")) { log::warn!( - "Horizon: could not keep the player name in {}: {e}", + "RustNotMC: could not keep the player name in {}: {e}", path.display() ); } diff --git a/crates/horizon-client/src/options_ui.rs b/crates/horizon-client/src/options_ui.rs index 5a7acab..9537c56 100644 --- a/crates/horizon-client/src/options_ui.rs +++ b/crates/horizon-client/src/options_ui.rs @@ -273,7 +273,7 @@ impl Opt { } } /// OptionInstance's static or value-dependent tooltip translation. - fn tooltip_key(self, s: &Settings) -> String { + pub(crate) fn tooltip_key(self, s: &Settings) -> String { let key = match self { Opt::InactivityFps => { if s.inactivity_fps_limit == "minimized" { @@ -561,7 +561,7 @@ impl Opt { }), Opt::PadKeyboard => Text::literal(match s.controller.keyboard { gamepad::TextKeyboard::Auto => "Auto", - gamepad::TextKeyboard::Horizon => "Horizon", + gamepad::TextKeyboard::Horizon => "RustNotMC", gamepad::TextKeyboard::Steam => "Steam", }), _ => Text::empty(), @@ -852,7 +852,7 @@ pub enum Page { Agent, } #[derive(Clone, Debug, PartialEq)] -enum Act { +pub(crate) enum Act { None, Push(Page), Done, @@ -877,7 +877,7 @@ enum Act { Credits, CopyAgentDetails, } -enum WKind { +pub(crate) enum WKind { Button { label: Text, active: bool, @@ -899,14 +899,14 @@ enum WKind { code: String, }, } -struct Widget { - x: f32, - y: f32, - w: f32, - h: f32, - kind: WKind, +pub(crate) struct Widget { + pub(crate) x: f32, + pub(crate) y: f32, + pub(crate) w: f32, + pub(crate) h: f32, + pub(crate) kind: WKind, /// Scrolls with the list (clipped to the list area). - list: bool, + pub(crate) list: bool, } /// What the game must do after an interaction. #[derive(Clone, Debug, PartialEq)] @@ -983,6 +983,24 @@ pub struct OptionsUi { /// When the pack screen's search box took the focus (its cursor blink). pack_focus: Instant, pub in_world: bool, + /// Draw the settings in the Rust style (`rustui::settings`) instead of + /// vanilla's screens; `horizon.txt`'s `rustSettings`, set by the game. + pub rust: bool, + /// The Rust settings' own list scroll, in dp: where it is drawn, where the + /// wheel wants it (the draw eases one to the other), and the rail's pair. + pub(crate) rust_scroll: f32, + pub(crate) rust_scroll_target: f32, + pub(crate) rust_rail: f32, + pub(crate) rust_rail_target: f32, + /// The last frame's time, for frame-rate independent easing. + pub(crate) rust_tick: Instant, + /// The graphics row whose slider a press took hold of. + pub(crate) rust_graphics_drag: Option, + /// Where the settings scrollbar was grabbed, while it is being dragged. + pub(crate) rust_scroll_drag: Option, + /// The GUI scale of the last drawn frame: a press converts its pixels to + /// dp the way the frame did. + pub(crate) scale: f32, /// Scroll the selected language into the middle on the next layout. center_language: bool, pub graphics: crate::graphics_ui::GraphicsUi, @@ -1014,6 +1032,15 @@ impl Default for OptionsUi { resource_error: None, pack_focus: Instant::now(), in_world: false, + rust: false, + rust_scroll: 0.0, + rust_scroll_target: 0.0, + rust_rail: 0.0, + rust_rail_target: 0.0, + rust_tick: Instant::now(), + rust_graphics_drag: None, + rust_scroll_drag: None, + scale: 1.0, center_language: false, graphics: Default::default(), agent_status: Vec::new(), @@ -1061,12 +1088,12 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget { } } /// What a button opening one of Horizon's pages says on hover. -fn horizon_page_tooltip(page: Page) -> Option<&'static str> { +pub(crate) fn page_tooltip(page: Page) -> Option<&'static str> { Some(match page { - Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.", + Page::Horizon => "RustNotMC's own settings: graphics, controllers, extras and mods.", Page::Graphics => "Graphics, simply: each setting says what it costs.", Page::GraphicsAdvanced => { - "Every graphics setting, Horizon's included, with what each costs." + "Every graphics setting, RustNotMC's included, with what each costs." } Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.", Page::Extras => { @@ -1074,10 +1101,10 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> { } Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.", Page::ShaderPacks => { - "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time." + "Shader packs from the shaderpacks folder: RustNotMC's own and those in the shaders/ format. One runs at a time." } Page::Mods => { - "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on." + "Mods made for RustNotMC, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on." } _ => return None, }) @@ -1146,14 +1173,14 @@ impl OptionsUi { } } /// The graphics screen level of `page`, if it is one. - fn graphics_level(page: Page) -> Option { + pub(crate) fn graphics_level(page: Page) -> Option { match page { Page::Graphics => Some(crate::graphics_ui::Level::Simple), Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced), _ => None, } } - fn title(page: Page) -> &'static str { + pub(crate) fn title(page: Page) -> &'static str { match page { Page::Main => "options.title", Page::Video => "options.videoTitle", @@ -1171,9 +1198,9 @@ impl OptionsUi { Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle", Page::Controller => "Controller Settings", Page::PadBinds => "Controller Binds", - Page::Extras => "Horizon Extras", + Page::Extras => "RustNotMC Extras", Page::Credits => "credits_and_attribution.screen.title", - Page::Horizon => "Horizon Settings", + Page::Horizon => "RustNotMC Settings", Page::Agent => "AI Agent", } } @@ -1330,7 +1357,7 @@ impl OptionsUi { 0.0, 0.0, 150.0, - Text::literal("Horizon Graphics..."), + Text::literal("RustNotMC Graphics..."), Act::Push(Page::Graphics), ), Some(button( @@ -1537,7 +1564,7 @@ impl OptionsUi { .enumerate() .map(|(i, pack)| { let kind = match pack.kind { - crate::shader_pack_list::Kind::Horizon => " (Horizon)", + crate::shader_pack_list::Kind::Horizon => " (RustNotMC)", crate::shader_pack_list::Kind::Classic => "", }; let label = Text::literal(format!("{}{kind}: ", pack.title)) @@ -1553,7 +1580,7 @@ impl OptionsUi { } } /// Build every widget of the current page. - fn layout(&mut self, s: &Settings, w: f32, h: f32) -> (Vec, f32, f32, f32) { + pub(crate) fn layout(&mut self, s: &Settings, w: f32, h: f32) -> (Vec, f32, f32, f32) { let mut out = Vec::new(); let page = self.page(); let cx = (w / 2.0).floor(); @@ -1598,7 +1625,7 @@ impl OptionsUi { // Vanilla's Telemetry Data and Credits slots: Horizon's // settings and its mods, as a mod adds its button here. // Credits & Attribution (WinScreen's credits) follows them. - ("Horizon Settings...", Some(Page::Horizon)), + ("RustNotMC Settings...", Some(Page::Horizon)), ("Mods...", Some(Page::Mods)), ("options.credits_and_attribution", Some(Page::Credits)), ]; @@ -2065,6 +2092,11 @@ impl OptionsUi { let max = (content - (bottom - top)).max(0.0); self.scroll = self.scroll.clamp(0.0, max); } + /// Whether the Rust settings shell draws this frame: the player asked for + /// it and the page has no screen of its own (graphics, the pack list). + fn rust_styled(&self) -> bool { + self.rust && self.resource_error.is_none() + } pub fn draw( &mut self, s: &Settings, @@ -2072,6 +2104,14 @@ impl OptionsUi { atlas: &HudAtlas, cursor: (f32, f32), ) { + if self.rust_styled() { + self.scale = batch.scale; + // The search box belongs to vanilla's screens; the shell has none. + self.settings_search.set_value(""); + self.settings_search.focused = false; + crate::rustui::settings::draw(self, s, atlas, batch, cursor); + return; + } let (w, h) = (batch.width, batch.height); if let Some(error) = &self.resource_error { ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world); @@ -2153,7 +2193,7 @@ impl OptionsUi { act: Act::Push(page), .. } = &wd.kind - && let Some(text) = horizon_page_tooltip(*page) + && let Some(text) = page_tooltip(*page) { tooltip = Some(atlas.font.split(&Text::literal(text), 170.0)); } @@ -2183,7 +2223,7 @@ impl OptionsUi { && !opt.available() { tooltip = Some(atlas.font.split( - &Text::literal("This feature is not yet available in Horizon."), + &Text::literal("This feature is not yet available in RustNotMC."), 170.0, )); } @@ -2352,6 +2392,34 @@ impl OptionsUi { framebuffer: [i32; 2], drag: bool, right: bool, + ) -> Vec { + if self.rust_styled() + && let Some(events) = crate::rustui::settings::click( + self, + s, + atlas, + (x, y), + size, + framebuffer, + drag, + right, + ) + { + return events; + } + self.click_vanilla(s, atlas, x, y, size, framebuffer, drag, right) + } + /// Vanilla's own hit-testing, over the widgets `layout` places. + pub(crate) fn click_vanilla( + &mut self, + s: &mut Settings, + atlas: &HudAtlas, + x: f32, + y: f32, + size: (f32, f32), + framebuffer: [i32; 2], + drag: bool, + right: bool, ) -> Vec { if self.resource_error.is_some() { if !drag @@ -2628,6 +2696,10 @@ impl OptionsUi { if self.resource_error.is_some() { return; } + if self.rust_styled() { + crate::rustui::settings::wheel(self, size, cursor, amount); + return; + } if let Some(level) = Self::graphics_level(self.page()) { self.graphics.scroll(level, size.1, amount); return; diff --git a/crates/horizon-client/src/rsx/buf.rs b/crates/horizon-client/src/rsx/buf.rs new file mode 100644 index 0000000..e5600bd --- /dev/null +++ b/crates/horizon-client/src/rsx/buf.rs @@ -0,0 +1,430 @@ +//! A growable byte buffer with the primitives the wire format is built from. +//! +//! Ported from the Java reference (`net.rustnotmc.protocol.Buf`): every read +//! is bounded, integers travel as zig-zag varints so small negatives stay +//! small, and nothing trusts a length before the bytes behind it have actually +//! arrived. Reads return [`Error::Incomplete`] when the bytes simply have not +//! shown up yet and [`Error::Protocol`] when they can never be right. + +use super::{Error, Result}; + +/// A varint of a 32 bit value never needs more than five bytes. +pub const MAX_VAR_INT_BYTES: usize = 5; + +/// A varint of a 64 bit value never needs more than ten bytes. +pub const MAX_VAR_LONG_BYTES: usize = 10; + +/// Bytes written, then read from the front: one buffer serves both sides, +/// exactly like the Java `Buf` it is ported from. +#[derive(Debug, Default)] +pub struct Buf { + data: Vec, + reader: usize, +} + +impl Buf { + /// An empty buffer to write into. + pub fn new() -> Self { + Self { + data: Vec::new(), + reader: 0, + } + } + + /// A buffer over bytes that already arrived, ready to be read. + pub fn reading(data: &[u8]) -> Self { + Self { + data: data.to_vec(), + reader: 0, + } + } + + /// How many bytes have been written. + pub fn written(&self) -> usize { + self.data.len() + } + + /// How many written bytes have not been read yet. + pub fn readable(&self) -> usize { + self.data.len() - self.reader + } + + /// Everything written, for putting on the wire. + pub fn into_inner(self) -> Vec { + self.data + } + + // ------------------------------------------------------------------ write + + pub fn write_byte(&mut self, value: u8) { + self.data.push(value); + } + + pub fn write_bool(&mut self, value: bool) { + self.write_byte(u8::from(value)); + } + + /// Big endian, like every fixed width integer on this wire. + pub fn write_short(&mut self, value: u16) { + self.data.extend_from_slice(&value.to_be_bytes()); + } + + pub fn write_int(&mut self, value: i32) { + self.data.extend_from_slice(&value.to_be_bytes()); + } + + pub fn write_long(&mut self, value: i64) { + self.data.extend_from_slice(&value.to_be_bytes()); + } + + pub fn write_float(&mut self, value: f32) { + self.write_int(value.to_bits() as i32); + } + + pub fn write_double(&mut self, value: f64) { + self.write_long(value.to_bits() as i64); + } + + /// Zig-zag encoded varint: `n -> (n << 1) ^ (n >> 31)`, so -1 is one + /// byte instead of five. Negative coordinates are ordinary in a world. + pub fn write_var_int(&mut self, value: i32) { + let mut zig = ((value as u32) << 1) ^ ((value >> 31) as u32); + loop { + if (zig & !0x7F) == 0 { + self.write_byte(zig as u8); + return; + } + self.write_byte(((zig & 0x7F) | 0x80) as u8); + zig >>= 7; + } + } + + /// Zig-zag encoded varint over a long. + pub fn write_var_long(&mut self, value: i64) { + let mut zig = ((value as u64) << 1) ^ ((value >> 63) as u64); + loop { + if (zig & !0x7F) == 0 { + self.write_byte(zig as u8); + return; + } + self.write_byte(((zig & 0x7F) | 0x80) as u8); + zig >>= 7; + } + } + + pub fn write_bytes(&mut self, value: &[u8]) { + self.data.extend_from_slice(value); + } + + /// A varint byte length followed by the UTF-8 bytes themselves. + pub fn write_string(&mut self, value: &str) { + self.write_var_int(value.len() as i32); + self.write_bytes(value.as_bytes()); + } + + /// One position as one varint. + /// + /// The axes do not get equal shares of the long: X and Z take 26 bits + /// each at 1/32 block resolution, Y takes the remaining 12 bits as whole + /// blocks in two's complement, covering -2048 to 2047. Y is stored signed + /// rather than offset by 2048 because an offset would need 13 bits, and + /// the twelfth bit is exactly the one that decides whether a value reads + /// back negative. + pub fn write_position(&mut self, x: f64, y: f64, z: f64) { + let packed = ((((x * 32.0) as i64) & 0x3FF_FFFF) << 38) + | ((((y as i32) as i64) & 0xFFF) << 26) + | (((z * 32.0) as i64) & 0x3FF_FFFF); + self.write_var_long(packed); + } + + // ------------------------------------------------------------------- read + + fn require(&self, count: usize) -> Result<()> { + if self.reader + count > self.data.len() { + return Err(Error::Incomplete(format!( + "wanted {count} byte(s) but only {} remain", + self.readable() + ))); + } + Ok(()) + } + + pub fn read_byte(&mut self) -> Result { + self.require(1)?; + let value = self.data[self.reader]; + self.reader += 1; + Ok(value) + } + + pub fn read_bool(&mut self) -> Result { + Ok(self.read_byte()? != 0) + } + + pub fn read_short(&mut self) -> Result { + self.require(2)?; + let value = u16::from_be_bytes([self.data[self.reader], self.data[self.reader + 1]]); + self.reader += 2; + Ok(value) + } + + pub fn read_int(&mut self) -> Result { + self.require(4)?; + let mut bytes = [0u8; 4]; + bytes.copy_from_slice(&self.data[self.reader..self.reader + 4]); + self.reader += 4; + Ok(i32::from_be_bytes(bytes)) + } + + pub fn read_long(&mut self) -> Result { + self.require(8)?; + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&self.data[self.reader..self.reader + 8]); + self.reader += 8; + Ok(i64::from_be_bytes(bytes)) + } + + pub fn read_float(&mut self) -> Result { + Ok(f32::from_bits(self.read_int()? as u32)) + } + + pub fn read_double(&mut self) -> Result { + Ok(f64::from_bits(self.read_long()? as u64)) + } + + /// Reads a varint, refusing anything longer than it has to be: without + /// the byte cap a peer can send ten bytes of 0xFF and turn a five byte + /// integer into an endless loop. + pub fn read_var_int(&mut self) -> Result { + let mut result: u32 = 0; + for shift in (0..MAX_VAR_INT_BYTES * 7).step_by(7) { + let byte = self.read_byte()?; + result |= ((byte & 0x7F) as u32) << shift; + if (byte & 0x80) == 0 { + return Ok(((result >> 1) as i32) ^ (-((result & 1) as i32))); + } + } + Err(Error::Protocol(format!( + "varint is longer than {MAX_VAR_INT_BYTES} bytes" + ))) + } + + pub fn read_var_long(&mut self) -> Result { + let mut result: u64 = 0; + for shift in (0..MAX_VAR_LONG_BYTES * 7).step_by(7) { + let byte = self.read_byte()?; + result |= ((byte & 0x7F) as u64) << shift; + if (byte & 0x80) == 0 { + return Ok(((result >> 1) as i64) ^ (-((result & 1) as i64))); + } + } + Err(Error::Protocol(format!( + "varlong is longer than {MAX_VAR_LONG_BYTES} bytes" + ))) + } + + pub fn read_bytes(&mut self, length: usize) -> Result> { + self.require(length)?; + let value = self.data[self.reader..self.reader + length].to_vec(); + self.reader += length; + Ok(value) + } + + /// A varint length, a range check against the caller's ceiling, then the + /// UTF-8 bytes. The length is checked before anything is allocated, so a + /// peer cannot ask for a gigabyte of string. + pub fn read_string(&mut self, max_length: usize) -> Result { + let length = self.read_var_int()?; + if length < 0 || length as usize > max_length { + return Err(Error::Protocol(format!( + "string length {length} is out of range" + ))); + } + let length = length as usize; + self.require(length)?; + let value = + String::from_utf8_lossy(&self.data[self.reader..self.reader + length]).into_owned(); + self.reader += length; + Ok(value) + } + + /// The inverse of [`Buf::write_position`]. Y comes back as whole blocks: + /// it only has 12 bits, so a fractional Y is quantised away. + pub fn read_position(&mut self) -> Result<(f64, f64, f64)> { + let packed = self.read_var_long()?; + // a field of n bits at offset k is sign extended by shifting left far + // enough to land its top bit at 63, then arithmetically shifting right + let x = (packed >> 38) as f64 / 32.0; + let y = ((packed << 26) >> 52) as f64; + let z = ((packed << 38) >> 38) as f64 / 32.0; + Ok((x, y, z)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn varint_round_trip() { + let values = [ + 0, + 1, + -1, + 127, + 128, + -128, + 12345, + -12345, + i32::MAX, + i32::MIN, + ]; + for value in values { + let mut buf = Buf::new(); + buf.write_var_int(value); + let mut read = Buf::reading(&buf.into_inner()); + assert_eq!(read.read_var_int().unwrap(), value, "varint {value}"); + assert_eq!(read.readable(), 0); + } + } + + #[test] + fn varint_rejects_overlong_input() { + // ten bytes of 0xFF: a five byte value cannot be this long + let evil = vec![0xFFu8; 10]; + let mut buf = Buf::reading(&evil); + match buf.read_var_int() { + Err(Error::Protocol(_)) => {} + other => panic!("overlong varint was {:?}, not a protocol error", other), + } + } + + #[test] + fn negative_numbers_stay_small() { + let mut minus_one = Buf::new(); + minus_one.write_var_int(-1); + assert_eq!(minus_one.written(), 1, "minus one is one byte, not five"); + + let mut zero = Buf::new(); + zero.write_var_int(0); + assert_eq!(zero.written(), 1, "zero is one byte"); + + let mut positive = Buf::new(); + positive.write_var_int(63); + let mut negative = Buf::new(); + negative.write_var_int(-63); + assert_eq!( + positive.written(), + negative.written(), + "63 and -63 cost the same" + ); + } + + #[test] + fn varlong_round_trip() { + let values = [ + 0, + 1, + -1, + i64::MAX, + i64::MIN, + -1234567890123, + 1234567890123456789, + ]; + for value in values { + let mut buf = Buf::new(); + buf.write_var_long(value); + let mut read = Buf::reading(&buf.into_inner()); + assert_eq!(read.read_var_long().unwrap(), value, "varlong {value}"); + assert_eq!(read.readable(), 0); + } + } + + #[test] + fn fixed_width_round_trip() { + let mut buf = Buf::new(); + buf.write_bool(true); + buf.write_byte(0x7F); + buf.write_short(0xBEEF); + buf.write_int(-559038737); + buf.write_long(-1234567890123); + buf.write_float(0.5); + buf.write_double(-2.25); + + let mut read = Buf::reading(&buf.into_inner()); + assert!(read.read_bool().unwrap()); + assert_eq!(read.read_byte().unwrap(), 0x7F); + assert_eq!(read.read_short().unwrap(), 0xBEEF); + assert_eq!(read.read_int().unwrap(), -559038737); + assert_eq!(read.read_long().unwrap(), -1234567890123); + assert_eq!(read.read_float().unwrap(), 0.5); + assert_eq!(read.read_double().unwrap(), -2.25); + assert_eq!(read.readable(), 0, "nothing left"); + } + + #[test] + fn string_round_trip_including_non_ascii() { + let values = ["", "player", "Игрок", "a name with spaces", "~!@#$%^&*()"]; + for value in values { + let mut buf = Buf::new(); + buf.write_string(value); + let mut read = Buf::reading(&buf.into_inner()); + assert_eq!( + read.read_string(512).unwrap(), + value, + "string {value:?}" + ); + assert_eq!(read.readable(), 0); + } + } + + #[test] + fn string_length_is_bounded() { + // announces a huge string and then does not send it + let mut buf = Buf::new(); + buf.write_var_int(0x7FFF_FFF0); + let mut read = Buf::reading(&buf.into_inner()); + match read.read_string(512) { + Err(Error::Protocol(_)) => {} + other => panic!("huge string was {:?}, not a protocol error", other), + } + } + + #[test] + fn reading_past_the_end_fails() { + let mut read = Buf::reading(&[0, 0]); + match read.read_int() { + Err(Error::Incomplete(_)) => {} + other => panic!("underflow was {:?}, not incomplete", other), + } + } + + #[test] + fn position_survives_the_round_trip() { + // Y is deliberately whole blocks: it only has 12 bits, so it is + // stored as a signed block number and a fractional Y is quantised away + let positions = [ + (0.0, 0.0, 0.0), + (1.5, -64.0, 300.25), + (-1234.5, 319.0, -4096.75), + (0.03125, -2048.0, 0.03125), + (0.03125, 2047.0, 0.03125), + ]; + for (x, y, z) in positions { + let mut buf = Buf::new(); + buf.write_position(x, y, z); + let mut read = Buf::reading(&buf.into_inner()); + let (back_x, back_y, back_z) = read.read_position().unwrap(); + assert_eq!(back_x, x, "position x {x}"); + assert_eq!(back_y, y, "position y {y}"); + assert_eq!(back_z, z, "position z {z}"); + assert_eq!(read.readable(), 0); + } + + // and the quantisation is a documented property, not an accident + let mut fractional = Buf::new(); + fractional.write_position(1.0, 64.5, 2.0); + let mut read = Buf::reading(&fractional.into_inner()); + let (_, y, _) = read.read_position().unwrap(); + assert_eq!(y, 64.0, "fractional Y rounds to a whole block"); + } +} diff --git a/crates/horizon-client/src/rsx/frame.rs b/crates/horizon-client/src/rsx/frame.rs new file mode 100644 index 0000000..ff8689d --- /dev/null +++ b/crates/horizon-client/src/rsx/frame.rs @@ -0,0 +1,402 @@ +//! One message on the wire: an envelope plus the packet body. +//! +//! ```text +//! magic 4 bytes 0x52535831, "RSX1" +//! version varint protocol version, refused if it is not ours +//! packetId varint what the body means +//! bodyLength varint bytes of body that follow +//! body bytes +//! ``` +//! +//! The magic sits in front so a port can answer immediately instead of +//! waiting to be told it is speaking nonsense. The declared body length is +//! checked against the maximum before a single byte is reserved, because the +//! only thing a malicious length is good for is making the server allocate. +//! +//! TCP hands over bytes, not messages: one read may be half a frame, or nine +//! and a half. [`FrameDecoder`] keeps whatever it could not use yet and +//! returns only whole frames, so the layer above sees a stream of packets and +//! never thinks about segmentation again. + +use super::{Error, Result}; + +/// "RSX1" in ASCII, as a big endian int. +pub const MAGIC: i32 = 0x5253_5831; + +/// The version this build speaks. +pub const VERSION: i32 = 1; + +/// Worst case envelope: magic plus four varints at their longest. +pub const MAX_ENVELOPE: usize = 4 + 5 + 5 + 5; + +/// Default ceiling for a frame body: two mebibytes. A chunk is 64 KiB +/// deflated to a few hundred bytes, so this leaves every real packet room +/// while still refusing a peer that announces a body of any size it likes. +pub const DEFAULT_MAX_PAYLOAD: usize = 2 * 1024 * 1024; + +/// One frame: what it means and what it carries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub version: i32, + pub packet_id: i32, + pub body: Vec, +} + +impl Frame { + /// A frame of the version this build speaks. + pub fn new(packet_id: i32, body: Vec) -> Self { + Self { + version: VERSION, + packet_id, + body, + } + } + + /// The bytes to put on the wire. + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(MAX_ENVELOPE + self.body.len()); + out.extend_from_slice(&MAGIC.to_be_bytes()); + push_var_int(&mut out, self.version); + push_var_int(&mut out, self.packet_id); + push_var_int(&mut out, self.body.len() as i32); + out.extend_from_slice(&self.body); + out + } + + /// Reads one frame from the front of the input. + /// + /// Returns `Ok(None)` when the input does not hold a whole frame yet, so + /// a caller feeding it a socket can keep the remainder and come back when + /// more arrives. Fails as soon as it can tell the stream is impossible, + /// rather than waiting forever for bytes that will make it valid. + pub fn decode(input: &[u8], max_body: usize) -> Result> { + if input.len() < 4 { + return Ok(None); + } + let magic = i32::from_be_bytes([input[0], input[1], input[2], input[3]]); + if magic != MAGIC { + return Err(Error::Protocol(format!( + "not a RustNotMC stream: magic is 0x{magic:08X}" + ))); + } + + // the envelope has to be complete before anything in it is judged: + // TCP cut it in half and the rest is on its way + let mut pos = 4; + let Some(version) = var_at(input, &mut pos)? else { + return Ok(None); + }; + let Some(packet_id) = var_at(input, &mut pos)? else { + return Ok(None); + }; + let Some(length) = var_at(input, &mut pos)? else { + return Ok(None); + }; + + if version != VERSION { + return Err(Error::Protocol(format!( + "protocol version {version}, this build speaks {VERSION}" + ))); + } + if length < 0 || length as usize > max_body { + return Err(Error::Protocol(format!( + "declared body of {length} bytes is outside the limit of {max_body}" + ))); + } + let length = length as usize; + if input.len() - pos < length { + return Ok(None); + } + Ok(Some(( + Self { + version, + packet_id, + body: input[pos..pos + length].to_vec(), + }, + pos + length, + ))) + } +} + +/// A varint decoded straight from a slice: `Ok(None)` while the bytes have +/// not all arrived, `Err` when they can never form a legal value. +fn var_at(input: &[u8], pos: &mut usize) -> Result> { + let mut result: u32 = 0; + let mut shift = 0; + loop { + if shift >= 5 * 7 { + return Err(Error::Protocol("varint is longer than 5 bytes".into())); + } + if *pos >= input.len() { + return Ok(None); + } + let byte = input[*pos]; + *pos += 1; + result |= ((byte & 0x7F) as u32) << shift; + if (byte & 0x80) == 0 { + return Ok(Some(((result >> 1) as i32) ^ (-((result & 1) as i32)))); + } + shift += 7; + } +} + +fn push_var_int(out: &mut Vec, value: i32) { + let mut zig = ((value as u32) << 1) ^ ((value >> 31) as u32); + loop { + if (zig & !0x7F) == 0 { + out.push(zig as u8); + return; + } + out.push(((zig & 0x7F) | 0x80) as u8); + zig >>= 7; + } +} + +/// Turns a byte stream into frames, one call per whatever the socket had. +/// +/// The one thing it will not do is buffer without limit: a peer that +/// announces a body of a gigabyte and then sends nothing must not be able to +/// hold a gigabyte of this process's heap, so a body larger than the +/// configured ceiling is refused the moment its length is read, and a buffer +/// that grows past one maximum frame plus a partial header is poisoned — +/// every later call fails instead of pretending the stream might recover. +#[derive(Debug)] +pub struct FrameDecoder { + pending: Vec, + max_body: usize, + poisoned: bool, + frames_read: u64, + bytes_read: u64, +} + +impl FrameDecoder { + pub fn new(max_body: usize) -> Self { + Self { + pending: Vec::new(), + max_body, + poisoned: false, + frames_read: 0, + bytes_read: 0, + } + } + + pub fn max_body(&self) -> usize { + self.max_body + } + + pub fn buffered_bytes(&self) -> usize { + self.pending.len() + } + + pub fn frames_read(&self) -> u64 { + self.frames_read + } + + pub fn bytes_read(&self) -> u64 { + self.bytes_read + } + + /// Feeds freshly read bytes in and returns every frame that is now + /// complete. Fails on a stream that can no longer become valid; a caller + /// should treat that as the end of the connection rather than something + /// to retry. + pub fn offer(&mut self, chunk: &[u8]) -> Result> { + if self.poisoned { + return Err(Error::Protocol( + "decoder was poisoned by an earlier failure".into(), + )); + } + if chunk.is_empty() { + return Ok(Vec::new()); + } + self.pending.extend_from_slice(chunk); + self.bytes_read += chunk.len() as u64; + + // refuse to sit on more than one maximum frame plus a partial header + let ceiling = self.max_body + MAX_ENVELOPE; + if self.pending.len() > ceiling { + self.poisoned = true; + return Err(Error::Protocol(format!( + "buffered {} bytes without a complete frame, limit is {ceiling}", + self.pending.len() + ))); + } + + let mut out = Vec::new(); + let mut pos = 0; + loop { + match Frame::decode(&self.pending[pos..], self.max_body) { + Ok(Some((frame, used))) => { + self.frames_read += 1; + out.push(frame); + pos += used; + } + Ok(None) => break, + Err(bad) => { + self.poisoned = true; + return Err(bad); + } + } + } + // hand the tail back to the next read instead of growing the buffer + if pos > 0 { + self.pending.drain(..pos); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rsx::buf::Buf; + + /// A deterministic stand-in for Java's `new Random(7).nextBytes`. + fn pseudo_random_body(len: usize) -> Vec { + let mut state = 7u32; + (0..len) + .map(|_| { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (state >> 24) as u8 + }) + .collect() + } + + #[test] + fn frame_round_trip() { + let body = pseudo_random_body(300); + let frame = Frame::new(0x01, body.clone()); + let encoded = frame.encode(); + let (back, used) = Frame::decode(&encoded, 1024 * 1024) + .unwrap() + .expect("frame came back"); + assert_eq!(used, encoded.len()); + assert_eq!(back.packet_id, 0x01); + assert_eq!(back.version, VERSION); + assert_eq!(back.body, body); + } + + #[test] + fn partial_frame_decodes_to_nothing() { + let all = Frame::new(0x05, vec![0u8; 64]).encode(); + // a fresh decoder per prefix: the question is whether any prefix + // short of the whole frame decodes to nothing + for cut in 0..all.len() - 1 { + let mut decoder = FrameDecoder::new(DEFAULT_MAX_PAYLOAD); + let got = decoder.offer(&all[..cut]).unwrap(); + assert!(got.is_empty(), "nothing complete after {cut} bytes"); + } + // and byte at a time it still arrives, exactly once + let mut whole = FrameDecoder::new(DEFAULT_MAX_PAYLOAD); + for byte in &all { + whole.offer(std::slice::from_ref(byte)).unwrap(); + } + assert_eq!(whole.frames_read(), 1, "exactly one frame once it all arrived"); + assert_eq!(whole.buffered_bytes(), 0); + } + + #[test] + fn foreign_magic_is_refused() { + let not_ours = [0x7F, b'E', b'L', b'F', 0x01, 0x00, 0x00, 0x00]; + let mut decoder = FrameDecoder::new(DEFAULT_MAX_PAYLOAD); + match decoder.offer(¬_ours) { + Err(Error::Protocol(_)) => {} + other => panic!("foreign magic was {other:?}, not a protocol error"), + } + // and it stays poisoned: the stream is finished, not retryable + assert!(decoder.offer(¬_ours).is_err()); + } + + #[test] + fn oversized_declared_body_is_refused() { + // valid magic and version, but a body of two gigabytes + let mut bytes = Vec::new(); + bytes.extend_from_slice(&MAGIC.to_be_bytes()); + push_var_int(&mut bytes, VERSION); + push_var_int(&mut bytes, 0x01); + push_var_int(&mut bytes, i32::MAX); + let mut decoder = FrameDecoder::new(4096); + match decoder.offer(&bytes) { + Err(Error::Protocol(_)) => {} + other => panic!("oversized claim was {other:?}, not a protocol error"), + } + } + + #[test] + fn byte_at_a_time_still_works() { + let bodies = [ + (0x01, "hello"), + (0x02, "hello-ack"), + (0x03, "auth"), + (0x04, "auth-ack"), + (0x05, "login"), + (0x06, "login-ack"), + (0x7F, "disconnect"), + ]; + let expected: Vec = bodies + .iter() + .map(|(id, text)| { + let mut body = Buf::new(); + body.write_string(text); + Frame::new(*id, body.into_inner()) + }) + .collect(); + + let mut wire = Vec::new(); + for frame in &expected { + wire.extend_from_slice(&frame.encode()); + } + + let mut decoder = FrameDecoder::new(DEFAULT_MAX_PAYLOAD); + let mut got = Vec::new(); + for byte in &wire { + got.extend(decoder.offer(std::slice::from_ref(byte)).unwrap()); + } + assert_eq!(got.len(), expected.len(), "every frame arrived"); + assert_eq!(got, expected); + assert_eq!(decoder.buffered_bytes(), 0, "nothing left buffered"); + } + + #[test] + fn many_frames_in_one_read() { + let mut decoder = FrameDecoder::new(DEFAULT_MAX_PAYLOAD); + let mut wire = Vec::new(); + let mut bodies = Vec::new(); + for i in 0..50 { + let body = format!("frame-{i}").into_bytes(); + wire.extend_from_slice(&Frame::new(0x05, body.clone()).encode()); + bodies.push(body); + } + let got = decoder.offer(&wire).unwrap(); + assert_eq!(got.len(), 50, "all fifty in one read"); + assert_eq!(decoder.frames_read(), 50, "counter agrees"); + for (i, frame) in got.iter().enumerate() { + assert_eq!(frame.body, bodies[i], "body {i}"); + } + } + + #[test] + fn a_lying_length_cannot_stall_the_heap() { + // announces a large but legal body, sends nothing, and keeps quiet. + // The decoder must hold it without growing and without pretending + // the frame arrived. + let mut decoder = FrameDecoder::new(64 * 1024); + let mut header = Vec::new(); + header.extend_from_slice(&MAGIC.to_be_bytes()); + push_var_int(&mut header, VERSION); + push_var_int(&mut header, 0x01); + push_var_int(&mut header, 60 * 1024); + let got = decoder.offer(&header).unwrap(); + assert!(got.is_empty(), "no frame yet"); + assert_eq!( + decoder.buffered_bytes(), + header.len(), + "held the header" + ); + + // and the rest arrives, so it completes + let body = vec![0u8; 60 * 1024]; + let got = decoder.offer(&body).unwrap(); + assert_eq!(got.len(), 1, "completes once the body arrives"); + } +} diff --git a/crates/horizon-client/src/rsx/mod.rs b/crates/horizon-client/src/rsx/mod.rs new file mode 100644 index 0000000..b7ecc7a --- /dev/null +++ b/crates/horizon-client/src/rsx/mod.rs @@ -0,0 +1,66 @@ +//! The RSX1 wire protocol of the RustNotMC game server, ported from the Java +//! reference implementation (`rsx-protocol`, `net.rustnotmc.protocol`). +//! +//! The client speaks this protocol to the Java rsx-server while the Rust +//! port of the server catches up, and later the same module carries the +//! in-process test doubles. Everything here is byte-for-byte compatible with +//! the reference: zig-zag varints, `RSX1` frames, positions packed 26/26/12 +//! and deflated 16x256x16 chunks. +//! +//! The shape of a connection: +//! +//! ```text +//! client -> Hello protocol version, the account it wants +//! server -> HelloAck accepted version, server name, motd, nonce, salt +//! client -> Login the session token (straight after hello) +//! server -> LoginAck player id and entity id, or a reason +//! client -> JoinWorld where the client thinks it is, or nothing +//! server -> WorldJoin entity id, seed, spawn, view distance, game mode +//! ``` +//! +//! After that the play phase has no fixed order: chunk data, block changes, +//! inventory updates and the rest arrive whenever the server says so. +pub mod buf; +pub mod frame; +pub mod packets; +pub mod session; + +pub use buf::Buf; +pub use frame::{Frame, FrameDecoder, DEFAULT_MAX_PAYLOAD, MAX_ENVELOPE, MAGIC, VERSION}; +pub use session::{Event, RsxSession}; + +/// Why a read or a parse failed. +#[derive(Debug)] +pub enum Error { + /// The bytes ran out mid-value. Inside a frame whose length has already + /// been announced complete this means the body does not match its own + /// declaration, so the stream is finished; at the socket it means the + /// peer stopped sending. + Incomplete(String), + /// The stream can never become valid: wrong magic, wrong version, a + /// length that lies, compressed data that is not compressed data. The + /// connection is over and nothing should retry it. + Protocol(String), + /// The transport itself failed or reached the end of the stream. + Io(std::io::Error), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Incomplete(message) => write!(f, "incomplete: {message}"), + Error::Protocol(message) => write!(f, "protocol error: {message}"), + Error::Io(error) => write!(f, "io error: {error}"), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(error: std::io::Error) -> Self { + Error::Io(error) + } +} + +pub type Result = std::result::Result; diff --git a/crates/horizon-client/src/rsx/packets.rs b/crates/horizon-client/src/rsx/packets.rs new file mode 100644 index 0000000..2a27522 --- /dev/null +++ b/crates/horizon-client/src/rsx/packets.rs @@ -0,0 +1,1230 @@ +//! The packets that start a connection, and the play phase that follows. +//! +//! The ids are written down rather than generated, because they end up in +//! every capture and every log line forever: renumbering one is a protocol +//! change and should look like a deliberate act. They match the Java +//! reference (`net.rustnotmc.protocol.Packets`) byte for byte. +//! +//! A chunk is sixteen by sixteen columns, two hundred and fifty six high, +//! one byte per block, indexed `(y << 8) | (z << 4) | x` — and almost all of +//! it is air, so it travels deflated. + +use super::buf::Buf; +use super::{Error, Result}; + +// ------------------------------------------------------------- text limits + +/// Longest a name, motd or reason may be, in bytes. +pub const MAX_TEXT: usize = 512; + +/// Longest a handshake proof may be, in bytes. +pub const MAX_PROOF: usize = 256; + +/// A salt and a nonce, in the sizes the crypto actually produces. +pub const SALT_BYTES: usize = 16; +pub const CHALLENGE_BYTES: usize = 32; + +// ------------------------------------------------------------------- ids + +pub const HELLO: i32 = 0x01; +pub const HELLO_ACK: i32 = 0x02; +pub const AUTH: i32 = 0x03; +pub const AUTH_ACK: i32 = 0x04; +pub const LOGIN: i32 = 0x05; +pub const LOGIN_ACK: i32 = 0x06; + +/// The play phase, once the handshake is done. Kept apart from the handshake +/// ids so that a packet can never be mistaken for one it is not. +pub const JOIN_WORLD: i32 = 0x10; +pub const WORLD_JOIN: i32 = 0x11; +pub const CHUNK_DATA: i32 = 0x12; + +/// One block changed: the same packet in both directions. +pub const BLOCK_CHANGE: i32 = 0x13; + +pub const PLAYER_MOVE: i32 = 0x14; +pub const ENTITY_MOVE: i32 = 0x15; +pub const PING: i32 = 0x16; +pub const PONG: i32 = 0x17; +pub const ENTITY_SPAWN: i32 = 0x18; +pub const ENTITY_REMOVE: i32 = 0x19; + +/// The launcher's session, presented to the game server instead of a +/// password. +pub const TOKEN_AUTH: i32 = 0x1A; + +/// The player's game mode, changed while the player is in the world. +pub const SET_GAME_MODE: i32 = 0x1B; + +/// The whole inventory, sent when the player arrives. +pub const INVENTORY: i32 = 0x1C; + +/// One slot of the inventory, when it changes. +pub const INVENTORY_SLOT: i32 = 0x1D; + +pub const DISCONNECT: i32 = 0x7F; + +/// Survival: the player cannot break blocks at all. +pub const GAMEMODE_SURVIVAL: i32 = 0; + +/// Creative: the player breaks the block they are looking at. +pub const GAMEMODE_CREATIVE: i32 = 1; + +// ---------------------------------------------------------- the chunk shape + +/// Blocks along one edge of a chunk. +pub const CHUNK_WIDTH: usize = 16; + +/// Blocks from the bottom of a chunk to the top. +pub const CHUNK_HEIGHT: usize = 256; + +/// Blocks in one layer of a chunk. +pub const CHUNK_AREA: usize = CHUNK_WIDTH * CHUNK_WIDTH; + +/// Blocks in a whole chunk. +pub const VOLUME: usize = CHUNK_AREA * CHUNK_HEIGHT; + +/// The most a compressed chunk may be: the raw size plus room for the worst +/// expansion, so a peer cannot announce a chunk body of any size it likes. +pub const MAX_COMPRESSED: usize = VOLUME + 1024; + +/// Where a block sits in a chunk's byte array: the sixteen blocks of one +/// layer are next to each other, so a layer is one run and a chunk is a +/// stack of layers. +pub fn chunk_index(x: usize, y: usize, z: usize) -> usize { + (y << 8) | (z << 4) | x +} + +/// Deflates a chunk at the fastest level, not the smallest: a chunk is +/// mostly zeros and zeros deflate to almost nothing at any level, so the +/// extra work of a smaller result buys nothing. +pub fn compress_chunk(raw: &[u8]) -> Result> { + use std::io::Write; + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(1)); + encoder.write_all(raw)?; + Ok(encoder.finish()?) +} + +/// Puts a chunk back together. The expected length is fixed by the format, +/// not by the peer, so a peer cannot make this allocate; anything that does +/// not come out at exactly the right size is a damaged or hostile chunk and +/// is refused. +pub fn decompress_chunk(packed: &[u8], expected: usize) -> Result> { + if expected > VOLUME { + return Err(Error::Protocol(format!( + "expected length {expected} is not a chunk size" + ))); + } + let mut out = vec![0u8; expected]; + let mut decoder = flate2::read::ZlibDecoder::new(packed); + std::io::Read::read_exact(&mut decoder, &mut out).map_err(|error| { + Error::Protocol(format!("chunk data is not valid compressed data: {error}")) + })?; + Ok(out) +} + +/// Parses a packet body that has already arrived in one piece: a body that +/// runs out mid-value is malformed rather than "not here yet", because the +/// frame around it promised these bytes exist. +fn parse_body(body: &[u8], parse: impl FnOnce(&mut Buf) -> Result) -> Result { + let mut buf = Buf::reading(body); + match parse(&mut buf) { + Err(Error::Incomplete(message)) => { + Err(Error::Protocol(format!("truncated packet body: {message}"))) + } + other => other, + } +} + +// ------------------------------------------------------------- handshake + +/// The client introducing itself, and naming the account it wants. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hello { + pub protocol_version: i32, + pub account: String, + pub client_name: String, + pub client_version: i32, +} + +impl Hello { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_var_int(self.protocol_version); + buf.write_string(&self.account); + buf.write_string(&self.client_name); + buf.write_var_int(self.client_version); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + Ok(Self { + protocol_version: input.read_var_int()?, + account: input.read_string(MAX_TEXT)?, + client_name: input.read_string(MAX_TEXT)?, + client_version: input.read_var_int()?, + }) + }) + } +} + +/// The server accepting, and describing itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HelloAck { + pub protocol_version: i32, + pub server_name: String, + pub motd: String, + pub online_players: i32, + pub max_players: i32, + /// A value fresh for this connection: the proof is computed over it, so + /// a recording of one login cannot be replayed against the next one. + pub nonce: Vec, + /// The salt of the account that was named, when it exists. + pub salt: Vec, +} + +impl HelloAck { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_var_int(self.protocol_version); + buf.write_string(&self.server_name); + buf.write_string(&self.motd); + buf.write_var_int(self.online_players); + buf.write_var_int(self.max_players); + buf.write_bytes(&self.nonce); + buf.write_bytes(&self.salt); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + Ok(Self { + protocol_version: input.read_var_int()?, + server_name: input.read_string(MAX_TEXT)?, + motd: input.read_string(MAX_TEXT)?, + online_players: input.read_var_int()?, + max_players: input.read_var_int()?, + nonce: input.read_bytes(CHALLENGE_BYTES)?, + salt: input.read_bytes(SALT_BYTES)?, + }) + }) + } +} + +/// Whether the account was accepted, and on what terms. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthAck { + pub accepted: bool, + pub reason: String, + pub token: String, + pub expires_at: i64, + pub reload_token: String, + pub reload_expires_at: i64, +} + +impl AuthAck { + pub fn ok(token: String, expires_at: i64) -> Self { + Self { + accepted: true, + reason: String::new(), + token, + expires_at, + reload_token: String::new(), + reload_expires_at: 0, + } + } + + pub fn refused(reason: String) -> Self { + Self { + accepted: false, + reason, + token: String::new(), + expires_at: 0, + reload_token: String::new(), + reload_expires_at: 0, + } + } + + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_bool(self.accepted); + buf.write_string(&self.reason); + if self.accepted { + buf.write_string(&self.token); + buf.write_long(self.expires_at); + buf.write_string(&self.reload_token); + buf.write_long(self.reload_expires_at); + } + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + let accepted = input.read_bool()?; + // the reason is written either way, so it has to be read either + // way: skipping it here shifts everything after it by one field + let reason = input.read_string(MAX_TEXT)?; + if !accepted { + return Ok(Self::refused(reason)); + } + Ok(Self { + accepted: true, + reason, + token: input.read_string(MAX_TEXT)?, + expires_at: input.read_long()?, + reload_token: input.read_string(MAX_TEXT)?, + reload_expires_at: input.read_long()?, + }) + }) + } +} + +/// The client redeeming the token it was just handed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Login { + pub account: String, + pub token: String, +} + +impl Login { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_string(&self.account); + buf.write_string(&self.token); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + Ok(Self { + account: input.read_string(MAX_TEXT)?, + token: input.read_string(MAX_TEXT)?, + }) + }) + } +} + +/// Whether the player is in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoginAck { + pub accepted: bool, + pub reason: String, + pub player_id: i32, + pub entity_id: i32, +} + +impl LoginAck { + pub fn ok(player_id: i32, entity_id: i32) -> Self { + Self { + accepted: true, + reason: String::new(), + player_id, + entity_id, + } + } + + pub fn refused(reason: String) -> Self { + Self { + accepted: false, + reason, + player_id: -1, + entity_id: -1, + } + } + + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_bool(self.accepted); + buf.write_string(&self.reason); + if self.accepted { + buf.write_var_int(self.player_id); + buf.write_var_int(self.entity_id); + } + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + let accepted = input.read_bool()?; + let reason = input.read_string(MAX_TEXT)?; + if !accepted { + return Ok(Self::refused(reason)); + } + Ok(Self { + accepted: true, + reason, + player_id: input.read_var_int()?, + entity_id: input.read_var_int()?, + }) + }) + } +} + +/// A reason, said out loud, before the connection goes away. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Disconnect { + pub reason: String, +} + +impl Disconnect { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_string(&self.reason); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + Ok(Self { + reason: input.read_string(MAX_TEXT)?, + }) + }) + } +} + +// ------------------------------------------------------------- play phase + +/// The client asking to be put in the world. The position it carries is +/// where the client thinks it is, which is how a reconnect puts a player +/// back where it was standing rather than at the spawn; a client that has +/// never been here sends no position at all and the server decides. +#[derive(Debug, Clone, PartialEq)] +pub struct JoinWorld { + pub x: f64, + pub y: f64, + pub z: f64, + pub yaw: f32, + pub pitch: f32, + pub has_position: bool, +} + +impl JoinWorld { + /// A client that has no place to come back to. + pub fn fresh() -> Self { + Self { + x: 0.0, + y: 0.0, + z: 0.0, + yaw: 0.0, + pitch: 0.0, + has_position: false, + } + } + + /// A client that knows where it is. + pub fn at(x: f64, y: f64, z: f64, yaw: f32, pitch: f32) -> Self { + Self { + x, + y, + z, + yaw, + pitch, + has_position: true, + } + } + + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_bool(self.has_position); + if self.has_position { + buf.write_position(self.x, self.y, self.z); + buf.write_float(self.yaw); + buf.write_float(self.pitch); + } + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + if !input.read_bool()? { + return Ok(Self::fresh()); + } + let (x, y, z) = input.read_position()?; + Ok(Self { + x, + y, + z, + yaw: input.read_float()?, + pitch: input.read_float()?, + has_position: true, + }) + }) + } +} + +/// The world the client is in. The seed goes across so that the client can +/// build the same sky, the same colours and the same cloud shapes the +/// server sees, without the server having to send any of it. +#[derive(Debug, Clone, PartialEq)] +pub struct WorldJoin { + pub entity_id: i32, + pub seed: i64, + pub spawn_x: f64, + pub spawn_y: f64, + pub spawn_z: f64, + pub view_distance: i32, + pub world_height: i32, + pub game_mode: i32, +} + +impl WorldJoin { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_var_int(self.entity_id); + buf.write_var_long(self.seed); + buf.write_position(self.spawn_x, self.spawn_y, self.spawn_z); + buf.write_var_int(self.view_distance); + buf.write_var_int(self.world_height); + buf.write_var_int(self.game_mode); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + let entity_id = input.read_var_int()?; + let seed = input.read_var_long()?; + let (spawn_x, spawn_y, spawn_z) = input.read_position()?; + Ok(Self { + entity_id, + seed, + spawn_x, + spawn_y, + spawn_z, + view_distance: input.read_var_int()?, + world_height: input.read_var_int()?, + game_mode: input.read_var_int()?, + }) + }) + } +} + +/// One chunk, on its way to the client, deflated: a chunk of mostly air is +/// a few hundred bytes compressed against sixty five kilobytes raw, which +/// is the difference between joining in a moment and joining in a minute. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChunkData { + pub chunk_x: i32, + pub chunk_z: i32, + pub blocks: Vec, +} + +impl ChunkData { + pub fn new(chunk_x: i32, chunk_z: i32, blocks: Vec) -> Result { + if blocks.len() != VOLUME { + return Err(Error::Protocol(format!( + "chunk data is {} bytes, expected {VOLUME}", + blocks.len() + ))); + } + Ok(Self { + chunk_x, + chunk_z, + blocks, + }) + } + + pub fn write(&self) -> Buf { + let packed = compress_chunk(&self.blocks).expect("chunk compression cannot fail"); + let mut buf = Buf::new(); + buf.write_var_int(self.chunk_x); + buf.write_var_int(self.chunk_z); + buf.write_var_int(packed.len() as i32); + buf.write_bytes(&packed); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + let chunk_x = input.read_var_int()?; + let chunk_z = input.read_var_int()?; + let length = input.read_var_int()?; + if length < 0 || length as usize > MAX_COMPRESSED { + return Err(Error::Protocol(format!( + "compressed chunk of {length} bytes is out of range" + ))); + } + let packed = input.read_bytes(length as usize)?; + Ok(Self { + chunk_x, + chunk_z, + blocks: decompress_chunk(&packed, VOLUME)?, + }) + }) + } +} + +/// One block, changed. The same packet either way: the client asks, the +/// server says, and the body is the same. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlockChange { + pub x: i32, + pub y: i32, + pub z: i32, + pub block_id: u8, +} + +impl BlockChange { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_var_int(self.x); + buf.write_var_int(self.y); + buf.write_var_int(self.z); + buf.write_byte(self.block_id); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + Ok(Self { + x: input.read_var_int()?, + y: input.read_var_int()?, + z: input.read_var_int()?, + block_id: input.read_byte()?, + }) + }) + } +} + +/// Where the player is now. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PlayerMove { + pub x: f64, + pub y: f64, + pub z: f64, + pub yaw: f32, + pub pitch: f32, + pub on_ground: bool, +} + +impl PlayerMove { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_position(self.x, self.y, self.z); + buf.write_float(self.yaw); + buf.write_float(self.pitch); + buf.write_bool(self.on_ground); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + let (x, y, z) = input.read_position()?; + Ok(Self { + x, + y, + z, + yaw: input.read_float()?, + pitch: input.read_float()?, + on_ground: input.read_bool()?, + }) + }) + } +} + +/// Somebody else moved. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct EntityMove { + pub entity_id: i32, + pub x: f64, + pub y: f64, + pub z: f64, + pub yaw: f32, + pub pitch: f32, +} + +impl EntityMove { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_var_int(self.entity_id); + buf.write_position(self.x, self.y, self.z); + buf.write_float(self.yaw); + buf.write_float(self.pitch); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + let entity_id = input.read_var_int()?; + let (x, y, z) = input.read_position()?; + Ok(Self { + entity_id, + x, + y, + z, + yaw: input.read_float()?, + pitch: input.read_float()?, + }) + }) + } +} + +/// A liveness check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Ping { + pub id: i64, +} + +impl Ping { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_long(self.id); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| Ok(Self { id: input.read_long()? })) + } +} + +/// The answer to a liveness check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Pong { + pub id: i64, +} + +impl Pong { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_long(self.id); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| Ok(Self { id: input.read_long()? })) + } +} + +/// Somebody else is here. The player id travels with the entity id because +/// the two are different things: the entity is what the client draws, the +/// player is who the server knows the player to be. +#[derive(Debug, Clone, PartialEq)] +pub struct EntitySpawn { + pub entity_id: i32, + pub player_id: i32, + pub name: String, + pub x: f64, + pub y: f64, + pub z: f64, + pub yaw: f32, + pub pitch: f32, +} + +impl EntitySpawn { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_var_int(self.entity_id); + buf.write_var_int(self.player_id); + buf.write_string(&self.name); + buf.write_position(self.x, self.y, self.z); + buf.write_float(self.yaw); + buf.write_float(self.pitch); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + let entity_id = input.read_var_int()?; + let player_id = input.read_var_int()?; + let name = input.read_string(MAX_TEXT)?; + let (x, y, z) = input.read_position()?; + Ok(Self { + entity_id, + player_id, + name, + x, + y, + z, + yaw: input.read_float()?, + pitch: input.read_float()?, + }) + }) + } +} + +/// Somebody else has left. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EntityRemove { + pub entity_id: i32, +} + +impl EntityRemove { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_var_int(self.entity_id); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| Ok(Self { + entity_id: input.read_var_int()?, + })) + } +} + +/// The game mode of one player. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GameMode { + pub mode: i32, +} + +impl GameMode { + /// True when the player may break blocks. + pub fn creative(&self) -> bool { + self.mode == GAMEMODE_CREATIVE + } + + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_var_int(self.mode); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| Ok(Self { + mode: input.read_var_int()?, + })) + } +} + +/// The launcher's session, as the game server sees it. The token is a +/// bearer credential: whoever holds it is the player. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TokenAuth { + pub account: String, + pub uuid: String, + pub token: String, +} + +impl TokenAuth { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_string(&self.account); + buf.write_string(&self.uuid); + buf.write_string(&self.token); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + Ok(Self { + account: input.read_string(MAX_TEXT)?, + uuid: input.read_string(MAX_TEXT)?, + token: input.read_string(MAX_TEXT)?, + }) + }) + } +} + +/// One slot of an inventory. The empty slot is a stack of nothing, so there +/// is no special case: an empty slot is one with the id of nothing and a +/// count of zero. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Slot { + pub item: i32, + pub count: i32, + pub condition: i32, +} + +impl Slot { + pub fn empty() -> Self { + Self { + item: 0, + count: 0, + condition: -1, + } + } + + pub fn write_to(&self, buf: &mut Buf) { + buf.write_var_int(self.item); + buf.write_var_int(self.count); + buf.write_var_int(self.condition); + } + + pub fn read_from(buf: &mut Buf) -> Result { + Ok(Self { + item: buf.read_var_int()?, + count: buf.read_var_int()?, + condition: buf.read_var_int()?, + }) + } +} + +/// The whole inventory of a player. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Inventory { + pub slots: Vec, + /// The slot the player is holding. + pub held: i32, + /// The clothing the player wears: seven places, two layers each. + pub armor: Vec, +} + +impl Inventory { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_var_int(self.held); + buf.write_var_int(self.slots.len() as i32); + for slot in &self.slots { + slot.write_to(&mut buf); + } + buf.write_var_int(self.armor.len() as i32); + for slot in &self.armor { + slot.write_to(&mut buf); + } + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + let held = input.read_var_int()?; + let count = input.read_var_int()?; + if count < 0 || count > 1024 { + return Err(Error::Protocol(format!( + "an inventory of {count} slots is not real" + ))); + } + let mut slots = Vec::with_capacity(count as usize); + for _ in 0..count { + slots.push(Slot::read_from(input)?); + } + let worn = input.read_var_int()?; + if worn < 0 || worn > 64 { + return Err(Error::Protocol( + "that much clothing is not real".into(), + )); + } + let mut armor = Vec::with_capacity(worn as usize); + for _ in 0..worn { + armor.push(Slot::read_from(input)?); + } + Ok(Self { + slots, + held, + armor, + }) + }) + } +} + +/// One slot of the inventory and what is in it now. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SlotChange { + pub slot: i32, + pub item: i32, + pub count: i32, + pub condition: i32, +} + +impl SlotChange { + pub fn write(&self) -> Buf { + let mut buf = Buf::new(); + buf.write_var_int(self.slot); + buf.write_var_int(self.item); + buf.write_var_int(self.count); + buf.write_var_int(self.condition); + buf + } + + pub fn read(body: &[u8]) -> Result { + parse_body(body, |input| { + Ok(Self { + slot: input.read_var_int()?, + item: input.read_var_int()?, + count: input.read_var_int()?, + condition: input.read_var_int()?, + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A terrain chunk the way the Java test builds one: stone below, a + /// grass surface at y=63, air above. + fn terrain_chunk() -> Vec { + let mut raw = vec![0u8; VOLUME]; + for z in 0..CHUNK_WIDTH { + for x in 0..CHUNK_WIDTH { + for y in 0..64 { + raw[chunk_index(x, y, z)] = if y == 63 { 3 } else { 1 }; + } + } + } + raw + } + + #[test] + fn handshake_packets_round_trip() { + let hello = Hello { + protocol_version: 1, + account: "Player".into(), + client_name: "cli".into(), + client_version: 1, + }; + let back = Hello::read(&hello.write().into_inner()).unwrap(); + assert_eq!(back, hello); + + let ack = HelloAck { + protocol_version: 1, + server_name: "RSX1".into(), + motd: "a world".into(), + online_players: 200, + max_players: 500, + nonce: vec![0xAB; CHALLENGE_BYTES], + salt: vec![0xCD; SALT_BYTES], + }; + let back = HelloAck::read(&ack.write().into_inner()).unwrap(); + assert_eq!(back, ack); + assert_eq!(back.nonce.len(), CHALLENGE_BYTES); + assert_eq!(back.salt.len(), SALT_BYTES); + + let refused = AuthAck::refused("wrong name or password".into()); + let back = AuthAck::read(&refused.write().into_inner()).unwrap(); + assert_eq!(back, refused); + + let accepted = AuthAck::ok("session-token".into(), 1_700_000_000_000); + let back = AuthAck::read(&accepted.write().into_inner()).unwrap(); + assert_eq!(back, accepted); + + let login = Login { + account: "Player".into(), + token: "session-token".into(), + }; + let back = Login::read(&login.write().into_inner()).unwrap(); + assert_eq!(back, login); + + let ok = LoginAck::ok(7, 7); + let back = LoginAck::read(&ok.write().into_inner()).unwrap(); + assert_eq!(back, ok); + + let no = LoginAck::refused("bad or expired token".into()); + let back = LoginAck::read(&no.write().into_inner()).unwrap(); + assert_eq!(back, no); + } + + #[test] + fn play_packets_round_trip() { + let join = JoinWorld::at(120.5, 64.0, -300.25, 90.0, -10.0); + let back = JoinWorld::read(&join.write().into_inner()).unwrap(); + assert!(back.has_position, "the join carried a position"); + assert_eq!(back.x, 120.5); + assert_eq!(back.y, 64.0); + assert_eq!(back.z, -300.25); + assert_eq!(back.yaw, 90.0); + assert_eq!(back.pitch, -10.0); + + let fresh = JoinWorld::fresh(); + let back = JoinWorld::read(&fresh.write().into_inner()).unwrap(); + assert!(!back.has_position, "a fresh join carries no position"); + + let world = WorldJoin { + entity_id: 7, + seed: 1234567890123, + spawn_x: 0.5, + spawn_y: 64.0, + spawn_z: 0.5, + view_distance: 8, + world_height: 256, + game_mode: GAMEMODE_CREATIVE, + }; + let back = WorldJoin::read(&world.write().into_inner()).unwrap(); + assert_eq!(back, world); + + let survival = GameMode { + mode: GAMEMODE_SURVIVAL, + }; + let back = GameMode::read(&survival.write().into_inner()).unwrap(); + assert_eq!(back, survival); + assert!(!back.creative(), "survival cannot break blocks"); + assert!( + GameMode { + mode: GAMEMODE_CREATIVE + } + .creative(), + "creative can break blocks" + ); + + let change = BlockChange { + x: -100, + y: 70, + z: 250, + block_id: 1, + }; + let back = BlockChange::read(&change.write().into_inner()).unwrap(); + assert_eq!(back, change); + + let move_ = PlayerMove { + x: 1.5, + y: 64.0, + z: -2.25, + yaw: 45.0, + pitch: -45.0, + on_ground: true, + }; + let back = PlayerMove::read(&move_.write().into_inner()).unwrap(); + assert_eq!(back, move_); + assert!(back.on_ground, "the player is on the ground"); + + let entity = EntityMove { + entity_id: 9, + x: 8.0, + y: 9.0, + z: 10.0, + yaw: 1.0, + pitch: 2.0, + }; + let back = EntityMove::read(&entity.write().into_inner()).unwrap(); + assert_eq!(back, entity); + + let ping = Ping { id: 1234567890123 }; + let back = Ping::read(&ping.write().into_inner()).unwrap(); + assert_eq!(back, ping); + let pong = Pong { id: 99 }; + let back = Pong::read(&pong.write().into_inner()).unwrap(); + assert_eq!(back, pong); + + let spawn = EntitySpawn { + entity_id: 4, + player_id: 4, + name: "Player".into(), + x: 1.0, + y: 65.0, + z: 2.0, + yaw: 30.0, + pitch: -60.0, + }; + let back = EntitySpawn::read(&spawn.write().into_inner()).unwrap(); + assert_eq!(back, spawn); + + let remove = EntityRemove { entity_id: 11 }; + let back = EntityRemove::read(&remove.write().into_inner()).unwrap(); + assert_eq!(back, remove); + + let launcher = TokenAuth { + account: "Player".into(), + uuid: "00000000-0000-0000-0000-000000000000".into(), + token: "a.b.c".into(), + }; + let back = TokenAuth::read(&launcher.write().into_inner()).unwrap(); + assert_eq!(back, launcher); + + let bye = Disconnect { + reason: "server is closing".into(), + }; + let back = Disconnect::read(&bye.write().into_inner()).unwrap(); + assert_eq!(back, bye); + } + + #[test] + fn a_chunk_survives_compression() { + let raw = terrain_chunk(); + + let packed = compress_chunk(&raw).unwrap(); + assert!( + packed.len() < 4096, + "a chunk of mostly air is small ({} bytes)", + packed.len() + ); + assert_eq!( + decompress_chunk(&packed, VOLUME).unwrap(), + raw, + "the chunk came back" + ); + + let data = ChunkData::new(-3, 9, raw.clone()).unwrap(); + let back = ChunkData::read(&data.write().into_inner()).unwrap(); + assert_eq!(back.chunk_x, -3); + assert_eq!(back.chunk_z, 9); + assert_eq!(back.blocks, raw, "the blocks came back"); + assert_eq!(back.blocks[chunk_index(0, 63, 0)], 3, "grass on top"); + assert_eq!(back.blocks[chunk_index(0, 0, 0)], 1, "stone below"); + assert_eq!(back.blocks[chunk_index(0, 100, 0)], 0, "air above"); + } + + #[test] + fn a_chunk_that_is_not_compressed_data_is_refused() { + // 0xFF is not a valid deflate block header, so this cannot decode + let rubbish = vec![0xFFu8; 64]; + match decompress_chunk(&rubbish, VOLUME) { + Err(Error::Protocol(_)) => {} + other => panic!("rubbish was {other:?}, not a protocol error"), + } + } + + #[test] + fn a_truncated_chunk_is_refused() { + let raw = vec![0u8; VOLUME]; + let packed = compress_chunk(&raw).unwrap(); + let cut = &packed[..packed.len() / 2]; + match decompress_chunk(cut, VOLUME) { + Err(Error::Protocol(_)) => {} + other => panic!("truncated chunk was {other:?}, not a protocol error"), + } + } + + #[test] + fn an_inventory_survives_the_wire() { + let slots = vec![ + Slot { + item: 1, + count: 1, + condition: 100, + }, + Slot { + item: 2, + count: 12, + condition: -1, + }, + Slot::empty(), + Slot { + item: 7, + count: 7, + condition: -1, + }, + Slot::empty(), + Slot::empty(), + ]; + let inventory = Inventory { + slots: slots.clone(), + held: 0, + armor: Vec::new(), + }; + let back = Inventory::read(&inventory.write().into_inner()).unwrap(); + assert_eq!(back.slots.len(), 6, "the slots survive"); + assert_eq!(back.slots[0].item, 1, "the rock is in the first slot"); + assert_eq!(back.slots[0].count, 1, "with one of it"); + assert_eq!(back.slots[0].condition, 100, "and its life"); + assert_eq!(back.slots[1].count, 12, "wood is in the second"); + assert_eq!( + back.slots[2].item, 0, + "an empty slot stays empty" + ); + assert_eq!(back.held, 0, "the hand survives"); + assert_eq!(back.slots, slots); + + let change = SlotChange { + slot: 3, + item: 7, + count: 7, + condition: -1, + }; + let back = SlotChange::read(&change.write().into_inner()).unwrap(); + assert_eq!(back, change); + } + + #[test] + fn an_absurd_inventory_is_refused() { + // announces a million slots and provides none of them + let mut body = Buf::new(); + body.write_var_int(0); + body.write_var_int(1_000_000); + match Inventory::read(&body.into_inner()) { + Err(Error::Protocol(_)) => {} + other => panic!("absurd inventory was {other:?}, not a protocol error"), + } + } +} diff --git a/crates/horizon-client/src/rsx/session.rs b/crates/horizon-client/src/rsx/session.rs new file mode 100644 index 0000000..08f5225 --- /dev/null +++ b/crates/horizon-client/src/rsx/session.rs @@ -0,0 +1,654 @@ +//! One connection to an RSX1 server: framing, the fixed handshake order and +//! then the play phase as a stream of events. +//! +//! The session is generic over anything that is a byte stream, so the same +//! code runs against a TCP socket, against the in-memory [`Pipe`] of +//! `horizon_protocol` in tests, and later against an in-process server. + +use super::buf::Buf; +use super::frame::{Frame, FrameDecoder, DEFAULT_MAX_PAYLOAD, VERSION}; +use super::packets::*; +use super::{Error, Result}; +use std::collections::VecDeque; +use std::io::{Read, Write}; + +/// One thing the server said, parsed as far as this build goes. Packets +/// that are not part of the spike yet arrive as [`Event::Other`] with their +/// id and raw body, so nothing is lost and nothing blocks. +#[derive(Debug)] +pub enum Event { + WorldJoin(WorldJoin), + Chunk(ChunkData), + BlockChange(BlockChange), + Inventory(Inventory), + SlotChange(SlotChange), + GameMode(GameMode), + EntitySpawn(EntitySpawn), + EntityRemove(EntityRemove), + PlayerMove(PlayerMove), + EntityMove(EntityMove), + /// The server said goodbye, with a reason. + Disconnect(String), + Other { + id: i32, + body: Vec, + }, +} + +/// A live RSX1 connection. +/// +/// Frames that arrive together with the one a blocking call is waiting for +/// are kept in `pending` and handed out later, in wire order: a real server +/// batches the world join, the inventory and the first chunks into one +/// write, and dropping the surplus would drop the world with it. +pub struct RsxSession { + stream: S, + decoder: FrameDecoder, + pending: VecDeque, + scratch: Vec, +} + +impl RsxSession { + pub fn new(stream: S) -> Self { + Self { + stream, + decoder: FrameDecoder::new(DEFAULT_MAX_PAYLOAD), + pending: VecDeque::new(), + scratch: vec![0u8; 8192], + } + } + + /// The transport, for callers that need to adjust it (timeouts and the + /// like on a TCP session). + pub fn stream_mut(&mut self) -> &mut S { + &mut self.stream + } + + /// Puts one packet on the wire. + pub fn send(&mut self, packet_id: i32, body: Buf) -> Result<()> { + let frame = Frame::new(packet_id, body.into_inner()); + self.stream.write_all(&frame.encode())?; + self.stream.flush()?; + Ok(()) + } + + /// The next frame, oldest first: queued frames first, then whatever the + /// transport still owes us. + pub fn recv(&mut self) -> Result { + if let Some(frame) = self.pending.pop_front() { + return Ok(frame); + } + self.pump() + } + + /// Reads the transport until at least one whole frame comes out. Anything + /// that arrives with it stays queued for later calls. + fn pump(&mut self) -> Result { + loop { + let read = match self.stream.read(&mut self.scratch) { + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + other => other?, + }; + if read == 0 { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "server closed the connection", + ))); + } + let frames = self.decoder.offer(&self.scratch[..read])?; + if let Some(first) = frames.first().cloned() { + self.pending.extend(frames.into_iter().skip(1)); + return Ok(first); + } + } + } + + /// The next frame, and it has to be the one the protocol says comes now. + fn expect(&mut self, packet_id: i32) -> Result { + let frame = self.recv()?; + if frame.packet_id == DISCONNECT { + return Err(Self::disconnect_error(&frame)); + } + if frame.packet_id != packet_id { + return Err(Error::Protocol(format!( + "expected packet 0x{packet_id:02X}, got 0x{:02X}", + frame.packet_id + ))); + } + Ok(frame) + } + + fn disconnect_error(frame: &Frame) -> Error { + match Disconnect::read(&frame.body) { + Ok(disconnect) => Error::Protocol(format!("disconnected: {}", disconnect.reason)), + Err(error) => Error::Protocol(format!("disconnected, reason unreadable: {error}")), + } + } + + /// Hello, and the server describing itself. The account travels here + /// because the salt in the reply belongs to that account and the client + /// cannot do anything without it. + pub fn hello(&mut self, account: &str, client_name: &str) -> Result { + let hello = Hello { + protocol_version: VERSION, + account: account.to_owned(), + client_name: client_name.to_owned(), + client_version: 1, + }; + self.send(HELLO, hello.write())?; + let ack = HelloAck::read(&self.expect(HELLO_ACK)?.body)?; + if ack.protocol_version != VERSION { + return Err(Error::Protocol(format!( + "server speaks protocol version {}, this build speaks {VERSION}", + ack.protocol_version + ))); + } + Ok(ack) + } + + /// Redeems a token the launcher or the server handed out. The verdict + /// comes back either way: check [`LoginAck::accepted`] (via + /// [`RsxSession::login`]) or [`AuthAck::accepted`] before going on. + pub fn token_auth(&mut self, account: &str, uuid: &str, token: &str) -> Result { + let request = TokenAuth { + account: account.to_owned(), + uuid: uuid.to_owned(), + token: token.to_owned(), + }; + self.send(TOKEN_AUTH, request.write())?; + AuthAck::read(&self.expect(AUTH_ACK)?.body) + } + + /// The token step of the handshake. A token may be redeemed straight + /// after hello, without any password step: the token is proof enough on + /// its own. + pub fn login(&mut self, account: &str, token: &str) -> Result { + let request = Login { + account: account.to_owned(), + token: token.to_owned(), + }; + self.send(LOGIN, request.write())?; + LoginAck::read(&self.expect(LOGIN_ACK)?.body) + } + + /// Asks to be put in the world and waits for the answer. Frames that + /// were already queued stay queued, in order; anything that arrives + /// before the world join is kept too rather than thrown away. + pub fn join_world(&mut self, join: JoinWorld) -> Result { + self.send(JOIN_WORLD, join.write())?; + + let mut index = 0; + while index < self.pending.len() { + let frame = &self.pending[index]; + if frame.packet_id == DISCONNECT { + let frame = self.pending.remove(index).expect("frame at index"); + return Err(Self::disconnect_error(&frame)); + } + if frame.packet_id == WORLD_JOIN { + let frame = self.pending.remove(index).expect("frame at index"); + return WorldJoin::read(&frame.body); + } + index += 1; + } + + loop { + let frame = self.pump()?; + match frame.packet_id { + DISCONNECT => return Err(Self::disconnect_error(&frame)), + WORLD_JOIN => return WorldJoin::read(&frame.body), + _ => self.pending.push_back(frame), + } + } + } + + /// The next thing the server said. Liveness checks are answered here, + /// on the spot, so the game never has to see them. + pub fn next_event(&mut self) -> Result { + loop { + let frame = self.recv()?; + match frame.packet_id { + PING => { + let ping = Ping::read(&frame.body)?; + self.send(PONG, Pong { id: ping.id }.write())?; + } + DISCONNECT => { + return Ok(Event::Disconnect(Disconnect::read(&frame.body)?.reason)) + } + WORLD_JOIN => return Ok(Event::WorldJoin(WorldJoin::read(&frame.body)?)), + CHUNK_DATA => return Ok(Event::Chunk(ChunkData::read(&frame.body)?)), + BLOCK_CHANGE => return Ok(Event::BlockChange(BlockChange::read(&frame.body)?)), + INVENTORY => return Ok(Event::Inventory(Inventory::read(&frame.body)?)), + INVENTORY_SLOT => { + return Ok(Event::SlotChange(SlotChange::read(&frame.body)?)) + } + SET_GAME_MODE => return Ok(Event::GameMode(GameMode::read(&frame.body)?)), + ENTITY_SPAWN => return Ok(Event::EntitySpawn(EntitySpawn::read(&frame.body)?)), + ENTITY_REMOVE => { + return Ok(Event::EntityRemove(EntityRemove::read(&frame.body)?)) + } + PLAYER_MOVE => return Ok(Event::PlayerMove(PlayerMove::read(&frame.body)?)), + ENTITY_MOVE => return Ok(Event::EntityMove(EntityMove::read(&frame.body)?)), + id => return Ok(Event::Other { id, body: frame.body }), + } + } + } +} + +impl RsxSession { + /// Connects with the connect/read/write timeouts of a handshake; clear + /// them with [`RsxSession::set_io_timeouts`] once the play phase starts, + /// or an idle moment between packets will look like a dead server. + pub fn connect( + addr: std::net::SocketAddr, + timeout: std::time::Duration, + ) -> Result { + let stream = std::net::TcpStream::connect_timeout(&addr, timeout)?; + stream.set_nodelay(true)?; + stream.set_read_timeout(Some(timeout))?; + stream.set_write_timeout(Some(timeout))?; + Ok(Self::new(stream)) + } + + pub fn set_io_timeouts( + &mut self, + read: Option, + write: Option, + ) -> Result<()> { + self.stream.set_read_timeout(read)?; + self.stream.set_write_timeout(write)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rsx::frame::MAGIC; + use horizon_protocol::connection::Pipe; + + /// The server's end of a test: closes the pipe however the thread ends, + /// so a client blocked in `read` sees the end of the stream instead of + /// waiting forever for a server that has already finished. + struct ServerEnd(Pipe); + + impl Drop for ServerEnd { + fn drop(&mut self) { + self.0.shutdown(); + } + } + + /// A terrain chunk the way the reference server builds one: stone below, + /// a grass surface at y=63, air above. + fn terrain_chunk() -> Vec { + let mut raw = vec![0u8; VOLUME]; + for z in 0..CHUNK_WIDTH { + for x in 0..CHUNK_WIDTH { + for y in 0..64 { + raw[chunk_index(x, y, z)] = if y == 63 { 3 } else { 1 }; + } + } + } + raw + } + + /// The reference handshake, mirrored: hello, token login, join — and the + /// answer to the join is batched the way `ClientSession.onJoinWorld` + /// batches it, with the inventory and a packet this build does not parse + /// yet riding along in the same write. + fn serve(stream: &mut Pipe) { + let mut decoder = FrameDecoder::new(DEFAULT_MAX_PAYLOAD); + let mut scratch = [0u8; 8192]; + loop { + let read = match stream.read(&mut scratch) { + Ok(0) | Err(_) => return, + Ok(read) => read, + }; + for frame in decoder.offer(&scratch[..read]).expect("server decode") { + match frame.packet_id { + HELLO => { + let hello = Hello::read(&frame.body).expect("hello body"); + assert_eq!(hello.protocol_version, VERSION); + assert_eq!(hello.account, "dev"); + let ack = HelloAck { + protocol_version: VERSION, + server_name: "RSX1 dev".into(), + motd: "in-memory".into(), + online_players: 1, + max_players: 8, + nonce: vec![0xAB; CHALLENGE_BYTES], + salt: vec![0xCD; SALT_BYTES], + }; + let reply = Frame::new(HELLO_ACK, ack.write().into_inner()).encode(); + stream.write_all(&reply).expect("server write"); + } + LOGIN => { + let login = Login::read(&frame.body).expect("login body"); + assert_eq!(login.account, "dev"); + assert_eq!(login.token, "dev-token-value"); + let ack = LoginAck::ok(1, 1); + let reply = Frame::new(LOGIN_ACK, ack.write().into_inner()).encode(); + stream.write_all(&reply).expect("server write"); + } + JOIN_WORLD => { + let join = JoinWorld::read(&frame.body).expect("join body"); + assert!(!join.has_position, "a first join carries no position"); + let world = WorldJoin { + entity_id: 1, + seed: 1234567890123, + spawn_x: 0.5, + spawn_y: 64.0, + spawn_z: 0.5, + view_distance: 8, + world_height: 256, + game_mode: GAMEMODE_CREATIVE, + }; + let inventory = Inventory { + slots: (0..6) + .map(|i| Slot { + item: i, + count: i, + condition: -1, + }) + .collect(), + held: 0, + armor: Vec::new(), + }; + let chunk = ChunkData::new(-3, 9, terrain_chunk()).unwrap(); + // KNOWN_RECIPES (0x25): real, not parsed yet + let mut recipes = Buf::new(); + recipes.write_var_int(0); + + let mut batch = Vec::new(); + batch.extend(Frame::new(WORLD_JOIN, world.write().into_inner()).encode()); + batch.extend( + Frame::new(INVENTORY, inventory.write().into_inner()).encode(), + ); + batch.extend(Frame::new(0x25, recipes.into_inner()).encode()); + batch.extend(Frame::new(CHUNK_DATA, chunk.write().into_inner()).encode()); + stream.write_all(&batch).expect("server write"); + return; + } + other => panic!("unexpected packet 0x{other:02X}"), + } + } + } + } + + #[test] + fn logs_in_and_receives_a_chunk_over_an_in_memory_pipe() { + let (client_end, server_end) = Pipe::pair(); + let server = std::thread::spawn(move || { + let mut end = ServerEnd(server_end); + serve(&mut end.0); + }); + + let mut session = RsxSession::new(client_end); + + let hello = session.hello("dev", "horizon-rs").expect("hello"); + assert_eq!(hello.protocol_version, VERSION, "version agreed"); + assert_eq!(hello.server_name, "RSX1 dev"); + assert_eq!(hello.nonce.len(), CHALLENGE_BYTES); + assert_eq!(hello.salt.len(), SALT_BYTES); + + let login = session + .login("dev", "dev-token-value") + .expect("login reply"); + assert!(login.accepted, "logged in: {}", login.reason); + assert_eq!((login.player_id, login.entity_id), (1, 1)); + + let world = session.join_world(JoinWorld::fresh()).expect("join"); + assert_eq!(world.seed, 1234567890123, "the seed came across"); + assert_eq!(world.game_mode, GAMEMODE_CREATIVE); + assert_eq!(world.world_height, 256); + assert_eq!(world.view_distance, 8); + assert_eq!((world.spawn_x, world.spawn_y), (0.5, 64.0)); + + match session.next_event().expect("inventory") { + Event::Inventory(inventory) => { + assert_eq!(inventory.slots.len(), 6); + assert_eq!(inventory.slots[3].item, 3); + assert_eq!(inventory.held, 0); + } + other => panic!("expected the inventory, got {other:?}"), + } + + let chunk = loop { + match session.next_event().expect("next event") { + Event::Chunk(chunk) => break chunk, + // the packets between inventory and chunks are kept, not dropped + Event::Other { id, .. } => assert_eq!(id, 0x25, "known recipes ride along"), + other => panic!("expected the chunk, got {other:?}"), + } + }; + assert_eq!((chunk.chunk_x, chunk.chunk_z), (-3, 9)); + assert_eq!(chunk.blocks.len(), VOLUME, "a whole chunk arrived"); + assert_eq!(chunk.blocks[chunk_index(0, 63, 0)], 3, "grass on top"); + assert_eq!(chunk.blocks[chunk_index(5, 10, 5)], 1, "stone below"); + assert_eq!(chunk.blocks[chunk_index(0, 200, 0)], 0, "air above"); + + // the server is finished: its end closes and the next read sees the + // end of the stream rather than hanging + match session.next_event() { + Err(Error::Io(_)) => {} + other => panic!("end of stream was {other:?}"), + } + + server.join().expect("server thread"); + } + + #[test] + fn a_foreign_stream_is_refused_before_anything_is_sent() { + // what a status probe or any non-RSX1 client looks like: the magic + // is checked before a single body byte is touched + let mut bytes = Vec::new(); + bytes.extend_from_slice(&0x0000_0115u32.to_be_bytes()); + bytes.extend_from_slice(&1u64.to_be_bytes()); + match Frame::decode(&bytes, DEFAULT_MAX_PAYLOAD) { + Err(Error::Protocol(message)) => { + assert!(message.contains("magic"), "wrong message: {message}") + } + other => panic!("foreign stream was {other:?}"), + } + assert_ne!(MAGIC, 0x0000_0115, "ours is RSX1, not the status magic"); + } + + #[test] + fn queued_frames_keep_their_order_across_a_batched_write() { + let (client_end, server_end) = Pipe::pair(); + let mut server = server_end; + let mut client = RsxSession::new(client_end); + + // three frames in one write, before the client asks for anything + let mut batch = Vec::new(); + batch.extend( + Frame::new( + INVENTORY, + Inventory { + slots: vec![Slot { + item: 4, + count: 2, + condition: -1, + }], + held: 2, + armor: Vec::new(), + } + .write() + .into_inner(), + ) + .encode(), + ); + batch.extend(Frame::new(SET_GAME_MODE, GameMode { mode: 1 }.write().into_inner()).encode()); + batch.extend( + Frame::new( + INVENTORY_SLOT, + SlotChange { + slot: 2, + item: 7, + count: 7, + condition: -1, + } + .write() + .into_inner(), + ) + .encode(), + ); + server.write_all(&batch).expect("write batch"); + + // the client sees them one by one, in the order they were written + match client.next_event().expect("first") { + Event::Inventory(inventory) => { + assert_eq!(inventory.held, 2); + assert_eq!(inventory.slots[0].item, 4); + } + other => panic!("first was {other:?}"), + } + match client.next_event().expect("second") { + Event::GameMode(mode) => assert!(mode.creative()), + other => panic!("second was {other:?}"), + } + match client.next_event().expect("third") { + Event::SlotChange(change) => assert_eq!(change.slot, 2), + other => panic!("third was {other:?}"), + } + + // and a disconnect is reported with its reason, not as a mystery + let bye = Disconnect { + reason: "server is closing".into(), + }; + server + .write_all(&Frame::new(DISCONNECT, bye.write().into_inner()).encode()) + .expect("write bye"); + match client.next_event().expect("goodbye") { + Event::Disconnect(reason) => assert_eq!(reason, "server is closing"), + other => panic!("goodbye was {other:?}"), + } + } + + /// The whole handshake against a real, running Java rsx-server: the + /// claim that "the protocol round trips in a unit test" and "somebody + /// can actually log in" are different claims, and this is the second. + /// + /// Run with a server up (see `tools/watch-game.ps1 -ServerOnly`, which + /// starts `ServerMain --port 25599 --dev-token Player` and writes the + /// token to `rsx-server-core\world\dev-token.txt`): + /// + /// ```text + /// $env:RSX_DEV_TOKEN = '' + /// cargo test -p horizon-client rsx -- --ignored --nocapture + /// ``` + /// + /// Optional: `RSX_ADDR` (default `127.0.0.1:25599`) and `RSX_ACCOUNT` + /// (default `Player`). The token is per-run: the server makes a new + /// secret every start, so a token from an earlier run is refused. + #[test] + #[ignore = "needs a running RSX server on 127.0.0.1:25599"] + fn logs_into_a_live_java_rsx_server() { + let addr: std::net::SocketAddr = std::env::var("RSX_ADDR") + .unwrap_or_else(|_| "127.0.0.1:25599".into()) + .parse() + .expect("RSX_ADDR must be host:port"); + let account = std::env::var("RSX_ACCOUNT").unwrap_or_else(|_| "Player".into()); + let token = std::env::var("RSX_DEV_TOKEN").expect( + "set RSX_DEV_TOKEN to the contents of rsx-server-core\\world\\dev-token.txt", + ); + + let mut session = + RsxSession::connect(addr, std::time::Duration::from_secs(10)).expect("connect"); + println!("connected to {addr}"); + + let hello = session.hello(&account, "horizon-rs").expect("hello"); + println!( + "server: {} - {} ({}/{}), version {}", + hello.server_name, hello.motd, hello.online_players, hello.max_players, + hello.protocol_version + ); + assert_eq!(hello.protocol_version, VERSION, "version agreed"); + + let login = session.login(&account, &token).expect("login reply"); + assert!(login.accepted, "login refused: {}", login.reason); + println!( + "logged in as {account}: player id {}, entity id {}", + login.player_id, login.entity_id + ); + + // play phase: a quiet moment is a quiet moment, not a dead server, + // but it still has to end in bounded time + session + .set_io_timeouts(Some(std::time::Duration::from_secs(5)), None) + .expect("read timeout"); + + let world = session.join_world(JoinWorld::fresh()).expect("join"); + println!( + "world: entity {}, seed {}, spawn {:.1}/{:.1}/{:.1}, view {}, height {}, game mode {}", + world.entity_id, world.seed, world.spawn_x, world.spawn_y, world.spawn_z, + world.view_distance, world.world_height, world.game_mode + ); + assert_eq!(world.world_height, 256, "the reference world is 256 tall"); + + let started = std::time::Instant::now(); + let deadline = started + std::time::Duration::from_secs(60); + let (mut chunks, mut events) = (0usize, 0usize); + let mut other_ids: Vec = Vec::new(); + loop { + if std::time::Instant::now() >= deadline { + println!("deadline reached, stopping"); + break; + } + match session.next_event() { + Ok(Event::Chunk(chunk)) => { + assert_eq!( + chunk.blocks.len(), + VOLUME, + "chunk {} {} came out wrong", + chunk.chunk_x, + chunk.chunk_z + ); + chunks += 1; + } + Ok(Event::Inventory(inventory)) => { + println!( + "inventory: {} slots, held {}, {} worn", + inventory.slots.len(), + inventory.held, + inventory.armor.len() + ); + events += 1; + } + Ok(Event::Disconnect(reason)) => { + panic!("the server disconnected us: {reason}"); + } + Ok(Event::Other { id, .. }) => { + if !other_ids.contains(&id) { + other_ids.push(id); + } + events += 1; + } + Ok(_) => events += 1, + Err(Error::Io(error)) + if matches!( + error.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) => + { + println!("quiet for five seconds, that is everything"); + break; + } + Err(error) => panic!("read failed: {error}"), + } + } + println!( + "received: {chunks} chunks, {events} other events, unparsed ids {other_ids:?} in {:.1}s", + started.elapsed().as_secs_f32() + ); + assert!(chunks > 0, "the server streams chunks on join"); + + let _ = session.send( + DISCONNECT, + Disconnect { + reason: "test finished".into(), + } + .write(), + ); + println!("said goodbye"); + } +} diff --git a/crates/horizon-client/src/rsx_terrain.rs b/crates/horizon-client/src/rsx_terrain.rs new file mode 100644 index 0000000..47207a1 --- /dev/null +++ b/crates/horizon-client/src/rsx_terrain.rs @@ -0,0 +1,384 @@ +//! The RSX world as a Horizon chunk generator. +//! +//! The server owns the world; the client builds the same columns locally. +//! A `WORLD_JOIN` carries the seed, so the client generates each column the +//! way the server generated it and then applies the diffs the server sends +//! on top (`BLOCK_CHANGE`), which is why this is a generator rather than a +//! remote world: `Streaming::remote` stays false and only the changed blocks +//! cross the wire. +//! +//! One column is `16 x 256 x 16` bytes of RSX block ids (see +//! [`crate::rsx::packets`]) and a `Chunk` is the same column in Horizon's +//! shape: the ids become block state ids and the y range grows from +//! `0..256` to `-64..320`. The mapping is 1:1 on y — the server's spawn, +//! movement and block change coordinates all stay valid as they are, and +//! the extra sky above y=255 is simply air. +//! +//! The RSX block ids have no vanilla counterpart for the last four +//! (barrels and a crate): they are mapped to the nearest breakable block +//! until the real blocks exist in the registry, and every one of them is +//! written down here rather than guessed at the call site. + +use crate::chunk::Chunk; +use crate::streaming::{ChunkGenerator, Proto}; +use horizon_world::registry::BlockRegistry; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +/// What one RSX block id becomes, as a block state id in this registry. +/// +/// Resolved once, when the world is entered, and then read on every column: +/// the names are the protocol's own (`Blocks.name`), and the two ids are +/// never allowed to drift apart because the table is filled by looking the +/// names up rather than by hardcoding state ids. +pub struct RsxBlockTable { + states: [u32; RSX_BLOCK_COUNT], +} + +/// How many block ids the RSX wire format defines (0 = air .. 13 = crate). +pub const RSX_BLOCK_COUNT: usize = 14; + +impl RsxBlockTable { + /// The table for a registry, resolved once. Fails loudly rather than + /// silently mapping a block to air: a missing name means the client + /// would build a different world than the server. + pub fn new(registry: &BlockRegistry) -> crate::Result { + let id = |name: &str| -> crate::Result { + Ok(registry + .default_state(name) + .ok_or_else(|| format!("missing {name}"))? + .0) + }; + Ok(Self { + states: [ + id("minecraft:air")?, + id("minecraft:stone")?, + id("minecraft:dirt")?, + id("minecraft:grass_block")?, + id("minecraft:sand")?, + id("minecraft:water")?, + id("minecraft:oak_log")?, + id("minecraft:oak_leaves")?, + id("minecraft:black_concrete")?, + id("minecraft:snow_block")?, + // the barrels and the crate are what the server's economy is + // built from and vanilla has nothing for them: three blocks + // that look right and break right stand in until the real + // ones exist + id("minecraft:blue_shulker_box")?, + id("minecraft:red_shulker_box")?, + id("minecraft:orange_shulker_box")?, + id("minecraft:hay_block")?, + ], + }) + } + + /// The state an RSX block id stands for. Ids the wire format does not + /// define are air rather than a panic: a peer can send any byte. + pub fn state(&self, id: u8) -> u32 { + self.states + .get(usize::from(id)) + .copied() + .unwrap_or(self.states[0]) + } +} + +/// What the game holds of the server world, for the generator and for the +/// packets that change it. +/// +/// The columns are shared because the two meet on different threads: the +/// streamer's workers read them to build chunks, and the session thread +/// writes them as `BLOCK_CHANGE` arrives. +pub struct RsxWorldShared { + pub seed: i64, + columns: Mutex>>>, +} + +/// The RSX world: a seed and every column the server has made so far. +/// +/// `Arc`'d because the streamer keeps a reference for the life of the world +/// while the session thread keeps its own, and both need to reach the same +/// columns. +pub struct RsxWorld(pub Arc); + +impl RsxWorld { + pub fn new(seed: i64) -> Self { + Self(Arc::new(RsxWorldShared { + seed, + columns: Mutex::new(HashMap::new()), + })) + } + + /// Puts a whole column in, as `CHUNK_DATA` carries it. The data is + /// copied once and shared from then on: every chunk built from it and + /// every later edit sees the same bytes until the next write. + pub fn insert_column(&self, chunk_x: i32, chunk_z: i32, blocks: Vec) { + debug_assert_eq!( + blocks.len(), + crate::rsx::packets::VOLUME, + "an RSX column is 16x256x16 bytes" + ); + self.0 + .columns + .lock() + .expect("rsx columns") + .insert((chunk_x, chunk_z), Arc::new(blocks)); + } + + /// One block changed, in world coordinates. The y range of the wire is + /// the y range of the world here (see the module docs), so nothing is + /// shifted. A change outside every known column is ignored: it belongs + /// to a column the server has not sent yet, and the column arrives with + /// the change already in it. + pub fn set_block(&self, x: i32, y: i32, z: i32, block: u8) { + if !(0..256).contains(&y) { + return; + } + let key = (x >> 4, z >> 4); + let mut columns = self.0.columns.lock().expect("rsx columns"); + let Some(column) = columns.get_mut(&key) else { + return; + }; + let index = crate::rsx::packets::chunk_index( + (x & 15) as usize, + y as usize, + (z & 15) as usize, + ); + // one column is shared between the workers that read it and this + // thread; the first edit of a column copies it, the rest write in + // place + let column = Arc::make_mut(column); + column[index] = block; + } +} + +/// The streaming side of [`RsxWorld`]: a column is whatever the server has +/// said it is, and nothing is generated out of thin air. +/// +/// A column the server has not sent yet comes out as air. That is the +/// honest answer — the client does not know the terrain before the server +/// sends the column — and the streamer fills it in later when the column +/// arrives through [`RsxWorld::insert_column`]. +pub struct RsxTerrain { + shared: Arc, + table: RsxBlockTable, +} + +impl RsxTerrain { + pub fn new(shared: Arc, table: RsxBlockTable) -> Self { + Self { shared, table } + } + + fn column(&self, cx: i32, cz: i32) -> Option>> { + self.shared + .columns + .lock() + .expect("rsx columns") + .get(&(cx, cz)) + .cloned() + } +} + +impl ChunkGenerator for RsxTerrain { + type Worker = (); + fn worker(&self) {} + + /// No far view of this world yet: the LOD rings of the reference client + /// are their own packet stream, and until that is ported the horizon is + /// simply empty rather than wrong. + fn lod_surface(&self, _: &mut (), _: i32, _: i32) -> Option { + None + } + + fn proto(&self, _: &mut (), cx: i32, cz: i32) -> Proto { + let mut chunk = Chunk::empty(cx, cz); + // One biome for the whole column: RSX sends no biomes, and the + // server's own generator is a heightmap, not a biome map. The first + // biome of the registry stands for the world until biomes exist — + // it colours the grass and nothing in this protocol depends on it. + for s in &mut chunk.sections { + std::sync::Arc::make_mut(s).biomes = horizon_core::paletted::PalettedContainer::new( + crate::chunk::BIOME_STRATEGY, + 0, + ); + } + if let Some(blocks) = self.column(cx, cz) { + for z in 0..16i32 { + for x in 0..16i32 { + for y in 0..256 { + let id = blocks[crate::rsx::packets::chunk_index( + x as usize, + y as usize, + z as usize, + )]; + if id == 0 { + continue; + } + chunk.set(x, y as i32, z, self.table.state(id)); + } + } + } + } + for s in &mut chunk.sections { + std::sync::Arc::make_mut(s).compact(); + } + Box::new(chunk) + } + + fn finish(&self, proto: Proto) -> Chunk { + let mut chunk = *proto + .downcast::() + .expect("rsx terrain proto is a chunk"); + // The column came from the server, so it is not the client's to + // save: `fresh` stays false and nothing is written to disk behind + // the server's back. + chunk.fresh = false; + chunk + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rsx::packets::chunk_index; + use horizon_world::registry::BlockStateId; + + /// A registry of exactly the blocks the table names, so the test shows + /// the mapping rather than the assets of whichever pack is installed. + fn test_registry() -> BlockRegistry { + let state = |name: &str| horizon_world::chunk::BlockState { + name: name.to_owned(), + properties: Default::default(), + }; + let names = [ + "minecraft:air", + "minecraft:stone", + "minecraft:dirt", + "minecraft:grass_block", + "minecraft:sand", + "minecraft:water", + "minecraft:oak_log", + "minecraft:oak_leaves", + "minecraft:black_concrete", + "minecraft:snow_block", + "minecraft:blue_shulker_box", + "minecraft:red_shulker_box", + "minecraft:orange_shulker_box", + "minecraft:hay_block", + ]; + let states: Vec<_> = names.iter().map(|n| state(n)).collect(); + let defaults: std::collections::BTreeMap = names + .iter() + .enumerate() + .map(|(i, n)| (n.to_string(), i as i64)) + .collect(); + BlockRegistry::from_states(states, defaults).expect("test registry") + } + + /// A column the server might have sent: stone below, a grass surface at + /// y=63, a barrel to find later. + fn test_column() -> Vec { + let mut raw = vec![0u8; crate::rsx::packets::VOLUME]; + for z in 0..16 { + for x in 0..16 { + for y in 0..64 { + raw[chunk_index(x, y, z)] = if y == 63 { 3 } else { 1 }; + } + } + } + raw[chunk_index(5, 64, 5)] = 10; + raw + } + + fn terrain() -> (RsxWorld, RsxTerrain) { + let world = RsxWorld::new(42); + let terrain = RsxTerrain::new( + world.0.clone(), + RsxBlockTable::new(&test_registry()).expect("table"), + ); + (world, terrain) + } + + #[test] + fn every_wire_id_resolves_to_its_own_block() { + let table = RsxBlockTable::new(&test_registry()).expect("table"); + let registry = test_registry(); + let name = |id: u8| registry.state(BlockStateId(table.state(id))).unwrap().name.clone(); + assert_eq!(name(0), "minecraft:air"); + assert_eq!(name(1), "minecraft:stone"); + assert_eq!(name(3), "minecraft:grass_block"); + assert_eq!(name(5), "minecraft:water"); + assert_eq!(name(10), "minecraft:blue_shulker_box"); + assert_eq!(name(13), "minecraft:hay_block"); + // an id past the end of the wire format is air, not a panic + assert_eq!(table.state(14), table.state(0)); + assert_eq!(table.state(200), table.state(0)); + } + + #[test] + fn a_column_from_the_server_becomes_a_chunk() { + let (world, terrain) = terrain(); + world.insert_column(-3, 9, test_column()); + + let proto = terrain.proto(&mut (), -3, 9); + let chunk = *proto.downcast::().expect("proto"); + assert_eq!((chunk.x, chunk.z), (-3, 9)); + assert!(!chunk.fresh, "a server column is not the client's to save"); + + let at = |x: i32, y: i32, z: i32| chunk.get(x, y, z); + assert_eq!(at(0, 0, 0), terrain.table.state(1), "stone below"); + assert_eq!(at(0, 63, 0), terrain.table.state(3), "grass on top"); + assert_eq!(at(5, 64, 5), terrain.table.state(10), "the barrel"); + assert_eq!(at(0, 100, 0), 0, "air above"); + assert_eq!(at(0, 300, 0), 0, "air above the RSX world too"); + } + + #[test] + fn a_column_the_server_has_not_sent_yet_is_air() { + let (_world, terrain) = terrain(); + let proto = terrain.proto(&mut (), 0, 0); + let chunk = *proto.downcast::().expect("proto"); + assert_eq!(chunk.get(0, 63, 0), 0, "nothing is guessed"); + assert_eq!(chunk.get(0, -64, 0), 0, "the floor is not invented"); + } + + #[test] + fn a_block_change_edits_the_column_the_next_chunk_is_built_from() { + let (world, terrain) = terrain(); + world.insert_column(0, 0, test_column()); + + // the server says the grass at 4/63/4 is gone and there is a crate + world.set_block(4, 63, 4, 0); + world.set_block(4, 64, 4, 13); + + let proto = terrain.proto(&mut (), 0, 0); + let chunk = *proto.downcast::().expect("proto"); + assert_eq!(chunk.get(4, 63, 4), 0, "the grass is gone"); + assert_eq!(chunk.get(4, 64, 4), terrain.table.state(13), "the crate"); + assert_eq!(chunk.get(5, 63, 5), terrain.table.state(3), "the rest is untouched"); + } + + #[test] + fn a_change_outside_the_world_or_a_missing_column_is_ignored() { + let (world, terrain) = terrain(); + world.insert_column(0, 0, test_column()); + world.set_block(100, 300, 100, 7); + world.set_block(1, 1, 1, 7); + world.set_block(500, 5, 500, 7); + + let proto = terrain.proto(&mut (), 0, 0); + let chunk = *proto.downcast::().expect("proto"); + assert_eq!(chunk.get(1, 1, 1), terrain.table.state(7), "a real edit"); + assert_eq!(chunk.get(1, 200, 1), 0, "nothing was written above the world"); + } + + #[test] + fn finish_keeps_the_column_as_the_server_sent_it() { + let (world, terrain) = terrain(); + world.insert_column(0, 0, test_column()); + let proto = terrain.proto(&mut (), 0, 0); + let chunk = terrain.finish(proto); + assert!(!chunk.fresh); + assert_eq!(chunk.get(0, 63, 0), terrain.table.state(3)); + } +} diff --git a/crates/horizon-client/src/rustui/icons.rs b/crates/horizon-client/src/rustui/icons.rs new file mode 100644 index 0000000..bc0d04d --- /dev/null +++ b/crates/horizon-client/src/rustui/icons.rs @@ -0,0 +1,86 @@ +//! The menu's outline icons: `@thesvg/ui` (MIT), rendered white at 64 px and +//! packed into the atlas as `horizon/rustui-icons/` by `HudAtlas::pack`. +//! Icons are tinted by the draw colour, so one white sheet serves every state. +use crate::ui_theme::Ui; + +/// Every icon in the sheet: its name and its PNG. +pub static ICONS: &[(&str, &[u8])] = &[ + ("house", include_bytes!("../../assets/rustui/icons/house.png")), + ("play", include_bytes!("../../assets/rustui/icons/play.png")), + ("shopping-cart", include_bytes!("../../assets/rustui/icons/shopping-cart.png")), + ("package", include_bytes!("../../assets/rustui/icons/package.png")), + ("settings", include_bytes!("../../assets/rustui/icons/settings.png")), + ("log-out", include_bytes!("../../assets/rustui/icons/log-out.png")), + ("search", include_bytes!("../../assets/rustui/icons/search.png")), + ("users", include_bytes!("../../assets/rustui/icons/users.png")), + ("pin", include_bytes!("../../assets/rustui/icons/pin.png")), + ("refresh-cw", include_bytes!("../../assets/rustui/icons/refresh-cw.png")), + ("chevron-down", include_bytes!("../../assets/rustui/icons/chevron-down.png")), + ("chevron-right", include_bytes!("../../assets/rustui/icons/chevron-right.png")), + ("arrow-left", include_bytes!("../../assets/rustui/icons/arrow-left.png")), + ("arrow-right", include_bytes!("../../assets/rustui/icons/arrow-right.png")), + ("list", include_bytes!("../../assets/rustui/icons/list.png")), + ("shield", include_bytes!("../../assets/rustui/icons/shield.png")), + ("monitor", include_bytes!("../../assets/rustui/icons/monitor.png")), + ("keyboard", include_bytes!("../../assets/rustui/icons/keyboard.png")), + ("mouse-pointer-click", include_bytes!("../../assets/rustui/icons/mouse-pointer-click.png")), + ("globe", include_bytes!("../../assets/rustui/icons/globe.png")), + ("eye", include_bytes!("../../assets/rustui/icons/eye.png")), + ("sliders-horizontal", include_bytes!("../../assets/rustui/icons/sliders-horizontal.png")), + ("headphones", include_bytes!("../../assets/rustui/icons/headphones.png")), + ("sparkles", include_bytes!("../../assets/rustui/icons/sparkles.png")), + ("wrench", include_bytes!("../../assets/rustui/icons/wrench.png")), + ("hand", include_bytes!("../../assets/rustui/icons/hand.png")), + ("chat-thread", include_bytes!("../../assets/rustui/icons/chat-thread.png")), + ("type", include_bytes!("../../assets/rustui/icons/type.png")), + ("layers", include_bytes!("../../assets/rustui/icons/layers.png")), + ("boxes", include_bytes!("../../assets/rustui/icons/boxes.png")), + ("wand", include_bytes!("../../assets/rustui/icons/wand.png")), + ("settings-2", include_bytes!("../../assets/rustui/icons/settings-2.png")), + ("bot", include_bytes!("../../assets/rustui/icons/bot.png")), + ("award", include_bytes!("../../assets/rustui/icons/award.png")), + ("newspaper", include_bytes!("../../assets/rustui/icons/newspaper.png")), + ("user", include_bytes!("../../assets/rustui/icons/user.png")), + ("x", include_bytes!("../../assets/rustui/icons/x.png")), + ("check", include_bytes!("../../assets/rustui/icons/check.png")), + ("plus", include_bytes!("../../assets/rustui/icons/plus.png")), + ("bell", include_bytes!("../../assets/rustui/icons/bell.png")), + ("message-square", include_bytes!("../../assets/rustui/icons/message-square.png")), + ("external-link", include_bytes!("../../assets/rustui/icons/external-link.png")), + ("circle-alert", include_bytes!("../../assets/rustui/icons/circle-alert.png")), + ("info", include_bytes!("../../assets/rustui/icons/info.png")), + ("star", include_bytes!("../../assets/rustui/icons/star.png")), + ("activity", include_bytes!("../../assets/rustui/icons/activity.png")), + ("server", include_bytes!("../../assets/rustui/icons/server.png")), + ("trash", include_bytes!("../../assets/rustui/icons/trash.png")), +]; + +/// The atlas sprite of `name`. +pub fn sprite(name: &str) -> String { + format!("horizon/rustui-icons/{name}") +} + +/// Draws `name` in the `rect` (dp), centred, tinted by `color`. +pub fn draw(ui: &mut Ui, name: &str, [x, y, w, h]: [f32; 4], color: u32) { + let Some(rect) = ui.atlas.sprites.get(&sprite(name)).copied() else { + return; + }; + let size = w.min(h); + let (dx, dy) = ((w - size) / 2.0, (h - size) / 2.0); + let (gx, gy, gs) = (ui.to_gui(x + dx), ui.to_gui(y + dy), ui.to_gui(size)); + ui.batch.quad( + [ + [gx, gy], + [gx + gs, gy], + [gx + gs, gy + gs], + [gx, gy + gs], + ], + [ + [rect.u0, rect.v0], + [rect.u1, rect.v0], + [rect.u1, rect.v1], + [rect.u0, rect.v1], + ], + color, + ); +} diff --git a/crates/horizon-client/src/rustui/mod.rs b/crates/horizon-client/src/rustui/mod.rs new file mode 100644 index 0000000..4775c82 --- /dev/null +++ b/crates/horizon-client/src/rustui/mod.rs @@ -0,0 +1,1778 @@ +//! Horizon's main menu in the Rust style: the scene itself behind a grey +//! scrim, a bar of tab plates on top, quick connect and the devblog on the +//! right, a news carousel and the chat strip along the bottom. No wordmark: +//! the tabs are the first thing in the bar. +//! +//! One pure [`RustMenu::layout`] in dp returns the frame's [`Node`]s; the +//! draw paints them and [`RustMenu::click`] hit-tests them. The layout is +//! cached until the frame, the tab or the server list changes, so a frame +//! does no formatting or measuring — only painting. +pub mod icons; +pub mod settings; +pub mod theme; + +use crate::hud::{HudAtlas, HudBatch}; +use crate::ui_theme::{Face, Ui}; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +/// What a locked control says in its tooltip. +pub const TIP: &str = "В разработке"; + +/// The top bar's height. +const TOP: f32 = 54.0; +/// The page margin. +const PAD: f32 = 24.0; +const TAB_H: f32 = 34.0; +const TAB_W: f32 = 100.0; +const TAB_GAP: f32 = 2.0; +/// Quick connect. +const QUICK_W: f32 = 320.0; +const QUICK_HEAD: f32 = 30.0; +const QUICK_ROW: f32 = 56.0; +/// The devblog block. +const BLOG_W: f32 = 430.0; +const BLOG_H: f32 = 186.0; +/// The news carousel. +const NEWS_W: f32 = 280.0; +const NEWS_H: f32 = 158.0; +/// The chat strip and the account plate. +const STRIP_H: f32 = 34.0; +const ACCOUNT_W: f32 = 34.0; +/// Between the bottom strip and the cards above it. +const GAP: f32 = 14.0; +/// The Play tab's category strip. +const FILTER_H: f32 = 40.0; +/// How long the content slides when the tab changes. +const SLIDE: f32 = 0.14; +/// How long a news slide stays before the carousel advances. +const NEWS_DWELL: f32 = 8.0; + +/// The tabs that are not built yet: grey plates with a tip. +const LOCKED: [(&str, &str); 2] = [("МАГАЗИН", "shopping-cart"), ("ИНВЕНТАРЬ", "package")]; +/// The carousel's placeholder slides. +const NEWS: [(&str, &str); 3] = [ + ( + "НОВОЕ МЕНЮ", + "Главное меню и настройки переписаны в стиле Rust.", + ), + ( + "МАГАЗИН", + "Скоро: магазин, инвентарь и награды.", + ), + ( + "СЕРВЕРЫ", + "Быстрое подключение к сохранённым серверам.", + ), +]; + +/// The top bar's tabs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Tab { + Home, + /// The server or world the menu is open over (the Rust pause menu). + Current, + Play, +} +impl Tab { + fn index(self) -> u8 { + match self { + Tab::Home | Tab::Current => 0, + Tab::Play => 1, + } + } +} + +/// A press a node reports. `Title` maps these onto its actions. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Hit { + /// Switch the menu's tab in place. + Tab(Tab), + Quit, + Options, + /// A saved world, the singleplayer list. + LocalWorlds, + /// Connect to this address. + Connect(String), + /// The news carousel. + NewsPrev, + NewsNext, + /// The browser's filters and actions. + Toggle(&'static str), + ResetFilters, + Refresh, + /// Open the vanilla server list (adding a server). + Servers, + /// A server's details card. + OpenServer(usize), + CloseModal, + /// The pause menu's actions. + Resume, + Stats, + Advancements, + WorldOptions, +} + +/// What a press asks the shell to do; tabs and the carousel stay inside the +/// menu and report [`Outcome::None`]. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Outcome { + #[default] + None, + Quit, + Options, + LocalWorlds, + Connect(String), + /// The vanilla server list, for adding or editing servers. + Servers, + /// The pause menu's actions. + Resume, + Stats, + Advancements, + WorldOptions, +} + +/// How a plate is painted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Variant { + /// A plain plate, no border: quick-connect rows. + Solid, + /// The amber primary action. + Primary, + /// Nothing until hovered: the bar's actions, the devblog's second button. + Ghost, + /// A badge or a locked tab. + Chip, + /// A top-bar tab, lit while active. + Tab { active: bool }, + /// A square icon button. + Icon, + /// A checkbox row: a box and its label. + Check { on: bool }, + /// Hit-testing only (a card is drawn from its own parts). + Invisible, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Align { + Left, + Center, + Right, +} + +/// One laid-out piece of the menu. +#[derive(Clone, Debug)] +pub enum Node { + /// A card plate with a hairline. + Panel { rect: [f32; 4] }, + /// A sunk plate: the chat strip, the account. + Well { rect: [f32; 4] }, + /// A vertical gradient (the news picture, the devblog backing). + Shade { rect: [f32; 4], top: u32, bottom: u32 }, + Line { rect: [f32; 4], color: u32 }, + Text { + rect: [f32; 4], + face: Face, + size: f32, + text: String, + color: u32, + align: Align, + }, + Icon { + rect: [f32; 4], + name: &'static str, + color: u32, + }, + Button { + rect: [f32; 4], + label: String, + icon: Option<&'static str>, + face: Face, + size: f32, + variant: Variant, + /// The press this plate fires; `None` for badges and locked controls. + id: Option, + enabled: bool, + /// Why the press is refused. + tip: Option<&'static str>, + }, +} +impl Node { + pub fn rect(&self) -> [f32; 4] { + match self { + Self::Panel { rect } | Self::Well { rect } => *rect, + Self::Shade { rect, .. } => *rect, + Self::Line { rect, .. } => *rect, + Self::Text { rect, .. } | Self::Icon { rect, .. } | Self::Button { rect, .. } => *rect, + } + } +} + +/// One menu frame: the page in dp, the density and GUI scale that produced it, +/// and the cursor in GUI pixels. +pub struct Frame { + pub w: f32, + pub h: f32, + density: f32, + scale: f32, + cursor: (f32, f32), +} +impl Frame { + pub fn new((w, h): (f32, f32), scale: f32, cursor: (f32, f32)) -> Self { + let (pw, ph) = (w * scale, h * scale); + let density = crate::ui_theme::density(pw, ph); + Self { + w: pw / density, + h: ph / density, + density, + scale, + cursor, + } + } + /// The cursor in dp. + pub fn at(&self) -> (f32, f32) { + ( + self.cursor.0 * self.scale / self.density, + self.cursor.1 * self.scale / self.density, + ) + } + /// dp in GUI pixels. + pub fn to_gui(&self, v: f32) -> f32 { + v * self.density / self.scale + } +} + +/// What only the shell knows: the title's uptime (the carousel and the slide). +#[derive(Clone, Copy)] +pub struct Meta { + pub elapsed: f32, +} + +/// The session the menu is open over (the Rust pause menu, later). +#[derive(Clone, Debug, PartialEq)] +pub struct CurrentServer { + pub name: String, + pub address: String, + pub singleplayer: bool, +} + +/// A saved server's status, filled in by `net::ping` threads. +#[derive(Clone, Debug, Default)] +struct ServerPing { + /// 0: still pinging, 1: answered, 2: failed. + state: u8, + online: i64, + max: i64, + ms: u128, + version: String, +} + +/// The client's main menu. +pub struct RustMenu { + /// The selected tab; a tab press switches it in place. + pub tab: Tab, + /// The saved servers, newest first (`saves/servers.dat`). + pub saved: Vec<(String, String)>, + /// The world or server the menu is open over; `None` at the title. + pub current: Option, + /// The last layout; `None` until the first draw or after a change. + cache: RefCell>, + /// When the open tab was entered, and which one it was. + tab_at: Cell, + tab_was: Cell, + /// The carousel's slide and when it changed. + news: Cell, + news_at: Cell, + /// The slide the timer was started for. + news_seen: Cell, + /// The first frame's time, so a slide starts at the menu's own zero. + first: Cell>, + /// The Play browser: pings by address, their revision (the layout cache + /// key), whether they were started, the open details card and the filters. + pings: Arc>>, + pings_rev: Arc, + pinged: Cell, + selected: Cell>, + filters: RefCell>, + filters_rev: Cell, +} +struct Cache { + key: Key, + nodes: Vec, +} +#[derive(Clone, PartialEq)] +struct Key { + w: u32, + h: u32, + tab: Tab, + news: usize, + last: String, + pings: u64, + filters: u64, + selected: Option, +} + +impl RustMenu { + /// Reads the multiplayer list for quick connect; an empty `saves` (the + /// engine's tests, a headless shell) has none. + pub fn new(saves: &Path) -> Self { + let mut saved = Vec::new(); + if !saves.as_os_str().is_empty() { + for entry in horizon_protocol::servers::load(&crate::multiplayer::servers_file(saves)) { + saved.push((entry.name, entry.ip)); + } + } + Self { + tab: Tab::Home, + saved, + cache: RefCell::default(), + tab_at: Cell::new(0.0), + tab_was: Cell::new(Tab::Home), + news: Cell::new(0), + news_at: Cell::new(0.0), + news_seen: Cell::new(0), + first: Cell::new(None), + current: None, + pings: Arc::default(), + pings_rev: Arc::new(AtomicU64::new(0)), + pinged: Cell::new(false), + selected: Cell::new(None), + filters: RefCell::default(), + filters_rev: Cell::new(0), + } + } + pub fn with_saved(mut self, saved: Vec<(String, String)>) -> Self { + self.saved = saved; + self.forget_pings(); + self + } + /// Re-reads the saved servers: the multiplayer screens edit the list while + /// they are open, and the menu shows the result when the player comes back. + pub fn reload(&mut self, saves: &Path) { + self.saved = Self::new(saves).saved; + self.forget_pings(); + } + /// Home's quick-connect rows: the last-played address first, then the + /// saved servers, deduplicated by address and capped at three. + pub fn rows(&self, last: &str) -> Vec<(String, String)> { + let mut rows: Vec<(String, String)> = Vec::new(); + let mut push = |name: &str, ip: &str| { + if !ip.is_empty() && rows.len() < 3 && !rows.iter().any(|(_, seen)| seen == ip) { + rows.push((name.to_owned(), ip.to_owned())); + } + }; + if !last.is_empty() { + let name = self + .saved + .iter() + .find(|(_, ip)| ip == last) + .map_or(last, |(name, _)| name.as_str()); + push(name, last); + } + for (name, ip) in &self.saved { + push(name, ip); + } + rows + } + + /// Forgets the pings (the list changed, or the browser asked again). + fn forget_pings(&self) { + if let Ok(mut pings) = self.pings.lock() { + pings.clear(); + } + self.pinged.set(false); + self.cache.borrow_mut().take(); + } + /// Pings every saved server once, in the background. + fn start_pings(&self) { + if self.pinged.get() { + return; + } + self.pinged.set(true); + // Tests never touch the network. + #[cfg(not(test))] + for (_, ip) in &self.saved { + let ip = ip.clone(); + let pings = Arc::clone(&self.pings); + let rev = Arc::clone(&self.pings_rev); + std::thread::spawn(move || { + let info = match crate::net::ping(&ip, std::time::Duration::from_secs(5)) { + Ok((status, ms)) => ServerPing { + state: 1, + online: status + .pointer("/players/online") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + max: status + .pointer("/players/max") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + ms, + version: status + .pointer("/version/name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_owned(), + }, + Err(_) => ServerPing { + state: 2, + ..Default::default() + }, + }; + if let Ok(mut map) = pings.lock() { + map.insert(ip, info); + } + rev.fetch_add(1, Ordering::Relaxed); + }); + } + } + fn ping(&self, ip: &str) -> ServerPing { + self.pings + .lock() + .ok() + .and_then(|map| map.get(ip).cloned()) + .unwrap_or_default() + } + /// Whether a toggle is on. + fn filter(&self, key: &str) -> bool { + self.filters.borrow().contains(key) + } + + /// The frame's nodes in dp, laid out fresh (tests, hit-testing). + pub fn layout(&self, frame: &Frame, last: &str) -> Vec { + self.build(frame, last, self.news.get()) + } + /// Cached layout key: pings, filters and the open card redraw the rows. + fn nodes(&self, frame: &Frame, last: &str, news: usize) -> std::cell::Ref<'_, Vec> { + let key = Key { + w: frame.w.round() as u32, + h: frame.h.round() as u32, + tab: self.tab, + news, + last: last.to_owned(), + pings: self.pings_rev.load(Ordering::Relaxed), + filters: self.filters_rev.get(), + selected: self.selected.get(), + }; + let stale = { + let cache = self.cache.borrow(); + cache.as_ref().is_none_or(|cache| cache.key != key) + }; + if stale { + let nodes = self.build(frame, last, news); + *self.cache.borrow_mut() = Some(Cache { key, nodes }); + } + std::cell::Ref::map(self.cache.borrow(), |cache| { + &cache.as_ref().expect("built above").nodes + }) + } + + fn build(&self, frame: &Frame, last: &str, news: usize) -> Vec { + let (w, h) = (frame.w, frame.h); + let mut nodes = Vec::new(); + self.bar(&mut nodes, w); + match self.tab { + Tab::Home => self.home(&mut nodes, w, h, last, news), + Tab::Current => current_tab(self, &mut nodes, w, h), + Tab::Play => play(self, &mut nodes, w, h), + } + nodes + } + + /// Tabs centred, actions right; there is no wordmark. + fn bar(&self, nodes: &mut Vec, w: f32) { + let mut tabs: Vec<(Tab, &str, &str)> = Vec::new(); + match &self.current { + Some(_) => tabs.push((Tab::Current, "ТЕКУЩИЙ СЕРВЕР", "boxes")), + None => tabs.push((Tab::Home, "ГЛАВНАЯ", "house")), + } + tabs.push((Tab::Play, "ИГРАТЬ", "play")); + let count = tabs.len() + LOCKED.len(); + let total = count as f32 * TAB_W + (count - 1) as f32 * TAB_GAP; + let y = (TOP - TAB_H) / 2.0; + let mut x = (w - total) / 2.0; + for (tab, label, icon) in tabs { + nodes.push(Node::Button { + rect: [x, y, TAB_W, TAB_H], + label: label.to_owned(), + icon: Some(icon), + face: Face::MenuStrong, + size: 11.0, + variant: Variant::Tab { + active: self.tab == tab, + }, + id: Some(Hit::Tab(tab)), + enabled: true, + tip: None, + }); + x += TAB_W + TAB_GAP; + } + for (label, icon) in LOCKED { + nodes.push(Node::Button { + rect: [x, y, TAB_W, TAB_H], + label: label.to_owned(), + icon: Some(icon), + face: Face::MenuStrong, + size: 11.0, + variant: Variant::Chip, + id: None, + enabled: false, + tip: Some(TIP), + }); + x += TAB_W + TAB_GAP; + } + let actions = [ + ("НАСТРОЙКИ", "settings", 118.0, Hit::Options), + ("ВЫЙТИ", "log-out", 92.0, Hit::Quit), + ]; + let aw: f32 = + actions.iter().map(|(_, _, w, _)| *w).sum::() + TAB_GAP * (actions.len() as f32 - 1.0); + let mut x = w - PAD - aw; + for (label, icon, bw, hit) in actions { + nodes.push(Node::Button { + rect: [x, y, bw, TAB_H], + label: label.to_owned(), + icon: Some(icon), + face: Face::MenuStrong, + size: 11.0, + variant: Variant::Ghost, + id: Some(hit), + enabled: true, + tip: None, + }); + x += bw + TAB_GAP; + } + } + + /// Quick connect top right, the devblog bottom right, the news carousel + /// bottom left, the chat strip and the account along the bottom. + fn home(&self, nodes: &mut Vec, w: f32, h: f32, last: &str, news: usize) { + let strip_y = h - PAD - STRIP_H; + let cards_bottom = strip_y - GAP; + + // Quick connect. + let rows = self.rows(last); + let x = w - PAD - QUICK_W; + let y = TOP + GAP; + let body = if rows.is_empty() { + 58.0 + } else { + rows.len() as f32 * (QUICK_ROW + 2.0) - 2.0 + 8.0 + }; + nodes.push(Node::Panel { + rect: [x, y, QUICK_W, QUICK_HEAD + body], + }); + nodes.push(Node::Icon { + rect: [x + 12.0, y + 7.0, 16.0, 16.0], + name: "users", + color: theme::MUTED, + }); + nodes.push(Node::Text { + rect: [x + 34.0, y + 6.0, QUICK_W - 46.0, 18.0], + face: Face::MenuStrong, + size: 9.5, + text: "БЫСТРОЕ ПОДКЛЮЧЕНИЕ".to_owned(), + color: theme::MUTED, + align: Align::Left, + }); + let ry = y + QUICK_HEAD; + if rows.is_empty() { + nodes.push(Node::Text { + rect: [x + 12.0, ry + 4.0, QUICK_W - 24.0, 18.0], + face: Face::Menu, + size: 11.0, + text: "Серверы не найдены".to_owned(), + color: theme::MUTED, + align: Align::Left, + }); + nodes.push(Node::Button { + rect: [x + 12.0, ry + 24.0, QUICK_W - 24.0, 30.0], + label: "ОДИНОЧНАЯ ИГРА".to_owned(), + icon: Some("play"), + face: Face::MenuStrong, + size: 10.5, + variant: Variant::Primary, + id: Some(Hit::LocalWorlds), + enabled: true, + tip: None, + }); + } else { + for (i, (name, ip)) in rows.iter().enumerate() { + let top = ry + i as f32 * (QUICK_ROW + 2.0); + // The card's parts, then an invisible button over them. + nodes.push(Node::Icon { + rect: [x + QUICK_W - 26.0, top + 8.0, 14.0, 14.0], + name: "pin", + color: theme::MUTED, + }); + nodes.push(Node::Text { + rect: [x + 12.0, top + 7.0, QUICK_W - 60.0, 16.0], + face: Face::MenuStrong, + size: 12.0, + text: fit(name, 26), + color: theme::TEXT, + align: Align::Left, + }); + nodes.push(Node::Text { + rect: [x + 12.0, top + 24.0, QUICK_W - 100.0, 12.0], + face: Face::Menu, + size: 9.0, + text: "PvE · VANILLA · RU".to_owned(), + color: theme::MUTED, + align: Align::Left, + }); + nodes.push(Node::Icon { + rect: [x + QUICK_W - 96.0, top + 24.0, 11.0, 11.0], + name: "users", + color: theme::MUTED, + }); + nodes.push(Node::Text { + rect: [x + QUICK_W - 82.0, top + 22.0, 58.0, 14.0], + face: Face::Menu, + size: 9.5, + text: "0 / 64".to_owned(), + color: theme::MUTED, + align: Align::Right, + }); + nodes.push(Node::Line { + rect: [x + 12.0, top + QUICK_ROW - 0.5, QUICK_W - 24.0, 1.0], + color: theme::BORDER_SOFT, + }); + nodes.push(Node::Button { + rect: [x + 6.0, top, QUICK_W - 12.0, QUICK_ROW], + label: String::new(), + icon: None, + face: Face::Menu, + size: 10.0, + variant: Variant::Invisible, + id: Some(Hit::Connect(ip.clone())), + enabled: true, + tip: None, + }); + } + + } + + // The devblog, right-aligned. + let bx = w - PAD - BLOG_W; + let by = cards_bottom - BLOG_H; + nodes.push(Node::Button { + rect: [bx + BLOG_W - 84.0, by, 84.0, 18.0], + label: "DEV BLOG".to_owned(), + icon: None, + face: Face::MenuStrong, + size: 8.5, + variant: Variant::Solid, + id: None, + enabled: true, + tip: None, + }); + nodes.push(Node::Text { + rect: [bx, by + 20.0, BLOG_W, 46.0], + face: Face::Brand, + size: 38.0, + text: "НОВОЕ МЕНЮ".to_owned(), + color: theme::TEXT, + align: Align::Right, + }); + for (i, line) in [ + "Главное меню и настройки переписаны в стиле Rust:", + "плотная сетка, прямые углы, живой фон под размытием.", + ] + .into_iter() + .enumerate() + { + nodes.push(Node::Text { + rect: [bx, by + 66.0 + i as f32 * 15.0, BLOG_W, 16.0], + face: Face::Menu, + size: 11.0, + text: line.to_owned(), + color: theme::SOFT, + align: Align::Right, + }); + } + nodes.push(Node::Button { + rect: [bx + BLOG_W - 298.0, by + BLOG_H - 34.0, 160.0, 34.0], + label: "СПИСОК ИЗМЕНЕНИЙ".to_owned(), + icon: Some("list"), + face: Face::MenuStrong, + size: 10.0, + variant: Variant::Ghost, + id: None, + enabled: false, + tip: Some(TIP), + }); + nodes.push(Node::Button { + rect: [bx + BLOG_W - 130.0, by + BLOG_H - 34.0, 130.0, 34.0], + label: "ПОДРОБНЕЕ".to_owned(), + icon: Some("arrow-right"), + face: Face::MenuStrong, + size: 10.0, + variant: Variant::Primary, + id: None, + enabled: false, + tip: Some(TIP), + }); + + // The news carousel, bottom left. + let nx = PAD; + let ny = cards_bottom - NEWS_H; + let slide = NEWS[news.min(NEWS.len() - 1)]; + nodes.push(Node::Panel { + rect: [nx, ny, NEWS_W, NEWS_H], + }); + // Its picture is procedural until the launcher ships art. + nodes.push(Node::Shade { + rect: [nx + 1.0, ny + 1.0, NEWS_W - 2.0, NEWS_H - 42.0], + top: theme::faded(theme::ACCENT, 0.22), + bottom: theme::faded(theme::RAIL, 0.05), + }); + nodes.push(Node::Icon { + rect: [nx + NEWS_W / 2.0 - 22.0, ny + 30.0, 44.0, 44.0], + name: "newspaper", + color: theme::faded(theme::SOFT, 0.55), + }); + nodes.push(Node::Line { + rect: [nx + 12.0, ny + NEWS_H - 40.0, NEWS_W - 24.0, 1.0], + color: theme::ACCENT, + }); + nodes.push(Node::Text { + rect: [nx + 12.0, ny + NEWS_H - 32.0, NEWS_W - 90.0, 16.0], + face: Face::MenuStrong, + size: 11.0, + text: slide.0.to_owned(), + color: theme::TEXT, + align: Align::Left, + }); + nodes.push(Node::Text { + rect: [nx + NEWS_W - 66.0, ny + NEWS_H - 30.0, 54.0, 14.0], + face: Face::Menu, + size: 9.5, + text: format!("{:02} / {:02}", news + 1, NEWS.len()), + color: theme::MUTED, + align: Align::Right, + }); + nodes.push(Node::Button { + rect: [nx + NEWS_W - 46.0, ny + 8.0, 18.0, 18.0], + label: String::new(), + icon: Some("chevron-right"), + face: Face::Menu, + size: 10.0, + variant: Variant::Icon, + id: Some(Hit::NewsNext), + enabled: true, + tip: None, + }); + nodes.push(Node::Button { + rect: [nx + NEWS_W - 68.0, ny + 8.0, 18.0, 18.0], + label: String::new(), + icon: Some("arrow-left"), + face: Face::Menu, + size: 10.0, + variant: Variant::Icon, + id: Some(Hit::NewsPrev), + enabled: true, + tip: None, + }); + + // The chat strip and the account plate. + let chat_w = w - PAD * 2.0 - ACCOUNT_W - 12.0; + nodes.push(Node::Well { + rect: [PAD, strip_y, chat_w, STRIP_H], + }); + nodes.push(Node::Icon { + rect: [PAD + 10.0, strip_y + 9.0, 16.0, 16.0], + name: "chat-thread", + color: theme::MUTED, + }); + nodes.push(Node::Text { + rect: [PAD + 34.0, strip_y + 8.0, chat_w - 46.0, 18.0], + face: Face::Menu, + size: 10.5, + text: "Нажмите [ENTER], чтобы начать общаться…".to_owned(), + color: theme::MUTED, + align: Align::Left, + }); + nodes.push(Node::Well { + rect: [w - PAD - ACCOUNT_W, strip_y, ACCOUNT_W, STRIP_H], + }); + nodes.push(Node::Icon { + rect: [w - PAD - ACCOUNT_W + 9.0, strip_y + 9.0, 16.0, 16.0], + name: "user", + color: theme::SOFT, + }); + } + + /// Paints the frame: the scrims, the cached nodes, the carousel picture and + /// the tooltip. + pub fn draw( + &self, + atlas: &HudAtlas, + batch: &mut HudBatch, + cursor: (f32, f32), + last: &str, + meta: &Meta, + ) { + let frame = Frame::new((batch.width, batch.height), batch.scale, cursor); + let at = frame.at(); + let now = self.first.get().unwrap_or(meta.elapsed); + if self.first.get().is_none() { + self.first.set(Some(meta.elapsed)); + } + let opened = meta.elapsed - now; + // The carousel advances itself while the menu sits open. + if self.news.get() != self.news_seen.get() { + self.news_at.set(meta.elapsed); + self.news_seen.set(self.news.get()); + } + if meta.elapsed - self.news_at.get() > NEWS_DWELL && opened > NEWS_DWELL { + self.news.set((self.news.get() + 1) % NEWS.len()); + self.news_at.set(meta.elapsed); + } + if self.tab != self.tab_was.get() { + self.tab_at.set(meta.elapsed); + self.tab_was.set(self.tab); + } + let nodes = self.nodes(&frame, last, self.news.get()); + let mut ui = Ui::new(atlas, batch, cursor); + + // The scrims: the scene stays visible, the text stays readable. + let (w, h) = (ui.w, ui.h); + ui.gradient_v(0.0, 0.0, w, 86.0, theme::SCRIM_TOP, theme::faded(theme::SCRIM_TOP, 0.0)); + ui.gradient_v( + 0.0, + h - 150.0, + w, + 150.0, + theme::faded(theme::SCRIM_BOTTOM, 0.0), + theme::SCRIM_BOTTOM, + ); + + // The content slides in when the tab changes. + let t = ((meta.elapsed - self.tab_at.get()) / SLIDE).clamp(0.0, 1.0); + let eased = 1.0 - (1.0 - t).powi(3); + let dir = if self.tab.index() >= self.tab_was.get().index() { + 1.0 + } else { + -1.0 + }; + let dx = (1.0 - eased) * 14.0 * dir; + let mut hovered_tip: Option<([f32; 4], String)> = None; + for node in nodes.iter() { + let in_bar = node.rect()[1] < TOP; + let shift = if in_bar { 0.0 } else { dx }; + + paint(&mut ui, node, at, shift); + } + // The tooltip over everything but the bar. + for node in nodes.iter().rev() { + let Node::Button { + rect, + tip: Some(tip), + .. + } = node + else { + continue; + }; + if at.0 >= rect[0] && at.0 < rect[0] + rect[2] && at.1 >= rect[1] && at.1 < rect[1] + rect[3] + { + hovered_tip = Some((*rect, (*tip).to_owned())); + break; + } + } + if let Some(([x, y, rw, _], tip)) = hovered_tip { + let tw = ui.measure(Face::Menu, 10.5, &tip) + 16.0; + let tx = (x + rw / 2.0 - tw / 2.0).clamp(4.0, (w - tw - 4.0).max(4.0)); + let ty = (y - 28.0).max(4.0); + ui.rounded(tx, ty, tw, 22.0, 0.0, theme::TIP_BG); + ui.border(tx, ty, tw, 22.0, 0.0, theme::BORDER); + ui.text(Face::Menu, 10.5, tx + 8.0, ty + 3.0, theme::TEXT, &tip); + } + ui.finish(); + } + + /// Hit-tests the cached layout: a tab or the carousel changes in place, + /// everything else is reported for the shell to route. + pub fn click(&mut self, frame: &Frame, last: &str) -> (Outcome, bool) { + let at = frame.at(); + let nodes = { + let cache = self.nodes(frame, last, self.news.get()); + cache.clone() + }; + let hit = nodes.iter().rev().find_map(|node| match node { + Node::Button { + rect, + id: Some(id), + enabled: true, + .. + } if inside(*rect, at) => Some(id.clone()), + _ => None, + }); + match hit { + Some(hit) => (self.fire(&hit), true), + None => (Outcome::None, false), + } + } + /// Closes the server card; true when one was open (Escape consumes it). + pub fn close_modal(&mut self) -> bool { + if self.selected.get().is_some() { + self.selected.set(None); + self.cache.borrow_mut().take(); + true + } else { + false + } + } + fn fire(&mut self, hit: &Hit) -> Outcome { + match hit { + Hit::Tab(tab) => { + if self.tab != *tab { + self.tab = *tab; + } + Outcome::None + } + Hit::NewsPrev => { + self.news.set((self.news.get() + NEWS.len() - 1) % NEWS.len()); + Outcome::None + } + Hit::NewsNext => { + self.news.set((self.news.get() + 1) % NEWS.len()); + Outcome::None + } + Hit::Toggle(key) => { + let mut filters = self.filters.borrow_mut(); + if key.starts_with("wipe") { + filters.retain(|seen| !seen.starts_with("wipe")); + } + if key.starts_with("region") { + filters.retain(|seen| !seen.starts_with("region")); + } + if !filters.remove(*key) { + filters.insert(key); + } + drop(filters); + self.filters_rev.set(self.filters_rev.get() + 1); + self.cache.borrow_mut().take(); + Outcome::None + } + Hit::ResetFilters => { + self.filters.borrow_mut().clear(); + self.filters_rev.set(self.filters_rev.get() + 1); + self.cache.borrow_mut().take(); + Outcome::None + } + Hit::Refresh => { + self.forget_pings(); + Outcome::None + } + Hit::Servers => Outcome::Servers, + Hit::Resume => Outcome::Resume, + Hit::Stats => Outcome::Stats, + Hit::Advancements => Outcome::Advancements, + Hit::WorldOptions => Outcome::WorldOptions, + Hit::OpenServer(i) => { + if *i < self.saved.len() { + self.selected.set(Some(*i)); + self.cache.borrow_mut().take(); + } + Outcome::None + } + Hit::CloseModal => { + self.selected.set(None); + self.cache.borrow_mut().take(); + Outcome::None + } + Hit::Quit => Outcome::Quit, + Hit::Options => Outcome::Options, + Hit::LocalWorlds => Outcome::LocalWorlds, + Hit::Connect(address) => Outcome::Connect(address.clone()), + } + } +} + +/// The Play tab: category strip, filter rail, the server table and the +/// details card. +fn play(menu: &RustMenu, nodes: &mut Vec, w: f32, h: f32) { + menu.start_pings(); + // In the pause menu the browser is read-only: leave the world first. + let paused = menu.current.is_some(); + let strip_y = TOP + 8.0; + let top = strip_y + FILTER_H + 10.0; + let bottom = h - PAD - STRIP_H - GAP; + + // The category strip: what the list is showing, and the counts. + let mut x = PAD; + for (label, count) in [ + ("СЕРВЕРЫ", menu.saved.len()), + ("ИЗБРАННОЕ", 0), + ("ИСТОРИЯ", menu.saved.len()), + ("ДРУЗЬЯ", 0), + ] { + nodes.push(Node::Button { + rect: [x, strip_y, 124.0, 28.0], + label: format!("{label} {count}"), + icon: None, + face: Face::MenuStrong, + size: 9.5, + variant: Variant::Chip, + id: None, + enabled: true, + tip: None, + }); + x += 128.0; + } + let search_w = (w - x - PAD - 76.0).clamp(120.0, 280.0); + let sx = w - PAD - 76.0 - search_w; + nodes.push(Node::Well { + rect: [sx, strip_y, search_w, 28.0], + }); + nodes.push(Node::Icon { + rect: [sx + 8.0, strip_y + 7.0, 14.0, 14.0], + name: "search", + color: theme::MUTED, + }); + nodes.push(Node::Text { + rect: [sx + 28.0, strip_y + 7.0, search_w - 34.0, 16.0], + face: Face::Menu, + size: 10.0, + text: "ПОИСК".to_owned(), + color: theme::MUTED, + align: Align::Left, + }); + nodes.push(Node::Button { + rect: [w - PAD - 64.0, strip_y, 28.0, 28.0], + label: String::new(), + icon: Some("refresh-cw"), + face: Face::Menu, + size: 10.0, + variant: Variant::Icon, + id: Some(Hit::Refresh), + enabled: true, + tip: None, + }); + nodes.push(Node::Button { + rect: [w - PAD - 32.0, strip_y, 28.0, 28.0], + label: String::new(), + icon: Some("plus"), + face: Face::Menu, + size: 10.0, + variant: Variant::Icon, + id: (!paused).then_some(Hit::Servers), + enabled: !paused, + tip: paused.then_some("Сначала выйдите в мир"), + }); + + // The filter rail. + let rail_w = 236.0; + nodes.push(Node::Panel { + rect: [PAD, top, rail_w, bottom - top], + }); + let mut y = top + 10.0; + y = filter_section(nodes, PAD + 10.0, y, rail_w - 20.0, "РЕЖИМЫ ИГРЫ"); + for (key, label, count) in [ + ("mode:original", "ОРИГИНАЛЬНЫЙ", menu.saved.len()), + ("mode:softcore", "СОФТКОР", 0), + ("mode:hardcore", "ХАРДКОР", 0), + ("mode:primitive", "ПРИМИТИВНЫЙ", 0), + ] { + y = filter_toggle(menu, nodes, PAD + 10.0, y, rail_w - 20.0, key, &format!("{label} {count}")); + } + y = filter_section(nodes, PAD + 10.0, y + 4.0, rail_w - 20.0, "ФИЛЬТРЫ"); + for (key, label) in [ + ("filter:premium", "СНАЧАЛА ПРЕМИУМ"), + ("filter:empty", "ПОКАЗАТЬ ПУСТЫЕ"), + ("filter:full", "ПОКАЗАТЬ ПОЛНЫЕ"), + ] { + y = filter_toggle(menu, nodes, PAD + 10.0, y, rail_w - 20.0, key, label); + } + y = filter_section(nodes, PAD + 10.0, y + 4.0, rail_w - 20.0, "ЧАСТОТА ВАЙПА"); + for (key, label) in [ + ("wipe:month", "ЕЖЕМЕСЯЧНО"), + ("wipe:double", "ДВАЖДЫ В МЕСЯЦ"), + ("wipe:week", "ЕЖЕНЕДЕЛЬНО"), + ] { + y = filter_toggle(menu, nodes, PAD + 10.0, y, rail_w - 20.0, key, label); + } + y = filter_section(nodes, PAD + 10.0, y + 4.0, rail_w - 20.0, "РЕГИОН"); + for (key, label) in [("region:EU", "EU"), ("region:RU", "RU"), ("region:US", "US")] { + y = filter_toggle(menu, nodes, PAD + 10.0, y, rail_w - 20.0, key, label); + } + let by = (bottom - 84.0).max(y + 4.0); + nodes.push(Node::Button { + rect: [PAD + 10.0, by, 100.0, 28.0], + label: "СБРОС".to_owned(), + icon: Some("refresh-cw"), + face: Face::MenuStrong, + size: 9.5, + variant: Variant::Ghost, + id: Some(Hit::ResetFilters), + enabled: true, + tip: None, + }); + nodes.push(Node::Button { + rect: [PAD + 118.0, by, 100.0, 28.0], + label: "ОБНОВИТЬ".to_owned(), + icon: None, + face: Face::MenuStrong, + size: 9.5, + variant: Variant::Ghost, + id: Some(Hit::Refresh), + enabled: true, + tip: None, + }); + nodes.push(Node::Button { + rect: [PAD + 10.0, by + 34.0, rail_w - 20.0, 34.0], + label: "РАЗМЕСТИТЕ СВОЙ СЕРВЕР".to_owned(), + icon: None, + face: Face::MenuStrong, + size: 9.5, + variant: Variant::Primary, + id: None, + enabled: false, + tip: Some(TIP), + }); + + // The table. + let tx = PAD + rail_w + 12.0; + let tw = w - PAD - tx; + nodes.push(Node::Icon { + rect: [tx + 8.0, top + 6.0, 13.0, 13.0], + name: "star", + color: theme::MUTED, + }); + nodes.push(Node::Text { + rect: [tx + 30.0, top + 4.0, tw - 390.0, 16.0], + face: Face::MenuStrong, + size: 8.5, + text: "НАЗВАНИЕ".to_owned(), + color: theme::MUTED, + align: Align::Left, + }); + for (label, right) in [("РЕЖИМ", 300.0), ("ИГРОКИ", 196.0), ("ПИНГ", 80.0)] { + nodes.push(Node::Text { + rect: [tx + tw - right, top + 4.0, 90.0, 16.0], + face: Face::MenuStrong, + size: 8.5, + text: label.to_owned(), + color: theme::MUTED, + align: Align::Left, + }); + } + let row_h = 46.0; + let mut y = top + 26.0; + // A chosen region narrows the rows; the other filters wait for the + // launcher's server list (they have no data here). + let regions: Vec<&'static str> = ["region:EU", "region:RU", "region:US"] + .into_iter() + .filter(|key| menu.filter(key)) + .collect(); + for (i, (name, ip)) in menu.saved.iter().enumerate() { + if !regions.is_empty() && !regions.contains(®ion(ip)) { + continue; + } + if y + row_h > bottom { + break; + } + let ping = menu.ping(ip); + nodes.push(Node::Line { + rect: [tx, y + row_h - 0.5, tw, 1.0], + color: theme::BORDER_SOFT, + }); + nodes.push(Node::Icon { + rect: [tx + 8.0, y + 16.0, 14.0, 14.0], + name: "star", + color: theme::LOCKED, + }); + nodes.push(Node::Text { + rect: [tx + 30.0, y + 6.0, tw - 360.0, 16.0], + face: Face::MenuStrong, + size: 12.0, + text: fit(name, 42), + color: theme::TEXT, + align: Align::Left, + }); + let version = if ping.state == 1 && !ping.version.is_empty() { + ping.version.clone() + } else { + "VANILLA".to_owned() + }; + nodes.push(Node::Text { + rect: [tx + 30.0, y + 24.0, tw - 360.0, 12.0], + face: Face::Menu, + size: 9.0, + text: format!("{version} · {} · PROCEDURAL MAP", region(ip)), + color: theme::MUTED, + align: Align::Left, + }); + nodes.push(Node::Button { + rect: [tx + tw - 300.0, y + 13.0, 84.0, 20.0], + label: "VANILLA".to_owned(), + icon: None, + face: Face::MenuStrong, + size: 8.5, + variant: Variant::Chip, + id: None, + enabled: true, + tip: None, + }); + let players = match ping.state { + 1 => format!("{} / {}", ping.online, ping.max), + 0 => "…".to_owned(), + _ => "—".to_owned(), + }; + nodes.push(Node::Text { + rect: [tx + tw - 196.0, y + 14.0, 96.0, 16.0], + face: Face::Menu, + size: 10.5, + text: players, + color: theme::SOFT, + align: Align::Right, + }); + let (latency, color) = match ping.state { + 1 => ( + format!("{}", ping.ms), + if ping.ms < 60 { + theme::POSITIVE + } else if ping.ms < 140 { + theme::PRIMARY + } else { + theme::ACCENT + }, + ), + 0 => ("…".to_owned(), theme::MUTED), + _ => ("—".to_owned(), theme::LOCKED), + }; + nodes.push(Node::Text { + rect: [tx + tw - 80.0, y + 14.0, 60.0, 16.0], + face: Face::Menu, + size: 10.5, + text: latency, + color, + align: Align::Right, + }); + nodes.push(Node::Button { + rect: [tx, y, tw, row_h], + label: String::new(), + icon: None, + face: Face::Menu, + size: 10.0, + variant: Variant::Invisible, + id: (!paused).then_some(Hit::OpenServer(i)), + enabled: !paused, + tip: paused.then_some("Сначала выйдите в мир"), + }); + y += row_h; + } + if menu.saved.is_empty() { + nodes.push(Node::Text { + rect: [tx, top + 80.0, tw, 22.0], + face: Face::MenuStrong, + size: 12.0, + text: "Серверы не найдены".to_owned(), + color: theme::MUTED, + align: Align::Center, + }); + nodes.push(Node::Button { + rect: [tx + tw / 2.0 - 100.0, top + 110.0, 200.0, 34.0], + label: "ДОБАВИТЬ СЕРВЕР".to_owned(), + icon: Some("plus"), + face: Face::MenuStrong, + size: 10.5, + variant: Variant::Primary, + id: (!paused).then_some(Hit::Servers), + enabled: !paused, + tip: paused.then_some("Сначала выйдите в мир"), + }); + } + + // The details card over everything. + if let Some(i) = menu.selected.get() + && let Some((name, ip)) = menu.saved.get(i) + { + server_card(menu, nodes, w, h, name, ip); + } +} + +/// A filter section's caption; returns the next row's top. +fn filter_section(nodes: &mut Vec, x: f32, y: f32, w: f32, title: &str) -> f32 { + nodes.push(Node::Text { + rect: [x, y, w, 16.0], + face: Face::MenuStrong, + size: 8.5, + text: title.to_owned(), + color: theme::MUTED, + align: Align::Left, + }); + y + 20.0 +} + +/// One checkbox row of the filter rail. +fn filter_toggle( + menu: &RustMenu, + nodes: &mut Vec, + x: f32, + y: f32, + w: f32, + key: &'static str, + label: &str, +) -> f32 { + nodes.push(Node::Button { + rect: [x, y, w, 18.0], + label: label.to_owned(), + icon: None, + face: Face::Menu, + size: 10.0, + variant: Variant::Check { + on: menu.filter(key), + }, + id: Some(Hit::Toggle(key)), + enabled: true, + tip: None, + }); + y + 18.0 +} + +/// The region tags a saved address suggests. +fn region(ip: &str) -> &'static str { + let lower = ip.to_ascii_lowercase(); + if lower.contains(".ru") { + "RU" + } else if lower.contains(".eu") || lower.contains(".de") { + "EU" + } else { + "US" + } +} + +/// One server's card: banner, facts, description and the actions. +fn server_card(menu: &RustMenu, nodes: &mut Vec, w: f32, h: f32, name: &str, ip: &str) { + nodes.push(Node::Shade { + rect: [0.0, 0.0, w, h], + top: theme::OVERLAY, + bottom: theme::OVERLAY, + }); + nodes.push(Node::Button { + rect: [0.0, 0.0, w, h], + label: String::new(), + icon: None, + face: Face::Menu, + size: 10.0, + variant: Variant::Invisible, + id: Some(Hit::CloseModal), + enabled: true, + tip: None, + }); + let cw = 560.0; + let ch = 360.0; + let cx = (w - cw) / 2.0; + let cy = (h - ch) / 2.0; + nodes.push(Node::Panel { + rect: [cx, cy, cw, ch], + }); + nodes.push(Node::Text { + rect: [cx + 16.0, cy + 12.0, cw - 32.0, 24.0], + face: Face::MenuStrong, + size: 18.0, + text: name.to_owned(), + color: theme::TEXT, + align: Align::Left, + }); + nodes.push(Node::Shade { + rect: [cx + 16.0, cy + 48.0, cw - 32.0, 120.0], + top: theme::faded(theme::ACCENT, 0.45), + bottom: theme::faded(theme::RAIL, 1.0), + }); + nodes.push(Node::Icon { + rect: [cx + cw / 2.0 - 22.0, cy + 84.0, 44.0, 44.0], + name: "server", + color: theme::faded(theme::TEXT, 0.75), + }); + let ping = menu.ping(ip); + for (i, (label, value)) in [ + ("ВРЕМЯ", "—".to_owned()), + ("КАРТА", "PROCEDURAL MAP".to_owned()), + ("ВАЙП", "ЕЖЕНЕДЕЛЬНО".to_owned()), + ("РЕЖИМ", "VANILLA".to_owned()), + ] + .into_iter() + .enumerate() + { + let col = (cw - 32.0) / 4.0; + let mx = cx + 16.0 + i as f32 * col; + nodes.push(Node::Text { + rect: [mx, cy + 180.0, col - 4.0, 12.0], + face: Face::Menu, + size: 8.0, + text: label.to_owned(), + color: theme::MUTED, + align: Align::Left, + }); + nodes.push(Node::Text { + rect: [mx, cy + 192.0, col - 4.0, 16.0], + face: Face::MenuStrong, + size: 9.5, + text: value, + color: theme::TEXT, + align: Align::Left, + }); + } + let facts = if ping.state == 1 { + format!("{} · {} / {} игроков · {} мс", ip, ping.online, ping.max, ping.ms) + } else { + ip.to_owned() + }; + nodes.push(Node::Text { + rect: [cx + 16.0, cy + 218.0, cw - 32.0, 18.0], + face: Face::Menu, + size: 10.5, + text: facts, + color: theme::SOFT, + align: Align::Left, + }); + let by = cy + ch - 46.0; + nodes.push(Node::Button { + rect: [cx + 16.0, by, 168.0, 32.0], + label: "СОХРАНИТЬ СЕРВЕР".to_owned(), + icon: Some("star"), + face: Face::MenuStrong, + size: 9.5, + variant: Variant::Ghost, + id: None, + enabled: false, + tip: Some(TIP), + }); + nodes.push(Node::Button { + rect: [cx + cw - 16.0 - 100.0 - 8.0 - 130.0, by, 100.0, 32.0], + label: "ОТМЕНА".to_owned(), + icon: None, + face: Face::MenuStrong, + size: 9.5, + variant: Variant::Chip, + id: Some(Hit::CloseModal), + enabled: true, + tip: None, + }); + nodes.push(Node::Button { + rect: [cx + cw - 16.0 - 130.0, by, 130.0, 32.0], + label: "ПОДКЛЮЧИТЬСЯ".to_owned(), + icon: Some("play"), + face: Face::MenuStrong, + size: 9.5, + variant: Variant::Primary, + id: Some(Hit::Connect(ip.to_owned())), + enabled: true, + tip: None, + }); +} + +/// The current server/world card (the Rust pause menu's first tab). +fn current_tab(menu: &RustMenu, nodes: &mut Vec, w: f32, _h: f32) { + let Some(current) = &menu.current else { + return; + }; + let (cw, ch) = (560.0, 300.0); + let (cx, cy) = ((w - cw) / 2.0, TOP + GAP + 16.0); + nodes.push(Node::Panel { + rect: [cx, cy, cw, ch], + }); + nodes.push(Node::Text { + rect: [cx + 16.0, cy + 12.0, cw - 32.0, 28.0], + face: Face::Brand, + size: 22.0, + text: current.name.clone(), + color: theme::TEXT, + align: Align::Left, + }); + nodes.push(Node::Text { + rect: [cx + 16.0, cy + 44.0, cw - 32.0, 16.0], + face: Face::Menu, + size: 10.5, + text: if current.singleplayer { + "Одиночная игра · SURVIVAL".to_owned() + } else { + format!("{} · VANILLA", current.address) + }, + color: theme::MUTED, + align: Align::Left, + }); + nodes.push(Node::Line { + rect: [cx + 16.0, cy + 72.0, cw - 32.0, 1.0], + color: theme::BORDER_SOFT, + }); + let bw = (cw - 48.0) / 2.0; + let (x1, x2) = (cx + 16.0, cx + 32.0 + bw); + let mut buttons: Vec<(f32, f32, &str, &str, Variant, Hit)> = vec![ + ( + x1, + cy + 96.0, + "ПРОДОЛЖИТЬ", + "play", + Variant::Primary, + Hit::Resume, + ), + ( + x2, + cy + 96.0, + "НАСТРОЙКИ", + "settings", + Variant::Ghost, + Hit::Options, + ), + ( + x1, + cy + 138.0, + "ДОСТИЖЕНИЯ", + "award", + Variant::Ghost, + Hit::Advancements, + ), + ( + x2, + cy + 138.0, + "СТАТИСТИКА", + "activity", + Variant::Ghost, + Hit::Stats, + ), + ]; + if current.singleplayer { + buttons.push(( + x1, + cy + 180.0, + "НАСТРОЙКИ МИРА", + "sliders-horizontal", + Variant::Ghost, + Hit::WorldOptions, + )); + buttons.push(( + x2, + cy + 180.0, + "СОХРАНИТЬ И ВЫЙТИ", + "log-out", + Variant::Ghost, + Hit::Quit, + )); + } else { + buttons.push(( + x2, + cy + 180.0, + "СОХРАНИТЬ И ВЫЙТИ", + "log-out", + Variant::Ghost, + Hit::Quit, + )); + } + for (bx, by, label, icon, variant, hit) in buttons { + nodes.push(Node::Button { + rect: [bx, by, bw, 36.0], + label: label.to_owned(), + icon: Some(icon), + face: Face::MenuStrong, + size: 10.0, + variant, + id: Some(hit), + enabled: true, + tip: None, + }); + } +} + +fn paint(ui: &mut Ui, node: &Node, at: (f32, f32), dx: f32) { + match node { + Node::Panel { rect } => { + let [x, y, w, h] = shift(*rect, dx); + ui.rounded(x, y, w, h, 0.0, theme::PANEL); + ui.border(x, y, w, h, 0.0, theme::BORDER); + } + Node::Well { rect } => { + let [x, y, w, h] = shift(*rect, dx); + ui.rounded(x, y, w, h, 0.0, theme::RAIL); + ui.border(x, y, w, h, 0.0, theme::BORDER); + } + Node::Shade { rect, top, bottom } => { + let [x, y, w, h] = shift(*rect, dx); + ui.gradient_v(x, y, w, h, *top, *bottom); + } + Node::Line { rect, color } => { + let [x, y, w, h] = shift(*rect, dx); + ui.rounded(x, y, w, h, 0.0, *color); + } + Node::Text { + rect, + face, + size, + text, + color, + align, + } => { + let [x, y, w, h] = shift(*rect, dx); + let ty = y + ((h - (size * 1.45).round()) / 2.0).max(0.0); + match align { + Align::Left => { + ui.text(*face, *size, x, ty, *color, text); + } + Align::Center => ui.text_centered(*face, *size, x + w / 2.0, ty, *color, text), + Align::Right => { + let tw = ui.measure(*face, *size, text); + ui.text(*face, *size, x + w - tw, ty, *color, text); + } + } + } + Node::Icon { rect, name, color } => { + let [x, y, w, h] = shift(*rect, dx); + icons::draw(ui, name, [x, y, w, h], *color); + } + Node::Button { + rect, + label, + icon, + face, + size, + variant, + id, + enabled, + .. + } => { + let rect = shift(*rect, dx); + let hover = id.is_some() && *enabled && inside(rect, at); + button(ui, rect, label, *icon, *face, *size, *variant, *enabled, hover); + } + } +} + +fn button( + ui: &mut Ui, + [x, y, w, h]: [f32; 4], + label: &str, + icon: Option<&'static str>, + face: Face, + size: f32, + variant: Variant, + enabled: bool, + hover: bool, +) { + let ty = y + (h - (size * 1.45).round()) / 2.0; + if variant == Variant::Invisible { + return; + } + if let Variant::Check { on } = variant { + let (bx, by) = (x + 6.0, y + (h - 12.0) / 2.0); + ui.border( + bx, + by, + 12.0, + 12.0, + 0.0, + if on { theme::ACCENT } else { theme::BORDER }, + ); + if on { + ui.rounded(bx + 1.0, by + 1.0, 10.0, 10.0, 0.0, theme::ACCENT); + icons::draw(ui, "check", [bx + 2.0, by + 2.0, 8.0, 8.0], theme::TEXT); + } + ui.text( + face, + size, + x + 24.0, + ty, + if on { theme::TEXT } else { theme::SOFT }, + label, + ); + return; + } + let (fill, ring, fg, icon_color) = match variant { + Variant::Tab { active } => ( + if active { + theme::ACTIVE + } else if hover { + theme::HOVER + } else { + 0 + }, + false, + if active { theme::TEXT } else { theme::SOFT }, + if active { theme::TEXT } else { theme::MUTED }, + ), + Variant::Solid => ( + if hover { theme::PANEL_HI } else { theme::CHIP }, + true, + theme::TEXT, + theme::SOFT, + ), + Variant::Primary => ( + if hover { theme::PRIMARY_HOVER } else { theme::PRIMARY }, + false, + theme::ON_PRIMARY, + theme::ON_PRIMARY, + ), + Variant::Ghost => ( + if hover { theme::HOVER } else { 0 }, + false, + if hover { theme::TEXT } else { theme::SOFT }, + if hover { theme::TEXT } else { theme::MUTED }, + ), + Variant::Chip => (theme::CHIP, true, theme::MUTED, theme::MUTED), + Variant::Icon => ( + if hover { theme::HOVER } else { 0 }, + false, + theme::SOFT, + if hover { theme::TEXT } else { theme::SOFT }, + ), + Variant::Check { .. } => unreachable!(), + Variant::Invisible => (0, false, theme::TEXT, theme::TEXT), + }; + if fill >> 24 != 0 { + ui.rounded(x, y, w, h, 0.0, fill); + } + if ring { + ui.border(x, y, w, h, 0.0, theme::BORDER); + } + let color = if enabled { fg } else { theme::LOCKED }; + let icon_color = if enabled { icon_color } else { theme::LOCKED }; + let isize = 15.0; + let gap = 7.0; + let lw = if label.is_empty() { + 0.0 + } else { + ui.measure(face, size, label) + }; + let iw = if icon.is_some() { isize } else { 0.0 }; + let total = iw + if iw > 0.0 && lw > 0.0 { gap } else { 0.0 } + lw; + let mut cx = x + (w - total) / 2.0; + if let Some(name) = icon { + icons::draw( + ui, + name, + [cx, y + (h - isize) / 2.0, isize, isize], + icon_color, + ); + cx += isize + if lw > 0.0 { gap } else { 0.0 }; + } + if !label.is_empty() { + ui.text(face, size, cx, ty, color, label); + } +} + +fn shift([x, y, w, h]: [f32; 4], dx: f32) -> [f32; 4] { + [x + dx, y, w, h] +} + +/// Whether a dp point is inside a rect (half-open, like the vanilla widgets). +fn inside([x, y, w, h]: [f32; 4], (px, py): (f32, f32)) -> bool { + px >= x && px < x + w && py >= y && py < y + h +} + +/// Cuts a single-line label to `chars` characters with an ellipsis. +fn fit(text: &str, chars: usize) -> String { + if text.chars().count() <= chars { + return text.to_owned(); + } + let mut out: String = text.chars().take(chars.saturating_sub(1)).collect(); + out.push('…'); + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/horizon-client/src/rustui/settings.rs b/crates/horizon-client/src/rustui/settings.rs new file mode 100644 index 0000000..a009a55 --- /dev/null +++ b/crates/horizon-client/src/rustui/settings.rs @@ -0,0 +1,1191 @@ +//! The settings screens in the Rust style: a category rail on the left, the +//! page's rows in the middle with their values and bars on the right, the +//! hovered setting's explanation in a panel of its own, and a footer with the +//! version, the credit and the way back. +//! +//! The rows are `options_ui`'s own widgets (`OptionsUi::layout`), so every page +//! and every setting behaves exactly as before: a press is forwarded to the +//! widget it stands for, in that widget's own coordinates. Pages with bespoke +//! renderers of their own (Graphics, Shader Packs' list, the pack screen) keep +//! them. +use super::theme; +use crate::hud::{HudAtlas, HudBatch}; +use crate::options_ui::{Act, OptionsUi, Page, WKind}; +use crate::settings::Settings; +use crate::ui_theme::{Face, Ui}; + +/// The category rail's width. +const RAIL: f32 = 236.0; +/// The explanation panel's width. +const TIPS: f32 = 300.0; +const PAD: f32 = 22.0; +/// The page title's band. +const HEAD: f32 = 74.0; +/// The footer's height. +const FOOTER: f32 = 56.0; +const ROW: f32 = 36.0; +const ROW_GAP: f32 = 4.0; +const HEADER: f32 = 30.0; +/// The rail's groups: a hairline separates them, so related pages read together. +const GROUPS: &[&[Page]] = &[ + &[ + Page::Video, + Page::Sound, + Page::Controls, + Page::KeyBinds, + Page::Mouse, + Page::Chat, + Page::Accessibility, + ], + &[Page::Language, Page::Font], + &[Page::Graphics, Page::GraphicsAdvanced], + &[Page::Packs, Page::Mods, Page::ShaderPacks], + &[ + Page::Controller, + Page::Extras, + Page::Horizon, + Page::Agent, + Page::Credits, + ], +]; +const RAIL_ITEM: f32 = 28.0; +const RAIL_GROUP_GAP: f32 = 12.0; +const RAIL_TOP: f32 = HEAD - 6.0; +/// A page whose own renderer stays (graphics, the shader and pack lists). +pub fn keeps_its_screen(_page: Page) -> bool { + false +} + +/// The rail's entries with their offset from the rail's top, and where the +/// hairlines between the groups sit. +fn rail_items() -> (Vec<(Page, f32)>, Vec) { + let mut items = Vec::new(); + let mut lines = Vec::new(); + let mut y = 0.0; + for (i, group) in GROUPS.iter().enumerate() { + if i > 0 { + lines.push(y); + y += RAIL_GROUP_GAP; + } + for &page in *group { + items.push((page, y)); + y += RAIL_ITEM; + } + } + (items, lines) +} +/// The rail's total height. +fn rail_height() -> f32 { + (rail_items().0.last().map_or(0.0, |(_, y)| y + RAIL_ITEM)).max(0.0) +} + +/// The rail's label: the page's own title, translated when a key exists. +pub fn label(page: Page) -> String { + match page { + // Graphics is not vanilla's Video Settings: it has its own label. + Page::Graphics => "Graphics".into(), + Page::GraphicsAdvanced => "Graphics (advanced)".into(), + Page::Mods => "Моды".into(), + Page::ShaderPacks => "Шейдеры".into(), + Page::Controller => "Контроллер".into(), + Page::Extras => "Дополнения".into(), + Page::Horizon => "RustNotMC".into(), + Page::Agent => "Агент".into(), + Page::Credits => "Титры".into(), + _ => { + let key = OptionsUi::title(page); + crate::lang::get_or(key, key) + } + } +} + +/// A section header's icon, by what the section is about. +fn header_icon(text: &str) -> &'static str { + let text = text.to_lowercase(); + if text.contains("display") || text.contains("экран") { + "monitor" + } else if text.contains("quality") || text.contains("качест") { + "sparkles" + } else if text.contains("preference") || text.contains("предпочт") { + "sliders-horizontal" + } else if text.contains("movement") || text.contains("движен") { + "hand" + } else if text.contains("resource") || text.contains("ресурс") { + "layers" + } else if text.contains("render") || text.contains("рендер") { + "monitor" + } else if text.contains("gameplay") || text.contains("геймплей") { + "play" + } else if text.contains("misc") || text.contains("разное") { + "boxes" + } else { + "chevron-right" + } +} + +/// The rail's icon for a page. +fn page_icon(page: Page) -> &'static str { + match page { + Page::Video => "play", + Page::Sound => "headphones", + Page::Graphics => "sliders-horizontal", + Page::GraphicsAdvanced => "sparkles", + Page::Controls => "hand", + Page::KeyBinds => "keyboard", + Page::Mouse => "mouse-pointer-click", + Page::Chat => "chat-thread", + Page::Accessibility => "eye", + Page::Language => "globe", + Page::Font => "type", + Page::Packs => "layers", + Page::Mods => "boxes", + Page::ShaderPacks => "sparkles", + Page::Controller => "hand", + Page::Extras => "wand", + Page::Horizon => "settings-2", + Page::Agent => "bot", + Page::Credits => "award", + _ => "settings", + } +} +/// What a row shows on the right. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Control { + /// A value with a plate to cycle. + Cycle, + /// A bar and its value, 0..1. + Slider(f32), + /// A button (a sub-page, an action). + Action, +} + +#[derive(Clone, Debug)] +enum Kind { + Header(String), + Row { + caption: String, + value: String, + control: Control, + enabled: bool, + tip: Option, + }, +} + +/// A resource pack row: its pack, or the folder button. +#[derive(Clone, Debug, PartialEq)] +enum PackHit { + Toggle(usize), + Folder, +} + +/// One laid-out row: its band in the list, the vanilla widget it forwards a +/// press to, and what it draws. +#[derive(Clone, Debug)] +struct Slot { + top: f32, + h: f32, + /// The widget's rectangle in GUI pixels (`options_ui`'s own layout). + vanilla: [f32; 4], + /// The option a slider row edits, for a press's drag. + opt: Option, + /// A graphics setting, edited in place instead of forwarded. + item: Option, + /// A resource pack row: its pack, or the folder button. + pack: Option, + /// A key binding row: the mapping it rebinds, and whether its reset is live. + key: Option<&'static str>, + reset: bool, + kind: Kind, +} + +/// The rows of the current page, in list order. +fn slots(o: &mut OptionsUi, s: &Settings, w: f32, h: f32) -> Vec { + let mut out = Vec::new(); + // The rail is the main page: its grid of sub-screen buttons is the rail. + if o.page() == Page::Main { + return out; + } + if let Some(level) = OptionsUi::graphics_level(o.page()) { + return graphics_slots(level, s); + } + if o.page() == Page::Packs { + return packs_slots(o, s); + } + if o.page() == Page::KeyBinds { + return keybind_slots(o, s); + } + let (widgets, _, _, _) = o.layout(s, w, h); + let mut y = 0.0; + for wd in widgets { + // The page title and the Done button are the shell's own. + if matches!(&wd.kind, WKind::Text { centered: true, .. } if wd.y < 14.0) { + continue; + } + if matches!(&wd.kind, WKind::Button { act: Act::Done, .. }) { + continue; + } + let (kind, h) = match &wd.kind { + WKind::Text { text, .. } => (Kind::Header(text.plain()), HEADER), + WKind::Button { label, active, act } => match act { + Act::Cycle(opt) => ( + Kind::Row { + caption: crate::lang::get(opt.caption()), + value: label.plain(), + control: Control::Cycle, + enabled: *active, + tip: tip_for(*opt, s), + }, + ROW, + ), + Act::Push(page) => ( + Kind::Row { + caption: label.plain(), + value: String::new(), + control: Control::Action, + enabled: *active, + tip: crate::options_ui::page_tooltip(*page).map(str::to_owned), + }, + ROW, + ), + Act::Extra(item) => { + let (caption, value) = split_label(&label.plain()); + ( + Kind::Row { + caption, + value, + control: Control::Cycle, + enabled: *active, + tip: Some(item.description().to_owned()), + }, + ROW, + ) + } + _ => { + let (caption, value) = split_label(&label.plain()); + ( + Kind::Row { + caption, + value, + control: Control::Action, + enabled: *active, + tip: None, + }, + ROW, + ) + } + }, + WKind::Slider { label, opt, .. } => ( + Kind::Row { + caption: crate::lang::get(opt.caption()), + value: label.plain(), + control: Control::Slider(opt.slider_position(s).unwrap_or(0.0)), + enabled: true, + tip: tip_for(*opt, s), + }, + ROW, + ), + WKind::LanguageRow { label, selected, .. } => ( + Kind::Row { + caption: label.plain(), + value: if *selected { "●".into() } else { String::new() }, + control: Control::Action, + enabled: !*selected, + tip: None, + }, + ROW, + ), + }; + let opt = match &wd.kind { + WKind::Slider { opt, .. } => Some(*opt), + WKind::Button { + act: Act::Cycle(opt), + .. + } => Some(*opt), + _ => None, + }; + out.push(Slot { + top: y, + h, + vanilla: [wd.x, wd.y, wd.w, wd.h], + opt, + item: None, + pack: None, + key: None, + reset: false, + kind, + }); + y += h + ROW_GAP; + } + out +} + +/// The graphics pages' rows: the same shell, the settings from `graphics_ui`. +fn graphics_slots(level: crate::graphics_ui::Level, s: &Settings) -> Vec { + use crate::graphics_ui::{Item, Row}; + let mut out = Vec::new(); + let mut y = 0.0; + for row in crate::graphics_ui::rows(level) { + let (kind, h, item) = match row { + Row::Header(text) => (Kind::Header(text.to_owned()), HEADER, None), + Row::Setting(setting) => { + let (caption, value) = split_label(&setting.item.label(s).plain()); + let control = match setting.item { + Item::Vanilla(opt) => opt + .slider_position(s) + .map_or(Control::Cycle, Control::Slider), + Item::Horizon(_) => setting + .item + .slider(s) + .map_or(Control::Cycle, Control::Slider), + }; + ( + Kind::Row { + caption, + value, + control, + enabled: true, + tip: Some(setting.text.to_owned()), + }, + ROW, + Some(setting.item), + ) + } + }; + out.push(Slot { + top: y, + h, + vanilla: [0.0; 4], + opt: None, + item, + pack: None, + key: None, + reset: false, + kind, + }); + y += h + ROW_GAP; + } + out +} +/// The resource packs page: every pack with its state, and the folder button. +fn packs_slots(o: &OptionsUi, s: &Settings) -> Vec { + let mut out = Vec::new(); + let mut y = 0.0; + out.push(Slot { + top: y, + h: HEADER, + vanilla: [0.0; 4], + opt: None, + item: None, + pack: None, + key: None, + reset: false, + kind: Kind::Header("РЕСУРСПАКИ".to_owned()), + }); + y += HEADER + ROW_GAP; + let row = |top: f32, caption: String, value: String, enabled: bool, tip: Option, pack: Option| Slot { + top, + h: ROW, + vanilla: [0.0; 4], + opt: None, + item: None, + pack, + key: None, + reset: false, + kind: Kind::Row { + caption, + value, + control: Control::Action, + enabled, + tip, + }, + }; + out.push(row( + y, + "Открыть папку".to_owned(), + String::new(), + true, + Some("Открыть папку с ресурспаками".to_owned()), + Some(PackHit::Folder), + )); + y += ROW + ROW_GAP; + for (i, info) in o.pack_infos.iter().enumerate() { + let selected = s.resource_packs.iter().any(|id| id == &info.id); + out.push(row( + y, + info.title.plain(), + if selected { "ВЫБРАН" } else { "ДОБАВИТЬ" }.to_owned(), + !info.required, + Some(info.description.plain()), + Some(PackHit::Toggle(i)), + )); + y += ROW + ROW_GAP; + } + out +} + +/// The key binds page: a row per action, its bind plate and a red trash +/// button that restores the default. +fn keybind_slots(o: &OptionsUi, s: &Settings) -> Vec { + use crate::keybinds::{CATEGORIES, MAPPINGS, display_name}; + let mut mappings: Vec<(&'static str, &'static str)> = + MAPPINGS.iter().map(|(name, _, category)| (*name, *category)).collect(); + mappings.sort_by(|a, b| { + let ca = CATEGORIES.iter().position(|c| *c == a.1).unwrap_or(99); + let cb = CATEGORIES.iter().position(|c| *c == b.1).unwrap_or(99); + ca.cmp(&cb) + .then_with(|| crate::lang::get(a.0).cmp(&crate::lang::get(b.0))) + }); + let mut out = Vec::new(); + let mut y = 0.0; + let mut last = ""; + for (name, category) in mappings { + if category != last { + last = category; + let key = format!("key.category.minecraft.{category}"); + out.push(Slot { + top: y, + h: HEADER, + vanilla: [0.0; 4], + opt: None, + item: None, + pack: None, + key: None, + reset: false, + kind: Kind::Header(crate::lang::get_or(&key, category)), + }); + y += HEADER + ROW_GAP; + } + let mut value = display_name(s.keys.get(name)).plain(); + if o.selected_key == Some(name) { + value = "> НАЖМИТЕ КЛАВИШУ <".to_owned(); + } else if !s.keys.conflicts(name).is_empty() { + value.push_str(" !"); + } + out.push(Slot { + top: y, + h: ROW, + vanilla: [0.0; 4], + opt: None, + item: None, + pack: None, + key: Some(name), + reset: !s.keys.is_default(name), + kind: Kind::Row { + caption: crate::lang::get_or(name, name), + value, + control: Control::Action, + enabled: true, + tip: None, + }, + }); + y += ROW + ROW_GAP; + } + out +} + +/// A resource pack row: select it or open the folder. +fn pack_click( + o: &mut OptionsUi, + hit: &PackHit, + s: &mut Settings, +) -> Vec { + match hit { + PackHit::Folder => vec![crate::options_ui::Event::OpenPackFolder], + PackHit::Toggle(i) => { + let Some(info) = o.pack_infos.get(*i) else { + return Vec::new(); + }; + if info.required { + return Vec::new(); + } + let id = info.id.clone(); + if s.resource_packs.iter().any(|seen| seen == &id) { + s.resource_packs.retain(|seen| seen != &id); + } else { + s.resource_packs.push(id); + } + o.pack_selection = s.resource_packs.clone(); + vec![crate::options_ui::Event::Packs(s.resource_packs.clone())] + } + } +} + +fn tip_for(opt: crate::options_ui::Opt, s: &Settings) -> Option { let key = opt.tooltip_key(s); + crate::lang::has(&key).then(|| crate::lang::get(&key)) +} +fn split_label(label: &str) -> (String, String) { + match label.split_once(": ") { + Some((caption, value)) => (caption.to_owned(), value.to_owned()), + None => (label.to_owned(), String::new()), + } +} + +/// The rail's item under a dp point, scrolled by `scroll`. +fn rail_at((x, y): (f32, f32), h: f32, scroll: f32) -> Option { + if x >= RAIL || y < RAIL_TOP || y >= h - FOOTER { + return None; + } + let (items, _) = rail_items(); + items.iter().find_map(|&(page, top)| { + let top = RAIL_TOP + top - scroll; + (y >= top && y < top + RAIL_ITEM).then_some(page) + }) +} + +/// The wheel: over the rail it scrolls the categories, elsewhere the rows. +pub fn wheel(o: &mut OptionsUi, size: (f32, f32), cursor: (f32, f32), amount: f32) { + let frame = super::Frame::new(size, o.scale.max(0.1), cursor); + let at = frame.at(); + if at.0 < RAIL { + o.rust_rail_target = (o.rust_rail_target - amount * 44.0).max(0.0); + } else { + o.rust_scroll_target = (o.rust_scroll_target - amount * 44.0).max(0.0); + } +} + +/// The back button's rect (dp). +fn back_rect(w: f32, h: f32) -> [f32; 4] { + [w - PAD - 120.0, h - FOOTER + 13.0, 120.0, 30.0] +} + +/// Draws the shell and the page. +pub fn draw( + o: &mut OptionsUi, + s: &Settings, + atlas: &HudAtlas, + batch: &mut HudBatch, + cursor: (f32, f32), +) { + let frame = super::Frame::new((batch.width, batch.height), batch.scale, cursor); + let (w, h) = (frame.w, frame.h); + let at = frame.at(); + let page = o.page(); + let slots = slots(o, s, batch.width, batch.height); + let list_top = HEAD; + let list_bottom = h - FOOTER - 8.0; + let content = slots.last().map_or(0.0, |slot| slot.top + slot.h); + let max_scroll = (content - (list_bottom - list_top)).max(0.0); + // Inertia: the wheel moves the target, the frame eases the view toward it. + let now = std::time::Instant::now(); + let dt = now.duration_since(o.rust_tick).as_secs_f32().clamp(0.0, 0.1); + o.rust_tick = now; + let ease = 1.0 - (-dt * 22.0).exp(); + let rail_max = (rail_height() - (h - FOOTER - RAIL_TOP)).max(0.0); + o.rust_rail_target = o.rust_rail_target.clamp(0.0, rail_max); + o.rust_rail += (o.rust_rail_target - o.rust_rail) * ease; + if (o.rust_rail_target - o.rust_rail).abs() < 0.25 { + o.rust_rail = o.rust_rail_target; + } + o.rust_scroll_target = o.rust_scroll_target.clamp(0.0, max_scroll); + o.rust_scroll += (o.rust_scroll_target - o.rust_scroll) * ease; + if (o.rust_scroll_target - o.rust_scroll).abs() < 0.25 { + o.rust_scroll = o.rust_scroll_target; + } + let (scroll, rail_scroll) = (o.rust_scroll, o.rust_rail); + let mut ui = Ui::new(atlas, batch, cursor); + // The pages dim the scene more than the menu does. + ui.rounded(0.0, 0.0, w, h, 0.0, theme::DIM); + + // Chrome: scrims over the blurred scene, the rail and the footer. + ui.gradient_v(0.0, 0.0, w, 92.0, theme::SCRIM_TOP, theme::faded(theme::SCRIM_TOP, 0.0)); + ui.gradient_v(0.0, h - 120.0, w, 120.0, theme::faded(theme::SCRIM_BOTTOM, 0.0), theme::SCRIM_BOTTOM); + ui.rounded(0.0, 0.0, RAIL, h - FOOTER, 0.0, theme::RAIL); + ui.rounded(0.0, h - FOOTER, w, FOOTER, 0.0, theme::RAIL); + ui.rounded(RAIL, 0.0, 1.0, h - FOOTER, 0.0, theme::BORDER_SOFT); + + // The rail: grouped categories, the open one on a raised plate with the + // accent, clipped to its own column while it scrolls. + let (items, lines) = rail_items(); + let rail_clip = ui.batch.vertices.len(); + for &ly in &lines { + let y = RAIL_TOP + ly - rail_scroll; + if y >= RAIL_TOP - 6.0 && y <= h - FOOTER { + ui.rounded(14.0, y + 5.0, RAIL - 28.0, 1.0, 0.0, theme::BORDER_SOFT); + } + } + for &(entry, top) in &items { + let y = RAIL_TOP + top - rail_scroll; + if y + RAIL_ITEM < RAIL_TOP || y > h - FOOTER { + continue; + } + let open = entry == page; + let hovered = !open + && at.0 < RAIL + && at.1 >= y + && at.1 < y + RAIL_ITEM + && at.1 >= RAIL_TOP + && at.1 < h - FOOTER; + if open || hovered { + ui.rounded( + 10.0, + y, + RAIL - 20.0, + RAIL_ITEM, + 0.0, + if open { theme::ACTIVE } else { theme::HOVER }, + ); + } + if open { + ui.rounded(10.0, y + 5.0, 3.0, RAIL_ITEM - 10.0, 0.0, theme::ACCENT); + } + super::icons::draw( + &mut ui, + page_icon(entry), + [14.0, y + 7.0, 14.0, 14.0], + if open { theme::TEXT } else { theme::MUTED }, + ); + ui.text( + if open { Face::MenuStrong } else { Face::Menu }, + 12.0, + 36.0, + y + 5.0, + if open { theme::TEXT } else { theme::SOFT }, + &label(entry), + ); + } + let clip = [ + ui.to_gui(0.0), + ui.to_gui(RAIL_TOP), + ui.to_gui(RAIL), + ui.to_gui(h - FOOTER), + ]; + ui.batch.clip_since(rail_clip, clip[0], clip[1], clip[2], clip[3]); + + // The page's title and the rows. + ui.text(Face::MenuStrong, 19.0, RAIL + PAD, 26.0, theme::TEXT, &label(page)); + ui.rounded(RAIL + PAD, HEAD - 10.0, 44.0, 2.0, 0.0, theme::ACCENT); + let mut hovered_tip: Option<(String, String)> = None; + let rows_clip = ui.batch.vertices.len(); + for slot in &slots { + let y = list_top + slot.top - scroll; + if y + slot.h < list_top || y > list_bottom { + continue; + } + let x = RAIL + PAD; + let row_w = w - RAIL - TIPS - PAD * 2.0; + match &slot.kind { + Kind::Header(text) => { + super::icons::draw( + &mut ui, + header_icon(text), + [x, y + 7.0, 12.0, 12.0], + theme::ACCENT, + ); + ui.text( + Face::MenuStrong, + 11.0, + x + 18.0, + y + 6.0, + theme::TEXT, + &upper(text), + ); + } + Kind::Row { + caption, + value, + control, + enabled, + tip, + } => { + let hover = at.0 >= x && at.0 < x + row_w && at.1 >= y && at.1 < y + slot.h; + if hover { + ui.rounded(x, y, row_w, slot.h, 0.0, theme::PANEL); + if let Some(tip) = tip { + hovered_tip = Some((caption.clone(), tip.clone())); + } + } + let color = if *enabled { theme::TEXT } else { theme::LOCKED }; + ui.text(Face::Menu, 12.0, x + 10.0, y + 9.0, color, caption); + let (cx, cw) = control_rect(w); + let cy = y + 6.0; + let ch = 24.0; + match control { + Control::Slider(v) => { + let bw = cw; + ui.text(Face::Menu, 11.0, cx + bw - ui.measure(Face::Menu, 11.0, value), y + 2.0, theme::SOFT, value); + ui.rounded(cx, cy + 14.0, bw, 5.0, 0.0, theme::TRACK); + ui.rounded(cx, cy + 14.0, (bw * v).max(2.0), 5.0, 0.0, theme::ACCENT); + ui.rounded(cx + (bw * v).clamp(0.0, bw - 10.0), cy + 11.5, 10.0, 10.0, 0.0, theme::TEXT); + } + _ => { + let key_row = slot.key.is_some(); + let bind_w = if key_row { cw - 32.0 } else { cw }; + let plate = if *enabled { theme::HOVER } else { theme::CHIP }; + ui.rounded(cx, cy, bind_w, ch, 0.0, plate); + ui.border(cx, cy, bind_w, ch, 0.0, theme::BORDER); + if !value.is_empty() { + let tw = ui.measure(Face::Menu, 11.0, value); + ui.text(Face::Menu, 11.0, cx + bind_w - tw - 10.0, cy + 4.0, color, value); + } + if key_row { + let rx = cx + cw - 26.0; + let hot = slot.reset; + ui.rounded( + rx, + cy, + 26.0, + ch, + 0.0, + if hot { + theme::faded(theme::ACCENT, 0.22) + } else { + theme::CHIP + }, + ); + ui.border( + rx, + cy, + 26.0, + ch, + 0.0, + if hot { theme::ACCENT } else { theme::BORDER }, + ); + super::icons::draw( + &mut ui, + "trash", + [rx + 6.0, cy + 5.0, 14.0, 14.0], + if hot { theme::ACCENT } else { theme::LOCKED }, + ); + } else if matches!(control, Control::Action) { + ui.rounded(cx + cw - 4.0, cy + 8.0, 2.0, 8.0, 0.0, theme::ACCENT); + } + } + } + } + } + } + + // The explanation panel: the hovered setting, else the page's own note. + let clip = [ + ui.to_gui(RAIL + PAD), + ui.to_gui(list_top), + ui.to_gui(w - TIPS + 8.0), + ui.to_gui(list_bottom), + ]; + ui.batch.clip_since(rows_clip, clip[0], clip[1], clip[2], clip[3]); + // The page's scrollbar, between the rows and the explanation panel. + let content_h = slots.last().map_or(0.0, |slot| slot.top + slot.h); + if content_h > list_bottom - list_top { + let track = list_bottom - list_top; + let thumb = (track * track / content_h).clamp(24.0, track); + let ty = list_top + (scroll / (content_h - track).max(1.0)) * (track - thumb); + let sx = w - TIPS - 6.0; + ui.rounded(sx, list_top, 4.0, track, 0.0, theme::BORDER_SOFT); + ui.rounded(sx, ty, 4.0, thumb, 0.0, theme::THUMB); + } + let tips_x = w - TIPS + 8.0; + ui.rounded(tips_x, list_top, TIPS - PAD - 8.0, list_bottom - list_top, 0.0, theme::PANEL); + let (tip_title, tip_text) = match hovered_tip { + Some(tip) => tip, + None => (label(page), crate::options_ui::page_tooltip(page).unwrap_or("").to_owned()), + }; + let mut ty = list_top + 16.0; + if !tip_title.is_empty() { + ui.text(Face::MenuStrong, 11.0, tips_x + 14.0, ty, theme::TEXT, &upper(&tip_title)); + ty += 24.0; + } + for line in wrap(&tip_text, 30) { + ui.text(Face::Menu, 11.0, tips_x + 14.0, ty, theme::SOFT, &line); + ty += 16.0; + } + + // The footer: where we are, the version with the credit, and the way back. + let credit = format!( + "RustNotMC {} · основано на Horizon (Rust-клиент, MIT)", + horizon_core::release::VERSION + ); + ui.text(Face::Menu, 10.0, PAD, h - FOOTER + 20.0, theme::MUTED, &credit); + let [bx, by, bw, bh] = back_rect(w, h); + let hover = at.0 >= bx && at.0 < bx + bw && at.1 >= by && at.1 < by + bh; + ui.rounded(bx, by, bw, bh, 0.0, if hover { theme::HOVER } else { theme::CHIP }); + ui.border(bx, by, bw, bh, 0.0, theme::BORDER); + ui.text_centered(Face::MenuStrong, 11.0, bx + bw / 2.0, by + 6.0, theme::TEXT, "НАЗАД"); + ui.finish(); +} + +/// Handles a press: the rail, the back button or a row. `None` means the press +/// is not the shell's and the caller should run its own handling. +pub fn click( + o: &mut OptionsUi, + s: &mut Settings, + atlas: &HudAtlas, + cursor: (f32, f32), + size: (f32, f32), + framebuffer: [i32; 2], + drag: bool, + right: bool, +) -> Option> { + let frame = super::Frame::new(size, o.scale.max(0.1), cursor); + let (w, h) = (frame.w, frame.h); + let at = frame.at(); + let slots = slots(o, s, size.0, size.1); + let content = slots.last().map_or(0.0, |slot| slot.top + slot.h); + let viewport = (h - FOOTER - 8.0 - HEAD).max(1.0); + let max_scroll = (content - viewport).max(0.0); + let sb_x = w - TIPS - 10.0; + let in_bar = at.0 >= sb_x && at.0 < w - TIPS && at.1 >= HEAD && at.1 < h - FOOTER - 8.0; + if drag && o.rust_scroll_drag.is_some() { + let t = ((at.1 - HEAD) / viewport).clamp(0.0, 1.0); + o.rust_scroll_target = t * max_scroll; + o.rust_scroll = o.rust_scroll_target; + return Some(Vec::new()); + } + if !right && in_bar && max_scroll > 0.0 { + o.rust_scroll_drag = Some(at.1); + let t = ((at.1 - HEAD) / viewport).clamp(0.0, 1.0); + o.rust_scroll_target = t * max_scroll; + o.rust_scroll = o.rust_scroll_target; + return Some(Vec::new()); + } + if drag { + // A held graphics slider follows the pointer across its bar. + if let Some(item) = o.rust_graphics_drag { + let (cx, cw) = control_rect(w); + let fx = ((at.0 - cx) / cw).clamp(0.0, 1.0); + item.set_slider(s, fx); + return Some(vec![crate::options_ui::Event::Changed]); + } + // A held slider follows the pointer across its own bar. + let Some(opt) = o.dragging else { + return Some(Vec::new()); + }; + let Some(slot) = slots.iter().find(|slot| slot.opt == Some(opt)) else { + return Some(Vec::new()); + }; + let (bx, bw) = control_rect(w); + let fx = ((at.0 - bx) / bw).clamp(0.0, 1.0); + let vx = slot.vanilla[0] + slot.vanilla[2] * fx; + let vy = slot.vanilla[1] + slot.vanilla[3] / 2.0; + return Some(o.click_vanilla(s, atlas, vx, vy, size, framebuffer, true, false)); + } + if !right { + let [bx, by, bw, bh] = back_rect(w, h); + if at.0 >= bx && at.0 < bx + bw && at.1 >= by && at.1 < by + bh { + return Some(o.back(s)); + } + if let Some(page) = rail_at(at, h, o.rust_rail) { + let in_world = o.in_world; + o.open_at(in_world, page); + o.rust_scroll = 0.0; + o.rust_scroll_target = 0.0; + return Some(vec![crate::options_ui::Event::ButtonClick]); + } + } + // A row forwards its press to the widget it stands for. + let list_top = HEAD; + let list_bottom = h - FOOTER - 8.0; + if at.0 >= RAIL + PAD && at.0 < w - TIPS && at.1 >= list_top && at.1 < list_bottom { + for slot in &slots { + let y = list_top + slot.top - o.rust_scroll; + if at.1 >= y && at.1 < y + slot.h { + // A key binding row: the plate rebinds, the red square resets. + if let Some(key) = slot.key { + let (cx, cw) = control_rect(w); + let cy = y + 6.0; + let reset_hot = slot.reset + && at.0 >= cx + cw - 26.0 + && at.0 < cx + cw + && at.1 >= cy + && at.1 < cy + 24.0; + if reset_hot { + s.keys + .set(key, crate::keybinds::KeyBinds::default_of(key)); + return Some(vec![ + crate::options_ui::Event::ButtonClick, + crate::options_ui::Event::Changed, + ]); + } + if at.0 >= cx && at.0 < cx + cw - 32.0 { + o.selected_key = Some(key); + return Some(vec![crate::options_ui::Event::ButtonClick]); + } + return Some(Vec::new()); + } + // A resource pack row selects or opens the folder. + if let Some(pack) = &slot.pack { + return Some(pack_click(o, pack, s)); + } + // A graphics row edits its own setting; the others forward + // their press to the widget they stand for. + if let Some(item) = slot.item { + let (cx, cw) = control_rect(w); + if at.0 >= cx && at.0 < cx + cw { + if item.slider(s).is_some() { + let fx = ((at.0 - cx) / cw).clamp(0.0, 1.0); + item.set_slider(s, fx); + o.rust_graphics_drag = Some(item); + return Some(vec![ + crate::options_ui::Event::ButtonClick, + crate::options_ui::Event::Changed, + ]); + } + item.cycle(s, right, framebuffer); + return Some(vec![ + crate::options_ui::Event::ButtonClick, + crate::options_ui::Event::Changed, + ]); + } + return Some(Vec::new()); + } + let fx = match &slot.kind { + Kind::Row { + control: Control::Slider(_), + .. + } => { + let (bx, bw) = control_rect(w); + ((at.0 - bx) / bw).clamp(0.0, 1.0) + } + _ => 0.5, + }; + let vx = slot.vanilla[0] + slot.vanilla[2] * fx; + let vy = slot.vanilla[1] + slot.vanilla[3] / 2.0; + return Some(o.click_vanilla(s, atlas, vx, vy, size, framebuffer, false, right)); + } + } + } + Some(Vec::new()) +} + +/// The row's control on the right: where it starts and how wide it is, in dp +/// (the same numbers `draw` paints with). +fn control_rect(w: f32) -> (f32, f32) { + let row_w = w - RAIL - TIPS - PAD * 2.0; + let cw = (row_w * 0.42).clamp(140.0, 240.0); + (RAIL + PAD + row_w - cw - 10.0, cw) +} + +fn upper(text: &str) -> String { + text.to_uppercase() +} +/// Greedy wrap for the explanation panel (the HUD's own font is not measured +/// here; 30 characters read well in the panel at 11 dp). +fn wrap(text: &str, limit: usize) -> Vec { + let mut lines = Vec::new(); + let mut line = String::new(); + for word in text.split_whitespace() { + if !line.is_empty() && line.chars().count() + 1 + word.chars().count() > limit { + lines.push(std::mem::take(&mut line)); + } + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); + } + if !line.is_empty() { + lines.push(line); + } + lines +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::{HudAtlas, HudBatch}; + use std::path::Path; + + fn atlas() -> HudAtlas { + HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap() + } + fn fixture(page: Page) -> (OptionsUi, Settings) { + let mut o = OptionsUi::default(); + o.rust = true; + o.open_at(false, page); + (o, Settings::default()) + } + fn canvas(w: f32, h: f32) -> HudBatch { + let mut batch = HudBatch::new([1920, 1080]); + batch.scale = 1.0; + batch.width = w; + batch.height = h; + batch + } + + /// Every page the shell draws paints, and its rows stack in order. + #[test] + fn every_rust_page_draws_its_rows_in_order() { + let atlas = atlas(); + for page in GROUPS.iter().flat_map(|group| group.iter().copied()) { + if keeps_its_screen(page) { + continue; + } + let (mut o, s) = fixture(page); + let rows = slots(&mut o, &s, 1040.0, 700.0); + let mut y = -1.0; + for row in &rows { + assert!(row.top > y, "{page:?}: rows out of order"); + y = row.top; + if !matches!(row.kind, Kind::Header(_)) { + assert!( + row.vanilla[2] > 0.0 + || row.item.is_some() + || row.pack.is_some() + || row.key.is_some(), + "{page:?}: a row without a widget" + ); + } + } + let mut batch = canvas(1040.0, 700.0); + crate::ui_theme::reset_atlas(); + o.draw(&s, &mut batch, &atlas, (0.0, 0.0)); + assert!(!batch.vertices.is_empty(), "{page:?} draws nothing"); + assert!(!batch.patches.is_empty(), "{page:?} draws no text"); + } + } + + /// The rail opens the page it names, and the footer's НАЗАД leaves it. + #[test] + fn the_rail_opens_a_page_and_the_footer_goes_back() { + let atlas = atlas(); + let (mut o, mut s) = fixture(Page::Main); + let (items, _) = rail_items(); + let second = RAIL_TOP + items[1].1 + RAIL_ITEM / 2.0; + o.click(&mut s, &atlas, 60.0, second, (1040.0, 700.0), [1040, 700], false, false); + assert_eq!(o.page(), items[1].0); + let [bx, by, bw, bh] = back_rect(1040.0, 700.0); + let events = o.click( + &mut s, + &atlas, + bx + bw / 2.0, + by + bh / 2.0, + (1040.0, 700.0), + [1040, 700], + false, + false, + ); + assert!(!events.is_empty(), "the way back reports itself"); + } + + /// A row forwards its press to the setting it stands for. + #[test] + fn a_cycle_row_changes_its_setting() { + let atlas = atlas(); + let (mut o, mut s) = fixture(Page::Video); + let rows = slots(&mut o, &s, 1040.0, 700.0); + let row = rows + .iter() + .find(|row| { + matches!( + row.kind, + Kind::Row { + control: Control::Cycle, + enabled: true, + .. + } + ) + }) + .expect("a setting to cycle"); + let events = o.click( + &mut s, + &atlas, + RAIL + PAD + 60.0, + HEAD + row.top + row.h / 2.0, + (1040.0, 700.0), + [1040, 700], + false, + false, + ); + assert!( + events.contains(&crate::options_ui::Event::Changed), + "{events:?}" + ); + } + + /// A graphics row edits its own setting, and lets go on release. + #[test] + fn a_graphics_row_edits_its_setting() { + let atlas = atlas(); + let (mut o, mut s) = fixture(Page::Graphics); + let rows = slots(&mut o, &s, 1040.0, 700.0); + let row = rows + .iter() + .find(|row| { + row.item.is_some() + && matches!(row.kind, Kind::Row { control: Control::Cycle, .. }) + }) + .expect("a graphics cycle row"); + let (cx, cw) = control_rect(1040.0); + let events = o.click( + &mut s, + &atlas, + cx + cw / 2.0, + HEAD + row.top + row.h / 2.0, + (1040.0, 700.0), + [1040, 700], + false, + false, + ); + assert!(events.contains(&crate::options_ui::Event::Changed), "{events:?}"); + } + /// The resource packs page is the shell's own: its folder row opens the + /// folder through the game's event. + #[test] + fn the_packs_page_opens_its_folder() { + let atlas = atlas(); + let (mut o, mut s) = fixture(Page::Packs); + let rows = slots(&mut o, &s, 1040.0, 700.0); + let row = rows + .iter() + .find(|row| row.pack == Some(PackHit::Folder)) + .expect("the folder row"); + let events = o.click( + &mut s, + &atlas, + RAIL + PAD + 40.0, + HEAD + row.top + row.h / 2.0, + (1040.0, 700.0), + [1040, 700], + false, + false, + ); + assert!( + events.contains(&crate::options_ui::Event::OpenPackFolder), + "{events:?}" + ); + } + /// Key binds pair the action, its bind plate and the red reset square. + #[test] + fn keybinds_pair_the_bind_and_the_reset() { + use crate::keybinds::KeyBinds; + let atlas = atlas(); + let (mut o, mut s) = fixture(Page::KeyBinds); + s.keys.set("key.jump", "key.keyboard.g"); + let rows = slots(&mut o, &s, 1040.0, 700.0); + let jump = rows + .iter() + .find(|row| row.key == Some("key.jump")) + .expect("the jump row"); + assert!(jump.reset, "a changed bind can be reset"); + let (cx, cw) = control_rect(1040.0); + let click = |o: &mut OptionsUi, s: &mut Settings, x: f32, y: f32| { + o.click( + s, + &atlas, + x, + y, + (1040.0, 700.0), + [1040, 700], + false, + false, + ) + }; + // The bind plate waits for a key. + let y = HEAD + jump.top + jump.h / 2.0; + let events = click(&mut o, &mut s, cx + 40.0, y); + assert!(events.contains(&crate::options_ui::Event::ButtonClick)); + assert_eq!(o.selected_key, Some("key.jump")); + // The trash square restores the default. + let events = click(&mut o, &mut s, cx + cw - 13.0, y); + assert!(events.contains(&crate::options_ui::Event::Changed), "{events:?}"); + assert_eq!(s.keys.get("key.jump"), KeyBinds::default_of("key.jump")); + } + /// The wheel scrolls the shell's own list, never past its top. + #[test] + fn the_wheel_scrolls_the_rows_and_stops_at_the_top() { + let (mut o, s) = fixture(Page::Video); + o.scroll_at(&s, (480.0, 270.0), (300.0, 200.0), -3.0); + assert!(o.rust_scroll_target > 0.0); + o.scroll_at(&s, (480.0, 270.0), (300.0, 200.0), 99.0); + assert_eq!(o.rust_scroll_target, 0.0); + } +} diff --git a/crates/horizon-client/src/rustui/tests.rs b/crates/horizon-client/src/rustui/tests.rs new file mode 100644 index 0000000..b73a638 --- /dev/null +++ b/crates/horizon-client/src/rustui/tests.rs @@ -0,0 +1,378 @@ +use super::*; +use crate::hud::HudBatch; +use std::path::Path; + +fn atlas() -> HudAtlas { + HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap() +} +fn frame(w: f32, h: f32) -> Frame { + Frame::new((w, h), 1.0, (0.0, 0.0)) +} +fn meta() -> Meta { + Meta { elapsed: 12.0 } +} +fn home() -> RustMenu { + RustMenu::new(Path::new("")) +} +/// A batch whose GUI pixels are its dp ones, so test coordinates are layout ones. +fn canvas(w: f32, h: f32) -> HudBatch { + let mut batch = HudBatch::new([1920, 1080]); + batch.scale = 1.0; + batch.width = w; + batch.height = h; + batch +} +fn button<'a>(nodes: &'a [Node], label: &str) -> &'a Node { + nodes + .iter() + .find(|node| matches!(node, Node::Button { label: l, .. } if l == label)) + .unwrap_or_else(|| panic!("no button {label}")) +} +fn center([x, y, w, h]: [f32; 4]) -> (f32, f32) { + (x + w / 2.0, y + h / 2.0) +} +fn overlap(a: [f32; 4], b: [f32; 4]) -> bool { + a[0] < b[0] + b[2] && b[0] < a[0] + a[2] && a[1] < b[1] + b[3] && b[1] < a[1] + a[3] +} + +#[test] +fn layout_stays_inside_the_frame_on_every_window() { + for (w, h) in [(1040.0, 700.0), (1244.0, 700.0), (900.0, 620.0)] { + for tab in [Tab::Home, Tab::Play] { + let mut menu = home().with_saved(vec![("Alpha".into(), "1.1.1.1".into())]); + menu.tab = tab; + let nodes = menu.layout(&frame(w, h), ""); + for node in &nodes { + let [x, y, bw, bh] = node.rect(); + assert!( + x >= -0.01 && y >= -0.01 && x + bw <= w + 0.01 && y + bh <= h + 0.01, + "{tab:?} {w}x{h}: {node:?}" + ); + } + // The bar's plates never collide. + let bar: Vec<[f32; 4]> = nodes + .iter() + .filter_map(|node| match node { + Node::Button { rect, .. } if rect[1] < TOP => Some(*rect), + _ => None, + }) + .collect(); + for (i, a) in bar.iter().enumerate() { + for b in &bar[i + 1..] { + assert!(!overlap(*a, *b), "{tab:?} {w}x{h}: {a:?} over {b:?}"); + } + } + } + } +} + +#[test] +fn bar_is_tabs_and_actions_without_a_wordmark() { + let nodes = home().layout(&frame(1040.0, 700.0), ""); + // Nothing sits at the far left of the bar: the tabs start in the centre. + assert!( + nodes + .iter() + .filter(|node| node.rect()[1] < TOP) + .all(|node| node.rect()[0] > 150.0), + "the bar has no wordmark" + ); + assert!(matches!( + button(&nodes, "ГЛАВНАЯ"), + Node::Button { + variant: Variant::Tab { active: true }, + id: Some(Hit::Tab(Tab::Home)), + icon: Some("house"), + .. + } + )); + assert!(matches!( + button(&nodes, "ИГРАТЬ"), + Node::Button { + variant: Variant::Tab { active: false }, + id: Some(Hit::Tab(Tab::Play)), + .. + } + )); + for locked in ["МАГАЗИН", "ИНВЕНТАРЬ"] { + assert!( + matches!( + button(&nodes, locked), + Node::Button { + id: None, + enabled: false, + tip: Some(TIP), + .. + } + ), + "{locked}" + ); + } + assert!(matches!( + button(&nodes, "НАСТРОЙКИ"), + Node::Button { + id: Some(Hit::Options), + .. + } + )); + assert!(matches!( + button(&nodes, "ВЫЙТИ"), + Node::Button { id: Some(Hit::Quit), .. } + )); +} + +#[test] +fn locked_tabs_do_not_press() { + let mut menu = home(); + let at = { + let nodes = menu.layout(&frame(1040.0, 700.0), ""); + center(button(&nodes, "МАГАЗИН").rect()) + }; + let (outcome, sound) = menu.click(&Frame::new((1040.0, 700.0), 1.0, at), ""); + assert_eq!(outcome, Outcome::None); + assert!(!sound, "a locked tab does not click"); +} + +#[test] +fn rows_merge_the_last_server_dedupe_and_cap() { + let menu = RustMenu::new(Path::new("")).with_saved(vec![ + ("Alpha".into(), "1.1.1.1".into()), + ("Beta".into(), "2.2.2.2".into()), + ("Alpha again".into(), "1.1.1.1".into()), + ("Gamma".into(), "3.3.3.3".into()), + ("Delta".into(), "4.4.4.4".into()), + ]); + let rows = menu.rows("9.9.9.9"); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0], ("9.9.9.9".to_owned(), "9.9.9.9".to_owned())); + assert_eq!(rows[1], ("Alpha".to_owned(), "1.1.1.1".to_owned())); + assert_eq!(rows[2], ("Beta".to_owned(), "2.2.2.2".to_owned())); + let rows = menu.rows("1.1.1.1"); + assert_eq!(rows[0], ("Alpha".to_owned(), "1.1.1.1".to_owned())); + assert_eq!(rows.len(), 3); + assert!(home().rows("").is_empty()); +} + +#[test] +fn click_routes_every_outcome() { + let mut menu = home(); + let (local, options, quit, play) = { + let nodes = menu.layout(&frame(1040.0, 700.0), ""); + ( + center(button(&nodes, "ОДИНОЧНАЯ ИГРА").rect()), + center(button(&nodes, "НАСТРОЙКИ").rect()), + center(button(&nodes, "ВЫЙТИ").rect()), + center(button(&nodes, "ИГРАТЬ").rect()), + ) + }; + let at = |cursor| Frame::new((1040.0, 700.0), 1.0, cursor); + assert_eq!(menu.click(&at(local), ""), (Outcome::LocalWorlds, true)); + assert_eq!(menu.click(&at(options), ""), (Outcome::Options, true)); + assert_eq!(menu.click(&at(quit), ""), (Outcome::Quit, true)); + assert_eq!(menu.click(&at((520.0, 350.0)), ""), (Outcome::None, false)); + assert_eq!(menu.click(&at(play), ""), (Outcome::None, true)); + assert_eq!(menu.tab, Tab::Play, "a tab press switches in place"); +} + +#[test] +fn click_on_a_quick_connect_row_reports_its_address() { + let mut menu = home().with_saved(vec![("Alpha".into(), "1.1.1.1".into())]); + let at = { + let nodes = menu.layout(&frame(1040.0, 700.0), ""); + nodes + .iter() + .find_map(|node| match node { + Node::Button { + rect, + id: Some(Hit::Connect(ip)), + .. + } if ip == "1.1.1.1" => Some(center(*rect)), + _ => None, + }) + .expect("the saved server is a row") + }; + let (outcome, sound) = menu.click(&Frame::new((1040.0, 700.0), 1.0, at), ""); + assert_eq!(outcome, Outcome::Connect("1.1.1.1".to_owned())); + assert!(sound); +} + +#[test] +fn the_carousel_turns() { + let mut menu = home(); + let at = |cursor| Frame::new((1040.0, 700.0), 1.0, cursor); + let next = { + let nodes = menu.layout(&frame(1040.0, 700.0), ""); + nodes + .iter() + .find_map(|node| match node { + Node::Button { + rect, + id: Some(Hit::NewsNext), + .. + } => Some(center(*rect)), + _ => None, + }) + .expect("next") + }; + assert_eq!(menu.click(&at(next), ""), (Outcome::None, true)); + assert_eq!(menu.news.get(), 1); + let caption = menu + .layout(&frame(1040.0, 700.0), "") + .into_iter() + .find_map(|node| match node { + Node::Text { text, .. } if text == NEWS[1].0 => Some(()), + _ => None, + }); + assert!(caption.is_some(), "the second slide is drawn"); +} + +#[test] +fn hovering_a_locked_tab_shows_its_tip() { + crate::ui_theme::reset_atlas(); + let atlas = atlas(); + let menu = home(); + let chip_at = { + let nodes = menu.layout(&frame(1040.0, 700.0), ""); + center(button(&nodes, "МАГАЗИН").rect()) + }; + let mut plain = canvas(1040.0, 700.0); + menu.draw(&atlas, &mut plain, (0.0, 0.0), "", &meta()); + let mut hovered = canvas(1040.0, 700.0); + menu.draw(&atlas, &mut hovered, chip_at, "", &meta()); + assert!( + hovered.vertices.len() > plain.vertices.len(), + "the tooltip adds a plate and a line: {} vs {}", + hovered.vertices.len(), + plain.vertices.len() + ); +} + +#[test] +fn draw_paints_the_shell_and_the_icons() { + crate::ui_theme::reset_atlas(); + let atlas = atlas(); + let menu = home(); + let mut batch = canvas(1244.0, 700.0); + menu.draw(&atlas, &mut batch, (0.0, 0.0), "", &meta()); + assert!(!batch.vertices.is_empty(), "scrims, plates and text"); + assert!(!batch.patches.is_empty(), "icons and glyphs upload"); +} + +#[test] +fn the_bundled_faces_cover_the_menu_strings() { + use ab_glyph::{Font as _, FontRef, GlyphId}; + let condensed = FontRef::try_from_slice(horizon_theme::fonts::ROBOTO_CONDENSED_REGULAR).unwrap(); + for ch in "ГЛАВНАЯ ИГРАТЬ НАСТРОЙКИ ВЫЙТИ МАГАЗИН ИНВЕНТАРЬ БЫСТРОЕ ПОДКЛЮЧЕНИЕ \ + ОДИНОЧНАЯ СЕРВЕРЫ НАЙДЕНЫ ДЕВБЛОГ СПИСОК ИЗМЕНЕНИЙ ПОДРОБНЕЕ НОВОЕ МЕНЮ \ + Нажмите чтобы начать общаться … · —" + .chars() + { + assert_ne!(condensed.glyph_id(ch), GlyphId(0), "Roboto Condensed lacks {ch:?}"); + } + let display = FontRef::try_from_slice(horizon_theme::fonts::OSWALD_SEMIBOLD).unwrap(); + for ch in "НОВОЕ МЕНЮ МАГАЗИН СЕРВЕРЫ".chars() { + assert_ne!(display.glyph_id(ch), GlyphId(0), "Oswald lacks {ch:?}"); + } +} + +#[test] +fn the_atlas_carries_the_menu_icons() { + let atlas = atlas(); + for name in ["house", "settings", "newspaper", "arrow-right", "user"] { + let key = icons::sprite(name); + let rect = *atlas.sprites.get(&key).unwrap_or_else(|| panic!("{key}")); + let (x, y) = ( + (rect.u0 * atlas.width as f32) as usize, + (rect.v0 * atlas.height as f32) as usize, + ); + let mut lit = 0; + for dy in 0..rect.h as usize { + for dx in 0..rect.w as usize { + let i = ((y + dy) * atlas.width as usize + x + dx) * 4; + if atlas.pixels.get(i + 3).is_some_and(|a| *a > 0) { + lit += 1; + } + } + } + assert!(lit > 50, "{name}: only {lit} lit pixels"); + } +} + +#[test] +fn the_play_tab_lists_servers_and_opens_their_card() { + let mut menu = home().with_saved(vec![("Alpha".into(), "alpha.example:25565".into())]); + menu.tab = Tab::Play; + let nodes = menu.layout(&frame(1040.0, 700.0), ""); + assert!( + nodes + .iter() + .any(|node| matches!(node, Node::Text { text, .. } if text == "Alpha")), + "the saved server is a row" + ); + // A press on the row opens the card. + let row = nodes + .iter() + .find_map(|node| match node { + Node::Button { + rect, + id: Some(Hit::OpenServer(0)), + .. + } => Some(center(*rect)), + _ => None, + }) + .expect("the row's hit area"); + assert!(menu.click(&Frame::new((1040.0, 700.0), 1.0, row), "").1); + let nodes = menu.layout(&frame(1040.0, 700.0), ""); + let (connect, ip) = nodes + .iter() + .find_map(|node| match node { + Node::Button { + rect, + id: Some(Hit::Connect(ip)), + .. + } => Some((center(*rect), ip.clone())), + _ => None, + }) + .expect("the card's connect button"); + assert_eq!(ip, "alpha.example:25565"); + assert_eq!( + menu.click(&Frame::new((1040.0, 700.0), 1.0, connect), ""), + (Outcome::Connect("alpha.example:25565".to_owned()), true) + ); +} + +#[test] +fn the_play_tab_has_an_empty_state_and_filters_toggle() { + let mut menu = home(); + menu.tab = Tab::Play; + let nodes = menu.layout(&frame(1040.0, 700.0), ""); + let add = nodes + .iter() + .find_map(|node| match node { + Node::Button { + rect, + id: Some(Hit::Servers), + .. + } => Some(center(*rect)), + _ => None, + }) + .expect("the add button"); + assert_eq!( + menu.click(&Frame::new((1040.0, 700.0), 1.0, add), ""), + (Outcome::Servers, true) + ); + let toggle = nodes + .iter() + .find_map(|node| match node { + Node::Button { + rect, + id: Some(Hit::Toggle("mode:hardcore")), + .. + } => Some(center(*rect)), + _ => None, + }) + .expect("the hardcore filter"); + assert!(!menu.filter("mode:hardcore")); + menu.click(&Frame::new((1040.0, 700.0), 1.0, toggle), ""); + assert!(menu.filter("mode:hardcore")); +} diff --git a/crates/horizon-client/src/rustui/theme.rs b/crates/horizon-client/src/rustui/theme.rs new file mode 100644 index 0000000..da23f95 --- /dev/null +++ b/crates/horizon-client/src/rustui/theme.rs @@ -0,0 +1,125 @@ +//! The Rust menu's palette: warm charcoal scrims over the blurred scene, one +//! rust-red accent, one amber primary, olive for positive marks. Square +//! corners everywhere; the palette is WCAG-tested against the brightest +//! backdrop the design system measures. +use crate::hud::{HudAtlas, Rect}; +use crate::ui_theme::color::hex; + +/// The top bar's scrim: a dark grey that fades to nothing over the scene. +pub const SCRIM_TOP: u32 = hex(0x141312, 195); +/// The bottom scrim under the chat strip. +pub const SCRIM_BOTTOM: u32 = hex(0x0a0a0a, 190); +/// Cards and floating panels (quick connect, devblog, news). +pub const PANEL: u32 = hex(0x171614, 215); +/// A hovered or focused panel. +pub const PANEL_HI: u32 = hex(0x201e1b, 225); +/// The settings rail and the footer. +pub const RAIL: u32 = hex(0x121110, 200); +/// Hovered plates. +pub const HOVER: u32 = hex(0xffffff, 16); +/// The active tab's plate: a grey lift, never a black box. +pub const ACTIVE: u32 = hex(0xffffff, 24); +/// Locked chips and badges. +pub const CHIP: u32 = hex(0xffffff, 10); +/// One-pixel light rules and panel borders. +pub const BORDER: u32 = hex(0xffffff, 26); +/// A darker inset rule (row separators). +pub const BORDER_SOFT: u32 = hex(0x000000, 110); +/// Text, secondary text and quiet labels. +pub const TEXT: u32 = hex(0xf0ede8, 255); +pub const SOFT: u32 = hex(0xc9c4bc, 255); +pub const MUTED: u32 = hex(0xa8a29a, 255); +/// Disabled controls. +pub const LOCKED: u32 = hex(0x6d6862, 255); +/// Rust's orange-red: sliders, the news accent, the active edge. +pub const ACCENT: u32 = hex(0xc8502d, 255); +pub const ACCENT_HOVER: u32 = hex(0xe0603c, 255); +/// The one primary action. +pub const PRIMARY: u32 = hex(0xe0a92e, 255); +pub const PRIMARY_HOVER: u32 = hex(0xf0bb42, 255); +pub const ON_PRIMARY: u32 = hex(0x1a1610, 255); +/// Fresh, positive marks (the devblog badge). +pub const POSITIVE: u32 = hex(0x7f9a4e, 255); +pub const ON_POSITIVE: u32 = hex(0x14170d, 255); +/// Tooltips. +pub const TIP_BG: u32 = hex(0x101010, 245); +/// The dimming under a modal card. +pub const OVERLAY: u32 = hex(0x000000, 170); +/// The settings pages dim the scene more than the menu does. +pub const DIM: u32 = hex(0x050505, 130); +/// Slider tracks. +pub const TRACK: u32 = hex(0xffffff, 34); +/// Scrollbar thumbs. +pub const THUMB: u32 = hex(0xffffff, 60); + +/// A Figma-exported sprite of this name, if the export is in the resource root +/// (`gui/sprites/rustui/`); `None` means the procedural painting takes +/// over. +pub fn sprite(atlas: &HudAtlas, name: &str) -> Option { + atlas + .sprites + .get(&format!("gui/sprites/rustui/{name}")) + .copied() +} + +/// `color` with its alpha scaled by `a` (0..1). +pub const fn faded(color: u32, a: f32) -> u32 { + let alpha = (color >> 24) as f32 * a; + ((alpha as u32) << 24) | (color & 0x00ff_ffff) +} + +/// `a` over `b` at `t`, each channel (including alpha) interpolated. +pub fn mix(a: u32, b: u32, t: f32) -> u32 { + let t = t.clamp(0.0, 1.0); + let ch = |shift: u32| { + let x = ((a >> shift) & 255) as f32; + let y = ((b >> shift) & 255) as f32; + ((x * (1.0 - t) + y * t).round() as u32) << shift + }; + ch(24) | ch(16) | ch(8) | ch(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use horizon_theme::{BRIGHT_BACKDROP, contrast}; + + fn rgb(c: u32) -> u32 { + c & 0x00ff_ffff + } + /// The worst case: a panel over the brightest backdrop the design system + /// measures (the blur is a photographic scene, not a flat colour). + fn panel(over: u32) -> u32 { + mix(rgb(over), rgb(PANEL), (PANEL >> 24) as f32 / 255.0) + } + /// The top scrim over the same backdrop. + fn scrim() -> u32 { + mix(rgb(BRIGHT_BACKDROP), rgb(SCRIM_TOP), (SCRIM_TOP >> 24) as f32 / 255.0) + } + + #[test] + fn menu_text_meets_wcag_aa_over_scrims_and_panels() { + let over_scrim = scrim(); + for text in [TEXT, SOFT] { + let ratio = contrast(rgb(text), over_scrim); + assert!(ratio >= 4.5, "#{text:06x} over scrim: {ratio:.2}"); + } + let card = panel(BRIGHT_BACKDROP); + for text in [TEXT, SOFT, MUTED] { + let ratio = contrast(rgb(text), card); + assert!(ratio >= 4.5, "#{text:06x} over the worst panel: {ratio:.2}"); + } + assert!(contrast(rgb(ON_PRIMARY), rgb(PRIMARY)) >= 4.5, "the primary label"); + assert!(contrast(rgb(ON_POSITIVE), rgb(POSITIVE)) >= 4.5, "the badge label"); + assert!(contrast(rgb(ON_PRIMARY), rgb(PRIMARY_HOVER)) >= 4.5, "the hovered primary"); + } + + #[test] + fn sprite_resolution_needs_the_export() { + let atlas = crate::hud::HudAtlas::build(std::path::Path::new( + "/nonexistent/horizon-test-assets", + )) + .unwrap(); + assert!(sprite(&atlas, "panel").is_none()); + } +} diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index f07fc08..7d3c508 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -1218,7 +1218,7 @@ pub fn encode_with( Some(tag) => Some((start.name.as_str().into(), tag)), None => { log::warn!( - "Horizon: structure start {} not saved: one of its pieces cannot be", + "RustNotMC: structure start {} not saved: one of its pieces cannot be", start.name ); None @@ -1798,7 +1798,7 @@ fn resolve_poi( return Some(record.clone()); }; let old = read_region_record(root, cache, "poi", x, z).unwrap_or_else(|e| { - log::warn!("Horizon: could not read the poi record of chunk {x} {z}: {e}"); + log::warn!("RustNotMC: could not read the poi record of chunk {x} {z}: {e}"); None }); let tag = match (encode_poi(chunk, tables), old) { @@ -2108,7 +2108,7 @@ impl SaveDir { let result = write_region_records(&root, cache, folder, &records); if let Err(e) = &result { let message = format!("failed to write {folder} files: {e}"); - log::warn!("Horizon: {message}"); + log::warn!("RustNotMC: {message}"); *outstanding.error.lock().unwrap() = Some(message); } // Keep failed snapshots readable; never report them as durable. @@ -3746,7 +3746,7 @@ impl ChunkGenerator for Persistent { .save .save_protos(&saved, self.registry.registry, &self.biomes) { - log::warn!("Horizon: failed to save proto chunks: {e}"); + log::warn!("RustNotMC: failed to save proto chunks: {e}"); } } fn save_finished(&self, mut chunk: Chunk) { @@ -3760,7 +3760,7 @@ impl ChunkGenerator for Persistent { .save_later(&[Arc::new(chunk)], self.registry.registry, &self.biomes) .and_then(|_| self.save.save_entities_later(vec![(key, entities)])); if let Err(e) = saved { - log::warn!("Horizon: failed to save chunk {} {}: {e}", key.0, key.1); + log::warn!("RustNotMC: failed to save chunk {} {}: {e}", key.0, key.1); } } fn decorates(&self) -> bool { diff --git a/crates/horizon-client/src/server_packs.rs b/crates/horizon-client/src/server_packs.rs index 8e11217..c27f1f8 100644 --- a/crates/horizon-client/src/server_packs.rs +++ b/crates/horizon-client/src/server_packs.rs @@ -170,7 +170,7 @@ impl Default for ServerPacks { } impl ServerPacks { fn report(&mut self, id: u128, status: i32) { - log::debug!("Horizon: server pack {id:032x} changed status to {status}"); + log::debug!("RustNotMC: server pack {id:032x} changed status to {status}"); if self.reporting { self.replies.push((id, status)); } diff --git a/crates/horizon-client/src/server_packs/download.rs b/crates/horizon-client/src/server_packs/download.rs index 4b07596..98cacac 100644 --- a/crates/horizon-client/src/server_packs/download.rs +++ b/crates/horizon-client/src/server_packs/download.rs @@ -129,12 +129,12 @@ fn run( Err(_) => ("error", "no_access".into()), }, Err(e) => { - log::error!("Horizon: failed to download {}: {e}", request.url); + log::error!("RustNotMC: failed to download {}: {e}", request.url); ("error", "download_failed".into()) } }); if let Err(e) = append_log(&dir.join("log.json"), &entry) { - log::error!("Horizon: failed to log download of {}: {e}", request.url); + log::error!("RustNotMC: failed to log download of {}: {e}", request.url); } match result { Ok(path) => batch.downloaded.push((request.id, path)), @@ -199,13 +199,13 @@ pub fn download_file( let target = request.sha1.as_ref().map(|sha1| dir.join(sha1)); if let (Some(target), Some(sha1)) = (&target, &request.sha1) { if matches(target, sha1) { - log::info!("Horizon: returning cached file since actual hash matches requested"); + log::info!("RustNotMC: returning cached file since actual hash matches requested"); touch(target); return Ok(target.clone()); } if target.exists() { log::warn!( - "Horizon: existing file {} not found or had mismatched hash", + "RustNotMC: existing file {} not found or had mismatched hash", target.display() ); } @@ -349,7 +349,7 @@ pub fn vacuum(dir: &Path, max_files: usize) { } Ok(()) => {} Err(e) => log::warn!( - "Horizon: failed to delete cache file {}: {e}", + "RustNotMC: failed to delete cache file {}: {e}", path.display() ), } diff --git a/crates/horizon-client/src/sprites.rs b/crates/horizon-client/src/sprites.rs index 0d58719..3ba848c 100644 --- a/crates/horizon-client/src/sprites.rs +++ b/crates/horizon-client/src/sprites.rs @@ -176,7 +176,7 @@ impl Sprites { // `MissingTextureAtlasSprite.getLocation()`: vanilla models name the // missing texture on purpose (block/air.json's particle), #853. if name != "minecraft:missingno" { - log::warn!("Horizon: using the missing texture for {name}: {e}"); + log::warn!("RustNotMC: using the missing texture for {name}: {e}"); } // MissingTextureAtlasSprite.generateMissingImage: 16x16, // magenta and black quarters. diff --git a/crates/horizon-client/src/streaming.rs b/crates/horizon-client/src/streaming.rs index 6588ac3..89cb87c 100644 --- a/crates/horizon-client/src/streaming.rs +++ b/crates/horizon-client/src/streaming.rs @@ -1408,7 +1408,7 @@ impl Streaming { Ok(Done::Saved(protos)) => self.receive_saved(protos), Ok(other) => self.deferred.push(other), Err(_) => { - log::warn!("Horizon: chunk generation did not finish before saving"); + log::warn!("RustNotMC: chunk generation did not finish before saving"); break; } } diff --git a/crates/horizon-client/src/structure_blocks.rs b/crates/horizon-client/src/structure_blocks.rs index d05649f..2480229 100644 --- a/crates/horizon-client/src/structure_blocks.rs +++ b/crates/horizon-client/src/structure_blocks.rs @@ -126,7 +126,7 @@ pub fn loot_data(root: &Path) -> Option> { loot.get_or_insert_with(|| match LootData::load(root) { Ok(data) => Some(Arc::new(data)), Err(e) => { - log::warn!("Horizon: loot tables unavailable, structure containers stay empty: {e}"); + log::warn!("RustNotMC: loot tables unavailable, structure containers stay empty: {e}"); None } }) diff --git a/crates/horizon-client/src/telemetry.rs b/crates/horizon-client/src/telemetry.rs index afd2dbc..8437b0d 100644 --- a/crates/horizon-client/src/telemetry.rs +++ b/crates/horizon-client/src/telemetry.rs @@ -17,7 +17,7 @@ const MIN_SECONDS: f32 = 60.0; /// The details of a crash report that say what the game was doing, and only those: not the /// world, the server or the resource packs. -const CRASH_DETAILS: [&str; 3] = ["Horizon Version", "Graphics Adapter", "Render Settings"]; +const CRASH_DETAILS: [&str; 3] = ["RustNotMC Version", "Graphics Adapter", "Render Settings"]; static STARTED: OnceLock = OnceLock::new(); /// When the menu bundle became ready, so the time to the title screen measures @@ -204,7 +204,7 @@ pub fn crash_item(report: &crate::crash::Report) -> Item { /// Keeps the crash for the launcher to send, if the player allows crash reports. pub fn keep_crash(home: &Path, report: &crate::crash::Report) { if let Err(e) = outbox::push(home, &crash_item(report)) { - log::warn!("Horizon: could not keep the crash report to send: {e}"); + log::warn!("RustNotMC: could not keep the crash report to send: {e}"); } } @@ -218,7 +218,7 @@ pub fn keep_benchmark(home: &Path, report: &horizon_bench::Report) { home, &Item::new(Source::Game, version(), Payload::Benchmark(data)), ) { - log::warn!("Horizon: could not keep the benchmark report to send: {e}"); + log::warn!("RustNotMC: could not keep the benchmark report to send: {e}"); } } @@ -301,7 +301,7 @@ impl Session { { let item = Item::new(Source::Game, version(), Payload::Perf(perf)); if let Err(e) = outbox::push(home, &item) { - log::warn!("Horizon: could not keep the session's performance summary: {e}"); + log::warn!("RustNotMC: could not keep the session's performance summary: {e}"); } } *self = Self { @@ -374,7 +374,7 @@ mod tests { side: "client", details: vec![ ( - "Horizon Version".into(), + "RustNotMC Version".into(), "v2026.10.15 (client 0.1.0)".into(), ), ( @@ -402,7 +402,7 @@ mod tests { assert_eq!( crash.details, [( - "Horizon Version".to_owned(), + "RustNotMC Version".to_owned(), "v2026.10.15 (client 0.1.0)".to_owned() )] ); diff --git a/crates/horizon-client/src/title.rs b/crates/horizon-client/src/title.rs index d089173..166218a 100644 --- a/crates/horizon-client/src/title.rs +++ b/crates/horizon-client/src/title.rs @@ -144,6 +144,13 @@ pub struct Title { /// The prepared resource root: the flat preset list and block registry. resources: PathBuf, pub started: std::time::Instant, + /// The Rust-style menu (`rustui`): its selected tab and quick connect. + pub menu: crate::rustui::RustMenu, + /// `horizon.txt`'s `rustMenu`; off draws vanilla's Main screen instead. + pub rust_menu: bool, + /// The GUI scale of the last drawn frame: a press converts GUI pixels to + /// the menu's dp exactly as that frame did. + frame_scale: Cell, search: String, search_focused: bool, /// The focused text field's cursor, selection and scroll. @@ -244,10 +251,22 @@ impl Title { let today = chrono::Local::now(); let mode = Mode::Main; let now = std::time::Instant::now(); + // `horizon.txt` lives beside `options.txt`, in the saves' parent; the + // Rust menu is the default and vanilla's Main screen the rollback. + let dir = saves + .parent() + .filter(|dir| !dir.as_os_str().is_empty()) + .unwrap_or(&saves); + let rust_menu = crate::ui_theme::preference(dir, "rustMenu") + .is_none_or(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "false" | "0" | "off")); + let menu = crate::rustui::RustMenu::new(&saves); Self { mode, last_server: String::new(), saves, + menu, + rust_menu, + frame_scale: Cell::new(1.0), resources: resources.to_owned(), started: now, search: String::new(), @@ -667,6 +686,11 @@ impl Title { if self.rules.borrow().is_some() { return Vec::new(); } + // The Rust menu's plates are in its own dp layout: `click` hit-tests + // that layout, not these vanilla rectangles. + if self.rust_menu && matches!(self.mode, Mode::Main) { + return Vec::new(); + } let (w, h) = (w.ceil(), h.ceil()); let b = |x: f32, y: f32, w: f32, label: &str, enabled: bool, id: u8| Button { x, @@ -919,7 +943,16 @@ impl Title { if let Some(rules) = self.rules.borrow_mut().as_mut() { return rules.draw(batch, atlas, cursor); } + // A press in `click` converts GUI pixels with this frame's scale. + self.frame_scale.set(batch.scale); match &self.mode { + Mode::Main if self.rust_menu => { + let meta = crate::rustui::Meta { + elapsed: self.started.elapsed().as_secs_f32(), + }; + self.menu + .draw(atlas, batch, cursor, &self.last_server, &meta); + } Mode::Main => { // LogoRenderer: the logo's top 44 of 64 rows, then the edition // banner's top 14 of 16 rows overlapping its bottom by 7. @@ -964,7 +997,7 @@ impl Title { ]; } } - let version = format!("Horizon {} (Rust)", horizon_core::VANILLA_VERSION); + let version = format!("RustNotMC {} (Rust)", horizon_core::VANILLA_VERSION); batch.text(atlas, &version, 2.0, h - 10.0, u32::MAX, true); // A nightly names its build in the top-left corner, the one spot no button reaches at any GUI // scale, so screenshots and reports say which build they come from. @@ -1323,6 +1356,12 @@ impl Title { return (Action::None, false); } let (w, h) = (size.0.ceil(), size.1.ceil()); + // The Rust menu hit-tests the same dp layout `draw` painted. + if self.rust_menu && matches!(self.mode, Mode::Main) { + let frame = crate::rustui::Frame::new(size, self.frame_scale.get(), (x, y)); + let (outcome, sound) = self.menu.click(&frame, &self.last_server); + return (self.rust_action(outcome), sound); + } // MenuTabBar: a new tab clears the focus and clicks. if let Mode::Create { focus, options, .. } = &mut self.mode && let Some(tab) = create_world::tab_at(w, (x, y)) @@ -1348,6 +1387,29 @@ impl Title { let sound = hit.is_some() || matches!(action, Action::Play { .. }); (action, sound) } + /// Routes the Rust menu's press into the screen loop's [`Action`]s; a tab + /// press and the singleplayer list both stay inside the title. + fn rust_action(&mut self, outcome: crate::rustui::Outcome) -> Action { + use crate::rustui::Outcome; + match outcome { + Outcome::None => Action::None, + Outcome::Quit => Action::Quit, + Outcome::Options => Action::Options, + Outcome::LocalWorlds => { + self.enter_singleplayer(); + Action::None + } + Outcome::Connect(address) => Action::Connect { address }, + Outcome::Servers => { + self.mode = Mode::Multiplayer(crate::multiplayer::Screen::list(&self.saves)); + Action::None + } + Outcome::Resume + | Outcome::Stats + | Outcome::Advancements + | Outcome::WorldOptions => Action::None, + } + } fn click_action(&mut self, x: f32, y: f32, (w, h): (f32, f32), hit: Option) -> Action { match (&mut self.mode, hit) { (Mode::Main, Some(1)) => { @@ -1766,6 +1828,10 @@ impl Title { } } pub fn escape(&mut self) { + // The Rust menu's server card takes the first Escape. + if self.rust_menu && matches!(self.mode, Mode::Main) && self.menu.close_modal() { + return; + } if let Some(rules) = self.rules.get_mut() { if let Some(exit) = rules.escape() { self.close_rules(exit); @@ -1796,7 +1862,11 @@ impl Title { } } Mode::Create { .. } => self.cancel_create(), - _ => self.mode = Mode::Main, + _ => { + self.mode = Mode::Main; + // The browser edits the saved list; Home shows it on return. + self.menu.reload(&self.saves); + } } } } @@ -1864,6 +1934,8 @@ mod classic_tests { PathBuf::new(), Path::new("/nonexistent/horizon-test-resources"), ); + // The vanilla Main screen this test measures, not the Rust menu. + title.rust_menu = false; for (w, h) in [(960.0, 523.0), (480.0, 320.0), (427.0, 240.0)] { let buttons = title.buttons(w, h); let cx = (w / 2.0).floor(); @@ -1974,6 +2046,7 @@ mod classic_tests { ); } let mut title = Title::new(PathBuf::new(), root); + title.rust_menu = false; title.splash = Some("Visible splash!".into()); let mut batch = HudBatch::new([1920, 1080]); title.draw(&atlas, &mut batch, (0.0, 0.0), false); diff --git a/crates/horizon-client/src/title/tests.rs b/crates/horizon-client/src/title/tests.rs index 04fefe3..09d49c1 100644 --- a/crates/horizon-client/src/title/tests.rs +++ b/crates/horizon-client/src/title/tests.rs @@ -18,10 +18,11 @@ impl Fixture { NEXT.fetch_add(1, Ordering::Relaxed) )); fs::create_dir_all(root.join("saves")).unwrap(); - Self { - title: Title::new(root.join("saves"), &root), - root, - } + let mut title = Title::new(root.join("saves"), &root); + // This suite measures vanilla's screens; the Rust menu's own layout + // and presses are covered in `rustui` and by its test at the end here. + title.rust_menu = false; + Self { title, root } } fn world(&self, folder: &str, name: &str) { crate::save::write_dat( @@ -874,3 +875,44 @@ fn server_fields_edit_at_their_cursor() { }; assert_eq!(address, "one "); } + +/// `rustMenu` replaces vanilla's Main screen: no vanilla rectangles, and the +/// menu's own dp plates report the same actions. +#[test] +fn rust_menu_replaces_the_main_screen() { + let mut f = Fixture::new(); + f.title.rust_menu = true; + assert!(f.title.buttons(1040.0, 700.0).is_empty()); + let frame = crate::rustui::Frame::new((1040.0, 700.0), 1.0, (0.0, 0.0)); + let nodes = f.title.menu.layout(&frame, ""); + let at = |hit: crate::rustui::Hit| { + nodes + .iter() + .find_map(|node| match node { + crate::rustui::Node::Button { rect, id: Some(id), .. } if *id == hit => { + Some((rect[0] + rect[2] / 2.0, rect[1] + rect[3] / 2.0)) + } + _ => None, + }) + .unwrap_or_else(|| panic!("no plate for {hit:?}")) + }; + let quit = at(crate::rustui::Hit::Quit); + assert_eq!( + f.title.click(quit.0, quit.1, (1040.0, 700.0)), + (Action::Quit, true) + ); + let options = at(crate::rustui::Hit::Options); + assert_eq!( + f.title.click(options.0, options.1, (1040.0, 700.0)), + (Action::Options, true) + ); + let local = at(crate::rustui::Hit::LocalWorlds); + assert_eq!( + f.title.click(local.0, local.1, (1040.0, 700.0)), + (Action::None, true) + ); + assert!(matches!( + f.title.mode, + Mode::Select { .. } | Mode::Create { .. } + )); +} diff --git a/crates/horizon-client/src/ui_theme.rs b/crates/horizon-client/src/ui_theme.rs index f4581f4..176d7ab 100644 --- a/crates/horizon-client/src/ui_theme.rs +++ b/crates/horizon-client/src/ui_theme.rs @@ -76,12 +76,18 @@ pub(crate) const RADIUS: f32 = horizon_theme::RADIUS; pub(crate) const RADIUS_PLATE: f32 = horizon_theme::RADIUS_PLATE; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub(crate) enum Face { +pub enum Face { Regular, Medium, + /// Permanent Marker: the Rust menu's brand words (Latin only). + Brand, + /// Roboto Regular: the Rust menu's localized body text (Cyrillic included). + Menu, + /// Roboto Medium: emphasized menu labels (tabs, headers). + MenuStrong, } struct Fonts { - faces: [FontRef<'static>; 2], + faces: [FontRef<'static>; 5], } impl Fonts { fn load() -> Self { @@ -90,6 +96,9 @@ impl Fonts { faces: [ f(horizon_theme::fonts::GEIST_REGULAR), f(horizon_theme::fonts::GEIST_MEDIUM), + f(horizon_theme::fonts::OSWALD_SEMIBOLD), + f(horizon_theme::fonts::ROBOTO_CONDENSED_REGULAR), + f(horizon_theme::fonts::ROBOTO_CONDENSED_MEDIUM), ], } } @@ -294,12 +303,20 @@ pub struct Ui<'a> { pub density: f32, scale: f32, } +/// The dp density of a window of this many physical pixels: the 1040x700 design +/// scaled to fit, clamped so a tiny window still lays out at a usable size. +/// (`Ui::new` derives the same value; menus that lay out in dp before building +/// a [`Ui`] — `rustui` — use it too, so draw and click agree.) +pub fn density(pw: f32, ph: f32) -> f32 { + (pw / 1040.0).min(ph / 700.0).clamp(1.0, 4.0) +} + impl<'a> Ui<'a> { /// `cursor` is in GUI pixels, like every screen's input. pub fn new(atlas: &'a HudAtlas, batch: &'a mut HudBatch, cursor: (f32, f32)) -> Self { let scale = batch.scale; let (pw, ph) = (batch.width * scale, batch.height * scale); - let density = (pw / 1040.0).min(ph / 700.0).clamp(1.0, 4.0); + let density = density(pw, ph); Self { atlas, w: pw / density, @@ -310,6 +327,12 @@ impl<'a> Ui<'a> { batch, } } + /// dp to GUI pixels (the inverse of `Ui::new`'s cursor conversion): for the + /// few helpers that draw in batch space directly (`ui::nine_slice`, + /// `widgets::record`). + pub fn to_gui(&self, v: f32) -> f32 { + v * self.density / self.scale + } /// Move rasterized glyphs into the batch's atlas uploads (real frames only). pub fn finish(self) { let pending = with_cache(self.atlas, |c| std::mem::take(&mut c.pending)); @@ -392,6 +415,30 @@ impl<'a> Ui<'a> { self.raw_fill(x1 - r, y0 + r, x1, y1 - r, color); self.corners(x0, y0, x1, y1, r, false, color); } + /// A vertical gradient in dp, banded at physical-pixel rows (a scrim, a + /// plate's inner light). `top` and `bottom` are ABGR with alpha. + pub(crate) fn gradient_v(&mut self, x: f32, y: f32, w: f32, h: f32, top: u32, bottom: u32) { + let (x0, x1) = (self.px_x(x), self.px_x(x + w)); + let (y0, y1) = (self.px_y(y), self.px_y(y + h)); + if x1 <= x0 || y1 <= y0 { + return; + } + let rows = ((y1 - y0) as u32).clamp(1, 96); + let lerp = |a: u32, b: u32, t: f32| { + let ch = |shift: u32| { + let u = ((a >> shift) & 255) as f32; + let v = ((b >> shift) & 255) as f32; + ((u * (1.0 - t) + v * t).round() as u32) << shift + }; + ch(24) | ch(16) | ch(8) | ch(0) + }; + for i in 0..rows { + let t = (i as f32 + 0.5) / rows as f32; + let ya = y0 + (y1 - y0) * i as f32 / rows as f32; + let yb = y0 + (y1 - y0) * (i + 1) as f32 / rows as f32; + self.raw_fill(x0, ya, x1, yb, lerp(top, bottom, t)); + } + } /// A 1px rounded border. pub(crate) fn border(&mut self, x: f32, y: f32, w: f32, h: f32, radius: f32, color: u32) { if color >> 24 == 0 { diff --git a/crates/horizon-client/src/vanillagen.rs b/crates/horizon-client/src/vanillagen.rs index f51eb82..5516e0d 100644 --- a/crates/horizon-client/src/vanillagen.rs +++ b/crates/horizon-client/src/vanillagen.rs @@ -153,12 +153,12 @@ impl VanillaTerrain { let unsupported = generator.unsupported_features(); if !unsupported.is_empty() { log::warn!( - "Horizon: {} placed features cannot run and will be skipped", + "RustNotMC: {} placed features cannot run and will be skipped", unsupported.len() ); if !dir.join("blocks/block-properties.nbt").exists() { log::warn!( - "Horizon: the worldgen bundle lacks the block behavior exports (worldgen/blocks/*.nbt); \ + "RustNotMC: the worldgen bundle lacks the block behavior exports (worldgen/blocks/*.nbt); \ prepare again (the launcher, or horizon-prepare --prepare) to add them" ); } @@ -417,7 +417,7 @@ pub fn shared_data( } let started = std::time::Instant::now(); let data = GeneratorData::load(dir, registry)?; - log::info!("Horizon: worldgen data loaded in {:?}", started.elapsed()); + log::info!("RustNotMC: worldgen data loaded in {:?}", started.elapsed()); cache.push((dir.to_owned(), data.clone())); Ok(data) } @@ -460,7 +460,7 @@ impl ChunkGenerator for VanillaTerrain { match worker.generate_proto(ChunkPos::new(x, z)) { Ok(chunk) => Box::new(VanillaProto::Ready(Box::new(chunk))), Err(e) => { - log::warn!("Horizon: generating chunk {x} {z} failed: {e}"); + log::warn!("RustNotMC: generating chunk {x} {z} failed: {e}"); Box::new(VanillaProto::Failed(x, z)) } } @@ -491,7 +491,7 @@ impl ChunkGenerator for VanillaTerrain { Err(_) => unreachable!(), }; if let Err(e) = worker.decorate(&mut array) { - log::warn!("Horizon: decorating chunk failed: {e}"); + log::warn!("RustNotMC: decorating chunk failed: {e}"); } for (slot, chunk) in region.iter_mut().zip(array) { *slot = Box::new(VanillaProto::Ready(Box::new(chunk))); diff --git a/crates/horizon-client/src/vanillagen/lod.rs b/crates/horizon-client/src/vanillagen/lod.rs index 7cce03a..a834c1a 100644 --- a/crates/horizon-client/src/vanillagen/lod.rs +++ b/crates/horizon-client/src/vanillagen/lod.rs @@ -72,7 +72,7 @@ impl Builder { ) { Ok(generator) => generator, Err(error) => { - log::warn!("Horizon: far decoration unavailable: {error}"); + log::warn!("RustNotMC: far decoration unavailable: {error}"); worker.stop.store(true, Ordering::Relaxed); return; } @@ -111,7 +111,7 @@ impl Builder { Ok(_) => {} Err(error) => { log::warn!( - "Horizon: far decoration failed at {} {}: {error}", + "RustNotMC: far decoration failed at {} {}: {error}", key.0, key.1 ); @@ -397,7 +397,7 @@ impl LodSampler for VanillaLodSampler { { Ok(samples) => samples, Err(error) => { - log::warn!("Horizon: direct far sampling failed: {error}"); + log::warn!("RustNotMC: direct far sampling failed: {error}"); return vec![Column::default(); side * side]; } }; diff --git a/crates/horizon-client/src/vanillagen/structure_mobs.rs b/crates/horizon-client/src/vanillagen/structure_mobs.rs index dec5aa9..cec5cec 100644 --- a/crates/horizon-client/src/vanillagen/structure_mobs.rs +++ b/crates/horizon-client/src/vanillagen/structure_mobs.rs @@ -176,7 +176,7 @@ impl StructureMobs for StructureMobFinalizer { let Some(data) = self.data.get_or_init(|| { let data = load(&self.root); if data.is_none() { - log::error!("Horizon: no mob data, structure mobs are finalized when loaded"); + log::error!("RustNotMC: no mob data, structure mobs are finalized when loaded"); } data }) else { @@ -215,7 +215,7 @@ impl StructureMobs for StructureMobFinalizer { } else if let Some(r) = any.downcast_mut::() { run(&mut level, r, difficulty, &spawn, &mut m) } else { - log::error!("Horizon: unknown region random, structure mobs are finalized when loaded"); + log::error!("RustNotMC: unknown region random, structure mobs are finalized when loaded"); return false; }; let mut mobs = vec![(m, Some(raw))]; diff --git a/crates/horizon-theme/fonts/Apache-PermanentMarker.txt b/crates/horizon-theme/fonts/Apache-PermanentMarker.txt new file mode 100644 index 0000000..21d961b --- /dev/null +++ b/crates/horizon-theme/fonts/Apache-PermanentMarker.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (c) 2010 by Font Diner, Inc. All rights reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/crates/horizon-theme/fonts/OFL-Oswald.txt b/crates/horizon-theme/fonts/OFL-Oswald.txt new file mode 100644 index 0000000..9d0219e --- /dev/null +++ b/crates/horizon-theme/fonts/OFL-Oswald.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Oswald Project Authors (https://github.com/googlefonts/OswaldFont) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/horizon-theme/fonts/OFL-RobotoCondensed.txt b/crates/horizon-theme/fonts/OFL-RobotoCondensed.txt new file mode 100644 index 0000000..2096249 --- /dev/null +++ b/crates/horizon-theme/fonts/OFL-RobotoCondensed.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/horizon-theme/fonts/Oswald-SemiBold.ttf b/crates/horizon-theme/fonts/Oswald-SemiBold.ttf new file mode 100644 index 0000000..dc930ec Binary files /dev/null and b/crates/horizon-theme/fonts/Oswald-SemiBold.ttf differ diff --git a/crates/horizon-theme/fonts/PermanentMarker-Regular.ttf b/crates/horizon-theme/fonts/PermanentMarker-Regular.ttf new file mode 100644 index 0000000..73ec4fe Binary files /dev/null and b/crates/horizon-theme/fonts/PermanentMarker-Regular.ttf differ diff --git a/crates/horizon-theme/fonts/Roboto-LICENSE.txt b/crates/horizon-theme/fonts/Roboto-LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/crates/horizon-theme/fonts/Roboto-LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/horizon-theme/fonts/Roboto-Medium.ttf b/crates/horizon-theme/fonts/Roboto-Medium.ttf new file mode 100644 index 0000000..6a95133 Binary files /dev/null and b/crates/horizon-theme/fonts/Roboto-Medium.ttf differ diff --git a/crates/horizon-theme/fonts/Roboto-Regular.ttf b/crates/horizon-theme/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..ddee473 Binary files /dev/null and b/crates/horizon-theme/fonts/Roboto-Regular.ttf differ diff --git a/crates/horizon-theme/fonts/RobotoCondensed-Medium.ttf b/crates/horizon-theme/fonts/RobotoCondensed-Medium.ttf new file mode 100644 index 0000000..e760222 Binary files /dev/null and b/crates/horizon-theme/fonts/RobotoCondensed-Medium.ttf differ diff --git a/crates/horizon-theme/fonts/RobotoCondensed-Regular.ttf b/crates/horizon-theme/fonts/RobotoCondensed-Regular.ttf new file mode 100644 index 0000000..fda2117 Binary files /dev/null and b/crates/horizon-theme/fonts/RobotoCondensed-Regular.ttf differ diff --git a/crates/horizon-theme/src/lib.rs b/crates/horizon-theme/src/lib.rs index b41536e..0d97939 100644 --- a/crates/horizon-theme/src/lib.rs +++ b/crates/horizon-theme/src/lib.rs @@ -24,6 +24,23 @@ pub mod fonts { pub const PIXEL_MEDIUM: &[u8] = include_bytes!("../fonts/PixelifySans-Medium.ttf"); pub const PIXEL_SEMIBOLD: &[u8] = include_bytes!("../fonts/PixelifySans-SemiBold.ttf"); pub const PIXEL_BOLD: &[u8] = include_bytes!("../fonts/PixelifySans-Bold.ttf"); + /// The Rust-menu brand face (Permanent Marker, Apache 2.0 — + /// `fonts/Apache-PermanentMarker.txt`); Latin only, so it draws brand words + /// ("Horizon", "RustNotMC", "DEVBLOG") and never localized menu strings. + pub const MARKER: &[u8] = include_bytes!("../fonts/PermanentMarker-Regular.ttf"); + /// The Rust-menu's body face (Roboto, Apache 2.0 — `fonts/Roboto-LICENSE.txt`), + /// Cyrillic included: every localized label of the menu draws with it. + pub const ROBOTO_REGULAR: &[u8] = include_bytes!("../fonts/Roboto-Regular.ttf"); + pub const ROBOTO_MEDIUM: &[u8] = include_bytes!("../fonts/Roboto-Medium.ttf"); + /// The Rust menu's display face (Oswald, OFL — `fonts/OFL-Oswald.txt`): + /// condensed caps for the big headings. + pub const OSWALD_SEMIBOLD: &[u8] = include_bytes!("../fonts/Oswald-SemiBold.ttf"); + /// The Rust menu's interface face (Roboto Condensed, OFL — + /// `fonts/OFL-RobotoCondensed.txt`); every localized label draws with it. + pub const ROBOTO_CONDENSED_REGULAR: &[u8] = + include_bytes!("../fonts/RobotoCondensed-Regular.ttf"); + pub const ROBOTO_CONDENSED_MEDIUM: &[u8] = + include_bytes!("../fonts/RobotoCondensed-Medium.ttf"); } /// The launcher's pixel icons: Pixelarticons (MIT, `icons/pixel/LICENSE-pixelarticons.txt`), 24 diff --git a/crates/horizon-world/src/registry.rs b/crates/horizon-world/src/registry.rs index 9ce9a68..579f523 100644 --- a/crates/horizon-world/src/registry.rs +++ b/crates/horizon-world/src/registry.rs @@ -82,7 +82,11 @@ impl BlockRegistry { Ok((Self::from_states(states, facts.defaults)?, flags)) } - fn from_states(states: Vec, defaults: BTreeMap) -> io::Result { + /// A registry from raw states and a default state per name, with the + /// state at `defaults[name]` standing for that block. Used by the + /// vanilla registry (whose states come from `facts.json`) and by tests + /// that need a handful of blocks and nothing of the assets. + pub fn from_states(states: Vec, defaults: BTreeMap) -> io::Result { if states.is_empty() || states.len() > i32::MAX as usize { return Err(invalid("invalid registry size")); } diff --git a/docs/design-system.md b/docs/design-system.md index 11ea46c..325a0f0 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -145,8 +145,22 @@ Geist (400, 500, 600) and Geist Mono (400), bundled under the SIL Open Font Lice stacks of panels, the stone texture behind the page and Horizon's pixel landscape (a dawn or a sunset) behind the hero. Light and dark follow the system, with a toggle. A giant wordmark in pixels sits in the footer. -- **Game**: vanilla's screens. The on-screen keyboard is a rounded plate of keys in the page - colour, the focused key in the accent. +- **Game**: vanilla's screens, except the main menu. `rustui` + (`horizon-client/src/rustui`) draws Horizon's Home in the Rust 2025 look: a veiled panorama + under charcoal plates with one Rust-orange accent, the top bar carrying the wordmark, the + tabs (ГЛАВНАЯ, ИГРАТЬ, two locked `???` chips that explain themselves on hover) and the + actions, quick connect and the devblog card on the right, the chat strip and the account plate + along the bottom. Its palette is `rustui::theme`, WCAG-tested over the veil; panels paint + procedurally until a Figma export sits in the resource root as `gui/sprites/rustui/`. + The menu lays out in dp (the launcher's 1040x700 frame scaled to the window) and hit-tests the + same layout it draws. `horizon.txt:rustMenu:false` restores vanilla's main menu. The settings + screens use the same language (`rustui::settings`): a category rail on the left, one row per + setting with its value, bar or button on the right, the hovered setting's explanation in a + panel, and a footer with the version, the credit (Horizon, the Rust client this is based on, + MIT) and the way back. Their rows are vanilla's own widgets, so every setting behaves as + before; the Graphics and pack screens keep vanilla's own layouts for now. + `horizon.txt:rustSettings:false` restores vanilla's options screens. The on-screen + keyboard is a rounded plate of keys in the page colour, the focused key in the accent. ## Blocks