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} :
+ *
+ * - {@code currentLivingMotion} + {@code currentCompositeMotion}
+ * - Nombre d'items bondage équipés (data-driven)
+ * - Nombre de bindings {@code livingAnimations} dans l'animator
+ * - Liste des layers actifs (base + composite) avec priority + anim courante
+ *
+ *
+ * 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 extends com.tiedup.remake.rig.anim.types.DynamicAnimation> 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));
+ }
+}