mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
183
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97b19c8273 | ||
|
|
e21640123d | ||
|
|
7e6cbea1a3 | ||
|
|
5b0fd8d4f5 | ||
|
|
afa453b431 | ||
|
|
b2258cbf68 | ||
|
|
b37404afd8 | ||
|
|
aa60eb76ea | ||
|
|
a261703d72 | ||
|
|
448899aa04 | ||
|
|
12ce2752a0 | ||
|
|
bbdffc6584 | ||
|
|
29aebd155c | ||
|
|
3c37ecbaba | ||
|
|
3354e5a9d5 | ||
|
|
7ed902b518 | ||
|
|
821b9c45e5 | ||
|
|
ba923b393d | ||
|
|
42b2cd1d3f | ||
|
|
e78c83330a | ||
|
|
7b0c5e3bf8 | ||
|
|
cd73a264c2 | ||
|
|
91acd76265 | ||
|
|
532339112f | ||
|
|
2bc228cfd0 | ||
|
|
ccda6631b3 | ||
|
|
59823928da | ||
|
|
e3274bed46 | ||
|
|
4c908814b1 | ||
|
|
6d9f9189a7 | ||
|
|
509a5e82c5 | ||
|
|
63eaabce49 | ||
|
|
da0403f293 | ||
|
|
c9343d6d72 | ||
|
|
13539c30a7 | ||
|
|
4c88a971c4 | ||
|
|
5197790e58 | ||
|
|
838324f811 | ||
|
|
18dba0563a | ||
|
|
848a87b731 | ||
|
|
8b895aca0f | ||
|
|
4ed42694bc | ||
|
|
d8845075d2 | ||
|
|
03df981326 | ||
|
|
676746bce5 | ||
|
|
a7df9ec019 | ||
|
|
76aa871c1e | ||
|
|
2d8126fbca | ||
|
|
1e3059a184 | ||
|
|
1adabb388f | ||
|
|
b9829e9cfa | ||
|
|
5a50067056 | ||
|
|
f567c765e9 | ||
|
|
2dc81db24b | ||
|
|
2e037e4cb6 | ||
|
|
2d6f2722d8 | ||
|
|
005824bd0d | ||
|
|
6ce752baa8 | ||
|
|
3a264943c7 | ||
|
|
6e3d4215c7 | ||
|
|
0e873b88ff | ||
|
|
8f7abd9070 | ||
|
|
519ce906cf | ||
|
|
5afa1de5f7 | ||
|
|
40e044da77 | ||
|
|
e7275ddee3 | ||
|
|
f7b73ed5f6 | ||
|
|
1c2ed6b613 | ||
|
|
c2d352e0a3 | ||
|
|
64c0291da2 | ||
|
|
4f1ebf7deb | ||
|
|
f4ee603baf | ||
|
|
daf749bbcd | ||
|
|
27877c639e | ||
|
|
dbb78b8a4c | ||
|
|
c4f9b6fc7f | ||
|
|
121c848cff | ||
|
|
c73dbb7b7d | ||
|
|
0cc0fd1510 | ||
|
|
8d9a75e8ef | ||
|
|
4c877c4c49 | ||
|
|
03d815532e | ||
|
|
e12185820a | ||
|
|
8f91c1534e | ||
|
|
916cca3b77 | ||
|
|
00e0ecbdaf | ||
|
|
3e642d0acd | ||
|
|
f31198cbf7 | ||
|
|
abaabf5cb0 | ||
|
|
b82e2a4b35 | ||
|
|
c9434646de | ||
|
|
4a4cfcd68f | ||
|
|
9784cf63fd | ||
|
|
84ad92068f | ||
|
|
79a60dea88 | ||
|
|
38ce16338c | ||
|
|
2eb2bc1538 | ||
|
|
e0302f4ee6 | ||
|
|
94ac2fb634 | ||
|
|
db63176599 | ||
|
|
bdee42e267 | ||
|
|
86989096e5 | ||
|
|
185ca1b2af | ||
|
|
78690099a2 | ||
|
|
961618fd6a | ||
|
|
1fcfcc3029 | ||
|
|
afe39ff137 | ||
|
|
d5be28d945 | ||
|
|
8a6024f8e1 | ||
|
|
2b874aea1c | ||
|
|
3011a79600 | ||
|
|
dbce14ff72 | ||
|
|
8128a5937d | ||
|
|
55331c9ece | ||
|
|
d8f75a8dd5 | ||
|
|
8dce381d48 | ||
|
|
a235c61c01 | ||
|
|
dd19d7e959 | ||
|
|
bd1485f3fb | ||
|
|
ed85828ead | ||
|
|
5112fc836c | ||
|
|
b75e56f185 | ||
|
|
1795bf290c | ||
|
|
a366ed22a2 | ||
|
|
bcece942b9 | ||
|
|
6f2273e42c | ||
|
|
4106cc0ac0 | ||
|
|
edb3022de8 | ||
|
|
85268545c0 | ||
|
|
c7fd37fcb8 | ||
|
|
ee1e81509c | ||
|
|
53304cdd14 | ||
|
|
b90de83c64 | ||
|
|
c35fd5899a | ||
|
|
587f377a87 | ||
|
|
f3d5a8b94b | ||
|
|
50fdee8614 | ||
|
|
00ad1449e5 | ||
|
|
d2589c1a6d | ||
|
|
b5541225ea | ||
|
|
f8a0ed7d4d | ||
|
|
494184464f | ||
|
|
fbb15eee5f | ||
|
|
860960c596 | ||
|
|
b95ce53d06 | ||
|
|
9762e4f3db | ||
|
|
21832e9390 | ||
|
|
ff0465854e | ||
|
|
635b622b58 | ||
|
|
2b78d2638e | ||
|
|
20e429c3f8 | ||
|
|
c481f9c68a | ||
|
|
878e9d5018 | ||
|
|
cffc6f24f6 | ||
|
|
db3ef8ecbc | ||
|
|
912941a8f7 | ||
|
|
4ae5d5425d | ||
|
|
37a10b81c4 | ||
|
|
780ac5470a | ||
|
|
6d98c0f8d5 | ||
|
|
b73e5b7c98 | ||
|
|
b1259ce32a | ||
|
|
894c33d745 | ||
|
|
aaf377b9ca | ||
|
|
7ec58998d6 | ||
|
|
7a52fde28f | ||
|
|
972519acdc | ||
|
|
b3dcefa2ac | ||
|
|
adea816573 | ||
|
|
a5da7abce2 | ||
|
|
8ac0773305 | ||
|
|
4e70c05497 | ||
|
|
ab3ebbdd65 | ||
|
|
69ce6f6c26 | ||
|
|
78295e840f | ||
|
|
69096a5877 | ||
|
|
69af122b0d | ||
|
|
3139a6b8c0 | ||
|
|
af3a264af1 | ||
|
|
4d0553be6e | ||
|
|
fb0496a105 | ||
|
|
d48b592ddd | ||
|
|
8a65c97f3b |
No files matched your search
Generated
+1
@@ -1733,6 +1733,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"horizon-core",
|
||||
"libm",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"postcard",
|
||||
"serde",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
//!
|
||||
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
|
||||
//!
|
||||
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
|
||||
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
|
||||
//! writes a summary with `--report`.
|
||||
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
|
||||
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
|
||||
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
|
||||
@@ -102,6 +103,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
to test what a server takes
|
||||
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
|
||||
--report FILE a JSON summary at the end (- prints it after the per-second lines)
|
||||
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
|
||||
for crates/horizon-protocol/examples/net_replay.rs
|
||||
";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -177,6 +180,8 @@ struct Options {
|
||||
/// The `minecraft:brand` to send, if any.
|
||||
brand: Option<String>,
|
||||
report: Option<String>,
|
||||
/// --capture: where the first bot's play packets go.
|
||||
capture: Option<String>,
|
||||
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
|
||||
timer: f64,
|
||||
}
|
||||
@@ -207,6 +212,7 @@ fn options() -> Result<Options, String> {
|
||||
click: None,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
capture: None,
|
||||
brand: None,
|
||||
timer: 1.0,
|
||||
};
|
||||
@@ -256,6 +262,7 @@ fn options() -> Result<Options, String> {
|
||||
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--capture" => o.capture = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
"--give" => {
|
||||
for part in value.split(',') {
|
||||
@@ -819,12 +826,53 @@ impl BatchSize {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
|
||||
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
|
||||
struct Capture {
|
||||
out: std::io::BufWriter<std::fs::File>,
|
||||
start: Instant,
|
||||
/// The second last flushed: the process may end before the reader thread does.
|
||||
flushed: u32,
|
||||
}
|
||||
impl Capture {
|
||||
fn create(path: &str) -> Option<Self> {
|
||||
match std::fs::File::create(path) {
|
||||
Ok(file) => Some(Self {
|
||||
out: std::io::BufWriter::new(file),
|
||||
start: Instant::now(),
|
||||
flushed: 0,
|
||||
}),
|
||||
Err(e) => {
|
||||
eprintln!("--capture {path}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
fn record(&mut self, id: i32, body: &[u8]) {
|
||||
use std::io::Write;
|
||||
let ms = self.start.elapsed().as_millis() as u32;
|
||||
let mut head = [0u8; 12];
|
||||
head[..4].copy_from_slice(&ms.to_le_bytes());
|
||||
head[4..8].copy_from_slice(&id.to_le_bytes());
|
||||
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
let _ = self
|
||||
.out
|
||||
.write_all(&head)
|
||||
.and_then(|()| self.out.write_all(body));
|
||||
if ms / 1000 != self.flushed {
|
||||
self.flushed = ms / 1000;
|
||||
let _ = self.out.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
|
||||
fn read(
|
||||
mut reader: horizon_protocol::connection::ConnReader,
|
||||
writer: Arc<Mutex<ConnWriter>>,
|
||||
bot: Arc<Bot>,
|
||||
track: bool,
|
||||
mut capture: Option<Capture>,
|
||||
) {
|
||||
use ids::play::clientbound as cb;
|
||||
use ids::play::serverbound as sb;
|
||||
@@ -848,6 +896,9 @@ fn read(
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(c) = &mut capture {
|
||||
c.record(id, &body);
|
||||
}
|
||||
let mut r = Reader::new(&body);
|
||||
let sent = match id {
|
||||
cb::KEEP_ALIVE => match r.i64() {
|
||||
@@ -1007,7 +1058,10 @@ fn run_bot(
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
let track = behaviour == Behaviour::Fight;
|
||||
thread::spawn(move || read(reader, writer, bot, track));
|
||||
let capture = (index == 0)
|
||||
.then(|| o.capture.as_deref().and_then(Capture::create))
|
||||
.flatten();
|
||||
thread::spawn(move || read(reader, writer, bot, track, capture));
|
||||
}
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
@@ -1025,6 +1079,10 @@ fn run_bot(
|
||||
};
|
||||
bot.joined_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
if index == 0 {
|
||||
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
|
||||
println!("{}", json!({ "spawn": spawn }));
|
||||
}
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
if !o.no_loaded {
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
|
||||
@@ -564,14 +564,20 @@ impl Chunk {
|
||||
}
|
||||
#[inline]
|
||||
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
|
||||
let s = (y - self.min_y) >> 4;
|
||||
if s < 0 {
|
||||
return 0;
|
||||
match self.section_at(y) {
|
||||
Ok(section) => section.light(index(x, y, z)),
|
||||
Err(light) => light,
|
||||
}
|
||||
if s >= self.sections.len() as i32 {
|
||||
return 0xF0;
|
||||
}
|
||||
/// The section holding block height `y`, or the light of the air outside the
|
||||
/// column's sections there: none below them, full sky above.
|
||||
#[inline]
|
||||
pub fn section_at(&self, y: i32) -> Result<&Section, u8> {
|
||||
match self.section_index(y) {
|
||||
Some(s) => Ok(&self.sections[s]),
|
||||
None if y < self.min_y => Err(0),
|
||||
None => Err(0xF0),
|
||||
}
|
||||
self.sections[s as usize].light(index(x, y, z))
|
||||
}
|
||||
/// Heap bytes held by the sections (benchmark accounting).
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
|
||||
@@ -518,7 +518,8 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
|
||||
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
|
||||
/// `Util` worker threads.
|
||||
fn is_worker_thread(name: Option<&str>) -> bool {
|
||||
name.is_some_and(|n| n.starts_with("horizon-worker-"))
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ mod arena;
|
||||
pub mod compositor;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
pub mod entity_skin;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod hdr;
|
||||
@@ -627,6 +628,9 @@ pub struct Frame<'a> {
|
||||
pub glowing: &'a [Vertex],
|
||||
/// Runs of `entities` by entity, while a shader pack draws them (`pack_draw::EntitySpan`).
|
||||
pub entity_spans: &'a [pack_draw::EntitySpan],
|
||||
/// Ordinary entity models posed on the GPU (`entity_skin`), drawn before `entities`'
|
||||
/// ordinary ones; only when `Renderer::skins_entities` said so.
|
||||
pub skin: Option<&'a entity_skin::SkinFrame>,
|
||||
pub weather: &'a [crate::weather::Vertex],
|
||||
pub weather_time: crate::weather::Time,
|
||||
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
|
||||
@@ -856,6 +860,8 @@ pub struct Renderer {
|
||||
entity_buffer: wgpu::Buffer,
|
||||
entity_capacity: u64,
|
||||
entity_batch: EntityBatch,
|
||||
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
|
||||
entity_skin: Option<entity_skin::EntitySkin>,
|
||||
group: wgpu::BindGroup,
|
||||
globals: wgpu::Buffer,
|
||||
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
|
||||
@@ -1523,37 +1529,64 @@ async fn open_device(
|
||||
api: GraphicsApi,
|
||||
layers: u32,
|
||||
) -> Result<Opened> {
|
||||
// A software adapter (WARP, llvmpipe) is a last resort: a later backend may
|
||||
// have the real GPU (#3978: a Fermi GPU with no Vulkan and no DX12 driver
|
||||
// still has OpenGL).
|
||||
match open_device_with(window, offscreen, api, layers, false).await {
|
||||
Ok(opened) => Ok(opened),
|
||||
Err(first) => match open_device_with(window, offscreen, api, layers, true).await {
|
||||
let wgpu_backend = std::env::var_os("WGPU_BACKEND").is_some();
|
||||
let mut first = None;
|
||||
for (order, allow_cpu) in device_search(api.with_env(), std::env::consts::OS, wgpu_backend) {
|
||||
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
|
||||
Ok(opened) => {
|
||||
log::warn!(
|
||||
"Horizon renderer: no GPU adapter could be used ({first}); \
|
||||
rendering on the CPU, which is slow"
|
||||
);
|
||||
Ok(opened)
|
||||
if let Some(first) = &first {
|
||||
log::warn!(
|
||||
"Horizon renderer: the chosen graphics API gave no GPU ({first}); {}",
|
||||
if allow_cpu {
|
||||
"rendering on the CPU, which is slow"
|
||||
} else {
|
||||
"using the GPU through another one"
|
||||
}
|
||||
);
|
||||
}
|
||||
return Ok(opened);
|
||||
}
|
||||
Err(_) => Err(first),
|
||||
},
|
||||
Err(e) => {
|
||||
first.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(first.unwrap_or_else(|| NO_GPU_DRIVER.into()))
|
||||
}
|
||||
/// `open_device`, taking an adapter of the CPU type (software rendering) only
|
||||
/// when `allow_cpu`.
|
||||
/// The tries `open_device` makes, in turn: the chosen API's backends (`backend_order`) on a
|
||||
/// real GPU; when the player chose one API, the other backends `Auto` would try, on a real
|
||||
/// GPU; and last the chosen backends with a software adapter (WARP, llvmpipe) allowed.
|
||||
/// Software rendering takes most of a weak CPU even on the title screen: an Intel HD 2500 has
|
||||
/// DirectX 12 only through WARP and its GPU through OpenGL (#5185), and a Fermi GPU has no
|
||||
/// Vulkan or DirectX 12 driver but OpenGL (#3978). DirectX 12 never falls back to Vulkan: the
|
||||
/// launcher chooses it after Vulkan crashed in the driver (#746), which the game can't catch.
|
||||
fn device_search(
|
||||
api: GraphicsApi,
|
||||
os: &str,
|
||||
wgpu_backend: bool,
|
||||
) -> Vec<(Vec<wgpu::Backends>, bool)> {
|
||||
let chosen = backend_order(api, os, wgpu_backend);
|
||||
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, wgpu_backend)
|
||||
.into_iter()
|
||||
.filter(|b| !chosen.contains(b))
|
||||
.filter(|&b| !(api == GraphicsApi::Dx12 && b == wgpu::Backends::VULKAN))
|
||||
.collect();
|
||||
let mut tries = vec![(chosen.clone(), false)];
|
||||
if !others.is_empty() {
|
||||
tries.push((others, false));
|
||||
}
|
||||
tries.push((chosen, true));
|
||||
tries
|
||||
}
|
||||
/// `open_device` on `order`'s backends in turn, taking an adapter of the CPU type
|
||||
/// (software rendering) only when `allow_cpu`.
|
||||
async fn open_device_with(
|
||||
window: &Arc<Window>,
|
||||
offscreen: bool,
|
||||
api: GraphicsApi,
|
||||
order: &[wgpu::Backends],
|
||||
layers: u32,
|
||||
allow_cpu: bool,
|
||||
) -> Result<Opened> {
|
||||
let order = backend_order(
|
||||
api.with_env(),
|
||||
std::env::consts::OS,
|
||||
std::env::var_os("WGPU_BACKEND").is_some(),
|
||||
);
|
||||
let mut failures = Vec::new();
|
||||
for (attempt, backends) in order.iter().copied().enumerate() {
|
||||
let name = backends_name(backends);
|
||||
@@ -3443,10 +3476,14 @@ impl Renderer {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let entity_capacity = 1 << 14;
|
||||
let entity_skin = entity_skin::EntitySkin::new(
|
||||
&device,
|
||||
animate::SpriteAnimator::compute(&adapter, &device),
|
||||
);
|
||||
let entity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Entity vertices"),
|
||||
size: entity_capacity * std::mem::size_of::<Vertex>() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
usage: entity_skin::EntitySkin::usage(entity_skin.is_some()),
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let flat_wanted = flat::wanted(&adapter, &device);
|
||||
@@ -3672,6 +3709,7 @@ impl Renderer {
|
||||
entity_buffer,
|
||||
entity_capacity,
|
||||
entity_batch: EntityBatch::default(),
|
||||
entity_skin,
|
||||
group,
|
||||
globals,
|
||||
pbr_maps,
|
||||
@@ -4841,6 +4879,23 @@ impl Renderer {
|
||||
}
|
||||
/// The share of real frames frame generation shows a made frame before (1 while it
|
||||
/// keeps up), `None` while it isn't running.
|
||||
/// Whether the game may pose ordinary entity models on the GPU (`Frame::skin`): not
|
||||
/// while a shader pack or a native pack draws entities from their vertices, nor while
|
||||
/// frame generation follows them.
|
||||
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
|
||||
pub fn skinned_models(&self) -> Option<u32> {
|
||||
self.entity_skin
|
||||
.as_ref()
|
||||
.map(entity_skin::EntitySkin::stored)
|
||||
}
|
||||
|
||||
pub fn skins_entities(&self) -> bool {
|
||||
self.entity_skin.is_some()
|
||||
&& !self.frame_gen.on
|
||||
&& self.pack_post.is_none()
|
||||
&& self.native.is_none()
|
||||
}
|
||||
|
||||
pub fn frame_generation_share(&self) -> Option<f32> {
|
||||
self.frame_gen.made_share
|
||||
}
|
||||
@@ -6412,6 +6467,13 @@ impl Renderer {
|
||||
oit_on,
|
||||
);
|
||||
self.entity_batch.append_shadow_only(frame.shadow_only);
|
||||
// The posed models' vertices come first: the ordinary entities' draw takes them in.
|
||||
let skin = frame.skin.filter(|skin| match &mut self.entity_skin {
|
||||
Some(skinner) => skinner.register(&self.device, &mut self.uploads, &mut encoder, skin),
|
||||
None => false,
|
||||
});
|
||||
let posed = skin.map_or(0, |s| s.vertices);
|
||||
let entity_quads = entity_quads + posed / 4;
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads = entity_quads
|
||||
+ text_quads
|
||||
@@ -6467,7 +6529,7 @@ impl Renderer {
|
||||
camera,
|
||||
);
|
||||
// Frame generation's made frame draws its own hand, halfway, after the others.
|
||||
let made_hand_vertex = self.entity_batch.combined.len() as i32;
|
||||
let made_hand_vertex = (posed as usize + self.entity_batch.combined.len()) as i32;
|
||||
if fg {
|
||||
self.frame_gen
|
||||
.halfway_hand(frame.hand, hand_view, &mut self.entity_batch.combined);
|
||||
@@ -6484,24 +6546,34 @@ impl Renderer {
|
||||
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
|
||||
let see_through_quads = (frame.see_through.len() / 4) as u32;
|
||||
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
|
||||
let entity_vertices = all_entities.len() as u64;
|
||||
let entity_vertices = u64::from(posed) + all_entities.len() as u64;
|
||||
if entity_vertices > self.entity_capacity {
|
||||
self.entity_capacity = entity_vertices.next_power_of_two();
|
||||
self.entity_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Entity vertices"),
|
||||
size: self.entity_capacity * std::mem::size_of::<Vertex>() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
usage: entity_skin::EntitySkin::usage(self.entity_skin.is_some()),
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
if entity_vertices > 0 {
|
||||
if !all_entities.is_empty() {
|
||||
self.uploads.buffer(
|
||||
&mut encoder,
|
||||
&self.entity_buffer,
|
||||
0,
|
||||
u64::from(posed) * std::mem::size_of::<Vertex>() as u64,
|
||||
bytemuck::cast_slice(all_entities),
|
||||
);
|
||||
}
|
||||
if let (Some(skinner), Some(skin)) = (&mut self.entity_skin, skin) {
|
||||
skinner.dispatch(
|
||||
&self.device,
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
skin,
|
||||
&self.entity_buffer,
|
||||
0,
|
||||
);
|
||||
}
|
||||
self.entity_outline.prepare(
|
||||
&self.device,
|
||||
&mut self.uploads,
|
||||
@@ -8522,8 +8594,9 @@ impl Renderer {
|
||||
if let Some(radius) = menu_blur {
|
||||
let format = self.config.format;
|
||||
let device = &self.device;
|
||||
let software = is_software(self.info.device_type);
|
||||
self.menu_blur
|
||||
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format))
|
||||
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software))
|
||||
.apply(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
@@ -8853,6 +8926,13 @@ pub struct Frustum {
|
||||
planes: [Vec4; 6],
|
||||
}
|
||||
impl Frustum {
|
||||
/// A view's frustum, without bobbing (`Renderer::camera_frustum`'s with
|
||||
/// no renderer to take the aspect from).
|
||||
pub fn of_view(view: &View) -> Self {
|
||||
Self {
|
||||
planes: frustum(view.view_proj(Mat4::IDENTITY)),
|
||||
}
|
||||
}
|
||||
/// Whether the camera-relative box `min..max` is at least partly inside.
|
||||
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
!self.planes.iter().any(|p| {
|
||||
@@ -9312,6 +9392,37 @@ mod tests {
|
||||
assert_eq!(order(api, "macos"), [Backends::default()]);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_chosen_api_without_a_gpu_tries_the_others_before_software() {
|
||||
use super::{GraphicsApi, device_search};
|
||||
use wgpu::Backends;
|
||||
let (v, d, g) = (Backends::VULKAN, Backends::DX12, Backends::GL);
|
||||
// #5185: DirectX 12 chosen on an Intel HD 2500 (WARP only): OpenGL's GPU before WARP.
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Dx12, "windows", false),
|
||||
[(vec![d], false), (vec![g], false), (vec![d], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Vulkan, "windows", false),
|
||||
[(vec![v], false), (vec![d, g], false), (vec![v], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Auto, "windows", false),
|
||||
[(vec![v, d, g], false), (vec![v, d, g], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::OpenGl, "linux", false),
|
||||
[(vec![g], false), (vec![v], false), (vec![g], true)]
|
||||
);
|
||||
// WGPU_BACKEND and macOS: one set of backends, nothing else to try.
|
||||
let all = Backends::default();
|
||||
for (os, env) in [("windows", true), ("macos", false)] {
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Vulkan, os, env),
|
||||
[(vec![all], false), (vec![all], true)]
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The second try at a backend shows the adapters wgpu hides for reporting
|
||||
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
|
||||
/// Haswell, reports 0.0.0.0 and works).
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Entity models posed on the GPU. The CPU path (`mobs::render::draw`) walks a
|
||||
//! model's parts and pushes every vertex of every polygon, transformed by its
|
||||
//! part's matrix: ~280 vertices of 44 bytes per mob, then copied by material
|
||||
//! and uploaded each frame. Here each model's vertices are uploaded once, in
|
||||
//! model space, and a frame only sends each drawn part's matrices and one job
|
||||
//! per run of drawn parts; `entity_skin.wgsl` writes the same vertices, in the
|
||||
//! same order, into the entity vertex buffer, after the CPU's ordinary entities
|
||||
//! (`EntityBatch` leaves the gap). Every pass that draws ordinary entities
|
||||
//! then draws them as before.
|
||||
//!
|
||||
//! The game decides which draws come here (`SkinFrame`): models whose
|
||||
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
|
||||
//! The rest stays on the CPU path.
|
||||
|
||||
use super::upload::Uploads;
|
||||
use crate::mesher::Vertex;
|
||||
|
||||
/// A model vertex: position / 16, model UV, the face's normal as an axis (0..2
|
||||
/// = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in its model.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct SkinVertex {
|
||||
pub pos: [f32; 3],
|
||||
pub uv: [f32; 2],
|
||||
pub axis: u32,
|
||||
pub part: u32,
|
||||
}
|
||||
|
||||
/// A drawn part: its geometry matrix and its normal matrix, by columns, the
|
||||
/// normal matrix's columns normalized when the part's scale is not uniform.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct SkinPart {
|
||||
pub matrix: [f32; 16],
|
||||
pub normal: [f32; 9],
|
||||
}
|
||||
|
||||
/// One run of a model's vertices drawn with one texture rect, colour, light
|
||||
/// and overlay. `dst` counts from the frame's first posed vertex.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct SkinJob {
|
||||
pub first: u32,
|
||||
pub count: u32,
|
||||
pub part: u32,
|
||||
pub dst: u32,
|
||||
pub rect: [f32; 4],
|
||||
pub scroll: f32,
|
||||
pub color: u32,
|
||||
pub light: u32,
|
||||
pub overlay: u32,
|
||||
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
|
||||
pub glint: u32,
|
||||
pub spare: [u32; 3],
|
||||
}
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
|
||||
const _: () = assert!(std::mem::size_of::<SkinVertex>() == 7 * 4);
|
||||
const _: () = assert!(std::mem::size_of::<SkinPart>() == 25 * 4);
|
||||
const _: () = assert!(std::mem::size_of::<SkinJob>() == 16 * 4);
|
||||
|
||||
/// What the game hands the renderer for one frame.
|
||||
#[derive(Default)]
|
||||
pub struct SkinFrame {
|
||||
/// The model store restarted (models are registered again from 0).
|
||||
pub reset: bool,
|
||||
/// The store's size before `new_model`: the renderer takes the frame only
|
||||
/// when its own store is that large (it missed none of the earlier ones).
|
||||
pub base: u32,
|
||||
/// Model vertices registered since the last frame, at the store's end.
|
||||
pub new_model: Vec<SkinVertex>,
|
||||
pub parts: Vec<SkinPart>,
|
||||
pub jobs: Vec<SkinJob>,
|
||||
/// Vertices the jobs write: a whole number of quads.
|
||||
pub vertices: u32,
|
||||
}
|
||||
|
||||
impl SkinFrame {
|
||||
/// Starts a frame's draws; models not yet handed to the renderer stay.
|
||||
pub fn clear_draws(&mut self) {
|
||||
self.parts.clear();
|
||||
self.jobs.clear();
|
||||
self.vertices = 0;
|
||||
}
|
||||
|
||||
/// The renderer took the new models.
|
||||
pub fn handed(&mut self) {
|
||||
self.base += self.new_model.len() as u32;
|
||||
self.new_model.clear();
|
||||
self.reset = false;
|
||||
}
|
||||
|
||||
/// Where the draws stand, to take back a mob's (`truncate`).
|
||||
pub fn mark(&self) -> (usize, usize, u32) {
|
||||
(self.jobs.len(), self.parts.len(), self.vertices)
|
||||
}
|
||||
|
||||
/// Takes back the draws since `mark` (a mob culled after drawing).
|
||||
pub fn truncate(&mut self, (jobs, parts, vertices): (usize, usize, u32)) {
|
||||
self.jobs.truncate(jobs);
|
||||
self.parts.truncate(parts);
|
||||
self.vertices = vertices;
|
||||
}
|
||||
|
||||
/// Whether the renderer has anything to take.
|
||||
pub fn pending(&self) -> bool {
|
||||
self.reset || !self.new_model.is_empty() || !self.jobs.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Jobs per row of workgroups (the dispatch limit is 65535 per dimension).
|
||||
const ROW: u32 = 32_768;
|
||||
|
||||
pub(super) struct EntitySkin {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
model: Vec<SkinVertex>,
|
||||
model_buffer: Option<wgpu::Buffer>,
|
||||
parts: Grow,
|
||||
jobs: Grow,
|
||||
/// The jobs with `dst` moved to the frame's gap, as uploaded.
|
||||
staged: Vec<SkinJob>,
|
||||
/// The bind group and the buffers it binds (buffers compare by identity).
|
||||
group: Option<(wgpu::BindGroup, [wgpu::Buffer; 4])>,
|
||||
}
|
||||
|
||||
/// A storage buffer that grows to the next power of two.
|
||||
struct Grow {
|
||||
label: &'static str,
|
||||
buffer: Option<wgpu::Buffer>,
|
||||
}
|
||||
|
||||
impl Grow {
|
||||
fn ensure(&mut self, device: &wgpu::Device, bytes: u64) -> bool {
|
||||
let bytes = bytes.max(256).next_power_of_two();
|
||||
if self.buffer.as_ref().is_some_and(|b| b.size() >= bytes) {
|
||||
return false;
|
||||
}
|
||||
self.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(self.label),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl EntitySkin {
|
||||
/// The entity vertex buffer's usage: the jobs write into it.
|
||||
pub(super) fn usage(skinning: bool) -> wgpu::BufferUsages {
|
||||
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST;
|
||||
if skinning {
|
||||
usage | wgpu::BufferUsages::STORAGE
|
||||
} else {
|
||||
usage
|
||||
}
|
||||
}
|
||||
|
||||
/// The compute pipeline, or `None` without compute shaders (the CPU path
|
||||
/// then draws every model).
|
||||
pub(super) fn new(device: &wgpu::Device, compute: bool) -> Option<Self> {
|
||||
// HORIZON_NO_ENTITY_SKIN: every model on the CPU path (A/B runs, a driver's bug).
|
||||
if !compute
|
||||
|| device.limits().max_storage_buffers_per_shader_stage < 4
|
||||
|| std::env::var_os("HORIZON_NO_ENTITY_SKIN").is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("entity_skin.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("skin"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
|
||||
log::warn!("Horizon renderer: entity models posed on the CPU ({e})");
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
pipeline,
|
||||
layout,
|
||||
model: Vec::new(),
|
||||
model_buffer: None,
|
||||
parts: Grow {
|
||||
label: "Entity skinning parts",
|
||||
buffer: None,
|
||||
},
|
||||
jobs: Grow {
|
||||
label: "Entity skinning jobs",
|
||||
buffer: None,
|
||||
},
|
||||
staged: Vec::new(),
|
||||
group: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The model vertices stored (`SkinFrame::base` of the next frame).
|
||||
pub(super) fn stored(&self) -> u32 {
|
||||
self.model.len() as u32
|
||||
}
|
||||
|
||||
/// Takes the frame's new models; whether its jobs can run (the store
|
||||
/// holds every model they read).
|
||||
pub(super) fn register(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &SkinFrame,
|
||||
) -> bool {
|
||||
if frame.reset {
|
||||
self.model.clear();
|
||||
self.model_buffer = None;
|
||||
}
|
||||
if self.model.len() as u32 != frame.base {
|
||||
// Out of step with the game's store: it restarts it.
|
||||
return false;
|
||||
}
|
||||
if frame.new_model.is_empty() {
|
||||
return self.model_buffer.is_some() || frame.jobs.is_empty();
|
||||
}
|
||||
let old = self.model.len();
|
||||
self.model.extend_from_slice(&frame.new_model);
|
||||
let bytes = (self.model.len() * std::mem::size_of::<SkinVertex>()) as u64;
|
||||
if self
|
||||
.model_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.size() >= bytes)
|
||||
{
|
||||
let buffer = self.model_buffer.as_ref().expect("checked");
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
buffer,
|
||||
(old * std::mem::size_of::<SkinVertex>()) as u64,
|
||||
bytemuck::cast_slice(&frame.new_model),
|
||||
);
|
||||
} else {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Entity skinning models"),
|
||||
size: bytes.max(256).next_power_of_two(),
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
uploads.buffer(encoder, &buffer, 0, bytemuck::cast_slice(&self.model));
|
||||
self.model_buffer = Some(buffer);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Records the frame's jobs: they write `frame.vertices` vertices into
|
||||
/// `out` from vertex `base` on. Call after the CPU vertices' upload.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn dispatch(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &SkinFrame,
|
||||
out: &wgpu::Buffer,
|
||||
base: u32,
|
||||
) {
|
||||
if frame.jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(model) = &self.model_buffer else {
|
||||
return;
|
||||
};
|
||||
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
|
||||
let count = frame.jobs.len() as u32;
|
||||
let rows = count.div_ceil(ROW);
|
||||
let padded = if rows > 1 {
|
||||
(rows * ROW) as usize
|
||||
} else {
|
||||
frame.jobs.len()
|
||||
};
|
||||
self.staged.clear();
|
||||
self.staged.extend(frame.jobs.iter().map(|job| SkinJob {
|
||||
dst: job.dst + base,
|
||||
..*job
|
||||
}));
|
||||
self.staged.resize(padded, SkinJob::default());
|
||||
self.parts.ensure(
|
||||
device,
|
||||
(frame.parts.len() * std::mem::size_of::<SkinPart>()) as u64,
|
||||
);
|
||||
self.jobs
|
||||
.ensure(device, (padded * std::mem::size_of::<SkinJob>()) as u64);
|
||||
let (Some(parts), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
|
||||
return;
|
||||
};
|
||||
uploads.buffer(encoder, parts, 0, bytemuck::cast_slice(&frame.parts));
|
||||
uploads.buffer(encoder, jobs, 0, bytemuck::cast_slice(&self.staged));
|
||||
let key = [model.clone(), parts.clone(), jobs.clone(), out.clone()];
|
||||
if self.group.as_ref().is_none_or(|(_, k)| *k != key) {
|
||||
let entries: Vec<_> = key
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(binding, buffer)| wgpu::BindGroupEntry {
|
||||
binding: binding as u32,
|
||||
resource: buffer.as_entire_binding(),
|
||||
})
|
||||
.collect();
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
layout: &self.layout,
|
||||
entries: &entries,
|
||||
});
|
||||
self.group = Some((group, key));
|
||||
}
|
||||
let Some((group, _)) = &self.group else {
|
||||
return;
|
||||
};
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups(count.min(ROW), rows, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// What `entity_skin.wgsl` writes for `frame`'s jobs, computed on the CPU
|
||||
/// the same way: the reference its tests hold the shader and the game's walk
|
||||
/// (`mobs::skin`) to. `model` is the whole store.
|
||||
pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
|
||||
let mut out = vec![bytemuck::Zeroable::zeroed(); frame.vertices as usize];
|
||||
for job in &frame.jobs {
|
||||
for k in 0..job.count {
|
||||
let v = model[(job.first + k) as usize];
|
||||
let part = &frame.parts[(job.part + v.part) as usize];
|
||||
let m = glam::Mat4::from_cols_array(&part.matrix);
|
||||
let pos = m.transform_point3(glam::Vec3::from_array(v.pos));
|
||||
let c = (v.axis % 3) as usize * 3;
|
||||
let column = glam::Vec3::from_slice(&part.normal[c..c + 3]);
|
||||
let n = if v.axis >= 3 { -column } else { column };
|
||||
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
|
||||
let u = if job.scroll == 0.0 {
|
||||
v.uv[0]
|
||||
} else {
|
||||
(v.uv[0] + job.scroll) % 1.0
|
||||
};
|
||||
let r = job.rect;
|
||||
out[(job.dst + k) as usize] = Vertex {
|
||||
pos: pos.to_array(),
|
||||
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
|
||||
layer: job.overlay,
|
||||
color: job.color,
|
||||
light: job.light,
|
||||
normal: [packed[0], packed[1], packed[2], -127],
|
||||
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
|
||||
};
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// A cube's 24 vertices for part `part`, its faces on every axis.
|
||||
fn cube(part: u32) -> Vec<SkinVertex> {
|
||||
let mut out = Vec::new();
|
||||
for axis in 0..6u32 {
|
||||
for corner in 0..4u32 {
|
||||
let a = (axis % 3) as usize;
|
||||
let sign = if axis >= 3 { -0.25 } else { 0.25 };
|
||||
let mut pos = [0.0f32; 3];
|
||||
pos[a] = sign;
|
||||
pos[(a + 1) % 3] = if corner & 1 == 0 { -0.25 } else { 0.25 } + 0.1 * part as f32;
|
||||
pos[(a + 2) % 3] = if corner & 2 == 0 { -0.3 } else { 0.3 };
|
||||
out.push(SkinVertex {
|
||||
pos,
|
||||
uv: [corner as f32 / 3.0, 0.125 * axis as f32 + 0.0625],
|
||||
axis,
|
||||
part,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn part(m: glam::Mat4, normal: glam::Mat3) -> SkinPart {
|
||||
SkinPart {
|
||||
matrix: m.to_cols_array(),
|
||||
normal: normal.to_cols_array(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two draws of a two-part model with turned, scaled parts, a scrolled
|
||||
/// texture and the armour glint: the shader writes what `expand` does,
|
||||
/// to the bit, after the vertices before them.
|
||||
#[test]
|
||||
fn the_shader_writes_what_the_cpu_reference_computes() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let Some(mut skin) = EntitySkin::new(&device, true) else {
|
||||
eprintln!("no compute shaders: skipped");
|
||||
return;
|
||||
};
|
||||
let model: Vec<SkinVertex> = cube(0).into_iter().chain(cube(1)).collect();
|
||||
let turn =
|
||||
glam::Mat4::from_quat(glam::Quat::from_euler(glam::EulerRot::ZYX, 0.3, -1.1, 2.0));
|
||||
let base = glam::Mat4::from_translation(glam::Vec3::new(-3.7, 1.25, 9.1)) * turn;
|
||||
let child = base
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.2, -0.4, 0.05))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(1.0, 2.5, 0.5));
|
||||
let normals = glam::Mat3::from_mat4(child).inverse().transpose();
|
||||
let normals = glam::Mat3::from_cols(
|
||||
normals.x_axis.normalize_or_zero(),
|
||||
normals.y_axis.normalize_or_zero(),
|
||||
normals.z_axis.normalize_or_zero(),
|
||||
);
|
||||
let frame = SkinFrame {
|
||||
reset: false,
|
||||
base: 0,
|
||||
new_model: model.clone(),
|
||||
parts: vec![
|
||||
part(base, glam::Mat3::from_mat4(turn)),
|
||||
part(child, normals),
|
||||
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
|
||||
part(child * turn, glam::Mat3::IDENTITY),
|
||||
],
|
||||
jobs: vec![
|
||||
SkinJob {
|
||||
first: 0,
|
||||
count: 48,
|
||||
part: 0,
|
||||
dst: 0,
|
||||
rect: [0.25, 0.5, 0.375, 0.5625],
|
||||
scroll: 0.0,
|
||||
color: 0xff80_40ff,
|
||||
light: 0xf0_70,
|
||||
overlay: 1,
|
||||
..Default::default()
|
||||
},
|
||||
SkinJob {
|
||||
first: 24,
|
||||
count: 24,
|
||||
part: 2,
|
||||
dst: 48,
|
||||
rect: [0.0, 0.0, 0.0625, 0.0625],
|
||||
scroll: 0.73,
|
||||
color: u32::MAX,
|
||||
light: 0xf0f0,
|
||||
overlay: 36,
|
||||
glint: 1,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
vertices: 72,
|
||||
};
|
||||
let expected = expand(&model, &frame);
|
||||
// The posed vertices land after 5 others, which they leave alone.
|
||||
let before = 5u32;
|
||||
let out = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(before + frame.vertices) * 44,
|
||||
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let sentinel = vec![0xabu8; before as usize * 44];
|
||||
queue.write_buffer(&out, 0, &sentinel);
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
assert!(skin.register(&device, &mut uploads, &mut encoder, &frame));
|
||||
skin.dispatch(&device, &mut uploads, &mut encoder, &frame, &out, before);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: out.size(),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(&out, 0, &read, 0, out.size());
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
assert_eq!(&data[..sentinel.len()], &sentinel[..]);
|
||||
let got: &[Vertex] = bytemuck::cast_slice(&data[sentinel.len()..]);
|
||||
for (i, (got, want)) in got.iter().zip(&expected).enumerate() {
|
||||
assert_eq!(
|
||||
bytemuck::bytes_of(got),
|
||||
bytemuck::bytes_of(want),
|
||||
"vertex {i}: {got:?} != {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Entity models posed on the GPU (gfx/entity_skin.rs): each job is one draw of
|
||||
// a model (`mobs::render::draw`), and writes the same vertices the CPU path
|
||||
// would push, in the same order, into the entity vertex buffer. One workgroup
|
||||
// per job; its invocations walk the model's vertices.
|
||||
//
|
||||
// Words, not structs: every record is read and written as u32 words, so the
|
||||
// layouts are the Rust ones byte for byte.
|
||||
|
||||
// A model vertex (7 words): position / 16, model UV, the face's normal as an
|
||||
// axis (0..2 = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in the model.
|
||||
const MODEL_WORDS: u32 = 7u;
|
||||
// A posed part (25 words): the geometry matrix (16, by columns), then the
|
||||
// normal matrix (9, by columns), its columns already normalized when the
|
||||
// part's scale is not uniform (`ModelNormals.trusted` false).
|
||||
const PART_WORDS: u32 = 25u;
|
||||
// A job (16 words): first model vertex, vertex count, first part, first output
|
||||
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
|
||||
// whether the overlay is the armour glint (its model UV in `glint_uv`), 3 spare.
|
||||
const JOB_WORDS: u32 = 16u;
|
||||
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
|
||||
// (4 x i8), glint_uv (2).
|
||||
const VERTEX_WORDS: u32 = 11u;
|
||||
|
||||
@group(0) @binding(0) var<storage, read> model: array<u32>;
|
||||
@group(0) @binding(1) var<storage, read> parts: array<u32>;
|
||||
@group(0) @binding(2) var<storage, read> jobs: array<u32>;
|
||||
@group(0) @binding(3) var<storage, read_write> out: array<u32>;
|
||||
|
||||
fn f(words: u32) -> f32 {
|
||||
return bitcast<f32>(words);
|
||||
}
|
||||
|
||||
fn part_f(part: u32, i: u32) -> f32 {
|
||||
return f(parts[part * PART_WORDS + i]);
|
||||
}
|
||||
|
||||
// `mobs::render::scroll_u`.
|
||||
fn scroll_u(u: f32, scroll: f32) -> f32 {
|
||||
if scroll == 0.0 {
|
||||
return u;
|
||||
}
|
||||
// Rust's `%` on floats: the remainder with the dividend's sign.
|
||||
let s = u + scroll;
|
||||
return s - trunc(s);
|
||||
}
|
||||
|
||||
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
|
||||
fn pack(n: vec3<f32>) -> u32 {
|
||||
let b = vec3<i32>(clamp(n, vec3(-1.0), vec3(1.0)) * 127.0);
|
||||
return (u32(b.x) & 0xffu) | ((u32(b.y) & 0xffu) << 8u) | ((u32(b.z) & 0xffu) << 16u)
|
||||
| (u32(-127 & 0xff) << 24u);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn skin(
|
||||
@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(num_workgroups) groups: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32,
|
||||
) {
|
||||
let job = group.y * groups.x + group.x;
|
||||
if job >= arrayLength(&jobs) / JOB_WORDS {
|
||||
return;
|
||||
}
|
||||
let j = job * JOB_WORDS;
|
||||
let first = jobs[j];
|
||||
let count = jobs[j + 1u];
|
||||
let first_part = jobs[j + 2u];
|
||||
let dst = jobs[j + 3u];
|
||||
let rect = vec4(f(jobs[j + 4u]), f(jobs[j + 5u]), f(jobs[j + 6u]), f(jobs[j + 7u]));
|
||||
let scroll = f(jobs[j + 8u]);
|
||||
let color = jobs[j + 9u];
|
||||
let light = jobs[j + 10u];
|
||||
let overlay = jobs[j + 11u];
|
||||
let glint = jobs[j + 12u] != 0u;
|
||||
for (var k = lane; k < count; k += 64u) {
|
||||
let v = (first + k) * MODEL_WORDS;
|
||||
let p = vec3(f(model[v]), f(model[v + 1u]), f(model[v + 2u]));
|
||||
let uv = vec2(f(model[v + 3u]), f(model[v + 4u]));
|
||||
let axis = model[v + 5u];
|
||||
let part = first_part + model[v + 6u];
|
||||
// glam's Mat4::transform_point3, in its order.
|
||||
var pos = vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x;
|
||||
pos = vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y + pos;
|
||||
pos = vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z + pos;
|
||||
pos = vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos;
|
||||
// An axis normal times the normal matrix is that column, exactly.
|
||||
let c = 16u + (axis % 3u) * 3u;
|
||||
var n = vec3(part_f(part, c), part_f(part, c + 1u), part_f(part, c + 2u));
|
||||
if axis >= 3u {
|
||||
n = -n;
|
||||
}
|
||||
let o = (dst + k) * VERTEX_WORDS;
|
||||
out[o] = bitcast<u32>(pos.x);
|
||||
out[o + 1u] = bitcast<u32>(pos.y);
|
||||
out[o + 2u] = bitcast<u32>(pos.z);
|
||||
out[o + 3u] = bitcast<u32>(rect.x + (rect.z - rect.x) * scroll_u(uv.x, scroll));
|
||||
out[o + 4u] = bitcast<u32>(rect.y + (rect.w - rect.y) * uv.y);
|
||||
out[o + 5u] = overlay;
|
||||
out[o + 6u] = color;
|
||||
out[o + 7u] = light;
|
||||
out[o + 8u] = pack(n);
|
||||
if glint {
|
||||
out[o + 9u] = bitcast<u32>(uv.x);
|
||||
out[o + 10u] = bitcast<u32>(uv.y);
|
||||
} else {
|
||||
out[o + 9u] = 0u;
|
||||
out[o + 10u] = 0u;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -969,6 +969,7 @@ mod initial_history_tests {
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
clouds: None,
|
||||
|
||||
@@ -49,7 +49,7 @@ pub mod hud_overlays;
|
||||
pub mod inventory_effects;
|
||||
pub mod java_text;
|
||||
pub mod jigsaw_block;
|
||||
pub mod json_folder;
|
||||
pub use horizon_world::json_folder;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
pub mod lod;
|
||||
@@ -91,7 +91,9 @@ pub mod sound_events;
|
||||
pub mod sound_preview;
|
||||
pub mod spans;
|
||||
pub mod split_layout;
|
||||
pub mod split_map;
|
||||
pub mod split_profiles;
|
||||
pub mod split_ui;
|
||||
mod sprite_cache;
|
||||
pub mod sprites;
|
||||
pub mod streaming;
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
//! framebuffer pixels wide, before the screen is drawn over it. Works in the
|
||||
//! frame's own (gamma) values, as vanilla's main target does, and needs a
|
||||
//! swapchain that can be copied from.
|
||||
//!
|
||||
//! On a software adapter (WARP, llvmpipe) the rounds run at half the frame's
|
||||
//! width and height with half the radius: the same blur, a quarter of the
|
||||
//! pixels. At full size they took 250 ms of a CPU per frame at 1440x900, and
|
||||
//! every blurred menu ran at 3 FPS (#5185).
|
||||
|
||||
/// `blur.json`: the passes' directions, ping-ponging between the copied
|
||||
/// frame and a swap texture; the last one writes the frame.
|
||||
@@ -27,15 +32,20 @@ pub struct MenuBlur {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
sampler: wgpu::Sampler,
|
||||
/// One per pass, then the half-size copy's.
|
||||
uniforms: Vec<wgpu::Buffer>,
|
||||
/// The copied frame (`minecraft:main`) and `swap`.
|
||||
/// The copied frame (`minecraft:main`) and `swap`; at half size, then the
|
||||
/// frame copied at full size.
|
||||
targets: Vec<(wgpu::Texture, wgpu::TextureView)>,
|
||||
size: [u32; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
/// Blur at half size (`new`'s `software`).
|
||||
half: bool,
|
||||
}
|
||||
impl MenuBlur {
|
||||
/// `format` is the swapchain's; the blur works in its non-sRGB view.
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
/// `format` is the swapchain's; the blur works in its non-sRGB view. `software`:
|
||||
/// the adapter renders on the CPU, so the blur runs at half size.
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, software: bool) -> Self {
|
||||
let format = format.remove_srgb_suffix();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Menu blur"),
|
||||
@@ -111,8 +121,7 @@ impl MenuBlur {
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
..Default::default()
|
||||
});
|
||||
let uniforms = PASSES
|
||||
.iter()
|
||||
let uniforms = (0..=PASSES.len())
|
||||
.map(|_| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
@@ -130,6 +139,15 @@ impl MenuBlur {
|
||||
targets: Vec::new(),
|
||||
size: [0, 0],
|
||||
format,
|
||||
half: software,
|
||||
}
|
||||
}
|
||||
/// The size the blur's rounds run at for a frame of `size`.
|
||||
fn work_size(&self, size: [u32; 2]) -> [u32; 2] {
|
||||
if self.half {
|
||||
size.map(|s| s.div_ceil(2))
|
||||
} else {
|
||||
size
|
||||
}
|
||||
}
|
||||
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
|
||||
@@ -137,8 +155,15 @@ impl MenuBlur {
|
||||
return;
|
||||
}
|
||||
self.size = size;
|
||||
self.targets = (0..2)
|
||||
.map(|_| {
|
||||
let work = self.work_size(size);
|
||||
let sizes = if self.half {
|
||||
vec![work, work, size]
|
||||
} else {
|
||||
vec![work, work]
|
||||
};
|
||||
self.targets = sizes
|
||||
.into_iter()
|
||||
.map(|size| {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Menu blur target"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -180,65 +205,112 @@ impl MenuBlur {
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.ensure(device, size);
|
||||
let work = self.work_size(size);
|
||||
let copy = if self.half { 2 } else { 0 };
|
||||
encoder.copy_texture_to_texture(
|
||||
frame.as_image_copy(),
|
||||
self.targets[0].0.as_image_copy(),
|
||||
self.targets[copy].0.as_image_copy(),
|
||||
wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let radius = if self.half { radius / 2.0 } else { radius };
|
||||
if self.half {
|
||||
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
|
||||
// corner: their mean.
|
||||
let data = Uniform {
|
||||
size_dir: [size[0] as f32, size[1] as f32, 0.0, 0.0],
|
||||
radius: [0.0; 4],
|
||||
};
|
||||
let (source, output) = (&self.targets[2].1, &self.targets[0].1);
|
||||
self.pass(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
PASSES.len(),
|
||||
data,
|
||||
source,
|
||||
output,
|
||||
None,
|
||||
);
|
||||
}
|
||||
for (i, dir) in PASSES.iter().enumerate() {
|
||||
let data = Uniform {
|
||||
size_dir: [size[0] as f32, size[1] as f32, dir[0], dir[1]],
|
||||
size_dir: [work[0] as f32, work[1] as f32, dir[0], dir[1]],
|
||||
radius: [radius, 0.0, 0.0, 0.0],
|
||||
};
|
||||
queue.write_buffer(&self.uniforms[i], 0, bytemuck::bytes_of(&data));
|
||||
let source = &self.targets[i % 2].1;
|
||||
// The last pass writes the frame; from half size it samples up as it blurs.
|
||||
let output = if i + 1 == PASSES.len() {
|
||||
target
|
||||
} else {
|
||||
&self.targets[(i + 1) % 2].1
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.uniforms[i].as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: output,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
self.pass(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
i,
|
||||
data,
|
||||
source,
|
||||
output,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
}
|
||||
}
|
||||
/// One `box_blur` pass from `source` into `output` with uniform buffer `slot`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pass(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
slot: usize,
|
||||
data: Uniform,
|
||||
source: &wgpu::TextureView,
|
||||
output: &wgpu::TextureView,
|
||||
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
queue.write_buffer(&self.uniforms[slot], 0, bytemuck::bytes_of(&data));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.uniforms[slot].as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: output,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("Menu blur")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -318,7 +390,7 @@ mod tests {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
|
||||
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, false);
|
||||
let size = [64, 8];
|
||||
let first = blurred(&device, &queue, &mut blur, size);
|
||||
// The stripes are blurred into greys.
|
||||
@@ -327,6 +399,44 @@ mod tests {
|
||||
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
|
||||
}
|
||||
|
||||
/// On a software adapter the blur runs at half size and still blurs the
|
||||
/// frame much as at full size: stripes two pixels wide turn to an even grey.
|
||||
#[test]
|
||||
fn the_half_size_blur_blurs_like_the_full_size_one() {
|
||||
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 Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
|
||||
let size = [64, 8];
|
||||
let full = blurred(
|
||||
&device,
|
||||
&queue,
|
||||
&mut MenuBlur::new(&device, format, false),
|
||||
size,
|
||||
);
|
||||
let half = blurred(
|
||||
&device,
|
||||
&queue,
|
||||
&mut MenuBlur::new(&device, format, true),
|
||||
size,
|
||||
);
|
||||
// Away from the edges, both are within a few levels of the stripes' mean grey.
|
||||
for row in 0..size[1] as usize {
|
||||
for x in 8..56 {
|
||||
let at = (row * size[0] as usize + x) * 4;
|
||||
for (name, frame) in [("full", &full), ("half", &half)] {
|
||||
let v = frame[at];
|
||||
assert!((120..=136).contains(&v), "{name} size: {v} at ({x}, {row})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_blur_shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
|
||||
|
||||
@@ -10,7 +10,7 @@ mod sides;
|
||||
use crate::connected::{Pick, TileLayer};
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
biome::{Biomes, GrassModifier},
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
chunk::{Chunk, Light, MIN_Y},
|
||||
graphics::LeafCullingMode,
|
||||
@@ -73,6 +73,15 @@ pub struct TerrainVertex {
|
||||
/// RGBA8: tint multiplied by directional shade and ambient occlusion.
|
||||
pub color: u32,
|
||||
}
|
||||
/// `v.round().clamp(0.0, 65535.0) as u16`, without the `roundf` call x86-64's
|
||||
/// baseline instruction set makes of `f32::round`: clamped first (the same
|
||||
/// result), the fraction is exact below 2^23, and every float above is whole.
|
||||
#[inline]
|
||||
fn round_to_u16(v: f32) -> u16 {
|
||||
let v = v.clamp(0.0, f32::from(u16::MAX));
|
||||
let whole = v as u32;
|
||||
(whole + u32::from(v - whole as f32 >= 0.5)) as u16
|
||||
}
|
||||
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
|
||||
pub const UV_STEPS: f32 = 32768.0;
|
||||
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
|
||||
@@ -82,7 +91,7 @@ impl TerrainVertex {
|
||||
pub fn new(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
|
||||
Self {
|
||||
pos,
|
||||
uv: uv.map(|v| (v * UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
|
||||
uv: uv.map(|v| round_to_u16(v * UV_STEPS)),
|
||||
layer,
|
||||
light,
|
||||
color,
|
||||
@@ -93,7 +102,7 @@ impl TerrainVertex {
|
||||
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
|
||||
Self {
|
||||
pos,
|
||||
uv: uv.map(|v| (v * TILED_UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
|
||||
uv: uv.map(|v| round_to_u16(v * TILED_UV_STEPS)),
|
||||
layer,
|
||||
light,
|
||||
color,
|
||||
@@ -116,6 +125,30 @@ mod vertex_tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn uv_rounding_matches_f32_round() {
|
||||
let reference = |v: f32| v.round().clamp(0.0, f32::from(u16::MAX)) as u16;
|
||||
let mut values = vec![
|
||||
f32::NAN,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
-0.0,
|
||||
0.5,
|
||||
1.5,
|
||||
65534.5,
|
||||
1e9,
|
||||
];
|
||||
for bits in (0..u32::MAX).step_by(9973) {
|
||||
values.push(f32::from_bits(bits));
|
||||
}
|
||||
for i in 0..=140_000 {
|
||||
values.push(i as f32 * 0.5 - 2.0);
|
||||
values.push(i as f32 * 0.5 - 2.0 + f32::EPSILON);
|
||||
}
|
||||
for v in values {
|
||||
assert_eq!(super::round_to_u16(v), reference(v), "{v:e}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn other_uvs_round_within_a_step() {
|
||||
// A flowing fluid's rotated UV.
|
||||
let uv = [0.5 + 0.25 * 0.3f32.sin(), 0.5 - 0.25 * 0.3f32.cos()];
|
||||
@@ -333,94 +366,112 @@ pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
|
||||
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
|
||||
#[cfg(test)]
|
||||
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
visibility_reusing(pad, info, &mut Vec::new())
|
||||
let solid: Vec<bool> = info.iter().map(|i| i.solid).collect();
|
||||
let mut rows = Vec::new();
|
||||
solid_rows(pad, &solid, &mut rows);
|
||||
visibility_rows(&open_rows(&rows))
|
||||
}
|
||||
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
|
||||
// The all-visible shortcut can leave a partially filled stack behind.
|
||||
stack.clear();
|
||||
// Opaque cells are already visited: resolve each state's opacity once,
|
||||
// then reuse this map for the flood fill and the uniform-section shortcuts.
|
||||
let mut seen = [false; 4096];
|
||||
let mut opaque_count = 0;
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
let row = pi(0, y, z);
|
||||
for x in 0..16 {
|
||||
let opaque = info[pad.blocks[row + x] as usize].solid;
|
||||
seen[(y as usize) << 8 | (z as usize) << 4 | x] = opaque;
|
||||
opaque_count += usize::from(opaque);
|
||||
}
|
||||
}
|
||||
/// The section's cells that are not `isSolidRender`, by row (`y << 4 | z`, bit
|
||||
/// x), from the padded rows of `MeshScratch::solid_rows`.
|
||||
fn open_rows(solid: &[u32]) -> [u16; 256] {
|
||||
std::array::from_fn(|r| {
|
||||
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
|
||||
!(solid[((y + B) as usize) * P + (z + B) as usize] >> B) as u16
|
||||
})
|
||||
}
|
||||
/// The cells of `open` connected to `seeds` (a subset) within the row: an
|
||||
/// occluded fill each way, doubling its reach at each step.
|
||||
#[inline]
|
||||
fn fill_row(seeds: u16, open: u16) -> u16 {
|
||||
let (mut up, mut through_up) = (seeds, open);
|
||||
let (mut down, mut through_down) = (seeds, open);
|
||||
for step in [1, 2, 4, 8] {
|
||||
up |= through_up & up << step;
|
||||
through_up &= through_up << step;
|
||||
down |= through_down & down >> step;
|
||||
through_down &= through_down >> step;
|
||||
}
|
||||
if opaque_count == 0 {
|
||||
up | down
|
||||
}
|
||||
/// `visibility` on bit rows, one region at a time: a region grows a row at
|
||||
/// a time (each row filled along its open runs at once) by sweeps over the
|
||||
/// rows it can reach, until a sweep adds nothing.
|
||||
fn visibility_rows(open: &[u16; 256]) -> u64 {
|
||||
let open_count: u32 = open.iter().map(|r| r.count_ones()).sum();
|
||||
if open_count == 4096 {
|
||||
return ALL_VISIBLE;
|
||||
}
|
||||
if opaque_count == 4096 {
|
||||
if open_count == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut unvisited = *open;
|
||||
let mut region = [0u16; 256];
|
||||
let mut result = 0u64;
|
||||
for start in 0..4096usize {
|
||||
if seen[start] {
|
||||
continue;
|
||||
}
|
||||
let mut faces = 0u8;
|
||||
seen[start] = true;
|
||||
stack.push(start);
|
||||
while let Some(c) = stack.pop() {
|
||||
let (x, y, z) = ((c & 15) as i32, (c >> 8) as i32, ((c >> 4) & 15) as i32);
|
||||
if y == 0 {
|
||||
faces |= 1 << 0;
|
||||
for start in 0..256 {
|
||||
while unvisited[start] != 0 {
|
||||
let seed = unvisited[start] & unvisited[start].wrapping_neg();
|
||||
region[start] = fill_row(seed, unvisited[start]);
|
||||
let (mut lo, mut hi) = (start, start);
|
||||
loop {
|
||||
let mut grown = false;
|
||||
let (first, last) = (lo.saturating_sub(16), (hi + 16).min(255));
|
||||
for r in (first..=last).chain((first..=last).rev()) {
|
||||
let mut near = region[r];
|
||||
if r >= 16 {
|
||||
near |= region[r - 16];
|
||||
}
|
||||
if r + 16 < 256 {
|
||||
near |= region[r + 16];
|
||||
}
|
||||
if r & 15 != 0 {
|
||||
near |= region[r - 1];
|
||||
}
|
||||
if r & 15 != 15 {
|
||||
near |= region[r + 1];
|
||||
}
|
||||
let reached = near & unvisited[r];
|
||||
if reached & !region[r] != 0 {
|
||||
region[r] = fill_row(reached, unvisited[r]);
|
||||
(lo, hi) = (lo.min(r), hi.max(r));
|
||||
grown = true;
|
||||
}
|
||||
}
|
||||
if !grown {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if y == 15 {
|
||||
faces |= 1 << 1;
|
||||
}
|
||||
if z == 0 {
|
||||
faces |= 1 << 2;
|
||||
}
|
||||
if z == 15 {
|
||||
faces |= 1 << 3;
|
||||
}
|
||||
if x == 0 {
|
||||
faces |= 1 << 4;
|
||||
}
|
||||
if x == 15 {
|
||||
faces |= 1 << 5;
|
||||
let mut faces = 0u8;
|
||||
for r in lo..=hi {
|
||||
let row = std::mem::take(&mut region[r]);
|
||||
if row == 0 {
|
||||
continue;
|
||||
}
|
||||
unvisited[r] &= !row;
|
||||
let (y, z) = (r >> 4, r & 15);
|
||||
faces |= u8::from(y == 0)
|
||||
| u8::from(y == 15) << 1
|
||||
| u8::from(z == 0) << 2
|
||||
| u8::from(z == 15) << 3
|
||||
| u8::from(row & 1 != 0) << 4
|
||||
| u8::from(row & 0x8000 != 0) << 5;
|
||||
}
|
||||
// A single connected region touching all six faces sets every
|
||||
// pair (including the diagonal); unvisited cavities cannot add more.
|
||||
if faces == 0b11_1111 {
|
||||
return ALL_VISIBLE;
|
||||
}
|
||||
for (dx, dy, dz) in [
|
||||
(1, 0, 0),
|
||||
(-1, 0, 0),
|
||||
(0, 1, 0),
|
||||
(0, -1, 0),
|
||||
(0, 0, 1),
|
||||
(0, 0, -1),
|
||||
] {
|
||||
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
|
||||
if !(0..16).contains(&nx) || !(0..16).contains(&ny) || !(0..16).contains(&nz) {
|
||||
continue;
|
||||
}
|
||||
let n = (ny << 8 | nz << 4 | nx) as usize;
|
||||
if !seen[n] {
|
||||
seen[n] = true;
|
||||
stack.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in 0..6 {
|
||||
if faces & (1 << a) != 0 {
|
||||
for b in 0..6 {
|
||||
if faces & (1 << b) != 0 {
|
||||
result |= 1 << (a * 6 + b);
|
||||
for a in 0..6 {
|
||||
if faces & (1 << a) != 0 {
|
||||
for b in 0..6 {
|
||||
if faces & (1 << b) != 0 {
|
||||
result |= 1 << (a * 6 + b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if result == ALL_VISIBLE {
|
||||
return result;
|
||||
if result == ALL_VISIBLE {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
@@ -509,6 +560,9 @@ pub struct MeshTables {
|
||||
/// reads 8,000 states, and a few bytes each stay in cache where `Info`
|
||||
/// does not.
|
||||
solid: Vec<bool>,
|
||||
/// By state, bit `d` set where its occlusion face toward direction `d` is the
|
||||
/// full block (`Info::face` 1): a face looking at it never shows.
|
||||
full_faces: Vec<u8>,
|
||||
pub visibility: FaceVisibility,
|
||||
water_still: u16,
|
||||
water_flow: u16,
|
||||
@@ -678,6 +732,10 @@ impl MeshTables {
|
||||
}
|
||||
Ok(Self {
|
||||
solid: info.iter().map(|i| i.solid).collect(),
|
||||
full_faces: info
|
||||
.iter()
|
||||
.map(|i| (0..6).fold(0, |bits, d| bits | u8::from(i.face[d] == 1) << d))
|
||||
.collect(),
|
||||
info,
|
||||
visibility,
|
||||
water_still: layer("minecraft:block/water_still")?,
|
||||
@@ -851,68 +909,99 @@ impl Padded {
|
||||
let (blocks, light) = (&mut self.blocks, &mut self.light);
|
||||
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
|
||||
// The section itself is unpacked in one pass and copied row by row;
|
||||
// the border cells of the neighbouring sections are read one by one,
|
||||
// each run of them from a section found once.
|
||||
// the border cells of the neighbouring sections are read in runs, from
|
||||
// sections found once per layer.
|
||||
own.fill(0);
|
||||
let center = region[1][1].map(|c| &c.sections[sy as usize]);
|
||||
if let Some(section) = center {
|
||||
section.unpack(own);
|
||||
let center = region[1][1];
|
||||
if let Some(c) = center {
|
||||
c.sections[sy as usize].unpack(own);
|
||||
}
|
||||
// The x runs of a padded row by the chunk column they fall in.
|
||||
const RUNS: [(usize, i32, i32); 3] = [(0, -B, 0), (1, 0, 16), (2, 16, 16 + B)];
|
||||
for y in -B..16 + B {
|
||||
let wy = base_y + y;
|
||||
// Where each chunk column's cells of this layer come from, found once.
|
||||
let sources: [[Source; 3]; 3] = std::array::from_fn(|rz| {
|
||||
std::array::from_fn(|rx| match region[rz][rx] {
|
||||
None => Source::Missing,
|
||||
Some(c) => match c.section_at(wy) {
|
||||
Ok(section) => Source::Section(section),
|
||||
Err(light) => Source::Outside(light),
|
||||
},
|
||||
})
|
||||
});
|
||||
for z in -B..16 + B {
|
||||
let row = pi(-B, y, z);
|
||||
let rz = ((z + 16) >> 4) as usize;
|
||||
for (rx, x0, x1) in RUNS {
|
||||
let (start, end) = (row + (x0 + B) as usize, row + (x1 + B) as usize);
|
||||
let (blocks, light) = (&mut blocks[start..end], &mut light[start..end]);
|
||||
let Some(c) = region[rz][rx] else {
|
||||
match region[1][1] {
|
||||
// A neighbour not loaded continues this chunk's edge
|
||||
// column. As lit air, the outermost chunks' water drew
|
||||
// a wall toward it (fluids show both sides), a light
|
||||
// sheet in the water where far terrain carries on.
|
||||
// Vanilla draws no chunk without its neighbours.
|
||||
Some(c) => {
|
||||
let ez = z.clamp(0, 15);
|
||||
for (k, x) in (x0..x1).enumerate() {
|
||||
let ex = x.clamp(0, 15);
|
||||
blocks[k] = c.get(ex, wy, ez);
|
||||
light[k] = c.light(ex, wy, ez);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
blocks.fill(0);
|
||||
light.fill(0xF0);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
// `Chunk::get` and `Chunk::light` with the section found once.
|
||||
let s = (wy - c.min_y()) >> 4;
|
||||
let Some(section) = c.sections.get(s as usize).filter(|_| s >= 0) else {
|
||||
blocks.fill(0);
|
||||
light.fill(if s < 0 { 0 } else { 0xF0 });
|
||||
continue;
|
||||
};
|
||||
let at = crate::chunk::index(x0, wy, z);
|
||||
if rx == 1 && rz == 1 && (0..16).contains(&y) {
|
||||
let own_row = (y << 8 | z << 4) as usize;
|
||||
blocks.copy_from_slice(&own[own_row..own_row + 16]);
|
||||
} else {
|
||||
section.blocks.get_run(at, blocks);
|
||||
}
|
||||
match §ion.light {
|
||||
Light::Uniform(v) => light.fill(*v),
|
||||
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
|
||||
}
|
||||
}
|
||||
let (west, rest) = blocks[row..row + P].split_at_mut(B as usize);
|
||||
let (middle, east) = rest.split_at_mut(16);
|
||||
let (west_light, rest) = light[row..row + P].split_at_mut(B as usize);
|
||||
let (middle_light, east_light) = rest.split_at_mut(16);
|
||||
let own_row = (rz == 1 && (0..16).contains(&y))
|
||||
.then(|| &own[(y << 8 | z << 4) as usize..][..16]);
|
||||
let at = |x: i32| crate::chunk::index(x, wy, z);
|
||||
let [w, m, e] = &sources[rz];
|
||||
let edge = (center, wy, z);
|
||||
fill_run(west, west_light, w, None, at(-B), -B, edge);
|
||||
fill_run(middle, middle_light, m, own_row, at(0), 0, edge);
|
||||
fill_run(east, east_light, e, None, at(16), 16, edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Where the cells of one chunk column at one height come from (`Padded::fill`).
|
||||
enum Source<'a> {
|
||||
/// A neighbour not loaded.
|
||||
Missing,
|
||||
/// Above or below the column's sections: air with this light.
|
||||
Outside(u8),
|
||||
Section(&'a crate::chunk::Section),
|
||||
}
|
||||
/// Fills one run of a padded row, `blocks.len()` cells from x = `x0`, starting
|
||||
/// at section index `at`; `own` is the centre section's row, already unpacked.
|
||||
#[inline(always)]
|
||||
fn fill_run(
|
||||
blocks: &mut [u32],
|
||||
light: &mut [u8],
|
||||
source: &Source,
|
||||
own: Option<&[u32]>,
|
||||
at: usize,
|
||||
x0: i32,
|
||||
(center, wy, z): (Option<&Chunk>, i32, i32),
|
||||
) {
|
||||
match source {
|
||||
Source::Section(section) => {
|
||||
match own {
|
||||
Some(own) => blocks.copy_from_slice(own),
|
||||
None => section.blocks.get_run(at, blocks),
|
||||
}
|
||||
match §ion.light {
|
||||
Light::Uniform(v) => light.fill(*v),
|
||||
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
|
||||
}
|
||||
}
|
||||
Source::Outside(v) => {
|
||||
blocks.fill(0);
|
||||
light.fill(*v);
|
||||
}
|
||||
// A neighbour not loaded continues this chunk's edge column. As lit
|
||||
// air, the outermost chunks' water drew a wall toward it (fluids show
|
||||
// both sides), a light sheet in the water where far terrain carries
|
||||
// on. Vanilla draws no chunk without its neighbours.
|
||||
Source::Missing => match center {
|
||||
Some(c) => {
|
||||
let ez = z.clamp(0, 15);
|
||||
for (k, (block, light)) in blocks.iter_mut().zip(light.iter_mut()).enumerate() {
|
||||
let ex = (x0 + k as i32).clamp(0, 15);
|
||||
*block = c.get(ex, wy, ez);
|
||||
*light = c.light(ex, wy, ez);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
blocks.fill(0);
|
||||
light.fill(0xF0);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Per-worker temporary arrays. They never hold resource-pack references or
|
||||
/// geometry still owned by the renderer, and total about 150 KiB per worker
|
||||
/// (the leaf mask adds 8,000 cells, depths and a queue).
|
||||
@@ -920,14 +1009,13 @@ impl Padded {
|
||||
pub struct MeshScratch {
|
||||
padded: Padded,
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
visibility: Vec<usize>,
|
||||
leaf: leaf_culling::LeafMask,
|
||||
/// Per padded row (`pi(-B, y, z) / P`), bit `x + B` set where the block
|
||||
/// is `isSolidRender`.
|
||||
opaque: Vec<u32>,
|
||||
/// is `isSolidRender` (`MeshTables::solid`).
|
||||
solid_rows: Vec<u32>,
|
||||
}
|
||||
/// Fills `rows` with the padded section's opacity, one bit per block (`MeshScratch::opaque`).
|
||||
fn opaque_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
|
||||
/// Fills `rows` with the padded section's `isSolidRender` blocks, one bit each (`MeshScratch::solid_rows`).
|
||||
fn solid_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
|
||||
rows.clear();
|
||||
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
|
||||
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
|
||||
@@ -949,6 +1037,8 @@ fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
|
||||
mod leaf_culling_tests;
|
||||
#[cfg(test)]
|
||||
mod reuse_tests;
|
||||
#[cfg(test)]
|
||||
mod tint_tests;
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
|
||||
@@ -1061,6 +1151,8 @@ struct Tints<'a> {
|
||||
/// Chunk x, chunk z and the section's lowest block y.
|
||||
origin: [i32; 3],
|
||||
layers: [Option<Box<Layer>>; 16],
|
||||
/// `uniform_biome`, once computed.
|
||||
uniform: Option<Option<u16>>,
|
||||
}
|
||||
impl<'a> Tints<'a> {
|
||||
fn new(
|
||||
@@ -1078,6 +1170,7 @@ impl<'a> Tints<'a> {
|
||||
plains: biomes.id("minecraft:plains"),
|
||||
origin,
|
||||
layers: Default::default(),
|
||||
uniform: None,
|
||||
}
|
||||
}
|
||||
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
|
||||
@@ -1116,8 +1209,52 @@ impl<'a> Tints<'a> {
|
||||
plains
|
||||
}
|
||||
}
|
||||
/// The biome every block of the section's blend squares zooms to, when they
|
||||
/// all do: `quart_at_block` picks one of the eight quart cells at its base
|
||||
/// cell plus 0 or 1 on each axis, so if every such cell the section reaches
|
||||
/// holds one biome, every zoomed cell is that biome.
|
||||
fn uniform_biome(&mut self) -> Option<u16> {
|
||||
if let Some(uniform) = self.uniform {
|
||||
return uniform;
|
||||
}
|
||||
let (r, [cx, cz, base_y]) = (self.radius, self.origin);
|
||||
// The base cells of blocks lo..=hi, and the cell past the last.
|
||||
let span = |lo: i32, hi: i32| (lo.wrapping_sub(2) >> 2)..=(hi.wrapping_sub(2) >> 2) + 1;
|
||||
let (xs, ys, zs) = (
|
||||
span(cx * 16 - r, cx * 16 + 15 + r),
|
||||
span(base_y, base_y + 15),
|
||||
span(cz * 16 - r, cz * 16 + 15 + r),
|
||||
);
|
||||
let first = Self::quart_biome(
|
||||
self.region,
|
||||
self.plains,
|
||||
[cx, cz],
|
||||
[*xs.start(), *ys.start(), *zs.start()],
|
||||
);
|
||||
let uniform = ys
|
||||
.flat_map(|y| zs.clone().map(move |z| (y, z)))
|
||||
.flat_map(|(y, z)| xs.clone().map(move |x| [x, y, z]))
|
||||
.all(|q| Self::quart_biome(self.region, self.plains, [cx, cz], q) == first)
|
||||
.then_some(first);
|
||||
self.uniform = Some(uniform);
|
||||
uniform
|
||||
}
|
||||
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
|
||||
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
|
||||
// One biome over every blend square: the mean of (2r+1)^2 equal
|
||||
// colours is that colour, unless it varies by position (swamp grass).
|
||||
if let Some(biome) = self.uniform_biome() {
|
||||
let info = self.biomes.get(biome);
|
||||
match resolver {
|
||||
Resolver::Grass if !matches!(info.modifier, GrassModifier::Swamp) => {
|
||||
return self.biomes.grass(biome, 0.0, 0.0) & 0xFF_FFFF;
|
||||
}
|
||||
Resolver::Grass => {}
|
||||
Resolver::Foliage => return info.foliage & 0xFF_FFFF,
|
||||
Resolver::Dry => return info.dry_foliage & 0xFF_FFFF,
|
||||
Resolver::Water => return info.water & 0xFF_FFFF,
|
||||
}
|
||||
}
|
||||
let r = self.radius;
|
||||
let dim = (16 + 2 * r) as usize;
|
||||
let layer = self.layers[y as usize].get_or_insert_with(|| {
|
||||
@@ -1255,9 +1392,9 @@ impl Mesher<'_> {
|
||||
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
|
||||
scratch.padded.fill(region, sy, own);
|
||||
let pad = &scratch.padded;
|
||||
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
|
||||
opaque_rows(pad, &self.tables.solid, &mut scratch.opaque);
|
||||
let opaque = &scratch.opaque;
|
||||
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
|
||||
let opaque = &scratch.solid_rows;
|
||||
out.visibility = visibility_rows(&open_rows(opaque));
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
if self.leaf_culling.is_interior() {
|
||||
scratch
|
||||
@@ -1272,6 +1409,7 @@ impl Mesher<'_> {
|
||||
[center.x, center.z, base_y],
|
||||
);
|
||||
let info = &self.tables.info;
|
||||
let full_faces = &self.tables.full_faces;
|
||||
let catalog = &self.render.model_catalog;
|
||||
let mut models = Vec::new();
|
||||
for y in 0..16 {
|
||||
@@ -1329,6 +1467,11 @@ impl Mesher<'_> {
|
||||
for d in Direction::ALL {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
// `should_render_face`'s first test, without its lookups: a
|
||||
// full occlusion face toward this block hides the face.
|
||||
if full_faces[neighbor as usize] >> d.opposite() as u8 & 1 != 0 {
|
||||
continue;
|
||||
}
|
||||
let other = &info[neighbor as usize];
|
||||
// LeavesBlock.skipRendering with opaque leaves.
|
||||
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
|
||||
|
||||
@@ -83,9 +83,17 @@ fn weighted_blend(c: [u32; 4], w: [f32; 4]) -> u32 {
|
||||
};
|
||||
channel(0) | channel(16) << 16
|
||||
}
|
||||
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`.
|
||||
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`. Without the `floorf`
|
||||
/// call x86-64's baseline instruction set makes of `f32::floor`: truncation,
|
||||
/// one lower where that rounded a negative value up (saturating as `as` does).
|
||||
pub(super) fn gray(brightness: f32) -> i32 {
|
||||
(brightness * 255.0).floor() as i32
|
||||
let v = brightness * 255.0;
|
||||
let whole = v as i32;
|
||||
if whole as f32 > v {
|
||||
whole.saturating_sub(1)
|
||||
} else {
|
||||
whole
|
||||
}
|
||||
}
|
||||
/// One channel of `ARGB.scaleRGB(color, float)`.
|
||||
pub(super) fn scale(channel: i32, by: f32) -> i32 {
|
||||
@@ -406,6 +414,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn gray_floors_like_f32_floor() {
|
||||
let mut values = vec![
|
||||
f32::NAN,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
-0.0,
|
||||
1.0,
|
||||
-1.0,
|
||||
1e10,
|
||||
-1e10,
|
||||
];
|
||||
for bits in (0..u32::MAX).step_by(7919) {
|
||||
values.push(f32::from_bits(bits));
|
||||
}
|
||||
for i in -3000..3000 {
|
||||
values.push(i as f32 / 1024.0);
|
||||
}
|
||||
for v in values {
|
||||
assert_eq!(gray(v), (v * 255.0).floor() as i32, "{v:e}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn colour_arithmetic_matches_argb_helpers() {
|
||||
assert_eq!(gray(1.0), 255);
|
||||
assert_eq!(gray(0.8), 204);
|
||||
|
||||
@@ -45,6 +45,65 @@ fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts(
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padded_fill_reads_every_neighbour_and_its_light_like_chunk_reads() {
|
||||
let mut state = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
let mut pad = Padded::default();
|
||||
let mut own = Box::new([0; 4096]);
|
||||
for min_y in [MIN_Y, 0] {
|
||||
let layout = crate::chunk::ColumnLayout::new(min_y, 32 * 16).unwrap();
|
||||
// Nine chunks with their own blocks and per-block light, near the
|
||||
// bottom and top sections too, where the padding leaves the column.
|
||||
let chunks: Vec<Chunk> = (0..9)
|
||||
.map(|i| {
|
||||
let mut c = Chunk::with_layout(i % 3 - 1, i / 3 - 1, layout);
|
||||
for s in [0, 1, 30, 31] {
|
||||
for _ in 0..300 {
|
||||
let (x, y, z) = (next() % 16, next() % 16, next() % 16);
|
||||
let y = min_y + s * 16 + y as i32;
|
||||
c.set(x as i32, y, z as i32, 1 + (next() % 40) as u32);
|
||||
}
|
||||
let mut light = Box::new([0u8; 4096]);
|
||||
light.iter_mut().for_each(|l| *l = next() as u8);
|
||||
std::sync::Arc::make_mut(&mut c.sections[s as usize]).light =
|
||||
Light::Full(light);
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
let region: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz * 3 + rx])));
|
||||
for sy in [0, 1, 30, 31] {
|
||||
pad.fill(®ion, sy, &mut own);
|
||||
let base_y = min_y + sy * 16;
|
||||
for y in -B..16 + B {
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let c = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize].unwrap();
|
||||
let (cx, cy, cz) = (x & 15, base_y + y, z & 15);
|
||||
assert_eq!(
|
||||
pad.blocks[pi(x, y, z)],
|
||||
c.get(cx, cy, cz),
|
||||
"{min_y} {sy} {x} {y} {z}"
|
||||
);
|
||||
assert_eq!(
|
||||
pad.light[pi(x, y, z)],
|
||||
c.light(cx, cy, cz),
|
||||
"{min_y} {sy} {x} {y} {z}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
|
||||
/// With a shader pack lighting the faces, every face of a lone stone block has one colour; the
|
||||
/// game's face shading (up 1.0, down 0.5, north/south 0.8, east/west 0.6) is left to the pack.
|
||||
@@ -181,7 +240,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
assert!(scratch.own.is_none());
|
||||
assert_eq!(scratch.padded.blocks.capacity(), 0);
|
||||
assert_eq!(scratch.padded.light.capacity(), 0);
|
||||
assert_eq!(scratch.visibility.capacity(), 0);
|
||||
assert_eq!(scratch.solid_rows.capacity(), 0);
|
||||
}
|
||||
fresh.sort_solid();
|
||||
recycled.sort_solid();
|
||||
|
||||
@@ -203,7 +203,8 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
|
||||
|
||||
/// Reorders `solid`'s quads by bucket, opaque ones first in each. Solid
|
||||
/// cutout quads are also ordered front to back within each axis-facing
|
||||
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
|
||||
/// bucket; quads that tie keep the mesher's order (the quad's index ends its
|
||||
/// sort key), so coplanar overlays still draw over their base.
|
||||
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
|
||||
}
|
||||
@@ -213,9 +214,9 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
|
||||
}
|
||||
|
||||
/// `sort`'s buffers, kept by each meshing thread: a section's quads went
|
||||
/// through fresh vectors, and the sorted copy replaced the mesh's own
|
||||
/// (recycled) buffer.
|
||||
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
|
||||
/// nothing: each quad's part, its sort key, and the sorted quads, which are
|
||||
/// copied back into the mesh's own (recycled) buffer.
|
||||
#[derive(Default)]
|
||||
struct SortScratch {
|
||||
slots: Vec<u8>,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! The one-biome shortcut of `Tints::get` against the full blend. Needs the
|
||||
//! prepared biome data, like the other mesher tests that load resources.
|
||||
use super::*;
|
||||
|
||||
fn biomes() -> Biomes {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
Biomes::load(&root).unwrap()
|
||||
}
|
||||
|
||||
/// A chunk whose every quart cell holds `biome`.
|
||||
fn filled(cx: i32, cz: i32, biome: u16) -> Chunk {
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for qy in 0..(384 / 4) {
|
||||
for qz in 0..4 {
|
||||
for qx in 0..4 {
|
||||
chunk.set_biome_cell(qx, qy, qz, biome);
|
||||
}
|
||||
}
|
||||
}
|
||||
chunk
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn one_biome_shortcut_blends_like_the_full_square() {
|
||||
let biomes = biomes();
|
||||
let desert = biomes.id("minecraft:desert");
|
||||
let (cx, cz) = (3, -2);
|
||||
// Grass without and with the dark forest modifier, swamp noise, and
|
||||
// biomes whose foliage, dry foliage and water differ from the defaults.
|
||||
for name in [
|
||||
"minecraft:plains",
|
||||
"minecraft:dark_forest",
|
||||
"minecraft:swamp",
|
||||
"minecraft:mangrove_swamp",
|
||||
"minecraft:cherry_grove",
|
||||
"minecraft:warm_ocean",
|
||||
"minecraft:badlands",
|
||||
] {
|
||||
let biome = biomes.id(name);
|
||||
let chunks: Vec<Vec<Chunk>> = (0..3)
|
||||
.map(|rz| {
|
||||
(0..3)
|
||||
.map(|rx| filled(cx + rx - 1, cz + rz - 1, biome))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
// One desert column in the east neighbour, next to the section's edge,
|
||||
// where every blend radius reaches it.
|
||||
let mut east = filled(cx + 1, cz, biome);
|
||||
for qy in 0..(384 / 4) {
|
||||
east.set_biome_cell(0, qy, 2, desert);
|
||||
}
|
||||
let all = |rz: usize, rx: usize| Some(&chunks[rz][rx]);
|
||||
let uniform: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| all(rz, rx)));
|
||||
let mut mixed = uniform;
|
||||
mixed[1][2] = Some(&east);
|
||||
// A missing neighbour reads as plains.
|
||||
let mut missing = uniform;
|
||||
missing[0][0] = None;
|
||||
for (region, one_biome) in [
|
||||
(&uniform, true),
|
||||
(&mixed, false),
|
||||
(&missing, name == "minecraft:plains"),
|
||||
] {
|
||||
for base_y in [MIN_Y, 0, 304] {
|
||||
for (radius, seed) in [(0, 0), (1, 42), (2, -7), (5, 1234), (7, i64::MAX)] {
|
||||
let new = || {
|
||||
Tints::new(
|
||||
region,
|
||||
&biomes,
|
||||
BiomeZoom::new(seed),
|
||||
radius,
|
||||
[cx, cz, base_y],
|
||||
)
|
||||
};
|
||||
let mut fast = new();
|
||||
let mut full = new();
|
||||
full.uniform = Some(None);
|
||||
assert_eq!(
|
||||
fast.uniform_biome().is_some(),
|
||||
one_biome,
|
||||
"{name} r{radius} y{base_y}"
|
||||
);
|
||||
for resolver in [
|
||||
Resolver::Grass,
|
||||
Resolver::Foliage,
|
||||
Resolver::Dry,
|
||||
Resolver::Water,
|
||||
] {
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
assert_eq!(
|
||||
fast.get(resolver, x, y, z),
|
||||
full.get(resolver, x, y, z),
|
||||
"{name} resolver {} r{radius} at {x} {y} {z} of y{base_y}",
|
||||
resolver as usize
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,20 +190,74 @@ fn visibility_matches_reference() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
let mut stack = vec![usize::MAX];
|
||||
for _ in 0..3 {
|
||||
for (name, pad) in cases() {
|
||||
assert_eq!(
|
||||
visibility_reusing(&pad, &info, &mut stack),
|
||||
reference(&pad, &info),
|
||||
"{name}"
|
||||
);
|
||||
fn row_fill_matches_a_cell_by_cell_fill() {
|
||||
let naive = |seeds: u16, open: u16| {
|
||||
let mut fill = seeds;
|
||||
loop {
|
||||
let next = (fill | fill << 1 | fill >> 1) & open;
|
||||
if next == fill {
|
||||
return fill;
|
||||
}
|
||||
fill = next;
|
||||
}
|
||||
};
|
||||
let mut state = 0x9E37_79B9u32;
|
||||
for _ in 0..200_000 {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
let open = state as u16;
|
||||
let seeds = (state >> 16) as u16 & open;
|
||||
assert_eq!(
|
||||
fill_row(seeds, open),
|
||||
naive(seeds, open),
|
||||
"{open:016b} {seeds:016b}"
|
||||
);
|
||||
}
|
||||
// Several seeds in one run, the case a carry-based fill gets wrong.
|
||||
assert_eq!(fill_row(0b0110, 0b1111), 0b1111);
|
||||
assert_eq!(fill_row(0xC000, 0xF000), 0xF000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_fill_follows_open_runs() {
|
||||
for open in [0u16, 0xFFFF, 0b0111_0110, 0x8001, 0xF0F0, 0x5555] {
|
||||
for bit in 0..16 {
|
||||
let seed = 1u16 << bit;
|
||||
if open & seed == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut expected = seed;
|
||||
for x in (0..bit).rev().take_while(|&x| open & 1 << x != 0) {
|
||||
expected |= 1 << x;
|
||||
}
|
||||
for x in (bit + 1..16).take_while(|&x| open & 1 << x != 0) {
|
||||
expected |= 1 << x;
|
||||
}
|
||||
assert_eq!(fill_row(seed, open), expected, "{open:016b} from {bit}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mazes: one long winding cavity, and many small ones, where the row
|
||||
/// sweeps are slowest.
|
||||
#[test]
|
||||
fn winding_and_scattered_cavities_match_reference() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
let serpentine = fixture(|x, y, z| {
|
||||
let lane = z % 4 == 0 && y % 4 == 0;
|
||||
let turn = (x == 0 && z % 8 == 4) || (x == 15 && z % 8 == 0);
|
||||
let climb = x == 0 && z == 0 && y % 4 != 0;
|
||||
!(lane || (turn && y % 4 == 0) || climb)
|
||||
});
|
||||
assert_eq!(
|
||||
visibility(&serpentine, &info),
|
||||
reference(&serpentine, &info)
|
||||
);
|
||||
let pockets = fixture(|x, y, z| !(x % 3 == 1 && y % 3 == 1 && z % 3 == 1));
|
||||
assert_eq!(visibility(&pockets, &info), reference(&pockets, &info));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_preserves_every_face_pair() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
|
||||
@@ -1264,7 +1264,7 @@ pub enum Page {
|
||||
Online,
|
||||
/// Splitscreen: the profile another player joins as (`split_profiles`).
|
||||
SplitProfiles,
|
||||
/// Splitscreen: enable joining on this computer.
|
||||
/// Splitscreen: the monitor map and draft window arrangement.
|
||||
SplitSetup,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -1512,6 +1512,7 @@ pub struct OptionsUi {
|
||||
pub calibration: Option<crate::hdr_calibration::Calibration>,
|
||||
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
|
||||
pub calibration_fullscreen: Option<bool>,
|
||||
pub split_setup: crate::split_ui::SplitUi,
|
||||
/// The AI Agent page's status lines (set by the game while it shows).
|
||||
pub agent_status: Vec<String>,
|
||||
/// Keyboard focus: the interactive target currently focused.
|
||||
@@ -1528,8 +1529,6 @@ pub struct OptionsUi {
|
||||
/// The list's rows as last laid out: each row's top and bottom (GUI
|
||||
/// pixels), for the narration's row under the mouse.
|
||||
row_bands: Vec<[f32; 2]>,
|
||||
/// Other local players are joined: leave before turning splitscreen off.
|
||||
pub split_joined: bool,
|
||||
}
|
||||
impl Default for OptionsUi {
|
||||
fn default() -> Self {
|
||||
@@ -1568,6 +1567,7 @@ impl Default for OptionsUi {
|
||||
split_profiles: Default::default(),
|
||||
calibration: None,
|
||||
calibration_fullscreen: None,
|
||||
split_setup: Default::default(),
|
||||
agent_status: Vec::new(),
|
||||
focus: None,
|
||||
can_change_value: false,
|
||||
@@ -1575,7 +1575,6 @@ impl Default for OptionsUi {
|
||||
framebuffer: [1280, 720],
|
||||
split_guest: false,
|
||||
row_bands: Vec::new(),
|
||||
split_joined: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2122,6 +2121,9 @@ impl OptionsUi {
|
||||
) {
|
||||
self.graphics.reset();
|
||||
}
|
||||
if page == Page::SplitSetup {
|
||||
self.split_setup.reset();
|
||||
}
|
||||
if page == Page::SplitProfiles {
|
||||
self.split_profiles.reset();
|
||||
}
|
||||
@@ -2262,14 +2264,7 @@ impl OptionsUi {
|
||||
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::SplitSetup => vec![Row::Buttons(
|
||||
if self.split_guest || self.split_joined {
|
||||
inactive(self.extra_button(s, crate::extras::Item::Splitscreen))
|
||||
} else {
|
||||
self.extra_button(s, crate::extras::Item::Splitscreen)
|
||||
},
|
||||
None,
|
||||
)],
|
||||
Page::SplitSetup => Vec::new(),
|
||||
Page::Extras => {
|
||||
// Each section's switches two to a row, as vanilla's
|
||||
// OptionsList.addSmall lays out an options screen.
|
||||
@@ -3346,6 +3341,11 @@ impl OptionsUi {
|
||||
.draw(level, s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
self.split_setup.on = s.extras.splitscreen;
|
||||
self.split_setup.draw(batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::SplitProfiles {
|
||||
self.split_profiles
|
||||
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
|
||||
@@ -3646,6 +3646,26 @@ impl OptionsUi {
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
use crate::split_ui::Click;
|
||||
if right {
|
||||
return Vec::new();
|
||||
}
|
||||
self.split_setup.on = s.extras.splitscreen;
|
||||
return match self.split_setup.click((x, y), size, drag) {
|
||||
Click::Nothing => Vec::new(),
|
||||
Click::Pressed => vec![Event::ButtonClick],
|
||||
Click::Switch => {
|
||||
crate::extras::Item::Splitscreen.cycle(&mut s.extras, false);
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
}
|
||||
Click::Done => {
|
||||
let mut events = vec![Event::ButtonClick];
|
||||
events.extend(self.back(s));
|
||||
events
|
||||
}
|
||||
};
|
||||
}
|
||||
if self.page() == Page::SplitProfiles {
|
||||
use crate::split_profiles::Click;
|
||||
if right || drag {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
|
||||
//! its window (`place`).
|
||||
//! its window (`place`), and the setup screen's draft (`Draft`): the windows, where each is, who
|
||||
//! plays in each, before any of it is applied.
|
||||
use winit::dpi::PhysicalSize;
|
||||
|
||||
/// Players at most: P1 and fifteen more.
|
||||
@@ -25,30 +26,6 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
|
||||
&[4, 4, 4, 4],
|
||||
];
|
||||
|
||||
/// Where a window is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Placement {
|
||||
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
|
||||
/// where the system opens it).
|
||||
Free(Option<([i32; 2], [u32; 2])>),
|
||||
/// Borderless over the monitor of this name.
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
|
||||
/// centred, each next one a step further, as systems cascade new windows (back to the centre
|
||||
/// after six), so new windows never open exactly on one another.
|
||||
pub fn cascade(number: u8) -> f64 {
|
||||
f64::from(number.saturating_sub(2) % 6) * 32.0
|
||||
}
|
||||
|
||||
/// A window: its number (1 is the game's own) and where it is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Window {
|
||||
pub number: u8,
|
||||
pub placement: Placement,
|
||||
}
|
||||
|
||||
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
|
||||
/// others, P1 first.
|
||||
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
|
||||
@@ -152,10 +129,310 @@ fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] {
|
||||
}
|
||||
}
|
||||
|
||||
/// A screen as the system reports it: its name, where it is on the desktop and its size (both
|
||||
/// physical pixels), and its scale.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Monitor {
|
||||
pub name: String,
|
||||
pub at: [i32; 2],
|
||||
pub size: [u32; 2],
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
/// Where it is on the desktop and its size, as windows are placed: macOS places them in
|
||||
/// points, each monitor at its own scale; Windows and X11 in desktop pixels.
|
||||
pub fn desktop(&self) -> [f64; 4] {
|
||||
let k = if cfg!(target_os = "macos") {
|
||||
self.scale
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
[
|
||||
f64::from(self.at[0]) / k,
|
||||
f64::from(self.at[1]) / k,
|
||||
f64::from(self.size[0]) / k,
|
||||
f64::from(self.size[1]) / k,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a window is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Placement {
|
||||
/// A normal window: its corner on the desktop (`Monitor::desktop`'s units) and its size in
|
||||
/// pixels (`None`: a new one, where the system opens it, `NEW_WINDOW` big).
|
||||
Free(Option<([i32; 2], [u32; 2])>),
|
||||
/// Borderless over the monitor of this name.
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
/// A new free window's size, in logical pixels.
|
||||
pub const NEW_WINDOW: [f64; 2] = [854.0, 480.0];
|
||||
|
||||
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
|
||||
/// centred, each next one a step further, as systems cascade new windows (back to the centre
|
||||
/// after six), so new windows never open exactly on one another.
|
||||
pub fn cascade(number: u8) -> f64 {
|
||||
f64::from(number.saturating_sub(2) % 6) * 32.0
|
||||
}
|
||||
|
||||
/// A window: its number (1 is the game's own) and where it is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Window {
|
||||
pub number: u8,
|
||||
pub placement: Placement,
|
||||
}
|
||||
|
||||
/// Where a player can go: a window there is (by number), a new free one, or a new one filling a
|
||||
/// monitor (by name).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Spot {
|
||||
Window(u8),
|
||||
New,
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
/// Something to tell before Apply.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Warning {
|
||||
/// Nobody plays in this window: it won't open (Window 1 stays, blank).
|
||||
Empty(u8),
|
||||
/// This window fills a monitor that isn't there: its players play in Window 1.
|
||||
Unplugged(u8, String),
|
||||
/// This window fills the monitor Window 1 is on: its players play in Window 1.
|
||||
OnWindow1(u8),
|
||||
}
|
||||
|
||||
/// The setup screen's arrangement until Apply: the windows, each player's window (by number, P1
|
||||
/// first) and the shapes two or three players sharing a window take (`place`'s `two`, `three`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Draft {
|
||||
pub windows: Vec<Window>,
|
||||
pub players: Vec<u8>,
|
||||
pub two: u8,
|
||||
pub three: u8,
|
||||
/// The players (indices) in the order their views fill a window shared with others: first
|
||||
/// left or on top. Empty, or missing some: P1, P2... (`ordered`).
|
||||
pub order: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
/// Everyone in Window 1 (where it is now).
|
||||
pub fn everyone_here(players: usize, window1: Placement, two: u8, three: u8) -> Self {
|
||||
Self {
|
||||
windows: vec![Window {
|
||||
number: 1,
|
||||
placement: window1,
|
||||
}],
|
||||
players: vec![1; players],
|
||||
two,
|
||||
three,
|
||||
order: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Players `i` and `j` trade places: their windows and their places in them.
|
||||
pub fn swap(&mut self, i: usize, j: usize) {
|
||||
let n = self.players.len();
|
||||
if i >= n || j >= n || i == j {
|
||||
return;
|
||||
}
|
||||
self.players.swap(i, j);
|
||||
let mut order = ordered(&self.order, n);
|
||||
let (a, b) = (
|
||||
order.iter().position(|&p| p == i),
|
||||
order.iter().position(|&p| p == j),
|
||||
);
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
order.swap(a, b);
|
||||
}
|
||||
self.order = order;
|
||||
}
|
||||
|
||||
/// Player `i` to place `k` among the others in its window (0: before them all; past the
|
||||
/// last: after them), the others keeping their order: the bumpers' "Window 1 (2 of 3)".
|
||||
pub fn put_at(&mut self, i: usize, k: usize) {
|
||||
let n = self.players.len();
|
||||
if i >= n {
|
||||
return;
|
||||
}
|
||||
let mine = self.players[i];
|
||||
let mut order: Vec<usize> = ordered(&self.order, n)
|
||||
.into_iter()
|
||||
.filter(|&p| p != i)
|
||||
.collect();
|
||||
let others: Vec<usize> = order
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&p| self.players[p] == mine)
|
||||
.collect();
|
||||
let at = match others.get(k) {
|
||||
Some(j) => order.iter().position(|p| p == j).unwrap_or(order.len()),
|
||||
None => others
|
||||
.last()
|
||||
.and_then(|j| order.iter().position(|p| p == j))
|
||||
.map_or(order.len(), |p| p + 1),
|
||||
};
|
||||
order.insert(at, i);
|
||||
self.order = order;
|
||||
}
|
||||
|
||||
/// The players but `i` in window `number`, in their order.
|
||||
pub fn sharing(&self, i: usize, number: u8) -> Vec<usize> {
|
||||
ordered(&self.order, self.players.len())
|
||||
.into_iter()
|
||||
.filter(|&p| p != i && self.players[p] == number)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A new free window, numbered with the lowest number free; its number.
|
||||
pub fn add_window(&mut self) -> u8 {
|
||||
self.open(Placement::Free(None))
|
||||
}
|
||||
|
||||
fn open(&mut self, placement: Placement) -> u8 {
|
||||
let number = (2..=u8::MAX)
|
||||
.find(|n| self.windows.iter().all(|w| w.number != *n))
|
||||
.unwrap_or(u8::MAX);
|
||||
self.windows.push(Window { number, placement });
|
||||
number
|
||||
}
|
||||
|
||||
/// Player `i` to `spot`: a new window opens for it. A window it leaves empty stays in the
|
||||
/// draft until `drop_empty` (at Apply), so numbers hold still while players move. A window
|
||||
/// number the draft doesn't have is ignored.
|
||||
pub fn place_player(&mut self, i: usize, spot: Spot) {
|
||||
if i >= self.players.len() {
|
||||
return;
|
||||
}
|
||||
let number = match spot {
|
||||
Spot::Window(n) if self.windows.iter().any(|w| w.number == n) => n,
|
||||
Spot::Window(_) => return,
|
||||
Spot::New => self.add_window(),
|
||||
Spot::Fill(name) => self.open(Placement::Fill(name)),
|
||||
};
|
||||
self.players[i] = number;
|
||||
}
|
||||
|
||||
/// The windows nobody plays in, gone, but Window 1 and those in `keep`. At Apply, all of
|
||||
/// them; while a draft is edited, the ones it added (the windows open now stay until Apply,
|
||||
/// so their numbers hold still as players move).
|
||||
pub fn drop_empty(&mut self, keep: &[u8]) {
|
||||
let players = &self.players;
|
||||
self.windows
|
||||
.retain(|w| w.number == 1 || players.contains(&w.number) || keep.contains(&w.number));
|
||||
}
|
||||
|
||||
/// Whether nobody plays in window `number`.
|
||||
pub fn empty(&self, number: u8) -> bool {
|
||||
!self.players.contains(&number)
|
||||
}
|
||||
|
||||
/// What to tell before Apply, in window order.
|
||||
pub fn warnings(&self, monitors: &[Monitor], main: &str) -> Vec<Warning> {
|
||||
let mut out = Vec::new();
|
||||
for w in &self.windows {
|
||||
if let Placement::Fill(name) = &w.placement
|
||||
&& !monitors.iter().any(|m| &m.name == name)
|
||||
{
|
||||
out.push(Warning::Unplugged(w.number, name.clone()));
|
||||
} else if w.number != 1 && w.placement == Placement::Fill(main.to_owned()) {
|
||||
out.push(Warning::OnWindow1(w.number));
|
||||
} else if !self.players.contains(&w.number) {
|
||||
out.push(Warning::Empty(w.number));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where each window would be: its corner on the desktop (`Monitor::desktop`'s units) and its
|
||||
/// size in pixels, in `windows` order: free ones where they were (new ones at `main`'s
|
||||
/// corner), the others over their monitor; a window over a missing monitor or Window 1's
|
||||
/// (its players play in Window 1, as the game does): `None`. Window 1 always has one: give
|
||||
/// it its real rect (`Free(Some)`); else it is taken as a new window would open.
|
||||
pub fn rects(&self, monitors: &[Monitor], main: &str) -> Vec<Option<([i32; 2], [u32; 2])>> {
|
||||
let home = monitors
|
||||
.iter()
|
||||
.find(|m| m.name == main)
|
||||
.or(monitors.first());
|
||||
// Centred on Window 1's monitor, as the game opens a new window.
|
||||
let new = |number: u8| {
|
||||
let (at, scale, k) = home.map_or(([0.0; 4], 1.0, 1.0), |m| {
|
||||
let at = m.desktop();
|
||||
(at, m.scale, at[2] / f64::from(m.size[0].max(1)))
|
||||
});
|
||||
let size = NEW_WINDOW.map(|v| (v * scale).round() as u32);
|
||||
let step = cascade(number) * scale * k;
|
||||
let x = at[0] + (at[2] - f64::from(size[0]) * k) / 2.0 + step;
|
||||
let y = at[1] + (at[3] - f64::from(size[1]) * k) / 2.0 + step;
|
||||
([x as i32, y as i32], size)
|
||||
};
|
||||
self.windows
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let rect = match &w.placement {
|
||||
Placement::Free(Some(rect)) => Some(*rect),
|
||||
Placement::Free(None) => Some(new(w.number)),
|
||||
Placement::Fill(name) if w.number != 1 && name == main => None,
|
||||
Placement::Fill(name) => monitors.iter().find(|m| &m.name == name).map(|m| {
|
||||
let at = m.desktop();
|
||||
([at[0] as i32, at[1] as i32], m.size)
|
||||
}),
|
||||
};
|
||||
// Everyone with nowhere else plays in Window 1: it never has no size.
|
||||
if w.number == 1 {
|
||||
rect.or_else(|| Some(new(1)))
|
||||
} else {
|
||||
rect
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every player's view as Apply would make it (`place`, as the game tiles them), with
|
||||
/// `View`'s window an index into `windows`; players of a window over a missing monitor in
|
||||
/// Window 1's.
|
||||
pub fn preview(&self, monitors: &[Monitor], main: &str) -> Vec<View> {
|
||||
let rects = self.rects(monitors, main);
|
||||
let index = |number: u8| self.windows.iter().position(|w| w.number == number);
|
||||
let window1 = index(1).unwrap_or(0);
|
||||
let windows: Vec<usize> = self
|
||||
.players
|
||||
.iter()
|
||||
.map(|&n| index(n).filter(|&i| rects[i].is_some()).unwrap_or(window1))
|
||||
.collect();
|
||||
let sizes: Vec<PhysicalSize<u32>> = rects
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.map_or(PhysicalSize::new(1, 1), |(_, [w, h])| {
|
||||
PhysicalSize::new(w, h)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
place_ordered(&windows, &self.order, &sizes, self.two, self.three)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn window_1_always_has_a_size_and_unknown_windows_take_nobody() {
|
||||
let monitors = [Monitor {
|
||||
name: "Main".into(),
|
||||
at: [0, 0],
|
||||
size: [1920, 1080],
|
||||
scale: 1.0,
|
||||
}];
|
||||
let mut d = Draft::everyone_here(2, Placement::Fill("Gone".into()), 0, 0);
|
||||
assert!(d.rects(&monitors, "Main")[0].is_some());
|
||||
assert!(d.preview(&monitors, "Main").iter().all(|v| v.2.width > 1));
|
||||
d.place_player(1, Spot::Window(9));
|
||||
assert_eq!(d.players, [1, 1], "no Window 9 in the draft");
|
||||
}
|
||||
|
||||
fn size(w: u32, h: u32) -> PhysicalSize<u32> {
|
||||
PhysicalSize::new(w, h)
|
||||
}
|
||||
@@ -272,6 +549,97 @@ mod tests {
|
||||
)
|
||||
);
|
||||
}
|
||||
let mut draft = here(16);
|
||||
draft.place_player(15, Spot::New);
|
||||
let views = draft.preview(&monitors(), "Built-in");
|
||||
assert_eq!(views.len(), 16);
|
||||
assert_ne!(views[0].0, views[15].0);
|
||||
}
|
||||
|
||||
fn monitors() -> Vec<Monitor> {
|
||||
vec![
|
||||
Monitor {
|
||||
name: "Built-in".into(),
|
||||
at: [0, 0],
|
||||
size: [3024, 1964],
|
||||
scale: 2.0,
|
||||
},
|
||||
Monitor {
|
||||
name: "TV".into(),
|
||||
at: [3024, 0],
|
||||
size: [3840, 2160],
|
||||
scale: 1.0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// P1 in Window 1, everyone else in Window 2 filling the TV.
|
||||
trait DeskAndTv {
|
||||
fn desk_and_tv(self, monitors: &[Monitor], main: &str) -> Option<Draft>;
|
||||
}
|
||||
impl DeskAndTv for Draft {
|
||||
fn desk_and_tv(mut self, monitors: &[Monitor], main: &str) -> Option<Draft> {
|
||||
let tv = monitors.iter().find(|m| m.name != main)?;
|
||||
self.windows.push(Window {
|
||||
number: 2,
|
||||
placement: Placement::Fill(tv.name.clone()),
|
||||
});
|
||||
for p in self.players.iter_mut().skip(1) {
|
||||
*p = 2;
|
||||
}
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
fn here(players: usize) -> Draft {
|
||||
Draft::everyone_here(
|
||||
players,
|
||||
Placement::Free(Some(([100, 100], [2000, 1200]))),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets() {
|
||||
let m = monitors();
|
||||
// Everyone here: four quarters of Window 1.
|
||||
let views = here(4).preview(&m, "Built-in");
|
||||
assert_eq!(views[3], (0, [1000, 600], size(1000, 600)));
|
||||
// P1 alone, the others share the TV in rows.
|
||||
let tv = here(4).desk_and_tv(&m, "Built-in").unwrap();
|
||||
assert_eq!(tv.players, [1, 2, 2, 2]);
|
||||
assert_eq!(tv.windows[1].placement, Placement::Fill("TV".into()));
|
||||
let views = tv.preview(&m, "Built-in");
|
||||
assert_eq!(views[0], (0, [0, 0], size(2000, 1200)));
|
||||
assert_eq!(views[1], (1, [0, 0], size(3840, 1080)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_windows_cascade_instead_of_stacking() {
|
||||
let m = monitors();
|
||||
let mut d = here(3);
|
||||
d.place_player(1, Spot::New);
|
||||
d.place_player(2, Spot::New);
|
||||
let rects = d.rects(&m, "Built-in");
|
||||
let ((a, _), (b, _)) = (rects[1].unwrap(), rects[2].unwrap());
|
||||
// Window 3 a step right and down of Window 2, on the same diagonal.
|
||||
assert!(b[0] > a[0] && b[0] - a[0] == b[1] - a[1]);
|
||||
assert_eq!((cascade(2), cascade(3), cascade(8)), (0.0, 32.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_at_a_place_among_a_window_s_players() {
|
||||
let mut d = here(3);
|
||||
d.put_at(2, 0);
|
||||
assert_eq!(d.order, [2, 0, 1]);
|
||||
d.put_at(2, 5); // past the last: after them
|
||||
assert_eq!(d.order, [0, 1, 2]);
|
||||
// Players in another window keep their places.
|
||||
d.players = vec![1, 2, 1];
|
||||
d.put_at(2, 0);
|
||||
assert_eq!(d.sharing(2, 1), [0]);
|
||||
assert_eq!(d.order, [2, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -293,4 +661,98 @@ mod tests {
|
||||
let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0);
|
||||
assert_eq!(views[2].2, size(2000, 500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_players_swap_in_a_window_and_between_windows() {
|
||||
let m = monitors();
|
||||
// In one window: P1 and P2 trade sides.
|
||||
let mut d = here(2);
|
||||
d.swap(0, 1);
|
||||
assert_eq!(d.order, [1, 0]);
|
||||
let views = d.preview(&m, "Built-in");
|
||||
assert!(views[1].1[0] < views[0].1[0], "P2 left of P1");
|
||||
// Between windows: P2 on the TV, P3 in Window 1; they trade windows.
|
||||
let mut d = here(3);
|
||||
d.place_player(1, Spot::Fill("TV".into()));
|
||||
d.swap(1, 2);
|
||||
assert_eq!(d.players, [1, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn players_placed() {
|
||||
let m = monitors();
|
||||
let mut d = here(3);
|
||||
d.place_player(1, Spot::Fill("TV".into()));
|
||||
d.place_player(2, Spot::Window(2));
|
||||
assert_eq!(d.players, [1, 2, 2]);
|
||||
d.place_player(2, Spot::New);
|
||||
assert_eq!(d.players, [1, 2, 3]);
|
||||
assert!(d.warnings(&m, "Built-in").is_empty());
|
||||
// The window left behind stays while the draft is edited (numbers hold still), and
|
||||
// closes at Apply; Window 1 never.
|
||||
d.place_player(1, Spot::Window(1));
|
||||
let numbers = |d: &Draft| d.windows.iter().map(|w| w.number).collect::<Vec<_>>();
|
||||
assert_eq!(numbers(&d), [1, 2, 3]);
|
||||
assert!(d.empty(2));
|
||||
d.drop_empty(&[]);
|
||||
assert_eq!(numbers(&d), [1, 3]);
|
||||
assert!(d.warnings(&m, "Built-in").is_empty());
|
||||
d.place_player(0, Spot::Window(3));
|
||||
d.place_player(1, Spot::Window(3));
|
||||
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_numbered_lowest_free() {
|
||||
let mut d = here(3);
|
||||
assert_eq!(d.add_window(), 2);
|
||||
assert_eq!(d.add_window(), 3);
|
||||
d.windows.retain(|w| w.number != 2);
|
||||
assert_eq!(d.add_window(), 2);
|
||||
// Nobody to place: no window opens for it.
|
||||
d.place_player(9, Spot::New);
|
||||
assert_eq!(d.windows.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_over_window_1s_monitor() {
|
||||
let m = monitors();
|
||||
let mut d = here(2);
|
||||
d.place_player(1, Spot::Fill("Built-in".into()));
|
||||
assert_eq!(d.warnings(&m, "Built-in"), [Warning::OnWindow1(2)]);
|
||||
// As the game does: P2 beside P1 in Window 1.
|
||||
assert_eq!(d.preview(&m, "Built-in")[1].0, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warnings_and_an_unplugged_monitor() {
|
||||
let m = monitors();
|
||||
let mut d = here(2).desk_and_tv(&m, "Built-in").unwrap();
|
||||
let three = d.add_window();
|
||||
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(three)]);
|
||||
// The TV unplugged: its player plays in Window 1 beside P1.
|
||||
assert_eq!(
|
||||
d.warnings(&m[..1], "Built-in"),
|
||||
[Warning::Unplugged(2, "TV".into()), Warning::Empty(three)]
|
||||
);
|
||||
let views = d.preview(&m[..1], "Built-in");
|
||||
assert_eq!(views[1], (0, [1000, 0], size(1000, 1200)));
|
||||
// P1 elsewhere: Window 1 is blank.
|
||||
d.players = vec![2, 2];
|
||||
assert_eq!(d.warnings(&m, "Built-in")[0], Warning::Empty(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_players_two_windows() {
|
||||
let m = monitors();
|
||||
let d = here(8).desk_and_tv(&m, "Built-in").unwrap();
|
||||
let views = d.preview(&m, "Built-in");
|
||||
// Seven on the TV: rows of 3, 2 and 2, the whole TV covered.
|
||||
let area: u64 = views[1..]
|
||||
.iter()
|
||||
.map(|v| u64::from(v.2.width) * u64::from(v.2.height))
|
||||
.sum();
|
||||
assert_eq!(area, 3840 * 2160);
|
||||
assert!(views[1..].iter().all(|v| v.0 == 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Project a draft arrangement onto the Setup screen, without opening a window.
|
||||
use crate::split_layout::{Draft, Monitor};
|
||||
|
||||
/// Map area: below the title and above the move row and footer.
|
||||
pub const TOP: f32 = 30.0;
|
||||
pub const BOTTOM: f32 = 80.0;
|
||||
pub type Rect = [f32; 4];
|
||||
pub type Map = (Vec<Rect>, Vec<(u8, Rect)>, Vec<(u8, Rect)>);
|
||||
|
||||
/// Monitor, window and player rectangles in UI coordinates, in their drawing order.
|
||||
pub fn project(draft: &Draft, monitors: &[Monitor], main: &str, w: f32, h: f32) -> Map {
|
||||
let desk: Vec<[f64; 4]> = monitors.iter().map(Monitor::desktop).collect();
|
||||
// A window's size on the desktop: its pixels at the scale of the monitor it is on.
|
||||
let rects = draft.rects(monitors, main);
|
||||
let on_desk: Vec<(u8, [f64; 4])> = draft
|
||||
.windows
|
||||
.iter()
|
||||
.zip(&rects)
|
||||
.filter_map(|(win, rect)| {
|
||||
let ([x, y], [pw, ph]) = (*rect)?;
|
||||
let (x, y) = (f64::from(x), f64::from(y));
|
||||
let scale = monitors
|
||||
.iter()
|
||||
.find(|m| {
|
||||
let [mx, my, mw, mh] = m.desktop();
|
||||
x >= mx && x < mx + mw && y >= my && y < my + mh
|
||||
})
|
||||
.or_else(|| monitors.iter().find(|m| m.name == main))
|
||||
.map_or(1.0, |m| m.desktop()[2] / f64::from(m.size[0]));
|
||||
Some((
|
||||
win.number,
|
||||
[x, y, f64::from(pw) * scale, f64::from(ph) * scale],
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
// The monitors, and windows past them (Wayland's, whose place is unknown) still shown.
|
||||
let all = || desk.iter().chain(on_desk.iter().map(|(_, d)| d));
|
||||
let x0 = all().map(|d| d[0]).fold(f64::MAX, f64::min);
|
||||
let y0 = all().map(|d| d[1]).fold(f64::MAX, f64::min);
|
||||
let x1 = all().map(|d| d[0] + d[2]).fold(f64::MIN, f64::max);
|
||||
let y1 = all().map(|d| d[1] + d[3]).fold(f64::MIN, f64::max);
|
||||
if desk.is_empty() || x1 <= x0 || y1 <= y0 {
|
||||
return Default::default();
|
||||
}
|
||||
let (aw, ah) = (
|
||||
f64::from(w) - 40.0,
|
||||
// Room under the monitors for their names, and always for the move row: the map
|
||||
// doesn't jump when a player is picked (a drop back to where its tile was misses).
|
||||
(f64::from(h - TOP - BOTTOM) - 18.0).max(10.0),
|
||||
);
|
||||
let k = (aw / (x1 - x0)).min(ah / (y1 - y0));
|
||||
let (ox, oy) = (
|
||||
(f64::from(w) - (x1 - x0) * k) / 2.0,
|
||||
f64::from(TOP) + 4.0 + (ah - (y1 - y0) * k) / 2.0,
|
||||
);
|
||||
let to_map = |[x, y, dw, dh]: [f64; 4]| {
|
||||
[
|
||||
(ox + (x - x0) * k) as f32,
|
||||
(oy + (y - y0) * k) as f32,
|
||||
(dw * k) as f32,
|
||||
(dh * k) as f32,
|
||||
]
|
||||
};
|
||||
let mapped_monitors = desk.iter().copied().map(to_map).collect();
|
||||
let windows: Vec<(u8, [f32; 4])> = on_desk.iter().map(|&(n, d)| (n, to_map(d))).collect();
|
||||
// In their own order, emptied ones too: a window a player was just dragged out of stays
|
||||
// where it was until Apply closes it, so the player can be dragged back into it.
|
||||
let tiles = draft
|
||||
.preview(monitors, main)
|
||||
.iter()
|
||||
.map(|&(i, [vx, vy], size)| {
|
||||
let number = draft.windows.get(i).map_or(1, |w| w.number);
|
||||
let Some((_, [wx, wy, ww, wh])) = windows.iter().find(|(n, _)| *n == number) else {
|
||||
return (number, [0.0; 4]);
|
||||
};
|
||||
let [pw, ph] = rects[i].map_or([1, 1], |r| r.1).map(|v| v.max(1) as f32);
|
||||
(
|
||||
number,
|
||||
[
|
||||
wx + vx as f32 / pw * ww,
|
||||
wy + vy as f32 / ph * wh,
|
||||
size.width as f32 / pw * ww,
|
||||
size.height as f32 / ph * wh,
|
||||
],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(mapped_monitors, windows, tiles)
|
||||
}
|
||||
|
||||
/// The largest visible side of a tile after later windows cover it.
|
||||
pub fn uncovered(mut r: [f32; 4], covers: impl Iterator<Item = [f32; 4]>) -> [f32; 4] {
|
||||
for [cx, cy, cw, ch] in covers {
|
||||
let [x, y, w, h] = r;
|
||||
if cx >= x + w || cx + cw <= x || cy >= y + h || cy + ch <= y {
|
||||
continue;
|
||||
}
|
||||
let sides = [
|
||||
[x, y, cx - x, h],
|
||||
[cx + cw, y, x + w - (cx + cw), h],
|
||||
[x, y, w, cy - y],
|
||||
[x, cy + ch, w, y + h - (cy + ch)],
|
||||
];
|
||||
r = sides
|
||||
.into_iter()
|
||||
.map(|[sx, sy, sw, sh]| [sx, sy, sw.max(0.0), sh.max(0.0)])
|
||||
.max_by(|a, b| (a[2] * a[3]).total_cmp(&(b[2] * b[3])))
|
||||
.unwrap_or([x, y, 0.0, 0.0]);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::split_layout::{Placement, Spot};
|
||||
|
||||
#[test]
|
||||
fn preview_tiles_share_a_window_and_fallback_when_a_monitor_disappears() {
|
||||
let monitors = vec![
|
||||
Monitor {
|
||||
name: "Desk".into(),
|
||||
at: [0, 0],
|
||||
size: [2000, 1200],
|
||||
scale: 1.0,
|
||||
},
|
||||
Monitor {
|
||||
name: "TV".into(),
|
||||
at: [-1920, 0],
|
||||
size: [1920, 1080],
|
||||
scale: 1.0,
|
||||
},
|
||||
];
|
||||
let mut draft =
|
||||
Draft::everyone_here(2, Placement::Free(Some(([0, 0], [2000, 1200]))), 0, 0);
|
||||
let (_, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
|
||||
assert_eq!(windows.len(), 1);
|
||||
let (a, b) = (tiles[0].1, tiles[1].1);
|
||||
assert!((a[2] - b[2]).abs() < 0.01);
|
||||
assert!((a[0] + a[2] - b[0]).abs() < 0.01);
|
||||
draft.place_player(1, Spot::Fill("TV".into()));
|
||||
let (screens, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
|
||||
assert!(screens[1][0] < screens[0][0]);
|
||||
assert_eq!(windows.len(), 2);
|
||||
assert_eq!((tiles[0].0, tiles[1].0), (1, 2));
|
||||
let (_, windows, tiles) = project(&draft, &monitors[..1], "Desk", 427.0, 300.0);
|
||||
assert_eq!(windows.len(), 1);
|
||||
assert_eq!((tiles[0].0, tiles[1].0), (1, 1));
|
||||
assert!(project(&draft, &[], "Desk", 427.0, 300.0).0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_tiles_only_expose_a_visible_side_for_clicks() {
|
||||
let tile = [0.0, 0.0, 100.0, 100.0];
|
||||
assert_eq!(
|
||||
uncovered(tile, [[0.0, 0.0, 60.0, 100.0]].into_iter()),
|
||||
[60.0, 0.0, 40.0, 100.0]
|
||||
);
|
||||
let hidden = uncovered(tile, [tile].into_iter());
|
||||
assert_eq!(hidden[2] * hidden[3], 0.0);
|
||||
assert_eq!(
|
||||
uncovered(tile, [[110.0, 0.0, 50.0, 50.0]].into_iter()),
|
||||
tile
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
//! Setup's monitor map and draft window choices. Apply is consumed by the game adapter.
|
||||
use crate::{
|
||||
graphics_style::STYLE,
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
lang,
|
||||
split_layout::{Draft, Monitor, Placement, Spot, Warning, ordered},
|
||||
split_map::{self, BOTTOM, uncovered},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
/// Resolve against the selected language on every draw, with bundled English
|
||||
/// defaults even in a resource-free UI test. Arguments use Minecraft's formatter.
|
||||
fn tr(key: &str, args: &[&dyn std::fmt::Display]) -> String {
|
||||
static ENGLISH: std::sync::OnceLock<lang::Language> = std::sync::OnceLock::new();
|
||||
let key = if key.starts_with("horizon.") {
|
||||
key.to_owned()
|
||||
} else {
|
||||
format!("horizon.splitscreen.setup.{key}")
|
||||
};
|
||||
let template = lang::current()
|
||||
.and_then(|language| language.get(&key).map(str::to_owned))
|
||||
.unwrap_or_else(|| {
|
||||
ENGLISH
|
||||
.get_or_init(|| lang::Language::load(&[], "en_us"))
|
||||
.get(&key)
|
||||
.unwrap_or(&key)
|
||||
.to_owned()
|
||||
});
|
||||
let args: Vec<_> = args
|
||||
.iter()
|
||||
.map(|arg| Text::literal(arg.to_string()))
|
||||
.collect();
|
||||
crate::text::format_translation(&template, &args).plain()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SplitUi {
|
||||
pub on: bool,
|
||||
pub idle: bool,
|
||||
pub live: Option<Draft>,
|
||||
pub monitors: Vec<Monitor>,
|
||||
pub main: String,
|
||||
pub names: Vec<String>,
|
||||
pub apply: Option<Draft>,
|
||||
edit: Option<Draft>,
|
||||
selected: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Target {
|
||||
Player(usize),
|
||||
Move(Spot),
|
||||
Revert,
|
||||
Apply,
|
||||
Done,
|
||||
Switch,
|
||||
}
|
||||
type Button = ([f32; 4], String, Target, bool);
|
||||
pub enum Click {
|
||||
Nothing,
|
||||
Pressed,
|
||||
Done,
|
||||
Switch,
|
||||
}
|
||||
|
||||
impl SplitUi {
|
||||
pub fn reset(&mut self) {
|
||||
self.edit = None;
|
||||
self.selected = None;
|
||||
self.apply = None;
|
||||
}
|
||||
fn draft(&self) -> Option<&Draft> {
|
||||
self.edit.as_ref().or(self.live.as_ref())
|
||||
}
|
||||
fn map(&self, w: f32, h: f32) -> split_map::Map {
|
||||
if self.idle {
|
||||
return Default::default();
|
||||
}
|
||||
self.draft().map_or_else(Default::default, |draft| {
|
||||
split_map::project(draft, &self.monitors, &self.main, w, h)
|
||||
})
|
||||
}
|
||||
fn editing(&mut self) -> Option<&mut Draft> {
|
||||
if self.edit.is_none() {
|
||||
self.edit = self.live.clone();
|
||||
}
|
||||
self.edit.as_mut()
|
||||
}
|
||||
|
||||
fn monitor_name(&self, name: &str) -> String {
|
||||
match self.monitors.iter().position(|m| m.name == name) {
|
||||
Some(n) => tr("monitor", &[&(n + 1)]),
|
||||
None => tr("monitor_unknown", &[]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the selected player can go: the other windows, a new one, a monitor no window fills.
|
||||
fn spots(&self) -> Vec<(String, Spot)> {
|
||||
let (Some(draft), Some(i)) = (self.draft(), self.selected) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mine = draft.players.get(i).copied();
|
||||
let mut spots: Vec<(String, Spot)> = draft
|
||||
.windows
|
||||
.iter()
|
||||
.filter(|w| Some(w.number) != mine)
|
||||
.map(|w| (tr("window", &[&w.number]), Spot::Window(w.number)))
|
||||
.collect();
|
||||
spots.push((tr("new_window", &[]), Spot::New));
|
||||
for m in &self.monitors {
|
||||
let filled = draft
|
||||
.windows
|
||||
.iter()
|
||||
.any(|w| w.placement == Placement::Fill(m.name.clone()));
|
||||
if m.name != self.main && !filled {
|
||||
spots.push((
|
||||
tr("fill_monitor", &[&self.monitor_name(&m.name)]),
|
||||
Spot::Fill(m.name.clone()),
|
||||
));
|
||||
}
|
||||
}
|
||||
spots
|
||||
}
|
||||
|
||||
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
|
||||
let row = |out: &mut Vec<Button>, y: f32, items: Vec<(f32, String, Target, bool)>| {
|
||||
let total: f32 = items.iter().map(|i| i.0 + 4.0).sum::<f32>() - 4.0;
|
||||
let mut x = ((w - total) / 2.0).floor();
|
||||
for (bw, label, target, active) in items {
|
||||
out.push(([x, y, bw, 20.0], label, target, active));
|
||||
x += bw + 4.0;
|
||||
}
|
||||
};
|
||||
let done = (
|
||||
64.0,
|
||||
lang::get_or("gui.done", &tr("done", &[])),
|
||||
Target::Done,
|
||||
true,
|
||||
);
|
||||
// The switch, top right (P1's): off only once P1 plays alone (turning it off with others
|
||||
// in drops them all at once).
|
||||
let switch = (
|
||||
[w - 110.0, 6.0, 104.0, 20.0],
|
||||
tr(
|
||||
"switch",
|
||||
&[
|
||||
&tr("horizon.extras.splitscreen", &[]),
|
||||
&tr(if self.on { "on" } else { "off" }, &[]),
|
||||
],
|
||||
),
|
||||
Target::Switch,
|
||||
!self.on || self.names.len() <= 1,
|
||||
);
|
||||
if !self.on || self.idle {
|
||||
let mut out = vec![switch];
|
||||
row(&mut out, h - 27.0, vec![done]);
|
||||
return out;
|
||||
}
|
||||
let (_, windows, tiles) = self.map(w, h);
|
||||
let mut order: Vec<usize> = (0..tiles.len()).collect();
|
||||
order.sort_by_key(|&i| windows.iter().position(|(n, _)| *n == tiles[i].0));
|
||||
let mut out: Vec<Button> = order
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let name = self
|
||||
.names
|
||||
.get(i)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| tr("player", &[&(i + 1)]));
|
||||
// The part no later window covers: what can be seen and pressed.
|
||||
let at = windows.iter().position(|(n, _)| *n == tiles[i].0);
|
||||
let covers = at.map_or(&[][..], |k| &windows[k + 1..]);
|
||||
let rect = uncovered(tiles[i].1, covers.iter().map(|(_, r)| *r));
|
||||
(rect, name, Target::Player(i), true)
|
||||
})
|
||||
.collect();
|
||||
// The move row: one button for the windows, which cycles (the next window after the
|
||||
// player's, by number, wrapping), then a new window and the monitors.
|
||||
let mut spots = self.spots();
|
||||
let mine = self
|
||||
.selected
|
||||
.and_then(|i| self.draft().and_then(|d| d.players.get(i).copied()))
|
||||
.unwrap_or(0);
|
||||
let next = spots
|
||||
.iter()
|
||||
.filter_map(|(_, s)| match s {
|
||||
Spot::Window(n) => Some(*n),
|
||||
_ => None,
|
||||
})
|
||||
.min_by_key(|&n| (n <= mine, n));
|
||||
spots.retain(|(_, s)| !matches!(s, Spot::Window(n) if Some(*n) != next));
|
||||
if !spots.is_empty() {
|
||||
let items = spots
|
||||
.into_iter()
|
||||
.map(|(label, spot)| (86.0, label, Target::Move(spot), true))
|
||||
.collect();
|
||||
row(&mut out, h - BOTTOM + 4.0, items);
|
||||
}
|
||||
out.push(switch);
|
||||
let mut footer = Vec::new();
|
||||
let editing = self.changed();
|
||||
footer.extend([
|
||||
(64.0, tr("revert", &[]), Target::Revert, editing),
|
||||
(64.0, tr("apply", &[]), Target::Apply, editing),
|
||||
done,
|
||||
]);
|
||||
row(&mut out, h - 27.0, footer);
|
||||
out
|
||||
}
|
||||
|
||||
fn warnings(&self) -> Vec<String> {
|
||||
let Some(draft) = self.draft() else {
|
||||
return Vec::new();
|
||||
};
|
||||
draft
|
||||
.warnings(&self.monitors, &self.main)
|
||||
.into_iter()
|
||||
.map(|w| match w {
|
||||
Warning::Empty(1) => tr("warning.empty_main", &[]),
|
||||
// One open now closes; one only in the draft never opens.
|
||||
Warning::Empty(n) if self.live.as_ref().is_some_and(|l| !l.empty(n)) => {
|
||||
tr("warning.empty_close", &[&n])
|
||||
}
|
||||
Warning::Empty(n) => tr("warning.empty_new", &[&n]),
|
||||
Warning::OnWindow1(n) => tr("warning.on_main", &[&n]),
|
||||
Warning::Unplugged(n, _) => tr("warning.unplugged", &[&n]),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The state represented by the preview area, also available to agent controls.
|
||||
pub fn status(&self) -> &'static str {
|
||||
if !self.on {
|
||||
"off"
|
||||
} else if self.idle {
|
||||
"idle"
|
||||
} else {
|
||||
"active"
|
||||
}
|
||||
}
|
||||
|
||||
fn status_lines(&self) -> Option<[String; 3]> {
|
||||
if !self.on {
|
||||
Some([
|
||||
tr("status.off", &[]),
|
||||
tr("status.enable_hint", &[]),
|
||||
tr("status.join_hint", &[]),
|
||||
])
|
||||
} else if self.idle {
|
||||
Some([
|
||||
tr("status.idle", &[]),
|
||||
tr("status.alone", &[]),
|
||||
tr("status.solo_controls", &[]),
|
||||
])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32), in_world: bool) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
STYLE.title(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(tr("horizon.pages.split_setup", &[])),
|
||||
);
|
||||
if let Some(lines) = self.status_lines() {
|
||||
let grey = rgba(170, 170, 170, 255);
|
||||
let lines: Vec<_> = lines
|
||||
.iter()
|
||||
.flat_map(|line| atlas.font.split(&Text::literal(line), (w - 40.0).max(10.0)))
|
||||
.collect();
|
||||
let top = h / 2.0 - 6.0 * lines.len() as f32;
|
||||
for (k, line) in lines.into_iter().enumerate() {
|
||||
let y = (top + 12.0 * k as f32).floor();
|
||||
ui::centered_text(batch, atlas, &line, 0.0, y, w, grey);
|
||||
}
|
||||
for b in &self.buttons(w, h) {
|
||||
self.draw_button(batch, atlas, cursor, b);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (monitors, windows, tiles) = self.map(w, h);
|
||||
let grey = rgba(160, 160, 160, 255);
|
||||
for &[x, y, mw, mh] in &monitors {
|
||||
batch.fill(atlas, x, y, mw, mh, rgba(255, 255, 255, 60));
|
||||
batch.fill(
|
||||
atlas,
|
||||
x + 1.0,
|
||||
y + 1.0,
|
||||
mw - 2.0,
|
||||
mh - 2.0,
|
||||
rgba(0, 0, 0, 170),
|
||||
);
|
||||
}
|
||||
let buttons = self.buttons(w, h);
|
||||
let yellow = rgba(255, 255, 85, 255);
|
||||
// Each window, then its players: a window over another covers it.
|
||||
let draft = self.draft();
|
||||
// A window nobody plays in any more stays until Apply: grey, under the others.
|
||||
let emptied = |n: u8| n != 1 && draft.is_some_and(|d| d.empty(n));
|
||||
for &(number, [x, y, ww, wh]) in &windows {
|
||||
let edge = match emptied(number) {
|
||||
true => rgba(160, 160, 160, 120),
|
||||
false => rgba(255, 255, 85, 200),
|
||||
};
|
||||
batch.fill(atlas, x, y, ww, wh, edge);
|
||||
if emptied(number) {
|
||||
let label = Text::literal(tr("window_empty", &[&number]));
|
||||
batch.text_styled(atlas, &label, x + 3.0, y - 10.0, grey, true);
|
||||
}
|
||||
batch.fill(
|
||||
atlas,
|
||||
x + 1.0,
|
||||
y + 1.0,
|
||||
ww - 2.0,
|
||||
wh - 2.0,
|
||||
rgba(40, 40, 40, 230),
|
||||
);
|
||||
for b in buttons
|
||||
.iter()
|
||||
.filter(|b| matches!(b.2, Target::Player(i) if tiles[i].0 == number))
|
||||
{
|
||||
self.draw_button(batch, atlas, cursor, b);
|
||||
}
|
||||
}
|
||||
// The windows' names over everything, so another window's tiles never hide them.
|
||||
for (number, [x, y, _, _]) in windows.iter().copied().filter(|w| !emptied(w.0)) {
|
||||
let (label, color) = (tr("window", &[&number]), yellow);
|
||||
// A title above this window can land on another player's visible tile.
|
||||
let covered = windows.iter().any(|(n, r)| {
|
||||
*n != number && ui::hovered((x + 3.0, y - 10.0), r[0], r[1], r[2], r[3])
|
||||
});
|
||||
let title_y = if covered { y + 3.0 } else { y - 10.0 };
|
||||
batch.text_styled(atlas, &Text::literal(label), x + 3.0, title_y, color, true);
|
||||
}
|
||||
for b in buttons.iter().filter(|b| !matches!(b.2, Target::Player(_))) {
|
||||
self.draw_button(batch, atlas, cursor, b);
|
||||
}
|
||||
// The monitors' names under them, where no window covers them.
|
||||
for (n, [x, y, _, mh]) in monitors.into_iter().enumerate() {
|
||||
let label = Text::literal(tr("monitor", &[&(n + 1)]));
|
||||
batch.text_styled(atlas, &label, x + 1.0, y + mh + 2.0, grey, true);
|
||||
}
|
||||
self.draw_notes(batch, atlas, cursor, &buttons);
|
||||
}
|
||||
|
||||
fn draw_button(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
([x, y, bw, bh], label, target, active): &Button,
|
||||
) {
|
||||
let (mut x, mut y, mut bw, mut bh) = (*x, *y, *bw, *bh);
|
||||
let hover = *active && ui::hovered(cursor, x, y, bw, bh);
|
||||
let selected = matches!(target, Target::Player(i) if Some(*i) == self.selected);
|
||||
if matches!(target, Target::Player(_)) {
|
||||
(x, y, bw, bh) = (x + 1.0, y + 1.0, (bw - 2.0).max(4.0), (bh - 2.0).max(4.0));
|
||||
}
|
||||
// The selected player's name in yellow: picked, not just under the pointer.
|
||||
let text = if selected {
|
||||
Text::colored(label.as_str(), crate::text::YELLOW)
|
||||
} else {
|
||||
Text::literal(label.as_str())
|
||||
};
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
bw,
|
||||
bh,
|
||||
&text,
|
||||
*active,
|
||||
hover || selected,
|
||||
);
|
||||
}
|
||||
fn draw_notes(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
buttons: &[Button],
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let note = match self.selected {
|
||||
None => tr("note.select", &[]),
|
||||
Some(_) if self.edit.is_some() => tr("note.apply", &[]),
|
||||
Some(i) => tr(
|
||||
"note.place",
|
||||
&[&self
|
||||
.names
|
||||
.get(i)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| tr("this_player", &[]))],
|
||||
),
|
||||
};
|
||||
for (k, (line, color)) in std::iter::once((note, rgba(170, 170, 170, 255)))
|
||||
.chain(
|
||||
self.warnings()
|
||||
.into_iter()
|
||||
.map(|w| (w, rgba(255, 170, 85, 255))),
|
||||
)
|
||||
.take(2)
|
||||
.enumerate()
|
||||
{
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(line),
|
||||
0.0,
|
||||
h - BOTTOM + 28.0 + k as f32 * 11.0,
|
||||
w,
|
||||
color,
|
||||
);
|
||||
}
|
||||
if buttons.iter().any(|(r, _, t, active)| {
|
||||
!active && matches!(t, Target::Switch) && ui::hovered(cursor, r[0], r[1], r[2], r[3])
|
||||
}) {
|
||||
ui::tooltip(
|
||||
batch,
|
||||
atlas,
|
||||
&atlas
|
||||
.font
|
||||
.split(&Text::literal(tr("switch_disabled", &[])), 170.0),
|
||||
cursor,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A press at `(x, y)` on a screen `w` x `h`, or the mouse moving with it held (`drag`).
|
||||
pub fn click(&mut self, (x, y): (f32, f32), (w, h): (f32, f32), drag: bool) -> Click {
|
||||
if drag {
|
||||
return Click::Nothing;
|
||||
}
|
||||
// The topmost: the last drawn.
|
||||
let hit = self
|
||||
.buttons(w, h)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|([bx, by, bw, bh], _, _, active)| {
|
||||
*active && ui::hovered((x, y), *bx, *by, *bw, *bh)
|
||||
});
|
||||
let Some((_, _, target, _)) = hit else {
|
||||
return Click::Nothing;
|
||||
};
|
||||
match target {
|
||||
Target::Player(i) => {
|
||||
self.selected = Some(i);
|
||||
}
|
||||
Target::Move(spot) => {
|
||||
if let Some(i) = self.selected {
|
||||
self.place(i, spot);
|
||||
}
|
||||
}
|
||||
Target::Revert => self.edit = None,
|
||||
Target::Apply => {
|
||||
// Windows emptied in the draft close now, not as players moved.
|
||||
self.apply = self.edit.take().map(|mut d| {
|
||||
d.drop_empty(&[]);
|
||||
d
|
||||
});
|
||||
}
|
||||
Target::Done => {
|
||||
self.reset();
|
||||
return Click::Done;
|
||||
}
|
||||
Target::Switch => {
|
||||
self.reset();
|
||||
return Click::Switch;
|
||||
}
|
||||
}
|
||||
Click::Pressed
|
||||
}
|
||||
|
||||
fn changed(&self) -> bool {
|
||||
// A free window's spot moves under the draft as it's dragged around: not a change.
|
||||
let windows = |d: &Draft| -> Vec<(u8, Option<String>)> {
|
||||
let fill = |p: &Placement| match p {
|
||||
Placement::Fill(name) => Some(name.clone()),
|
||||
Placement::Free(_) => None,
|
||||
};
|
||||
d.windows
|
||||
.iter()
|
||||
.map(|w| (w.number, fill(&w.placement)))
|
||||
.collect()
|
||||
};
|
||||
// The order only counts among players sharing a window (who is first in it).
|
||||
let order = |d: &Draft| -> Vec<(u8, usize)> {
|
||||
let mut by_window: Vec<(u8, usize)> = ordered(&d.order, d.players.len())
|
||||
.into_iter()
|
||||
.filter_map(|i| d.players.get(i).map(|&n| (n, i)))
|
||||
.collect();
|
||||
by_window.sort_by_key(|&(n, _)| n);
|
||||
by_window
|
||||
};
|
||||
let same = |a: &Draft, b: &Draft| {
|
||||
windows(a) == windows(b)
|
||||
&& a.players == b.players
|
||||
&& (a.two, a.three) == (b.two, b.three)
|
||||
&& order(a) == order(b)
|
||||
};
|
||||
match (&self.edit, &self.live) {
|
||||
(Some(edit), Some(live)) => !same(edit, live),
|
||||
(edit, _) => edit.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Player `i` to `spot` in the draft.
|
||||
fn place(&mut self, i: usize, spot: Spot) {
|
||||
let open = self.open_now();
|
||||
if let Some(draft) = self.editing() {
|
||||
draft.place_player(i, spot);
|
||||
draft.drop_empty(&open);
|
||||
}
|
||||
}
|
||||
|
||||
/// The windows open now: emptied in a draft, they stay until Apply (a window the draft
|
||||
/// added goes as soon as nobody is in it).
|
||||
fn open_now(&self) -> Vec<u8> {
|
||||
self.live
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |l| l.windows.iter().map(|w| w.number).collect())
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SIZE: (f32, f32) = (427.0, 300.0);
|
||||
|
||||
fn screen(players: usize) -> SplitUi {
|
||||
SplitUi {
|
||||
live: Some(Draft::everyone_here(
|
||||
players,
|
||||
Placement::Free(Some(([0, 0], [2000, 1200]))),
|
||||
0,
|
||||
0,
|
||||
)),
|
||||
monitors: vec![
|
||||
Monitor {
|
||||
name: "Built-in".into(),
|
||||
at: [0, 0],
|
||||
size: [3024, 1964],
|
||||
scale: 2.0,
|
||||
},
|
||||
Monitor {
|
||||
name: "TV".into(),
|
||||
at: [3024, 0],
|
||||
size: [1920, 1080],
|
||||
scale: 1.0,
|
||||
},
|
||||
],
|
||||
main: "Built-in".into(),
|
||||
names: (1..=players).map(|n| format!("P{n}")).collect(),
|
||||
on: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn labels(ui: &SplitUi) -> Vec<String> {
|
||||
ui.buttons(SIZE.0, SIZE.1)
|
||||
.into_iter()
|
||||
.map(|b| b.1)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_only_the_switch_and_done() {
|
||||
let mut ui = screen(2);
|
||||
ui.on = false;
|
||||
assert_eq!(labels(&ui), ["Splitscreen: Off", "Done"]);
|
||||
let r = rect(&ui, "Splitscreen: Off");
|
||||
assert!(matches!(
|
||||
ui.click((r[0] + 1.0, r[1] + 1.0), SIZE, false),
|
||||
Click::Switch
|
||||
));
|
||||
}
|
||||
|
||||
fn rect(ui: &SplitUi, label: &str) -> [f32; 4] {
|
||||
ui.buttons(SIZE.0, SIZE.1)
|
||||
.into_iter()
|
||||
.find(|(_, l, _, _)| l == label)
|
||||
.unwrap_or_else(|| panic!("no {label}"))
|
||||
.0
|
||||
}
|
||||
|
||||
fn press(ui: &mut SplitUi, label: &str) {
|
||||
let r = rect(ui, label);
|
||||
ui.click((r[0] + 1.0, r[1] + 1.0), SIZE, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_a_player_then_apply() {
|
||||
let mut ui = screen(2);
|
||||
press(&mut ui, "P2");
|
||||
press(&mut ui, "Fill Monitor 2");
|
||||
// Nothing applied yet: the draft only.
|
||||
assert_eq!(ui.apply, None);
|
||||
assert_eq!(ui.live.as_ref().unwrap().players, [1, 1]);
|
||||
press(&mut ui, "Apply");
|
||||
let applied = ui.apply.take().unwrap();
|
||||
assert_eq!(applied.players, [1, 2]);
|
||||
assert_eq!(applied.windows[1].placement, Placement::Fill("TV".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_drops_the_draft() {
|
||||
let mut ui = screen(2);
|
||||
press(&mut ui, "P2");
|
||||
press(&mut ui, "New window");
|
||||
assert!(ui.edit.is_some());
|
||||
press(&mut ui, "Revert");
|
||||
assert!(ui.edit.is_none());
|
||||
// Done closes and forgets the selection.
|
||||
press(&mut ui, "Done");
|
||||
assert_eq!(ui.selected, None);
|
||||
}
|
||||
#[test]
|
||||
fn locale_switch_and_pack_overrides_keep_the_same_draft() {
|
||||
// The language table is process-wide. Run this test alone in a child so
|
||||
// other UI tests cannot observe its temporary French language.
|
||||
const CHILD: &str = "HORIZON_TEST_SPLIT_SETUP_LOCALE";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
let module = module_path!().split_once("::").unwrap().1;
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
&format!("{module}::locale_switch_and_pack_overrides_keep_the_same_draft"),
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stdout).contains("1 passed;"),
|
||||
"locale child test did not run: {}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
struct Pack;
|
||||
impl lang::ResourceSource for Pack {
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
vec!["horizon".into()]
|
||||
}
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
(path == "assets/horizon/lang/fr_fr.json").then(|| {
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"horizon.splitscreen.setup.new_window": "Créer une fenêtre",
|
||||
"horizon.splitscreen.setup.monitor": "Écran personnalisé %s",
|
||||
"horizon.splitscreen.setup.fill_monitor": "Occuper %s",
|
||||
"horizon.splitscreen.setup.warning.empty_main": "Fenêtre principale masquée"
|
||||
}))
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut ui = screen(2);
|
||||
ui.names[1] = "Élodie %s".into();
|
||||
lang::set(lang::Language::load(&[], "en_us"));
|
||||
press(&mut ui, "Élodie %s");
|
||||
assert!(labels(&ui).contains(&"New window".to_owned()));
|
||||
lang::set(lang::Language::load(&[], "fr_fr"));
|
||||
assert!(labels(&ui).contains(&"Nouvelle fenêtre".to_owned()));
|
||||
assert!(labels(&ui).contains(&"Appliquer".to_owned()));
|
||||
assert!(
|
||||
labels(&ui).contains(&"Élodie %s".to_owned()),
|
||||
"player names remain literal"
|
||||
);
|
||||
press(&mut ui, "Nouvelle fenêtre");
|
||||
let draft = ui.edit.clone().unwrap();
|
||||
assert_eq!(draft.players, [1, 2]);
|
||||
lang::set(lang::Language::load(&[&Pack], "fr_fr"));
|
||||
let translated = labels(&ui);
|
||||
assert!(translated.contains(&"Créer une fenêtre".to_owned()));
|
||||
assert!(translated.contains(&"Occuper Écran personnalisé 2".to_owned()));
|
||||
assert_eq!(
|
||||
ui.edit,
|
||||
Some(draft),
|
||||
"a language change must not reset the arrangement"
|
||||
);
|
||||
ui.place(0, Spot::Window(2));
|
||||
assert!(
|
||||
ui.warnings()
|
||||
.contains(&"Fenêtre principale masquée".to_owned())
|
||||
);
|
||||
press(&mut ui, "Appliquer");
|
||||
assert_eq!(ui.apply.take().unwrap().players, [2, 2]);
|
||||
ui.on = false;
|
||||
assert_eq!(
|
||||
ui.status(),
|
||||
"off",
|
||||
"agent protocol states stay language independent"
|
||||
);
|
||||
assert_eq!(
|
||||
ui.status_lines().unwrap()[0],
|
||||
"L'écran partagé est désactivé."
|
||||
);
|
||||
assert!(labels(&ui).contains(&"Écran partagé : Désactivé".to_owned()));
|
||||
lang::set(lang::Language::load(&[], "de_de"));
|
||||
assert_eq!(
|
||||
ui.status_lines().unwrap()[0],
|
||||
"Splitscreen is off.",
|
||||
"untranslated new keys use bundled English"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -32,6 +32,17 @@ impl DirtySections {
|
||||
pub fn take_column(&mut self, x: i32, z: i32) -> SectionMask {
|
||||
self.columns.remove(&(x, z)).unwrap_or_default()
|
||||
}
|
||||
/// Section indexes `sections` of column (x, z), no longer dirty.
|
||||
pub fn take(&mut self, x: i32, z: i32, sections: SectionMask) {
|
||||
if let Some(mask) = self.columns.get_mut(&(x, z)) {
|
||||
for sy in sections.iter() {
|
||||
mask.remove(sy);
|
||||
}
|
||||
if mask.is_empty() {
|
||||
self.columns.remove(&(x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Keep the columns `keep` accepts.
|
||||
pub fn retain_columns(&mut self, mut keep: impl FnMut(i32, i32) -> bool) {
|
||||
self.columns.retain(|&(x, z), _| keep(x, z));
|
||||
|
||||
@@ -35,16 +35,16 @@
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.pages.split_settings": "Splitscreen Settings",
|
||||
"horizon.pages.split_setup": "Splitscreen",
|
||||
"horizon.pages.split_profiles": "Who's playing?",
|
||||
"horizon.pages.split_setup": "Splitscreen Setup",
|
||||
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
|
||||
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
|
||||
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
"horizon.pages.split_setup.tooltip": "Players on this computer: controllers can join your world.",
|
||||
"horizon.pages.split_profiles.tooltip": "Who plays: a profile kept in this game folder.",
|
||||
"horizon.pages.split_setup.tooltip": "The windows and monitors, and who plays where.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
|
||||
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
|
||||
"horizon.pages.open.graphics": "Graphics...",
|
||||
@@ -306,6 +306,35 @@
|
||||
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
|
||||
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
|
||||
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.",
|
||||
"horizon.splitscreen.setup.monitor": "Monitor %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "a monitor",
|
||||
"horizon.splitscreen.setup.window": "Window %s",
|
||||
"horizon.splitscreen.setup.new_window": "New window",
|
||||
"horizon.splitscreen.setup.fill_monitor": "Fill %s",
|
||||
"horizon.splitscreen.setup.done": "Done",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "On",
|
||||
"horizon.splitscreen.setup.off": "Off",
|
||||
"horizon.splitscreen.setup.player": "Player %s",
|
||||
"horizon.splitscreen.setup.revert": "Revert",
|
||||
"horizon.splitscreen.setup.apply": "Apply",
|
||||
"horizon.splitscreen.setup.warning.empty_main": "Nobody plays in Window 1: it hides",
|
||||
"horizon.splitscreen.setup.warning.empty_close": "Nobody plays in Window %s: it closes on Apply",
|
||||
"horizon.splitscreen.setup.warning.empty_new": "Nobody plays in Window %s: it won't open",
|
||||
"horizon.splitscreen.setup.warning.on_main": "Window %s fills Window 1's monitor: its players play in Window 1",
|
||||
"horizon.splitscreen.setup.warning.unplugged": "Window %s's monitor is not connected: its players play in Window 1",
|
||||
"horizon.splitscreen.setup.status.off": "Splitscreen is off.",
|
||||
"horizon.splitscreen.setup.status.enable_hint": "Turn it on, then press a button on another controller in a world.",
|
||||
"horizon.splitscreen.setup.status.join_hint": "Press A to join and pick a profile.",
|
||||
"horizon.splitscreen.setup.status.idle": "Splitscreen idle",
|
||||
"horizon.splitscreen.setup.status.alone": "No other local players.",
|
||||
"horizon.splitscreen.setup.status.solo_controls": "Solo controls and controller feedback are unchanged.",
|
||||
"horizon.splitscreen.setup.window_empty": "Window %s: empty",
|
||||
"horizon.splitscreen.setup.note.select": "Click a player to choose its window.",
|
||||
"horizon.splitscreen.setup.note.apply": "Apply to play this way, Revert to undo.",
|
||||
"horizon.splitscreen.setup.note.place": "Where should %s play?",
|
||||
"horizon.splitscreen.setup.this_player": "this player",
|
||||
"horizon.splitscreen.setup.switch_disabled": "Splitscreen turns off once the others have left (their pause menu).",
|
||||
"Culling": "Culling",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
|
||||
"Leaf Culling": "Leaf Culling",
|
||||
|
||||
@@ -289,6 +289,40 @@
|
||||
"horizon.multiplayer.invalid_address": "Adresse de serveur invalide : %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connecté directement à votre ami...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais...",
|
||||
"horizon.splitscreen.setup.monitor": "Écran %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "un écran",
|
||||
"horizon.splitscreen.setup.window": "Fenêtre %s",
|
||||
"horizon.splitscreen.setup.new_window": "Nouvelle fenêtre",
|
||||
"horizon.splitscreen.setup.fill_monitor": "Remplir %s",
|
||||
"horizon.splitscreen.setup.done": "Terminé",
|
||||
"horizon.splitscreen.setup.switch": "%s : %s",
|
||||
"horizon.splitscreen.setup.on": "Activé",
|
||||
"horizon.splitscreen.setup.off": "Désactivé",
|
||||
"horizon.splitscreen.setup.player": "Joueur %s",
|
||||
"horizon.splitscreen.setup.revert": "Rétablir",
|
||||
"horizon.splitscreen.setup.apply": "Appliquer",
|
||||
"horizon.splitscreen.setup.warning.empty_main": "La fenêtre 1 se masque si personne n'y joue.",
|
||||
"horizon.splitscreen.setup.warning.empty_close": "Personne ne joue dans la fenêtre %s : elle se fermera après application.",
|
||||
"horizon.splitscreen.setup.warning.empty_new": "Personne ne joue dans la fenêtre %s : elle ne s'ouvrira pas.",
|
||||
"horizon.splitscreen.setup.warning.on_main": "La fenêtre %s occupe l'écran de la fenêtre 1 : ses joueurs restent dans la fenêtre 1.",
|
||||
"horizon.splitscreen.setup.warning.unplugged": "L'écran de la fenêtre %s est débranché : ses joueurs restent dans la fenêtre 1.",
|
||||
"horizon.splitscreen.setup.status.off": "L'écran partagé est désactivé.",
|
||||
"horizon.splitscreen.setup.status.enable_hint": "Activez-le, puis appuyez sur un bouton d'une autre manette dans un monde.",
|
||||
"horizon.splitscreen.setup.status.join_hint": "Appuyez sur A pour rejoindre et choisir un profil.",
|
||||
"horizon.splitscreen.setup.status.idle": "Écran partagé en attente",
|
||||
"horizon.splitscreen.setup.status.alone": "Aucun autre joueur local.",
|
||||
"horizon.splitscreen.setup.status.solo_controls": "Les commandes et les vibrations en solo restent inchangées.",
|
||||
"horizon.splitscreen.setup.window_empty": "Fenêtre %s : vide",
|
||||
"horizon.splitscreen.setup.note.select": "Cliquez sur un joueur pour choisir sa fenêtre.",
|
||||
"horizon.splitscreen.setup.note.apply": "Appliquez les modifications ou annulez-les.",
|
||||
"horizon.splitscreen.setup.note.place": "Où %s doit-il jouer ?",
|
||||
"horizon.splitscreen.setup.this_player": "ce joueur",
|
||||
"horizon.splitscreen.setup.switch_disabled": "L'écran partagé peut être désactivé après le départ des autres joueurs depuis leur menu pause.",
|
||||
"horizon.pages.split_setup": "Configuration de l'écran partagé",
|
||||
"horizon.pages.split_setup.tooltip": "Les fenêtres, les écrans et la place de chaque joueur.",
|
||||
"horizon.pages.open.split_setup": "Écran partagé...",
|
||||
"horizon.pages.split_settings": "Options de l'écran partagé",
|
||||
"horizon.extras.splitscreen": "Écran partagé",
|
||||
"Culling": "Occlusion",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
|
||||
"Leaf Culling": "Occlusion feuillage",
|
||||
@@ -329,12 +363,7 @@
|
||||
"horizon.extras.header.sound": "Son",
|
||||
"horizon.extras.hide_block_particles": "Masquer particules",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Masque les débris qui jaillissent d'un bloc cassé et s'effritent de celui que vous minez. Les autres particules restent.",
|
||||
"horizon.extras.splitscreen": "Écran partagé",
|
||||
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
|
||||
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu.",
|
||||
"horizon.pages.open.split_setup": "Écran partagé...",
|
||||
"horizon.pages.split_settings": "Options de l'écran partagé",
|
||||
"horizon.pages.split_setup": "Écran partagé",
|
||||
"horizon.pages.split_setup.tooltip": "Joueurs sur cet ordinateur : des manettes peuvent rejoindre votre monde."
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu."
|
||||
}
|
||||
@@ -24,7 +24,15 @@ fn main() {
|
||||
resource.set_manifest_file(&manifest);
|
||||
}
|
||||
if let Err(e) = resource.compile() {
|
||||
panic!("embedding Windows resources: {e}");
|
||||
// The GNU toolchain's `windres` (the launcher's mod builds use it) can't take a path
|
||||
// with a space, and a Windows user name often has one: such a build goes on without
|
||||
// the icon. Release builds run in CI, where the icon stays required.
|
||||
if std::env::var_os("CI").is_some() {
|
||||
panic!("embedding Windows resources: {e}");
|
||||
}
|
||||
println!(
|
||||
"cargo:warning=building without Horizon's icon: embedding Windows resources failed: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,9 @@ fn main() -> Result<(), Error> {
|
||||
});
|
||||
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
|
||||
|
||||
// Generation and light, on the game's worker pool. Meshing is left out
|
||||
// here and measured on its own below.
|
||||
// Generation and light, on the game's worker pool (`threads` workers).
|
||||
// Meshing is left out here and measured on its own below.
|
||||
horizon_threads::jobs::configure(threads);
|
||||
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
|
||||
@@ -116,6 +116,39 @@ impl Control {
|
||||
}
|
||||
}
|
||||
|
||||
/// The wait before a scene's flight, spent timing Horizon's own render passes on the GPU:
|
||||
/// `gpu_pass.<scene>.<pass>_ms`, where a slow GPU spends its frame. The timing is off again for
|
||||
/// the flight, whose frames it would slow. False, having waited for nothing, on Metal: its passes
|
||||
/// overlap on the GPU, so their times add up to more than the frame.
|
||||
fn time_passes(control: &Control, scene: &str, report: &mut Report) -> Result<bool, String> {
|
||||
if report
|
||||
.hardware
|
||||
.gpu
|
||||
.as_ref()
|
||||
.is_some_and(|g| g.backend == "metal")
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
control.call("gpu-passes on")?;
|
||||
let held = control.call(&format!("bench {}", SETTLE.as_secs_f64()));
|
||||
control.call("gpu-passes off")?;
|
||||
for (pass, ms) in held?["gpu_passes"].as_object().into_iter().flatten() {
|
||||
let pass: String = pass
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'a'..='z' | '0'..='9' => c,
|
||||
'A'..='Z' => c.to_ascii_lowercase(),
|
||||
_ => '_',
|
||||
})
|
||||
.collect();
|
||||
if let Some(ms) = ms.as_f64() {
|
||||
report.metric(&format!("gpu_pass.{scene}.{pass}_ms"), &[ms]);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
let scenes =
|
||||
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
|
||||
@@ -204,7 +237,9 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
&[chunks / load.as_secs_f64()],
|
||||
);
|
||||
}
|
||||
thread::sleep(SETTLE);
|
||||
if !time_passes(control, name, &mut report)? {
|
||||
thread::sleep(SETTLE);
|
||||
}
|
||||
let [ex, ey, ez] = end;
|
||||
let begin = Instant::now();
|
||||
let flight = control.call(&format!(
|
||||
@@ -499,6 +534,60 @@ mod tests {
|
||||
let _ = server.join();
|
||||
}
|
||||
|
||||
/// The wait before a flight times the passes and turns the timing off again before the
|
||||
/// flight; the report gets one metric per pass. Not on Metal.
|
||||
#[test]
|
||||
fn the_wait_times_the_passes_but_not_the_flight() {
|
||||
let time_on = |backend: &str| {
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let server = thread::spawn(move || {
|
||||
let mut lines = Vec::new();
|
||||
while let Ok(request) = rx.recv() {
|
||||
let reply = if request.line.starts_with("bench ") {
|
||||
serde_json::json!({
|
||||
"frames": 120,
|
||||
"gpu_passes": {"World": 1.5, "World after culling": 0.25, "HUD": 0.01},
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "ok": true })
|
||||
};
|
||||
let _ = request.reply.send(reply.to_string());
|
||||
lines.push(request.line);
|
||||
}
|
||||
lines
|
||||
});
|
||||
let hardware = horizon_bench::Hardware {
|
||||
gpu: Some(horizon_bench::Gpu {
|
||||
backend: backend.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mut report = Report::with_hardware(Kind::Game, App::default(), hardware);
|
||||
let control = Control(tx);
|
||||
let timed = time_passes(&control, "forest", &mut report).expect("the wait runs");
|
||||
drop(control);
|
||||
(timed, server.join().unwrap(), report.metrics)
|
||||
};
|
||||
|
||||
let (timed, lines, metrics) = time_on("vulkan");
|
||||
assert!(timed);
|
||||
assert_eq!(lines, ["gpu-passes on", "bench 2", "gpu-passes off"]);
|
||||
let mean = |name: &str| metrics.get(name).map(|s| s.mean);
|
||||
assert_eq!(mean("gpu_pass.forest.world_ms"), Some(1.5));
|
||||
assert_eq!(mean("gpu_pass.forest.world_after_culling_ms"), Some(0.25));
|
||||
assert_eq!(mean("gpu_pass.forest.hud_ms"), Some(0.01));
|
||||
assert_eq!(metrics.len(), 3);
|
||||
|
||||
let (timed, lines, metrics) = time_on("metal");
|
||||
assert!(!timed);
|
||||
assert!(
|
||||
lines.is_empty(),
|
||||
"Metal's overlapping passes are not timed: {lines:?}"
|
||||
);
|
||||
assert!(metrics.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaw_matches_vanilla() {
|
||||
// South (+Z) is 0, west (-X) 90, east (+X) -90.
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn native_living_breathe(
|
||||
consume: &mut i32,
|
||||
refill: &mut i32,
|
||||
) {
|
||||
let _ = (entity, kind, can_breathe, consume, refill);
|
||||
crate::native_client_hooks::living_breathe(entity, kind, can_breathe, consume, refill);
|
||||
}
|
||||
|
||||
/// `vanilla`'s breathing after the mods have had their say.
|
||||
|
||||
@@ -18,9 +18,9 @@ pub use horizon_client_render::{
|
||||
native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par,
|
||||
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
|
||||
screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins,
|
||||
sound_engine, sound_events, sound_preview, spans, split_layout, split_profiles, sprites,
|
||||
streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen,
|
||||
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
|
||||
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
|
||||
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
@@ -89,6 +89,7 @@ pub mod mod_content;
|
||||
pub mod motion;
|
||||
pub mod multiplayer;
|
||||
pub mod music;
|
||||
pub mod native_client_hooks;
|
||||
pub mod native_data;
|
||||
pub mod net;
|
||||
pub mod nether_portal;
|
||||
|
||||
@@ -33,7 +33,7 @@ pub fn native_ingredient(
|
||||
data: &serde_json::Map<String, Value>,
|
||||
items: &mut Vec<String>,
|
||||
) {
|
||||
let _ = (kind, data, items);
|
||||
crate::native_client_hooks::ingredient(kind, data, items);
|
||||
}
|
||||
|
||||
/// Keep first-seen order, drop the rest.
|
||||
|
||||
@@ -74,12 +74,59 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
components,
|
||||
);
|
||||
},
|
||||
loot_function: |kind, data, ctx, stack| {
|
||||
crate::mod_api::dispatch_loot_function(
|
||||
crate::mod_api::mods(),
|
||||
kind,
|
||||
data,
|
||||
ctx,
|
||||
stack,
|
||||
);
|
||||
},
|
||||
loot_condition: |kind, data, ctx, holds| {
|
||||
crate::mod_api::dispatch_loot_condition(
|
||||
crate::mod_api::mods(),
|
||||
kind,
|
||||
data,
|
||||
ctx,
|
||||
holds,
|
||||
);
|
||||
},
|
||||
},
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
|
||||
}
|
||||
if crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
|
||||
menu_slot_rules: |menu, slot, stack, may_place, max_stack| {
|
||||
crate::mod_api::dispatch_menu_slot_rules(
|
||||
crate::mod_api::mods(),
|
||||
menu,
|
||||
slot,
|
||||
stack,
|
||||
may_place,
|
||||
max_stack,
|
||||
);
|
||||
},
|
||||
living_breathe: |entity, kind, can_breathe, consume, refill| {
|
||||
crate::mod_api::dispatch_living_breathe(
|
||||
crate::mod_api::mods(),
|
||||
entity,
|
||||
kind,
|
||||
can_breathe,
|
||||
consume,
|
||||
refill,
|
||||
);
|
||||
},
|
||||
ingredient: |kind, data, items| {
|
||||
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
|
||||
},
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("Native client hooks already installed; keeping the first callbacks");
|
||||
}
|
||||
raise_open_file_limit();
|
||||
if let Err(error) = game::run_with() {
|
||||
// A bad command line is the caller's mistake, not a crash: no report.
|
||||
|
||||
@@ -183,6 +183,7 @@ mod maps;
|
||||
mod minecarts;
|
||||
mod mob_blocks;
|
||||
mod mob_gear;
|
||||
mod mob_index;
|
||||
mod mob_looting;
|
||||
mod mob_sounds;
|
||||
mod mod_api_host;
|
||||
@@ -385,6 +386,16 @@ use horizon_world::{
|
||||
},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
pub use maps::{NativeMapDecoration, NativeMapInfo};
|
||||
pub use native_block_entity_draw::BlockEntityDraw;
|
||||
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
|
||||
pub use native_data_maps::NativeDataMapType;
|
||||
pub use native_falling::NativeFalling;
|
||||
pub use native_hooks::{NativeClock, NativeShake};
|
||||
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
|
||||
pub use native_server::NativeExperienceOrb;
|
||||
pub use native_world_columns::NativeColumnBlocks;
|
||||
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -2289,6 +2300,8 @@ struct Game<'a> {
|
||||
regrab_on_refocus: bool,
|
||||
/// Only the local player owning keyboard and mouse changes the native pointer.
|
||||
keyboard_mouse: bool,
|
||||
/// Offscreen rendering into a visible splitscreen window still accepts native input.
|
||||
split_window: bool,
|
||||
/// This view's corner in its shared window; cursor warps include the offset.
|
||||
view_at: [u32; 2],
|
||||
/// Splitscreen: what it draws over this player's view (its badge, questions, notices).
|
||||
@@ -2343,6 +2356,10 @@ struct Game<'a> {
|
||||
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
|
||||
/// Which entity this frame's entity vertices belong to (`Frame::entity_motion`).
|
||||
entity_motion: std::cell::RefCell<Vec<(u32, u64)>>,
|
||||
/// Mob models posed on the GPU (`Frame::skin`, `mobs::skin`): the models stored there,
|
||||
/// and this frame's draws.
|
||||
skin_models: std::cell::RefCell<horizon_client::mobs::skin::SkinModels>,
|
||||
skin_frame: std::cell::RefCell<horizon_client::gfx::entity_skin::SkinFrame>,
|
||||
/// The glowing entities' silhouettes this frame (game/glowing.rs).
|
||||
glowing_vertices: std::cell::RefCell<Vec<Vertex>>,
|
||||
/// The shader pack's entity and block entity ids, while one draws entities.
|
||||
@@ -2439,6 +2456,8 @@ struct Game<'a> {
|
||||
/// player's own (taken by `update_world`).
|
||||
spawn_wait: Vec<(i32, i32)>,
|
||||
mobs: Vec<Mob>,
|
||||
/// `mobs` by network id, for the packets that name one (`game/mob_index.rs`).
|
||||
mob_index: mob_index::MobIndex,
|
||||
creatures: creatures::Creatures,
|
||||
sculk: sculk::SculkState,
|
||||
raiding: raids::Raiding,
|
||||
@@ -2577,6 +2596,9 @@ struct Game<'a> {
|
||||
server_trips: Vec<server_travel::Trip>,
|
||||
/// Players a command sends to another level, once it has run.
|
||||
level_teleports: Vec<server_travel::LevelTeleport>,
|
||||
/// Teleports a command made inside a level, for `native_player_teleported`
|
||||
/// once the player's move has reached their client (`leave_guest`).
|
||||
teleport_notes: Vec<server_travel::TeleportNote>,
|
||||
/// A server's levels other than the one in place (game/levels.rs).
|
||||
levels: levels::Levels,
|
||||
/// Which pass of a server tick runs (`tick_server`): the first one reads
|
||||
@@ -2642,6 +2664,8 @@ struct Game<'a> {
|
||||
/// Models drawn only once such an entity shows up (lead knots, wind charges...).
|
||||
entity_models: EntityModels,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
/// The boats and minecarts `native_vehicle_moved` follows (lan_vehicles.rs).
|
||||
watched_vehicles: Vec<lan_vehicles::WatchedVehicle>,
|
||||
/// Songs playing from jukeboxes (tracked sound handles).
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
bolts: Vec<lightning::Bolt>,
|
||||
@@ -2913,6 +2937,9 @@ struct Game<'a> {
|
||||
lan_acting_name: Option<String>,
|
||||
/// Their UUID (`Player.getUUID`).
|
||||
lan_acting_uuid: Option<u128>,
|
||||
/// Where the mods refused to prime TNT: the sound and game event the level
|
||||
/// queued right after the prime are dropped (`prime_tnt_event`).
|
||||
tnt_refused: Option<BlockPos>,
|
||||
/// Their permission level: an op's above 0 (`PlayerList.isOp`).
|
||||
lan_acting_permission: Option<u8>,
|
||||
/// Their entity id, which the hub (often taken out of `lan` while they
|
||||
@@ -5012,6 +5039,15 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A prime the mods refused is silent: no fuse sound, no game event.
|
||||
LevelEvent::Sound { pos, ref sound, .. }
|
||||
if self.tnt_refused == Some(pos)
|
||||
&& &**sound == "minecraft:entity.tnt.primed" => {}
|
||||
LevelEvent::GameEvent {
|
||||
pos,
|
||||
event: "minecraft:prime_fuse",
|
||||
..
|
||||
} if self.tnt_refused == Some(pos) => self.tnt_refused = None,
|
||||
LevelEvent::Sound {
|
||||
pos,
|
||||
sound,
|
||||
@@ -5101,7 +5137,7 @@ impl Game<'_> {
|
||||
}
|
||||
PlayerEffect::InteractLoot(_) => {}
|
||||
},
|
||||
LevelEvent::PrimeTnt { pos, rot } => self.add_primed_tnt(pos, rot, 80, None, None),
|
||||
LevelEvent::PrimeTnt { pos, rot } => self.prime_tnt_event(pos, rot),
|
||||
LevelEvent::BlockEvent {
|
||||
pos,
|
||||
state,
|
||||
@@ -6417,6 +6453,22 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// Asks the mods whether the acting guest may empty `stack` at `pos`
|
||||
/// (`native_bucket_empty`): true when one cancelled it. A game's own
|
||||
/// player is not asked.
|
||||
fn bucket_empty_cancelled(&mut self, stack: &ItemStack, pos: [i32; 3]) -> bool {
|
||||
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let fluid = match &*stack.item {
|
||||
"minecraft:lava_bucket" => "minecraft:lava",
|
||||
"minecraft:powder_snow_bucket" => "minecraft:powder_snow",
|
||||
_ => "minecraft:water",
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_bucket_empty(uuid, &name, pos, fluid, &mut cancel);
|
||||
cancel
|
||||
}
|
||||
/// The player as a block's `useItemOn` / `useWithoutItem` sees them.
|
||||
fn use_context(&self) -> PlayerContext {
|
||||
PlayerContext {
|
||||
@@ -7542,6 +7594,7 @@ impl Game<'_> {
|
||||
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
|
||||
horizon_client::spans::time("tick_boats", || self.tick_boats());
|
||||
horizon_client::spans::time("tick_carts", || self.tick_carts());
|
||||
self.tick_vehicle_watch();
|
||||
horizon_client::spans::time("tick_thunder", || self.tick_thunder());
|
||||
horizon_client::spans::time("tick_lecterns", || self.tick_lecterns());
|
||||
horizon_client::spans::time("tick_maps", || self.tick_maps());
|
||||
@@ -7964,9 +8017,9 @@ impl Game<'_> {
|
||||
};
|
||||
let scale = HudBatch::new(size).scale;
|
||||
let was_grabbed = self.captured;
|
||||
if self.options.offscreen {
|
||||
// `--offscreen` shows no window: the system pointer stays where the
|
||||
// person at the computer has it, and the grab is the game's own (an
|
||||
if self.options.offscreen && !self.split_window {
|
||||
// A genuinely hidden `--offscreen` run leaves the system pointer where
|
||||
// the person at the computer has it. Its grab is internal (an
|
||||
// agent turns the view with `mouse-move`).
|
||||
if capture {
|
||||
self.mouse.set_ignore_first_move();
|
||||
@@ -8027,7 +8080,7 @@ impl Game<'_> {
|
||||
) {
|
||||
self.cursor = pos;
|
||||
}
|
||||
if !self.options.offscreen {
|
||||
if !self.options.offscreen || self.split_window {
|
||||
window.set_cursor_visible(!self.captured);
|
||||
}
|
||||
}
|
||||
@@ -13064,7 +13117,7 @@ impl Game<'_> {
|
||||
let target = self.bucket_target(content, &hit);
|
||||
let mut canceled = false;
|
||||
self.native_fill_bucket(stack, target, &mut canceled);
|
||||
if canceled {
|
||||
if canceled || self.bucket_empty_cancelled(stack, target) {
|
||||
return Ok(UseResult::Fail);
|
||||
}
|
||||
let pos = BlockPos::new(target[0], target[1], target[2]);
|
||||
@@ -15548,6 +15601,7 @@ impl Game<'_> {
|
||||
let entity_motion = std::mem::take(self.entity_motion.get_mut());
|
||||
let glowing = std::mem::take(self.glowing_vertices.get_mut());
|
||||
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
|
||||
let skin = std::mem::take(self.skin_frame.get_mut());
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
|
||||
@@ -15631,6 +15685,7 @@ impl Game<'_> {
|
||||
entities: &entities,
|
||||
entity_motion: &entity_motion,
|
||||
entity_spans: &entity_spans,
|
||||
skin: skin.pending().then_some(&skin),
|
||||
glowing: &glowing,
|
||||
dynamic_lights: &self.dynamic_lights,
|
||||
colored_lights: &colored_lights,
|
||||
@@ -15711,6 +15766,18 @@ impl Game<'_> {
|
||||
*self.entity_motion.get_mut() = entity_motion;
|
||||
*self.glowing_vertices.get_mut() = glowing;
|
||||
*self.shadow_only_vertices.get_mut() = shadow_only;
|
||||
let mut skin = skin;
|
||||
// The renderer took the new models when its store grew by them; otherwise they stay
|
||||
// pending, or the stores restart (`SkinModels::begin`).
|
||||
if self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.and_then(|r| r.skinned_models())
|
||||
.is_some_and(|stored| stored == skin.base + skin.new_model.len() as u32)
|
||||
{
|
||||
skin.handed();
|
||||
}
|
||||
*self.skin_frame.get_mut() = skin;
|
||||
self.see_through_vertices = see_through;
|
||||
result
|
||||
}
|
||||
@@ -16284,6 +16351,14 @@ impl Game<'_> {
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
);
|
||||
}
|
||||
// The sections the camera saw last frame mesh first, as vanilla compiles only those.
|
||||
self.streaming.view =
|
||||
self.view_frustum
|
||||
.zip(self.locator_camera)
|
||||
.map(|(frustum, camera)| horizon_client::streaming::CameraView {
|
||||
frustum,
|
||||
eye: camera.0,
|
||||
});
|
||||
// The loading screen's chunk map fills ring by ring, as vanilla's.
|
||||
self.streaming.loading =
|
||||
self.options.execution_role.is_client() && self.loading_reason().is_some();
|
||||
@@ -16550,11 +16625,13 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
}
|
||||
fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
|
||||
// The real mouse never steers an offscreen game: its pointer is not grabbed.
|
||||
// A composited view renders offscreen but its keyboard/mouse owner still
|
||||
// controls a visible native window. A headless offscreen run never does.
|
||||
if self.pending_save.is_none()
|
||||
&& self.captured
|
||||
&& self.focused
|
||||
&& !self.options.offscreen
|
||||
&& self.keyboard_mouse
|
||||
&& (!self.options.offscreen || self.split_window)
|
||||
&& self.screen == Screen::Playing
|
||||
&& let DeviceEvent::MouseMotion { delta: (x, y) } = event
|
||||
{
|
||||
@@ -18233,6 +18310,7 @@ impl<'a> Game<'a> {
|
||||
captured: false,
|
||||
regrab_on_refocus: false,
|
||||
keyboard_mouse: true,
|
||||
split_window: false,
|
||||
view_at: [0, 0],
|
||||
split_hud: Default::default(),
|
||||
focused: true,
|
||||
@@ -18261,6 +18339,8 @@ impl<'a> Game<'a> {
|
||||
shadow_only_vertices: Default::default(),
|
||||
entity_spans: Default::default(),
|
||||
entity_motion: Default::default(),
|
||||
skin_models: Default::default(),
|
||||
skin_frame: Default::default(),
|
||||
glowing_vertices: Default::default(),
|
||||
pack_ids: None,
|
||||
health_hud: hud_layout::HealthHud::default(),
|
||||
@@ -18322,6 +18402,7 @@ impl<'a> Game<'a> {
|
||||
player_spawn: None,
|
||||
spawn_wait: Vec::new(),
|
||||
mobs: Vec::new(),
|
||||
mob_index: Default::default(),
|
||||
creatures: Default::default(),
|
||||
sculk: Default::default(),
|
||||
raiding: Default::default(),
|
||||
@@ -18394,6 +18475,7 @@ impl<'a> Game<'a> {
|
||||
travelling: None,
|
||||
server_trips: Vec::new(),
|
||||
level_teleports: Vec::new(),
|
||||
teleport_notes: Vec::new(),
|
||||
levels: Default::default(),
|
||||
level_pass: Default::default(),
|
||||
portals: Default::default(),
|
||||
@@ -18434,6 +18516,7 @@ impl<'a> Game<'a> {
|
||||
cushion_model: prepared.cushion_model,
|
||||
entity_models: prepared.entity_models,
|
||||
carts: Vec::new(),
|
||||
watched_vehicles: Vec::new(),
|
||||
jukebox_sounds: HashMap::new(),
|
||||
bolts: Vec::new(),
|
||||
maps: maps::Maps::default(),
|
||||
@@ -18588,6 +18671,7 @@ impl<'a> Game<'a> {
|
||||
guest_player_sounds: Vec::new(),
|
||||
lan_acting_name: None,
|
||||
lan_acting_uuid: None,
|
||||
tnt_refused: None,
|
||||
lan_acting_permission: None,
|
||||
lan_acting_entity: None,
|
||||
chat_capture: None,
|
||||
@@ -19371,6 +19455,9 @@ fn load_resources(
|
||||
publish: &mut dyn FnMut(UiResources, &std::path::Path),
|
||||
) -> Result<Loaded> {
|
||||
let threads = settings.horizon.worker_threads();
|
||||
// One pool of workers for every level's streaming, the client's and the
|
||||
// server thread's (horizon_threads::jobs), sized before any world opens.
|
||||
horizon_threads::jobs::configure(threads);
|
||||
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
|
||||
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_native_pack(settings));
|
||||
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
|
||||
@@ -19953,6 +20040,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
entities: &[],
|
||||
entity_motion: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
colored_lights: &[],
|
||||
@@ -20689,9 +20777,8 @@ pub fn run_with() -> Result<()> {
|
||||
horizon_threads::put_helpers_behind();
|
||||
if options.execution_role.is_client() {
|
||||
// The render thread, like the server thread, ahead of the workers on
|
||||
// macOS and Windows. While terrain loads, the worker pools (two with
|
||||
// single player's server thread) and other programs outnumber the
|
||||
// cores, and a frame that gave its core up (in a GPU submit) waited
|
||||
// macOS and Windows. While terrain loads, the workers
|
||||
// (horizon_threads::jobs) and other programs outnumber the cores, and a frame that gave its core up (in a GPU submit) waited
|
||||
// 10 to 200 ms to get one back.
|
||||
horizon_threads::favour_current();
|
||||
}
|
||||
|
||||
@@ -500,7 +500,7 @@ impl Game<'_> {
|
||||
json!({
|
||||
"dialog": self.dialog_status(),
|
||||
"screen": self.screen_label(),
|
||||
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
|
||||
"menu": (self.title.is_some() || self.split_profiles_open() || self.split_setup_open()).then(|| self.title_value()),
|
||||
"world": (self.title.is_none()).then(|| match &self.integrated {
|
||||
Some(server) => server.world_name.clone(),
|
||||
None => self.level.name.clone(),
|
||||
@@ -535,11 +535,18 @@ impl Game<'_> {
|
||||
self.screen == Screen::Options
|
||||
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
|
||||
}
|
||||
fn split_setup_open(&self) -> bool {
|
||||
self.screen == Screen::Options
|
||||
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitSetup
|
||||
}
|
||||
fn title_value(&self) -> Value {
|
||||
use horizon_client::title::Mode;
|
||||
if self.split_profiles_open() {
|
||||
return self.ui.options.split_profiles.state();
|
||||
}
|
||||
if self.split_setup_open() {
|
||||
return json!({ "mode": "splitscreen-setup", "status": self.ui.options.split_setup.status() });
|
||||
}
|
||||
let Some(title) = &self.title else {
|
||||
return Value::Null;
|
||||
};
|
||||
@@ -742,7 +749,7 @@ impl Game<'_> {
|
||||
v["chat_input"] = json!(chat.input.value);
|
||||
v["chat_suggestions"] = json!(chat.suggestions_visible());
|
||||
}
|
||||
if self.title.is_some() || self.split_profiles_open() {
|
||||
if self.title.is_some() || self.split_profiles_open() || self.split_setup_open() {
|
||||
v["menu"] = self.title_value();
|
||||
}
|
||||
if self.screen == Screen::Inventory && self.title.is_none() {
|
||||
@@ -1705,7 +1712,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
let chat = server
|
||||
.call(move |g| g.owner_console_command(&command))
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
return ok(json!({ "ok": true, "chat": chat }));
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
|
||||
@@ -600,6 +600,23 @@ impl Game<'_> {
|
||||
if held.is_none() && current.is_none() {
|
||||
return false;
|
||||
}
|
||||
// A server's mods hear the swap before it happens, and may stop it.
|
||||
// A stack of several on an occupied slot goes nowhere (below).
|
||||
let moves = held
|
||||
.as_ref()
|
||||
.is_none_or(|h| h.count <= 1 || current.is_none());
|
||||
if moves
|
||||
&& target.is_some()
|
||||
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
let stand = self.armor_stands[i].id as i32;
|
||||
let slot_name = ["mainhand", "offhand", "feet", "legs", "chest", "head"][slot];
|
||||
let mut cancel = false;
|
||||
self.native_armor_stand_manipulate(player, &name, stand, slot_name, &mut cancel);
|
||||
if cancel {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// ArmorStand.swapItem.
|
||||
match held {
|
||||
// A player with infinite materials puts a copy of one on an empty
|
||||
|
||||
@@ -76,7 +76,7 @@ impl Game<'_> {
|
||||
let words: Vec<&str> = owned.iter().map(String::as_str).collect();
|
||||
g.in_owner_level(|g| g.block_command(&words).map_err(|e| e.to_string()))
|
||||
})
|
||||
.ok_or("the integrated server stopped")?
|
||||
.ok_or(super::integrated::NO_ANSWER)?
|
||||
.map_err(Into::into);
|
||||
}
|
||||
// Another server's world changes only through its own commands, which
|
||||
|
||||
@@ -1483,6 +1483,10 @@ impl Game<'_> {
|
||||
if let Some(conn) = self.lan_acting {
|
||||
self.server_trips.retain(|trip| !trip.is(conn));
|
||||
}
|
||||
let from = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = v;
|
||||
p.velocity = Vec3::new(0.0, 0.0, 0.0);
|
||||
@@ -1494,6 +1498,24 @@ impl Game<'_> {
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch.clamp(-90.0, 90.0);
|
||||
}
|
||||
// A server player's teleport is told to the mods once their
|
||||
// move is in the hub (`leave_guest`).
|
||||
if let (Some(conn), Some(uuid), Some(name), Some(from)) = (
|
||||
self.lan_acting,
|
||||
self.lan_acting_uuid,
|
||||
self.lan_acting_name.clone(),
|
||||
from,
|
||||
) {
|
||||
self.teleport_notes
|
||||
.push(super::server_travel::TeleportNote {
|
||||
conn,
|
||||
uuid,
|
||||
name,
|
||||
from,
|
||||
to: pos,
|
||||
dimension: self.dim.id().to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
|
||||
@@ -394,7 +394,7 @@ impl Game<'_> {
|
||||
json!({ "game_time": g.world.game_time, "projectiles": g.projectiles_status() })
|
||||
})
|
||||
})
|
||||
.ok_or("the integrated server stopped")?
|
||||
.ok_or(super::integrated::NO_ANSWER)?
|
||||
} else {
|
||||
json!({ "game_time": self.world.game_time, "projectiles": self.projectiles_status() })
|
||||
};
|
||||
@@ -410,7 +410,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.agent.hold_server = held)
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
} else if self.remote.is_some() {
|
||||
return Err("server-hold requires the world's host".into());
|
||||
} else {
|
||||
@@ -674,7 +674,7 @@ impl Game<'_> {
|
||||
let command = format!("give @s {item} {count}");
|
||||
server
|
||||
.call(move |g| g.owner_console_command(&command))
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
return Ok(Some(json!({ "ok": true, "given": count })));
|
||||
}
|
||||
let max = info.max_stack;
|
||||
@@ -727,7 +727,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.world.day_time = time)
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
@@ -746,7 +746,7 @@ impl Game<'_> {
|
||||
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let value = server
|
||||
.call(move |g| g.in_owner_level(|g| g.control_mobs_at(radius, center)))
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
return Ok(Some(value));
|
||||
}
|
||||
Ok(Some(self.control_mobs(radius)))
|
||||
@@ -756,7 +756,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
let value = server
|
||||
.call(|g| g.control_hurt_log())
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
return Ok(Some(value));
|
||||
}
|
||||
Ok(Some(self.control_hurt_log()))
|
||||
@@ -986,7 +986,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.control_save(all).map_err(|e| e.to_string()))
|
||||
.ok_or("the integrated server stopped")??;
|
||||
.ok_or(super::integrated::NO_ANSWER)??;
|
||||
} else if self.remote.is_none() {
|
||||
self.begin_control_save(all, reply.clone())?;
|
||||
return Ok(None);
|
||||
|
||||
@@ -3897,9 +3897,11 @@ impl Game<'_> {
|
||||
ArrowEvent::BubbleColumnParticles { pos } => self.bubble_column_particles(pos),
|
||||
ArrowEvent::Block { pos, reaction } => match reaction {
|
||||
BlockReaction::PrimeTnt => {
|
||||
self.spawn_primed_tnt(pos, false, None, None);
|
||||
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
|
||||
let _ = self.process_level_events();
|
||||
if self.tnt_prime_allowed(pos) {
|
||||
self.spawn_primed_tnt(pos, false, None, None);
|
||||
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
}
|
||||
BlockReaction::Light(lit) => {
|
||||
let _ = self.level_set([pos.x, pos.y, pos.z], lit);
|
||||
@@ -6098,6 +6100,19 @@ impl Game<'_> {
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = self.view_scale();
|
||||
self.rendered_mobs.set(0);
|
||||
// Mob models posed on the GPU (mobs::skin), when the renderer takes them this frame.
|
||||
let skinning = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.filter(|r| r.skins_entities())
|
||||
.and_then(|r| r.skinned_models());
|
||||
match skinning {
|
||||
Some(stored) => self
|
||||
.skin_models
|
||||
.borrow_mut()
|
||||
.begin(&mut self.skin_frame.borrow_mut(), stored),
|
||||
None => self.skin_frame.borrow_mut().clear_draws(),
|
||||
}
|
||||
// What custom entity model animations read of the frame.
|
||||
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
|
||||
time: self.world.game_time as f64 + alpha,
|
||||
@@ -6240,21 +6255,55 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
let body = out.len();
|
||||
let render = if layers_only {
|
||||
horizon_client::mobs::render::render_layers
|
||||
// The GPU path: an ordinary mob, no outline, no layers the CPU path changes after
|
||||
// drawing them (a player's, the HDR tiers below, the sulfur cube's shell).
|
||||
let posable = skinning.is_some()
|
||||
&& !is_player
|
||||
&& !layers_only
|
||||
&& outline.is_none()
|
||||
&& armor.is_empty()
|
||||
&& !matches!(
|
||||
mob.species(),
|
||||
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
|
||||
);
|
||||
let mut skinned = None;
|
||||
if posable {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let mark = frame.mark();
|
||||
let mut skin = horizon_client::mobs::skin::Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
horizon_client::mobs::render::render_skinned(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
out,
|
||||
&mut skin,
|
||||
);
|
||||
skinned = Some((mark, skin.bounds));
|
||||
} else {
|
||||
horizon_client::mobs::render::render_with
|
||||
};
|
||||
render(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&armor,
|
||||
out,
|
||||
);
|
||||
let render = if layers_only {
|
||||
horizon_client::mobs::render::render_layers
|
||||
} else {
|
||||
horizon_client::mobs::render::render_with
|
||||
};
|
||||
render(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&armor,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
|
||||
// fs_hdr_mark); a tier's window lifts only their bright texels.
|
||||
if let Some(tier) = match mob.species() {
|
||||
@@ -6302,7 +6351,23 @@ impl Game<'_> {
|
||||
if outline.is_none()
|
||||
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
|
||||
{
|
||||
// What the GPU draws of the mob counts by its bounds' corners.
|
||||
let corners = skinned.and_then(|(_, bounds)| bounds);
|
||||
if let Some((lo, hi)) = corners {
|
||||
out.extend([lo, hi].map(|pos| Vertex {
|
||||
pos: pos.to_array(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}));
|
||||
}
|
||||
let len = out.len();
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
if out.len() < len {
|
||||
if let Some((mark, _)) = skinned {
|
||||
self.skin_frame.borrow_mut().truncate(mark);
|
||||
}
|
||||
} else if corners.is_some() {
|
||||
out.truncate(len - 2);
|
||||
}
|
||||
}
|
||||
// GuardianRenderer: the laser to the locked target.
|
||||
if let Some((time, scale)) = horizon_client::mobs::guardian::beam(mob, alpha as f32)
|
||||
|
||||
@@ -1236,7 +1236,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(|e| (e.user.id, e.level as u8))
|
||||
.collect();
|
||||
host.hub.resend_commands();
|
||||
self.resend_commands_filtered();
|
||||
}
|
||||
|
||||
/// A player on the server, by name.
|
||||
|
||||
@@ -1374,7 +1374,7 @@ impl Game<'_> {
|
||||
None,
|
||||
);
|
||||
} else if &*name == "minecraft:tnt" {
|
||||
if self.ui.rules.bool("tnt_explodes") {
|
||||
if self.ui.rules.bool("tnt_explodes") && self.tnt_prime_allowed(target_pos) {
|
||||
self.spawn_primed_tnt(target_pos, false, None, None);
|
||||
self.with_level(|b, level, ticks| b.remove_block(level, ticks, target_pos, false));
|
||||
self.process_level_events()?;
|
||||
@@ -1435,9 +1435,13 @@ impl Game<'_> {
|
||||
if self.sulfur_cube_swallow(front, &stack) {
|
||||
return (shrink(stack), optional_event("tnt"));
|
||||
}
|
||||
let target = Self::pos_of(front);
|
||||
if !self.tnt_prime_allowed(target) {
|
||||
set_success("tnt", false);
|
||||
return (Some(stack), FAIL);
|
||||
}
|
||||
set_success("tnt", true);
|
||||
// new PrimedTnt(level, x + 0.5, y, z + 0.5, null), its sound and ENTITY_PLACE.
|
||||
let target = Self::pos_of(front);
|
||||
let rot = self.world.random.next_double() * f64::from(std::f32::consts::TAU);
|
||||
self.add_primed_tnt(target, rot, 80, None, None);
|
||||
self.audio.play(
|
||||
|
||||
@@ -712,6 +712,17 @@ mod tests {
|
||||
assert_eq!(range, 48.0);
|
||||
}
|
||||
|
||||
/// `Entity.shouldRenderAtSqrDistance` for a dropped item (`ItemEntity`
|
||||
/// keeps the default and its box is 0.25 on each side): 24 blocks at
|
||||
/// render distance 12, 32 at 16, 40 from 20 on; never 64.
|
||||
#[test]
|
||||
fn a_dropped_item_is_drawn_sixteen_blocks_times_the_view_scale() {
|
||||
let item = glam::DVec3::splat(0.25);
|
||||
assert_eq!(default_range(item, entity_view_scale(12, 1.0)), 24.0);
|
||||
assert_eq!(default_range(item, entity_view_scale(16, 1.0)), 32.0);
|
||||
assert_eq!(default_range(item, entity_view_scale(32, 1.0)), 40.0);
|
||||
}
|
||||
|
||||
/// `Entity.shouldRenderAtSqrDistance` on a broken box: the size falls
|
||||
/// back to one block.
|
||||
#[test]
|
||||
|
||||
@@ -125,6 +125,17 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.any(|o| o.id != f.id && o.facing == f.facing && overlaps(o.bounds()))
|
||||
}
|
||||
/// Asks the mods whether the acting guest may place a hanging entity
|
||||
/// of type `kind` at `pos` (`native_hanging_place`): true when one
|
||||
/// cancelled it. A game's own player is not asked.
|
||||
pub(super) fn hanging_place_cancelled(&mut self, kind: &str, pos: [i32; 3]) -> bool {
|
||||
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_hanging_place(uuid, &name, kind, pos, &mut cancel);
|
||||
cancel
|
||||
}
|
||||
/// `ItemFrameItem.useOn`.
|
||||
pub(super) fn place_item_frame(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
|
||||
let glow = match &*stack.item {
|
||||
@@ -162,6 +173,14 @@ impl Game<'_> {
|
||||
if !self.frame_survives(&frame) {
|
||||
return false;
|
||||
}
|
||||
let kind = if glow {
|
||||
"minecraft:glow_item_frame"
|
||||
} else {
|
||||
"minecraft:item_frame"
|
||||
};
|
||||
if self.hanging_place_cancelled(kind, pos) {
|
||||
return true;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
self.audio
|
||||
.play(&sound(&frame, "place"), Some(center(&frame)), 1.0, 1.0);
|
||||
|
||||
@@ -32,12 +32,19 @@ use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
|
||||
mpsc::{Receiver, Sender, channel, sync_channel},
|
||||
mpsc::{Receiver, RecvTimeoutError, Sender, channel, sync_channel},
|
||||
},
|
||||
thread::JoinHandle,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// How long the window waits for the server thread to answer a `call`.
|
||||
const CALL_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// The error of a `call` without an answer: the server stopped, or it was
|
||||
/// busy past `CALL_TIMEOUT` (the log says which).
|
||||
pub(super) const NO_ANSWER: &str =
|
||||
"the integrated server stopped or did not answer within 10 s (the game log says which)";
|
||||
|
||||
/// `LevelLoadProgressTracker.EXPECTED_PLAYER_CHUNKS`: the 7x7 chunks around the spawn.
|
||||
const PLAYER_CHUNKS: usize = 49;
|
||||
|
||||
@@ -177,7 +184,9 @@ impl Integrated {
|
||||
Some(self.flags.published.load(Ordering::Relaxed)).filter(|&p| p != 0)
|
||||
}
|
||||
/// `executeBlocking`: run `f` on the server thread between two ticks and
|
||||
/// wait for its answer; `None` once the server has stopped.
|
||||
/// wait for its answer; `None` once the server has stopped, or when it has
|
||||
/// not answered within `CALL_TIMEOUT` (`f` still runs there later). The log
|
||||
/// tells the two apart.
|
||||
pub fn call<T: Send + 'static>(
|
||||
&self,
|
||||
f: impl FnOnce(&mut Game<'_>) -> T + Send + 'static,
|
||||
@@ -188,7 +197,21 @@ impl Integrated {
|
||||
let _ = tx.send(f(g));
|
||||
}))
|
||||
.ok()?;
|
||||
rx.recv_timeout(Duration::from_secs(10)).ok()
|
||||
match rx.recv_timeout(CALL_TIMEOUT) {
|
||||
Ok(answer) => Some(answer),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
log::warn!(
|
||||
"Horizon: the server thread did not answer within {} s (running: {}, \
|
||||
autosaving: {}, smoothed tick {:.1} ms)",
|
||||
CALL_TIMEOUT.as_secs(),
|
||||
!self.stopped(),
|
||||
self.is_saving(),
|
||||
self.smoothed_tick_ms()
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => None,
|
||||
}
|
||||
}
|
||||
/// `IntegratedServer.halt(true)`: returns once the world is saved and let go.
|
||||
pub fn stop(mut self) -> Result<()> {
|
||||
|
||||
@@ -483,12 +483,22 @@ impl Game<'_> {
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
// Entity.shouldRenderAtSqrDistance for the item's 0.25 box: 16 blocks
|
||||
// times the view scale (32 at render distance 16), from its current
|
||||
// position. A pickup particle is not an entity and keeps 64 blocks.
|
||||
let range =
|
||||
super::entity_culling::default_range(glam::DVec3::splat(0.25), self.view_scale());
|
||||
for (item, at, partial, entity) in live.chain(taken) {
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
|
||||
let q = item.position;
|
||||
let pos = glam::DVec3::from_array(at) - camera;
|
||||
if pos.length_squared() > 64.0 * 64.0 {
|
||||
let far = if entity {
|
||||
(glam::DVec3::new(q.x, q.y, q.z) - camera).length_squared() >= range * range
|
||||
} else {
|
||||
pos.length_squared() > 64.0 * 64.0
|
||||
};
|
||||
if far {
|
||||
continue;
|
||||
}
|
||||
// EntityRenderer.shouldRender: the item's 0.25 box, grown by 0.5,
|
||||
|
||||
@@ -1133,7 +1133,7 @@ impl Game<'_> {
|
||||
// The hub is the server thread's.
|
||||
let status = server
|
||||
.call(|g| g.lan_status())
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
return Ok(Some(status));
|
||||
}
|
||||
match words.get(1).copied() {
|
||||
@@ -1225,7 +1225,7 @@ impl Game<'_> {
|
||||
// ShareToLanScreen: IntegratedServer.publishServer.
|
||||
server
|
||||
.call(move |g| g.publish(settings))
|
||||
.ok_or("the integrated server stopped")??
|
||||
.ok_or(super::integrated::NO_ANSWER)??
|
||||
} else if self.serving() {
|
||||
return self.publish(settings).map(drop).map_err(Into::into);
|
||||
} else {
|
||||
@@ -1708,8 +1708,8 @@ impl Game<'_> {
|
||||
host.hub.config.guest_commands = effective;
|
||||
host.hub.config.force_game_mode = force;
|
||||
enforce_guest_game_mode(host, &self.world, self.collision);
|
||||
host.hub.resend_commands();
|
||||
}
|
||||
self.resend_commands_filtered();
|
||||
}
|
||||
|
||||
pub(super) fn lan_port(&self) -> Option<u16> {
|
||||
@@ -1884,6 +1884,27 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
HostEvent::Joined(conn) => {
|
||||
// The mods may refuse the login before the player is placed.
|
||||
if let Some((uuid, name)) =
|
||||
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
|
||||
{
|
||||
let mut cancel = false;
|
||||
let mut reason = None;
|
||||
self.native_player_login(uuid, &name, &mut cancel, &mut reason);
|
||||
if cancel {
|
||||
let reason = match reason {
|
||||
Some(text) => {
|
||||
horizon_client::lan::data::component_nbt(&text.component())
|
||||
}
|
||||
None => horizon_protocol::text::translatable(
|
||||
"multiplayer.disconnect.generic",
|
||||
&[],
|
||||
),
|
||||
};
|
||||
host.hub.kick(conn, reason);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.lan_guest_joined(&mut host, conn, state, entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
&& let Some(g) = host.hub.guest(conn)
|
||||
@@ -2381,6 +2402,8 @@ impl Game<'_> {
|
||||
g.dimension = in_place;
|
||||
}
|
||||
self.load_guest_records(host, conn);
|
||||
// The mods say which commands the newcomer's tree keeps.
|
||||
self.native_filter_commands(host, Some(conn));
|
||||
let mut theirs = States::default();
|
||||
let entities = if elsewhere {
|
||||
if let Err(e) = self.enter_level(Some(host), level) {
|
||||
|
||||
@@ -663,6 +663,28 @@ impl Game<'_> {
|
||||
}
|
||||
// A totem that saved the guest this turn (its hurt set it off).
|
||||
let totem = self.apply_totem();
|
||||
// The mods hear the food level the turn changed (eating, hunger,
|
||||
// starving...), once for the change it made as a whole, with the
|
||||
// guest's state still swapped in; they may change or undo it before it
|
||||
// reaches the guest.
|
||||
if let Some(now) = self.player.as_ref().map(|p| p.health.food.food)
|
||||
&& let Some(g) = host.hub.guests.get(swap.i)
|
||||
&& g.food != now
|
||||
{
|
||||
let (uuid, name, from, saturation) = (g.uuid, g.name.clone(), g.food, g.saturation);
|
||||
let (mut to, mut cancel) = (now, false);
|
||||
self.native_food_level_changed(uuid, &name, from, &mut to, &mut cancel);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if cancel {
|
||||
p.health.food.food = from;
|
||||
p.health.food.saturation = saturation;
|
||||
} else if to != now {
|
||||
p.health.food.food = to.clamp(0, 20);
|
||||
p.health.food.saturation =
|
||||
p.health.food.saturation.min(p.health.food.food as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
let GuestSwap {
|
||||
conn,
|
||||
i,
|
||||
@@ -1025,6 +1047,8 @@ impl Game<'_> {
|
||||
if let Some(mob) = guest_mount {
|
||||
self.lan_open_mount(host, conn, mob);
|
||||
}
|
||||
// The turn's teleports are in the hub now: the mods hear them.
|
||||
self.flush_teleport_notes(host);
|
||||
}
|
||||
|
||||
/// `ServerLevel.broadcastDamageEvent` for a guest that took an unblocked hit.
|
||||
@@ -1098,6 +1122,31 @@ impl Game<'_> {
|
||||
self.creative = self.mode == GameMode::Creative;
|
||||
}
|
||||
|
||||
/// Whether the mods let the guest open the menu `kind` (`native_container_opened`),
|
||||
/// heard with the guest's state swapped in. Asked before the menu that
|
||||
/// was open closes.
|
||||
pub(super) fn guest_may_open(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
kind: &str,
|
||||
pos: Option<[i32; 3]>,
|
||||
) -> bool {
|
||||
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
|
||||
return true;
|
||||
};
|
||||
let mut cancel = false;
|
||||
let mut hook =
|
||||
|game: &mut Self| game.native_container_opened(uuid, &name, kind, pos, &mut cancel);
|
||||
// The guest whose turn it is is in place already.
|
||||
if self.lan_acting == Some(conn) {
|
||||
hook(self);
|
||||
} else {
|
||||
self.as_guest(host, conn, hook);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
|
||||
/// Hand a container the guest opened over (ServerPlayer.openMenu);
|
||||
/// false when it is not one a guest can open.
|
||||
fn lan_open_container(&mut self, host: &mut LanHost, conn: u32, pos: (i32, i32, i32)) -> bool {
|
||||
@@ -1246,6 +1295,10 @@ impl Game<'_> {
|
||||
if !horizon_client::containers::menu_reaches(brand, menu) {
|
||||
return false;
|
||||
}
|
||||
// The mods may stop the menu; the click is spent.
|
||||
if !self.guest_may_open(host, conn, menu, Some([pos.0, pos.1, pos.2])) {
|
||||
return true;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let hidden = self.native_hidden_slots(pos, items.len());
|
||||
@@ -1294,6 +1347,10 @@ impl Game<'_> {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// The mods may stop the menu; the click is spent.
|
||||
if !self.guest_may_open(host, conn, menu, None) {
|
||||
return true;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
host.hub
|
||||
@@ -1784,6 +1841,25 @@ impl Game<'_> {
|
||||
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
|
||||
// (ServerGamePacketListenerImpl.handlePlayerAction), not for spectators.
|
||||
7 if !spectator => {
|
||||
// The mods hear the swap first, and may stop it: the
|
||||
// client, which swapped at once, gets its slots back.
|
||||
let (uuid, name) = {
|
||||
let g = &host.hub.guests[gi];
|
||||
(g.uuid, g.name.clone())
|
||||
};
|
||||
// With the player's state swapped in, so that the helpers
|
||||
// reach them.
|
||||
let cancel = self
|
||||
.as_guest(host, conn, |g| {
|
||||
let mut cancel = false;
|
||||
g.native_player_swap_hands(uuid, &name, &mut cancel);
|
||||
cancel
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if cancel {
|
||||
host.hub.send_inventory(gi);
|
||||
return;
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let slot = g.selected;
|
||||
std::mem::swap(&mut g.inventory[slot], &mut g.offhand);
|
||||
@@ -2034,6 +2110,9 @@ impl Game<'_> {
|
||||
if !self.creative_stack_valid(host, gi, &stack) {
|
||||
return;
|
||||
}
|
||||
if !self.guest_may_set_creative_slot(host, conn, i32::from(p.slot), Some(&stack)) {
|
||||
return;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
if play.creative_drops >= 1480 {
|
||||
eprintln!(
|
||||
@@ -2058,6 +2137,12 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.guest_may_set_creative_slot(host, conn, i32::from(p.slot), item.as_ref()) {
|
||||
// The creative client already holds the stack it sent: treat that as
|
||||
// what it was told, so the next broadcast sends the real slot back.
|
||||
host.hub.guests[gi].menu_sent[p.slot as usize] = item;
|
||||
return;
|
||||
}
|
||||
let slot = p.slot as usize;
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let old = g.menu_slot(slot);
|
||||
@@ -2096,6 +2181,22 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
S::PlayerCommand(p) if p.action == 6 => self.lan_start_gliding(host, conn),
|
||||
// ServerboundResourcePackPacket in play: the server has nothing to
|
||||
// do with it (a declined required pack is handled on the connection).
|
||||
S::ResourcePack(r) => {
|
||||
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.native_resource_pack_status(uuid, &name, r.action);
|
||||
});
|
||||
}
|
||||
// START_SPRINTING and STOP_SPRINTING: the hub has set the sprint
|
||||
// and sends the packet on when it changed it.
|
||||
S::PlayerCommand(p) if matches!(p.action, 1 | 2) => {
|
||||
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.native_player_sprint(uuid, &name, p.action == 1);
|
||||
});
|
||||
}
|
||||
S::ContainerButtonClick(p) => {
|
||||
self.lan_station_button(host, conn, p.container, p.button);
|
||||
}
|
||||
@@ -2452,6 +2553,32 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the mods let a creative player set `slot` to `stack`
|
||||
/// (`native_creative_slot`), heard with the guest's state swapped in.
|
||||
fn guest_may_set_creative_slot(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
slot: i32,
|
||||
stack: Option<&ItemStack>,
|
||||
) -> bool {
|
||||
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
|
||||
return true;
|
||||
};
|
||||
let (item, count) = stack.map_or(("minecraft:air", 0), |s| (&*s.item, s.count as i32));
|
||||
let mut cancel = false;
|
||||
let mut hook = |game: &mut Self| {
|
||||
game.native_creative_slot(uuid, &name, slot, item, count, &mut cancel)
|
||||
};
|
||||
// The guest whose turn it is is in place already.
|
||||
if self.lan_acting == Some(conn) {
|
||||
hook(self);
|
||||
} else {
|
||||
self.as_guest(host, conn, hook);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
|
||||
/// What ServerboundSetCreativeModeSlotPacket asks of a stack. Its codec
|
||||
/// (`ItemStack.validatedStreamCodec`) encodes the stack with `ItemStack.CODEC`, whose count is
|
||||
/// 1 to 99: more fails the packet and disconnects the player, as vanilla's decoder error does.
|
||||
|
||||
@@ -469,6 +469,9 @@ impl Game<'_> {
|
||||
|
||||
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
|
||||
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
|
||||
if !self.guest_may_open(host, conn, "minecraft:crafting", Some(pos)) {
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let title = horizon_protocol::text::translatable("container.crafting", &[]);
|
||||
|
||||
@@ -31,6 +31,9 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
|
||||
if !self.guest_may_open(host, conn, "minecraft:horse_inventory", None) {
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
|
||||
|
||||
@@ -69,6 +69,10 @@ impl Game<'_> {
|
||||
let Some((menu_type, kind, _)) = menu_of(station.kind) else {
|
||||
return false;
|
||||
};
|
||||
// The mods may stop the menu; the click is spent.
|
||||
if !self.guest_may_open(host, conn, menu_type, Some(station.pos)) {
|
||||
return true;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let title = horizon_protocol::text::translatable(station.kind.title_key(), &[]);
|
||||
@@ -149,6 +153,9 @@ impl Game<'_> {
|
||||
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
|
||||
/// block entity, with its book and page.
|
||||
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
|
||||
if !self.guest_may_open(host, conn, "minecraft:lectern", Some([key.0, key.1, key.2])) {
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let title = horizon_protocol::text::translatable("container.lectern", &[]);
|
||||
|
||||
@@ -57,6 +57,10 @@ impl Game<'_> {
|
||||
state.session.update_special_prices();
|
||||
}
|
||||
let title = self.merchant_display_name(&state);
|
||||
if !self.guest_may_open(host, conn, "minecraft:merchant", None) {
|
||||
self.merchant_closed(&mut state);
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let Some(window) = host.hub.open_merchant_window(conn, title) else {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
|
||||
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
|
||||
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
|
||||
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use super::{Dim, Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_client::lan::hub::no_blocks_in;
|
||||
use horizon_client::mobs::equine;
|
||||
@@ -38,7 +38,116 @@ fn corners(b: Aabb) -> ([f64; 3], [f64; 3]) {
|
||||
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
|
||||
}
|
||||
|
||||
/// A boat or minecart a server player rode, followed for
|
||||
/// `native_vehicle_moved`.
|
||||
pub(super) struct WatchedVehicle {
|
||||
id: u32,
|
||||
dim: Dim,
|
||||
/// The server player it carries now.
|
||||
rider: Option<u128>,
|
||||
/// Where the hook last heard of it.
|
||||
at: [f64; 3],
|
||||
/// Ticks it has stood still.
|
||||
still: u32,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Where the boat or minecart `id` of this level is.
|
||||
fn watched_vehicle_position(&self, id: u32) -> Option<[f64; 3]> {
|
||||
let at = |v: Vec3| [v.x, v.y, v.z];
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| b.id == id)
|
||||
.map(|b| at(b.position))
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| at(c.position))
|
||||
})
|
||||
}
|
||||
|
||||
/// `VehicleMoveEvent` for the boats and minecarts server players ride
|
||||
/// (and left less than ten seconds ago): once a tick, after they moved.
|
||||
pub(super) fn tick_vehicle_watch(&mut self) {
|
||||
let riders: Vec<(u32, u32, u128)> = self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter_map(|g| Some((g.vehicle?, g.conn, g.uuid)))
|
||||
.collect()
|
||||
});
|
||||
if riders.is_empty() && self.watched_vehicles.is_empty() {
|
||||
return;
|
||||
}
|
||||
let dim = self.dim;
|
||||
for &(id, _, uuid) in &riders {
|
||||
let Some(at) = self.watched_vehicle_position(id) else {
|
||||
continue;
|
||||
};
|
||||
match self.watched_vehicles.iter_mut().find(|w| w.id == id) {
|
||||
Some(w) => (w.dim, w.rider) = (dim, Some(uuid)),
|
||||
None => self.watched_vehicles.push(WatchedVehicle {
|
||||
id,
|
||||
dim,
|
||||
rider: Some(uuid),
|
||||
at,
|
||||
still: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
let mut kept = Vec::new();
|
||||
// (rider's connection and UUID, vehicle type, from, to)
|
||||
let mut moved = Vec::new();
|
||||
for mut w in std::mem::take(&mut self.watched_vehicles) {
|
||||
// Another level's vehicle is told of on that level's tick.
|
||||
if w.dim != dim {
|
||||
kept.push(w);
|
||||
continue;
|
||||
}
|
||||
// Broken, or gone from this level.
|
||||
let Some(now) = self.watched_vehicle_position(w.id) else {
|
||||
continue;
|
||||
};
|
||||
let rider = riders.iter().find(|r| r.0 == w.id);
|
||||
w.rider = rider.map(|r| r.2);
|
||||
if now != w.at {
|
||||
let from = std::mem::replace(&mut w.at, now);
|
||||
w.still = 0;
|
||||
let kind = self.target_type(
|
||||
self.level_entity(w.id as i32)
|
||||
.unwrap_or(super::commands::Target::Boat(w.id)),
|
||||
);
|
||||
moved.push((rider.map(|r| (r.1, r.2)), kind, from, now));
|
||||
} else {
|
||||
w.still += 1;
|
||||
if w.rider.is_none() && w.still > 200 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
kept.push(w);
|
||||
}
|
||||
self.watched_vehicles = kept;
|
||||
// The rider's state is swapped in for the call, so the helpers reach
|
||||
// them; a vehicle nobody rides is told of with nobody swapped in.
|
||||
for (rider, kind, from, to) in moved {
|
||||
let Some((conn, uuid)) = rider else {
|
||||
self.native_vehicle_moved(None, &kind, from, to);
|
||||
continue;
|
||||
};
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
continue;
|
||||
};
|
||||
let hook = |game: &mut Self| game.native_vehicle_moved(Some(uuid), &kind, from, to);
|
||||
if self.lan_acting == Some(conn) {
|
||||
hook(self);
|
||||
} else {
|
||||
self.as_guest(&mut host, conn, hook);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
|
||||
/// The box of a boat, minecart, armor stand, item frame or painting, and
|
||||
/// the point an interact packet's location is measured from (the
|
||||
/// client's `remote_object_hit` uses the same points).
|
||||
|
||||
@@ -401,6 +401,14 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// A knot is made when a lead is tied to a fence that has none.
|
||||
if existing.is_empty()
|
||||
&& existing_boats.is_empty()
|
||||
&& (!in_hand.is_empty() || !boats_in_hand.is_empty())
|
||||
&& self.hanging_place_cancelled("minecraft:leash_knot", knot_pos)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let mut attached = self.tie_player_boats(center, Holder::Knot(knot_pos));
|
||||
for j in in_hand {
|
||||
if self.can_attach_lead(j, center) {
|
||||
|
||||
@@ -40,7 +40,6 @@ pub(super) struct Decoration {
|
||||
}
|
||||
/// A map as a native mod sees it when `native_map_decorations` is asked.
|
||||
// Native mods read it; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativeMapInfo<'a> {
|
||||
/// The map's id (`map_<id>`).
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Finding a mob by its network id without walking the whole list: a
|
||||
//! multiplayer client looks one up for every movement, data, motion and
|
||||
//! event packet. A `Mob` is ~3 KB, so a linear search touches a cache line
|
||||
//! per mob: 1000 movement packets in a 3000-mob farm cost ~5 ms a tick that
|
||||
//! way, against ~0.01 ms through the index.
|
||||
//!
|
||||
//! The index is a cache: the mob list changes in many places (spawns,
|
||||
//! removals, dimension swaps), and none of them has to tell it. A hit is
|
||||
//! checked against the list, and a miss falls back to the linear search,
|
||||
//! which rebuilds the index when it finds the mob. Ids are unique in the list
|
||||
//! (`spawn_entity` drops a mob with the same id before adding one), so the
|
||||
//! answer is always the linear search's.
|
||||
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::mobs::Mob;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct MobIndex {
|
||||
slots: FxHashMap<u32, u32>,
|
||||
}
|
||||
|
||||
impl MobIndex {
|
||||
/// The position of the mob with this id in `mobs`, as
|
||||
/// `mobs.iter().position(|m| m.id == id)`.
|
||||
pub(super) fn find(&mut self, mobs: &[Mob], id: u32) -> Option<usize> {
|
||||
self.position(mobs, |m| m.id, id)
|
||||
}
|
||||
|
||||
fn position<T>(&mut self, items: &[T], id_of: impl Fn(&T) -> u32, id: u32) -> Option<usize> {
|
||||
if let Some(&i) = self.slots.get(&id)
|
||||
&& items.get(i as usize).is_some_and(|m| id_of(m) == id)
|
||||
{
|
||||
return Some(i as usize);
|
||||
}
|
||||
let i = items.iter().position(|m| id_of(m) == id)?;
|
||||
self.slots.clear();
|
||||
for (i, m) in items.iter().enumerate() {
|
||||
self.slots.entry(id_of(m)).or_insert(i as u32);
|
||||
}
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// The mob with this id, as `mobs.iter_mut().find(|m| m.id == id)`.
|
||||
pub(super) fn get_mut<'a>(&mut self, mobs: &'a mut [Mob], id: u32) -> Option<&'a mut Mob> {
|
||||
let i = self.find(mobs, id)?;
|
||||
Some(&mut mobs[i])
|
||||
}
|
||||
|
||||
/// The mob with this id, as `mobs.iter().find(|m| m.id == id)`.
|
||||
pub(super) fn get<'a>(&mut self, mobs: &'a [Mob], id: u32) -> Option<&'a Mob> {
|
||||
let i = self.find(mobs, id)?;
|
||||
Some(&mobs[i])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Whatever the list went through since the last lookup, the index
|
||||
/// answers as the linear search does.
|
||||
#[test]
|
||||
fn finds_what_a_linear_search_finds_after_any_change() {
|
||||
let mut index = MobIndex::default();
|
||||
let check = |index: &mut MobIndex, ids: &[u32]| {
|
||||
for id in 0..20 {
|
||||
let found = index.position(ids, |&m| m, id);
|
||||
assert_eq!(
|
||||
found,
|
||||
ids.iter().position(|&m| m == id),
|
||||
"id {id} in {ids:?}"
|
||||
);
|
||||
}
|
||||
};
|
||||
let mut ids = vec![7, 3, 9, 12];
|
||||
check(&mut index, &ids);
|
||||
ids.remove(1);
|
||||
check(&mut index, &ids);
|
||||
ids.push(3);
|
||||
ids.swap(0, 2);
|
||||
check(&mut index, &ids);
|
||||
ids.retain(|&m| m != 9);
|
||||
ids.insert(0, 15);
|
||||
check(&mut index, &ids);
|
||||
// Another list altogether: a dimension swapped in.
|
||||
check(&mut index, &[12, 15, 1]);
|
||||
ids.clear();
|
||||
check(&mut index, &ids);
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,115 @@ impl Host for super::Game<'_> {
|
||||
) -> bool {
|
||||
self.native_set_block(dimension, pos, state, flags)
|
||||
}
|
||||
fn edit_blocks(&mut self, dimension: &str, edit: &NativeBlockEdit) -> Result<u64, String> {
|
||||
self.native_edit_blocks(dimension, edit)
|
||||
}
|
||||
fn surface_heights(
|
||||
&self,
|
||||
dimension: &str,
|
||||
columns: &[(i32, i32)],
|
||||
heightmap: NativeHeightmap,
|
||||
) -> Result<Vec<Option<i32>>, String> {
|
||||
self.native_surface_heights(dimension, columns, heightmap)
|
||||
}
|
||||
fn read_columns(
|
||||
&self,
|
||||
dimension: &str,
|
||||
columns: &[(i32, i32)],
|
||||
heightmap: NativeHeightmap,
|
||||
range: (u32, u32),
|
||||
) -> Result<NativeColumnBlocks, String> {
|
||||
self.native_read_columns(dimension, columns, heightmap, range)
|
||||
}
|
||||
fn refresh_biome_colors(&mut self) {
|
||||
self.native_refresh_biome_colors()
|
||||
}
|
||||
fn neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32 {
|
||||
self.native_neighbor_signal(dimension, pos)
|
||||
}
|
||||
fn light(&self, pos: [i32; 3]) -> (u8, u8) {
|
||||
self.native_light(pos)
|
||||
}
|
||||
fn weather(&self) -> (f32, f32) {
|
||||
self.native_weather()
|
||||
}
|
||||
fn day_time(&self) -> i64 {
|
||||
self.native_day_time()
|
||||
}
|
||||
fn world_height(&self) -> (i32, i32) {
|
||||
self.native_world_height()
|
||||
}
|
||||
fn max_stack_size(&self, item: &str) -> Option<u32> {
|
||||
self.native_max_stack_size(item)
|
||||
}
|
||||
fn item_model(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
item: &str,
|
||||
model: &str,
|
||||
at: [f32; 2],
|
||||
size: f32,
|
||||
) -> bool {
|
||||
self.native_item_model(batch, item, model, at, size)
|
||||
}
|
||||
fn quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32) {
|
||||
self.native_quad(batch, points, argb)
|
||||
}
|
||||
fn mod_screen_open(&mut self, mod_id: &str) -> bool {
|
||||
self.native_mod_screen_open(mod_id)
|
||||
}
|
||||
fn mod_screen_close(&mut self, mod_id: &str) {
|
||||
self.native_mod_screen_close(mod_id)
|
||||
}
|
||||
fn mod_screen(&self) -> Option<&str> {
|
||||
self.native_mod_screen()
|
||||
}
|
||||
fn set_experience_orb(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
id: u32,
|
||||
value: Option<u32>,
|
||||
count: Option<u32>,
|
||||
age: Option<u32>,
|
||||
) -> bool {
|
||||
self.native_set_experience_orb(dimension, id, value, count, age)
|
||||
}
|
||||
fn experience_orbs(
|
||||
&self,
|
||||
dimension: &str,
|
||||
min: [f64; 3],
|
||||
max: [f64; 3],
|
||||
) -> Vec<NativeExperienceOrb> {
|
||||
self.native_experience_orbs(dimension, min, max)
|
||||
}
|
||||
fn clock(&self) -> Option<NativeClock> {
|
||||
self.native_clock()
|
||||
}
|
||||
fn spawn_falling_block(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
state: BlockStateId,
|
||||
pos: [f64; 3],
|
||||
velocity: [f64; 3],
|
||||
falling: NativeFalling,
|
||||
) -> bool {
|
||||
self.native_spawn_falling_block(dimension, state, pos, velocity, falling)
|
||||
}
|
||||
fn block_state_property(&self, state: BlockStateId, key: &str) -> Option<&str> {
|
||||
self.native_block_state_property(state, key)
|
||||
}
|
||||
fn raycast(&self, distance: f32) -> Option<NativeHit> {
|
||||
self.native_raycast(distance)
|
||||
}
|
||||
fn view_hit(&self) -> Option<(f32, NativeHit)> {
|
||||
self.native_view_hit()
|
||||
}
|
||||
fn claim_items(&mut self, mod_id: &str, ids: &[String]) {
|
||||
self.native_claim_items(mod_id, ids)
|
||||
}
|
||||
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
|
||||
self.native_poll(mod_id)
|
||||
}
|
||||
fn set_item_entity(&mut self, dimension: &str, id: u32, stack: ItemStack) -> bool {
|
||||
self.native_set_item_entity(dimension, id, stack)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ fn in_frustum(frustum: Option<&impl Cull>, origin: Vec3, bounds: [f32; 6]) -> bo
|
||||
/// partial tick, game time and light, and the pose stack the helpers draw
|
||||
/// under (`native_draw_quad`, `native_draw_cube`, `native_draw_block`).
|
||||
// Native mods read and drive it; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
pub struct BlockEntityDraw {
|
||||
/// The frame's partial tick, 0 to 1 (`BlockEntityRenderState`).
|
||||
pub partial: f32,
|
||||
@@ -174,7 +173,6 @@ impl BlockEntityDraw {
|
||||
/// One face of a `native_draw_cube` box: its sprite (`mymod:entity/cog`,
|
||||
/// `mymod:block/casing`) and its UVs in texels of 16 per side, as a model
|
||||
/// face's `uv` (none takes the box's own, as a model's default).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CubeFace<'a> {
|
||||
pub sprite: &'a str,
|
||||
|
||||
@@ -5,36 +5,33 @@ use super::Game;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A stack as a client mod's use events carry it.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeHeldItem {
|
||||
pub(crate) id: String,
|
||||
pub(crate) count: u32,
|
||||
pub struct NativeHeldItem {
|
||||
pub id: String,
|
||||
pub count: u32,
|
||||
/// The inventory slot: 0 to 8, the hotbar (a mod use is the main hand's).
|
||||
pub(crate) slot: u32,
|
||||
pub slot: u32,
|
||||
/// `minecraft:custom_name` as SNBT, when the stack has one.
|
||||
pub(crate) custom_name: Option<String>,
|
||||
pub custom_name: Option<String>,
|
||||
/// `minecraft:custom_data` as SNBT, when the stack has some.
|
||||
pub(crate) custom_data: Option<String>,
|
||||
pub custom_data: Option<String>,
|
||||
}
|
||||
/// The block a ray along the view meets.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeHit {
|
||||
pub(crate) block_x: i32,
|
||||
pub(crate) block_y: i32,
|
||||
pub(crate) block_z: i32,
|
||||
pub struct NativeHit {
|
||||
pub block_x: i32,
|
||||
pub block_y: i32,
|
||||
pub block_z: i32,
|
||||
/// Where the ray meets the block.
|
||||
pub(crate) x: f64,
|
||||
pub(crate) y: f64,
|
||||
pub(crate) z: f64,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
/// The block's id (`minecraft:stone`).
|
||||
pub(crate) block: String,
|
||||
pub block: String,
|
||||
}
|
||||
/// What happened to a mod's claimed item, oldest first.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum NativeModEvent {
|
||||
pub enum NativeModEvent {
|
||||
/// A right-click with the item began its use: the stack, and 0 ticks.
|
||||
UseStart(NativeHeldItem, u32),
|
||||
/// The use ended: the stack it began with, and the ticks it was held. It
|
||||
|
||||
@@ -15,12 +15,12 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
/// A data map type a mod registers (`RegisterDataMapTypesEvent`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct NativeDataMapType {
|
||||
pub struct NativeDataMapType {
|
||||
/// `mod_id:name`: not in the `minecraft` namespace. Its files are
|
||||
/// `data/<mod_id>/data_maps/<registry path>/<name>.json`.
|
||||
pub(crate) id: String,
|
||||
pub id: String,
|
||||
/// The registry it is for: `minecraft:item` or `minecraft:block`.
|
||||
pub(crate) registry: String,
|
||||
pub registry: String,
|
||||
}
|
||||
|
||||
/// The registries data maps can be attached to so far, with their folder
|
||||
|
||||
@@ -16,7 +16,7 @@ const NATIVE_FALLING_MAX_SPEED: f64 = 3.9;
|
||||
|
||||
/// What a mod's falling block does when it lands and when it hits.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct NativeFalling {
|
||||
pub struct NativeFalling {
|
||||
/// `FallingBlockEntity.dropItem`: whether it drops as an item where it
|
||||
/// can't be placed.
|
||||
pub drop_item: bool,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -20,7 +20,6 @@ use horizon_client::{
|
||||
/// `LivingEntityRenderState`: what a model reads to pose and animate itself.
|
||||
/// Angles are in degrees, interpolated to the frame's partial tick.
|
||||
// Native mods read it; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NativeMobRender<'a> {
|
||||
/// The mob's network id, as the mob helpers and mod messages name it.
|
||||
@@ -83,7 +82,6 @@ impl<'a> NativeMobRender<'a> {
|
||||
/// Which player a [`NativeMobRender`] is, for `native_player_submit` and
|
||||
/// `native_mob_layer`.
|
||||
// Native mods read it; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativePlayerRender<'a> {
|
||||
/// The player's profile name.
|
||||
|
||||
@@ -722,9 +722,8 @@ impl Game<'_> {
|
||||
|
||||
/// An experience orb as a server mod reads it (`native_experience_orbs`).
|
||||
/// Read by mods only.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct NativeExperienceOrb {
|
||||
pub struct NativeExperienceOrb {
|
||||
/// The entity's ID, as `native_entity_join_level` names it.
|
||||
pub id: u32,
|
||||
pub position: [f64; 3],
|
||||
@@ -893,6 +892,49 @@ impl Game<'_> {
|
||||
host.native_commands = Some(levels);
|
||||
}
|
||||
|
||||
/// Asks the mods what root commands each player's tree keeps
|
||||
/// (`native_player_commands_sent`) and stores the answer in the hub,
|
||||
/// which sends the trees without what the mods took out: for `only`, or
|
||||
/// for every placed player.
|
||||
pub(super) fn native_filter_commands(&mut self, host: &mut LanHost, only: Option<u32>) {
|
||||
let players: Vec<(u32, u128, String)> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| only.map_or(g.placed, |conn| g.conn == conn))
|
||||
.map(|g| (g.conn, g.uuid, g.name.clone()))
|
||||
.collect();
|
||||
for (conn, uuid, name) in players {
|
||||
let names = host.hub.command_names(conn);
|
||||
let mut kept = names.clone();
|
||||
let placed = host.hub.guest(conn).is_some_and(|g| g.placed);
|
||||
if !placed {
|
||||
// A joining player has no world yet: nothing can be told them.
|
||||
self.native_player_commands_sent(uuid, &name, &mut kept);
|
||||
} else if self.lan_acting == Some(conn) {
|
||||
self.native_player_commands_sent(uuid, &name, &mut kept);
|
||||
} else {
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.native_player_commands_sent(uuid, &name, &mut kept);
|
||||
});
|
||||
}
|
||||
if let Some(g) = host.hub.guest_mut(conn) {
|
||||
g.hidden_commands = names.into_iter().filter(|n| !kept.contains(n)).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Hub::resend_commands` with the mods' say on each tree.
|
||||
pub(super) fn resend_commands_filtered(&mut self) {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
host.hub.refresh_permissions();
|
||||
self.native_filter_commands(&mut host, None);
|
||||
host.hub.resend_commands();
|
||||
self.lan = Some(host);
|
||||
}
|
||||
|
||||
/// The level a guest needs for a mod's command (`line`, without its
|
||||
/// slash), when a mod registered it.
|
||||
pub(super) fn native_command_level(host: &LanHost, line: &str) -> Option<u8> {
|
||||
|
||||
@@ -7,11 +7,11 @@ use super::native_world_edit::NativeHeightmap;
|
||||
/// What `native_read_columns` read: the blocks round each column's height (`bases`, none where it isn't loaded),
|
||||
/// as indexes into `palette`, a column after another, and three biome tints a column (grass, foliage, water).
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub(crate) struct NativeColumnBlocks {
|
||||
pub(crate) palette: Vec<String>,
|
||||
pub(crate) bases: Vec<Option<i32>>,
|
||||
pub(crate) blocks: Vec<u16>,
|
||||
pub(crate) tints: Vec<u32>,
|
||||
pub struct NativeColumnBlocks {
|
||||
pub palette: Vec<String>,
|
||||
pub bases: Vec<Option<i32>>,
|
||||
pub blocks: Vec<u16>,
|
||||
pub tints: Vec<u32>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -51,8 +51,9 @@ impl Game<'_> {
|
||||
dimension: &str,
|
||||
columns: &[(i32, i32)],
|
||||
heightmap: NativeHeightmap,
|
||||
(below, above): (u32, u32),
|
||||
range: (u32, u32),
|
||||
) -> Result<NativeColumnBlocks, String> {
|
||||
let (below, above) = range;
|
||||
if !self.native_level_open(dimension) {
|
||||
return Err(format!("{dimension} is not the level being ticked"));
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
use super::Game;
|
||||
|
||||
/// Which of a column's heights (`Heightmap.Types`) a run's `y` counts from.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum NativeHeightmap {
|
||||
pub enum NativeHeightmap {
|
||||
WorldSurface,
|
||||
MotionBlocking,
|
||||
MotionBlockingNoLeaves,
|
||||
@@ -14,22 +13,22 @@ pub(crate) enum NativeHeightmap {
|
||||
/// A run: `height` blocks up a column from `y` (from the column's `surface` height when one is
|
||||
/// given), set to palette entry `block`, only where filter `only` matches when one is given.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct NativeBlockRun {
|
||||
pub(crate) x: i32,
|
||||
pub(crate) z: i32,
|
||||
pub(crate) y: i32,
|
||||
pub(crate) height: u32,
|
||||
pub(crate) surface: Option<NativeHeightmap>,
|
||||
pub(crate) block: u32,
|
||||
pub(crate) only: Option<u32>,
|
||||
pub struct NativeBlockRun {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
pub y: i32,
|
||||
pub height: u32,
|
||||
pub surface: Option<NativeHeightmap>,
|
||||
pub block: u32,
|
||||
pub only: Option<u32>,
|
||||
}
|
||||
/// A bulk edit: block states and block predicates as commands write them (without NBT), and the runs naming them
|
||||
/// by index.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub(crate) struct NativeBlockEdit {
|
||||
pub(crate) palette: Vec<String>,
|
||||
pub(crate) filters: Vec<String>,
|
||||
pub(crate) runs: Vec<NativeBlockRun>,
|
||||
pub struct NativeBlockEdit {
|
||||
pub palette: Vec<String>,
|
||||
pub filters: Vec<String>,
|
||||
pub runs: Vec<NativeBlockRun>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -230,6 +230,9 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if self.hanging_place_cancelled("minecraft:painting", pos) {
|
||||
return true;
|
||||
}
|
||||
let (lo, hi) = candidate.bounds();
|
||||
let at = [
|
||||
(lo[0] + hi[0]) / 2.0,
|
||||
|
||||
@@ -2230,7 +2230,7 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
} else {
|
||||
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
|
||||
self.mob_index.get(&self.mobs, id as u32).map(|m| {
|
||||
(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.kind.width,
|
||||
@@ -2448,9 +2448,8 @@ impl Game<'_> {
|
||||
let dragon = self.dragon.as_ref().is_some_and(|d| d.id as i32 == id);
|
||||
let driven = self.driven_mount().is_some_and(|m| m.id as i32 == id);
|
||||
let mob = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id as u32)
|
||||
.mob_index
|
||||
.get_mut(&mut self.mobs, id as u32)
|
||||
.filter(|_| !dragon);
|
||||
let tracked = self
|
||||
.remote
|
||||
@@ -2852,7 +2851,7 @@ impl Game<'_> {
|
||||
value("minecraft:movement_speed"),
|
||||
value("minecraft:jump_strength"),
|
||||
);
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) else {
|
||||
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
|
||||
return;
|
||||
};
|
||||
// A horse its rider's client moves goes at its own speed and jumps
|
||||
@@ -3284,7 +3283,7 @@ impl Game<'_> {
|
||||
None => Some(Holder::Mob(d as u32)),
|
||||
},
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.source as u32) {
|
||||
m.extra.leash = holder;
|
||||
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == p.source as u32) {
|
||||
// A leashed boat or raft (AbstractBoat is Leashable).
|
||||
@@ -3304,7 +3303,7 @@ impl Game<'_> {
|
||||
// Entity.DATA_SHARED_FLAGS_ID, DATA_CUSTOM_NAME and
|
||||
// DATA_CUSTOM_NAME_VISIBLE: server NPCs and holograms rely on
|
||||
// these instead of saved NBT.
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
for (index, _, value) in &p.values {
|
||||
match (index, value) {
|
||||
(0, Value::Byte(flags)) => {
|
||||
@@ -3337,7 +3336,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.remote_mob_data(p.id, &p.values);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Creeper
|
||||
{
|
||||
for (index, _, value) in &p.values {
|
||||
@@ -3350,7 +3349,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
for (index, _, value) in &p.values {
|
||||
match (index, value) {
|
||||
// Entity.DATA_TICKS_FROZEN: isFullyFrozen shakes it.
|
||||
@@ -3382,7 +3381,7 @@ impl Game<'_> {
|
||||
for (index, _, value) in &p.values {
|
||||
if let (14, Value::OptPos(bed)) = (index, value) {
|
||||
let code = bed.map_or(0, |b| self.bed_facing(b));
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
m.extra.ints[1] = code;
|
||||
if let Some(head) = *bed {
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): setPosToBed.
|
||||
@@ -3401,14 +3400,14 @@ impl Game<'_> {
|
||||
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
|
||||
// right): the arm a swing of the main hand moves.
|
||||
if let (15, Value::Id(arm)) = (index, value)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
{
|
||||
m.extra.main_hand_left = *arm == 0;
|
||||
}
|
||||
// Avatar.DATA_PLAYER_MODE_CUSTOMISATION: the skin layers
|
||||
// the player shows (PlayerModelPart bits).
|
||||
if let (16, Value::Byte(bits)) = (index, value)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
{
|
||||
m.extra.hidden_model_parts = !(*bits as u8) & 0x7F;
|
||||
}
|
||||
@@ -3518,7 +3517,7 @@ impl Game<'_> {
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&p.id)))
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
{
|
||||
for (index, _, value) in &p.values {
|
||||
if let (0, Value::Byte(flags)) = (index, value) {
|
||||
@@ -3571,7 +3570,7 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
});
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
if let Some(charged) = charged
|
||||
&& &*m.kind.id == "minecraft:player"
|
||||
{
|
||||
@@ -3753,7 +3752,7 @@ impl Game<'_> {
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
m.limb_speed = 1.5;
|
||||
m.hurt_time = 10;
|
||||
} else {
|
||||
@@ -3766,7 +3765,7 @@ impl Game<'_> {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt_dir(p.yaw);
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
m.hurt_time = 10;
|
||||
} else {
|
||||
self.remote_dragon_hurt(p.id);
|
||||
@@ -3810,20 +3809,20 @@ impl Game<'_> {
|
||||
self.fox_eat_particles(p.id as u32);
|
||||
}
|
||||
if p.event == 1
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Rabbit
|
||||
{
|
||||
horizon_client::mobs::rabbit::hop_event(m);
|
||||
}
|
||||
if p.event == 19
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
{
|
||||
horizon_client::mobs::aquatic::squid_cycle_event(m);
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 55: another player swapped
|
||||
// its main and off hand (`swapHandItems`).
|
||||
if p.event == 55
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
{
|
||||
std::mem::swap(&mut m.extra.main_hand, &mut m.extra.off_hand);
|
||||
}
|
||||
@@ -3835,7 +3834,7 @@ impl Game<'_> {
|
||||
}
|
||||
// EntityEvent.DEATH: start the death animation.
|
||||
if p.event == 3
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
{
|
||||
m.health = 0.0;
|
||||
}
|
||||
@@ -3859,12 +3858,12 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
// The events that start a mob's animation (the warden's).
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
horizon_client::mobs::sync::entity_event(m, p.event as u8);
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
|
||||
if p.event == 60
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get(&self.mobs, p.id as u32)
|
||||
{
|
||||
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
|
||||
self.death_poof([at.x, at.y, at.z], w, h);
|
||||
@@ -3941,7 +3940,7 @@ impl Game<'_> {
|
||||
};
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
self.swing_hand(hand, animation);
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
m.swing_hand_modified(hand == Hand::Off, animation);
|
||||
}
|
||||
}
|
||||
@@ -5219,7 +5218,7 @@ impl Game<'_> {
|
||||
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
self.remote_dragon_motion(id, velocity);
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
|
||||
// Squid.aiStep derives its body pitch and roll from network motion.
|
||||
m.velocity = v;
|
||||
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
|
||||
@@ -487,7 +487,7 @@ impl Game<'_> {
|
||||
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
|
||||
apply(m, values, &r.builtin, r.entity_id);
|
||||
apply_registry_data(m, values, &r.registries, self.world.game_time);
|
||||
if let Some(code) = bed
|
||||
|
||||
@@ -170,7 +170,7 @@ impl Game<'_> {
|
||||
} else if self.riding == Some(vehicle_id) {
|
||||
// The server takes the player out, then teleports it.
|
||||
self.riding = None;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == vehicle_id) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, vehicle_id) {
|
||||
m.rider = None;
|
||||
}
|
||||
// HappyGhast.removePassenger: a still timeout of 10 (the server's
|
||||
@@ -286,7 +286,7 @@ impl Game<'_> {
|
||||
pub(super) fn remote_move_vehicle(&mut self, position: [f64; 3], yaw: f32) {
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
if let Some(id) = self.driven_mount().map(|m| m.id)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, id)
|
||||
{
|
||||
m.previous = at;
|
||||
m.position = at;
|
||||
|
||||
@@ -258,7 +258,7 @@ impl Game<'_> {
|
||||
self.external_server(),
|
||||
links,
|
||||
additions.is_some(),
|
||||
self.options.split_guest,
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
) {
|
||||
let is_focused = self.pause_focus == Some(id);
|
||||
let hover = active && (ui::hovered(self.cursor, x, y, bw, 20.0) || is_focused);
|
||||
@@ -272,10 +272,15 @@ impl Game<'_> {
|
||||
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
|
||||
}
|
||||
}
|
||||
for (i, (x, y, sprite)) in
|
||||
pause_icons(w, h, links, additions.is_some(), self.options.split_guest)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
for (i, (x, y, sprite)) in pause_icons(
|
||||
w,
|
||||
h,
|
||||
links,
|
||||
additions.is_some(),
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let active = icon_active(i, friends);
|
||||
let is_focused = (self.pause_focus == Some(10) && i == FRIENDS_ICON)
|
||||
@@ -391,7 +396,13 @@ impl Game<'_> {
|
||||
};
|
||||
let links = !self.server_links().is_empty();
|
||||
let additions = self.pause_screen_additions();
|
||||
let icons = pause_icons(w, h, links, additions.is_some(), self.options.split_guest);
|
||||
let icons = pause_icons(
|
||||
w,
|
||||
h,
|
||||
links,
|
||||
additions.is_some(),
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
);
|
||||
let friends = icons[FRIENDS_ICON];
|
||||
let hit = pause_buttons(
|
||||
w,
|
||||
@@ -399,7 +410,7 @@ impl Game<'_> {
|
||||
self.external_server(),
|
||||
links,
|
||||
additions.is_some(),
|
||||
self.options.split_guest,
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
)
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
@@ -439,7 +450,7 @@ impl Game<'_> {
|
||||
self.external_server(),
|
||||
links,
|
||||
self.pause_screen_additions().is_some(),
|
||||
self.options.split_guest,
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
);
|
||||
let mut active_ids: Vec<u8> = buttons.into_iter().filter(|b| b.5).map(|b| b.4).collect();
|
||||
let friends = self.friends_view().shown;
|
||||
@@ -487,7 +498,7 @@ impl Game<'_> {
|
||||
self.external_server(),
|
||||
links,
|
||||
self.pause_screen_additions().is_some(),
|
||||
self.options.split_guest,
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
);
|
||||
let mut rects: Vec<(u8, f32, f32, f32)> = buttons
|
||||
.into_iter()
|
||||
@@ -500,7 +511,7 @@ impl Game<'_> {
|
||||
h,
|
||||
links,
|
||||
self.pause_screen_additions().is_some(),
|
||||
self.options.split_guest,
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
|
||||
@@ -128,6 +128,17 @@ pub(super) struct LevelTeleport {
|
||||
pub rotation: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
/// A teleport a command made inside a level: told to the mods once it has
|
||||
/// reached the hub (`native_player_teleported`).
|
||||
pub(super) struct TeleportNote {
|
||||
pub conn: u32,
|
||||
pub uuid: u128,
|
||||
pub name: String,
|
||||
pub from: [f64; 3],
|
||||
pub to: [f64; 3],
|
||||
pub dimension: String,
|
||||
}
|
||||
|
||||
/// A player's box: Player's standing dimensions.
|
||||
const WIDTH: f64 = 0.6;
|
||||
const HEIGHT: f64 = 1.8;
|
||||
@@ -708,6 +719,29 @@ impl Game<'_> {
|
||||
self.set_portal_cooldown(id);
|
||||
}
|
||||
|
||||
/// Tells the mods of the teleports a command made inside a level, with
|
||||
/// each player's state swapped in: where the move has reached the hub.
|
||||
pub(super) fn flush_teleport_notes(&mut self, host: &mut LanHost) {
|
||||
for note in std::mem::take(&mut self.teleport_notes) {
|
||||
let TeleportNote {
|
||||
conn,
|
||||
uuid,
|
||||
name,
|
||||
from,
|
||||
to,
|
||||
dimension,
|
||||
} = note;
|
||||
// In place when it is their turn already (as_guest finds them swapped in).
|
||||
if self.lan_acting == Some(conn) {
|
||||
self.native_player_teleported(uuid, &name, from, to, &dimension);
|
||||
} else {
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.native_player_teleported(uuid, &name, from, to, &dimension);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The players the command just run sends to another level
|
||||
/// (`ServerPlayer.teleport` from TeleportCommand): a Respawn packet
|
||||
/// keeping all data and the teleport, with no portal sound. The server
|
||||
@@ -725,6 +759,7 @@ impl Game<'_> {
|
||||
log::error!("could not open {}: {e}", t.dim.id());
|
||||
continue;
|
||||
}
|
||||
let (uuid, name) = (host.hub.guests[i].uuid, host.hub.guests[i].name.clone());
|
||||
let g = &mut host.hub.guests[i];
|
||||
let [yaw, pitch] = t.rotation.unwrap_or([g.yaw, g.pitch]);
|
||||
let origin = g.position;
|
||||
@@ -735,7 +770,12 @@ impl Game<'_> {
|
||||
let ctx = self.server_ctx();
|
||||
host.hub
|
||||
.change_level(i, t.at, yaw, Some(pitch), false, cooldown, &ctx);
|
||||
self.as_guest(host, t.conn, |g| g.trigger_dimension(source, t.dim, origin));
|
||||
self.as_guest(host, t.conn, |g| {
|
||||
g.trigger_dimension(source, t.dim, origin);
|
||||
// The mods hear the teleport into another level, with the
|
||||
// player's state swapped in.
|
||||
g.native_player_teleported(uuid, &name, origin, t.at, t.dim.id());
|
||||
});
|
||||
self.server_trips.retain(|trip| !trip.is(t.conn));
|
||||
self.server_trips.push(Trip {
|
||||
conn: t.conn,
|
||||
|
||||
@@ -32,7 +32,7 @@ use super::{
|
||||
start_game,
|
||||
};
|
||||
use horizon_client::gamepad::Button;
|
||||
use horizon_client::split_layout::{self as layout, MAX_PLAYERS, Placement, View};
|
||||
use horizon_client::split_layout::{self as layout, MAX_PLAYERS, Placement, Spot, View};
|
||||
use horizon_client::{Result, gfx::compositor::Compositor, widgets};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
@@ -52,6 +52,7 @@ use winit::{
|
||||
mod hud;
|
||||
mod pads;
|
||||
mod players;
|
||||
mod setup;
|
||||
mod windows;
|
||||
|
||||
pub(super) use hud::Hud;
|
||||
@@ -82,14 +83,6 @@ impl Game<'_> {
|
||||
const WINDOW_1: &str = "Window 1";
|
||||
const NEW_WINDOW: &str = "New window";
|
||||
|
||||
/// A place offered while picking a profile.
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum Spot {
|
||||
Window(u8),
|
||||
New,
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
struct Guest<'a> {
|
||||
game: Box<Game<'a>>,
|
||||
/// The profile it plays as; `None` while it picks one.
|
||||
@@ -125,6 +118,8 @@ pub(super) struct Split<'a> {
|
||||
p1_window: u8,
|
||||
/// Seats in the order their views fill a shared window.
|
||||
order: Vec<usize>,
|
||||
/// Seats represented by the open Setup draft, to detect join/leave changes.
|
||||
setup_seats: Vec<usize>,
|
||||
/// Whether Window 1 is shown (hidden while nobody plays in it): set once per change, since
|
||||
/// Wayland can't say whether a window is visible.
|
||||
window1_shown: bool,
|
||||
@@ -179,6 +174,7 @@ impl<'a> Split<'a> {
|
||||
}],
|
||||
p1_window: 1,
|
||||
order: Vec::new(),
|
||||
setup_seats: Vec::new(),
|
||||
window1_shown: true,
|
||||
window_rects: Default::default(),
|
||||
screens: Vec::new(),
|
||||
@@ -410,6 +406,7 @@ impl<'a> Split<'a> {
|
||||
}
|
||||
};
|
||||
self.host.renderer = Some(renderer);
|
||||
self.host.split_window = !self.host.options.offscreen;
|
||||
self.host.options.offscreen = true;
|
||||
self.host.options.paced_by_split = true;
|
||||
// The new renderer takes the old one's present mode, settings and all: P1's again.
|
||||
@@ -437,6 +434,7 @@ impl<'a> Split<'a> {
|
||||
}
|
||||
self.applied.clear();
|
||||
self.order.clear();
|
||||
self.setup_seats.clear();
|
||||
if !watching {
|
||||
self.stop_routing();
|
||||
}
|
||||
@@ -479,6 +477,7 @@ impl<'a> Split<'a> {
|
||||
drop(old);
|
||||
self.host.renderer = Some(renderer);
|
||||
self.host.options.offscreen = false;
|
||||
self.host.split_window = false;
|
||||
self.host.options.paced_by_split = false;
|
||||
// Alone again: the pointer is P1's, its view the whole window.
|
||||
self.focus = 0;
|
||||
@@ -705,7 +704,7 @@ impl ApplicationHandler for Split<'_> {
|
||||
self.screens_read = Some(Instant::now());
|
||||
self.refresh_monitors();
|
||||
}
|
||||
self.host.ui.options.split_joined = self.guests.iter().any(|g| g.name.is_some());
|
||||
self.host.split_hud.menu = self.guests.iter().any(|g| g.name.is_some());
|
||||
// Off, or the last other player gone: P1 draws on its window again.
|
||||
if !self.surfaces.is_empty()
|
||||
&& (!on || self.guests.is_empty())
|
||||
@@ -714,6 +713,7 @@ impl ApplicationHandler for Split<'_> {
|
||||
log::warn!("Horizon: splitscreen could not switch back: {e}");
|
||||
}
|
||||
if self.surfaces.is_empty() {
|
||||
self.sync_setup();
|
||||
if !on {
|
||||
self.stop_routing();
|
||||
return self.host.about_to_wait(event_loop);
|
||||
@@ -740,6 +740,7 @@ impl ApplicationHandler for Split<'_> {
|
||||
let mut changed = self.drop_out() | self.admit(event_loop, pad_joins);
|
||||
self.sync_profiles();
|
||||
changed |= self.pick_profiles();
|
||||
changed |= self.sync_setup();
|
||||
changed |= self.sync_surfaces(event_loop);
|
||||
if changed || self.views() != self.applied {
|
||||
self.relayout(event_loop);
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::time::Duration;
|
||||
/// moment, with why), a question its controller answers, and notices nobody has to answer.
|
||||
#[derive(Default)]
|
||||
pub(in crate::game) struct Hud {
|
||||
pub(in crate::game) menu: bool,
|
||||
pub(super) badge: Option<String>,
|
||||
pub(super) place: Option<(String, Instant)>,
|
||||
pub(super) prompt: Option<Prompt>,
|
||||
@@ -77,9 +78,14 @@ pub(super) fn lost_prompt(name: &str) -> Prompt {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Another player's pause menu opens its profiles to switch identity.
|
||||
/// P1 opens Setup; another player opens profiles to switch identity.
|
||||
pub(in crate::game) fn open_split_settings(&mut self) {
|
||||
self.open_options_at(false, horizon_client::options_ui::Page::SplitProfiles);
|
||||
let page = if self.options.split_guest {
|
||||
horizon_client::options_ui::Page::SplitProfiles
|
||||
} else {
|
||||
horizon_client::options_ui::Page::SplitSetup
|
||||
};
|
||||
self.open_options_at(false, page);
|
||||
}
|
||||
|
||||
/// Splitscreen over this player's view, above the other toasts (`ToastManager`'s slots).
|
||||
|
||||
@@ -330,6 +330,7 @@ impl Split<'_> {
|
||||
guest.window = Some(window);
|
||||
guest.world_renderer_ready = true;
|
||||
guest.keyboard_mouse = false;
|
||||
guest.split_window = host.split_window;
|
||||
guest.apply_settings();
|
||||
// Its pad comes from the split (`route_pads`): it never opens the controllers itself.
|
||||
guest.pad.feed = Some(PadState::default());
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! P1's Setup screen's side in the game: the arrangement as a draft, and Apply.
|
||||
use super::*;
|
||||
|
||||
impl<'a> Split<'a> {
|
||||
/// The arrangement now, for the Setup screen: free windows where they are.
|
||||
pub(super) fn draft(&self) -> layout::Draft {
|
||||
// Wayland never says where a window is: those are shown at their real size, side by side
|
||||
// from the first monitor's corner, so the map never stacks them.
|
||||
let mut shelf = self.monitors().first().map_or(0, |(_, m)| m.position().x);
|
||||
let windows = self
|
||||
.windows
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let here = self.surfaces.iter().find(|s| s.key == w.number);
|
||||
let placement = match (&w.placement, here) {
|
||||
(Placement::Free(_), Some(s)) => {
|
||||
// On the desktop as windows are placed: macOS in points.
|
||||
let k = if cfg!(target_os = "macos") {
|
||||
s.window.scale_factor()
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let at = match s.window.outer_position() {
|
||||
Ok(at) => [(f64::from(at.x) / k) as i32, (f64::from(at.y) / k) as i32],
|
||||
Err(_) => {
|
||||
let y = self.monitors().first().map_or(0, |(_, m)| m.position().y);
|
||||
let at = [shelf, y];
|
||||
shelf += (f64::from(s.size.width) / k) as i32 + 16;
|
||||
at
|
||||
}
|
||||
};
|
||||
Placement::Free(Some((at, [s.size.width, s.size.height])))
|
||||
}
|
||||
(p, _) => p.clone(),
|
||||
};
|
||||
layout::Window {
|
||||
number: w.number,
|
||||
placement,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
layout::Draft {
|
||||
windows,
|
||||
players: (0..self.players()).map(|i| self.window_number(i)).collect(),
|
||||
two: 0,
|
||||
three: 0,
|
||||
order: self.player_order(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The monitors for the Setup screen, left to right (as `monitor_label` numbers them), and
|
||||
/// the one Window 1 is on.
|
||||
pub(super) fn setup_monitors(&self) -> (Vec<layout::Monitor>, String) {
|
||||
let monitors = self
|
||||
.monitors()
|
||||
.iter()
|
||||
.map(|(name, m)| layout::Monitor {
|
||||
name: name.clone(),
|
||||
at: [m.position().x, m.position().y],
|
||||
size: [m.size().width, m.size().height],
|
||||
scale: m.scale_factor(),
|
||||
})
|
||||
.collect();
|
||||
(monitors, self.main_monitor().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The Setup screen applied: its windows, and every player in its window. Windows nobody
|
||||
/// plays in close (`forget_empty_windows`).
|
||||
pub(super) fn apply(&mut self, draft: layout::Draft) {
|
||||
let mut windows: Vec<layout::Window> = draft
|
||||
.windows
|
||||
.into_iter()
|
||||
.map(|w| {
|
||||
// A free window stays where it is; a new one opens where the system puts it.
|
||||
let placement = match (&w.placement, self.placement(w.number)) {
|
||||
(Placement::Free(_), Some(Placement::Free(p))) => Placement::Free(*p),
|
||||
(Placement::Free(_), _) => Placement::Free(None),
|
||||
(p, _) => p.clone(),
|
||||
};
|
||||
layout::Window {
|
||||
number: w.number,
|
||||
placement,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !windows.iter().any(|w| w.number == 1) {
|
||||
windows.insert(
|
||||
0,
|
||||
layout::Window {
|
||||
number: 1,
|
||||
placement: Placement::Free(None),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.windows = windows;
|
||||
// Players who joined while it was open stay where they are.
|
||||
for (i, &number) in draft.players.iter().enumerate().take(self.players()) {
|
||||
let number = if self.placement(number).is_some() {
|
||||
number
|
||||
} else {
|
||||
1
|
||||
};
|
||||
self.set_window(i, number);
|
||||
}
|
||||
// Who is first in a shared window, by seat (players are by index in the draft).
|
||||
let n = self.players();
|
||||
self.order = layout::ordered(&draft.order, n)
|
||||
.into_iter()
|
||||
.map(|i| self.seat_of(i))
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// P1's Setup screen: the arrangement now, the monitors and the players' names; Apply
|
||||
/// arranges them. True when it did.
|
||||
pub(super) fn sync_setup(&mut self) -> bool {
|
||||
use horizon_client::options_ui::Page;
|
||||
if self.host.ui.options.page() != Page::SplitSetup {
|
||||
return false;
|
||||
}
|
||||
// Window/fullscreen changes during P1's turn can invalidate Windows monitor handles.
|
||||
self.refresh_monitors();
|
||||
let live = self.draft();
|
||||
let (monitors, main) = self.setup_monitors();
|
||||
let names = (0..self.players()).map(|i| self.name(i)).collect();
|
||||
let seats: Vec<usize> = (0..self.players()).map(|i| self.seat_of(i)).collect();
|
||||
let moved = std::mem::replace(&mut self.setup_seats, seats) != self.setup_seats;
|
||||
let setup = &mut self.host.ui.options.split_setup;
|
||||
// Someone joined or left: the draft would put the players in the wrong places.
|
||||
if moved {
|
||||
setup.reset();
|
||||
}
|
||||
let applied = setup.apply.take();
|
||||
let setup = &mut self.host.ui.options.split_setup;
|
||||
setup.idle = self.surfaces.is_empty();
|
||||
setup.live = Some(live);
|
||||
setup.monitors = monitors;
|
||||
setup.main = main;
|
||||
setup.names = names;
|
||||
let changed = applied.is_some();
|
||||
if let Some(draft) = applied {
|
||||
self.apply(draft);
|
||||
}
|
||||
// Every view says where it is: after a change, or asked.
|
||||
if changed {
|
||||
for i in 0..self.players() {
|
||||
let label = horizon_client::lang::tr(
|
||||
"horizon.splitscreen.setup.window",
|
||||
&[&self.window_of(i)],
|
||||
);
|
||||
self.tag(i, &label);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
@@ -65,9 +65,13 @@ pub(super) fn above_menu_bar(title: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Player `game` into `window`; when it `moved` from another, the mouse it held there goes free
|
||||
/// (a click takes it in the new one).
|
||||
pub(super) fn into_window(game: &mut Game<'_>, window: Arc<Window>, moved: bool) {
|
||||
/// Move a player into a native window. Changing only its viewport keeps the grab;
|
||||
/// moving to another window releases it until a click there.
|
||||
pub(super) fn into_window(game: &mut Game<'_>, window: Arc<Window>) {
|
||||
let moved = game
|
||||
.window
|
||||
.as_ref()
|
||||
.is_none_or(|old| old.id() != window.id());
|
||||
if moved && game.captured {
|
||||
game.capture(false);
|
||||
}
|
||||
@@ -311,9 +315,12 @@ impl<'a> Split<'a> {
|
||||
let Some(window) = self.surfaces.get(surface).map(|s| s.window.clone()) else {
|
||||
continue;
|
||||
};
|
||||
let moved = before.get(i).is_none_or(|b| b.0 != surface);
|
||||
let moved = self
|
||||
.game(i)
|
||||
.and_then(|g| g.window.as_ref())
|
||||
.is_none_or(|old| old.id() != window.id());
|
||||
if let Some(game) = self.game_mut(i) {
|
||||
into_window(game, window.clone(), moved);
|
||||
into_window(game, window.clone());
|
||||
game.options.split_in_main_window = surface == 0;
|
||||
game.view_at = at;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,31 @@ impl PrimedTnt {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Asks the mods whether the TNT at `pos` may be primed
|
||||
/// (`native_tnt_primed`); the acting guest, if any, is its player.
|
||||
pub(super) fn tnt_prime_allowed(&mut self, pos: BlockPos) -> bool {
|
||||
let mut cancel = false;
|
||||
self.native_tnt_primed(self.lan_acting_uuid, [pos.x, pos.y, pos.z], &mut cancel);
|
||||
!cancel
|
||||
}
|
||||
/// Primes TNT a level event announced, or, when the mods refuse, puts
|
||||
/// back the block the fire or signal had already taken out.
|
||||
pub(super) fn prime_tnt_event(&mut self, pos: BlockPos, rot: f64) {
|
||||
if self.tnt_prime_allowed(pos) {
|
||||
self.add_primed_tnt(pos, rot, 80, None, None);
|
||||
return;
|
||||
}
|
||||
// Refused: the level queued the fuse sound and the game event right
|
||||
// after the prime; they are dropped as they come up.
|
||||
self.tnt_refused = Some(pos);
|
||||
let Some(tnt) = self.pack.assets.registry.default_state("minecraft:tnt") else {
|
||||
return;
|
||||
};
|
||||
if self.world.block(pos.x, pos.y, pos.z) == self.world.air {
|
||||
let flags = level_flags::UPDATE_CLIENTS | level_flags::UPDATE_SKIP_ON_PLACE;
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, pos, tnt, flags));
|
||||
}
|
||||
}
|
||||
/// `new PrimedTnt(level, x + 0.5, y, z + 0.5, owner)`; `short` applies
|
||||
/// `wasExploded`'s random short fuse. Plays `entity.tnt.primed` for primes.
|
||||
pub(super) fn spawn_primed_tnt(
|
||||
@@ -263,6 +288,9 @@ impl Game<'_> {
|
||||
return Ok(false);
|
||||
}
|
||||
let pos = BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
if !self.tnt_prime_allowed(pos) {
|
||||
return Ok(true);
|
||||
}
|
||||
let guest = self.lan_acting;
|
||||
self.spawn_primed_tnt(pos, false, Some(Who::Player), guest);
|
||||
let air = self.world.air;
|
||||
|
||||
@@ -29,10 +29,14 @@ impl Game<'_> {
|
||||
/// count it, set health to 1, then clear and apply its effects. Returns
|
||||
/// the player's body, for entity event 35, which the caller broadcasts.
|
||||
pub(super) fn apply_totem(&mut self) -> Option<Body> {
|
||||
let p = self.player.as_mut()?;
|
||||
if !p.health.totem_used {
|
||||
if !self.player.as_ref()?.health.totem_used {
|
||||
return None;
|
||||
}
|
||||
// The mods hear a server player's totem before it is used up.
|
||||
if let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_totem(uuid, &name);
|
||||
}
|
||||
let p = self.player.as_mut()?;
|
||||
p.health.totem_used = false;
|
||||
let _ = p.set_health(1.0, false);
|
||||
let body = Body {
|
||||
|
||||
@@ -388,6 +388,10 @@ pub(super) fn preload_world_data(
|
||||
// seed shares: about 140 ms off opening the world.
|
||||
let _ = data.prepare_decoration("minecraft:normal", "minecraft:overworld");
|
||||
}
|
||||
// The loot data's item tables and the server's data both
|
||||
// read the recipe folder: held here, it is read once.
|
||||
let _recipe_files =
|
||||
horizon_client::json_folder::read(&resources.join("data/minecraft/recipe"));
|
||||
// The preload is the game's own data; the world's packs'
|
||||
// tables are built when it opens (finish_open_world).
|
||||
let _ = horizon_client::structure_blocks::loot_data(
|
||||
@@ -459,6 +463,10 @@ pub(super) fn preload_launch_world(
|
||||
let preset = horizon_client::save::WorldGen::preset(&world_root);
|
||||
let _ = data.prepare_decoration(preset, "minecraft:overworld");
|
||||
}
|
||||
// As in `preload_world_data`: the loot data and the server's
|
||||
// data share one read of the recipe folder.
|
||||
let _recipe_files =
|
||||
horizon_client::json_folder::read(&resources.join("data/minecraft/recipe"));
|
||||
let _ = horizon_client::structure_blocks::loot_data(
|
||||
&resources,
|
||||
&horizon_world::loot::PackOverlay::NONE,
|
||||
|
||||
@@ -208,6 +208,10 @@ impl SyncData {
|
||||
resources: &Path,
|
||||
overlay: &horizon_world::loot::PackOverlay,
|
||||
) -> Result<Self, String> {
|
||||
// The recipe book, the recipe sync and the advancements read the
|
||||
// recipe folder: held here, they share one read of it.
|
||||
let _recipe_files =
|
||||
horizon_world::json_folder::read(&resources.join("data/minecraft/recipe"));
|
||||
let builtin = Builtin::find(resources)
|
||||
.and_then(|p| Builtin::load(&p).ok())
|
||||
.ok_or("registries.json not found next to the resources")?;
|
||||
|
||||
@@ -296,6 +296,9 @@ pub struct Guest {
|
||||
pub death_time: u32,
|
||||
/// The join packets were sent (PlayerList.placeNewPlayer ran).
|
||||
pub placed: bool,
|
||||
/// Root command names the mods took out of this player's command tree
|
||||
/// (`native_player_commands_sent`); their tree is sent without them.
|
||||
pub hidden_commands: Vec<String>,
|
||||
/// `Abilities.flyingSpeed`: vanilla's 0.05, unless a server mod set
|
||||
/// another (`native_set_flying_speed`). Saved with the player.
|
||||
pub flying_speed: f32,
|
||||
@@ -456,6 +459,7 @@ impl Guest {
|
||||
dead: false,
|
||||
death_time: 0,
|
||||
placed: false,
|
||||
hidden_commands: Vec::new(),
|
||||
flying_speed: 0.05,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
menu: None,
|
||||
|
||||
@@ -1162,14 +1162,42 @@ impl Hub {
|
||||
|
||||
/// The permissions changed: tell everyone their level and commands again.
|
||||
pub fn resend_commands(&mut self) {
|
||||
self.refresh_permissions();
|
||||
for i in 0..self.guests.len() {
|
||||
self.guests[i].permission = self.default_permission(self.guests[i].uuid);
|
||||
if self.guests[i].placed {
|
||||
self.send_permission_level(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every guest's level from the operator list and the settings, the first
|
||||
/// half of [`Hub::resend_commands`].
|
||||
pub fn refresh_permissions(&mut self) {
|
||||
for i in 0..self.guests.len() {
|
||||
self.guests[i].permission = self.default_permission(self.guests[i].uuid);
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of the root commands the tree `conn`'s player would get
|
||||
/// holds (before the mods take any out), in the tree's order.
|
||||
pub fn command_names(&self, conn: u32) -> Vec<String> {
|
||||
let Some(g) = self.guest(conn) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let packet = self.commands_packet(g.permission);
|
||||
packet
|
||||
.nodes
|
||||
.get(packet.root as usize)
|
||||
.map(|root| {
|
||||
root.children
|
||||
.iter()
|
||||
.filter_map(|&c| packet.nodes.get(c as usize))
|
||||
.map(|n| n.name.clone())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether vanilla's command tree has a command of this name.
|
||||
pub fn vanilla_command(&self, name: &str) -> bool {
|
||||
self.sync.commands.as_ref().is_some_and(|t| t.has(name))
|
||||
@@ -1196,11 +1224,31 @@ impl Hub {
|
||||
/// Commands.sendCommands: the command tree for this player.
|
||||
fn send_commands(&self, i: usize) {
|
||||
let g = &self.guests[i];
|
||||
let packet = match &self.sync.commands {
|
||||
let mut packet = self.commands_packet(g.permission);
|
||||
// What the mods took out is not in the tree the client gets.
|
||||
if !g.hidden_commands.is_empty() {
|
||||
let hidden: Vec<i32> = packet
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, n)| n.kind == 1 && g.hidden_commands.contains(&n.name))
|
||||
.map(|(i, _)| i as i32)
|
||||
.collect();
|
||||
let root = packet.root as usize;
|
||||
if let Some(root) = packet.nodes.get_mut(root) {
|
||||
root.children.retain(|c| !hidden.contains(c));
|
||||
}
|
||||
}
|
||||
g.send(&C::Commands(packet));
|
||||
}
|
||||
|
||||
/// The command tree a player of this permission level gets.
|
||||
fn commands_packet(&self, permission: u8) -> play::Commands {
|
||||
match &self.sync.commands {
|
||||
Some(tree) => {
|
||||
let dedicated = self.config.dedicated && !self.config.integrated;
|
||||
tree.packet_with(
|
||||
g.permission,
|
||||
permission,
|
||||
dedicated,
|
||||
&self.sync.builtin,
|
||||
&self.mod_commands.0,
|
||||
@@ -1219,8 +1267,7 @@ impl Hub {
|
||||
}],
|
||||
root: 0,
|
||||
},
|
||||
};
|
||||
g.send(&C::Commands(packet));
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.teleport: move the client and wait for its confirmation.
|
||||
@@ -2976,7 +3023,15 @@ impl Hub {
|
||||
events.push(HostEvent::Packet(conn, Box::new(S::PlayerCommand(p))));
|
||||
}
|
||||
S::PlayerCommand(p) => match p.action {
|
||||
1 | 2 => self.set_sprinting(i, p.action == 1),
|
||||
1 | 2 => {
|
||||
let sprinting = p.action == 1;
|
||||
let changed = self.guests[i].sprinting != sprinting;
|
||||
self.set_sprinting(i, sprinting);
|
||||
// The game tells the mods of a change.
|
||||
if changed {
|
||||
events.push(HostEvent::Packet(conn, Box::new(S::PlayerCommand(p))));
|
||||
}
|
||||
}
|
||||
// STOP_SLEEPING.
|
||||
0 => self.wake(i),
|
||||
_ => {}
|
||||
|
||||
@@ -52,17 +52,16 @@ fn ingredient(v: &Value) -> Option<Ingredient> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every recipe file, by recipe id (the registry's order).
|
||||
pub(super) fn recipes(dir: &Path, prefix: &str, out: &mut BTreeMap<String, Value>) {
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if path.is_dir() {
|
||||
recipes(&path, &format!("{prefix}{name}/"), out);
|
||||
} else if let Some(stem) = name.strip_suffix(".json")
|
||||
&& let Ok(v) = serde_json::from_slice(&std::fs::read(&path).unwrap_or_default())
|
||||
{
|
||||
out.insert(format!("minecraft:{prefix}{stem}"), v);
|
||||
/// Every recipe file, by recipe id (the registry's order): read through
|
||||
/// `json_folder`, so a load holding the folder shares one read of it. A file
|
||||
/// that can't be read or doesn't parse is left out.
|
||||
pub(super) fn recipes(dir: &Path, out: &mut BTreeMap<String, Value>) {
|
||||
let Ok(folder) = horizon_world::json_folder::read(dir) else {
|
||||
return;
|
||||
};
|
||||
for file in &folder.files {
|
||||
if let Ok(v) = serde_json::from_slice(file.bytes().unwrap_or_default()) {
|
||||
out.insert(format!("minecraft:{}", file.name), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +93,7 @@ pub(super) fn network_recipes(
|
||||
overlay: &horizon_world::loot::PackOverlay,
|
||||
) -> (BTreeMap<String, Box<str>>, HashMap<String, i32>) {
|
||||
let mut all = BTreeMap::new();
|
||||
recipes(&resources.join("data/minecraft/recipe"), "", &mut all);
|
||||
recipes(&resources.join("data/minecraft/recipe"), &mut all);
|
||||
overlay_recipes(overlay, &mut all);
|
||||
// The data's recipe filters, as the server's own tables apply them
|
||||
// (#5910), once, before the ingredient normalization, so a swapped
|
||||
|
||||
@@ -406,7 +406,7 @@ pub fn native_menu_slot_rules(
|
||||
may_place: &mut bool,
|
||||
max_stack: &mut u32,
|
||||
) {
|
||||
let _ = (menu, slot, stack, may_place, max_stack);
|
||||
crate::native_client_hooks::menu_slot_rules(menu, slot, stack, may_place, max_stack);
|
||||
}
|
||||
|
||||
/// An index outside the slot list: vanilla's `IndexOutOfBoundsException`.
|
||||
@@ -3459,6 +3459,31 @@ pub(crate) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_slot_rules_change_mod_menu_placement() {
|
||||
let installed =
|
||||
crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
|
||||
menu_slot_rules: |menu, slot, _, may_place, max_stack| {
|
||||
if menu == "test:rules" && slot == 0 {
|
||||
*may_place = false;
|
||||
*max_stack = 7;
|
||||
}
|
||||
},
|
||||
living_breathe: |_, _, _, _, _| {},
|
||||
ingredient: |_, _, _| {},
|
||||
});
|
||||
assert!(installed.is_ok());
|
||||
let kind = Kind::Mod(Box::leak(Box::new(crate::containers::ModMenuType {
|
||||
id: "test:rules",
|
||||
slots: 1,
|
||||
data_slots: 0,
|
||||
results: vec![],
|
||||
})));
|
||||
let menu = Menu::new(kind);
|
||||
let stack = ItemStack::new("minecraft:stone", 1);
|
||||
assert_eq!(menu.mod_slot(0, &stack), (false, 7));
|
||||
}
|
||||
|
||||
/// A mod menu type: three slots, then a result (slot 3).
|
||||
fn mod_menu() -> Kind {
|
||||
Kind::Mod(Box::leak(Box::new(crate::containers::ModMenuType {
|
||||
|
||||
@@ -73,6 +73,7 @@ pub mod render;
|
||||
pub mod riders;
|
||||
pub mod seen_grid;
|
||||
pub mod shulker;
|
||||
pub mod skin;
|
||||
pub mod sniffer;
|
||||
pub mod spawn_rules;
|
||||
pub mod spawn_selection;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! (quadrupeds, humanoids, creepers, spiders, chickens...) applied to a map of
|
||||
//! part poses before the model is drawn.
|
||||
use super::{Mob, Part, keyframes::AnimationState, living::wrap};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
use std::collections::HashMap;
|
||||
use std::f32::consts::{FRAC_PI_6, PI};
|
||||
@@ -101,15 +102,27 @@ impl AnimState {
|
||||
}
|
||||
pub fn end_tick(&mut self, _swinging: bool) {}
|
||||
}
|
||||
/// A part name as `Poses` keys it: its Fx hash, so filling a pose map allocates
|
||||
/// nothing (a mob farm resets one per layer per mob each frame). Names are short
|
||||
/// and few per model; `no_two_part_names_share_a_key` checks every vanilla model.
|
||||
#[inline]
|
||||
pub(crate) fn key(name: &str) -> u64 {
|
||||
use std::hash::Hasher;
|
||||
let mut h = crate::fast_hash::FxHasher::default();
|
||||
h.write(name.as_bytes());
|
||||
// The length too, so "a" and "a\0" differ (str's own Hash adds a terminator).
|
||||
h.write_usize(name.len());
|
||||
h.finish()
|
||||
}
|
||||
/// Part poses for one draw, keyed by part name.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Poses {
|
||||
map: HashMap<String, [f32; 9]>,
|
||||
map: FxHashMap<u64, [f32; 9]>,
|
||||
dummy: [f32; 9],
|
||||
/// Parts with `skipDraw` set: their own cubes are not drawn, children are.
|
||||
skipped: std::collections::HashSet<String>,
|
||||
skipped: FxHashSet<u64>,
|
||||
/// Parts with `visible` cleared: neither they nor their children draw.
|
||||
hidden: std::collections::HashSet<String>,
|
||||
hidden: FxHashSet<u64>,
|
||||
}
|
||||
impl Poses {
|
||||
pub fn new(model: &Part) -> Self {
|
||||
@@ -120,9 +133,9 @@ impl Poses {
|
||||
/// Refills this for another model, keeping the map and the sets' capacity:
|
||||
/// a mob draws its body and every equipment layer each frame.
|
||||
pub fn reset(&mut self, model: &Part) {
|
||||
fn walk(p: &Part, map: &mut HashMap<String, [f32; 9]>) {
|
||||
fn walk(p: &Part, map: &mut FxHashMap<u64, [f32; 9]>) {
|
||||
// ModelPart.createPartLookup fills its map with putIfAbsent: the first part keeps a name.
|
||||
map.entry(p.name.clone()).or_insert(p.pose);
|
||||
map.entry(key(&p.name)).or_insert(p.pose);
|
||||
for c in &p.children {
|
||||
walk(c, map);
|
||||
}
|
||||
@@ -134,26 +147,26 @@ impl Poses {
|
||||
walk(model, &mut self.map);
|
||||
}
|
||||
pub fn get(&mut self, name: &str) -> &mut [f32; 9] {
|
||||
match self.map.get_mut(name) {
|
||||
match self.map.get_mut(&key(name)) {
|
||||
Some(p) => p,
|
||||
None => &mut self.dummy,
|
||||
}
|
||||
}
|
||||
pub fn pose(&self, part: &Part) -> [f32; 9] {
|
||||
self.map.get(&part.name).copied().unwrap_or(part.pose)
|
||||
self.map.get(&key(&part.name)).copied().unwrap_or(part.pose)
|
||||
}
|
||||
pub fn skip_draw(&mut self, name: &str) {
|
||||
self.skipped.insert(name.to_owned());
|
||||
self.skipped.insert(key(name));
|
||||
}
|
||||
pub fn skips(&self, part: &Part) -> bool {
|
||||
self.skips_named(&part.name)
|
||||
}
|
||||
/// `skips` by the part's name.
|
||||
pub fn skips_named(&self, name: &str) -> bool {
|
||||
self.skipped.contains(name)
|
||||
!self.skipped.is_empty() && self.skipped.contains(&key(name))
|
||||
}
|
||||
pub fn has(&self, name: &str) -> bool {
|
||||
self.map.contains_key(name)
|
||||
self.map.contains_key(&key(name))
|
||||
}
|
||||
pub fn xrot(&mut self, n: &str, v: f32) {
|
||||
self.get(n)[3] = v;
|
||||
@@ -167,9 +180,9 @@ impl Poses {
|
||||
/// `ModelPart.visible = v`; a hidden part and its children do not draw.
|
||||
pub fn visible(&mut self, n: &str, v: bool) {
|
||||
if v {
|
||||
self.hidden.remove(n);
|
||||
} else if self.map.contains_key(n) {
|
||||
self.hidden.insert(n.to_owned());
|
||||
self.hidden.remove(&key(n));
|
||||
} else if self.map.contains_key(&key(n)) {
|
||||
self.hidden.insert(key(n));
|
||||
}
|
||||
}
|
||||
pub fn is_hidden(&self, part: &Part) -> bool {
|
||||
@@ -177,7 +190,7 @@ impl Poses {
|
||||
}
|
||||
/// `is_hidden` by the part's name.
|
||||
pub fn is_hidden_named(&self, name: &str) -> bool {
|
||||
self.hidden.contains(name)
|
||||
!self.hidden.is_empty() && self.hidden.contains(&key(name))
|
||||
}
|
||||
}
|
||||
/// The LivingEntityRenderState values setupAnim reads.
|
||||
@@ -1003,7 +1016,7 @@ mod flight_pose_tests {
|
||||
"left_leg",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1011,13 +1024,13 @@ mod flight_pose_tests {
|
||||
};
|
||||
player_model_visibility(&mut p, 0, true);
|
||||
for part in ["body", "right_arm", "left_arm", "right_leg", "left_leg"] {
|
||||
assert!(p.hidden.contains(part), "{part}");
|
||||
assert!(p.hidden.contains(&key(part)), "{part}");
|
||||
}
|
||||
assert!(!p.hidden.contains("head") && !p.hidden.contains("hat"));
|
||||
assert!(!p.hidden.contains(&key("head")) && !p.hidden.contains(&key("hat")));
|
||||
// Back in another mode they all show, but for the layers the player hid.
|
||||
player_model_visibility(&mut p, 1 << 6, false);
|
||||
assert!(!p.hidden.contains("body") && !p.hidden.contains("left_leg"));
|
||||
assert!(p.hidden.contains("hat"));
|
||||
assert!(!p.hidden.contains(&key("body")) && !p.hidden.contains(&key("left_leg")));
|
||||
assert!(p.hidden.contains(&key("hat")));
|
||||
}
|
||||
#[test]
|
||||
fn blocking_arm_follows_the_head_with_limits_in_both_hands() {
|
||||
@@ -1030,7 +1043,7 @@ mod flight_pose_tests {
|
||||
let mut p = Poses {
|
||||
map: ["head", "right_arm", "left_arm"]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1087,7 +1100,7 @@ mod flight_pose_tests {
|
||||
"left_leg",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1142,7 +1155,7 @@ mod flight_pose_tests {
|
||||
"body",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1183,7 +1196,7 @@ mod flight_pose_tests {
|
||||
let make = || Poses {
|
||||
map: ["head", "right_arm", "left_arm", "right_leg", "left_leg"]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1289,7 +1302,7 @@ mod flight_pose_tests {
|
||||
let mut p = Poses {
|
||||
map: ["head", "right_arm", "left_arm"]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1310,7 +1323,7 @@ mod flight_pose_tests {
|
||||
let mut p = Poses {
|
||||
map: ["head", "right_arm", "left_arm"]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1338,7 +1351,7 @@ mod flight_pose_tests {
|
||||
let make = || Poses {
|
||||
map: ["head", "right_arm", "left_arm"]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
|
||||
@@ -1278,15 +1278,7 @@ fn draw_normals(
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let p = poses.pose(part);
|
||||
let m = base
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(p[0], p[1], p[2]) / 16.0)
|
||||
* glam::Mat4::from_quat(glam::Quat::from_euler(
|
||||
glam::EulerRot::ZYX,
|
||||
p[5],
|
||||
p[4],
|
||||
p[3],
|
||||
))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]));
|
||||
let m = posed(base, p);
|
||||
if poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
|
||||
return;
|
||||
}
|
||||
@@ -1324,6 +1316,46 @@ fn draw_normals(
|
||||
draw_normals(c, m, normals, poses, uv, scroll, color, light, overlay, out);
|
||||
}
|
||||
}
|
||||
/// A part's matrix under `base` for its pose (`ModelPart.translateAndRotate`
|
||||
/// and its scale), as `draw` and the GPU path (`super::skin`) both use it.
|
||||
pub(super) fn posed(base: glam::Mat4, p: [f32; 9]) -> glam::Mat4 {
|
||||
base * glam::Mat4::from_translation(glam::Vec3::new(p[0], p[1], p[2]) / 16.0)
|
||||
* glam::Mat4::from_quat(glam::Quat::from_euler(
|
||||
glam::EulerRot::ZYX,
|
||||
p[5],
|
||||
p[4],
|
||||
p[3],
|
||||
))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]))
|
||||
}
|
||||
/// `draw_scrolled` through the GPU path when the mob takes it (`skin`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn put(
|
||||
skin: &mut Option<&mut super::skin::Skin>,
|
||||
part: &std::sync::Arc<Part>,
|
||||
base: glam::Mat4,
|
||||
poses: &Poses,
|
||||
uv: [f32; 4],
|
||||
scroll: f32,
|
||||
color: u32,
|
||||
light: u32,
|
||||
overlay: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let paint = super::skin::Paint {
|
||||
uv,
|
||||
scroll,
|
||||
color,
|
||||
light,
|
||||
overlay,
|
||||
};
|
||||
if let Some(skin) = skin
|
||||
&& skin.draw(part, base, poses, paint)
|
||||
{
|
||||
return;
|
||||
}
|
||||
draw_scrolled(part, base, poses, uv, scroll, color, light, overlay, out);
|
||||
}
|
||||
/// Render a mob with its variant model, texture and layers.
|
||||
pub fn render(
|
||||
m: &Mob,
|
||||
@@ -1360,7 +1392,7 @@ pub fn render_with(
|
||||
let shifted =
|
||||
camera - glam::DVec3::new(off[0] + w[0], off[1] + w[1], off[2] + w[2]);
|
||||
let base = base_transform(m, alpha, shifted);
|
||||
render_visible(m, alpha, base, atlas, light, data, extra, true, out);
|
||||
render_visible(m, alpha, base, atlas, light, data, extra, true, out, None);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -1375,6 +1407,37 @@ pub fn render_with(
|
||||
extra,
|
||||
true,
|
||||
out,
|
||||
None,
|
||||
);
|
||||
}
|
||||
/// `render` with the model's ordinary draws posed on the GPU when the mob
|
||||
/// allows it (`super::skin`): the same picture, no vertices on the CPU.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_skinned(
|
||||
m: &Mob,
|
||||
alpha: f32,
|
||||
camera: glam::DVec3,
|
||||
atlas: Atlas,
|
||||
light: u32,
|
||||
data: &MobData,
|
||||
out: &mut Vec<Vertex>,
|
||||
skin: &mut super::skin::Skin,
|
||||
) {
|
||||
if super::trader::is_invisible(m) {
|
||||
render_with(m, alpha, camera, atlas, light, data, &[], out);
|
||||
return;
|
||||
}
|
||||
render_visible(
|
||||
m,
|
||||
alpha,
|
||||
base_transform(m, alpha, camera),
|
||||
atlas,
|
||||
light,
|
||||
data,
|
||||
&[],
|
||||
true,
|
||||
out,
|
||||
Some(skin),
|
||||
);
|
||||
}
|
||||
/// `CapeLayer.submit` with `PlayerCapeModel`: the player model's body posed
|
||||
@@ -1632,7 +1695,7 @@ pub fn render_layers(
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let base = base_transform(m, alpha, camera);
|
||||
render_visible(m, alpha, base, atlas, light, data, extra, false, out);
|
||||
render_visible(m, alpha, base, atlas, light, data, extra, false, out, None);
|
||||
}
|
||||
/// A mob drawn under `origin` instead of at its position (the spinning mob
|
||||
/// of `SpawnerRenderer.submitEntityInSpawner`).
|
||||
@@ -1646,7 +1709,7 @@ pub fn render_posed(
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let base = base_transform_at(m, alpha, origin);
|
||||
render_visible(m, alpha, base, atlas, light, data, &[], true, out);
|
||||
render_visible(m, alpha, base, atlas, light, data, &[], true, out, None);
|
||||
}
|
||||
/// The custom model's key of a body model: `main` is the wide player model, `kind.model`.
|
||||
fn cem_key(model_key: &str) -> &str {
|
||||
@@ -1730,6 +1793,7 @@ fn render_visible(
|
||||
extra: &[(&Part, [f32; 4], u32, u32, bool)],
|
||||
body_visible: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
skin: Option<&mut super::skin::Skin>,
|
||||
) {
|
||||
let overlay = u32::from(red_overlay(m));
|
||||
let s = anim::state(m, alpha);
|
||||
@@ -1799,56 +1863,6 @@ fn render_visible(
|
||||
if let Some(animation) = body_animation {
|
||||
animation.run(m, &s, &mut poses);
|
||||
}
|
||||
let draw_body = |out: &mut Vec<Vertex>| {
|
||||
// A player or mannequin whose skin has no atlas sprite (an evicted or
|
||||
// never stored download) draws with its default skin, not invisibly.
|
||||
let drawn = sprite(texture).or_else(|| Some((texture, atlas(texture)?)));
|
||||
let uv = drawn.map(|(_, uv)| uv).or_else(|| {
|
||||
if avatar(m) {
|
||||
atlas(&m.kind.texture)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if body_visible && let Some(uv) = uv {
|
||||
let start = out.len();
|
||||
draw(
|
||||
&model,
|
||||
base,
|
||||
&poses,
|
||||
uv,
|
||||
body_color(m, alpha),
|
||||
light,
|
||||
body_overlay(m, alpha),
|
||||
out,
|
||||
);
|
||||
if let Some(glow) = drawn.and_then(|(name, _)| emissive_uv(name)) {
|
||||
draw(
|
||||
&model,
|
||||
base,
|
||||
&poses,
|
||||
glow,
|
||||
body_color(m, alpha),
|
||||
FULL_BRIGHT,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
if translucent_body(m) {
|
||||
mark_oit(&mut out[start..]);
|
||||
}
|
||||
}
|
||||
};
|
||||
// The translucent shell writes depth: draw the opaque core before it.
|
||||
let shell_last = m.species() == Species::SulfurCube;
|
||||
if !shell_last {
|
||||
draw_body(out);
|
||||
}
|
||||
// The worn armour's layers (and their emissive overlays, full bright).
|
||||
for (part, uv, color, layer_overlay, glows) in extra {
|
||||
let light = if *glows { FULL_BRIGHT } else { light };
|
||||
draw(part, base, &poses, *uv, *color, light, *layer_overlay, out);
|
||||
}
|
||||
let mut all_layers = layers(m, s.age);
|
||||
// WardenRenderer: the translucent emissive layers fade with the animations.
|
||||
if m.species() == Species::Warden {
|
||||
@@ -1864,6 +1878,87 @@ fn render_visible(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The GPU path takes the whole mob or none of it, so its draws keep their
|
||||
// order: only plain opaque and cutout draws of models it can pose (no
|
||||
// custom models, translucency, eyes or energy swirl, which the CPU path
|
||||
// changes after drawing).
|
||||
let mut skin = skin;
|
||||
let posable = match skin.as_deref_mut() {
|
||||
Some(skin) => {
|
||||
custom_body.is_none()
|
||||
&& extra.is_empty()
|
||||
&& m.species() != Species::SulfurCube
|
||||
&& !translucent_body(m)
|
||||
&& energy_swirl(m, s.age).is_none()
|
||||
&& skin.supports(&model)
|
||||
&& all_layers.iter().all(|layer| {
|
||||
!layer.full_bright
|
||||
&& !translucent_layer(m, layer)
|
||||
&& cem.layer(&layer.model).is_none()
|
||||
&& m.kind
|
||||
.layer(&layer.model)
|
||||
.is_none_or(|part| skin.supports(part))
|
||||
})
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if !posable {
|
||||
skin = None;
|
||||
}
|
||||
let draw_body = |out: &mut Vec<Vertex>, skin: &mut Option<&mut super::skin::Skin>| {
|
||||
// A player or mannequin whose skin has no atlas sprite (an evicted or
|
||||
// never stored download) draws with its default skin, not invisibly.
|
||||
let drawn = sprite(texture).or_else(|| Some((texture, atlas(texture)?)));
|
||||
let uv = drawn.map(|(_, uv)| uv).or_else(|| {
|
||||
if avatar(m) {
|
||||
atlas(&m.kind.texture)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if body_visible && let Some(uv) = uv {
|
||||
let start = out.len();
|
||||
put(
|
||||
skin,
|
||||
&model,
|
||||
base,
|
||||
&poses,
|
||||
uv,
|
||||
0.0,
|
||||
body_color(m, alpha),
|
||||
light,
|
||||
body_overlay(m, alpha),
|
||||
out,
|
||||
);
|
||||
if let Some(glow) = drawn.and_then(|(name, _)| emissive_uv(name)) {
|
||||
put(
|
||||
skin,
|
||||
&model,
|
||||
base,
|
||||
&poses,
|
||||
glow,
|
||||
0.0,
|
||||
body_color(m, alpha),
|
||||
FULL_BRIGHT,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
if translucent_body(m) {
|
||||
mark_oit(&mut out[start..]);
|
||||
}
|
||||
}
|
||||
};
|
||||
// The translucent shell writes depth: draw the opaque core before it.
|
||||
let shell_last = m.species() == Species::SulfurCube;
|
||||
if !shell_last {
|
||||
draw_body(out, &mut skin);
|
||||
}
|
||||
// The worn armour's layers (and their emissive overlays, full bright).
|
||||
for (part, uv, color, layer_overlay, glows) in extra {
|
||||
let light = if *glows { FULL_BRIGHT } else { light };
|
||||
draw(part, base, &poses, *uv, *color, light, *layer_overlay, out);
|
||||
}
|
||||
// One Poses refilled for every layer (armour draws up to seven): it keeps
|
||||
// its maps' capacity.
|
||||
let mut layer_poses = Poses::default();
|
||||
@@ -1917,7 +2012,8 @@ fn render_visible(
|
||||
None => overlay,
|
||||
};
|
||||
let start = out.len();
|
||||
draw_scrolled(
|
||||
put(
|
||||
&mut skin,
|
||||
&part,
|
||||
base,
|
||||
&layer_poses,
|
||||
@@ -1929,7 +2025,8 @@ fn render_visible(
|
||||
out,
|
||||
);
|
||||
if let Some(glow) = emissive_uv(drawn) {
|
||||
draw_scrolled(
|
||||
put(
|
||||
&mut skin,
|
||||
&part,
|
||||
base,
|
||||
&layer_poses,
|
||||
@@ -1979,7 +2076,7 @@ fn render_visible(
|
||||
}
|
||||
}
|
||||
if shell_last {
|
||||
draw_body(out);
|
||||
draw_body(out, &mut skin);
|
||||
}
|
||||
}
|
||||
/// `EnergySwirlLayer`'s model, texture and `xOffset(t)` for a charged creeper
|
||||
@@ -3084,6 +3181,170 @@ mod scroll_tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `anim::Poses` keys parts by a hash of their name: within any vanilla model, two
|
||||
/// different names never share one (they would share a pose).
|
||||
#[test]
|
||||
fn no_two_part_names_share_a_key() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
fn names<'a>(p: &'a Part, out: &mut Vec<&'a str>) {
|
||||
out.push(&p.name);
|
||||
for c in &p.children {
|
||||
names(c, out);
|
||||
}
|
||||
}
|
||||
let types = super::super::MobTypes::load(&root).unwrap();
|
||||
let mut models = 0;
|
||||
for kind in types.types.values() {
|
||||
for model in std::iter::once(&kind.model).chain(kind.layers.values()) {
|
||||
let mut all = Vec::new();
|
||||
names(model, &mut all);
|
||||
let mut keys = std::collections::HashMap::new();
|
||||
for name in all {
|
||||
let other = *keys.entry(super::super::anim::key(name)).or_insert(name);
|
||||
assert_eq!(other, name, "{} shares a key", kind.id);
|
||||
}
|
||||
models += 1;
|
||||
}
|
||||
}
|
||||
assert!(models > 100);
|
||||
}
|
||||
|
||||
/// The CPU cost of posing a mob farm (tools/stress_scenes.py `stress_mobs`): 1000 mobs of
|
||||
/// 8 kinds through `render_with`, as `mob_geometry` draws them each frame. Prints the median
|
||||
/// thread CPU time per frame (busy machines stretch wall time), the vertices and a hash of them (the same hash before and after a speed-up
|
||||
/// means the same picture). `cargo test --profile fast -p horizon-client --lib
|
||||
/// benchmark_mob_farm_geometry -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "manual benchmark; needs the prepared resources"]
|
||||
fn benchmark_mob_farm_geometry() {
|
||||
use std::{hash::Hasher, hint::black_box, time::Instant};
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
let types = super::super::MobTypes::load(&root).unwrap();
|
||||
let kinds = [
|
||||
"husk", "creeper", "cow", "pig", "sheep", "chicken", "villager", "spider",
|
||||
]
|
||||
.map(|k| std::sync::Arc::new(types.types[&format!("minecraft:{k}")].clone()));
|
||||
let mobs: Vec<Mob> = (0..1000u32)
|
||||
.map(|i| {
|
||||
let (layer, row, col) = (i / 200, i / 20 % 10, i % 20);
|
||||
let at = horizon_core::geometry::Vec3::new(
|
||||
-14.25 + 1.5 * f64::from(col),
|
||||
1.0 + 2.5 * f64::from(layer),
|
||||
8.0 + 2.0 * f64::from(row),
|
||||
);
|
||||
let mut m = Mob::new(i + 1, kinds[i as usize % 8].clone(), at, 180.0);
|
||||
m.previous = at;
|
||||
m.age = i;
|
||||
m
|
||||
})
|
||||
.collect();
|
||||
let data = MobData {
|
||||
states: Vec::new(),
|
||||
placements: Default::default(),
|
||||
heightmaps: Default::default(),
|
||||
animations: Default::default(),
|
||||
biome_tags: Default::default(),
|
||||
entity_tags: Default::default(),
|
||||
villager_hats: Default::default(),
|
||||
cem: Default::default(),
|
||||
random_textures: Default::default(),
|
||||
emissive: Default::default(),
|
||||
blinks: Default::default(),
|
||||
pois: Default::default(),
|
||||
species_placements: Default::default(),
|
||||
};
|
||||
let atlas = |name: &str| {
|
||||
let h = name.len() as f32 / 64.0;
|
||||
Some([h, h, h + 0.25, h + 0.25])
|
||||
};
|
||||
let camera = glam::DVec3::new(0.5, 9.6, 0.5);
|
||||
let mut out = Vec::new();
|
||||
let frame = |out: &mut Vec<Vertex>| {
|
||||
out.clear();
|
||||
for m in &mobs {
|
||||
render_with(m, 0.5, camera, &atlas, 0xf0f0, &data, &[], out);
|
||||
}
|
||||
};
|
||||
frame(&mut out);
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
hash.write(bytemuck::cast_slice(&out));
|
||||
// The thread's own CPU time where the platform gives it, else wall time.
|
||||
let timed = |f: &mut dyn FnMut()| {
|
||||
let (wall, cpu) = (Instant::now(), horizon_client_render::spans::thread_cpu());
|
||||
f();
|
||||
match (horizon_client_render::spans::thread_cpu(), cpu) {
|
||||
(Some(end), Some(start)) => (end - start).as_secs_f64() * 1000.0,
|
||||
_ => wall.elapsed().as_secs_f64() * 1000.0,
|
||||
}
|
||||
};
|
||||
let mut times = Vec::new();
|
||||
for _ in 0..30 {
|
||||
times.push(timed(&mut || frame(black_box(&mut out))));
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
println!(
|
||||
"mob farm: 1000 mobs, {} vertices, {:.3} ms/frame (median of 30, min {:.3}), hash {:016x}",
|
||||
out.len(),
|
||||
times[15],
|
||||
times[0],
|
||||
hash.finish()
|
||||
);
|
||||
// Where it goes: the body's poses alone (state, pose map, setupAnim), then the
|
||||
// body's vertices alone from those poses.
|
||||
let median = |mut f: Box<dyn FnMut() + '_>| {
|
||||
let mut t: Vec<f64> = (0..30).map(|_| timed(&mut f)).collect();
|
||||
t.sort_by(f64::total_cmp);
|
||||
t[15]
|
||||
};
|
||||
let posed: Vec<(std::sync::Arc<Part>, Poses)> = mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let model = m.kind.model.clone();
|
||||
let mut poses = Poses::new(&model);
|
||||
anim::setup(m, &anim::state(m, 0.5), &mut poses, &data.animations);
|
||||
(model, poses)
|
||||
})
|
||||
.collect();
|
||||
let poses_ms = median(Box::new(|| {
|
||||
for m in &mobs {
|
||||
let model = m.kind.model.clone();
|
||||
let mut poses = Poses::new(&model);
|
||||
anim::setup(m, &anim::state(m, 0.5), &mut poses, &data.animations);
|
||||
black_box(&poses);
|
||||
}
|
||||
}));
|
||||
let mut body = Vec::new();
|
||||
let draw_ms = median(Box::new(|| {
|
||||
body.clear();
|
||||
for (m, (model, poses)) in mobs.iter().zip(&posed) {
|
||||
let base = base_transform(m, 0.5, camera);
|
||||
draw(
|
||||
model,
|
||||
base,
|
||||
poses,
|
||||
[0.0, 0.0, 0.25, 0.25],
|
||||
u32::MAX,
|
||||
0xf0f0,
|
||||
0,
|
||||
&mut body,
|
||||
);
|
||||
}
|
||||
black_box(&body);
|
||||
}));
|
||||
println!(
|
||||
" body poses only {poses_ms:.3} ms; body vertices only {draw_ms:.3} ms ({} vertices)",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
//! Mob models posed on the GPU (`gfx::entity_skin`): the store of models
|
||||
//! uploaded once, and the walk that turns one `render::draw` into the parts'
|
||||
//! matrices and jobs the shader expands into the very vertices `draw` would
|
||||
//! push, in its order.
|
||||
//!
|
||||
//! A model takes this path only when every polygon faces an axis: the
|
||||
//! normal matrix times an axis is one of its columns, exactly, so the shader's
|
||||
//! packed normals are the CPU's to the bit. Positions and UVs are the same
|
||||
//! sums in the same order (`Mat4::transform_point3`).
|
||||
|
||||
use super::{ModelNormals, Part, anim::Poses};
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::gfx::entity_skin::{SkinFrame, SkinJob, SkinPart, SkinVertex};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A stored model: where its vertices start, and per part (pre-order) its
|
||||
/// own vertices and the end of its subtree, for the walk.
|
||||
struct Stored {
|
||||
first: u32,
|
||||
parts: Vec<StoredPart>,
|
||||
/// The model's bounds in its parts' own space, per part: min and max of
|
||||
/// its own vertices (none: no polygons).
|
||||
bounds: Vec<Option<([f32; 3], [f32; 3])>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct StoredPart {
|
||||
/// The part's first vertex, from the model's.
|
||||
first: u32,
|
||||
count: u32,
|
||||
/// One past the last part of its subtree.
|
||||
end: u32,
|
||||
}
|
||||
|
||||
/// The models on the GPU, by the `Arc` they are drawn from. An entry holds
|
||||
/// its `Arc`, so the address is never reused while stored; models no one
|
||||
/// else holds any more are dropped by `begin`.
|
||||
#[derive(Default)]
|
||||
pub struct SkinModels {
|
||||
stored: FxHashMap<usize, (Arc<Part>, Option<Stored>)>,
|
||||
vertices: u32,
|
||||
}
|
||||
|
||||
/// Models whose store would grow past this restart it (a pack reload's
|
||||
/// models pile up otherwise).
|
||||
const STORE_LIMIT: u32 = 4 << 20;
|
||||
|
||||
impl SkinModels {
|
||||
/// Starts a frame: drops the models nothing else holds (their vertices
|
||||
/// stay in the store until it restarts), restarts the store when it grew
|
||||
/// too large or the renderer's (`stored`, its size) is out of step.
|
||||
pub fn begin(&mut self, frame: &mut SkinFrame, stored: u32) {
|
||||
if self.vertices > STORE_LIMIT || stored + frame.new_model.len() as u32 != self.vertices {
|
||||
self.restart(frame);
|
||||
}
|
||||
self.stored
|
||||
.retain(|_, (model, _)| Arc::strong_count(model) > 1);
|
||||
frame.clear_draws();
|
||||
}
|
||||
|
||||
fn restart(&mut self, frame: &mut SkinFrame) {
|
||||
self.stored.clear();
|
||||
self.vertices = 0;
|
||||
frame.new_model.clear();
|
||||
frame.base = 0;
|
||||
frame.reset = true;
|
||||
}
|
||||
|
||||
fn entry(&mut self, model: &Arc<Part>, frame: &mut SkinFrame) -> Option<&Stored> {
|
||||
let key = Arc::as_ptr(model) as usize;
|
||||
let vertices = &mut self.vertices;
|
||||
let (_, stored) = self.stored.entry(key).or_insert_with(|| {
|
||||
// The renderer appends `new_model` to its store in order, so a
|
||||
// model's first vertex is the store's size when it is added.
|
||||
let before = frame.new_model.len();
|
||||
let stored = flatten(model, *vertices, &mut frame.new_model);
|
||||
*vertices += (frame.new_model.len() - before) as u32;
|
||||
(model.clone(), stored)
|
||||
});
|
||||
stored.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// The model's vertices in pre-order (a part's polygons, then its children),
|
||||
/// appended to `out`; `None` when a polygon does not face an axis.
|
||||
fn flatten(model: &Part, first: u32, out: &mut Vec<SkinVertex>) -> Option<Stored> {
|
||||
let start = out.len();
|
||||
let mut parts = Vec::new();
|
||||
let mut bounds = Vec::new();
|
||||
fn walk(
|
||||
part: &Part,
|
||||
start: usize,
|
||||
out: &mut Vec<SkinVertex>,
|
||||
parts: &mut Vec<StoredPart>,
|
||||
bounds: &mut Vec<Option<([f32; 3], [f32; 3])>>,
|
||||
) -> bool {
|
||||
let index = parts.len();
|
||||
let own = (out.len() - start) as u32;
|
||||
parts.push(StoredPart {
|
||||
first: own,
|
||||
count: 0,
|
||||
end: 0,
|
||||
});
|
||||
bounds.push(None);
|
||||
let mut lo = [f32::INFINITY; 3];
|
||||
let mut hi = [f32::NEG_INFINITY; 3];
|
||||
for poly in &part.polygons {
|
||||
let Some(axis) = axis(poly.normal) else {
|
||||
return false;
|
||||
};
|
||||
for v in poly.vertices {
|
||||
let pos = (glam::Vec3::new(v[0], v[1], v[2]) / 16.0).to_array();
|
||||
for a in 0..3 {
|
||||
lo[a] = lo[a].min(pos[a]);
|
||||
hi[a] = hi[a].max(pos[a]);
|
||||
}
|
||||
out.push(SkinVertex {
|
||||
pos,
|
||||
uv: [v[3], v[4]],
|
||||
axis,
|
||||
part: index as u32,
|
||||
});
|
||||
}
|
||||
}
|
||||
parts[index].count = (out.len() - start) as u32 - own;
|
||||
if !part.polygons.is_empty() {
|
||||
bounds[index] = Some((lo, hi));
|
||||
}
|
||||
for child in &part.children {
|
||||
if !walk(child, start, out, parts, bounds) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
parts[index].end = parts.len() as u32;
|
||||
true
|
||||
}
|
||||
if !walk(model, start, out, &mut parts, &mut bounds) {
|
||||
out.truncate(start);
|
||||
return None;
|
||||
}
|
||||
Some(Stored {
|
||||
first,
|
||||
parts,
|
||||
bounds,
|
||||
})
|
||||
}
|
||||
|
||||
/// 0..2 for +x, +y, +z, 3..5 for -x, -y, -z; `None` for any other normal.
|
||||
fn axis(n: [f32; 3]) -> Option<u32> {
|
||||
let mut found = None;
|
||||
for (a, &c) in n.iter().enumerate() {
|
||||
if c == 1.0 || c == -1.0 {
|
||||
if found.is_some() {
|
||||
return None;
|
||||
}
|
||||
found = Some(a as u32 + if c < 0.0 { 3 } else { 0 });
|
||||
} else if c != 0.0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// One draw's parameters (`render::draw_scrolled`'s).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Paint {
|
||||
pub uv: [f32; 4],
|
||||
pub scroll: f32,
|
||||
pub color: u32,
|
||||
pub light: u32,
|
||||
pub overlay: u32,
|
||||
}
|
||||
|
||||
/// Where a frame's posed draws go, and the bounds of the current mob's.
|
||||
pub struct Skin<'a> {
|
||||
pub models: &'a mut SkinModels,
|
||||
pub frame: &'a mut SkinFrame,
|
||||
/// Camera-relative bounds of what this mob drew here (for culling).
|
||||
pub bounds: Option<(glam::Vec3, glam::Vec3)>,
|
||||
}
|
||||
|
||||
impl Skin<'_> {
|
||||
/// Whether this model can be posed here (stored on first sight).
|
||||
pub fn supports(&mut self, model: &Arc<Part>) -> bool {
|
||||
self.models.entry(model, self.frame).is_some()
|
||||
}
|
||||
|
||||
/// `render::draw_scrolled` on the GPU: false (nothing done) when the
|
||||
/// model cannot take this path.
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
model: &Arc<Part>,
|
||||
base: glam::Mat4,
|
||||
poses: &Poses,
|
||||
paint: Paint,
|
||||
) -> bool {
|
||||
let Some(stored) = self.models.entry(model, self.frame) else {
|
||||
return false;
|
||||
};
|
||||
let first_part = self.frame.parts.len() as u32;
|
||||
self.frame.parts.resize(
|
||||
self.frame.parts.len() + stored.parts.len(),
|
||||
bytemuck::Zeroable::zeroed(),
|
||||
);
|
||||
let mut walk = Walk {
|
||||
stored,
|
||||
poses,
|
||||
parts: &mut self.frame.parts[first_part as usize..],
|
||||
jobs: &mut self.frame.jobs,
|
||||
vertices: &mut self.frame.vertices,
|
||||
bounds: &mut self.bounds,
|
||||
first_part,
|
||||
paint,
|
||||
index: 0,
|
||||
};
|
||||
walk.part(model, base, ModelNormals::new(base));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct Walk<'a> {
|
||||
stored: &'a Stored,
|
||||
poses: &'a Poses,
|
||||
parts: &'a mut [SkinPart],
|
||||
jobs: &'a mut Vec<SkinJob>,
|
||||
vertices: &'a mut u32,
|
||||
bounds: &'a mut Option<(glam::Vec3, glam::Vec3)>,
|
||||
first_part: u32,
|
||||
paint: Paint,
|
||||
/// The pre-order index of the part being walked.
|
||||
index: u32,
|
||||
}
|
||||
|
||||
impl Walk<'_> {
|
||||
/// `render::draw_normals` without the vertices: the part's matrices, and
|
||||
/// its polygons as (part of) a job.
|
||||
fn part(&mut self, part: &Part, base: glam::Mat4, normals: ModelNormals) {
|
||||
let index = self.index;
|
||||
let stored = self.stored.parts[index as usize];
|
||||
let p = self.poses.pose(part);
|
||||
let m = super::render::posed(base, p);
|
||||
if self.poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
|
||||
// The whole subtree is left out.
|
||||
self.index = stored.end;
|
||||
return;
|
||||
}
|
||||
let normals = normals.part(p);
|
||||
let n = if normals.trusted {
|
||||
normals.matrix
|
||||
} else {
|
||||
glam::Mat3::from_cols(
|
||||
normals.matrix.x_axis.normalize_or_zero(),
|
||||
normals.matrix.y_axis.normalize_or_zero(),
|
||||
normals.matrix.z_axis.normalize_or_zero(),
|
||||
)
|
||||
};
|
||||
self.parts[index as usize] = SkinPart {
|
||||
matrix: m.to_cols_array(),
|
||||
normal: n.to_cols_array(),
|
||||
};
|
||||
if !self.poses.skips(part) && stored.count > 0 {
|
||||
self.emit(stored);
|
||||
if let Some((lo, hi)) = self.stored.bounds[index as usize] {
|
||||
let (lo, hi) = (glam::Vec3::from_array(lo), glam::Vec3::from_array(hi));
|
||||
let center = m.transform_point3((lo + hi) * 0.5);
|
||||
let half = (hi - lo) * 0.5;
|
||||
let r = glam::Mat3::from_mat4(m);
|
||||
let extent =
|
||||
r.x_axis.abs() * half.x + r.y_axis.abs() * half.y + r.z_axis.abs() * half.z;
|
||||
let (a, b) = (center - extent, center + extent);
|
||||
*self.bounds = Some(match *self.bounds {
|
||||
Some((l, h)) => (l.min(a), h.max(b)),
|
||||
None => (a, b),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.index += 1;
|
||||
for child in &part.children {
|
||||
self.part(child, m, normals);
|
||||
}
|
||||
}
|
||||
|
||||
/// The part's vertices: extends the last job when they follow its own.
|
||||
fn emit(&mut self, stored: StoredPart) {
|
||||
let first = self.stored.first + stored.first;
|
||||
let paint = self.paint;
|
||||
if let Some(job) = self.jobs.last_mut()
|
||||
&& job.first + job.count == first
|
||||
&& job.part == self.first_part
|
||||
&& job.dst + job.count == *self.vertices
|
||||
{
|
||||
job.count += stored.count;
|
||||
} else {
|
||||
self.jobs.push(SkinJob {
|
||||
first,
|
||||
count: stored.count,
|
||||
part: self.first_part,
|
||||
dst: *self.vertices,
|
||||
rect: paint.uv,
|
||||
scroll: paint.scroll,
|
||||
color: paint.color,
|
||||
light: paint.light,
|
||||
overlay: paint.overlay,
|
||||
glint: u32::from(paint.overlay == super::render::ARMOR_GLINT),
|
||||
spare: [0; 3],
|
||||
});
|
||||
}
|
||||
*self.vertices += stored.count;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gfx::entity_skin::expand;
|
||||
use crate::mobs::{Mob, MobData, MobTypes, render};
|
||||
|
||||
/// Every vanilla mob, at a few ages and turns: the GPU path's vertices
|
||||
/// (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
|
||||
/// the bit and in the same order, for each mob it takes; the mobs it
|
||||
/// leaves to the CPU draw exactly as before. Needs the prepared resources.
|
||||
#[test]
|
||||
fn posed_on_the_gpu_every_mob_draws_what_the_cpu_draws() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("entity-models.json").exists() {
|
||||
eprintln!("no prepared resources: skipped");
|
||||
return;
|
||||
}
|
||||
let types = MobTypes::load(&root).unwrap();
|
||||
let data = MobData {
|
||||
states: Vec::new(),
|
||||
placements: Default::default(),
|
||||
heightmaps: Default::default(),
|
||||
animations: MobData::load_animations(&root),
|
||||
biome_tags: Default::default(),
|
||||
entity_tags: Default::default(),
|
||||
villager_hats: Default::default(),
|
||||
cem: Default::default(),
|
||||
random_textures: Default::default(),
|
||||
emissive: Default::default(),
|
||||
blinks: Default::default(),
|
||||
pois: Default::default(),
|
||||
species_placements: Default::default(),
|
||||
};
|
||||
// Every texture its own rect, so a swapped texture shows.
|
||||
let atlas = |name: &str| {
|
||||
let h = name
|
||||
.bytes()
|
||||
.fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
|
||||
let u = (h % 61) as f32 / 64.0;
|
||||
let v = (h / 61 % 61) as f32 / 64.0;
|
||||
Some([u, v, u + 1.0 / 64.0, v + 1.0 / 32.0])
|
||||
};
|
||||
let camera = glam::DVec3::new(0.3, 62.0, -4.0);
|
||||
let mut models = SkinModels::default();
|
||||
let mut frame = SkinFrame::default();
|
||||
let mut store: Vec<crate::gfx::entity_skin::SkinVertex> = Vec::new();
|
||||
let (mut posed, mut kept) = (0, 0);
|
||||
let mut ids: Vec<_> = types.types.keys().cloned().collect();
|
||||
ids.sort();
|
||||
for (n, id) in ids.iter().enumerate() {
|
||||
let kind = Arc::new(types.types[id].clone());
|
||||
for (age, yaw) in [(0u32, 0.0f32), (37, 123.0), (412, -71.5)] {
|
||||
let at = horizon_core::geometry::Vec3::new(1.5 + n as f64 % 5.0, 64.0, 3.25);
|
||||
let mut m = Mob::new(n as u32 + 1, kind.clone(), at, yaw);
|
||||
m.previous = horizon_core::geometry::Vec3::new(at.x - 0.1, at.y, at.z + 0.05);
|
||||
m.age = age;
|
||||
m.limb_speed = 0.6;
|
||||
m.limb_position = age as f32 * 0.37;
|
||||
let mut cpu = Vec::new();
|
||||
render::render_with(&m, 0.4, camera, &atlas, 0x50a0, &data, &[], &mut cpu);
|
||||
models.begin(&mut frame, store.len() as u32);
|
||||
let mut gpu = Vec::new();
|
||||
let mut skin = Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
render::render_skinned(&m, 0.4, camera, &atlas, 0x50a0, &data, &mut gpu, &mut skin);
|
||||
let bounds = skin.bounds;
|
||||
if frame.reset {
|
||||
store.clear();
|
||||
}
|
||||
assert_eq!(store.len() as u32, frame.base);
|
||||
store.extend_from_slice(&frame.new_model);
|
||||
frame.handed();
|
||||
if frame.jobs.is_empty() {
|
||||
kept += 1;
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&gpu),
|
||||
bytemuck::cast_slice::<_, u8>(&cpu),
|
||||
"{id} age {age}: the CPU path changed"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
posed += 1;
|
||||
assert!(gpu.is_empty(), "{id}: a posed mob left vertices on the CPU");
|
||||
let expanded = expand(&store, &frame);
|
||||
assert_eq!(expanded.len(), cpu.len(), "{id} age {age}: vertex count");
|
||||
for (i, (a, b)) in expanded.iter().zip(&cpu).enumerate() {
|
||||
assert_eq!(
|
||||
bytemuck::bytes_of(a),
|
||||
bytemuck::bytes_of(b),
|
||||
"{id} age {age} vertex {i}: {a:?} != {b:?}"
|
||||
);
|
||||
}
|
||||
// The culling bounds hold every vertex.
|
||||
let (lo, hi) = bounds.expect("a posed mob has bounds");
|
||||
for v in &cpu {
|
||||
let p = glam::Vec3::from_array(v.pos);
|
||||
assert!(
|
||||
p.cmpge(lo - 1e-4).all() && p.cmple(hi + 1e-4).all(),
|
||||
"{id}: {p} outside {lo}..{hi}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("posed on the GPU: {posed} mob draws; kept on the CPU: {kept}");
|
||||
assert!(posed > 3 * 40, "only {posed} draws took the GPU path");
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,28 @@
|
||||
//! Generate the trait and dispatch with `cargo run -p xtask -- mod-api`.
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub use crate::containers::{ModBlockEntity, ModContainer, ModFluidTanks};
|
||||
pub use crate::biome::BiomeColors;
|
||||
pub use crate::camera::CameraEntity;
|
||||
pub use crate::containers::{ModBlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks};
|
||||
pub use crate::crafting::{CustomRecipe, Ingredient};
|
||||
pub use crate::extras::Corner;
|
||||
pub use crate::extras_hud::Corners;
|
||||
pub use crate::first_person::Hand;
|
||||
pub use crate::fog::{FogData, FogType};
|
||||
pub use crate::game::native_block_entity_draw::{BlockEntityDraw, CubeFace};
|
||||
pub use crate::game::{
|
||||
NativeBlockEdit, NativeBlockRun, NativeClock, NativeColumnBlocks, NativeDataMapType,
|
||||
NativeExperienceOrb, NativeFalling, NativeHeightmap, NativeHeldItem, NativeHit,
|
||||
NativeMapDecoration, NativeMapInfo, NativeMobRender, NativeModEvent, NativePlayerRender,
|
||||
NativeShake,
|
||||
};
|
||||
pub use crate::gfx::native_render::{
|
||||
RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent, wgpu,
|
||||
};
|
||||
pub use crate::hud::HudBatch;
|
||||
pub use crate::hud_layout::{GuiDraw, HealthInputs};
|
||||
pub use crate::items::ItemStack;
|
||||
pub use crate::music::{Music, MusicLayer};
|
||||
pub use crate::text::Text;
|
||||
pub use horizon_assets::item_model::DisplayContext;
|
||||
pub use horizon_core::{
|
||||
@@ -23,18 +34,21 @@ pub use horizon_core::{
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
pub use horizon_world::consumable::UseAnimation;
|
||||
pub use horizon_world::{
|
||||
attachments::{
|
||||
AttachmentCodec, AttachmentError, AttachmentHost, AttachmentType, Attachments, Holder,
|
||||
},
|
||||
fluid::FluidState,
|
||||
level::{BlockId, Dimension as LevelDimension, PlacementContext},
|
||||
loot::{LootStack, NativeLootContext},
|
||||
nbt::{Compound, Tag},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
pub use serde_json;
|
||||
use serde_json::{Map, Value};
|
||||
use std::time::Duration;
|
||||
pub use winit::event::KeyEvent;
|
||||
|
||||
/// A dispenser hook's answer: the slot's remainder (None empties it) and the
|
||||
/// level event to play, `CLICK` (1000), `FAIL` (1001) or 0 for none.
|
||||
|
||||
+4355
File diff suppressed because it is too large.
Load diff
+161
@@ -237,6 +237,167 @@ pub trait Host {
|
||||
state: BlockStateId,
|
||||
flags: i32,
|
||||
) -> bool;
|
||||
/// For a server mod: the runs made at once, straight into the chunks of the authoritative level `dimension`
|
||||
/// (the one being ticked, as for `native_set_block`), with what `fill ... strict` does for block entities (a
|
||||
/// block replaced by another loses its block entity, and a new block gets the one `native_set_block` would
|
||||
/// give it, a mod block's included); no neighbour is told, and the chunks changed go to the players whole.
|
||||
/// Runs in chunks not loaded, and the parts of runs outside the column, are left out. The blocks changed, or
|
||||
/// why the edit was left out, with nothing changed: another dimension, a client of a server, a palette entry
|
||||
/// or filter that doesn't read or carries NBT (refused), a run naming a palette entry or filter past the end,
|
||||
/// or more than 65536 filters. API 126.
|
||||
fn edit_blocks(&mut self, dimension: &str, edit: &NativeBlockEdit) -> Result<u64, String>;
|
||||
/// For a server mod: each column's height by `heightmap` in the authoritative level `dimension` (the one being
|
||||
/// ticked), none for a column not loaded. `Err` says why nothing was read: another dimension or a client of a
|
||||
/// server, or more than 65,536 columns (`MAX_COLUMNS`). API 127.
|
||||
fn surface_heights(
|
||||
&self,
|
||||
dimension: &str,
|
||||
columns: &[(i32, i32)],
|
||||
heightmap: NativeHeightmap,
|
||||
) -> Result<Vec<Option<i32>>, String>;
|
||||
/// For a server mod: `below` blocks under and `above` blocks from each column's height by `heightmap`, as a
|
||||
/// palette and indexes, with the biome's grass, foliage and water tints (air outside the column and in columns
|
||||
/// not loaded). The tints are the biome's at the column's top, not blended over the biome blend radius as the
|
||||
/// game draws them: near a biome border they can differ from what players see. `Err` says why nothing was
|
||||
/// read: another dimension or a client of a server, `below + above` of 0 or more than the level's height, or
|
||||
/// a read of more than 1,048,576 blocks (`MAX_READ`; it runs on the server tick, so read big areas in pieces).
|
||||
/// API 127.
|
||||
fn read_columns(
|
||||
&self,
|
||||
dimension: &str,
|
||||
columns: &[(i32, i32)],
|
||||
heightmap: NativeHeightmap,
|
||||
range: (u32, u32),
|
||||
) -> Result<NativeColumnBlocks, String>;
|
||||
/// Asks `native_biome_colors` again for every biome and, when the colours
|
||||
/// differ from those in use, rebuilds every loaded section and the far
|
||||
/// terrain with them (`LevelRenderer.allChanged`, as changing Biome Blend
|
||||
/// does). Call it when your colours change (a new season, a new day of a
|
||||
/// slow fade) and once when the player joins, not every frame: a rebuild
|
||||
/// takes the workers a few seconds. API 107.
|
||||
fn refresh_biome_colors(&mut self);
|
||||
/// The strongest redstone signal reaching the block at `pos` from its six
|
||||
/// neighbours, 0 to 15 (`SignalGetter.getBestNeighborSignal`, what a
|
||||
/// dispenser or a piston reads), in the level in place, `dimension`. 0 on
|
||||
/// a client's copy of the world and for another dimension. A mod's
|
||||
/// explosive, launcher or machine reads it in its block entity's tick to
|
||||
/// go off or switch on. API 96.
|
||||
fn neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32;
|
||||
/// The light at `pos` in the world in place, as `(block, sky)`, each 0 to
|
||||
/// 15 (`Level.getBrightness` for `LightLayer.BLOCK` and `LightLayer.SKY`).
|
||||
/// A solar panel checks for full sky light above it, a lamp or a mob farm
|
||||
/// for darkness. `(0, 0)` in a chunk that isn't loaded. Like the other
|
||||
/// reads here it reads the world this game holds: the level in place on
|
||||
/// a server, the server's copy on a client. API 97.
|
||||
fn light(&self, pos: [i32; 3]) -> (u8, u8);
|
||||
/// The rain and thunder levels of the world in place, each 0.0 to 1.0
|
||||
/// (`Level.getRainLevel` and `Level.getThunderLevel`): what a solar panel
|
||||
/// or a wind turbine reads. Both 0 in the Nether and the End, which have
|
||||
/// no weather (`Level.canHaveWeather`). API 97.
|
||||
fn weather(&self) -> (f32, f32);
|
||||
/// The world's time of day in ticks (`Level.getOverworldClockTime`), the
|
||||
/// same as `native_clock`'s `day_time`; it keeps counting past 24000, so
|
||||
/// take it modulo 24000 for the hour. API 97.
|
||||
fn day_time(&self) -> i64;
|
||||
/// The lowest block y and the height of the world in place
|
||||
/// (`LevelHeightAccessor.getMinY` and `getHeight`): how deep a quarry or a
|
||||
/// miner may dig. API 97.
|
||||
fn world_height(&self) -> (i32, i32);
|
||||
/// The most of the item `item` one slot holds (`Item.getDefaultMaxStackSize`),
|
||||
/// or `None` for an unknown item: for a mod's container that fills or
|
||||
/// merges stacks itself. API 97.
|
||||
fn max_stack_size(&self, item: &str) -> Option<u32>;
|
||||
/// Item `item`'s icon drawn with model `model` (`minecraft:paper` as
|
||||
/// `mymod:wheel_icon`, an `item_model` of a content mod's), as `native_item`
|
||||
/// draws one; false for an unknown item. API 111.
|
||||
fn item_model(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
item: &str,
|
||||
model: &str,
|
||||
at: [f32; 2],
|
||||
size: f32,
|
||||
) -> bool;
|
||||
/// A quad of corners `points` (in order round it) in GUI pixels, `argb`:
|
||||
/// a wedge, a slanted bar, what `native_fill`'s rectangles can't draw.
|
||||
/// API 111.
|
||||
fn quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32);
|
||||
/// Opens a screen of mod `mod_id`'s own over the world: the screen hooks
|
||||
/// fire over it as over any other, so the mod draws it in
|
||||
/// `native_screen_render_post` and takes its clicks, scroll, keys and
|
||||
/// typing there; the mouse is freed, and Escape closes it. Every mod's own
|
||||
/// screen has the `NativeScreenView.id` `mod`, so a mod's screen hooks
|
||||
/// check `native_mod_screen()` is its own before drawing. It doesn't pause
|
||||
/// a single-player game (as the inventory doesn't). True when it is open:
|
||||
/// false while another screen is (or another mod's), over the title
|
||||
/// screen, a book, a sign editor, a dialog or a prompt, while the world
|
||||
/// loads, and anywhere but the window's own client (the server thread, a
|
||||
/// dedicated server). A gamepad can close it (B, Start) but not click on
|
||||
/// it: its clicks don't reach the mouse hooks. API 111.
|
||||
fn mod_screen_open(&mut self, mod_id: &str) -> bool;
|
||||
/// Closes mod `mod_id`'s own screen, if it is open. API 111.
|
||||
fn mod_screen_close(&mut self, mod_id: &str);
|
||||
/// The mod whose own screen is open, if one is (Escape, or another screen
|
||||
/// replacing it, closes it). API 111.
|
||||
fn mod_screen(&self) -> Option<&str>;
|
||||
/// Writes the experience orb `id`'s `value` (the points a pickup gives), `count`
|
||||
/// (orbs merged into it, each worth `value`) and `age` (ticks lived; an orb
|
||||
/// despawns at 6000); a `None` is left as it is. API 128.
|
||||
/// False, and nothing written, for an orb not in the server level `dimension`, a
|
||||
/// `value` or `count` of 0, and on a client of a server.
|
||||
fn set_experience_orb(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
id: u32,
|
||||
value: Option<u32>,
|
||||
count: Option<u32>,
|
||||
age: Option<u32>,
|
||||
) -> bool;
|
||||
/// The experience orbs of the server level `dimension` whose box meets
|
||||
/// the box `min`..`max`, in the level's order: what a mod reads before it
|
||||
/// cancels an orb in `native_entity_join_level`, to give its points with
|
||||
/// `native_give_experience`. Empty for a level not in place and on a
|
||||
/// client of a server.
|
||||
fn experience_orbs(
|
||||
&self,
|
||||
dimension: &str,
|
||||
min: [f64; 3],
|
||||
max: [f64; 3],
|
||||
) -> Vec<NativeExperienceOrb>;
|
||||
/// The clock of the world this window shows, for client mods: its game
|
||||
/// time and time of day as the server last sent them
|
||||
/// (`ClientboundSetTimePacket`), advanced each tick between packets; none
|
||||
/// on the title screen. API 92.
|
||||
fn clock(&self) -> Option<NativeClock>;
|
||||
/// Throws a falling block of `state` from `pos` (its bottom centre) at
|
||||
/// `velocity` (blocks a tick) in the level in place, `dimension`: it
|
||||
/// falls, lands and places itself as sand does, or drops as an item
|
||||
/// where it can't. The block at `pos` is left as it is: take it away first
|
||||
/// with `native_set_block` to make a block fall out of the world. False,
|
||||
/// with nothing thrown, on a client's copy of the world, for another
|
||||
/// dimension, an air or unknown state, a chunk that isn't loaded, a value
|
||||
/// that isn't finite, or with 1024 falling blocks in the level already.
|
||||
/// Each axis of `velocity` is cut to 3.9. API 81.
|
||||
fn spawn_falling_block(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
state: BlockStateId,
|
||||
pos: [f64; 3],
|
||||
velocity: [f64; 3],
|
||||
falling: NativeFalling,
|
||||
) -> bool;
|
||||
/// Property `key` of block state `state` (a machine's `facing`, from the
|
||||
/// `state` `native_block_entity_submit` gets), if it has one: no lookup in
|
||||
/// the world, so cheap each frame. API 91.
|
||||
fn block_state_property(&self, state: BlockStateId, key: &str) -> Option<&str>;
|
||||
/// The first block along the view within `distance` (at most 1024), past the loaded chunks over the distant terrain. API 116.
|
||||
fn raycast(&self, distance: f32) -> Option<NativeHit>;
|
||||
/// The first block along the view within `RAYCAST_MAX`, and its distance. API 116.
|
||||
fn view_hit(&self) -> Option<(f32, NativeHit)>;
|
||||
/// Right-clicks with these items (at most 64 ids) are mod `mod_id`'s; a later call replaces the list. API 116.
|
||||
fn claim_items(&mut self, mod_id: &str, ids: &[String]);
|
||||
/// Mod `mod_id`'s events since its last poll (at most 256 wait). API 116.
|
||||
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent>;
|
||||
/// Sets the stack of item entity `id` in the server level `dimension`, as
|
||||
/// a hopper's `addItem` leaves what it couldn't take (`ItemEntity.setItem`);
|
||||
/// an empty stack (count 0) removes the entity (`setItem(EMPTY)`, then
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Callbacks into the executable for native trait mods' client-library hooks.
|
||||
use crate::items::ItemStack;
|
||||
use serde_json::{Map, Value};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Client-library hooks, installed before any menu, entity or recipe is used.
|
||||
pub struct NativeClientHooks {
|
||||
pub menu_slot_rules: fn(&str, usize, &ItemStack, &mut bool, &mut u32),
|
||||
pub living_breathe: fn(u32, &str, &mut bool, &mut i32, &mut i32),
|
||||
pub ingredient: fn(&str, &Map<String, Value>, &mut Vec<String>),
|
||||
}
|
||||
|
||||
static HOOKS: OnceLock<NativeClientHooks> = OnceLock::new();
|
||||
|
||||
/// Install the process-wide callbacks once; a second installation keeps the first.
|
||||
pub fn install(hooks: NativeClientHooks) -> Result<(), NativeClientHooks> {
|
||||
HOOKS.set(hooks)
|
||||
}
|
||||
|
||||
pub(crate) fn menu_slot_rules(
|
||||
menu: &str,
|
||||
slot: usize,
|
||||
stack: &ItemStack,
|
||||
may_place: &mut bool,
|
||||
max_stack: &mut u32,
|
||||
) {
|
||||
if let Some(hooks) = HOOKS.get() {
|
||||
(hooks.menu_slot_rules)(menu, slot, stack, may_place, max_stack);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn living_breathe(
|
||||
entity: u32,
|
||||
kind: &str,
|
||||
can_breathe: &mut bool,
|
||||
consume: &mut i32,
|
||||
refill: &mut i32,
|
||||
) {
|
||||
if let Some(hooks) = HOOKS.get() {
|
||||
(hooks.living_breathe)(entity, kind, can_breathe, consume, refill);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ingredient(kind: &str, data: &Map<String, Value>, items: &mut Vec<String>) {
|
||||
if let Some(hooks) = HOOKS.get() {
|
||||
(hooks.ingredient)(kind, data, items);
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,7 @@ impl Session {
|
||||
let before = (self.streaming.generated_total, self.world.chunks.len());
|
||||
self.update();
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
let idle = self.streaming.pending() == 0
|
||||
let idle = !self.streaming.busy()
|
||||
&& before == (self.streaming.generated_total, self.world.chunks.len());
|
||||
quiet = if idle { quiet + 1 } else { 0 };
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ fn columns_loaded_past_the_render_distance_mesh_when_they_come_in_range() {
|
||||
// and nothing comes back (`pending` empties).
|
||||
streaming.free(uploads);
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
let idle = streaming.pending() == 0 && before == (world.chunks.len(), meshed.len());
|
||||
let idle = !streaming.busy() && before == (world.chunks.len(), meshed.len());
|
||||
quiet = if idle { quiet + 1 } else { 0 };
|
||||
}
|
||||
};
|
||||
@@ -181,7 +181,7 @@ fn relit_columns_keep_every_mesh_current() {
|
||||
while quiet < 40 {
|
||||
assert!(Instant::now() < deadline, "streaming never settled");
|
||||
let (uploads, _) = streaming.update(world);
|
||||
let idle = uploads.is_empty() && streaming.pending() == 0;
|
||||
let idle = uploads.is_empty() && !streaming.busy();
|
||||
meshes.extend(uploads);
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
quiet = if idle { quiet + 1 } else { 0 };
|
||||
|
||||
@@ -90,7 +90,7 @@ fn a_tall_column_streams_lights_and_meshes_to_its_top() {
|
||||
while quiet < 40 {
|
||||
assert!(Instant::now() < deadline, "streaming never settled");
|
||||
let (uploads, _) = streaming.update(world);
|
||||
let idle = uploads.is_empty() && streaming.pending() == 0;
|
||||
let idle = uploads.is_empty() && !streaming.busy();
|
||||
meshes.extend(uploads);
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
quiet = if idle { quiet + 1 } else { 0 };
|
||||
|
||||
@@ -753,7 +753,7 @@ mod tests {
|
||||
*state
|
||||
}
|
||||
#[test]
|
||||
fn runs_read_what_get_reads() {
|
||||
fn runs_read_the_stored_values() {
|
||||
let mut state = 0x9E37_79B9_7F4A_7C15;
|
||||
// One value, a list palette, and the global palette.
|
||||
for distinct in [1u64, 5, 3000] {
|
||||
@@ -767,8 +767,11 @@ mod tests {
|
||||
let len = len.min(4096 - start);
|
||||
let mut run = vec![u32::MAX; len];
|
||||
container.get_run(start, &mut run);
|
||||
let expected: Vec<u32> = (start..start + len).map(|i| container.get(i)).collect();
|
||||
assert_eq!(run, expected, "{distinct} values, {start}+{len}");
|
||||
assert_eq!(
|
||||
run,
|
||||
values[start..start + len],
|
||||
"{distinct} values, {start}+{len}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Replays the play packets of a `horizon-bots --capture` file through the client's receive path on
|
||||
//! one thread, and prints the CPU each stage takes and what each packet type costs to decode:
|
||||
//!
|
||||
//! horizon-bots --server 127.0.0.1:25565 --bots 1 --behaviour explore --view-distance 32 \
|
||||
//! --seconds 60 --capture join.cap
|
||||
//! cargo run --release -p horizon-protocol --example net_replay -- join.cap
|
||||
//!
|
||||
//! The packets go through a connection three times, as they reach the client:
|
||||
//! - `memory`: single player (an integrated server's pipe: no compression, no encryption);
|
||||
//! - `tcp`: a server's default compression threshold (256), as offline servers send it;
|
||||
//! - `tcp+aes`: the same encrypted, as online-mode servers send it.
|
||||
//!
|
||||
//! Each pass sends every packet into an in-memory pipe first, then times `ConnReader::recv` for all of
|
||||
//! them (pipe read, decryption, framing, inflate). `decode` is `play::Clientbound::decode`, and
|
||||
//! `sections` parses chunk packets' sections (overworld: 24 sections; `--biome-bits` from the server's
|
||||
//! biome registry, 7 for vanilla's 65 biomes). `--repeat N` keeps each stage's fastest of N runs.
|
||||
use horizon_protocol::{
|
||||
Direction, State,
|
||||
buf::write_varint,
|
||||
connection::{Connection, Pipe},
|
||||
ids,
|
||||
packets::play::Clientbound,
|
||||
};
|
||||
use std::{collections::BTreeMap, error::Error, time::Instant};
|
||||
|
||||
struct Packet {
|
||||
id: i32,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
fn load(path: &str) -> Result<Vec<Packet>, Box<dyn Error>> {
|
||||
let data = std::fs::read(path)?;
|
||||
let mut packets = Vec::new();
|
||||
let mut at = 0;
|
||||
while at + 12 <= data.len() {
|
||||
let id = i32::from_le_bytes(data[at + 4..at + 8].try_into()?);
|
||||
let len = u32::from_le_bytes(data[at + 8..at + 12].try_into()?) as usize;
|
||||
let Some(payload) = data.get(at + 12..at + 12 + len) else {
|
||||
break;
|
||||
};
|
||||
let mut body = Vec::with_capacity(len + 3);
|
||||
write_varint(&mut body, id);
|
||||
body.extend_from_slice(payload);
|
||||
packets.push(Packet { id, body });
|
||||
at += 12 + len;
|
||||
}
|
||||
Ok(packets)
|
||||
}
|
||||
|
||||
/// Seconds to receive every packet through a connection with this threshold and encryption,
|
||||
/// and the bytes the connection read.
|
||||
fn receive(packets: &[Packet], threshold: i32, encrypt: bool) -> (f64, i64, Vec<(i32, Vec<u8>)>) {
|
||||
let (a, b) = Pipe::pair();
|
||||
let (mut server, mut client) = (Connection::local(b), Connection::local(a));
|
||||
server.set_compression(threshold);
|
||||
client.set_compression(threshold);
|
||||
if encrypt {
|
||||
let key = [0x5a; 16];
|
||||
server.enable_encryption(&key);
|
||||
client.enable_encryption(&key);
|
||||
}
|
||||
for p in packets {
|
||||
server.send(&p.body).expect("the pipe is open");
|
||||
}
|
||||
let start = Instant::now();
|
||||
let received: Vec<_> = (0..packets.len())
|
||||
.map(|_| client.recv().expect("every packet sent arrives"))
|
||||
.collect();
|
||||
let seconds = start.elapsed().as_secs_f64();
|
||||
let bytes = client
|
||||
.reader
|
||||
.counters
|
||||
.bytes_in
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
(seconds, bytes, received)
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Cost {
|
||||
count: u64,
|
||||
bytes: u64,
|
||||
nanos: u64,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let path = args
|
||||
.first()
|
||||
.ok_or("usage: net_replay CAPTURE [--repeat N] [--biome-bits B]")?;
|
||||
let flag = |name: &str, default: usize| {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
};
|
||||
let repeat = flag("--repeat", 3).max(1);
|
||||
let biome_bits = flag("--biome-bits", 7) as u8;
|
||||
let packets = load(path)?;
|
||||
let body_bytes: usize = packets.iter().map(|p| p.body.len()).sum();
|
||||
let chunk_id = ids::play::clientbound::LEVEL_CHUNK_WITH_LIGHT;
|
||||
let chunks = packets.iter().filter(|p| p.id == chunk_id).count();
|
||||
println!(
|
||||
"{path}: {} packets, {:.1} MB of packet bodies, {chunks} chunk packets",
|
||||
packets.len(),
|
||||
body_bytes as f64 / 1e6
|
||||
);
|
||||
|
||||
let mut received = Vec::new();
|
||||
println!("\nreceive (ConnReader::recv for every packet, fastest of {repeat}):");
|
||||
println!(
|
||||
" {:<8} {:>10} {:>10} {:>12} {:>14}",
|
||||
"pass", "wire MB", "ms", "ns/packet", "ns/wire byte"
|
||||
);
|
||||
for (name, threshold, encrypt) in [
|
||||
("memory", -1, false),
|
||||
("tcp", 256, false),
|
||||
("tcp+aes", 256, true),
|
||||
] {
|
||||
let mut best = f64::MAX;
|
||||
let mut wire = 0;
|
||||
for _ in 0..repeat {
|
||||
let (seconds, bytes, got) = receive(&packets, threshold, encrypt);
|
||||
best = best.min(seconds);
|
||||
wire = bytes;
|
||||
received = got;
|
||||
}
|
||||
println!(
|
||||
" {name:<8} {:>10.1} {:>10.1} {:>12.0} {:>14.2}",
|
||||
wire as f64 / 1e6,
|
||||
best * 1e3,
|
||||
best * 1e9 / packets.len() as f64,
|
||||
best * 1e9 / wire.max(1) as f64
|
||||
);
|
||||
}
|
||||
|
||||
// Decode, per packet type: the fastest of the runs for each type.
|
||||
let mut costs: BTreeMap<i32, Cost> = BTreeMap::new();
|
||||
let mut sections = Cost::default();
|
||||
let mut errors = 0;
|
||||
for run in 0..repeat {
|
||||
let mut this: BTreeMap<i32, Cost> = BTreeMap::new();
|
||||
let mut this_sections = Cost::default();
|
||||
for (id, payload) in &received {
|
||||
let start = Instant::now();
|
||||
let packet = Clientbound::decode(*id, payload);
|
||||
let nanos = start.elapsed().as_nanos() as u64;
|
||||
let c = this.entry(*id).or_default();
|
||||
c.count += 1;
|
||||
c.bytes += payload.len() as u64;
|
||||
c.nanos += nanos;
|
||||
match packet {
|
||||
Ok(Clientbound::LevelChunkWithLight(p)) => {
|
||||
let start = Instant::now();
|
||||
if p.chunk.sections(24, 16, biome_bits).is_err() && run == 0 {
|
||||
errors += 1;
|
||||
}
|
||||
this_sections.count += 1;
|
||||
this_sections.bytes += p.chunk.buffer.len() as u64;
|
||||
this_sections.nanos += start.elapsed().as_nanos() as u64;
|
||||
}
|
||||
Err(_) if run == 0 => errors += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (id, c) in this {
|
||||
let best = costs.entry(id).or_insert(c);
|
||||
if c.nanos < best.nanos {
|
||||
*best = c;
|
||||
}
|
||||
}
|
||||
if run == 0 || this_sections.nanos < sections.nanos {
|
||||
sections = this_sections;
|
||||
}
|
||||
}
|
||||
let mut rows: Vec<_> = costs.into_iter().collect();
|
||||
rows.sort_by_key(|(_, c)| std::cmp::Reverse(c.nanos));
|
||||
let total: u64 = rows.iter().map(|(_, c)| c.nanos).sum();
|
||||
println!(
|
||||
"\ndecode (play::Clientbound::decode): {:.1} ms for {} packets, {errors} errors",
|
||||
total as f64 / 1e6,
|
||||
packets.len()
|
||||
);
|
||||
println!(
|
||||
" {:<34} {:>8} {:>10} {:>10} {:>10}",
|
||||
"packet", "count", "MB", "ms", "ns/packet"
|
||||
);
|
||||
for (id, c) in rows.iter().take(20) {
|
||||
let name = ids::name(State::Play, Direction::Clientbound, *id).unwrap_or("?");
|
||||
println!(
|
||||
" {name:<34} {:>8} {:>10.2} {:>10.2} {:>10.0}",
|
||||
c.count,
|
||||
c.bytes as f64 / 1e6,
|
||||
c.nanos as f64 / 1e6,
|
||||
c.nanos as f64 / c.count as f64
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"\nsections (LevelChunkPacketData.sections): {} chunks, {:.1} ms, {:.0} us a chunk",
|
||||
sections.count,
|
||||
sections.nanos as f64 / 1e6,
|
||||
sections.nanos as f64 / 1e3 / sections.count.max(1) as f64
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
//! One pool of worker threads for the whole process, shared by every source of
|
||||
//! background work: chunk generation, light and meshing (the client's and each
|
||||
//! of the server thread's levels), chunk encoding, far terrain.
|
||||
//!
|
||||
//! Before it, each of those started its own threads, about one per core each:
|
||||
//! a single player world ran four or five such pools at once, ~100 threads on a
|
||||
//! 32-thread CPU, and they fought each other and the render and server threads
|
||||
//! for cores. Here the threads are counted once, for the machine
|
||||
//! ([`configure`]), and the work goes to them in the order it matters.
|
||||
//!
|
||||
//! A [`Source`] keeps its own queue (it can rank its jobs again when the player
|
||||
//! moves, or drop the ones that left the view); a worker that is free asks every
|
||||
//! source for the [`Rank`] of its next job and runs the most urgent one: the
|
||||
//! [`Class`] first, then the source's key (a chunk's distance from the player).
|
||||
//! Every free worker takes the next job as soon as it is free, so no core waits
|
||||
//! while work is queued, whichever source queued it. Jobs are coarse (a chunk, a
|
||||
//! section, a tile: a millisecond or more), so one lock for the choice costs
|
||||
//! nothing next to them.
|
||||
//!
|
||||
//! A worker asks the sources for their next rank under the pool's read lock
|
||||
//! of its sources and nothing else, so a source may call [`Pool::notify`]
|
||||
//! anywhere, its queue's lock held or not; a notify never waits for a worker.
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How urgent a job is, before its source's key: every queued job of a lower
|
||||
/// class runs before any of a higher one.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Class {
|
||||
/// Work something waits for now: saves, a tick's chunk encoding.
|
||||
Urgent,
|
||||
/// The chunks around the player: generation, light, meshing.
|
||||
Near,
|
||||
/// Far terrain: runs on what the near work leaves, and never on every
|
||||
/// worker at once, so near work that arrives finds a worker free.
|
||||
Far,
|
||||
}
|
||||
|
||||
/// A job's place in the pool's order: lower runs first.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Rank {
|
||||
pub class: Class,
|
||||
/// The source's own order within the class (a column's distance).
|
||||
pub key: i64,
|
||||
}
|
||||
|
||||
impl Rank {
|
||||
pub const fn new(class: Class, key: i64) -> Self {
|
||||
Self { class, key }
|
||||
}
|
||||
}
|
||||
|
||||
/// Work queued for the pool.
|
||||
pub trait Source: Send + Sync {
|
||||
/// The rank of the job [`Source::run`] would take next, or None while
|
||||
/// nothing is queued. Called under the pool's lock: cheap, no blocking.
|
||||
fn next(&self) -> Option<Rank>;
|
||||
/// Take the next job and run it on worker `worker` (0..[`Pool::threads`]).
|
||||
/// False if there was none after all.
|
||||
fn run(&self, worker: usize) -> bool;
|
||||
/// Worker `worker` has waited [`IDLE_RELEASE`] with no work: free what it
|
||||
/// keeps between jobs.
|
||||
fn idle(&self, _worker: usize) {}
|
||||
/// Every worker is idle: free what the workers share.
|
||||
fn all_idle(&self) {}
|
||||
}
|
||||
|
||||
/// How long a worker waits with no job before it frees its buffers.
|
||||
pub const IDLE_RELEASE: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Per-worker state for a source: worker `i` only ever touches slot `i`, so
|
||||
/// the locks are never contended. The source owns it, so it goes with the source.
|
||||
pub struct PerWorker<T> {
|
||||
slots: Box<[Mutex<Option<T>>]>,
|
||||
}
|
||||
|
||||
impl<T> PerWorker<T> {
|
||||
pub fn new(workers: usize) -> Self {
|
||||
Self {
|
||||
slots: (0..workers.max(1)).map(|_| Mutex::new(None)).collect(),
|
||||
}
|
||||
}
|
||||
/// `f` with worker `worker`'s state, made by `init` the first time.
|
||||
pub fn with<R>(
|
||||
&self,
|
||||
worker: usize,
|
||||
init: impl FnOnce() -> T,
|
||||
f: impl FnOnce(&mut T) -> R,
|
||||
) -> R {
|
||||
let mut slot = lock(&self.slots[worker % self.slots.len()]);
|
||||
f(slot.get_or_insert_with(init))
|
||||
}
|
||||
/// `f` with worker `worker`'s state if it has one.
|
||||
pub fn with_existing<R>(&self, worker: usize, f: impl FnOnce(&mut T) -> R) -> Option<R> {
|
||||
lock(&self.slots[worker % self.slots.len()]).as_mut().map(f)
|
||||
}
|
||||
/// Drop worker `worker`'s state.
|
||||
pub fn clear(&self, worker: usize) -> Option<T> {
|
||||
lock(&self.slots[worker % self.slots.len()]).take()
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
id: u64,
|
||||
source: Arc<dyn Source>,
|
||||
/// Workers running one of its jobs.
|
||||
running: AtomicUsize,
|
||||
/// Taken out of the pool: the worker that ends its last job tells
|
||||
/// [`Registration`]'s drop, which waits for it.
|
||||
retired: AtomicBool,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
/// Read by every choice, written only to add or take out a source.
|
||||
sources: RwLock<Vec<Arc<Entry>>>,
|
||||
next_id: AtomicU64,
|
||||
/// Bumped by every [`Pool::notify`]: a worker that found nothing sleeps
|
||||
/// until it changes, so a job queued while it looked is not missed. A
|
||||
/// notify is two atomic operations while every worker is busy, and takes
|
||||
/// `sleep` (held by no source code) only to wake one: the thread queuing
|
||||
/// work (a frame, a tick) never waits behind a worker's choice.
|
||||
epoch: AtomicU64,
|
||||
sleepers: AtomicUsize,
|
||||
sleep: Mutex<()>,
|
||||
wake: Condvar,
|
||||
/// Signalled when a retired source's job ends.
|
||||
finished: Condvar,
|
||||
/// Workers running a [`Class::Far`] job.
|
||||
far: AtomicUsize,
|
||||
/// Workers that freed their buffers and took no job since.
|
||||
released: AtomicUsize,
|
||||
threads: AtomicUsize,
|
||||
idle_release: Duration,
|
||||
}
|
||||
|
||||
/// The pool: [`pool`] for the process's own, [`Pool::new`] for a test's.
|
||||
pub struct Pool {
|
||||
inner: Arc<Inner>,
|
||||
threads: usize,
|
||||
}
|
||||
|
||||
/// A source's place in the pool; dropping it takes the source out and waits
|
||||
/// for the jobs of it that are running (so never drop it in one of its own jobs).
|
||||
pub struct Registration {
|
||||
inner: Arc<Inner>,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
// A panic in a job is caught; the pool's own state is never left half
|
||||
// changed, so a poisoned lock is still consistent.
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
static CONFIGURED: AtomicUsize = AtomicUsize::new(0);
|
||||
static POOL: OnceLock<Pool> = OnceLock::new();
|
||||
|
||||
/// The number of workers the machine gets by default: one thread per core
|
||||
/// but one, which the render and server threads share with them (they run
|
||||
/// ahead of the workers: [`crate::favour_current`], [`crate::behind_server`]).
|
||||
pub fn default_threads() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map_or(2, |n| n.get())
|
||||
.saturating_sub(1)
|
||||
.max(1)
|
||||
}
|
||||
|
||||
/// Size the process's pool before its first use (later calls change
|
||||
/// nothing): `threads` workers, or `HORIZON_JOB_THREADS` when it is set.
|
||||
pub fn configure(threads: usize) {
|
||||
CONFIGURED.store(threads.max(1), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The process's pool, started on first use.
|
||||
pub fn pool() -> &'static Pool {
|
||||
POOL.get_or_init(|| {
|
||||
let threads = std::env::var("HORIZON_JOB_THREADS")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.filter(|&n| n > 0)
|
||||
.unwrap_or_else(|| match CONFIGURED.load(Ordering::Relaxed) {
|
||||
0 => default_threads(),
|
||||
n => n,
|
||||
});
|
||||
// The name the crash reports know a worker by (vanilla's `Util` workers).
|
||||
Pool::new(threads, "horizon-worker")
|
||||
})
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
/// A pool of `threads` workers named `name-<i>`. Workers that the system
|
||||
/// refuses to start leave it smaller ([`Pool::threads`] counts those that started).
|
||||
pub fn new(threads: usize, name: &str) -> Self {
|
||||
Self::with_idle_release(threads, name, IDLE_RELEASE)
|
||||
}
|
||||
|
||||
/// [`Pool::new`] whose workers free their buffers after `idle_release`.
|
||||
pub fn with_idle_release(threads: usize, name: &str, idle_release: Duration) -> Self {
|
||||
let inner = Arc::new(Inner {
|
||||
sources: RwLock::new(Vec::new()),
|
||||
next_id: AtomicU64::new(0),
|
||||
epoch: AtomicU64::new(0),
|
||||
sleepers: AtomicUsize::new(0),
|
||||
sleep: Mutex::new(()),
|
||||
wake: Condvar::new(),
|
||||
finished: Condvar::new(),
|
||||
far: AtomicUsize::new(0),
|
||||
released: AtomicUsize::new(0),
|
||||
threads: AtomicUsize::new(threads.max(1)),
|
||||
idle_release,
|
||||
});
|
||||
let mut started = 0;
|
||||
for i in 0..threads.max(1) {
|
||||
let worker = inner.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name(format!("{name}-{i}"))
|
||||
.spawn(move || {
|
||||
// Ticks and frames come first.
|
||||
crate::behind_server();
|
||||
work(&worker, i);
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
started += 1;
|
||||
}
|
||||
}
|
||||
inner.threads.store(started.max(1), Ordering::Relaxed);
|
||||
Self {
|
||||
inner,
|
||||
threads: started.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// The workers: the size of a source's [`PerWorker`] and of the work it
|
||||
/// keeps queued.
|
||||
pub fn threads(&self) -> usize {
|
||||
self.threads
|
||||
}
|
||||
|
||||
/// Add a source; its jobs run until the registration is dropped.
|
||||
pub fn register(&self, source: Arc<dyn Source>) -> Registration {
|
||||
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
self.inner
|
||||
.sources
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(Arc::new(Entry {
|
||||
id,
|
||||
source,
|
||||
running: AtomicUsize::new(0),
|
||||
retired: AtomicBool::new(false),
|
||||
}));
|
||||
self.inner.notify(true);
|
||||
Registration {
|
||||
inner: self.inner.clone(),
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake a worker for a job just queued (after the queue's lock is released).
|
||||
pub fn notify(&self) {
|
||||
self.inner.notify(false);
|
||||
}
|
||||
|
||||
/// Wake every worker: many jobs were queued at once.
|
||||
pub fn notify_all(&self) {
|
||||
self.inner.notify(true);
|
||||
}
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn notify(&self, all: bool) {
|
||||
self.epoch.fetch_add(1, Ordering::SeqCst);
|
||||
if self.sleepers.load(Ordering::SeqCst) > 0 {
|
||||
// A worker between counting itself asleep and waiting holds
|
||||
// `sleep`: taking it here means it is waiting when told.
|
||||
drop(lock(&self.sleep));
|
||||
if all {
|
||||
self.wake.notify_all();
|
||||
} else {
|
||||
self.wake.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Registration {
|
||||
/// Wake a worker for a job just queued: [`Pool::notify`].
|
||||
pub fn notify(&self) {
|
||||
self.inner.notify(false);
|
||||
}
|
||||
pub fn notify_all(&self) {
|
||||
self.inner.notify(true);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Registration {
|
||||
fn drop(&mut self) {
|
||||
let entry = {
|
||||
let mut sources = self
|
||||
.inner
|
||||
.sources
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let Some(at) = sources.iter().position(|e| e.id == self.id) else {
|
||||
return;
|
||||
};
|
||||
sources.swap_remove(at)
|
||||
};
|
||||
// No worker takes a job of it now (a worker counts itself running
|
||||
// while it holds the sources' read lock); wait for those that did.
|
||||
entry.retired.store(true, Ordering::SeqCst);
|
||||
let mut sleep = lock(&self.inner.sleep);
|
||||
while entry.running.load(Ordering::SeqCst) > 0 {
|
||||
sleep = self
|
||||
.inner
|
||||
.finished
|
||||
.wait(sleep)
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
}
|
||||
drop(sleep);
|
||||
// The source's last reference may go here, outside every lock.
|
||||
drop(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// The most urgent queued job's source, counted as running, if any may run now.
|
||||
fn choose(inner: &Inner) -> Option<(Arc<Entry>, bool)> {
|
||||
let threads = inner.threads.load(Ordering::Relaxed);
|
||||
// Far work keeps one worker free for near work (when there are two or more).
|
||||
let far_cap = threads.saturating_sub(1).max(1);
|
||||
let sources = inner.sources.read().unwrap_or_else(|e| e.into_inner());
|
||||
let mut skip_far = false;
|
||||
loop {
|
||||
let far_open = !skip_far && inner.far.load(Ordering::SeqCst) < far_cap;
|
||||
let mut best: Option<(&Arc<Entry>, Rank)> = None;
|
||||
for entry in sources.iter() {
|
||||
let Some(rank) = entry.source.next() else {
|
||||
continue;
|
||||
};
|
||||
if rank.class == Class::Far && !far_open {
|
||||
continue;
|
||||
}
|
||||
if best.is_none_or(|(_, b)| rank < b) {
|
||||
best = Some((entry, rank));
|
||||
}
|
||||
}
|
||||
let (entry, rank) = best?;
|
||||
let far = rank.class == Class::Far;
|
||||
if far && !take_far_place(inner, far_cap) {
|
||||
// Another worker took the last far place meanwhile.
|
||||
skip_far = true;
|
||||
continue;
|
||||
}
|
||||
entry.running.fetch_add(1, Ordering::SeqCst);
|
||||
return Some((entry.clone(), far));
|
||||
}
|
||||
}
|
||||
|
||||
/// One more far job running, if fewer than `cap` are.
|
||||
fn take_far_place(inner: &Inner, cap: usize) -> bool {
|
||||
let mut n = inner.far.load(Ordering::SeqCst);
|
||||
while n < cap {
|
||||
match inner
|
||||
.far
|
||||
.compare_exchange_weak(n, n + 1, Ordering::SeqCst, Ordering::SeqCst)
|
||||
{
|
||||
Ok(_) => return true,
|
||||
Err(now) => n = now,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn work(inner: &Inner, worker: usize) {
|
||||
let mut released = false;
|
||||
loop {
|
||||
let epoch = inner.epoch.load(Ordering::SeqCst);
|
||||
if let Some((entry, far)) = choose(inner) {
|
||||
if released {
|
||||
inner.released.fetch_sub(1, Ordering::Relaxed);
|
||||
released = false;
|
||||
}
|
||||
// A panicking job loses its own work, not the worker: the panic
|
||||
// hook has already reported it.
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| entry.source.run(worker)));
|
||||
if far {
|
||||
inner.far.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
entry.running.fetch_sub(1, Ordering::SeqCst);
|
||||
if entry.retired.load(Ordering::SeqCst) {
|
||||
drop(lock(&inner.sleep));
|
||||
inner.finished.notify_all();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let sleep = lock(&inner.sleep);
|
||||
inner.sleepers.fetch_add(1, Ordering::SeqCst);
|
||||
let still = |_: &mut ()| inner.epoch.load(Ordering::SeqCst) == epoch;
|
||||
let timed_out = if released {
|
||||
drop(
|
||||
inner
|
||||
.wake
|
||||
.wait_while(sleep, still)
|
||||
.unwrap_or_else(|e| e.into_inner()),
|
||||
);
|
||||
false
|
||||
} else {
|
||||
let (sleep, wait) = inner
|
||||
.wake
|
||||
.wait_timeout_while(sleep, inner.idle_release, still)
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
drop(sleep);
|
||||
wait.timed_out()
|
||||
};
|
||||
inner.sleepers.fetch_sub(1, Ordering::SeqCst);
|
||||
if !timed_out {
|
||||
continue;
|
||||
}
|
||||
// Nothing to do for a while (a server nobody is on, a player standing
|
||||
// still): free this worker's buffers until the next job needs them.
|
||||
released = true;
|
||||
let last = inner.released.fetch_add(1, Ordering::Relaxed) + 1
|
||||
== inner.threads.load(Ordering::Relaxed);
|
||||
let sources: Vec<_> = inner
|
||||
.sources
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.map(|e| e.source.clone())
|
||||
.collect();
|
||||
for source in &sources {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| source.idle(worker)));
|
||||
}
|
||||
if last {
|
||||
// The allocator keeps freed pages until memory runs short: once
|
||||
// every worker has freed its own, hand them back.
|
||||
for source in &sources {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| source.all_idle()));
|
||||
}
|
||||
crate::release_free_memory();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Jobs with fixed ranks; each records its name when it runs.
|
||||
struct Fixed {
|
||||
jobs: Mutex<VecDeque<(Rank, &'static str)>>,
|
||||
ran: mpsc::Sender<&'static str>,
|
||||
hold: Option<Arc<(Mutex<bool>, Condvar)>>,
|
||||
}
|
||||
impl Source for Fixed {
|
||||
fn next(&self) -> Option<Rank> {
|
||||
lock(&self.jobs).iter().map(|(r, _)| *r).min()
|
||||
}
|
||||
fn run(&self, _worker: usize) -> bool {
|
||||
let job = {
|
||||
let mut jobs = lock(&self.jobs);
|
||||
let Some(at) = jobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, (r, _))| *r)
|
||||
.map(|(i, _)| i)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
jobs.remove(at).unwrap()
|
||||
};
|
||||
if let Some(hold) = &self.hold {
|
||||
let (open, cv) = &**hold;
|
||||
let mut open = lock(open);
|
||||
while !*open {
|
||||
open = cv.wait(open).unwrap();
|
||||
}
|
||||
}
|
||||
let _ = self.ran.send(job.1);
|
||||
true
|
||||
}
|
||||
}
|
||||
fn fixed(jobs: &[(Rank, &'static str)], ran: mpsc::Sender<&'static str>) -> Arc<Fixed> {
|
||||
Arc::new(Fixed {
|
||||
jobs: Mutex::new(jobs.iter().copied().collect()),
|
||||
ran,
|
||||
hold: None,
|
||||
})
|
||||
}
|
||||
fn recv_n(rx: &mpsc::Receiver<&'static str>, n: usize) -> Vec<&'static str> {
|
||||
(0..n)
|
||||
.map(|_| rx.recv_timeout(Duration::from_secs(10)).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_worker_runs_every_source_in_rank_order() {
|
||||
let pool = Pool::new(1, "test-job");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
// Held until both sources are in, so the order is the pool's choice.
|
||||
let gate = Arc::new((Mutex::new(false), Condvar::new()));
|
||||
let first = Arc::new(Fixed {
|
||||
jobs: Mutex::new([(Rank::new(Class::Urgent, i64::MIN), "gate")].into()),
|
||||
ran: tx.clone(),
|
||||
hold: Some(gate.clone()),
|
||||
});
|
||||
let _g = pool.register(first);
|
||||
let a = fixed(
|
||||
&[
|
||||
(Rank::new(Class::Near, 5), "near5"),
|
||||
(Rank::new(Class::Far, 0), "far0"),
|
||||
],
|
||||
tx.clone(),
|
||||
);
|
||||
let b = fixed(
|
||||
&[
|
||||
(Rank::new(Class::Near, 1), "near1"),
|
||||
(Rank::new(Class::Urgent, 9), "urgent9"),
|
||||
],
|
||||
tx,
|
||||
);
|
||||
let _a = pool.register(a);
|
||||
let _b = pool.register(b);
|
||||
*gate.0.lock().unwrap() = true;
|
||||
gate.1.notify_all();
|
||||
assert_eq!(
|
||||
recv_n(&rx, 5),
|
||||
["gate", "urgent9", "near1", "near5", "far0"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_work_leaves_a_worker_for_near_work() {
|
||||
let pool = Pool::new(3, "test-job");
|
||||
let gate = Arc::new((Mutex::new(false), Condvar::new()));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let far = Arc::new(Fixed {
|
||||
jobs: Mutex::new((0..6).map(|i| (Rank::new(Class::Far, i), "far")).collect()),
|
||||
ran: tx.clone(),
|
||||
hold: Some(gate.clone()),
|
||||
});
|
||||
let _far = pool.register(far);
|
||||
// Two far jobs hold two workers; the third stays free for this one.
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let near = fixed(&[(Rank::new(Class::Near, 0), "near")], tx);
|
||||
let near_reg = pool.register(near);
|
||||
near_reg.notify();
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap(), "near");
|
||||
*gate.0.lock().unwrap() = true;
|
||||
gate.1.notify_all();
|
||||
assert_eq!(recv_n(&rx, 6), ["far"; 6]);
|
||||
}
|
||||
|
||||
/// A source that counts its jobs, queued from outside while the workers run.
|
||||
struct Counter {
|
||||
left: Mutex<u64>,
|
||||
done: AtomicUsize,
|
||||
per_worker: PerWorker<u64>,
|
||||
idle: AtomicBool,
|
||||
all_idle: AtomicBool,
|
||||
}
|
||||
impl Source for Counter {
|
||||
fn next(&self) -> Option<Rank> {
|
||||
(*lock(&self.left) > 0).then_some(Rank::new(Class::Near, 0))
|
||||
}
|
||||
fn run(&self, worker: usize) -> bool {
|
||||
{
|
||||
let mut left = lock(&self.left);
|
||||
if *left == 0 {
|
||||
return false;
|
||||
}
|
||||
*left -= 1;
|
||||
}
|
||||
self.per_worker.with(worker, || 0, |n| *n += 1);
|
||||
self.done.fetch_add(1, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
fn idle(&self, worker: usize) {
|
||||
self.per_worker.clear(worker);
|
||||
self.idle.store(true, Ordering::Relaxed);
|
||||
}
|
||||
fn all_idle(&self) {
|
||||
self.all_idle.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_job_is_missed_when_queued_while_workers_look() {
|
||||
let pool = Pool::new(4, "test-job");
|
||||
let counter = Arc::new(Counter {
|
||||
left: Mutex::new(0),
|
||||
done: AtomicUsize::new(0),
|
||||
per_worker: PerWorker::new(pool.threads()),
|
||||
idle: AtomicBool::new(false),
|
||||
all_idle: AtomicBool::new(false),
|
||||
});
|
||||
let reg = pool.register(counter.clone());
|
||||
for _ in 0..20_000 {
|
||||
*lock(&counter.left) += 1;
|
||||
reg.notify();
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
while counter.done.load(Ordering::Relaxed) < 20_000 {
|
||||
assert!(Instant::now() < deadline, "jobs left behind");
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_workers_free_their_state() {
|
||||
let release = Duration::from_millis(300);
|
||||
let pool = Pool::with_idle_release(2, "test-job", release);
|
||||
let counter = Arc::new(Counter {
|
||||
left: Mutex::new(10),
|
||||
done: AtomicUsize::new(0),
|
||||
per_worker: PerWorker::new(pool.threads()),
|
||||
idle: AtomicBool::new(false),
|
||||
all_idle: AtomicBool::new(false),
|
||||
});
|
||||
let reg = pool.register(counter.clone());
|
||||
reg.notify_all();
|
||||
let deadline = Instant::now() + release * 20;
|
||||
while !counter.all_idle.load(Ordering::Relaxed) {
|
||||
assert!(Instant::now() < deadline, "workers never released");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert!(counter.idle.load(Ordering::Relaxed));
|
||||
assert_eq!(counter.done.load(Ordering::Relaxed), 10);
|
||||
assert!(counter.per_worker.slots.iter().all(|s| lock(s).is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_a_registration_waits_for_its_running_job() {
|
||||
let pool = Pool::new(2, "test-job");
|
||||
let gate = Arc::new((Mutex::new(false), Condvar::new()));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let held = Arc::new(Fixed {
|
||||
jobs: Mutex::new(
|
||||
[
|
||||
(Rank::new(Class::Near, 0), "held"),
|
||||
(Rank::new(Class::Near, 1), "second"),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
ran: tx,
|
||||
hold: Some(gate.clone()),
|
||||
});
|
||||
let reg = pool.register(held.clone());
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let opener = {
|
||||
let gate = gate.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
*gate.0.lock().unwrap() = true;
|
||||
gate.1.notify_all();
|
||||
})
|
||||
};
|
||||
let began = Instant::now();
|
||||
drop(reg);
|
||||
// Both jobs may have started (two workers); none runs after the drop.
|
||||
assert!(began.elapsed() >= Duration::from_millis(150));
|
||||
opener.join().unwrap();
|
||||
let ran: Vec<_> = rx.try_iter().collect();
|
||||
assert!(ran.contains(&"held"));
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
assert!(rx.try_iter().next().is_none());
|
||||
// The pool's own references are gone.
|
||||
assert_eq!(Arc::strong_count(&held), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panicking_job_keeps_its_worker() {
|
||||
struct Boom(AtomicUsize);
|
||||
impl Source for Boom {
|
||||
fn next(&self) -> Option<Rank> {
|
||||
(self.0.load(Ordering::Relaxed) < 2).then_some(Rank::new(Class::Near, 0))
|
||||
}
|
||||
fn run(&self, _worker: usize) -> bool {
|
||||
if self.0.fetch_add(1, Ordering::Relaxed) == 0 {
|
||||
panic!("first job fails");
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
let pool = Pool::new(1, "test-job");
|
||||
let boom = Arc::new(Boom(AtomicUsize::new(0)));
|
||||
let reg = pool.register(boom.clone());
|
||||
reg.notify();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while boom.0.load(Ordering::Relaxed) < 2 {
|
||||
assert!(Instant::now() < deadline, "the worker died with the panic");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
/// The game window's boot screen, before wgpu's first frame paints over it.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod boot_paint;
|
||||
pub mod jobs;
|
||||
pub mod memory_limit;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
@@ -8,6 +8,7 @@ description = "Horizon vanilla world data, NBT, regions and chunk storage"
|
||||
|
||||
[dependencies]
|
||||
libm = "=0.2.16"
|
||||
log = "0.4"
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
sha2 = "0.10"
|
||||
|
||||
File renamed without changes.
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user