/* * Minecraft Forge * Copyright (c) 2016-2019. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation version 2.1 * of the License. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ package net.minecraftforge.debug; import static net.minecraftforge.debug.DataGeneratorTest.MODID; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.function.Consumer; import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jline.utils.InputStreamReader; import com.google.common.collect.HashMultimap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Multimap; import com.google.common.collect.ObjectArrays; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import net.minecraft.advancements.Advancement; import net.minecraft.advancements.AdvancementRewards; import net.minecraft.advancements.FrameType; import net.minecraft.block.BarrelBlock; import net.minecraft.block.Block; import net.minecraft.block.Blocks; import net.minecraft.block.DoorBlock; import net.minecraft.block.FenceBlock; import net.minecraft.block.FenceGateBlock; import net.minecraft.block.FurnaceBlock; import net.minecraft.block.LogBlock; import net.minecraft.block.PaneBlock; import net.minecraft.block.SlabBlock; import net.minecraft.block.StairsBlock; import net.minecraft.block.TrapDoorBlock; import net.minecraft.block.WallBlock; import net.minecraft.client.renderer.model.ItemCameraTransforms; import net.minecraft.client.renderer.model.ItemTransformVec3f; import net.minecraft.client.renderer.model.Variant; import net.minecraft.data.DataGenerator; import net.minecraft.data.DirectoryCache; import net.minecraft.data.IFinishedRecipe; import net.minecraft.data.RecipeProvider; import net.minecraft.data.ShapedRecipeBuilder; import net.minecraft.resources.IResource; import net.minecraft.resources.ResourcePackType; import net.minecraft.util.Direction; import net.minecraft.util.ResourceLocation; import net.minecraft.util.text.StringTextComponent; import net.minecraftforge.client.model.generators.BlockStateProvider; import net.minecraftforge.client.model.generators.ConfiguredModel; import net.minecraftforge.client.model.generators.ExistingFileHelper; import net.minecraftforge.client.model.generators.ItemModelProvider; import net.minecraftforge.client.model.generators.ModelBuilder; import net.minecraftforge.client.model.generators.ModelBuilder.Perspective; import net.minecraftforge.client.model.generators.ModelFile; import net.minecraftforge.client.model.generators.ModelFile.UncheckedModelFile; import net.minecraftforge.client.model.generators.MultiPartBlockStateBuilder; import net.minecraftforge.client.model.generators.VariantBlockStateBuilder; import net.minecraftforge.common.crafting.ConditionalAdvancement; import net.minecraftforge.common.crafting.ConditionalRecipe; import net.minecraftforge.common.crafting.conditions.IConditionBuilder; import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.common.Mod.EventBusSubscriber.Bus; import net.minecraftforge.fml.event.lifecycle.GatherDataEvent; @SuppressWarnings("deprecation") @Mod(MODID) @Mod.EventBusSubscriber(bus = Bus.MOD) public class DataGeneratorTest { static final String MODID = "data_gen_test"; private static final Gson GSON = new GsonBuilder() .registerTypeAdapter(Variant.class, new Variant.Deserializer()) .registerTypeAdapter(ItemCameraTransforms.class, new ItemCameraTransforms.Deserializer()) .registerTypeAdapter(ItemTransformVec3f.class, new ItemTransformVec3f.Deserializer()) .create(); @SubscribeEvent public static void gatherData(GatherDataEvent event) { DataGenerator gen = event.getGenerator(); if (event.includeClient()) { gen.addProvider(new ItemModels(gen, event.getExistingFileHelper())); gen.addProvider(new BlockStates(gen, event.getExistingFileHelper())); } if (event.includeServer()) { gen.addProvider(new Recipes(gen)); } } public static class Recipes extends RecipeProvider implements IConditionBuilder { public Recipes(DataGenerator gen) { super(gen); } protected void registerRecipes(Consumer consumer) { ResourceLocation ID = new ResourceLocation("data_gen_test", "conditional"); ConditionalRecipe.builder() .addCondition( and( not(modLoaded("minecraft")), itemExists("minecraft", "dirt"), FALSE() ) ) .addRecipe( ShapedRecipeBuilder.shapedRecipe(Blocks.DIAMOND_BLOCK, 64) .patternLine("XXX") .patternLine("XXX") .patternLine("XXX") .key('X', Blocks.DIRT) .setGroup("") .addCriterion("has_dirt", hasItem(Blocks.DIRT)) //Doesn't actually print... TODO: nested/conditional advancements? ::build ) .setAdvancement(ID, ConditionalAdvancement.builder() .addCondition( and( not(modLoaded("minecraft")), itemExists("minecraft", "dirt"), FALSE() ) ) .addAdvancement( Advancement.Builder.builder() .withParentId(new ResourceLocation("minecraft", "root")) .withDisplay(Blocks.DIAMOND_BLOCK, new StringTextComponent("Dirt2Diamonds"), new StringTextComponent("The BEST crafting recipe in the game!"), null, FrameType.TASK, false, false, false ) .withRewards(AdvancementRewards.Builder.recipe(ID)) .withCriterion("has_dirt", hasItem(Blocks.DIRT)) ) ) .build(consumer, ID); } } public static class ItemModels extends ItemModelProvider { private static final Logger LOGGER = LogManager.getLogger(); public ItemModels(DataGenerator generator, ExistingFileHelper existingFileHelper) { super(generator, MODID, existingFileHelper); } @Override protected void registerModels() { getBuilder("test_generated_model") .parent(new UncheckedModelFile("item/generated")) .texture("layer0", mcLoc("block/stone")); getBuilder("test_block_model") .parent(getExistingFile(mcLoc("block/block"))) .texture("all", mcLoc("block/dirt")) .texture("top", mcLoc("block/stone")) .element() .cube("#all") .face(Direction.UP) .texture("#top") .tintindex(0) .end() .end(); // Testing consistency // Test overrides ModelFile fishingRod = withExistingParent("fishing_rod", "handheld_rod") .texture("layer0", mcLoc("item/fishing_rod")) .override() .predicate(mcLoc("cast"), 1) .model(getExistingFile(mcLoc("item/fishing_rod_cast"))) // Use the vanilla model for validation .end(); withExistingParent("fishing_rod_cast", modLoc("fishing_rod")) .parent(fishingRod) .texture("layer0", mcLoc("item/fishing_rod_cast")); } private static final Set IGNORED_MODELS = ImmutableSet.of("test_generated_model", "test_block_model"); @Override public void act(DirectoryCache cache) throws IOException { super.act(cache); List errors = testModelResults(this.generatedModels, existingFileHelper, IGNORED_MODELS.stream().map(s -> new ResourceLocation(MODID, folder + "/" + s)).collect(Collectors.toSet())); if (!errors.isEmpty()) { LOGGER.error("Found {} discrepancies between generated and vanilla item models: ", errors.size()); for (String s : errors) { LOGGER.error(" {}", s); } throw new AssertionError("Generated item models differed from vanilla equivalents, check above errors."); } } @Override public String getName() { return "Forge Test Item Models"; } } public static class BlockStates extends BlockStateProvider { private static final Logger LOGGER = LogManager.getLogger(); public BlockStates(DataGenerator gen, ExistingFileHelper exFileHelper) { super(gen, MODID, exFileHelper); } @Override protected void registerStatesAndModels() { // Unnecessarily complicated example to showcase how manual building works ModelFile birchFenceGate = fenceGate("birch_fence_gate", mcLoc("block/birch_planks")); ModelFile birchFenceGateOpen = fenceGateOpen("birch_fence_gate_open", mcLoc("block/birch_planks")); ModelFile birchFenceGateWall = fenceGateWall("birch_fence_gate_wall", mcLoc("block/birch_planks")); ModelFile birchFenceGateWallOpen = fenceGateWallOpen("birch_fence_gate_wall_open", mcLoc("block/birch_planks")); ModelFile invisbleModel = new UncheckedModelFile(new ResourceLocation("builtin/generated")); VariantBlockStateBuilder builder = getVariantBuilder(Blocks.BIRCH_FENCE_GATE); for (Direction dir : FenceGateBlock.HORIZONTAL_FACING.getAllowedValues()) { int angle = (int) dir.getHorizontalAngle(); builder .partialState() .with(FenceGateBlock.HORIZONTAL_FACING, dir) .with(FenceGateBlock.IN_WALL, false) .with(FenceGateBlock.OPEN, false) .modelForState() .modelFile(invisbleModel) .nextModel() .modelFile(birchFenceGate) .rotationY(angle) .uvLock(true) .weight(100) .addModel() .partialState() .with(FenceGateBlock.HORIZONTAL_FACING, dir) .with(FenceGateBlock.IN_WALL, false) .with(FenceGateBlock.OPEN, true) .modelForState() .modelFile(birchFenceGateOpen) .rotationY(angle) .uvLock(true) .addModel() .partialState() .with(FenceGateBlock.HORIZONTAL_FACING, dir) .with(FenceGateBlock.IN_WALL, true) .with(FenceGateBlock.OPEN, false) .modelForState() .modelFile(birchFenceGateWall) .rotationY(angle) .uvLock(true) .addModel() .partialState() .with(FenceGateBlock.HORIZONTAL_FACING, dir) .with(FenceGateBlock.IN_WALL, true) .with(FenceGateBlock.OPEN, true) .modelForState() .modelFile(birchFenceGateWallOpen) .rotationY(angle) .uvLock(true) .addModel(); } // Realistic examples using helpers simpleBlock(Blocks.STONE, model -> ObjectArrays.concat( ConfiguredModel.allYRotations(model, 0, false), ConfiguredModel.allYRotations(model, 180, false), ConfiguredModel.class)); // From here on, models are 1-to-1 copies of vanilla (except for model locations) and will be tested as such below ModelFile block = getBuilder("block").transforms() .transform(Perspective.GUI) .rotation(30, 225, 0) .scale(0.625f) .end() .transform(Perspective.GROUND) .translation(0, 3, 0) .scale(0.25f) .end() .transform(Perspective.FIXED) .scale(0.5f) .end() .transform(Perspective.THIRDPERSON_RIGHT) .rotation(75, 45, 0) .translation(0, 2.5f, 0) .scale(0.375f) .end() .transform(Perspective.FIRSTPERSON_RIGHT) .rotation(0, 45, 0) .scale(0.4f) .end() .transform(Perspective.FIRSTPERSON_LEFT) .rotation(0, 225, 0) .scale(0.4f) .end() .end(); getBuilder("cube") .parent(block) .element() .allFaces((dir, face) -> face.texture("#" + dir.getName()).cullface(dir)); ModelFile furnace = orientable("furnace", mcLoc("block/furnace_side"), mcLoc("block/furnace_front"), mcLoc("block/furnace_top")); ModelFile furnaceLit = orientable("furnace_on", mcLoc("block/furnace_side"), mcLoc("block/furnace_front_on"), mcLoc("block/furnace_top")); getVariantBuilder(Blocks.FURNACE) .forAllStates(state -> ConfiguredModel.builder() .modelFile(state.get(FurnaceBlock.LIT) ? furnaceLit : furnace) .rotationY((int) state.get(FurnaceBlock.FACING).getOpposite().getHorizontalAngle()) .build() ); ModelFile barrel = cubeBottomTop("barrel", mcLoc("block/barrel_side"), mcLoc("block/barrel_bottom"), mcLoc("block/barrel_top")); ModelFile barrelOpen = cubeBottomTop("barrel_open", mcLoc("block/barrel_side"), mcLoc("block/barrel_bottom"), mcLoc("block/barrel_top_open")); directionalBlock(Blocks.BARREL, state -> state.get(BarrelBlock.PROPERTY_OPEN) ? barrelOpen : barrel); // Testing custom state interpreter logBlock((LogBlock) Blocks.ACACIA_LOG); stairsBlock((StairsBlock) Blocks.ACACIA_STAIRS, "acacia", mcLoc("block/acacia_planks")); slabBlock((SlabBlock) Blocks.ACACIA_SLAB, Blocks.ACACIA_PLANKS.getRegistryName(), mcLoc("block/acacia_planks")); fenceBlock((FenceBlock) Blocks.ACACIA_FENCE, "acacia", mcLoc("block/acacia_planks")); fenceGateBlock((FenceGateBlock) Blocks.ACACIA_FENCE_GATE, "acacia", mcLoc("block/acacia_planks")); wallBlock((WallBlock) Blocks.COBBLESTONE_WALL, "cobblestone", mcLoc("block/cobblestone")); paneBlock((PaneBlock) Blocks.GLASS_PANE, "glass", mcLoc("block/glass"), mcLoc("block/glass_pane_top")); doorBlock((DoorBlock) Blocks.ACACIA_DOOR, "acacia", mcLoc("block/acacia_door_bottom"), mcLoc("block/acacia_door_top")); trapdoorBlock((TrapDoorBlock) Blocks.ACACIA_TRAPDOOR, "acacia", mcLoc("block/acacia_trapdoor"), true); trapdoorBlock((TrapDoorBlock) Blocks.OAK_TRAPDOOR, "oak", mcLoc("block/oak_trapdoor"), false); // Test a non-orientable trapdoor simpleBlock(Blocks.TORCH, torch("torch", mcLoc("block/torch"))); horizontalBlock(Blocks.WALL_TORCH, torchWall("wall_torch", mcLoc("block/torch")), 90); } // Testing the outputs private static final Set IGNORED_BLOCKS = ImmutableSet.of(Blocks.BIRCH_FENCE_GATE, Blocks.STONE); private static final Set IGNORED_MODELS = ImmutableSet.of(); private List errors = new ArrayList<>(); @Override public void act(DirectoryCache cache) throws IOException { super.act(cache); this.errors.addAll(testModelResults(this.generatedModels, existingFileHelper, IGNORED_MODELS)); this.registeredBlocks.forEach((block, state) -> { if (IGNORED_BLOCKS.contains(block)) return; JsonObject generated = state.toJson(); try { IResource vanillaResource = existingFileHelper.getResource(block.getRegistryName(), ResourcePackType.CLIENT_RESOURCES, ".json", "blockstates"); JsonObject existing = GSON.fromJson(new InputStreamReader(vanillaResource.getInputStream()), JsonObject.class); if (state instanceof VariantBlockStateBuilder) { compareVariantBlockstates(block, generated, existing); } else if (state instanceof MultiPartBlockStateBuilder) { compareMultipartBlockstates(block, generated, existing); } else { throw new IllegalStateException("Unknown blockstate type: " + state.getClass()); } } catch (IOException e) { throw new RuntimeException(e); } }); if (!errors.isEmpty()) { LOGGER.error("Found {} discrepancies between generated and vanilla models/blockstates: ", errors.size()); for (String s : errors) { LOGGER.error(" {}", s); } throw new AssertionError("Generated blockstates/models differed from vanilla equivalents, check above errors."); } } private void compareVariantBlockstates(Block block, JsonObject generated, JsonObject vanilla) { JsonObject generatedVariants = generated.getAsJsonObject("variants"); JsonObject vanillaVariants = vanilla.getAsJsonObject("variants"); Stream.concat(generatedVariants.entrySet().stream(), vanillaVariants.entrySet().stream()) .map(e -> e.getKey()) .distinct() .forEach(key -> { JsonElement generatedVariant = generatedVariants.get(key); JsonElement vanillaVariant = vanillaVariants.get(key); if (generatedVariant.isJsonArray()) { compareArrays(block, "key " + key, "random variants", generatedVariant, vanillaVariant); for (int i = 0; i < generatedVariant.getAsJsonArray().size(); i++) { compareVariant(block, key + "[" + i + "]", generatedVariant.getAsJsonArray().get(i).getAsJsonObject(), vanillaVariant.getAsJsonArray().get(i).getAsJsonObject()); } } if (generatedVariant.isJsonObject()) { if (!vanillaVariant.isJsonObject()) { blockstateError(block, "incorrectly does not have an array of variants for key %s", key); return; } compareVariant(block, key, generatedVariant.getAsJsonObject(), vanillaVariant.getAsJsonObject()); } }); } private void compareVariant(Block block, String key, JsonObject generatedVariant, JsonObject vanillaVariant) { if (generatedVariant == null) { blockstateError(block, "missing variant for %s", key); return; } if (vanillaVariant == null) { blockstateError(block, "has extra variant %s", key); return; } String generatedModel = toVanillaModel(generatedVariant.get("model").getAsString()); String vanillaModel = vanillaVariant.get("model").getAsString(); if (!generatedModel.equals(vanillaModel)) { blockstateError(block, "has incorrect model \"%s\" for variant %s. Expecting: %s", generatedModel, key, vanillaModel); return; } generatedVariant.addProperty("model", generatedModel); // Parse variants to objects to handle default values in vanilla jsons Variant parsedGeneratedVariant = GSON.fromJson(generatedVariant, Variant.class); Variant parsedVanillaVariant = GSON.fromJson(vanillaVariant, Variant.class); if (!parsedGeneratedVariant.equals(parsedVanillaVariant)) { blockstateError(block, "has incorrect variant %s. Expecting: %s, Found: %s", key, vanillaVariant, generatedVariant); return; } } private void compareMultipartBlockstates(Block block, JsonObject generated, JsonObject vanilla) { JsonElement generatedPartsElement = generated.get("multipart"); JsonElement vanillaPartsElement = vanilla.getAsJsonArray("multipart"); compareArrays(block, "parts", "multipart", generatedPartsElement, vanillaPartsElement); // String instead of JSON types due to inconsistent hashing Multimap generatedPartsByCondition = HashMultimap.create(); Multimap vanillaPartsByCondition = HashMultimap.create(); JsonArray generatedParts = generatedPartsElement.getAsJsonArray(); JsonArray vanillaParts = vanillaPartsElement.getAsJsonArray(); for (int i = 0; i < generatedParts.size(); i++) { JsonObject generatedPart = generatedParts.get(i).getAsJsonObject(); String generatedCondition = toEquivalentString(generatedPart.get("when")); JsonElement generatedVariants = generatedPart.get("apply"); if (generatedVariants.isJsonObject()) { correctVariant(generatedVariants.getAsJsonObject()); } else if (generatedVariants.isJsonArray()) { for (int j = 0; j < generatedVariants.getAsJsonArray().size(); j++) { correctVariant(generatedVariants.getAsJsonArray().get(i).getAsJsonObject()); } } generatedPartsByCondition.put(generatedCondition, toEquivalentString(generatedVariants)); JsonObject vanillaPart = vanillaParts.get(i).getAsJsonObject(); String vanillaCondition = toEquivalentString(vanillaPart.get("when")); String vanillaVariants = toEquivalentString(vanillaPart.get("apply")); vanillaPartsByCondition.put(vanillaCondition, vanillaVariants); } Stream.concat(generatedPartsByCondition.keySet().stream(), vanillaPartsByCondition.keySet().stream()) .distinct() .forEach(cond -> { Collection generatedVariants = generatedPartsByCondition.get(cond); Collection vanillaVariants = vanillaPartsByCondition.get(cond); if (generatedVariants.size() != vanillaVariants.size()) { if (vanillaVariants.isEmpty()) { blockstateError(block, " has extra condition %s", cond); } else if (generatedVariants.isEmpty()) { blockstateError(block, " is missing condition %s", cond); } else { blockstateError(block, " has differing amounts of variant lists matching condition %s. Expected: %d, Found: %d", cond, vanillaVariants.size(), generatedVariants.size()); } return; } if (!vanillaVariants.containsAll(generatedVariants) || !generatedVariants.containsAll(vanillaVariants)) { List extra = new ArrayList<>(generatedVariants); extra.removeAll(vanillaVariants); List missing = new ArrayList<>(vanillaVariants); missing.removeAll(generatedVariants); if (!extra.isEmpty()) { blockstateError(block, " has extra variants for condition %s: %s", cond, extra); } if (!missing.isEmpty()) { blockstateError(block, " has missing variants for condition %s: %s", cond, missing); } } }); } // Eliminate some formatting differences that are not meaningful private String toEquivalentString(JsonElement element) { return Objects.toString(element) .replaceAll("\"(true|false)\"", "$1") // Unwrap booleans in strings .replaceAll("\"(-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)\"", "$1"); // Unwrap numbers in strings, regex from https://stackoverflow.com/questions/13340717/json-numbers-regular-expression } private void correctVariant(JsonObject variant) { variant.addProperty("model", toVanillaModel(variant.get("model").getAsString())); } private boolean compareArrays(Block block, String key, String name, JsonElement generated, JsonElement vanilla) { if (!vanilla.isJsonArray()) { blockstateError(block, "incorrectly has an array of %s for %s", name, key); return false; } JsonArray generatedArray = generated.getAsJsonArray(); JsonArray vanillaArray = vanilla.getAsJsonArray(); if (generatedArray.size() != vanillaArray.size()) { blockstateError(block, "has incorrect number of %s for %s. Expecting: %s, Found: %s", name, key, vanillaArray.size(), generatedArray.size()); return false; } return true; } private void blockstateError(Block block, String fmt, Object... args) { errors.add("Generated blockstate for block " + block + " " + String.format(fmt, args)); } @Override public String getName() { return "Forge Test Blockstates"; } } private static > List testModelResults(Map models, ExistingFileHelper existingFileHelper, Set toIgnore) { List ret = new ArrayList<>(); models.forEach((loc, model) -> { if (toIgnore.contains(loc)) return; JsonObject generated = model.toJson(); if (generated.has("parent")) { generated.addProperty("parent", toVanillaModel(generated.get("parent").getAsString())); } try { IResource vanillaResource = existingFileHelper.getResource(new ResourceLocation(loc.getPath()), ResourcePackType.CLIENT_RESOURCES, ".json", "models"); JsonObject existing = GSON.fromJson(new InputStreamReader(vanillaResource.getInputStream()), JsonObject.class); JsonElement generatedDisplay = generated.remove("display"); JsonElement vanillaDisplay = existing.remove("display"); if (generatedDisplay == null && vanillaDisplay != null) { ret.add("Model " + loc + " is missing transforms"); return; } else if (generatedDisplay != null && vanillaDisplay == null) { ret.add("Model " + loc + " has transforms when vanilla equivalent does not"); return; } else if (generatedDisplay != null) { // Both must be non-null ItemCameraTransforms generatedTransforms = GSON.fromJson(generatedDisplay, ItemCameraTransforms.class); ItemCameraTransforms vanillaTransforms = GSON.fromJson(vanillaDisplay, ItemCameraTransforms.class); for (Perspective type : Perspective.values()) { if (!generatedTransforms.getTransform(type.vanillaType).equals(vanillaTransforms.getTransform(type.vanillaType))) { ret.add("Model " + loc + " has transforms that differ from vanilla equivalent for perspective " + type.name()); return; } } } if (!existing.equals(generated)) { ret.add("Model " + loc + " does not match vanilla equivalent"); } } catch (IOException e) { throw new RuntimeException(e); } }); return ret; } private static String toVanillaModel(String model) { // We generate our own model jsons to test model building, but otherwise our blockstates should be identical // So remove modid to match return model.replaceAll("^\\w+:", ""); } }