Merge origin/main into fix/vehicle-passenger-pose
Keeps main's crystals check and this branch's boat-dismount-pose check in tools/lan_e2e.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
Generated
+16
@@ -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",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -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<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
|
||||
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<u8>| writer.lock().unwrap().send(&body);
|
||||
let send = |body: Vec<u8>| 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<u8>| writer.lock().unwrap().send(&body).is_ok();
|
||||
let send = |body: Vec<u8>| 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::<Vec<_>>(),
|
||||
});
|
||||
if o.report.as_deref() == Some("-") {
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Экранная клавиатура"
|
||||
}
|
||||
@@ -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<Event>,
|
||||
/// `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<BossBar> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<u64>,
|
||||
}
|
||||
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<Shared> = Mutex::new(Shared {
|
||||
wanted: Wanted {
|
||||
status: Status::Menus,
|
||||
since: None,
|
||||
},
|
||||
stop: false,
|
||||
stopped: false,
|
||||
});
|
||||
static STOPPED: Condvar = Condvar::new();
|
||||
static THREAD: OnceLock<Thread> = 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<Client> = None;
|
||||
let mut sent: Option<(Wanted, Instant)> = None;
|
||||
let mut last_try: Option<Instant> = 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<u64>) -> Option<Value> {
|
||||
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<Instant>,
|
||||
}
|
||||
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<u8> {
|
||||
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<T: Read + Write + Send> Pipe for T {}
|
||||
|
||||
struct Client {
|
||||
pipe: Box<dyn Pipe>,
|
||||
nonce: u64,
|
||||
}
|
||||
impl Client {
|
||||
/// The first Discord socket that answers the handshake with READY.
|
||||
fn connect() -> Option<Self> {
|
||||
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<Value>) -> 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<PathBuf> {
|
||||
sockets_with(|key| std::env::var_os(key), cfg!(windows))
|
||||
}
|
||||
|
||||
fn sockets_with(var: impl Fn(&str) -> Option<std::ffi::OsString>, windows: bool) -> Vec<PathBuf> {
|
||||
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<PathBuf> = ["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<Box<dyn Pipe>> {
|
||||
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<Box<dyn Pipe>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Tag> {
|
||||
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<Projectile> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Result<Item, &'static str>> {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<f32>,
|
||||
/// `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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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<u128, HashMap<u128, Offer>>,
|
||||
}
|
||||
|
||||
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<i32> {
|
||||
@@ -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 [<targets>]
|
||||
let include = if ctx.get("targets").is_some() {
|
||||
!self.entities(ctx, "targets", true)?.is_empty()
|
||||
// players [<targets>]: 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<hud_overlays::BossBar>) {
|
||||
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<Text> {
|
||||
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<u128> = 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<Viewer> {
|
||||
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<Offer> {
|
||||
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<Offer>) -> Vec<play::BossEvent> {
|
||||
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<u32>,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<f32> {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<hud_overlays::BossBar>) {
|
||||
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::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ObjectHit> {
|
||||
/// view hits within reach; also End crystals when attacking.
|
||||
pub(super) fn remote_object_hit(&self, attacking: bool) -> Option<ObjectHit> {
|
||||
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 {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Shader packs, for development until the Shader Packs page exists: `HORIZON_SHADER_PACK=<name>`
|
||||
//! runs `shaderpacks/<name>` (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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<hud_overlays::BossBar>) {
|
||||
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(
|
||||
|
||||
@@ -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<BorderDraw>,
|
||||
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<f32>,
|
||||
@@ -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<pack_post::PackPost>)>,
|
||||
/// The block sprites as one atlas, while a shader pack is loaded.
|
||||
pack_atlas: Option<pack_atlas::PackAtlas>,
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
@@ -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<horizon_shaders::Post>,
|
||||
) -> Option<(Vec<String>, Vec<String>)> {
|
||||
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<i32> {
|
||||
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<bool> {
|
||||
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 {
|
||||
|
||||
@@ -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<u32>,
|
||||
}
|
||||
|
||||
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],
|
||||
|
||||
@@ -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<Self> {
|
||||
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<Item = u32>,
|
||||
) {
|
||||
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<wgpu::Texture> = (0..2)
|
||||
.map(|a| {
|
||||
let count = (layers - a * split).min(split);
|
||||
let pixels: Vec<u8> = (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]);
|
||||
}
|
||||
}
|
||||
@@ -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<u8>,
|
||||
/// (index into `pack_uniforms::NAMES`, declared type, offset).
|
||||
uniforms: Vec<(Option<usize>, String, u32)>,
|
||||
samplers: Vec<Binding>,
|
||||
/// (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<u16, wgpu::BindGroup>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
passes: Vec<Pass>,
|
||||
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<Targets>,
|
||||
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<usize>,
|
||||
/// Buffers cleared in passes of their own (more than the blit's eight attachments).
|
||||
other_clears: Vec<usize>,
|
||||
/// 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<u8> = (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<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, String> {
|
||||
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::ColorTargetState>],
|
||||
) -> (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<wgpu::ColorTargetState> {
|
||||
Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})
|
||||
}
|
||||
|
||||
fn attach(
|
||||
view: &wgpu::TextureView,
|
||||
ops: wgpu::Operations<wgpu::Color>,
|
||||
) -> Option<wgpu::RenderPassColorAttachment<'_>> {
|
||||
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<Option<wgpu::RenderPassColorAttachment>> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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<f32> {
|
||||
let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world_depth: texture_depth_2d;
|
||||
|
||||
struct World {
|
||||
@location(0) color: vec4<f32>,
|
||||
@location(1) depth: vec4<f32>,
|
||||
}
|
||||
|
||||
// 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<f32>) -> World {
|
||||
let size = vec2<i32>(textureDimensions(source));
|
||||
let at = vec2<i32>(i32(p.x), size.y - 1 - i32(p.y));
|
||||
var out: World;
|
||||
out.color = textureLoad(source, at, 0);
|
||||
out.depth = vec4<f32>(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<f32>) -> @location(0) vec4<f32> {
|
||||
let size = vec2<i32>(textureDimensions(source));
|
||||
return textureLoad(source, vec2<i32>(i32(p.x), size.y - 1 - i32(p.y)), 0);
|
||||
}
|
||||
|
||||
@group(0) @binding(2) var linear: sampler;
|
||||
|
||||
struct Full {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_uv(@builtin(vertex_index) i: u32) -> Full {
|
||||
let uv = vec2<f32>(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<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), vec2<f32>(uv.x, 1.0 - uv.y));
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_copy(v: Full) -> @location(0) vec4<f32> {
|
||||
return textureSample(source, linear, v.uv);
|
||||
}
|
||||
@@ -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<Value>,
|
||||
start: std::time::Instant,
|
||||
last: Option<std::time::Instant>,
|
||||
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<usize> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<String> {
|
||||
vec!["horizon".into()]
|
||||
}
|
||||
}
|
||||
impl ResourceSource for std::path::PathBuf {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
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<String> =
|
||||
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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Box<crate::arrows::ArrowState>>,
|
||||
/// `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`.
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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, ..
|
||||
|
||||
Generated
+1352
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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 `<sha256>.<extension>`, and opens a pull
|
||||
request adding or updating `mods/<id>.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 `<it>/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.
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, Failure>;
|
||||
|
||||
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<Value> {
|
||||
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/<id>.toml` on main, with its blob sha; None when it isn't there.
|
||||
pub fn entry(&self, id: &str) -> Result<Option<(Entry, String)>> {
|
||||
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<bool> {
|
||||
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<String> {
|
||||
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()
|
||||
}
|
||||
@@ -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<u8>,
|
||||
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<Config, String> {
|
||||
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<Inner>);
|
||||
|
||||
struct Inner {
|
||||
config: Config,
|
||||
recent: Mutex<HashMap<String, VecDeque<Instant>>>,
|
||||
}
|
||||
|
||||
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<String>) -> 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<String>) -> 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: `<payload>.<HMAC-SHA256>`, both base64url.
|
||||
|
||||
fn sign(key: &[u8], payload: &str) -> String {
|
||||
let mut mac = Hmac::<Sha256>::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<String> {
|
||||
let (payload, tag) = value.split_once('.')?;
|
||||
let payload = String::from_utf8(URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
|
||||
let mut mac = Hmac::<Sha256>::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<String> {
|
||||
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::<u64>().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<String> {
|
||||
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<App>) -> 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<App>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<Callback>,
|
||||
) -> Result<Response, Refusal> {
|
||||
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<App>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, 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<String>,
|
||||
pub license: String,
|
||||
pub source: String,
|
||||
/// `https://` only; an icon in the repository is a pull request by hand.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
/// A new project's minisign public key.
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
/// The `.minisig` text.
|
||||
#[serde(default)]
|
||||
pub signature: Option<String>,
|
||||
pub version: String,
|
||||
pub game: String,
|
||||
#[serde(default)]
|
||||
pub changelog: String,
|
||||
}
|
||||
|
||||
fn take(window: &mut VecDeque<Instant>, 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<App>,
|
||||
headers: HeaderMap,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, 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::<Form>(&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<String, Refusal> {
|
||||
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<String> = v
|
||||
.capabilities
|
||||
.iter()
|
||||
.map(|c| format!("- may {}", c.describe()))
|
||||
.collect();
|
||||
let requires: Vec<String> = v
|
||||
.requires
|
||||
.iter()
|
||||
.map(|r| format!("- needs {} {}", r.id, r.version))
|
||||
.collect();
|
||||
let body = format!(
|
||||
"<!-- submitted-by: {login} -->\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);
|
||||
}
|
||||
}
|
||||
@@ -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}"));
|
||||
}
|
||||
@@ -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<String, String>,
|
||||
assets: Vec<(String, usize)>,
|
||||
branches: Vec<String>,
|
||||
/// (branch, path) -> (text, message, replaced blob).
|
||||
commits: HashMap<(String, String), (String, String, Option<String>)>,
|
||||
pulls: Vec<Value>,
|
||||
/// The account the next sign-in gets.
|
||||
login: String,
|
||||
}
|
||||
|
||||
type Shared = Arc<Mutex<Mock>>;
|
||||
|
||||
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<Value>| async move {
|
||||
Json(if body["code"] == "good" {
|
||||
json!({"access_token": "gho_user"})
|
||||
} else {
|
||||
json!({"error": "bad_verification_code"})
|
||||
})
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/user",
|
||||
get(|State(s): State<Shared>| async move {
|
||||
Json(json!({"login": s.lock().unwrap().login, "id": 7}))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/repos/o/r/contents/mods/{file}",
|
||||
get(
|
||||
|State(s): State<Shared>, UrlPath(file): UrlPath<String>| 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<Shared>, UrlPath(file): UrlPath<String>, Json(b): Json<Value>| 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<Shared>| async move {
|
||||
let names: Vec<Value> = 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<Shared>, Query(q): Query<HashMap<String, String>>, 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<Shared>, Json(b): Json<Value>| 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<Shared>, Json(b): Json<Value>| 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<String, String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
async fn send(&mut self, request: Request<Body>) -> (StatusCode, Value, Option<String>) {
|
||||
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::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
fn get(&self, uri: &str) -> Request<Body> {
|
||||
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("<!-- submitted-by: alice -->"), "{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"
|
||||
);
|
||||
}
|
||||
@@ -1616,6 +1616,125 @@ impl Codec<Vec<(i32, i32, i32)>> 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<BossEvent> for BossEvent {
|
||||
fn read(r: &mut Reader) -> Result<BossEvent> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}-{}",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<i64, String> {
|
||||
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<Branch>);
|
||||
|
||||
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<bool, String> {
|
||||
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<bool, String> {
|
||||
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<String, String> {
|
||||
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<usize> {
|
||||
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<Vec<Token>, 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<i64, String> {
|
||||
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<Token> {
|
||||
let t = self.tokens.get(self.at).copied();
|
||||
self.at += 1;
|
||||
t
|
||||
}
|
||||
|
||||
fn binary(&mut self, level: usize) -> Result<i64, String> {
|
||||
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<i64, String> {
|
||||
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<i64, String> {
|
||||
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<i64, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<Format> {
|
||||
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 (`<buffer>Clear`, default true).
|
||||
pub clear: bool,
|
||||
/// `<buffer>ClearColor`, else 6.1.5's default: colortex0 the fog colour, colortex1 white,
|
||||
/// the others transparent black.
|
||||
pub clear_color: Option<[f32; 4]>,
|
||||
/// `<buffer>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<String>,
|
||||
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<usize> {
|
||||
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<Vec<u32>> {
|
||||
let mut found = None;
|
||||
for (_, comment) in &pp.comments {
|
||||
let text = comment.trim();
|
||||
if let Some(digits) = text.strip_prefix("DRAWBUFFERS:") {
|
||||
let list: Option<Vec<u32>> = 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<Vec<u32>> = 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 <type> <name> = <value>;` 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<bool> {
|
||||
match value {
|
||||
"true" => Some(true),
|
||||
"false" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn number(text: &str) -> Option<f32> {
|
||||
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<Vec<f32>> = 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Macros(BTreeMap<String, Macro>);
|
||||
|
||||
/// `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<String>, value: impl Into<String>) {
|
||||
let body = value.into();
|
||||
self.0.insert(name.into(), Macro { params: None, body });
|
||||
}
|
||||
|
||||
pub fn define_function(&mut self, name: &str, params: Vec<String>, 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<String>, 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
pub(crate) fn normalize(path: &str) -> Option<String> {
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for part in path.split(['/', '\\']) {
|
||||
match part {
|
||||
|
||||
@@ -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<u32>,
|
||||
/// `flip.<name>.<buffer>`: 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<PostProgram>,
|
||||
/// 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<PostProgram>,
|
||||
pub config: Config,
|
||||
/// Programs skipped and why, as `program: message` or `file:line: message`.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// The folder a dimension reads its programs from: `world<id>` 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<String> = 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<Program, Vec<crate::Error>>,
|
||||
) -> Option<PostProgram> {
|
||||
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<String> = [&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<String> {
|
||||
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.<name>.enabled` (or `program.<folder>/<name>.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.<program>.<buffer>=<bool>` 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::<Vec<_>>());
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Origin>,
|
||||
/// The pack paths of the files read, the stage file first.
|
||||
pub files: Vec<String>,
|
||||
/// `#version` of the stage file: the number and the profile word, if any.
|
||||
pub version: Option<(u32, Option<String>)>,
|
||||
/// `#extension` lines of live code, as written after `#extension`.
|
||||
pub extensions: Vec<String>,
|
||||
/// The text of every block comment in live code (`DRAWBUFFERS:`, buffer formats).
|
||||
pub comments: Vec<(Origin, String)>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// Files already read: a guarded header is included many times.
|
||||
texts: HashMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String> = 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`"]);
|
||||
}
|
||||
}
|
||||
@@ -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<Entry>,
|
||||
pub notes: Vec<Note>,
|
||||
}
|
||||
|
||||
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<u32> = p.notes.iter().map(|n| n.line).collect();
|
||||
assert_eq!(lines, [1, 2, 3, 8, 8]);
|
||||
assert!(p.entries.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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<Uniform>,
|
||||
/// Each sampler: texture at `binding`, sampler object at `binding + 1`.
|
||||
pub samplers: Vec<Sampler>,
|
||||
/// The fragment stage's output locations.
|
||||
pub outputs: Vec<u32>,
|
||||
}
|
||||
|
||||
#[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<Program, Vec<Error>> {
|
||||
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<Program, Vec<Error>> {
|
||||
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<Program, Vec<Error>> {
|
||||
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<Error> = v.errors.drain(..).chain(f.errors.drain(..)).collect();
|
||||
|
||||
// One uniform block and one set of samplers for both stages.
|
||||
let mut uniforms: BTreeMap<String, String> = 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<String, String> = 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<Sampler> = 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<String, (u32, String)> = 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<naga::Module, Vec<Error>> {
|
||||
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: "<horizon>".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::<Vec<_>>()
|
||||
})?;
|
||||
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<Program, Vec<Error>> {
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, String>,
|
||||
) -> 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<String, String> = 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<String> = 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<usize> {
|
||||
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)"),
|
||||
}
|
||||
}
|
||||
@@ -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<std::ops::Range<usize>>,
|
||||
/// 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<Var>,
|
||||
pub(super) outs: Vec<Var>,
|
||||
/// 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<String, Vec<bool>>,
|
||||
pub(super) outputs: BTreeSet<u32>,
|
||||
pub(super) frag_coord: bool,
|
||||
/// Tokens written differently, by index.
|
||||
pub(super) replaced: HashMap<usize, String>,
|
||||
/// Globals whose initializer runs in our `main` (`g = hz_init_g();`), in order.
|
||||
pub(super) inits: Vec<String>,
|
||||
pub(super) errors: Vec<Error>,
|
||||
}
|
||||
|
||||
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<usize> = (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::<u32>().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#version 120
|
||||
|
||||
varying vec2 texcoord;
|
||||
|
||||
void main() {
|
||||
gl_Position = ftransform();
|
||||
texcoord = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#version 330 compatibility
|
||||
|
||||
out vec2 texcoord;
|
||||
|
||||
void main() {
|
||||
gl_Position = ftransform();
|
||||
texcoord = gl_MultiTexCoord0.xy;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#version 120
|
||||
|
||||
varying vec2 texcoord;
|
||||
|
||||
void main() {
|
||||
gl_Position = ftransform();
|
||||
texcoord = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Horizon's post-processing test pack.
|
||||
program.composite1.enabled = !NO_SPLIT
|
||||
@@ -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<Properties> = ["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::<usize>(),
|
||||
ids.iter().map(|p| p.notes.len()).sum::<usize>(),
|
||||
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::<String, usize>::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());
|
||||
|
||||
@@ -192,17 +192,7 @@ impl super::block_predicate::PredicateBlockResolver for FeatureBlockBindings<'_>
|
||||
&self,
|
||||
property: BlockProperty,
|
||||
) -> Result<BoundBlockProperty, DecodeError> {
|
||||
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,
|
||||
|
||||
@@ -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<BoundBlockProperty, DecodeError> {
|
||||
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 {
|
||||
|
||||
@@ -186,17 +186,7 @@ impl PredicateBlockResolver for StateProviderBindings<'_> {
|
||||
&self,
|
||||
property: BlockProperty,
|
||||
) -> Result<BoundBlockProperty, DecodeError> {
|
||||
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,
|
||||
|
||||
@@ -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 `<sha256>.<extension>` 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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-1
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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('<II', header)
|
||||
return op, json.loads(conn.recv(length, socket.MSG_WAITALL) or b'null')
|
||||
|
||||
def send(op, payload):
|
||||
body = json.dumps(payload).encode()
|
||||
conn.sendall(struct.pack('<II', op, len(body)) + body)
|
||||
|
||||
try:
|
||||
while True:
|
||||
op, msg = read()
|
||||
if op == HANDSHAKE:
|
||||
with self.lock:
|
||||
self.handshakes.append(msg)
|
||||
send(FRAME, {'cmd': 'DISPATCH', 'evt': 'READY', 'data': {'v': 1}})
|
||||
elif op == FRAME and msg.get('cmd') == 'SET_ACTIVITY':
|
||||
with self.lock:
|
||||
self.activities.append((round(time.time() - self.started, 1), msg['args']['activity']))
|
||||
send(FRAME, {'cmd': 'SET_ACTIVITY', 'nonce': msg['nonce'], 'data': msg['args']['activity']})
|
||||
elif op == CLOSE:
|
||||
return
|
||||
except (EOFError, OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def last(self):
|
||||
with self.lock:
|
||||
return self.activities[-1][1] if self.activities else 'nothing yet'
|
||||
|
||||
def wait(self, what, check, timeout=60):
|
||||
"""Waits until the latest activity passes `check` (Discord's limit can hold one back 20 s)."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if check(self.last()):
|
||||
print(f' ok: {what}: {json.dumps(self.last())}')
|
||||
return
|
||||
time.sleep(0.25)
|
||||
raise AssertionError(f'{what}: the last activity was {self.last()!r}; all: {self.activities}')
|
||||
|
||||
def close(self):
|
||||
if self.server:
|
||||
self.server.close()
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
|
||||
def shows(details, state=None):
|
||||
return lambda a: isinstance(a, dict) and a.get('details') == details and (state is None or a.get('state') == state)
|
||||
|
||||
|
||||
class Game:
|
||||
"""One game started by horizon-ctl in a scratch home, with its own fake Discord."""
|
||||
|
||||
def __init__(self, bins, name, args=(), extras=None, discord_running=True):
|
||||
self.ctl = bins / 'horizon-ctl'
|
||||
self.home = Path(tempfile.mkdtemp(prefix=f'hzdp-{name}-'))
|
||||
if extras:
|
||||
(self.home / 'horizon-extras.txt').write_text(extras)
|
||||
self.socket = self.home / 'c.sock'
|
||||
self.discord = FakeDiscord()
|
||||
if discord_running:
|
||||
self.discord.start()
|
||||
extra = {'HORIZON_HOME': self.home, 'HORIZON_TELEMETRY_URL': 'off', 'HORIZON_DISCORD_IPC_DIR': self.discord.dir}
|
||||
self.env = lab.game_env(**extra)
|
||||
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-client'), '--resources',
|
||||
str(lab.RESOURCES.resolve()), *args)
|
||||
|
||||
def ctl_run(self, *words, timeout=600):
|
||||
r = subprocess.run([str(self.ctl), '--socket', str(self.socket), *words], cwd=self.home, env=self.env,
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
out = r.stdout.strip().splitlines()
|
||||
reply = json.loads(out[-1]) if out else {'error': r.stderr.strip()}
|
||||
if r.returncode or 'error' in reply:
|
||||
raise AssertionError(f'horizon-ctl {" ".join(words)}: exit {r.returncode}: {reply}')
|
||||
return reply
|
||||
|
||||
def quit(self):
|
||||
try:
|
||||
self.ctl_run('quit', timeout=120)
|
||||
except Exception as e: # the reply can be lost as the game exits
|
||||
print(f' quit: {e}')
|
||||
|
||||
def close(self):
|
||||
self.discord.close()
|
||||
shutil.rmtree(self.home, ignore_errors=True)
|
||||
|
||||
|
||||
def single(bins, args=()):
|
||||
game = Game(bins, 'single', args=args)
|
||||
d = game.discord
|
||||
try:
|
||||
d.wait('title screen', shows('In the menus'))
|
||||
assert d.handshakes[0]['v'] == 1 and d.handshakes[0]['client_id'], d.handshakes
|
||||
game.ctl_run('open', 'Presence', '5', 'creative')
|
||||
game.ctl_run('wait-for', 'loaded', '--timeout', '300')
|
||||
d.wait('in the world', shows('Singleplayer', 'In the Overworld'))
|
||||
start = d.last()['timestamps']['start']
|
||||
assert abs(start - time.time()) < 120 and 'party' not in d.last(), d.last()
|
||||
game.ctl_run('command', 'execute in minecraft:the_nether run tp @s 0 80 0')
|
||||
d.wait('in the Nether', shows('Singleplayer', 'In the Nether'))
|
||||
assert d.last()['timestamps']['start'] == start, 'a dimension change restarted the clock'
|
||||
text = json.dumps(d.activities)
|
||||
assert 'Presence' not in text, f'the world name reached Discord: {text}'
|
||||
game.quit()
|
||||
d.wait('cleared on quit', lambda a: a is None)
|
||||
finally:
|
||||
game.close()
|
||||
|
||||
|
||||
def lan(bins, host_args=()):
|
||||
host = Game(bins, 'host', args=host_args)
|
||||
guest = None
|
||||
try:
|
||||
host.ctl_run('open', 'Hosted', '5', 'creative')
|
||||
host.ctl_run('wait-for', 'loaded', '--timeout', '300')
|
||||
port = lab.free_port()
|
||||
host.ctl_run('lan', 'open', str(port))
|
||||
host.discord.wait('host, alone', lambda a: shows('Hosting a world')(a) and a['party']['size'] == [1, 8])
|
||||
guest = Game(bins, 'guest')
|
||||
guest.discord.wait('guest title screen', shows('In the menus'))
|
||||
guest.ctl_run('connect', f'127.0.0.1:{port}', 'Guest')
|
||||
guest.ctl_run('wait-for', 'loaded', '--timeout', '300')
|
||||
guest.discord.wait('guest in the world', shows('Multiplayer', 'In the Overworld'))
|
||||
assert '127.0.0.1' not in json.dumps(guest.discord.activities), guest.discord.activities
|
||||
host.discord.wait('host with a guest', lambda a: shows('Hosting a world')(a) and a['party']['size'] == [2, 8])
|
||||
guest.quit()
|
||||
guest.discord.wait('guest cleared on quit', lambda a: a is None)
|
||||
host.quit()
|
||||
host.discord.wait('host cleared on quit', lambda a: a is None)
|
||||
finally:
|
||||
for g in (guest, host):
|
||||
if g:
|
||||
g.close()
|
||||
|
||||
|
||||
def setting(bins):
|
||||
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Horizon Settings: the
|
||||
activity appears; off again: it is cleared."""
|
||||
game = Game(bins, 'setting', extras='discordPresence:false\n')
|
||||
d = game.discord
|
||||
try:
|
||||
game.ctl_run('wait-for', 'screen', 'title')
|
||||
time.sleep(3)
|
||||
assert not d.handshakes and not d.activities, (d.handshakes, d.activities)
|
||||
game.ctl_run('options', 'horizon')
|
||||
switch = next(w for w in game.ctl_run('ui')['widgets'] if w.get('label', '').startswith('Discord Rich'))
|
||||
assert switch['label'] == 'Discord Rich Presence: Off', switch
|
||||
game.ctl_run('click', switch['id'])
|
||||
d.wait('turned on', shows('In the menus'))
|
||||
game.ctl_run('click', switch['id'])
|
||||
d.wait('turned off', lambda a: a is None)
|
||||
assert 'discordPresence:false' in (game.home / 'horizon-extras.txt').read_text()
|
||||
print(' ok: saved off')
|
||||
game.quit()
|
||||
finally:
|
||||
game.close()
|
||||
|
||||
|
||||
def late(bins):
|
||||
"""Discord started after the game: it is found at the next retry (every 15 s)."""
|
||||
game = Game(bins, 'late', discord_running=False)
|
||||
try:
|
||||
game.ctl_run('wait-for', 'screen', 'title')
|
||||
time.sleep(2)
|
||||
game.discord.start()
|
||||
started = time.time()
|
||||
game.discord.wait('found Discord', shows('In the menus'), timeout=30)
|
||||
print(f' ok: after {time.time() - started:.1f} s')
|
||||
game.quit()
|
||||
finally:
|
||||
game.close()
|
||||
|
||||
|
||||
CHECKS = {'single': single, 'in-frame': lambda b: single(b, ['--in-frame']), 'lan': lan,
|
||||
'lan-in-frame': lambda b: lan(b, ['--in-frame']), 'setting': setting,
|
||||
'late': late}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--check', choices=[*CHECKS, 'all'], default='all')
|
||||
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
|
||||
args = parser.parse_args()
|
||||
if sys.platform == 'win32':
|
||||
sys.exit('the fake Discord is a Unix socket: macOS or Linux only')
|
||||
if args.bin_dir:
|
||||
bins = Path(args.bin_dir).resolve()
|
||||
else:
|
||||
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '--bins'],
|
||||
cwd=ROOT, check=True)
|
||||
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
|
||||
capture_output=True, text=True, check=True).stdout)
|
||||
bins = Path(meta['target_directory']) / 'fast'
|
||||
if not (lab.RESOURCES / 'horizon-resources.json').exists():
|
||||
sys.exit(f'no prepared resources in {lab.RESOURCES}')
|
||||
results = {}
|
||||
for name in CHECKS if args.check == 'all' else [args.check]:
|
||||
print(f'[{name}]')
|
||||
started = time.time()
|
||||
try:
|
||||
CHECKS[name](bins)
|
||||
results[name] = True
|
||||
print(f'[{name}] PASS in {time.time() - started:.0f} s')
|
||||
except (AssertionError, KeyError, StopIteration, subprocess.TimeoutExpired) as e:
|
||||
results[name] = False
|
||||
print(f'[{name}] FAIL: {e}')
|
||||
print(json.dumps(results))
|
||||
sys.exit(0 if all(results.values()) else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+4
-1
@@ -72,7 +72,7 @@ def _remove_scratch_dirs():
|
||||
|
||||
def game_env(base=None, **extra):
|
||||
"""The environment for every run of the game, the launcher or horizon-ctl from a tool or a harness: no audio,
|
||||
and fresh scratch folders for the game's home (logs, saves, options), the launcher's home and the XDG folders,
|
||||
no Discord presence, and fresh scratch folders for the game's home (logs, saves, options), the launcher's home and the XDG folders,
|
||||
so a run never writes into a real player's folders (the game falls back to them when HORIZON_HOME is unset).
|
||||
A folder the caller already has in `base` (default os.environ) or passes in `extra` is kept. The scratch folders
|
||||
are removed when this process exits."""
|
||||
@@ -80,6 +80,9 @@ def game_env(base=None, **extra):
|
||||
env.update({key: str(value) for key, value in extra.items()})
|
||||
env["HORIZON_NO_AUDIO"] = "1"
|
||||
env["HORIZON_LAUNCHER_NO_SELF_UPDATE"] = "1"
|
||||
# Never a real Discord's Rich Presence; tools/discord_presence_e2e.py points the game at a fake one.
|
||||
if not env.get("HORIZON_DISCORD_IPC_DIR"):
|
||||
env["HORIZON_NO_DISCORD"] = "1"
|
||||
for key, prefix in (("HORIZON_HOME", "horizon-home-"), ("HORIZON_LAUNCHER_HOME", "horizon-launcher-home-"),
|
||||
("XDG_DATA_HOME", "horizon-xdg-data-"), ("XDG_CONFIG_HOME", "horizon-xdg-config-")):
|
||||
if not env.get(key):
|
||||
|
||||
@@ -117,6 +117,8 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
the host has them; a falling block lands on both sides.
|
||||
- shulker_bullets: every shot a shulker fires has its own id and flies on both sides, following
|
||||
the same trajectory; the bullets disappear on both sides when they hit blocks (#1494).
|
||||
- crystals: the host and the guest punch placed End crystals in survival and creative; the blast
|
||||
kills the survival player, and attacks respect reach and blocks in front (#1532).
|
||||
- thrown: a splash potion and an eye of ender the host and the guest each throw fly on both
|
||||
sides (#1363, #1391);
|
||||
- sparks: level events' particles on both sides: the host's and the guest's bone meal on wheat
|
||||
@@ -6162,6 +6164,94 @@ CHECKS += ['falling']
|
||||
CHECK_FNS.update(falling=check_falling)
|
||||
|
||||
|
||||
def check_crystals(host, guest, out):
|
||||
"""#1532: a real left click detonates a placed End crystal in survival and creative,
|
||||
on the host and the guest; reach, block occlusion and passing right clicks still apply."""
|
||||
spots = apart(host, guest)
|
||||
seen = {}
|
||||
|
||||
def crystals(game, at):
|
||||
return [p for p in game('status')['projectiles'] if p['kind'] == 'minecraft:end_crystal'
|
||||
and math.dist(p['position'], at) < 0.01]
|
||||
|
||||
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
||||
x, y, z = spots[label]
|
||||
y += 32 # Above the terrain and its entities: EndCrystalItem requires an empty box.
|
||||
at = [x + 2.5, y, z + 0.5]
|
||||
other('command', command='gamemode creative')
|
||||
for mode in ('creative', 'survival'):
|
||||
case = f'{label}-{mode}'
|
||||
try:
|
||||
host('command', command=f'fill {x - 5} {y - 1} {z - 3} {x + 5} {y - 1} {z + 3} minecraft:obsidian')
|
||||
host('command', command=f'fill {x - 5} {y} {z - 3} {x + 5} {y + 3} {z + 3} minecraft:air')
|
||||
player('command', command=f'gamemode {mode}')
|
||||
player('command', command='clear @s')
|
||||
player('command', command='effect clear @s')
|
||||
player('command', command='effect give @s minecraft:instant_health 1 5 true')
|
||||
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
||||
player('command', command='give @s minecraft:end_crystal 1')
|
||||
player.raw('select', 1)
|
||||
player.wait(10)
|
||||
player.raw('look-at', at[0], y - 0.01, at[2])
|
||||
player.raw('mouse', 'right', 'click')
|
||||
player.wait(10)
|
||||
placed = crystals(player, at)
|
||||
assert len(placed) == 1, f'{case}: placing the crystal: {placed}'
|
||||
crystal_id = placed[0]['id']
|
||||
tracked = crystals(other, at)
|
||||
assert crystal_id != 0 and [p['id'] for p in tracked] == [crystal_id], f'{case}: crystal not tracked on both sides'
|
||||
|
||||
# The nearest face is six blocks away, beyond both modes' attack reach.
|
||||
player('command', command=f'tp @s {x - 4.5} {y} {z + 0.5}')
|
||||
player.wait(5)
|
||||
player.raw('look-at', at[0], y + 1, at[2])
|
||||
player.raw('mouse', 'left', 'click')
|
||||
player.wait(10)
|
||||
assert crystals(player, at), f'{case}: attacked a crystal out of reach'
|
||||
|
||||
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
||||
host('command', command=f'fill {x + 1} {y} {z} {x + 1} {y + 2} {z} minecraft:obsidian')
|
||||
player.wait(5)
|
||||
player.raw('look-at', at[0], y + 1, at[2])
|
||||
player.raw('mouse', 'left', 'click')
|
||||
player.wait(10)
|
||||
assert crystals(player, at), f'{case}: attacked a crystal through a block'
|
||||
host('command', command=f'fill {x + 1} {y} {z} {x + 1} {y + 2} {z} minecraft:air')
|
||||
player('command', command='item replace entity @s weapon.mainhand with minecraft:shield')
|
||||
player.wait(10)
|
||||
player.raw('look-at', at[0], y + 1, at[2])
|
||||
player.raw('mouse', 'right', 'down')
|
||||
player.wait(10)
|
||||
assert player('state').get('using') and server_using(host, player), f'{case}: crystal consumed right click'
|
||||
player.raw('mouse', 'right', 'up')
|
||||
player('command', command='clear @s')
|
||||
player.wait(5)
|
||||
player.shot(out, f'crystals-{mode}-before')
|
||||
|
||||
player.raw('mouse', 'left', 'click')
|
||||
player.wait(40)
|
||||
remaining = {g.name: len(crystals(g, at)) for g in (player, other)}
|
||||
health = server_player(host, player)['health']
|
||||
screen = player('state').get('screen')
|
||||
seen[case] = {'id': crystal_id, 'remaining': remaining, 'health': health, 'screen': screen}
|
||||
player.shot(out, f'crystals-{mode}-after')
|
||||
assert not any(remaining.values()), f'{case}: left click left the crystal intact: {seen[case]}'
|
||||
assert health == (20 if mode == 'creative' else 0), f'{case}: explosion damage: {seen[case]}'
|
||||
assert screen == ('playing' if mode == 'creative' else 'dead'), f'{case}: explosion death: {seen[case]}'
|
||||
finally:
|
||||
if player('state').get('screen') == 'dead':
|
||||
respawn_when_ready(player)
|
||||
player.wait(30)
|
||||
else:
|
||||
player.raw('mouse', 'right', 'up')
|
||||
player('command', command='gamemode creative')
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['crystals']
|
||||
CHECK_FNS.update(crystals=check_crystals)
|
||||
|
||||
|
||||
def check_boat_dismount_pose(host, guest, out):
|
||||
"""Removing a boat detaches its mobs on the server and every observing client."""
|
||||
x, y, z = map(math.floor, pos_of(host))
|
||||
|
||||
@@ -10,6 +10,9 @@ stone texture and a pixel landscape drawn in code (`scripts/art.mjs`), and short
|
||||
releases, the community links and the contributors. Plain HTML, CSS and JavaScript; no framework and
|
||||
no runtime dependencies.
|
||||
|
||||
Home, Releases, Benchmarks and every Docs page share the header and phone menu rendered by
|
||||
`build.mjs`, with the same links, theme switch and Download button.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
@@ -29,6 +32,7 @@ src/main.js the home page: download buttons, recent releases, links, cont
|
||||
src/chrome.js the light/dark switch, the phone menu and GitHub fetching, shared by the pages
|
||||
src/releases-page.js the Releases page: the channel filter and the refresh from GitHub
|
||||
src/benchmarks-page.js the Benchmarks page: players' results from the telemetry server, the picker
|
||||
src/docs-page.js the Docs pages: the shared theme switch and phone menu
|
||||
src/bench.js the Benchmarks tables, shared by build and page
|
||||
src/hardware.js the spec table estimates use (GPU TFLOPS and bandwidth, CPU single-thread score)
|
||||
src/estimate.js estimates for unmeasured hardware from the nearest measured machine
|
||||
|
||||
+13
-9
@@ -170,7 +170,7 @@ await writeFile(join(dist, "favicon.ico"), Buffer.concat([ico, png32]));
|
||||
const names = {};
|
||||
const rewrite = (code) => 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 = `<p class="lede">The release notes could not be loaded here.</p><a class="button secondary" href="https://github.com/${REPO}/releases">Read them on GitHub</a>`;
|
||||
|
||||
// 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) => ` <header class="nav is-scrolled" id="top">
|
||||
// One top bar and phone menu for Home, Releases, Benchmarks and every Docs page.
|
||||
const navSections = [["/#about", "About"], ["/#screenshots", "In game"], ["/#parity", "Parity"], ["/#launcher", "Launcher"], ["/#recent", "New", "Recently added"], ["/#community", "Community"]];
|
||||
const navPages = [["/releases", "Releases"], ["/benchmarks", "Benchmarks"], ["/docs/", "Docs", "Documentation"]];
|
||||
const navLink = ([href, label, menuLabel], current, menu = false) =>
|
||||
`<a href="${href}"${href === current ? ' aria-current="page"' : ""}${href === "/#contributors" ? ` data-contributors${contributors.length ? "" : " hidden"}` : ""}>${menu && menuLabel ? menuLabel : label}</a>`;
|
||||
const siteNav = (current) => ` <header class="nav" id="top">
|
||||
<div class="nav-inner">
|
||||
<a class="nav-mark" href="/" aria-label="Horizon, home"><svg class="mark" viewBox="0 0 16 16" aria-hidden="true"><path class="mark-stem" d="M4 3h2v4H4zM10 3h2v4h-2z"/><path class="mark-low" d="M4 8h2v4H4zM10 8h2v4h-2z"/><path class="mark-line" d="M1 7h14v1H1z"/></svg>Horizon</a>
|
||||
<nav class="nav-links" aria-label="Pages">
|
||||
${navLinks.map(([href, label]) => ` <a href="${href}"${href === current ? ' aria-current="page"' : ""}>${label}</a>`).join("\n")}
|
||||
<nav class="nav-links" id="nav-links" aria-label="Sections and pages">
|
||||
${[...navSections, ...navPages].map((link) => ` ${navLink(link, current)}`).join("\n")}
|
||||
</nav>
|
||||
<div class="nav-end">
|
||||
<button class="theme-toggle" type="button" aria-label="Switch between light and dark"><span data-theme-label>Dark</span></button>
|
||||
@@ -218,9 +221,8 @@ ${navLinks.map(([href, label]) => ` <a href="${href}"${href === current
|
||||
</div>
|
||||
</header>
|
||||
<div class="menu" id="menu" hidden>
|
||||
<nav aria-label="Pages">
|
||||
<a href="/">Home</a>
|
||||
${navLinks.map(([href, label]) => ` <a href="${href}">${label}</a>`).join("\n")}
|
||||
<nav aria-label="Sections and pages">
|
||||
${[...navSections, ["/#contributors", "Contributors"], ...navPages].map((link) => ` ${navLink(link, current, true)}`).join("\n")}
|
||||
</nav>
|
||||
<div class="menu-foot">
|
||||
<a class="button secondary large" href="https://github.com/${REPO}">GitHub</a>
|
||||
@@ -261,6 +263,7 @@ const values = {
|
||||
RECENT: recentHtml(game) || unavailable,
|
||||
SITE_NAV: "",
|
||||
SITE_FOOTER: siteFooter,
|
||||
DOCS_JS: `/assets/${names["docs-page.js"]}`,
|
||||
RELEASES_JS: `/assets/${names["releases-page.js"]}`,
|
||||
RELEASE_LIST: releasesPageHtml(ours) || unavailable,
|
||||
RELEASE_COUNT: ours.length ? `${ours.length} releases` : "",
|
||||
@@ -350,6 +353,7 @@ const docPages = renderedDocs.map((d, i) => {
|
||||
DOC_BODY: d.html,
|
||||
DOC_PAGER: pagerLink(renderedDocs[i - 1], "prev") + pagerLink(renderedDocs[i + 1], "next"),
|
||||
DOC_FILE: d.file,
|
||||
SITE_NAV: siteNav("/docs/"),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -39,9 +39,6 @@ for (const [width, height] of sizes) {
|
||||
scrollTo({ top: 0, behavior: "instant" });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
// A full-page capture draws the fixed bar once, at the top, over the hero: show it as the
|
||||
// hero sees it, not in its scrolled state.
|
||||
await page.evaluate(() => document.querySelector(".nav").classList.remove("is-scrolled"));
|
||||
const report = await page.evaluate((touch) => {
|
||||
const out = [];
|
||||
const doc = document.documentElement;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// What every page with the site's top bar shares: the light and dark switch and the phone menu.
|
||||
// Used by main.js (the home page) and page.js (Releases, Benchmarks).
|
||||
// Used by Home, Releases, Benchmarks and Docs.
|
||||
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
// The page follows the system; the toggle picks the other appearance and remembers it.
|
||||
@@ -27,7 +27,7 @@ export function setUpMenu() {
|
||||
const bar = document.querySelector(".nav");
|
||||
const button = document.querySelector(".menu-button");
|
||||
const menu = document.getElementById("menu");
|
||||
const main = document.getElementById("main");
|
||||
const content = document.querySelector("[data-menu-content]") || document.getElementById("main");
|
||||
if (!bar || !button || !menu) return;
|
||||
const label = button.querySelector(".menu-label");
|
||||
const items = [...menu.querySelectorAll("nav a")];
|
||||
@@ -38,7 +38,7 @@ export function setUpMenu() {
|
||||
button.setAttribute("aria-expanded", String(open));
|
||||
label.textContent = open ? "Close" : "Menu";
|
||||
bar.classList.toggle("is-menu", open);
|
||||
main.inert = open;
|
||||
content.inert = open;
|
||||
document.body.style.overflow = open ? "hidden" : "";
|
||||
if (open) {
|
||||
menu.hidden = false;
|
||||
@@ -57,7 +57,7 @@ export function setUpMenu() {
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
matchMedia("(min-width: 1120px)").addEventListener("change", (e) => e.matches && setOpen(false));
|
||||
matchMedia("(min-width: 1280px)").addEventListener("change", (e) => e.matches && setOpen(false));
|
||||
}
|
||||
|
||||
// GitHub answers, cached for ten minutes in the tab.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// The Docs pages share the site's theme switch and phone menu.
|
||||
import { setUpMenu, setUpTheme } from "./chrome.js";
|
||||
|
||||
setUpTheme();
|
||||
setUpMenu();
|
||||
+6
-14
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="no-js">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{{DOC_TITLE}} · Horizon docs</title>
|
||||
<meta name="description" content="{{DOC_DESC}}">
|
||||
<meta name="theme-color" content="#ece7f7" media="(prefers-color-scheme: light)">
|
||||
@@ -19,22 +19,14 @@
|
||||
<meta property="og:url" content="https://playhorizon.dev{{DOC_PATH}}">
|
||||
<meta property="og:image" content="https://playhorizon.dev/og.png">
|
||||
<link rel="stylesheet" href="{{CSS}}">
|
||||
<script>try { const t = localStorage.getItem("theme"); if (t === "light" || t === "dark") document.documentElement.dataset.theme = t } catch {}</script>
|
||||
<script>document.documentElement.classList.replace("no-js", "js"); try { const t = localStorage.getItem("theme"); if (t === "light" || t === "dark") document.documentElement.dataset.theme = t } catch {}</script>
|
||||
<script type="module" src="{{DOCS_JS}}"></script>
|
||||
</head>
|
||||
<body class="docs-page">
|
||||
<a class="skip" href="#main">Skip to the content</a>
|
||||
<header class="docs-bar">
|
||||
<div class="docs-bar-inner">
|
||||
<a class="nav-mark" href="/" aria-label="Horizon, home"><svg class="mark" viewBox="0 0 16 16" aria-hidden="true"><path class="mark-stem" d="M4 3h2v4H4zM10 3h2v4h-2z"/><path class="mark-low" d="M4 8h2v4H4zM10 8h2v4h-2z"/><path class="mark-line" d="M1 7h14v1H1z"/></svg>Horizon</a>
|
||||
<a class="docs-bar-title" href="/docs/">Docs</a>
|
||||
<span class="docs-bar-end">
|
||||
<a class="docs-bar-link" href="https://github.com/ludovic111/horizon">GitHub</a>
|
||||
<a class="button primary small" href="/docs/install">Download</a>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
{{SITE_NAV}}
|
||||
|
||||
<div class="docs">
|
||||
<div class="docs" data-menu-content>
|
||||
<details class="docs-nav-wrap" open>
|
||||
<summary>Docs menu</summary>
|
||||
<nav class="docs-nav" aria-label="Documentation">
|
||||
|
||||
+1
-41
@@ -35,47 +35,7 @@
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to the content</a>
|
||||
|
||||
<header class="nav" id="top">
|
||||
<div class="nav-inner">
|
||||
<a class="nav-mark" href="#top" aria-label="Horizon, back to the top"><svg class="mark" viewBox="0 0 16 16" aria-hidden="true"><path class="mark-stem" d="M4 3h2v4H4zM10 3h2v4h-2z"/><path class="mark-low" d="M4 8h2v4H4zM10 8h2v4h-2z"/><path class="mark-line" d="M1 7h14v1H1z"/></svg>Horizon</a>
|
||||
<nav class="nav-links" id="nav-links" aria-label="Sections">
|
||||
<a href="#about">About</a>
|
||||
<a href="#screenshots">In game</a>
|
||||
<a href="#parity">Parity</a>
|
||||
<a href="#launcher">Launcher</a>
|
||||
<a href="#recent">New</a>
|
||||
<a href="#community">Community</a>
|
||||
<a href="/releases">Releases</a>
|
||||
<a href="/benchmarks">Benchmarks</a>
|
||||
<a href="/docs/">Docs</a>
|
||||
</nav>
|
||||
<div class="nav-end">
|
||||
<button class="theme-toggle" type="button" aria-label="Switch between light and dark"><span data-theme-label>Dark</span></button>
|
||||
<a class="nav-github" href="https://github.com/ludovic111/horizon">GitHub</a>
|
||||
<a class="button primary small" data-download href="https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true">Download</a>
|
||||
<button class="menu-button" type="button" aria-expanded="false" aria-controls="menu"><span class="menu-label">Menu</span><span class="menu-icon" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="menu" id="menu" hidden>
|
||||
<nav aria-label="Sections">
|
||||
<a href="#about">About</a>
|
||||
<a href="#screenshots">In game</a>
|
||||
<a href="#parity">Parity</a>
|
||||
<a href="#launcher">Launcher</a>
|
||||
<a href="#recent">Recently added</a>
|
||||
<a href="#community">Community</a>
|
||||
<a href="#contributors" data-contributors{{CONTRIBUTORS_HIDDEN}}>Contributors</a>
|
||||
<a href="/releases">Releases</a>
|
||||
<a href="/benchmarks">Benchmarks</a>
|
||||
<a href="/docs/">Documentation</a>
|
||||
</nav>
|
||||
<div class="menu-foot">
|
||||
<a class="button secondary large" href="https://github.com/ludovic111/horizon">GitHub</a>
|
||||
<p>Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{SITE_NAV}}
|
||||
|
||||
<main id="main">
|
||||
<section class="hero" aria-labelledby="hero-title">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user