P3-19 : RIG debug overlay (F6 toggle)

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.
This commit is contained in:
notevil
2026-04-24 00:31:26 +02:00
parent a2bcfe2dda
commit de691e24ec
4 changed files with 496 additions and 1 deletions

View File

@@ -125,6 +125,18 @@ public class ModKeybindings {
CATEGORY 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 */ /** Track last sent state to avoid spamming packets */
private static boolean lastForceSeatState = false; private static boolean lastForceSeatState = false;
@@ -149,7 +161,8 @@ public class ModKeybindings {
event.register(BOUNTY_KEY); event.register(BOUNTY_KEY);
event.register(FORCE_SEAT_KEY); event.register(FORCE_SEAT_KEY);
event.register(TIGHTEN_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) ==================== // ==================== 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"
);
}
} }
/** /**

View File

@@ -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).
*
* <p>Affiche en temps réel l'état du pipeline RIG du {@link LocalPlayer} :</p>
* <ul>
* <li>{@code currentLivingMotion} + {@code currentCompositeMotion}</li>
* <li>Nombre d'items bondage équipés (data-driven)</li>
* <li>Nombre de bindings {@code livingAnimations} dans l'animator</li>
* <li>Liste des layers actifs (base + composite) avec priority + anim courante</li>
* </ul>
*
* <p><b>Objectif dev</b> : 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.</p>
*
* <h2>Usage</h2>
* <p>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}.</p>
*
* <h2>Testabilité</h2>
* <p>{@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.</p>
*
* <h2>Perf</h2>
* <p>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.</p>
*/
@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<String> 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<String> 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.
*
* <p>Contrat null-safety :</p>
* <ul>
* <li>{@code player == null} → liste avec header + "player: null"</li>
* <li>patch absent → header + "patch: null"</li>
* <li>animator absent (server side / pas ClientAnimator) → header
* + "animator: null"</li>
* <li>nominal : header + motion + items + bindings + layers</li>
* </ul>
*
* @return liste non-null, jamais vide (header toujours présent)
*/
static List<String> buildOverlayLines(Player player) {
List<String> 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<String> 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<String> lines, Player player) {
Map<BodyRegionV2, ItemStack> 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<String> 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).
*
* <p>Format ligne layer : {@code " [BASE|priority] anim_name"} — avec
* indentation 2 espaces pour visual grouping sous le header.</p>
*/
private static void appendLayerLines(List<String> 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<String> layerLines = new ArrayList<>();
Consumer<Layer> 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é.
*
* <p>Format :</p>
* <ul>
* <li>Base layer : {@code " [BASE/PRIORITY] anim_registry_name"}</li>
* <li>Composite layer : {@code " [COMP/PRIORITY] anim_registry_name"}</li>
* <li>Anim null/empty : {@code "(empty)"}</li>
* </ul>
*/
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;
}
}

View File

@@ -134,6 +134,7 @@
"key.tiedup.bounties": "Bounty List", "key.tiedup.bounties": "Bounty List",
"key.tiedup.force_seat": "Force Seat", "key.tiedup.force_seat": "Force Seat",
"key.tiedup.tighten": "Tighten Binds", "key.tiedup.tighten": "Tighten Binds",
"key.tiedup.rig_debug": "Toggle RIG Debug Overlay",
"gui.tiedup.adjust_position": "Adjust Position", "gui.tiedup.adjust_position": "Adjust Position",
"gui.tiedup.bondage_inventory": "Bondage Inventory", "gui.tiedup.bondage_inventory": "Bondage Inventory",

View File

@@ -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).
*
* <p>Les tests ne couvrent que la logique pure testable sans MC runtime :
* <ul>
* <li>toggle flag state</li>
* <li>null-player early return dans {@link RigDebugOverlay#buildOverlayLines}</li>
* <li>motionName pour enum + null</li>
* </ul>
*
* <p>Le rendu effectif (GuiGraphics, Font, iteration layers non-null) nécessite
* un client Minecraft live — validable uniquement en gameday.</p>
*/
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".
*
* <p>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.</p>
*/
@Test
void buildOverlayLines_nullPlayer_returnsErrorLineNoThrow() {
List<String> 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));
}
}