diff --git a/src/main/java/com/tiedup/remake/rig/render/TiedUpRenderEngine.java b/src/main/java/com/tiedup/remake/rig/render/TiedUpRenderEngine.java new file mode 100644 index 0000000..46d59d7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/render/TiedUpRenderEngine.java @@ -0,0 +1,287 @@ +/* + * Derived from Epic Fight (https://github.com/Epic-Fight/epicfight) + * by the Epic Fight Team, licensed under GPLv3. + * Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.render; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; + +import com.google.common.collect.Maps; +import com.mojang.blaze3d.vertex.PoseStack; + +import net.minecraft.client.model.EntityModel; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.EntityRenderersEvent; +import net.minecraftforge.client.event.RenderLivingEvent; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.ModLoader; +import net.minecraftforge.fml.common.Mod; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.entities.AbstractTiedUpNpc; +import com.tiedup.remake.rig.TiedUpRigConstants; +import com.tiedup.remake.rig.event.PatchedRenderersEvent; +import com.tiedup.remake.rig.patch.LivingEntityPatch; +import com.tiedup.remake.rig.patch.TiedUpCapabilities; + +/** + * Dispatch renderer RIG — Phase 2.6. + * + *

Singleton stateless qui câble deux événements Forge client-only sur deux + * buses différents :

+ * + *
    + *
  1. MOD bus — {@link EntityRenderersEvent.AddLayers} : construit la + * map {@code EntityType → PatchedEntityRenderer} (Phase 2 : PLAYER seul) + * et poste {@link PatchedRenderersEvent.Add} sur le mod event bus pour + * que des tiers (datapacks, modules TiedUp futurs) puissent enregistrer + * leurs propres patched renderers.
  2. + *
  3. FORGE bus — {@link RenderLivingEvent.Pre} (priority HIGH) : + * intercepte le render vanilla des entités whitelist, vérifie la + * présence d'un patch avec {@code overrideRender() == true}, dispatche + * vers le {@link PatchedEntityRenderer} approprié, puis annule le render + * vanilla.
  4. + *
+ * + *

Les deux subscribers sont des inner classes (un par bus) — Forge n'autorise + * pas un même class à être abonnée aux deux buses via annotation.

+ * + *

Filtre strict d'entités : seuls {@link Player} et + * {@link AbstractTiedUpNpc} passent le filtre instanceof. Objectif concret : + * ne pas intercepter les villagers MCA — cf. + * {@code docs/plans/rig/V3_REWORK_BACKLOG.md} V3-REW-10. Ajouter un EntityType + * dans {@link #entityRendererProvider} ne suffit pas à déclencher le dispatch, + * l'entity instance doit aussi matcher le filtre ; ça protège si un tiers + * registre une clé PLAYER pour un VillagerEntity custom (très improbable mais + * défensif).

+ * + *

Priority HIGH : firer avant les subscribers à priorité NORMAL (ex : + * mods cosmetics qui veulent canceler notre render). Si un subscriber HIGH+ + * nous a déjà canceled, on respecte et on skip.

+ * + *

Robustesse : toute exception pendant le dispatch est logguée une + * seule fois par UUID d'entity (évite le spam log si le rendu crash pour une + * entity donnée à chaque frame). L'event n'est pas canceled en cas d'exception + * → MC fallback sur le rendu vanilla.

+ * + *

Scope vs EF : fork simplifié de {@code RenderEngine} EF (850 LOC). + * Supprimé : GUI battlemode, EntityUI indicators, item renderers (stubbed), + * FirstPersonRenderer, BossHealthOverlay. Gardé : le dispatch core + * RenderLivingEvent + le pattern addLayers.

+ */ +@OnlyIn(Dist.CLIENT) +@SuppressWarnings("rawtypes") +public final class TiedUpRenderEngine { + + /** + * Provider map — construite en {@link ModBusEvents#onAddLayers} (MOD bus) + * puis consultée par {@link ForgeBusEvents#onRenderLiving} (FORGE bus). + * Vide tant qu'{@code AddLayers} n'a pas firé → {@code onRenderLiving} + * devient no-op. + * + *

Function prend {@link EntityType} en entrée (la context-free factory, + * pattern EF) — la {@link EntityRendererProvider.Context} est déjà capturée + * en closure dans le lambda builder.

+ */ + private static final Map, Function, PatchedEntityRenderer>> entityRendererProvider = + Maps.newHashMap(); + + /** + * Cache résolu des renderers (construit lazy au 1er hit, via {@link #getRendererFor}). + * Invalidé à chaque {@code AddLayers} via {@link ModBusEvents#onAddLayers}. + */ + private static final Map, PatchedEntityRenderer> rendererCache = Maps.newHashMap(); + + /** UUIDs pour lesquels on a déjà loggé une erreur render (anti-spam). */ + private static final Set loggedRenderErrors = new HashSet<>(); + + private TiedUpRenderEngine() {} + + /** + * Résout le renderer pour un {@link EntityType} — lazy-loaded au premier + * hit, cached ensuite. Null si aucun renderer enregistré (pass-through vers + * vanilla). + */ + private static PatchedEntityRenderer getRendererFor(EntityType entityType) { + return rendererCache.computeIfAbsent(entityType, key -> { + Function, PatchedEntityRenderer> provider = entityRendererProvider.get(key); + return provider != null ? provider.apply(key) : null; + }); + } + + /** + * Vérifie si un {@link Entity} peut être considéré pour un dispatch RIG + * (avant même le lookup patch / renderer). Utilisé par tests unitaires + * pour verrouiller le filtre. + */ + public static boolean isEligibleForDispatch(Entity entity) { + return entity instanceof Player || entity instanceof AbstractTiedUpNpc; + } + + /** + * Exposé pour tests unitaires — vérifie la présence d'un provider + * enregistré. Ne déclenche PAS la construction du renderer (qui requiert + * {@link EntityRendererProvider.Context}). + */ + public static boolean hasProviderFor(EntityType entityType) { + return entityRendererProvider.containsKey(entityType); + } + + /** + * Exposé pour tests unitaires — reset de l'état pour éviter les fuites + * entre tests. À ne PAS appeler depuis le code runtime. + */ + public static void resetForTest() { + entityRendererProvider.clear(); + rendererCache.clear(); + loggedRenderErrors.clear(); + } + + // ------------------------------------------------------------------ + // Subscribers — split entre MOD bus (AddLayers) et FORGE bus (RenderLiving) + // ------------------------------------------------------------------ + + /** + * Subscriber MOD bus — {@link EntityRenderersEvent.AddLayers} est posté + * sur ce bus (événement lié au loading des renderers). Auto-registered via + * l'annotation ; pas de wiring explicite requis dans {@code TiedUpMod}. + */ + @OnlyIn(Dist.CLIENT) + @Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, bus = Mod.EventBusSubscriber.Bus.MOD, value = Dist.CLIENT) + public static final class ModBusEvents { + + private ModBusEvents() {} + + /** + * Phase 2 : on enregistre uniquement {@link EntityType#PLAYER} → + * {@link TiedUpPlayerRenderer}. Phase 5 ajoutera les NPCs TiedUp (Damsel, + * Kidnapper, Maid, Master). + */ + @SubscribeEvent + public static void onAddLayers(EntityRenderersEvent.AddLayers event) { + final EntityRendererProvider.Context context = event.getContext(); + + entityRendererProvider.clear(); + rendererCache.clear(); + + // Phase 2 : player seul. Le lambda capture la context finale. + entityRendererProvider.put(EntityType.PLAYER, entityType -> new TiedUpPlayerRenderer(context, entityType)); + + // Extension hook : tierces parties peuvent ajouter leurs patched renderers. + ModLoader.get().postEvent(new PatchedRenderersEvent.Add(entityRendererProvider, context)); + + TiedUpRigConstants.LOGGER.info( + "TiedUpRenderEngine: registered {} patched entity renderer(s)", + entityRendererProvider.size() + ); + } + } + + /** + * Subscriber FORGE bus — {@link RenderLivingEvent.Pre} est posté sur ce + * bus (événement de rendu par frame). Auto-registered via l'annotation. + */ + @OnlyIn(Dist.CLIENT) + @Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, bus = Mod.EventBusSubscriber.Bus.FORGE, value = Dist.CLIENT) + public static final class ForgeBusEvents { + + private ForgeBusEvents() {} + + /** + * Dispatch principal — priority HIGH pour firer avant les mods cosmetics + * (NORMAL). Si un subscriber HIGH+ a déjà canceled, on respecte. + * + *

Generic wildcard avec {@code extends LivingEntity} — Forge fire un + * seul event instance générique, on ne peut pas filtrer par type param. + * Le filtre se fait par {@code instanceof} sur l'entity, et par + * {@link #getRendererFor}.

+ */ + @SubscribeEvent(priority = EventPriority.HIGH) + public static void onRenderLiving(RenderLivingEvent.Pre> event) { + if (event.isCanceled()) { + return; + } + + LivingEntity entity = event.getEntity(); + + // STRICT FILTER — Player (Phase 2) + NPCs TiedUp (Phase 5). Protège + // notamment contre une interception accidentelle des MCA villagers + // (cf. V3-REW-10 dans V3_REWORK_BACKLOG.md). Bonus : si un tiers mod + // registre un patched renderer pour un EntityType autre que Player / + // AbstractTiedUpNpc, le filtre l'arrête (force-opt-in volontaire). + if (!isEligibleForDispatch(entity)) { + return; + } + + PatchedEntityRenderer patchedRenderer = getRendererFor(entity.getType()); + if (patchedRenderer == null) { + return; + } + + LivingEntityPatch patch = TiedUpCapabilities.getEntityPatch(entity, LivingEntityPatch.class); + if (patch == null || !patch.overrideRender()) { + return; + } + + try { + dispatchRender(event, entity, patch, patchedRenderer); + event.setCanceled(true); + } catch (Throwable t) { + // Fallback vanilla — on ne cancel pas pour que MC render normalement. + // Log une seule fois par UUID (anti-spam : erreur potentiellement à + // chaque frame sinon). + if (loggedRenderErrors.add(entity.getUUID())) { + TiedUpRigConstants.LOGGER.warn( + "TiedUpRenderEngine: patched render failed for entity {} (type={}), falling back to vanilla render", + entity.getUUID(), entity.getType(), t + ); + } + } + } + + /** + * Dispatch typé — cast raw obligatoire car {@link PatchedEntityRenderer#render} + * est fortement typé avec des variances qu'on ne peut pas reconstruire + * depuis l'event Forge générique. + * + *

Les casts sont sûrs par construction : l'invariant de + * {@link #entityRendererProvider} est que le renderer construit est + * compatible avec le {@code EntityType} clé — donc pour + * {@code EntityType.PLAYER} → {@link TiedUpPlayerRenderer} qui accepte + * {@code AbstractClientPlayer} → {@link LivingEntity} passed. Un + * mismatch (improbable : seul registeur Phase 2 = player) crasherait + * au premier accès field dans le render body ; désiré fail-fast, le + * try/catch supérieur récupère et fallback vanilla.

+ */ + @SuppressWarnings({ "unchecked" }) + private static void dispatchRender( + RenderLivingEvent.Pre> event, + LivingEntity entity, + LivingEntityPatch patch, + PatchedEntityRenderer patchedRenderer + ) { + EntityRenderer vanillaRenderer = event.getRenderer(); + MultiBufferSource buffer = event.getMultiBufferSource(); + PoseStack poseStack = event.getPoseStack(); + int packedLight = event.getPackedLight(); + float partialTick = event.getPartialTick(); + + patchedRenderer.render(entity, patch, vanillaRenderer, buffer, poseStack, packedLight, partialTick); + } + } +} diff --git a/src/test/java/com/tiedup/remake/rig/render/TiedUpRenderEngineTest.java b/src/test/java/com/tiedup/remake/rig/render/TiedUpRenderEngineTest.java new file mode 100644 index 0000000..994fe26 --- /dev/null +++ b/src/test/java/com/tiedup/remake/rig/render/TiedUpRenderEngineTest.java @@ -0,0 +1,61 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.render; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests du dispatch engine RIG — scope limité au code pur qui ne dépend pas + * d'une instance {@code EntityType} / {@code Entity} Minecraft (tests pure-logic, + * voir {@code build.gradle}). + * + *

Les cas couverts ici vérouillent les protections défensives (null-safety + * du filtre d'entité, propreté du reset de registry entre appels {@code AddLayers}). + * Le dispatch complet (instancier un renderer, traiter un event {@code RenderLivingEvent.Pre}) + * ne peut pas être testé sans MC runtime — ce sera validé Phase 2.7+ via + * runClient smoke test.

+ */ +class TiedUpRenderEngineTest { + + @BeforeEach + void setUp() { + TiedUpRenderEngine.resetForTest(); + } + + @AfterEach + void tearDown() { + TiedUpRenderEngine.resetForTest(); + } + + @Test + void isEligibleForDispatchRejectsNull() { + assertFalse(TiedUpRenderEngine.isEligibleForDispatch(null), + "null entity must NOT be eligible — NPE guard upstream of instanceof chain"); + } + + @Test + void providerMapStartsEmpty() { + // hasProviderFor doit dériver l'état courant — sans AddLayers fired, la + // map est vide donc tous les EntityType (non testables ici sans MC + // runtime) retournent false. On valide au minimum le contrat null-safe. + assertFalse(TiedUpRenderEngine.hasProviderFor(null), + "null entity type must not crash, must return false"); + } + + @Test + void resetForTestClearsState() { + TiedUpRenderEngine.resetForTest(); + // Après reset, la map est vide — les helpers d'inspection retournent + // false. Une invocation supplémentaire de reset doit rester idempotente + // (pas de NPE sur structures pré-reset). + TiedUpRenderEngine.resetForTest(); + assertFalse(TiedUpRenderEngine.hasProviderFor(null), + "state reset must be idempotent and leave the engine in a clean slate"); + } +}