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 :
+ * + *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 MapGeneric 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 extends LivingEntity, ? extends EntityModel extends LivingEntity>> 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 extends LivingEntity, ? extends EntityModel extends LivingEntity>> 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"); + } +}