diff --git a/src/godotiq/tools/animation/__init__.py b/src/godotiq/tools/animation/__init__.py
index 48dc912..9db7524 100644
--- a/src/godotiq/tools/animation/__init__.py
+++ b/src/godotiq/tools/animation/__init__.py
@@ -1 +1,454 @@
-"""Animation Intelligence tools — state machines, timelines."""
+"""Animation Intelligence tools -- animation data extraction and analysis.
+
+These tools provide complete animation understanding for Godot 4 projects,
+extracting data from inline .tscn animations, code-referenced GLB animations,
+.tres resources, and AnimationTree state machines.
+"""
+
+from __future__ import annotations
+
+import re
+from collections import defaultdict
+
+from godotiq.parsers.tscn_parser import TscnScene
+
+# ---------------------------------------------------------------------------
+# Compiled regex patterns for GDScript analysis
+# ---------------------------------------------------------------------------
+_RE_PLAY = re.compile(r'\.play\(\s*(?:&)?"([^"]+)"')
+_RE_QUEUE = re.compile(r'\.queue\(\s*(?:&)?"([^"]+)"')
+_RE_TRAVEL = re.compile(r'\.travel\(\s*(?:&)?"([^"]+)"')
+_RE_ANIM_PLAYER_VAR = re.compile(
+    r'(?:var|@onready\s+var)\s+(\w+)\s*[:=].*AnimationPlayer'
+)
+_RE_ANIM_TREE_VAR = re.compile(
+    r'(?:var|@onready\s+var)\s+(\w+)\s*[:=].*AnimationTree'
+)
+_RE_DICT_DECL = re.compile(r'(?:const|var)\s+(\w+)\s*:?=?\s*\{')
+_RE_ARRAY_DECL = re.compile(r'(?:const|var)\s+(\w+)\s*:?=?\s*\[')
+_RE_KV_PAIR = re.compile(r'"([^"]+)"\s*:\s*"([^"]+)"')
+_RE_QUOTED_STR = re.compile(r'"([^"]+)"')
+_RE_TRACK_TYPE = re.compile(r'^tracks/(\d+)/type$')
+_RE_STRINGNAME = re.compile(r'^&"(.+)"$')
+_RE_LIB_DATA = re.compile(r'"(\w+)":\s*SubResource\("([^"]+)"\)')
+_RE_STATE_NODE = re.compile(r'^states/(\w+)/node$')
+
+# Heuristic keywords for animation-related dict/array names
+_ANIM_NAME_KEYWORDS = {"anim", "animation", "motion", "clip"}
+
+
+# ---------------------------------------------------------------------------
+# 2.0  _strip_string_name
+# ---------------------------------------------------------------------------
+
+def _strip_string_name(value: object) -> object:
+    """Strip Godot StringName syntax (&\"name\") from a value.
+
+    Args:
+        value: Any value; only strings are processed.
+
+    Returns:
+        The unwrapped string if it matched &\"...\", otherwise the original value.
+    """
+    if not isinstance(value, str):
+        return value
+    m = _RE_STRINGNAME.match(value)
+    if m:
+        return m.group(1)
+    return value
+
+
+# ---------------------------------------------------------------------------
+# 2.1  _extract_tscn_animations
+# ---------------------------------------------------------------------------
+
+def _extract_tscn_animations(scene: TscnScene) -> list[dict]:
+    """Extract inline Animation sub_resources from a parsed scene.
+
+    Args:
+        scene: A parsed TscnScene object.
+
+    Returns:
+        List of animation info dicts with name, length, loop_mode,
+        track_count, tracks_by_type, track_paths, sub_resource_id, source.
+    """
+    loop_mode_map = {0: "none", 1: "linear", 2: "pingpong"}
+    results: list[dict] = []
+
+    for sub_id, sub in scene.sub_resources.items():
+        if sub.type != "Animation":
+            continue
+
+        name = sub.properties.get("resource_name", "")
+        name = str(_strip_string_name(name)) if name else sub_id
+
+        length = float(sub.properties.get("length", 1.0))
+        loop_raw = sub.properties.get("loop_mode", 0)
+        loop_mode = loop_mode_map.get(int(loop_raw), "none")
+
+        # Count and categorize tracks
+        tracks_by_type: dict[str, int] = defaultdict(int)
+        track_paths: list[str] = []
+        track_indices: set[int] = set()
+
+        for key in sub.properties:
+            m = _RE_TRACK_TYPE.match(key)
+            if m:
+                idx = int(m.group(1))
+                track_indices.add(idx)
+                track_type = str(sub.properties[key])
+                tracks_by_type[track_type] += 1
+
+        for idx in sorted(track_indices):
+            path_key = f"tracks/{idx}/path"
+            if path_key in sub.properties:
+                track_paths.append(str(sub.properties[path_key]))
+
+        results.append({
+            "name": name,
+            "length": length,
+            "loop_mode": loop_mode,
+            "track_count": len(track_indices),
+            "tracks_by_type": dict(tracks_by_type),
+            "track_paths": track_paths,
+            "sub_resource_id": sub_id,
+            "source": "tscn_inline",
+        })
+
+    return results
+
+
+# ---------------------------------------------------------------------------
+# 2.2  _extract_animation_libraries
+# ---------------------------------------------------------------------------
+
+def _extract_animation_libraries(scene: TscnScene) -> dict[str, dict[str, str]]:
+    """Extract AnimationLibrary sub_resources and their animation mappings.
+
+    Handles both parsed dict format (single-line _data) and raw string
+    format (multiline _data).
+
+    Args:
+        scene: A parsed TscnScene object.
+
+    Returns:
+        Dict keyed by library sub_resource id, values are dicts mapping
+        animation name to Animation sub_resource id.
+    """
+    results: dict[str, dict[str, str]] = {}
+
+    for sub_id, sub in scene.sub_resources.items():
+        if sub.type != "AnimationLibrary":
+            continue
+
+        data = sub.properties.get("_data")
+        if data is None:
+            results[sub_id] = {}
+            continue
+
+        mapping: dict[str, str] = {}
+
+        if isinstance(data, dict):
+            # Format A: parsed Python dict
+            for anim_name, ref in data.items():
+                if isinstance(ref, dict) and ref.get("type") == "sub_resource":
+                    mapping[str(anim_name)] = ref["id"]
+        elif isinstance(data, str):
+            # Format B: raw string
+            for m in _RE_LIB_DATA.finditer(data):
+                mapping[m.group(1)] = m.group(2)
+
+        results[sub_id] = mapping
+
+    return results
+
+
+# ---------------------------------------------------------------------------
+# 2.3  _extract_animation_players
+# ---------------------------------------------------------------------------
+
+def _extract_animation_players(scene: TscnScene) -> list[dict]:
+    """Extract AnimationPlayer nodes and their library references.
+
+    Args:
+        scene: A parsed TscnScene object.
+
+    Returns:
+        List of player info dicts with name, path, and library_refs.
+    """
+    results: list[dict] = []
+
+    for node in scene.get_nodes_by_type("AnimationPlayer"):
+        path = node.name if node.parent == "." else f"{node.parent}/{node.name}"
+
+        library_refs: dict[str, str] = {}
+        libs_prop = node.properties.get("libraries")
+        if isinstance(libs_prop, dict):
+            for lib_name, ref in libs_prop.items():
+                if isinstance(ref, dict) and ref.get("type") == "sub_resource":
+                    library_refs[str(lib_name)] = ref["id"]
+
+        results.append({
+            "name": node.name,
+            "path": path,
+            "library_refs": library_refs,
+        })
+
+    return results
+
+
+# ---------------------------------------------------------------------------
+# 2.4  _extract_animation_trees
+# ---------------------------------------------------------------------------
+
+def _extract_animation_trees(scene: TscnScene) -> list[dict]:
+    """Extract AnimationTree nodes with state machine data.
+
+    Correlates AnimationTree nodes with AnimationNodeStateMachine and
+    AnimationNodeAnimation sub_resources.
+
+    Args:
+        scene: A parsed TscnScene object.
+
+    Returns:
+        List of tree info dicts with node_name, node_path, anim_player_path,
+        states, transitions, and animation_refs.
+    """
+    # Collect AnimationNodeAnimation refs
+    anim_node_refs: dict[str, str] = {}  # sub_id -> animation name
+    for sub_id, sub in scene.sub_resources.items():
+        if sub.type == "AnimationNodeAnimation":
+            anim_val = sub.properties.get("animation", "")
+            anim_node_refs[sub_id] = str(_strip_string_name(anim_val))
+
+    # Collect StateMachine data
+    state_machines: dict[str, dict] = {}
+    for sub_id, sub in scene.sub_resources.items():
+        if sub.type != "AnimationNodeStateMachine":
+            continue
+
+        states: list[str] = []
+        for key in sub.properties:
+            m = _RE_STATE_NODE.match(key)
+            if m:
+                states.append(m.group(1))
+
+        transitions: list[dict] = []
+        trans_list = sub.properties.get("transitions")
+        if isinstance(trans_list, list) and len(trans_list) >= 3:
+            for i in range(0, len(trans_list) - 2, 3):
+                from_state = str(_strip_string_name(trans_list[i]))
+                to_state = str(_strip_string_name(trans_list[i + 1]))
+                transitions.append({"from": from_state, "to": to_state})
+
+        # Collect animation refs from states
+        animation_refs: list[str] = []
+        for state_name in states:
+            node_key = f"states/{state_name}/node"
+            node_ref = sub.properties.get(node_key)
+            if isinstance(node_ref, dict) and node_ref.get("type") == "sub_resource":
+                ref_id = node_ref["id"]
+                if ref_id in anim_node_refs:
+                    animation_refs.append(anim_node_refs[ref_id])
+
+        state_machines[sub_id] = {
+            "states": states,
+            "transitions": transitions,
+            "animation_refs": animation_refs,
+        }
+
+    # Build results from AnimationTree nodes
+    results: list[dict] = []
+    for node in scene.get_nodes_by_type("AnimationTree"):
+        path = node.name if node.parent == "." else f"{node.parent}/{node.name}"
+        anim_player_path = str(node.properties.get("anim_player", ""))
+
+        tree_root = node.properties.get("tree_root")
+        sm_data: dict = {"states": [], "transitions": [], "animation_refs": []}
+
+        if isinstance(tree_root, dict) and tree_root.get("type") == "sub_resource":
+            root_id = tree_root["id"]
+            if root_id in state_machines:
+                sm_data = state_machines[root_id]
+
+        results.append({
+            "node_name": node.name,
+            "node_path": path,
+            "anim_player_path": anim_player_path,
+            "states": sm_data["states"],
+            "transitions": sm_data["transitions"],
+            "animation_refs": sm_data["animation_refs"],
+        })
+
+    return results
+
+
+# ---------------------------------------------------------------------------
+# 2.5  _extract_code_animations
+# ---------------------------------------------------------------------------
+
+def _extract_code_animations(gd_content: str) -> dict:
+    """Extract animation-related patterns from GDScript source code.
+
+    Scans for .play()/.queue()/.travel() calls, AnimationPlayer/Tree
+    variable declarations, animation dicts (ANIM_MAP), and animation
+    arrays (LOOPING_ANIMS).
+
+    Args:
+        gd_content: Raw GDScript source text.
+
+    Returns:
+        Dict with play_calls, travel_calls, anim_maps, looping_lists,
+        has_anim_player, has_anim_tree, anim_player_vars, anim_tree_vars, source.
+    """
+    play_calls: list[str] = []
+    travel_calls: list[str] = []
+    anim_maps: list[dict] = []
+    looping_lists: list[dict] = []
+    anim_player_vars: list[str] = []
+    anim_tree_vars: list[str] = []
+
+    # Simple regex scans
+    play_calls.extend(m.group(1) for m in _RE_PLAY.finditer(gd_content))
+    play_calls.extend(m.group(1) for m in _RE_QUEUE.finditer(gd_content))
+    travel_calls.extend(m.group(1) for m in _RE_TRAVEL.finditer(gd_content))
+    anim_player_vars.extend(m.group(1) for m in _RE_ANIM_PLAYER_VAR.finditer(gd_content))
+    anim_tree_vars.extend(m.group(1) for m in _RE_ANIM_TREE_VAR.finditer(gd_content))
+
+    # Dict and array extraction with brace/bracket counting
+    lines = gd_content.split("\n")
+    i = 0
+    while i < len(lines):
+        line = lines[i]
+
+        # Check for dict declarations
+        dm = _RE_DICT_DECL.search(line)
+        if dm and _is_anim_name(dm.group(1)):
+            name = dm.group(1)
+            body, end_i = _collect_balanced(lines, i, "{", "}")
+            entries = dict(_RE_KV_PAIR.findall(body))
+            anim_maps.append({"name": name, "entries": entries})
+            i = end_i + 1
+            continue
+
+        # Check for array declarations
+        am = _RE_ARRAY_DECL.search(line)
+        if am and _is_anim_name(am.group(1)):
+            name = am.group(1)
+            body, end_i = _collect_balanced(lines, i, "[", "]")
+            entries = _RE_QUOTED_STR.findall(body)
+            looping_lists.append({"name": name, "entries": entries})
+            i = end_i + 1
+            continue
+
+        i += 1
+
+    return {
+        "play_calls": play_calls,
+        "travel_calls": travel_calls,
+        "anim_maps": anim_maps,
+        "looping_lists": looping_lists,
+        "has_anim_player": len(anim_player_vars) > 0,
+        "has_anim_tree": len(anim_tree_vars) > 0,
+        "anim_player_vars": anim_player_vars,
+        "anim_tree_vars": anim_tree_vars,
+        "source": "code",
+    }
+
+
+def _is_anim_name(name: str) -> bool:
+    """Check if a variable name looks animation-related."""
+    lower = name.lower()
+    return any(kw in lower for kw in _ANIM_NAME_KEYWORDS)
+
+
+def _collect_balanced(
+    lines: list[str], start: int, open_char: str, close_char: str
+) -> tuple[str, int]:
+    """Collect lines until brackets/braces are balanced.
+
+    Args:
+        lines: All source lines.
+        start: Starting line index.
+        open_char: Opening bracket character.
+        close_char: Closing bracket character.
+
+    Returns:
+        Tuple of (collected text, end line index).
+    """
+    depth = 0
+    collected: list[str] = []
+    for i in range(start, len(lines)):
+        line = lines[i]
+        collected.append(line)
+        depth += line.count(open_char) - line.count(close_char)
+        if depth <= 0:
+            return "\n".join(collected), i
+    return "\n".join(collected), len(lines) - 1
+
+
+# ---------------------------------------------------------------------------
+# 2.6  _build_animation_summary
+# ---------------------------------------------------------------------------
+
+def _build_animation_summary(
+    scene_path: str,
+    scene: TscnScene,
+    session: object,
+) -> dict:
+    """Aggregate all animation data for a single scene.
+
+    Args:
+        scene_path: The res:// path of the scene.
+        scene: The parsed TscnScene object.
+        session: A GodotIQSession with _resolve_res_path method.
+
+    Returns:
+        Dict with animations, libraries, players, trees, code_animations,
+        code_only, and unused lists.
+    """
+    animations = _extract_tscn_animations(scene)
+    libraries = _extract_animation_libraries(scene)
+    players = _extract_animation_players(scene)
+    trees = _extract_animation_trees(scene)
+
+    # Extract code animations from attached scripts
+    code_animations: list[dict] = []
+    script_paths = scene.get_all_scripts()
+    for script_res in script_paths:
+        try:
+            fs_path = session._resolve_res_path(script_res)  # type: ignore[attr-defined]
+            content = fs_path.read_text(encoding="utf-8")
+            code_animations.append(_extract_code_animations(content))
+        except (OSError, AttributeError):
+            continue
+
+    # Build known animation names from scene data
+    scene_anim_names: set[str] = {a["name"] for a in animations}
+
+    # Build referenced animation names from code + trees
+    referenced_names: set[str] = set()
+    for ca in code_animations:
+        referenced_names.update(ca["play_calls"])
+        referenced_names.update(ca["travel_calls"])
+        for am in ca["anim_maps"]:
+            referenced_names.update(am["entries"].values())
+    for tree in trees:
+        referenced_names.update(tree["animation_refs"])
+
+    # Code-only: referenced but not in scene
+    code_only = sorted(referenced_names - scene_anim_names)
+
+    # Unused: in scene but not referenced (only if scripts exist or trees reference the player)
+    has_references = bool(script_paths) or bool(trees)
+    unused = sorted(scene_anim_names - referenced_names) if has_references else []
+
+    return {
+        "animations": animations,
+        "libraries": libraries,
+        "players": players,
+        "trees": trees,
+        "code_animations": code_animations,
+        "code_only": code_only,
+        "unused": unused,
+    }
diff --git a/tests/test_tools/test_extraction_helpers.py b/tests/test_tools/test_extraction_helpers.py
new file mode 100644
index 0000000..fa7c1f4
--- /dev/null
+++ b/tests/test_tools/test_extraction_helpers.py
@@ -0,0 +1,233 @@
+"""Tests for animation extraction helpers."""
+
+from __future__ import annotations
+
+import pytest
+
+
+# ---------------------------------------------------------------------------
+# _strip_string_name
+# ---------------------------------------------------------------------------
+
+class TestStripStringName:
+    """Tests for _strip_string_name utility."""
+
+    def test_strips_ampersand_syntax(self):
+        """&"idle" should become "idle"."""
+        from godotiq.tools.animation import _strip_string_name
+        assert _strip_string_name('&"idle"') == "idle"
+
+    def test_passthrough_clean_string(self):
+        """Already clean string returned as-is."""
+        from godotiq.tools.animation import _strip_string_name
+        assert _strip_string_name("idle") == "idle"
+
+    def test_non_string_passthrough(self):
+        """Non-string values returned unchanged."""
+        from godotiq.tools.animation import _strip_string_name
+        assert _strip_string_name(42) == 42
+
+
+# ---------------------------------------------------------------------------
+# _extract_tscn_animations
+# ---------------------------------------------------------------------------
+
+class TestExtractTscnAnimations:
+    """Tests for inline animation extraction from .tscn sub_resources."""
+
+    def test_four_animations_from_character(self, anim_session):
+        """animated_character.tscn should yield 4 inline animations."""
+        from godotiq.tools.animation import _extract_tscn_animations
+        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
+        anims = _extract_tscn_animations(scene)
+        names = {a["name"] for a in anims}
+        assert names == {"idle", "walk", "attack", "idle_ground"}
+
+    def test_idle_metadata(self, anim_session):
+        """idle animation: length=2.0, loop_mode="linear", track_count=4, all value type."""
+        from godotiq.tools.animation import _extract_tscn_animations
+        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
+        anims = _extract_tscn_animations(scene)
+        idle = next(a for a in anims if a["name"] == "idle")
+        assert idle["length"] == 2.0
+        assert idle["loop_mode"] == "linear"
+        assert idle["track_count"] == 4
+        assert idle["tracks_by_type"].get("value", 0) == 4
+
+    def test_walk_mixed_tracks(self, anim_session):
+        """walk animation: 3 value + 1 method track."""
+        from godotiq.tools.animation import _extract_tscn_animations
+        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
+        anims = _extract_tscn_animations(scene)
+        walk = next(a for a in anims if a["name"] == "walk")
+        assert walk["tracks_by_type"]["value"] == 3
+        assert walk["tracks_by_type"]["method"] == 1
+
+    def test_attack_broken_track_path(self, anim_session):
+        """attack animation should have "NonExistent:position" in track_paths."""
+        from godotiq.tools.animation import _extract_tscn_animations
+        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
+        anims = _extract_tscn_animations(scene)
+        attack = next(a for a in anims if a["name"] == "attack")
+        assert any("NonExistent" in p for p in attack["track_paths"])
+
+    def test_no_animations_returns_empty(self, anim_session):
+        """no_animation_scene.tscn should return empty list."""
+        from godotiq.tools.animation import _extract_tscn_animations
+        scene = anim_session.get_scene("res://scenes/no_animation_scene.tscn")
+        anims = _extract_tscn_animations(scene)
+        assert anims == []
+
+
+# ---------------------------------------------------------------------------
+# _extract_animation_libraries
+# ---------------------------------------------------------------------------
+
+class TestExtractAnimationLibraries:
+    """Tests for AnimationLibrary sub_resource extraction."""
+
+    def test_one_library_four_refs(self, anim_session):
+        """animated_character.tscn should have 1 library with 4 animation refs."""
+        from godotiq.tools.animation import _extract_animation_libraries
+        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
+        libs = _extract_animation_libraries(scene)
+        assert len(libs) == 1
+        lib_data = list(libs.values())[0]
+        assert len(lib_data) == 4
+
+    def test_library_maps_idle(self, anim_session):
+        """Library should map "idle" to "Animation_idle" sub_resource id."""
+        from godotiq.tools.animation import _extract_animation_libraries
+        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
+        libs = _extract_animation_libraries(scene)
+        lib_data = list(libs.values())[0]
+        assert lib_data["idle"] == "Animation_idle"
+
+
+# ---------------------------------------------------------------------------
+# _extract_animation_players
+# ---------------------------------------------------------------------------
+
+class TestExtractAnimationPlayers:
+    """Tests for AnimationPlayer node extraction."""
+
+    def test_one_player_named(self, anim_session):
+        """animated_character.tscn should have 1 player named "AnimationPlayer"."""
+        from godotiq.tools.animation import _extract_animation_players
+        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
+        players = _extract_animation_players(scene)
+        assert len(players) == 1
+        assert players[0]["name"] == "AnimationPlayer"
+
+    def test_player_has_default_library(self, anim_session):
+        """Player should have library_refs with empty string key (default)."""
+        from godotiq.tools.animation import _extract_animation_players
+        scene = anim_session.get_scene("res://scenes/animated_character.tscn")
+        players = _extract_animation_players(scene)
+        assert "" in players[0]["library_refs"]
+
+    def test_no_player_returns_empty(self, anim_session):
+        """no_animation_scene.tscn should return empty list."""
+        from godotiq.tools.animation import _extract_animation_players
+        scene = anim_session.get_scene("res://scenes/no_animation_scene.tscn")
+        players = _extract_animation_players(scene)
+        assert players == []
+
+
+# ---------------------------------------------------------------------------
+# _extract_animation_trees
+# ---------------------------------------------------------------------------
+
+class TestExtractAnimationTrees:
+    """Tests for AnimationTree + StateMachine extraction."""
+
+    def test_one_tree_two_states(self, anim_session):
+        """animated_scene_tree.tscn should have 1 tree with 2 states."""
+        from godotiq.tools.animation import _extract_animation_trees
+        scene = anim_session.get_scene("res://scenes/animated_scene_tree.tscn")
+        trees = _extract_animation_trees(scene)
+        assert len(trees) == 1
+        assert set(trees[0]["states"]) == {"idle", "walk"}
+
+    def test_tree_two_transitions(self, anim_session):
+        """Tree should have 2 transitions: idle->walk and walk->idle."""
+        from godotiq.tools.animation import _extract_animation_trees
+        scene = anim_session.get_scene("res://scenes/animated_scene_tree.tscn")
+        trees = _extract_animation_trees(scene)
+        transitions = trees[0]["transitions"]
+        assert len(transitions) == 2
+        pairs = {(t["from"], t["to"]) for t in transitions}
+        assert ("idle", "walk") in pairs
+        assert ("walk", "idle") in pairs
+
+    def test_animation_refs(self, anim_session):
+        """animation_refs should include "idle" and "walk"."""
+        from godotiq.tools.animation import _extract_animation_trees
+        scene = anim_session.get_scene("res://scenes/animated_scene_tree.tscn")
+        trees = _extract_animation_trees(scene)
+        assert "idle" in trees[0]["animation_refs"]
+        assert "walk" in trees[0]["animation_refs"]
+
+
+# ---------------------------------------------------------------------------
+# _extract_code_animations
+# ---------------------------------------------------------------------------
+
+class TestExtractCodeAnimations:
+    """Tests for GDScript animation pattern extraction."""
+
+    def test_play_calls_from_character(self, anim_root):
+        """character.gd should have play_calls including "idle", "attack", "special_move"."""
+        from godotiq.tools.animation import _extract_code_animations
+        content = (anim_root / "scripts" / "entities" / "character.gd").read_text()
+        result = _extract_code_animations(content)
+        assert "idle" in result["play_calls"]
+        assert "attack" in result["play_calls"]
+        assert "special_move" in result["play_calls"]
+
+    def test_anim_maps_from_character(self, anim_root):
+        """character.gd should have ANIM_MAP with 4 entries."""
+        from godotiq.tools.animation import _extract_code_animations
+        content = (anim_root / "scripts" / "entities" / "character.gd").read_text()
+        result = _extract_code_animations(content)
+        assert len(result["anim_maps"]) > 0
+        anim_map = result["anim_maps"][0]
+        assert len(anim_map["entries"]) == 4
+
+    def test_looping_lists_from_character(self, anim_root):
+        """character.gd should have LOOPING_ANIMS with 2 entries."""
+        from godotiq.tools.animation import _extract_code_animations
+        content = (anim_root / "scripts" / "entities" / "character.gd").read_text()
+        result = _extract_code_animations(content)
+        assert len(result["looping_lists"]) > 0
+        assert len(result["looping_lists"][0]["entries"]) == 2
+
+    def test_glb_nlatrack_entries(self, anim_root):
+        """glb_animated_entity.gd should have ANIM_MAP with NlaTrack values."""
+        from godotiq.tools.animation import _extract_code_animations
+        content = (anim_root / "scripts" / "entities" / "glb_animated_entity.gd").read_text()
+        result = _extract_code_animations(content)
+        assert len(result["anim_maps"]) > 0
+        entries = result["anim_maps"][0]["entries"]
+        assert any("NlaTrack" in v for v in entries.values())
+
+    def test_stringname_play_detected(self):
+        """`.play(&"name")` syntax should be detected."""
+        from godotiq.tools.animation import _extract_code_animations
+        result = _extract_code_animations('anim_player.play(&"jump")')
+        assert "jump" in result["play_calls"]
+
+    def test_queue_included_in_play_calls(self):
+        """`.queue("name")` should appear in play_calls."""
+        from godotiq.tools.animation import _extract_code_animations
+        result = _extract_code_animations('anim_player.queue("fade_out")')
+        assert "fade_out" in result["play_calls"]
+
+    def test_empty_script_no_crash(self):
+        """Empty script should return empty results without errors."""
+        from godotiq.tools.animation import _extract_code_animations
+        result = _extract_code_animations("")
+        assert result["play_calls"] == []
+        assert result["travel_calls"] == []
+        assert result["anim_maps"] == []
+        assert result["looping_lists"] == []
