Merge main and retain synchronous command reinforcements

Keep the reinforcement request collection and post-hit drain around the
verified-owner damage adapter, and preserve all upstream corpus groups.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
This commit is contained in:
TheMrZZandCodex gpt-6.1-sol committed 2026-11-05 06:09:51 +01:00
commit 6ff0152b94
118 files changed
+8765 -1233

No files matched your search

Generated
+1
View File
@@ -1400,6 +1400,7 @@ dependencies = [
"chrono",
"flate2",
"fs4",
"getrandom 0.3.4",
"gilrs",
"glam",
"horizon-anticheat",
@@ -0,0 +1,42 @@
# Entity occlusion split measurements — 2026-11-02
These measurements came from the pre-split integration branch for PR #7054. That build contained the entity-occlusion rewrite and particle light lookup cache; Particle Culling was Off. They are useful comparison evidence, but they are not a benchmark of this isolated entity-only commit. The cache-only run in PR #7391 measured effectively the same CPU time on the ordinary scene, but the combined result still cannot isolate interactions.
Host: Intel Core i7-3770S, Intel HD Graphics 4000, Vulkan/Mesa, 675×727, render distance 4, VSync off, 260 FPS cap. Two opposite-order passes measured for 8 seconds after warmup. Values are means. CPU is harness average CPU ms per frame; p99 is frame time. The driver supplied no usable GPU timing.
## Ordinary scene
The scene had 24 mobs, roughly 9,400–10,100 rain particles and campfire smoke.
| Build and mode | FPS | CPU ms/frame | Frame p99 ms |
| --- | ---: | ---: | ---: |
| Main, entity culling Off | 88.59 | 1.829 | 24.51 |
| Pre-split integration branch, all culling Off | 88.33 | 2.063 | 24.51 |
| Main, entity culling Synchronous | 89.34 | 1.965 | 23.99 |
| Pre-split integration branch, entity Synchronous | 89.13 | 2.113 | 24.15 |
This normal-scene run does not show a large FPS change; its CPU differences are small relative to frame time and not attributable to the entity-only change by themselves.
## Opaque-wall scene
A 32×32 platform at Y=300, a 7-block wall with a one-block vertical opening, and 192 pigs spawned behind the wall (216 mobs in the captured scene). The player looked toward the opening from the near side. The same world and camera path were used for the main and integration builds.
| Build and mode | FPS | CPU ms/frame | Frame p99 ms |
| --- | ---: | ---: | ---: |
| Main, Off | 89.08 | 2.423 | 13.52 |
| Integration branch, Off | 89.23 | 2.355 | 13.27 |
| Main, Synchronous | 92.81 | 2.566 | 14.60 |
| Integration branch, Synchronous | 93.21 | 2.550 | 12.84 |
| Main, Asynchronous | 92.73 | 2.364 | 13.04 |
| Integration branch, Asynchronous | 92.62 | 2.314 | 13.18 |
The asynchronous mode reduced measured CPU time slightly in this wall scene but did not improve FPS beyond run-to-run spread. This is a synthetic occlusion-heavy scene, not a broad performance claim.
## Captures
The HUD performance overlay is visible in each capture. The integration-build screenshots were recorded with the same wall and camera position in Off, Synchronous, and Asynchronous modes.
- Main, Off: `main-off.png`
- Integration branch, Off: `branch-off.png`
- Integration branch, Synchronous: `branch-sync.png`
- Integration branch, Asynchronous: `branch-async.png`
Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

@@ -0,0 +1,42 @@
# Entity occlusion pipeline — release/OpenGL run (2026-11-04)
This is the final local smoke benchmark for the entity-occlusion pipeline before opening the PR. It was run from the branch after the release build completed, with the OpenGL backend selected explicitly. The same process created each scene, switched the culling mode, flew the camera for two seconds, collected frame/CPU telemetry, and captured the screenshot after the scene settled.
**Host:** Intel Core i7-3770S, Intel HD Graphics 4000, Mesa OpenGL 4.2
**Window:** 1280×720, offscreen, VSync disabled
**World:** fresh temporary home, 32×32 platform at Y=300; wall scenes add a solid stone wall and an optional one-block slit
**Entities:** 24 pigs in the normal scene, 192 pigs in wall scenes, 512 pigs in stress scenes
**Settings:** render distance 8, `entityCullingPrecheck:64`, FPS overlay on, no resource-pack occlusion assumptions
**Build:** `cargo build --release -p horizon-client`
## Results
The values below are branch-only measurements. They are useful for comparing the three modes under identical geometry and for checking that the worker is doing useful work. A fresh main binary was not built in this short pass, so these numbers do not claim a controlled main-to-branch delta.
| Scene | Off FPS / CPU p50 | Sync FPS / CPU p50 | Async FPS / CPU p50 | Async entities culled |
| --- | ---: | ---: | ---: | ---: |
| `entity_normal` (24 visible) | 240.5 / 0.586 ms | 233.5 / 0.762 ms | 239.3 / 0.686 ms | 0 |
| `entity_wall` (192, solid) | 228.8 / 1.245 ms | 318.6 / 1.342 ms | **324.5 / 0.478 ms** | 124,608 |
| `entity_wall_slit` (192, one-block slit) | 231.4 / 1.102 ms | 265.0 / 1.387 ms | **281.2 / 0.716 ms** | 102,826 |
| `entity_stress` (512, solid) | 190.3 / 2.467 ms | 299.2 / 2.593 ms | **325.5 / 0.691 ms** | 290,234 |
| `entity_stress_slit` (512, one-block slit) | 190.4 / 2.476 ms | 262.2 / 2.573 ms | **262.1 / 1.297 ms** | 227,301 |
Async telemetry confirmed that the render thread stayed non-blocking (`try_recv`/`try_send`, `worker_blocking_wait_ms: 0`). The latest wall/slit run completed 562 worker jobs, queued 3,982 queries, avoided about 4.0 million entity bytes, and had 379 ns recent p50 worker-job time. The solid 512-pig run culled 15,552 generated vertices; the slit run culled 120,290 generated vertices. The normal scene recorded no pre-model hides and kept the opt-in path close to Off.
## Captures
Every image includes the FPS performance overlay. The names are `<mode>-<scene>.png`:
- [Off — normal](screenshots/off-entity_normal.png), [wall](screenshots/off-entity_wall.png), [wall slit](screenshots/off-entity_wall_slit.png), [stress](screenshots/off-entity_stress.png), [stress slit](screenshots/off-entity_stress_slit.png)
- [Sync — normal](screenshots/sync-entity_normal.png), [wall](screenshots/sync-entity_wall.png), [wall slit](screenshots/sync-entity_wall_slit.png), [stress](screenshots/sync-entity_stress.png), [stress slit](screenshots/sync-entity_stress_slit.png)
- [Async — normal](screenshots/async-entity_normal.png), [wall](screenshots/async-entity_wall.png), [wall slit](screenshots/async-entity_wall_slit.png), [stress](screenshots/async-entity_stress.png), [stress slit](screenshots/async-entity_stress_slit.png)
## Reproduction
From the repository root, with prepared 26.3 resources available:
```sh
python3 tools/entity_occlusion_bench.py
```
The script uses fresh temporary game data, the release binary in `target/release`, OpenGL, the FPS overlay, and writes JSON plus captures under `/tmp/horizon-entity-occlusion-release-quick/`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

+2
View File
@@ -30,6 +30,8 @@ horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# The control endpoint's token (control.rs), as horizon-server's and the relay's.
getrandom = "0.3"
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
@@ -271,10 +271,10 @@ pub const DYES: [(&str, u32); 16] = [
("black", 0x1D1D21),
];
fn dye_prefix(name: &str) -> Option<(usize, &str)> {
// Longest names first so "light_gray" wins over "gray".
let mut order: Vec<usize> = (0..16).collect();
order.sort_by_key(|&i| std::cmp::Reverse(DYES[i].0.len()));
order.into_iter().find_map(|i| {
// Longest names first; this is the same stable order as sorting DYES by
// descending prefix length, without a Vec allocation or comparison sort.
const ORDER: [usize; 16] = [3, 8, 2, 1, 4, 10, 0, 12, 13, 15, 5, 6, 7, 9, 11, 14];
ORDER.into_iter().find_map(|i| {
name.strip_prefix(DYES[i].0)
.and_then(|rest| rest.strip_prefix('_'))
.map(|rest| (i, rest))
@@ -841,6 +841,9 @@ pub struct Scene<'a> {
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
/// Per-block visibility for dynamic, animated, or uncached models. Bounds
/// are world-space and conservatively include model animation extents.
pub dynamic_visible: Option<&'a dyn Fn([i32; 3], u32, DVec3, DVec3) -> bool>,
}
/// Empty by default (a dedicated server): no state has a block entity model.
@@ -1410,6 +1413,167 @@ impl BlockEntityRenderer {
fn kind(&self, state: u32) -> &Kind {
self.kinds.get(state as usize).unwrap_or(&Kind::None)
}
fn dynamic_bounds(
&self,
scene: &Scene,
pos: [i32; 3],
kind: &Kind,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> (DVec3, DVec3) {
let [x, y, z] = pos.map(f64::from);
let mut lo = DVec3::new(x, y, z);
let mut hi = DVec3::new(x + 1.0, y + 1.0, z + 1.0);
match kind {
Kind::Chest { double_with, .. } => {
if let Some(offset) = double_with {
lo.x += f64::from(offset[0].min(0));
lo.z += f64::from(offset[2].min(0));
hi.x += f64::from(offset[0].max(0));
hi.z += f64::from(offset[2].max(0));
}
let is_open = chest_openness.map_or_else(
|| {
(scene.lid)(pos) != 0.0
|| double_with.is_some_and(|offset| {
let other = [
pos[0].saturating_add(offset[0]),
pos[1].saturating_add(offset[1]),
pos[2].saturating_add(offset[2]),
];
(scene.lid)(other) != 0.0
})
},
|(open, _)| open != 0.0,
);
if is_open {
// The lid pivots up from the rear edge. Its longest corner
// rises by less than one block in the fully open pose.
hi.y += 0.8;
}
}
Kind::Shulker { facing, .. } => {
let offset = step(facing);
lo.x += f64::from(offset[0].min(0)) * 0.52;
lo.y += f64::from(offset[1].min(0)) * 0.52;
lo.z += f64::from(offset[2].min(0)) * 0.52;
hi.x += f64::from(offset[0].max(0)) * 0.52;
hi.y += f64::from(offset[1].max(0)) * 0.52;
hi.z += f64::from(offset[2].max(0)) * 0.52;
}
Kind::Banner { wall: false, .. } => {
hi.y += 1.0;
lo.x -= 0.05;
lo.z -= 0.05;
hi.x += 0.05;
hi.z += 0.05;
}
Kind::Banner { wall: true, .. } => {
lo.y -= 1.0;
lo.x -= 0.05;
lo.z -= 0.05;
hi.x += 0.05;
hi.z += 0.05;
}
Kind::Sign { hanging: true, .. } => {
lo.y -= 1.0;
lo.x -= 0.15;
lo.z -= 0.15;
hi.x += 0.15;
hi.z += 0.15;
}
Kind::Sign { hanging: false, .. } => {
hi.y += 0.3;
lo.x -= 0.02;
lo.z -= 0.02;
hi.x += 0.02;
hi.z += 0.02;
}
Kind::EnchantingTable | Kind::Lectern { .. } => {
// The book and statue models rise above the block's collision
// cube; include their full rendered height and rotated corners.
hi.y += 1.0;
lo.x -= 0.25;
lo.z -= 0.25;
hi.x += 0.25;
hi.z += 0.25;
}
Kind::CopperGolem { .. } => {
// The statue pose rotates around the root, so some variants
// extend below the block as well as above it.
lo.y -= 1.0;
hi.y += 1.0;
lo.x -= 0.25;
lo.z -= 0.25;
hi.x += 0.25;
hi.z += 0.25;
}
Kind::Conduit => {
// Its animated cage is centered above the conduit block and
// the wind model spans multiple cells. The caller bypasses
// voxel occlusion for envelopes this tall, but retains frustum
// culling with this conservative volume.
lo.x -= 1.0;
lo.y -= 1.0;
lo.z -= 1.0;
hi.x += 1.0;
hi.y += 1.0;
hi.z += 1.0;
}
Kind::Bell => {
// The bell's pivoted swing moves its corners slightly outside
// the block even though the model starts centered in it.
lo.x -= 0.1;
lo.y -= 0.1;
lo.z -= 0.1;
hi.x += 0.1;
hi.y += 0.1;
hi.z += 0.1;
}
Kind::Skull { .. } => {
// Wall-mounted and dragon heads can protrude from their block.
lo.x -= 0.25;
lo.y -= 0.25;
lo.z -= 0.25;
hi.x += 0.25;
hi.y += 0.25;
hi.z += 0.25;
}
Kind::EndGateway { .. } => {
let anim = (scene.anim)(pos);
let beam = gateway_beam(anim.gateway, scene.partial, scene.max_y, scene.game_time);
let height = f64::from(beam.height.max(0));
lo.y -= height;
hi.y += height;
}
_ => {}
}
(lo, hi)
}
fn is_dynamic_visible(
&self,
scene: &Scene,
shapes: &Arc<BlockEntityShapes>,
pos: [i32; 3],
state: u32,
kind: &Kind,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> bool {
let Some(visible) = scene.dynamic_visible else {
return true;
};
if matches!(kind, Kind::EndGateway { .. }) {
return true;
}
// A pack model may extend outside the vanilla envelope. Keep that
// geometry on the uncullable path unless its model bounds are known.
if !Arc::ptr_eq(shapes, &self.vanilla) {
return true;
}
let (lo, hi) = self.dynamic_bounds(scene, pos, kind, chest_openness);
visible(pos, state, lo, hi)
}
fn cached_entry_is_current(
&self,
scene: &Scene,
@@ -1419,6 +1583,7 @@ impl BlockEntityRenderer {
options: CacheOptions,
entry: &StaticEntry,
section_revision: StaticSectionRevision,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> bool {
if entry.key.state != state
|| entry.key.nether != scene.nether
@@ -1430,15 +1595,24 @@ impl BlockEntityRenderer {
}
match kind {
Kind::Chest { .. } => {
let (open, other) = self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
);
// Double chests depend on the neighboring half's light too;
// let those uncommon models take the full key path.
open == 0.0 && other.is_none()
let (open, other) = chest_openness.unwrap_or_else(|| {
self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
)
});
if open != 0.0 {
return false;
}
let mut light = scene.world.light(pos[0], pos[1], pos[2]);
if let Some(other) = other {
let other_light = scene.world.light(other[0], other[1], other[2]);
light = (light >> 4).max(other_light >> 4) << 4
| (light & 15).max(other_light & 15);
}
entry.key.light == light
}
Kind::Shulker { .. } => {
let (progress, old) = (scene.anim)(pos).shulker;
@@ -1701,6 +1875,17 @@ impl BlockEntityRenderer {
}
let local = static_local_index(pos, section_min_y);
let current_section_revision = active_section_revision.unwrap_or_default();
let chest_openness = (cache.is_some()
&& options.chests
&& matches!(kind, Kind::Chest { .. }))
.then(|| {
self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
)
});
if cache_only && section_revision.is_some() && active_section_revision.is_none() {
break 'draw;
}
@@ -1716,6 +1901,7 @@ impl BlockEntityRenderer {
options,
&active_entries[index].1,
current_section_revision,
chest_openness,
)
{
let entry = &mut active_entries[index].1;
@@ -1735,7 +1921,9 @@ impl BlockEntityRenderer {
..*vertex
}));
}
if matches!(kind, Kind::Banner { .. }) {
if matches!(kind, Kind::Banner { .. })
&& self.is_dynamic_visible(scene, &shapes, pos, state, kind, None)
{
let packed = scene.world.light(pos[0], pos[1], pos[2]);
let origin =
(DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
@@ -1756,11 +1944,25 @@ impl BlockEntityRenderer {
}
break 'draw;
}
if cache_only && chest_openness.is_some_and(|(open, _)| open != 0.0) {
if let Some(cache) = &mut cache
&& let Ok(index) =
active_entries.binary_search_by_key(&local, |(index, _)| *index)
{
active_entries.remove(index);
cache.len -= 1;
cache.mark_section_dirty(section);
}
break 'draw;
}
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
- scene.camera)
.as_vec3();
let mut packed = scene.world.light(pos[0], pos[1], pos[2]);
if matches!(kind, Kind::Sign { .. }) && scene.texts.is_none() {
if !self.is_dynamic_visible(scene, &shapes, pos, state, kind, None) {
break 'draw;
}
if options.signs {
self.sign_text(scene, pos, state, origin, packed, out);
} else {
@@ -1779,8 +1981,15 @@ impl BlockEntityRenderer {
break 'draw;
}
if let Some(cache) = &mut cache
&& let Some(key) =
self.static_key(scene, pos, state, kind, options, &mut packed)
&& let Some(key) = self.static_key(
scene,
pos,
state,
kind,
options,
&mut packed,
chest_openness,
)
{
let slot = active_entries.binary_search_by_key(&local, |(index, _)| *index);
let entry = match slot {
@@ -1865,7 +2074,9 @@ impl BlockEntityRenderer {
..*vertex
}));
}
if matches!(kind, Kind::Banner { .. }) {
if matches!(kind, Kind::Banner { .. })
&& self.is_dynamic_visible(scene, &shapes, pos, state, kind, None)
{
self.render_banner(
&shapes,
scene,
@@ -1892,6 +2103,9 @@ impl BlockEntityRenderer {
if cache_only {
break 'draw;
}
if !self.is_dynamic_visible(scene, &shapes, pos, state, kind, chest_openness) {
break 'draw;
}
let base = Mat4::from_translation(origin);
self.render_one(scene, pos, kind, base, packed, lights, false, true, out);
}
@@ -1958,6 +2172,7 @@ impl BlockEntityRenderer {
kind: &Kind,
options: CacheOptions,
packed: &mut u8,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> Option<StaticKey> {
let mut sprite_rects = [None; 5];
let mut sprite_count = 0;
@@ -1975,12 +2190,14 @@ impl BlockEntityRenderer {
part,
..
} if options.chests => {
let (open, other) = self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
);
let (open, other) = chest_openness.unwrap_or_else(|| {
self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
)
});
if open != 0.0 {
return None;
}
@@ -3362,6 +3579,7 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
// Compare the cached world path with the original model renderer,
// including atlas replacement, current light and animated fallback.
@@ -3656,6 +3874,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let mut call = TextCall {
matrix: Mat4::IDENTITY,
@@ -3728,6 +3947,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let call = TextCall {
matrix: Mat4::IDENTITY,
@@ -3843,6 +4063,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
let mut cached = Vec::new();
@@ -3983,6 +4204,7 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let mut verts = Vec::new();
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
@@ -4068,6 +4290,7 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
let draw = |renderer: &BlockEntityRenderer, block: &str, texture: &'static str| {
@@ -358,6 +358,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
// Reverse local-position order to check that cache indexing does not
// change the order of emitted entities.
@@ -446,6 +447,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
shulker_boxes: true,
@@ -500,6 +502,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([100, 70, -32], 0)];
let other_block = [([116, 70, -32], 0)];
@@ -686,6 +689,7 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let enabled = CacheOptions {
@@ -739,6 +743,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -840,6 +845,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
shulker_boxes: true,
@@ -894,6 +900,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -955,6 +962,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -1050,6 +1058,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let pos = [0, 64, 0];
let options = CacheOptions {
@@ -1138,6 +1147,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
let options = CacheOptions {
@@ -1213,6 +1223,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -1291,6 +1302,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -1357,6 +1369,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let pos = [0, 64, 0];
let options = CacheOptions {
@@ -1404,6 +1417,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
copper_golem_statues: true,
@@ -1509,6 +1523,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
copper_golem_statues: true,
+202 -3
View File
@@ -5202,6 +5202,16 @@ impl Renderer {
pub fn start_occlusion(&mut self, camera: DVec3, distance: f32) {
self.occlusion.update(camera, (distance / 16.0) as i32 + 2);
}
/// Whether the camera's current section graph knows this section is
/// reachable. `None` means the graph cannot safely answer for this camera
/// or section, so callers should keep the geometry.
pub fn section_occlusion_visible(
&self,
key: SectionKey,
camera_section: SectionKey,
) -> Option<bool> {
self.occlusion.visible_for_camera(key, camera_section)
}
pub fn upload(&mut self, key: SectionKey, mesh: &SectionMesh) {
if let Some(copies) = &mut self.capture_copies {
copies.section(key, mesh);
@@ -7441,7 +7451,8 @@ impl Renderer {
});
}
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul.
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul; for
// an Iris-format pack, only those whose shadow can fall into the camera's view.
let shadow_view = self
.pack_post
.as_ref()
@@ -7596,8 +7607,11 @@ impl Renderer {
let radius = v
.distance
.map_or(radius, |d| radius.min((d / 16.0).ceil() as i32));
let light = Frustum {
planes: frustum(v.cull),
let light = ShadowCull {
light: Frustum {
planes: frustum(v.cull),
},
casters: v.view_casters.then(|| shadow_casters(cull_proj, v.light)),
};
crate::spans::time("shadow visibility", || {
// Within the unculled distance every section, then those in the light's box.
@@ -11242,6 +11256,10 @@ pub struct Frusta(pub Frustum, pub Option<Frustum>);
pub trait Cull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool;
fn contains(&self, min: Vec3, max: Vec3) -> bool;
/// Whether a section is tested by the box around its geometry, not its whole 16 blocks.
fn tight(&self) -> bool {
true
}
}
impl Cull for Frustum {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
@@ -11260,6 +11278,31 @@ impl Cull for Frusta {
self.0.contains(min, max) || self.1.is_some_and(|f| f.contains(min, max))
}
}
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
/// (`shadow_casters`): a box must meet all of them.
#[derive(Clone, Copy, Debug)]
pub struct ShadowCull {
pub light: Frustum,
pub casters: Option<[Frustum; 2]>,
}
impl Cull for ShadowCull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
self.light.intersects(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.intersects(min, max)))
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
self.light.contains(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.contains(min, max)))
}
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
fn tight(&self) -> bool {
self.casters.is_none()
}
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -11418,6 +11461,68 @@ fn facing_runs_from(
None => sides.every(part, draw),
}
}
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
/// view. Its planes are the view's faces turned toward the light, and through each edge between
/// such a face and one turned away, the plane along the light; unused ones take in everything.
fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
let faces = frustum(view_proj);
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
let inverse = view_proj.inverse();
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
let corner = |c: [usize; 3]| {
inverse.project_point3(Vec3::new(
c[0] as f32 * 2.0 - 1.0,
c[1] as f32 * 2.0 - 1.0,
c[2] as f32,
))
};
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
let mut planes = [Vec4::W; 12];
let mut n = 0;
for (k, &face) in faces.iter().enumerate() {
if toward[k] {
planes[n] = face;
n += 1;
}
}
for a in &corners {
for axis in 0..3 {
if a[axis] == 1 {
continue;
}
let mut b = *a;
b[axis] = 1;
// The edge from a to b lies on the faces of the two other axes.
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
if toward[f] == toward[g] {
continue;
}
let (p, q) = (corner(*a), corner(b));
let normal = (q - p).cross(light);
if normal.length_squared() < 1e-12 {
continue;
}
let normal = normal.normalize();
let side = if normal.dot(centre - p) < 0.0 {
-1.0
} else {
1.0
};
// At most six such edges with at most four faces toward the light: 12 planes.
if let Some(plane) = planes.get_mut(n) {
*plane = (normal * side).extend(-normal.dot(p) * side);
n += 1;
}
}
}
let [a, b] = [0, 6].map(|i| Frustum {
planes: planes[i..i + 6].try_into().expect("six planes"),
});
[a, b]
}
fn frustum(m: Mat4) -> [Vec4; 6] {
let r = |i| m.row(i);
let planes = [
@@ -13069,6 +13174,7 @@ mod shadow_face_tests {
distance: None,
unculled: None,
every_face,
view_casters: true,
}
}
@@ -13083,3 +13189,96 @@ mod shadow_face_tests {
assert!(!drawn(&view(false), origin).contains(&3));
}
}
#[cfg(test)]
mod shadow_caster_tests {
use super::*;
/// What the shadow pass keeps of a camera-relative section at `min`, the camera at the
/// origin looking along -X (yaw 90) with the scenes' 70 degree, 16:9 view, and the light in
/// direction `light`; the light's own box takes in everything.
fn kept(light: Vec3, min: [f32; 3]) -> bool {
kept_looking(Vec3::NEG_X, light, min)
}
/// `kept` with the camera looking along `forward`.
fn kept_looking(forward: Vec3, light: Vec3, min: [f32; 3]) -> bool {
let camera = world_projection(70.0, 16.0 / 9.0, 192.0)
* Mat4::look_to_rh(Vec3::ZERO, forward, Vec3::Y);
let cull = ShadowCull {
light: Frustum {
planes: [Vec4::W; 6],
},
casters: Some(shadow_casters(camera, light)),
};
let min = Vec3::from(min);
cull.intersects(min, min + 16.0)
}
/// Our probes' Iris 1.11.7 shadow maps fit keeping only the terrain whose shadow can fall
/// into the camera's view (#8464).
#[test]
fn the_shadow_pass_keeps_what_can_shade_the_view() {
// A noon sun: a treetop high above the ground ahead shades it, though the camera doesn't
// see it; behind the camera, or far off to the side, nothing does.
assert!(kept(Vec3::Y, [-48.0, 64.0, -8.0]));
assert!(!kept(Vec3::Y, [32.0, 0.0, -8.0]));
assert!(!kept(Vec3::Y, [-32.0, 0.0, 96.0]));
// Nor does what lies under the ground the camera looks along.
assert!(!kept(Vec3::Y, [-48.0, -96.0, -8.0]));
// A low sun ahead (west, -X) throws shadows toward the camera: what is behind it can't
// reach the view.
let west = Vec3::new(-1.0, 0.1, 0.0).normalize();
assert!(kept(west, [-48.0, 0.0, -8.0]));
assert!(!kept(west, [32.0, 0.0, -8.0]));
// The same sun behind the camera throws the terrain behind it forward, into the view.
let east = Vec3::new(1.0, 0.1, 0.0).normalize();
assert!(kept(east, [32.0, 0.0, -8.0]));
assert!(kept(east, [96.0, 0.0, -8.0]));
// Looking 20 degrees down under a noon sun, the view's sides lean out below the camera:
// a treetop above the ground ahead still shades it, one off to the side doesn't, though
// it lies inside each of the view's planes that faces the light.
let down = Vec3::new(-20f32.to_radians().cos(), -20f32.to_radians().sin(), 0.0);
assert!(kept_looking(down, Vec3::Y, [-48.0, 16.0, -8.0]));
assert!(!kept_looking(down, Vec3::Y, [-32.0, 16.0, -96.0]));
}
/// With the camera looking straight up under a noon sun, our probe's Iris 1.11.7 map holds
/// 7 columns there, which testing whole sections reproduces (a guess): the sections beside
/// the camera whose 16 blocks reach above it are kept, even where their terrain lies below
/// the camera.
#[test]
fn the_shadow_pass_tests_whole_sections() {
let camera = DVec3::new(8.0, 6.0, 8.0);
let up = world_projection(70.0, 16.0 / 9.0, 192.0)
* Mat4::look_to_rh(Vec3::ZERO, Vec3::Y, Vec3::Z);
let view = Frustum {
planes: frustum(up),
};
// The section east of the camera's, y 0..16, with ground only in its lowest 2 blocks.
let mut sections = resident::Resident::<()>::default();
let key = (1, -crate::chunk::MIN_Y / 16, 0);
sections.insert_with_bounds(key, (), Some([Vec3::ZERO, Vec3::new(16.0, 2.0, 16.0)]));
let drawn = |cull: &dyn Fn(&mut Vec<resident::Visible>)| {
let mut out = Vec::new();
cull(&mut out);
out.iter().map(|v| v.key).collect::<Vec<_>>()
};
let all = Frustum {
planes: [Vec4::W; 6],
};
let shadow = ShadowCull {
light: all,
casters: Some(shadow_casters(up, Vec3::Y)),
};
assert_eq!(
drawn(&|out| sections.visible(camera, 4, &shadow, |_| true, |_, _| false, out)),
[key]
);
// The camera's own view goes by the terrain's box, which lies below it.
assert!(
drawn(&|out| sections.visible(camera, 4, &view, |_| true, |_, _| false, out))
.is_empty()
);
}
}
@@ -367,6 +367,7 @@ impl NativeFrame {
distance: None,
unculled: None,
every_face: false,
view_casters: false,
})
}
@@ -692,6 +692,25 @@ impl Occlusion {
pub fn contains(&self, key: SectionKey) -> bool {
self.graph.as_ref().is_none_or(|g| g.contains(key))
}
/// Whether the current graph knows that `key` is visible from
/// `camera_section`. Unknown is returned when the graph is absent, was
/// searched from another section, or does not cover the queried section.
pub fn visible_for_camera(&self, key: SectionKey, camera_section: SectionKey) -> Option<bool> {
let graph = self.graph.as_ref()?;
if graph.from != camera_section {
return None;
}
let dx = i64::from(key.0) - i64::from(graph.from.0);
let dz = i64::from(key.2) - i64::from(graph.from.2);
if dx.abs() > i64::from(graph.radius)
|| dz.abs() > i64::from(graph.radius)
|| key.1 < graph.y0
|| key.1 > graph.sections
{
return None;
}
Some(graph.contains(key))
}
/// Changes whenever `contains` may answer differently: a search landed, a
/// partial update reached more sections, or the graph went.
#[cfg(any(test, debug_assertions))]
@@ -904,6 +923,77 @@ mod tests {
assert!(o.contains((2, 1, 0)));
}
/// `visible_for_camera` answers only for the box of the graph it holds, and
/// only for the camera that search started from. Everywhere else it says
/// unknown, so the caller keeps the geometry.
#[test]
fn visible_for_camera_is_unknown_outside_its_box_and_its_camera() {
// No graph yet: nothing to answer with.
assert_eq!(
Occlusion::new(0, 4).visible_for_camera((0, 1, 0), (0, 1, 0)),
None
);
let mut o = Occlusion::new(0, 4);
o.settle(at((0, 1, 0)), 2);
let from = o.columns.section_of(at((0, 1, 0)));
assert_eq!(from, (0, 1, 0), "the camera section searched from");
// Inside the searched box the answer is `contains`, its edges too.
for x in -2..=2 {
for z in -2..=2 {
for y in -1..=4 {
assert_eq!(
o.visible_for_camera((x, y, z), from),
Some(o.contains((x, y, z))),
"{x} {y} {z}"
);
}
}
}
assert_eq!(
o.visible_for_camera((2, 1, 0), from),
Some(true),
"the box's edge"
);
// Off the box's sides, below it, or above the level: unknown.
for key in [
(3, 1, 0),
(-3, 1, 0),
(0, 1, 3),
(0, 1, -3),
(0, -2, 0),
(0, 5, 0),
] {
assert_eq!(o.visible_for_camera(key, from), None, "{key:?}");
}
// Another camera section: this graph is not about it.
assert_eq!(o.visible_for_camera((0, 1, 0), (1, 1, 0)), None);
}
/// The answer follows the graph: a closed wall hides the section behind it,
/// and opening the wall shows it again.
#[test]
fn visible_for_camera_reports_what_the_graph_hides() {
let mut o = Occlusion::new(0, 4);
for (dx, dy, dz) in STEPS {
o.set((dx, 1 + dy, dz), 0);
}
o.settle(at((0, 1, 0)), 3);
let from = (0, 1, 0);
assert_eq!(
o.visible_for_camera((1, 1, 0), from),
Some(true),
"the wall itself"
);
assert_eq!(
o.visible_for_camera((2, 1, 0), from),
Some(false),
"hidden behind it"
);
o.set((1, 1, 0), ALL_VISIBLE);
o.settle(at((0, 1, 0)), 3);
assert_eq!(o.visible_for_camera((2, 1, 0), from), Some(true));
}
/// Faces `a` and `b` of a section see each other, nothing else does.
fn connects(a: usize, b: usize) -> u64 {
1 << (a * 6 + b) | 1 << (b * 6 + a)
@@ -1088,6 +1088,11 @@ pub(super) struct ShadowView {
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
/// a cave's ceiling, which faces away, is often all that shadows the cave.
pub every_face: bool,
/// Only the sections whose shadow can fall into the camera's view: those the camera's
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
}
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
@@ -3905,6 +3910,7 @@ impl PackPost {
horizon_shaders::ShadowCulling::Reversed => Some(shadow.voxel_distance),
},
every_face: true,
view_casters: true,
})
}
@@ -475,7 +475,8 @@ impl<T> Resident<T> {
}
let oy = f64::from(self.min_y + y * 16) - camera.y;
let min = Vec3::new(min_x, oy as f32, min_z);
let (bmin, bmax) = section_box(min, self.bounds[slot as usize]);
let bounds = self.bounds[slot as usize].filter(|_| frustum.tight());
let (bmin, bmax) = section_box(min, bounds);
if !frustum.intersects(bmin, bmax) {
continue;
}
+9 -1
View File
@@ -661,6 +661,8 @@ pub struct Graphics {
pub gpu_culling: bool,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
pub entity_culling: EntityCullingMode,
/// Minimum entity population for coarse AABB culling; zero disables it.
pub entity_culling_precheck: u32,
/// Skip particles outside the view or hidden behind opaque terrain.
pub particle_culling: ParticleCullingMode,
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
@@ -769,6 +771,7 @@ impl Default for Graphics {
cave_culling: true,
gpu_culling: false,
entity_culling: EntityCullingMode::Off,
entity_culling_precheck: 64,
particle_culling: ParticleCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
cache_shulker_boxes: false,
@@ -866,6 +869,9 @@ impl Graphics {
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"gpuCulling" => g.gpu_culling = flag(g.gpu_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"entityCullingPrecheck" => {
g.entity_culling_precheck = number(g.entity_culling_precheck).min(512)
}
"particleCulling" => g.particle_culling = ParticleCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"blockEntityCache" => {
@@ -959,12 +965,13 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
self.entity_culling.name(),
self.entity_culling_precheck,
self.particle_culling.name(),
self.leaf_culling.name(),
bool_text(self.cache_shulker_boxes),
@@ -1153,6 +1160,7 @@ mod tests {
cave_culling: false,
gpu_culling: true,
entity_culling: EntityCullingMode::Synchronous,
entity_culling_precheck: 128,
particle_culling: ParticleCullingMode::Asynchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
cache_shulker_boxes: true,
+104 -4
View File
@@ -67,6 +67,7 @@ pub enum H {
CaveCulling,
GpuCulling,
EntityCulling,
EntityCullingPrecheck,
ParticleCulling,
LeafCulling,
CacheShulkerBoxes,
@@ -390,8 +391,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
),
h(
H::ParticleCulling,
None,
"Skips offscreen, subpixel and terrain-hidden particles.",
Saves,
"Skips particles outside the view, below one pixel or behind terrain.",
),
h(
H::EntityCullingPrecheck,
Saves,
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.",
),
h(
H::LeafCulling,
@@ -698,6 +704,7 @@ impl H {
let g = &s.horizon;
match self {
H::ChunkUpdates => Some(g.chunk_updates as f32 / 64.0),
H::EntityCullingPrecheck => Some(g.entity_culling_precheck as f32 / 512.0),
H::UploadBudget => {
let i = UPLOAD_STEPS
.iter()
@@ -729,6 +736,7 @@ impl H {
pub fn slider_steps(self) -> Option<u32> {
match self {
H::ChunkUpdates => Some(64),
H::EntityCullingPrecheck => Some(32),
H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
H::Workers => Some(max_workers()),
H::ResolutionScale => Some(30),
@@ -761,6 +769,14 @@ impl H {
on_off(g.gpu_culling && crate::graphics::gpu_culling_offer().reason().is_none()),
),
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
H::EntityCullingPrecheck => labelled(
"Entity Culling Precheck",
if g.entity_culling_precheck == 0 {
"Off".to_string()
} else {
lang::tr("%s mobs", &[&g.entity_culling_precheck])
},
),
H::ParticleCulling => labelled("Particle Culling", g.particle_culling.label()),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
H::CacheShulkerBoxes => labelled("Shulker Boxes", on_off(g.cache_shulker_boxes)),
@@ -873,6 +889,7 @@ impl H {
let g = &mut s.horizon;
match self {
H::ChunkUpdates => g.chunk_updates = (v * 64.0).round() as u32,
H::EntityCullingPrecheck => g.entity_culling_precheck = (v * 512.0).round() as u32,
H::UploadBudget => {
g.upload_budget_kib =
UPLOAD_STEPS[(v * (UPLOAD_STEPS.len() - 1) as f32).round() as usize]
@@ -1044,6 +1061,11 @@ impl Item {
{
Some("Your GPU or graphics API doesn't support it.")
}
Item::Horizon(H::EntityCullingPrecheck)
if s.horizon.entity_culling != EntityCullingMode::Asynchronous =>
{
Some("Only applies to asynchronous entity culling.")
}
Item::Horizon(H::GpuCulling) => crate::graphics::gpu_culling_offer().reason(),
_ => None,
}
@@ -1114,7 +1136,7 @@ impl Item {
}
}
pub fn set_slider(self, s: &mut Settings, v: f32) {
if !self.available() {
if !self.available() || self.blocked(s).is_some() {
return;
}
match self {
@@ -2222,7 +2244,8 @@ impl GraphicsUi {
content: f32,
) {
self.focus = Some(target);
self.can_change_value = matches!(target, Target::Item(i) if i.slider(s).is_some());
self.can_change_value =
matches!(target, Target::Item(i) if i.slider(s).is_some() && i.blocked(s).is_none());
if let Target::Item(_) = target {
let [_, y, _, h] = rect;
if y < cols.top {
@@ -2291,6 +2314,7 @@ impl GraphicsUi {
&& self.can_change_value
&& let Some(Target::Item(item)) = self.focus
&& item.slider(s).is_some()
&& item.blocked(s).is_none()
{
item.step_slider(s, key == K::ArrowRight, cols.track().1 + 8.0);
self.changed = true;
@@ -3182,6 +3206,82 @@ mod tests {
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
}
#[test]
fn entity_culling_precheck_slider_is_only_live_for_async() {
let item = Item::Horizon(H::EntityCullingPrecheck);
let mut s = Settings::default();
let initial = s.horizon.entity_culling_precheck;
for mode in [EntityCullingMode::Off, EntityCullingMode::Synchronous] {
s.horizon.entity_culling = mode;
assert_eq!(
item.blocked(&s),
Some("Only applies to asynchronous entity culling.")
);
let mut ui = GraphicsUi::default();
let row_rect = row(&mut ui, &s, item);
assert!(press(&mut ui, &mut s, (row_rect[0] + 4.0, row_rect[1] + 4.0)).is_empty());
item.set_slider(&mut s, 1.0);
assert_eq!(s.horizon.entity_culling_precheck, initial);
let (cols, lines, content) = ui.layout(&s, SIZE.0, SIZE.1);
assert!(
!ui.focusables(&s, &cols, &lines, false)
.iter()
.any(|(target, _)| *target == Target::Item(item))
);
ui.focus_on(&s, Target::Item(item), row_rect, &cols, content);
assert!(!ui.can_change_value);
}
for (code, expected) in [
("en_us", "Only applies to asynchronous entity culling."),
("de_de", "Nur für asynchrones Objekt-Culling."),
(
"es_es",
"Solo se aplica a la oclusión asíncrona de entidades.",
),
(
"fr_fr",
"S'applique uniquement à l'occlusion asynchrone des entités.",
),
("pl_pl", "Dotyczy tylko asynchronicznego pomijania bytów."),
(
"ru_ru",
"Применяется только при асинхронном отсечении сущностей.",
),
("tr_tr", "Yalnızca eşzamansız varlık elemesine uygulanır."),
(
"uk_ua",
"Застосовується лише для асинхронного відсікання сутностей.",
),
("zh_cn", "仅适用于异步实体剔除。"),
] {
let (shown, missed) = lang::scoped(lang::Language::load(&[], code), || {
lang::get(item.blocked(&s).unwrap())
});
assert_eq!(shown, expected, "{code}");
assert!(missed.is_empty(), "{code}: {missed:?}");
}
s.horizon.entity_culling = EntityCullingMode::Asynchronous;
assert_eq!(item.blocked(&s), None);
let mut ui = GraphicsUi::default();
let row_rect = row(&mut ui, &s, item);
let (cols, _, _) = ui.layout(&s, SIZE.0, SIZE.1);
let (track_x, track_w) = cols.track();
assert_eq!(
press(
&mut ui,
&mut s,
(track_x + track_w - 4.0, row_rect[1] + 4.0)
),
[Action::SliderDrag]
);
assert_eq!(s.horizon.entity_culling_precheck, 512);
}
/// Translucent Batching: a switch on the Advanced page, on by default, no part of a
/// preset, and greyed out, reading Off, where the device can't batch.
#[test]
+1
View File
@@ -107,6 +107,7 @@ mod sprite_cache;
pub mod sprites;
pub mod streaming;
pub mod structure_block;
pub mod studio_ui;
pub mod tab_list;
pub mod telemetry;
pub mod test_block;
@@ -1280,6 +1280,8 @@ pub enum Page {
/// Horizon Settings: every page Horizon adds, as a mod's settings
/// screen hangs off vanilla's Options.
Horizon,
/// Replay Studio: saved recordings browser and 3D video editor launcher.
Studio,
/// AI Agent: agent control on or off, whether an agent is connected, the connection details.
Agent,
/// SkinCustomizationScreen: the skin layers and Main Hand.
@@ -1507,6 +1509,8 @@ pub enum Event {
ListMusicTracks,
/// A Music page row: play this track of this event now.
PlayMusicTrack(String, String),
/// Open the recordings folder in the system file browser.
OpenStudioFolder,
}
/// A row of the Music page: the track, and the pool it is played from.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -1608,6 +1612,7 @@ pub struct OptionsUi {
/// 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,
pub studio: crate::studio_ui::StudioUi,
/// The AI Agent page's status lines (set by the game while it shows).
pub agent_status: Vec<String>,
/// A repro recording is running: the AI Agent page's button says stop.
@@ -1675,6 +1680,7 @@ impl Default for OptionsUi {
calibration: None,
calibration_fullscreen: None,
split_setup: Default::default(),
studio: Default::default(),
agent_status: Vec::new(),
agent_recording: false,
music_groups: Vec::new(),
@@ -1740,6 +1746,7 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Studio => "horizon.pages.studio.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
@@ -2286,6 +2293,9 @@ impl OptionsUi {
if page == Page::SplitProfiles {
self.split_profiles.reset();
}
if page == Page::Studio {
self.studio.refresh();
}
}
/// What the graphics screen's actions do to these options.
fn graphics_events(
@@ -2355,6 +2365,7 @@ impl OptionsUi {
Page::Pvp => "horizon.pages.pvp",
Page::Credits => "credits_and_attribution.screen.title",
Page::Horizon => "horizon.pages.settings",
Page::Studio => "horizon.pages.studio",
Page::Agent => "horizon.pages.agent",
Page::SplitProfiles => "horizon.pages.split_profiles",
Page::SplitSetup => "horizon.pages.split_setup",
@@ -2550,6 +2561,7 @@ impl OptionsUi {
),
Some(open("horizon.pages.open.pvp", Page::Pvp)),
),
Row::Buttons(open("horizon.pages.open.studio", Page::Studio), None),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
@@ -3669,6 +3681,10 @@ impl OptionsUi {
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
return;
}
if self.page() == Page::Studio {
self.studio.draw(batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::Packs {
self.watch_data_packs(atlas, (w, h));
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
@@ -4056,6 +4072,22 @@ impl OptionsUi {
}
};
}
if self.page() == Page::Studio {
if right || drag {
return Vec::new();
}
use crate::studio_ui::StudioAction;
return match self.studio.click((x, y), size) {
StudioAction::None => Vec::new(),
StudioAction::Refresh => vec![Event::ButtonClick],
StudioAction::OpenFolder => vec![Event::ButtonClick, Event::OpenStudioFolder],
StudioAction::Back => {
let mut events = vec![Event::ButtonClick];
events.extend(self.back(s));
events
}
};
}
if self.page() == Page::HdrCalibration
&& let Some(c) = &mut self.calibration
{
@@ -4556,6 +4588,10 @@ impl OptionsUi {
let actions = self.graphics.wheel(s, size, cursor, framebuffer, amount);
return self.graphics_events(s, actions);
}
if self.page() == Page::Studio {
self.studio.wheel(amount);
return Vec::new();
}
let plain = self.resource_error.is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
@@ -7641,6 +7677,7 @@ mod tests {
Page::Mods,
Page::SplitSetup,
Page::Pvp,
Page::Studio,
Page::Agent,
]
);
@@ -0,0 +1,532 @@
//! Replay Studio Browser & Saved Recordings UI
//!
//! Provides an in-game graphical browser to inspect saved `.mcpr` recordings,
//! view recording metadata (duration, packet count, timestamp, server),
//! and open the recordings directory.
use crate::{
graphics_style as style,
hud::{HudAtlas, HudBatch},
lang,
text::Text,
ui,
};
use serde::{Deserialize, Serialize};
use std::{
fs::{self, File},
io::Read,
path::{Path, PathBuf},
};
/// High-level session metadata read from `metaData.json` inside `.mcpr`.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordingMetadata {
#[serde(default)]
pub server_name: String,
#[serde(default)]
pub custom_server_name: Option<String>,
#[serde(default)]
pub singleplayer: bool,
#[serde(default)]
pub duration: u32,
#[serde(default)]
pub date: u64,
#[serde(default)]
pub mcversion: String,
#[serde(default)]
pub file_format: String,
#[serde(default)]
pub file_format_version: u32,
#[serde(default)]
pub generator: String,
#[serde(default)]
pub players: Vec<String>,
}
/// A saved replay item displayed in the Studio Browser list.
#[derive(Clone, Debug, PartialEq)]
pub struct SavedRecordingItem {
pub path: PathBuf,
pub filename: String,
pub title: String,
pub duration_ms: u32,
pub date: u64,
pub file_size: u64,
pub server_name: String,
pub singleplayer: bool,
}
impl SavedRecordingItem {
/// Formatted duration string `MM:SS` or `HH:MM:SS`.
pub fn formatted_duration(&self) -> String {
let total_secs = self.duration_ms / 1000;
let hours = total_secs / 3600;
let mins = (total_secs % 3600) / 60;
let secs = total_secs % 60;
if hours > 0 {
format!("{hours:02}:{mins:02}:{secs:02}")
} else {
format!("{mins:02}:{secs:02}")
}
}
/// Formatted file size string (KB or MB).
pub fn formatted_size(&self) -> String {
let bytes = self.file_size;
if bytes >= 1024 * 1024 {
let mb = bytes as f64 / (1024.0 * 1024.0);
format!("{mb:.1} MB")
} else {
let kb = bytes.div_ceil(1024);
format!("{kb} KB")
}
}
}
/// User actions triggered by the Studio Browser UI.
#[derive(Clone, Debug, PartialEq)]
pub enum StudioAction {
None,
OpenFolder,
Refresh,
Back,
}
/// The Studio Replay Browser screen state and renderer.
#[derive(Clone, Debug)]
pub struct StudioUi {
pub recordings: Vec<SavedRecordingItem>,
pub selected: Option<usize>,
pub scroll: f32,
pub search: String,
pub record_dir: PathBuf,
}
impl Default for StudioUi {
fn default() -> Self {
let dir = PathBuf::from("recordings");
let mut ui = Self {
recordings: Vec::new(),
selected: None,
scroll: 0.0,
search: String::new(),
record_dir: dir.clone(),
};
ui.scan(&dir);
ui
}
}
impl StudioUi {
/// Create a new StudioUi for the given recordings directory.
pub fn new(record_dir: impl Into<PathBuf>) -> Self {
let dir = record_dir.into();
let mut ui = Self {
recordings: Vec::new(),
selected: None,
scroll: 0.0,
search: String::new(),
record_dir: dir.clone(),
};
ui.scan(&dir);
ui
}
/// Re-scan the recordings folder.
pub fn refresh(&mut self) {
self.scan(&self.record_dir.clone());
}
/// Read metadata from a `.mcpr` archive without extracting the full packet stream.
pub fn read_metadata(path: &Path) -> Option<RecordingMetadata> {
let file = File::open(path).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
let mut meta_file = archive.by_name("metaData.json").ok()?;
let mut meta_bytes = Vec::new();
meta_file.read_to_end(&mut meta_bytes).ok()?;
serde_json::from_slice(&meta_bytes).ok()
}
/// Scan `dir` for `.mcpr` files and parse their metadata.
pub fn scan(&mut self, dir: &Path) {
self.recordings.clear();
self.selected = None;
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("mcpr") {
continue;
}
let Ok(meta) = entry.metadata() else {
continue;
};
let file_size = meta.len();
let filename = path
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default();
let title = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| filename.clone());
let (duration_ms, date, server_name, singleplayer) =
if let Some(parsed) = Self::read_metadata(&path) {
let srv = if parsed.singleplayer {
lang::get("menu.singleplayer")
} else if !parsed.server_name.is_empty() {
parsed.server_name
} else {
lang::get("menu.multiplayer")
};
(parsed.duration, parsed.date, srv, parsed.singleplayer)
} else {
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
(
0,
modified,
lang::get("horizon.studio.unknown_server"),
true,
)
};
self.recordings.push(SavedRecordingItem {
path,
filename,
title,
duration_ms,
date,
file_size,
server_name,
singleplayer,
});
}
// Sort descending by date (newest recordings first)
self.recordings.sort_by_key(|a| std::cmp::Reverse(a.date));
if !self.recordings.is_empty() {
self.selected = Some(0);
}
}
/// Draw the Studio Browser screen.
pub fn draw(
&mut 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);
// Header Title: centered at top
style::title(batch, atlas, &Text::tr("horizon.pages.studio", &[]));
// Subtitle strip: centered info text
let subtitle = Text::tr("horizon.studio.saved_recordings", &[]);
ui::centered_text(batch, atlas, &subtitle, 0.0, 26.0, w, style::GREY);
// Main content area layout
let panel_w = (w - 40.0).clamp(280.0, 440.0);
let panel_x = ((w - panel_w) / 2.0).floor();
let panel_y = 42.0;
let panel_h = (h - 76.0).max(100.0);
// Glass panel backdrop for recordings list
style::panel(batch, atlas, [panel_x, panel_y, panel_w, panel_h]);
if self.recordings.is_empty() {
// Empty state
let empty_text = Text::tr("horizon.studio.empty", &[]);
let hint_text = Text::tr("horizon.studio.empty_hint", &[]);
ui::centered_text(
batch,
atlas,
&empty_text,
panel_x,
panel_y + panel_h / 2.0 - 16.0,
panel_w,
style::WHITE,
);
ui::centered_text(
batch,
atlas,
&hint_text,
panel_x,
panel_y + panel_h / 2.0,
panel_w,
style::GREY,
);
} else {
// Render scrollable cards
let card_h = 36.0;
let card_gap = 4.0;
let row_step = card_h + card_gap;
let visible_cards = (panel_h / row_step).floor() as usize;
let max_scroll =
(self.recordings.len().saturating_sub(visible_cards) as f32 * row_step).max(0.0);
self.scroll = self.scroll.clamp(0.0, max_scroll);
let card_w = panel_w - 12.0;
let card_x = panel_x + 6.0;
let mut y = panel_y + 6.0 - (self.scroll % row_step);
let start_idx = (self.scroll / row_step) as usize;
for (idx, item) in self.recordings.iter().enumerate().skip(start_idx) {
if y + card_h > panel_y + panel_h - 4.0 {
break;
}
let rect = [card_x, y, card_w, card_h];
let is_hovered = ui::hovered(cursor, card_x, y, card_w, card_h);
let is_selected = self.selected == Some(idx);
// Card background & highlight
if is_selected {
style::fill(batch, atlas, rect, style::OPEN);
} else if is_hovered {
style::fill(batch, atlas, rect, style::HOVER);
} else {
style::well(batch, atlas, rect);
}
let border_color = if is_selected {
style::ACCENT
} else if is_hovered {
style::WHITE
} else {
style::ROW_RULE
};
style::ring(batch, atlas, rect, border_color);
// Card Line 1: Recording Title (bold white)
let title_text = Text::literal(&item.title);
batch.text_styled(
atlas,
&title_text,
card_x + 8.0,
y + 6.0,
style::WHITE,
true,
);
// Card Line 2: Duration, Size, Server
let meta_str = format!(
"{} • {} • {}",
item.formatted_duration(),
item.formatted_size(),
item.server_name
);
let meta_text = Text::literal(&meta_str);
batch.text_styled(atlas, &meta_text, card_x + 8.0, y + 20.0, style::GREY, true);
y += row_step;
}
// Scrollbar track & thumb if scrollable
if max_scroll > 0.0 {
let track_x = panel_x + panel_w - 4.0;
let track_y = panel_y + 2.0;
let track_h = panel_h - 4.0;
let thumb_h = ((panel_h / (panel_h + max_scroll)) * track_h).max(12.0);
let thumb_y = track_y + (self.scroll / max_scroll) * (track_h - thumb_h);
style::scroller(
batch,
atlas,
[track_x, track_y, 2.0, track_h],
[track_x, thumb_y, 2.0, thumb_h],
);
}
}
// Bottom Button Bar
let btn_y = h - 28.0;
let btn_h = 20.0;
// Button 1: Open Folder (Left)
let f_btn = [panel_x, btn_y, 130.0, btn_h];
let f_hover = ui::hovered(cursor, f_btn[0], f_btn[1], f_btn[2], f_btn[3]);
style::button(
batch,
atlas,
f_btn,
&Text::tr("horizon.studio.open_folder", &[]),
(f_hover, true, false),
);
// Button 2: Refresh (Center-Left)
let r_btn = [panel_x + 136.0, btn_y, 65.0, btn_h];
let r_hover = ui::hovered(cursor, r_btn[0], r_btn[1], r_btn[2], r_btn[3]);
style::button(
batch,
atlas,
r_btn,
&Text::tr("horizon.studio.refresh", &[]),
(r_hover, true, false),
);
// Button 3: Done / Back (Right)
let d_btn = [panel_x + panel_w - 64.0, btn_y, 64.0, btn_h];
let d_hover = ui::hovered(cursor, d_btn[0], d_btn[1], d_btn[2], d_btn[3]);
style::button(
batch,
atlas,
d_btn,
&Text::tr("gui.done", &[]),
(d_hover, true, false),
);
}
/// Process mouse click on the Studio Browser screen.
pub fn click(&mut self, cursor: (f32, f32), size: (f32, f32)) -> StudioAction {
let (w, h) = size;
let panel_w = (w - 40.0).clamp(280.0, 440.0);
let panel_x = ((w - panel_w) / 2.0).floor();
let panel_y = 42.0;
let panel_h = (h - 76.0).max(100.0);
let btn_y = h - 28.0;
let btn_h = 20.0;
// Button 1: Open Folder
let f_btn = [panel_x, btn_y, 130.0, btn_h];
if ui::hovered(cursor, f_btn[0], f_btn[1], f_btn[2], f_btn[3]) {
return StudioAction::OpenFolder;
}
// Button 2: Refresh
let r_btn = [panel_x + 136.0, btn_y, 65.0, btn_h];
if ui::hovered(cursor, r_btn[0], r_btn[1], r_btn[2], r_btn[3]) {
self.refresh();
return StudioAction::Refresh;
}
// Button 3: Done
let d_btn = [panel_x + panel_w - 64.0, btn_y, 64.0, btn_h];
if ui::hovered(cursor, d_btn[0], d_btn[1], d_btn[2], d_btn[3]) {
return StudioAction::Back;
}
// Card clicks
let card_h = 36.0;
let card_gap = 4.0;
let row_step = card_h + card_gap;
let card_w = panel_w - 12.0;
let card_x = panel_x + 6.0;
let start_idx = (self.scroll / row_step) as usize;
let mut y = panel_y + 6.0 - (self.scroll % row_step);
for (idx, _) in self.recordings.iter().enumerate().skip(start_idx) {
if y + card_h > panel_y + panel_h - 4.0 {
break;
}
if ui::hovered(cursor, card_x, y, card_w, card_h) {
self.selected = Some(idx);
return StudioAction::None;
}
y += row_step;
}
StudioAction::None
}
/// Scroll list by mouse wheel delta.
pub fn wheel(&mut self, amount: f32) {
self.scroll = (self.scroll - amount * 24.0).max(0.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn formatted_duration_and_size() {
let item = SavedRecordingItem {
path: PathBuf::from("recordings/test.mcpr"),
filename: "test.mcpr".to_string(),
title: "test".to_string(),
duration_ms: 125_000,
date: 1_700_000_000,
file_size: 4_500_000,
server_name: "Local World".to_string(),
singleplayer: true,
};
assert_eq!(item.formatted_duration(), "02:05");
assert_eq!(item.formatted_size(), "4.3 MB");
let short_item = SavedRecordingItem {
path: PathBuf::from("recordings/short.mcpr"),
filename: "short.mcpr".to_string(),
title: "short".to_string(),
duration_ms: 45_000,
date: 1_700_000_000,
file_size: 512 * 1024,
server_name: "Hypixel".to_string(),
singleplayer: false,
};
assert_eq!(short_item.formatted_duration(), "00:45");
assert_eq!(short_item.formatted_size(), "512 KB");
}
#[test]
fn scan_and_select_recordings() {
let temp_dir = std::env::temp_dir().join(format!("studio_test_{}", std::process::id()));
let _ = fs::create_dir_all(&temp_dir);
let rec_path = temp_dir.join("sample.mcpr");
{
let file = File::create(&rec_path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default();
zip.start_file("metaData.json", options).unwrap();
let meta = RecordingMetadata {
server_name: "Test Server".to_string(),
duration: 65_000,
date: 1_700_000_500,
..Default::default()
};
let json_bytes = serde_json::to_vec(&meta).unwrap();
zip.write_all(&json_bytes).unwrap();
zip.finish().unwrap();
}
let mut ui = StudioUi::new(&temp_dir);
assert_eq!(ui.recordings.len(), 1);
assert_eq!(ui.recordings[0].title, "sample");
assert_eq!(ui.recordings[0].duration_ms, 65_000);
assert_eq!(ui.recordings[0].server_name, "Test Server");
assert_eq!(ui.selected, Some(0));
// Clicking a card selects it for inspection without opening the editor.
let action = ui.click((200.0, 60.0), (427.0, 720.0));
assert_eq!(action, StudioAction::None);
assert_eq!(ui.selected, Some(0));
// Clean up
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn studio_wheel_scrolling() {
let mut ui = StudioUi::default();
ui.wheel(2.0);
assert_eq!(ui.scroll, 0.0); // clamped at 0
ui.scroll = 100.0;
ui.wheel(1.0);
assert_eq!(ui.scroll, 76.0);
}
}
@@ -24,6 +24,12 @@
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Diese Funktion ist in Horizon noch nicht verfügbar.",
"horizon.options.search": "Einstellungen durchsuchen …",
"horizon.studio.record.usage": "Verwendung: /studio record <start [name]|stop|status>",
"horizon.studio.record.started": "Studio-Aufzeichnung gestartet: %s",
"horizon.studio.record.saved": "Studio-Aufzeichnung gespeichert: %s",
"horizon.studio.record.error": "Studio-Aufzeichnungsfehler: %s",
"horizon.studio.record.status.active": "Studio-Aufzeichnung aktiv — %s s, %s Pakete, %s KB (%s)",
"horizon.studio.record.status.inactive": "Studio-Aufzeichnung inaktiv",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader-Pakete",
"horizon.pages.shader_options": "Shader-Optionen",
@@ -52,6 +58,7 @@
"horizon.pages.open.horizon_graphics": "Horizon-Grafik …",
"horizon.pages.open.controller_binds": "Controller-Belegung …",
"horizon.pages.open.horizon_settings": "Horizon-Einstellungen …",
"horizon.pages.open.studio": "Replay Studio …",
"horizon.mods.empty": "Keine Mods: Lege .hmod-Dateien in den Ordner horizon-mods",
"horizon.mods.stopped": " (angehalten)",
"horizon.mods.not_built": " (nicht gebaut)",
@@ -135,6 +142,7 @@
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
"Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.",
"Your GPU or graphics API doesn't support it.": "Deine GPU oder Grafik-API unterstützt das nicht.",
"Only applies to asynchronous entity culling.": "Nur für asynchrones Objekt-Culling.",
"%s chunks": "%s Chunks",
"%s per frame": "%s pro Bild",
"%s MiB/frame": "%s MiB/Bild",
@@ -24,6 +24,12 @@
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
"horizon.options.search": "Search settings...",
"horizon.studio.record.usage": "Usage: /studio record <start [name]|stop|status>",
"horizon.studio.record.started": "Studio recording started: %s",
"horizon.studio.record.saved": "Studio recording saved: %s",
"horizon.studio.record.error": "Studio recording error: %s",
"horizon.studio.record.status.active": "Studio recording active — %s s, %s packets, %s KB (%s)",
"horizon.studio.record.status.inactive": "Studio recording inactive",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.shader_options": "Shader Options",
@@ -31,6 +37,7 @@
"horizon.pages.controller_binds": "Controller Binds",
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.studio": "Replay Studio",
"horizon.pages.agent": "AI Agent",
"horizon.split.leave": "Leave",
"horizon.pages.split_settings": "Splitscreen Settings",
@@ -51,6 +58,7 @@
"horizon.pages.music.empty": "No music tracks: the game's sounds are not loaded.",
"horizon.pages.music.tooltip": "The built-in tracks, by situation: play one now, or turn any off so the game stops picking it.",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.studio.tooltip": "Browse saved recordings and their metadata.",
"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.",
@@ -71,6 +79,13 @@
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
"horizon.pages.open.controller_binds": "Controller Binds...",
"horizon.pages.open.horizon_settings": "Horizon Settings...",
"horizon.pages.open.studio": "Replay Studio...",
"horizon.studio.saved_recordings": "Saved Recordings",
"horizon.studio.empty": "No recordings found in the recordings folder.",
"horizon.studio.empty_hint": "Use /studio record start <name> in a world, or drop .mcpr files there.",
"horizon.studio.open_folder": "Open Recordings",
"horizon.studio.refresh": "Refresh",
"horizon.studio.unknown_server": "Unknown",
"horizon.pages.open.music": "Music...",
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
"horizon.mods.stopped": " (Stopped)",
@@ -480,6 +495,10 @@
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
"Entity Culling Precheck": "Entity Culling Precheck",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Runs the mob AABB precheck after this many loaded mobs. Zero disables it.",
"%s mobs": "%s mobs",
"Only applies to asynchronous entity culling.": "Only applies to asynchronous entity culling.",
"horizon.extras.repro_keys": "Recording shortcuts",
"horizon.extras.repro_keys.tooltip": "F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world.",
"horizon.agent.record_session": "Start Recording Session",
@@ -123,6 +123,7 @@
"Done": "Hecho",
"This feature is not yet available in Horizon.": "Esta función aún no está disponible en Horizon.",
"Your display or system doesn't offer HDR output.": "Tu pantalla o sistema no ofrece salida HDR.",
"Only applies to asynchronous entity culling.": "Solo se aplica a la oclusión asíncrona de entidades.",
"Turn HDR on to use it.": "Activa el HDR para usarlo.",
"%s chunks": "%s chunks",
"%s per frame": "%s por fotograma",
@@ -105,6 +105,7 @@
"Done": "Terminé",
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
"Only applies to asynchronous entity culling.": "S'applique uniquement à l'occlusion asynchrone des entités.",
"Turn HDR on to use it.": "Activez le HDR pour l'utiliser.",
"Your GPU or graphics API doesn't support it.": "Votre GPU ou votre API graphique ne le prend pas en charge.",
"%s per frame": "%s par image",
@@ -132,6 +132,7 @@
"Done": "Gotowe",
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
"Only applies to asynchronous entity culling.": "Dotyczy tylko asynchronicznego pomijania bytów.",
"Turn HDR on to use it.": "Włącz HDR, aby tego użyć.",
"Your GPU or graphics API doesn't support it.": "Twoje GPU lub API graficzne tego nie obsługuje.",
"%s chunks": "%s chunków",
@@ -122,6 +122,7 @@
"Done": "Готово",
"This feature is not yet available in Horizon.": "Эта функция в Horizon пока недоступна.",
"Your display or system doesn't offer HDR output.": "Ваш экран или система не поддерживает HDR-вывод.",
"Only applies to asynchronous entity culling.": "Применяется только при асинхронном отсечении сущностей.",
"Turn HDR on to use it.": "Включите HDR, чтобы пользоваться этим.",
"%s chunks": "%s чанков",
"%s per frame": "%s за кадр",
@@ -123,6 +123,7 @@
"Done": "Tamam",
"This feature is not yet available in Horizon.": "Bu özellik henüz Horizon'da mevcut değil.",
"Your display or system doesn't offer HDR output.": "Ekranınız veya sisteminiz HDR çıkışını desteklemiyor.",
"Only applies to asynchronous entity culling.": "Yalnızca eşzamansız varlık elemesine uygulanır.",
"Turn HDR on to use it.": "Bu özelliği kullanmak için HDR'yi etkinleştirin.",
"%s chunks": "%s parça",
"%s per frame": "Kare başına %s",
@@ -132,6 +132,7 @@
"Done": "Готово",
"This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.",
"Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.",
"Only applies to asynchronous entity culling.": "Застосовується лише для асинхронного відсікання сутностей.",
"Turn HDR on to use it.": "Увімкніть HDR, щоб користуватися цим.",
"Your GPU or graphics API doesn't support it.": "Ваш GPU або графічний API цього не підтримує.",
"%s chunks": "%s чанків",
@@ -124,6 +124,7 @@
"Done": "完成",
"This feature is not yet available in Horizon.": "此功能在 Horizon 中尚不可用。",
"Your display or system doesn't offer HDR output.": "你的显示器或系统不支持 HDR 输出。",
"Only applies to asynchronous entity culling.": "仅适用于异步实体剔除。",
"Turn HDR on to use it.": "开启 HDR 后才能使用。",
"Your GPU or graphics API doesn't support it.": "你的 GPU 或图形 API 不支持此功能。",
"%s chunks": "%s 个区块",
@@ -373,6 +373,7 @@ pub fn main() -> Result<()> {
skull: &|_| None,
glyph: &|c| atlas.glyph_cell(c),
glyphs: atlas.font.epoch(),
dynamic_visible: None,
};
let mut verts = Vec::new();
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
+4 -1
View File
@@ -59,7 +59,6 @@ pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
pub mod entity_culling;
pub mod entity_nbt;
pub mod entity_rules;
pub mod extras_hud;
@@ -138,6 +137,10 @@ pub mod title;
pub mod toasts;
pub mod ui_theme;
pub mod vanillagen;
pub mod voxel_occlusion;
// Keep the old path for native callers while giving the implementation a name
// that does not collide with the gameplay adapter in `game/entity_culling.rs`.
pub use voxel_occlusion as entity_culling;
pub mod win_screen;
pub mod world_primitives;
pub mod world_spawn;
+9 -25
View File
@@ -114,32 +114,17 @@ pub fn connect(path: &Path) -> io::Result<Stream> {
Ok(Stream::Tcp(stream))
}
/// 128 random bits as hex, from the standard library's OS-seeded hasher keys.
/// 128 random bits from the OS's random generator, as 32 hex digits: the TCP endpoint's token, which
/// gates full control of the running game.
fn random_token() -> String {
use std::hash::{BuildHasher, Hasher};
(0..2)
.map(|i| {
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
h.write_u64(i);
h.write_u128(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos()),
);
h.write_u32(std::process::id());
format!("{:016x}", h.finish())
})
.collect()
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).expect("OS random generator");
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Write the endpoint file so that only this user can read it.
fn write_private(path: &Path, text: &str) -> io::Result<()> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
options.open(path)?.write_all(text.as_bytes())
}
/// Writes the endpoint file so that only this user can read it, even over an older file
/// (`horizon_server_files::private_file`).
use horizon_server_files::private_file::write_private;
/// Constant-time comparison of the presented token.
fn same_token(a: &str, b: &str) -> bool {
@@ -196,8 +181,7 @@ impl Server {
tcp_port = Some(port);
write_private(
path,
&serde_json::json!({ "transport": "tcp", "port": port, "token": token })
.to_string(),
serde_json::json!({ "transport": "tcp", "port": port, "token": token }).to_string(),
)?;
spawn_listener(
move || listener.accept().map(|(s, _)| s),
+60
View File
@@ -1717,6 +1717,14 @@ impl Recipes {
crafting.push((name, recipe));
}
}
// ResourceManagerRegistryLoadTask registers resource-file Identifiers by
// path, then namespace; RecipeMap preserves that first-match order.
// Include .json: the resource file, rather than the bare recipe id, is sorted.
// Other recipe types retain their existing table order.
crafting.sort_by_cached_key(|(name, _)| {
let (namespace, path) = name.split_once(':').unwrap_or(("minecraft", name.as_str()));
(format!("{path}.json"), namespace.to_owned())
});
let facts = items
.items
.iter()
@@ -2312,6 +2320,58 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
/// ResourceManagerRegistryLoadTask registers by resource-file Identifier,
/// and RecipeMap keeps that order when taking the first matching recipe.
#[test]
fn crafting_selection_follows_resource_file_order() {
let root =
std::env::temp_dir().join(format!("horizon-recipe-order-{}", std::process::id()));
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
let items = Items::for_tests(HashMap::new());
let recipe = |item: &str| {
serde_json::json!({
"type": "minecraft:crafting_shapeless", "ingredients": ["minecraft:flint"],
"result": {"id": item}
})
};
let cases = [
(vec!["minecraft:z_pick", "parity:a_pick"], "parity:a_pick"),
(
vec!["a_priority:z_pick", "z_priority:a_pick"],
"z_priority:a_pick",
),
(
vec!["z_priority:equal", "a_priority:equal"],
"a_priority:equal",
),
// File names include .json: '-' sorts before the parent's '.'.
(
vec!["parity:prefix", "parity:prefix-early"],
"parity:prefix-early",
),
(vec!["z_pick", "parity:a_pick"], "parity:a_pick"),
];
for (ids, expected) in cases {
for reverse in [false, true] {
let mut sources: Vec<_> = ids
.iter()
.map(|id| (id.to_string(), recipe("minecraft:stick")))
.collect();
if reverse {
sources.reverse();
}
let recipes =
Recipes::from_sources(&root, &items, &PackOverlay::NONE, sources).unwrap();
let (id, result) = recipes
.find(&[Some(ItemStack::new("minecraft:flint", 1))], 1)
.unwrap();
assert_eq!(id, expected, "input order: {ids:?}, reversed={reverse}");
assert_eq!(&*result.item, "minecraft:stick");
}
}
let _ = fs::remove_dir_all(&root);
}
/// A recipe whose `neoforge:conditions` hold loads, one whose don't is
/// silently absent, from the root's own files and a pack's alike.
#[test]
-294
View File
@@ -1,294 +0,0 @@
//! Conservative, budgeted occlusion against full opaque block cross-sections.
//!
//! A covered projection rectangle proves that an entire bounding box is hidden.
//! Testing a few rays would miss openings between those rays.
use std::cell::Cell;
const EPSILON: f64 = 1.0e-5;
pub struct Culler {
pub camera: [f64; 3],
remaining: Cell<usize>,
}
impl Culler {
pub fn new(camera: [f64; 3], budget: usize) -> Self {
Self {
camera,
remaining: Cell::new(budget),
}
}
/// Remove only this entity's completed camera-relative mesh when hidden.
pub fn cull_vertices(
&self,
vertices: &mut Vec<crate::mesher::Vertex>,
start: usize,
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
let hidden = self.hidden(vertices, start, opaque);
if hidden {
vertices.truncate(start);
}
hidden
}
/// Whether this entity's completed camera-relative mesh (`vertices[start..]`) is hidden.
pub fn hidden(
&self,
vertices: &[crate::mesher::Vertex],
start: usize,
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if self.remaining.get() == 0 {
return false;
}
let Some((min, max)) = Self::bounds(self.camera, vertices, start) else {
return false;
};
self.occluded(min, max, opaque)
}
/// Camera-relative bounds grown to include floating-point and rasterization
/// edges. The asynchronous path sends these small queries to its worker.
pub fn bounds(
camera: [f64; 3],
vertices: &[crate::mesher::Vertex],
start: usize,
) -> Option<([f64; 3], [f64; 3])> {
if start >= vertices.len() {
return None;
}
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for vertex in &vertices[start..] {
for a in 0..3 {
let value = f64::from(vertex.pos[a]) + camera[a];
if !value.is_finite() {
return None;
}
min[a] = min[a].min(value);
max[a] = max[a].max(value);
}
}
// Cover floating-point and rasterization edges as well as the vertices.
Some((min.map(|v| v - 0.05), max.map(|v| v + 0.05)))
}
/// Returns true only when a complete opaque block plane covers the bounds.
/// Unknown blocks, invalid bounds and an exhausted lookup budget stay visible.
pub fn occluded(
&self,
min: [f64; 3],
max: [f64; 3],
mut opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if (0..3).any(|a| {
!self.camera[a].is_finite()
|| !min[a].is_finite()
|| !max[a].is_finite()
|| min[a] > max[a]
}) {
return false;
}
let mut best = (0, 0.0, 0.0);
for a in 0..3 {
let (gap, sign) = if self.camera[a] < min[a] {
(min[a] - self.camera[a], 1.0)
} else {
(self.camera[a] - max[a], -1.0)
};
if gap > best.1 {
best = (a, gap, sign);
}
}
let (axis, gap, sign) = best;
if !gap.is_finite() || gap <= EPSILON {
return false;
}
let across = [(axis + 1) % 3, (axis + 2) % 3];
let Some(mut cell) = floor_cell(self.camera[axis]) else {
return false;
};
let step = sign as i32;
if (f64::from(cell) + 0.5 - self.camera[axis]) * sign <= EPSILON {
let Some(next) = cell.checked_add(step) else {
return false;
};
cell = next;
}
loop {
let depth = (f64::from(cell) + 0.5 - self.camera[axis]) * sign;
if depth >= gap - EPSILON || self.remaining.get() == 0 {
return false;
}
let mut lo = [f64::INFINITY; 2];
let mut hi = [f64::NEG_INFINITY; 2];
for corner in 0..8 {
let point: [f64; 3] = std::array::from_fn(|a| {
if corner & (1 << a) == 0 {
min[a]
} else {
max[a]
}
});
let t = depth / ((point[axis] - self.camera[axis]) * sign);
for (i, &a) in across.iter().enumerate() {
let p = self.camera[a] + t * (point[a] - self.camera[a]);
if !p.is_finite() {
return false;
}
lo[i] = lo[i].min(p);
hi[i] = hi[i].max(p);
}
}
let bounds = [
floor_cell(lo[0] - EPSILON),
floor_cell(hi[0] + EPSILON),
floor_cell(lo[1] - EPSILON),
floor_cell(hi[1] + EPSILON),
];
let [Some(x0), Some(x1), Some(y0), Some(y1)] = bounds else {
return false;
};
let mut pos = [0; 3];
pos[axis] = cell;
pos[across[0]] = x0 + ((i64::from(x1) - i64::from(x0)) / 2) as i32;
pos[across[1]] = y0 + ((i64::from(y1) - i64::from(y0)) / 2) as i32;
self.remaining.set(self.remaining.get() - 1);
if opaque(pos) {
let area = (i64::from(x1) - i64::from(x0) + 1)
.checked_mul(i64::from(y1) - i64::from(y0) + 1)
.and_then(|n| usize::try_from(n).ok());
let Some(area) = area.filter(|&n| n <= self.remaining.get()) else {
return false;
};
// Reserve the worst-case lookups before walking the rectangle.
self.remaining.set(self.remaining.get() - area);
let covered = (x0..=x1).all(|x| {
(y0..=y1).all(|y| {
pos[across[0]] = x;
pos[across[1]] = y;
opaque(pos)
})
});
if covered {
return true;
}
}
let Some(next) = cell.checked_add(step) else {
return false;
};
cell = next;
}
}
}
fn floor_cell(value: f64) -> Option<i32> {
let n = value.floor();
(n.is_finite() && n >= f64::from(i32::MIN) && n <= f64::from(i32::MAX)).then_some(n as i32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completed_mesh_culling_preserves_prior_geometry_and_protruding_parts() {
let vertex = |pos| crate::mesher::Vertex {
pos,
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0; 4],
glint_uv: [0.0, 0.0],
};
let prior = vertex([0.0; 3]);
let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
let wall = |p: [i32; 3]| p[0] == 4 && (-1..=0).contains(&p[1]);
let mut hidden = vec![prior];
hidden.extend(body);
assert!(Culler::new([0.0; 3], 1_000).cull_vertices(&mut hidden, 1, wall));
assert_eq!(hidden.len(), 1);
assert_eq!(hidden[0].pos, prior.pos);
// A held item or animated part beyond the wall keeps the whole model.
let mut exposed = vec![prior];
exposed.extend(body);
exposed.push(vertex([8.0, 5.0, 0.0]));
let before = exposed.clone();
assert!(!Culler::new([0.0; 3], 1_000).cull_vertices(&mut exposed, 1, wall));
assert_eq!(
bytemuck::cast_slice::<_, u8>(&exposed),
bytemuck::cast_slice::<_, u8>(&before)
);
assert!(!Culler::new([0.0; 3], 0).cull_vertices(&mut exposed, 1, |_| panic!()));
}
#[test]
fn opaque_walls_hide_bounds_in_all_directions() {
for axis in 0..3 {
for sign in [-1.0, 1.0] {
let mut min = [-2.0; 3];
let mut max = [2.0; 3];
min[axis] = if sign > 0.0 { 8.0 } else { -10.0 };
max[axis] = if sign > 0.0 { 10.0 } else { -8.0 };
let wall = if sign > 0.0 { 4 } else { -5 };
assert!(Culler::new([0.0; 3], 1_000).occluded(min, max, |p| p[axis] == wall));
}
}
}
#[test]
fn windows_slits_and_partial_walls_stay_visible() {
let min = [8.0, -4.0, -4.0];
let max = [10.0, 4.0, 4.0];
for opening in 0..3 {
assert!(!Culler::new([0.0; 3], 1_000).occluded(min, max, |p| {
p[0] == 4
&& match opening {
0 => !(p[1] == 1 && p[2] == 1),
1 => p[1] != 1,
_ => p[1] < 0,
}
}));
}
assert!(!Culler::new([0.0; 3], 1_000).occluded(min, max, |_| false));
}
#[test]
fn budgets_are_shared_and_exhaustion_fails_open() {
let culler = Culler::new([0.0; 3], 3);
let calls = Cell::new(0);
for _ in 0..2 {
assert!(!culler.occluded([8.0; 3], [10.0; 3], |_| {
calls.set(calls.get() + 1);
false
}));
}
assert_eq!(calls.get(), 3);
assert!(!Culler::new([0.0; 3], 0).occluded([8.0; 3], [10.0; 3], |_| panic!()));
assert!(!Culler::new([0.0; 3], 1).occluded([8.0; 3], [10.0; 3], |_| true));
}
#[test]
fn camera_inside_invalid_and_target_crossing_wall_stay_visible() {
let culler = Culler::new([0.0; 3], 1_000);
assert!(!culler.occluded([-1.0; 3], [1.0; 3], |_| panic!()));
assert!(!culler.occluded([f64::NAN; 3], [1.0; 3], |_| panic!()));
assert!(!culler.occluded([2.0; 3], [1.0; 3], |_| panic!()));
assert!(!culler.occluded([4.0, -1.0, -1.0], [8.0, 1.0, 1.0], |p| p[0] == 4));
assert!(!Culler::new([f64::INFINITY; 3], 1_000).occluded(
[8.0; 3],
[10.0; 3],
|_| panic!()
));
assert!(!Culler::new([f64::MAX; 3], 1_000).occluded(
[-f64::MAX; 3],
[-1.0; 3],
|_| panic!()
));
}
}
+3 -1
View File
@@ -536,7 +536,9 @@ pub fn mob_to_nbt(
] {
map.entry(key.into()).or_insert(value);
}
let id = m.kind.id.as_str();
// What the mob's class saves: a content mod's living type saves as its
// template does (the `id` written above stays its own).
let id = m.kind.class_id();
// AgeableMob.addAdditionalSaveData, only if canBeABaby (not a frog or a
// parrot); a tadpole saves its own age.
let species = m.species();
+78 -49
View File
@@ -255,6 +255,7 @@ mod nautili;
mod net_harness;
mod nether_portal;
mod no_resources;
mod occlusion_culling;
mod ominous_item_spawner;
mod options;
mod ordered_tick;
@@ -333,6 +334,7 @@ mod storage;
mod structure_blocks;
mod structure_edit;
mod structures;
mod studio;
mod subtitle_hud;
mod sulfur_cubes;
mod tempban;
@@ -388,6 +390,7 @@ use horizon_client::{
first_person::{Hand, SwingAnimation},
furnace::FurnaceCooking,
gfx::{Frame, Renderer},
graphics::EntityCullingMode,
hud::{HudAtlas, HudBatch, rgba},
hud_layout, hud_overlays,
items::{ItemStack, Items},
@@ -3021,9 +3024,13 @@ struct Game<'a> {
pending_uploads: Vec<((i32, i32, i32), horizon_client::mesher::SectionMesh)>,
/// This frame's view frustum, for culling entities and block entities.
view_frustum: Option<horizon_client::gfx::Frustum>,
entity_culler: Option<horizon_client::entity_culling::Culler>,
async_entity_culler: Option<entity_culling::AsyncCuller>,
async_entity_culling_failed: bool,
/// Whether the renderer's section-occlusion graph is active for this view.
entity_section_visibility_valid: bool,
/// The camera section in the current world layout.
entity_section_camera: Option<(i32, i32, i32)>,
sync_occlusion_culler: Option<horizon_client::voxel_occlusion::Culler>,
async_occlusion_culler: Option<Box<occlusion_culling::AsyncOcclusionCuller>>,
async_occlusion_culling_failed: bool,
async_particle_culler: RefCell<particle_culling::ParticleAsyncState>,
/// `Renderer::pack_shadow_casters` for this frame's entities: (keep what the culler hides,
/// build the player's model in first person); (false, false) without a shader pack.
@@ -11101,7 +11108,8 @@ impl Game<'_> {
self.entity_motion.borrow_mut().clear();
// LevelExtractor.extract: the transient blocks queued since the last frame.
self.drain_transient_block_queue();
self.prepare_entity_culling(camera);
self.prepare_entity_culling();
self.prepare_occlusion_culling(camera);
self.prepare_trims();
self.begin_skinning();
self.mark_entity(out.len(), "minecraft:entity_flame");
@@ -11316,32 +11324,6 @@ impl Game<'_> {
laps.lap("eg_shadows");
// Block entities (BlockEntityRenderers): chests, shulker boxes, banners, skulls...
if let Some(r) = &self.renderer {
let lid = |p: [i32; 3]| self.chest_lid(p, alpha as f32);
let anim = |p: [i32; 3]| self.block_anim(p, alpha as f32);
let scene = BlockEntityScene {
camera,
orientation: self.camera_orientation(),
anim: &anim,
texts: None,
partial: alpha as f32,
game_time: self.world.game_time,
max_y: self.world.shape.max_y() - 1,
world: &self.world,
sprites: &self.gui.atlas.sprites,
emissive: &self.gui.atlas.emissive,
translucent_sprites: &self.gui.atlas.translucent,
atlas_revision: self.gui.atlas.revision,
nether: self.dim == Dim::Nether,
lid: &lid,
sign: &|p| match self.world.block_entities.get(&(p[0], p[1], p[2])) {
Some(BlockEntity::Sign(sign)) => Some(sign),
_ => None,
},
banner: &|p| self.banner_layers_at(p),
skull: &|p| self.skull_skin_at(p),
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
};
// Block entities outside the view frustum are skipped (with a
// block of margin for models larger than their block).
let view = self.view_frustum;
@@ -11368,6 +11350,62 @@ impl Game<'_> {
v.intersects(o - glam::Vec3::ONE, o + glam::Vec3::splat(2.0))
})
};
let dynamic_visible = |_: [i32; 3], state: u32, lo: glam::DVec3, hi: glam::DVec3| {
if off_screen(state) {
return true;
}
if let Some(view) = view
&& !view.intersects(
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
)
{
return false;
}
let name = &*self.res.facts().get(BlockStateId(state)).name;
if self.settings.horizon.entity_culling == EntityCullingMode::Off
|| self.pack_shadow_casters.0
|| name == "minecraft:end_portal"
|| name == "minecraft:end_gateway"
|| hi.y - lo.y > 2.0
{
return true;
}
// Culler::aabb_bounds adds 0.05 on each side. Keep its final
// query just inside the conservative rendered-model envelope.
let bounds = Aabb::new(
Vec3::new(lo.x + 0.06, lo.y + 0.06, lo.z + 0.06),
Vec3::new(hi.x - 0.06, hi.y - 0.06, hi.z - 0.06),
);
!self.block_entity_occluded(bounds)
};
let lid = |p: [i32; 3]| self.chest_lid(p, alpha as f32);
let anim = |p: [i32; 3]| self.block_anim(p, alpha as f32);
let scene = BlockEntityScene {
camera,
orientation: self.camera_orientation(),
anim: &anim,
texts: None,
partial: alpha as f32,
game_time: self.world.game_time,
max_y: self.world.shape.max_y() - 1,
world: &self.world,
sprites: &self.gui.atlas.sprites,
emissive: &self.gui.atlas.emissive,
translucent_sprites: &self.gui.atlas.translucent,
atlas_revision: self.gui.atlas.revision,
nether: self.dim == Dim::Nether,
lid: &lid,
sign: &|p| match self.world.block_entities.get(&(p[0], p[1], p[2])) {
Some(BlockEntity::Sign(sign)) => Some(sign),
_ => None,
},
banner: &|p| self.banner_layers_at(p),
skull: &|p| self.skull_skin_at(p),
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
dynamic_visible: Some(&dynamic_visible),
};
let visible = r
.visible_block_entities_in_frustum(
self.settings.chunk_fade,
@@ -11411,20 +11449,6 @@ impl Game<'_> {
|_, state, start| self.mark_block_entity(start, state),
|_, _, _, _| {},
);
} else if (self.entity_culler.is_some() || self.async_entity_culler.is_some())
&& !block_entity_cache.any_model_cache()
{
// Entity Culling on and no model cache: a block entity hidden
// behind a wall stays culled, as it was before the caches.
for block in visible {
let start = out.len();
self.res
.block_entity_render()
.render(&scene, std::iter::once(block), &mut out);
if !off_screen(block.1) {
self.cull_entity_vertices(&mut out, start);
}
}
} else {
let cache_candidates = r
.visible_block_entities_in_frustum(
@@ -11695,7 +11719,7 @@ impl Game<'_> {
}
}
laps.lap("eg_cracks");
self.finish_async_entity_culling();
self.finish_async_occlusion_culling();
laps.lap("eg_culling_finish");
self.native_world_geometry(camera, alpha as f32, &mut out);
(
@@ -15991,8 +16015,11 @@ impl Game<'_> {
}
});
// What faces the camera (particles, item sprites, block entity text) faces a mod's.
// A spectated entity's view is not the local player's yaw and pitch
// (`Camera.setup` reads the camera entity), so it is kept too.
let spectating = self.camera_mob().is_some();
self.posed_rotation = view
.filter(|posed| Some(*posed) != vanilla)
.filter(|posed| spectating || Some(*posed) != vanilla)
.map(|(_, yaw, pitch)| (yaw, pitch));
let eye = view.map(|_| {
(
@@ -19567,9 +19594,11 @@ impl<'a> Game<'a> {
repro: Default::default(),
agent: agent::Agent::default(),
view_frustum: None,
entity_culler: None,
async_entity_culler: None,
async_entity_culling_failed: false,
entity_section_visibility_valid: false,
entity_section_camera: None,
sync_occlusion_culler: None,
async_occlusion_culler: None,
async_occlusion_culling_failed: false,
async_particle_culler: RefCell::new(Default::default()),
pack_shadow_casters: (false, false),
pending_uploads: Vec::new(),
@@ -1040,7 +1040,6 @@ impl Game<'_> {
// The stands' models posed on the GPU path (`mobs::skin`), when it runs this frame.
let skinning = self.skinning();
for a in &self.armor_stands {
let entity_start = out.len();
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
// edge (4 for a marker's empty box), whatever the entity distance.
// LivingEntityRenderer grows the culling box by 0.5 for a dragon head.
@@ -1070,6 +1069,11 @@ impl Game<'_> {
) {
continue;
}
let culling = self.entity_cull_decision(a, alpha);
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
// EntityRenderer: drawn between last tick's position and this one's.
let at = a.render_position(alpha);
let p = super::entity_culling::at(at) - camera;
@@ -1103,7 +1107,9 @@ impl Game<'_> {
self.armor_stand_vertices(a, base, packed, None, out);
None
};
self.cull_skinned(out, entity_start, skinned);
if !self.entity_glowing(a.id) {
self.cull_skinned(out, entity_start, skinned);
}
}
}
/// `ArmorStandRenderer.submit` for stand `a` under `base` (its
+6 -2
View File
@@ -1477,7 +1477,6 @@ impl Game<'_> {
self.view_scale(),
);
for b in &self.boats {
let entity_start = out.len();
// EntityRenderer.shouldRender.
let (previous, position) = (at(b.previous), at(b.position));
if !self.standing_entity_in_view(
@@ -1485,6 +1484,11 @@ impl Game<'_> {
) {
continue;
}
let culling = self.entity_cull_decision(b, alpha);
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
let models = if b.chest {
&self.boat_models.chest_models
} else {
@@ -1579,7 +1583,7 @@ impl Game<'_> {
out,
);
}
self.cull_entity_vertices(out, entity_start);
self.finish_entity_occlusion(culling, b, out, entity_start);
}
}
}
+1
View File
@@ -63,6 +63,7 @@ impl Game<'_> {
) -> horizon_client::command::Dispatcher {
let mut names = Vec::new();
self.native_commands(&mut names);
names.push("studio");
let base = match base {
Some(tree) => tree,
None => horizon_client::command::vanilla::game_dispatcher(),
@@ -6,6 +6,7 @@
//! system message it produced, as styled spans, with the results reported to
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
//! waits until the terrain and entities around the player are loaded;
//! `use_item` dispatches the main-hand server packet; `map_state` reads saved map metadata;
//! `use_block` uses the main hand at a block top;
//! `break` destroys a block as the server player; `portal` sends the player
//! through the nether portal it stands in; `falling` counts physical falling
@@ -433,7 +434,7 @@ impl Game<'_> {
}
self.harness_owner(|g| g.harness_settle(deadline, op.get("required_chunks")))?;
}
"use_item" => {
"use_item" if op.get("sequence").is_some() => {
// Exercise the connected owner's normal server packet route.
let sequence = op["sequence"].as_i64().unwrap_or(0) as i32;
let yaw = op["yaw"].as_f64().unwrap_or(0.0) as f32;
@@ -479,6 +480,76 @@ impl Game<'_> {
Ok(())
})?;
}
// Java produced the fixture file before this world was opened; map_state loads it.
"crafter_max_map_fixture" => {}
"use_item" => {
let owner = self
.serving
.as_ref()
.ok_or("command harness: no server")?
.owner;
self.in_owner_level(|g| -> Result<()> {
let conn = g
.lan
.as_ref()
.and_then(|h| {
h.hub
.guests
.iter()
.find(|guest| guest.uuid == owner && guest.placed)
.map(|guest| guest.conn)
})
.ok_or("command harness: owner is not connected")?;
let mut host = g.lan.take().ok_or("command harness: no hub")?;
g.lan_guest_packet(
&mut host,
conn,
horizon_protocol::packets::play::Serverbound::UseItem(
horizon_protocol::packets::play::UseItem {
hand: 0,
sequence: 0,
yaw: 0.0,
pitch: 0.0,
},
),
);
g.lan = Some(host);
Ok(())
})?;
}
"map_state" => {
let id = op["id"].as_i64().ok_or("command harness: map id")? as i32;
let value = self.harness_owner(|g| {
Ok(match g.map(id) {
Some(m) => format!(
"scale={},locked={},center={},{}",
m.scale, m.locked, m.center[0], m.center[1]
),
None => "missing".to_owned(),
})
})?;
let record = json!({"cmd": format!("#map_state {id}"),
"messages": [spans(&Text::literal(value))], "results": []});
out.push_str(&record.to_string());
out.push('\n');
}
"light" => {
let pos = std::array::from_fn::<_, 3, _>(|i| {
op["pos"][i].as_i64().unwrap_or(0) as i32
});
let levels = self.harness_owner(|g| {
use horizon_client::mobs::spawn_rules::SpawnLevel;
let level = g.natural_level();
Ok([
level.block_light(pos[0], pos[1], pos[2]),
level.sky_light(pos[0], pos[1], pos[2]),
])
})?;
let record = json!({"cmd": format!("#light {}", op["pos"]),
"messages": [], "results": levels.map(|v| (true, v))});
out.push_str(&record.to_string());
out.push('\n');
}
"use_block" => {
// Exercise the connected owner's normal server packet route.
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
@@ -520,6 +591,17 @@ impl Game<'_> {
Ok(())
})?;
}
"crafter_tick" => {
let [x, y, z] =
std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
self.harness_owner(|g| {
let state = g.world.block(x, y, z);
if &*g.res.facts().get(state).name != "minecraft:crafter" {
return Err("command harness: crafter tick without a crafter".into());
}
g.dispense_from(horizon_core::position::BlockPos::new(x, y, z), state)
})?;
}
"break" => {
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
self.harness_owner(|g| g.break_block(pos))?;
@@ -556,7 +556,9 @@ impl Game<'_> {
return Ok(0);
};
let ty = self.target_type(t);
let table = format!("minecraft:entities/{}", ty.trim_start_matches("minecraft:"));
// `EntityType.getDefaultLootTable`, in the type's namespace.
let (namespace, path) = ty.split_once(':').unwrap_or(("minecraft", ty.as_str()));
let table = format!("{namespace}:entities/{path}");
if t == Target::Player || !world_items!(self).has_loot_table(&table) {
return Err(err(
"commands.drop.no_loot_table.entity",
@@ -2,7 +2,7 @@
//! `/particle`, `/attribute`, `/ride` and `/spreadplayers`.
use super::{Arg, Context, Game, R, Target, err, wrap_degrees};
use horizon_client::{
mobs::{Damage, DamageKind, Who, finalize},
mobs::{Damage, DamageKind, MobEvent, Who, finalize},
scoreboard_cmd::{Holder, Tracker},
settings::SOUND_SOURCES,
text::Text,
@@ -12,6 +12,24 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_world::attribute_map::ValueKind;
use horizon_world::nbt::Tag;
/// Queue bookkeeping for synchronous command damage, preserving prior requests
/// and unrelated events. The existing game policy handles each extracted request.
fn take_new_reinforcements(events: &mut Vec<MobEvent>, queued: usize) -> Vec<(u32, Who)> {
let mut requests = Vec::new();
let mut index = 0;
events.retain(|event| {
let new = index >= queued;
index += 1;
if new && let MobEvent::Reinforcements { mob, target } = event {
requests.push((*mob, *target));
false
} else {
true
}
});
requests
}
fn num(n: impl std::fmt::Display) -> Text {
Text::literal(n.to_string())
}
@@ -359,19 +377,24 @@ impl Game<'_> {
true
}
/// Retain the mob hit's causing-player connection after the hit is accepted.
/// `DamageCommand.damage`: retain the accepted hit's player context and run
/// `Zombie.hurtServer`'s reinforcement requests before the command returns.
fn command_damage_mob(&mut self, id: u32, amount: f32, damage: Damage) -> bool {
let player_uuid = (damage.attacker == Some(Who::Player))
.then(|| self.owner_damage_uuid())
.flatten();
let hurt = self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| match player_uuid {
let mut reinforcements = Vec::new();
let hurt = self.mobs.iter_mut().find(|m| m.id == id).is_some_and(|m| {
let queued = m.outbox.len();
let hurt = match player_uuid {
Some(uuid) => m.damage_with_player_uuid(amount, damage, uuid, None),
None => m.damage(amount, damage),
});
};
if hurt {
reinforcements = take_new_reinforcements(&mut m.outbox, queued);
}
hurt
});
if hurt {
self.resolve_player_responsible(id, damage);
if damage.attacker == Some(Who::Player) {
@@ -388,6 +411,9 @@ impl Game<'_> {
}
}
}
for (mob, target) in reinforcements {
self.call_reinforcements(mob, target);
}
hurt
}
@@ -1990,7 +2016,44 @@ mod allay_owner_damage_tests {
#[cfg(test)]
mod tests {
use super::{look_at, playsound_placement};
use super::{look_at, playsound_placement, take_new_reinforcements};
use horizon_client::mobs::{MobEvent, Who};
#[test]
fn command_reinforcements_leave_prior_requests_and_other_events_queued() {
let request = |mob| MobEvent::Reinforcements {
mob,
target: Who::Player,
};
let mut events = vec![request(1), MobEvent::Sound("before", [0.; 3], 1., 1.)];
let queued = events.len();
events.extend([
request(2),
MobEvent::Sound("after", [0.; 3], 1., 1.),
request(3),
]);
assert_eq!(
take_new_reinforcements(&mut events, queued),
[(2, Who::Player), (3, Who::Player)]
);
assert!(matches!(
events.as_slice(),
[
MobEvent::Reinforcements { mob: 1, .. },
MobEvent::Sound("before", ..),
MobEvent::Sound("after", ..)
]
));
assert!(take_new_reinforcements(&mut events, queued).is_empty());
assert_eq!(events.len(), 3);
}
#[test]
fn command_reinforcements_tolerate_a_damage_handler_clearing_its_outbox() {
let mut events = vec![MobEvent::Sound("after", [0.; 3], 1., 1.)];
assert!(take_new_reinforcements(&mut events, 4).is_empty());
assert_eq!(events.len(), 1);
}
fn assert_angle(got: f32, want: f32) {
assert!((got - want).abs() < 1.0e-4, "{got} is not {want}");
+23 -4
View File
@@ -17,7 +17,7 @@ const PROTOCOL: &str = "0.1.0";
/// The command groups the game serves, as `help` chains them: what `hello` reports as its features.
const FEATURES: &[&str] = &[
"game", "station", "blocks", "lan", "friends", "render", "sculk", "agent", "ui", "options",
"events", "saves", "pad",
"events", "saves", "pad", "studio",
];
/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves.
@@ -118,6 +118,7 @@ impl Game<'_> {
&& let Some(flight) = self.flight.take()
{
let mut summary = self.frame_stats.summary();
self.add_async_occlusion_stats(&mut summary);
let n = flight.tick_ms.len().max(1) as f32;
summary["tick_ms"] = (flight.tick_ms.iter().sum::<f32>() / n).into();
summary["tick_ms_max"] = flight.tick_ms.iter().copied().fold(0.0, f32::max).into();
@@ -164,8 +165,22 @@ impl Game<'_> {
.collect(),
);
}
self.add_async_occlusion_stats(&mut summary);
summary
}
fn add_async_occlusion_stats(&self, summary: &mut Value) {
if let Some(culler) = &self.async_occlusion_culler {
summary["async_occlusion_culling"] = culler.stats_value();
}
}
fn reset_frame_stats(&mut self) {
self.frame_stats.reset();
if let Some(culler) = &self.async_occlusion_culler {
culler.reset_stats();
}
}
pub(super) fn poll_control(&mut self, event_loop: &dyn EventLoopCtl) {
if let Some((deadline, reply)) = self.pending_bench.take() {
if Instant::now() >= deadline {
@@ -362,6 +377,9 @@ impl Game<'_> {
if let Some(value) = self.friends_control(&words)? {
return Ok(Some(value));
}
if let Some(value) = self.studio_control(&words)? {
return Ok(Some(value));
}
match self.agent_command(&words, line, reply, event_loop)? {
super::agent::Handled::Reply(value) => return Ok(Some(value)),
super::agent::Handled::Deferred => return Ok(None),
@@ -384,6 +402,7 @@ impl Game<'_> {
.chain(super::pad::HELP)
.chain(super::input_trace::HELP)
.chain(super::repro::HELP)
.chain(super::studio::HELP)
.collect::<Vec<_>>() }))),
"setblock" | "fill" | "particle" | "signedit" => self.render_command(&words),
"section-draws" => {
@@ -1024,7 +1043,7 @@ impl Game<'_> {
summary["gpu_ms"] = self.frame_stats.gpu_millis().into();
}
if words[1..].contains(&"reset") {
self.frame_stats.reset();
self.reset_frame_stats();
}
Ok(Some(summary))
}
@@ -1033,7 +1052,7 @@ impl Game<'_> {
if self.pending_bench.is_some() {
return Err("a benchmark is already running".into());
}
self.frame_stats.reset();
self.reset_frame_stats();
self.pending_bench = Some((
Instant::now() + Duration::from_secs_f64(seconds),
reply.clone(),
@@ -1047,7 +1066,7 @@ impl Game<'_> {
}
let n = |i: usize| -> Result<f64> { Ok(arg(i)?.parse()?) };
let pitch = words.get(8).map_or(Ok(10.0), |p| p.parse::<f32>())?;
self.frame_stats.reset();
self.reset_frame_stats();
self.flight = Some(Flight {
from: Vec3::new(n(1)?, n(2)?, n(3)?),
to: Vec3::new(n(4)?, n(5)?, n(6)?),
+88 -28
View File
@@ -5,8 +5,8 @@
mod pufferfish_player;
use super::{
Dim, Game, Hand, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
native_projectiles::Impact, view_vector,
Dim, Game, Hand, InteractTarget, dispenser, entity_culling::EntityCullDecision,
native_mob_draw::NativePlayerRender, native_projectiles::Impact, view_vector,
};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
@@ -15,6 +15,7 @@ use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
crafting::Recipes,
graphics::EntityCullingMode,
health::{HurtSource, Hurting, Knock, knockback_canceled},
items::ItemStack,
mesher::Vertex,
@@ -6901,6 +6902,25 @@ impl Game<'_> {
skinned: Skinned,
) {
let corners = skinned.and_then(|(_, bounds)| bounds);
if out.len() == entity_start {
if let Some((lo, hi)) = corners
&& self.cull_entity_camera_bounds(lo, hi)
&& let Some((mark, _)) = skinned
{
if self.pack_shadow_casters.0 {
let mut frame = self.skin_frame.borrow_mut();
let end = frame.vertices as usize;
horizon_client::gfx::pack_draw::EntitySpan::hide(
&mut frame.spans,
mark.2 as usize,
end,
);
} else {
self.skin_frame.borrow_mut().truncate(mark);
}
}
return;
}
if let Some((lo, hi)) = corners {
out.extend([lo, hi].map(|pos| Vertex {
pos: pos.to_array(),
@@ -6940,6 +6960,30 @@ impl Game<'_> {
}
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
// The coarse mob bounds pay off only in dense async scenes; otherwise,
// keep the existing exact mesh-culling path.
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
&& self
.async_occlusion_culler
.as_ref()
.is_some_and(|culler| culler.camera_is_stationary())
&& self.settings.horizon.entity_culling_precheck != 0
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
&& !self.pack_shadow_casters.0
&& horizon_client::mod_api::mods().is_empty()
{
self.mob_geometry_inner::<true>(camera, alpha, out);
} else {
self.mob_geometry_inner::<false>(camera, alpha, out);
}
}
fn mob_geometry_inner<const PRECHECK: bool>(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
@@ -6965,43 +7009,27 @@ impl Game<'_> {
..Default::default()
});
for mob in &self.mobs {
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
// A player keeps its armour, elytra and held items
// (`LivingEntityRenderer.submit` skips only the body).
let is_player = &*mob.kind.id == "minecraft:player";
let layers_only = mob.invisible && outline.is_none() && is_player;
if mob.invisible && outline.is_none() && !layers_only {
continue;
}
// LevelRenderer: the entity the view looks out of is not drawn
// in first person.
if self.hides_as_camera(mob.id) {
continue;
}
let is_player = &*mob.kind.id == "minecraft:player";
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
let (w, h, _) = mob.dims();
let edge = (w + h + w) / 3.0
* if &*mob.kind.id == "minecraft:player" {
10.0
} else {
1.0
};
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
let range = edge * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
// EntityRenderer.shouldRender: the box its renderer culls with
// (`getBoundingBoxForCulling`, interpolated over the frame), grown
// by 0.5 (GuardianRenderer also draws while its beam is out).
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
if let Some(view) = &self.view_frustum
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
&& beam.is_none()
{
let (lo, hi) = super::entity_culling::cull_box(mob, alpha);
let (lo, hi) = (
@@ -7012,6 +7040,40 @@ impl Game<'_> {
continue;
}
}
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
// A player keeps its armour, elytra and held items
// (`LivingEntityRenderer.submit` skips only the body).
let layers_only = mob.invisible && outline.is_none() && is_player;
if mob.invisible && outline.is_none() && !layers_only {
continue;
}
let culling = if PRECHECK
&& outline.is_none()
&& !is_player
&& mob.kind.id.starts_with("minecraft:")
&& [
&mob.extra.head,
&mob.extra.chest,
&mob.extra.legs,
&mob.extra.feet,
&mob.extra.main_hand,
&mob.extra.off_hand,
]
.iter()
.all(|item| item.is_none())
&& beam.is_none()
{
self.entity_cull_decision(mob, alpha)
} else {
EntityCullDecision::CheckVertices
};
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
self.mark_entity(entity_start, &mob.kind.id);
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
let light = self.world.light(
@@ -7210,14 +7272,12 @@ impl Game<'_> {
}
// Glowing outlines and a guardian's beam remain visible independently
// of whether the body is hidden behind terrain.
if outline.is_none()
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
{
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
// What the GPU draws of the mob counts by its bounds' corners.
self.cull_skinned(out, entity_start, skinned);
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = horizon_client::mobs::guardian::beam(mob, alpha as f32)
if let Some((time, scale)) = beam
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
{
+10 -6
View File
@@ -6,7 +6,7 @@
//! A cushion is a `BlockAttachedEntity`, not a block: it has no block state,
//! only an entity whose `Pos` is the click point's height. Placement uses
//! outline bounds, collision shapes and suffocation predicates.
use super::{Game, Hit, InteractTarget};
use super::{Game, Hit, InteractTarget, Keys, entity_culling::default_range, ray_box};
use horizon_client::entities::{Cushion, dye_index};
use horizon_client::items::ItemStack;
use horizon_client::mobs::anim::Poses;
@@ -149,7 +149,7 @@ impl Game<'_> {
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, c) in self.cushions.iter().enumerate() {
let Some(d) = super::ray_box(eye, dir, reach, aabb(c.bounds())) else {
let Some(d) = ray_box(eye, dir, reach, aabb(c.bounds())) else {
continue;
};
if best.is_none_or(|b| d < b.1) {
@@ -525,7 +525,7 @@ impl Game<'_> {
}
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
pub(super) fn tick_cushion_riding(&mut self, keys: Keys) -> bool {
let Some(c) = self
.cushions
.iter()
@@ -560,14 +560,13 @@ impl Game<'_> {
};
let sprites = &self.gui.atlas.sprites;
for c in &self.cushions {
let entity_start = out.len();
let a = aabb(c.bounds());
let (lo, hi) = (a.min_position(), a.max_position());
let (lo, hi) = (
glam::DVec3::new(lo.x, lo.y, lo.z),
glam::DVec3::new(hi.x, hi.y, hi.z),
);
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
let range = default_range(hi - lo, self.view_scale());
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
@@ -577,6 +576,11 @@ impl Game<'_> {
else {
continue;
};
let culling = self.entity_cull_decision(c, 1.0);
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
let p = glam::DVec3::new(c.position.x, c.position.y, c.position.z) - camera;
let base = glam::Mat4::from_translation(p.as_vec3())
* glam::Mat4::from_rotation_y((180.0 - snap_yaw(c.yaw)).to_radians())
@@ -599,7 +603,7 @@ impl Game<'_> {
0,
out,
);
self.cull_entity_vertices(out, entity_start);
self.finish_entity_occlusion(culling, c, out, entity_start);
}
}
}
+269 -1
View File
@@ -13,7 +13,9 @@ use super::{FallingBlock, Game};
use super::{
boats::Boat, fireworks::Rocket, minecarts::Minecart, tnt::PrimedTnt, trident::ThrownTrident,
};
use horizon_client::entities::{ArmorStand, ExperienceOrb, ItemEntity};
use horizon_client::entities::{
ArmorStand, Cushion, ExperienceOrb, ItemEntity, ItemFrame, Painting,
};
use horizon_client::entity_rules::EntityRules;
use horizon_client::join_seq::JoinSeq;
use horizon_client::join_seq::Joined as _;
@@ -278,6 +280,272 @@ pub(super) trait Entity: EntityRules {
}
}
/// Bounds and identity needed by the render occlusion culler. Moving renderers
/// use interpolated entity bounds; attached entities use their own world bounds.
pub(super) trait RenderEntityBounds {
fn render_id(&self) -> u32;
fn render_bounds(&self, alpha: f64) -> Aabb;
fn render_glowing(&self, game: &Game<'_>) -> bool {
game.entity_glowing(self.render_id())
}
}
impl RenderEntityBounds for Mob {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, alpha: f64) -> Aabb {
// Keep models with large independent appendages on the old envelope:
// their tentacles and wings can extend several blocks past the entity
// dimensions. Equipment and custom rendering bypass this pre-check at
// the call site.
let (width, height, _) = self.awake_dims();
let at = self.previous.lerp(self.position, f64::from(alpha as f32));
if matches!(
self.species(),
Species::Guardian | Species::ElderGuardian | Species::Phantom
) {
let radius = width.max(height) * 2.5 + 3.0;
return Aabb::of_size(at, Vec3::new(radius * 2.0, radius * 2.0, radius * 2.0));
}
// Vanilla's ordinary models can rotate their head, limbs and death
// pose beyond the collision box. This envelope covers those poses while
// remaining close to the rendered mesh for the common upright mobs.
// A few quadruped death and riding poses reach several blocks past
// their standing width. Keep a bounded fallback radius for ordinary
// models while still shrinking the common pig/zombie envelope below
// the old five-block radius.
let horizontal = (width * 2.0).max(height * 1.5).max(3.5);
let vertical = height * 3.0 + 0.5;
Aabb::new(
at.subtract(Vec3::new(horizontal, vertical, horizontal)),
at.add_vector(Vec3::new(horizontal, vertical, horizontal)),
)
}
fn render_glowing(&self, _game: &Game<'_>) -> bool {
self.is_currently_glowing()
}
}
impl RenderEntityBounds for ArmorStand {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, alpha: f64) -> Aabb {
let at = self.previous.lerp(self.position, alpha);
// ArmorStandRenderer poses the limbs, then attaches hand items, head
// items, armour and folded elytra. Those can extend well beyond the
// 0.5 x 1.975 collision box (especially raised arms and head banners).
// This axis-aligned envelope contains every vanilla pose while staying
// independent of the stand's yaw and partial-tick rotation.
let scale = if self.small { 0.5 } else { 1.0 };
let equipped = self.equipment.iter().any(Option::is_some);
let horizontal = if equipped { 2.0 } else { 1.5 * scale };
let lower = if equipped { -1.0 } else { -0.5 * scale };
let upper = 3.5 * scale
+ if self.equipment[5].is_some() {
1.5
} else {
0.0
};
Aabb::new(
at.subtract(Vec3::new(horizontal, -lower, horizontal)),
at.add_vector(Vec3::new(horizontal, upper, horizontal)),
)
}
}
impl RenderEntityBounds for ItemEntity {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, alpha: f64) -> Aabb {
let at = self.previous.lerp(self.position, alpha);
// DroppedItemRenderer bobs, spins, and fans out up to five item copies.
// The draw path uses the resolved model's exact posed extents; this
// fallback envelope also covers the largest vanilla ground item.
Aabb::new(
at.subtract(Vec3::new(0.75, 0.1, 0.75)),
at.add_vector(Vec3::new(0.75, 1.0, 0.75)),
)
}
}
impl RenderEntityBounds for Boat {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, alpha: f64) -> Aabb {
let at = self.previous.lerp(self.position, alpha);
// The boat model turns with yaw and its animated paddles sweep outside
// the sized collision box. Hurt and bubble-column rotations are covered
// by this orientation-independent model envelope.
Aabb::new(
at.subtract(Vec3::new(1.75, 0.75, 1.75)),
at.add_vector(Vec3::new(1.75, 1.5, 1.75)),
)
}
}
impl RenderEntityBounds for Minecart {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, alpha: f64) -> Aabb {
let at = self.previous.lerp(self.position, alpha);
let offset = self.custom_offset.map_or_else(
|| {
if self.kind == super::minecarts::CartKind::Hopper {
1.0
} else {
6.0
}
},
f64::from,
);
let display_shift = 0.75 * (offset - 8.0) / 16.0;
let display_low = at.y + 0.375 - 0.4 + display_shift;
let display_high = at.y + 0.375 + 0.4 + display_shift + 0.9;
Aabb::new(
Vec3::new(at.x - 1.25, (at.y - 0.5).min(display_low), at.z - 1.25),
Vec3::new(at.x + 1.25, (at.y + 1.0).max(display_high), at.z + 1.25),
)
}
}
fn aabb_from_extents(bounds: ([f64; 3], [f64; 3])) -> Aabb {
Aabb::new(
Vec3::new(bounds.0[0], bounds.0[1], bounds.0[2]),
Vec3::new(bounds.1[0], bounds.1[1], bounds.1[2]),
)
}
impl RenderEntityBounds for Painting {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, _alpha: f64) -> Aabb {
aabb_from_extents(self.bounds())
}
}
impl RenderEntityBounds for ItemFrame {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, _alpha: f64) -> Aabb {
let (lo, hi) = self.bounds();
let step = self.step().map(f64::from);
let center: [f64; 3] = std::array::from_fn(|axis| (lo[axis] + hi[axis]) * 0.5);
// Maps fill a full block, while the held item projects toward the
// camera from the frame plane. Include both in the occlusion envelope.
let half: [f64; 3] = std::array::from_fn(|axis| if step[axis] != 0.0 { 0.75 } else { 0.5 });
aabb_from_extents((
std::array::from_fn(|axis| center[axis] - half[axis]),
std::array::from_fn(|axis| center[axis] + half[axis]),
))
}
}
impl RenderEntityBounds for Cushion {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, _alpha: f64) -> Aabb {
aabb_from_extents(self.bounds())
}
}
#[cfg(test)]
mod render_bounds_tests {
use super::*;
#[test]
fn armor_stand_bounds_include_raised_equipment_space() {
let mut stand = ArmorStand::new(1, Vec3::new(4.0, 64.0, -2.0), 90.0);
stand.show_arms = true;
stand.pose[2] = [180.0, 0.0, 45.0];
stand.equipment[0] = Some(horizon_client::items::ItemStack {
item: "minecraft:iron_sword".into(),
count: 1,
damage: 0,
extra: None,
});
stand.equipment[5] = Some(horizon_client::items::ItemStack {
item: "minecraft:banner".into(),
count: 1,
damage: 0,
extra: None,
});
let bounds = stand.render_bounds(0.5);
assert!(bounds.min_position().x <= 2.0);
assert!(bounds.max_position().x >= 6.0);
assert!(bounds.min_position().y <= 63.0);
assert!(bounds.max_position().y >= 69.0);
assert!(bounds.min_position().z <= -4.0);
assert!(bounds.max_position().z >= 0.0);
}
#[test]
fn boat_bounds_cover_the_rotated_paddle_sweep() {
let boat = Boat::new(2, "oak".into(), Vec3::new(0.0, 10.0, 0.0), 90.0);
let bounds = boat.render_bounds(0.5);
assert!(bounds.min_position().x <= -1.75);
assert!(bounds.max_position().x >= 1.75);
assert!(bounds.min_position().y <= 9.25);
assert!(bounds.max_position().y >= 11.5);
assert!(bounds.min_position().z <= -1.75);
assert!(bounds.max_position().z >= 1.75);
}
#[test]
fn item_frame_bounds_include_the_full_map_and_held_item_depth() {
let frame = ItemFrame {
id: 3,
pos: [0, 0, 0],
facing: 5,
glow: false,
item: None,
rotation: 0,
drop_chance: 1.0,
fixed: false,
invisible: false,
check_interval: 0,
custom_name: None,
};
let bounds = frame.render_bounds(1.0);
assert!(bounds.min_position().x <= -0.71875);
assert!(bounds.max_position().x >= 0.78125);
assert!(bounds.min_position().y <= 0.0);
assert!(bounds.max_position().y >= 1.0);
assert!(bounds.min_position().z <= 0.0);
assert!(bounds.max_position().z >= 1.0);
}
#[test]
fn minecart_bounds_follow_custom_display_offset() {
let mut cart = Minecart::new(
4,
super::super::minecarts::CartKind::Chest,
Vec3::new(0.0, 10.0, 0.0),
);
cart.custom_offset = Some(32);
let bounds = cart.render_bounds(1.0);
assert!(bounds.min_position().y <= 9.5);
assert!(bounds.max_position().y >= 12.8);
}
}
/// The fields every kind stores under the same names.
macro_rules! stored_motion {
() => {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,98 @@
use super::*;
use horizon_client::voxel_occlusion::Culler;
#[allow(dead_code)]
fn vertex(pos: [f32; 3]) -> Vertex {
Vertex {
pos,
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0; 4],
glint_uv: [0.0; 2],
}
}
/// `Entity.setViewScale`: the entity distance option times the render
/// distance's boost — 1 at eight chunks, 1.5 at twelve, 2.5 at twenty.
#[test]
fn view_scale_follows_the_render_distance() {
assert_eq!(entity_view_scale(4, 1.0), 1.0);
assert_eq!(entity_view_scale(8, 1.0), 1.0);
assert_eq!(entity_view_scale(12, 1.0), 1.5);
assert_eq!(entity_view_scale(20, 1.0), 2.5);
assert_eq!(entity_view_scale(32, 1.0), 2.5);
assert_eq!(entity_view_scale(12, 0.5), 0.75);
}
/// `Entity.shouldRenderAtSqrDistance`: a chicken's box (0.4 by 0.7 by
/// 0.4) is drawn up to 48 blocks away at the vanilla defaults.
#[test]
fn small_mobs_use_the_mean_edge_for_render_distance() {
let range = default_range(glam::DVec3::new(0.4, 0.7, 0.4), entity_view_scale(12, 1.0));
assert_eq!(range, 48.0);
}
/// Dropped items keep their 0.25-block box, so their default range scales
/// with the render distance but never exceeds the vanilla clamp.
#[test]
fn dropped_items_use_their_small_render_box() {
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);
}
#[test]
fn invalid_render_sizes_fail_open_to_one_block() {
let range = default_range(
glam::DVec3::new(f64::NAN, 0.7, 0.4),
entity_view_scale(12, 1.0),
);
assert_eq!(range, 96.0);
}
#[test]
fn culling_decisions_only_hide_after_a_confirmed_occlusion() {
assert!(!EntityCullDecision::Bypass.hides_entity());
assert!(!EntityCullDecision::CheckVertices.hides_entity());
assert!(!EntityCullDecision::Pending.hides_entity());
assert!(EntityCullDecision::Hidden.hides_entity());
}
#[test]
fn coarse_aabb_and_mesh_paths_agree_for_a_complete_wall() {
let camera = [0.0; 3];
let bounds = Aabb::new(Vec3::new(8.0, -1.0, -1.0), Vec3::new(10.0, 1.0, 1.0));
let wall = |pos: [i32; 3]| pos[0] == 4;
assert!(Culler::new(camera, 1_000).hidden_bounds(bounds, wall));
let mesh = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
assert!(Culler::new(camera, 1_000).hidden(&mesh, 0, wall));
}
#[test]
fn partial_wall_and_budget_exhaustion_fail_open() {
let bounds = [8.0, -4.0, -4.0];
let max = [10.0, 4.0, 4.0];
let culler = Culler::new([0.0; 3], 1_000);
assert!(!culler.occluded(bounds, max, |pos| {
pos[0] == 4 && !(pos[1] == 1 && pos[2] == 1)
}));
let exhausted = Culler::new([0.0; 3], 1);
assert!(!exhausted.occluded(bounds, max, |_| true));
}
#[test]
fn mesh_culling_keeps_geometry_before_the_entity_prefix() {
let prefix = vertex([0.0; 3]);
let mesh = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
let mut vertices = vec![prefix];
vertices.extend(mesh);
let hidden = Culler::new([0.0; 3], 1_000).cull_vertices(&mut vertices, 1, |pos| pos[0] == 4);
assert!(hidden);
assert_eq!(vertices.len(), 1);
assert_eq!(vertices[0].pos, prefix.pos);
}
+26 -9
View File
@@ -488,8 +488,8 @@ impl Game<'_> {
let sprites = &self.gui.atlas.sprites;
// Each with its frame's type: a pack tells a glow frame and what it holds apart by its
// `entityId` (`glow_item_frame` in entity.properties), the item drawn within its frame.
let mut maps: Vec<(i32, glam::Mat4, u32, &'static str)> = Vec::new();
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8, &'static str)> = Vec::new();
let mut maps: Vec<(i32, glam::Mat4, u32, bool, &'static str)> = Vec::new();
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8, bool, &'static str)> = Vec::new();
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
// of 1.
let range = 16.0 * 64.0 * self.view_scale();
@@ -513,6 +513,11 @@ impl Game<'_> {
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
let culling = self.entity_cull_decision(f, 1.0);
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
let s = f.step();
// Pose: to the block centre, then X/Y rotation by facing.
let (x_rot, y_rot) = if s[1] == 0 {
@@ -599,7 +604,8 @@ impl Game<'_> {
v.layer |= u32::from(horizon_client::mesher::INCANDESCENT) << 16;
}
}
self.cull_entity_vertices(out, entity_start);
self.finish_entity_occlusion(culling, f, out, entity_start);
let cull_contents = culling.checks_vertices();
if let Some(stack) = &f.item {
let mut canceled = false;
self.native_render_item_in_frame(f.id, f.pos, stack, &mut canceled);
@@ -621,7 +627,7 @@ impl Game<'_> {
* glam::DMat4::from_rotation_z(std::f64::consts::PI)
* glam::DMat4::from_scale(glam::DVec3::splat(0.0078125))
* glam::DMat4::from_translation(glam::DVec3::new(-64.0, -64.0, -1.0));
maps.push((id, m.as_mat4(), item_light, f.kind()));
maps.push((id, m.as_mat4(), item_light, cull_contents, f.kind()));
continue;
}
let Some(stack) = f.item.clone() else {
@@ -634,13 +640,20 @@ impl Game<'_> {
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
* glam::DMat4::from_rotation_z((f64::from(f.rotation) * 45.0).to_radians())
* glam::DMat4::from_scale(glam::DVec3::splat(0.5));
items.push((stack, f.id, m.as_mat4(), item_light, f.kind()));
items.push((
stack,
f.id,
m.as_mat4(),
item_light,
cull_contents,
f.kind(),
));
}
// `extractRenderState`: FIXED, seeded by the frame entity.
let skinning = self.skinning();
let lights =
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
for (stack, id, m, light, kind) in items {
for (stack, id, m, light, cull, kind) in items {
let entity_start = out.len();
self.mark_entity(entity_start, kind);
let owner = super::item_draw::owner_at([0.0; 3], 0.0, "minecraft:item_frame");
@@ -658,13 +671,17 @@ impl Game<'_> {
if skinned.is_none() {
self.submit_item_world(&state, m, light, out);
}
self.cull_skinned(out, entity_start, skinned);
if cull {
self.cull_skinned(out, entity_start, skinned);
}
}
for (id, m, light, kind) in maps {
for (id, m, light, cull, kind) in maps {
let entity_start = out.len();
self.mark_entity(entity_start, kind);
self.map_vertices(id, true, m, light, out);
self.cull_entity_vertices(out, entity_start);
if cull {
self.cull_entity_vertices(out, entity_start);
}
}
}
}
@@ -15,6 +15,7 @@ use horizon_client::{
items::ItemStack,
mesher::Vertex,
};
use horizon_core::geometry::{Aabb, Vec3};
use serde_json::Value;
use std::{cell::RefCell, collections::HashMap};
@@ -632,6 +633,34 @@ impl Game<'_> {
item_render::rendered_amount(item.stack.count),
seed,
);
// Use the same animated, spun, clustered transforms as the draw.
// ItemEntity's collision box is only 0.25 blocks and cannot prove
// that this larger rendered model stays behind an occluder.
let culling = entity.then(|| {
state
.posed_bounds(&copies)
.map(|(lo, hi)| {
self.entity_cull_decision_in_bounds(
item,
Aabb::new(
Vec3::new(
camera.x + f64::from(lo.x),
camera.y + f64::from(lo.y),
camera.z + f64::from(lo.z),
),
Vec3::new(
camera.x + f64::from(hi.x),
camera.y + f64::from(hi.y),
camera.z + f64::from(hi.z),
),
),
)
})
.unwrap_or_else(|| self.entity_cull_decision(item, f64::from(partial)))
});
if culling.is_some_and(|decision| decision.hides_entity()) {
continue;
}
let outline = self.thing_outline(item.id, "minecraft:item");
// Posed on the GPU when every layer can be, its silhouette too when it glows.
let skinned = skinning
@@ -648,6 +677,8 @@ impl Game<'_> {
let color = horizon_client::mobs::render::abgr(rgb);
self.skin_frame.borrow_mut().outline_since(first, color);
}
} else if let Some(culling) = culling {
self.finish_entity_occlusion(culling, item, out, entity_start);
} else {
self.cull_skinned(out, entity_start, skinned);
}
+55 -19
View File
@@ -497,7 +497,9 @@ fn mob_meta(
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
let kind = m.kind.id.as_str();
// The entries are the class's: a content mod's living type has its
// template's.
let kind = m.kind.class_id();
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, sprinting 0x08 (a dashing
// cat or ocelot), swimming 0x10 (a drowned under water,
// `Drowned.setSwimming`), invisible 0x20 (the effect's flag) and glowing
@@ -3644,14 +3646,63 @@ mod tests {
/// the raider celebrates.
#[test]
fn a_celebrating_raider_is_sent_by_mob_meta() {
use horizon_client::lan::{Lan, NetConfig, chunks::ChunkEncoder, data, hub};
let Some(mut pillager) = mob_of("minecraft:pillager") else {
return;
};
let hub = meta_hub("RaiderMetaTest");
let meta_of = |m: &Mob| {
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
let (meta, defaults) = meta_of(&pillager);
assert!(defaults.contains(&(16, serializer::BOOLEAN, Value::Bool(false))));
assert!(meta.iter().all(|e| e.0 != 16), "{meta:?}");
horizon_client::mobs::raider::state_mut(&mut pillager).celebrating = true;
let (meta, _) = meta_of(&pillager);
assert!(
meta.contains(&(16, serializer::BOOLEAN, Value::Bool(true))),
"{meta:?}"
);
}
/// A content mod's living type sends the entity data of its template's
/// class (`Zombie.DATA_BABY_ID`, the villager's unhappy counter), entry
/// for entry what its template sends.
#[test]
fn a_mod_living_type_sends_its_templates_entity_data() {
let types = super::super::mods::living_mod_types();
let hub = meta_hub("ModMobMetaTest");
let meta_of = |id: &str| {
let at = horizon_core::geometry::Vec3::new(0.5, 64.0, 0.5);
let mut m = Mob::new(1, types[id].clone(), at, 0.0);
m.extra.age = -24000;
// The same health: the ghoul's own maximum is 40, the zombie's 20.
m.health = 10.0;
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::mob_meta(&m, &hub, None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
for (template, id) in [
("minecraft:zombie", "ruby:ghoul"),
("minecraft:villager", "ruby:settler"),
] {
assert_eq!(
meta_of(id),
meta_of(template),
"{id} sends what {template} sends"
);
}
}
/// A hub with no players and empty registries, for `mob_meta`.
fn meta_hub(name: &str) -> horizon_client::lan::hub::Hub {
use horizon_client::lan::{Lan, NetConfig, chunks::ChunkEncoder, data, hub};
let sync = std::sync::Arc::new(data::SyncData::empty());
let config = NetConfig {
announce: false,
..NetConfig::lan(0, "RaiderMetaTest".into(), 2)
..NetConfig::lan(0, name.into(), 2)
};
let lan = Lan::start_with(sync.clone(), config).unwrap();
let encoder = ChunkEncoder::new(
@@ -3674,22 +3725,7 @@ mod tests {
integrated: false,
levels: Default::default(),
};
let hub = hub::Hub::new(lan, sync, encoder, hub_config);
let meta_of = |m: &Mob| {
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
let (meta, defaults) = meta_of(&pillager);
assert!(defaults.contains(&(16, serializer::BOOLEAN, Value::Bool(false))));
assert!(meta.iter().all(|e| e.0 != 16), "{meta:?}");
horizon_client::mobs::raider::state_mut(&mut pillager).celebrating = true;
let (meta, _) = meta_of(&pillager);
assert!(
meta.contains(&(16, serializer::BOOLEAN, Value::Bool(true))),
"{meta:?}"
);
hub::Hub::new(lan, sync, encoder, hub_config)
}
/// `LivingEntity.startUsingItem` sets flag 1 for the use and flag 2 for
@@ -7,6 +7,7 @@ use horizon_client::{
advancements as adv,
containers::Crafter,
hud::{HudBatch, rgba},
menu::{Maps as _, Menu},
};
use horizon_protocol::packets::play::{self, Serverbound as S};
use serde_json::Value;
@@ -92,14 +93,14 @@ impl Game<'_> {
return Ok(());
};
let grid: Vec<Option<ItemStack>> = crafter.items.to_vec();
// RecipeCache.get: the first crafting recipe that matches, assembled.
// Query active world recipes with this level's saved-map context.
let maps = self.level_maps(grid.iter().flatten());
let found = self
.res
.recipes()
.find(&grid, 3)
.find_with(&grid, 3, &|id| maps.state(id).map(|(_, scale)| scale))
.map(|(name, result)| (name.to_owned(), result))
.filter(|(_, result)| result.count > 0);
let Some((recipe, result)) = found else {
let Some((recipe, mut result)) = found else {
self.dispenser_event(1050, key, 0);
return Ok(());
};
@@ -120,6 +121,10 @@ impl Game<'_> {
true,
horizon_world::level::flags::UPDATE_CLIENTS,
);
// ItemStack.onCraftedBySystem invokes MapItem.onCraftedPostProcess
// before dispensing, registering the derived map under its new id.
Menu::map_crafted(Some(&maps), &mut result);
self.register_made_maps(maps);
// The remainders come from the grid as it was before anything moved.
let remainders = self.recipes().remainders(&recipe, &grid, 3);
self.crafter_eject(key, facing, result, &recipe);
@@ -182,7 +187,7 @@ impl Game<'_> {
(0..3).all(|i| (e[i] - c[i]).abs() <= 8.5 + 1.0)
});
if near {
let id = format!("minecraft:{recipe}");
let id = horizon_client::crafting::qualified(recipe);
let me = self.player_entity_for("minecraft:crafter_recipe_crafted");
self.trigger("minecraft:crafter_recipe_crafted", move |c, w| {
let ctx = adv::Entities {
+31 -2
View File
@@ -198,6 +198,24 @@ fn display_offset(c: &Minecart) -> f64 {
c.custom_offset
.map_or_else(|| default_display_offset(c.kind), f64::from)
}
/// World-space envelope of the cart model and its optional displayed block,
/// based on the same root position and display offset used by the renderer.
fn cart_render_bounds(position: Vec3, offset: f64, has_display: bool) -> Aabb {
let (bottom, top) = if has_display {
let display_shift = 0.75 * (offset - 8.0) / 16.0;
(
(position.y - 0.5).min(position.y + 0.375 - 0.4 + display_shift),
(position.y + 0.9).max(position.y + 0.375 + 0.4 + display_shift + 0.9),
)
} else {
(position.y - 0.5, position.y + 0.9)
};
Aabb::new(
Vec3::new(position.x - 1.0, bottom, position.z - 1.0),
Vec3::new(position.x + 1.0, top, position.z + 1.0),
)
}
/// A display state that is air shows nothing.
fn shown_unless_air(
state: Option<BlockStateId>,
@@ -2531,7 +2549,6 @@ impl Game<'_> {
// edge, scaled by the view scale.
let range = (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * self.view_scale();
for c in &self.carts {
let entity_start = out.len();
let Some(model) = self.cart_models.models.get(&c.kind) else {
continue;
};
@@ -2591,6 +2608,17 @@ impl Game<'_> {
pitch = (d.y.atan() * 73.0) as f32;
}
}
let render_position = Vec3::new(x + offset.x, y + offset.y, z + offset.z);
let has_display = shown.as_ref().is_some_and(Option::is_some)
|| (shown.is_none() && self.display_state(c).is_some());
let culling = self.entity_cull_decision_in_bounds(
c,
cart_render_bounds(render_position, display_offset(c), has_display),
);
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
// The per-entity jitter (offsetSeed) that keeps stacked carts from z-fighting.
let seed = i64::from(c.id).wrapping_mul(493_286_711);
let seed = seed
@@ -2667,7 +2695,7 @@ impl Game<'_> {
let m = base * glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
let poses = Poses::new(model);
horizon_client::mobs::render::draw(model, m, &poses, uv, 0xFFFF_FFFF, packed, 0, out);
self.cull_entity_vertices(out, entity_start);
self.finish_entity_occlusion(culling, c, out, entity_start);
}
}
/// The model of a block drawn by its block-entity renderer, as a chest
@@ -2700,6 +2728,7 @@ impl Game<'_> {
skull: &|_| None,
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
dynamic_visible: None,
};
self.res
.block_entity_render()
+100 -3
View File
@@ -232,14 +232,16 @@ fn register_mob_effects(mods: &[horizon_client::mod_content::ContentMod]) {
}
/// the mods' thrown entity types, as their mob effects: set once, from the
/// startup root.
/// startup root. Living types are mobs, loaded with vanilla's from the
/// content root's `entity-types.json`.
fn register_entity_types(mods: &[horizon_client::mod_content::ContentMod]) {
use horizon_client::projectiles;
let types = horizon_client::mod_content::entity_types(mods);
let mut types = horizon_client::mod_content::entity_types(mods);
types.retain(|t| t.item.is_some());
let set = projectiles::register_thrown_types(
types
.iter()
.map(|t| (t.id.clone(), t.item.clone(), t.damage)),
.filter_map(|t| Some((t.id.clone(), t.item.clone()?, t.damage))),
);
if !set
&& projectiles::thrown_types()
@@ -434,11 +436,106 @@ impl Game<'_> {
}
}
/// Mob types loaded from a scratch root whose `entity-types.json` has a
/// zombie, a villager, and the rows a content root writes for a mod's types
/// (`mod_content`): `ruby:ghoul` (a zombie with 40 health), `ruby:settler` (a
/// villager as it is) and `ruby:bolt` (a thrown snowball, not a mob).
#[cfg(test)]
pub(super) fn living_mod_types()
-> std::collections::HashMap<String, std::sync::Arc<horizon_client::mobs::MobType>> {
let zombie =
r#""width":0.6,"height":1.95,"eye_height":1.74,"category":"monster","fire_immune":false"#;
let villager =
r#""width":0.6,"height":1.95,"eye_height":1.62,"category":"misc","fire_immune":false"#;
let table = format!(
r#"{{"minecraft:zombie":{{{zombie},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.23000000417232513}}}},
"minecraft:villager":{{{villager},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.5}}}},
"minecraft:snowball":{{"width":0.25,"height":0.25,"eye_height":0.0,"category":"misc","fire_immune":false}},
"ruby:ghoul":{{{zombie},"attributes":{{"minecraft:max_health":40.0,"minecraft:movement_speed":0.23000000417232513}},"template":"minecraft:zombie"}},
"ruby:settler":{{{villager},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.5}},"template":"minecraft:villager"}},
"ruby:bolt":{{"width":0.25,"height":0.25,"eye_height":0.0,"category":"misc","fire_immune":false,"template":"minecraft:snowball"}}}}"#
);
let root =
std::env::temp_dir().join(format!("horizon-living-mod-types-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("entity-types.json"), table).unwrap();
let types = horizon_client::mobs::MobTypes::load_for_server(&root).unwrap();
let _ = std::fs::remove_dir_all(&root);
types
.types
.into_iter()
.map(|(id, t)| (id, std::sync::Arc::new(t)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
/// A content mod's living type is a mob of its template's species under
/// its own id, with its own attributes: it saves its id with every field
/// its template's class saves, loads back as itself, and rolls its own
/// loot table. A thrown type stays a projectile.
#[test]
fn a_mod_living_type_is_its_templates_mob_with_its_own_attributes() {
use horizon_client::mobs::{Mob, Species, drops::death_loot_table};
use horizon_core::geometry::Vec3;
let types = living_mod_types();
let (zombie, ghoul) = (&types["minecraft:zombie"], &types["ruby:ghoul"]);
assert_eq!(ghoul.species, Species::Zombie);
assert_eq!(ghoul.class_id(), "minecraft:zombie");
assert_eq!(types["ruby:settler"].species, Species::Villager);
assert!(!types.contains_key("ruby:bolt"), "a thrown type is no mob");
assert_eq!((zombie.max_health, ghoul.max_health), (20.0, 40.0));
assert_eq!(ghoul.speed.to_bits(), zombie.speed.to_bits());
assert_eq!(
(ghoul.sounds, ghoul.texture.as_str()),
(zombie.sounds, zombie.texture.as_str())
);
let at = Vec3::new(0.5, 64.0, 0.5);
let (zombie, ghoul) = (
Mob::new(1, zombie.clone(), at, 0.0),
Mob::new(2, ghoul.clone(), at, 0.0),
);
assert_eq!(ghoul.health, 40.0);
let saved = |m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None);
let keys = |tag: &horizon_world::nbt::Tag| -> Vec<String> {
let horizon_world::nbt::Tag::Compound(map) = tag else {
panic!("a mob saves a compound");
};
map.keys().map(|k| k.to_string_lossy()).collect()
};
let (zombie_tag, ghoul_tag) = (saved(&zombie), saved(&ghoul));
let Some(horizon_world::nbt::Tag::String(id)) = ghoul_tag.get("id") else {
panic!("a saved mob has its id");
};
assert_eq!(id.to_string_lossy(), "ruby:ghoul");
assert_eq!(
keys(&ghoul_tag),
keys(&zombie_tag),
"it saves what a zombie saves"
);
for key in ["IsBaby", "CanBreakDoors", "DrownedConversionTime"] {
assert!(keys(&ghoul_tag).iter().any(|k| k == key), "{key}");
}
let loaded = horizon_client::entity_nbt::mob_from_nbt(&ghoul_tag, &types, 3, None)
.expect("it loads back");
assert_eq!(
(loaded.kind.id.as_str(), loaded.health),
("ruby:ghoul", 40.0)
);
assert_eq!(
death_loot_table(&ghoul, true).as_deref(),
Some("ruby:entities/ghoul")
);
assert_eq!(
death_loot_table(&zombie, true).as_deref(),
Some("minecraft:entities/zombie")
);
}
/// Only a build whose `native-mods.json` lists a mod counts as built with mods.
#[test]
fn only_a_listed_mod_makes_a_build_a_mod_build() {
+7 -5
View File
@@ -547,12 +547,14 @@ impl Game<'_> {
const BACKGROUND: u32 = 0x3F00_0000;
// The camera's facing (Camera.rotation()), turned round in third-person
// front view, so the text reads the right way from where it is seen.
let (view_yaw, view_pitch) =
if self.camera_type == super::perspective::CameraType::ThirdPersonFront {
let (view_yaw, view_pitch) = match self.posed_rotation {
// The frame's camera, already turned round in front view.
Some(rotation) => rotation,
None if self.camera_type == super::perspective::CameraType::ThirdPersonFront => {
(self.yaw + 180.0, -self.pitch)
} else {
(self.yaw, self.pitch)
};
}
None => (self.yaw, self.pitch),
};
let forward = glam::DVec3::from_array(view_vector(view_yaw, view_pitch));
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
let up = right.cross(forward).normalize_or_zero();
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,399 @@
use super::*;
use crate::game::entity::RenderEntityBounds;
use horizon_client::chunk::ColumnLayout;
use horizon_client::mobs::{Mob, MobData, MobTypes, Species, render};
use horizon_core::geometry::Vec3;
use std::path::Path;
#[test]
fn vanilla_models_and_animations_stay_inside_the_precheck_envelope() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
eprintln!("no prepared resources: skipped model coverage");
return;
}
let types = MobTypes::load(&root).unwrap();
let data = MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: MobData::load_animations(&root),
villager_hats: Default::default(),
cem: Default::default(),
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
biome_tags: Default::default(),
entity_tags: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
let camera = glam::DVec3::new(3.0, 62.0, -4.0);
let mut checked = 0;
for kind in types.types.values() {
for sample in 0..12 {
let mut mob = Mob::new(1, Arc::new(kind.clone()), Vec3::new(1.5, 64.0, 3.25), 37.0);
mob.previous = Vec3::new(1.25, 64.25, 3.0);
mob.age = sample * 37;
mob.limb_speed = 0.8;
mob.limb_position = sample as f32 * 1.7;
mob.head_pitch = if sample % 2 == 0 { 85.0 } else { -85.0 };
mob.extra.age = if sample >= 6 { -100 } else { 0 };
mob.death_time = if sample % 3 == 0 { 10 } else { 0 };
mob.extra.flag = sample % 2 == 0;
mob.extra.size = match mob.species() {
Species::Slime | Species::MagmaCube | Species::SulfurCube | Species::Phantom => {
if sample % 2 == 0 {
1
} else {
4
}
}
_ => mob.extra.size,
};
if sample % 3 == 1 {
mob.custom_name = Some("Dinnerbone".into());
}
for partial in [0.0, 0.4, 1.0] {
let bounds = mob.render_bounds(f64::from(partial));
let lo = bounds.min_position();
let hi = bounds.max_position();
let lo = [lo.x, lo.y, lo.z];
let hi = [hi.x, hi.y, hi.z];
let mut vertices = Vec::new();
render::render_with(
&mob,
partial,
camera,
&|_| Some([0.0, 0.0, 1.0, 1.0]),
0xF0F0,
&data,
&[],
&mut vertices,
);
for vertex in &vertices {
for axis in 0..3 {
let world = f64::from(vertex.pos[axis]) + camera[axis];
assert!(
world >= lo[axis] - 1.0e-4 && world <= hi[axis] + 1.0e-4,
"{} sample {sample} partial {partial}: {world} outside {lo:?}..{hi:?}",
kind.id,
);
}
}
checked += usize::from(!vertices.is_empty());
}
}
}
assert!(
checked > 1_000,
"insufficient real model coverage: {checked}"
);
}
#[test]
fn a_hole_inside_a_coarse_envelope_remains_visible() {
let camera = [0.0; 3];
let min = [7.95, -3.0, -3.0];
let max = [10.05, 3.0, 3.0];
assert!(OcclusionCuller::new(camera, 8192).occluded(min, max, |p| p[0] == 4));
assert!(
!OcclusionCuller::new(camera, 8192).occluded(min, max, |p| p[0] == 4 && p != [4, 1, 0])
);
assert!(!OcclusionCuller::new(camera, 0).occluded(min, max, |p| p[0] == 4));
}
fn ray_wall() -> (Arc<OcclusionSnapshot>, Vec<Arc<Chunk>>) {
let mut live = Vec::new();
let mut chunks = Vec::new();
for chunk_z in [-1, 0] {
let mut chunk = Chunk::with_layout(0, chunk_z, ColumnLayout::VANILLA);
for y in -1..=0 {
for z in -1..=0 {
chunk.set(4, y, z & 15, 1);
}
}
let chunk = Arc::new(chunk);
chunks.push(((0, chunk_z), Arc::downgrade(&chunk)));
live.push(chunk);
}
chunks.sort_unstable_by_key(|(key, _)| *key);
(
Arc::new(OcclusionSnapshot {
chunks,
opaque_states: Arc::from([false, true]),
}),
live,
)
}
#[test]
fn hidden_cache_entries_require_the_same_camera_and_bounds() {
let mut culler = AsyncOcclusionCuller::new().unwrap();
culler.query_cache_camera_cell = Some([0; 3]);
let bounds = Aabb::new(Vec3::new(8.0, -1.0, -1.0), Vec3::new(10.0, 1.0, 1.0));
let original = culler.query_entity_bounds(bounds).unwrap();
culler.query_results.push(CachedQueryResult {
key: original,
stamp: culler.stamp,
camera_cell: Some([0; 3]),
proof: Some(OcclusionProof {
sections: Vec::new(),
chunks: Vec::new(),
validated_epoch: Cell::new(0),
}),
last_used_frame: Cell::new(0),
});
assert!(matches!(
culler.query_result(&original, QuerySource::EntityAabb),
Some((true, _))
));
let moved = culler
.query_entity_bounds(bounds.move_by(Vec3::new(0.26, 0.0, 0.0)))
.unwrap();
assert!(
culler
.query_result(&moved, QuerySource::EntityAabb)
.is_none()
);
culler.camera[0] = 0.01;
let same_subcell_camera = culler.query_entity_bounds(bounds).unwrap();
assert!(
culler
.query_result(&same_subcell_camera, QuerySource::EntityAabb)
.is_none()
);
culler.camera[0] = 0.26;
culler.query_cache_camera_cell = camera_cell(culler.camera);
let moved_camera = culler.query_entity_bounds(bounds).unwrap();
assert!(
culler
.query_result(&moved_camera, QuerySource::EntityAabb)
.is_none()
);
}
#[test]
fn camera_reuse_uses_quarter_block_cells() {
assert_eq!(camera_cell([0.249, 0.0, -0.001]), Some([0, 0, -1]));
assert_eq!(camera_cell([0.25, 0.0, 0.0]), Some([1, 0, 0]));
assert_eq!(camera_cell([-0.25, 0.0, 0.0]), Some([-1, 0, 0]));
}
#[test]
fn stale_world_results_cannot_replace_a_current_proof_in_the_same_camera_cell() {
let mut culler = AsyncOcclusionCuller::new().unwrap();
culler.query_cache_camera_cell = Some([0; 3]);
culler.stamp = SnapshotKey {
world_revision: 2,
chunks_revision: 4,
bottom_y: 0,
};
let bounds = Aabb::new(Vec3::new(8.0, -1.0, -1.0), Vec3::new(10.0, 1.0, 1.0));
let current_key = culler.query_entity_bounds(bounds).unwrap();
culler.query_results.push(CachedQueryResult {
key: current_key,
stamp: culler.stamp,
camera_cell: Some([0; 3]),
proof: Some(OcclusionProof {
sections: Vec::new(),
chunks: Vec::new(),
validated_epoch: Cell::new(0),
}),
last_used_frame: Cell::new(0),
});
culler.camera[0] = 0.1;
let stale_key = culler.query_entity_bounds(bounds).unwrap();
let mut stale = CullResults {
stamp: SnapshotKey {
world_revision: 1,
chunks_revision: 4,
bottom_y: 0,
},
camera: stale_key.camera,
generation: 0,
cancelled: false,
hidden: vec![(
stale_key,
OcclusionProof {
sections: Vec::new(),
chunks: Vec::new(),
validated_epoch: Cell::new(0),
},
)],
visible: Vec::new(),
scratch_queries: Vec::new(),
worker_ns: 0,
hidden_entities: 1,
visible_entities: 0,
deferred_entities: 0,
};
culler.cache_query_results(&mut stale);
let index = culler
.query_results
.binary_search_by_key(&query_bounds_key(&current_key), |entry| {
query_bounds_key(&entry.key)
})
.unwrap();
assert_eq!(
culler.query_results[index].stamp.world_revision, 2,
"the stale result must not replace the current proof"
);
}
#[test]
fn stale_camera_visible_results_are_discarded_for_a_fresh_query() {
let mut culler = AsyncOcclusionCuller::new().unwrap();
culler.stamp = SnapshotKey {
world_revision: 2,
chunks_revision: 4,
bottom_y: 0,
};
culler.camera = [0.1, 0.0, 0.0];
let key = culler
.query_entity_bounds(Aabb::new(
Vec3::new(8.0, -1.0, -1.0),
Vec3::new(10.0, 1.0, 1.0),
))
.unwrap();
culler.camera = [0.3, 0.0, 0.0];
culler.query_cache_camera_cell = Some([1, 0, 0]);
let mut current_key = key;
current_key.camera = culler.camera.map(f64::to_bits);
let mut results = CullResults {
stamp: culler.stamp,
camera: key.camera,
generation: 0,
cancelled: false,
hidden: Vec::new(),
visible: vec![key],
scratch_queries: Vec::new(),
worker_ns: 0,
hidden_entities: 0,
visible_entities: 1,
deferred_entities: 0,
};
culler.cache_results_for_camera(&mut results, current_key.camera);
assert!(results.visible.is_empty());
assert!(results.hidden.is_empty());
assert!(
culler
.query_result(&current_key, QuerySource::EntityAabb)
.is_none()
);
}
#[test]
fn camera_relative_bounds_are_rebased_before_querying() {
let mut culler = AsyncOcclusionCuller::new().unwrap();
culler.camera = [10.25, -4.5, 7.75];
let key = culler
.query_camera_bounds(
glam::Vec3::new(2.0, 4.0, -1.0),
glam::Vec3::new(-2.0, -1.0, 3.0),
)
.unwrap();
assert_eq!(key.min.map(f64::from_bits), [8.25, -5.5, 6.75]);
assert_eq!(key.max.map(f64::from_bits), [12.25, -0.5, 10.75]);
assert_eq!(culler.stats_value()["entity_vertices_scanned"], 2);
}
#[test]
fn resetting_benchmark_counters_preserves_the_profiling_choice() {
let culler = AsyncOcclusionCuller::new().unwrap();
for enabled in [false, true] {
culler.measure_bounds_timing.set(enabled);
culler.reset_stats();
assert_eq!(culler.stats_value()["profiling_enabled"], enabled);
}
}
#[test]
fn inactive_answers_expire_while_frequently_used_proofs_stay_cached() {
let mut culler = AsyncOcclusionCuller::new().unwrap();
let bounds = |x| Aabb::new(Vec3::new(x, -1.0, -1.0), Vec3::new(x + 1.0, 1.0, 1.0));
let hot = culler.query_entity_bounds(bounds(8.0)).unwrap();
let cold = culler.query_entity_bounds(bounds(12.0)).unwrap();
let fresh = culler.query_entity_bounds(bounds(16.0)).unwrap();
for key in [hot, cold] {
culler.query_results.push(CachedQueryResult {
key,
stamp: culler.stamp,
camera_cell: Some([0; 3]),
proof: Some(OcclusionProof {
sections: Vec::new(),
chunks: Vec::new(),
validated_epoch: Cell::new(0),
}),
last_used_frame: Cell::new(0),
});
}
culler.frame = 10;
assert!(matches!(
culler.query_result(&hot, QuerySource::EntityAabb),
Some((true, _))
));
culler.cache_query_results(&mut CullResults {
stamp: culler.stamp,
camera: culler.camera.map(f64::to_bits),
generation: 0,
cancelled: false,
hidden: Vec::new(),
visible: vec![fresh],
scratch_queries: Vec::new(),
worker_ns: 0,
hidden_entities: 0,
visible_entities: 1,
deferred_entities: 0,
});
assert!(matches!(
culler.query_result(&hot, QuerySource::EntityAabb),
Some((true, _))
));
assert!(
culler
.query_result(&cold, QuerySource::EntityAabb)
.is_none()
);
assert!(matches!(
culler.query_result(&fresh, QuerySource::EntityAabb),
Some((false, _))
));
}
#[test]
fn entity_ray_walks_negative_axes_without_skipping_an_opaque_cell() {
let (snapshot, live) = ray_wall();
let mut last_chunk = LastChunk::default();
let mut opacity_grid = OpacityGrid::default();
assert!(!ray_reaches_without_opaque(
[8.0, 0.0, 0.0],
[0.0, 0.0, 0.0],
&snapshot,
&mut last_chunk,
&mut opacity_grid,
));
drop(live);
}
#[test]
fn entity_ray_fails_open_when_the_traversal_budget_is_exhausted() {
let chunk = Chunk::with_layout(0, 0, ColumnLayout::VANILLA);
let chunk = Arc::new(chunk);
let snapshot = OcclusionSnapshot {
chunks: vec![((0, 0), Arc::downgrade(&chunk))],
opaque_states: Arc::from([false, true]),
};
let snapshot = Arc::new(snapshot);
let mut last_chunk = LastChunk::default();
let mut opacity_grid = OpacityGrid::default();
assert!(ray_reaches_without_opaque(
[0.5, 0.5, 0.5],
[f64::from(MAX_OCCLUSION_RAY_STEPS as u32) + 1.5, 0.5, 0.5],
&snapshot,
&mut last_chunk,
&mut opacity_grid,
));
}
@@ -424,6 +424,11 @@ impl Game<'_> {
self.confirm_trusted_link(ACCESSIBILITY_HELP_LINK, Screen::Options)
}
Event::Narrator => self.narrator_status_changed(),
Event::OpenStudioFolder => {
let dir = std::path::PathBuf::from("recordings");
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
}
}
Ok(())
+8 -4
View File
@@ -2,7 +2,7 @@
//! variant choice (or the stack's `painting/variant`), `HangingEntity.survives`
//! checks every 100 ticks, breaking by attack (`dropItem`), saving, and
//! `PaintingRenderer` quads.
use super::{Game, Hit};
use super::{Game, Hit, entity_culling::default_range};
use horizon_client::{entities::Painting, items::ItemStack, mesher::Vertex};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -351,15 +351,18 @@ impl Game<'_> {
return;
};
for p in &self.paintings {
let entity_start = out.len();
// EntityRenderer.shouldRender: the default range and its box,
// measured from the box's centre (HangingEntity's position).
let (lo, hi) = p.bounds();
let (lo, hi) = (glam::DVec3::from(lo), glam::DVec3::from(hi));
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
let range = default_range(hi - lo, self.view_scale());
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
let culling = self.entity_cull_decision(p, 1.0);
if culling.hides_entity() {
continue;
}
let Some(v) = self.painting_variants.get(&p.variant) else {
continue;
};
@@ -383,6 +386,7 @@ impl Game<'_> {
f64::from(p.height) / 2.0,
1.0 / 32.0,
);
let entity_start = out.len();
let light = self.world.light(p.pos[0], p.pos[1], p.pos[2]);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let mut quad =
@@ -486,7 +490,7 @@ impl Game<'_> {
strip,
0.6,
);
self.cull_entity_vertices(out, entity_start);
self.finish_entity_occlusion(culling, p, out, entity_start);
}
}
}
+3
View File
@@ -9305,6 +9305,9 @@ impl Game<'_> {
return;
}
if let Some(command) = message.strip_prefix('/') {
if self.studio_command(command) {
return;
}
self.send_command(command);
} else {
self.send_signed_chat(message);
@@ -622,6 +622,11 @@ impl Loading {
| Event::ListMusicTracks
| Event::PlayMusicTrack(..)
| Event::Volume(..) => {}
Event::OpenStudioFolder => {
let dir = std::path::PathBuf::from("recordings");
let _ = std::fs::create_dir_all(&dir);
packs::open_folder(&dir);
}
}
}
}
+179
View File
@@ -0,0 +1,179 @@
//! Studio recording commands.
use super::Game;
use horizon_client::{
Result,
studio::{Metadata, generate_recording_filename},
text::Text,
};
use serde_json::{Value, json};
use std::path::PathBuf;
pub const HELP: &[&str] = &[
"studio record start [NAME] start recording clientbound packets into an .mcpr archive",
"studio record stop stop recording and finalize the archive",
"studio record status show recording elapsed time, packets, and size",
];
fn studio_recording_metadata(is_singleplayer: bool) -> Metadata {
let server_name = if is_singleplayer {
"Singleplayer World"
} else {
"Multiplayer Server"
};
Metadata::new(
server_name.into(),
is_singleplayer,
horizon_core::release::VERSION.into(),
)
}
impl Game<'_> {
/// Handles Horizon's `/studio record` command without intercepting server
/// commands that merely share its prefix.
pub(super) fn studio_command(&mut self, command: &str) -> bool {
let Some(rest) = command.strip_prefix("studio") else {
return false;
};
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
return false;
}
let words: Vec<&str> = rest.split_whitespace().collect();
if words.first().copied() != Some("record") {
self.system_message(Text::translate("horizon.studio.record.usage", &[]));
return true;
}
match words.get(1).copied() {
Some("start") => match self.start_studio_recording(words.get(2).copied()) {
Ok(v) if v["ok"].as_bool() == Some(true) => {
let path = v["path"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.started", &[&path]));
}
Ok(v) => {
let error = v["error"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
Err(error) => {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
},
Some("stop") => match self.stop_studio_recording() {
Ok(v) if v["ok"].as_bool() == Some(true) => {
let path = v["path"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.saved", &[&path]));
}
Ok(v) => {
let error = v["error"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
Err(error) => {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
},
Some("status") => {
let status = self.studio_recording_status();
if status["active"].as_bool() == Some(true) {
let elapsed = (status["elapsed_ms"].as_u64().unwrap_or(0) / 1000).to_string();
let packets = status["packet_count"].as_u64().unwrap_or(0).to_string();
let size = (status["bytes_written"].as_u64().unwrap_or(0) / 1024).to_string();
let path = status["path"].as_str().unwrap_or_default();
self.system_message(Text::tr(
"horizon.studio.record.status.active",
&[&elapsed, &packets, &size, &path],
));
} else if let Some(error) = status["error"].as_str() {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
} else {
self.system_message(Text::translate(
"horizon.studio.record.status.inactive",
&[],
));
}
}
_ => self.system_message(Text::translate("horizon.studio.record.usage", &[])),
}
true
}
/// Control socket command handler for `studio record`.
pub(super) fn studio_control(&mut self, words: &[&str]) -> Result<Option<Value>> {
if words.first().copied() != Some("studio") {
return Ok(None);
}
let value = match words.get(1).copied() {
Some("record") => match words.get(2).copied() {
Some("start") => self.start_studio_recording(words.get(3).copied())?,
Some("stop") => self.stop_studio_recording()?,
Some("status") => self.studio_recording_status(),
_ => json!({ "error": "unknown studio record subcommand" }),
},
_ => json!({ "error": "unknown studio command" }),
};
Ok(Some(value))
}
pub(super) fn start_studio_recording(&mut self, name: Option<&str>) -> Result<Value> {
let Some(remote) = self.remote.as_ref() else {
return Ok(json!({ "ok": false, "error": "no active world session" }));
};
if remote.session.is_recording() {
return Ok(json!({ "ok": false, "error": "recording already active" }));
}
let recordings = PathBuf::from("recordings");
std::fs::create_dir_all(&recordings)?;
let path = recordings.join(generate_recording_filename(name));
let is_singleplayer =
remote.session.address.is_empty() || remote.session.address == "local";
let metadata = studio_recording_metadata(is_singleplayer);
match remote.session.start_recording(&path, metadata) {
Ok(()) => Ok(json!({ "ok": true, "path": path.display().to_string() })),
Err(error) => Ok(json!({ "ok": false, "error": error.to_string() })),
}
}
pub(super) fn stop_studio_recording(&mut self) -> Result<Value> {
let Some(remote) = self.remote.as_ref() else {
return Ok(json!({ "ok": false, "error": "no active world session" }));
};
match remote.session.stop_recording() {
Some(Ok(path)) => Ok(json!({ "ok": true, "path": path.display().to_string() })),
Some(Err(error)) => Ok(json!({ "ok": false, "error": error.to_string() })),
None => Ok(json!({ "ok": false, "error": "no active recording" })),
}
}
pub(super) fn studio_recording_status(&self) -> Value {
if let Some(remote) = self.remote.as_ref()
&& let Some(status) = remote.session.recorder_status()
{
json!({
"ok": true,
"active": status.active,
"path": status.path.display().to_string(),
"elapsed_ms": status.elapsed.as_millis(),
"packet_count": status.packet_count,
"bytes_written": status.bytes_written,
"error": status.error,
})
} else {
json!({ "ok": true, "active": false, "error": null })
}
}
}
#[cfg(test)]
mod tests {
use super::studio_recording_metadata;
#[test]
fn recording_metadata_does_not_store_server_addresses_or_player_names() {
let multiplayer = studio_recording_metadata(false);
assert_eq!(multiplayer.server_name, "Multiplayer Server");
assert!(multiplayer.players.is_empty());
let singleplayer = studio_recording_metadata(true);
assert_eq!(singleplayer.server_name, "Singleplayer World");
assert!(singleplayer.players.is_empty());
}
}
+57
View File
@@ -241,6 +241,27 @@ impl ItemRenderState {
(lo, hi)
}
}
/// Bounds of the actual submitted item geometry under each world pose.
/// Used for dropped-item occlusion because its collision box omits the
/// render bob, model size, and copies in a stacked item cluster.
pub fn posed_bounds(&self, poses: &[Mat4]) -> Option<(Vec3, Vec3)> {
let mut lo = Vec3::splat(f32::INFINITY);
let mut hi = Vec3::splat(f32::NEG_INFINITY);
for &pose in poses {
for layer in &self.layers {
let transform = pose * layer.matrix;
for &point in layer.extents.iter() {
let point = transform.transform_point3(point);
if !point.is_finite() {
return None;
}
lo = lo.min(point);
hi = hi.max(point);
}
}
}
(lo.x <= hi.x && lo.y <= hi.y && lo.z <= hi.z).then_some((lo, hi))
}
/// `ItemStackRenderState.submit` under `pose`.
pub fn submit(
&self,
@@ -1151,6 +1172,42 @@ pub fn cluster_poses(state: &ItemRenderState, pose: Mat4, amount: u32, seed: i64
mod tests {
use super::*;
#[test]
fn posed_item_bounds_include_each_cluster_transform() {
let state = ItemRenderState {
display: DisplayContext::Ground,
layers: vec![LayerRender {
geometry: LayerGeometry::Special {
model: SpecialModel {
kind: horizon_assets::item_model::SpecialKind::Trident,
},
argument: Value::Null,
},
tints: Vec::new(),
foil: FoilType::None,
matrix: Mat4::from_translation(Vec3::new(0.0, 2.0, 0.0)),
particle: String::new(),
uses_block_light: false,
extents: Arc::new(vec![
Vec3::new(-1.0, -0.5, -0.25),
Vec3::new(0.5, 1.0, 0.25),
]),
}],
oversized_in_gui: false,
animated: false,
cit_glint: None,
hdr_tier: 0,
hdr_light: 0,
};
let poses = [
Mat4::from_translation(Vec3::new(2.0, 0.0, 0.0)),
Mat4::from_translation(Vec3::new(-1.0, 0.0, 0.0)),
];
let (lo, hi) = state.posed_bounds(&poses).expect("item model has vertices");
assert_eq!(lo, Vec3::new(-2.0, 1.5, -0.25));
assert_eq!(hi, Vec3::new(2.5, 3.0, 0.25));
}
/// One frame's glint is read once and shared: every item drawn from the
/// same `GlintFrame` scrolls by the same phase, and without the atlas
/// sprite no item gets a glint (`TextureTransform.setupGlintTexturing`
+10 -11
View File
@@ -1279,10 +1279,9 @@ impl Menu {
};
}
/// `Item.onCraftedBy` of a taken cartography result
/// (`MapItem.onCraftedPostProcess`): the post processing goes, and on a
/// level with maps the stack names the scaled or locked copy.
fn map_crafted(env: &Env, stack: &mut ItemStack) {
/// `MapItem.onCraftedPostProcess`, shared by menu takes and system crafting:
/// remove the directive and name the level's scaled or locked saved copy.
pub fn map_crafted(maps: Option<&dyn Maps>, stack: &mut ItemStack) {
if &*stack.item != "minecraft:filled_map" {
return;
}
@@ -1291,7 +1290,7 @@ impl Menu {
};
stack.data_mut().map_post_processing = None;
let id = stack.data().and_then(|d| d.map_id);
if let (Some(maps), Some(id)) = (env.maps, id)
if let (Some(maps), Some(id)) = (maps, id)
&& let Some(new) = maps.derive(id, post == 1)
{
stack.data_mut().map_id = Some(new);
@@ -1972,7 +1971,7 @@ impl Menu {
let mut result = self.try_remove(env, s, amount, max_amount)?;
self.on_take(env, s, &result);
if self.slots[s].role == Role::CartoResult {
Self::map_crafted(env, &mut result);
Self::map_crafted(env.maps, &mut result);
}
Some(result)
}
@@ -2383,7 +2382,7 @@ impl Menu {
after_take = true;
if index == 2 {
// onCraftedBy edits the result in its slot first.
Self::map_crafted(env, &mut stack);
Self::map_crafted(env.maps, &mut stack);
self.containers[RESULT][0] = Some(stack.clone());
if !self.move_to(env, &mut stack, 3, 39, true) {
return None;
@@ -2850,7 +2849,7 @@ impl Menu {
copy.count = Self::max_stack(env, stack);
// The cartography result's safeClone runs onCraftedBy.
if self.slots[slot_index as usize].role == Role::CartoResult {
Self::map_crafted(env, &mut copy);
Self::map_crafted(env.maps, &mut copy);
}
self.carried = Some(copy);
}
@@ -2926,7 +2925,7 @@ impl Menu {
self.on_take(env, s, &taken);
if self.slots[s].role == Role::CartoResult {
// onTake's onCraftedBy edits the carried stack.
Self::map_crafted(env, &mut taken);
Self::map_crafted(env.maps, &mut taken);
self.carried = Some(taken);
}
}
@@ -2960,7 +2959,7 @@ impl Menu {
self.on_take(env, s, &taken);
if self.slots[s].role == Role::CartoResult {
// onCraftedBy edits the taken stack, not the carried one.
Self::map_crafted(env, &mut taken);
Self::map_crafted(env.maps, &mut taken);
}
}
} else {
@@ -2989,7 +2988,7 @@ impl Menu {
if self.slots[target].role == Role::CartoResult
&& let Some(stack) = self.containers[PLAYER][inv_slot].as_mut()
{
Self::map_crafted(env, stack);
Self::map_crafted(env.maps, stack);
}
}
}
+28 -2
View File
@@ -430,6 +430,18 @@ impl MobType {
pub fn layer(&self, key: &str) -> Option<&Arc<Part>> {
self.layers.get(key)
}
/// The vanilla entity type whose class this type's mobs are: the type
/// itself, or the template a content mod's living type copies. What a
/// class saves and syncs of its own (`Zombie.addAdditionalSaveData`, its
/// entity data) follows it; the saved and sent id stays [`Self::id`].
pub fn class_id(&self) -> &str {
if self.id.starts_with("minecraft:") {
&self.id
} else {
species::vanilla_id(self.species)
}
}
}
/// Each type's default attribute values, parsed exactly (serde_json's
/// default float parsing can miss the nearest double by one ulp).
@@ -520,8 +532,22 @@ impl MobTypes {
.collect(),
);
let mut out = HashMap::new();
for def in species::ALL {
let id = format!("minecraft:{}", def.name);
// A content mod's living type (its row names a vanilla `template`) is
// its template's mob under its own id: the template's model, sounds
// and behaviour, with the attributes its row has.
let mod_types = types
.as_object()
.into_iter()
.flatten()
.filter_map(|(id, row)| {
let template = row["template"].as_str()?.strip_prefix("minecraft:")?;
let def = species::ALL.iter().find(|d| d.name == template)?;
Some((id.clone(), def))
});
let vanilla = species::ALL
.iter()
.map(|def| (format!("minecraft:{}", def.name), def));
for (id, def) in vanilla.chain(mod_types) {
let t = &types[&id];
if t.is_null() {
continue;
+50
View File
@@ -688,6 +688,7 @@ pub fn tick(
farm::chicken_egg_step(m, events);
}
if m.species() == Species::Allay {
super::allay::passive_heal(m);
super::allay::update_duplication_cooldown(m);
}
if horse {
@@ -698,6 +699,10 @@ pub fn tick(
if m.species() == Species::Ravager {
super::ravager::ai_step(m, w, events);
}
if m.species() == Species::Pufferfish {
// Pufferfish.aiStep's mob contacts run even when serverAiStep is skipped.
aquatic::sting_touching_mobs(m, w, events);
}
} else {
ai_step(m, w, level_random, events);
}
@@ -4792,6 +4797,51 @@ mod iron_golem_push_tests {
}
}
#[cfg(test)]
mod allay_healing_tests {
use super::*;
use std::sync::Arc;
#[test]
fn allays_heal_on_tenth_ticks_without_enabling_noai_work() {
let ran = crate::mobs::world::with_test_world(&[], |w, types| {
for no_ai in [true, false] {
for initial in [10.0_f32, 19.5, 20.0] {
let kind = Arc::new(types.types["minecraft:allay"].clone());
let mut mob = Mob::new(1, kind, Vec3::new(16.5, 101.0, 16.5), 0.0);
mob.no_ai = no_ai;
mob.no_gravity = true;
mob.persistent = true;
mob.health = initial;
mob.extra.ints[2] = 37;
mob.extra.ints[3] = 41;
mob.extra.egg_time = 30;
assert_eq!(mob.attrs.max_health, 20.0);
assert_eq!(mob.age, 0);
let mut random = LegacyRandom::new(0);
let mut events = Vec::new();
for elapsed in 1..=20 {
assert!(tick(&mut mob, w, &mut random, &mut events));
assert_eq!(mob.age, elapsed);
assert_eq!(
mob.health,
(initial + (elapsed / 10) as f32).min(20.0),
"NoAI={no_ai}, initial={initial}, tick={elapsed}"
);
assert_eq!(mob.extra.egg_time, 30 - elapsed as i32);
let skipped = if no_ai { 0 } else { elapsed as i32 };
assert_eq!(mob.extra.ints[2], 37 - skipped);
assert_eq!(mob.extra.ints[3], 41 - skipped);
}
}
}
});
if !ran {
eprintln!("Prepared mob data unavailable; Allay tick coverage did not run");
}
}
}
#[cfg(test)]
mod ravager_noai_tests {
use super::*;
+7 -3
View File
@@ -415,9 +415,7 @@ pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>
}
/// `Allay.aiStep` / `tick`: healing, cooldowns, the hold and spin clocks.
pub fn ai_step(m: &mut Mob) {
if m.alive() && m.age.is_multiple_of(10) {
m.heal(1.0);
}
passive_heal(m);
for i in [2, 3] {
if m.extra.ints[i] > 0 {
m.extra.ints[i] -= 1;
@@ -426,6 +424,12 @@ pub fn ai_step(m: &mut Mob) {
update_duplication_cooldown(m);
animate(m);
}
/// `Allay.aiStep`: server-side healing runs independently of the AI gate.
pub fn passive_heal(m: &mut Mob) {
if m.alive() && m.age.is_multiple_of(10) {
m.heal(1.0);
}
}
/// `Allay.updateDuplicationCooldown`: the server decrements positive counters,
/// independently of the AI goal gate.
pub fn update_duplication_cooldown(m: &mut Mob) {
+54 -1
View File
@@ -399,7 +399,7 @@ pub fn not_scary_for_pufferfish(s: Species) -> bool {
/// not tested), and each one takes `Pufferfish.touch`: `doTeamsAllowDamage`,
/// then a `mobAttack` hit for `getPuffState() + 1`. The poison and the sting
/// sound follow a hit that landed, which only the game layer knows.
fn sting_touching_mobs(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
pub(super) fn sting_touching_mobs(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if !m.alive() || m.extra.size <= 0 {
return;
}
@@ -1227,8 +1227,61 @@ mod school_tests {
mod pufferfish_sting_tests {
use super::*;
use crate::mobs::world::with_test_world;
use horizon_core::random::LegacyRandom;
use std::sync::Arc;
#[test]
fn mob_tick_dispatches_puffed_contacts_with_or_without_ai() {
with_test_world(&[], |w, types| {
let target = Mob::new(
2,
Arc::new(types.types["minecraft:pig"].clone()),
Vec3::new(0.5, 64.0, 0.5),
0.0,
);
let others = [target.seen(None)];
let world = MobWorld {
others: &others,
..w.clone()
};
for no_ai in [true, false] {
for puff in [0, 2] {
let mut fish = Mob::new(
1,
Arc::new(types.types["minecraft:pufferfish"].clone()),
Vec3::new(0.5, 64.0, 0.5),
0.0,
);
fish.no_ai = no_ai;
fish.no_gravity = true;
fish.persistent = true;
fish.extra.size = puff;
let mut events = Vec::new();
assert!(fish.tick(&world, &mut LegacyRandom::new(0), &mut events));
let stings: Vec<_> = events
.iter()
.filter_map(|event| match event {
MobEvent::PufferfishSting {
target,
amount,
source,
poison_ticks,
..
} => Some((*target, *amount, *poison_ticks, source.attacker)),
_ => None,
})
.collect();
let expected = if puff == 2 {
vec![(2, 3.0, 120, Some(Who::Mob(1)))]
} else {
Vec::new()
};
assert_eq!(stings, expected, "NoAI={no_ai}, puff={puff}");
}
}
});
}
/// What a pufferfish puffed to `puff` stings in one `ai_step`, as
/// `(target, damage)`, with `others` standing `x` blocks east of it.
fn stings(puff: i32, others: &[(&str, f64)]) -> Option<Vec<(u32, f32)>> {
+6 -5
View File
@@ -603,13 +603,14 @@ pub fn die(
/// The loot table `die` rolls for `m` (`LivingEntity.getLootTable`), when it
/// rolls one: none with `doMobLoot` off, nor for a baby that isn't a monster.
pub fn death_loot_table(m: &Mob, mob_drops: bool) -> Option<String> {
// Mob.getLootTable: the mob's own `DeathLootTable`, else its type's.
// Mob.getLootTable: the mob's own `DeathLootTable`, else its type's
// (`EntityType.getDefaultLootTable`: `entities/` before the type's path,
// in its namespace, so a content mod's type rolls its own table).
(mob_drops && (is_monster(m.species()) || !m.is_baby())).then(|| {
m.extra.death_loot_table.clone().unwrap_or_else(|| {
format!(
"minecraft:entities/{}",
m.kind.id.trim_start_matches("minecraft:")
)
let id = m.kind.id.as_str();
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
format!("{namespace}:entities/{path}")
})
})
}
+10
View File
@@ -1324,6 +1324,16 @@ pub const ALL: &[Def] = &[
pub fn def(s: Species) -> &'static Def {
ALL.iter().find(|d| d.species == s).unwrap_or(&ALL[0])
}
/// The vanilla entity type of a species, `minecraft:` and its [`Def::name`].
pub fn vanilla_id(s: Species) -> &'static str {
static IDS: std::sync::LazyLock<Vec<String>> = std::sync::LazyLock::new(|| {
ALL.iter()
.map(|d| format!("minecraft:{}", d.name))
.collect()
});
let index = ALL.iter().position(|d| d.species == s).unwrap_or(0);
&IDS[index]
}
impl Def {
/// Model layer families to load for this entity (adult, baby, variants).
pub fn layer_prefixes(&self) -> Vec<&'static str> {
+41 -3
View File
@@ -865,8 +865,10 @@ fn read_json(path: &Path) -> Result<J> {
impl Plan<'_> {
/// `entity-types.json` with a row for each mod type: its template's, so its
/// size, eyes and category are the template's (`execute anchored eyes`,
/// spawner checks and the commands' entity type list, `reg.entity_types`).
/// The rows are appended as text: parsed and written back, a float such as
/// spawner checks and the commands' entity type list, `reg.entity_types`),
/// with `template` naming it and a living type's own attribute bases
/// (`MobTypes` makes the mob from it). The rows are appended as text, each
/// value as the template has it: parsed and written back, a float such as
/// a zombie's `movement_speed` can come back one ulp off.
fn entity_types_json(&self, bytes: &[u8]) -> std::result::Result<Vec<u8>, String> {
use serde_json::value::RawValue;
@@ -889,13 +891,49 @@ impl Plan<'_> {
}
out.extend(serde_json::to_vec(&t.id).map_err(|e| e.to_string())?);
out.push(b':');
out.extend(row.get().as_bytes());
out.extend(mod_entity_row(row, t)?);
}
out.push(b'}');
Ok(out)
}
}
/// A mod entity type's row in `entity-types.json`: its template's `row` with
/// `template` added and the type's attribute bases in place of the
/// template's. The other values keep the template's text.
fn mod_entity_row(
row: &serde_json::value::RawValue,
t: &EntityType,
) -> std::result::Result<Vec<u8>, String> {
use serde_json::value::{RawValue, to_raw_value};
type Raw = BTreeMap<String, Box<RawValue>>;
let fail = |e: serde_json::Error| format!("{}: {e}", t.template);
let mut fields: Raw = serde_json::from_str(row.get()).map_err(fail)?;
if !t.attributes.is_empty() {
let mut attributes: Raw = match fields.get("attributes") {
Some(a) => serde_json::from_str(a.get()).map_err(fail)?,
None => Raw::new(),
};
for (id, value) in &t.attributes {
// Refused rather than added: the mob reads the attributes its
// template's class has, so an extra one would do nothing.
let Some(base) = attributes.get_mut(id) else {
return Err(format!(
"{}: attribute {id} is not one of {}'s",
t.id, t.template
));
};
*base = to_raw_value(value).map_err(fail)?;
}
fields.insert(
"attributes".into(),
to_raw_value(&attributes).map_err(fail)?,
);
}
fields.insert("template".into(), to_raw_value(&t.template).map_err(fail)?);
serde_json::to_vec(&fields).map_err(fail)
}
impl<'a> Plan<'a> {
fn new(base: &Path, mods: &'a [ContentMod]) -> Result<Self> {
let vanilla_items = match base.join("items.json").exists() {
+36 -1
View File
@@ -1492,6 +1492,38 @@ fn content_root_with_only_an_item_checks_its_identity() {
fs::remove_dir_all(&scratch).unwrap();
}
/// a living mod type's row in `entity-types.json` is its template's with
/// `template` added and its own attribute bases; every other value keeps the
/// template's text, to the digit. An attribute the template lacks is refused.
#[test]
fn a_living_mod_types_row_is_its_templates_with_its_attributes() {
let zombie = r#"{"width":0.6,"height":1.95,"eye_height":1.74,"category":"monster",
"fire_immune":false,"attributes":{"minecraft:max_health":20.0,
"minecraft:movement_speed":0.23000000417232513,"minecraft:armor":2.0}}"#;
let row = serde_json::value::RawValue::from_string(zombie.to_owned()).unwrap();
let ghoul = |attributes: &[(&str, f64)]| EntityType {
id: "ruby:ghoul".into(),
template: "minecraft:zombie".into(),
item: None,
damage: 0.0,
attributes: attributes
.iter()
.map(|(a, v)| (a.to_string(), *v))
.collect(),
};
let written = mod_entity_row(&row, &ghoul(&[("minecraft:max_health", 40.0)])).unwrap();
let written: serde_json::Value = serde_json::from_slice(&written).unwrap();
let mut expected: serde_json::Value = serde_json::from_str(zombie).unwrap();
expected["attributes"]["minecraft:max_health"] = 40.0.into();
expected["template"] = "minecraft:zombie".into();
assert_eq!(written, expected);
// the untouched speed keeps the template's text, not a reparsed float.
let text = String::from_utf8(mod_entity_row(&row, &ghoul(&[])).unwrap()).unwrap();
assert!(text.contains("0.23000000417232513"), "{text}");
let e = mod_entity_row(&row, &ghoul(&[("minecraft:flying_speed", 1.0)])).unwrap_err();
assert!(e.contains("not one of minecraft:zombie's"), "{e}");
}
/// a mod's thrown entity type gets the network id after vanilla's last, the
/// commands accept it, and it changes the content identity.
#[test]
@@ -1541,7 +1573,10 @@ fn content_root_registers_a_mod_entity_type() {
let types: serde_json::Value =
serde_json::from_slice(&fs::read(root.join("entity-types.json")).unwrap()).unwrap();
assert!(types["minecraft:snowball"].is_object());
assert_eq!(types["example:bolt"], types["minecraft:snowball"]);
let mut bolt = types["example:bolt"].clone();
assert_eq!(bolt["template"], "minecraft:snowball");
bolt.as_object_mut().unwrap().remove("template");
assert_eq!(bolt, types["minecraft:snowball"]);
// the rewritten table keeps vanilla's floats to the bit (a zombie's speed).
let speed = |root: &Path| {
let attributes =
+361 -2
View File
@@ -7,8 +7,10 @@
//! (crate::auth): the login joins the server through the session service and
//! encrypts the connection (ClientHandshakePacketListenerImpl.handleHello).
use crate::auth::AuthSession;
use crate::studio::{Metadata, RecorderStatus, StudioRecorder};
use horizon_protocol::{
PROTOCOL_VERSION,
buf::Reader,
connection::{ConnReader, ConnWriter, Connection, Counters},
crypto::{generate_secret, offline_uuid, server_hash},
packets::{Empty, configuration as cfg, handshake, login, play},
@@ -114,6 +116,8 @@ pub struct Session {
debug: bool,
/// The far terrain requests waiting for the server (`lod_net`).
far: Arc<crate::lod_net::Inbox>,
/// Active packet recorder for Studio replay recordings.
pub recorder: Arc<Mutex<Option<StudioRecorder>>>,
}
impl Session {
/// Start connecting in the background, as `auth`'s account when there is one.
@@ -209,6 +213,8 @@ impl Session {
let thread_configuring = configuring.clone();
let far = Arc::new(crate::lod_net::Inbox::default());
let thread_far = far.clone();
let recorder = Arc::new(Mutex::new(None));
let thread_recorder = recorder.clone();
let (address_owned, name_owned) = (address.to_owned(), name.to_owned());
let thread = std::thread::Builder::new()
.name("horizon-net".into())
@@ -229,6 +235,7 @@ impl Session {
transferred,
&resources,
lan,
&thread_recorder,
);
thread_far.cancel();
let from_server = result.is_ok();
@@ -255,12 +262,40 @@ impl Session {
cookies,
debug,
far,
recorder,
}
}
/// Everything received since the last poll.
pub fn poll(&self) -> Vec<Event> {
self.events.try_iter().collect()
}
/// Start recording network packets to `.mcpr` format at `path`.
pub fn start_recording(
&self,
path: impl Into<PathBuf>,
metadata: Metadata,
) -> std::io::Result<()> {
let rec = StudioRecorder::start(path.into(), metadata)?;
*self.recorder.lock().unwrap() = Some(rec);
Ok(())
}
/// Stop recording and finalize the `.mcpr` archive.
pub fn stop_recording(&self) -> Option<std::io::Result<PathBuf>> {
let rec = self.recorder.lock().unwrap().take()?;
Some(rec.stop())
}
/// Whether recording is currently active.
pub fn is_recording(&self) -> bool {
self.recorder
.lock()
.unwrap()
.as_ref()
.is_some_and(|r| r.is_active())
}
/// Status of the active recording, if any.
pub fn recorder_status(&self) -> Option<RecorderStatus> {
self.recorder.lock().unwrap().as_ref().map(|r| r.status())
}
/// Send a play packet; silently dropped once the connection is gone,
/// and while the server has the connection back in configuration (a
/// proxy switching servers). Vanilla's `handleConfigurationStart` clears
@@ -616,6 +651,7 @@ fn run(
transferred: bool,
resources: &Path,
lan: bool,
recorder: &Arc<Mutex<Option<StudioRecorder>>>,
) -> Result<Reason, Reason> {
let (host, port, mut c) = if let Some(pipe) = local {
(address.to_owned(), 0, Connection::local(pipe))
@@ -797,6 +833,7 @@ fn run(
cookies,
far,
resources,
recorder,
)
}
@@ -809,6 +846,7 @@ fn receive_play(
cookies: &Cookies,
far: &crate::lod_net::Inbox,
resources: &Path,
recorder: &Arc<Mutex<Option<StudioRecorder>>>,
) -> Result<Reason, Reason> {
loop {
let (id, payload) = reader
@@ -827,6 +865,15 @@ fn receive_play(
continue;
}
};
// Only while a recording runs: the filter copies what it keeps, and re-encodes
// AddEntity with a fresh UUID.
if let Some(rec) = recorder.lock().unwrap().as_ref()
&& rec.is_active()
&& let Some((recorded_id, recorded_payload)) =
studio_recording_packet(&packet, id, &payload)
{
rec.record_packet(recorded_id, &recorded_payload);
}
let reply = |p: play::Serverbound| {
if let Some(w) = shared_writer.lock().unwrap().as_mut() {
let _ = w.send(&p.encode());
@@ -899,6 +946,52 @@ fn receive_play(
}
}
/// Return the packet bytes safe to persist in a Studio recording.
///
/// Unknown packets fail closed so a newly added protocol packet cannot bypass
/// the privacy policy. `AddEntity` retains its entity id and state, but gets a
/// fresh UUID so the recording cannot link that entity to an account UUID.
fn studio_recording_packet<'a>(
packet: &play::Clientbound,
packet_id: i32,
payload: &'a [u8],
) -> Option<(i32, std::borrow::Cow<'a, [u8]>)> {
use play::Clientbound as P;
use std::borrow::Cow;
match packet {
P::AddEntity(entity) => {
let mut anonymous = entity.clone();
anonymous.uuid = u128::from_be_bytes(generate_secret().ok()?);
let encoded = P::AddEntity(anonymous).encode();
let mut reader = Reader::new(&encoded);
let id = reader.varint().ok()?;
Some((id, Cow::Owned(reader.rest().to_vec())))
}
P::CookieRequest(_)
| P::CustomChatCompletions(_)
| P::CustomPayload(_)
| P::DeleteChat(_)
| P::Disconnect(_)
| P::DisguisedChat(_)
| P::PlayerChat(_)
| P::PlayerCombatKill(_)
| P::PlayerInfoRemove(_)
| P::PlayerInfoUpdate(_)
| P::ResetScore(_)
| P::ResourcePackPush(_)
| P::ServerLinks(_)
| P::SetPlayerTeam(_)
| P::SetScore(_)
| P::StoreCookie(_)
| P::SystemChat(_)
| P::TabList(_)
| P::TrackedWaypoint(_)
| P::Transfer(_)
| P::Unknown(..) => None,
_ => Some((packet_id, Cow::Borrowed(payload))),
}
}
/// The configuration phase: registries, tags and known packs until the
/// server finishes it (ClientConfigurationPacketListenerImpl).
fn configure(
@@ -1375,6 +1468,170 @@ mod tests {
assert_eq!(line("[::1]:25566"), "Connecting to ::1, 25566");
}
#[test]
fn studio_recording_drops_packets_with_direct_player_identity_or_private_text() {
use horizon_protocol::packets::configuration::{
ResourcePackPush, ServerLink, ServerLinkKind, ServerLinks, StoreCookie, Transfer,
};
use play::{
ChatTypeBound, Clientbound as P, CustomChatCompletions, PlayerInfo, PlayerInfoRemove,
PlayerInfoUpdate, ResetScore, SetPlayerTeam, SetScore, TrackedWaypoint, WaypointId,
WaypointTarget,
};
let packets = [
P::CookieRequest(cfg::CookieRequest {
key: "private_cookie".into(),
}),
P::CustomChatCompletions(CustomChatCompletions {
action: 2,
entries: vec!["/msg PlayerName private text".into()],
}),
P::CustomPayload(cfg::CustomPayload {
channel: "private:channel".into(),
data: b"private payload".to_vec(),
}),
P::DeleteChat(play::DeleteChat {
signature: (3, Some(vec![1, 2, 3])),
}),
P::DisguisedChat(play::DisguisedChat {
message: Tag::String("private message".into()),
chat_type: ChatTypeBound {
chat_type: 0,
name: Tag::String("PlayerName".into()),
target: None,
},
}),
P::PlayerCombatKill(play::PlayerCombatKill {
id: 7,
message: Tag::String("PlayerName was slain".into()),
}),
P::PlayerChat(play::PlayerChat {
global_index: 1,
sender: 7,
index: 2,
signature: None,
content: "private message".into(),
timestamp: 3,
salt: 4,
last_seen: vec![],
unsigned_content: None,
filter: (0, vec![]),
chat_type: ChatTypeBound {
chat_type: 0,
name: Tag::String("PlayerName".into()),
target: None,
},
}),
P::ResetScore(ResetScore {
owner: "PlayerName".into(),
objective: Some("deaths".into()),
}),
P::SetScore(SetScore {
owner: "PlayerName".into(),
objective: "deaths".into(),
score: 1,
display: None,
number_format: None,
}),
P::PlayerInfoRemove(PlayerInfoRemove { uuids: vec![7] }),
P::PlayerInfoUpdate(PlayerInfoUpdate {
actions: 1,
entries: vec![PlayerInfo {
uuid: 7,
name: Some("PlayerName".into()),
..PlayerInfo::default()
}],
}),
P::ResourcePackPush(ResourcePackPush {
id: 8,
url: "https://private.example/pack.zip".into(),
hash: "hash".into(),
required: false,
prompt: None,
}),
P::ServerLinks(ServerLinks {
links: vec![ServerLink {
kind: ServerLinkKind::Known(0),
link: "https://private.example".into(),
}],
}),
P::SetPlayerTeam(SetPlayerTeam {
name: "red".into(),
method: 3,
parameters: None,
players: vec!["PlayerName".into()],
}),
P::StoreCookie(StoreCookie {
key: "private_cookie".into(),
payload: b"secret".to_vec(),
}),
P::SystemChat(play::SystemChat {
content: Tag::String("private message".into()),
overlay: false,
}),
P::TabList(play::TabList {
header: Tag::String("PlayerName".into()),
footer: Tag::End,
}),
P::TrackedWaypoint(TrackedWaypoint {
operation: play::waypoint_operation::TRACK,
id: WaypointId::Uuid(7),
style: "minecraft:default".into(),
color: None,
target: WaypointTarget::Empty,
}),
P::Transfer(Transfer {
host: "private.example".into(),
port: 25565,
}),
P::Disconnect(cfg::Disconnect {
reason: Tag::String("PlayerName was disconnected".into()),
}),
P::Unknown(999, b"unclassified private payload".to_vec()),
];
for packet in packets {
assert!(
studio_recording_packet(&packet, packet.id(), b"raw packet payload").is_none(),
"{} should be excluded from recordings",
packet.name()
);
}
}
#[test]
fn studio_recording_replaces_entity_uuids_but_keeps_entity_state() {
let original = play::AddEntity {
id: 18,
uuid: 0x0102_0304_0506_0708_090a_0b0c_0d0e_0f10,
kind: 42,
position: [1.0, 64.0, -2.0],
velocity: [0.0, 0.0, 0.0],
pitch: 1,
yaw: 2,
head_yaw: 3,
data: 4,
};
let packet = play::Clientbound::AddEntity(original.clone());
let (id, payload) = studio_recording_packet(&packet, packet.id(), b"unused").unwrap();
let play::Clientbound::AddEntity(recorded) =
play::Clientbound::decode(id, &payload).unwrap()
else {
panic!("sanitized packet changed type")
};
assert_ne!(recorded.uuid, original.uuid);
assert_eq!(recorded.id, original.id);
assert_eq!(recorded.kind, original.kind);
assert_eq!(recorded.position, original.position);
assert_eq!(recorded.velocity, original.velocity);
assert_eq!(recorded.pitch, original.pitch);
assert_eq!(recorded.yaw, original.yaw);
assert_eq!(recorded.head_yaw, original.head_yaw);
assert_eq!(recorded.data, original.data);
}
#[test]
fn direct_login_preserves_hostname_and_negotiates_brand() {
for server_brand in [
@@ -1547,6 +1804,7 @@ mod tests {
&Cookies::default(),
&crate::lod_net::Inbox::default(),
Path::new("."),
&Arc::new(Mutex::new(None)),
));
});
let ended = result
@@ -1666,6 +1924,7 @@ mod tests {
cookies: Cookies::default(),
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
};
let started = std::time::Instant::now();
if explicit_disconnect {
@@ -1715,6 +1974,7 @@ mod tests {
&Cookies::default(),
&crate::lod_net::Inbox::default(),
Path::new("."),
&Arc::new(Mutex::new(None)),
)
});
let mut server = Connection::local(server);
@@ -1767,9 +2027,13 @@ mod tests {
cookies: Cookies::default(),
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
};
let (thread_writer, thread_configuring) =
(session.writer.clone(), session.configuring.clone());
let (thread_writer, thread_configuring, thread_recorder) = (
session.writer.clone(),
session.configuring.clone(),
session.recorder.clone(),
);
let thread = std::thread::spawn(move || {
receive_play(
&mut reader,
@@ -1780,6 +2044,7 @@ mod tests {
&Cookies::default(),
&crate::lod_net::Inbox::default(),
Path::new("."),
&thread_recorder,
)
});
let mut server = Connection::local(server);
@@ -1828,6 +2093,7 @@ mod tests {
&thread_cookies,
&crate::lod_net::Inbox::default(),
Path::new("."),
&Arc::new(Mutex::new(None)),
)
});
let mut server = Connection::local(server);
@@ -1990,4 +2256,97 @@ mod tests {
);
}
}
#[test]
fn session_studio_recording_tap_records_packets_to_mcpr() {
let (client, server) = horizon_protocol::connection::Pipe::pair();
let (mut reader, writer) = Connection::local(client).split();
let (send, events) = channel();
let session = Session {
events,
writer: Arc::new(Mutex::new(Some(writer))),
configuring: Arc::new(AtomicBool::new(false)),
thread: None,
address: "local".into(),
name: "StudioTester".into(),
counters: Arc::new(Mutex::new(None)),
cookies: Cookies::default(),
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
};
let temp_dir = std::env::temp_dir().join(format!(
"horizon-studio-test-{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::create_dir_all(&temp_dir);
let mcpr_path = temp_dir.join("test_tap.mcpr");
let meta = Metadata::new("Singleplayer World".into(), true, "26.3".into());
session.start_recording(&mcpr_path, meta).unwrap();
assert!(session.is_recording());
let (thread_writer, thread_configuring, thread_recorder) = (
session.writer.clone(),
session.configuring.clone(),
session.recorder.clone(),
);
let thread = std::thread::spawn(move || {
receive_play(
&mut reader,
&thread_writer,
&thread_configuring,
&Language::new(),
&send,
&Cookies::default(),
&crate::lod_net::Inbox::default(),
Path::new("."),
&thread_recorder,
)
});
let mut server = Connection::local(server);
let pos_pkt = play::Clientbound::PlayerPosition(play::PlayerPosition {
teleport: 1,
position: [100.0, 64.0, -200.0],
delta: [0.0; 3],
yaw: 45.0,
pitch: 10.0,
relatives: 0,
});
server.send(&pos_pkt.encode()).unwrap();
// Disconnect ends receive_play loop
let disc_pkt = play::Clientbound::Disconnect(cfg::Disconnect {
reason: Tag::String("Replay tap test completed".into()),
});
server.send(&disc_pkt.encode()).unwrap();
let _ = thread.join().unwrap();
let saved_path = session.stop_recording().unwrap().unwrap();
assert_eq!(saved_path, mcpr_path);
assert!(!session.is_recording());
assert!(mcpr_path.exists());
// Verify that the recording archive contains the clientbound packets.
let mut recording = crate::studio::McprReader::open(&mcpr_path).unwrap();
assert_eq!(recording.total_packets(), 1);
assert_eq!(recording.metadata.server_name, "Singleplayer World");
assert!(recording.metadata.singleplayer);
assert!(recording.metadata.players.is_empty());
let packet1 = recording.next_packet().unwrap().unwrap();
assert!(recording.next_packet().unwrap().is_none());
let decoded1 = play::Clientbound::decode(packet1.id, &packet1.payload).unwrap();
assert_eq!(decoded1, pos_pkt);
let _ = std::fs::remove_dir_all(&temp_dir);
}
}
+4
View File
@@ -1,7 +1,11 @@
//! Horizon In-Game Video Studio.
//!
//! Provides native session packet recording (`.mcpr` format).
pub mod mcpr;
pub mod recorder;
pub use mcpr::{
McprReader, McprWriter, Metadata, RecoveryReport, TimestampedPacket, recover_crashed_recordings,
};
pub use recorder::{RecorderStatus, StudioRecorder, generate_recording_filename};
@@ -0,0 +1,286 @@
//! Studio Live Session Packet Recorder
//!
//! Captures clientbound network packets in real-time onto a background writer
//! thread so gameplay frame rates and latency are unaffected.
use super::mcpr::{McprWriter, Metadata, TimestampedPacket};
use std::{
io,
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc::{Receiver, SyncSender, TrySendError, sync_channel},
},
thread::{self, JoinHandle},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
static RECORDING_NAME_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Commands sent to the background recording thread.
enum RecordCommand {
Packet(TimestampedPacket),
Stop,
}
const RECORD_QUEUE_PACKETS: usize = 2048;
/// Status summary of the live recording.
#[derive(Clone, Debug, PartialEq)]
pub struct RecorderStatus {
pub active: bool,
pub path: PathBuf,
pub elapsed: Duration,
pub packet_count: u64,
pub bytes_written: u64,
pub error: Option<String>,
}
/// Asynchronous packet recorder that writes `.mcpr` sessions on a worker thread.
pub struct StudioRecorder {
active: Arc<AtomicBool>,
packet_count: Arc<AtomicU64>,
bytes_written: Arc<AtomicU64>,
sender: SyncSender<RecordCommand>,
failure: Arc<std::sync::Mutex<Option<String>>>,
worker: Option<JoinHandle<io::Result<PathBuf>>>,
start_time: Instant,
dest_path: PathBuf,
}
impl StudioRecorder {
/// Start a new recording session saving to `dest_path`.
pub fn start(dest_path: PathBuf, mut metadata: Metadata) -> io::Result<Self> {
let active = Arc::new(AtomicBool::new(true));
let packet_count = Arc::new(AtomicU64::new(0));
let bytes_written = Arc::new(AtomicU64::new(0));
let start_time = Instant::now();
let start_unix_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
metadata.date = start_unix_ms;
let (sender, receiver): (SyncSender<RecordCommand>, Receiver<RecordCommand>) =
sync_channel(RECORD_QUEUE_PACKETS);
let mut writer = McprWriter::new(dest_path.clone(), metadata)?;
let worker_active = active.clone();
let worker_count = packet_count.clone();
let worker_bytes = bytes_written.clone();
let failure = Arc::new(std::sync::Mutex::new(None));
let worker_failure = failure.clone();
let thread_dest = dest_path.clone();
let worker = thread::Builder::new()
.name("Studio Recorder".into())
.spawn(move || -> io::Result<PathBuf> {
let mut duration_ms = 0;
while let Ok(cmd) = receiver.recv() {
match cmd {
RecordCommand::Packet(pkt) => {
duration_ms = duration_ms.max(pkt.timestamp_ms);
if let Err(error) =
writer.write_packet(pkt.timestamp_ms, pkt.id, &pkt.payload)
{
*worker_failure.lock().unwrap() = Some(error.to_string());
worker_active.store(false, Ordering::SeqCst);
break;
}
worker_count.fetch_add(1, Ordering::Relaxed);
worker_bytes.store(writer.bytes_written(), Ordering::Relaxed);
}
RecordCommand::Stop => break,
}
}
// Drain any lingering packets in queue
while let Ok(cmd) = receiver.try_recv() {
if let RecordCommand::Packet(pkt) = cmd {
duration_ms = duration_ms.max(pkt.timestamp_ms);
if let Err(error) =
writer.write_packet(pkt.timestamp_ms, pkt.id, &pkt.payload)
{
*worker_failure.lock().unwrap() = Some(error.to_string());
break;
}
worker_count.fetch_add(1, Ordering::Relaxed);
worker_bytes.store(writer.bytes_written(), Ordering::Relaxed);
}
}
worker_active.store(false, Ordering::SeqCst);
writer.finish(duration_ms)?;
if let Some(error) = worker_failure.lock().unwrap().take() {
return Err(io::Error::other(format!(
"recording stopped with an error: {error}"
)));
}
Ok(thread_dest)
})?;
Ok(Self {
active,
packet_count,
bytes_written,
sender,
failure,
worker: Some(worker),
start_time,
dest_path,
})
}
/// Record a clientbound packet if recording is active.
pub fn record_packet(&self, id: i32, payload: &[u8]) {
if !self.active.load(Ordering::Relaxed) {
return;
}
let timestamp_ms = self.start_time.elapsed().as_millis() as u32;
let pkt = TimestampedPacket::new(timestamp_ms, id, payload.to_vec());
if let Err(error) = self.sender.try_send(RecordCommand::Packet(pkt)) {
let message = match error {
TrySendError::Full(_) => "recorder queue is full; capture stopped",
TrySendError::Disconnected(_) => "recorder worker stopped unexpectedly",
};
*self.failure.lock().unwrap() = Some(message.into());
self.active.store(false, Ordering::SeqCst);
}
}
/// Record a clientbound packet at an explicit timestamp (e.g. 0ms for initial world baseline).
pub fn record_packet_at(&self, timestamp_ms: u32, id: i32, payload: &[u8]) {
if !self.active.load(Ordering::Relaxed) {
return;
}
let pkt = TimestampedPacket::new(timestamp_ms, id, payload.to_vec());
if let Err(error) = self.sender.try_send(RecordCommand::Packet(pkt)) {
let message = match error {
TrySendError::Full(_) => "recorder queue is full; capture stopped",
TrySendError::Disconnected(_) => "recorder worker stopped unexpectedly",
};
*self.failure.lock().unwrap() = Some(message.into());
self.active.store(false, Ordering::SeqCst);
}
}
/// Check if currently recording.
pub fn is_active(&self) -> bool {
self.active.load(Ordering::Relaxed)
}
/// Query current recording status.
pub fn status(&self) -> RecorderStatus {
RecorderStatus {
active: self.is_active(),
path: self.dest_path.clone(),
elapsed: self.start_time.elapsed(),
packet_count: self.packet_count.load(Ordering::Relaxed),
bytes_written: self.bytes_written.load(Ordering::Relaxed),
error: self.failure.lock().unwrap().clone(),
}
}
/// Stop recording and finalize the `.mcpr` archive.
pub fn stop(mut self) -> io::Result<PathBuf> {
self.active.store(false, Ordering::SeqCst);
let _ = self.sender.send(RecordCommand::Stop);
if let Some(worker) = self.worker.take() {
match worker.join() {
Ok(res) => res,
Err(_) => Err(io::Error::other("studio recorder worker thread panicked")),
}
} else {
Ok(self.dest_path.clone())
}
}
}
impl Drop for StudioRecorder {
fn drop(&mut self) {
self.active.store(false, Ordering::SeqCst);
let _ = self.sender.send(RecordCommand::Stop);
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
/// Default storage directory for video studio recordings (`recordings/`).
pub fn default_recordings_dir() -> PathBuf {
Path::new("recordings").to_path_buf()
}
/// Generate a timestamped file name for a recording.
pub fn generate_recording_filename(name: Option<&str>) -> String {
let now = chrono::Local::now();
let sequence = RECORDING_NAME_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let stamp = format!("{}_{}", now.format("%Y-%m-%d_%H-%M-%S_%3f"), sequence);
match name {
Some(custom) if !custom.trim().is_empty() => {
let stem: String = custom
.trim()
.chars()
.filter_map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => Some(c),
_ if c.is_whitespace() => Some('_'),
_ => None,
})
.take(80)
.collect();
let stem = stem.trim_matches(['-', '_']);
if stem.is_empty() {
format!("recording_{stamp}.mcpr")
} else {
format!("{stem}_{stamp}.mcpr")
}
}
_ => format!("recording_{stamp}.mcpr"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_studio_recorder_lifecycle() {
let temp_dir = std::env::temp_dir().join(format!(
"studio_rec_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = fs::create_dir_all(&temp_dir);
let mcpr_path = temp_dir.join("live_test.mcpr");
let meta = Metadata::default();
let recorder = StudioRecorder::start(mcpr_path.clone(), meta).unwrap();
assert!(recorder.is_active());
// Send a few packets
recorder.record_packet(0x01, &[1, 2, 3]);
recorder.record_packet(0x02, &[4, 5]);
let st = recorder.status();
assert_eq!(st.path, mcpr_path);
let finished = recorder.stop().unwrap();
assert_eq!(finished, mcpr_path);
assert!(mcpr_path.exists());
// Verify with reader
let mut reader = super::super::mcpr::McprReader::open(&mcpr_path).unwrap();
assert_eq!(reader.total_packets(), 2);
let p0 = reader.next_packet().unwrap().unwrap();
assert_eq!(p0.id, 0x01);
let p1 = reader.next_packet().unwrap().unwrap();
assert_eq!(p1.id, 0x02);
let _ = fs::remove_file(mcpr_path);
let _ = fs::remove_dir(temp_dir);
}
}
@@ -0,0 +1,311 @@
//! Conservative, budgeted occlusion against full opaque block cross-sections.
//!
//! A covered projection rectangle proves that an entire bounding box is hidden.
//! Testing a few rays would miss openings between those rays.
use horizon_core::geometry::Aabb;
use std::cell::Cell;
const EPSILON: f64 = 1.0e-5;
pub struct Culler {
pub camera: [f64; 3],
remaining: Cell<usize>,
}
impl Culler {
pub fn new(camera: [f64; 3], budget: usize) -> Self {
Self {
camera,
remaining: Cell::new(budget),
}
}
/// Remaining block lookups in this frame's shared culling budget.
pub fn remaining(&self) -> usize {
self.remaining.get()
}
/// Remove only this entity's completed camera-relative mesh when hidden.
pub fn cull_vertices(
&self,
vertices: &mut Vec<crate::mesher::Vertex>,
start: usize,
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
let hidden = self.hidden(vertices, start, opaque);
if hidden {
vertices.truncate(start);
}
hidden
}
/// Whether this entity's completed camera-relative mesh (`vertices[start..]`) is hidden.
pub fn hidden(
&self,
vertices: &[crate::mesher::Vertex],
start: usize,
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if self.remaining.get() == 0 {
return false;
}
let Some((min, max)) = Self::bounds(self.camera, vertices, start) else {
return false;
};
self.occluded(min, max, opaque)
}
/// Whether a declared world-space entity AABB is hidden. Entity model
/// bounds receive the same small edge margin as mesh-derived bounds.
pub fn hidden_bounds(&self, bounds: Aabb, opaque: impl FnMut([i32; 3]) -> bool) -> bool {
if self.remaining.get() == 0 {
return false;
}
let Some((min, max)) = Self::aabb_bounds(bounds) else {
return false;
};
self.occluded(min, max, opaque)
}
/// Whether camera-relative model bounds are hidden. This avoids building
/// synthetic vertices when a renderer already has a conservative AABB.
pub fn hidden_camera_bounds(
&self,
lo: [f32; 3],
hi: [f32; 3],
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if self.remaining.get() == 0
|| self.camera.into_iter().any(|value| !value.is_finite())
|| lo.into_iter().chain(hi).any(|value| !value.is_finite())
{
return false;
}
let min = std::array::from_fn(|axis| {
f64::from(lo[axis].min(hi[axis])) + self.camera[axis] - 0.05
});
let max = std::array::from_fn(|axis| {
f64::from(lo[axis].max(hi[axis])) + self.camera[axis] + 0.05
});
self.occluded(min, max, opaque)
}
/// World-space bounds expanded for floating-point and rasterization edges.
pub fn aabb_bounds(bounds: Aabb) -> Option<([f64; 3], [f64; 3])> {
let lo = bounds.min_position();
let hi = bounds.max_position();
let min = [lo.x, lo.y, lo.z];
let max = [hi.x, hi.y, hi.z];
if (0..3)
.any(|axis| !min[axis].is_finite() || !max[axis].is_finite() || min[axis] > max[axis])
{
return None;
}
Some((min.map(|value| value - 0.05), max.map(|value| value + 0.05)))
}
/// World-space bounds from camera-relative vertices, grown for floating-point
/// and rasterization edges. The asynchronous path sends these to its worker.
pub fn bounds(
camera: [f64; 3],
vertices: &[crate::mesher::Vertex],
start: usize,
) -> Option<([f64; 3], [f64; 3])> {
if start >= vertices.len() || camera.into_iter().any(|value| !value.is_finite()) {
return None;
}
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for vertex in &vertices[start..] {
let [x, y, z] = vertex.pos;
if !x.is_finite() || !y.is_finite() || !z.is_finite() {
return None;
}
let [x, y, z] = [f64::from(x), f64::from(y), f64::from(z)];
min[0] = min[0].min(x);
max[0] = max[0].max(x);
min[1] = min[1].min(y);
max[1] = max[1].max(y);
min[2] = min[2].min(z);
max[2] = max[2].max(z);
}
// Camera translation is monotonic, so offsetting the extrema is exact
// and avoids three f64 additions for every scanned vertex.
let min = std::array::from_fn(|axis| min[axis] + camera[axis]);
let max = std::array::from_fn(|axis| max[axis] + camera[axis]);
// Cover floating-point and rasterization edges as well as the vertices.
let min = min.map(|value| value - 0.05);
let max = max.map(|value| value + 0.05);
Some((min, max))
}
/// Returns true only when a complete opaque block plane covers the bounds.
/// Unknown blocks, invalid bounds and an exhausted lookup budget stay visible.
pub fn occluded(
&self,
min: [f64; 3],
max: [f64; 3],
mut opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
let mut remaining = self.remaining.get();
let hidden = Self::occluded_with_budget(self.camera, &mut remaining, min, max, &mut opaque);
self.remaining.set(remaining);
hidden
}
/// The worker-facing form of [`Self::occluded`]. Keeping the budget in a
/// local integer avoids `Cell` loads and stores for every projected plane
/// while preserving the same conservative lookup accounting.
pub fn occluded_with_budget(
camera: [f64; 3],
remaining: &mut usize,
min: [f64; 3],
max: [f64; 3],
mut opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if (0..3).any(|a| {
!camera[a].is_finite() || !min[a].is_finite() || !max[a].is_finite() || min[a] > max[a]
}) {
return false;
}
let mut best = (0, 0.0, 0.0);
for a in 0..3 {
let (gap, sign) = if camera[a] < min[a] {
(min[a] - camera[a], 1.0)
} else {
(camera[a] - max[a], -1.0)
};
if gap > best.1 {
best = (a, gap, sign);
}
}
let (axis, gap, sign) = best;
if !gap.is_finite() || gap <= EPSILON {
return false;
}
let across = [(axis + 1) % 3, (axis + 2) % 3];
let Some(mut cell) = floor_cell(camera[axis]) else {
return false;
};
let step = if sign > 0.0 { 1 } else { -1 };
if (f64::from(cell) + 0.5 - camera[axis]) * sign <= EPSILON {
let Some(next) = cell.checked_add(step) else {
return false;
};
cell = next;
}
// These two reciprocals are invariant for every plane along the ray.
let denominator_min = (min[axis] - camera[axis]) * sign;
let denominator_max = (max[axis] - camera[axis]) * sign;
if !denominator_min.is_finite()
|| !denominator_max.is_finite()
|| denominator_min.abs() <= EPSILON
|| denominator_max.abs() <= EPSILON
{
return false;
}
let reciprocal_min = 1.0 / denominator_min;
let reciprocal_max = 1.0 / denominator_max;
let transverse_min = [
min[across[0]] - camera[across[0]],
min[across[1]] - camera[across[1]],
];
let transverse_max = [
max[across[0]] - camera[across[0]],
max[across[1]] - camera[across[1]],
];
loop {
let depth = (f64::from(cell) + 0.5 - camera[axis]) * sign;
if depth >= gap - EPSILON || *remaining == 0 {
return false;
}
let t_min = depth * reciprocal_min;
let t_max = depth * reciprocal_max;
let mut lo = [f64::INFINITY; 2];
let mut hi = [f64::NEG_INFINITY; 2];
for i in 0..2 {
let camera_axis = camera[across[i]];
for t in [t_min, t_max] {
let p_min = camera_axis + t * transverse_min[i];
let p_max = camera_axis + t * transverse_max[i];
if !p_min.is_finite() || !p_max.is_finite() {
return false;
}
lo[i] = lo[i].min(p_min);
hi[i] = hi[i].max(p_max);
}
}
let bounds = [
floor_cell(lo[0] - EPSILON),
floor_cell(hi[0] + EPSILON),
floor_cell(lo[1] - EPSILON),
floor_cell(hi[1] + EPSILON),
];
let [Some(x0), Some(x1), Some(y0), Some(y1)] = bounds else {
return false;
};
let center_x = x0 + ((i64::from(x1) - i64::from(x0)) / 2) as i32;
let center_y = y0 + ((i64::from(y1) - i64::from(y0)) / 2) as i32;
let mut pos = [0; 3];
pos[axis] = cell;
pos[across[0]] = center_x;
pos[across[1]] = center_y;
*remaining -= 1;
if opaque(pos) {
let area = (i64::from(x1) - i64::from(x0) + 1)
.checked_mul(i64::from(y1) - i64::from(y0) + 1)
.and_then(|n| usize::try_from(n).ok());
let Some(additional) = area.and_then(|n| n.checked_sub(1)) else {
return false;
};
if additional > *remaining {
return false;
}
// Keep Y in the inner loop for X-facing planes. This keeps
// consecutive probes in one chunk column. Charge only probes
// actually visited when a gap short-circuits the rectangle.
let covered = if across[0] == 1 {
(y0..=y1).all(|y| {
pos[across[1]] = y;
(x0..=x1).all(|x| {
if x == center_x && y == center_y {
return true;
}
*remaining -= 1;
pos[across[0]] = x;
opaque(pos)
})
})
} else {
(x0..=x1).all(|x| {
pos[across[0]] = x;
(y0..=y1).all(|y| {
if x == center_x && y == center_y {
return true;
}
*remaining -= 1;
pos[across[1]] = y;
opaque(pos)
})
})
};
if covered {
return true;
}
}
let Some(next) = cell.checked_add(step) else {
return false;
};
cell = next;
}
}
}
fn floor_cell(value: f64) -> Option<i32> {
let n = value.floor();
(n.is_finite() && n >= f64::from(i32::MIN) && n <= f64::from(i32::MAX)).then_some(n as i32)
}
#[cfg(test)]
#[path = "voxel_occlusion_tests.rs"]
mod tests;
@@ -0,0 +1,183 @@
use super::*;
use std::cell::Cell;
fn vertex(pos: [f32; 3]) -> crate::mesher::Vertex {
crate::mesher::Vertex {
pos,
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0; 4],
glint_uv: [0.0; 2],
}
}
fn bits(bounds: ([f64; 3], [f64; 3])) -> ([u64; 3], [u64; 3]) {
(bounds.0.map(f64::to_bits), bounds.1.map(f64::to_bits))
}
fn per_vertex_reference(
camera: [f64; 3],
vertices: &[crate::mesher::Vertex],
start: usize,
) -> Option<([f64; 3], [f64; 3])> {
let points = vertices.get(start..)?;
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
if points.is_empty() {
return None;
}
for vertex in points {
for axis in 0..3 {
let value = f64::from(vertex.pos[axis]) + camera[axis];
if !value.is_finite() {
return None;
}
min[axis] = min[axis].min(value);
max[axis] = max[axis].max(value);
}
}
Some((min.map(|value| value - 0.05), max.map(|value| value + 0.05)))
}
#[test]
fn bounds_match_per_vertex_reference_bit_for_bit() {
let camera = [12.25, -64.5, 31.75];
let vertices = [
vertex([500.0, 500.0, 500.0]),
vertex([-1.25, 2.5, -0.125]),
vertex([3.75, -4.5, 1.0]),
vertex([0.5, 0.25, 8.0]),
];
let actual = Culler::bounds(camera, &vertices, 1).unwrap();
let expected = per_vertex_reference(camera, &vertices, 1).unwrap();
assert_eq!(bits(actual), bits(expected));
let signed_zero_vertices = [vertex([0.0; 3]), vertex([-0.0; 3])];
let actual = Culler::bounds([0.0; 3], &signed_zero_vertices, 0).unwrap();
let expected = per_vertex_reference([0.0; 3], &signed_zero_vertices, 0).unwrap();
assert_eq!(bits(actual), bits(expected));
}
#[test]
fn bounds_fail_open_for_empty_and_non_finite_geometry() {
assert_eq!(Culler::bounds([0.0; 3], &[], 0), None);
assert_eq!(Culler::bounds([0.0; 3], &[vertex([1.0; 3])], 1), None);
for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert_eq!(
Culler::bounds([0.0; 3], &[vertex([value, 0.0, 0.0])], 0),
None
);
}
assert_eq!(
Culler::bounds(
[0.0; 3],
&[vertex([1.0; 3]), vertex([0.0, f32::NAN, 0.0])],
0
),
None
);
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(
Culler::bounds([value, 0.0, 0.0], &[vertex([1.0; 3])], 0),
None
);
}
}
#[test]
fn bounds_handle_a_single_vertex_and_nonzero_start() {
let vertices = [vertex([-100.0; 3]), vertex([1.25, -2.5, 4.0])];
let actual = Culler::bounds([10.0, 20.0, 30.0], &vertices, 1).unwrap();
assert_eq!(
bits(actual),
bits(([11.2, 17.45, 33.95], [11.3, 17.55, 34.05]))
);
}
#[test]
fn completed_mesh_culling_preserves_prior_geometry_and_protruding_parts() {
let prior = vertex([0.0; 3]);
let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
let wall = |p: [i32; 3]| p[0] == 4 && (-1..=0).contains(&p[1]);
let mut hidden = vec![prior];
hidden.extend(body);
assert!(Culler::new([0.0; 3], 1_000).cull_vertices(&mut hidden, 1, wall));
assert_eq!(hidden.len(), 1);
assert_eq!(hidden[0].pos, prior.pos);
// A held item or animated part beyond the wall keeps the whole model.
let mut exposed = vec![prior];
exposed.extend(body);
exposed.push(vertex([8.0, 5.0, 0.0]));
let before = exposed.clone();
assert!(!Culler::new([0.0; 3], 1_000).cull_vertices(&mut exposed, 1, wall));
assert_eq!(
bytemuck::cast_slice::<_, u8>(&exposed),
bytemuck::cast_slice::<_, u8>(&before)
);
assert!(!Culler::new([0.0; 3], 0).cull_vertices(&mut exposed, 1, |_| panic!()));
}
#[test]
fn opaque_walls_hide_bounds_in_all_directions() {
for axis in 0..3 {
for sign in [-1.0, 1.0] {
let mut min = [-2.0; 3];
let mut max = [2.0; 3];
min[axis] = if sign > 0.0 { 8.0 } else { -10.0 };
max[axis] = if sign > 0.0 { 10.0 } else { -8.0 };
let wall = if sign > 0.0 { 4 } else { -5 };
assert!(Culler::new([0.0; 3], 1_000).occluded(min, max, |p| p[axis] == wall));
}
}
}
#[test]
fn windows_slits_and_partial_walls_stay_visible() {
let min = [8.0, -4.0, -4.0];
let max = [10.0, 4.0, 4.0];
for opening in 0..3 {
assert!(!Culler::new([0.0; 3], 1_000).occluded(min, max, |p| {
p[0] == 4
&& match opening {
0 => !(p[1] == 1 && p[2] == 1),
1 => p[1] != 1,
_ => p[1] < 0,
}
}));
}
assert!(!Culler::new([0.0; 3], 1_000).occluded(min, max, |_| false));
}
#[test]
fn budgets_are_shared_and_exhaustion_fails_open() {
let culler = Culler::new([0.0; 3], 3);
let calls = Cell::new(0);
for _ in 0..2 {
assert!(!culler.occluded([8.0; 3], [10.0; 3], |_| {
calls.set(calls.get() + 1);
false
}));
}
assert_eq!(calls.get(), 3);
assert!(!Culler::new([0.0; 3], 0).occluded([8.0; 3], [10.0; 3], |_| panic!()));
// This plane projects to four cells: four lookups prove it covered, while a
// budget of one runs out first.
assert!(Culler::new([0.0; 3], 4).occluded([8.0, -4.0, -4.0], [10.0, 4.0, 4.0], |_| true));
assert!(!Culler::new([0.0; 3], 1).occluded([8.0, -4.0, -4.0], [10.0, 4.0, 4.0], |_| true));
}
#[test]
fn camera_inside_invalid_and_target_crossing_wall_stay_visible() {
let culler = Culler::new([0.0; 3], 1_000);
assert!(!culler.occluded([-1.0; 3], [1.0; 3], |_| panic!()));
assert!(!culler.occluded([f64::NAN; 3], [1.0; 3], |_| panic!()));
assert!(!culler.occluded([2.0; 3], [1.0; 3], |_| panic!()));
assert!(!culler.occluded([4.0, -1.0, -1.0], [8.0, 1.0, 1.0], |p| p[0] == 4));
assert!(!Culler::new([f64::INFINITY; 3], 1_000).occluded([8.0; 3], [10.0; 3], |_| panic!()));
assert!(!Culler::new([f64::MAX; 3], 1_000).occluded([-f64::MAX; 3], [-1.0; 3], |_| panic!()));
}
+166 -17
View File
@@ -374,11 +374,22 @@ pub struct MobEffect {
}
/// the vanilla entity types a mod's type may copy: thrown item
/// projectiles (`ThrowableItemProjectile`).
pub const ENTITY_TEMPLATES: [&str; 1] = ["minecraft:snowball"];
/// projectiles (`ThrowableItemProjectile`), then living mobs
/// ([`LIVING_TEMPLATES`]).
pub const ENTITY_TEMPLATES: [&str; 3] = [
"minecraft:snowball",
"minecraft:villager",
"minecraft:zombie",
];
/// an entity type the mod adds: a copy of a vanilla template's, thrown
/// from `item` and drawn as it.
/// the [`ENTITY_TEMPLATES`] that are living mobs: a type copying one is a mob
/// of its template's class (NeoForge's `EntityType.Builder.of(Zombie::new,
/// ...)`), with its own default attributes (`EntityAttributeCreationEvent`).
pub const LIVING_TEMPLATES: [&str; 2] = ["minecraft:villager", "minecraft:zombie"];
/// an entity type the mod adds: a copy of a vanilla template's. A thrown one
/// is thrown from `item` and drawn as it; a living one is its template's mob
/// with other `attributes`.
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct EntityType {
@@ -387,11 +398,26 @@ pub struct EntityType {
pub id: String,
/// one of [`ENTITY_TEMPLATES`].
pub template: String,
/// one of the mod's own items, which throws it.
pub item: String,
/// what a hit deals to an entity, as `thrown` damage.
/// a thrown type's: one of the mod's own items, which throws it. A
/// living type has none.
#[serde(default)]
pub item: Option<String>,
/// a thrown type's: what a hit deals to an entity, as `thrown` damage.
#[serde(default)]
pub damage: f32,
/// a living type's default attribute values that differ from its
/// template's, by attribute id (`minecraft:max_health`): the template's
/// own attributes only, checked against its row in `entity-types.json`
/// when the content root builds.
#[serde(default)]
pub attributes: BTreeMap<String, f64>,
}
impl EntityType {
/// whether the type copies a living mob ([`LIVING_TEMPLATES`]).
pub fn is_living(&self) -> bool {
LIVING_TEMPLATES.contains(&self.template.as_str())
}
}
/// a menu type the mod adds (vanilla's `MenuType`, NeoForge's
@@ -837,17 +863,55 @@ impl Content {
file.template
));
}
// its own item, so no vanilla item loses its use to it.
if !ids.contains(&file.item) {
if file.is_living() {
// a mob is spawned, not thrown: nothing throws it, and its
// attack is its `minecraft:attack_damage`.
if file.item.is_some() || file.damage != 0.0 {
return Err(format!(
"{name}: a living type has no item or damage (its template is {})",
file.template
));
}
for (attribute, value) in &file.attributes {
let id_like = attribute
.split_once(':')
.is_some_and(|(n, p)| !n.is_empty() && !p.is_empty());
if !id_like {
return Err(format!(
"{name}: attribute {attribute:?} is an attribute id (namespace:path)"
));
}
if !value.is_finite() {
return Err(format!("{name}: attribute {attribute} is a finite number"));
}
}
self.entity_types.push(EntityType { id: kind, ..file });
continue;
}
if !file.attributes.is_empty() {
return Err(format!(
"{name}: item {:?} is not one of this mod's items",
file.item
"{name}: only a living type has attributes (its template is {})",
file.template
));
}
if self.entity_types.iter().any(|t| t.item == file.item) {
let Some(item) = &file.item else {
return Err(format!(
"{name}: item {:?} already throws another entity type",
file.item
"{name}: a thrown type names the item that throws it"
));
};
// its own item, so no vanilla item loses its use to it.
if !ids.contains(item) {
return Err(format!(
"{name}: item {item:?} is not one of this mod's items"
));
}
if self
.entity_types
.iter()
.any(|t| t.item.as_ref() == Some(item))
{
return Err(format!(
"{name}: item {item:?} already throws another entity type"
));
}
if !(file.damage.is_finite() && file.damage >= 0.0) {
@@ -1537,13 +1601,13 @@ capabilities = ["registries"]
let types: Vec<_> = content
.entity_types
.iter()
.map(|t| (t.id.as_str(), t.item.as_str(), t.damage))
.map(|t| (t.id.as_str(), t.item.as_deref(), t.damage))
.collect();
assert_eq!(
types,
[
("ruby:bolt", "ruby:gem", 2.5),
("ruby:puff", "ruby:dust", 0.0)
("ruby:bolt", Some("ruby:gem"), 2.5),
("ruby:puff", Some("ruby:dust"), 0.0)
]
);
let snowball = |rest: &str| format!(r#"{{"template": "minecraft:snowball"{rest}}}"#);
@@ -1573,6 +1637,16 @@ capabilities = ["registries"]
snowball(r#", "item": "ruby:gem", "size": 1"#),
"unknown field",
),
(
"type-no-item",
snowball(""),
"names the item that throws it",
),
(
"type-thrown-attributes",
snowball(r#", "item": "ruby:gem", "attributes": {"minecraft:max_health": 4}"#),
"only a living type has attributes",
),
] {
let files: &[(&str, &[u8])] = &[
("data/ruby/horizon/item/gem.json", ITEM),
@@ -1591,6 +1665,81 @@ capabilities = ["registries"]
assert!(e.contains("already throws another entity type"), "{e}");
}
#[test]
fn living_entity_types_copy_a_mob_with_their_own_attributes() {
let files: &[(&str, &[u8])] = &[
(
"data/ruby/horizon/entity_type/ghoul.json",
br#"{"template": "minecraft:zombie",
"attributes": {"minecraft:max_health": 40, "minecraft:movement_speed": 0.3}}"#,
),
(
"data/ruby/horizon/entity_type/settler.json",
br#"{"template": "minecraft:villager"}"#,
),
];
let content = hmod("living", MANIFEST_TEXT, files).unwrap();
let types: Vec<_> = content
.entity_types
.iter()
.map(|t| {
let attributes: Vec<_> =
t.attributes.iter().map(|(a, v)| (a.as_str(), *v)).collect();
(
t.id.as_str(),
t.template.as_str(),
t.is_living(),
attributes,
)
})
.collect();
assert_eq!(
types,
[
(
"ruby:ghoul",
"minecraft:zombie",
true,
vec![
("minecraft:max_health", 40.0),
("minecraft:movement_speed", 0.3)
]
),
("ruby:settler", "minecraft:villager", true, vec![]),
]
);
let zombie = |rest: &str| format!(r#"{{"template": "minecraft:zombie"{rest}}}"#);
for (case, file, why) in [
(
"living-item",
zombie(r#", "item": "ruby:gem""#),
"a living type has no item or damage",
),
(
"living-damage",
zombie(r#", "damage": 2"#),
"a living type has no item or damage",
),
(
"living-attribute-id",
zombie(r#", "attributes": {"max_health": 40}"#),
"is an attribute id",
),
(
"living-attribute-value",
zombie(r#", "attributes": {"minecraft:max_health": "40"}"#),
"invalid type",
),
] {
let files: &[(&str, &[u8])] = &[
("data/ruby/horizon/item/gem.json", ITEM),
("data/ruby/horizon/entity_type/a.json", file.as_bytes()),
];
let e = hmod(case, MANIFEST_TEXT, files).unwrap_err();
assert!(e.contains(why), "{case}: {e}");
}
}
#[test]
fn a_block_may_occlude_nothing() {
let block = |occludes: &str| {
+45 -19
View File
@@ -119,6 +119,8 @@ impl Config {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Counts {
pub hosts: usize,
/// Bridges open, and joins matched with a host and waiting for its attach (each holds one of
/// `max_bridges` from the match on).
pub bridges: usize,
pub registrations: u64,
pub joins: u64,
@@ -502,8 +504,28 @@ fn serve_host(
Ok(())
}
enum Lookup {
Found(Sender<Out>),
/// One of `max_bridges`, reserved when a guest is matched with its host and given back when it drops:
/// on every way out of the join (no attach, a refusal, an error, a panic) and at the bridge's end. A
/// reservation counts from the match, not from the bridge, so joins waiting for their host's attach
/// can't add up past the cap.
struct BridgeSlot<'a>(&'a AtomicUsize);
impl<'a> BridgeSlot<'a> {
fn take(count: &'a AtomicUsize, max: usize) -> Option<Self> {
if count.fetch_add(1, Ordering::AcqRel) >= max {
count.fetch_sub(1, Ordering::AcqRel);
return None;
}
Some(Self(count))
}
}
impl Drop for BridgeSlot<'_> {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::AcqRel);
}
}
enum Lookup<'a> {
Found(Sender<Out>, BridgeSlot<'a>),
Unknown,
Ended,
Version(String),
@@ -538,18 +560,20 @@ fn join(
.ended
.get(&nameplate)
.is_some_and(|t| now.saturating_duration_since(*t) < state.config.ended_kept);
let busy = state.bridges.load(Ordering::Relaxed) >= state.config.max_bridges;
let lookup = match inner.hosts.get_mut(&nameplate) {
None if ended => Lookup::Ended,
None => Lookup::Unknown,
Some(h) if h.protocol != protocol => Lookup::Version(h.game.clone()),
Some(h) if busy || h.pending >= state.config.max_pending_per_host => Lookup::Full,
Some(h) => {
h.pending += 1;
Lookup::Found(h.to_host.clone())
}
Some(h) if h.pending >= state.config.max_pending_per_host => Lookup::Full,
Some(h) => match BridgeSlot::take(&state.bridges, state.config.max_bridges) {
Some(slot) => {
h.pending += 1;
Lookup::Found(h.to_host.clone(), slot)
}
None => Lookup::Full,
},
};
if matches!(lookup, Lookup::Found(_)) {
if matches!(lookup, Lookup::Found(..)) {
inner.pending.insert(
session,
Pending {
@@ -560,8 +584,8 @@ fn join(
}
lookup
};
let to_host = match lookup {
Lookup::Found(tx) => tx,
let (to_host, slot) = match lookup {
Lookup::Found(tx, slot) => (tx, slot),
Lookup::Unknown => {
state.unknown.fetch_add(1, Ordering::Relaxed);
locked(&state.limiter).unknown(peer.ip(), now);
@@ -590,8 +614,11 @@ fn join(
}
}
match attached {
Ok((host, host_peer)) => bridge(state, stream, peer, host, host_peer, session),
Err(_) => refuse(&mut stream, ErrorCode::HostUnreachable),
Ok((host, host_peer)) => bridge(state, stream, peer, host, host_peer, session, slot),
Err(_) => {
drop(slot);
refuse(&mut stream, ErrorCode::HostUnreachable)
}
}
}
@@ -627,7 +654,8 @@ fn attach(
}
}
/// Tell both ends they are bridged, then copy each way until either ends.
/// Tell both ends they are bridged, then copy each way until either ends. `_slot` (the guest's
/// reservation of one of `max_bridges`) is given back when this returns, however it returns.
fn bridge(
state: &Arc<State>,
mut guest: TcpStream,
@@ -635,9 +663,9 @@ fn bridge(
mut host: TcpStream,
host_peer: SocketAddr,
session: u64,
_slot: BridgeSlot<'_>,
) -> io::Result<()> {
state.bridges.fetch_add(1, Ordering::Relaxed);
let result = (|| -> io::Result<()> {
(|| -> io::Result<()> {
wire::send(
&mut guest,
&FromRelay::Bridged {
@@ -663,9 +691,7 @@ fn bridge(
copy(state, host, guest);
let _ = up.join();
Ok(())
})();
state.bridges.fetch_sub(1, Ordering::Relaxed);
result
})()
}
fn copy(state: &State, mut from: TcpStream, mut to: TcpStream) {
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more