diff --git a/forge/src/main/java/org/embeddedt/modernfix/forge/dynresources/ModelBakeEventHelper.java b/forge/src/main/java/org/embeddedt/modernfix/forge/dynresources/ModelBakeEventHelper.java new file mode 100644 index 00000000..9a15f31d --- /dev/null +++ b/forge/src/main/java/org/embeddedt/modernfix/forge/dynresources/ModelBakeEventHelper.java @@ -0,0 +1,301 @@ +package org.embeddedt.modernfix.forge.dynresources; + +import com.google.common.collect.ForwardingMap; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Sets; +import com.google.common.graph.GraphBuilder; +import com.google.common.graph.MutableGraph; +import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet; +import net.minecraft.client.renderer.block.BlockModelShaper; +import net.minecraft.client.resources.model.BakedModel; +import net.minecraft.client.resources.model.ModelBakery; +import net.minecraft.client.resources.model.ModelResourceLocation; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraftforge.fml.ModContainer; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.forgespi.language.IModInfo; +import org.embeddedt.modernfix.ModernFix; +import org.embeddedt.modernfix.util.ForwardingInclDefaultsMap; +import org.jetbrains.annotations.Nullable; + +import java.util.AbstractSet; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; + +/** + * Stores a list of all known default block/item models in the game, and provides a namespaced version + * of the model registry that emulates vanilla keySet behavior. + */ +public class ModelBakeEventHelper { + private enum UniverseVisibility { + /** + * Mod cannot see any view of the universe of model locations. + */ + NONE, + /** + * Mod can see its own model locations and those of dependencies/dependents. + */ + SELF_AND_DEPS, + /** + * Mod can see every model location. + */ + EVERYTHING + } + private static final Map MOD_VISIBILITY_CONFIGURATION = ImmutableMap.builder() + .build(); + private final Map modelRegistry; + private final Set topLevelModelLocations; + private final MutableGraph dependencyGraph; + public ModelBakeEventHelper(Map modelRegistry) { + this.modelRegistry = modelRegistry; + this.topLevelModelLocations = new ObjectLinkedOpenHashSet<>(); + // Skip going through ModelLocationCache because most of the accesses will be misses + BuiltInRegistries.BLOCK.entrySet().forEach(entry -> { + var location = entry.getKey().location(); + for(BlockState state : entry.getValue().getStateDefinition().getPossibleStates()) { + topLevelModelLocations.add(BlockModelShaper.stateToModelLocation(location, state)); + } + }); + BuiltInRegistries.ITEM.keySet().forEach(key -> topLevelModelLocations.add(new ModelResourceLocation(key, "inventory"))); + this.topLevelModelLocations.addAll(modelRegistry.keySet()); + this.dependencyGraph = buildDependencyGraph(); + } + + private static MutableGraph buildDependencyGraph() { + MutableGraph dependencyGraph = GraphBuilder.undirected().build(); + ModList.get().forEachModContainer((id, mc) -> { + dependencyGraph.addNode(id); + for(IModInfo.ModVersion version : mc.getModInfo().getDependencies()) { + dependencyGraph.addNode(version.getModId()); + } + }); + for(String id : dependencyGraph.nodes()) { + Optional mContainer = ModList.get().getModContainerById(id); + if(mContainer.isPresent()) { + for(IModInfo.ModVersion version : mContainer.get().getModInfo().getDependencies()) { + // avoid self-loops + if(!Objects.equals(id, version.getModId())) + dependencyGraph.putEdge(id, version.getModId()); + } + } + } + return dependencyGraph; + } + + private static final Set WARNED_MOD_IDS = new HashSet<>(); + + /** + * Create a model registry that warns if keySet, entrySet, values are accessed. + * @param modId the mod that the event is being fired for + * @return a wrapper around the model registry + */ + private Map createWarningRegistry(String modId) { + return new ForwardingInclDefaultsMap() { + @Override + protected Map delegate() { + return modelRegistry; + } + + private void logWarning() { + if(!WARNED_MOD_IDS.add(modId)) + return; + ModernFix.LOGGER.warn("Mod '{}' is accessing Map#keySet/entrySet/values/replaceAll on the model registry map inside its event handler." + + " This probably won't work as expected with dynamic resources on. Prefer using Map#get/put and constructing ModelResourceLocations another way.", modId); + } + + @Override + public Set keySet() { + logWarning(); + return super.keySet(); + } + + @Override + public Set> entrySet() { + logWarning(); + return super.entrySet(); + } + + @Override + public Collection values() { + logWarning(); + return super.values(); + } + + @Override + public void replaceAll(BiFunction function) { + logWarning(); + super.replaceAll(function); + } + }; + } + + public Map wrapRegistry(String modId) { + var config = MOD_VISIBILITY_CONFIGURATION.getOrDefault(modId, UniverseVisibility.EVERYTHING); + if (config == UniverseVisibility.NONE) { + return createWarningRegistry(modId); + } + final Set modIdsToInclude = new HashSet<>(); + modIdsToInclude.add(modId); + try { + modIdsToInclude.addAll(this.dependencyGraph.adjacentNodes(modId)); + } catch(IllegalArgumentException ignored) { /* sanity check */ } + modIdsToInclude.remove("minecraft"); + Set ourModelLocations; + if (config == UniverseVisibility.SELF_AND_DEPS) { + ourModelLocations = Sets.filter(this.topLevelModelLocations, loc -> modIdsToInclude.contains(loc.getNamespace())); + } else { + ourModelLocations = this.topLevelModelLocations; + } + BakedModel missingModel = modelRegistry.get(ModelBakery.MISSING_MODEL_LOCATION); + return new EmulatedModelRegistry(modId, modIdsToInclude, missingModel, ourModelLocations); + } + + public class EmulatedModelRegistry extends ForwardingMap { + private final Set modIdsToInclude; + private final BakedModel missingModel; + private final Set ourModelLocations; + private final String modId; + + private EmulatedModelRegistry(String modId, Set modIdsToInclude, BakedModel missingModel, Set ourModelLocations) { + this.modId = modId; + this.modIdsToInclude = modIdsToInclude; + this.missingModel = missingModel; + this.ourModelLocations = ourModelLocations; + } + + @Override + protected Map delegate() { + return modelRegistry; + } + + @Override + public BakedModel get(@Nullable Object key) { + BakedModel model = super.get(key); + if(model == null && key != null && modIdsToInclude.contains(((ResourceLocation)key).getNamespace())) { + ModernFix.LOGGER.warn("Model {} is missing, but was requested in model bake event. Returning missing model", key); + return missingModel; + } + return model; + } + + @Override + public Set keySet() { + return Collections.unmodifiableSet(ourModelLocations); + } + + @Override + public boolean containsKey(@Nullable Object key) { + return ourModelLocations.contains(key) || super.containsKey(key); + } + + @Override + public Set> entrySet() { + return new DynamicModelEntrySet(this, ourModelLocations); + } + + @Override + public void replaceAll(BiFunction function) { + ModernFix.LOGGER.warn("Mod '{}' is calling replaceAll on the model registry. Some hacks will be used to keep this fast, but they may not be 100% compatible.", modId); + List locations = new ArrayList<>(ourModelLocations); + for(ResourceLocation location : locations) { + /* + * Fetching every model is insanely slow. So we call the function with a null object first, since it + * probably isn't expecting that. If we get an exception thrown, or it returns nonnull, then we know + * it actually cares about the given model. + */ + boolean needsReplacement; + try { + needsReplacement = function.apply(location, null) != null; + } catch(Throwable e) { + needsReplacement = true; + } + if(needsReplacement) { + BakedModel existing = get(location); + BakedModel replacement = function.apply(location, existing); + if(replacement != existing) { + put(location, replacement); + } + } + } + } + } + + private static class DynamicModelEntrySet extends AbstractSet> { + private final Map modelRegistry; + private final Set modelLocations; + + private DynamicModelEntrySet(Map modelRegistry, Set modelLocations) { + this.modelRegistry = modelRegistry; + this.modelLocations = modelLocations; + } + + @Override + public Iterator> iterator() { + var iter = this.modelLocations.iterator(); + return new Iterator<>() { + @Override + public boolean hasNext() { + return iter.hasNext(); + } + + @Override + public Map.Entry next() { + return new DynamicModelEntry(iter.next()); + } + }; + } + + @Override + public boolean contains(Object o) { + if(o instanceof Map.Entry entry) { + return modelRegistry.containsKey(entry.getKey()); + } else { + return false; + } + } + + @Override + public int size() { + return modelRegistry.size(); + } + + @Override + public boolean removeAll(Collection c) { + throw new UnsupportedOperationException(); + } + + private class DynamicModelEntry implements Map.Entry { + private final ResourceLocation location; + + private DynamicModelEntry(ResourceLocation location) { + this.location = location; + } + + @Override + public ResourceLocation getKey() { + return this.location; + } + + @Override + public BakedModel getValue() { + return modelRegistry.get(this.location); + } + + @Override + public BakedModel setValue(BakedModel value) { + return modelRegistry.put(this.location, value); + } + } + } +} diff --git a/forge/src/main/java/org/embeddedt/modernfix/forge/mixin/perf/dynamic_resources/ForgeHooksClientMixin.java b/forge/src/main/java/org/embeddedt/modernfix/forge/mixin/perf/dynamic_resources/ForgeHooksClientMixin.java new file mode 100644 index 00000000..198420e7 --- /dev/null +++ b/forge/src/main/java/org/embeddedt/modernfix/forge/mixin/perf/dynamic_resources/ForgeHooksClientMixin.java @@ -0,0 +1,62 @@ +package org.embeddedt.modernfix.forge.mixin.perf.dynamic_resources; + +import com.google.common.base.Stopwatch; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import net.minecraft.client.resources.model.BakedModel; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.client.ForgeHooksClient; +import net.minecraftforge.client.event.ModelEvent; +import net.minecraftforge.eventbus.api.Event; +import net.minecraftforge.fml.ModContainer; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.fml.ModLoader; +import net.minecraftforge.fml.util.ObfuscationReflectionHelper; +import org.embeddedt.modernfix.ModernFix; +import org.embeddedt.modernfix.forge.dynresources.ModelBakeEventHelper; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.Comparator; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Mixin(ForgeHooksClient.class) +public class ForgeHooksClientMixin { + /** + * Generate a more realistic keySet that contains every item and block model location, to help with mod compat. + */ + @Redirect(method = "onModifyBakingResult", at = @At(value = "INVOKE", target = "Lnet/minecraftforge/fml/ModLoader;postEvent(Lnet/minecraftforge/eventbus/api/Event;)V"), remap = false) + private static void postNamespacedKeySetEvent(ModLoader loader, Event event) { + if(!ModLoader.isLoadingStateValid()) + return; + ModelEvent.ModifyBakingResult bakeEvent = ((ModelEvent.ModifyBakingResult)event); + ModelBakeEventHelper helper = new ModelBakeEventHelper(bakeEvent.getModels()); + Method acceptEv = ObfuscationReflectionHelper.findMethod(ModContainer.class, "acceptEvent", Event.class); + Stopwatch globalTimer = Stopwatch.createStarted(); + Map times = new Object2ObjectOpenHashMap<>(); + ModList.get().forEachModContainer((id, mc) -> { + Map newRegistry = helper.wrapRegistry(id); + ModelEvent.ModifyBakingResult postedEvent = new ModelEvent.ModifyBakingResult(newRegistry, bakeEvent.getModelBakery()); + Stopwatch timer = times.computeIfAbsent(id, $ -> Stopwatch.createStarted()); + try { + acceptEv.invoke(mc, postedEvent); + } catch(ReflectiveOperationException e) { + e.printStackTrace(); + } + timer.stop(); + }); + globalTimer.stop(); + if (globalTimer.elapsed(TimeUnit.SECONDS) >= 1) { + ModernFix.LOGGER.warn("Posting dynamic ModelEvent.ModifyBakingResult to mods took {}, breakdown below:", globalTimer); + times.entrySet().stream() + .sorted(Comparator., Duration>comparing(e -> e.getValue().elapsed()).reversed()) + .filter(e -> e.getValue().elapsed(TimeUnit.MILLISECONDS) > 50) + .forEach(entry -> { + ModernFix.LOGGER.warn(" {}: {}", entry.getKey(), entry.getValue().toString()); + }); + } + } +}