diff --git a/.github/workflows/mod-registry.yml b/.github/workflows/mod-registry.yml new file mode 100644 index 000000000..0f252bb68 --- /dev/null +++ b/.github/workflows/mod-registry.yml @@ -0,0 +1,34 @@ +name: mod-registry +# The submission service is its own workspace (crates/horizon-mod-registry), so the checks job doesn't build +# it: this one does, when it or horizon-mods (the checks it shares with the launcher and the game) changes. +on: + push: + branches: [main] + paths: + - 'crates/horizon-mods/**' + - 'crates/horizon-mod-registry/**' + - '.github/workflows/mod-registry.yml' + pull_request: + paths: + - 'crates/horizon-mods/**' + - 'crates/horizon-mod-registry/**' + - '.github/workflows/mod-registry.yml' +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: crates/horizon-mod-registry + steps: + - uses: actions/checkout@v4 + - run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: crates/horizon-mod-registry + save-if: ${{ github.ref == 'refs/heads/main' }} + - run: cargo +stable fmt --check + - run: cargo +stable clippy --all-targets --locked -- -D warnings + - run: cargo +stable test --locked diff --git a/Cargo.lock b/Cargo.lock index 54be02330..2d09d2d0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -428,6 +428,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ + "serde", + "termcolor", "unicode-width", ] @@ -1266,6 +1268,7 @@ dependencies = [ "horizon-mods", "horizon-protocol", "horizon-server-files", + "horizon-shaders", "horizon-telemetry", "horizon-theme", "horizon-threads", @@ -1438,6 +1441,8 @@ dependencies = [ name = "horizon-shaders" version = "0.1.0" dependencies = [ + "horizon-core", + "naga", "zip", ] @@ -1973,6 +1978,7 @@ dependencies = [ "naga-types", "num-traits", "once_cell", + "pp-rs", "rustc-hash 1.1.0", "spirv", "thiserror 2.0.21", @@ -2649,6 +2655,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "pp-rs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee" +dependencies = [ + "unicode-xid", +] + [[package]] name = "presser" version = "0.3.1" @@ -4197,6 +4212,7 @@ dependencies = [ "hashbrown 0.17.1", "js-sys", "log", + "naga", "parking_lot", "portable-atomic", "profiling", diff --git a/Cargo.toml b/Cargo.toml index b8e6b044e..c6f2bc581 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"] +exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"] members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"] resolver = "2" diff --git a/crates/horizon-bots/src/main.rs b/crates/horizon-bots/src/main.rs index 090daeb4e..66326a74e 100644 --- a/crates/horizon-bots/src/main.rs +++ b/crates/horizon-bots/src/main.rs @@ -173,6 +173,11 @@ impl Random { } } +/// One bot thread that panics poisons the locks it held; the others and the report go on. +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec { let mut w = Writer::new(); w.varint(id); @@ -329,7 +334,7 @@ fn read( ) { use ids::play::clientbound as cb; use ids::play::serverbound as sb; - let send = |body: Vec| writer.lock().unwrap().send(&body); + let send = |body: Vec| lock(&writer).send(&body); let mut seen = HashSet::new(); let mut batch = BatchSize::new(); let mut teleports = 0u64; @@ -338,7 +343,7 @@ fn read( Ok(packet) => packet, Err(e) => { if bot.online.swap(false, Ordering::SeqCst) { - *bot.error.lock().unwrap() = Some(e.to_string()); + *lock(&bot.error) = Some(e.to_string()); } return; } @@ -356,7 +361,7 @@ fn read( cb::LEVEL_CHUNK_WITH_LIGHT => { bot.chunks.fetch_add(1, Ordering::Relaxed); if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) { - if !bot.held.lock().unwrap().insert((x, z)) { + if !lock(&bot.held).insert((x, z)) { bot.resent.fetch_add(1, Ordering::Relaxed); } if seen.insert((x, z)) { @@ -368,7 +373,7 @@ fn read( cb::FORGET_LEVEL_CHUNK => { if let Ok(play::Clientbound::ForgetLevelChunk(p)) = play::Clientbound::decode(id, &body) - && bot.held.lock().unwrap().remove(&(p.pos[0], p.pos[1])) + && lock(&bot.held).remove(&(p.pos[0], p.pos[1])) { bot.forgotten.fetch_add(1, Ordering::Relaxed); } @@ -389,7 +394,7 @@ fn read( } cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) { Ok(play::Clientbound::PlayerPosition(p)) => { - let mut at = bot.teleport.lock().unwrap(); + let mut at = lock(&bot.teleport); let previous = at.unwrap_or([0.0; 3]); // Relative flags: X 1, Y 2, Z 4. let pos: [f64; 3] = std::array::from_fn(|i| { @@ -420,7 +425,7 @@ fn read( }, cb::DISCONNECT | cb::START_CONFIGURATION => { bot.online.store(false, Ordering::SeqCst); - *bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT { + *lock(&bot.error) = Some(if id == cb::DISCONNECT { format!( "disconnected: {}", String::from_utf8_lossy(&body) @@ -455,7 +460,7 @@ fn run_bot( let connection = match join(&o, &name) { Ok(c) => c, Err(e) => { - *bot.error.lock().unwrap() = Some(e); + *lock(&bot.error) = Some(e); return; } }; @@ -469,12 +474,12 @@ fn run_bot( use ids::play::serverbound as sb; // Wait for the spawn position. let spawn = loop { - if let Some(at) = *bot.teleport.lock().unwrap() { + if let Some(at) = *lock(&bot.teleport) { break at; } if !bot.online.load(Ordering::SeqCst) || asked.elapsed() > Duration::from_secs(60) { - if bot.error.lock().unwrap().is_none() { - *bot.error.lock().unwrap() = Some("no spawn position within 60 s".into()); + if lock(&bot.error).is_none() { + *lock(&bot.error) = Some("no spawn position within 60 s".into()); } return; } @@ -482,7 +487,7 @@ fn run_bot( }; bot.joined_after_ms .store(asked.elapsed().as_millis() as u64, Ordering::SeqCst); - let send = |body: Vec| writer.lock().unwrap().send(&body).is_ok(); + let send = |body: Vec| lock(&writer).send(&body).is_ok(); send(raw(sb::PLAYER_LOADED, |_| {})); let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D)); let mut pos = spawn; @@ -491,17 +496,15 @@ fn run_bot( let mut target = pos; let mut next = Instant::now(); loop { - if until.lock().unwrap().is_some_and(|u| Instant::now() >= u) - || !bot.online.load(Ordering::SeqCst) - { + if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) { break; } // A teleport from the server wins over where the bot thought it was. - if let Some(at) = bot.teleport.lock().unwrap().take() { + if let Some(at) = lock(&bot.teleport).take() { pos = at; } let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4); - if !bot.held.lock().unwrap().contains(&here) { + if !lock(&bot.held).contains(&here) { bot.waited.fetch_add(1, Ordering::Relaxed); } else if pos[1] < cruise { pos[1] = (pos[1] + 0.5).min(cruise); @@ -558,7 +561,7 @@ fn run_bot( } // Offline first, so the reader doesn't count the closed connection as a failure. bot.online.store(false, Ordering::SeqCst); - writer.lock().unwrap().shutdown(); + lock(&writer).shutdown(); } fn behaviour_of(mode: &str, index: usize) -> Behaviour { @@ -612,13 +615,10 @@ fn main() { let total = |f: fn(&Bot) -> &AtomicU64| -> u64 { bots.iter().map(|b| f(b).load(Ordering::Relaxed)).sum() }; - let failed = bots - .iter() - .filter(|b| b.error.lock().unwrap().is_some()) - .count(); + let failed = bots.iter().filter(|b| lock(&b.error).is_some()).count(); if first.is_none() && online > 0 { first = Some(Instant::now()); - *until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds)); + *lock(&until) = Some(Instant::now() + Duration::from_secs(o.seconds)); } println!( "{}", @@ -636,10 +636,10 @@ fn main() { }) ); previous_chunks = chunks; - let over = until.lock().unwrap().is_some_and(|u| Instant::now() >= u); + let over = lock(&until).is_some_and(|u| Instant::now() >= u); let all_failed = failed == o.bots; if over || all_failed || (first.is_none() && start.elapsed() > Duration::from_secs(120)) { - *until.lock().unwrap() = Some(Instant::now()); + *lock(&until) = Some(Instant::now()); break; } } @@ -669,7 +669,7 @@ fn main() { "forgotten": b.forgotten.load(Ordering::Relaxed), "pulled_back": b.pulled_back.load(Ordering::Relaxed), "waited_ticks": b.waited.load(Ordering::Relaxed), - "error": *b.error.lock().unwrap(), + "error": *lock(&b.error), })).collect::>(), }); if o.report.as_deref() == Some("-") { diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml index 85373b288..ea5859c23 100644 --- a/crates/horizon-client/Cargo.toml +++ b/crates/horizon-client/Cargo.toml @@ -16,11 +16,13 @@ horizon-world = { path = "../horizon-world" } horizon-protocol = { path = "../horizon-protocol" } # server.properties, eula.txt and the user lists, shared with horizon-server. horizon-server-files = { path = "../horizon-server-files" } +# Shader packs: reading them and turning their GLSL into naga modules. +horizon-shaders = { path = "../horizon-shaders" } horizon-friends = { path = "../horizon-friends" } # Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md). # Their runtime is the `mods` feature. horizon-mods = { path = "../horizon-mods", default-features = false } -wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] } +wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] } winit = "0.30.13" pollster = "0.4" bytemuck = { version = "1.24", features = ["derive"] } diff --git a/crates/horizon-client/assets/horizon/lang/en_us.json b/crates/horizon-client/assets/horizon/lang/en_us.json new file mode 100644 index 000000000..de02589e5 --- /dev/null +++ b/crates/horizon-client/assets/horizon/lang/en_us.json @@ -0,0 +1,15 @@ +{ + "horizon.options.controller": "Controller", + "horizon.options.vibration": "Vibration", + "horizon.options.adaptive_triggers": "Adaptive Triggers", + "horizon.options.lightbar": "Lightbar", + "horizon.options.look_sensitivity": "Look Sensitivity", + "horizon.options.invert_x": "Invert Look X", + "horizon.options.invert_y": "Invert Look Y", + "horizon.options.sneak_button": "Sneak Button", + "horizon.options.move_deadzone": "Move Deadzone", + "horizon.options.look_deadzone": "Look Deadzone", + "horizon.options.button_glyphs": "Button Glyphs", + "horizon.options.menu_navigation": "Menu Navigation", + "horizon.options.on_screen_keyboard": "On-Screen Keyboard" +} diff --git a/crates/horizon-client/assets/horizon/lang/ru_ru.json b/crates/horizon-client/assets/horizon/lang/ru_ru.json new file mode 100644 index 000000000..422fbbc1d --- /dev/null +++ b/crates/horizon-client/assets/horizon/lang/ru_ru.json @@ -0,0 +1,15 @@ +{ + "horizon.options.controller": "Геймпад", + "horizon.options.vibration": "Вибрация", + "horizon.options.adaptive_triggers": "Адаптивные триггеры", + "horizon.options.lightbar": "Подсветка", + "horizon.options.look_sensitivity": "Чувствительность обзора", + "horizon.options.invert_x": "Инверсия обзора по X", + "horizon.options.invert_y": "Инверсия обзора по Y", + "horizon.options.sneak_button": "Кнопка приседания", + "horizon.options.move_deadzone": "Мёртвая зона движения", + "horizon.options.look_deadzone": "Мёртвая зона обзора", + "horizon.options.button_glyphs": "Символы кнопок", + "horizon.options.menu_navigation": "Навигация по меню", + "horizon.options.on_screen_keyboard": "Экранная клавиатура" +} diff --git a/crates/horizon-client/src/boss_overlay.rs b/crates/horizon-client/src/boss_overlay.rs new file mode 100644 index 000000000..12920e0d0 --- /dev/null +++ b/crates/horizon-client/src/boss_overlay.rs @@ -0,0 +1,243 @@ +//! `BossHealthOverlay` and `LerpingBossEvent`: the boss bars a client is +//! showing, in the order their add packets arrived. +use crate::{ + hud_overlays::BossBar, + scoreboard::{comp_text, tag_to_json}, + text::Text, +}; +use horizon_protocol::packets::play::{BossAction, BossEvent}; + +/// `LerpingBossEvent.LERP_MILLISECONDS`. +const LERP_MS: u128 = 100; + +/// `Util.getMillis`. +pub fn now_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +/// One `LerpingBossEvent`. +#[derive(Clone, Debug)] +struct Event { + id: u128, + name: Text, + /// The progress the lerp started from (`progress`). + progress: f32, + /// `targetPercent`. + target: f32, + /// `setTime`. + set_at: u128, + color: u8, + overlay: u8, + darken: bool, + music: bool, + fog: bool, +} +impl Event { + /// `LerpingBossEvent.getProgress`. + fn displayed(&self, now: u128) -> f32 { + let t = ((now.saturating_sub(self.set_at)) as f32 / LERP_MS as f32).clamp(0.0, 1.0); + self.progress + (self.target - self.progress) * t + } + /// `LerpingBossEvent.setProgress`: the bar starts the next lerp from + /// whatever it is showing now. + fn set_progress(&mut self, progress: f32, now: u128) { + self.progress = self.displayed(now); + self.target = progress; + self.set_at = now; + } +} + +/// `BossHealthOverlay`: the events, plus `GameRenderer`'s world darkening. +#[derive(Clone, Debug, Default)] +pub struct BossOverlay { + events: Vec, + /// `bossOverlayWorldDarkening` and the previous tick's value. + darken: f32, + darken_o: f32, +} +impl BossOverlay { + pub fn reset(&mut self) { + *self = Self::default(); + } + /// `ClientboundBossEventPacket.dispatch` into `BossHealthOverlay`'s + /// handler. An id already shown keeps its place when added again. + pub fn apply(&mut self, packet: &BossEvent, now: u128) { + // An ordinal past the enum shows as its last value. + let color = |c: i32| c.clamp(0, 6) as u8; + let overlay = |o: i32| o.clamp(0, 4) as u8; + let id = packet.id; + if let BossAction::Add { + name, + progress, + color: c, + overlay: o, + darken, + music, + fog, + } = &packet.action + { + let event = Event { + id, + name: comp_text(&tag_to_json(name)), + progress: *progress, + target: *progress, + set_at: now, + color: color(*c), + overlay: overlay(*o), + darken: *darken, + music: *music, + fog: *fog, + }; + match self.events.iter_mut().find(|e| e.id == id) { + Some(slot) => *slot = event, + None => self.events.push(event), + } + return; + } + if let BossAction::Remove = packet.action { + self.events.retain(|e| e.id != id); + return; + } + let Some(e) = self.events.iter_mut().find(|e| e.id == id) else { + return; + }; + match &packet.action { + BossAction::UpdateProgress { progress } => e.set_progress(*progress, now), + BossAction::UpdateName { name } => e.name = comp_text(&tag_to_json(name)), + BossAction::UpdateStyle { + color: c, + overlay: o, + } => { + e.color = color(*c); + e.overlay = overlay(*o); + } + BossAction::UpdateProperties { darken, music, fog } => { + (e.darken, e.music, e.fog) = (*darken, *music, *fog); + } + BossAction::Add { .. } | BossAction::Remove => {} + } + } + /// `GameRenderer.tick` while the level runs. + pub fn tick_darken(&mut self, running: bool) { + if !running { + return; + } + self.darken_o = self.darken; + if self.should_darken() { + self.darken = (self.darken + 0.05).min(1.0); + } else if self.darken > 0.0 { + self.darken = (self.darken - 0.0125).max(0.0); + } + } + /// `GameRenderer.bossOverlayWorldDarkening`. + pub fn darken_amount(&self, partial: f32) -> f32 { + let t = partial.clamp(0.0, 1.0); + self.darken_o + (self.darken - self.darken_o) * t + } + pub fn should_darken(&self) -> bool { + self.events.iter().any(|e| e.darken) + } + pub fn should_play_music(&self) -> bool { + self.events.iter().any(|e| e.music) + } + pub fn should_create_world_fog(&self) -> bool { + self.events.iter().any(|e| e.fog) + } + /// The bars `extractRenderState` walks, with the lerped progress. + pub fn bars(&self, now: u128) -> Vec { + self.events + .iter() + .map(|e| BossBar { + name: e.name.clone(), + progress: e.displayed(now), + color: e.color as usize, + overlay: e.overlay as usize, + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use horizon_world::nbt::Tag; + + fn add(id: u128, name: &str, progress: f32, darken: bool, fog: bool) -> BossEvent { + BossEvent { + id, + action: BossAction::Add { + name: Tag::String(name.into()), + progress, + color: 5, + overlay: 0, + darken, + music: false, + fog, + }, + } + } + fn progress(id: u128, progress: f32) -> BossEvent { + BossEvent { + id, + action: BossAction::UpdateProgress { progress }, + } + } + + #[test] + fn progress_lerps_over_a_tenth_of_a_second() { + let mut overlay = BossOverlay::default(); + overlay.apply(&add(1, "W", 0.0, true, false), 0); + overlay.apply(&progress(1, 1.0), 0); + assert!((overlay.bars(50)[0].progress - 0.5).abs() < 1.0e-5); + assert!((overlay.bars(100)[0].progress - 1.0).abs() < 1.0e-5); + // A new target starts from the value on screen. + overlay.apply(&progress(1, 0.0), 50); + assert!((overlay.bars(150)[0].progress - 0.0).abs() < 1.0e-5); + } + + #[test] + fn darken_ramps_and_fades() { + let mut overlay = BossOverlay::default(); + overlay.apply(&add(1, "W", 1.0, true, false), 0); + overlay.tick_darken(true); + assert!((overlay.darken_amount(1.0) - 0.05).abs() < 1.0e-5); + overlay.apply( + &BossEvent { + id: 1, + action: BossAction::Remove, + }, + 0, + ); + overlay.tick_darken(true); + assert!((overlay.darken - 0.0375).abs() < 1.0e-5); + assert!(!overlay.should_play_music()); + } + + #[test] + fn bars_keep_arrival_order() { + let mut overlay = BossOverlay::default(); + overlay.apply(&add(2, "b", 1.0, false, true), 0); + overlay.apply(&add(1, "a", 1.0, false, false), 0); + overlay.apply(&add(2, "c", 0.5, false, true), 0); + // An update for a bar this client never got is ignored. + overlay.apply(&progress(9, 0.5), 0); + let names: Vec<_> = overlay + .bars(0) + .into_iter() + .map(|b| b.name.plain()) + .collect(); + assert_eq!(names, ["c", "a"]); + assert!(overlay.should_create_world_fog()); + overlay.apply( + &BossEvent { + id: 2, + action: BossAction::Remove, + }, + 0, + ); + assert!(!overlay.should_create_world_fog()); + } +} diff --git a/crates/horizon-client/src/bossbar.rs b/crates/horizon-client/src/bossbar.rs index eab31a5e5..2ecdb25e9 100644 --- a/crates/horizon-client/src/bossbar.rs +++ b/crates/horizon-client/src/bossbar.rs @@ -14,40 +14,64 @@ pub const STYLES: [&str; 5] = [ "notched_12", "notched_20", ]; -/// `BossBarColor.getFormatting`. +/// `BossBarColor.getFormatting`. Purple is dark blue in 26.3. pub fn color_rgb(color: &str) -> u32 { let name = match color { "pink" => "red", "red" => "dark_red", - "purple" => "dark_purple", + "purple" => "dark_blue", other => other, }; text::named_color(name).unwrap_or(text::WHITE) } +/// A version-4 shaped id from a resource id. Saved boss events do not keep +/// their UUID (`CustomBossEvents` mints a new one on load). +pub fn stable_uuid(id: &str) -> u128 { + let mut h = 0xcbf29ce484222325u64; + for b in id.as_bytes() { + h ^= u64::from(*b); + h = h.wrapping_mul(0x100000001b3); + } + let most = (h as i64) & -61441 | 16384; + let least = (h.rotate_left(17) as i64) & 4_611_686_018_427_387_903 | i64::MIN; + ((most as u64 as u128) << 64) | (least as u64 as u128) +} + #[derive(Clone, Debug, PartialEq)] pub struct BossBar { pub id: String, + /// The `ServerBossEvent` UUID. Not saved. + pub uuid: u128, pub name: Comp, pub color: String, pub style: String, pub value: i32, pub max: i32, + /// `ServerBossEvent.visible`. A new bar is visible; nobody sees it until + /// a player is added. pub visible: bool, - /// Whether the (single) local player is in the bar's player set. - pub player: bool, + pub darken: bool, + pub music: bool, + pub fog: bool, + /// `CustomBossEvent.players`, the UUIDs told about the bar. + pub players: Vec<[i32; 4]>, } impl BossBar { pub fn new(id: &str, name: Comp) -> Self { Self { id: id.to_owned(), + uuid: stable_uuid(id), name, color: "white".into(), style: "progress".into(), value: 0, max: 100, visible: true, - player: false, + darken: false, + music: false, + fog: false, + players: Vec::new(), } } /// `CustomBossEvent.getDisplayName`. @@ -91,31 +115,34 @@ impl BossBars { pub fn ids(&self) -> Vec { self.bars.iter().map(|b| b.id.clone()).collect() } - /// The saved compound; the player's UUID stands for "the local player". - pub fn pack(&self, player: Option<[i32; 4]>) -> Tag { + /// The saved compound (`CustomBossEvent.Packed`). + pub fn pack(&self) -> Tag { let mut c = Compound::new(); for b in &self.bars { - let mut e = vec![ - ("Name", json_to_tag(&b.name)), - ("Visible", Tag::Byte(i8::from(b.visible))), - ("Value", Tag::Int(b.value)), - ("Max", Tag::Int(b.max)), - ("Color", Tag::String(b.color.as_str().into())), - ("Overlay", Tag::String(b.style.as_str().into())), - ("DarkenScreen", Tag::Byte(0)), - ("PlayBossMusic", Tag::Byte(0)), - ("CreateWorldFog", Tag::Byte(0)), - ]; - let players = match (b.player, player) { - (true, Some(u)) => vec![Tag::IntArray(u.to_vec())], - _ => Vec::new(), - }; - e.push(("Players", Tag::List(players))); - c.insert(b.id.as_str().into(), Tag::compound(e)); + let players = b + .players + .iter() + .map(|u| Tag::IntArray(u.to_vec())) + .collect(); + c.insert( + b.id.as_str().into(), + Tag::compound([ + ("Name", json_to_tag(&b.name)), + ("Visible", Tag::Byte(i8::from(b.visible))), + ("Value", Tag::Int(b.value)), + ("Max", Tag::Int(b.max)), + ("Color", Tag::String(b.color.as_str().into())), + ("Overlay", Tag::String(b.style.as_str().into())), + ("DarkenScreen", Tag::Byte(i8::from(b.darken))), + ("PlayBossMusic", Tag::Byte(i8::from(b.music))), + ("CreateWorldFog", Tag::Byte(i8::from(b.fog))), + ("Players", Tag::List(players)), + ]), + ); } Tag::Compound(c) } - pub fn unpack(data: &Tag, player: Option<[i32; 4]>) -> Self { + pub fn unpack(data: &Tag) -> Self { let mut bars = Vec::new(); if let Tag::Compound(c) = data { for (id, e) in c { @@ -130,21 +157,30 @@ impl BossBars { Some(Tag::Byte(v)) => i32::from(*v), _ => d, }; - let in_players = match (get("Players"), player) { - (Some(Tag::List(l)), Some(u)) => l + let players = match get("Players") { + Some(Tag::List(l)) => l .iter() - .any(|t| matches!(t, Tag::IntArray(a) if a.as_slice() == u)), - _ => false, + .filter_map(|t| match t { + Tag::IntArray(a) if a.len() == 4 => Some([a[0], a[1], a[2], a[3]]), + _ => None, + }) + .collect(), + _ => Vec::new(), }; + let id = id.to_string_lossy(); bars.push(BossBar { - id: id.to_string_lossy(), + uuid: stable_uuid(&id), + id, name: get("Name").map_or_else(|| literal(""), tag_to_json), color: text("Color", "white"), style: text("Overlay", "progress"), value: num("Value", 0), max: num("Max", 100), visible: num("Visible", 0) != 0, - player: in_players, + darken: num("DarkenScreen", 0) != 0, + music: num("PlayBossMusic", 0) != 0, + fog: num("CreateWorldFog", 0) != 0, + players, }); } } @@ -167,17 +203,15 @@ mod tests { bar.value = 25; bar.max = 50; bar.visible = true; - bar.player = true; - let uuid = Some([1, 2, 3, 4]); - let back = BossBars::unpack(&b.pack(uuid), uuid); + bar.darken = true; + bar.players = vec![[1, 2, 3, 4]]; + let back = BossBars::unpack(&b.pack()); assert_eq!(back, b); - // Another player's UUID does not include the local player. - assert!(!BossBars::unpack(&b.pack(uuid), Some([9, 9, 9, 9])).bars[0].player); let bar = b.get("test:x").unwrap(); assert_eq!(bar.progress(), 0.5); assert_eq!(bar.display_name().plain(), "chat.square_brackets"); assert_eq!(color_rgb("pink"), text::RED); - assert_eq!(color_rgb("purple"), 0xAA00AA); + assert_eq!(color_rgb("purple"), text::named_color("dark_blue").unwrap()); b.remove("test:x"); assert!(b.bars.is_empty()); } diff --git a/crates/horizon-client/src/command_storage.rs b/crates/horizon-client/src/command_storage.rs index ce6e88f5c..560423e90 100644 --- a/crates/horizon-client/src/command_storage.rs +++ b/crates/horizon-client/src/command_storage.rs @@ -55,7 +55,7 @@ impl CommandStorage { .iter() .map(|(k, v)| (k.as_str().into(), v.clone())) .collect(); - let _ = save::write_dat( + if let Err(e) = save::write_dat( &file(root, namespace), Tag::compound([ ( @@ -64,7 +64,9 @@ impl CommandStorage { ), ("DataVersion", Tag::Int(horizon_world::DATA_VERSION)), ]), - ); + ) { + log::warn!("command storage {namespace} was not written: {e}"); + } } } /// `get`: an empty compound when unset. diff --git a/crates/horizon-client/src/discord_presence.rs b/crates/horizon-client/src/discord_presence.rs new file mode 100644 index 000000000..5f8cc5837 --- /dev/null +++ b/crates/horizon-client/src/discord_presence.rs @@ -0,0 +1,623 @@ +//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are +//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer +//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE + +//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread, +//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's +//! update limit and clears the activity on quit. Nothing is sent with the setting off, +//! or with `HORIZON_NO_DISCORD=1`. +use std::collections::VecDeque; +use std::io::{self, Read, Write}; +use std::path::PathBuf; +use std::sync::{Condvar, Mutex, OnceLock}; +use std::thread::Thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde_json::{Value, json}; + +use crate::command::args::GameMode; + +/// The "Horizon" 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`, +/// `spectator`) beside the logo; it doesn't yet. +const MODE_IMAGES: bool = false; +/// Points the game at a test's fake Discord (a folder holding `discord-ipc-0`). +const IPC_DIR_ENV: &str = "HORIZON_DISCORD_IPC_DIR"; + +/// Discord drops activity updates beyond 5 in 20 seconds. +const BURST: usize = 5; +const WINDOW: Duration = Duration::from_secs(20); +/// Between two tries to reach Discord while it isn't running. +const RETRY: Duration = Duration::from_secs(15); +/// The activity is sent again this often, so a Discord restarted meanwhile gets it back. +const REFRESH: Duration = Duration::from_secs(60); + +const HANDSHAKE: u32 = 0; +const FRAME: u32 = 1; +const CLOSE: u32 = 2; +const PING: u32 = 3; +const PONG: u32 = 4; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dimension { + Overworld, + Nether, + End, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Play { + Singleplayer, + /// A server or someone else's LAN world. Its address and name are never shown. + Multiplayer, + /// This world is open to LAN or friends; `players` counts the host. + Hosting { + players: usize, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + /// The player turned Rich Presence off: the activity is cleared. + Off, + Menus, + World { + play: Play, + dimension: Dimension, + mode: GameMode, + }, +} + +/// What the game wants shown, and since when it has been in a world. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Wanted { + status: Status, + /// Unix seconds: when the player entered the world (dimension changes keep it). + since: Option, +} +impl Wanted { + /// A new status at `now`; false if it is the one already wanted. + fn update(&mut self, status: Status, now: u64) -> bool { + if self.status == status { + return false; + } + let in_world = |s: Status| matches!(s, Status::World { .. }); + if !in_world(status) { + self.since = None; + } else if !in_world(self.status) { + self.since = Some(now); + } + self.status = status; + true + } +} + +struct Shared { + wanted: Wanted, + stop: bool, + stopped: bool, +} +static SHARED: Mutex = Mutex::new(Shared { + wanted: Wanted { + status: Status::Menus, + since: None, + }, + stop: false, + stopped: false, +}); +static STOPPED: Condvar = Condvar::new(); +static THREAD: OnceLock = OnceLock::new(); + +/// Starts the presence thread, once: when the game starts, never for a dedicated server. +/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord. +pub fn start(enabled: bool, automated: bool) { + let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some(); + if THREAD.get().is_some() + || std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0") + || (automated && !fake_discord) + { + return; + } + if !enabled { + lock().wanted.status = Status::Off; + } + match std::thread::Builder::new() + .name("Discord presence".into()) + .spawn(run) + { + Ok(handle) => { + let _ = THREAD.set(handle.thread().clone()); + } + Err(e) => log::warn!("Horizon: no Discord presence: {e}"), + } +} + +/// What the player does now; only a change wakes the thread. Free without [`start`]. +pub fn set(status: Status) { + let Some(thread) = THREAD.get() else { + return; + }; + if lock().wanted.update(status, unix_now()) { + thread.unpark(); + } +} + +/// On quit: clears the activity, waiting at most a moment for Discord. +pub fn stop() { + let Some(thread) = THREAD.get() else { + return; + }; + let mut shared = lock(); + shared.stop = true; + thread.unpark(); + let _ = STOPPED.wait_timeout_while(shared, Duration::from_millis(300), |s| !s.stopped); +} + +fn lock() -> std::sync::MutexGuard<'static, Shared> { + SHARED.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +fn run() { + let mut client: Option = None; + let mut sent: Option<(Wanted, Instant)> = None; + let mut last_try: Option = None; + let mut limit = RateLimit::default(); + loop { + let (wanted, stop) = { + let s = lock(); + (s.wanted, s.stop) + }; + if stop || wanted.status == Status::Off { + if let Some(mut c) = client.take() { + let _ = c.set_activity(None); + c.close(); + } + sent = None; + if stop { + lock().stopped = true; + STOPPED.notify_all(); + return; + } + std::thread::park(); + continue; + } + let now = Instant::now(); + if client.is_none() { + if let Some(wait) = last_try.and_then(|t| RETRY.checked_sub(now - t)) { + std::thread::park_timeout(wait); + continue; + } + last_try = Some(now); + client = Client::connect(); + if client.is_none() { + continue; + } + } + let due = sent.is_none_or(|(w, at)| w != wanted || now - at >= REFRESH); + if !due { + let (_, at) = sent.expect("sent"); + std::thread::park_timeout(REFRESH - (now - at)); + continue; + } + let wait = limit.wait(now); + if !wait.is_zero() { + std::thread::park_timeout(wait); + continue; + } + limit.record(now); + let c = client.as_mut().expect("connected"); + match c.set_activity(activity(wanted.status, wanted.since)) { + Ok(()) => sent = Some((wanted, now)), + Err(e) => { + log::info!("Horizon: Discord presence disconnected ({e})"); + client = None; + sent = None; + } + } + } +} + +/// 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 (details, state, party) = match status { + Status::Off => return None, + Status::Menus => ("In the menus", None, None), + Status::World { + play, + dimension, + mode, + } => { + if MODE_IMAGES { + let (key, text) = match mode { + GameMode::Survival => ("survival", "Survival"), + GameMode::Creative => ("creative", "Creative"), + GameMode::Adventure => ("adventure", "Adventure"), + GameMode::Spectator => ("spectator", "Spectator"), + }; + assets["small_image"] = key.into(); + assets["small_text"] = text.into(); + } + let place = match dimension { + Dimension::Overworld => "In the Overworld", + Dimension::Nether => "In the Nether", + Dimension::End => "In the End", + }; + let (details, party) = match play { + Play::Singleplayer => ("Singleplayer", None), + Play::Multiplayer => ("Multiplayer", None), + Play::Hosting { players } => ("Hosting a world", Some(players)), + }; + (details, Some(place), party) + } + }; + let mut a = json!({"details": details, "assets": assets}); + if let Some(state) = state { + a["state"] = state.into(); + } + if let Some(start) = since { + a["timestamps"] = json!({"start": start}); + } + if let Some(players) = party { + let max = crate::lan::MAX_PLAYERS; + a["party"] = json!({ + "id": format!("horizon-{}", std::process::id()), + "size": [players.clamp(1, max), max], + }); + } + Some(a) +} + +/// At most [`BURST`] updates in any [`WINDOW`]. +#[derive(Default)] +struct RateLimit { + sent: VecDeque, +} +impl RateLimit { + /// How long until the next update may go. + fn wait(&self, now: Instant) -> Duration { + if self.sent.len() < BURST { + return Duration::ZERO; + } + (self.sent[0] + WINDOW).saturating_duration_since(now) + } + fn record(&mut self, now: Instant) { + if self.sent.len() == BURST { + self.sent.pop_front(); + } + self.sent.push_back(now); + } +} + +fn encode(op: u32, payload: &Value) -> Vec { + let body = payload.to_string(); + let mut frame = Vec::with_capacity(8 + body.len()); + frame.extend_from_slice(&op.to_le_bytes()); + frame.extend_from_slice(&(body.len() as u32).to_le_bytes()); + frame.extend_from_slice(body.as_bytes()); + frame +} + +fn read_frame(r: &mut impl Read) -> io::Result<(u32, Value)> { + let mut header = [0; 8]; + r.read_exact(&mut header)?; + let op = u32::from_le_bytes(header[..4].try_into().expect("4 bytes")); + let len = u32::from_le_bytes(header[4..].try_into().expect("4 bytes")) as usize; + if len > 1 << 20 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "frame too large", + )); + } + let mut body = vec![0; len]; + r.read_exact(&mut body)?; + Ok((op, serde_json::from_slice(&body)?)) +} + +trait Pipe: Read + Write + Send {} +impl Pipe for T {} + +struct Client { + pipe: Box, + nonce: u64, +} +impl Client { + /// The first Discord socket that answers the handshake with READY. + fn connect() -> Option { + sockets().into_iter().find_map(|path| { + let mut client = Client { + pipe: open(&path).ok()?, + nonce: 0, + }; + match client.handshake() { + Ok(()) => { + log::info!("Horizon: connected to Discord for Rich Presence"); + Some(client) + } + Err(e) => { + log::debug!("Horizon: Discord at {} refused: {e}", path.display()); + None + } + } + }) + } + fn handshake(&mut self) -> io::Result<()> { + let hello = json!({"v": 1, "client_id": DISCORD_APP_ID}); + self.pipe.write_all(&encode(HANDSHAKE, &hello))?; + self.reply(|frame| frame["evt"] == "READY") + } + fn set_activity(&mut self, activity: Option) -> io::Result<()> { + self.nonce += 1; + let nonce = self.nonce.to_string(); + let command = json!({ + "cmd": "SET_ACTIVITY", + "args": {"pid": std::process::id(), "activity": activity}, + "nonce": nonce, + }); + self.pipe.write_all(&encode(FRAME, &command))?; + self.reply(|frame| frame["nonce"] == nonce.as_str()) + } + /// Reads until the frame `ours` picks, answering pings; an ERROR is logged, not fatal. + fn reply(&mut self, ours: impl Fn(&Value) -> bool) -> io::Result<()> { + loop { + match read_frame(&mut self.pipe)? { + (FRAME, frame) if ours(&frame) => { + if frame["evt"] == "ERROR" { + log::info!("Horizon: Discord presence: {}", frame["data"]["message"]); + } + return Ok(()); + } + (PING, frame) => self.pipe.write_all(&encode(PONG, &frame))?, + (CLOSE, frame) => { + return Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + frame["message"].as_str().unwrap_or("closed").to_owned(), + )); + } + _ => {} + } + } + } + fn close(mut self) { + let _ = self.pipe.write_all(&encode(CLOSE, &json!({}))); + } +} + +/// Where Discord listens: `discord-ipc-0..9` in the temporary folders (and Flatpak's and +/// Snap's subfolders) on Linux and macOS, named pipes on Windows. +fn sockets() -> Vec { + sockets_with(|key| std::env::var_os(key), cfg!(windows)) +} + +fn sockets_with(var: impl Fn(&str) -> Option, windows: bool) -> Vec { + let names = (0..10).map(|n| format!("discord-ipc-{n}")); + if let Some(dir) = var(IPC_DIR_ENV) { + return names.map(|n| PathBuf::from(&dir).join(n)).collect(); + } + if windows { + return names.map(|n| PathBuf::from(r"\\?\pipe\").join(n)).collect(); + } + let mut dirs: Vec = ["XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"] + .into_iter() + .filter_map(&var) + .map(PathBuf::from) + .collect(); + dirs.push("/tmp".into()); + dirs.dedup(); + let mut paths = Vec::new(); + for dir in dirs { + for sub in ["", "app/com.discordapp.Discord", "snap.discord"] { + paths.extend(names.clone().map(|n| dir.join(sub).join(n))); + } + } + paths +} + +#[cfg(unix)] +fn open(path: &std::path::Path) -> io::Result> { + let stream = std::os::unix::net::UnixStream::connect(path)?; + // A Discord that stops answering costs this thread a few seconds, never the game. + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + Ok(Box::new(stream)) +} + +#[cfg(windows)] +fn open(path: &std::path::Path) -> io::Result> { + let pipe = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path)?; + Ok(Box::new(pipe)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn world(play: Play, dimension: Dimension) -> Status { + Status::World { + play, + dimension, + mode: GameMode::Survival, + } + } + + /// The folders a real Discord listens in: `$TMPDIR` on macOS, `$XDG_RUNTIME_DIR` on Linux (and its + /// Flatpak and Snap subfolders), named pipes on Windows. + #[test] + fn sockets_cover_where_discord_listens() { + let env = |pairs: &'static [(&'static str, &'static str)]| { + move |key: &str| { + pairs + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| std::ffi::OsString::from(v)) + } + }; + let macos = sockets_with(env(&[("TMPDIR", "/var/folders/xy/abc/T/")]), false); + assert_eq!( + macos[0], + PathBuf::from("/var/folders/xy/abc/T/discord-ipc-0") + ); + assert!(macos.contains(&PathBuf::from("/var/folders/xy/abc/T/discord-ipc-9"))); + assert!(macos.contains(&PathBuf::from("/tmp/discord-ipc-0"))); + + let linux = sockets_with( + env(&[("XDG_RUNTIME_DIR", "/run/user/1000"), ("TMPDIR", "/tmp")]), + false, + ); + assert_eq!(linux[0], PathBuf::from("/run/user/1000/discord-ipc-0")); + for path in [ + "/run/user/1000/app/com.discordapp.Discord/discord-ipc-0", + "/run/user/1000/snap.discord/discord-ipc-0", + "/tmp/discord-ipc-0", + ] { + assert!(linux.contains(&PathBuf::from(path)), "{path}"); + } + assert_eq!(linux.len(), 2 * 3 * 10, "/tmp is listed once"); + + let bare = sockets_with(env(&[]), false); + assert_eq!(bare[0], PathBuf::from("/tmp/discord-ipc-0")); + + let windows = sockets_with(env(&[("TEMP", r"C:\Temp")]), true); + assert_eq!(windows.len(), 10); + // `Path::join` only uses `\` as the separator on Windows itself. + #[cfg(windows)] + assert_eq!(windows[0], PathBuf::from(r"\\?\pipe\discord-ipc-0")); + + let fake = sockets_with( + env(&[("HORIZON_DISCORD_IPC_DIR", "/tmp/hz"), ("TMPDIR", "/x")]), + false, + ); + assert_eq!(fake.len(), 10); + assert_eq!(fake[0], PathBuf::from("/tmp/hz/discord-ipc-0")); + } + + #[test] + fn frames_round_trip() { + let frame = encode(FRAME, &json!({"cmd": "SET_ACTIVITY"})); + assert_eq!(&frame[..4], &[1, 0, 0, 0]); + assert_eq!(frame[4..8], (frame.len() as u32 - 8).to_le_bytes()); + let (op, value) = read_frame(&mut frame.as_slice()).unwrap(); + assert_eq!((op, value), (FRAME, json!({"cmd": "SET_ACTIVITY"}))); + assert!(read_frame(&mut &frame[..6]).is_err()); + let huge = [1, 0, 0, 0, 0, 0, 0, 2]; + assert!(read_frame(&mut huge.as_slice()).is_err()); + } + + #[test] + fn activities_say_what_the_player_does_and_nothing_private() { + assert_eq!(activity(Status::Off, None), None); + let menus = activity(Status::Menus, None).unwrap(); + assert_eq!(menus["details"], "In the menus"); + assert!(menus.get("state").is_none() && menus.get("timestamps").is_none()); + assert_eq!(menus["assets"]["large_image"], "horizon"); + + let single = activity(world(Play::Singleplayer, Dimension::Nether), Some(1000)).unwrap(); + assert_eq!(single["details"], "Singleplayer"); + assert_eq!(single["state"], "In the Nether"); + assert_eq!(single["timestamps"]["start"], 1000); + assert!(single.get("party").is_none()); + assert!(single["assets"].get("small_image").is_none()); + + let multi = activity(world(Play::Multiplayer, Dimension::End), Some(5)).unwrap(); + assert_eq!(multi["details"], "Multiplayer"); + assert_eq!(multi["state"], "In the End"); + + let host = world(Play::Hosting { players: 3 }, Dimension::Overworld); + let host = activity(host, Some(5)).unwrap(); + assert_eq!(host["details"], "Hosting a world"); + assert_eq!(host["state"], "In the Overworld"); + assert_eq!(host["party"]["size"], json!([3, 8])); + } + + #[test] + fn time_counts_from_entering_the_world() { + let mut w = Wanted { + status: Status::Menus, + since: None, + }; + assert!(!w.update(Status::Menus, 10)); + assert!(w.update(world(Play::Singleplayer, Dimension::Overworld), 20)); + assert_eq!(w.since, Some(20)); + // Another dimension, opening to LAN: the same visit. + assert!(w.update(world(Play::Singleplayer, Dimension::Nether), 30)); + assert!(w.update(world(Play::Hosting { players: 1 }, Dimension::Nether), 40)); + assert_eq!(w.since, Some(20)); + assert!(w.update(Status::Menus, 50)); + assert_eq!(w.since, None); + assert!(w.update(world(Play::Multiplayer, Dimension::Overworld), 60)); + assert_eq!(w.since, Some(60)); + assert!(w.update(Status::Off, 70)); + assert_eq!(w.since, None); + } + + #[test] + fn at_most_five_updates_in_twenty_seconds() { + let t = Instant::now(); + let mut limit = RateLimit::default(); + for i in 0..5 { + let now = t + Duration::from_secs(i); + assert_eq!(limit.wait(now), Duration::ZERO); + limit.record(now); + } + assert_eq!( + limit.wait(t + Duration::from_secs(5)), + Duration::from_secs(15) + ); + assert_eq!(limit.wait(t + WINDOW), Duration::ZERO); + limit.record(t + WINDOW); + assert_eq!(limit.wait(t + WINDOW), Duration::from_secs(1)); + } + + /// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping. + #[cfg(unix)] + #[test] + fn talks_to_discord_over_its_socket() { + let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("discord-ipc-0"); + let _ = std::fs::remove_file(&path); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + let server = std::thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + let (op, hello) = read_frame(&mut s).unwrap(); + assert_eq!((op, &hello["v"]), (HANDSHAKE, &json!(1))); + let ready = json!({"cmd": "DISPATCH", "evt": "READY", "data": {"v": 1}}); + s.write_all(&encode(FRAME, &ready)).unwrap(); + let mut got = Vec::new(); + for _ in 0..2 { + let (op, cmd) = read_frame(&mut s).unwrap(); + assert_eq!(op, FRAME); + s.write_all(&encode(PING, &json!({"n": 1}))).unwrap(); + assert_eq!(read_frame(&mut s).unwrap(), (PONG, json!({"n": 1}))); + let reply = json!({"cmd": "SET_ACTIVITY", "nonce": cmd["nonce"], "data": null}); + s.write_all(&encode(FRAME, &reply)).unwrap(); + got.push(cmd); + } + got + }); + let mut client = Client { + pipe: open(&path).unwrap(), + nonce: 0, + }; + client.handshake().unwrap(); + let shown = activity(world(Play::Singleplayer, Dimension::End), Some(7)); + client.set_activity(shown.clone()).unwrap(); + client.set_activity(None).unwrap(); + let got = server.join().unwrap(); + assert_eq!(got[0]["cmd"], "SET_ACTIVITY"); + assert_eq!(got[0]["args"]["pid"], std::process::id()); + assert_eq!(got[0]["args"]["activity"], shown.unwrap()); + assert_eq!(got[1]["args"]["activity"], Value::Null); + assert_ne!(got[0]["nonce"], got[1]["nonce"]); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 49ea776ba..bf0764a6b 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -1155,6 +1155,72 @@ pub fn arrow_from_nbt( Some((p, tag.get("inBlockState").cloned())) } +/// `LargeFireball` and `SmallFireball`: `AbstractHurtingProjectile`'s +/// `acceleration_power`, `Projectile`'s owner and flags, `ExplosionPower`. +pub fn fireball_to_nbt( + p: &Projectile, + uuid: [i32; 4], + owner: Option<[i32; 4]>, + raw: Option<&Tag>, +) -> Option { + let power = match p.kind { + ProjectileKind::LargeFireball { power } => Some(power), + ProjectileKind::SmallFireball => None, + _ => return None, + }; + let base = Base { + id: p.kind.id(), + uuid, + position: p.position, + motion: p.velocity, + rotation: [p.yaw, p.pitch], + on_ground: false, + fall_distance: 0.0, + }; + let mut tag = base.tag(raw); + put( + &mut tag, + "acceleration_power", + Tag::Double(p.acceleration.to_bits()), + ); + // Projectile: LeftOwner only when set; HasBeenShot once it has ticked + // (a fireball saved before its first tick keeps what it was loaded with). + if let Tag::Compound(map) = &mut tag { + map.remove(&"LeftOwner".into()); + } + if p.left_owner { + put(&mut tag, "LeftOwner", Tag::Byte(1)); + } + if p.age > 0 || tag.get("HasBeenShot").is_none() { + put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0))); + } + if let Some(power) = power { + put(&mut tag, "ExplosionPower", Tag::Byte(power as i8)); + } + if let Some(owner) = owner { + put(&mut tag, "Owner", Tag::IntArray(owner.to_vec())); + } + Some(tag) +} + +pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option { + let kind = match kind(tag)?.as_str() { + "minecraft:fireball" => ProjectileKind::LargeFireball { + power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32, + }, + "minecraft:small_fireball" => ProjectileKind::SmallFireball, + _ => return None, + }; + let owner = uuid_from_tag(tag.get("Owner")); + let from_player = player.is_some() && owner == player; + let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player)); + p.velocity = motion(tag); + [p.yaw, p.pitch] = rotation(tag); + p.left_owner = flag(tag, "LeftOwner"); + p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1); + Some(p) +} + /// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities. #[allow(clippy::too_many_arguments)] pub fn falling_block_to_nbt( @@ -2139,3 +2205,41 @@ mod sheep_nbt_tests { assert_eq!((back.extra.color, back.extra.sheared), (14, true)); } } + +#[cfg(test)] +mod fireball_nbt_tests { + use super::*; + #[test] + fn a_fireball_round_trips_its_flight() { + let raw = Tag::compound([ + ("id", Tag::String("minecraft:fireball".into())), + ("Pos", doubles(&[1.0, 70.0, -2.5])), + ("Motion", doubles(&[0.0, -0.5, 1.0])), + ("ExplosionPower", Tag::Byte(4)), + ("acceleration_power", Tag::Double(0.0f64.to_bits())), + ]); + let p = fireball_from_nbt(&raw, None).unwrap(); + assert_eq!(p.kind, ProjectileKind::LargeFireball { power: 4 }); + assert_eq!((p.velocity.z, p.acceleration), (1.0, 0.0)); + let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, None).unwrap(); + assert_eq!(number(back.get("ExplosionPower")), Some(4)); + assert_eq!(float(back.get("acceleration_power")), Some(0.0)); + // Projectile.addAdditionalSaveData: no LeftOwner unless set, HasBeenShot always. + assert!(back.get("LeftOwner").is_none()); + assert_eq!(number(back.get("HasBeenShot")), Some(0)); + assert_eq!( + read_doubles::<3>(back.get("Motion")), + Some([0.0, -0.5, 1.0]) + ); + // A default fireball keeps vanilla's power and acceleration. + let plain = Tag::compound([ + ("id", Tag::String("minecraft:fireball".into())), + ("Pos", doubles(&[0.0, 0.0, 0.0])), + ]); + let p = fireball_from_nbt(&plain, None).unwrap(); + assert_eq!( + (p.kind, p.acceleration), + (ProjectileKind::LargeFireball { power: 1 }, 0.1) + ); + } +} diff --git a/crates/horizon-client/src/extras.rs b/crates/horizon-client/src/extras.rs index 698741846..40e87214e 100644 --- a/crates/horizon-client/src/extras.rs +++ b/crates/horizon-client/src/extras.rs @@ -1,7 +1,8 @@ //! Horizon's optional extras (zoom, FPS counter, keystroke overlay): their //! settings, kept in `horizon-extras.txt` next to `options.txt` so that file //! stays exactly vanilla's, and the logic behind them. Every extra is off by -//! default; see `docs/extras.md`. +//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page +//! (No AI, Agent Control, Discord Rich Presence) are kept here too. use std::{collections::VecDeque, fs, path::Path}; pub const FILE: &str = "horizon-extras.txt"; @@ -112,6 +113,8 @@ pub struct Extras { /// Agents may control the game the player starts: it opens its control socket for /// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it). pub agent_control: bool, + /// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default). + pub discord_presence: bool, } impl Default for Extras { fn default() -> Self { @@ -142,6 +145,7 @@ impl Default for Extras { sort_key: "key.keyboard.unknown".into(), no_ai: false, agent_control: false, + discord_presence: true, } } } @@ -207,6 +211,7 @@ impl Extras { "sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(), horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai), "agentControl" => e.agent_control = flag(e.agent_control), + "discordPresence" => e.discord_presence = flag(e.discord_presence), _ => {} } } @@ -214,7 +219,7 @@ impl Extras { } pub fn serialize(&self) -> String { format!( - "fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\n", + "fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n", bool_text(self.fullbright), bool_text(self.dynamic_lights), bool_text(self.zoom), @@ -246,6 +251,7 @@ impl Extras { horizon_core::no_ai::KEY, bool_text(self.no_ai), bool_text(self.agent_control), + bool_text(self.discord_presence), ) } /// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for @@ -466,6 +472,8 @@ pub enum Item { /// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control. NoAi, AgentControl, + /// On the Horizon Settings page. + DiscordPresence, } /// The Extras page: headers and items. pub fn rows() -> Vec> { @@ -546,6 +554,7 @@ impl Item { Item::KeystrokesCps => "Clicks per second", Item::NoAi => "No AI", Item::AgentControl => "Agent Control", + Item::DiscordPresence => "Discord Rich Presence", } } /// The switch this setting belongs to: it does nothing while that is off. @@ -559,7 +568,7 @@ impl Item { MouseTweaksWheel => MouseTweaks, SortKey => Sort, Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort - | AutoReconnect | NoAi | AgentControl => { + | AutoReconnect | NoAi | AgentControl | DiscordPresence => { return None; } }) @@ -606,6 +615,9 @@ impl Item { Item::NoAi => { "Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too." } + Item::DiscordPresence => { + "Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address." + } Item::AgentControl => { "Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only." } @@ -641,6 +653,7 @@ impl Item { Item::Sort => e.sort, Item::NoAi => e.no_ai, Item::AgentControl => e.agent_control, + Item::DiscordPresence => e.discord_presence, _ => return None, }) } @@ -698,6 +711,7 @@ impl Item { Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back), Item::NoAi => e.no_ai = !e.no_ai, Item::AgentControl => e.agent_control = !e.agent_control, + Item::DiscordPresence => e.discord_presence = !e.discord_presence, Item::ZoomKey | Item::SortKey => {} } } @@ -721,6 +735,7 @@ mod tests { assert!(!d.fullbright && !d.dynamic_lights); assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect); assert!(!d.mouse_tweaks && !d.sort); + assert!(d.discord_presence); assert_eq!(Extras::parse(""), d); assert_eq!(Extras::parse(&d.serialize()), d); let mut e = d.clone(); @@ -746,6 +761,7 @@ mod tests { e.mouse_tweaks_wheel = false; e.sort = true; e.sort_key = "key.keyboard.r".into(); + e.discord_presence = false; assert_eq!(Extras::parse(&e.serialize()), e); } diff --git a/crates/horizon-client/src/fog.rs b/crates/horizon-client/src/fog.rs index 942bae556..11ffda151 100644 --- a/crates/horizon-client/src/fog.rs +++ b/crates/horizon-client/src/fog.rs @@ -198,6 +198,8 @@ pub struct FogInputs<'a> { pub blindness: Option<(i32, bool)>, /// The darkness effect's blend factor, while the effect is on. pub darkness: Option, + /// `GameRenderer.bossOverlayWorldDarkening`: a wither's bar dims the fog. + pub darken_world: f32, /// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with /// `createWorldFog` is shown (the Ender Dragon's). pub boss_world_fog: bool, @@ -263,6 +265,13 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog let brightness = (1.0 - darkness) * (1.0 - darkness); color = color.map(|c| c * brightness); } + // FogRenderer.computeFogColor: a boss bar's world darkening. + if input.darken_world > 0.0 { + let t = input.darken_world; + color[0] += (color[0] * 0.7 - color[0]) * t; + color[1] += (color[1] * 0.6 - color[1]) * t; + color[2] += (color[2] * 0.6 - color[2]) * t; + } let brighten = if eye_in_water { water_vision } else { 0.0 }; if color.iter().all(|&c| c != 0.0) { let scale = 1.0 / color[0].max(color[1]).max(color[2]); @@ -394,6 +403,7 @@ mod tests { skybox: true, blindness: None, darkness: None, + darken_world: 0.0, boss_world_fog: false, } } @@ -422,6 +432,25 @@ mod tests { assert_eq!(water.environmental_start, -8.0); } #[test] + fn boss_darkening_dims_fog_color() { + // FogRenderer.computeFogColor: lerp toward 70/60/60% of each channel. + let sky = Sky { + fog_color: [1.0, 1.0, 1.0], + sky_color: [1.0, 1.0, 1.0], + ..Sky::default() + }; + let tables = FogTables { + water_fog_end: vec![1.0], + }; + let mut input = inputs(&sky); + input.skybox = false; + input.darken_world = 1.0; + let fog = setup_fog(&tables, &FogState::default(), &input); + assert!((fog.color[0] - 0.7).abs() < 1.0e-5); + assert!((fog.color[1] - 0.6).abs() < 1.0e-5); + assert!((fog.color[2] - 0.6).abs() < 1.0e-5); + } + #[test] fn void_darkness_starts_32_above_the_floor() { let sky = Sky { fog_end: 1024.0, diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 653a1d038..699468651 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -36,6 +36,7 @@ mod debug_keys; mod decorated_pot; mod dedicated; mod deflect; +mod discord; mod dispense_fixture; mod dispenser; mod dragon; @@ -141,6 +142,7 @@ mod sculk; mod sculk_fixture; mod server_packs; mod server_travel; +mod shader_packs; mod shield; mod sign_edit; mod sniffers; @@ -1571,6 +1573,8 @@ struct Game<'a> { score: scoreboard::ScoreState, fns: functions::FnState, bosses: bossbars::BossState, + /// `Hud.bossOverlay`: the bars this window is showing. + boss_overlay: horizon_client::boss_overlay::BossOverlay, exec: commands::execute::ExecState, rand_sequences: horizon_client::random_sequences::RandomSequences, border: horizon_client::worldborder::WorldBorder, @@ -4083,6 +4087,8 @@ impl Game<'_> { self.track_experience_display(); // `/tick freeze` stops the world but not the player. let running = self.clock.world_runs(); + // GameRenderer.tick: a wither's bar darkens the world, then fades. + self.boss_overlay.tick_darken(running); if running { self.world.game_time += 1; if self.ui.rules.bool("advance_weather") { @@ -4442,6 +4448,8 @@ impl Game<'_> { }); horizon_client::spans::time("tick_particles", || self.tick_particles()); } + // ServerBossEvent broadcasts, after this level's entities have ticked. + self.publish_boss_bars(); // Minecraft.handleKeybinds: held use repeats only while no item is in // use (a raised shield or a drawn bow places and uses nothing). if self.right @@ -7530,7 +7538,6 @@ impl Game<'_> { return Ok(()); } self.join_world_rest()?; - let save_root = std::env::current_dir()?.join("saves").join(folder); if self .options .execution_role @@ -7977,6 +7984,8 @@ impl Game<'_> { self.session_lock = None; horizon_client::crash::clear_detail("World"); self.debug_charts.reset(); + self.boss_overlay.reset(); + self.bosses = Default::default(); self.streaming.paused = true; self.world = World::new(self.res.facts().air, 0); self.player = None; @@ -9555,13 +9564,13 @@ impl Game<'_> { let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0); let stack = self.inventory[c].clone(); self.draw_slot_item(batch, drag.as_ref(), c, stack.clone(), x, y); - if let Some(stack) = stack - && self.cursor.0 >= x + if self.cursor.0 >= x && self.cursor.0 < x + 16.0 && self.cursor.1 >= y && self.cursor.1 < y + 16.0 { - hovered = Some(stack); + batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128)); + hovered = stack; } } let scrollable = self.creative_screen.rows(&self.pack.names) > 5; @@ -10078,6 +10087,11 @@ impl Game<'_> { } else { self.gather_dynamic_lights(camera, alpha); } + let pack_inputs = if title { + Default::default() + } else { + self.shader_pack_inputs(camera, alpha as f32) + }; let frame = Frame { weather: &weather, weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32), @@ -10110,7 +10124,7 @@ impl Game<'_> { if !self.settings.horizon.fog && !underwater && self.darkness_fog().is_none() - && !self.dragon_bar_shown() + && !self.boss_overlay.should_create_world_fog() && self.potions.effects.get("blindness").is_none() && fog.environmental_end >= (self.streaming.radius * 16) as f32 { @@ -10161,6 +10175,7 @@ impl Game<'_> { hand: &hand, hand_fov_degrees: self.hud_fov_degrees(camera_alpha as f32, camera.to_array()), screen: &screen, + pack: pack_inputs, border, game_time: self.world.game_time as f32, shader_game_time: ((self.world.game_time % 24000) as f32 + alpha as f32) / 24000.0, @@ -10181,6 +10196,9 @@ impl Game<'_> { darkness: self.darkness_lighting(alpha as f32), night_vision: self.night_vision_intensity(), block_flicker: self.fog.block_light_flicker, + boss_darken: self + .boss_overlay + .darken_amount(self.clock.fraction() as f32), menu_blur: self.menu_blur(), }; let result = match self.renderer.as_mut() { @@ -11408,6 +11426,7 @@ impl ApplicationHandler for Game<'_> { self.track_toggle_screens(); // For the "I'm playing" ping: an atomic, sent on when it changes. horizon_client::heartbeat::set_lan(self.is_published()); + self.update_discord_presence(); let steps = self.clock.advance(elapsed); // The world ticking in the frame: once its ticks took a // tick's length, the rest are dropped (`FixedStep::tick_fits`). @@ -11987,6 +12006,7 @@ impl<'a> Game<'a> { score: Default::default(), fns: Default::default(), bosses: Default::default(), + boss_overlay: Default::default(), exec: Default::default(), rand_sequences: Default::default(), border: Default::default(), @@ -12705,6 +12725,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> { hand: &[], hand_fov_degrees: 70.0, screen: &[], + pack: Default::default(), border: None, game_time: 0.0, shader_game_time: 0.0, @@ -12714,6 +12735,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> { darkness: (0.0, 0.0), night_vision: 0.0, block_flicker: 0.0, + boss_darken: 0.0, menu_blur: None, } } @@ -12876,6 +12898,7 @@ fn write_report(game: &Game<'_>) -> Result<()> { fn run_event_loop(mut game: Game<'_>) -> Result<()> { EventLoop::new()?.run_app(&mut game)?; + horizon_client::discord_presence::stop(); write_report(&game)?; if let Some(e) = game.error.take() { return Err(e); @@ -13233,6 +13256,7 @@ fn run_loading( }; let mut app = App::Loading(Box::new(loading)); EventLoop::new()?.run_app(&mut app)?; + horizon_client::discord_presence::stop(); match app { App::Ready(mut game) => { write_report(&game)?; @@ -13283,6 +13307,14 @@ pub fn run() -> Result<()> { horizon_client::spans::enable(options.spikes.is_some()); let vanilla_root = options.resources.clone(); let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt")); + // "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord. + if options.dedicated.is_none() { + let automated = options.benchmark.is_some() + || options.offscreen + || options.frames.is_some() + || options.world_frames.is_some(); + horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated); + } // Snapshots of derived tables live in the game folder, never beside the // shared resources. Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache")); diff --git a/crates/horizon-client/src/game/bossbars.rs b/crates/horizon-client/src/game/bossbars.rs index ec7b72632..c6acb9616 100644 --- a/crates/horizon-client/src/game/bossbars.rs +++ b/crates/horizon-client/src/game/bossbars.rs @@ -1,22 +1,29 @@ //! `/bossbar` (BossBarCommands) and drawing the custom boss bars. use super::{ - Game, - commands::{R, err}, + Dim, Game, + commands::{R, Target, err}, }; use horizon_client::{ + boss_overlay, bossbar::{self, BossBars}, command::{Arg, Context}, - hud_overlays, save, - scoreboard::{self as sb, Comp, comp_text}, + mobs::Species, + save, + scoreboard::{self as sb, Comp, json_to_tag}, scoreboard_cmd::format_list, text::Text, }; +use horizon_core::random::RandomSource; +use horizon_protocol::packets::play::{self, BossAction, Clientbound as C}; use horizon_world::nbt::Tag; +use std::collections::{HashMap, HashSet}; #[derive(Default)] pub(super) struct BossState { pub bars: BossBars, loaded: bool, + /// What each viewer was last sent, so a change becomes one packet. + sent: HashMap>, } fn num(n: impl std::fmt::Display) -> Text { @@ -36,7 +43,7 @@ impl Game<'_> { if let Some(root) = save::read_dat(&self.bossbar_path()) && let Some(data) = root.get("data") { - self.bosses.bars = BossBars::unpack(data, self.level.player_uuid); + self.bosses.bars = BossBars::unpack(data); } self.ui.registries.bossbars = self.bosses.bars.ids(); } @@ -47,13 +54,15 @@ impl Game<'_> { if self.bosses.bars.bars.is_empty() && !self.bossbar_path().exists() { return; } - let _ = save::write_dat( + if let Err(e) = save::write_dat( &self.bossbar_path(), Tag::compound([ - ("data", self.bosses.bars.pack(self.level.player_uuid)), + ("data", self.bosses.bars.pack()), ("DataVersion", Tag::Int(horizon_world::DATA_VERSION)), ]), - ); + ) { + log::warn!("boss bars were not written: {e}"); + } } pub(in crate::game) fn bossbar_exists(&self, id: &str) -> bool { self.bosses.bars.get(id).is_some() @@ -68,6 +77,7 @@ impl Game<'_> { b.value = b.value.min(b.max); } } + self.publish_boss_bars(); } fn bar_id(ctx: &Context) -> String { match ctx.get("id") { @@ -86,6 +96,7 @@ impl Game<'_> { self.load_bossbars(); let result = self.bossbar_command(id, ctx); self.ui.registries.bossbars = self.bosses.bars.ids(); + self.publish_boss_bars(); result } fn bossbar_command(&mut self, id: &str, ctx: &Context) -> R { @@ -97,6 +108,10 @@ impl Game<'_> { } let name = self.component_json(ctx, "name")?; self.bosses.bars.add(&bar, name); + if let Some(b) = self.bosses.bars.get_mut(&bar) { + // CustomBossEvents.create: Mth.createInsecureUUID(level.random). + b.uuid = insecure_uuid(&mut self.world.random); + } let shown = self .bosses .bars @@ -173,19 +188,20 @@ impl Game<'_> { i32::from(b.visible) } _ => { - if b.player { - let name = self.target_name(super::commands::Target::Player); - self.feedback(Text::translate( - "commands.bossbar.get.players.some", - &[s, num(1), format_list(vec![name])], - )); - } else { + let names = self.bossbar_player_names(&b.players); + let n = names.len(); + if n == 0 { self.feedback(Text::translate( "commands.bossbar.get.players.none", &[s], )); + } else { + self.feedback(Text::translate( + "commands.bossbar.get.players.some", + &[s, num(n), format_list(names)], + )); } - i32::from(b.player) + n as i32 } }) } @@ -295,31 +311,46 @@ impl Game<'_> { Ok(0) } _ => { - // players [] - let include = if ctx.get("targets").is_some() { - !self.entities(ctx, "targets", true)?.is_empty() + // players []: replace the set. No argument clears it. + let targets = if ctx.get("targets").is_some() { + self.entities(ctx, "targets", true)? } else { - false + Vec::new() }; + let mut uuids = Vec::new(); + let mut names = Vec::new(); + for t in &targets { + let Some(uuid) = self.player_target_uuid(*t) else { + continue; + }; + if !uuids.contains(&uuid) { + uuids.push(uuid); + names.push(self.target_name(*t)); + } + } let b = self.bosses.bars.get_mut(bar).expect("checked"); - if b.player == include { + let mut had = b.players.clone(); + had.sort(); + let mut next = uuids.clone(); + next.sort(); + if had == next { return Err(err("commands.bossbar.set.players.unchanged", &[])); } - b.player = include; + b.players = uuids; + let n = b.players.len(); let s = shown(self); - if include { - let name = self.target_name(super::commands::Target::Player); - self.feedback(Text::translate( - "commands.bossbar.set.players.success.some", - &[s, num(1), format_list(vec![name])], - )); - } else { + if n == 0 { self.feedback(Text::translate( "commands.bossbar.set.players.success.none", &[s], )); + } else { + self.feedback(Text::translate( + "commands.bossbar.set.players.success.some", + &[s, num(targets.len()), format_list(names)], + )); } - Ok(i32::from(include)) + Ok(n as i32) } } } @@ -333,29 +364,316 @@ impl Game<'_> { Ok(self.resolve_component(value)) } - // ---- drawing ------------------------------------------------------------------ - - /// The visible custom bars (`/bossbar`), after the built-in ones. - pub(super) fn custom_bossbars(&self, bars: &mut Vec) { - for b in self - .bosses - .bars - .bars - .iter() - .filter(|b| b.visible && b.player) - { - bars.push(hud_overlays::BossBar { - name: comp_text(&b.name), - progress: b.progress(), - color: bossbar::COLORS + /// Names of the online players in a boss bar's set, in join order. + fn bossbar_player_names(&self, players: &[[i32; 4]]) -> Vec { + self.known_boss_players() + .into_iter() + .filter(|(uuid, _)| players.contains(uuid)) + .map(|(_, name)| name) + .collect() + } + /// The players who can be told about a bar: the one in place, then guests. + fn known_boss_players(&self) -> Vec<([i32; 4], Text)> { + let mut out = Vec::new(); + if self.player.is_some() { + out.push((self.acting_uuid(), self.target_name(Target::Player))); + } + if let Some(host) = &self.lan { + for g in host.hub.guests.iter().filter(|g| g.placed) { + let ints = horizon_client::lan::playerdata::uuid_ints(g.uuid); + if out.iter().any(|(uuid, _)| *uuid == ints) { + continue; + } + out.push((ints, Text::literal(g.name.clone()))); + } + } + out + } + fn player_target_uuid(&self, target: Target) -> Option<[i32; 4]> { + match target { + Target::Player => Some(self.acting_uuid()), + Target::Guest(conn) => self.lan.as_ref().and_then(|h| { + h.hub + .guests .iter() - .position(|c| *c == b.color) - .unwrap_or(0), - overlay: bossbar::STYLES - .iter() - .position(|s| *s == b.style) - .unwrap_or(0), - }); + .find(|g| g.conn == conn) + .map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid)) + }), + _ => None, } } + + // ---- ServerBossEvent delivery ------------------------------------------------ + + /// Tell every viewer the boss events they should be seeing. The window + /// that simulates the world applies them to its own overlay; everyone + /// else gets `ClientboundBossEventPacket`s. + pub(super) fn publish_boss_bars(&mut self) { + if self.remote.is_some() { + return; + } + self.load_bossbars(); + let now = boss_overlay::now_millis(); + let viewers = self.boss_viewers(); + let live: HashSet = viewers.iter().map(|v| v.uuid).collect(); + self.bosses.sent.retain(|id, _| live.contains(id)); + let mut packets = Vec::new(); + for viewer in &viewers { + let desired = self.boss_events_for(viewer); + for event in self.diff_boss_events(viewer, desired) { + if viewer.local { + self.boss_overlay.apply(&event, now); + } else if let Some(conn) = viewer.conn { + packets.push((conn, C::BossEvent(event))); + } + } + } + if packets.is_empty() { + return; + } + let Some(host) = self.lan.as_ref() else { + return; + }; + for (conn, packet) in &packets { + host.hub.send_to(*conn, packet); + } + } + fn boss_viewers(&self) -> Vec { + let mut out = Vec::new(); + // While `as_guest` has swapped a guest in, the host's own position + // is not in place. Their overlay updates once the tick puts it back. + let local = + self.serving.is_none() && self.options.dedicated.is_none() && self.lan_acting.is_none(); + if local && let Some(p) = &self.player { + let ints = self.level.player_uuid.unwrap_or_else(|| self.acting_uuid()); + out.push(Viewer { + uuid: horizon_client::lan::playerdata::ints_uuid(ints), + ints, + at: [p.position.x, p.position.y, p.position.z], + alive: !p.health.dead_or_dying(), + here: true, + local: true, + conn: None, + view_chunks: self.settings.render_distance, + }); + } + if let Some(host) = &self.lan { + for g in host.hub.guests.iter().filter(|g| g.placed) { + if out.iter().any(|v| v.uuid == g.uuid) { + continue; + } + out.push(Viewer { + uuid: g.uuid, + ints: horizon_client::lan::playerdata::uuid_ints(g.uuid), + at: g.position, + alive: !g.dead, + here: g.dimension == self.dim.id(), + local: false, + conn: Some(g.conn), + view_chunks: g.view_distance, + }); + } + } + out + } + /// Custom bars (`CustomBossEvents.onPlayerConnect` sends them as a player + /// joins), then the dragon, withers and the raid. + fn boss_events_for(&self, viewer: &Viewer) -> Vec { + let mut out = Vec::new(); + for b in &self.bosses.bars.bars { + if !b.visible || !b.players.contains(&viewer.ints) { + continue; + } + out.push(Offer { + id: b.uuid, + name: json_to_tag(&b.name), + progress: b.progress(), + color: color_index(&b.color), + overlay: style_index(&b.style), + darken: b.darken, + music: b.music, + fog: b.fog, + scope: Scope::Global, + }); + } + if viewer.here + && let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive) + { + out.push(Offer { + id: bossbar::stable_uuid("minecraft:ender_dragon"), + name: translate_tag("entity.minecraft.ender_dragon"), + progress, + color: 0, + overlay: 0, + darken: false, + music: true, + fog: true, + scope: Scope::Level(self.dim), + }); + } + if viewer.here { + let range = f64::from(10.min(viewer.view_chunks.max(0)) * 16); + for m in self + .mobs + .iter() + .filter(|m| m.species() == Species::Wither && m.alive() && !m.removed) + { + let (dx, dz) = (m.position.x - viewer.at[0], m.position.z - viewer.at[2]); + if dx * dx + dz * dz > range * range { + continue; + } + let progress = if m.extra.counter > 0 { + 1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32 + } else { + (m.health / m.attrs.max_health).clamp(0.0, 1.0) + }; + let name = if let Some(text) = &m.custom_name_text { + json_to_tag(&text.component()) + } else if let Some(name) = &m.custom_name { + Tag::String(name.as_str().into()) + } else { + translate_tag("entity.minecraft.wither") + }; + out.push(Offer { + id: super::lan::entity_uuid(m.id), + name, + progress, + color: 5, + overlay: 0, + darken: true, + music: false, + fog: false, + scope: Scope::Level(self.dim), + }); + } + } + if viewer.here + && let Some((id, name, progress)) = self.raid_boss_at(viewer.at, viewer.alive) + { + out.push(Offer { + id: bossbar::stable_uuid(&format!("raid:{id}")), + name, + progress, + color: 2, + overlay: 2, + darken: false, + music: false, + fog: false, + scope: Scope::Level(self.dim), + }); + } + out + } + /// The packets that take `viewer` from what it was last sent to `desired` + /// (`ServerBossEvent`'s broadcasts). + fn diff_boss_events(&mut self, viewer: &Viewer, desired: Vec) -> Vec { + let full = viewer.local; + let dim = self.dim; + let sent = self.bosses.sent.entry(viewer.uuid).or_default(); + let mut out = Vec::new(); + let event = |id, action| play::BossEvent { id, action }; + sent.retain(|id, prev| { + let owned = full || prev.scope == Scope::Global || prev.scope == Scope::Level(dim); + let keep = !owned || desired.iter().any(|e| e.id == *id); + if !keep { + out.push(event(*id, BossAction::Remove)); + } + keep + }); + for offer in desired { + let Some(prev) = sent.get(&offer.id) else { + out.push(event( + offer.id, + BossAction::Add { + name: offer.name.clone(), + progress: offer.progress, + color: offer.color, + overlay: offer.overlay, + darken: offer.darken, + music: offer.music, + fog: offer.fog, + }, + )); + sent.insert(offer.id, offer); + continue; + }; + let id = offer.id; + if prev.progress != offer.progress { + let progress = offer.progress; + out.push(event(id, BossAction::UpdateProgress { progress })); + } + if prev.name != offer.name { + let name = offer.name.clone(); + out.push(event(id, BossAction::UpdateName { name })); + } + if (prev.color, prev.overlay) != (offer.color, offer.overlay) { + let (color, overlay) = (offer.color, offer.overlay); + out.push(event(id, BossAction::UpdateStyle { color, overlay })); + } + if (prev.darken, prev.music, prev.fog) != (offer.darken, offer.music, offer.fog) { + let (darken, music, fog) = (offer.darken, offer.music, offer.fog); + out.push(event( + id, + BossAction::UpdateProperties { darken, music, fog }, + )); + } + sent.insert(id, offer); + } + out + } + /// `ClientPacketListener.handleBossUpdate`. + pub(super) fn apply_boss_event(&mut self, packet: &play::BossEvent) { + self.boss_overlay.apply(packet, boss_overlay::now_millis()); + } +} + +/// `Mth.createInsecureUUID`. +fn insecure_uuid(random: &mut impl RandomSource) -> u128 { + let most = random.next_long() & -61441 | 16384; + let least = random.next_long() & 4_611_686_018_427_387_903 | i64::MIN; + ((most as u64 as u128) << 64) | (least as u64 as u128) +} +fn translate_tag(key: &str) -> Tag { + Tag::compound([("translate", Tag::String(key.into()))]) +} +fn color_index(color: &str) -> i32 { + bossbar::COLORS + .iter() + .position(|c| *c == color) + .unwrap_or(6) as i32 +} +fn style_index(style: &str) -> i32 { + bossbar::STYLES + .iter() + .position(|s| *s == style) + .unwrap_or(0) as i32 +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Scope { + Global, + Level(Dim), +} +struct Viewer { + uuid: u128, + ints: [i32; 4], + at: [f64; 3], + alive: bool, + here: bool, + local: bool, + conn: Option, + view_chunks: i32, +} +/// A boss event as one viewer should see it, and as it was last sent. +struct Offer { + id: u128, + name: Tag, + progress: f32, + /// `BossBarColor` and `BossBarOverlay` ordinals. + color: i32, + overlay: i32, + darken: bool, + music: bool, + fog: bool, + scope: Scope, } diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index f760bd82f..ec85732ae 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -570,8 +570,14 @@ impl Game<'_> { /// ChatScreen.handleChatInput: history, then a command or a chat line. pub(super) fn handle_chat_input(&mut self, message: &str) { self.ui.chat.add_recent(message); - // On a server (single player's own included) the server runs it. + // On a server (single player's own included) the server runs it, + // unless a client mod owns the command. if self.remote.is_some() { + if let Some(command) = message.strip_prefix('/') + && self.mod_command(command) + { + return; + } self.send_chat(message); return; } @@ -816,8 +822,12 @@ impl Game<'_> { ("data", data), ("DataVersion", Tag::Int(horizon_world::DATA_VERSION)), ]); - let _ = horizon_client::save::write_dat(&path, root); - let _ = write_difficulty(&self.world_root, self.ui.difficulty); + if let Err(e) = horizon_client::save::write_dat(&path, root) { + log::warn!("the world clocks were not written: {e}"); + } + if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) { + log::warn!("the difficulty was not written: {e}"); + } } /// Death message in chat (CombatTracker.getDeathMessage, as far as the /// cause is known to the client). diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 2e551f440..19a16e261 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -42,7 +42,8 @@ pub(super) enum Target { Mob(u32), Item(u32), Orb(usize), - Arrow(usize), + /// By index in `creatures.projectiles`. + Projectile(usize), Stand(u32), Frame(u32), Painting(u32), @@ -327,7 +328,7 @@ impl Game<'_> { .orbs .get(i) .map(|o| [o.position.x, o.position.y, o.position.z]), - Target::Arrow(i) => self + Target::Projectile(i) => self .creatures .projectiles .get(i) @@ -382,7 +383,11 @@ impl Game<'_> { }), Target::Item(_) => "minecraft:item".into(), Target::Orb(_) => "minecraft:experience_orb".into(), - Target::Arrow(_) => "minecraft:arrow".into(), + Target::Projectile(i) => self + .creatures + .projectiles + .get(i) + .map_or_else(String::new, |p| p.kind.id().into()), Target::Stand(_) => "minecraft:armor_stand".into(), Target::Frame(id) => { if self.item_frames.iter().any(|f| f.id == id && f.glow) { @@ -503,6 +508,7 @@ impl Game<'_> { _ => {} } if s.kind != 's' { + use horizon_client::projectiles::ProjectileKind as K; candidates.extend(self.player_candidates(s)); candidates.extend( self.mobs @@ -517,8 +523,12 @@ impl Game<'_> { .projectiles .iter() .enumerate() - .filter(|(_, p)| p.arrow.is_some()) - .map(|(i, _)| Target::Arrow(i)), + // The kinds /data can write (entity_nbt). + .filter(|(_, p)| { + p.arrow.is_some() + || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball) + }) + .map(|(i, _)| Target::Projectile(i)), ); candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id))); candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id))); @@ -1100,9 +1110,15 @@ impl Game<'_> { if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { m.position = v; m.previous = v; - if let Some([yaw, _]) = rotation { + // Entity.teleportSetPosition: setYRot, setYHeadRot, setXRot + // (clamped to -90..90), then setOldRot. + if let Some([yaw, pitch]) = rotation { m.yaw = yaw; m.body_yaw = yaw; + m.head_yaw = yaw; + m.head_pitch = pitch.clamp(-90.0, 90.0); + m.previous_yaw = m.yaw; + m.previous_head_pitch = m.head_pitch; } } } @@ -1118,7 +1134,7 @@ impl Game<'_> { o.previous = v; } } - Target::Arrow(i) => { + Target::Projectile(i) => { if let Some(a) = self.creatures.projectiles.get_mut(i) { a.position = v; a.previous = v; @@ -1542,14 +1558,10 @@ impl Game<'_> { self.orbs[i].age = u32::MAX / 2; } } - Target::Arrow(i) => { - if let Some(a) = self - .creatures - .projectiles - .get_mut(i) - .and_then(|p| p.arrow.as_mut()) - { - a.removed = true; + // cmd_kill goes from the highest index down. + Target::Projectile(i) => { + if i < self.creatures.projectiles.len() { + self.creatures.projectiles.remove(i); } } Target::Stand(id) => self.armor_stands.retain(|a| a.id != id), @@ -1571,7 +1583,7 @@ impl Game<'_> { let mut ordered = targets.clone(); ordered.sort_by_key(|t| { std::cmp::Reverse(match t { - Target::Orb(i) | Target::Arrow(i) => *i, + Target::Orb(i) | Target::Projectile(i) => *i, _ => 0, }) }); @@ -1579,9 +1591,6 @@ impl Game<'_> { self.kill_target(t); } self.orbs.retain(|o| o.age < u32::MAX / 2); - self.creatures - .projectiles - .retain(|p| !p.arrow.as_ref().is_some_and(|a| a.removed)); let message = if targets.len() == 1 { Text::translate("commands.kill.success.single", &[names[0].clone()]) } else { diff --git a/crates/horizon-client/src/game/commands/data.rs b/crates/horizon-client/src/game/commands/data.rs index 6e4c9825f..18035ddd7 100644 --- a/crates/horizon-client/src/game/commands/data.rs +++ b/crates/horizon-client/src/game/commands/data.rs @@ -114,7 +114,7 @@ impl Game<'_> { self.other_to_nbt(&o, uuid) } Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()), - Target::Arrow(i) => { + Target::Projectile(i) => { let p = self.creatures.projectiles.get(i)?; let owner = if p.owner == Some(horizon_client::mobs::Who::Player) { player_uuid @@ -126,7 +126,8 @@ impl Game<'_> { .as_ref() .and_then(|a| a.last_state) .map(|s| self.state_to_nbt(s)); - entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)? + entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None) + .or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))? } }) } diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 3c80a0f32..a0d8987d8 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -513,7 +513,10 @@ impl Game<'_> { if self.pending_screenshot.is_some() { return Err("a screenshot is already pending".into()); } - renderer.screenshot = Some(arg(1)?.into()); + arg(1)?; + // The rest of the line: a path may hold spaces. + let path = line.trim_start().strip_prefix("screenshot").unwrap_or(""); + renderer.screenshot = Some(path.trim().into()); self.pending_screenshot = Some(reply.clone()); Ok(None) } diff --git a/crates/horizon-client/src/game/debug_keys.rs b/crates/horizon-client/src/game/debug_keys.rs index e0ad706de..61e2b2f05 100644 --- a/crates/horizon-client/src/game/debug_keys.rs +++ b/crates/horizon-client/src/game/debug_keys.rs @@ -185,7 +185,9 @@ impl Game<'_> { // Minecraft.emergencySaveAndCrash: the world is saved, then the // game stops; main writes the crash report. let crash = horizon_client::crash::DebugCrash::new(); - let _ = self.save_game(); + if let Err(e) = self.save_game() { + log::error!("the emergency save before the crash failed: {e}"); + } return Err(crash.into()); } } diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index 4c436c60c..9cc892fe0 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -218,21 +218,22 @@ impl Lists { use_whitelist: false, enforce_whitelist: false, }; + // DedicatedPlayerList's constructor order and its LOGGER.warn lines. let warn = |what: &str, r: std::result::Result<(), String>| { if let Err(e) = r { - log("WARN", &format!("Failed to load {what}: {e}")); + log("WARN", &format!("Failed to {what}: {e}")); } }; - // DedicatedPlayerList's constructor order. - warn("user banlist", l.bans.load()); - let _ = l.bans.save(); - warn("ip banlist", l.ip_bans.load()); - let _ = l.ip_bans.save(); - warn("operators list", l.ops.load()); - warn("white-list", l.white.load()); - let _ = l.ops.save(); + let saved = |r: std::io::Result<()>| r.map_err(|e| e.to_string()); + warn("load user banlist", l.bans.load()); + warn("save user banlist", saved(l.bans.save())); + warn("load ip banlist", l.ip_bans.load()); + warn("save ip banlist", saved(l.ip_bans.save())); + warn("load operators list", l.ops.load()); + warn("load white-list", l.white.load()); + warn("save operators list", saved(l.ops.save())); if !l.white.file.exists() { - let _ = l.white.save(); + warn("save white-list", saved(l.white.save())); } l } diff --git a/crates/horizon-client/src/game/discord.rs b/crates/horizon-client/src/game/discord.rs new file mode 100644 index 000000000..23fd3edda --- /dev/null +++ b/crates/horizon-client/src/game/discord.rs @@ -0,0 +1,43 @@ +//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`). +use super::*; +use horizon_client::discord_presence::{self as presence, Dimension, Play, Status}; + +impl Game<'_> { + /// Each frame: only a change reaches the presence thread. + pub(super) fn update_discord_presence(&self) { + presence::set(self.discord_status()); + } + fn discord_status(&self) -> Status { + if !self.settings.extras.discord_presence { + return Status::Off; + } + if self.title.is_some() { + return Status::Menus; + } + let play = if let Some(host) = &self.lan { + Play::Hosting { + players: 1 + host.hub.guests.len(), + } + } else if let Some(server) = &self.integrated { + match (server.published(), &self.remote) { + (Some(_), Some(remote)) => Play::Hosting { + players: remote.player_count(), + }, + _ => Play::Singleplayer, + } + } else if self.remote.is_some() { + Play::Multiplayer + } else { + Play::Singleplayer + }; + Status::World { + play, + dimension: match self.dim { + Dim::Overworld => Dimension::Overworld, + Dim::Nether => Dimension::Nether, + Dim::End => Dimension::End, + }, + mode: self.mode, + } + } +} diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index c6f5d419b..54b260c67 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -12,7 +12,6 @@ use horizon_client::{ hud_layout, hud_overlays, mesher::Vertex, particles::{ParticleOptions, ParticleType}, - text::Text, }; use horizon_core::{ geometry::{Aabb, Vec3}, @@ -2157,23 +2156,12 @@ impl Game<'_> { }); } } - /// `BossHealthOverlay`: the dragon's pink bar, then the withers', the - /// raids' and the custom bars, in that order. + /// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars` + /// (or a server's packets) put on this client's overlay. pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) { - let mut bars = Vec::new(); - if let Some(d) = self.dragon.as_ref().filter(|_| self.dragon_bar_shown()) { - bars.push(hud_overlays::BossBar { - name: Text::literal( - self.translate("entity.minecraft.ender_dragon", "Ender Dragon"), - ), - progress: (d.health / MAX_HEALTH).clamp(0.0, 1.0), - color: 0, - overlay: 0, - }); - } - self.wither_bars(&mut bars); - self.raid_bars(&mut bars); - self.custom_bossbars(&mut bars); + let bars = self + .boss_overlay + .bars(horizon_client::boss_overlay::now_millis()); let draws = hud_overlays::boss_overlay( batch.width as i32, batch.height as i32, @@ -2182,14 +2170,26 @@ impl Game<'_> { ); hud_layout::paint(batch, &self.gui.atlas, &draws); } - /// The dragon fight's boss bar shows (`EnderDragonFight.dragonEvent`, - /// with `setCreateWorldFog`). - pub(super) fn dragon_bar_shown(&self) -> bool { - self.dim == Dim::End && self.dragon.is_some() + /// The dragon fight's bar for a player at `at` + /// (`EnderDragonFight.dragonEvent`, visible while the fight is not won + /// and the player is alive within 192 blocks of (0, 128, 0)). + pub(super) fn dragon_boss_progress(&self, at: [f64; 3], alive: bool) -> Option { + if self.dim != Dim::End || !alive || !self.fight.loaded || self.fight.killed { + return None; + } + let near = at[0].powi(2) + (at[1] - 128.0).powi(2) + at[2].powi(2) <= 192.0 * 192.0; + if !near { + return None; + } + Some( + self.dragon + .as_ref() + .map_or(1.0, |d| (d.health / MAX_HEALTH).clamp(0.0, 1.0)), + ) } - /// `music.dragon` while the boss bar plays music. + /// `BossHealthOverlay.shouldPlayMusic`. pub(super) fn boss_music(&self) -> bool { - self.dim == Dim::End && self.dragon.is_some() || self.wither_near() + self.boss_overlay.should_play_music() } } pub(super) struct DragonFireball { diff --git a/crates/horizon-client/src/game/fov.rs b/crates/horizon-client/src/game/fov.rs index 37796c8c3..27d3fb61e 100644 --- a/crates/horizon-client/src/game/fov.rs +++ b/crates/horizon-client/src/game/fov.rs @@ -74,7 +74,7 @@ impl Game<'_> { }) } /// The fluid at the camera, as `Camera.getFluidInCamera` reports it. - fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid { + pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid { match fog_type(&self.world, self.res.facts(), camera, self.forward()) { FogType::Water => CameraFluid::Water, FogType::Lava => CameraFluid::Lava, diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index db0c3e8b2..dd272719a 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -321,20 +321,7 @@ impl Game<'_> { } for p in self.creatures.projectiles.iter().filter(|p| p.id != 0) { use horizon_client::projectiles::ProjectileKind as K; - let kind = match &p.kind { - K::Arrow { .. } => "minecraft:arrow", - K::Snowball => "minecraft:snowball", - K::Egg => "minecraft:egg", - K::EnderPearl => "minecraft:ender_pearl", - K::SmallFireball => "minecraft:small_fireball", - K::LargeFireball { .. } => "minecraft:fireball", - K::LlamaSpit => "minecraft:llama_spit", - K::WindCharge => "minecraft:wind_charge", - K::BreezeWindCharge => "minecraft:breeze_wind_charge", - K::ShulkerBullet { .. } => "minecraft:shulker_bullet", - K::WitherSkull { .. } => "minecraft:wither_skull", - K::Potion { .. } => "minecraft:splash_potion", - }; + let kind = p.kind.id(); let mut e = state(p.id, kind, at(p.position), p.yaw, false); e.pitch = p.pitch; e.velocity = at(p.velocity); diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index ad9d7fe7e..d3afbf6ce 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -156,6 +156,8 @@ impl Game<'_> { kind.as_str(), "minecraft:experience_orb" | "minecraft:arrow" + | "minecraft:fireball" + | "minecraft:small_fireball" | "minecraft:falling_block" | "minecraft:end_crystal" | "minecraft:ender_dragon" @@ -195,6 +197,11 @@ impl Game<'_> { self.creatures.projectiles.push(p); } } + "minecraft:fireball" | "minecraft:small_fireball" => { + if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) { + self.creatures.projectiles.push(p); + } + } "minecraft:falling_block" => { if let Some((state, position, velocity, age, fall_distance)) = entity_nbt::falling_block_from_nbt(&tag) @@ -642,13 +649,15 @@ impl Game<'_> { } else { None }; - let (uuid, raw) = anon(&self.persist, "minecraft:arrow", p.position); + let (uuid, raw) = anon(&self.persist, p.kind.id(), p.position); let in_block = p .arrow .as_ref() .and_then(|a| a.last_state) .map(|s| self.state_to_nbt(s)); - if let Some(tag) = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref()) { + let tag = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref()) + .or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw.as_ref())); + if let Some(tag) = tag { out.entry(chunk_of(p.position)).or_default().push(tag); } } diff --git a/crates/horizon-client/src/game/raids.rs b/crates/horizon-client/src/game/raids.rs index bff4a9918..bedca5708 100644 --- a/crates/horizon-client/src/game/raids.rs +++ b/crates/horizon-client/src/game/raids.rs @@ -6,7 +6,6 @@ use super::{Dim, Game}; use horizon_client::mobs::villager as mobs_villager; use horizon_client::{ effects::{EffectInstance, OmenTick}, - hud_overlays, mobs::{ Mob, Species, raider, spawn_rules::{self, SpawnLevel}, @@ -15,9 +14,9 @@ use horizon_client::{ }, patrol::{GameplayAttributes, Member, PatrolLevel, PatrolSpawner}, raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, RaiderType, Raids, Spawn}, - text::Text, }; use horizon_core::{geometry::Vec3, random::RandomSource}; +use horizon_world::nbt::Tag; use std::collections::{HashMap, HashSet}; /// Patrol and raid state owned by the game. @@ -1063,37 +1062,18 @@ impl Game<'_> { } } - /// The raid boss bars: red and notched in tens, with the raid's title. - pub(super) fn raid_bars(&self, bars: &mut Vec) { - for raid in self.raiding.raids.map.values() { - if !raid.bar.visible || !raid.bar.player || raid.is_stopped() { - continue; - } - let title = self.translate("event.minecraft.raid", "Raid"); - let name = match raid.bar.name { - BarName::Raid => title, - BarName::Remaining(n) => format!( - "{title} - {}", - self.translate( - "event.minecraft.raid.raiders_remaining", - "Raiders Remaining: %s" - ) - .replace("%s", &n.to_string()) - ), - BarName::Victory => { - self.translate("event.minecraft.raid.victory.full", "Raid - Victory") - } - BarName::Defeat => { - self.translate("event.minecraft.raid.defeat.full", "Raid - Defeat") - } - }; - bars.push(hud_overlays::BossBar { - name: Text::literal(name), - progress: raid.bar.progress, - color: 2, - overlay: 2, - }); + /// The raid bar a living player at `at` is in (`Raid.getPlayers`): red, + /// notched in tens. The name is the translate component the client draws. + pub(super) fn raid_boss_at(&self, at: [f64; 3], alive: bool) -> Option<(i32, Tag, f32)> { + if !alive || self.dim != Dim::Overworld { + return None; } + let block = block_of(Vec3::new(at[0], at[1], at[2])); + let (id, raid) = self.raiding.raids.nearby(block)?; + if !raid.bar.visible || raid.is_stopped() { + return None; + } + Some((id, raid_name_tag(raid.bar.name), raid.bar.progress)) } /// A firework a villager launches to celebrate (`CelebrateVillagersSurvivedRaid`). pub(super) fn villager_firework(&mut self, at: [f64; 3], color: usize, flight: u8) { @@ -1239,6 +1219,36 @@ impl Game<'_> { } } +/// `Raid.raidEvent`'s name: the raid title, the remaining-raiders line, or +/// the victory and defeat lines. +fn raid_name_tag(name: BarName) -> Tag { + let key = |k: &str| Tag::compound([("translate", Tag::String(k.into()))]); + match name { + BarName::Raid => key("event.minecraft.raid"), + BarName::Victory => key("event.minecraft.raid.victory.full"), + BarName::Defeat => key("event.minecraft.raid.defeat.full"), + BarName::Remaining(n) => Tag::compound([ + ("translate", Tag::String("event.minecraft.raid".into())), + ( + "extra", + Tag::List(vec![ + Tag::String(" - ".into()), + Tag::compound([ + ( + "translate", + Tag::String("event.minecraft.raid.raiders_remaining".into()), + ), + ( + "with", + Tag::List(vec![Tag::String(n.to_string().as_str().into())]), + ), + ]), + ]), + ), + ]), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index efaf02b95..bc14b5263 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -383,6 +383,7 @@ impl Game<'_> { } self.apply_settings(); self.clear_waypoints(); + self.boss_overlay.reset(); self.remote = Some(Remote { session, brand: "unknown".into(), @@ -465,6 +466,7 @@ impl Game<'_> { self.extras_left(); } self.clear_waypoints(); + self.boss_overlay.reset(); if let Some(mut r) = self.remote.take() { r.session.disconnect(); } @@ -753,6 +755,9 @@ impl Game<'_> { self.ticks, self.screen == Screen::Chat, ); + if self.mods.running() { + self.mods_chat(&text); + } if let Some(r) = self.remote.as_mut() { r.chat.push((text, Instant::now())); r.chat_received += 1; @@ -849,7 +854,11 @@ impl Game<'_> { r.joined = true; r.profile = uuid; r.profile_name = name.clone(); - r.status = format!("Joined as {name}; loading terrain..."); + // LevelLoadingScreen's line: no player name. + r.status = horizon_client::lang::get_or( + "multiplayer.downloadingTerrain", + "Loading terrain...", + ); } } Event::Brand(brand) => { @@ -2717,6 +2726,7 @@ impl Game<'_> { } // ClientPacketListener.setActionBarText, setTitleText, // setSubtitleText, setTitlesAnimation and handleTitlesClear. + C::BossEvent(p) => self.apply_boss_event(&p), C::SetActionBarText(p) => self.titles.set_overlay(remote_text(&p.text), false), C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)), C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)), @@ -3126,6 +3136,8 @@ impl Game<'_> { /// One client tick while connected (Minecraft.tick with a ClientLevel). pub(super) fn tick_remote(&mut self) -> Result<()> { self.tick_remote_level()?; + // Client mods tick on any server, single player's own included. + self.tick_mods(); // Minecraft.tick sends this even when the player cannot tick yet, // including while waiting for chunks. Keep it outside early returns. if self.remote.as_ref().is_some_and(|r| r.joined) { @@ -3151,6 +3163,7 @@ impl Game<'_> { // Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances // the time only while the level runs normally. let running = self.clock.world_runs(); + self.boss_overlay.tick_darken(running); if running { self.world.game_time += 1; self.remote_clock_tick(); @@ -3914,7 +3927,7 @@ impl Game<'_> { let sneaking = self.keys.contains(&KeyCode::ShiftLeft); // A boat, minecart, item frame, armor stand or painting nearer than // any mob: the server uses it (AbstractBoat.interact boards it). - if let Some(hit) = self.remote_object_hit() + if let Some(hit) = self.remote_object_hit(false) && self.mob_hit().is_none_or(|(_, d)| hit.distance < d) && self.before_block(hit.distance) { @@ -4423,12 +4436,12 @@ impl Game<'_> { id: self.creatures.projectiles[i].id as i32, })); self.reset_attack_strength(); - } else if let Some(hit) = self.remote_object_hit() + } else if let Some(hit) = self.remote_object_hit(true) && self.mob_hit().is_none_or(|(_, d)| hit.distance < d) && self.before_block(hit.distance) { // VehicleEntity.hurtServer, ItemFrame and ArmorStand hurtServer, - // a painting broken: the server decides. + // a painting broken or an EndCrystal hurtServer: the server decides. self.send(S::Attack(play::Attack { id: hit.id })); self.reset_attack_strength(); } else if let Some((i, distance)) = self.mob_hit() @@ -5437,6 +5450,10 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_ } impl Remote { + /// Players in the server's player list, this one included. + pub(super) fn player_count(&self) -> usize { + self.players.len() + } /// The local player's profile UUID. pub(super) fn profile(&self) -> u128 { self.profile diff --git a/crates/horizon-client/src/game/remote_vehicles.rs b/crates/horizon-client/src/game/remote_vehicles.rs index cf1b2d683..620aff97c 100644 --- a/crates/horizon-client/src/game/remote_vehicles.rs +++ b/crates/horizon-client/src/game/remote_vehicles.rs @@ -9,8 +9,8 @@ //! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`, //! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). A minecart moves on //! the server with the rider's input, and the rider sits in it. -use super::{Game, Keys, view_vector}; -use horizon_core::geometry::Vec3; +use super::{Game, Keys, ray_box, view_vector}; +use horizon_core::geometry::{Aabb, Vec3}; use horizon_protocol::packets::play::{self, Serverbound as S}; /// An object under the crosshair: its entity id, how far along the view it @@ -24,8 +24,8 @@ pub(super) struct ObjectHit { impl Game<'_> { /// The nearest boat, minecart, item frame, armor stand or painting the - /// view hits within reach. - pub(super) fn remote_object_hit(&self) -> Option { + /// view hits within reach; also End crystals when attacking. + pub(super) fn remote_object_hit(&self, attacking: bool) -> Option { let eye = self.eye_position()?; let dir = view_vector(self.yaw, self.pitch); let at = |d: f64| { @@ -64,6 +64,22 @@ impl Game<'_> { let p = self.carts[i].position; hits.push((self.carts[i].id as i32, d, [p.x, p.y, p.z])); } + if attacking { + // EndCrystal.isPickable: its 2 x 2 box, with the server's entity + // id, not special_targets' local projectile target id. + // Entity.interact passes on a crystal, so right clicks use the item. + let reach = if self.creative { 5.0 } else { 3.0 }; + for c in self.crystals.iter().filter(|c| c.id != 0) { + let p = c.position; + let bounds = Aabb::new( + Vec3::new(p.x - 1.0, p.y, p.z - 1.0), + Vec3::new(p.x + 1.0, p.y + 2.0, p.z + 1.0), + ); + if let Some(d) = ray_box(eye, dir, reach, bounds) { + hits.push((c.id as i32, d, [p.x, p.y, p.z])); + } + } + } let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?; let point = at(distance); Some(ObjectHit { diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index 331db8833..e013c8d29 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -100,7 +100,10 @@ impl Game<'_> { skybox: self.dim == Dim::Overworld, blindness: None, darkness: self.darkness_fog(), - boss_world_fog: self.dragon_bar_shown(), + darken_world: self + .boss_overlay + .darken_amount(self.clock.fraction() as f32), + boss_world_fog: self.boss_overlay.should_create_world_fog(), }, ) } diff --git a/crates/horizon-client/src/game/scoreboard.rs b/crates/horizon-client/src/game/scoreboard.rs index 4905fb80f..227a09a6c 100644 --- a/crates/horizon-client/src/game/scoreboard.rs +++ b/crates/horizon-client/src/game/scoreboard.rs @@ -180,7 +180,7 @@ impl Game<'_> { | Target::Cart(id) | Target::Other(id) => self.persist.raw.get(&id), Target::Player => self.level.player_extra.as_ref(), - Target::Guest(_) | Target::Orb(_) | Target::Arrow(_) => None, + Target::Guest(_) | Target::Orb(_) | Target::Projectile(_) => None, }; match raw.and_then(|r| r.get("Tags")) { Some(Tag::List(l)) => l diff --git a/crates/horizon-client/src/game/shader_packs.rs b/crates/horizon-client/src/game/shader_packs.rs new file mode 100644 index 000000000..04af297b6 --- /dev/null +++ b/crates/horizon-client/src/game/shader_packs.rs @@ -0,0 +1,84 @@ +//! Shader packs, for development until the Shader Packs page exists: `HORIZON_SHADER_PACK=` +//! runs `shaderpacks/` (a folder or a zip beside `options.txt`) over the world. The +//! programs of the current dimension's folder are loaded when the dimension changes (spec 1.2). + +use horizon_client::camera::CameraFluid; +use horizon_client::gfx::pack_uniforms::PackInputs; +use horizon_shaders::{Macros, Pack, Properties, dimension_folder, load_post}; + +use super::{Dim, Game}; + +impl Game<'_> { + /// Loads the pack for the current dimension when it changed, and returns this frame's + /// inputs to the pack's uniforms. + pub(super) fn shader_pack_inputs(&mut self, camera: glam::DVec3, partial: f32) -> PackInputs { + let Some(name) = std::env::var_os("HORIZON_SHADER_PACK") else { + return PackInputs::default(); + }; + let dimension = match self.dim { + Dim::Overworld => 0, + Dim::Nether => -1, + Dim::End => 1, + }; + if let Some(renderer) = &mut self.renderer + && renderer.shader_pack_dimension() != Some(dimension) + { + let name = name.to_string_lossy(); + let started = std::time::Instant::now(); + let path = std::env::current_dir() + .unwrap_or_default() + .join("shaderpacks") + .join(&*name); + match Pack::open(&path) { + Ok(pack) => { + let macros = Macros::standard(); + let text = pack.read_text("shaders.properties").unwrap_or_default(); + let properties = Properties::parse(&text, ¯os); + for note in &properties.notes { + log::warn!( + "shader pack {name}: shaders.properties:{}: {}", + note.line, + note.message + ); + } + let folder = dimension_folder(&pack, dimension); + let post = load_post(&pack, &folder, &properties, ¯os, None); + if let Some((programs, notes)) = renderer.set_shader_pack(dimension, Some(post)) + { + for note in notes { + log::warn!("shader pack {name}: {note}"); + } + log::info!( + "shader pack {name}: folder `{folder}`, {} programs ({}) in {} ms", + programs.len(), + programs.join(" "), + started.elapsed().as_millis() + ); + } + } + Err(e) => { + log::warn!("shader pack {name}: {e}"); + renderer.set_shader_pack(dimension, None); + } + } + } + let lerp = |(old, new): (f32, f32)| old + (new - old) * partial; + let at = camera.floor().as_ivec3(); + let light = self.world.light(at.x, at.y, at.z); + PackInputs { + day_time: self.world.day_time, + rain: lerp(self.world.weather.rain_level), + thunder: lerp(self.world.weather.thunder_level), + eye_in: match self.camera_fluid(camera.to_array()) { + CameraFluid::None => 0, + CameraFluid::Water => 1, + CameraFluid::Lava => 2, + CameraFluid::PowderSnow => 3, + }, + blindness: f32::from(u8::from(self.potions.effects.get("blindness").is_some())), + eye_light: [i32::from(light & 15), i32::from(light >> 4)], + hide_gui: self.ui.hide_gui, + brightness: self.settings.gamma, + } + } +} diff --git a/crates/horizon-client/src/game/wither.rs b/crates/horizon-client/src/game/wither.rs index 7b86dc527..91fe91b00 100644 --- a/crates/horizon-client/src/game/wither.rs +++ b/crates/horizon-client/src/game/wither.rs @@ -2,10 +2,8 @@ //! its boss bar, and its skulls hitting and exploding. use super::Game; use horizon_client::{ - hud_overlays, mobs::{Damage, DamageKind, Species, Who}, projectiles::{Projectile, ProjectileKind}, - text::Text, }; impl Game<'_> { @@ -78,45 +76,6 @@ impl Game<'_> { } Ok(()) } - /// The wither boss bars (purple), within range. - pub(super) fn wither_bars(&self, bars: &mut Vec) { - let Some(p) = self.player.as_ref() else { - return; - }; - for m in self - .mobs - .iter() - .filter(|m| m.species() == Species::Wither && m.alive()) - { - if m.position.distance_squared(p.position) > 64.0 * 64.0 { - continue; - } - let progress = if m.extra.counter > 0 { - 1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32 - } else { - (m.health / m.attrs.max_health).clamp(0.0, 1.0) - }; - let name = m - .custom_name - .clone() - .unwrap_or_else(|| self.translate("entity.minecraft.wither", "Wither")); - bars.push(hud_overlays::BossBar { - name: Text::literal(name), - progress, - color: 5, - overlay: 0, - }); - } - } - pub(super) fn wither_near(&self) -> bool { - self.player.as_ref().is_some_and(|p| { - self.mobs.iter().any(|m| { - m.species() == Species::Wither - && m.alive() - && m.position.distance_squared(p.position) <= 64.0 * 64.0 - }) - }) - } /// `WitherSkull.onHitEntity` / `onHit`: 8 damage (healing the wither 5 /// when it kills), withering for 10 seconds, then a power 1 blast. pub(super) fn wither_skull_hit( diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index a848d36a5..31853ad92 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -68,6 +68,9 @@ mod flat; pub mod lightning; mod lod; mod occlusion; +mod pack_atlas; +mod pack_post; +pub mod pack_uniforms; mod page_runs; pub mod panorama; mod resident; @@ -87,6 +90,7 @@ fn world_pass_after_culling<'e>( encoder: &'e mut wgpu::CommandEncoder, view: &wgpu::TextureView, depth: &wgpu::TextureView, + keep_depth: bool, ) -> wgpu::RenderPass<'e> { encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Horizon world, after culling"), @@ -103,7 +107,12 @@ fn world_pass_after_culling<'e>( view: depth, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, - store: wgpu::StoreOp::Discard, + // A shader pack reads it as depthtex0. + store: if keep_depth { + wgpu::StoreOp::Store + } else { + wgpu::StoreOp::Discard + }, }), stencil_ops: None, }), @@ -249,6 +258,8 @@ pub struct Frame<'a> { pub hand_fov_degrees: f32, /// Full-screen camera effects drawn over the world and hand, under the HUD. pub screen: &'a [crate::screen_effects::ScreenQuad], + /// What a shader pack's uniforms need from the game. + pub pack: pack_uniforms::PackInputs, /// The world border wall, when near enough to be drawn. pub border: Option, pub game_time: f32, @@ -268,6 +279,8 @@ pub struct Frame<'a> { pub night_vision: f32, /// Torch flicker added to the block light factor of 1.4. pub block_flicker: f32, + /// `lightmap.fsh` BossOverlayWorldDarkeningFactor (a wither's bar). + pub boss_darken: f32, /// `Screen.extractBlurredBackground`: blur the frame by this radius /// (`menuBackgroundBlurriness`) before the screen is drawn over it. pub menu_blur: Option, @@ -458,6 +471,10 @@ pub struct Renderer { occlusion: occlusion::Occlusion, /// Resolution scale and FXAA (Horizon settings). world_post: world_post::Post, + /// The shader pack's post-processing, and the dimension (legacy id) it was loaded for. + pack_post: Option<(i32, Option)>, + /// The block sprites as one atlas, while a shader pack is loaded. + pack_atlas: Option, pub hud: HudRenderer, /// Vanilla's menu background blur, made on first use. menu_blur: Option, @@ -1306,6 +1323,8 @@ impl Renderer { format: wgpu::TextureFormat::Rgba8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST + // Shader packs' atlas copies them (pack_atlas.rs). + | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::STORAGE_BINDING, view_formats: &[], }, @@ -2303,6 +2322,8 @@ impl Renderer { block_entity_revision: next_block_entity_revision(), occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16), world_post, + pack_post: None, + pack_atlas: None, hud, menu_blur: None, info, @@ -2733,6 +2754,51 @@ impl Renderer { self.world_post.scale = scale.clamp(0.25, 2.0); self.world_post.antialias = antialias; } + /// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`. + /// Returns the programs that run and the notes of loading them. + pub fn set_shader_pack( + &mut self, + dimension: i32, + pack: Option, + ) -> Option<(Vec, Vec)> { + let post = pack.map(|post| { + pack_post::PackPost::new( + &self.device, + &self.queue, + post, + self.config.format.remove_srgb_suffix(), + ) + }); + if post.is_none() { + self.pack_atlas = None; + } else if self.pack_atlas.is_none() { + let mut encoder = self.device.create_command_encoder(&Default::default()); + let layers = self + .sprite_textures + .iter() + .map(|t| t.depth_or_array_layers()) + .sum(); + self.pack_atlas = pack_atlas::PackAtlas::new( + &self.device, + &mut encoder, + &self.sprite_textures, + sprite_split(), + layers, + self.sprite_textures[0].width(), + ); + self.queue.submit([encoder.finish()]); + } + let report = post.as_ref().map(|p| { + let programs = p.programs().into_iter().map(str::to_owned).collect(); + (programs, p.notes.clone()) + }); + self.pack_post = Some((dimension, post)); + report + } + /// The dimension the shader pack was last set for. + pub fn shader_pack_dimension(&self) -> Option { + self.pack_post.as_ref().map(|p| p.0) + } /// The Mipmap Levels option: block textures sample at most `levels` /// reduced copies (0: none). pub fn set_mipmap_levels(&mut self, levels: u32) { @@ -2793,6 +2859,7 @@ impl Renderer { gamma: f32, night_vision: f32, flicker: f32, + boss_darken: f32, ) { // lightmap.fsh: per (block, sky) level. let brightness = |level: f32| level / (4.0 - 3.0 * level); @@ -2810,6 +2877,8 @@ impl Renderer { let parabolic = (2.0 * block_level - 1.0).powi(2); let block_color = Vec3::from(sky.block_light_tint).lerp(Vec3::ONE, 0.9 * parabolic); color += block_color * block_brightness; + // lightmap.fsh: a boss bar mixes the light toward 70/60/60%. + color = color.lerp(color * Vec3::new(0.7, 0.6, 0.6), boss_darken); color -= Vec3::splat(darkness); color = color.clamp(Vec3::ZERO, Vec3::ONE); let max = color.max_element().max(1e-6); @@ -2849,6 +2918,13 @@ impl Renderer { } fn animate(&mut self, encoder: &mut wgpu::CommandEncoder, tick: u64) { self.animations.animate(encoder, &mut self.uploads, tick); + if let Some(atlas) = &self.pack_atlas { + atlas.copy( + encoder, + &self.sprite_textures, + self.animations.changed().iter().copied(), + ); + } } pub fn render(&mut self, frame: &Frame) -> Result { let rendered = self.draw_frame(frame); @@ -2924,6 +3000,7 @@ impl Renderer { (self.gamma - frame.darkness.0).max(0.0), frame.night_vision, frame.block_flicker, + frame.boss_darken, ); let view_proj = self.view_projection(frame); if frame.title { @@ -3594,9 +3671,12 @@ impl Renderer { timer.begin(&mut encoder); } // The world's targets: the screen, or the scaled target of `post`. - let scaled = self - .world_post - .prepare(&self.device, [self.config.width, self.config.height]); + let pack_post = !frame.title && self.pack_post.as_ref().is_some_and(|p| p.1.is_some()); + let scaled = self.world_post.prepare( + &self.device, + [self.config.width, self.config.height], + pack_post, + ); let (world_view, world_depth) = match &scaled { Some((color, depth)) => (color, depth), None => (&view_target, &self.depth), @@ -3633,8 +3713,8 @@ impl Renderer { view: world_depth, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0.0), - // Phase 2 of occlusion culling draws over it. - store: if cull.is_some() { + // Phase 2 of occlusion culling draws over it; a shader pack reads it. + store: if cull.is_some() || pack_post { wgpu::StoreOp::Store } else { wgpu::StoreOp::Discard @@ -3693,7 +3773,12 @@ impl Renderer { drop(pass); cull.test_pass(&mut encoder); flat.flatten_phase2(&mut encoder); - pass = world_pass_after_culling(&mut encoder, world_view, world_depth); + pass = world_pass_after_culling( + &mut encoder, + world_view, + world_depth, + pack_post, + ); pass.set_bind_group(0, &self.group, &[]); pass.set_pipeline(&flat.solid); flat.draw_phase2(&mut pass, vertices); @@ -3717,7 +3802,12 @@ impl Renderer { cull.test(&mut pass); drop(pass); cull.test_pass(&mut encoder); - pass = world_pass_after_culling(&mut encoder, world_view, world_depth); + pass = world_pass_after_culling( + &mut encoder, + world_view, + world_depth, + pack_post, + ); pass.set_bind_group(0, &self.group, &[]); pass.set_pipeline(&self.solid); pass.set_index_buffer( @@ -3876,7 +3966,24 @@ impl Renderer { ); } } - if scaled.is_some() { + if let Some((color, depth)) = &scaled + && pack_post + && let Some((_, Some(post))) = &mut self.pack_post + { + let size = self.world_post.size().unwrap_or([1, 1]); + post.run( + &self.device, + &self.queue, + &mut encoder, + color, + depth, + &view_target, + frame, + &frame.pack, + size, + [self.config.width, self.config.height], + ); + } else if scaled.is_some() { self.world_post.finish(&mut encoder, &view_target); } if hand_quads > 0 { diff --git a/crates/horizon-client/src/gfx/animate.rs b/crates/horizon-client/src/gfx/animate.rs index f4528902b..b7c44429b 100644 --- a/crates/horizon-client/src/gfx/animate.rs +++ b/crates/horizon-client/src/gfx/animate.rs @@ -51,6 +51,8 @@ pub(super) struct SpriteAnimator { pipeline: wgpu::ComputePipeline, /// Edge of the arrays' layers. size: u32, + /// The layers the last `animate` rewrote, counted over all arrays. + changed: Vec, } impl SpriteAnimator { @@ -205,9 +207,15 @@ impl SpriteAnimator { jobs, pipeline, size, + changed: Vec::new(), } } + /// The layers the last `animate` rewrote (for the shader pack atlas). + pub fn changed(&self) -> &[u32] { + &self.changed + } + /// Show every sprite's frame at `tick`, rewriting only the sprites whose /// frame or mix changed (`SpriteContents.AnimationState.tick`). pub fn animate( @@ -245,12 +253,14 @@ impl SpriteAnimator { /// its layer shows; the number of jobs. fn collect(&mut self, tick: u64) -> usize { let mut count = 0; + self.changed.clear(); for sprite in &mut self.sprites { let blend = sprite.animation.blend(tick); if sprite.shown == Some(blend) { continue; } sprite.shown = Some(blend); + self.changed.push(sprite.animation.layer); let job = [ sprite.layer, sprite.frames[blend.frame], diff --git a/crates/horizon-client/src/gfx/pack_atlas.rs b/crates/horizon-client/src/gfx/pack_atlas.rs new file mode 100644 index 000000000..c90fa6ac1 --- /dev/null +++ b/crates/horizon-client/src/gfx/pack_atlas.rs @@ -0,0 +1,197 @@ +//! The block atlas of shader packs (spec 5.1, `atlasSize` in 5.2): packs sample one 2D atlas +//! with atlas coordinates, Horizon keeps block sprites in texture arrays. With a pack, every +//! layer is copied into a grid, row by row from the top left, and an animated layer again +//! whenever its frame changes. + +#[expect(dead_code, reason = "the gbuffers pass binds it (next slice)")] +pub(super) struct PackAtlas { + texture: wgpu::Texture, + pub view: wgpu::TextureView, + /// Sprites per row. + pub columns: u32, + /// A sprite's edge in pixels, at the first level. + pub sprite: u32, + /// Layers per sprite array. + split: u32, +} + +impl PackAtlas { + /// Copies `layers` layers of `arrays` (`split` per array, `sprite` pixels wide, `levels` + /// mip levels); `None` when the grid is larger than the device allows. + pub fn new( + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + arrays: &[wgpu::Texture], + split: u32, + layers: u32, + sprite: u32, + ) -> Option { + let layers = layers.max(1); + let columns = (f64::from(layers).sqrt().ceil() as u32).max(1); + let rows = layers.div_ceil(columns); + let max = device.limits().max_texture_dimension_2d; + if columns * sprite > max || rows * sprite > max { + return None; + } + // Levels past a sprite's single pixel would mix neighbours: stop there. + let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Shader pack block atlas"), + size: wgpu::Extent3d { + width: columns * sprite, + height: rows * sprite, + depth_or_array_layers: 1, + }, + mip_level_count: levels, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + let atlas = Self { + texture, + view, + columns, + sprite, + split, + }; + atlas.copy(encoder, arrays, 0..layers); + Some(atlas) + } + + /// The atlas's size in pixels (`atlasSize`). + #[cfg_attr( + not(test), + expect(dead_code, reason = "the gbuffers pass reads it (next slice)") + )] + pub fn size(&self) -> [u32; 2] { + [self.texture.width(), self.texture.height()] + } + + /// Copies `layers` from the sprite arrays again, every level. + pub fn copy( + &self, + encoder: &mut wgpu::CommandEncoder, + arrays: &[wgpu::Texture], + layers: impl IntoIterator, + ) { + for layer in layers { + let Some(array) = arrays.get((layer / self.split) as usize) else { + continue; + }; + let (column, row) = (layer % self.columns, layer / self.columns); + for level in 0..self.texture.mip_level_count() { + let edge = self.sprite >> level; + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: array, + mip_level: level, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: layer % self.split, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: &self.texture, + mip_level: level, + origin: wgpu::Origin3d { + x: column * edge, + y: row * edge, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: edge, + height: edge, + depth_or_array_layers: 1, + }, + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Two arrays of 2x2 sprites, one layer split; each layer a colour of its own. + #[test] + fn layers_land_in_their_cells() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else { + return; + }; + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let (split, layers, sprite) = (3u32, 5u32, 2u32); + let arrays: Vec = (0..2) + .map(|a| { + let count = (layers - a * split).min(split); + let pixels: Vec = (0..count) + .flat_map(|l| [(a * split + l) as u8 * 40, 0, 0, 255].repeat(4)) + .collect(); + wgpu::util::DeviceExt::create_texture_with_data( + &device, + &queue, + &wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: sprite, + height: sprite, + depth_or_array_layers: count, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::LayerMajor, + &pixels, + ) + }) + .collect(); + let mut encoder = device.create_command_encoder(&Default::default()); + let atlas = PackAtlas::new(&device, &mut encoder, &arrays, split, layers, sprite).unwrap(); + assert_eq!((atlas.columns, atlas.size()), (3, [6, 4])); + let read = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: 256 * 4, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + atlas.texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &read, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(256), + rows_per_image: None, + }, + }, + wgpu::Extent3d { + width: 6, + height: 4, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + let data = read.slice(..).get_mapped_range().unwrap(); + let red = |x: usize, y: usize| data[y * 256 + x * 4]; + // Layer 4 (second array, its second layer) is in row 1, column 1. + assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]); + assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]); + } +} diff --git a/crates/horizon-client/src/gfx/pack_post.rs b/crates/horizon-client/src/gfx/pack_post.rs new file mode 100644 index 000000000..d5aa9fb7c --- /dev/null +++ b/crates/horizon-client/src/gfx/pack_post.rs @@ -0,0 +1,929 @@ +//! A shader pack's post-processing over Horizon's own world (spec 2.1 rows 4, 6, 8, 9; 6.1; 6.2; +//! 6.4): the world renders into its own target as with a resolution scale, its colour becomes +//! colortex0 and its depth `depthtex0`, then the pack's `prepare`, `deferred`, `composite` and +//! `final` programs run in order, and `final` draws onto the screen under the hand and HUD. +//! Gbuffers programs come later; until then the pack's composites read Horizon's picture. +//! +//! Every colortex buffer a program reads or writes has two textures, A and B. Programs read +//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). The +//! mapping carries over to the next frame, so a buffer that is not cleared reads back what was +//! written last (OQ-8). Buffers are cleared at the start of the frame, "main" only. +//! Pack buffers store rows as GL does (row 0 at the bottom: the translator negates clip y), so +//! the world is copied in upside down, and its reverse-Z depth becomes GL window depth. + +use std::collections::HashMap; + +use horizon_shaders::{Format, Group, Kind, Post}; + +use super::pack_uniforms::{self, FrameUniforms, PackInputs}; + +const BUFFERS: usize = horizon_shaders::BUFFERS; + +/// The texture behind a pack sampler name. +#[derive(Clone, Copy, PartialEq)] +enum Source { + Color(usize), + Depth, + Noise, + /// A 1×1 stand-in for names this slice does not provide yet (custom textures...). + Dummy, + /// A 1×1 depth of 1.0 behind a comparison sampler, until shadow maps exist: no shadow. + Shadow, +} + +struct Binding { + source: Source, + binding: u32, + /// A filtering sampler (the texture is filterable). + filtering: bool, +} + +struct Pass { + name: String, + group: Group, + pipeline: wgpu::RenderPipeline, + layout: wgpu::BindGroupLayout, + ubo: wgpu::Buffer, + block: Vec, + /// (index into `pack_uniforms::NAMES`, declared type, offset). + uniforms: Vec<(Option, String, u32)>, + samplers: Vec, + /// (location, buffer) of each output that reaches a buffer. + writes: Vec<(u32, usize)>, + /// Buffers flipped after the pass. + flip: u16, + /// Buffers sampled, for the bind group cache key. + reads: u16, + /// Buffers written. + written: u16, + groups: HashMap, +} + +struct Targets { + size: [u32; 2], + /// A and B of each buffer used. + color: [Option<[wgpu::TextureView; 2]>; BUFFERS], + depth: wgpu::TextureView, +} + +pub struct PackPost { + pub notes: Vec, + passes: Vec, + deferred_pre: u16, + composite_pre: u16, + formats: [wgpu::TextureFormat; BUFFERS], + clears: [Option<[f32; 4]>; BUFFERS], + used: u16, + /// Bit n set: B is buffer n's "main". + main: u16, + targets: Option, + noise: wgpu::TextureView, + dummy: wgpu::TextureView, + /// The stand-in shadow map and its comparison sampler; cleared on the first frame. + shadow: (wgpu::TextureView, wgpu::Sampler), + shadow_cleared: bool, + linear: wgpu::Sampler, + nearest: wgpu::Sampler, + repeat: wgpu::Sampler, + blit: (wgpu::RenderPipeline, wgpu::BindGroupLayout), + /// The blit also writes depthtex0 (some program reads it) and clears these buffers. + blit_depth: bool, + blit_clears: Vec, + /// Buffers cleared in passes of their own (more than the blit's eight attachments). + other_clears: Vec, + /// The blit's bindings, for the world target of this size. + blit_group: Option<([u32; 2], wgpu::BindGroup)>, + /// Shows colortex0 when the pack has no `final` (OQ-5). + copy: (wgpu::RenderPipeline, wgpu::BindGroupLayout), + uniforms: FrameUniforms, +} + +/// The wgpu format of a pack format (6.7), the nearest one wgpu can render to. +fn texture_format(f: Format, features: wgpu::Features) -> wgpu::TextureFormat { + use wgpu::TextureFormat as T; + let pick = |r, rg, rgba| match f.channels { + 1 => r, + 2 => rg, + _ => rgba, + }; + let half = pick(T::R16Float, T::Rg16Float, T::Rgba16Float); + match (f.kind, f.bits) { + (Kind::Unorm, 10) => T::Rgb10a2Unorm, + (Kind::Unorm, 16) if features.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM) => { + pick(T::R16Unorm, T::Rg16Unorm, T::Rgba16Unorm) + } + (Kind::Unorm, b) if b <= 8 => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm), + (Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float), + (Kind::Float, 11) if features.contains(wgpu::Features::RG11B10UFLOAT_RENDERABLE) => { + T::Rg11b10Ufloat + } + (Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint), + (Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint), + (Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint), + (Kind::Uint, 8) => pick(T::R8Uint, T::Rg8Uint, T::Rgba8Uint), + (Kind::Uint, 10) => T::Rgb10a2Uint, + (Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint), + (Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint), + // Snorm, 16-bit norm without the feature, RGB9_E5: half floats hold them. + _ => half, + } +} + +fn source(name: &str) -> Source { + match name { + "depthtex0" | "gdepthtex" | "depthtex1" | "depthtex2" => Source::Depth, + "noisetex" => Source::Noise, + n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color), + } +} + +/// 256×256 RGB white noise (6.4), from a fixed seed so every run looks the same. +fn noise(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView { + let mut state = 0x2545_f491_4f6c_dd1du64; + let texels: Vec = (0..256 * 256 * 4) + .map(|i| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + if i % 4 == 3 { 255 } else { (state >> 56) as u8 } + }) + .collect(); + texture(device, queue, "Shader pack noisetex", 256, &texels) +} + +fn texture( + device: &wgpu::Device, + queue: &wgpu::Queue, + label: &str, + side: u32, + texels: &[u8], +) -> wgpu::TextureView { + let size = wgpu::Extent3d { + width: side, + height: side, + depth_or_array_layers: 1, + }; + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + tex.as_image_copy(), + texels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(side * 4), + rows_per_image: None, + }, + size, + ); + tex.create_view(&Default::default()) +} + +fn sampler( + device: &wgpu::Device, + filter: wgpu::FilterMode, + address: wgpu::AddressMode, +) -> wgpu::Sampler { + device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Shader pack"), + address_mode_u: address, + address_mode_v: address, + address_mode_w: address, + mag_filter: filter, + min_filter: filter, + ..Default::default() + }) +} + +/// Runs `create` and returns its validation error, if any. +fn checked(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result { + let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + let value = create(); + match pollster::block_on(scope.pop()) { + Some(e) => { + // The details are in the error's sources. + let mut message = e.to_string(); + let mut source = std::error::Error::source(&e); + while let Some(s) = source { + message = format!("{message}: {s}"); + source = s.source(); + } + Err(message) + } + None => Ok(value), + } +} + +/// A full-screen helper pipeline of ours (`pack_post.wgsl`). +fn helper( + device: &wgpu::Device, + vertex: &str, + entry: &str, + entries: &[wgpu::BindGroupLayoutEntry], + targets: &[Option], +) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout) { + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Shader pack helpers"), + source: wgpu::ShaderSource::Wgsl(include_str!("pack_post.wgsl").into()), + }); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(entry), + entries, + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(entry), + bind_group_layouts: &[Some(&layout)], + ..Default::default() + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(entry), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some(vertex), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some(entry), + compilation_options: Default::default(), + targets, + }), + multiview_mask: None, + cache: None, + }); + (pipeline, layout) +} + +fn entry(binding: u32, ty: wgpu::BindingType) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty, + count: None, + } +} + +fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry { + entry( + binding, + wgpu::BindingType::Texture { + sample_type, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + ) +} + +fn target(format: wgpu::TextureFormat) -> Option { + Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }) +} + +fn attach( + view: &wgpu::TextureView, + ops: wgpu::Operations, +) -> Option> { + Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops, + }) +} + +impl PackPost { + /// Builds the pipelines of `post` for a screen of `screen` format. + pub fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + post: Post, + screen: wgpu::TextureFormat, + ) -> Self { + let features = device.features(); + let formats: [wgpu::TextureFormat; BUFFERS] = + std::array::from_fn(|i| texture_format(post.config.buffers[i].format, features)); + let clears = std::array::from_fn(|i| { + let b = &post.config.buffers[i]; + b.clear.then(|| { + b.clear_color + .unwrap_or(if i == 1 { [1.0; 4] } else { [0.0; 4] }) + }) + }); + let mut notes = post.notes; + notes.extend(post.config.notes); + let mask = + |flips: &[(u32, bool)]| flips.iter().filter(|f| f.1).fold(0u16, |m, f| m | 1 << f.0); + let mut used = 1u16; + let mut passes = Vec::new(); + for p in post.programs { + let interface = &p.program.interface; + let mut entries = vec![entry( + 0, + wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + )]; + let mut samplers = Vec::new(); + let mut reads = 0u16; + for s in &interface.samplers { + let mut src = source(&s.name); + let float = wgpu::TextureSampleType::Float { filterable: true }; + let mut sample_type = match src { + Source::Color(n) => formats[n] + .sample_type(None, Some(features)) + .unwrap_or(float), + Source::Depth => wgpu::TextureSampleType::Float { filterable: false }, + _ => float, + }; + let int = !matches!(sample_type, wgpu::TextureSampleType::Float { .. }); + if s.ty == "sampler2DShadow" { + (src, sample_type) = (Source::Shadow, wgpu::TextureSampleType::Depth); + } + let wanted = match s.ty.as_str() { + "sampler2DShadow" => true, + "sampler2D" => !int, + "isampler2D" => sample_type == wgpu::TextureSampleType::Sint, + "usampler2D" => sample_type == wgpu::TextureSampleType::Uint, + _ => false, + }; + if !wanted { + notes.push(format!( + "{}: `{} {}` is not provided yet", + p.name, s.ty, s.name + )); + (src, sample_type) = (Source::Dummy, float); + } + let filtering = sample_type == float; + if let Source::Color(n) = src { + reads |= 1 << n; + } + entries.push(texture_entry(s.binding, sample_type)); + entries.push(entry( + s.binding + 1, + wgpu::BindingType::Sampler(if src == Source::Shadow { + wgpu::SamplerBindingType::Comparison + } else if filtering { + wgpu::SamplerBindingType::Filtering + } else { + wgpu::SamplerBindingType::NonFiltering + }), + )); + samplers.push(Binding { + source: src, + binding: s.binding, + filtering, + }); + } + let final_pass = p.group == Group::Final; + let mut targets = Vec::new(); + let mut writes = Vec::new(); + for &location in &interface.outputs { + let l = location as usize; + if targets.len() <= l { + targets.resize(l + 1, None); + } + if final_pass { + if location == 0 { + targets[0] = target(screen); + } + } else if let Some(&buffer) = p.buffers.get(l) { + targets[l] = target(formats[buffer as usize]); + writes.push((location, buffer as usize)); + } + } + if !final_pass && writes.is_empty() { + notes.push(format!("{}: writes no buffer; skipped", p.name)); + continue; + } + let written = writes.iter().fold(0u16, |m, w| m | 1 << w.1); + let kept = p + .flips + .iter() + .filter(|f| !f.1) + .fold(0u16, |m, f| m | 1 << f.0); + let flip = if final_pass { + 0 + } else { + (written & !kept) | mask(&p.flips) + }; + used |= reads | written | flip; + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(&p.name), + entries: &entries, + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(&p.name), + bind_group_layouts: &[Some(&layout)], + ..Default::default() + }); + let created = checked(device, || { + let module = |m: naga::Module| { + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(&p.name), + source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)), + }) + }; + let (vs, fs) = (module(p.program.vertex), module(p.program.fragment)); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(&p.name), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &vs, + entry_point: Some("main"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &fs, + entry_point: Some("main"), + compilation_options: Default::default(), + targets: &targets, + }), + multiview_mask: None, + cache: None, + }) + }); + let pipeline = match created { + Ok(p) => p, + Err(e) => { + notes.push(format!("{}: skipped: {e}", p.name)); + continue; + } + }; + let interface = &p.program.interface; + passes.push(Pass { + ubo: device.create_buffer(&wgpu::BufferDescriptor { + label: Some(&p.name), + size: u64::from(interface.uniform_size.max(16)), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + block: vec![0; interface.uniform_size as usize], + uniforms: interface + .uniforms + .iter() + .map(|u| (pack_uniforms::index(&u.name), u.ty.clone(), u.offset)) + .collect(), + name: p.name, + group: p.group, + pipeline, + layout, + samplers, + writes, + flip, + reads, + written, + groups: HashMap::new(), + }); + } + // A clear only matters when a program reads the buffer before one writes all of it + // (composite passes cover their whole target); the others are skipped. + let mut cleared = Vec::new(); + for b in 1..BUFFERS { + let bit = 1u16 << b; + let first = passes + .iter() + .find(|p| (p.reads | p.flip | p.written) & bit != 0); + if clears[b].is_some() + && first.is_some_and(|p| p.reads & bit != 0 || p.written & bit == 0) + { + cleared.push(b); + } + } + let blit_depth = passes + .iter() + .any(|p| p.samplers.iter().any(|s| s.source == Source::Depth)); + let room = 8 - 1 - usize::from(blit_depth); + let other_clears = cleared.split_off(room.min(cleared.len())); + let float = wgpu::TextureSampleType::Float { filterable: true }; + let mut blit_targets = vec![target(formats[0])]; + if blit_depth { + blit_targets.push(target(wgpu::TextureFormat::R32Float)); + } + // Cleared by the pass's load operation; the shader writes nothing there. + blit_targets.extend(cleared.iter().map(|&b| { + Some(wgpu::ColorTargetState { + format: formats[b], + blend: None, + write_mask: wgpu::ColorWrites::empty(), + }) + })); + let blit = helper( + device, + "vs_full", + if blit_depth { + "fs_blit" + } else { + "fs_blit_color" + }, + &[ + texture_entry(0, float), + texture_entry(1, wgpu::TextureSampleType::Depth), + ], + &blit_targets, + ); + let copy = helper( + device, + "vs_uv", + "fs_copy", + &[ + texture_entry(0, float), + entry( + 2, + wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + ), + ], + &[target(screen)], + ); + let white = [255u8; 4]; + Self { + notes, + passes, + deferred_pre: mask(&post.deferred_pre), + composite_pre: mask(&post.composite_pre), + formats, + clears, + used, + blit_depth, + blit_clears: cleared, + other_clears, + blit_group: None, + main: 0, + targets: None, + noise: noise(device, queue), + dummy: texture(device, queue, "Shader pack stand-in", 1, &white), + shadow: ( + device + .create_texture(&wgpu::TextureDescriptor { + label: Some("Shader pack shadow stand-in"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }) + .create_view(&Default::default()), + device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Shader pack shadow"), + compare: Some(wgpu::CompareFunction::LessEqual), + ..Default::default() + }), + ), + shadow_cleared: false, + linear: sampler( + device, + wgpu::FilterMode::Linear, + wgpu::AddressMode::ClampToEdge, + ), + nearest: sampler( + device, + wgpu::FilterMode::Nearest, + wgpu::AddressMode::ClampToEdge, + ), + repeat: sampler(device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat), + blit, + copy, + uniforms: FrameUniforms::default(), + } + } + + /// The names of the programs that run, in order. + pub fn programs(&self) -> Vec<&str> { + self.passes.iter().map(|p| p.name.as_str()).collect() + } + + fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) { + if self.targets.as_ref().is_some_and(|t| t.size == size) { + return; + } + let make = |label: &str, format| { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&Default::default()) + }; + let color = std::array::from_fn(|i| { + (self.used & 1 << i != 0).then(|| { + let label = format!("colortex{i}"); + [make(&label, self.formats[i]), make(&label, self.formats[i])] + }) + }); + self.targets = Some(Targets { + size, + color, + depth: make("depthtex0", wgpu::TextureFormat::R32Float), + }); + for pass in &mut self.passes { + pass.groups.clear(); + } + } + + /// Runs the pack over the world drawn into `world` (colour) and `world_depth`, then draws + /// the result onto `screen`. + #[allow(clippy::too_many_arguments)] + pub fn run( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + world: &wgpu::TextureView, + world_depth: &wgpu::TextureView, + screen: &wgpu::TextureView, + frame: &super::Frame, + inputs: &PackInputs, + size: [u32; 2], + window: [u32; 2], + ) { + self.resize(device, size); + self.uniforms.update(frame, inputs, size); + let Some(targets) = &self.targets else { return }; + let main = |m: u16, i: usize| usize::from(m & 1 << i != 0); + let view = |m: u16, i: usize, alt: bool| { + targets.color[i] + .as_ref() + .map(|t| &t[main(m, i) ^ usize::from(alt)]) + }; + + if !self.shadow_cleared { + self.shadow_cleared = true; + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Shader pack shadow stand-in"), + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.shadow.0, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + ..Default::default() + }); + } + let clear_op = |b: usize| { + let c = self.clears[b].unwrap_or_default(); + wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: c[0].into(), + g: c[1].into(), + b: c[2].into(), + a: c[3].into(), + }), + store: wgpu::StoreOp::Store, + } + }; + // Overwritten whole: clearing lets a tiled GPU skip loading the old content. + let overwrite = wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }; + for chunk in self.other_clears.chunks(8) { + let attachments: Vec<_> = chunk + .iter() + .map(|&b| attach(view(self.main, b, false).expect("used buffer"), clear_op(b))) + .collect(); + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Shader pack clear"), + color_attachments: &attachments, + ..Default::default() + }); + } + + // The world into colortex0 and depthtex0, flipped to GL's rows, and the clears. + if self.blit_group.as_ref().is_none_or(|g| g.0 != size) { + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Shader pack world"), + layout: &self.blit.1, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(world), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(world_depth), + }, + ], + }); + self.blit_group = Some((size, group)); + } + { + let mut attachments = vec![attach( + view(self.main, 0, false).expect("colortex0"), + overwrite, + )]; + if self.blit_depth { + attachments.push(attach(&targets.depth, overwrite)); + } + for &b in &self.blit_clears { + attachments.push(attach( + view(self.main, b, false).expect("used buffer"), + clear_op(b), + )); + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Shader pack world"), + color_attachments: &attachments, + ..Default::default() + }); + pass.set_pipeline(&self.blit.0); + pass.set_bind_group(0, self.blit_group.as_ref().map(|g| &g.1), &[]); + pass.draw(0..3, 0..1); + } + + let fog = self.uniforms.fog(); + let mut seen = (false, false); + let mut shown = false; + for pass in &mut self.passes { + if pass.group == Group::Deferred && !seen.0 { + seen.0 = true; + self.main ^= self.deferred_pre; + } + if pass.group == Group::Composite && !seen.1 { + seen.1 = true; + self.main ^= self.composite_pre; + } + let final_pass = pass.group == Group::Final; + let out = if final_pass { window } else { size }; + // Our block members first (`hz_target`, `hz_fog_color`, `hz_fog`), then the pack's. + let ours = [ + [ + out[0] as f32, + out[1] as f32, + 1.0 / out[0] as f32, + 1.0 / out[1] as f32, + ], + fog[0].to_array(), + fog[1].to_array(), + ]; + let head: &[u8] = bytemuck::cast_slice(&ours); + let n = head.len().min(pass.block.len()); + pass.block[..n].copy_from_slice(&head[..n]); + for (index, ty, offset) in &pass.uniforms { + if let Some(v) = index.and_then(|i| self.uniforms.values.get(i)) { + v.write(ty, &mut pass.block, *offset as usize); + } + } + if !pass.block.is_empty() { + queue.write_buffer(&pass.ubo, 0, &pass.block); + } + let key = self.main & pass.reads; + let group = pass.groups.entry(key).or_insert_with(|| { + let mut entries = vec![wgpu::BindGroupEntry { + binding: 0, + resource: pass.ubo.as_entire_binding(), + }]; + for s in &pass.samplers { + let filter = if s.filtering { + &self.linear + } else { + &self.nearest + }; + let (texture, sampler) = match s.source { + Source::Color(i) => (view(key, i, false).unwrap_or(&self.dummy), filter), + Source::Depth => (&targets.depth, &self.nearest), + Source::Noise => (&self.noise, &self.repeat), + Source::Dummy => (&self.dummy, &self.linear), + Source::Shadow => (&self.shadow.0, &self.shadow.1), + }; + entries.push(wgpu::BindGroupEntry { + binding: s.binding, + resource: wgpu::BindingResource::TextureView(texture), + }); + entries.push(wgpu::BindGroupEntry { + binding: s.binding + 1, + resource: wgpu::BindingResource::Sampler(sampler), + }); + } + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(&pass.name), + layout: &pass.layout, + entries: &entries, + }) + }); + let mut attachments: Vec> = Vec::new(); + if final_pass { + attachments.push(attach(screen, overwrite)); + shown = true; + } else { + for &(location, buffer) in &pass.writes { + let l = location as usize; + if attachments.len() <= l { + attachments.resize(l + 1, None); + } + attachments[l] = + view(self.main, buffer, true).and_then(|v| attach(v, overwrite)); + } + } + { + let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(&pass.name), + color_attachments: &attachments, + ..Default::default() + }); + rp.set_pipeline(&pass.pipeline); + rp.set_bind_group(0, &*group, &[]); + rp.draw(0..6, 0..1); + } + self.main ^= pass.flip; + } + if !shown { + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Shader pack colortex0"), + layout: &self.copy.1, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView( + view(self.main, 0, false).expect("colortex0"), + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.linear), + }, + ], + }); + let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Shader pack colortex0"), + color_attachments: &[attach(screen, overwrite)], + ..Default::default() + }); + rp.set_pipeline(&self.copy.0); + rp.set_bind_group(0, &group, &[]); + rp.draw(0..3, 0..1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wgpu::TextureFormat as T; + + #[test] + fn pack_formats_map_to_renderable_ones() { + let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty()); + assert_eq!(f("RGBA"), T::Rgba8Unorm); + assert_eq!(f("RGB8"), T::Rgba8Unorm, "no three-channel formats in wgpu"); + assert_eq!(f("RG16F"), T::Rg16Float); + assert_eq!(f("R32F"), T::R32Float); + assert_eq!(f("RGB10_A2"), T::Rgb10a2Unorm); + assert_eq!(f("R32UI"), T::R32Uint); + assert_eq!(f("RGBA16"), T::Rgba16Float, "16-bit norm needs a feature"); + assert_eq!(f("RGBA8_SNORM"), T::Rgba16Float, "snorm is not renderable"); + assert_eq!(f("R11F_G11F_B10F"), T::Rgba16Float); + let all = + wgpu::Features::TEXTURE_FORMAT_16BIT_NORM | wgpu::Features::RG11B10UFLOAT_RENDERABLE; + let g = |name| texture_format(Format::parse(name).unwrap(), all); + assert_eq!(g("RGBA16"), T::Rgba16Unorm); + assert_eq!(g("R11F_G11F_B10F"), T::Rg11b10Ufloat); + } + + #[test] + fn sampler_names() { + assert!(source("gaux1") == Source::Color(4)); + assert!(source("gdepthtex") == Source::Depth); + assert!(source("noisetex") == Source::Noise); + assert!(source("shadowcolor0") == Source::Dummy); + } +} diff --git a/crates/horizon-client/src/gfx/pack_post.wgsl b/crates/horizon-client/src/gfx/pack_post.wgsl new file mode 100644 index 000000000..30345b00f --- /dev/null +++ b/crates/horizon-client/src/gfx/pack_post.wgsl @@ -0,0 +1,54 @@ +// Helpers of a shader pack's post-processing (pack_post.rs): the world into colortex0 and +// depthtex0, and colortex0 onto the screen for packs without `final`. + +@vertex +fn vs_full(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { + let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u)); + return vec4(uv * 2.0 - 1.0, 0.0, 1.0); +} + +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var world_depth: texture_depth_2d; + +struct World { + @location(0) color: vec4, + @location(1) depth: vec4, +} + +// Pack buffers keep GL's rows (row 0 at the bottom), and GL's window depth: Horizon's +// reverse-Z depth is 1 - d for the same near and far planes. +@fragment +fn fs_blit(@builtin(position) p: vec4) -> World { + let size = vec2(textureDimensions(source)); + let at = vec2(i32(p.x), size.y - 1 - i32(p.y)); + var out: World; + out.color = textureLoad(source, at, 0); + out.depth = vec4(1.0 - textureLoad(world_depth, at, 0), 0.0, 0.0, 1.0); + return out; +} + +// The same without depthtex0, for packs that never read it. +@fragment +fn fs_blit_color(@builtin(position) p: vec4) -> @location(0) vec4 { + let size = vec2(textureDimensions(source)); + return textureLoad(source, vec2(i32(p.x), size.y - 1 - i32(p.y)), 0); +} + +@group(0) @binding(2) var linear: sampler; + +struct Full { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_uv(@builtin(vertex_index) i: u32) -> Full { + let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u)); + // Screen top (uv.y = 0 in wgpu) shows the buffer's top row, GL's last. + return Full(vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), vec2(uv.x, 1.0 - uv.y)); +} + +@fragment +fn fs_copy(v: Full) -> @location(0) vec4 { + return textureSample(source, linear, v.uv); +} diff --git a/crates/horizon-client/src/gfx/pack_uniforms.rs b/crates/horizon-client/src/gfx/pack_uniforms.rs new file mode 100644 index 000000000..7d2a73a6a --- /dev/null +++ b/crates/horizon-client/src/gfx/pack_uniforms.rs @@ -0,0 +1,329 @@ +//! The uniforms a shader pack's post-processing programs read once per frame (spec 5.2, class +//! F), from the renderer's `Frame` and the few values only the game knows (`PackInputs`). +//! Matrices follow GL's conventions (5.5): `gbufferProjection` maps view depth to clip z in +//! [-w, w], and the depth buffers packs read hold GL window depth. + +use glam::{Mat4, Vec3, Vec4}; + +use super::Frame; + +/// What the game passes for the pack's uniforms, beyond what `Frame` carries. +#[derive(Clone, Copy, Debug, Default)] +pub struct PackInputs { + /// `Level.getDayTime`. + pub day_time: i64, + /// The interpolated rain and thunder levels, 0..1. + pub rain: f32, + pub thunder: f32, + /// The fluid at the camera: 0 none, 1 water, 2 lava, 3 powder snow. + pub eye_in: i32, + pub blindness: f32, + /// Block and sky light at the camera, 0..15. + pub eye_light: [i32; 2], + pub hide_gui: bool, + /// The brightness option, 0..1. + pub brightness: f32, +} + +/// One uniform's value: up to 16 floats or 3 integers. +#[derive(Clone, Copy, Debug)] +pub enum Value { + F([f32; 16]), + I([i32; 3]), +} + +impl Value { + fn f(v: &[f32]) -> Self { + let mut a = [0.0; 16]; + a[..v.len()].copy_from_slice(v); + Value::F(a) + } + fn i(v: &[i32]) -> Self { + let mut a = [0; 3]; + a[..v.len()].copy_from_slice(v); + Value::I(a) + } + + /// Writes the value as the type the pack declared, at `offset` of a std140 block. + pub fn write(&self, ty: &str, out: &mut [u8], offset: usize) { + let ints = matches!(ty, "int" | "bool" | "uint") || ty.starts_with(['i', 'u']); + let (floats, integers): ([f32; 16], [i32; 16]) = match *self { + Value::F(f) => (f, f.map(|v| v as i32)), + Value::I(i) => { + let mut f = [0.0; 16]; + let mut v = [0; 16]; + for k in 0..3 { + f[k] = i[k] as f32; + v[k] = i[k]; + } + (f, v) + } + }; + let count = match ty { + "mat4" => 16, + "mat3" => { + // std140: three columns, each padded to a vec4, from the mat4's upper left. + for c in 0..3 { + let column: [f32; 3] = std::array::from_fn(|r| floats[c * 4 + r]); + put(out, offset + c * 16, bytemuck::cast_slice(&column)); + } + return; + } + t => match t.as_bytes().last() { + Some(n @ b'2'..=b'4') if t.contains("vec") => (n - b'0') as usize, + _ => 1, + }, + }; + if ints { + put(out, offset, bytemuck::cast_slice(&integers[..count])); + } else { + put(out, offset, bytemuck::cast_slice(&floats[..count])); + } + } +} + +fn put(out: &mut [u8], offset: usize, bytes: &[u8]) { + if let Some(dst) = out.get_mut(offset..offset + bytes.len()) { + dst.copy_from_slice(bytes); + } +} + +/// The uniforms provided, in the order of [`FrameUniforms::values`]. +pub const NAMES: &[&str] = &[ + "cameraPosition", + "previousCameraPosition", + "eyeAltitude", + "upPosition", + "sunPosition", + "moonPosition", + "shadowLightPosition", + "sunAngle", + "shadowAngle", + "worldTime", + "worldDay", + "moonPhase", + "frameCounter", + "frameTime", + "frameTimeCounter", + "viewWidth", + "viewHeight", + "aspectRatio", + "near", + "far", + "rainStrength", + "wetness", + "thunderStrength", + "fogColor", + "skyColor", + "fogMode", + "fogStart", + "fogEnd", + "fogDensity", + "fogShape", + "isEyeInWater", + "blindness", + "nightVision", + "darknessFactor", + "darknessLightFactor", + "screenBrightness", + "hideGUI", + "eyeBrightness", + "eyeBrightnessSmooth", + "heldItemId", + "heldItemId2", + "heldBlockLightValue", + "heldBlockLightValue2", + "renderStage", + "gbufferModelView", + "gbufferModelViewInverse", + "gbufferProjection", + "gbufferProjectionInverse", + "gbufferPreviousModelView", + "gbufferPreviousProjection", +]; + +/// Values carried from frame to frame. +pub struct FrameUniforms { + pub values: Vec, + start: std::time::Instant, + last: Option, + frame: u32, + previous: Option<(Vec3, Mat4, Mat4)>, + wetness: f32, + eye_smooth: [f32; 2], +} + +impl Default for FrameUniforms { + fn default() -> Self { + Self { + values: Vec::new(), + start: std::time::Instant::now(), + last: None, + frame: 0, + previous: None, + wetness: 0.0, + eye_smooth: [0.0; 2], + } + } +} + +/// `(0, 100, 0)` on the frame the sky draws a body on: Y -90°, then X by its angle in degrees. +fn body(angle: f32) -> Vec3 { + (Mat4::from_rotation_y((-90.0f32).to_radians()) * Mat4::from_rotation_x(angle.to_radians())) + .transform_vector3(Vec3::new(0.0, 100.0, 0.0)) +} + +/// `value` moved toward `target` with a half-life of `half_life` seconds over `dt` (5.6). +fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 { + target + (value - target) * 0.5f32.powf(dt / half_life) +} + +impl FrameUniforms { + /// This frame's values for a render size of `size` pixels. + pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) { + let now = std::time::Instant::now(); + let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32()); + self.last = Some(now); + let aspect = size[0] as f32 / size[1].max(1) as f32; + let view = crate::camera::view_matrix(frame.yaw, frame.pitch); + let projection = Mat4::perspective_rh_gl( + frame.fov_degrees.to_radians(), + aspect, + 0.05, + frame.depth_far, + ) * frame.bob; + let camera = frame.camera.as_vec3(); + let (previous_camera, previous_view, previous_projection) = + self.previous + .unwrap_or((Vec3::ZERO, Mat4::ZERO, Mat4::ZERO)); + self.previous = Some((camera, view, projection)); + + let to_view = |v: Vec3| view.transform_vector3(v); + let sun_angle = (frame.sky.sun_angle / 360.0 + 0.25).rem_euclid(1.0); + let day = sun_angle < 0.5; + let sun = to_view(body(frame.sky.sun_angle)); + let moon = to_view(body(frame.sky.moon_angle)); + // Half-lives are given in ticks (7.2); smoothing runs per frame in seconds (OQ-26). + let half_life = if inputs.rain > self.wetness { + 600.0 + } else { + 200.0 + }; + self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt); + let eye = inputs.eye_light.map(|l| (l * 16) as f32); + for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) { + *smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt); + } + let (fog_color, fog_start, fog_end) = match &frame.fog { + Some(f) => ( + [f.color[0], f.color[1], f.color[2]], + f.environmental_start.min(f.render_distance_start), + f.environmental_end.min(f.render_distance_end), + ), + None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance), + }; + let day_time = inputs.day_time.rem_euclid(24000) as i32; + let world_day = inputs.day_time.div_euclid(24000); + let seconds = (now - self.start).as_secs_f32() % 3600.0; + + let value = |name: &str| -> Value { + let v3 = |v: Vec3| Value::f(&v.to_array()); + let m4 = |m: Mat4| Value::f(&m.to_cols_array()); + match name { + "cameraPosition" => v3(camera), + "previousCameraPosition" => v3(previous_camera), + "eyeAltitude" => Value::f(&[frame.camera.y as f32]), + "upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))), + "sunPosition" => v3(sun), + "moonPosition" => v3(moon), + "shadowLightPosition" => v3(if day { sun } else { moon }), + "sunAngle" => Value::f(&[sun_angle]), + "shadowAngle" => Value::f(&[if day { sun_angle } else { sun_angle - 0.5 }]), + "worldTime" => Value::i(&[day_time]), + "worldDay" => Value::i(&[world_day as i32]), + "moonPhase" => Value::i(&[world_day.rem_euclid(8) as i32]), + "frameCounter" => Value::i(&[self.frame as i32]), + "frameTime" => Value::f(&[dt]), + "frameTimeCounter" => Value::f(&[seconds]), + "viewWidth" => Value::f(&[size[0] as f32]), + "viewHeight" => Value::f(&[size[1] as f32]), + "aspectRatio" => Value::f(&[aspect]), + "near" => Value::f(&[0.05]), + "far" => Value::f(&[frame.distance]), + "rainStrength" => Value::f(&[inputs.rain]), + "wetness" => Value::f(&[self.wetness]), + "thunderStrength" => Value::f(&[inputs.thunder]), + "fogColor" => Value::f(&fog_color), + "skyColor" => Value::f(&frame.sky.sky_color), + // GL_LINEAR: vanilla's fog is linear. + "fogMode" => Value::i(&[9729]), + "fogStart" => Value::f(&[fog_start]), + "fogEnd" => Value::f(&[fog_end]), + "fogDensity" => Value::f(&[1.0]), + "fogShape" => Value::i(&[0]), + "isEyeInWater" => Value::i(&[inputs.eye_in]), + "blindness" => Value::f(&[inputs.blindness]), + "nightVision" => Value::f(&[frame.night_vision]), + "darknessFactor" => Value::f(&[frame.darkness.0]), + "darknessLightFactor" => Value::f(&[frame.darkness.1]), + "screenBrightness" => Value::f(&[inputs.brightness]), + "hideGUI" => Value::i(&[inputs.hide_gui as i32]), + "eyeBrightness" => Value::i(&eye.map(|v| v as i32)), + "eyeBrightnessSmooth" => Value::i(&self.eye_smooth.map(|v| v.round() as i32)), + "gbufferModelView" => m4(view), + "gbufferModelViewInverse" => m4(view.inverse()), + "gbufferProjection" => m4(projection), + "gbufferProjectionInverse" => m4(projection.inverse()), + "gbufferPreviousModelView" => m4(previous_view), + "gbufferPreviousProjection" => m4(previous_projection), + // Held items wait for `item.properties` (5.3: 0 without the file); renderStage + // is NONE outside gbuffers draws. + _ => Value::i(&[0]), + } + }; + self.values = NAMES.iter().map(|n| value(n)).collect(); + self.frame = (self.frame + 1) % 720_720; + } + + /// `hz_fog_color` and `hz_fog` of the translator's block (the compatibility `gl_Fog`). + pub fn fog(&self) -> [Vec4; 2] { + let get = |name| match index(name).and_then(|i| self.values.get(i)) { + Some(Value::F(f)) => *f, + _ => [0.0; 16], + }; + let (color, start, end) = (get("fogColor"), get("fogStart")[0], get("fogEnd")[0]); + [ + Vec4::new(color[0], color[1], color[2], 1.0), + Vec4::new(1.0, start, end, 1.0 / (end - start).max(1e-3)), + ] + } +} + +/// The position of `name` in [`NAMES`]. +pub fn index(name: &str) -> Option { + NAMES.iter().position(|n| *n == name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn values_follow_the_declared_type() { + let mut out = vec![0u8; 64]; + Value::i(&[7]).write("float", &mut out, 0); + assert_eq!(f32::from_ne_bytes(out[0..4].try_into().unwrap()), 7.0); + Value::f(&[1.5, 2.5]).write("ivec2", &mut out, 4); + assert_eq!(i32::from_ne_bytes(out[8..12].try_into().unwrap()), 2); + Value::i(&[1]).write("bool", &mut out, 16); + assert_eq!(i32::from_ne_bytes(out[16..20].try_into().unwrap()), 1); + Value::f(&[1.0; 16]).write("mat4", &mut out, 60); + assert_eq!(out[60..64], [0; 4], "a value past the block is dropped"); + } + + #[test] + fn noon_is_straight_up_and_the_sun_sets_in_the_west() { + assert!(body(0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3)); + assert!(body(90.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3)); + } +} diff --git a/crates/horizon-client/src/gfx/world_post.rs b/crates/horizon-client/src/gfx/world_post.rs index 934fed6d6..17609d27d 100644 --- a/crates/horizon-client/src/gfx/world_post.rs +++ b/crates/horizon-client/src/gfx/world_post.rs @@ -101,12 +101,14 @@ impl Post { /// The world's colour and depth targets this frame, or `None` to draw /// it straight to the screen. + /// `always`: a target even at scale 1 without FXAA (a shader pack reads it). pub fn prepare( &mut self, device: &wgpu::Device, window: [u32; 2], + always: bool, ) -> Option<(wgpu::TextureView, wgpu::TextureView)> { - if self.scale == 1.0 && !self.antialias { + if self.scale == 1.0 && !self.antialias && !always { self.target = None; return None; } @@ -155,6 +157,11 @@ impl Post { .map(|t| (t.color.clone(), t.depth.clone())) } + /// The size of the world's own target, when it has one. + pub fn size(&self) -> Option<[u32; 2]> { + self.target.as_ref().map(|t| t.size) + } + /// 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 { diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index 62102ade1..90fe0bcfe 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -27,6 +27,11 @@ pub fn uuid_ints(uuid: u128) -> [i32; 4] { uuid as i32, ] } +/// `UUIDUtil.uuidFromIntArray`. +pub fn ints_uuid(ints: [i32; 4]) -> u128 { + ints.iter() + .fold(0, |acc, &i| acc << 32 | u128::from(i as u32)) +} /// Whether a saved file exists for this player. pub fn exists(world: &Path, uuid: u128) -> bool { diff --git a/crates/horizon-client/src/lang.rs b/crates/horizon-client/src/lang.rs index 15f47285a..94ec4b7ab 100644 --- a/crates/horizon-client/src/lang.rs +++ b/crates/horizon-client/src/lang.rs @@ -37,6 +37,21 @@ pub trait ResourceSource { vec!["minecraft".into()] } } +/// Horizon's own strings, compiled in so they need no prepared resources. +struct Bundled; +impl ResourceSource for Bundled { + fn read(&self, path: &str) -> Option> { + let body = match path { + "assets/horizon/lang/en_us.json" => include_str!("../assets/horizon/lang/en_us.json"), + "assets/horizon/lang/ru_ru.json" => include_str!("../assets/horizon/lang/ru_ru.json"), + _ => return None, + }; + Some(body.as_bytes().to_vec()) + } + fn namespaces(&self) -> Vec { + vec!["horizon".into()] + } +} impl ResourceSource for std::path::PathBuf { fn read(&self, path: &str) -> Option> { std::fs::read(self.join(path)).ok() @@ -86,6 +101,12 @@ impl Language { if code != DEFAULT { codes.push(code); } + // Horizon's own strings first, so a pack or the prepared root can still + // override them. Vanilla has no key for "Controller vibration". + let bundled = Bundled; + let mut all: Vec<&dyn ResourceSource> = vec![&bundled]; + all.extend_from_slice(sources); + let sources = all.as_slice(); let namespaces: std::collections::BTreeSet = sources.iter().flat_map(|s| s.namespaces()).collect(); for c in codes { @@ -204,6 +225,16 @@ mod tests { self.0.get(path).cloned() } } + #[test] + fn bundled_horizon_strings_follow_the_language() { + // No prepared resources at all: the namespace is compiled in. + let empty = Mem(HashMap::new()); + let en = Language::load(&[&empty], "en_us"); + assert_eq!(en.get("horizon.options.controller"), Some("Controller")); + let ru = Language::load(&[&empty], "ru_ru"); + assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад")); + } + #[test] fn layers_selected_language_over_english() { let base = Mem(HashMap::from([ diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index 7b3023629..ae95e1bdb 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -24,6 +24,7 @@ pub mod block_entity_render; pub mod block_transform; pub mod blocks; pub mod border_render; +pub mod boss_overlay; pub mod bossbar; pub mod breaking; pub mod brewing; @@ -53,6 +54,7 @@ pub mod datapack; pub mod debug_chart; pub mod debug_keys; pub mod debug_overlay; +pub mod discord_presence; pub mod dynamic_light; pub mod effects; pub mod enchant; diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 5da5d8e79..560cadc80 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -1758,6 +1758,11 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> { state: BlockStateId, position: BlockPos, ) -> Result<()> { + // LivingEntity.aiStep: Slow Falling and Levitation reset the fall + // distance every tick, so landing under either does not hurt. + if self.player.status.slow_falling || self.player.status.levitation.is_some() { + self.player.fall_distance = 0.0; + } if self.player.spawn_extra_particles_on_fall && grounded && self.player.fall_distance > 0.0 { self.player.events.push(MotionEvent::SmashLanding { @@ -2918,6 +2923,46 @@ mod tests { } } #[test] + fn slow_falling_clears_the_fall_distance_so_landing_does_not_hurt() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("movement-properties.nbt").exists() { + return; + } + let assets = Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let binding = crate::collisions::load(&root, assets.registry).unwrap(); + let resources = MotionResources::load(&root, &assets).unwrap(); + for slow_falling in [false, true] { + let mut scene = World::test_scene(&assets, &render, 2).unwrap(); + let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 9.0, 20.5)).unwrap(); + player.fall_distance = 30.0; + player.status.slow_falling = slow_falling; + let mut w = MotionWorld { + entities: &[], + player: &mut player, + scene: &mut scene, + assets: &assets, + collision_binding: &binding, + resources: &resources, + }; + for _ in 0..200 { + if w.player.ground.on_ground { + break; + } + w.player_movement_tick([0.0, 0.0], false).unwrap(); + } + assert!(w.player.ground.on_ground); + if slow_falling { + assert_eq!(w.player.health.health(), 20.0); + assert_eq!(w.player.fall_distance, 0.0); + } else { + assert!(w.player.health.health() < 20.0); + } + } + } + #[test] fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../.local/horizon-client") diff --git a/crates/horizon-client/src/options_ui.rs b/crates/horizon-client/src/options_ui.rs index 99a83072f..0bc1e933d 100644 --- a/crates/horizon-client/src/options_ui.rs +++ b/crates/horizon-client/src/options_ui.rs @@ -267,20 +267,21 @@ impl Opt { Opt::HighContrastBlockOutlines => "options.accessibility.high_contrast_block_outline", Opt::ForceUnicode => "options.forceUnicodeFont", Opt::JapaneseGlyphs => "options.japaneseGlyphVariants", - // Horizon's own options: English captions (no vanilla keys). - Opt::PadEnabled => "Controller", - Opt::PadVibration => "Vibration", - Opt::PadAdaptiveTriggers => "Adaptive Triggers", - Opt::PadLightbar => "Lightbar", - Opt::PadLookSensitivity => "Look Sensitivity", - Opt::PadInvertX => "Invert Look X", - Opt::PadInvertY => "Invert Look Y", - Opt::PadToggleSneak => "Sneak Button", - Opt::PadMoveDeadzone => "Move Deadzone", - Opt::PadLookDeadzone => "Look Deadzone", - Opt::PadGlyphs => "Button Glyphs", - Opt::PadMenuNav => "Menu Navigation", - Opt::PadKeyboard => "On-Screen Keyboard", + // Horizon's own options: vanilla has no keys for them, so they read + // the namespace the client bundles (assets/horizon/lang). + Opt::PadEnabled => "horizon.options.controller", + Opt::PadVibration => "horizon.options.vibration", + Opt::PadAdaptiveTriggers => "horizon.options.adaptive_triggers", + Opt::PadLightbar => "horizon.options.lightbar", + Opt::PadLookSensitivity => "horizon.options.look_sensitivity", + Opt::PadInvertX => "horizon.options.invert_x", + Opt::PadInvertY => "horizon.options.invert_y", + Opt::PadToggleSneak => "horizon.options.sneak_button", + Opt::PadMoveDeadzone => "horizon.options.move_deadzone", + Opt::PadLookDeadzone => "horizon.options.look_deadzone", + Opt::PadGlyphs => "horizon.options.button_glyphs", + Opt::PadMenuNav => "horizon.options.menu_navigation", + Opt::PadKeyboard => "horizon.options.on_screen_keyboard", } } pub(crate) fn kind(self, s: &Settings) -> Kind { @@ -1291,7 +1292,10 @@ impl OptionsUi { open("Graphics...", Page::Graphics), Some(open("Advanced Graphics...", Page::GraphicsAdvanced)), ), - Row::Buttons(open("Controller Settings...", Page::Controller), None), + Row::Buttons( + open("Controller Settings...", Page::Controller), + Some(self.extra_button(s, crate::extras::Item::DiscordPresence)), + ), Row::Buttons( open("Extras...", Page::Extras), Some(open("Mods...", Page::Mods)), diff --git a/crates/horizon-client/src/packs.rs b/crates/horizon-client/src/packs.rs index 5b4d11e82..1e012fd90 100644 --- a/crates/horizon-client/src/packs.rs +++ b/crates/horizon-client/src/packs.rs @@ -517,7 +517,11 @@ fn build_overlay_uncached( } } } else if touched.contains(&child) - || (vanilla.join(&child).is_dir() && !filters.is_empty()) + // Windows cannot link a directory the way unix does (`link` above), so an + // untouched one has to be mirrored: skipping it hid textures/font and the + // title panorama from the overlay, and a font json that names those sheets + // then failed to load (#1688). + || (vanilla.join(&child).is_dir() && (cfg!(not(unix)) || !filters.is_empty())) { mirror( &child, diff --git a/crates/horizon-client/src/projectiles.rs b/crates/horizon-client/src/projectiles.rs index 093482a08..8244da4d9 100644 --- a/crates/horizon-client/src/projectiles.rs +++ b/crates/horizon-client/src/projectiles.rs @@ -61,6 +61,25 @@ pub enum ProjectileKind { color: u32, }, } +impl ProjectileKind { + /// Its entity type. + pub fn id(&self) -> &'static str { + match self { + Self::Arrow { .. } => "minecraft:arrow", + Self::Snowball => "minecraft:snowball", + Self::Egg => "minecraft:egg", + Self::EnderPearl => "minecraft:ender_pearl", + Self::SmallFireball => "minecraft:small_fireball", + Self::LargeFireball { .. } => "minecraft:fireball", + Self::LlamaSpit => "minecraft:llama_spit", + Self::WindCharge => "minecraft:wind_charge", + Self::BreezeWindCharge => "minecraft:breeze_wind_charge", + Self::ShulkerBullet { .. } => "minecraft:shulker_bullet", + Self::WitherSkull { .. } => "minecraft:wither_skull", + Self::Potion { .. } => "minecraft:splash_potion", + } + } +} #[derive(Clone, Debug, PartialEq)] pub struct Projectile { /// The entity id a server tracks it by (0 until it is tracked). @@ -83,6 +102,8 @@ pub struct Projectile { pub bonus: crate::mobs::gear::ArrowBonus, /// Arrows' `AbstractArrow` state (ticked by `arrows::tick`). pub arrow: Option>, + /// `AbstractHurtingProjectile.accelerationPower`. + pub acceleration: f64, } /// What a projectile struck this tick. #[derive(Clone, Copy, Debug, PartialEq)] @@ -146,6 +167,7 @@ impl Projectile { stuck: false, left_owner: false, bonus: Default::default(), + acceleration: 0.1, arrow: kind_is_arrow.then(|| { // Entity.random: seeded unpredictably, the position stands in. Box::new(crate::arrows::ArrowState::new( @@ -571,9 +593,9 @@ impl Projectile { let v = self.velocity; let len = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt().max(1.0e-9); self.velocity = Vec3::new( - (v.x + v.x / len * 0.1) * inertia, - (v.y + v.y / len * 0.1) * inertia, - (v.z + v.z / len * 0.1) * inertia, + (v.x + v.x / len * self.acceleration) * inertia, + (v.y + v.y / len * self.acceleration) * inertia, + (v.z + v.z / len * self.acceleration) * inertia, ); } // Arrows tick with `arrows::tick`. diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 7819a9b0b..da5735c17 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -1913,7 +1913,8 @@ pub fn write_dat(path: &Path, tag: Tag) -> Result<()> { Some(stem) => path.with_file_name(format!("{stem}.dat_old")), None => path.with_file_name(format!("{file}_old")), }; - let _ = fs::rename(path, old); + // Util.safeReplaceFile: no backup, no replacement. + fs::rename(path, old).map_err(|e| format!("{}: {e}", path.display()))?; } fs::rename(temporary, path)?; Ok(()) diff --git a/crates/horizon-client/src/streaming.rs b/crates/horizon-client/src/streaming.rs index 99e8a48d6..55a2f0086 100644 --- a/crates/horizon-client/src/streaming.rs +++ b/crates/horizon-client/src/streaming.rs @@ -638,7 +638,7 @@ impl Streaming { }); self.shared.ready.notify_one(); } - fn receive_decorated(&mut self, x: i32, z: i32, region: Box<[Proto; 9]>) { + fn receive_decorated(&mut self, x: i32, z: i32, region: [Proto; 9]) { self.generation_scan.exhausted = false; self.decorating -= 1; self.decorated.insert((x, z)); @@ -825,7 +825,7 @@ impl Streaming { } } } - Done::Decorated(x, z, region) => self.receive_decorated(x, z, region), + Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region), Done::Generated(chunk) => self.receive_generated(chunk, world), Done::Saved(protos) => self.receive_saved(protos), Done::Lit(x, z, sections, seq) => { @@ -1307,7 +1307,7 @@ impl Streaming { while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 { let left = deadline.saturating_duration_since(std::time::Instant::now()); match self.results.recv_timeout(left) { - Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, region), + Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region), Ok(Done::Generated(chunk)) => self.receive_generated(chunk, world), Ok(Done::Saved(protos)) => self.receive_saved(protos), Ok(other) => self.deferred.push(other), @@ -1335,7 +1335,7 @@ impl Streaming { let started = std::time::Instant::now(); while let Ok(done) = self.results.try_recv() { match done { - Done::Decorated(x, z, region) => self.receive_decorated(x, z, region), + Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region), Done::Generated(chunk) => self.receive_generated(chunk, world), Done::Saved(protos) => self.receive_saved(protos), other => self.deferred.push(other), diff --git a/crates/horizon-client/src/title.rs b/crates/horizon-client/src/title.rs index ce1c8abf8..a6967816a 100644 --- a/crates/horizon-client/src/title.rs +++ b/crates/horizon-client/src/title.rs @@ -581,23 +581,57 @@ impl Title { let cx = (w / 2.0).floor(); let y = (h / 4.0).floor() + 48.0; let mut buttons = vec![ - b(cx - 100.0, y, 200.0, "Singleplayer", true, 1), - b(cx - 100.0, y + 24.0, 200.0, "Multiplayer", true, 4), - b(cx - 100.0, y + 48.0, 200.0, "Minecraft Realms", false, 0), - b(cx - 100.0, y + 96.0, 98.0, "Options...", true, 3), - b(cx + 2.0, y + 96.0, 98.0, "Quit Game", true, 2), - ]; - for (i, (label, enabled, id, icon, size)) in [ - // Horizon's Join a Friend (an invite code) behind - // vanilla's Friends button. - ("Friends", true, 5, "friends/friends", 15.0), - ("Language", true, 6, "icon/language", 15.0), - ( - "Accessibility Settings", + b( + cx - 100.0, + y, + 200.0, + &lang::get_or("menu.singleplayer", "Singleplayer"), true, + 1, + ), + b( + cx - 100.0, + y + 24.0, + 200.0, + &lang::get_or("menu.multiplayer", "Multiplayer"), + true, + 4, + ), + b( + cx - 100.0, + y + 48.0, + 200.0, + &lang::get_or("menu.online", "Minecraft Realms"), + false, + 0, + ), + b( + cx - 100.0, + y + 96.0, + 98.0, + &lang::get_or("menu.options", "Options..."), + true, + 3, + ), + b( + cx + 2.0, + y + 96.0, + 98.0, + &lang::get_or("menu.quit", "Quit Game"), + true, + 2, + ), + ]; + // CommonButtons' icon buttons; Horizon's Join a Friend (an + // invite code) sits behind vanilla's Friends button. + for (i, (key, plain, id, icon)) in [ + ("gui.friends.open", "Friends", 5, "friends/friends"), + ("options.language", "Language...", 6, "icon/language"), + ( + "options.accessibility", + "Accessibility Settings...", 7, "icon/accessibility", - 15.0, ), ] .into_iter() @@ -607,11 +641,11 @@ impl Title { cx - 34.0 + i as f32 * 24.0, y + 72.0, 20.0, - label, - enabled, + &lang::get_or(key, plain), + true, id, ); - button.icon = Some((icon, size)); + button.icon = Some((icon, 15.0)); buttons.push(button); } buttons @@ -621,7 +655,7 @@ impl Title { (w / 2.0).floor() - 154.0, h - 52.0, 150.0, - "Play Selected World", + &lang::get_or("selectWorld.select", "Play Selected World"), selected.is_some(), 10, ), @@ -629,7 +663,7 @@ impl Title { (w / 2.0).floor() + 4.0, h - 52.0, 150.0, - "Create New World", + &lang::get_or("selectWorld.create", "Create New World"), true, 11, ), @@ -657,7 +691,14 @@ impl Title { selected.is_some(), 15, ), - b((w / 2.0).floor() + 83.0, h - 28.0, 71.0, "Back", true, 12), + b( + (w / 2.0).floor() + 83.0, + h - 28.0, + 71.0, + &lang::get_or("gui.back", "Back"), + true, + 12, + ), ], Mode::Create { creative, options, .. diff --git a/crates/horizon-mod-registry/Cargo.lock b/crates/horizon-mod-registry/Cargo.lock new file mode 100644 index 000000000..44ef1e078 --- /dev/null +++ b/crates/horizon-mod-registry/Cargo.lock @@ -0,0 +1,1352 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "horizon-mod-registry" +version = "0.1.0" +dependencies = [ + "axum", + "base64", + "getrandom 0.3.4", + "hmac", + "horizon-mods", + "http-body-util", + "serde", + "serde_json", + "sha2", + "tokio", + "toml", + "tower", + "ureq", +] + +[[package]] +name = "horizon-mods" +version = "0.1.0" +dependencies = [ + "log", + "minisign-verify", + "serde", + "serde_json", + "sha2", + "toml", + "zip", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.11", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/crates/horizon-mod-registry/Cargo.toml b/crates/horizon-mod-registry/Cargo.toml new file mode 100644 index 000000000..affc08f62 --- /dev/null +++ b/crates/horizon-mod-registry/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "horizon-mod-registry" +version = "0.1.0" +edition = "2024" +license = "MIT" +rust-version = "1.90" +publish = false +description = "The registry's submission service: GitHub sign-in, the file's checks, staging, and a pull request (docs/api/mod-index.md)" + +# Its own workspace, as the telemetry server's: the game's workspace never builds a web server. +[workspace] + +[dependencies] +horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] } +axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json", "query", "multipart"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "fs", "io-util"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "1" +# GitHub's API, blocking, on tokio's blocking pool: the launcher's HTTP client. +ureq = { version = "2", default-features = false, features = ["json", "tls"] } +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" +getrandom = "0.3" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" + +[profile.release] +strip = true +lto = "thin" diff --git a/crates/horizon-mod-registry/Dockerfile b/crates/horizon-mod-registry/Dockerfile new file mode 100644 index 000000000..13522165a --- /dev/null +++ b/crates/horizon-mod-registry/Dockerfile @@ -0,0 +1,14 @@ +# Built from the repository root: docker build -f crates/horizon-mod-registry/Dockerfile . +FROM rust:1-slim AS build +WORKDIR /src +# The root manifest only for the shared crate's workspace settings (edition, lints). +COPY Cargo.toml Cargo.toml +COPY crates/horizon-mods crates/horizon-mods +COPY crates/horizon-mod-registry crates/horizon-mod-registry +RUN cargo build --release --locked --manifest-path crates/horizon-mod-registry/Cargo.toml + +FROM debian:stable-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=build /src/crates/horizon-mod-registry/target/release/horizon-mod-registry /usr/local/bin/ +USER nobody +CMD ["horizon-mod-registry"] diff --git a/crates/horizon-mod-registry/README.md b/crates/horizon-mod-registry/README.md new file mode 100644 index 000000000..936fe5acf --- /dev/null +++ b/crates/horizon-mod-registry/README.md @@ -0,0 +1,44 @@ +# horizon-mod-registry + +The service behind the registry's submission form (`playhorizon.dev/mods/submit`). A creator signs in with GitHub and +sends a file with its page; the service runs the file's own checks (`horizon_mods::index::inspect`, the signature, the +entry's fields), uploads it to the `staging` release of +[ludovic111/horizon-mods](https://github.com/ludovic111/horizon-mods) as `.`, and opens a pull +request adding or updating `mods/.toml`. That repository's CI checks it again, with a trial run of a mod in the +sandbox; reviewers decide, and merging publishes. Formats and rules: [docs/api/mod-index.md](../../docs/api/mod-index.md). + +It keeps no data: a session is a signed cookie holding the GitHub login, and the user's OAuth token is used once, to +read that login, and dropped. The sign-in asks for no scope. + +| Route | | +| --- | --- | +| `GET /api/mods/auth/login` | Starts the GitHub sign-in | +| `GET /api/mods/auth/callback` | GitHub's redirect: sets the session, back to `/mods/submit` | +| `POST /api/mods/auth/logout` | Ends it | +| `GET /api/mods/me` | `{login, csrf}`, or 401 | +| `POST /api/mods/submit` | Multipart: `entry` (JSON: `id kind name summary description authors license source icon key signature version game changelog`) and `file`; `{pull_request}` or `{error}` | +| `GET /healthz` | `ok` | + +A submission must come from `REGISTRY_PUBLIC_URL`'s origin with the session's `X-CSRF-Token`. Six per account and sixty +in all per hour. Only an entry's `owners` publish its updates; a key change, an icon in the repository or anything else +is a pull request by hand. + +## Running it + +```sh +cargo test # against a mock GitHub (tests/submit.rs) +REGISTRY_PUBLIC_URL=http://localhost:4173 REGISTRY_SESSION_KEY=... GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=... \ + GITHUB_BOT_TOKEN=... REGISTRY_REPO=you/horizon-mods-test cargo run +``` + +| Variable | | +| --- | --- | +| `REGISTRY_PUBLIC_URL` | The site, `https://playhorizon.dev`: the OAuth callback is `/api/mods/auth/callback` | +| `REGISTRY_SESSION_KEY` | 32 characters or more, random: signs sessions | +| `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | The OAuth app | +| `GITHUB_BOT_TOKEN` | Fine-grained, the registry repository only: Contents and Pull requests read and write | +| `REGISTRY_REPO` | Default `ludovic111/horizon-mods` | +| `PORT` | Default 8080 | + +On Railway it is its own service (`railway.json`, built with the `Dockerfile` from the repository root); the website's +Caddy sends `/api/mods/*` to it over Railway's private network. diff --git a/crates/horizon-mod-registry/railway.json b/crates/horizon-mod-registry/railway.json new file mode 100644 index 000000000..cfafe4367 --- /dev/null +++ b/crates/horizon-mod-registry/railway.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "crates/horizon-mod-registry/Dockerfile", + "watchPatterns": [ + "/crates/horizon-mods/**", + "/crates/horizon-mod-registry/**", + "/Cargo.toml" + ] + }, + "deploy": { + "healthcheckPath": "/healthz", + "healthcheckTimeout": 60, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 5, + "multiRegionConfig": { + "europe-west4-drams3a": { "numReplicas": 1 } + } + } +} diff --git a/crates/horizon-mod-registry/src/github.rs b/crates/horizon-mod-registry/src/github.rs new file mode 100644 index 000000000..01a1b1bdb --- /dev/null +++ b/crates/horizon-mod-registry/src/github.rs @@ -0,0 +1,276 @@ +//! What the service asks of GitHub: the sign-in (OAuth, no scope: only the +//! public profile), and with the bot's token on the registry repository +//! alone, an entry, the `staging` release, a branch, a file, a pull request. +//! Blocking (ureq): called from tokio's blocking pool. +use base64::{Engine, engine::general_purpose::STANDARD}; +use horizon_mods::index::registry::Entry; +use serde_json::{Value, json}; +use std::{path::Path, time::Duration}; + +/// Where GitHub is: the real one, or a test's mock. +#[derive(Clone, Debug)] +pub struct Endpoints { + /// `https://github.com`: OAuth. + pub web: String, + /// `https://api.github.com`. + pub api: String, + /// `https://uploads.github.com`: release assets. + pub uploads: String, +} + +impl Default for Endpoints { + fn default() -> Self { + Endpoints { + web: "https://github.com".into(), + api: "https://api.github.com".into(), + uploads: "https://uploads.github.com".into(), + } + } +} + +#[derive(Clone)] +pub struct GitHub { + pub at: Endpoints, + /// `owner/name` of the registry repository. + pub repo: String, + pub client_id: String, + pub client_secret: String, + /// Fine-grained, this repository only: contents and pull requests. + pub bot_token: String, + agent: ureq::Agent, +} + +/// A failed call: `status` is GitHub's, 0 when it wasn't reached. +#[derive(Debug)] +pub struct Failure { + pub status: u16, + pub message: String, +} + +type Result = std::result::Result; + +fn failure(e: ureq::Error) -> Failure { + match e { + ureq::Error::Status(status, response) => Failure { + status, + message: response + .into_string() + .unwrap_or_default() + .chars() + .take(300) + .collect(), + }, + other => Failure { + status: 0, + message: other.to_string(), + }, + } +} + +fn read(response: ureq::Response) -> Result { + response.into_json().map_err(|e| Failure { + status: 0, + message: e.to_string(), + }) +} + +impl GitHub { + pub fn new( + at: Endpoints, + repo: String, + client_id: String, + client_secret: String, + bot_token: String, + ) -> GitHub { + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(60)) + .user_agent("horizon-mod-registry") + .build(); + GitHub { + at, + repo, + client_id, + client_secret, + bot_token, + agent, + } + } + + /// Where the sign-in starts. + pub fn authorize_url(&self, redirect: &str, state: &str) -> String { + format!( + "{}/login/oauth/authorize?client_id={}&redirect_uri={}&state={state}&allow_signup=true", + self.at.web, + self.client_id, + encode(redirect) + ) + } + + /// The signed-in account (login, id) for an OAuth `code`. Its token is + /// used once and dropped. + pub fn sign_in(&self, code: &str, redirect: &str) -> Result<(String, u64)> { + let token = self + .agent + .post(&format!("{}/login/oauth/access_token", self.at.web)) + .set("Accept", "application/json") + .send_json(json!({ + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "redirect_uri": redirect, + })) + .map_err(failure) + .and_then(read)?; + let Some(token) = token["access_token"].as_str() else { + return Err(Failure { + status: 401, + message: "GitHub refused the sign-in".into(), + }); + }; + let user = self + .agent + .get(&format!("{}/user", self.at.api)) + .set("Authorization", &format!("Bearer {token}")) + .set("Accept", "application/vnd.github+json") + .call() + .map_err(failure) + .and_then(read)?; + match (user["login"].as_str(), user["id"].as_u64()) { + (Some(login), Some(id)) => Ok((login.to_owned(), id)), + _ => Err(Failure { + status: 0, + message: "GitHub sent no login".into(), + }), + } + } + + fn call(&self, method: &str, path: &str) -> ureq::Request { + self.agent + .request( + method, + &format!("{}/repos/{}{path}", self.at.api, self.repo), + ) + .set("Authorization", &format!("Bearer {}", self.bot_token)) + .set("Accept", "application/vnd.github+json") + .set("X-GitHub-Api-Version", "2022-11-28") + } + + /// `mods/.toml` on main, with its blob sha; None when it isn't there. + pub fn entry(&self, id: &str) -> Result> { + let response = self + .call("GET", &format!("/contents/mods/{id}.toml")) + .query("ref", "main") + .call(); + let file = match response { + Ok(r) => read(r)?, + Err(ureq::Error::Status(404, _)) => return Ok(None), + Err(e) => return Err(failure(e)), + }; + let bytes = file["content"] + .as_str() + .map(|c| c.replace('\n', "")) + .and_then(|c| STANDARD.decode(c).ok()) + .unwrap_or_default(); + let entry = toml::from_str(&String::from_utf8_lossy(&bytes)).map_err(|e| Failure { + status: 0, + message: format!("mods/{id}.toml on main: {e}"), + })?; + Ok(Some((entry, file["sha"].as_str().unwrap_or("").to_owned()))) + } + + /// Uploads `path` as asset `name` of the `staging` prerelease, unless it + /// is already there (the same file submitted again). + pub fn stage(&self, name: &str, path: &Path) -> Result<()> { + let release = self + .call("GET", "/releases/tags/staging") + .call() + .map_err(failure) + .and_then(read)?; + let there = release["assets"] + .as_array() + .is_some_and(|a| a.iter().any(|x| x["name"] == name)); + if there { + return Ok(()); + } + let id = release["id"].as_u64().unwrap_or(0); + let bytes = std::fs::read(path).map_err(|e| Failure { + status: 0, + message: e.to_string(), + })?; + self.agent + .post(&format!( + "{}/repos/{}/releases/{id}/assets", + self.at.uploads, self.repo + )) + .query("name", name) + .set("Authorization", &format!("Bearer {}", self.bot_token)) + .set("Accept", "application/vnd.github+json") + .set("Content-Type", "application/octet-stream") + .send_bytes(&bytes) + .map_err(failure)?; + Ok(()) + } + + /// Makes branch `name` from main; false when it already exists. + pub fn branch(&self, name: &str) -> Result { + let main = self + .call("GET", "/git/ref/heads/main") + .call() + .map_err(failure) + .and_then(read)?; + let sha = main["object"]["sha"].as_str().unwrap_or(""); + match self + .call("POST", "/git/refs") + .send_json(json!({"ref": format!("refs/heads/{name}"), "sha": sha})) + { + Ok(_) => Ok(true), + Err(ureq::Error::Status(422, _)) => Ok(false), + Err(e) => Err(failure(e)), + } + } + + /// Writes `path` on `branch` in one commit; `sha` is the blob it replaces. + pub fn put( + &self, + path: &str, + text: &str, + branch: &str, + sha: Option<&str>, + message: &str, + ) -> Result<()> { + let mut body = json!({ + "message": message, + "content": STANDARD.encode(text), + "branch": branch, + }); + if let Some(sha) = sha { + body["sha"] = json!(sha); + } + self.call("PUT", &format!("/contents/{path}")) + .send_json(body) + .map_err(failure)?; + Ok(()) + } + + /// Opens the pull request; its address. + pub fn pull(&self, title: &str, head: &str, body: &str) -> Result { + let pr = self + .call("POST", "/pulls") + .send_json(json!({"title": title, "head": head, "base": "main", "body": body})) + .map_err(failure) + .and_then(read)?; + Ok(pr["html_url"].as_str().unwrap_or("").to_owned()) + } +} + +/// Percent-encodes a query value. +fn encode(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + (b as char).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect() +} diff --git a/crates/horizon-mod-registry/src/lib.rs b/crates/horizon-mod-registry/src/lib.rs new file mode 100644 index 000000000..9799bace8 --- /dev/null +++ b/crates/horizon-mod-registry/src/lib.rs @@ -0,0 +1,675 @@ +//! `horizon-mod-registry`: the submission form's service at +//! `playhorizon.dev/api/mods/*` (docs/api/mod-index.md). A creator signs in +//! with GitHub, sends a file and its page; the service runs the file's own +//! checks, uploads it to the registry's `staging` release and opens a pull +//! request with the entry. Reviewers decide; merging publishes. It keeps no +//! data: sessions are signed cookies, and GitHub holds everything else. +pub mod github; + +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, Multipart, Query, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Redirect, Response}, + routing::{get, post}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use github::GitHub; +use hmac::{Hmac, Mac}; +use horizon_mods::index::{ + self, Kind, + registry::{self, Entry, EntryVersion, Sources}, +}; +use serde::Deserialize; +use serde_json::json; +use sha2::Sha256; +use std::{ + collections::{HashMap, VecDeque}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +/// How long a sign-in lasts. +const SESSION: u64 = 12 * 3600; +/// Submissions per account per hour, and from everyone. +const PER_ACCOUNT: usize = 6; +const PER_HOUR: usize = 60; +/// The page's JSON. +const MAX_FORM: usize = 64 << 10; + +pub struct Config { + /// `https://playhorizon.dev`: where the form is, and the only origin it + /// takes submissions from. + pub public_url: String, + /// At least 32 bytes: signs sessions. + pub session_key: Vec, + pub github: GitHub, + /// Where files wait during a submission. + pub scratch: PathBuf, +} + +impl Config { + /// `REGISTRY_PUBLIC_URL`, `REGISTRY_SESSION_KEY`, `REGISTRY_REPO`, + /// `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_BOT_TOKEN`. + pub fn from_env() -> Result { + let var = |name: &str| std::env::var(name).map_err(|_| format!("{name} is not set")); + let key = var("REGISTRY_SESSION_KEY")?; + if key.len() < 32 { + return Err("REGISTRY_SESSION_KEY needs at least 32 characters".into()); + } + Ok(Config { + public_url: var("REGISTRY_PUBLIC_URL")?.trim_end_matches('/').to_owned(), + session_key: key.into_bytes(), + github: GitHub::new( + github::Endpoints::default(), + std::env::var("REGISTRY_REPO").unwrap_or_else(|_| "ludovic111/horizon-mods".into()), + var("GITHUB_CLIENT_ID")?, + var("GITHUB_CLIENT_SECRET")?, + var("GITHUB_BOT_TOKEN")?, + ), + scratch: std::env::temp_dir().join("horizon-mod-registry"), + }) + } + + fn callback(&self) -> String { + format!("{}/api/mods/auth/callback", self.public_url) + } +} + +#[derive(Clone)] +pub struct App(Arc); + +struct Inner { + config: Config, + recent: Mutex>>, +} + +impl App { + pub fn new(config: Config) -> App { + App(Arc::new(Inner { + config, + recent: Mutex::new(HashMap::new()), + })) + } +} + +pub fn router(app: App) -> Router { + Router::new() + .route("/healthz", get(|| async { "ok" })) + .route("/api/mods/auth/login", get(login)) + .route("/api/mods/auth/callback", get(callback)) + .route("/api/mods/auth/logout", post(logout)) + .route("/api/mods/me", get(me)) + .route( + "/api/mods/submit", + post(submit).layer(DefaultBodyLimit::max( + index::MAX_FILE as usize + MAX_FORM * 2, + )), + ) + .with_state(app) +} + +/// An error players read in the form. +#[derive(Debug)] +pub struct Refusal(StatusCode, String); + +impl Refusal { + fn new(status: StatusCode, message: impl Into) -> Refusal { + Refusal(status, message.into()) + } +} + +impl IntoResponse for Refusal { + fn into_response(self) -> Response { + (self.0, Json(json!({"error": self.1}))).into_response() + } +} + +fn bad(message: impl Into) -> Refusal { + Refusal::new(StatusCode::BAD_REQUEST, message) +} + +fn github_down(e: github::Failure) -> Refusal { + eprintln!("horizon-mod-registry: GitHub {}: {}", e.status, e.message); + Refusal::new( + StatusCode::BAD_GATEWAY, + "GitHub didn't answer as expected: try again in a minute", + ) +} + +// Signed values: `.`, both base64url. + +fn sign(key: &[u8], payload: &str) -> String { + let mut mac = Hmac::::new_from_slice(key).expect("any key length"); + mac.update(payload.as_bytes()); + format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(payload), + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) + ) +} + +fn unsign(key: &[u8], value: &str) -> Option { + let (payload, tag) = value.split_once('.')?; + let payload = String::from_utf8(URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?; + let mut mac = Hmac::::new_from_slice(key).ok()?; + mac.update(payload.as_bytes()); + mac.verify_slice(&URL_SAFE_NO_PAD.decode(tag).ok()?).ok()?; + Some(payload) +} + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn random_hex() -> String { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("the system's random numbers"); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn cookie(headers: &HeaderMap, name: &str) -> Option { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|v| v.split(';')) + .filter_map(|c| c.trim().split_once('=')) + .find(|(k, _)| *k == name) + .map(|(_, v)| v.to_owned()) +} + +fn set_cookie(name: &str, value: &str, max_age: u64) -> String { + format!("{name}={value}; Path=/api/mods; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age}") +} + +/// The signed-in account: (login, id). +fn session(app: &Inner, headers: &HeaderMap) -> Option<(String, u64)> { + let payload = unsign(&app.config.session_key, &cookie(headers, "hm_session")?)?; + let mut parts = payload.split('\n'); + let (login, id, until) = (parts.next()?, parts.next()?, parts.next()?); + (until.parse::().ok()? > now()).then(|| (login.to_owned(), id.parse().unwrap_or(0))) +} + +/// The form sends this with each submission: a page on another site can't +/// read it, so it can't submit in a signed-in creator's name. +fn csrf(app: &Inner, headers: &HeaderMap) -> Option { + let session = cookie(headers, "hm_session")?; + let signed = sign(&app.config.session_key, &format!("csrf\n{session}")); + Some(signed.rsplit('.').next()?.chars().take(32).collect()) +} + +async fn login(State(app): State) -> Response { + let state = random_hex(); + let signed = sign(&app.0.config.session_key, &state); + let url = app + .0 + .config + .github + .authorize_url(&app.0.config.callback(), &state); + ( + [(header::SET_COOKIE, set_cookie("hm_state", &signed, 600))], + Redirect::to(&url), + ) + .into_response() +} + +#[derive(Deserialize)] +struct Callback { + code: String, + state: String, +} + +async fn callback( + State(app): State, + headers: HeaderMap, + Query(q): Query, +) -> Result { + let expected = cookie(&headers, "hm_state").and_then(|v| unsign(&app.0.config.session_key, &v)); + if expected.as_deref() != Some(q.state.as_str()) { + return Err(Refusal::new( + StatusCode::FORBIDDEN, + "the sign-in expired or didn't start here: try again", + )); + } + let inner = app.0.clone(); + let (login, id) = tokio::task::spawn_blocking(move || { + inner + .config + .github + .sign_in(&q.code, &inner.config.callback()) + }) + .await + .map_err(|_| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, "sign-in failed"))? + .map_err(github_down)?; + let value = sign( + &app.0.config.session_key, + &format!("{login}\n{id}\n{}", now() + SESSION), + ); + let back = format!("{}/mods/submit", app.0.config.public_url); + let mut response = Redirect::to(&back).into_response(); + let headers = response.headers_mut(); + headers.append( + header::SET_COOKIE, + set_cookie("hm_session", &value, SESSION).parse().unwrap(), + ); + headers.append( + header::SET_COOKIE, + set_cookie("hm_state", "", 0).parse().unwrap(), + ); + Ok(response) +} + +async fn logout() -> Response { + ( + [(header::SET_COOKIE, set_cookie("hm_session", "", 0))], + StatusCode::NO_CONTENT, + ) + .into_response() +} + +async fn me( + State(app): State, + headers: HeaderMap, +) -> Result, Refusal> { + let (login, _) = session(&app.0, &headers) + .ok_or_else(|| Refusal::new(StatusCode::UNAUTHORIZED, "not signed in"))?; + Ok(Json( + json!({"login": login, "csrf": csrf(&app.0, &headers)}), + )) +} + +/// The page a submission fills in: a project's fields, and the version's. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Form { + pub id: String, + pub kind: String, + pub name: String, + pub summary: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub authors: Vec, + pub license: String, + pub source: String, + /// `https://` only; an icon in the repository is a pull request by hand. + #[serde(default)] + pub icon: Option, + /// A new project's minisign public key. + #[serde(default)] + pub key: Option, + /// The `.minisig` text. + #[serde(default)] + pub signature: Option, + pub version: String, + pub game: String, + #[serde(default)] + pub changelog: String, +} + +fn take(window: &mut VecDeque, max: usize) -> bool { + while window + .front() + .is_some_and(|t| t.elapsed() > Duration::from_secs(3600)) + { + window.pop_front(); + } + window.len() < max +} + +async fn submit( + State(app): State, + headers: HeaderMap, + mut multipart: Multipart, +) -> Result, Refusal> { + let inner = app.0.clone(); + let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()); + if origin != Some(inner.config.public_url.as_str()) { + return Err(Refusal::new( + StatusCode::FORBIDDEN, + "submit from the registry's form", + )); + } + let (login, user_id) = session(&inner, &headers) + .ok_or_else(|| Refusal::new(StatusCode::UNAUTHORIZED, "sign in with GitHub first"))?; + let sent = headers.get("x-csrf-token").and_then(|v| v.to_str().ok()); + if sent.is_none() || sent.map(str::to_owned) != csrf(&inner, &headers) { + return Err(Refusal::new( + StatusCode::FORBIDDEN, + "reload the form and try again", + )); + } + { + let mut recent = inner.recent.lock().unwrap(); + let ok = take(recent.entry(String::new()).or_default(), PER_HOUR) + && take(recent.entry(login.to_lowercase()).or_default(), PER_ACCOUNT); + if !ok { + return Err(Refusal::new( + StatusCode::TOO_MANY_REQUESTS, + "too many submissions this hour: try again later", + )); + } + let at = Instant::now(); + recent.entry(String::new()).or_default().push_back(at); + recent + .entry(login.to_lowercase()) + .or_default() + .push_back(at); + } + let dir = inner.config.scratch.join(random_hex()); + std::fs::create_dir_all(&dir) + .map_err(|e| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let result = receive(&mut multipart, &dir).await; + let result = match result { + Ok((form, file)) => { + let inner = inner.clone(); + tokio::task::spawn_blocking(move || { + publish(&inner.config, &login, user_id, &form, &file) + }) + .await + .unwrap_or_else(|_| { + Err(Refusal::new( + StatusCode::INTERNAL_SERVER_ERROR, + "the check crashed", + )) + }) + } + Err(e) => Err(e), + }; + let _ = std::fs::remove_dir_all(&dir); + result.map(|url| Json(json!({"pull_request": url}))) +} + +/// Reads the form's two parts: `entry` (JSON) and `file`, streamed to `dir`. +async fn receive(multipart: &mut Multipart, dir: &Path) -> Result<(Form, PathBuf), Refusal> { + use tokio::io::AsyncWriteExt; + let (mut form, mut file) = (None, None); + while let Some(mut field) = multipart + .next_field() + .await + .map_err(|e| bad(e.body_text()))? + { + match field.name() { + Some("entry") => { + let text = field.text().await.map_err(|e| bad(e.body_text()))?; + if text.len() > MAX_FORM { + return Err(bad("the page's text is too long")); + } + form = Some(serde_json::from_str::
(&text).map_err(|e| bad(e.to_string()))?); + } + Some("file") => { + let path = dir.join("upload"); + let mut out = tokio::fs::File::create(&path) + .await + .map_err(|e| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let mut size = 0u64; + while let Some(chunk) = field.chunk().await.map_err(|e| bad(e.body_text()))? { + size += chunk.len() as u64; + if size > index::MAX_FILE { + return Err(bad(format!( + "the file is over {} MiB", + index::MAX_FILE >> 20 + ))); + } + out.write_all(&chunk).await.map_err(|e| { + Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + } + out.flush().await.ok(); + file = Some(path); + } + _ => {} + } + } + match (form, file) { + (Some(form), Some(file)) => Ok((form, file)), + _ => Err(bad("send the page and the file")), + } +} + +/// `YYYY-MM-DD` of a Unix time (UTC). +fn date(secs: u64) -> String { + // Howard Hinnant's days-to-civil. + let z = (secs / 86400) as i64 + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(m <= 2); + format!("{y:04}-{m:02}-{d:02}") +} + +/// The submission, past its checks: the entry written on a new branch and a +/// pull request opened. Returns the pull request's address. +pub fn publish( + config: &Config, + login: &str, + user_id: u64, + form: &Form, + file: &Path, +) -> Result { + let gh = &config.github; + let kind = Kind::from(form.kind.clone()); + registry::open(&kind).map_err(bad)?; + if !index::valid_id(&form.id) { + return Err(bad("an id is 1 to 64 of a-z, 0-9 and _")); + } + if form + .icon + .as_ref() + .is_some_and(|i| !i.starts_with("https://")) + { + return Err(bad("the icon must be an https:// address")); + } + if !form.source.trim().starts_with("https://") { + return Err(bad("the source must be an https:// address of the code")); + } + if form.name.trim().is_empty() || form.license.trim().is_empty() { + return Err(bad("a project needs a name and a licence")); + } + // The file's own checks first: a wrong file never reaches GitHub. + let facts = index::inspect(&kind, file).map_err(bad)?; + if let Some(id) = facts.id.as_ref().filter(|id| **id != form.id) { + return Err(bad(format!("the file's id is {id:?}, not {:?}", form.id))); + } + if let Some(v) = facts + .version + .as_ref() + .filter(|v| **v != form.version.trim()) + { + return Err(bad(format!( + "the file says version {v:?}, not {:?}", + form.version.trim() + ))); + } + let sha256 = index::sha256_file(file).map_err(bad)?; + let extension = kind.extension().unwrap_or("zip"); + let version = EntryVersion { + version: form.version.trim().to_owned(), + published: date(now()), + game: form.game.trim().to_owned(), + file: format!("{}-{}.{extension}", form.id, form.version.trim()), + sha256: sha256.clone(), + signature: form.signature.clone().filter(|s| !s.trim().is_empty()), + changelog: form.changelog.clone(), + }; + let existing = gh.entry(&form.id).map_err(github_down)?; + let is_new = existing.is_none(); + let (entry, blob) = match existing { + None => ( + Entry { + id: form.id.clone(), + kind: kind.clone(), + name: form.name.trim().to_owned(), + summary: form.summary.trim().to_owned(), + description: form.description.clone(), + authors: form.authors.clone(), + owners: vec![login.to_owned()], + license: form.license.trim().to_owned(), + source: form.source.trim().to_owned(), + icon: form.icon.clone(), + key: form.key.clone().filter(|k| !k.trim().is_empty()), + versions: vec![version.clone()], + }, + None, + ), + Some((mut entry, blob)) => { + if !entry.owners.iter().any(|o| o.eq_ignore_ascii_case(login)) { + return Err(Refusal::new( + StatusCode::FORBIDDEN, + format!( + "{} is {}'s: only its owners publish its updates", + form.id, + entry.owners.join(", ") + ), + )); + } + if entry.kind != kind { + return Err(bad(format!("{} is a {}", form.id, entry.kind.name()))); + } + if entry.versions.iter().any(|v| v.version == version.version) { + return Err(Refusal::new( + StatusCode::CONFLICT, + format!( + "{} {} is already published: give this file a new version", + form.id, version.version + ), + )); + } + let key = form.key.clone().filter(|k| !k.trim().is_empty()); + if key.is_some() && key != entry.key { + return Err(bad( + "changing a project's key is a pull request by hand, reviewed on its own", + )); + } + entry.name = form.name.trim().to_owned(); + entry.summary = form.summary.trim().to_owned(); + entry.description = form.description.clone(); + entry.authors = form.authors.clone(); + entry.license = form.license.trim().to_owned(); + entry.source = form.source.trim().to_owned(); + if form.icon.is_some() { + entry.icon = form.icon.clone(); + } + entry.versions.insert(0, version.clone()); + (entry, Some(blob)) + } + }; + // Every check CI runs but the trial run: the file as its kind, its id and + // version, its signature, the entry's fields. CI runs it all again. + let files = file.parent().unwrap_or(Path::new(".")); + std::fs::copy(file, files.join(&sha256)).map_err(|e| bad(e.to_string()))?; + let mut checked = entry.clone(); + checked.versions.truncate(1); + if checked + .icon + .as_ref() + .is_some_and(|i| !i.starts_with("https://")) + { + checked.icon = None; + } + let project = registry::project( + &checked, + &Sources { + registry: files, + files, + release: index::FILE_HOST, + site: "https://example.invalid/", + generated: String::new(), + }, + ) + .map_err(|errors| bad(errors.join("; ")))?; + let text = entry.to_toml().map_err(bad)?; + gh.stage(&format!("{sha256}.{extension}"), file) + .map_err(github_down)?; + let branch = format!("submit/{}-{}", form.id, version.version); + if !gh.branch(&branch).map_err(github_down)? { + return Err(Refusal::new( + StatusCode::CONFLICT, + format!( + "{} {} was already submitted: its pull request is open", + form.id, version.version + ), + )); + } + let what = if is_new { "Add" } else { "Update" }; + let message = format!( + "{what} {} {}\n\nSubmitted by @{login} through the form.\n\nCo-authored-by: {login} <{user_id}+{login}@users.noreply.github.com>", + form.id, version.version + ); + gh.put( + &format!("mods/{}.toml", form.id), + &text, + &branch, + blob.as_deref(), + &message, + ) + .map_err(github_down)?; + let v = &project.versions[0]; + let capabilities: Vec = v + .capabilities + .iter() + .map(|c| format!("- may {}", c.describe())) + .collect(); + let requires: Vec = v + .requires + .iter() + .map(|r| format!("- needs {} {}", r.id, r.version)) + .collect(); + let body = format!( + "\nSubmitted by @{login} through [the form]({public}/mods/submit).\n\n\ + | | |\n|---|---|\n| Project | `{id}` ({kind}, {new}) |\n| Version | {version} for {game} |\n| File | `{file}`, {size} bytes, SHA-256 `{sha256}` |\n| Signed | {signed} |\n| Source | {source} |\n\n{lists}\n\n\ + The checks below run the file's own checks again and, for a mod, a trial run in the sandbox. \ + Reviewers: read the source and the checks' report before you approve.", + public = config.public_url, + id = form.id, + kind = kind.name(), + new = if is_new { "new" } else { "update" }, + version = v.version, + game = v.game, + file = v.file.name, + size = v.file.size, + signed = if entry.key.is_some() { + "yes, with the project's key" + } else { + "no" + }, + source = entry.source, + lists = [capabilities, requires].concat().join("\n"), + ); + gh.pull( + &format!("{what} {} {}", form.id, version.version), + &branch, + &body, + ) + .map_err(github_down) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dates_and_signatures() { + assert_eq!(date(0), "1970-01-01"); + assert_eq!(date(1_792_800_000), "2026-10-24"); + let key = b"0123456789abcdef0123456789abcdef"; + let signed = sign(key, "alice\n7\n99"); + assert_eq!(unsign(key, &signed).as_deref(), Some("alice\n7\n99")); + assert_eq!(unsign(b"another key, another key, another", &signed), None); + let forged = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode("mallory\n8\n99"), + signed.split_once('.').unwrap().1 + ); + assert_eq!(unsign(key, &forged), None); + } +} diff --git a/crates/horizon-mod-registry/src/main.rs b/crates/horizon-mod-registry/src/main.rs new file mode 100644 index 000000000..81fe1b7b6 --- /dev/null +++ b/crates/horizon-mod-registry/src/main.rs @@ -0,0 +1,25 @@ +//! `horizon-mod-registry`: serves on `PORT` (default 8080). Configured by the +//! environment (see `Config::from_env`); keeps no data. +use std::net::SocketAddr; + +use horizon_mod_registry::{App, Config, router}; + +#[tokio::main(flavor = "multi_thread", worker_threads = 2)] +async fn main() { + let port: u16 = std::env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(8080); + let config = Config::from_env().unwrap_or_else(|e| panic!("horizon-mod-registry: {e}")); + let repo = config.github.repo.clone(); + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))) + .await + .unwrap_or_else(|e| panic!("port {port}: {e}")); + eprintln!("horizon-mod-registry: listening on {port}, submitting to {repo}"); + axum::serve(listener, router(App::new(config))) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + }) + .await + .unwrap_or_else(|e| panic!("{e}")); +} diff --git a/crates/horizon-mod-registry/tests/submit.rs b/crates/horizon-mod-registry/tests/submit.rs new file mode 100644 index 000000000..77487089d --- /dev/null +++ b/crates/horizon-mod-registry/tests/submit.rs @@ -0,0 +1,373 @@ +//! The whole submission against a mock GitHub: sign in, submit the sample +//! signed mod (`crates/horizon-mods/tests/index`), and what lands on GitHub; +//! then what is refused. +use axum::{ + Json, Router, + body::{Body, Bytes}, + extract::{Path as UrlPath, Query, State}, + http::{Request, StatusCode, header}, + routing::{get, post}, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use horizon_mod_registry::{App, Config, github, router}; +use horizon_mods::index::registry::Entry; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + path::PathBuf, + sync::{Arc, Mutex}, +}; +use tower::ServiceExt; + +const PUBLIC: &str = "https://playhorizon.test"; +const HMOD_SHA: &str = "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53"; + +#[derive(Default)] +struct Mock { + /// Path -> text on main. + main: HashMap, + assets: Vec<(String, usize)>, + branches: Vec, + /// (branch, path) -> (text, message, replaced blob). + commits: HashMap<(String, String), (String, String, Option)>, + pulls: Vec, + /// The account the next sign-in gets. + login: String, +} + +type Shared = Arc>; + +async fn mock_github() -> (String, Shared) { + let state: Shared = Arc::default(); + state.lock().unwrap().login = "alice".into(); + let app = Router::new() + .route( + "/login/oauth/access_token", + post(|Json(body): Json| async move { + Json(if body["code"] == "good" { + json!({"access_token": "gho_user"}) + } else { + json!({"error": "bad_verification_code"}) + }) + }), + ) + .route( + "/user", + get(|State(s): State| async move { + Json(json!({"login": s.lock().unwrap().login, "id": 7})) + }), + ) + .route( + "/repos/o/r/contents/mods/{file}", + get( + |State(s): State, UrlPath(file): UrlPath| async move { + let s = s.lock().unwrap(); + match s.main.get(&format!("mods/{file}")) { + Some(text) => Ok(Json(json!({"content": STANDARD.encode(text), "sha": "blob1"}))), + None => Err(StatusCode::NOT_FOUND), + } + }, + ) + .put( + |State(s): State, UrlPath(file): UrlPath, Json(b): Json| async move { + let text = String::from_utf8(STANDARD.decode(b["content"].as_str().unwrap()).unwrap()).unwrap(); + let key = (b["branch"].as_str().unwrap().to_owned(), format!("mods/{file}")); + let value = (text, b["message"].as_str().unwrap().to_owned(), b["sha"].as_str().map(str::to_owned)); + s.lock().unwrap().commits.insert(key, value); + Json(json!({})) + }, + ), + ) + .route( + "/repos/o/r/releases/tags/staging", + get(|State(s): State| async move { + let names: Vec = s.lock().unwrap().assets.iter().map(|(n, _)| json!({"name": n})).collect(); + Json(json!({"id": 42, "assets": names})) + }), + ) + .route( + "/repos/o/r/releases/42/assets", + post( + |State(s): State, Query(q): Query>, body: Bytes| async move { + s.lock().unwrap().assets.push((q["name"].clone(), body.len())); + Json(json!({})) + }, + ), + ) + .route( + "/repos/o/r/git/ref/heads/main", + get(|| async { Json(json!({"object": {"sha": "abc123"}})) }), + ) + .route( + "/repos/o/r/git/refs", + post(|State(s): State, Json(b): Json| async move { + assert_eq!(b["sha"], "abc123"); + let name = b["ref"].as_str().unwrap().trim_start_matches("refs/heads/").to_owned(); + let mut s = s.lock().unwrap(); + if s.branches.contains(&name) { + return Err(StatusCode::UNPROCESSABLE_ENTITY); + } + s.branches.push(name); + Ok(Json(json!({}))) + }), + ) + .route( + "/repos/o/r/pulls", + post(|State(s): State, Json(b): Json| async move { + let mut s = s.lock().unwrap(); + s.pulls.push(b); + Json(json!({"html_url": format!("https://github.com/o/r/pull/{}", s.pulls.len())})) + }), + ) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (base, state) +} + +fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../horizon-mods/tests/index") +} + +struct Client { + app: Router, + cookies: HashMap, +} + +impl Client { + async fn send(&mut self, request: Request) -> (StatusCode, Value, Option) { + let response = self.app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + for c in response.headers().get_all(header::SET_COOKIE) { + let (pair, _) = c.to_str().unwrap().split_once(';').unwrap(); + let (k, v) = pair.split_once('=').unwrap(); + self.cookies.insert(k.into(), v.into()); + } + let location = response + .headers() + .get(header::LOCATION) + .map(|l| l.to_str().unwrap().to_owned()); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + location, + ) + } + + fn cookie_header(&self) -> String { + self.cookies + .iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("; ") + } + + fn get(&self, uri: &str) -> Request { + Request::get(uri) + .header(header::COOKIE, self.cookie_header()) + .body(Body::empty()) + .unwrap() + } + + async fn sign_in(&mut self) -> String { + let (status, _, location) = self.send(self.get("/api/mods/auth/login")).await; + assert_eq!(status, StatusCode::SEE_OTHER); + let location = location.unwrap(); + assert!(location.contains( + "redirect_uri=https%3A%2F%2Fplayhorizon.test%2Fapi%2Fmods%2Fauth%2Fcallback" + )); + let state = location + .split("state=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap() + .to_owned(); + // Another state than the one this browser started with is refused. + let (status, ..) = self + .send(self.get("/api/mods/auth/callback?code=good&state=other")) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + let (status, _, location) = self + .send(self.get(&format!("/api/mods/auth/callback?code=good&state={state}"))) + .await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert_eq!( + location.as_deref(), + Some("https://playhorizon.test/mods/submit") + ); + let (status, me, _) = self.send(self.get("/api/mods/me")).await; + assert_eq!(status, StatusCode::OK); + me["csrf"].as_str().unwrap().to_owned() + } + + async fn submit(&mut self, csrf: &str, form: &Value, file: &[u8]) -> (StatusCode, Value) { + let boundary = "XhorizonX"; + let mut body = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"entry\"\r\n\r\n{form}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"f\"\r\nContent-Type: application/octet-stream\r\n\r\n" + ) + .into_bytes(); + body.extend_from_slice(file); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + let request = Request::post("/api/mods/submit") + .header(header::COOKIE, self.cookie_header()) + .header(header::ORIGIN, PUBLIC) + .header("x-csrf-token", csrf) + .header( + header::CONTENT_TYPE, + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(); + let (status, json, _) = self.send(request).await; + (status, json) + } +} + +fn form() -> Value { + let key = std::fs::read_to_string(fixtures().join("test.pub")).unwrap(); + json!({ + "id": "coords_hud", + "kind": "mod", + "name": "Coordinates HUD", + "summary": "Your coordinates in a corner.", + "authors": ["Alice"], + "license": "MIT", + "source": "https://github.com/alice/coords", + "key": key.lines().last().unwrap(), + "signature": std::fs::read_to_string(fixtures().join("coords_hud-0.1.0.hmod.minisig")).unwrap(), + "version": "0.1.0", + "game": "26.3", + }) +} + +async fn setup() -> (Client, Shared) { + let (base, mock) = mock_github().await; + let scratch = + std::env::temp_dir().join(format!("horizon-mod-registry-test-{}", std::process::id())); + let config = Config { + public_url: PUBLIC.into(), + session_key: b"a test key of at least thirty-two bytes".to_vec(), + github: github::GitHub::new( + github::Endpoints { + web: base.clone(), + api: base.clone(), + uploads: base, + }, + "o/r".into(), + "client".into(), + "secret".into(), + "bot".into(), + ), + scratch, + }; + let client = Client { + app: router(App::new(config)), + cookies: HashMap::new(), + }; + (client, mock) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_submission_becomes_a_pull_request() { + let (mut client, mock) = setup().await; + let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap(); + // Not signed in. + let (status, _) = client.submit("x", &form(), &file).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let csrf = client.sign_in().await; + let (status, _) = client.submit("wrong", &form(), &file).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + let (status, json) = client.submit(&csrf, &form(), &file).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["pull_request"], "https://github.com/o/r/pull/1"); + { + let m = mock.lock().unwrap(); + assert_eq!(m.assets, [(format!("{HMOD_SHA}.hmod"), file.len())]); + assert_eq!(m.branches, ["submit/coords_hud-0.1.0"]); + let (text, message, replaced) = &m.commits[&( + "submit/coords_hud-0.1.0".into(), + "mods/coords_hud.toml".into(), + )]; + let entry: Entry = toml::from_str(text).unwrap(); + assert_eq!(entry.owners, ["alice"]); + assert_eq!(entry.versions[0].sha256, HMOD_SHA); + assert_eq!(entry.versions[0].file, "coords_hud-0.1.0.hmod"); + assert!(entry.versions[0].signature.is_some() && entry.key.is_some()); + assert!(message.contains("Co-authored-by: alice <7+alice@users.noreply.github.com>")); + assert_eq!(replaced, &None); + let body = m.pulls[0]["body"].as_str().unwrap(); + assert!(body.starts_with(""), "{body}"); + assert!(body.contains("may draw text on your screen"), "{body}"); + } + // The same version again: its branch is there. + let (status, json) = client.submit(&csrf, &form(), &file).await; + assert_eq!(status, StatusCode::CONFLICT, "{json}"); + assert_eq!(mock.lock().unwrap().assets.len(), 1, "uploaded once"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn what_the_form_refuses() { + let (mut client, mock) = setup().await; + let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap(); + let csrf = client.sign_in().await; + let refused = |status: StatusCode, json: &Value, part: &str| { + assert!( + json["error"].as_str().unwrap_or("").contains(part), + "{status} {json}" + ); + }; + // A file that isn't what the page says. + let mut f = form(); + f["id"] = json!("other_mod"); + let (status, json) = client.submit(&csrf, &f, &file).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + refused(status, &json, "the file's id is \"coords_hud\""); + // A broken signature. + let mut f = form(); + f["signature"] = json!( + std::fs::read_to_string(fixtures().join("coords_hud-0.1.0.hmod.minisig")) + .unwrap() + .replace("RUTV", "RUTW") + ); + let (status, json) = client.submit(&csrf, &f, &file).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{json}"); + // Shader packs aren't open. + let mut f = form(); + f["kind"] = json!("shader"); + let (status, json) = client.submit(&csrf, &f, &file).await; + refused(status, &json, "shader packs open"); + // An update by someone who isn't an owner. + let owned = "id = \"coords_hud\"\nkind = \"mod\"\nname = \"C\"\nsummary = \"s\"\nowners = [\"bob\"]\nlicense = \"MIT\"\nsource = \"https://x.test\"\n\n[[versions]]\nversion = \"0.0.1\"\npublished = \"2026-01-01\"\ngame = \"26.3\"\nfile = \"coords_hud-0.0.1.hmod\"\nsha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n"; + mock.lock() + .unwrap() + .main + .insert("mods/coords_hud.toml".into(), owned.into()); + let (status, json) = client.submit(&csrf, &form(), &file).await; + assert_eq!(status, StatusCode::FORBIDDEN); + refused(status, &json, "bob's"); + // From another site. + let mut request = Request::post("/api/mods/submit") + .header(header::COOKIE, client.cookie_header()) + .header(header::ORIGIN, "https://evil.test") + .header("x-csrf-token", &csrf) + .body(Body::empty()) + .unwrap(); + request.headers_mut().insert( + header::CONTENT_TYPE, + "multipart/form-data; boundary=x".parse().unwrap(), + ); + let (status, ..) = client.send(request).await; + assert_eq!(status, StatusCode::FORBIDDEN); + let m = mock.lock().unwrap(); + assert!( + m.assets.is_empty() && m.pulls.is_empty(), + "nothing reached GitHub" + ); +} diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 519568282..26bb96316 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -1616,6 +1616,125 @@ impl Codec> for StatList { } } +/// `ClientboundBossEventPacket` (`boss_event`). +#[derive(Clone, Debug, PartialEq)] +pub enum BossAction { + Add { + name: Tag, + progress: f32, + /// `BossBarColor` ordinal. + color: i32, + /// `BossBarOverlay` ordinal. + overlay: i32, + darken: bool, + music: bool, + fog: bool, + }, + Remove, + UpdateProgress { + progress: f32, + }, + UpdateName { + name: Tag, + }, + UpdateStyle { + color: i32, + overlay: i32, + }, + UpdateProperties { + darken: bool, + music: bool, + fog: bool, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BossEvent { + pub id: u128, + pub action: BossAction, +} +fn boss_flags(darken: bool, music: bool, fog: bool) -> u8 { + u8::from(darken) | (u8::from(music) << 1) | (u8::from(fog) << 2) +} +fn split_flags(flags: u8) -> (bool, bool, bool) { + (flags & 1 != 0, flags & 2 != 0, flags & 4 != 0) +} +impl Codec for BossEvent { + fn read(r: &mut Reader) -> Result { + let id = r.uuid()?; + let action = match r.varint()? { + 0 => { + let name = r.nbt()?; + let progress = r.f32()?; + let color = r.varint()?; + let overlay = r.varint()?; + let (darken, music, fog) = split_flags(r.u8()?); + BossAction::Add { + name, + progress, + color, + overlay, + darken, + music, + fog, + } + } + 1 => BossAction::Remove, + 2 => BossAction::UpdateProgress { progress: r.f32()? }, + 3 => BossAction::UpdateName { name: r.nbt()? }, + 4 => BossAction::UpdateStyle { + color: r.varint()?, + overlay: r.varint()?, + }, + 5 => { + let (darken, music, fog) = split_flags(r.u8()?); + BossAction::UpdateProperties { darken, music, fog } + } + op => return Err(Error::new(format!("unknown boss event operation {op}"))), + }; + Ok(BossEvent { id, action }) + } + fn write(v: &BossEvent, w: &mut Writer) { + w.uuid(v.id); + match &v.action { + BossAction::Add { + name, + progress, + color, + overlay, + darken, + music, + fog, + } => { + w.varint(0); + w.nbt(name); + w.f32(*progress); + w.varint(*color); + w.varint(*overlay); + w.u8(boss_flags(*darken, *music, *fog)); + } + BossAction::Remove => w.varint(1), + BossAction::UpdateProgress { progress } => { + w.varint(2); + w.f32(*progress); + } + BossAction::UpdateName { name } => { + w.varint(3); + w.nbt(name); + } + BossAction::UpdateStyle { color, overlay } => { + w.varint(4); + w.varint(*color); + w.varint(*overlay); + } + BossAction::UpdateProperties { darken, music, fog } => { + w.varint(5); + w.u8(boss_flags(*darken, *music, *fog)); + } + } + } +} + packet_enum!(Clientbound, crate::ids::play::clientbound { BundleDelimiter(Empty) = BUNDLE_DELIMITER, AddEntity(AddEntity) = ADD_ENTITY, @@ -1626,6 +1745,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { BlockEntityData(BlockEntityData) = BLOCK_ENTITY_DATA, BlockEvent(BlockEvent) = BLOCK_EVENT, BlockUpdate(BlockUpdate) = BLOCK_UPDATE, + BossEvent(BossEvent) = BOSS_EVENT, ChangeDifficulty(ChangeDifficulty) = CHANGE_DIFFICULTY, ChunkBatchFinished(ChunkBatchFinished) = CHUNK_BATCH_FINISHED, ChunkBatchStart(Empty) = CHUNK_BATCH_START, @@ -2798,10 +2918,11 @@ mod tests { #[test] fn unknown_packets_keep_their_payload() { - let p = Clientbound::decode(crate::ids::play::clientbound::BOSS_EVENT, &[1, 2, 3]).unwrap(); + let p = + Clientbound::decode(crate::ids::play::clientbound::PLAYER_LOOK_AT, &[1, 2, 3]).unwrap(); assert_eq!( p, - Clientbound::Unknown(crate::ids::play::clientbound::BOSS_EVENT, vec![1, 2, 3]) + Clientbound::Unknown(crate::ids::play::clientbound::PLAYER_LOOK_AT, vec![1, 2, 3]) ); assert_eq!(p.encode()[1..], [1, 2, 3]); } @@ -2898,4 +3019,41 @@ mod tests { w.varint(101); assert!(BookPages::read(&mut Reader::new(&w.data)).is_err()); } + #[test] + fn boss_event_round_trips() { + let add = Clientbound::BossEvent(BossEvent { + id: 0x0123_4567_89ab_cdef_fedc_ba98_7654_3210, + action: BossAction::Add { + name: Tag::String("Wither".into()), + progress: 0.5, + color: 5, + overlay: 0, + darken: true, + music: false, + fog: true, + }, + }); + let body = add.encode(); + // Packet id 9, then the uuid, operation 0, and the property byte + // (darken 1, fog 4). + assert_eq!(body[0], 9); + assert_eq!(*body.last().unwrap(), 5); + round_trip(add, Clientbound::encode, Clientbound::decode); + round_trip( + Clientbound::BossEvent(BossEvent { + id: 7, + action: BossAction::UpdateProgress { progress: 0.25 }, + }), + Clientbound::encode, + Clientbound::decode, + ); + round_trip( + Clientbound::BossEvent(BossEvent { + id: 7, + action: BossAction::Remove, + }), + Clientbound::encode, + Clientbound::decode, + ); + } } diff --git a/crates/horizon-server/src/update.rs b/crates/horizon-server/src/update.rs index 8c87c66bc..06b498af9 100644 --- a/crates/horizon-server/src/update.rs +++ b/crates/horizon-server/src/update.rs @@ -781,6 +781,7 @@ mod tests { use super::*; use serde_json::json; + #[cfg(unix)] fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "horizon-server-update-{name}-{}", diff --git a/crates/horizon-shaders/Cargo.toml b/crates/horizon-shaders/Cargo.toml index ed3a24266..38e068b94 100644 --- a/crates/horizon-shaders/Cargo.toml +++ b/crates/horizon-shaders/Cargo.toml @@ -7,6 +7,8 @@ rust-version = "1.85" description = "Shader packs (the shaders/ folder format): reading packs, their .properties and .lang files, and later their GLSL" [dependencies] +horizon-core = { path = "../horizon-core" } +naga = { version = "30.0.1", features = ["glsl-in", "wgsl-in"] } zip = { version = "2", default-features = false, features = ["deflate"] } [lints] diff --git a/crates/horizon-shaders/src/condition.rs b/crates/horizon-shaders/src/condition.rs new file mode 100644 index 000000000..c476bb663 --- /dev/null +++ b/crates/horizon-shaders/src/condition.rs @@ -0,0 +1,369 @@ +//! The integer arithmetic of `#if` and `#elif` (spec 1.5, GLSL 4.60 §3.3): `defined`, integer +//! literals, macros expanded to their values, and the C operators with C precedence. +//! An identifier that is not a macro counts as 0, as in C: GLSL would reject the line, and a +//! pack would then lose a whole section of its settings over one unknown name. + +use crate::Macros; +use crate::macros::{is_ident, is_ident_start}; + +/// The value of a condition; `Err` says what is wrong with it. +pub fn eval_condition(expr: &str, macros: &Macros) -> Result { + let tokens = tokens(&resolve_defined(expr, macros)?, macros)?; + let mut parser = Parser { + tokens: &tokens, + at: 0, + }; + let value = parser.binary(0)?; + match parser.tokens.get(parser.at) { + None => Ok(value), + Some(t) => Err(format!("unexpected `{}`", t.text())), + } +} + +/// The text after a line's `#`, split into the directive word and the rest without its `//` +/// comment. `None` when the first word is followed by something other than white space, `//` or +/// the end of the line (`#ifdefault=1` is a comment, not `#ifdef`). +pub(crate) fn split_directive(after_hash: &str) -> Option<(&str, &str)> { + let line = after_hash.trim_start(); + let len = line + .find(|c: char| !c.is_ascii_alphabetic()) + .unwrap_or(line.len()); + let (word, rest) = line.split_at(len); + let ok = rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with("//"); + ok.then(|| (word, rest.split("//").next().unwrap_or("").trim())) +} + +/// One level of `#if` ... `#endif`. +struct Branch { + /// This branch's lines are read. + on: bool, + /// A branch of this `#if` was already taken (the rest are skipped). + taken: bool, + /// The enclosing level is read. + outer: bool, + seen_else: bool, +} + +/// The state of nested conditional directives. +#[derive(Default)] +pub(crate) struct Conditionals(Vec); + +impl Conditionals { + /// Lines here are read. + pub(crate) fn active(&self) -> bool { + self.0.last().is_none_or(|b| b.on) + } + + /// An `#if` is still open. + pub(crate) fn open(&self) -> bool { + !self.0.is_empty() + } + + /// Applies `#word rest` when it is a conditional directive (`Ok(false)` when it is not). A + /// condition that can't be evaluated reads as false and returns why. + pub(crate) fn apply( + &mut self, + word: &str, + rest: &str, + macros: &Macros, + ) -> Result { + let on = self.active(); + match word { + "if" | "ifdef" | "ifndef" => { + let value = if on { + test(word, rest, macros) + } else { + Ok(false) + }; + let v = *value.as_ref().unwrap_or(&false); + self.0.push(Branch { + on: v, + taken: v, + outer: on, + seen_else: false, + }); + value.map(|_| true) + } + "elif" | "else" => { + let b = self + .0 + .last_mut() + .ok_or_else(|| format!("`#{word}` without `#if`"))?; + if b.seen_else { + return Err(format!("`#{word}` after `#else`")); + } + b.seen_else = word == "else"; + let take = b.outer && !b.taken; + let value = match word { + "else" => Ok(take), + _ if take => test("if", rest, macros), + _ => Ok(false), + }; + b.on = *value.as_ref().unwrap_or(&false); + b.taken |= b.on; + value.map(|_| true) + } + "endif" => self + .0 + .pop() + .map(|_| true) + .ok_or_else(|| "`#endif` without `#if`".into()), + _ => Ok(false), + } + } +} + +fn test(word: &str, rest: &str, macros: &Macros) -> Result { + let name = rest.split_whitespace().next().unwrap_or(""); + match word { + "ifdef" => Ok(macros.is_defined(name)), + "ifndef" => Ok(!macros.is_defined(name)), + _ => eval_condition(rest, macros) + .map(|v| v != 0) + .map_err(|e| format!("`#{word} {rest}`: {e}; read as false")), + } +} + +/// `defined X` and `defined(X)` become `1` or `0` before anything is expanded. +fn resolve_defined(expr: &str, macros: &Macros) -> Result { + let mut out = String::with_capacity(expr.len()); + let mut rest = expr; + while let Some(at) = find_word(rest, "defined") { + out.push_str(&rest[..at]); + let after = rest[at + "defined".len()..].trim_start(); + let (inner, tail) = match after.strip_prefix('(') { + Some(open) => { + let close = open.find(')').ok_or("`defined(` without `)`")?; + (open[..close].trim(), &open[close + 1..]) + } + None => { + let len = after.find(|c: char| !is_ident(c)).unwrap_or(after.len()); + (&after[..len], &after[len..]) + } + }; + if inner.is_empty() || !inner.starts_with(is_ident_start) || !inner.chars().all(is_ident) { + return Err("`defined` needs a macro name".into()); + } + out.push_str(if macros.is_defined(inner) { + " 1 " + } else { + " 0 " + }); + rest = tail; + } + out.push_str(rest); + Ok(out) +} + +/// Where `word` starts in `text` as a whole identifier. +fn find_word(text: &str, word: &str) -> Option { + let mut from = 0; + while let Some(i) = text[from..].find(word).map(|i| i + from) { + let before = text[..i].chars().next_back().is_some_and(is_ident); + let after = text[i + word.len()..].chars().next().is_some_and(is_ident); + if !before && !after { + return Some(i); + } + from = i + word.len(); + } + None +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Token { + Num(i64), + Op(&'static str), +} + +impl Token { + fn text(&self) -> String { + match self { + Token::Num(n) => n.to_string(), + Token::Op(op) => (*op).to_owned(), + } + } +} + +const OPS: [&str; 22] = [ + "<<", ">>", "<=", ">=", "==", "!=", "&&", "||", "(", ")", "!", "~", "+", "-", "*", "/", "%", + "<", ">", "&", "^", "|", +]; + +fn tokens(expr: &str, macros: &Macros) -> Result, String> { + let expanded = macros.expand(expr); + let mut out = Vec::new(); + let mut rest = expanded.trim_start(); + while let Some(c) = rest.chars().next() { + if c.is_ascii_digit() { + let len = rest + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(rest.len()); + if rest[len..].starts_with('.') { + return Err(format!("`{}`: `#if` takes integers only", &rest[..len + 1])); + } + out.push(Token::Num(int_literal(&rest[..len])?)); + rest = &rest[len..]; + } else if is_ident_start(c) { + // Not a macro: 0, as the C preprocessor counts it. + let len = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len()); + out.push(Token::Num(0)); + rest = &rest[len..]; + } else { + let op = OPS + .iter() + .find(|op| rest.starts_with(**op)) + .ok_or_else(|| format!("unexpected `{c}`"))?; + out.push(Token::Op(op)); + rest = &rest[op.len()..]; + } + rest = rest.trim_start(); + } + Ok(out) +} + +/// Decimal, `0x` hexadecimal or leading-zero octal, with an optional `u` suffix. +fn int_literal(text: &str) -> Result { + let digits = text.strip_suffix(['u', 'U']).unwrap_or(text); + let parsed = if let Some(hex) = digits.strip_prefix("0x").or(digits.strip_prefix("0X")) { + i64::from_str_radix(hex, 16) + } else if digits.len() > 1 && digits.starts_with('0') { + i64::from_str_radix(&digits[1..], 8) + } else { + digits.parse() + }; + parsed.map_err(|_| format!("`{text}` is not an integer")) +} + +struct Parser<'a> { + tokens: &'a [Token], + at: usize, +} + +/// Binary operators from the loosest to the tightest (GLSL §5.1 / §3.3). +const LEVELS: [&[&str]; 10] = [ + &["||"], + &["&&"], + &["|"], + &["^"], + &["&"], + &["==", "!="], + &["<", ">", "<=", ">="], + &["<<", ">>"], + &["+", "-"], + &["*", "/", "%"], +]; + +impl Parser<'_> { + fn next(&mut self) -> Option { + let t = self.tokens.get(self.at).copied(); + self.at += 1; + t + } + + fn binary(&mut self, level: usize) -> Result { + if level == LEVELS.len() { + return self.unary(); + } + let mut left = self.binary(level + 1)?; + while let Some(&Token::Op(op)) = self.tokens.get(self.at) { + if !LEVELS[level].contains(&op) { + break; + } + self.at += 1; + let right = self.binary(level + 1)?; + left = apply(op, left, right)?; + } + Ok(left) + } + + fn unary(&mut self) -> Result { + match self.next() { + Some(Token::Num(n)) => Ok(n), + Some(Token::Op("(")) => { + let value = self.binary(0)?; + match self.next() { + Some(Token::Op(")")) => Ok(value), + _ => Err("missing `)`".into()), + } + } + Some(Token::Op("-")) => Ok(self.unary()?.wrapping_neg()), + Some(Token::Op("+")) => self.unary(), + Some(Token::Op("!")) => Ok((self.unary()? == 0) as i64), + Some(Token::Op("~")) => Ok(!self.unary()?), + Some(t) => Err(format!("unexpected `{}`", t.text())), + None => Err("the condition ends too early".into()), + } + } +} + +fn apply(op: &str, a: i64, b: i64) -> Result { + Ok(match op { + "||" => (a != 0 || b != 0) as i64, + "&&" => (a != 0 && b != 0) as i64, + "|" => a | b, + "^" => a ^ b, + "&" => a & b, + "==" => (a == b) as i64, + "!=" => (a != b) as i64, + "<" => (a < b) as i64, + ">" => (a > b) as i64, + "<=" => (a <= b) as i64, + ">=" => (a >= b) as i64, + "<<" => a.wrapping_shl(b as u32), + ">>" => a.wrapping_shr(b as u32), + "+" => a.wrapping_add(b), + "-" => a.wrapping_sub(b), + "*" => a.wrapping_mul(b), + "/" | "%" if b == 0 => return Err("division by zero".into()), + "/" => a.wrapping_div(b), + _ => a.wrapping_rem(b), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn eval(expr: &str) -> Result { + let mut m = Macros::standard(); + m.define("SHADOW_RES", "2048"); + m.define("WAVES", ""); + m.define("QUALITY", "SHADOW_RES / 1024"); + eval_condition(expr, &m) + } + + #[test] + fn precedence_and_operators_follow_c() { + assert_eq!(eval("1 + 2 * 3"), Ok(7)); + assert_eq!(eval("(1 + 2) * 3"), Ok(9)); + assert_eq!(eval("1 << 2 + 1"), Ok(8)); + assert_eq!(eval("6 & 3 == 3"), Ok(0)); + assert_eq!(eval("1 || 0 && 0"), Ok(1)); + assert_eq!(eval("-7 / 2"), Ok(-3)); + assert_eq!(eval("-7 % 2"), Ok(-1)); + assert_eq!(eval("!0 + ~0"), Ok(0)); + assert_eq!(eval("0x10 + 010 + 3u"), Ok(27)); + } + + #[test] + fn macros_and_defined() { + assert_eq!(eval("MC_VERSION >= 11300"), Ok(1)); + assert_eq!(eval("MC_VERSION <= 12111"), Ok(0)); + assert_eq!(eval("QUALITY == 2"), Ok(1)); + assert_eq!(eval("defined WAVES && defined(SHADOW_RES)"), Ok(1)); + assert_eq!(eval("!defined NOPE"), Ok(1)); + assert_eq!(eval("NOPE == 0"), Ok(1)); + assert_eq!(eval("undefinedFeature"), Ok(0)); + } + + #[test] + fn bad_conditions_say_why() { + assert!(eval("1 / 0").is_err()); + assert!(eval("SHADOW_RES == 0.5").is_err()); + assert!(eval("(1 + 2").is_err()); + assert!(eval("1 2").is_err()); + assert!(eval("").is_err()); + assert!(eval("defined").is_err()); + assert!(eval("1 @ 2").is_err()); + } +} diff --git a/crates/horizon-shaders/src/config.rs b/crates/horizon-shaders/src/config.rs new file mode 100644 index 000000000..0f21e4900 --- /dev/null +++ b/crates/horizon-shaders/src/config.rs @@ -0,0 +1,431 @@ +//! The configuration packs write into their stage files (spec 1.7, 6.1): which buffers a fragment +//! shader writes (`/* DRAWBUFFERS:0257 */`, `/* RENDERTARGETS: 0,2 */`) and, per colortex buffer, +//! its format (`const int colortex2Format = RGBA16F;`, inside a block comment because the format +//! names are not GLSL), whether and to what it is cleared, and whether it gets mipmaps. +//! +//! Everything is read from live code only: the preprocessor has already dropped the false +//! branches, so a format chosen by an option is the one the option selects. Every stage file of +//! the programs that run is read (OQ-17); a later file overrides an earlier one, with a note when +//! the values differ. + +use crate::Preprocessed; + +/// colortex buffers (6.1.1). +pub const BUFFERS: usize = 16; + +/// A colour format of 6.7, as channels, bits per channel and kind. Packed formats are described +/// by their widest channel: `RGB10_A2` is 4 × 10 bits, `R11F_G11F_B10F` 3 × 11 bits float. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Format { + pub channels: u8, + pub bits: u8, + pub kind: Kind, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Unorm, + Snorm, + Float, + Int, + Uint, +} + +impl Format { + /// The default `RGBA` (6.1.3). + pub const RGBA8: Format = Format { + channels: 4, + bits: 8, + kind: Kind::Unorm, + }; + + /// A format name of 6.7, in any case. + pub fn parse(name: &str) -> Option { + let name = name.trim().to_ascii_uppercase(); + let packed = |channels, bits, kind| { + Some(Format { + channels, + bits, + kind, + }) + }; + match name.as_str() { + "RGBA" => return Some(Self::RGBA8), + "R3_G3_B2" => return packed(3, 3, Kind::Unorm), + "RGB5_A1" => return packed(4, 5, Kind::Unorm), + "RGB10_A2" => return packed(4, 10, Kind::Unorm), + "RGB10_A2UI" => return packed(4, 10, Kind::Uint), + "R11F_G11F_B10F" => return packed(3, 11, Kind::Float), + "RGB9_E5" => return packed(3, 9, Kind::Float), + "RGBA4" => return packed(4, 4, Kind::Unorm), + "RGB565" => return packed(3, 6, Kind::Unorm), + "RGBA2" => return packed(4, 2, Kind::Unorm), + _ => {} + } + let letters = name.bytes().take_while(u8::is_ascii_alphabetic).count(); + let channels = match &name[..letters] { + "R" => 1, + "RG" => 2, + "RGB" => 3, + "RGBA" => 4, + _ => return None, + }; + let rest = &name[letters..]; + let digits = rest.bytes().take_while(u8::is_ascii_digit).count(); + let bits: u8 = rest[..digits].parse().ok()?; + let kind = match &rest[digits..] { + "" => Kind::Unorm, + "_SNORM" => Kind::Snorm, + "F" => Kind::Float, + "I" => Kind::Int, + "UI" => Kind::Uint, + _ => return None, + }; + let valid = match kind { + Kind::Unorm | Kind::Snorm => matches!(bits, 8 | 16), + Kind::Float => matches!(bits, 16 | 32), + Kind::Int | Kind::Uint => matches!(bits, 8 | 16 | 32), + }; + valid.then_some(Format { + channels, + bits, + kind, + }) + } +} + +/// The settings of one colortex buffer. +#[derive(Clone, Debug, PartialEq)] +pub struct Buffer { + pub format: Format, + /// Cleared every frame (`Clear`, default true). + pub clear: bool, + /// `ClearColor`, else 6.1.5's default: colortex0 the fog colour, colortex1 white, + /// the others transparent black. + pub clear_color: Option<[f32; 4]>, + /// `MipmapEnabled`. + pub mipmap: bool, +} + +impl Default for Buffer { + fn default() -> Self { + Self { + format: Format::RGBA8, + clear: true, + clear_color: None, + mipmap: false, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Config { + pub buffers: [Buffer; BUFFERS], + /// Conflicting or unreadable settings, as `file:line: message`. + pub notes: Vec, + format_set: [bool; BUFFERS], + /// Each setting read so far and its value, to report a later file changing it. + seen: Vec<(String, String)>, + gdepth: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + buffers: std::array::from_fn(|_| Buffer::default()), + notes: Vec::new(), + format_set: [false; BUFFERS], + seen: Vec::new(), + gdepth: false, + } + } +} + +/// The colortex index of a buffer name or alias (6.1.1). +pub fn buffer_index(name: &str) -> Option { + let alias = [ + "gcolor", + "gdepth", + "gnormal", + "composite", + "gaux1", + "gaux2", + "gaux3", + "gaux4", + ]; + if let Some(i) = alias.iter().position(|a| *a == name) { + return Some(i); + } + let n: usize = name.strip_prefix("colortex")?.parse().ok()?; + (n < BUFFERS).then_some(n) +} + +impl Config { + /// Reads one preprocessed stage file. + pub fn read(&mut self, pp: &Preprocessed) { + let line = |at: usize| pp.locate(code_line(&pp.code, at)); + for (at, ty, name, value) in constants(&pp.code) { + let place = line(at); + self.constant(&place, ty, name, value); + } + for (origin, comment) in &pp.comments { + for (_, ty, name, value) in constants(comment) { + let file = pp + .files + .get(origin.file as usize) + .map_or("?", String::as_str); + let place = format!("{file}:{}", origin.line); + self.constant(&place, ty, name, value); + } + } + if declares(&pp.code, "gdepth") { + self.gdepth = true; + } + } + + /// The settings once every file is read: buffer 1 becomes RGBA32F when a program declares + /// `gdepth` and the pack gave it no format (6.1.3). + pub fn finish(mut self) -> Self { + if self.gdepth && !self.format_set[1] { + self.buffers[1].format = Format { + channels: 4, + bits: 32, + kind: Kind::Float, + }; + } + self + } + + fn constant(&mut self, place: &str, ty: &str, name: &str, value: &str) { + let settings = [ + ("Format", "int"), + ("ClearColor", "vec4"), + ("Clear", "bool"), + ("MipmapEnabled", "bool"), + ]; + let Some((buffer, setting)) = settings.iter().find_map(|(suffix, want)| { + let buffer = buffer_index(name.strip_suffix(suffix)?)?; + (ty == *want).then_some((buffer, *suffix)) + }) else { + return; + }; + let b = &mut self.buffers[buffer]; + let ok = match setting { + "Format" => Format::parse(value).map(|f| { + b.format = f; + self.format_set[buffer] = true; + }), + "ClearColor" => vec4(value).map(|c| b.clear_color = Some(c)), + "Clear" => boolean(value).map(|v| b.clear = v), + _ => boolean(value).map(|v| b.mipmap = v), + }; + if ok.is_none() { + let problem = match setting { + "Format" => format!("unknown format `{value}`"), + "ClearColor" => format!("`{name}` is not a vec4: `{value}`"), + _ => format!("`{name}` is not a bool: `{value}`"), + }; + self.notes.push(format!("{place}: {problem}")); + return; + } + // The same setting under another name (an alias) counts as the same. + let key = format!("{buffer}{setting}"); + match self.seen.iter_mut().find(|(k, _)| *k == key) { + Some((_, old)) if old != value => { + self.notes.push(format!( + "{place}: `{name}` changed by a later file to `{value}`" + )); + *old = value.to_owned(); + } + Some(_) => {} + None => self.seen.push((key, value.to_owned())), + } + } +} + +/// The buffers a fragment shader writes, from the last `DRAWBUFFERS` or `RENDERTARGETS` comment +/// of its live code, or `None` without one (then it writes buffers 0 to 7). 6.1.4. +pub fn draw_buffers(pp: &Preprocessed) -> Option> { + let mut found = None; + for (_, comment) in &pp.comments { + let text = comment.trim(); + if let Some(digits) = text.strip_prefix("DRAWBUFFERS:") { + let list: Option> = digits.trim().chars().map(|c| c.to_digit(10)).collect(); + found = list.or(found); + } else if let Some(list) = text.strip_prefix("RENDERTARGETS:") { + let list: Option> = list + .split(',') + .map(|n| { + n.trim() + .parse() + .ok() + .filter(|&n: &u32| (n as usize) < BUFFERS) + }) + .collect(); + found = list.or(found); + } + } + found.map(|mut list| { + list.truncate(8); + list + }) +} + +/// `const = ;` anywhere in `text`: (byte offset, type, name, value). +fn constants(text: &str) -> Vec<(usize, &str, &str, &str)> { + let mut out = Vec::new(); + let mut from = 0; + while let Some(found) = text[from..].find("const") { + let at = from + found; + from = at + 5; + let before = text[..at].chars().next_back(); + if before.is_some_and(|c| c.is_alphanumeric() || c == '_') { + continue; + } + let rest = &text[from..]; + let Some(end) = rest.find(';') else { break }; + let decl = &rest[..end]; + let Some((left, value)) = decl.split_once('=') else { + continue; + }; + let mut words = left.split_whitespace(); + if let (Some(ty), Some(name), None) = (words.next(), words.next(), words.next()) { + out.push((at, ty, name, value.trim())); + } + } + out +} + +/// Whether `text` declares a uniform sampler called `name`. +fn declares(text: &str, name: &str) -> bool { + text.split(';').any(|decl| { + let words: Vec<&str> = decl.split_whitespace().collect(); + words.first() == Some(&"uniform") && words.last() == Some(&name) + }) +} + +fn code_line(code: &str, at: usize) -> usize { + code[..at].matches('\n').count() +} + +fn boolean(value: &str) -> Option { + match value { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +fn number(text: &str) -> Option { + let text = text.trim(); + text.strip_suffix(['f', 'F']).unwrap_or(text).parse().ok() +} + +/// `vec4(r, g, b, a)` or `vec4(x)`. +fn vec4(value: &str) -> Option<[f32; 4]> { + let inner = value + .strip_prefix("vec4")? + .trim() + .strip_prefix('(')? + .strip_suffix(')')?; + let parts: Option> = inner.split(',').map(number).collect(); + match parts?.as_slice() { + [x] => Some([*x; 4]), + [r, g, b, a] => Some([*r, *g, *b, *a]), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pack::tests::zip; + use crate::{Macros, Pack, preprocess}; + + fn stage(text: &str) -> Preprocessed { + let pack = Pack::from_zip_bytes("t", zip(&[("shaders/composite.fsh", text)])).unwrap(); + preprocess(&pack, "composite.fsh", &Macros::standard()) + } + + #[test] + fn formats_parse() { + let f = |channels, bits, kind| { + Some(Format { + channels, + bits, + kind, + }) + }; + assert_eq!(Format::parse("rgba16f"), f(4, 16, Kind::Float)); + assert_eq!(Format::parse("R32UI"), f(1, 32, Kind::Uint)); + assert_eq!(Format::parse("RG8_SNORM"), f(2, 8, Kind::Snorm)); + assert_eq!(Format::parse("R11F_G11F_B10F"), f(3, 11, Kind::Float)); + assert_eq!(Format::parse("RGB16"), f(3, 16, Kind::Unorm)); + assert_eq!(Format::parse("RGBA12"), None); + assert_eq!(Format::parse("R16_SNORMX"), None); + } + + #[test] + fn draw_buffers_and_constants_come_from_live_code() { + let pp = stage( + "#version 120\n\ + #define HQ\n\ + /*\n\ + const int colortex2Format = RGBA16F;\n\ + const int gaux4Format = R32F;\n\ + */\n\ + #ifdef HQ\n\ + /* RENDERTARGETS: 0,2,11 */\n\ + #else\n\ + /* DRAWBUFFERS:01 */\n\ + const bool colortex5Clear = true;\n\ + #endif\n\ + const bool colortex5Clear = false;\n\ + const vec4 colortex3ClearColor = vec4(0.5, 0.25, 0.0, 1.0f);\n\ + const bool colortex3MipmapEnabled = true;\n\ + const vec4 gnormalClearColor = vec4(1.0);\n\ + uniform sampler2D gdepth;\n\ + void main() {}\n", + ); + assert_eq!(draw_buffers(&pp), Some(vec![0, 2, 11])); + let mut config = Config::default(); + config.read(&pp); + let config = config.finish(); + let b = &config.buffers; + assert_eq!(b[2].format, Format::parse("RGBA16F").unwrap()); + assert_eq!(b[7].format, Format::parse("R32F").unwrap()); + assert!(!b[5].clear && b[0].clear); + assert_eq!(b[3].clear_color, Some([0.5, 0.25, 0.0, 1.0])); + assert!(b[3].mipmap && !b[2].mipmap); + assert_eq!(b[2].clear_color, Some([1.0; 4])); + assert_eq!(b[1].format, Format::parse("RGBA32F").unwrap(), "gdepth"); + assert!(config.notes.is_empty(), "{:?}", config.notes); + } + + #[test] + fn no_directive_and_conflicts() { + let pp = stage("void main() {}\n"); + assert_eq!(draw_buffers(&pp), None); + let a = + stage("/* const int colortex1Format = RGB8; */\nconst bool colortex1Clear = maybe;\n"); + let b = + stage("/* const int colortex1Format = RGBA16; const int colortex2Format = XYZ; */\n"); + let mut config = Config::default(); + config.read(&a); + config.read(&b); + let notes = config.notes.join("\n"); + assert!( + notes.contains("composite.fsh:2: `colortex1Clear` is not a bool"), + "{notes}" + ); + assert!( + notes.contains("composite.fsh:1: `colortex1Format` changed"), + "{notes}" + ); + assert!(notes.contains("unknown format `XYZ`"), "{notes}"); + assert_eq!( + config.finish().buffers[1].format, + Format::parse("RGBA16").unwrap() + ); + } +} diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index 1c9b46dd1..e31eaec6f 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -4,12 +4,35 @@ //! //! - [`Pack`]: a pack as a folder or a zip, with paths relative to its `shaders` folder (spec 1.1). //! - [`discover`]: the packs in a `shaderpacks` folder. +//! - [`Properties`]: `shaders.properties` and the id files, with their conditional directives (3.1, 1.5). //! - [`Lang`]: the pack's translated names (8.4). +//! - [`preprocess`]: a stage file through `#include` and the GLSL preprocessor, lines mapped back (1.4, 1.5). +//! - [`translate_composite`], [`translate_gbuffers`]: a program into naga modules and its interface (1.6, 2.2). +//! - [`Config`] and [`draw_buffers`]: the buffer settings and output lists stage files carry (1.7, 6.1). +//! - [`load_post`]: a dimension's `prepare`, `deferred`, `composite` and `final` programs, in order (2.1). +//! - [`Macros`] and [`eval_condition`]: the macros packs test, and the `#if` arithmetic (1.5). +mod condition; +mod config; mod discover; mod lang; +mod macros; mod pack; +mod post; +mod preprocess; +mod properties; +mod translate; +pub use condition::eval_condition; +pub use config::{BUFFERS, Buffer, Config, Format, Kind, buffer_index, draw_buffers}; pub use discover::{Found, discover}; pub use lang::Lang; +pub use macros::{MC_VERSION, Macros, RENDER_STAGES}; pub use pack::{Pack, PackError}; +pub use post::{Group, Post, PostProgram, dimension_folder, load_post}; +pub use preprocess::{Diagnostic, Origin, Preprocessed, preprocess}; +pub use properties::{Entry, Note, Properties}; +pub use translate::{ + Error, GbuffersInputs, Interface, Program, Sampler, Target, Uniform, translate_composite, + translate_gbuffers, +}; diff --git a/crates/horizon-shaders/src/macros.rs b/crates/horizon-shaders/src/macros.rs new file mode 100644 index 000000000..d8a184f41 --- /dev/null +++ b/crates/horizon-shaders/src/macros.rs @@ -0,0 +1,317 @@ +//! Object-like macros: the ones packs test in `#if` lines and use inside property values +//! (spec 1.5, 1.5.1). The GLSL preprocessor's function-like macros are not needed here. + +use std::collections::BTreeMap; + +/// `MC_VERSION` of the version Horizon plays: 26.3 → 260300. +pub const MC_VERSION: u32 = mc_version(horizon_core::version::VERSION_ID); + +/// The documented rule, `major * 10000 + minor * 100 + patch` (spec 1.5.1, OQ-1): `1.21` → 12100, +/// `1.7.10` → 10710. Parts that are not digits count as 0. +pub const fn mc_version(id: &str) -> u32 { + let bytes = id.as_bytes(); + let (mut parts, mut part, mut i) = ([0u32; 3], 0, 0); + while i < bytes.len() && part < 3 { + match bytes[i] { + b'.' => part += 1, + b @ b'0'..=b'9' => parts[part] = parts[part] * 10 + (b - b'0') as u32, + _ => break, + } + i += 1; + } + parts[0] * 10000 + parts[1] * 100 + parts[2] +} + +#[derive(Clone, Debug, PartialEq)] +struct Macro { + /// `None` for an object-like macro; the parameter names of a function-like one. + params: Option>, + body: String, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Macros(BTreeMap); + +/// `MC_RENDER_STAGE_*` in the order the stages draw (spec 5.4); each is its index (OQ-7). +pub const RENDER_STAGES: [&str; 24] = [ + "NONE", + "SKY", + "SUNSET", + "CUSTOM_SKY", + "SUN", + "MOON", + "STARS", + "VOID", + "TERRAIN_SOLID", + "TERRAIN_CUTOUT_MIPPED", + "TERRAIN_CUTOUT", + "ENTITIES", + "BLOCK_ENTITIES", + "DESTROY", + "OUTLINE", + "DEBUG", + "HAND_SOLID", + "TERRAIN_TRANSLUCENT", + "TRIPWIRE", + "PARTICLES", + "CLOUDS", + "RAIN_SNOW", + "WORLD_BORDER", + "HAND_TRANSLUCENT", +]; + +impl Macros { + /// The standard macros that need no renderer (spec 1.5.1): `MC_VERSION`, this system's + /// `MC_OS_*`, the GL and GLSL versions Horizon's translation accepts, the render stages and + /// the host settings at their defaults. The GPU vendor and family arrive with the renderer. + pub fn standard() -> Self { + let mut m = Self::default(); + m.define("MC_VERSION", MC_VERSION.to_string()); + let os = match std::env::consts::OS { + "windows" => "MC_OS_WINDOWS", + "macos" => "MC_OS_MAC", + "linux" => "MC_OS_LINUX", + _ => "MC_OS_OTHER", + }; + m.define(os, ""); + m.define("MC_GL_VERSION", "460"); + m.define("MC_GLSL_VERSION", "460"); + for (i, stage) in RENDER_STAGES.iter().enumerate() { + m.define(format!("MC_RENDER_STAGE_{stage}"), i.to_string()); + } + m.define("MC_RENDER_QUALITY", "1.0"); + m.define("MC_SHADOW_QUALITY", "1.0"); + m.define("MC_HAND_DEPTH", "0.125"); + m.define("MAX_COLOR_BUFFERS", "16"); + m + } + + pub fn define(&mut self, name: impl Into, value: impl Into) { + let body = value.into(); + self.0.insert(name.into(), Macro { params: None, body }); + } + + pub fn define_function(&mut self, name: &str, params: Vec, body: &str) { + let body = body.to_owned(); + self.0.insert( + name.to_owned(), + Macro { + params: Some(params), + body, + }, + ); + } + + pub fn undef(&mut self, name: &str) { + self.0.remove(name); + } + + pub fn is_defined(&self, name: &str) -> bool { + self.0.contains_key(name) + } + + /// The value of an object-like macro. + pub fn get(&self, name: &str) -> Option<&str> { + self.0 + .get(name) + .filter(|m| m.params.is_none()) + .map(|m| m.body.as_str()) + } + + /// `text` with every macro expanded, repeatedly; a macro is not expanded again inside its own + /// expansion, so `A` defined as `A + 1` stops. Function-like macros take their arguments + /// (expanded first, raw beside `##`, which pastes) when a `(` follows their name. + pub fn expand(&self, text: &str) -> String { + self.expand_hiding(text, &mut Vec::new()) + } + + fn expand_hiding<'a>(&'a self, text: &str, hidden: &mut Vec<&'a str>) -> String { + let mut out = String::with_capacity(text.len()); + let tokens = lex(text); + let mut i = 0; + while i < tokens.len() { + let word = tokens[i]; + i += 1; + let Some((name, m)) = self.0.get_key_value(word) else { + out.push_str(word); + continue; + }; + if hidden.contains(&name.as_str()) { + out.push_str(word); + continue; + } + let body = match &m.params { + None => m.body.clone(), + Some(params) => { + let Some((args, next)) = arguments(&tokens, i) else { + out.push_str(word); + continue; + }; + i = next; + self.substitute(params, &args, &m.body, hidden) + } + }; + hidden.push(name); + out.push_str(&self.expand_hiding(&body, hidden)); + hidden.pop(); + } + out + } + + /// A function-like macro's body with its parameters replaced. + fn substitute<'a>( + &'a self, + params: &[String], + args: &[String], + body: &str, + hidden: &mut Vec<&'a str>, + ) -> String { + let tokens = lex(body); + let pasted = |at: usize, step: isize| { + let mut j = at as isize + step; + while (0..tokens.len() as isize).contains(&j) && tokens[j as usize].trim().is_empty() { + j += step; + } + (0..tokens.len() as isize).contains(&j) && tokens[j as usize] == "##" + }; + let mut out = String::new(); + for (at, token) in tokens.iter().enumerate() { + match params.iter().position(|p| p == token) { + Some(k) => { + let arg = args.get(k).map_or("", |a| a.trim()); + if pasted(at, -1) || pasted(at, 1) { + out.push_str(arg); + } else { + out.push_str(&self.expand_hiding(arg, hidden)); + } + } + None => out.push_str(token), + } + } + // `a ## b` → `ab`. + let mut joined = String::with_capacity(out.len()); + for (k, part) in out.split("##").enumerate() { + joined.push_str(if k == 0 { part.trim_end() } else { part.trim() }); + } + if out.contains("##") { joined } else { out } + } +} + +/// The arguments of a call whose `(` is the next non-blank token from `i`, split at top-level +/// commas, and the index after its `)`. `None` when no `(` follows or it never closes. +fn arguments(tokens: &[&str], mut i: usize) -> Option<(Vec, usize)> { + while tokens.get(i)?.trim().is_empty() { + i += 1; + } + if tokens[i] != "(" { + return None; + } + let (mut depth, mut args, mut from) = (0, Vec::new(), i + 1); + for (j, t) in tokens.iter().enumerate().skip(i + 1) { + match *t { + "(" => depth += 1, + ")" if depth == 0 => { + args.push(tokens[from..j].concat()); + return Some((args, j + 1)); + } + ")" => depth -= 1, + "," if depth == 0 => { + args.push(tokens[from..j].concat()); + from = j + 1; + } + _ => {} + } + } + None +} + +/// Preprocessing tokens: identifiers, numbers (with their suffixes and exponents), runs of +/// white space, `##`, and single characters. Concatenated they give back `text`. +pub(crate) fn lex(text: &str) -> Vec<&str> { + let bytes = text.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i] as char; + let start = i; + i += 1; + if is_ident_start(c) { + while i < bytes.len() && is_ident(bytes[i] as char) { + i += 1; + } + } else if c.is_ascii_digit() || (c == '.' && bytes.get(i).is_some_and(u8::is_ascii_digit)) { + while i < bytes.len() { + let d = bytes[i] as char; + let exp_sign = (d == '+' || d == '-') && matches!(bytes[i - 1], b'e' | b'E'); + if !(is_ident(d) || d == '.' || exp_sign) { + break; + } + i += 1; + } + } else if c.is_whitespace() { + while i < bytes.len() && (bytes[i] as char).is_whitespace() { + i += 1; + } + } else if c == '#' && bytes.get(i) == Some(&b'#') { + i += 1; + } else { + while !text.is_char_boundary(i) { + i += 1; + } + } + out.push(&text[start..i]); + } + out +} + +pub(crate) fn is_ident_start(c: char) -> bool { + c.is_ascii_alphabetic() || c == '_' +} + +pub(crate) fn is_ident(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_follow_the_documented_rule() { + assert_eq!(mc_version("1.21"), 12100); + assert_eq!(mc_version("1.21.0"), 12100); + assert_eq!(mc_version("1.7.10"), 10710); + assert_eq!(mc_version("26.3"), 260300); + assert_eq!(MC_VERSION, 260300); + assert_eq!(Macros::standard().get("MC_VERSION"), Some("260300")); + } + + #[test] + fn expansion_replaces_whole_words_and_stops_on_recursion() { + let mut m = Macros::default(); + m.define("RES", "SIZE"); + m.define("SIZE", "512"); + m.define("LOOP", "LOOP + 1"); + assert_eq!(m.expand("RES RES 0.5"), "512 512 0.5"); + assert_eq!( + m.expand("RESOLUTION colortex4 LOOP"), + "RESOLUTION colortex4 LOOP + 1" + ); + m.define("e5", "x"); + assert_eq!(m.expand("1e5"), "1e5"); + } + + #[test] + fn function_like_macros_take_arguments_and_paste() { + let mut m = Macros::default(); + m.define("N", "4"); + m.define_function("SQ", vec!["x".into()], "((x) * (x))"); + m.define_function("CAT", vec!["a".into(), "b".into()], "a ## b"); + m.define_function("PI", vec![], "3.14"); + assert_eq!(m.expand("SQ(N + 1)"), "((4 + 1) * (4 + 1))"); + assert_eq!(m.expand("SQ(min(N, 2))"), "((min(4, 2)) * (min(4, 2)))"); + assert_eq!(m.expand("CAT(N, 2) CAT(col, ortex4)"), "N2 colortex4"); + assert_eq!(m.expand("PI() SQ SQ(1"), "3.14 SQ SQ(1"); + assert_eq!(m.get("SQ"), None); + } +} diff --git a/crates/horizon-shaders/src/pack.rs b/crates/horizon-shaders/src/pack.rs index 113db9ac9..ae2230618 100644 --- a/crates/horizon-shaders/src/pack.rs +++ b/crates/horizon-shaders/src/pack.rs @@ -170,7 +170,7 @@ impl Pack { } /// `a/./b/../c` → `a/c`; `None` for a path that climbs out of `shaders`. -fn normalize(path: &str) -> Option { +pub(crate) fn normalize(path: &str) -> Option { let mut parts: Vec<&str> = Vec::new(); for part in path.split(['/', '\\']) { match part { diff --git a/crates/horizon-shaders/src/post.rs b/crates/horizon-shaders/src/post.rs new file mode 100644 index 000000000..cdf7fae80 --- /dev/null +++ b/crates/horizon-shaders/src/post.rs @@ -0,0 +1,360 @@ +//! The post-processing programs of one dimension, ready for the renderer: `prepare`, `deferred`, +//! `composite` and `final` with their numbered variants, in the order they run (spec 2.1), each +//! preprocessed and translated, with the buffers it writes and the flips that follow it, and the +//! buffer settings their files carry (6.1). +//! +//! Decisions (impl-plan.md): +//! - OQ-12: a dimension's folder (`world0`, `world-1`, `world1`) replaces the base folder for that +//! dimension when it exists; every stage file is read from it. +//! - OQ-29: a program with a geometry stage is reported and treated as missing. +//! - A program that fails to translate is reported and treated as missing (2.5). +//! - Terrain draws with the first program of its fallback chain that loads (2.3). + +use crate::config::{self, Config}; +use crate::{GbuffersInputs, Macros, Pack, Preprocessed, Program, Properties, Target}; +use crate::{eval_condition, preprocess, translate_composite, translate_gbuffers}; + +/// Which group of 2.1 a program belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Group { + Prepare, + Deferred, + Composite, + Final, + /// A gbuffers program: the game draws its geometry with it. + Gbuffers, +} + +/// `gbuffers_terrain` and its fallbacks (2.3). +const TERRAIN: [&str; 4] = [ + "gbuffers_terrain", + "gbuffers_textured_lit", + "gbuffers_textured", + "gbuffers_basic", +]; + +const GROUPS: [(Group, &str); 4] = [ + (Group::Prepare, "prepare"), + (Group::Deferred, "deferred"), + (Group::Composite, "composite"), + (Group::Final, "final"), +]; + +pub struct PostProgram { + /// The program's name (`composite3`). + pub name: String, + pub group: Group, + pub program: Program, + /// colortex buffer of each fragment output location (location n writes `buffers[n]`). + pub buffers: Vec, + /// `flip..`: buffers forced to flip (true) or kept (false) after this program. + pub flips: Vec<(u32, bool)>, +} + +pub struct Post { + /// The folder the programs came from: `""` for `shaders` itself, else `world0`... + pub folder: String, + pub programs: Vec, + /// The flips of the virtual `deferred_pre` and `composite_pre` (6.1.6). + pub deferred_pre: Vec<(u32, bool)>, + pub composite_pre: Vec<(u32, bool)>, + /// What draws solid and cutout terrain, when the game asked for it and one loads. + pub terrain: Option, + pub config: Config, + /// Programs skipped and why, as `program: message` or `file:line: message`. + pub notes: Vec, +} + +/// The folder a dimension reads its programs from: `world` when the pack has it, else the +/// base folder `""` (1.2). `id` is the legacy number: 0 the Overworld, -1 the Nether, 1 the End. +pub fn dimension_folder(pack: &Pack, id: i32) -> String { + let folder = format!("world{id}"); + if pack.list("").iter().any(|f| f == &folder) || !pack.list(&folder).is_empty() { + folder + } else { + String::new() + } +} + +/// Loads the post-processing programs of `folder` and, given the game's terrain vertices, the +/// program terrain draws with. +pub fn load_post( + pack: &Pack, + folder: &str, + properties: &Properties, + macros: &Macros, + terrain: Option<&GbuffersInputs>, +) -> Post { + let mut post = Post { + folder: folder.to_owned(), + programs: Vec::new(), + deferred_pre: flips(properties, "deferred_pre"), + composite_pre: flips(properties, "composite_pre"), + terrain: None, + config: Config::default(), + notes: Vec::new(), + }; + let mut loader = Loader { + pack, + folder, + properties, + macros, + post: &mut post, + }; + for (group, base) in GROUPS { + let names: Vec = if group == Group::Final { + vec![base.to_owned()] + } else { + std::iter::once(base.to_owned()) + .chain((1..=99).map(|n| format!("{base}{n}"))) + .collect() + }; + let target = match group { + Group::Final => Target::Window, + _ => Target::Buffers, + }; + for name in names { + if let Some(p) = loader.load(name, group, |v, f| translate_composite(v, f, target)) { + loader.post.programs.push(p); + } + } + } + if let Some(inputs) = terrain { + loader.post.terrain = TERRAIN.iter().find_map(|name| { + loader.load(name.to_string(), Group::Gbuffers, |v, f| { + translate_gbuffers(v, f, Target::Buffers, inputs) + }) + }); + } + post.config = std::mem::take(&mut post.config).finish(); + post +} + +struct Loader<'a> { + pack: &'a Pack, + folder: &'a str, + properties: &'a Properties, + macros: &'a Macros, + post: &'a mut Post, +} + +impl Loader<'_> { + /// The program `name` translated by `translate`, or `None` when it is missing, disabled or + /// fails (with a note). + fn load( + &mut self, + name: String, + group: Group, + translate: impl FnOnce(&Preprocessed, &Preprocessed) -> Result>, + ) -> Option { + let path = |ext: &str| match self.folder { + "" => format!("{name}.{ext}"), + f => format!("{f}/{name}.{ext}"), + }; + let (vsh, fsh) = (path("vsh"), path("fsh")); + let notes = &mut self.post.notes; + if !self.pack.exists(&fsh) || !self.pack.exists(&vsh) { + return None; + } + if !enabled(self.properties, self.folder, &name, self.macros) { + return None; + } + if self.pack.exists(&path("gsh")) { + notes.push(format!( + "{name}: uses a geometry stage (not supported); skipped" + )); + return None; + } + let vertex = preprocess(self.pack, &vsh, self.macros); + let fragment = preprocess(self.pack, &fsh, self.macros); + let problems: Vec = [&vertex, &fragment] + .iter() + .flat_map(|pp| diagnostics(pp)) + .collect(); + if !problems.is_empty() { + notes.extend(problems); + notes.push(format!("{name}: skipped")); + return None; + } + let program = match translate(&vertex, &fragment) { + Ok(p) => p, + Err(errors) => { + notes.extend( + errors + .iter() + .map(|e| format!("{}:{}: {}", e.file, e.line, e.message)), + ); + notes.push(format!("{name}: skipped")); + return None; + } + }; + self.post.config.read(&vertex); + self.post.config.read(&fragment); + let buffers = config::draw_buffers(&fragment).unwrap_or_else(|| (0..8).collect()); + Some(PostProgram { + flips: flips(self.properties, &name), + name, + group, + program, + buffers, + }) + } +} + +fn diagnostics(pp: &Preprocessed) -> Vec { + pp.diagnostics + .iter() + .map(|d| { + let file = pp + .files + .get(d.origin.file as usize) + .map_or("?", String::as_str); + format!("{file}:{}: {}", d.origin.line, d.message) + }) + .collect() +} + +/// `program..enabled` (or `program./.enabled`): a boolean expression over +/// switch options (3.3). An option is on when its macro is defined. +fn enabled(properties: &Properties, folder: &str, name: &str, macros: &Macros) -> bool { + let keys = [ + format!("program.{folder}/{name}.enabled"), + format!("program.{name}.enabled"), + ]; + let Some(expr) = keys.iter().find_map(|k| properties.get(k)) else { + return true; + }; + // `true`/`false` as numbers, every other name as `defined(name)`, then the `#if` arithmetic. + let mut condition = String::new(); + for token in crate::macros::lex(expr) { + match token { + "true" => condition.push('1'), + "false" => condition.push('0'), + t if t.starts_with(|c: char| c.is_alphabetic() || c == '_') => { + condition.push_str(&format!("defined({t})")); + } + t => condition.push_str(t), + } + } + eval_condition(&condition, macros).ok() != Some(0) +} + +/// `flip..=` keys of one program. +fn flips(properties: &Properties, program: &str) -> Vec<(u32, bool)> { + let prefix = format!("flip.{program}."); + properties + .entries + .iter() + .filter_map(|e| { + let buffer = config::buffer_index(e.key.strip_prefix(&prefix)?)?; + let value = match e.value.trim() { + "true" => true, + "false" => false, + _ => return None, + }; + Some((buffer as u32, value)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pack::tests::zip; + + const VSH: &str = "#version 120\nvarying vec2 uv;\nvoid main() {\n\ + gl_Position = ftransform();\nuv = gl_MultiTexCoord0.xy;\n}\n"; + const FSH: &str = "#version 120\nuniform sampler2D colortex0;\nvarying vec2 uv;\n\ + void main() {\n/* DRAWBUFFERS:3 */\n\ + gl_FragData[0] = texture2D(colortex0, uv);\n}\n"; + + #[test] + fn programs_load_in_order_with_their_settings() { + let pack = Pack::from_zip_bytes( + "t", + zip(&[ + ("shaders/composite.vsh", VSH), + ("shaders/composite.fsh", FSH), + ("shaders/composite2.vsh", VSH), + ("shaders/composite2.fsh", FSH), + ("shaders/deferred.vsh", VSH), + ( + "shaders/deferred.fsh", + "#version 120\nvoid main() { gl_FragColor = vec4(1.0); }\n", + ), + ("shaders/composite1.fsh", FSH), + ("shaders/composite3.vsh", VSH), + ("shaders/composite3.fsh", FSH), + ("shaders/composite4.vsh", VSH), + ("shaders/composite4.fsh", FSH), + ("shaders/composite4.gsh", "x"), + ("shaders/composite5.vsh", VSH), + ( + "shaders/composite5.fsh", + "#version 120\nvoid main() { undefined(); }\n", + ), + ("shaders/final.vsh", VSH), + ("shaders/final.fsh", FSH), + ("shaders/world-1/final.vsh", VSH), + ("shaders/world-1/final.fsh", FSH), + ]), + ) + .unwrap(); + let properties = Properties::parse( + "program.composite3.enabled = HQ && !LOW\nflip.composite2.gaux1=true\n\ + flip.composite.colortex3 = false\nflip.composite_pre.colortex1=true\n", + &Macros::default(), + ); + assert_eq!(dimension_folder(&pack, 0), ""); + assert_eq!(dimension_folder(&pack, -1), "world-1"); + let post = load_post(&pack, "", &properties, &Macros::standard(), None); + let names: Vec<&str> = post.programs.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["deferred", "composite", "composite2", "final"]); + let p = &post.programs; + assert_eq!(p[0].buffers, (0..8).collect::>()); + assert_eq!(p[1].buffers, [3]); + assert_eq!(p[1].flips, [(3, false)]); + assert_eq!(p[2].flips, [(4, true)]); + assert_eq!(post.composite_pre, [(1, true)]); + assert_eq!(p[3].group, Group::Final); + let notes = post.notes.join("\n"); + assert!( + notes.contains("composite4: uses a geometry stage"), + "{notes}" + ); + assert!(notes.contains("composite5.fsh:2:"), "{notes}"); + assert!(notes.contains("composite5: skipped"), "{notes}"); + + let mut macros = Macros::standard(); + macros.define("HQ", ""); + let post = load_post(&pack, "world-1", &properties, ¯os, None); + let names: Vec<&str> = post.programs.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["final"]); + let post = load_post(&pack, "", &properties, ¯os, None); + assert!(post.programs.iter().any(|p| p.name == "composite3")); + } + + #[test] + fn horizons_test_pack_loads() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/packs/post-test"); + let pack = Pack::open(&dir).unwrap(); + let macros = Macros::standard(); + let properties = Properties::parse(&pack.read_text("shaders.properties").unwrap(), ¯os); + let post = load_post( + &pack, + &dimension_folder(&pack, 0), + &properties, + ¯os, + None, + ); + assert!(post.notes.is_empty(), "{:?}", post.notes); + let names: Vec<&str> = post.programs.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["composite", "composite1", "final"]); + assert_eq!(post.programs[0].buffers, [0, 2]); + assert_eq!(post.programs[1].buffers, [0]); + assert_eq!( + post.config.buffers[2].format, + crate::Format::parse("RGBA16F").unwrap() + ); + } +} diff --git a/crates/horizon-shaders/src/preprocess.rs b/crates/horizon-shaders/src/preprocess.rs new file mode 100644 index 000000000..4884f28f7 --- /dev/null +++ b/crates/horizon-shaders/src/preprocess.rs @@ -0,0 +1,374 @@ +//! The GLSL preprocessor a program's stage file goes through before translation (spec 1.4, +//! 1.5, 1.6): comments removed, `#include` expanded, conditionals evaluated, macros expanded. +//! Every output line remembers the pack file and line it came from, so errors found later point +//! at the pack's own code. +//! +//! `#include` is expanded where it stands in live code only: an include inside a false `#if` +//! is not read, so it can't fail (OQ-14). Ten levels deep at most, which also stops a file that +//! includes itself. `#version` and `#extension` are taken out of the code and kept apart: the +//! translation writes its own header. + +use std::collections::HashMap; + +use crate::Macros; +use crate::Pack; +use crate::condition::{Conditionals, split_directive}; +use crate::pack::normalize; + +const MAX_DEPTH: usize = 10; + +/// Where a line came from: an index into [`Preprocessed::files`] and a line number from 1. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Origin { + pub file: u16, + pub line: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Diagnostic { + pub origin: Origin, + pub message: String, +} + +#[derive(Debug, Default)] +pub struct Preprocessed { + /// The code, one line per entry of `lines`. + pub code: String, + pub lines: Vec, + /// The pack paths of the files read, the stage file first. + pub files: Vec, + /// `#version` of the stage file: the number and the profile word, if any. + pub version: Option<(u32, Option)>, + /// `#extension` lines of live code, as written after `#extension`. + pub extensions: Vec, + /// The text of every block comment in live code (`DRAWBUFFERS:`, buffer formats). + pub comments: Vec<(Origin, String)>, + pub diagnostics: Vec, +} + +impl Preprocessed { + /// `file:line` of an output line (from 0), for messages. + pub fn locate(&self, line: usize) -> String { + let o = self.lines.get(line).copied().unwrap_or_default(); + let file = self.files.get(o.file as usize).map_or("?", String::as_str); + format!("{file}:{}", o.line) + } +} + +/// Runs the preprocessor on the stage file `path` of `pack` with `macros` defined. +pub fn preprocess(pack: &Pack, path: &str, macros: &Macros) -> Preprocessed { + let mut run = Run { + pack, + macros: macros.clone(), + conditionals: Conditionals::default(), + out: Preprocessed::default(), + code: Vec::new(), + texts: HashMap::new(), + }; + run.file(path, 0, Origin::default()); + if run.conditionals.open() { + run.diagnose(Origin::default(), "`#if` without `#endif`"); + } + run.out.code = run.code.join("\n"); + run.out +} + +struct Run<'p> { + pack: &'p Pack, + macros: Macros, + conditionals: Conditionals, + out: Preprocessed, + code: Vec, + /// Files already read: a guarded header is included many times. + texts: HashMap>, +} + +impl Run<'_> { + fn file(&mut self, path: &str, depth: usize, from: Origin) { + let cached = self + .texts + .entry(path.to_owned()) + .or_insert_with(|| self.pack.read_text(path)); + let Some(text) = cached.clone() else { + return self.diagnose(from, &format!("can't find `{path}`")); + }; + let file = match self.out.files.iter().position(|f| f == path) { + Some(i) => i, + None => { + self.out.files.push(path.to_owned()); + self.out.files.len() - 1 + } + } as u16; + let (text, comments) = strip_comments(&text); + let lines: Vec<&str> = text.split('\n').map(|l| l.trim_end_matches('\r')).collect(); + let mut comments = comments.into_iter().peekable(); + let mut n = 0; + while n < lines.len() { + let first = n; + let mut line = lines[n].to_owned(); + while line.ends_with('\\') && n + 1 < lines.len() { + line.pop(); + n += 1; + line.push_str(lines[n]); + } + n += 1; + let origin = Origin { + file, + line: first as u32 + 1, + }; + self.line(&line, origin, path, depth); + for k in first + 1..n { + self.emit( + String::new(), + Origin { + file, + line: k as u32 + 1, + }, + ); + } + while let Some((_, comment)) = comments.next_if(|(at, _)| *at < n) { + if self.conditionals.active() { + self.out.comments.push((origin, comment)); + } + } + } + } + + fn line(&mut self, line: &str, origin: Origin, path: &str, depth: usize) { + let Some(after) = line.trim_start().strip_prefix('#') else { + let code = if self.conditionals.active() { + self.macros.define("__LINE__", origin.line.to_string()); + self.macros.expand(line) + } else { + String::new() + }; + return self.emit(code, origin); + }; + self.emit(String::new(), origin); + let Some((word, rest)) = split_directive(after) else { + return self.diagnose(origin, &format!("unknown directive `#{}`", after.trim())); + }; + match self.conditionals.apply(word, rest, &self.macros) { + Err(e) => return self.diagnose(origin, &e), + Ok(true) => return, + Ok(false) if !self.conditionals.active() => return, + Ok(false) => {} + } + match word { + "define" => self.define(rest, origin), + "undef" => self.macros.undef(rest), + "include" => { + let target = rest.trim_matches(|c| c == '"' || c == '<' || c == '>'); + match resolve(path, target) { + _ if depth + 1 >= MAX_DEPTH => self.diagnose( + origin, + &format!("`{target}`: includes nest more than {MAX_DEPTH} deep"), + ), + Some(file) => { + // The include line's own (empty) output line stays; the file follows. + self.file(&file, depth + 1, origin); + } + None => self.diagnose(origin, &format!("`{target}` is outside the pack")), + } + } + "version" if depth == 0 && self.out.version.is_none() => { + let mut words = rest.split_whitespace(); + let number = words.next().and_then(|v| v.parse().ok()).unwrap_or(110); + self.out.version = Some((number, words.next().map(str::to_owned))); + self.macros.define("__VERSION__", number.to_string()); + } + "extension" => self.out.extensions.push(rest.to_owned()), + "error" => self.diagnose(origin, &format!("#error {rest}")), + "version" | "pragma" | "line" | "" => {} + _ => self.diagnose(origin, &format!("unknown directive `#{word}`")), + } + } + + /// `#define NAME body` or `#define NAME(a, b) body` (no space before the `(`). + fn define(&mut self, rest: &str, origin: Origin) { + let len = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + let (name, tail) = rest.split_at(len); + if name.is_empty() { + return self.diagnose(origin, "`#define` without a name"); + } + if let Some(params) = tail.strip_prefix('(') { + let Some((params, body)) = params.split_once(')') else { + return self.diagnose(origin, &format!("`#define {name}(` without `)`")); + }; + let params = params + .split(',') + .map(|p| p.trim().to_owned()) + .filter(|p| !p.is_empty()) + .collect(); + self.macros.define_function(name, params, body.trim()); + } else { + self.macros.define(name, tail.trim()); + } + } + + fn emit(&mut self, code: String, origin: Origin) { + self.code.push(code); + self.out.lines.push(origin); + } + + fn diagnose(&mut self, origin: Origin, message: &str) { + self.out.diagnostics.push(Diagnostic { + origin, + message: message.to_owned(), + }); + } +} + +/// An `#include` path: from the `shaders` folder with a leading `/`, else from the including +/// file's folder (spec 1.4). `None` when it climbs out of the pack. +fn resolve(from_file: &str, target: &str) -> Option { + match target.strip_prefix('/') { + Some(absolute) => normalize(absolute), + None => { + let dir = from_file.rsplit_once('/').map_or("", |(dir, _)| dir); + normalize(&format!("{dir}/{target}")) + } + } +} + +/// `text` with comments replaced by spaces (new lines kept, so line numbers hold), and the body of +/// each block comment with the index of the line it starts on. +fn strip_comments(text: &str) -> (String, Vec<(usize, String)>) { + let mut out = String::with_capacity(text.len()); + let mut comments = Vec::new(); + let (mut rest, mut line) = (text, 0); + while let Some(at) = rest.find('/') { + let (before, from) = rest.split_at(at); + out.push_str(before); + line += before.matches('\n').count(); + if let Some(body) = from.strip_prefix("//") { + let end = body.find('\n').unwrap_or(body.len()); + rest = &body[end..]; + } else if let Some(body) = from.strip_prefix("/*") { + let end = body.find("*/").unwrap_or(body.len()); + let comment = &body[..end]; + comments.push((line, comment.to_owned())); + let newlines = comment.matches('\n').count(); + line += newlines; + out.push(' '); + out.extend(std::iter::repeat_n('\n', newlines)); + rest = body.get(end + 2..).unwrap_or(""); + } else { + out.push('/'); + rest = &from[1..]; + } + } + out.push_str(rest); + (out, comments) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pack::tests::zip; + + fn run(files: &[(&str, &str)]) -> Preprocessed { + let files: Vec<(String, &str)> = files + .iter() + .map(|(p, t)| (format!("shaders/{p}"), *t)) + .collect(); + let files: Vec<(&str, &str)> = files.iter().map(|(p, t)| (p.as_str(), *t)).collect(); + let pack = Pack::from_zip_bytes("P.zip", zip(&files)).unwrap(); + preprocess(&pack, "program/final.fsh", &Macros::standard()) + } + + fn code_lines(p: &Preprocessed) -> Vec<(&str, String)> { + p.code + .lines() + .enumerate() + .filter(|(_, l)| !l.trim().is_empty()) + .map(|(i, l)| (l.trim(), p.locate(i))) + .collect() + } + + #[test] + fn includes_resolve_and_lines_map_back() { + let p = run(&[ + ( + "program/final.fsh", + "#version 330 compatibility\n#include \"/lib/a.glsl\"\nvoid main() {}\n", + ), + ( + "lib/a.glsl", + "#ifndef A\n#define A\n#include \"b.glsl\"\nfloat a;\n#endif\n", + ), + ("lib/b.glsl", "// b\nfloat b;\n#include \"a.glsl\"\n"), + ]); + assert_eq!(p.version, Some((330, Some("compatibility".into())))); + assert_eq!( + code_lines(&p), + [ + ("float b;", "lib/b.glsl:2".into()), + ("float a;", "lib/a.glsl:4".into()), + ("void main() {}", "program/final.fsh:3".into()), + ] + ); + assert!(p.diagnostics.is_empty(), "{:?}", p.diagnostics); + } + + #[test] + fn missing_and_runaway_includes_are_diagnosed_where_they_stand() { + let p = run(&[( + "program/final.fsh", + "#ifdef NOPE\n#include \"gone.glsl\"\n#endif\n#include \"gone.glsl\"\n#include \"final.fsh\"\n", + )]); + let messages: Vec = p + .diagnostics + .iter() + .map(|d| format!("{}: {}", d.origin.line, d.message)) + .collect(); + assert_eq!(messages[0], "4: can't find `program/gone.glsl`"); + assert!( + messages + .last() + .unwrap() + .ends_with("includes nest more than 10 deep") + ); + assert!( + run(&[("program/final.fsh", "#include \"../../x\"")]).diagnostics[0] + .message + .contains("outside") + ); + } + + #[test] + fn macros_expand_like_the_glsl_preprocessor() { + let p = run(&[( + "program/final.fsh", + "#define SQ(x) ((x) * (x))\n#define CAT(a, b) a ## b\n#define N 4\n#define \\\n LONG 1\n\ + float v = SQ(N + 1) + CAT(N, 2) + LONG;\nint l = __LINE__;\n#if MC_VERSION > 12100 && defined MC_RENDER_STAGE_SKY\nint ok;\n#else\nint bad;\n#endif\n", + )]); + let lines: Vec<&str> = code_lines(&p).into_iter().map(|(l, _)| l).collect(); + assert_eq!( + lines, + [ + "float v = ((4 + 1) * (4 + 1)) + N2 + 1;", + "int l = 7;", + "int ok;" + ] + ); + assert_eq!(p.lines.len(), p.code.split('\n').count()); + } + + #[test] + fn comments_extensions_and_errors() { + let p = run(&[( + "program/final.fsh", + "#version 120\n#extension GL_ARB_shader_texture_lod : enable\n/* DRAWBUFFERS:04 */\n#if 0\n/* RENDERTARGETS: 1 */\n#error dead\n#endif\n\ + /* multi\nline */ float x; // gone\n#error live\n#bogus\n", + )]); + assert_eq!(p.extensions, ["GL_ARB_shader_texture_lod : enable"]); + let comments: Vec<&str> = p.comments.iter().map(|(_, c)| c.trim()).collect(); + assert_eq!(comments, ["DRAWBUFFERS:04", "multi\nline"]); + assert_eq!(code_lines(&p), [("float x;", "program/final.fsh:9".into())]); + let messages: Vec<&str> = p.diagnostics.iter().map(|d| d.message.as_str()).collect(); + assert_eq!(messages, ["#error live", "unknown directive `#bogus`"]); + } +} diff --git a/crates/horizon-shaders/src/properties.rs b/crates/horizon-shaders/src/properties.rs new file mode 100644 index 000000000..b9d27f3e3 --- /dev/null +++ b/crates/horizon-shaders/src/properties.rs @@ -0,0 +1,229 @@ +//! `.properties` files: `shaders.properties` (spec 3.1) and the id files `block.properties`, +//! `item.properties`, `entity.properties` (4.1). `key=value` lines, `#` comments, a trailing `\` +//! continuing a value on the next line, and conditional directives evaluated with the +//! standard and option macros (1.5 item 2). `#define` and `#undef` work in every one of them: +//! a public pack defines a macro in its `shaders.properties` and tests it further down. +//! No other escape is read: values are names, numbers and expressions. A later duplicate key +//! replaces the earlier one, with a note. + +use crate::Macros; +use crate::condition::{Conditionals, split_directive}; + +/// One `key=value`, with the line it starts on (from 1). +#[derive(Clone, Debug, PartialEq)] +pub struct Entry { + pub key: String, + pub value: String, + pub line: u32, +} + +/// Something wrong with a line, for the pack's log. +#[derive(Clone, Debug, PartialEq)] +pub struct Note { + pub line: u32, + pub message: String, +} + +#[derive(Debug, Default, PartialEq)] +pub struct Properties { + /// In file order; a key appears once (a later duplicate replaces the earlier one). + pub entries: Vec, + pub notes: Vec, +} + +impl Properties { + /// Reads `text`; `#define`/`#undef` change the macros for the lines after them only. + pub fn parse(text: &str, macros: &Macros) -> Self { + let mut out = Self::default(); + let mut macros = macros.clone(); + let mut conditionals = Conditionals::default(); + let mut lines = text.lines().zip(1u32..); + while let Some((raw, number)) = lines.next() { + let line = raw.trim(); + let on = conditionals.active(); + if let Some(directive) = line.strip_prefix('#') { + if let Some((word, rest)) = split_directive(directive) { + out.directive(word, rest, number, &mut conditionals, &mut macros); + } + continue; + } + // Continuation lines belong to their entry even inside a skipped branch. + let mut logical = line.to_owned(); + while ends_with_continuation(&logical) { + logical.pop(); + match lines.next() { + Some((next, _)) => logical.push_str(next.trim()), + None => break, + } + } + if !on || logical.is_empty() { + continue; + } + match logical.split_once('=') { + Some((key, value)) if !key.trim().is_empty() => out.insert(Entry { + key: key.trim().to_owned(), + value: value.trim().to_owned(), + line: number, + }), + _ => out.note(number, "not a key=value line"), + } + } + if conditionals.open() { + out.note(text.lines().count() as u32, "`#if` without `#endif`"); + } + out + } + + pub fn get(&self, key: &str) -> Option<&str> { + self.entry(key).map(|e| e.value.as_str()) + } + + pub fn entry(&self, key: &str) -> Option<&Entry> { + self.entries.iter().find(|e| e.key == key) + } + + fn insert(&mut self, entry: Entry) { + if let Some(at) = self.entries.iter().position(|e| e.key == entry.key) { + let first = self.entries.remove(at).line; + self.note( + entry.line, + &format!( + "`{}` is set again (first on line {first}); this one counts", + entry.key + ), + ); + } + self.entries.push(entry); + } + + fn note(&mut self, line: u32, message: &str) { + self.notes.push(Note { + line, + message: message.to_owned(), + }); + } + + /// A line starting with `#` whose first word may be a directive; any other word makes it a + /// comment. + fn directive( + &mut self, + word: &str, + rest: &str, + number: u32, + conditionals: &mut Conditionals, + macros: &mut Macros, + ) { + match conditionals.apply(word, rest, macros) { + Err(e) => self.note(number, &e), + Ok(true) => {} + Ok(false) if conditionals.active() => match word { + "define" => { + let (name, value) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + macros.define(name, value.trim()); + } + "undef" => macros.undef(rest), + _ => {} + }, + Ok(false) => {} + } + } +} + +/// An odd number of trailing backslashes: the last one joins the next line (`\\` is a literal). +fn ends_with_continuation(line: &str) -> bool { + line.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(text: &str) -> Properties { + let mut m = Macros::standard(); + m.define("SHADOWS", ""); + m.define("QUALITY", "2"); + Properties::parse(text, &m) + } + + fn pairs(p: &Properties) -> Vec<(&str, &str)> { + p.entries + .iter() + .map(|e| (e.key.as_str(), e.value.as_str())) + .collect() + } + + #[test] + fn keys_values_and_comments() { + let p = + read("# a comment\n\n sun = false\n#commented.key=1\nscreen=A B [C]\r\nbad line\n"); + assert_eq!(pairs(&p), [("sun", "false"), ("screen", "A B [C]")]); + assert_eq!(p.entry("screen").unwrap().line, 5); + assert_eq!( + p.notes, + [Note { + line: 6, + message: "not a key=value line".into() + }] + ); + } + + #[test] + fn continuation_lines_join() { + let p = read("screen=A B \\\n C D\\\n E\nsliders=X\nliteral=a\\\\\nnext=1"); + assert_eq!( + pairs(&p), + [ + ("screen", "A B C DE"), + ("sliders", "X"), + ("literal", "a\\\\"), + ("next", "1") + ] + ); + assert_eq!(p.entry("sliders").unwrap().line, 4); + } + + #[test] + fn conditionals_pick_one_branch() { + let p = read( + "#if MC_VERSION >= 11300\nnew=1\n#else\nold=1\n#endif\n\ + #ifdef SHADOWS\n#if QUALITY == 1\nq=1\n#elif QUALITY == 2\nq=2\n#elif QUALITY >= 2\nq=3\n#endif\n#endif\n\ + #ifndef SHADOWS\nnone=1\n#elif 0\nnever=1\n#else\nfallback=1\n#endif\n# ifdef SHADOWS // trailing comment\nspaced=1\n#endif", + ); + assert_eq!( + pairs(&p), + [("new", "1"), ("q", "2"), ("fallback", "1"), ("spaced", "1")] + ); + assert!(p.notes.is_empty(), "{:?}", p.notes); + } + + #[test] + fn comment_lines_are_not_directives() { + let p = read("#ifdefault=1\n#iffy\n#endiff\n#else-where\nkey=1"); + assert_eq!(pairs(&p), [("key", "1")]); + assert!(p.notes.is_empty(), "{:?}", p.notes); + } + + #[test] + fn defines_apply_to_the_lines_after_them() { + let text = "#ifdef MC_OS_NONE\n#define WATER 0\n#endif\n#define WATER 8\n\ + #if WATER == 8\nblock.8=minecraft:water\n#endif\n#undef WATER\n#ifdef WATER\nno=1\n#endif"; + let p = Properties::parse(text, &Macros::default()); + assert_eq!(pairs(&p), [("block.8", "minecraft:water")]); + assert!(p.notes.is_empty(), "{:?}", p.notes); + } + + #[test] + fn duplicate_keys_keep_the_last() { + let p = read("a=1\nb=2\na=3"); + assert_eq!(pairs(&p), [("b", "2"), ("a", "3")]); + assert_eq!(p.notes[0].line, 3); + } + + #[test] + fn broken_conditionals_are_noted() { + let p = read("#endif\n#else\n#if 1 +\nx=1\n#endif\n#if 1\n#else\n#else\n"); + let lines: Vec = p.notes.iter().map(|n| n.line).collect(); + assert_eq!(lines, [1, 2, 3, 8, 8]); + assert!(p.entries.is_empty()); + } +} diff --git a/crates/horizon-shaders/src/translate/mod.rs b/crates/horizon-shaders/src/translate/mod.rs new file mode 100644 index 000000000..32eecb3cc --- /dev/null +++ b/crates/horizon-shaders/src/translate/mod.rs @@ -0,0 +1,619 @@ +//! A program's preprocessed GLSL (any version from 110 to 460, compatibility or core) turned +//! into the Vulkan-flavoured GLSL 450 naga reads, then into validated naga modules (spec 1.6, +//! 2.2). The rewrite works on preprocessing tokens and never adds or removes a line of the pack's +//! code, so naga's line numbers map back through the preprocessor to the pack's files. +//! +//! What it does: +//! - Loose uniforms go into one uniform block shared by both stages (`bool` stored as `int`). +//! - Each sampler is split into a texture and a sampler object, combined again where it is used +//! (`sampler2D(t, s)`), or passed as two arguments to the pack's own functions. +//! - The compatibility built-ins (`gl_Vertex`, `gl_MultiTexCoordN`, `gl_*Matrix`, `ftransform()`, +//! `gl_FragData`, `gl_FragColor`, `gl_TexCoord`, `texture2D`...) become declarations of ours, +//! named with the reserved prefix `hz_`; varyings get locations by name across the stages. +//! - The pack's `main` becomes `hz_main`, called by a `main` of ours that sets the inputs and +//! adapts GL's conventions (spec 5.5): clip y is negated for every target but the window, so +//! memory rows are GL's rows and `gl_FragCoord` needs no change, and clip z goes from GL's +//! [-w, w] to [0, w], so stored depth is GL's window depth. +//! +//! Composite-style programs draw one quad from (0,0) to (1,1) with an identity model-view and an +//! orthographic projection onto it, the texture coordinate equal to the position (OQ-6). + +use std::collections::BTreeMap; +use std::fmt::Write; + +use naga::ShaderStage; +use naga::front::glsl::{Frontend, Options}; +use naga::valid::{Capabilities, ValidationFlags, Validator}; + +use crate::Preprocessed; + +mod rewrite; +mod scan; + +use scan::Stage; + +/// What a program's stages need from the game. +#[derive(Debug, Default, PartialEq)] +pub struct Interface { + /// The uniform block at binding 0: its size and each value's name, GLSL type and offset. + /// Ours come first: `hz_target` (the target's width, height, 1/width, 1/height), and the + /// fixed-function fog the compatibility `gl_Fog` reads (OQ-28): `hz_fog_color` (vanilla's fog + /// colour) and `hz_fog` (density, start, end, 1 / (end - start)). + pub uniform_size: u32, + pub uniforms: Vec, + /// Each sampler: texture at `binding`, sampler object at `binding + 1`. + pub samplers: Vec, + /// The fragment stage's output locations. + pub outputs: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Uniform { + pub name: String, + /// The type the pack declared (`float`, `ivec2`, `mat4`; `bool` is stored as an `int`). + pub ty: String, + pub offset: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Sampler { + pub name: String, + /// The type the pack declared: `sampler2D`, `sampler2DShadow`, `usampler2D`, `sampler3D`... + pub ty: String, + pub binding: u32, +} + +/// A program that failed, at a pack file and line. +#[derive(Clone, Debug, PartialEq)] +pub struct Error { + pub file: String, + pub line: u32, + pub message: String, +} + +pub struct Program { + pub vertex: naga::Module, + pub fragment: naga::Module, + pub interface: Interface, +} + +/// Where the program draws, which decides the conventions of 5.5. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Target { + /// Pack buffers: rows stored as GL stores them. + Buffers, + /// The window (`final`). + Window, +} + +/// What a gbuffers-style program's vertex stage reads. The game writes it: it knows its vertex +/// formats, the translator only places it. +#[derive(Clone, Debug, Default)] +pub struct GbuffersInputs { + /// GLSL declarations: the vertex inputs and any bindings of set 1. + pub declarations: String, + /// GLSL statements that set `hz_Vertex`, `hz_Color`, `hz_MultiTexCoord0`, + /// `hz_MultiTexCoord1` and `hz_Normal` (the compatibility inputs), run first in `main`. + pub body: String, + /// The value of each attribute a pack may declare (`mc_Entity`, `vaPosition`...), as a GLSL + /// expression of the declared type's size; an attribute not listed reads zero. + pub attributes: Vec<(String, String)>, +} + +/// Translates a composite-style program (`composite`, `deferred`, `final`...). +pub fn translate_composite( + vertex: &Preprocessed, + fragment: &Preprocessed, + target: Target, +) -> Result> { + translate(vertex, fragment, target, None) +} + +/// Translates a gbuffers-style program (`gbuffers_terrain`...) drawing the game's geometry +/// described by `inputs`, into `target` (the pack's buffers). +pub fn translate_gbuffers( + vertex: &Preprocessed, + fragment: &Preprocessed, + target: Target, + inputs: &GbuffersInputs, +) -> Result> { + translate(vertex, fragment, target, Some(inputs)) +} + +/// The matrices of the compatibility profile in a gbuffers-style program, from the frame's. +const GBUFFERS_MATRICES: &str = " hz_ModelViewMatrix = gbufferModelView;\n \ + hz_ProjectionMatrix = gbufferProjection;\n \ + hz_ModelViewProjectionMatrix = gbufferProjection * gbufferModelView;\n \ + hz_ModelViewMatrixInverse = gbufferModelViewInverse;\n \ + hz_ProjectionMatrixInverse = gbufferProjectionInverse;\n \ + hz_NormalMatrix = mat3(gbufferModelView);\n \ + mat4 hz_light = mat4(vec4(1.0 / 256.0, 0.0, 0.0, 0.0), vec4(0.0, 1.0 / 256.0, 0.0, 0.0), vec4(0.0, 0.0, 1.0, 0.0), vec4(1.0 / 32.0, 1.0 / 32.0, 0.0, 1.0));\n \ + hz_TextureMatrix = mat4[8](mat4(1.0), hz_light, hz_light, mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0));\n"; + +fn translate( + vertex: &Preprocessed, + fragment: &Preprocessed, + target: Target, + gbuffers: Option<&GbuffersInputs>, +) -> Result> { + let mut v = Stage::scan(vertex, ShaderStage::Vertex); + let mut f = Stage::scan(fragment, ShaderStage::Fragment); + v.gbuffers = gbuffers.is_some(); + f.gbuffers = gbuffers.is_some(); + let mut errors: Vec = v.errors.drain(..).chain(f.errors.drain(..)).collect(); + + // One uniform block and one set of samplers for both stages. + let mut uniforms: BTreeMap = BTreeMap::new(); + if gbuffers.is_some() { + for name in [ + "gbufferModelView", + "gbufferModelViewInverse", + "gbufferProjection", + "gbufferProjectionInverse", + ] { + uniforms.insert(name.to_owned(), "mat4".to_owned()); + } + } + let mut samplers: BTreeMap = BTreeMap::new(); + for stage in [&v, &f] { + for (name, ty, at) in &stage.uniforms { + if let Some(old) = uniforms.insert(name.clone(), ty.clone()) + && &old != ty + { + errors.push(stage.error( + *at, + &format!("`{name}` is `{old}` in one stage and `{ty}` in the other"), + )); + } + } + for (name, ty) in &stage.samplers { + samplers.insert(name.clone(), ty.clone()); + } + } + let samplers: Vec = samplers + .into_iter() + .enumerate() + .map(|(i, (name, ty))| Sampler { + name, + ty, + binding: 1 + 2 * i as u32, + }) + .collect(); + + // Varyings by name: the vertex outputs, then fragment inputs the vertex stage lacks. + let mut locations: BTreeMap = BTreeMap::new(); + for var in v.outs.iter().chain(&f.ins) { + let next = locations.len() as u32; + locations + .entry(var.name.clone()) + .or_insert((next, var.decl.clone())); + } + + let header = |stage: &Stage| { + let mut h = String::from("#version 450\n"); + h.push_str("layout(set = 0, binding = 0) uniform HzUniforms { vec4 hz_target; vec4 hz_fog_color; vec4 hz_fog;"); + for (name, ty) in &uniforms { + match ty.as_str() { + "bool" => write!(h, " int hz_bool_{name};"), + _ => match ty.split_once('[') { + Some((base, size)) => write!(h, " {base} {name}[{size};"), + None => write!(h, " {ty} {name};"), + }, + } + .unwrap(); + } + h.push_str(" };\n"); + for s in &samplers { + let (texture, sampler) = split_sampler(&s.ty); + writeln!( + h, + "layout(set = 0, binding = {}) uniform {texture} {}; layout(set = 0, binding = {}) uniform {sampler} hz_s_{};", + s.binding, s.name, s.binding + 1, s.name + ) + .unwrap(); + } + let direction = if stage.kind == ShaderStage::Vertex { + "out" + } else { + "in" + }; + for (name, (location, decl)) in &locations { + writeln!( + h, + "layout(location = {location}) {direction} {}", + decl.replace("@", name) + ) + .unwrap(); + } + if let (Some(inputs), ShaderStage::Vertex) = (gbuffers, stage.kind) { + h.push_str(&inputs.declarations); + h.push('\n'); + } + h.push_str(&stage.prelude()); + h + }; + let flip = if target == Target::Buffers { "-" } else { "" }; + let fog = + " hz_Fog = hz_FogParameters(hz_fog_color, hz_fog.x, hz_fog.y, hz_fog.z, hz_fog.w);\n"; + let mut vertex_main = String::from("\nvoid main() {\n"); + match gbuffers { + None => vertex_main.push_str( + " vec2 hz_p = hz_quad[gl_VertexIndex];\n hz_Vertex = vec4(hz_p, 0.0, 1.0);\n \ + hz_MultiTexCoord0 = hz_Vertex;\n hz_Color = vec4(1.0);\n hz_Normal = vec3(0.0, 0.0, 1.0);\n", + ), + Some(inputs) => { + vertex_main.push_str(GBUFFERS_MATRICES); + vertex_main.push_str(&inputs.body); + } + } + for (name, value, ty) in &v.attributes { + let value = match gbuffers { + // Our quad's values for a composite. + None => value.clone(), + // The game's, or zero for anything it doesn't provide. + Some(inputs) => match inputs.attributes.iter().find(|a| &a.0 == name) { + Some((_, given)) => format!("{ty}({given})"), + None => format!("{ty}(0)"), + }, + }; + writeln!(vertex_main, " {name} = {value};").unwrap(); + } + vertex_main.push_str(fog); + for name in &v.inits { + writeln!(vertex_main, " {name} = hz_init_{name}();").unwrap(); + } + write!( + vertex_main, + " hz_main();\n gl_Position = vec4(gl_Position.x, {flip}gl_Position.y, (gl_Position.z + gl_Position.w) * 0.5, gl_Position.w);\n}}\n" + ) + .unwrap(); + let mut fragment_main = String::from("\nvoid main() {\n"); + if gbuffers.is_some() { + fragment_main.push_str(GBUFFERS_MATRICES); + } + fragment_main.push_str(fog); + if f.frag_coord { + fragment_main.push_str(match target { + Target::Window => " hz_FragCoord = vec4(gl_FragCoord.x, hz_target.y - gl_FragCoord.y, gl_FragCoord.zw);\n", + Target::Buffers => " hz_FragCoord = gl_FragCoord;\n", + }); + } + for name in &f.inits { + writeln!(fragment_main, " {name} = hz_init_{name}();").unwrap(); + } + fragment_main.push_str(" hz_main();\n}\n"); + + let v_text = format!( + "{}{}{vertex_main}", + header(&v), + v.rewrite(&samplers, &uniforms) + ); + let f_text = format!( + "{}{}{fragment_main}", + header(&f), + f.rewrite(&samplers, &uniforms) + ); + if !errors.is_empty() { + return Err(errors); + } + let vertex_module = compile(&v, &v_text, header(&v).lines().count()); + let fragment_module = compile(&f, &f_text, header(&f).lines().count()); + let (vertex, fragment) = match (vertex_module, fragment_module) { + (Ok(v), Ok(f)) => (v, f), + (a, b) => return Err(a.err().into_iter().chain(b.err()).flatten().collect()), + }; + let (uniform_size, offsets) = block_layout(&fragment); + let uniforms = offsets + .into_iter() + .filter_map(|(member, offset)| { + let name = member + .strip_prefix("hz_bool_") + .unwrap_or(&member) + .to_owned(); + let ty = if name.starts_with("hz_") { + "vec4".to_owned() + } else { + uniforms.get(&name)?.clone() + }; + Some(Uniform { name, ty, offset }) + }) + .collect(); + let outputs = f.outputs.iter().copied().collect(); + Ok(Program { + vertex, + fragment, + interface: Interface { + uniform_size, + uniforms, + samplers, + outputs, + }, + }) +} + +/// `sampler2DShadow` → (`texture2D`, `samplerShadow`); `isampler3D` → (`itexture3D`, `sampler`). +pub(super) fn split_sampler(ty: &str) -> (String, &'static str) { + let shadow = ty.ends_with("Shadow"); + let base = ty + .trim_end_matches("Shadow") + .replacen("sampler", "texture", 1); + (base, if shadow { "samplerShadow" } else { "sampler" }) +} + +/// Parses and validates one stage; errors at the pack's files and lines. +fn compile(stage: &Stage, text: &str, header_lines: usize) -> Result> { + let at = |span: naga::Span, message: String| { + let start = span.to_range().map_or(0, |r| r.start); + let line = text[..start.min(text.len())].matches('\n').count(); + match line + .checked_sub(header_lines) + .and_then(|l| stage.pp.lines.get(l)) + { + Some(o) => Error { + file: stage + .pp + .files + .get(o.file as usize) + .cloned() + .unwrap_or_default(), + line: o.line, + message, + }, + None => Error { + file: "".into(), + line: line as u32 + 1, + message, + }, + } + }; + let module = Frontend::default() + .parse(&Options::from(stage.kind), text) + .map_err(|e| { + e.errors + .into_iter() + .map(|e| at(e.meta, e.kind.to_string())) + .collect::>() + })?; + Validator::new(ValidationFlags::all(), Capabilities::all()) + .validate(&module) + .map_err(|e| { + let span = e.spans().next().map_or(naga::Span::default(), |(s, _)| *s); + // The validator nests its reasons: say them all. + let inner = e.into_inner(); + let mut message = inner.to_string(); + let mut source = std::error::Error::source(&inner); + while let Some(s) = source { + write!(message, ": {s}").unwrap(); + source = s.source(); + } + vec![at(span, message)] + })?; + Ok(module) +} + +/// The uniform block's size and member offsets, from naga's layout. +fn block_layout(module: &naga::Module) -> (u32, Vec<(String, u32)>) { + for (_, var) in module.global_variables.iter() { + if var.space != naga::AddressSpace::Uniform { + continue; + } + if let naga::TypeInner::Struct { members, span } = &module.types[var.ty].inner { + let offsets = members + .iter() + .filter_map(|m| Some((m.name.clone()?, m.offset))) + .collect(); + return (*span, offsets); + } + } + (16, vec![("hz_target".into(), 0)]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pack::tests::zip; + use crate::{Macros, Pack, preprocess}; + + fn program(files: &[(&str, &str)], target: Target) -> Result> { + let files: Vec<(String, &str)> = files + .iter() + .map(|(p, t)| (format!("shaders/{p}"), *t)) + .collect(); + let files: Vec<(&str, &str)> = files.iter().map(|(p, t)| (p.as_str(), *t)).collect(); + let pack = Pack::from_zip_bytes("P.zip", zip(&files)).unwrap(); + let m = Macros::standard(); + translate_composite( + &preprocess(&pack, "composite.vsh", &m), + &preprocess(&pack, "composite.fsh", &m), + target, + ) + } + + const VSH_120: &str = "#version 120\nvarying vec2 texcoord;\nvoid main() {\n gl_Position = ftransform();\n \ + texcoord = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy;\n}\n"; + + #[test] + fn compatibility_programs_translate() { + let fsh = "#version 120\nuniform sampler2D colortex0;\nuniform sampler2D depthtex0;\nuniform float viewWidth;\n\ + uniform bool hideGUI;\nuniform int frameCounter;\nvarying vec2 texcoord;\n\ + vec3 tint(sampler2D s, vec2 c) { return texture2D(s, c).rgb; }\nvoid main() {\n \ + vec3 c = tint(colortex0, texcoord) * texture2D(depthtex0, texcoord).r;\n if (hideGUI) c *= 0.5;\n \ + /* DRAWBUFFERS:04 */\n gl_FragData[0] = vec4(c, 1.0);\n gl_FragData[1] = vec4(frameCounter / viewWidth);\n}\n"; + let p = program( + &[("composite.vsh", VSH_120), ("composite.fsh", fsh)], + Target::Buffers, + ) + .unwrap(); + let i = &p.interface; + let samplers: Vec<(&str, u32)> = i + .samplers + .iter() + .map(|s| (s.name.as_str(), s.binding)) + .collect(); + assert_eq!(samplers, [("colortex0", 1), ("depthtex0", 3)]); + let uniforms: Vec<(&str, &str)> = i + .uniforms + .iter() + .map(|u| (u.name.as_str(), u.ty.as_str())) + .collect(); + assert_eq!( + uniforms, + [ + ("hz_target", "vec4"), + ("hz_fog_color", "vec4"), + ("hz_fog", "vec4"), + ("frameCounter", "int"), + ("hideGUI", "bool"), + ("viewWidth", "float") + ] + ); + assert_eq!(i.uniforms[0].offset, 0); + assert!(i.uniform_size >= 28); + assert_eq!(i.outputs, [0, 1]); + } + + #[test] + fn core_programs_translate() { + let vsh = "#version 330 core\nin vec3 vaPosition;\nin vec2 vaUV0;\nuniform mat4 modelViewMatrix, projectionMatrix;\n\ + out vec2 uv;\nflat out int flatId;\nvoid main() {\n \ + gl_Position = projectionMatrix * modelViewMatrix * vec4(vaPosition, 1.0);\n uv = vaUV0;\n flatId = 1;\n}\n"; + let fsh = "#version 330 core\nin vec2 uv;\nuniform sampler2D colortex0;\nuniform usampler2D colortex5;\n\ + layout(location = 2) out vec4 color;\nvoid main() {\n \ + if (texture(colortex0, uv).a < 0.1) discard;\n color = texture(colortex0, uv) + float(texelFetch(colortex5, ivec2(gl_FragCoord.xy), 0).r);\n}\n"; + let p = program( + &[("composite.vsh", vsh), ("composite.fsh", fsh)], + Target::Window, + ) + .unwrap(); + assert_eq!(p.interface.outputs, [2]); + assert!( + p.interface + .uniforms + .iter() + .any(|u| u.name == "projectionMatrix" && u.ty == "mat4") + ); + } + + #[test] + fn frag_coord_is_set_for_every_target() { + let fsh = "#version 120\nvoid main() { gl_FragColor = vec4(gl_FragCoord.x); }\n"; + for target in [Target::Buffers, Target::Window] { + let p = program( + &[("composite.vsh", VSH_120), ("composite.fsh", fsh)], + target, + ) + .unwrap(); + let m = &p.fragment; + let (handle, _) = m + .global_variables + .iter() + .find(|(_, g)| g.name.as_deref() == Some("hz_FragCoord")) + .unwrap(); + // naga calls our `main` from its entry point: look in every function. + let functions = m.functions.iter().map(|(_, f)| f); + let stores = functions.chain([&m.entry_points[0].function]).any(|f| { + f.body.iter().any(|s| match s { + naga::Statement::Store { pointer, .. } => { + matches!(f.expressions[*pointer], naga::Expression::GlobalVariable(h) if h == handle) + } + _ => false, + }) + }); + assert!(stores, "{target:?}: main does not set hz_FragCoord"); + } + } + + #[test] + fn gbuffers_programs_read_the_games_vertices() { + let vsh = "#version 120\nattribute vec4 mc_Entity;\nattribute vec2 mc_midTexCoord;\nvarying vec2 uv;\nvarying vec2 light;\n\ + varying vec4 color;\nvarying vec3 normal;\nvarying float id;\nvoid main() {\n \ + gl_Position = ftransform();\n uv = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy;\n \ + light = (gl_TextureMatrix[1] * gl_MultiTexCoord1).xy;\n color = gl_Color;\n \ + normal = normalize(gl_NormalMatrix * gl_Normal);\n id = mc_Entity.x + mc_midTexCoord.x;\n}\n"; + let fsh = "#version 120\nuniform sampler2D texture;\nuniform sampler2D lightmap;\nuniform mat4 gbufferModelView;\n\ + varying vec2 uv;\nvarying vec2 light;\nvarying vec4 color;\nvarying vec3 normal;\nvarying float id;\n\ + void main() {\n vec4 c = texture2D(texture, uv) * texture2D(lightmap, light) * color;\n \ + /* DRAWBUFFERS:02 */\n gl_FragData[0] = c * gl_FragCoord.z;\n \ + gl_FragData[1] = vec4(normal * 0.5 + 0.5, id);\n}\n"; + let files: Vec<(String, &str)> = + [("gbuffers_terrain.vsh", vsh), ("gbuffers_terrain.fsh", fsh)] + .iter() + .map(|(p, t)| (format!("shaders/{p}"), *t)) + .collect(); + let files: Vec<(&str, &str)> = files.iter().map(|(p, t)| (p.as_str(), *t)).collect(); + let pack = Pack::from_zip_bytes("P.zip", zip(&files)).unwrap(); + let m = Macros::standard(); + let inputs = GbuffersInputs { + declarations: "layout(location = 0) in vec3 game_pos;\nlayout(location = 1) in uvec2 game_uv;".into(), + body: " hz_Vertex = vec4(game_pos, 1.0);\n hz_MultiTexCoord0 = vec4(vec2(game_uv) / 32768.0, 0.0, 1.0);\n \ + hz_MultiTexCoord1 = vec4(240.0, 240.0, 0.0, 1.0);\n hz_Color = vec4(1.0);\n hz_Normal = vec3(0.0, 1.0, 0.0);\n" + .into(), + attributes: vec![("mc_midTexCoord".into(), "vec2(game_uv) / 32768.0".into())], + }; + let p = translate_gbuffers( + &preprocess(&pack, "gbuffers_terrain.vsh", &m), + &preprocess(&pack, "gbuffers_terrain.fsh", &m), + Target::Buffers, + &inputs, + ) + .unwrap_or_else(|e| panic!("{e:?}")); + let names: Vec<&str> = p + .interface + .uniforms + .iter() + .map(|u| u.name.as_str()) + .collect(); + for name in [ + "gbufferModelView", + "gbufferProjection", + "gbufferModelViewInverse", + ] { + assert!(names.contains(&name), "{name} missing from {names:?}"); + } + assert_eq!(p.interface.outputs, vec![0, 1]); + let inputs: Vec<&str> = p.vertex.entry_points[0] + .function + .arguments + .iter() + .filter_map(|a| a.name.as_deref()) + .collect(); + assert!( + inputs.contains(&"game_pos") && inputs.contains(&"game_uv"), + "{inputs:?}" + ); + } + + #[test] + fn locals_may_hide_a_sampler() { + let fsh = "#version 120\nuniform sampler2D specular;\nstruct M { float specular; };\n\ + float f(float x) { float specular = x * 2.0; return specular; }\n\ + void main() { M m = M(0.5); gl_FragColor = texture2D(specular, vec2(0.5)) * f(m.specular); }\n"; + program( + &[("composite.vsh", VSH_120), ("composite.fsh", fsh)], + Target::Buffers, + ) + .unwrap_or_else(|e| panic!("{e:?}")); + } + + #[test] + fn errors_point_at_the_pack_file_and_line() { + let fsh = + "#version 120\n#include \"/lib/bad.glsl\"\nvoid main() { gl_FragColor = vec4(f()); }\n"; + let bad = "// fine\nfloat f() {\n return undefinedThing;\n}\n"; + let errors = program( + &[ + ("composite.vsh", VSH_120), + ("composite.fsh", fsh), + ("lib/bad.glsl", bad), + ], + Target::Buffers, + ) + .err() + .unwrap(); + assert_eq!( + (errors[0].file.as_str(), errors[0].line), + ("lib/bad.glsl", 3), + "{errors:?}" + ); + } +} diff --git a/crates/horizon-shaders/src/translate/rewrite.rs b/crates/horizon-shaders/src/translate/rewrite.rs new file mode 100644 index 000000000..85cb0fcd9 --- /dev/null +++ b/crates/horizon-shaders/src/translate/rewrite.rs @@ -0,0 +1,286 @@ +//! The second pass: the stage's code written again for naga, line for line, and the +//! declarations of ours it needs. + +use std::collections::{BTreeMap, HashMap}; +use std::fmt::Write; + +use naga::ShaderStage; + +use super::scan::{LATER_KEYWORDS, Stage, UNIFORM_STATE, VERTEX_INPUTS, is_ident, significant}; +use super::{Sampler, split_sampler}; + +impl Stage<'_> { + /// Declarations of ours this stage uses (after the uniform block, samplers and varyings). + pub(super) fn prelude(&self) -> String { + let mut p = String::from( + "float hz_modf(float x, out float i) { i = trunc(x); return x - i; }\n\ + vec2 hz_modf(vec2 x, out vec2 i) { i = trunc(x); return x - i; }\n\ + vec3 hz_modf(vec3 x, out vec3 i) { i = trunc(x); return x - i; }\n\ + vec4 hz_modf(vec4 x, out vec4 i) { i = trunc(x); return x - i; }\n\ + struct hz_FogParameters { vec4 color; float density; float start; float end; float scale; };\n\ + hz_FogParameters hz_Fog;\n", + ); + if self.gbuffers { + // Set by our `main` from the frame's matrices (gbuffers.rs). + p.push_str( + "mat4 hz_ModelViewMatrix; mat4 hz_ProjectionMatrix; mat4 hz_ModelViewProjectionMatrix;\n\ + mat4 hz_ModelViewMatrixInverse; mat4 hz_ProjectionMatrixInverse; mat3 hz_NormalMatrix; mat4 hz_TextureMatrix[8];\n", + ); + } else { + p.push_str( + "const mat4 hz_ModelViewMatrix = mat4(1.0); const mat4 hz_ProjectionMatrix = mat4(2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, -1.0, -1.0, 0.0, 1.0);\n\ + const mat4 hz_ModelViewProjectionMatrix = hz_ProjectionMatrix; const mat4 hz_ModelViewMatrixInverse = mat4(1.0); const mat3 hz_NormalMatrix = mat3(1.0);\n\ + const mat4 hz_ProjectionMatrixInverse = mat4(0.5, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.5, 0.5, 0.0, 1.0);\n\ + const mat4 hz_TextureMatrix[8] = mat4[8](mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0));\n", + ); + } + if self.kind == ShaderStage::Vertex { + p.push_str( + "const vec2 hz_quad[6] = vec2[6](vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 1.0));\n\ + vec4 hz_Vertex; vec4 hz_Color; vec4 hz_SecondaryColor; vec3 hz_Normal; float hz_FogCoord;\n\ + vec4 hz_MultiTexCoord0; vec4 hz_MultiTexCoord1; vec4 hz_MultiTexCoord2; vec4 hz_MultiTexCoord3;\n\ + vec4 hz_MultiTexCoord4; vec4 hz_MultiTexCoord5; vec4 hz_MultiTexCoord6; vec4 hz_MultiTexCoord7;\n", + ); + } else { + for n in &self.outputs { + if !self.frag_outs.iter().any(|(at, _)| at == n) { + writeln!(p, "layout(location = {n}) out vec4 hz_out{n};").unwrap(); + } + } + for (_, decl) in &self.frag_outs { + writeln!(p, "{decl}").unwrap(); + } + p.push_str("vec4 hz_FragCoord;\n"); + } + p + } + + /// The pack's code with the rewrites above; the same number of lines. + pub(super) fn rewrite( + &self, + samplers: &[Sampler], + uniforms: &BTreeMap, + ) -> String { + let removed = |i: usize| self.removed.iter().any(|r| r.contains(&i)); + let sampler_ty: HashMap<&str, &str> = samplers + .iter() + .map(|s| (s.name.as_str(), s.ty.as_str())) + .collect(); + let old = self.pp.version.as_ref().is_none_or(|(v, _)| *v < 400); + let fragment = self.kind == ShaderStage::Fragment; + let mut out = String::with_capacity(self.pp.code.len() + 1024); + // Per open bracket: the function called, and the closing text to add. + let mut calls: Vec<(&str, &str, usize)> = Vec::new(); + // Sampler parameters of the function being written: name → type. + let mut params: HashMap = HashMap::new(); + // Names of samplers a local variable or parameter of the function hides. + let mut hidden: Vec<&str> = Vec::new(); + let mut depth = 0; + let mut skip_until = 0; + let next = |i: usize| (i + 1..self.tokens.len()).find(|&j| significant(self.tokens[j])); + let prev = |i: usize| (0..i).rev().find(|&j| significant(self.tokens[j])); + for (i, &t) in self.tokens.iter().enumerate() { + if i < skip_until { + continue; + } + if let Some(text) = self.replaced.get(&i) { + out.push_str(text); + continue; + } + if removed(i) { + out.extend(t.chars().filter(|&c| c == '\n')); + if t.contains(' ') && !t.contains('\n') { + out.push(' '); + } + continue; + } + let called = next(i).is_some_and(|j| self.tokens[j] == "("); + match t { + "{" => depth += 1, + "}" => { + depth -= 1; + if depth == 0 { + params.clear(); + hidden.clear(); + } + } + "(" => { + let callee = prev(i).map_or("", |j| self.tokens[j]); + let close = if matches!(callee, "shadow2D" | "shadow2DLod" | "shadow2DProj") { + ")" + } else { + "" + }; + calls.push((callee, close, 0)); + } + ")" => { + if let Some((_, close, _)) = calls.pop() { + out.push_str(close); + } + } + "," => { + if let Some(c) = calls.last_mut() { + c.2 += 1; + } + } + _ => {} + } + // `T x[] = T[](a, b)`: naga gives unsized arrays no size, so the count is written in. + if t == "[" + && let Some(close) = next(i).filter(|&j| self.tokens[j] == "]") + { + if let Some(n) = self.array_length(close) { + write!(out, "[{n}]").unwrap(); + skip_until = close + 1; + continue; + } + } + if !is_ident(t) { + out.push_str(t); + continue; + } + // A function definition's sampler parameters: `sampler2D s` → `texture2D s, sampler hz_s_s`. + if depth == 0 && t.contains("sampler") && calls.len() == 1 { + if let Some(n) = next(i).filter(|&n| is_ident(self.tokens[n])) { + let name = self.tokens[n]; + let (texture, sampler) = split_sampler(t); + write!(out, "{texture} {name}, {sampler} hz_s_{name}").unwrap(); + params.insert(name.to_owned(), t.to_owned()); + skip_until = n + 1; + continue; + } + } + // `float specular`: a variable of the same name as a sampler, from here to the + // function's end; `.specular` a member. + if sampler_ty.contains_key(t) && !params.contains_key(t) { + let before = prev(i).map(|j| self.tokens[j]); + if before == Some(".") { + out.push_str(t); + continue; + } + if before.is_some_and(|b| is_ident(b) && !matches!(b, "return" | "case" | "else")) { + hidden.push(t); + } + } + if hidden.contains(&t) { + out.push_str(t); + continue; + } + let sampler = params + .get(t) + .map(String::as_str) + .or_else(|| sampler_ty.get(t).copied()); + if let Some(ty) = sampler { + let as_argument = calls.last().is_some_and(|(callee, _, arg)| { + self.sampler_functions + .get(*callee) + .is_some_and(|p| p.get(*arg) == Some(&true)) + }) && prev(i) + .is_some_and(|j| matches!(self.tokens[j], "(" | ",")); + if as_argument { + write!(out, "{t}, hz_s_{t}").unwrap(); + } else { + write!(out, "{ty}({t}, hz_s_{t})").unwrap(); + } + continue; + } + if uniforms.get(t).is_some_and(|ty| ty == "bool") { + write!(out, "(hz_bool_{t} != 0)").unwrap(); + continue; + } + let replaced: Option = match t { + "main" if depth == 0 && called => Some("hz_main".into()), + // GLSL's `modf`, which naga's frontend lacks. + "modf" if called => Some("hz_modf".into()), + "texture2D" | "texture1D" | "texture3D" | "textureCube" if called => { + Some("texture".into()) + } + "texture2DLod" | "texture3DLod" | "texture2DLodARB" | "textureCubeLod" + if called => + { + Some("textureLod".into()) + } + "texture2DProj" if called => Some("textureProj".into()), + "texture2DGrad" | "texture2DGradARB" if called => Some("textureGrad".into()), + "shadow2D" if called => Some("vec4(texture".into()), + "shadow2DLod" if called => Some("vec4(textureLod".into()), + "shadow2DProj" if called => Some("vec4(textureProj".into()), + "ftransform" if called => { + // `ftransform()` → the product it stands for; its `()` stays as a no-op call... so skip it. + let close = next(i).and_then(next); + skip_until = close.map_or(i + 1, |c| c + 1); + Some("(hz_ModelViewProjectionMatrix * hz_Vertex)".into()) + } + "gl_FragColor" => Some("hz_out0".into()), + "gl_FragData" | "gl_TexCoord" => match self.index_after(i) { + Some((n, close)) => { + skip_until = close + 1; + Some(match t { + "gl_FragData" => format!("hz_out{n}"), + _ => format!("hz_TexCoord{n}"), + }) + } + None => None, + }, + "gl_Color" if fragment => Some("hz_FrontColor".into()), + "gl_FragCoord" if !fragment => None, + "gl_FragCoord" => Some("hz_FragCoord".into()), + "gl_FrontColor" => Some("hz_FrontColor".into()), + "gl_Fog" => Some("hz_Fog".into()), + _ if UNIFORM_STATE.iter().any(|b| t.starts_with(b)) => { + Some(format!("hz_{}", &t[3..])) + } + _ if !fragment && VERTEX_INPUTS.iter().any(|b| t.starts_with(b)) => { + Some(format!("hz_{}", &t[3..])) + } + "gl_VertexID" => Some("gl_VertexIndex".into()), + "gl_InstanceID" => Some("gl_InstanceIndex".into()), + _ if old && LATER_KEYWORDS.contains(&t) => Some(format!("hz_kw_{t}")), + _ => None, + }; + out.push_str(replaced.as_deref().unwrap_or(t)); + } + out + } +} + +impl Stage<'_> { + /// The element count of an unsized array whose `[]` ends at `close`: the arguments of the + /// constructor that follows (`[] = T[](...)` or `[](...)`). + pub(super) fn array_length(&self, close: usize) -> Option { + let mut j = close + 1; + let mut seen_eq = false; + while let Some(&t) = self.tokens.get(j) { + match t { + "(" => break, + "=" if !seen_eq => seen_eq = true, + ";" | "," | ")" | "{" => return None, + _ => {} + } + j += 1; + } + let (mut depth, mut args, mut any) = (0, 1, false); + for &t in &self.tokens[j + 1..] { + match t { + "(" | "[" | "{" => depth += 1, + ")" if depth == 0 => return any.then_some(args), + ")" | "]" | "}" => depth -= 1, + "," if depth == 0 => args += 1, + _ if significant(t) => any = true, + _ => {} + } + } + None + } +} + +/// What our vertex `main` gives a vertex input of a composite-style program. +pub(super) fn attribute_value(name: &str, ty: &str) -> String { + match name { + "vaPosition" => "vec3(hz_p, 0.0)".into(), + "vaUV0" => "hz_p".into(), + "vaColor" => "vec4(1.0)".into(), + "vaNormal" => "vec3(0.0, 0.0, 1.0)".into(), + _ => format!("{ty}(0)"), + } +} diff --git a/crates/horizon-shaders/src/translate/scan.rs b/crates/horizon-shaders/src/translate/scan.rs new file mode 100644 index 000000000..6789893f4 --- /dev/null +++ b/crates/horizon-shaders/src/translate/scan.rs @@ -0,0 +1,403 @@ +//! The first pass over a stage's tokens: what it declares at global scope, which of its +//! functions take samplers, which outputs and built-ins it uses. + +use std::collections::{BTreeSet, HashMap}; +use std::fmt::Write; + +use naga::ShaderStage; + +use super::Error; +use super::rewrite::attribute_value; +use crate::Preprocessed; +use crate::macros::lex; + +/// A varying: its name and its declaration with `@` where the name goes (`flat ivec2 @[2];`). +pub(super) struct Var { + pub(super) name: String, + pub(super) decl: String, +} + +pub(super) struct Stage<'p> { + pub(super) kind: ShaderStage, + pub(super) pp: &'p Preprocessed, + pub(super) tokens: Vec<&'p str>, + /// Token ranges to blank out (declarations moved into the header). + pub(super) removed: Vec>, + /// Name, type with its array suffix, token of the type. + pub(super) uniforms: Vec<(String, String, usize)>, + pub(super) samplers: Vec<(String, String)>, + pub(super) ins: Vec, + pub(super) outs: Vec, + /// Vertex inputs, now private globals, and the value our `main` gives each. + /// Vertex inputs: name, our composite value, type. + pub(super) attributes: Vec<(String, String, String)>, + /// A gbuffers-style program: the matrices are the frame's, not the quad's. + pub(super) gbuffers: bool, + /// The pack's fragment outputs, declared again with their locations. + pub(super) frag_outs: Vec<(u32, String)>, + /// The pack's functions that take samplers: which parameters are samplers. + pub(super) sampler_functions: HashMap>, + pub(super) outputs: BTreeSet, + pub(super) frag_coord: bool, + /// Tokens written differently, by index. + pub(super) replaced: HashMap, + /// Globals whose initializer runs in our `main` (`g = hz_init_g();`), in order. + pub(super) inits: Vec, + pub(super) errors: Vec, +} + +pub(super) const STORAGE: [&str; 5] = ["uniform", "in", "out", "attribute", "varying"]; +pub(super) const QUALIFIERS: [&str; 11] = [ + "flat", + "smooth", + "noperspective", + "centroid", + "invariant", + "highp", + "mediump", + "lowp", + "precise", + "sample", + "const", +]; +/// The compatibility profile's uniform state (with its `Inverse`/`Transpose` forms). +pub(super) const UNIFORM_STATE: [&str; 5] = [ + "gl_ModelViewMatrix", + "gl_ProjectionMatrix", + "gl_ModelViewProjectionMatrix", + "gl_NormalMatrix", + "gl_TextureMatrix", +]; +/// The compatibility profile's vertex inputs. +pub(super) const VERTEX_INPUTS: [&str; 6] = [ + "gl_Vertex", + "gl_Color", + "gl_SecondaryColor", + "gl_Normal", + "gl_FogCoord", + "gl_MultiTexCoord", +]; +/// Words that became keywords after GLSL 1.x and that old packs use as names. +pub(super) const LATER_KEYWORDS: [&str; 17] = [ + "sample", + "buffer", + "shared", + "filter", + "input", + "output", + "common", + "partition", + "active", + "resource", + "patch", + "subroutine", + "coherent", + "volatile", + "restrict", + "readonly", + "writeonly", +]; + +pub(super) fn significant(t: &str) -> bool { + !t.trim().is_empty() +} + +pub(super) fn is_ident(t: &str) -> bool { + t.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') +} + +impl<'p> Stage<'p> { + pub(super) fn scan(pp: &'p Preprocessed, kind: ShaderStage) -> Self { + let mut s = Stage { + kind, + pp, + tokens: lex(&pp.code), + removed: Vec::new(), + uniforms: Vec::new(), + samplers: Vec::new(), + ins: Vec::new(), + outs: Vec::new(), + attributes: Vec::new(), + frag_outs: Vec::new(), + sampler_functions: HashMap::new(), + outputs: BTreeSet::new(), + frag_coord: false, + gbuffers: false, + replaced: HashMap::new(), + inits: Vec::new(), + errors: Vec::new(), + }; + let (mut start, mut depth, mut parens) = (0, 0usize, 0usize); + for i in 0..s.tokens.len() { + match s.tokens[i] { + "(" => parens += 1, + ")" => parens = parens.saturating_sub(1), + "{" if depth == 0 && parens == 0 => { + s.global(start, i, true); + depth = 1; + } + "{" => depth += 1, + "}" if depth > 0 => { + depth -= 1; + if depth == 0 { + start = i + 1; + } + } + ";" if depth == 0 && parens == 0 => { + s.global(start, i, false); + start = i + 1; + } + "gl_FragData" | "gl_FragColor" if kind == ShaderStage::Fragment => { + let n = if s.tokens[i] == "gl_FragColor" { + Some(0) + } else { + s.index_after(i).map(|(n, _)| n) + }; + match n { + Some(n) => _ = s.outputs.insert(n), + None => s + .errors + .push(s.error(i, "`gl_FragData` needs a constant index")), + } + } + "gl_FragCoord" => s.frag_coord = true, + "gl_TexCoord" => { + if let Some((n, _)) = s.index_after(i) { + s.builtin_varying(format!("hz_TexCoord{n}")); + } + } + "gl_FrontColor" => s.builtin_varying("hz_FrontColor".into()), + "gl_Color" if kind == ShaderStage::Fragment => { + s.builtin_varying("hz_FrontColor".into()) + } + _ => {} + } + } + s + } + + pub(super) fn builtin_varying(&mut self, name: String) { + let list = if self.kind == ShaderStage::Vertex { + &mut self.outs + } else { + &mut self.ins + }; + if !list.iter().any(|v| v.name == name) { + list.push(Var { + name, + decl: "vec4 @;".into(), + }); + } + } + + /// `[ n ]` right after token `i`: the number and the index of the `]`. + pub(super) fn index_after(&self, i: usize) -> Option<(u32, usize)> { + let mut it = (i + 1..self.tokens.len()).filter(|&j| significant(self.tokens[j])); + let open = it.next()?; + let n = it.next()?; + let close = it.next()?; + (self.tokens[open] == "[" && self.tokens[close] == "]") + .then(|| self.tokens[n].parse().ok().map(|n| (n, close))) + .flatten() + } + + pub(super) fn error(&self, token: usize, message: &str) -> Error { + let line: usize = self.tokens[..token.min(self.tokens.len())] + .iter() + .map(|t| t.matches('\n').count()) + .sum(); + let o = self.pp.lines.get(line).copied().unwrap_or_default(); + Error { + file: self + .pp + .files + .get(o.file as usize) + .cloned() + .unwrap_or_default(), + line: o.line, + message: message.to_owned(), + } + } + + /// A statement at global scope, tokens `from..to` (without its `;`, or before a `{`). + pub(super) fn global(&mut self, from: usize, to: usize, body: bool) { + let words: Vec = (from..to) + .filter(|&i| significant(self.tokens[i])) + .collect(); + // A prototype: a `(` before any `=`, and no storage qualifier. + let paren = words + .iter() + .position(|&i| matches!(self.tokens[i], "(" | "=")); + let prototype = paren.is_some_and(|p| self.tokens[words[p]] == "(") + && !words.iter().any(|&i| STORAGE.contains(&self.tokens[i])); + if body || prototype { + return self.function(&words); + } + let mut k = 0; + let mut storage = None; + let mut location = None; + let mut interpolation = String::new(); + while let Some(&i) = words.get(k) { + let w = self.tokens[i]; + if w == "layout" { + // layout(location = n, ...) + let mut j = k + 1; + while let Some(&t) = words.get(j) { + j += 1; + if self.tokens[t] == "location" { + location = words + .get(j + 1) + .and_then(|&n| self.tokens[n].parse::().ok()); + } + if self.tokens[t] == ")" { + break; + } + } + k = j; + continue; + } + if STORAGE.contains(&w) { + storage = Some(w); + } else if QUALIFIERS.contains(&w) { + if matches!( + w, + "flat" | "smooth" | "noperspective" | "centroid" | "sample" + ) { + write!(interpolation, "{w} ").unwrap(); + } + } else { + break; + } + k += 1; + } + let Some(storage) = storage else { + return self.initializer(&words, to); + }; + let Some(&ty_at) = words.get(k) else { return }; + let ty = self.tokens[ty_at].to_owned(); + if words[k..].iter().any(|&i| self.tokens[i] == "{") { + return; // An interface block: left as the pack wrote it. + } + // Declarators: name [array] [= init], separated by commas. + let mut declarators: Vec<(String, String)> = Vec::new(); + let mut j = k + 1; + while let Some(&n) = words.get(j) { + let name = self.tokens[n].to_owned(); + let mut array = String::new(); + j += 1; + let mut nesting = 0; + while let Some(&t) = words.get(j) { + match self.tokens[t] { + "," if nesting == 0 => break, + "(" | "[" => nesting += 1, + ")" | "]" => nesting -= 1, + _ => {} + } + if self.tokens[t] == "=" && nesting == 0 { + // An initializer: uniforms are set by the game, so it is dropped. + while words.get(j).is_some_and(|&t| self.tokens[t] != ",") { + j += 1; + } + break; + } + array.push_str(self.tokens[t]); + j += 1; + } + j += 1; + declarators.push((name, array)); + } + let fragment = self.kind == ShaderStage::Fragment; + let input = matches!(storage, "in" | "attribute") || (storage == "varying" && fragment); + self.removed.push(from..to + 1); + for (name, array) in declarators { + let decl = format!("{interpolation}{ty} @{array};"); + match storage { + "uniform" if ty.contains("sampler") => self.samplers.push((name, ty.clone())), + "uniform" if ty.contains("image") => self + .errors + .push(self.error(ty_at, &format!("`{name}`: images are not supported yet"))), + "uniform" => self.uniforms.push((name, format!("{ty}{array}"), ty_at)), + _ if input && !fragment => { + let value = attribute_value(&name, &ty); + self.attributes.push((name, value, ty.clone())); + self.removed.pop(); + // A private global of ours now: `in`/`attribute` and qualifiers go. + for &w in &words[..k] { + self.removed.push(w..w + 1); + } + } + _ if input => self.ins.push(Var { name, decl }), + _ if fragment => { + let n = location.unwrap_or(self.outputs.len() as u32); + self.outputs.insert(n); + self.frag_outs + .push((n, format!("layout(location = {n}) out {ty} {name}{array};"))); + } + _ => self.outs.push(Var { name, decl }), + } + } + } + + /// A global `T name = a ? b : c;`: naga can't evaluate a conditional at global scope, so + /// the initializer becomes a function of ours on the same line (`T name; T hz_init_name() { + /// return a ? b : c; }`), called by our `main` before the pack's. + pub(super) fn initializer(&mut self, words: &[usize], to: usize) { + let text = |i: usize| self.tokens[i]; + let Some(eq) = words.iter().position(|&i| text(i) == "=") else { + return; + }; + if !words[eq..].iter().any(|&i| text(i) == "?") + || words[..eq].iter().any(|&i| text(i) == ",") + { + return; + } + let Some(name_at) = words[..eq].iter().rposition(|&i| is_ident(text(i))) else { + return; + }; + let name = text(words[name_at]).to_owned(); + let array: String = words[name_at + 1..eq].iter().map(|&i| text(i)).collect(); + let ty: Vec<&str> = words[..name_at] + .iter() + .map(|&i| text(i)) + .filter(|&t| t != "const") + .collect(); + let Some(ty) = ty.last() else { return }; + for &i in &words[..name_at] { + if text(i) == "const" { + self.removed.push(i..i + 1); + } + } + self.replaced.insert( + words[eq], + format!("; {ty}{array} hz_init_{name}() {{ return"), + ); + self.replaced.insert(to, "; }".into()); + self.inits.push(name); + } + + /// A function definition or prototype: remember which parameters are samplers. + pub(super) fn function(&mut self, words: &[usize]) { + let Some(open) = words.iter().position(|&i| self.tokens[i] == "(") else { + return; + }; + let Some(&name) = open.checked_sub(1).and_then(|n| words.get(n)) else { + return; + }; + let mut params = Vec::new(); + let mut is_sampler = false; + for &i in &words[open + 1..] { + match self.tokens[i] { + "," | ")" => { + params.push(is_sampler); + is_sampler = false; + } + t if t.contains("sampler") => is_sampler = true, + _ => {} + } + } + if params.iter().any(|&p| p) { + self.sampler_functions + .insert(self.tokens[name].to_owned(), params); + } + } +} diff --git a/crates/horizon-shaders/tests/packs/post-test/README.md b/crates/horizon-shaders/tests/packs/post-test/README.md new file mode 100644 index 000000000..c3d124eec --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/README.md @@ -0,0 +1,6 @@ +# post-test + +Horizon's own shader pack for testing post-processing (written for Horizon, no other pack's code). +`composite` tints the world warm and stores linear depth in colortex2, `composite1` shows that +depth as fog bands on the right half with a little noise, and `final` adds a vignette. Copy the +folder into `shaderpacks/` and run the game with `HORIZON_SHADER_PACK=post-test`. diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/composite.fsh b/crates/horizon-shaders/tests/packs/post-test/shaders/composite.fsh new file mode 100644 index 000000000..9261e9fae --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/composite.fsh @@ -0,0 +1,23 @@ +#version 120 + +/* +const int colortex2Format = RGBA16F; +*/ + +uniform sampler2D colortex0; +uniform sampler2D depthtex0; +uniform mat4 gbufferProjectionInverse; +uniform float far; + +varying vec2 texcoord; + +void main() { + vec3 color = texture2D(colortex0, texcoord).rgb; + float depth = texture2D(depthtex0, texcoord).r; + // Window depth back to a view-space distance. + vec4 view = gbufferProjectionInverse * vec4(texcoord * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); + float distance = -view.z / view.w; + /* DRAWBUFFERS:02 */ + gl_FragData[0] = vec4(color * vec3(1.08, 1.0, 0.86), 1.0); + gl_FragData[1] = vec4(distance / far, depth, 0.0, 1.0); +} diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/composite.vsh b/crates/horizon-shaders/tests/packs/post-test/shaders/composite.vsh new file mode 100644 index 000000000..6dbee8d1f --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/composite.vsh @@ -0,0 +1,8 @@ +#version 120 + +varying vec2 texcoord; + +void main() { + gl_Position = ftransform(); + texcoord = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy; +} diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/composite1.fsh b/crates/horizon-shaders/tests/packs/post-test/shaders/composite1.fsh new file mode 100644 index 000000000..5207ad758 --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/composite1.fsh @@ -0,0 +1,25 @@ +#version 330 compatibility + +uniform sampler2D colortex0; +uniform sampler2D colortex2; +uniform sampler2D noisetex; +uniform float viewWidth; +uniform float viewHeight; + +in vec2 texcoord; + +/* RENDERTARGETS: 0 */ +layout(location = 0) out vec4 outColor; + +void main() { + vec3 color = texture(colortex0, texcoord).rgb; + if (gl_FragCoord.x > viewWidth * 0.5) { + float d = clamp(texture(colortex2, texcoord).r, 0.0, 1.0); + float band = step(0.5, fract(d * 8.0)); + vec3 fog = mix(vec3(0.2, 0.45, 0.9), vec3(0.95, 0.75, 0.3), d); + color = mix(color, fog, 0.35 + 0.15 * band); + float grain = texture(noisetex, gl_FragCoord.xy / 256.0).r - 0.5; + color += grain * 0.04; + } + outColor = vec4(color, 1.0); +} diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/composite1.vsh b/crates/horizon-shaders/tests/packs/post-test/shaders/composite1.vsh new file mode 100644 index 000000000..12ef6da1f --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/composite1.vsh @@ -0,0 +1,8 @@ +#version 330 compatibility + +out vec2 texcoord; + +void main() { + gl_Position = ftransform(); + texcoord = gl_MultiTexCoord0.xy; +} diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/final.fsh b/crates/horizon-shaders/tests/packs/post-test/shaders/final.fsh new file mode 100644 index 000000000..5efbf50df --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/final.fsh @@ -0,0 +1,12 @@ +#version 120 + +uniform sampler2D colortex0; + +varying vec2 texcoord; + +void main() { + vec3 color = texture2D(colortex0, texcoord).rgb; + vec2 centred = texcoord - 0.5; + float vignette = 1.0 - dot(centred, centred) * 0.9; + gl_FragColor = vec4(color * vignette, 1.0); +} diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/final.vsh b/crates/horizon-shaders/tests/packs/post-test/shaders/final.vsh new file mode 100644 index 000000000..6dbee8d1f --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/final.vsh @@ -0,0 +1,8 @@ +#version 120 + +varying vec2 texcoord; + +void main() { + gl_Position = ftransform(); + texcoord = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy; +} diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/shaders.properties b/crates/horizon-shaders/tests/packs/post-test/shaders/shaders.properties new file mode 100644 index 000000000..510ada3a9 --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/shaders.properties @@ -0,0 +1,2 @@ +# Horizon's post-processing test pack. +program.composite1.enabled = !NO_SPLIT diff --git a/crates/horizon-shaders/tests/real_packs.rs b/crates/horizon-shaders/tests/real_packs.rs index b35aef519..d0a28d71d 100644 --- a/crates/horizon-shaders/tests/real_packs.rs +++ b/crates/horizon-shaders/tests/real_packs.rs @@ -5,7 +5,12 @@ use std::path::Path; use std::time::Instant; -use horizon_shaders::{Lang, Pack, discover}; +use std::collections::BTreeMap; + +use horizon_shaders::{ + Lang, Macros, Pack, Properties, Target, dimension_folder, discover, load_post, preprocess, + translate_composite, +}; #[test] #[ignore = "needs HORIZON_TEST_SHADER_PACKS"] @@ -22,21 +27,166 @@ fn real_packs_read() { for f in found.iter().filter(|f| f.problem.is_none()) { let start = Instant::now(); let pack = Pack::open(&f.path).unwrap(); - let properties = pack.read_text("shaders.properties"); + let macros = Macros::standard(); + let shaders = Properties::parse( + &pack.read_text("shaders.properties").unwrap_or_default(), + ¯os, + ); + let ids: Vec = ["block", "item", "entity", "dimension"] + .iter() + .filter_map(|n| pack.read_text(&format!("{n}.properties"))) + .map(|t| Properties::parse(&t, ¯os)) + .collect(); let lang = Lang::load(&pack, "en_us"); + let elapsed = start.elapsed(); println!( - "{}: {} files at the root, shaders.properties {} bytes, lang {}, {:.2} ms", + "{}: shaders.properties {} keys ({} notes), {} id files {} keys ({} notes), lang {}, {:.2} ms", f.name, - pack.list("").len(), - properties.as_ref().map_or(0, String::len), + shaders.entries.len(), + shaders.notes.len(), + ids.len(), + ids.iter().map(|p| p.entries.len()).sum::(), + ids.iter().map(|p| p.notes.len()).sum::(), if lang == Lang::default() { "none" } else { "read" }, + elapsed.as_secs_f64() * 1000.0 + ); + for n in &shaders.notes { + println!(" shaders.properties:{}: {}", n.line, n.message); + } + // Every stage file of the base and dimension folders through the preprocessor. + let start = Instant::now(); + let (mut stages, mut lines, mut diagnostics) = (0, 0, Vec::new()); + for dir in ["", "world0", "world-1", "world1"] { + for file in pack.list(dir) { + if [".vsh", ".fsh", ".gsh", ".csh"] + .iter() + .any(|e| file.ends_with(e)) + { + let path = if dir.is_empty() { + file + } else { + format!("{dir}/{file}") + }; + let p = preprocess(&pack, &path, ¯os); + stages += 1; + lines += p.lines.len(); + diagnostics.extend(p.diagnostics.iter().map(|d| { + let file = &p.files[d.origin.file as usize]; + format!("{file}:{}: {}", d.origin.line, d.message) + })); + } + } + } + println!( + " {stages} stage files preprocessed, {lines} lines out, {} diagnostics, {:.1} ms", + diagnostics.len(), start.elapsed().as_secs_f64() * 1000.0 ); - assert!(properties.is_some(), "{}: no shaders.properties", f.name); + diagnostics.sort(); + diagnostics.dedup(); + for d in diagnostics.iter().take(8) { + println!(" {d}"); + } + // Every composite-style program with both stages, translated and validated. + let start = Instant::now(); + let (mut ok, mut failed, mut causes) = (0, 0, BTreeMap::::new()); + for dir in ["", "world0", "world-1", "world1"] { + for file in pack.list(dir) { + let Some(name) = file.strip_suffix(".fsh") else { + continue; + }; + let composite = [ + "composite", + "deferred", + "final", + "prepare", + "begin", + "shadowcomp", + ] + .iter() + .any(|p| { + name.strip_prefix(p) + .is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())) + }); + let path = |ext: &str| { + if dir.is_empty() { + format!("{name}.{ext}") + } else { + format!("{dir}/{name}.{ext}") + } + }; + if !composite || !pack.exists(&path("vsh")) { + continue; + } + let target = if name == "final" { + Target::Window + } else { + Target::Buffers + }; + let v = preprocess(&pack, &path("vsh"), ¯os); + let fr = preprocess(&pack, &path("fsh"), ¯os); + match translate_composite(&v, &fr, target) { + Ok(_) => ok += 1, + Err(errors) => { + failed += 1; + let first = &errors[0]; + let cause: String = first.message.chars().take(90).collect(); + *causes.entry(cause).or_default() += 1; + if std::env::var_os("SHOW_ERRORS").is_some() { + println!( + " {}: {}:{}: {}", + path("fsh"), + first.file, + first.line, + first.message + ); + } + } + } + } + } + println!( + " composite-style programs: {ok} translate, {failed} fail, {:.0} ms", + start.elapsed().as_secs_f64() * 1000.0 + ); + let mut causes: Vec<_> = causes.into_iter().collect(); + causes.sort_by_key(|c| std::cmp::Reverse(c.1)); + for (cause, n) in causes.iter().take(6) { + println!(" {n} x {cause}"); + } + // The post-processing set of each dimension, as the game loads it. + for id in [0, -1, 1] { + let start = std::time::Instant::now(); + let folder = dimension_folder(&pack, id); + // Vertex inputs like the game's (gfx/pack_post.rs writes the real ones). + let terrain = horizon_shaders::GbuffersInputs { + declarations: "layout(location = 0) in vec3 hz_v_pos;".into(), + body: " hz_Vertex = vec4(hz_v_pos, 1.0);\n hz_Color = vec4(1.0);\n".into(), + attributes: vec![("mc_Entity".into(), "vec4(-1.0)".into())], + }; + let post = load_post( + &pack, + &folder, + &shaders, + &Macros::standard(), + Some(&terrain), + ); + println!( + " dimension {id} (folder `{folder}`): {} post programs, terrain {:?}, {} notes, {:.0} ms", + post.programs.len(), + post.terrain.as_ref().map(|t| &t.name), + post.notes.len() + post.config.notes.len(), + start.elapsed().as_secs_f64() * 1000.0 + ); + for n in post.notes.iter().chain(&post.config.notes).take(12) { + println!(" {n}"); + } + } + assert!(!shaders.entries.is_empty(), "{}: no keys read", f.name); read += 1; } assert!(read > 0, "no packs under {}", dir.display()); diff --git a/crates/horizon-world/src/worldgen/block_feature.rs b/crates/horizon-world/src/worldgen/block_feature.rs index 1ee122772..0131b93e2 100644 --- a/crates/horizon-world/src/worldgen/block_feature.rs +++ b/crates/horizon-world/src/worldgen/block_feature.rs @@ -192,17 +192,7 @@ impl super::block_predicate::PredicateBlockResolver for FeatureBlockBindings<'_> &self, property: BlockProperty, ) -> Result { - let values = self - .physical_properties - .as_ref() - .ok_or(DecodeError::Field("predicate block property registry"))?; - Ok(values[match property { - BlockProperty::Solid => 0, - BlockProperty::Replaceable => 1, - BlockProperty::Liquid => 2, - BlockProperty::SolidRender => 3, - }] - .clone()) + super::block_predicate::bound_property(self.physical_properties.as_ref(), property) } fn predicate_biome_set( &self, diff --git a/crates/horizon-world/src/worldgen/block_predicate.rs b/crates/horizon-world/src/worldgen/block_predicate.rs index a4709abc2..7d27de22e 100644 --- a/crates/horizon-world/src/worldgen/block_predicate.rs +++ b/crates/horizon-world/src/worldgen/block_predicate.rs @@ -10,6 +10,22 @@ use horizon_core::geometry::Direction; use serde_json::value::RawValue; use std::{collections::BTreeSet, sync::Arc}; +/// `predicate_property` for resolvers that hold the bound physical properties +/// (`block_feature` and `state_provider`), in `BlockProperty` order. +pub(super) fn bound_property( + values: Option<&[BoundBlockProperty; 4]>, + property: BlockProperty, +) -> Result { + let values = values.ok_or(DecodeError::Field("predicate block property registry"))?; + Ok(values[match property { + BlockProperty::Solid => 0, + BlockProperty::Replaceable => 1, + BlockProperty::Liquid => 2, + BlockProperty::SolidRender => 3, + }] + .clone()) +} + /// Resolve a block holder set against the same immutable registry used by world /// queries. Unknown blocks/tags fail during decoding, even in unused branches. pub trait PredicateBlockResolver { diff --git a/crates/horizon-world/src/worldgen/state_provider.rs b/crates/horizon-world/src/worldgen/state_provider.rs index da39cd246..93005b86f 100644 --- a/crates/horizon-world/src/worldgen/state_provider.rs +++ b/crates/horizon-world/src/worldgen/state_provider.rs @@ -186,17 +186,7 @@ impl PredicateBlockResolver for StateProviderBindings<'_> { &self, property: BlockProperty, ) -> Result { - let values = self - .physical_properties - .as_ref() - .ok_or(DecodeError::Field("predicate block property registry"))?; - Ok(values[match property { - BlockProperty::Solid => 0, - BlockProperty::Replaceable => 1, - BlockProperty::Liquid => 2, - BlockProperty::SolidRender => 3, - }] - .clone()) + super::block_predicate::bound_property(self.physical_properties.as_ref(), property) } fn predicate_biome_set( &self, diff --git a/docs/api/mod-index.md b/docs/api/mod-index.md index afa6e8944..beb6ec2c8 100644 --- a/docs/api/mod-index.md +++ b/docs/api/mod-index.md @@ -129,6 +129,8 @@ reason = "Sent chat messages it didn't declare." date = "2026-10-24" ``` +The submission form's service (`crates/horizon-mod-registry`) writes these entries: it signs the creator in with GitHub, runs the file's checks, uploads the file to the repository's `staging` release as `.` and opens the pull request. Only an entry's `owners` publish updates through it. + Unknown keys are refused. Its CI runs `cargo-horizon-mod` (`crates/cargo-horizon-mod`): | Command | What it does | diff --git a/docs/native-client.md b/docs/native-client.md index f6251592d..2c123bac4 100644 --- a/docs/native-client.md +++ b/docs/native-client.md @@ -208,6 +208,28 @@ p50/p95/p99/max frame times), plus CPU and GPU time per frame; [the graphics benchmark](graphics.md#benchmark) is built on it. Frame times include waits for presentation, so `bench` reports what is on screen. +### Discord Rich Presence + +While Discord runs on the same computer, the player's Discord profile shows +"Playing Horizon" and what they are doing: "In the menus", "Singleplayer", +"Multiplayer" or "Hosting a world" (with the party size, of 8), the dimension +("In the Nether") and the time since they entered the world. It never shows a +world's name or a server's address. Horizon Settings has the switch +(**Discord Rich Presence**, on by default; `discordPresence` in +`horizon-extras.txt`), and `HORIZON_NO_DISCORD=1` turns it off for one run. + +`crates/horizon-client/src/discord_presence.rs` speaks Discord's local IPC +itself (the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its +own: the frame only hands it a status when it changes. It retries every 15 s +while Discord isn't running and stays within Discord's 5 updates per 20 s. +Dedicated servers never start it, and automated runs (offscreen, benchmarks) +only talk to a fake Discord: `lab.game_env` sets `HORIZON_NO_DISCORD=1`, and +`tools/discord_presence_e2e.py` points games at a fake server with +`HORIZON_DISCORD_IPC_DIR` (title, world, Nether and quit on both single-player +paths; a LAN host and guest; the switch; a Discord started after the game). +`DISCORD_APP_ID` is Discord's "Horizon" application, whose `horizon` image is +the large one; there are no game-mode images yet. + ### Play on another of your machines Horizon targets Linux on x86-64 with an NVIDIA GPU (Vulkan); macOS and Windows diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 2f63951c8..270f856a0 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -558,7 +558,19 @@ pub fn install(release: &Release, progress: impl Fn(f32)) -> Result<(), String> } pub fn remove(tag: &str) -> Result<(), String> { - fs::remove_dir_all(version_dir(tag)).map_err(|e| e.to_string()) + let dir = version_dir(tag); + // Windows holds a file for a moment after the game, a scan or the launcher's own reader lets + // go of it, and remove_dir_all stops at the first one. Retry for a second; a folder that is + // already gone is removed. + for attempt in 1.. { + match fs::remove_dir_all(&dir) { + Ok(()) => break, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => break, + Err(e) if attempt == 5 => return Err(e.to_string()), + Err(_) => std::thread::sleep(std::time::Duration::from_millis(250)), + } + } + Ok(()) } /// The Minecraft version a Horizon build targets, from its research-lock.json. diff --git a/launcher/src/mojang.rs b/launcher/src/mojang.rs index 070db43aa..27f9c3f0b 100644 --- a/launcher/src/mojang.rs +++ b/launcher/src/mojang.rs @@ -181,12 +181,16 @@ pub fn java_runtime( Ok(home) } +// Used by the unix link step below; the path-traversal check itself is tested on every +// platform, so these stay compiled and are allowed to look unused elsewhere. +#[cfg_attr(not(unix), allow(dead_code))] fn name_in<'a>(home: &Path, path: &'a Path) -> &'a Path { path.strip_prefix(home).unwrap_or(path) } /// Whether a link at `link` (relative to the runtime's home) pointing at `target` stays in the /// home. +#[cfg_attr(not(unix), allow(dead_code))] fn link_stays_inside(link: &Path, target: &str) -> bool { let mut depth = link.components().count().saturating_sub(1); for c in Path::new(target).components() { diff --git a/tools/discord_presence_e2e.py b/tools/discord_presence_e2e.py new file mode 100644 index 000000000..512c6530f --- /dev/null +++ b/tools/discord_presence_e2e.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""End-to-end check of Discord Rich Presence: real games talk to a fake Discord. + +Each game gets its own fake Discord IPC server (a Unix socket in a scratch folder, pointed to by +HORIZON_DISCORD_IPC_DIR) that answers the handshake with READY and records every SET_ACTIVITY. +The game goes title -> world -> Nether -> quit through horizon-ctl, and the activities must say +"In the menus", "Singleplayer" in the Overworld, then the Nether, and be cleared on quit: + + single single player on its server thread (the default) + in-frame single player ticked in the window's frame (--in-frame) + lan a host opens its world to LAN and a guest joins: the guest shows "Multiplayer", + the host "Hosting a world" with a party of 2 of 8 (lan-in-frame: the host in-frame) + setting off in horizon-extras.txt: nothing reaches Discord; the Horizon Settings switch + turns it on (the activity appears) and off (it is cleared) + late Discord starts after the game: found within the 15 s retry + + python3 tools/discord_presence_e2e.py # all of them + python3 tools/discord_presence_e2e.py --check lan --bin-dir target/fast + +macOS and Linux (Discord's Windows pipes aren't faked here). Games render offscreen, have no audio +and use scratch homes (lab.game_env). Needs the prepared resources in .local/horizon-client. +""" +import argparse +import json +import os +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +try: + from . import lab +except ImportError: + import lab + +ROOT = Path(__file__).resolve().parents[1] +HANDSHAKE, FRAME, CLOSE = 0, 1, 2 + + +class FakeDiscord: + """discord-ipc-0 in a scratch folder: READY on handshake, a reply to each command.""" + + def __init__(self): + # Short: Unix socket paths are limited to ~100 bytes. + self.dir = Path(tempfile.mkdtemp(prefix='hzdc-', dir='/tmp')) + self.server = None + self.activities = [] # (seconds since start, activity or None) + self.handshakes = [] + self.started = time.time() + self.lock = threading.Lock() + + def start(self): + """Discord starts: its socket appears.""" + self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.server.bind(str(self.dir / 'discord-ipc-0')) + self.server.listen() + threading.Thread(target=self.accept, daemon=True).start() + + def accept(self): + while True: + try: + conn, _ = self.server.accept() + except OSError: + return + threading.Thread(target=self.serve, args=(conn,), daemon=True).start() + + def serve(self, conn): + def read(): + header = conn.recv(8, socket.MSG_WAITALL) + if len(header) < 8: + raise EOFError + op, length = struct.unpack(' code.replace(/"\.\/(\w+\.js)"/g, (all, file) => (names[file] ? `"./${names[file]}"` : all)); // Dependencies before what imports them. -const modules = ["markdown.js", "versions.js", "chrome.js", "hardware.js", "estimate.js", "releases.js", "contributors.js", "bench.js", "main.js", "releases-page.js", "benchmarks-page.js"]; +const modules = ["markdown.js", "versions.js", "chrome.js", "hardware.js", "estimate.js", "releases.js", "contributors.js", "bench.js", "main.js", "releases-page.js", "benchmarks-page.js", "docs-page.js"]; for (const file of modules) { const code = rewrite(await readFile(join(site, "src", file), "utf8")); names[file] = `${file.replace(".js", "")}.${fingerprint(code)}.js`; @@ -201,13 +201,16 @@ const baked = { const unavailable = `

The release notes could not be loaded here.

Read them on GitHub`; -// The top bar and the footer of the pages beside the home page (Releases, Benchmarks). -const navLinks = [["/#about", "About"], ["/releases", "Releases"], ["/benchmarks", "Benchmarks"], ["/docs/", "Docs"]]; -const siteNav = (current) => `