From de691e24ec7692bc19a9d1bef6b6aa0b1686bbd3 Mon Sep 17 00:00:00 2001 From: notevil Date: Fri, 24 Apr 2026 00:31:26 +0200 Subject: [PATCH] P3-19 : RIG debug overlay (F6 toggle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope reduced to overlay only — E2E integration test deferred. Overlay displays current living motion + composite motion, equipped bondage count, livingAnimations bindings count, active layers w/ priority + anim. Critical for dev-visible feedback when placeholder identity anims don't change the visual rig but the pipeline is running correctly. Keybind : F6 (configurable in Controls menu, category TiedUp!). F6 chosen because unused by vanilla and preserves F3 for debug screen. No new accessor needed on Animator — getLivingAnimations() already exists (returns ImmutableMap.copyOf, safe for debug read). Layer priority reading falls back on Layer.toString() parsing for composite layers where the priority field is protected ; base layer uses the public getBaseLayerPriority(). Tests : 6 pure unit tests (toggle flag, null-player branch, motionName on vanilla+tiedup enums+null). Real render path is MC-runtime-bound and validated gameday only. --- .../tiedup/remake/client/ModKeybindings.java | 24 +- .../remake/rig/debug/RigDebugOverlay.java | 357 ++++++++++++++++++ .../resources/assets/tiedup/lang/en_us.json | 1 + .../remake/rig/debug/RigDebugOverlayTest.java | 115 ++++++ 4 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/tiedup/remake/rig/debug/RigDebugOverlay.java create mode 100644 src/test/java/com/tiedup/remake/rig/debug/RigDebugOverlayTest.java diff --git a/src/main/java/com/tiedup/remake/client/ModKeybindings.java b/src/main/java/com/tiedup/remake/client/ModKeybindings.java index 751405c..4be2114 100644 --- a/src/main/java/com/tiedup/remake/client/ModKeybindings.java +++ b/src/main/java/com/tiedup/remake/client/ModKeybindings.java @@ -125,6 +125,18 @@ public class ModKeybindings { CATEGORY ); + /** + * RIG debug overlay keybinding (P3-19) - Toggle the F3+B-style debug + * overlay that displays rig state in real-time. + * Default: F6 (unused by vanilla Minecraft). + */ + public static final KeyMapping RIG_DEBUG_KEY = new KeyMapping( + "key.tiedup.rig_debug", + InputConstants.Type.KEYSYM, + org.lwjgl.glfw.GLFW.GLFW_KEY_F6, // Default key: F6 + CATEGORY + ); + /** Track last sent state to avoid spamming packets */ private static boolean lastForceSeatState = false; @@ -149,7 +161,8 @@ public class ModKeybindings { event.register(BOUNTY_KEY); event.register(FORCE_SEAT_KEY); event.register(TIGHTEN_KEY); - TiedUpMod.LOGGER.info("Registered {} keybindings", 7); + event.register(RIG_DEBUG_KEY); + TiedUpMod.LOGGER.info("Registered {} keybindings", 8); } // ==================== STRUGGLE MINI-GAME (uses vanilla movement keys) ==================== @@ -280,6 +293,15 @@ public class ModKeybindings { ); } } + + // RIG debug overlay toggle (P3-19) — F6 by default + while (RIG_DEBUG_KEY.consumeClick()) { + boolean nowOn = com.tiedup.remake.rig.debug.RigDebugOverlay.toggle(); + TiedUpMod.LOGGER.debug( + "[CLIENT] RIG debug overlay: {}", + nowOn ? "ENABLED" : "DISABLED" + ); + } } /** diff --git a/src/main/java/com/tiedup/remake/rig/debug/RigDebugOverlay.java b/src/main/java/com/tiedup/remake/rig/debug/RigDebugOverlay.java new file mode 100644 index 0000000..3b45688 --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/debug/RigDebugOverlay.java @@ -0,0 +1,357 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.debug; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.RenderGuiOverlayEvent; +import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import com.tiedup.remake.core.TiedUpMod; +import com.tiedup.remake.rig.anim.LivingMotion; +import com.tiedup.remake.rig.anim.client.ClientAnimator; +import com.tiedup.remake.rig.anim.client.Layer; +import com.tiedup.remake.rig.anim.types.StaticAnimation; +import com.tiedup.remake.rig.asset.AssetAccessor; +import com.tiedup.remake.rig.patch.PlayerPatch; +import com.tiedup.remake.rig.patch.TiedUpCapabilities; +import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry; + +/** + * P3-19 — debug overlay textuel inspiré de F3+B (vanilla debug hitboxes). + * + *

Affiche en temps réel l'état du pipeline RIG du {@link LocalPlayer} :

+ * + * + *

Objectif dev : distinguer pipeline broken vs pipeline OK mais anim + * identity invisible (cas placeholder JSON pré-Phase 4 où aucune anim Blender + * co-authored n'est encore bound). Sans ce feedback visuel, impossible de + * vérifier gameday que {@code addLivingAnimation(WALK_BOUND, ...)} a bien + * pushé le binding.

+ * + *

Usage

+ *

Toggle via la keybind {@code key.tiedup.rig_debug} (default F6, configurable + * dans le menu Controls, catégorie TiedUp!). L'overlay ne render que lorsque + * {@link #DEBUG_OVERLAY_ENABLED} est {@code true}.

+ * + *

Testabilité

+ *

{@link #buildOverlayLines(Player)} est package-private et pur — testable + * unit avec un player null ou un player sans patch. Le rendu effectif + * ({@link #onRenderOverlay}) nécessite MC runtime (GuiGraphics, Font, + * Minecraft.getInstance) et n'est validable qu'en gameday.

+ * + *

Perf

+ *

Coût négligeable quand {@link #DEBUG_OVERLAY_ENABLED} = false (early + * return). Quand actif, le build lit la capability + itère les layers composite + * (5 priorities max) → O(1) par frame.

+ */ +@OnlyIn(Dist.CLIENT) +@Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.FORGE, + value = Dist.CLIENT +) +public final class RigDebugOverlay { + + /** Toggle global — flipped par la keybind dans {@code ModKeybindings#onClientTick}. */ + private static boolean DEBUG_OVERLAY_ENABLED = false; + + /** Padding gauche/haut de l'overlay en pixels guiScaled. */ + private static final int MARGIN = 4; + + /** Hauteur d'une ligne en pixels (vanilla font = 9, +3 pour l'espacement). */ + private static final int LINE_HEIGHT = 12; + + /** Couleur par défaut (blanc), drop shadow activé dans drawString. */ + private static final int COLOR_DEFAULT = 0xFFFFFFFF; + + private RigDebugOverlay() { + // utility class — enregistrée via @EventBusSubscriber + } + + // ==================== TOGGLE STATE ==================== + + /** + * Flip l'état de l'overlay. Appelé depuis le handler de keybind dans + * {@code ModKeybindings}. + * + * @return le nouvel état (après toggle) + */ + public static boolean toggle() { + DEBUG_OVERLAY_ENABLED = !DEBUG_OVERLAY_ENABLED; + return DEBUG_OVERLAY_ENABLED; + } + + /** Lecture de l'état courant (utilisé en tests). */ + public static boolean isEnabled() { + return DEBUG_OVERLAY_ENABLED; + } + + /** Reset state — uniquement pour les tests. */ + static void resetEnabledForTesting() { + DEBUG_OVERLAY_ENABLED = false; + } + + // ==================== RENDER EVENT ==================== + + /** + * Hook render — s'attache au post-hotbar overlay (non-critical placement + * qui ne perturbe pas les HUD vanilla de gameplay). + */ + @SubscribeEvent + public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) { + if (!DEBUG_OVERLAY_ENABLED) return; + + // On attache sur le HOTBAR overlay pour ne render qu'une fois par frame + // (choisi arbitrairement — n'importe quel overlay post-render ferait + // l'affaire, HOTBAR est présent dans 100% des contextes gameplay). + if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) { + return; + } + + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + if (player == null) return; + + GuiGraphics graphics = event.getGuiGraphics(); + Font font = mc.font; + if (font == null) return; + + List lines = buildOverlayLines(player); + renderLines(graphics, font, lines); + } + + /** + * Render effectif des lignes en haut-gauche. Extrait pour pouvoir être + * appelé depuis un contexte non-event (future devtool). + */ + private static void renderLines(GuiGraphics graphics, Font font, List lines) { + int y = MARGIN; + for (String line : lines) { + graphics.drawString(font, line, MARGIN, y, COLOR_DEFAULT, true); + y += LINE_HEIGHT; + } + } + + // ==================== PURE LOGIC (testable) ==================== + + /** + * Construit la liste de lignes à afficher. Pur (à l'exception de la lecture + * de la capability) — testable unit avec un player null. + * + *

Contrat null-safety :

+ *
    + *
  • {@code player == null} → liste avec header + "player: null"
  • + *
  • patch absent → header + "patch: null"
  • + *
  • animator absent (server side / pas ClientAnimator) → header + * + "animator: null"
  • + *
  • nominal : header + motion + items + bindings + layers
  • + *
+ * + * @return liste non-null, jamais vide (header toujours présent) + */ + static List buildOverlayLines(Player player) { + List lines = new ArrayList<>(); + lines.add("§l[TiedUp RIG Debug]§r"); + + if (player == null) { + lines.add("§cplayer: null§r"); + return lines; + } + + PlayerPatch patch = TiedUpCapabilities.getPlayerPatch(player); + if (patch == null) { + lines.add("§cpatch: null§r"); + return lines; + } + + ClientAnimator animator = patch.getClientAnimator(); + if (animator == null) { + lines.add("§canimator: null§r"); + return lines; + } + + appendMotionLines(lines, patch); + appendItemCountLine(lines, player); + appendBindingsCountLine(lines, animator); + appendLayerLines(lines, animator); + + return lines; + } + + /** + * Append les lignes {@code motion:} et {@code composite:} pour le patch + * fourni. Extrait pour lisibilité du {@link #buildOverlayLines} pipeline. + */ + private static void appendMotionLines(List lines, PlayerPatch patch) { + LivingMotion motion = patch.currentLivingMotion; + LivingMotion composite = patch.currentCompositeMotion; + lines.add(String.format("motion: §a%s§r", motionName(motion))); + lines.add(String.format("composite: §a%s§r", motionName(composite))); + } + + /** + * Compte les items data-driven équipés. Un armbinder occupant 3 régions + * compte pour 1 (dédup identity via {@code distinct()} sur + * {@code identityHashCode} des stacks). + */ + private static void appendItemCountLine(List lines, Player player) { + Map equipped = V2EquipmentHelper.getAllEquipped(player); + long count = equipped.values().stream() + .filter(s -> s != null && !s.isEmpty()) + .filter(s -> DataDrivenItemRegistry.get(s) != null) + .mapToInt(System::identityHashCode) + .distinct() + .count(); + lines.add(String.format("items: §e%d§r", count)); + } + + /** + * Compte les bindings {@code livingAnimations} actifs dans l'animator. + * Utilise l'API publique {@link com.tiedup.remake.rig.anim.Animator#getLivingAnimations()} + * qui retourne une {@code ImmutableMap.copyOf} — safe à lire sans + * modification. + */ + private static void appendBindingsCountLine(List lines, ClientAnimator animator) { + int bindings = animator.getLivingAnimations().size(); + lines.add(String.format("bindings: §e%d§r", bindings)); + } + + /** + * Append une ligne header {@code layers:} puis une ligne par layer actif + * (base + composite non-off). + * + *

Format ligne layer : {@code " [BASE|priority] anim_name"} — avec + * indentation 2 espaces pour visual grouping sous le header.

+ */ + private static void appendLayerLines(List lines, ClientAnimator animator) { + lines.add("§7layers:§r"); + + // Collect layer descriptions via iterAllLayers. On ne peut pas early- + // return depuis un Consumer, on collecte tout puis filtre. + List layerLines = new ArrayList<>(); + Consumer layerCollector = layer -> { + if (layer.isOff() && !isBaseLayer(layer)) { + // Skip composite layers off — le base est toujours rendu, + // même s'il tourne en IDLE. + return; + } + layerLines.add(describeLayer(layer)); + }; + + animator.iterAllLayers(layerCollector); + + if (layerLines.isEmpty()) { + lines.add(" §8(none)§r"); + } else { + lines.addAll(layerLines); + } + } + + /** + * Format une ligne de description pour un {@link Layer}. Public-ish + * (package-private) pour testabilité. + * + *

Format :

+ *
    + *
  • Base layer : {@code " [BASE/PRIORITY] anim_registry_name"}
  • + *
  • Composite layer : {@code " [COMP/PRIORITY] anim_registry_name"}
  • + *
  • Anim null/empty : {@code "(empty)"}
  • + *
+ */ + static String describeLayer(Layer layer) { + String kind = isBaseLayer(layer) ? "BASE" : "COMP"; + String priority = layerPriorityName(layer); + String animName = currentAnimationName(layer); + return String.format(" §7[%s/%s]§r §b%s§r", kind, priority, animName); + } + + /** + * Retourne le nom d'une {@link LivingMotion}. Pour un enum classique + * (LivingMotions, TiedUpLivingMotions), {@code toString()} renvoie + * {@code name()}. Pour null, retourne {@code "null"}. + */ + static String motionName(LivingMotion motion) { + return motion != null ? motion.toString() : "null"; + } + + /** + * Retourne le nom du registre de l'animation courante jouée par le layer, + * ou {@code "(empty)"} si le player n'a pas d'anim (layer off / fraichement + * initialisé). + */ + private static String currentAnimationName(Layer layer) { + if (layer.animationPlayer == null || layer.animationPlayer.isEmpty()) { + return "(empty)"; + } + AssetAccessor anim = + layer.animationPlayer.getAnimation(); + if (anim == null) return "(null)"; + try { + var rl = anim.registryName(); + return rl != null ? rl.toString() : "(unnamed)"; + } catch (Exception e) { + // registryName() peut throw pour EMPTY_ANIMATION / LinkAnimation + // / LayerOffAnimation ; on fallback sur le class name. + return anim.getClass().getSimpleName(); + } + } + + /** + * Retourne le nom de la priority d'un layer. Pour la base layer, on lit + * {@link Layer.BaseLayer#getBaseLayerPriority()}. Pour les composite, + * on lit le champ priority (protected — accessed via public describeLayer + * helper). Priority peut être null sur la base layer si elle n'a pas encore + * été initialisée (ne devrait jamais arriver après postInit, mais défensif). + */ + private static String layerPriorityName(Layer layer) { + if (layer instanceof Layer.BaseLayer baseLayer) { + Layer.Priority p = baseLayer.getBaseLayerPriority(); + return p != null ? p.name() : "?"; + } + // Pour un Layer composite, priority est protected — on passe par + // toString() en fallback et on parse. Plus simple : on regarde dans + // quelle entry du map il se trouve. Mais on n'a pas accès au map. + // Compromis pragmatique : le toString() de Layer inclut déjà la priority. + // Exemple : " Composite Layer(HIGH) : ..." → on extrait HIGH. + String toString = layer.toString(); + int open = toString.indexOf('('); + int close = toString.indexOf(')'); + if (open >= 0 && close > open) { + return toString.substring(open + 1, close); + } + return "?"; + } + + /** + * Teste si un {@link Layer} est la base layer. On ne peut pas utiliser + * {@code instanceof BaseLayer} car certains tests fournissent des mocks ; + * pragmatique : instanceof + null-check. + */ + private static boolean isBaseLayer(Layer layer) { + return layer instanceof Layer.BaseLayer; + } +} diff --git a/src/main/resources/assets/tiedup/lang/en_us.json b/src/main/resources/assets/tiedup/lang/en_us.json index 9b4d34e..7f79a2f 100644 --- a/src/main/resources/assets/tiedup/lang/en_us.json +++ b/src/main/resources/assets/tiedup/lang/en_us.json @@ -134,6 +134,7 @@ "key.tiedup.bounties": "Bounty List", "key.tiedup.force_seat": "Force Seat", "key.tiedup.tighten": "Tighten Binds", + "key.tiedup.rig_debug": "Toggle RIG Debug Overlay", "gui.tiedup.adjust_position": "Adjust Position", "gui.tiedup.bondage_inventory": "Bondage Inventory", diff --git a/src/test/java/com/tiedup/remake/rig/debug/RigDebugOverlayTest.java b/src/test/java/com/tiedup/remake/rig/debug/RigDebugOverlayTest.java new file mode 100644 index 0000000..a3b08c0 --- /dev/null +++ b/src/test/java/com/tiedup/remake/rig/debug/RigDebugOverlayTest.java @@ -0,0 +1,115 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.debug; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.tiedup.remake.rig.anim.LivingMotion; +import com.tiedup.remake.rig.anim.LivingMotions; +import com.tiedup.remake.rig.anim.TiedUpLivingMotions; + +/** + * Tests unitaires pour {@link RigDebugOverlay} (P3-19). + * + *

Les tests ne couvrent que la logique pure testable sans MC runtime : + *

    + *
  • toggle flag state
  • + *
  • null-player early return dans {@link RigDebugOverlay#buildOverlayLines}
  • + *
  • motionName pour enum + null
  • + *
+ * + *

Le rendu effectif (GuiGraphics, Font, iteration layers non-null) nécessite + * un client Minecraft live — validable uniquement en gameday.

+ */ +class RigDebugOverlayTest { + + @BeforeEach + void ensureOff() { + RigDebugOverlay.resetEnabledForTesting(); + } + + @AfterEach + void resetAfter() { + RigDebugOverlay.resetEnabledForTesting(); + } + + // ========== toggle state ========== + + /** L'état initial doit être false (overlay off par défaut). */ + @Test + void isEnabled_defaultIsFalse() { + assertFalse(RigDebugOverlay.isEnabled(), + "Overlay doit etre off au bootstrap — on active a la demande via keybind"); + } + + /** Toggle flip l'état ; retour = nouvel état (true après un flip). */ + @Test + void toggle_flipsState() { + assertFalse(RigDebugOverlay.isEnabled()); + + boolean afterFirst = RigDebugOverlay.toggle(); + assertTrue(afterFirst, "Apres 1er toggle depuis off, retour = true"); + assertTrue(RigDebugOverlay.isEnabled()); + + boolean afterSecond = RigDebugOverlay.toggle(); + assertFalse(afterSecond, "Apres 2e toggle, retour = false"); + assertFalse(RigDebugOverlay.isEnabled()); + } + + // ========== buildOverlayLines null-safety ========== + + /** + * {@link RigDebugOverlay#buildOverlayLines(net.minecraft.world.entity.player.Player)} + * avec null ne doit pas throw — early return avec le header + "player: null". + * + *

Ce test ne référence JAMAIS une classe MC runtime autre que + * {@code Player} (qui n'est que dans la signature). Appeler avec null + * n'exécute pas le code qui touche les capabilities.

+ */ + @Test + void buildOverlayLines_nullPlayer_returnsErrorLineNoThrow() { + List lines = assertDoesNotThrow(() -> RigDebugOverlay.buildOverlayLines(null)); + assertNotNull(lines); + assertEquals(2, lines.size(), + "Header + player: null — pas d'autre ligne quand player est null"); + assertTrue(lines.get(0).contains("TiedUp RIG Debug"), + "Premiere ligne = header"); + assertTrue(lines.get(1).contains("player: null"), + "Deuxieme ligne = marqueur d'erreur player null"); + } + + // ========== motionName ========== + + /** Motion non-null → toString() (= enum name() pour LivingMotions vanilla). */ + @Test + void motionName_vanillaEnum_returnsName() { + LivingMotion walk = LivingMotions.WALK; + assertEquals("WALK", RigDebugOverlay.motionName(walk)); + } + + /** Motion TiedUp enum → toString() idem. */ + @Test + void motionName_tiedUpEnum_returnsName() { + LivingMotion bound = TiedUpLivingMotions.values()[0]; // premier motion custom + assertEquals(bound.toString(), RigDebugOverlay.motionName(bound), + "TiedUpLivingMotions est aussi un enum → toString() = name()"); + } + + /** Motion null → "null" (ne throw pas). */ + @Test + void motionName_nullMotion_returnsLiteralNull() { + assertEquals("null", RigDebugOverlay.motionName(null)); + } +}