test: the scene motion corpus resolves the player against entity boxes

`MotionWorld.entities` was already real — `EntityCollisions` inflates the
entity query by 1e-7 and turns each box into a collision shape — but nothing
ever reached it: `VanillaSceneMotionOracle.Level.getEntityCollisions` returned
`List.of()` and the probe passed `entities: &[]`, so no fixture resolved the
player against another body's box.

`entity_box_corpus` adds 29 fixtures of boxes: a wall whose near face sits on
the player's leading face plus or minus the amounts the 1e-7 inflation decides
between (0, 5e-8, 1e-7, 2e-7, 1e-4, signed zero, half a block), a box low
enough to step onto and ones too tall, a box the player starts inside, a gap
narrower than the player, diagonal and sprinting approaches, and boxes met
while jumping and overhead. The oracle answers
`getEntityCollisions` from them the way `ColliderGetter` does: the
`getSize() < 1e-7` shortcut, `bounds.inflate(1e-7)`, then one
`Shapes.create(box)` per intersecting body.

Dropping the slice again fails at output line 4263: vanilla stops at the wall
with `horizontalCollision` true and z held at 4039800000000000, the probe
walks through with z 40398504816ec226. With the slice the suite matches at
2,853,483 states per side.

The corpus claims collisions only. `LivingEntity.pushEntities` gives the
bodies a velocity change and can cram the player, and the snapshot carries
player state alone, so `getPushableEntities` stays empty and nothing here
claims the pushing half.
This commit is contained in:
MSedgeMC committed 2026-11-03 16:03:10 +00:00
1 parent 47c09e613f
commit bd143d0b43
3 files changed
+79 -2

No files matched your search

@@ -7,6 +7,7 @@ use horizon_client::{
motion::{LAVA, MotionEvent, MotionResources, MotionWorld, PlayerMotion, WATER},
world::World,
};
use horizon_core::geometry::Aabb;
use horizon_core::geometry::Vec3;
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::attributes::{Modifier, Operation};
@@ -299,9 +300,25 @@ pub fn main() -> Result<()> {
// portal_tick that went through.
let mut portal = horizon_client::nether_portal::PortalState::default();
let mut travel: Option<horizon_core::position::BlockPos> = None;
// The fixture's other entities, as the boxes `Level.getEntityCollisions`
// selects from: the resident scene holds only the player.
let entities: Vec<Aabb> = f["entities"]
.as_array()
.map(|boxes| {
boxes
.iter()
.map(|b| {
Aabb::new(
Vec3::new(number(&b[0]), number(&b[1]), number(&b[2])),
Vec3::new(number(&b[3]), number(&b[4]), number(&b[5])),
)
})
.collect()
})
.unwrap_or_default();
for op in f["ops"].as_array().unwrap() {
let mut world = MotionWorld {
entities: &[],
entities: &entities,
player: &mut player,
scene: &mut scene,
assets: &assets,
@@ -65,6 +65,7 @@ public final class VanillaSceneMotionOracle {
@Override public void recordDamage(DamageSource source,float amount){actor.events.add(String.format("record-damage %08x",Float.floatToIntBits(amount)));}
}
static float single(JsonElement v){return Float.intBitsToFloat(Integer.parseUnsignedInt(v.getAsString(),16));}
static double dbl(JsonElement v){return Double.longBitsToDouble(Long.parseUnsignedLong(v.getAsString(),16));}
static void write(Field f,Object o,Object value){try{f.set(o,value);}catch(Exception e){throw new AssertionError(e);}}
static Field field(Class<?> c,String n)throws Exception{var f=c.getDeclaredField(n);f.setAccessible(true);return f;}
static String blockPos(BlockPos p){return p.getX()+","+p.getY()+","+p.getZ();}
@@ -125,7 +126,20 @@ public final class VanillaSceneMotionOracle {
@Override public void addParticle(net.minecraft.core.particles.ParticleOptions particle,double x,double y,double z,double xd,double yd,double zd){
actor.events.add("splash-particle "+(particle==net.minecraft.core.particles.ParticleTypes.BUBBLE?"bubble":particle==net.minecraft.core.particles.ParticleTypes.SPLASH?"splash":"unknown")+" "+vectorBits(new Vec3(x,y,z))+" "+vectorBits(new Vec3(xd,yd,zd)));
}
@Override public List<VoxelShape> getEntityCollisions(Entity e,AABB b){return List.of();}
// CollisionGetter.getEntityCollisions over the fixture's other entities,
// which the scene holds only as boxes: the 1e-7 query inflation, the
// degenerate-bounds shortcut and the per-entity shape, without needing
// Entity objects for bodies whose velocity the scene never compares.
// getPushableEntities stays empty: LivingEntity.pushEntities would push
// those bodies (and can cram the player), and the probe carries player
// state only, so the corpus does not claim the pushing half.
List<AABB> boxes=new ArrayList<>();
@Override public List<VoxelShape> getEntityCollisions(Entity e,AABB b){
if(b.getSize()<1e-7)return List.of();
var shapes=new ArrayList<VoxelShape>();
for(var box:boxes)if(box.intersects(b.inflate(1e-7)))shapes.add(Shapes.create(box));
return shapes;
}
@Override public List<Entity> getPushableEntities(Entity e,AABB b){return List.of();}
@Override public List<Entity> getEntities(Entity e,AABB b){return e!=actor&&b.intersects(actor.getBoundingBox())?List.of(actor):List.of();}
@Override public WorldBorder getWorldBorder(){return border;}
@@ -391,6 +405,9 @@ public final class VanillaSceneMotionOracle {
items[healthKey.id()]=p.healthItem=new SynchedEntityData.DataItem<Float>(healthKey,1f);items[absorptionKey.id()]=p.absorptionItem=new SynchedEntityData.DataItem<Float>(absorptionKey,0f);
write(field(Entity.class,"entityData"),p,dataCtor.newInstance(p,items));p.setHealth(p.getMaxHealth());p.events.clear();level.time=0;level.rules.regenerate=true;level.difficulty=net.minecraft.world.Difficulty.NORMAL;
p.setDeltaMovement(Vec3.ZERO);p.setPos(VanillaSceneCollisionOracle.vector(row.get("position")));
// The fixture's other entities as boxes: min xyz then max xyz, exact bits.
level.boxes=new ArrayList<>();
if(row.has("entities"))for(var box:row.getAsJsonArray("entities")){var c=box.getAsJsonArray();level.boxes.add(new AABB(dbl(c.get(0)),dbl(c.get(1)),dbl(c.get(2)),dbl(c.get(3)),dbl(c.get(4)),dbl(c.get(5))));}
for(var value:row.getAsJsonArray("ops")){
var op=value.getAsJsonObject();switch(op.get("mode").getAsString()){
case "shift"->p.setShiftKeyDown(op.get("value").getAsBoolean());
+43
View File
@@ -480,8 +480,51 @@ def swim_corpus():
ops+=[op('air_tick') for _ in range(40)]+[tick() for _ in range(40)]+clear((21,4,21),(29,7,29))+fill('stone',(25,3,25),(25,3,25))
yield dict(position=vec((25.5,5,25.5)),ops=ops)
def entity_box_corpus():
"""Other entities as boxes the player walks into. `Level.getEntityCollisions`
queries with the box inflated by 1e-7, so the corpus puts shapes exactly on
that boundary; `Entity.move` then resolves against them like block shapes.
The bodies themselves are not simulated: the snapshot carries player state,
and LivingEntity.pushEntities (their velocity, cramming) is out of scope."""
def tick(direction=(0,0,.98),jumping=False):
return op('simulation_tick',vec(direction),jumping=jumping)
def box(minimum,maximum):
return dict(entities=[vec(minimum)+vec(maximum)])
# A one-block wall in front of a player walking along +z, its near face put
# at the player's leading face (z 25.8) plus or minus the amounts the 1e-7
# query inflation decides between: strict AABB intersection keeps the wall
# out at 0 and 5e-8, and only the inflation admits it there.
for delta in [-1e-4,-2e-7,-1e-7,-5e-8,-0.,0.,5e-8,1e-7,2e-7,1e-4,1.]:
z=25.8+delta
yield dict(position=vec((25.5,4,25.5)),**box((25,4,z),(26,6,z+1)),
ops=[op('speed',single(.2))]+[tick() for _ in range(30)])
# A low box the player steps onto, and the same box too tall to step.
for height in [.5,.6,1.]:
yield dict(position=vec((25.5,4,25.5)),**box((24,4,26),(27,4+height,27)),
ops=[op('speed',single(.2))]+[tick() for _ in range(30)])
# A box the player starts inside: Entity.move's collect-collisions path with
# a shape that already overlaps the moving bounds.
for inset in [0.,.25,.5,1.]:
yield dict(position=vec((25.5,4,25.5)),**box((25.5-inset,4,25.5-inset),(25.5+inset,6,25.5+inset)),
ops=[op('speed',single(.2))]+[tick() for _ in range(20)]+[tick((0,0,-.98)) for _ in range(20)])
# Two boxes leaving a gap narrower than the player, and a diagonal approach
# into their corner (one axis blocked, the other sliding).
for width in [0.,.2,.5]:
yield dict(position=vec((25.5,4,20.5)),
entities=[vec((20,4,25)) + vec((26,6,26)), vec((20,4,26+width)) + vec((26,6,27+width))],
ops=[op('speed',single(.2)),op('yaw',single(0))]+[tick() for _ in range(60)])
for yaw in [0,45,90,225]:
yield dict(position=vec((25.5,4,24.5)),**box((24,4,26),(27,6,27)),
ops=[op('speed',single(.2)),op('yaw',single(yaw)),op('sprint',True)]+[tick((0,0,.98)) for _ in range(40)])
# A box that only meets the player while jumping over it, and one above the
# head so the jump is cut short.
for height in [.5,1.5,2.5,3.5]:
yield dict(position=vec((25.5,4,25.5)),entities=[vec((25,4,26))+vec((26,4+height,27)),vec((25,4+height+1.8,26))+vec((26,4+height+2.8,27))],
ops=[op('speed',single(.2))]+[tick(jumping=i%20==0) for i in range(60)])
def corpus():
yield from flight_corpus()
yield from entity_box_corpus()
yield from attribute_corpus(False)
yield from portal_corpus()
yield from pose_corpus(False)