package org.geysermc.geyser.registry.populator; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; import org.checkerframework.checker.nullness.qual.NonNull; import org.cloudburstmc.nbt.NbtMap; import org.cloudburstmc.nbt.NbtMapBuilder; import org.cloudburstmc.nbt.NbtType; import org.cloudburstmc.protocol.bedrock.codec.v594.Bedrock_v594; import org.cloudburstmc.protocol.bedrock.data.BlockPropertyData; import org.geysermc.geyser.GeyserImpl; import org.geysermc.geyser.api.block.custom.CustomBlockData; import org.geysermc.geyser.api.block.custom.CustomBlockPermutation; import org.geysermc.geyser.api.block.custom.CustomBlockState; import org.geysermc.geyser.api.block.custom.component.BoxComponent; import org.geysermc.geyser.api.block.custom.component.CustomBlockComponents; import org.geysermc.geyser.api.block.custom.component.MaterialInstance; import org.geysermc.geyser.api.block.custom.component.PlacementConditions; import org.geysermc.geyser.api.block.custom.component.PlacementConditions.Face; import org.geysermc.geyser.api.block.custom.nonvanilla.JavaBlockState; import org.geysermc.geyser.api.block.custom.property.CustomBlockProperty; import org.geysermc.geyser.api.block.custom.property.PropertyType; import org.geysermc.geyser.api.event.lifecycle.GeyserDefineCustomBlocksEvent; import org.geysermc.geyser.api.util.CreativeCategory; import org.geysermc.geyser.level.block.GeyserCustomBlockComponents; import org.geysermc.geyser.level.block.GeyserCustomBlockData; import org.geysermc.geyser.level.block.GeyserCustomBlockState; import org.geysermc.geyser.level.block.GeyserGeometryComponent; import org.geysermc.geyser.level.block.GeyserMaterialInstance; import org.geysermc.geyser.network.GameProtocol; import org.geysermc.geyser.registry.BlockRegistries; import org.geysermc.geyser.registry.mappings.MappingsConfigReader; import org.geysermc.geyser.registry.type.CustomSkull; import org.geysermc.geyser.util.MathUtils; import java.util.ArrayList; import java.util.concurrent.atomic.AtomicInteger; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; public class CustomBlockRegistryPopulator { // Since 1.20.60, custom blocks need a block_id in their nbt tag public static AtomicInteger BLOCK_ID = new AtomicInteger(); // Custom block id's start at 10000, and count up public static final int START_OFFSET = 10000; /** * The stage of population */ public enum Stage { DEFINITION, VANILLA_REGISTRATION, NON_VANILLA_REGISTRATION, CUSTOM_REGISTRATION } /** * Populates the custom block registries by stage * * @param stage the stage to populate */ public static void populate(Stage stage) { if (!GeyserImpl.getInstance().getConfig().isAddNonBedrockItems()) { return; } switch (stage) { case DEFINITION -> populateBedrock(); case VANILLA_REGISTRATION -> populateVanilla(); case NON_VANILLA_REGISTRATION -> populateNonVanilla(); case CUSTOM_REGISTRATION -> registration(); default -> throw new IllegalArgumentException("Unknown stage: " + stage); } } private static Set CUSTOM_BLOCKS; private static Set CUSTOM_BLOCK_NAMES; private static Map CUSTOM_BLOCK_ITEM_OVERRIDES; private static Map NON_VANILLA_BLOCK_STATE_OVERRIDES; private static Map BLOCK_STATE_OVERRIDES_QUEUE; /** * Initializes custom blocks defined by API */ private static void populateBedrock() { CUSTOM_BLOCKS = new ObjectOpenHashSet<>(); CUSTOM_BLOCK_NAMES = new ObjectOpenHashSet<>(); CUSTOM_BLOCK_ITEM_OVERRIDES = new HashMap<>(); NON_VANILLA_BLOCK_STATE_OVERRIDES = new HashMap<>(); BLOCK_STATE_OVERRIDES_QUEUE = new HashMap<>(); GeyserImpl.getInstance().getEventBus().fire(new GeyserDefineCustomBlocksEvent() { @Override public void register(@NonNull CustomBlockData customBlockData) { if (customBlockData.name().isEmpty()) { throw new IllegalArgumentException("Custom block name must have at least 1 character."); } if (!CUSTOM_BLOCK_NAMES.add(customBlockData.name())) { throw new IllegalArgumentException("Another custom block was already registered under the name: " + customBlockData.name()); } if (Character.isDigit(customBlockData.name().charAt(0))) { throw new IllegalArgumentException("Custom block can not start with a digit. Name: " + customBlockData.name()); } CUSTOM_BLOCKS.add(customBlockData); } @Override public void registerOverride(@NonNull String javaIdentifier, @NonNull CustomBlockState customBlockState) { if (!CUSTOM_BLOCKS.contains(customBlockState.block())) { throw new IllegalArgumentException("Custom block is unregistered. Name: " + customBlockState.name()); } // We can't register these yet as we don't have the java block id registry populated BLOCK_STATE_OVERRIDES_QUEUE.put(javaIdentifier, customBlockState); } @Override public void registerItemOverride(@NonNull String javaIdentifier, @NonNull CustomBlockData customBlockData) { if (!CUSTOM_BLOCKS.contains(customBlockData)) { throw new IllegalArgumentException("Custom block is unregistered. Name: " + customBlockData.name()); } CUSTOM_BLOCK_ITEM_OVERRIDES.put(javaIdentifier, customBlockData); } @Override public void registerOverride(@NonNull JavaBlockState javaBlockState, @NonNull CustomBlockState customBlockState) { if (!CUSTOM_BLOCKS.contains(customBlockState.block())) { throw new IllegalArgumentException("Custom block is unregistered. Name: " + customBlockState.name()); } NON_VANILLA_BLOCK_STATE_OVERRIDES.put(javaBlockState, customBlockState); } }); } /** * Registers all vanilla custom blocks and skulls defined by API and mappings */ private static void populateVanilla() { Int2ObjectMap blockStateOverrides = new Int2ObjectOpenHashMap<>(); for (CustomSkull customSkull : BlockRegistries.CUSTOM_SKULLS.get().values()) { CUSTOM_BLOCKS.add(customSkull.getCustomBlockData()); } for(Map.Entry entry : BLOCK_STATE_OVERRIDES_QUEUE.entrySet()) { int id = BlockRegistries.JAVA_IDENTIFIER_TO_ID.getOrDefault(entry.getKey(), -1); if (id == -1) { GeyserImpl.getInstance().getLogger().warning("Custom block state override for Java Identifier: " + entry.getKey() + " could not be registered as it is not a valid block state."); continue; } CustomBlockState oldBlockState = blockStateOverrides.put(id, entry.getValue()); if (oldBlockState != null) { GeyserImpl.getInstance().getLogger().warning("Duplicate block state override for Java Identifier: " + entry.getKey() + " Old override: " + oldBlockState.name() + " New override: " + entry.getValue().name()); } } BLOCK_STATE_OVERRIDES_QUEUE = null; Map> extendedCollisionBoxes = new HashMap<>(); Map extendedCollisionBoxSet = new HashMap<>(); MappingsConfigReader mappingsConfigReader = new MappingsConfigReader(); mappingsConfigReader.loadBlockMappingsFromJson((key, block) -> { CUSTOM_BLOCKS.add(block.data()); if (block.overrideItem()) { CUSTOM_BLOCK_ITEM_OVERRIDES.put(block.javaIdentifier(), block.data()); } block.states().forEach((javaIdentifier, customBlockState) -> { int id = BlockRegistries.JAVA_IDENTIFIER_TO_ID.getOrDefault(javaIdentifier, -1); blockStateOverrides.put(id, customBlockState.state()); BoxComponent extendedCollisionBox = customBlockState.extendedCollisionBox(); if (extendedCollisionBox != null) { CustomBlockData extendedCollisionBlock = extendedCollisionBoxSet.computeIfAbsent(extendedCollisionBox, box -> { CustomBlockData collisionBlock = createExtendedCollisionBlock(box, extendedCollisionBoxSet.size()); CUSTOM_BLOCKS.add(collisionBlock); return collisionBlock; }); extendedCollisionBoxes.computeIfAbsent(extendedCollisionBlock, k -> new HashSet<>()) .add(id); } }); }); BlockRegistries.CUSTOM_BLOCK_STATE_OVERRIDES.set(blockStateOverrides); if (!blockStateOverrides.isEmpty()) { GeyserImpl.getInstance().getLogger().info("Registered " + blockStateOverrides.size() + " custom block overrides."); } BlockRegistries.CUSTOM_BLOCK_ITEM_OVERRIDES.set(CUSTOM_BLOCK_ITEM_OVERRIDES); if (!CUSTOM_BLOCK_ITEM_OVERRIDES.isEmpty()) { GeyserImpl.getInstance().getLogger().info("Registered " + CUSTOM_BLOCK_ITEM_OVERRIDES.size() + " custom block item overrides."); } BlockRegistries.EXTENDED_COLLISION_BOXES.set(extendedCollisionBoxes); if (!extendedCollisionBoxes.isEmpty()) { GeyserImpl.getInstance().getLogger().info("Registered " + extendedCollisionBoxes.size() + " custom block extended collision boxes."); } } /** * Registers all non-vanilla custom blocks defined by API */ private static void populateNonVanilla() { BlockRegistries.NON_VANILLA_BLOCK_STATE_OVERRIDES.set(NON_VANILLA_BLOCK_STATE_OVERRIDES); if (!NON_VANILLA_BLOCK_STATE_OVERRIDES.isEmpty()) { GeyserImpl.getInstance().getLogger().info("Registered " + NON_VANILLA_BLOCK_STATE_OVERRIDES.size() + " non-vanilla block overrides."); } } /** * Registers all bedrock custom blocks defined in previous stages */ private static void registration() { BlockRegistries.CUSTOM_BLOCKS.set(CUSTOM_BLOCKS.toArray(new CustomBlockData[0])); if (!CUSTOM_BLOCKS.isEmpty()) { GeyserImpl.getInstance().getLogger().info("Registered " + CUSTOM_BLOCKS.size() + " custom blocks."); } } /** * Generates and appends all custom block states to the provided list of custom block states * Appends the custom block states to the provided list of NBT maps * * @param customBlock the custom block data to generate states for * @param blockStates the list of NBT maps to append the custom block states to * @param customExtBlockStates the list of custom block states to append the custom block states to */ static void generateCustomBlockStates(CustomBlockData customBlock, List blockStates, List customExtBlockStates) { int totalPermutations = 1; for (CustomBlockProperty property : customBlock.properties().values()) { totalPermutations *= property.values().size(); } for (int i = 0; i < totalPermutations; i++) { NbtMapBuilder statesBuilder = NbtMap.builder(); int permIndex = i; for (CustomBlockProperty property : customBlock.properties().values()) { statesBuilder.put(property.name(), property.values().get(permIndex % property.values().size())); permIndex /= property.values().size(); } NbtMap states = statesBuilder.build(); blockStates.add(NbtMap.builder() .putString("name", customBlock.identifier()) .putCompound("states", states) .build()); customExtBlockStates.add(new GeyserCustomBlockState(customBlock, states)); } } /** * Generates and returns the block property data for the provided custom block * * @param customBlock the custom block to generate block property data for * @param protocolVersion the protocol version to use for the block property data * @return the block property data for the provided custom block */ @SuppressWarnings("unchecked") static BlockPropertyData generateBlockPropertyData(CustomBlockData customBlock, int protocolVersion) { List permutations = new ArrayList<>(); for (CustomBlockPermutation permutation : customBlock.permutations()) { permutations.add(NbtMap.builder() .putCompound("components", CustomBlockRegistryPopulator.convertComponents(permutation.components(), protocolVersion)) .putString("condition", permutation.condition()) .build()); } // The order that properties are defined influences the order that block states are generated List properties = new ArrayList<>(); for (CustomBlockProperty property : customBlock.properties().values()) { NbtMapBuilder propertyBuilder = NbtMap.builder() .putString("name", property.name()); if (property.type() == PropertyType.booleanProp()) { propertyBuilder.putList("enum", NbtType.BYTE, List.of((byte) 0, (byte) 1)); } else if (property.type() == PropertyType.integerProp()) { propertyBuilder.putList("enum", NbtType.INT, (List) property.values()); } else if (property.type() == PropertyType.stringProp()) { propertyBuilder.putList("enum", NbtType.STRING, (List) property.values()); } properties.add(propertyBuilder.build()); } CreativeCategory creativeCategory = customBlock.creativeCategory() != null ? customBlock.creativeCategory() : CreativeCategory.NONE; String creativeGroup = customBlock.creativeGroup() != null ? customBlock.creativeGroup() : ""; NbtMapBuilder propertyTag = NbtMap.builder() .putCompound("components", CustomBlockRegistryPopulator.convertComponents(customBlock.components(), protocolVersion)) // this is required or the client will crash // in the future, this can be used to replace items in the creative inventory // this would require us to map https://wiki.bedrock.dev/documentation/creative-categories.html#for-blocks programatically .putCompound("menu_category", NbtMap.builder() .putString("category", creativeCategory.internalName()) .putString("group", creativeGroup) .putBoolean("is_hidden_in_commands", false) .build()) // meaning of this version is unknown, but it's required for tags to work and should probably be checked periodically .putInt("molangVersion", 1) .putList("permutations", NbtType.COMPOUND, permutations) .putList("properties", NbtType.COMPOUND, properties); if (GameProtocol.is1_20_60orHigher(protocolVersion)) { propertyTag.putCompound("vanilla_block_data", NbtMap.builder() .putInt("block_id", BLOCK_ID.getAndIncrement()) .build()); } return new BlockPropertyData(customBlock.identifier(), propertyTag.build()); } /** * Converts the provided custom block components to an {@link NbtMap} to be sent to the client in the StartGame packet * * @param components the custom block components to convert * @param protocolVersion the protocol version to use for the conversion * @return the NBT representation of the provided custom block components */ private static NbtMap convertComponents(CustomBlockComponents components, int protocolVersion) { if (components == null) { return NbtMap.EMPTY; } NbtMapBuilder builder = NbtMap.builder(); if (components.displayName() != null) { builder.putCompound("minecraft:display_name", NbtMap.builder() .putString("value", components.displayName()) .build()); } if (components.selectionBox() != null) { builder.putCompound("minecraft:selection_box", convertBox(components.selectionBox())); } if (components.collisionBox() != null) { builder.putCompound("minecraft:collision_box", convertBox(components.collisionBox())); } if (components.geometry() != null) { NbtMapBuilder geometryBuilder = NbtMap.builder(); if (protocolVersion >= Bedrock_v594.CODEC.getProtocolVersion()) { geometryBuilder.putString("identifier", components.geometry().identifier()); if (components.geometry().boneVisibility() != null) { NbtMapBuilder boneVisibilityBuilder = NbtMap.builder(); components.geometry().boneVisibility().entrySet().forEach( entry -> boneVisibilityBuilder.putString(entry.getKey(), entry.getValue())); geometryBuilder.putCompound("bone_visibility", boneVisibilityBuilder.build()); } } else { geometryBuilder.putString("value", components.geometry().identifier()); } builder.putCompound("minecraft:geometry", geometryBuilder.build()); } if (!components.materialInstances().isEmpty()) { NbtMapBuilder materialsBuilder = NbtMap.builder(); for (Map.Entry entry : components.materialInstances().entrySet()) { MaterialInstance materialInstance = entry.getValue(); NbtMapBuilder materialBuilder = NbtMap.builder() .putString("render_method", materialInstance.renderMethod()) .putBoolean("face_dimming", materialInstance.faceDimming()) .putBoolean("ambient_occlusion", materialInstance.faceDimming()); // Texture can be unspecified when blocks.json is used in RP (https://wiki.bedrock.dev/blocks/blocks-stable.html#minecraft-material-instances) if (materialInstance.texture() != null) { materialBuilder.putString("texture", materialInstance.texture()); } materialsBuilder.putCompound(entry.getKey(), materialBuilder.build()); } builder.putCompound("minecraft:material_instances", NbtMap.builder() // we could read these, but there is no functional reason to use them at the moment // they only allow you to make aliases for material instances // but you could already just define the same instance twice if this was really needed .putCompound("mappings", NbtMap.EMPTY) .putCompound("materials", materialsBuilder.build()) .build()); } if (components.placementFilter() != null) { builder.putCompound("minecraft:placement_filter", NbtMap.builder() .putList("conditions", NbtType.COMPOUND, convertPlacementFilter(components.placementFilter())) .build()); } if (components.destructibleByMining() != null) { builder.putCompound("minecraft:destructible_by_mining", NbtMap.builder() .putFloat("value", components.destructibleByMining()) .build()); } if (components.friction() != null) { builder.putCompound("minecraft:friction", NbtMap.builder() .putFloat("value", components.friction()) .build()); } if (components.lightEmission() != null) { builder.putCompound("minecraft:light_emission", NbtMap.builder() .putByte("emission", components.lightEmission().byteValue()) .build()); } if (components.lightDampening() != null) { builder.putCompound("minecraft:light_dampening", NbtMap.builder() .putByte("lightLevel", components.lightDampening().byteValue()) .build()); } if (components.transformation() != null) { builder.putCompound("minecraft:transformation", NbtMap.builder() .putInt("RX", MathUtils.unwrapDegreesToInt(components.transformation().rx()) / 90) .putInt("RY", MathUtils.unwrapDegreesToInt(components.transformation().ry()) / 90) .putInt("RZ", MathUtils.unwrapDegreesToInt(components.transformation().rz()) / 90) .putFloat("SX", components.transformation().sx()) .putFloat("SY", components.transformation().sy()) .putFloat("SZ", components.transformation().sz()) .putFloat("TX", components.transformation().tx()) .putFloat("TY", components.transformation().ty()) .putFloat("TZ", components.transformation().tz()) .build()); } // place_air is not an actual component // We just apply a dummy event to prevent the client from trying to place a block // This mitigates the issue with the client sometimes double placing blocks if (components.placeAir()) { builder.putCompound("minecraft:on_player_placing", NbtMap.builder() .putString("triggerType", "geyser:place_event") .build()); } if (!components.tags().isEmpty()) { components.tags().forEach(tag -> builder.putCompound("tag:" + tag, NbtMap.EMPTY)); } return builder.build(); } /** * Converts the provided box component to an {@link NbtMap} * * @param boxComponent the box component to convert * @return the NBT representation of the provided box component */ private static NbtMap convertBox(BoxComponent boxComponent) { return NbtMap.builder() .putBoolean("enabled", !boxComponent.isEmpty()) .putList("origin", NbtType.FLOAT, boxComponent.originX(), boxComponent.originY(), boxComponent.originZ()) .putList("size", NbtType.FLOAT, boxComponent.sizeX(), boxComponent.sizeY(), boxComponent.sizeZ()) .build(); } /** * Converts the provided placement filter to a list of {@link NbtMap} * * @param placementFilter the placement filter to convert * @return the NBT representation of the provided placement filter */ private static List convertPlacementFilter(List placementFilter) { List conditions = new ArrayList<>(); placementFilter.forEach((condition) -> { NbtMapBuilder conditionBuilder = NbtMap.builder(); // allowed_faces on the network is represented by 6 bits for the 6 possible faces // the enum has the proper values for that face only, so we just bitwise OR them together byte allowedFaces = 0; for (Face face : condition.allowedFaces()) { allowedFaces |= (1 << face.ordinal()); } conditionBuilder.putByte("allowed_faces", allowedFaces); // block_filters is a list of either blocks or queries for block tags // if these match the block the player is trying to place on, the placement is allowed by the client List blockFilters = new ArrayList<>(); condition.blockFilters().forEach((value, type) -> { NbtMapBuilder blockFilterBuilder = NbtMap.builder(); switch (type) { case BLOCK -> blockFilterBuilder.putString("name", value); // meaning of this version is unknown, but it's required for tags to work and should probably be checked periodically case TAG -> blockFilterBuilder.putString("tags", value).putInt("tags_version", 6); } blockFilters.add(blockFilterBuilder.build()); }); conditionBuilder.putList("block_filters", NbtType.COMPOUND, blockFilters); conditions.add(conditionBuilder.build()); }); return conditions; } private static CustomBlockData createExtendedCollisionBlock(BoxComponent boxComponent, int extendedCollisionBlock) { return new GeyserCustomBlockData.Builder() .name("extended_collision_" + extendedCollisionBlock) .components( new GeyserCustomBlockComponents.Builder() .collisionBox(boxComponent) .selectionBox(BoxComponent.emptyBox()) .materialInstance("*", new GeyserMaterialInstance.Builder() .texture("glass") .renderMethod("alpha_test") .faceDimming(false) .ambientOcclusion(false) .build()) .lightDampening(0) .geometry(new GeyserGeometryComponent.Builder() .identifier("geometry.invisible") .build()) .build()) .build(); } }