# FBX Animation Retargeting — Cross-Skeleton Pipeline

> Partager des animations entre personnages de squelettes différents.
> Testé avec PolygonDungeon (personnage) + SwordAnimation (animations combat) — deux packs Synty, squelettes différents.

---

## Le piège : pourquoi le runtime ne marche pas

L'instinct c'est de charger un FBX au runtime, lire les keyframes, et les appliquer sur un autre squelette. **Ça ne marche pas.**

`load("res://animations/attack.fbx")` → les quaternions extraits des tracks sont **identiques aux rest poses** du squelette source. Le vrai décodage des données d'animation FBX se fait dans le pipeline d'import de Godot, pas au `load()`. Des heures de debug de formules de quaternions pour un problème qui n'est pas mathématique — les données sont simplement absentes.

**Règle : le retargeting se fait à l'import, jamais au runtime.**

---

## Pipeline complet

### Étape 1 — Configurer le BoneMap dans le `.import`

Le fichier `.import` d'un FBX accepte un `_subresources` qui configure le retargeting à l'import. Le BoneMap mappe les bones du FBX source vers un profil standard (SkeletonProfileHumanoid).

```ini
# attack.fbx.import — section à ajouter/modifier
[params]
_subresources={
  "nodes": {
    "PATH:AnimationPlayer": {
      "retarget/bone_map": <BoneMap resource>,
      "retarget/skeleton_profile": <SkeletonProfileHumanoid>
    }
  }
}
```

En pratique, le plus fiable : configurer une première animation manuellement dans l'éditeur (Import dock → Animation → Retarget), puis copier le `.import` résultant comme template pour les autres.

### Étape 2 — Déclencher le reimport

```gdscript
# Via execute_script dans l'éditeur
EditorInterface.get_resource_filesystem().reimport_files(["res://animations/attack.fbx"])
```

Le reimport applique le BoneMap et produit des animations retargetées. Les track paths pointent maintenant vers le squelette cible.

### Étape 3 — Charger et remap les track paths

Après reimport, les animations utilisent des track paths avec le prefix `%` (unique name) :
```
%GeneralSkeleton:Hips
%GeneralSkeleton:Spine_01
%GeneralSkeleton:UpperArm_L
```

Ce prefix fonctionne pour les animations embarquées dans un .tscn, mais **PAS pour les animations chargées dynamiquement**. L'AnimationPlayer ne résout pas le `%` sur des animations ajoutées au runtime.

```gdscript
func _remap_animation_tracks(anim: Animation, prefix: String = "%GeneralSkeleton:") -> void:
    var clean: String = prefix.substr(1)  # "GeneralSkeleton:"
    for i in range(anim.get_track_count()):
        var path: String = str(anim.track_get_path(i))
        if path.begins_with(prefix):
            anim.track_set_path(i, NodePath(clean + path.substr(prefix.length())))
```

**Piège off-by-one** : `prefix.length()`, jamais un nombre hardcodé. `"%GeneralSkeleton:"` = 19 chars, pas 18. Hardcoder = couper la première lettre du bone name (`ips` au lieu de `Hips`).

### Étape 4 — Charger et jouer (runtime loading pipeline)

Un FBX importé est une scène packée, pas une animation brute. Le pipeline complet pour extraire et jouer une animation au runtime :

```gdscript
const LOOPING_ANIMS: Array[String] = ["idle", "walk", "run"]

func _load_fbx_animation(fbx_path: String, anim_name: String) -> void:
    # 1. Load le FBX comme PackedScene
    var packed: PackedScene = load(fbx_path)
    if packed == null:
        return

    # 2. Instantiate pour accéder à l'AnimationPlayer
    var instance: Node = packed.instantiate()

    # 3. Trouver l'AnimationPlayer (souvent pas à la racine)
    var source_player: AnimationPlayer = _find_anim_player(instance)
    if source_player == null:
        instance.queue_free()
        return

    # 4. Extraire la première animation
    var source_lib: AnimationLibrary = source_player.get_animation_library("")
    var anim_names: PackedStringArray = source_lib.get_animation_list()
    if anim_names.is_empty():
        instance.queue_free()
        return

    # 5. Duplicate + remap + loop mode
    var anim: Animation = source_lib.get_animation(anim_names[0]).duplicate()
    _remap_animation_tracks(anim)
    if anim_name in LOOPING_ANIMS:
        anim.loop_mode = Animation.LOOP_LINEAR

    # 6. Ajouter à notre AnimationPlayer
    var lib: AnimationLibrary = anim_player.get_animation_library("")
    lib.add_animation(anim_name, anim)

    # 7. Cleanup — l'instance temporaire n'est jamais ajoutée à l'arbre
    instance.queue_free()


func _find_anim_player(node: Node) -> AnimationPlayer:
    if node is AnimationPlayer:
        return node
    for child in node.get_children():
        var found: AnimationPlayer = _find_anim_player(child)
        if found != null:
            return found
    return null
```

**Pattern `LOOPING_ANIMS`** : les animations FBX n'ont pas de metadata de loop. Maintenir une liste explicite des animations qui doivent boucler (idle, walk, run) plutôt que de deviner.

---

## Le tueur silencieux : MeshInstance3D.skeleton vide

Avant même de toucher au retargeting, vérifier que le mesh **réagit** au squelette. Les FBX importés (Synty notamment) peuvent avoir `skeleton = ""` sur les MeshInstance3D — le mesh reste en T-pose quoi qu'on fasse. Aucune erreur, aucun warning.

### Diagnostic T-pose (dans cet ordre)

| Étape | Test | Si KO |
|-------|------|-------|
| 1 | Rotation manuelle d'un bone → mesh bouge ? | `skeleton = NodePath("..")` sur le MeshInstance3D |
| 2 | `anim_player.root_node` pointe vers le bon parent ? | `root_node = ".."` (CharacterBody3D parent) |
| 3 | Track paths résolus ? (pas de `%` qui casse) | Remap avec `_remap_animation_tracks()` |
| 4 | Valeurs d'animation ≠ rest poses ? | Si identiques → source FBX corrompue ou reimport nécessaire |

**L'erreur classique** : sauter directement à l'étape 4 (debug quaternions) alors que le problème est à l'étape 1 (skeleton path vide). Le diagnostic séquentiel évite des heures de brute force.

### AnimationPlayer root_node hérité

Les scènes FBX importées ont souvent `root_node = "../.."` — correct dans le contexte de l'import, mais faux après instanciation dans une autre scène. Le root_node doit pointer vers le parent direct du Skeleton3D (typiquement le CharacterBody3D).

### Runtime patching dans `_ready()` — quand save_scene ne suffit pas

`godot_update_property` + `godot_save_scene` sur un node hérité d'un FBX importé **ne persiste pas** les overrides dans le .tscn. L'éditeur montre la bonne valeur (mémoire), `inspect_node` confirme, mais au runtime c'est l'ancienne valeur. Le piège : le diagnostic MCP entier dit "tout va bien" alors que rien n'a changé sur disque.

**Pattern complet — tout ce qui ne persiste pas doit être dans `_ready()` :**

```gdscript
# character_setup.gd — attaché au CharacterBody3D
extends CharacterBody3D

func _ready() -> void:
    # 1. Skeleton path — FBX imports laissent skeleton = "" sur les MeshInstance3D
    var mesh: MeshInstance3D = $Skeleton3D/MeshInstance3D
    if mesh and mesh.skeleton == NodePath(""):
        mesh.skeleton = NodePath("..")

    # 2. AnimationPlayer root_node — hérité "../.." de l'import, faux après instanciation
    var anim: AnimationPlayer = $AnimationPlayer
    if anim:
        anim.root_node = NodePath("..")

    # 3. Toute autre propriété que save_scene refuse de sérialiser
    # Ajouter ici au fur et à mesure des découvertes
```

**Diagnostic : éditeur vs disque vs runtime**

| Niveau | Comment vérifier | Fiabilité |
|--------|-----------------|-----------|
| Mémoire éditeur | `godot_inspect_node` | Peut mentir (faux positif) |
| Disque | `grep skeleton character.tscn` | Vérité — ce que Godot charge |
| Runtime | `GolemCapture.game_log("skeleton=" + str(mesh.skeleton))` | Preuve finale |

**Règle** : après tout `update_property` sur un node FBX/GLB, toujours `grep` le .tscn. Si la propriété n'y est pas → `_ready()` patching obligatoire.

---

## Synty cross-pack : un BoneMap pour les gouverner tous

Les packs Synty (PolygonDungeon, PolygonKnights, SwordAnimation, etc.) partagent la même convention de bones :

| Bone | Convention Synty |
|------|-----------------|
| Spine | `Spine_01`, `Spine_02` |
| Arms | `UpperArm_L`, `LowerArm_L`, `Hand_L` |
| Legs | `UpperLeg_L`, `LowerLeg_L`, `Foot_L` |
| Head | `Head`, `Neck` |

**Différences mineures** : les doigts. Certains packs utilisent `_L`/`_R` suffix, d'autres `_1` ou pas de suffix. Ces bones sont rarement animés dans les packs combat/locomotion, donc ignorables pour le retargeting de base.

**En pratique** : configurer UN BoneMap pour le premier FBX Synty, puis réutiliser le même `.import` template pour tous les autres. Le pipeline de l'étape 1 fonctionne tel quel entre packs.

---

## Batch : traiter N animations FBX

Testé sur 118 FBX SwordAnimation. Le pattern "preserve header, replace params" :

### Principe

Chaque `.import` a 3 sections :
- `[remap]` — uid et metadata uniques par fichier. **JAMAIS toucher**
- `[deps]` — dest_files, chemins de cache uniques. **JAMAIS toucher**
- `[params]` — configuration d'import, identique entre fichiers du même pack. **Remplacer**

### Pipeline

1. Configurer UN `.import` manuellement (éditeur Import dock → Animation → Retarget)
2. Extraire la section `[params]` comme template (tout ce qui est après `[params]`)
3. Script batch : pour chaque `.import`, préserver `[remap]` + `[deps]`, remplacer `[params]`
4. `reimport_files()` en batch
5. **Redémarrer Godot** avant de `load()` les ressources reimportées

```python
# batch_configure_imports.py — pattern réutilisable
import os

def configure_imports(fbx_dir: str, params_template: str) -> list[str]:
    """Preserve [remap]+[deps], replace [params] with template."""
    modified = []
    for fname in os.listdir(fbx_dir):
        if not fname.endswith(".fbx.import"):
            continue
        import_path = os.path.join(fbx_dir, fname)
        with open(import_path, "r", encoding="utf-8") as f:
            content = f.read()

        # Find [params] section and replace everything after it
        params_idx = content.find("[params]")
        if params_idx == -1:
            continue
        header = content[:params_idx]  # [remap] + [deps] preserved

        with open(import_path, "w", encoding="utf-8") as f:
            f.write(header + params_template)
        modified.append(fname)
    return modified
```

```gdscript
# Batch reimport — via execute_script dans l'éditeur
var dir := DirAccess.open("res://animations/")
var files: PackedStringArray = []
dir.list_dir_begin()
var fname: String = dir.get_next()
while fname != "":
    if fname.ends_with(".fbx"):
        files.append("res://animations/" + fname)
    fname = dir.get_next()

EditorInterface.get_resource_filesystem().reimport_files(files)
```

### Piège post-reimport

**Ne PAS `load()` immédiatement après un reimport massif.** Le cache `.godot/imported/` peut être instable — le jeu freeze sans erreur, sans log, sans trace. Redémarrer Godot entre le reimport et le test.

---

## Pièges

| Piège | Symptôme | Fix |
|-------|----------|-----|
| Retargeting runtime via load() | Quaternions = rest poses, personnage statique | Pipeline d'import avec BoneMap |
| `skeleton = ""` sur MeshInstance3D | T-pose permanente, aucune erreur | `skeleton = NodePath("..")` |
| Track paths `%GeneralSkeleton:` | Animations embarquées OK, dynamiques KO | `_remap_animation_tracks()` |
| `substr(18)` hardcodé | Bone name amputé (`ips` au lieu de `Hips`) | `substr(prefix.length())` |
| `root_node = "../.."` hérité | AnimationPlayer ne trouve pas le squelette | `root_node = ".."` |
| `rotation_track_get_value()` | Crash — méthode inexistante | `track_get_key_value()` (Variant) |
| Sauter le diagnostic séquentiel | Heures perdues sur les quaternions alors que skeleton path = vide | Toujours étape 1 → 4 dans l'ordre |
| `load()` après reimport massif | Crash silencieux, freeze sans trace | Redémarrer Godot entre reimport et test |
| FBX sans loop metadata | Idle/walk ne bouclent pas | Liste explicite `LOOPING_ANIMS` + `Animation.LOOP_LINEAR` |
| `update_property` + `save_scene` sur node FBX | Override OK en éditeur, ancienne valeur au runtime | `_ready()` patching (skeleton, root_node, etc.) |
| `inspect_node` faux positif sur imports | Confirme la valeur alors qu'elle n'est pas sur disque | `grep property file.tscn` pour vérifier la persistance |
