Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 862c7e58de perf(hoppers): scan first after a write, build the columns on the second ask
The carts' own pass writes the cart list before every hopper cart's
question, so rebuilding the columns on each new stamp cost more than the
scan it replaced (ab3: tick_carts +0.1 to +2.1 ms). After a write, the
first question now scans as before; the second with nothing written in
between builds the columns. The block-entity pass still shares one build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:23:17 +01:00
Horizon agentandClaude Opus 5.5 3af552f880 perf(hoppers): rule out container entities by column before scanning
`entity_container_at` (a hopper or dropper with no block container next
to it looking for a chest/hopper cart or chest boat) scanned every cart
and boat for each question. Keep the block columns those entities' boxes
reach, rebuilt when either list's stamp changes, and answer "none" when
the query box's columns hold none. A box that meets the query shares a
point with it, so the answer is exact; the scan, its order and the
random pick are unchanged when a column matches.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:07:01 +01:00
2 changed files with 161 additions and 0 deletions

No files matched your search

+4
View File
@@ -3098,6 +3098,9 @@ struct Game<'a> {
mod_chunk_tickets: HashMap<Dim, HashMap<(i32, i32), std::collections::BTreeSet<String>>>,
/// Where item entities are, while the block entities tick (hoppers).
item_columns: Option<machines::ItemColumns>,
/// Where container carts and chest boats are (hoppers and droppers
/// looking for a container entity), kept until either list is written.
container_entity_columns: machines::ContainerEntityColumns,
/// Where the mobs are, while the block entities tick (potent sulfur gas).
pass_mobs: Option<potent_sulfur::PassMobs>,
/// Block-entity positions retained between ticks for client effects.
@@ -17521,6 +17524,7 @@ impl<'a> Game<'a> {
mod_container_viewers: HashMap::new(),
mod_chunk_tickets: HashMap::new(),
item_columns: None,
container_entity_columns: Default::default(),
pass_mobs: None,
spawner_tick_positions: Vec::new(),
trial_spawner_tick_positions: Vec::new(),
+157
View File
@@ -480,6 +480,73 @@ impl ItemColumns {
}
}
/// The block columns that container carts' and chest boats' boxes reach. The
/// lists' stamps say when they hold: after any write to either (a move, a
/// spawn, a removal, items put in) the first question scans as before, and
/// the second with nothing written in between builds them. A tick's hoppers
/// share one build while nothing moves; the carts' own pass, which writes the
/// carts between questions, never pays for one.
#[derive(Default)]
pub(super) struct ContainerEntityColumns {
/// The carts' and boats' stamps when last asked, and whether the columns
/// were built for them.
seen: Option<([horizon_client::join_seq::ListStamp; 2], bool)>,
cells: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
}
impl ContainerEntityColumns {
/// Whether a container entity's box may meet the unit box around `at`
/// (`entity_container_at`'s test). A box that meets it shares a point with
/// it, and that point's column is both in the box's columns and in the
/// query's, so `false` is exact.
pub(super) fn may_meet(
&mut self,
carts: &horizon_client::join_seq::JoinList<super::minecarts::Minecart>,
boats: &horizon_client::join_seq::JoinList<super::boats::Boat>,
at: [f64; 3],
) -> bool {
let stamps = [carts.stamp(), boats.stamp()];
match self.seen {
Some((seen, true)) if seen == stamps => {}
Some((seen, false)) if seen == stamps => {
self.seen = Some((stamps, true));
self.build(carts, boats);
}
_ => {
self.seen = Some((stamps, false));
return true;
}
}
let (x0, x1) = ((at[0] - 0.5).floor() as i32, (at[0] + 0.5).floor() as i32);
let (z0, z1) = ((at[2] - 0.5).floor() as i32, (at[2] + 0.5).floor() as i32);
(x0..=x1).any(|x| (z0..=z1).any(|z| self.cells.contains(&(x, z))))
}
fn build(
&mut self,
carts: &horizon_client::join_seq::JoinList<super::minecarts::Minecart>,
boats: &horizon_client::join_seq::JoinList<super::boats::Boat>,
) {
self.cells.clear();
let boxes = carts
.iter()
.filter(|c| {
matches!(
c.kind,
super::minecarts::CartKind::Chest | super::minecarts::CartKind::Hopper
)
})
.map(|c| c.bounds())
.chain(boats.iter().filter(|b| b.chest).map(|b| b.bounds()));
for b in boxes {
let (lo, hi) = (b.min_position(), b.max_position());
for cx in lo.x.floor() as i32..=hi.x.floor() as i32 {
for cz in lo.z.floor() as i32..=hi.z.floor() as i32 {
self.cells.insert((cx, cz));
}
}
}
}
}
fn bone_meal() -> ItemStack {
ItemStack {
item: "minecraft:bone_meal".into(),
@@ -1183,6 +1250,14 @@ impl Game<'_> {
&& lo.z < at[2] + 0.5
&& hi.z > at[2] - 0.5
};
// Most hoppers have no container entity next to them: the columns
// the query box spans rule that out without scanning every cart.
if !self
.container_entity_columns
.may_meet(&self.carts, &self.boats, at)
{
return None;
}
let mut found: Vec<u32> = self
.carts
.iter()
@@ -1666,6 +1741,88 @@ mod brewing_fuel_transfer_tests {
}
}
#[cfg(test)]
mod container_entity_column_tests {
use super::super::{
boats::Boat,
minecarts::{CartKind, Minecart},
};
use super::ContainerEntityColumns;
use horizon_client::join_seq::JoinList;
use horizon_core::geometry::Vec3;
/// `entity_container_at`'s scan: the container entities whose box meets
/// the unit box around `at`.
fn scan(carts: &[Minecart], boats: &[Boat], at: [f64; 3]) -> usize {
let meets = |b: horizon_core::geometry::Aabb| {
let (lo, hi) = (b.min_position(), b.max_position());
(0..3).all(|i| {
let (l, h) = ([lo.x, lo.y, lo.z][i], [hi.x, hi.y, hi.z][i]);
l < at[i] + 0.5 && h > at[i] - 0.5
})
};
carts
.iter()
.filter(|c| matches!(c.kind, CartKind::Chest | CartKind::Hopper) && meets(c.bounds()))
.count()
+ boats
.iter()
.filter(|b| b.chest && meets(b.bounds()))
.count()
}
#[test]
fn a_container_entity_is_never_ruled_out_and_a_move_is_seen() {
let mut seed = 7u64;
let mut next = |range: f64| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
};
let kinds = [CartKind::Chest, CartKind::Hopper, CartKind::Rideable];
let mut carts = JoinList::default();
let mut boats = JoinList::default();
for id in 0..60u32 {
let at = Vec3::new(next(24.0) - 12.0, 64.0, next(24.0) - 12.0);
carts.push(Minecart::new(id, kinds[id as usize % 3], at));
let boat = Boat::new(
100 + id,
"oak".into(),
Vec3::new(at.x + 3.0, at.y, at.z),
0.0,
);
boats.push(if id % 2 == 0 { boat.with_chest() } else { boat });
}
let mut columns = ContainerEntityColumns::default();
let (mut met, mut ruled_out) = (0, 0);
for x in -16..16 {
for z in -16..16 {
let at = [f64::from(x) + 0.5, 64.5, f64::from(z) + 0.5];
let found = scan(&carts, &boats, at);
if !columns.may_meet(&carts, &boats, at) {
assert_eq!(found, 0, "ruled out a container entity at {at:?}");
ruled_out += 1;
}
met += usize::from(found > 0);
}
}
assert!(met > 0 && ruled_out > 0, "met {met}, ruled out {ruled_out}");
// A chest cart moved somewhere empty is found there on the next question.
let far = [200.5, 64.5, 200.5];
assert!(!columns.may_meet(&carts, &boats, far));
let chest = carts
.iter()
.position(|c| c.kind == CartKind::Chest)
.unwrap();
carts[chest].position = Vec3::new(200.5, 64.0, 200.5);
assert_eq!(scan(&carts, &boats, far), 1);
// The first question after a write scans; the second builds.
assert!(columns.may_meet(&carts, &boats, far));
assert!(columns.may_meet(&carts, &boats, far));
assert!(!columns.may_meet(&carts, &boats, [-300.5, 64.5, 0.5]));
}
}
#[cfg(test)]
mod item_column_tests {
use super::ItemColumns;