Add random tick and redstone harnesses, wire detector rails and plates

Adds differential harnesses for random/scheduled ticks and redstone circuits on top of the block update oracle, and makes the oracle report pending block events as the deduplicated set vanilla keeps. The harness found that detector rails never sensed anything, so they now check for minecarts on place, tick and entity contact, and the client reports the player and minecarts to pressure plates, tripwires and detector rails each tick.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Sonnet 5.5 committed 2026-10-10 09:12:17 +02:00
1 parent 40802dd9af
commit bd9b873fcd
8 files changed
+227 -5

No files matched your search

+70 -3
View File
@@ -120,13 +120,16 @@ use horizon_client::{
vanillagen::VanillaTerrain,
world::World,
};
use horizon_core::{geometry::Direction, position::BlockPos};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Direction},
position::BlockPos,
};
use horizon_world::{
block_collision::BlockCollisionRegistry,
level::{
Behaviours, Dimension as LevelDimension, Landing, LevelAccess, LevelEvent, PlaceContext,
PlaceOutcome, PlayerContext, PlayerEffect, Ticks, flags as level_flags,
Behaviours, Dimension as LevelDimension, EntityQuery, Landing, LevelAccess, LevelEvent,
PlaceContext, PlaceOutcome, PlayerContext, PlayerEffect, Ticks, flags as level_flags,
},
registry::BlockStateId,
};
@@ -312,6 +315,8 @@ struct GameLevel<'w> {
raining: bool,
thundering: bool,
player_box: Option<([f64; 3], [f64; 3])>,
/// Minecart boxes (detector rails).
cart_boxes: Vec<([f64; 3], [f64; 3])>,
biomes: &'w Biomes,
seed: i64,
behaviours: &'w Behaviours,
@@ -444,6 +449,22 @@ impl LevelAccess for GameLevel<'_> {
&& z < hi[2] - 1e-7
&& z + 1.0 > lo[2] + 1e-7)
}
/// Entities that trigger pressure plates, tripwires and detector rails:
/// the player (a mob) and the minecarts.
fn count_entities(&self, area: Aabb, query: EntityQuery) -> usize {
let hit = |(lo, hi): &([f64; 3], [f64; 3])| {
area.intersects(Aabb::new(
Vec3::new(lo[0], lo[1], lo[2]),
Vec3::new(hi[0], hi[1], hi[2]),
))
};
let carts = || self.cart_boxes.iter().filter(|b| hit(b)).count();
match query {
EntityQuery::Minecart => carts(),
EntityQuery::Mobs => self.player_box.iter().filter(|b| hit(b)).count(),
EntityQuery::Everything => self.player_box.iter().filter(|b| hit(b)).count() + carts(),
}
}
fn emit(&mut self, event: LevelEvent) {
self.events.push(event);
}
@@ -1191,6 +1212,17 @@ impl Game<'_> {
],
)
});
let cart_boxes = self
.carts
.iter()
.map(|c| {
let p = c.position;
(
[p.x - 0.49, p.y, p.z - 0.49],
[p.x + 0.49, p.y + 0.7, p.z + 0.49],
)
})
.collect();
let dimension = match self.dim {
Dim::Overworld => LevelDimension::Overworld,
Dim::Nether => LevelDimension::Nether,
@@ -1198,6 +1230,7 @@ impl Game<'_> {
};
let rules = self.level_rules();
let mut level = GameLevel {
cart_boxes,
world: &mut self.world,
facts: &self.res.facts,
events: &mut self.level_events,
@@ -1266,7 +1299,41 @@ impl Game<'_> {
.collect();
ticking.push((x, z, sections));
}
// Entity.checkInsideBlocks for the plates, tripwires and detector rails
// under the player and the minecarts.
let mut inside = Vec::new();
let boxes = self
.player
.iter()
.filter(|_| !self.spectator())
.map(|p| {
let w = f64::from(p.dimensions.width) / 2.0;
let h = f64::from(p.dimensions.height);
(
[p.position.x - w, p.position.y, p.position.z - w],
[p.position.x + w, p.position.y + h, p.position.z + w],
)
})
.chain(self.carts.iter().map(|c| {
let p = c.position;
(
[p.x - 0.49, p.y, p.z - 0.49],
[p.x + 0.49, p.y + 0.7, p.z + 0.49],
)
}));
for (lo, hi) in boxes {
for x in (lo[0] + 1e-7).floor() as i32..=(hi[0] - 1e-7).floor() as i32 {
for y in (lo[1] + 1e-7).floor() as i32..=(hi[1] - 1e-7).floor() as i32 {
for z in (lo[2] + 1e-7).floor() as i32..=(hi[2] - 1e-7).floor() as i32 {
inside.push(BlockPos::new(x, y, z));
}
}
}
}
self.with_level(|b, level, ticks| {
for pos in inside {
b.entity_inside(level, ticks, pos);
}
b.tick_scheduled(level, ticks);
for (x, z, sections) in ticking {
b.tick_chunk_random(
+8 -1
View File
@@ -90,7 +90,10 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
Class::BaseFire => self.base_fire_on_place(pos, state, same),
Class::DetectorRail | Class::BaseRail => {
if !same {
self.rail_update_state(pos, state, moved);
let updated = self.rail_update_state(pos, state, moved);
if class == Class::DetectorRail {
self.detector_rail_check_pressed(pos, updated);
}
}
}
Class::Brushable => {
@@ -416,6 +419,10 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
if signal == 0 {
self.plate_check_pressed(pos, state, signal);
}
} else if data.is(Class::DetectorRail) {
if !b.table.bool(state, b.props.powered) {
self.detector_rail_check_pressed(pos, state);
}
} else if data.is(Class::TripWire)
&& !b.table.bool(state, b.props.powered)
&& !self.ticks.has_block_tick(pos, b.block(state))
+2
View File
@@ -196,6 +196,8 @@ pub enum EntityQuery {
Everything,
/// Living entities only (stone plates).
Mobs,
/// Minecarts only (detector rails).
Minecart,
}
/// Side effects reported to the caller, in vanilla call order.
+34
View File
@@ -515,6 +515,40 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
}
}
}
/// `DetectorRailBlock.checkPressed`.
pub(crate) fn detector_rail_check_pressed(&mut self, pos: BlockPos, state: BlockStateId) {
let b = self.b;
if !self.can_survive(state, pos) || !b.is_block(self.state(pos), b.block(state)) {
return;
}
let was = b.table.bool(state, b.props.powered);
// getInteractingMinecartOfType: the 0.2-inset search box.
let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z));
let area = Aabb::new(
Vec3::new(x + 0.2, y, z + 0.2),
Vec3::new(x + 0.8, y + 0.8, z + 0.8),
);
let pressed = self
.level
.count_entities(area, super::EntityQuery::Minecart)
> 0;
let block = b.block(state);
if pressed != was {
let next = b.table.set_bool(state, b.props.powered, pressed);
self.set_block_and_update(pos, next);
let rail = self.rail_new(pos, next);
for connection in rail.connections {
let at = self.state(connection);
self.neighbor_changed_full(at, connection, b.block(at), false);
}
self.update_neighbors_at(pos, block);
self.update_neighbors_at(below(pos), block);
}
if pressed {
self.schedule_tick(pos, block, 20);
}
self.update_neighbour_for_output_signal(pos, block);
}
pub(crate) fn tripwire_tick(&mut self, pos: BlockPos) {
let b = self.b;
let state = self.state(pos);
+5
View File
@@ -52,6 +52,11 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
self.plate_check_pressed(pos, state, signal);
}
}
Class::DetectorRail => {
if b.table.bool(state, b.props.powered) {
self.detector_rail_check_pressed(pos, state);
}
}
Class::BigDripleaf => self.big_dripleaf_tick(pos, state),
Class::AbstractCauldron => self.cauldron_tick(state, pos),
Class::BubbleColumn => {
@@ -117,6 +117,7 @@ public final class VanillaBlockUpdateOracle {
World() {
super(null, null, null, null, null, null, false, 0L, List.of(), false);
}
java.util.LinkedHashSet<String> blockEvents;
void emit(String line) {
log.append(line).append('\n');
}
@@ -173,7 +174,7 @@ public final class VanillaBlockUpdateOracle {
emit("G " + pos(BlockPos.containing(p)) + " " + e.getRegisteredName());
}
@Override public <T extends net.minecraft.core.particles.ParticleOptions> int sendParticles(T p, double x, double y, double z, int n, double a, double b, double c, double s) { return 0; }
@Override public void blockEvent(BlockPos p, Block block, int a, int b) { emit("B " + pos(p) + " " + BuiltInRegistries.BLOCK.getKey(block) + " " + a + " " + b); }
@Override public void blockEvent(BlockPos p, Block block, int a, int b) { blockEvents.add("B " + pos(p) + " " + BuiltInRegistries.BLOCK.getKey(block) + " " + a + " " + b); }
@Override public void updatePOIOnBlockStateChange(BlockPos p, BlockState a, BlockState b) { }
@Override public boolean isPositionEntityTicking(BlockPos p) { return true; }
@Override public boolean shouldTickBlocksAt(BlockPos p) { return true; }
@@ -287,6 +288,7 @@ public final class VanillaBlockUpdateOracle {
World w = make(World.class);
w.cells = new HashMap<>();
w.log = new StringBuilder();
w.blockEvents = new java.util.LinkedHashSet<>();
for (var cell : f.getAsJsonArray("cells")) {
var c = cell.getAsJsonArray();
BlockState s = Block.stateById(c.get(3).getAsInt());
@@ -372,6 +374,7 @@ public final class VanillaBlockUpdateOracle {
for (String s : summary(w, before)) result.append(s).append('\n');
pending(w, result);
result.append(w.log);
for (String s : w.blockEvents) result.append(s).append('\n');
result.append("rng ").append(w.rng.nextLong()).append('\n');
}
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Compare vanilla random ticks (and scheduled ticks) with horizon-world, state by state.
Every block state is put at the origin of a random neighbourhood and `randomTick`
(or `tick`) is run with a seeded RNG on both sides; the resulting block changes,
scheduled ticks, side effects and the RNG fingerprint after the call must match.
"""
import random
try:
from . import block_update_harness as h
except ImportError:
import block_update_harness as h
def cases(facts, count, seed, only):
rng = random.Random(seed)
pool = h.state_pool(facts, only)
for n in range(count):
state = pool[n % len(pool)]
case = {**h.environment(rng), 'pos': [0, 0, 0], 'cells': facts.cells(rng, state),
'mode': 'random' if n % 4 else 'tick'}
yield case
def main():
args = h.argument_parser(__doc__).parse_args()
facts = h.load_facts()
differing = h.execute('random-tick-parity', list(cases(facts, args.count, args.seed, args.only)), args, facts)
h.finish(differing)
if __name__ == '__main__':
main()
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Compare vanilla redstone circuits with horizon-world, tick by tick.
A small floor carries randomly placed redstone components (wire, repeaters,
comparators, torches, observers, pistons, lamps, rails, plates, buttons...). A power
source is placed and later removed through `Level.setBlock` and the scheduled-tick
queue is run for N game ticks; the block changes and pending ticks after every
tick (and the RNG fingerprint at the end) must match.
"""
import random
try:
from . import block_update_harness as h
except ImportError:
import block_update_harness as h
COMPONENTS = [
'redstone_wire', 'redstone_wire', 'redstone_wire', 'redstone_wire', 'repeater', 'repeater', 'comparator', 'redstone_torch',
'redstone_wall_torch', 'observer', 'piston', 'sticky_piston', 'redstone_lamp', 'lever', 'stone_button', 'oak_button',
'stone_pressure_plate', 'note_block', 'target', 'dropper', 'dispenser', 'hopper', 'rail', 'powered_rail', 'activator_rail',
'detector_rail', 'oak_door', 'iron_door', 'oak_trapdoor', 'oak_fence_gate', 'tnt', 'slime_block', 'honey_block', 'stone',
'redstone_block', 'daylight_detector', 'trapped_chest', 'copper_bulb', 'sculk_sensor', 'tripwire_hook', 'lectern',
'oak_slab', 'iron_block', 'glass', 'crafter',
]
def build(facts, rng):
floor = facts.defaults['minecraft:stone']
names = [n for n in ('minecraft:' + c for c in COMPONENTS) if n in facts.by_block]
cells = []
for x in range(-4, 5):
for z in range(-4, 5):
cells.append([x, -1, z, floor])
if rng.random() < 0.5:
name = rng.choice(names)
states = facts.by_block[name]
state = rng.choice(states)
cells.append([x, 0, z, state])
if rng.random() < 0.15:
cells.append([x, 1, z, rng.choice(facts.by_block[rng.choice(names)])])
return cells
def cases(facts, count, seed, only):
rng = random.Random(seed)
source = facts.defaults['minecraft:redstone_block']
air = facts.defaults['minecraft:air']
for n in range(count):
cells = build(facts, rng)
at = [rng.randrange(-4, 5), 0, rng.randrange(-4, 5)]
cells = [c for c in cells if c[:3] != at]
cells.append([*at, air])
case = {**h.environment(rng), 'mode': 'run', 'pos': [0, -1, 0], 'cells': cells, 'ops': [
{'op': 'set', 'pos': at, 'state': source, 'flags': 3},
{'op': 'ticks', 'n': 12, 'snap': True},
{'op': 'set', 'pos': at, 'state': air, 'flags': 3},
{'op': 'ticks', 'n': 12, 'snap': True},
]}
case.pop('raining', None)
yield case
def main():
args = h.argument_parser(__doc__).parse_args()
args.count = min(args.count, 4000) if args.count == 40000 else args.count
facts = h.load_facts()
differing = h.execute('redstone-parity', list(cases(facts, args.count, args.seed, args.only)), args, facts)
h.finish(differing)
if __name__ == '__main__':
main()