Copper chests join into double chests

ChestBlock.combine pairs every ChestBlock, and copper chests in each
weathering state, waxed or not, are ChestBlocks. Horizon placed and drew
them as double chests but only paired chests and trapped chests when
opening a menu, so a double copper chest opened as one 27-slot half. Hoppers,
comparators and the spectator crosshair missed them the same way.

The pairing now lives in horizon_client::containers (is_chest_block,
ChestHalf) and follows DoubleBlockCombiner.combineWithNeigbour: the same
block, the other half's type and the same facing (the facing check was
missing for every chest). The menus, hoppers and the in-frame chest screen
use it.

New harness tools/test_chest_pair_parity.py (VanillaChestPairOracle,
chest_pair_fixture): 3660/3660 cases, 3640/3660 with the old rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
d3grandoandClaude Opus 5.5 committed 2026-10-29 11:41:57 +07:00
1 parent 62a01db8f0
commit 8bb8239603
8 files changed
+363 -78

No files matched your search

@@ -378,6 +378,58 @@ impl BlockEntity {
}
pub use crate::providers::Providers;
/// A `ChestBlock`, which `ChestBlock.combine` can join into a double chest:
/// chests, trapped chests and copper chests in every weathering state, waxed
/// or not (`CopperChestBlock` extends `ChestBlock`). Ender chests are
/// `EnderChestBlock`s and always single.
pub fn is_chest_block(name: &str) -> bool {
matches!(name, "minecraft:chest" | "minecraft:trapped_chest") || name.ends_with("copper_chest")
}
/// A chest block's name with its `type` and `facing` properties.
#[derive(Clone, Copy, Debug)]
pub struct ChestHalf<'a> {
pub block: &'a str,
pub kind: &'a str,
pub facing: &'a str,
}
impl ChestHalf<'_> {
/// `ChestBlock.getConnectedDirection` as an (x, z) step to the other
/// half: clockwise from the facing for a left half, counter-clockwise for
/// a right one. None for a single chest.
pub fn connected_step(&self) -> Option<(i32, i32)> {
let step = |facing: &str| match facing {
"north" => Some((0, -1)),
"south" => Some((0, 1)),
"west" => Some((-1, 0)),
"east" => Some((1, 0)),
_ => None,
};
let (x, z) = step(self.facing)?;
// Turning clockwise seen from above takes (x, z) to (-z, x).
match self.kind {
"left" => Some((-z, x)),
"right" => Some((z, -x)),
_ => None,
}
}
/// `DoubleBlockCombiner.combineWithNeigbour` as `ChestBlock.combine` calls
/// it: `other`, the block in this half's connected direction, is its other
/// half when it is the same block, a half of the other side and faces the
/// same way. Copper chests in different weathering states never join
/// (`CopperChestBlock` keeps both halves in one state instead).
pub fn pairs_with(&self, other: &ChestHalf) -> bool {
is_chest_block(self.block)
&& self.kind != "single"
&& other.block == self.block
&& other.kind != "single"
&& other.kind != self.kind
&& other.facing == self.facing
}
}
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
/// more with the chance of what is left over (a level random draw only when there is some).
pub fn create_experience(
@@ -0,0 +1,74 @@
//! Double chests for tools/test_chest_pair_parity.py: reads one JSON case per
//! line (see VanillaChestPairOracle), a chest's block state at the origin and
//! another block state at the offset "at", and prints what
//! `horizon_client::containers::ChestHalf` pairs them into, as the game's
//! menus and hoppers do: "double" with the right half's x,z then the left
//! half's, or "single".
//!
//! usage: chest_pair_fixture INPUT OUTPUT
use horizon_client::containers::{ChestHalf, is_chest_block};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
/// `minecraft:chest[facing=north,type=left]` as its name and properties.
fn parse(state: &str) -> Option<(&str, BTreeMap<&str, &str>)> {
let (name, props) = match state.split_once('[') {
Some((name, rest)) => (name, rest.strip_suffix(']')?),
None => (state, ""),
};
let props = props
.split(',')
.filter(|p| !p.is_empty())
.map(|p| p.split_once('='))
.collect::<Option<_>>()?;
Some((name, props))
}
fn half<'a>(name: &'a str, props: &BTreeMap<&str, &'a str>) -> Option<ChestHalf<'a>> {
Some(ChestHalf {
block: name,
kind: props.get("type")?,
facing: props.get("facing")?,
})
}
fn case(line: &str) -> Option<String> {
let case: Value = serde_json::from_str(line).ok()?;
let (name, props) = parse(case["state"].as_str()?)?;
let (other_name, other_props) = parse(case["other"].as_str()?)?;
let at = (
i32::try_from(case["at"][0].as_i64()?).ok()?,
i32::try_from(case["at"][1].as_i64()?).ok()?,
);
if !is_chest_block(name) {
return None;
}
let own = half(name, &props)?;
let paired = own.connected_step() == Some(at)
&& half(other_name, &other_props).is_some_and(|other| own.pairs_with(&other));
Some(if !paired {
"single".to_owned()
} else if own.kind == "right" {
format!("double 0,0 {},{}", at.0, at.1)
} else {
format!("double {},{} 0,0", at.0, at.1)
})
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: chest_pair_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
@@ -24,6 +24,7 @@ mod breaking_fixture;
mod camera_probe;
mod cauldron_fixture;
mod chat_screen_fixture;
mod chest_pair_fixture;
mod cloud_fixture;
mod command_parse_fixture;
mod container_screen_probe;
@@ -115,6 +116,7 @@ probes! {
cushion_placement_probe,
cauldron_fixture,
chat_screen_fixture,
chest_pair_fixture,
cloud_fixture,
command_parse_fixture,
container_screen_probe,
+5 -4
View File
@@ -324,12 +324,12 @@ impl Game<'_> {
let Some(hit) = self.pick() else {
return false;
};
let name = &*self
let id = &*self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.name;
let name = name.trim_start_matches("minecraft:");
let name = id.trim_start_matches("minecraft:");
match name {
// BlockBehaviour.getMenuProvider and `instanceof Portal`.
"crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil"
@@ -338,8 +338,9 @@ impl Game<'_> {
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal"
| "end_portal" | "end_gateway" => true,
// ChestBlock.getMenuProvider: none while a redstone conductor
// sits on top of either half (a sitting cat aside).
"chest" | "trapped_chest" => {
// sits on top of either half (a sitting cat aside). Copper
// chests are `ChestBlock`s too.
_ if horizon_client::containers::is_chest_block(id) => {
let key = (hit.pos[0], hit.pos[1], hit.pos[2]);
let blocked = |p: (i32, i32, i32)| {
let above = self.world.block(p.0, p.1 + 1, p.2);
+13 -26
View File
@@ -109,16 +109,6 @@ fn offset(p: Pos, d: Direction) -> Pos {
let s = step(d);
(p.0 + s[0], p.1 + s[1], p.2 + s[2])
}
fn clockwise(d: Direction) -> Direction {
match d {
Direction::North => Direction::East,
Direction::East => Direction::South,
Direction::South => Direction::West,
Direction::West => Direction::North,
other => other,
}
}
/// State-level container queries shared by the game and comparator signals.
struct Containers<'a> {
world: &'a World,
@@ -176,7 +166,7 @@ impl Containers<'_> {
_ => Kind::Generic,
};
let size = entity.slots().len();
if matches!(name, "minecraft:chest" | "minecraft:trapped_chest") {
if horizon_client::containers::is_chest_block(name) {
let is_blocked = |p: Pos| {
let above = self.world.block(p.0, p.1 + 1, p.2);
self.behaviours.facts(above).flags
@@ -186,22 +176,19 @@ impl Containers<'_> {
if blocked_chests && is_blocked(pos) {
return None;
}
let kind_prop = self.prop(state, "type").unwrap_or("single");
if kind_prop != "single"
&& let Some(facing) = self.prop(state, "facing").and_then(dir_from_name)
let half = |s: BlockStateId| {
Some(horizon_client::containers::ChestHalf {
block: self.name(s),
kind: self.prop(s, "type")?,
facing: self.prop(s, "facing")?,
})
};
if let Some(own) = half(state)
&& let Some((dx, dz)) = own.connected_step()
{
// ChestBlock.getConnectedDirection
let connected = if kind_prop == "left" {
clockwise(facing)
} else {
clockwise(clockwise(clockwise(facing)))
};
let other = offset(pos, connected);
let other = (pos.0 + dx, pos.1, pos.2 + dz);
let other_state = self.world.block(other.0, other.1, other.2);
let paired = self.name(other_state) == name
&& self
.prop(other_state, "type")
.is_some_and(|t| t != "single" && t != kind_prop)
let paired = half(other_state).is_some_and(|o| own.pairs_with(&o))
&& self
.world
.block_entities
@@ -211,7 +198,7 @@ impl Containers<'_> {
if blocked_chests && is_blocked(other) {
return None;
}
let parts = if kind_prop == "right" {
let parts = if own.kind == "right" {
vec![(pos, size), (other, 27)]
} else {
vec![(other, 27), (pos, size)]
+51 -48
View File
@@ -2,7 +2,11 @@
//! kept in the dropped item's `container` component) and ender chests (the
//! player's `PlayerEnderChestContainer`, saved as `EnderItems`).
use super::Game;
use horizon_client::{containers::BlockEntity, items::ItemStack, text::Text};
use horizon_client::{
containers::{BlockEntity, ChestHalf, is_chest_block},
items::ItemStack,
text::Text,
};
use horizon_core::random::RandomSource;
pub(super) fn is_shulker_box(name: &str) -> bool {
@@ -72,7 +76,7 @@ impl Game<'_> {
/// with either half blocked, opens no menu.
pub(super) fn chest_menu_blocked(&self, key: (i32, i32, i32), name: &str) -> bool {
match name {
"minecraft:chest" | "minecraft:trapped_chest" => {
n if is_chest_block(n) => {
self.chest_blocked_at(key)
|| self
.chest_partner(key)
@@ -96,22 +100,13 @@ impl Game<'_> {
// ---- chest menus: double chests, titles and shift-click ----
/// `Direction.getClockWise` for horizontal facings.
fn clockwise(facing: &str) -> &'static str {
match facing {
"north" => "east",
"east" => "south",
"south" => "west",
_ => "north",
}
}
fn counter_clockwise(facing: &str) -> &'static str {
match clockwise(facing) {
"east" => "west",
"south" => "north",
"west" => "east",
_ => "south",
}
/// A chest block state's half: its block, `type` and `facing`.
fn chest_half(state: &horizon_world::chunk::BlockState) -> Option<ChestHalf<'_>> {
Some(ChestHalf {
block: &state.name,
kind: state.properties.get("type")?,
facing: state.properties.get("facing")?,
})
}
fn facing_step(facing: &str) -> (i32, i32) {
match facing {
@@ -128,34 +123,19 @@ impl Game<'_> {
pub(super) fn chest_partner(&self, key: (i32, i32, i32)) -> Option<((i32, i32, i32), bool)> {
let registry = self.pack.assets.registry;
let state = registry.state(self.world.block(key.0, key.1, key.2))?;
if !matches!(
state.name.as_str(),
"minecraft:chest" | "minecraft:trapped_chest"
) {
if !is_chest_block(&state.name) {
return None;
}
let kind = state.properties.get("type")?.as_str();
if kind == "single" {
return None;
}
let facing = state.properties.get("facing")?.as_str();
let dir = if kind == "left" {
clockwise(facing)
} else {
counter_clockwise(facing)
};
let (dx, dz) = facing_step(dir);
let own = chest_half(state)?;
let (dx, dz) = own.connected_step()?;
let other = (key.0 + dx, key.1, key.2 + dz);
let other_state = registry.state(self.world.block(other.0, other.1, other.2))?;
let other_kind = other_state.properties.get("type")?.as_str();
let paired = other_state.name == state.name
&& other_kind != "single"
&& other_kind != kind
let paired = own.pairs_with(&chest_half(other_state)?)
&& matches!(
self.world.block_entities.get(&other),
Some(BlockEntity::Chest(items)) if items.len() == 27
);
paired.then_some((other, kind == "right"))
paired.then_some((other, own.kind == "right"))
}
/// The open or close sound of a container block (`ChestBlockEntity.playSound`,
/// `BarrelBlockEntity.playSound`, `ShulkerBoxBlockEntity.startOpen`): volume
@@ -345,12 +325,8 @@ impl Game<'_> {
return None;
}
let generic = match self.container_block().as_deref() {
Some(
"minecraft:chest"
| "minecraft:trapped_chest"
| "minecraft:barrel"
| "minecraft:ender_chest",
) => true,
Some("minecraft:barrel" | "minecraft:ender_chest") => true,
Some(n) if is_chest_block(n) => true,
_ => self.open_cart_kind() == Some(super::minecarts::CartKind::Chest),
};
generic.then(|| self.container_size() / 9)
@@ -452,11 +428,38 @@ mod tests {
#[test]
fn double_chest_halves_connect_around_the_facing() {
let half = |kind, facing| ChestHalf {
block: "minecraft:chest",
kind,
facing,
};
// A left half facing north connects east, a right half west.
assert_eq!(facing_step(clockwise("north")), (1, 0));
assert_eq!(facing_step(counter_clockwise("north")), (-1, 0));
assert_eq!(facing_step(clockwise("south")), (-1, 0));
assert_eq!(facing_step(counter_clockwise("east")), (0, -1));
assert_eq!(half("left", "north").connected_step(), Some((1, 0)));
assert_eq!(half("right", "north").connected_step(), Some((-1, 0)));
assert_eq!(half("left", "south").connected_step(), Some((-1, 0)));
assert_eq!(half("right", "east").connected_step(), Some((0, -1)));
assert_eq!(half("single", "east").connected_step(), None);
}
#[test]
fn copper_chests_join_like_chests() {
let half = |block, kind| ChestHalf {
block,
kind,
facing: "north",
};
for block in [
"minecraft:chest",
"minecraft:trapped_chest",
"minecraft:copper_chest",
"minecraft:waxed_weathered_copper_chest",
] {
assert!(is_chest_block(block));
assert!(half(block, "left").pairs_with(&half(block, "right")));
}
// ChestBlock.combine needs the same block on both halves.
let copper = half("minecraft:copper_chest", "left");
assert!(!copper.pairs_with(&half("minecraft:exposed_copper_chest", "right")));
assert!(!is_chest_block("minecraft:ender_chest"));
}
#[test]
fn shulker_boxes_are_recognised_by_name() {
@@ -0,0 +1,97 @@
package dev.horizonlab.parity;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.PrintWriter;
import java.lang.reflect.Proxy;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import net.minecraft.commands.arguments.blocks.BlockStateParser;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.level.LevelAccessor;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.ChestBlock;
import net.minecraft.world.level.block.DoubleBlockCombiner;
import net.minecraft.world.level.block.EntityBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/**
* Original oracle: which chests join into a double chest. Each case line is
* JSON: a chest's block state ("state") at the origin and another block state
* ("other") at the horizontal offset "at" ([dx, dz]); each holds the block
* entity its block makes (EntityBlock.newBlockEntity). Runs
* DoubleBlockCombiner.combineWithNeigbour as ChestBlock.combine(state, level,
* pos, true) calls it (the block's blockEntityType, ChestBlock.getBlockType,
* ChestBlock.getConnectedDirection, FACING; nothing blocked) on a stand-in
* level, and prints "double" with the first (right) half's position and the
* second's, or "single".
*/
public final class VanillaChestPairOracle {
static BlockState parse(String s) throws Exception {
return BlockStateParser.parseForBlock(BuiltInRegistries.BLOCK, s, false).blockState();
}
static String xz(BlockPos p) {
return p.getX() + "," + p.getZ();
}
static String pair(JsonObject c) throws Exception {
BlockState state = parse(c.get("state").getAsString());
BlockState other = parse(c.get("other").getAsString());
var at = c.getAsJsonArray("at");
BlockPos pos = BlockPos.ZERO;
BlockPos otherPos = pos.offset(at.get(0).getAsInt(), 0, at.get(1).getAsInt());
Map<BlockPos, BlockState> blocks = new HashMap<>();
Map<BlockPos, BlockEntity> entities = new HashMap<>();
blocks.put(pos, state);
blocks.put(otherPos, other);
for (var e : blocks.entrySet()) {
if (e.getValue().getBlock() instanceof EntityBlock block) {
BlockEntity entity = block.newBlockEntity(e.getKey(), e.getValue());
if (entity != null) entities.put(e.getKey(), entity);
}
}
LevelAccessor level = (LevelAccessor) Proxy.newProxyInstance(
LevelAccessor.class.getClassLoader(), new Class<?>[] {LevelAccessor.class}, (proxy, method, args) -> {
switch (method.getName()) {
case "getBlockState": return blocks.getOrDefault(((BlockPos) args[0]).immutable(), Blocks.AIR.defaultBlockState());
case "getBlockEntity":
if (args.length == 1) return entities.get(((BlockPos) args[0]).immutable());
break;
default: break;
}
throw new UnsupportedOperationException(method.toString());
});
ChestBlock chest = (ChestBlock) state.getBlock();
var result = DoubleBlockCombiner.combineWithNeigbour(chest.blockEntityType(), ChestBlock::getBlockType,
ChestBlock::getConnectedDirection, ChestBlock.FACING, state, level, pos, (l, p) -> false);
return result.apply(new DoubleBlockCombiner.Combiner<ChestBlockEntity, String>() {
@Override public String acceptDouble(ChestBlockEntity first, ChestBlockEntity second) {
return "double " + xz(first.getBlockPos()) + " " + xz(second.getBlockPos());
}
@Override public String acceptSingle(ChestBlockEntity single) { return "single"; }
@Override public String acceptNone() { return "none"; }
});
}
public static void main(String[] args) throws Exception {
ParityData.init();
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
var lines = Files.lines(Path.of(args[0]))) {
for (String line : (Iterable<String>) lines::iterator) {
try {
out.println(pair(JsonParser.parseString(line).getAsJsonObject()));
} catch (Exception e) {
System.err.println(line + ": " + e);
out.println("error");
}
}
}
}
}
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Compare which chests join into a double chest against vanilla 26.3.
The Java oracle (VanillaChestPairOracle) puts a chest and another block next
to it, each with its block entity, and runs DoubleBlockCombiner's
combineWithNeigbour as ChestBlock.combine does: the double chest's halves
(the right one first) or a single chest. That pairing decides a chest's
menu (9x6 or 9x3), what a hopper fills and the lid both halves share. The
Rust example (chest_pair_fixture) answers with horizon_client::containers'
ChestHalf, which the game's menus and hoppers use. Compared for chests,
trapped chests and copper chests in every weathering state, waxed or not,
next to each of them and a barrel, with every chest type, two facings and
the block on either side.
"""
import argparse
import json
try:
from . import interaction_lab as lib
except ImportError:
import interaction_lab as lib
CHESTS = ['chest', 'trapped_chest', 'copper_chest', 'exposed_copper_chest', 'weathered_copper_chest',
'oxidized_copper_chest', 'waxed_copper_chest', 'waxed_exposed_copper_chest',
'waxed_weathered_copper_chest', 'waxed_oxidized_copper_chest']
TYPES = ['single', 'left', 'right']
# A chest facing north connects east (left) or west (right): ChestBlock.getConnectedDirection.
SIDES = [(1, 0), (-1, 0)]
def state(block, facing, kind):
return f'minecraft:{block}[facing={facing},type={kind}]'
def cases():
out = []
for block in CHESTS:
for kind in TYPES:
own = state(block, 'north', kind)
for at in SIDES:
for other in CHESTS:
for facing in ('north', 'south'):
for other_kind in TYPES:
out.append({'state': own, 'other': state(other, facing, other_kind), 'at': at})
out.append({'state': own, 'other': 'minecraft:barrel[facing=north]', 'at': at})
return out
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--limit', type=int, default=4)
args = parser.parse_args()
work = lib.work_dir('chest-pair-parity')
vanilla = lib.Vanilla(work)
vanilla.compile('ParityData', 'VanillaChestPairOracle')
all_cases = cases()
(work / 'cases.jsonl').write_text(''.join(json.dumps(c) + '\n' for c in all_cases), encoding='utf-8')
vanilla.run('VanillaChestPairOracle', work / 'cases.jsonl', work / 'native.txt')
lib.cargo_example('horizon-client', 'chest_pair_fixture', work / 'cases.jsonl', work / 'rust.txt')
errors = sum(line == 'error' for line in (work / 'native.txt').read_text(encoding='utf-8').split('\n'))
if errors:
print(f'{errors} cases fail in vanilla')
total = lib.compare(all_cases, work / 'native.txt', work / 'rust.txt',
lambda case, line: case['state'].split('[')[0] + ' next to ' + case['other'].split('[')[0],
json.dumps, args.limit)
raise SystemExit(1 if total or errors else 0)
if __name__ == '__main__':
main()