⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
animate-roblox-characters
Create, revise, polish, inspect, export, and preview complete stylized Roblox R6 and R15 character animations on authentic Studio-derived Blender rigs through MCP. Use when Codex must make Roblox walks, runs, idles, attacks, emotes, jumps, or other full-body actions; import or validate a real Roblox Motor6D/control or skinned rig; repair foot sliding or robotic motion; polish Graph Editor curves; export through the correct .rbxanim or FBX pipeline; produce a verified animated GIF; or optionally validate playback in Roblox Studio.
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
Not yet listed on ClawHub or SkillsMP
// README
Animate Roblox Characters Skill
animate-roblox-characters is an open-source Codex and Claude Code skill for creating complete stylized Roblox R6 and R15 character animations in Blender. It operates on an authentic premade or Studio-derived Roblox rig, handles bone/control inspection, pose planning, keyframing, Graph Editor polish, foot-lock and loop checks, source-appropriate export, a rendered animated GIF preview, and optional Roblox Studio validation.
Every pose is created on armature pose bones, checked from front and side views, critiqued, corrected, and polished before completion.

Example 24-frame, 30 FPS stylized R6 walk created with GPT-5.6 Sol using extra-high reasoning. The preview was rendered from Blender frames and encoded with one shared palette.
Every completed animation includes:
- A final
.blendworking file saved separately from the original rig. - A Roblox
.rbxanimor animation-only FBX export selected from the rig type. - Front/side pose reviews plus curve, contact, hierarchy, and loop audits.
- A verified animated GIF rendered from the final action—not a screen recording.
Requirements
- Codex or Claude Code with Agent Skills support.
- Blender 4.5 LTS (recommended) or a compatible newer release.
- A Blender MCP server that can execute arbitrary Python in the open Blender process.
- Python 3.10 or newer.
- Pillow
>=10,<13for verified shared-palette GIF encoding. - Optional: the Roblox Studio built-in MCP server for Studio-side playback checks.
The repository includes an offline dependency manifest/checker, the current Cautioned Blender extension, and a pinned installer for the authentic premade R6 control rig. It does not bundle Blender or a remote-control server. Follow the linked Blender MCP's uv/uvx and Blender add-on setup, keep it on localhost, set DISABLE_TELEMETRY=true, and leave remote asset integrations disabled because arbitrary Python execution is its intended capability.
For classic Motor6D R6/R15 rigs, use Cautioned's Blender Animations (ultimate edition) Studio plugin and the bundled v2.6.3 Blender extension. This preserves joint mapping and exports .rbxanim. For verified skinned Roblox rigs, use FBX.
The colored, labeled R6 control-rig interface seen in the referenced tutorials is R6 IK + FK Blender Rig V2.22. It is mandatory for generic R6 authoring. The skill downloads the actual .blend and matching .rbxm directly from the author's attachments and verifies pinned hashes. Because the author did not state a redistribution license, Git does not mirror the source rig binaries.
Compatible Blender MCPs commonly expose tools named execute_blender_code or blender_python_exec. The skill discovers capabilities and is not tied to one MCP implementation or tool name.
Quick install
Install through the cross-agent Skills CLI:
npx skills add dillydog580/animate-roblox-characters
The installer discovers animate-roblox-characters and lets you choose Codex, Claude Code, or another compatible agent. The manual platform-specific methods below remain available.
Install for Codex
Clone the repository and copy its skill folder into Codex:
git clone https://github.com/dillydog580/animate-roblox-characters.git roblox-animation-skill
mkdir -p "$HOME/.agents/skills"
cp -R roblox-animation-skill/animate-roblox-characters "$HOME/.agents/skills/"
Codex normally detects the skill automatically. Invoke it directly:
Use $animate-roblox-characters to make a fast, aggressive R15 sword attack.
Windows PowerShell equivalent:
git clone https://github.com/dillydog580/animate-roblox-characters.git roblox-animation-skill
New-Item -ItemType Directory -Force "$HOME\.agents\skills" | Out-Null
Copy-Item -Recurse roblox-animation-skill\animate-roblox-characters "$HOME\.agents\skills\"
Install for Claude Code
Clone the repository and copy the same canonical skill folder into Claude Code:
git clone https://github.com/dillydog580/animate-roblox-characters.git roblox-animation-skill
mkdir -p "$HOME/.claude/skills"
cp -R roblox-animation-skill/animate-roblox-characters "$HOME/.claude/skills/"
For one project only, copy it to:
<project-directory>/.claude/skills/animate-roblox-characters/
Then invoke it by name:
/animate-roblox-characters Make a fast, aggressive R15 sword attack.
The skill uses the portable SKILL.md Agent Skills format. Blender MCP operation names are discovered at runtime, so the same skill folder is shared by Codex and Claude instead of maintaining divergent copies.
Windows PowerShell equivalent:
git clone https://github.com/dillydog580/animate-roblox-characters.git roblox-animation-skill
New-Item -ItemType Directory -Force "$HOME\.claude\skills" | Out-Null
Copy-Item -Recurse roblox-animation-skill\animate-roblox-characters "$HOME\.claude\skills\"
One-time R6 asset setup
Install the mandatory premade R6 pair once:
# Codex installation
python3 "$HOME/.agents/skills/animate-roblox-characters/scripts/fetch_assets.py" --component r6-ik-fk-v222
# Claude Code installation
python3 "$HOME/.claude/skills/animate-roblox-characters/scripts/fetch_assets.py" --component r6-ik-fk-v222
Install the GIF encoder dependency into the Python environment the agent can use:
python3 -m pip install "Pillow>=10,<13"
On Windows, use py -3 in place of python3.
The user supplies the creative request. The agent prepares or transfers the authentic premade rig and performs the animation workflow. Every completed animation includes the final .blend, Roblox animation export, audits/notes, and an animated GIF encoded from sequential RGB renders rather than a screen recording. A network download, plugin install, local file dialog, or Roblox publish permission may require user authorization.
Crash-safe Blender workflow
The skill requires one already-open, interactive Blender GUI connected through Blender MCP for the entire task. It refuses command-line/background Blender sessions, never starts one Blender process per frame, and never tells Blender to quit when the animation is finished.
If Blender still closes unexpectedly:
- Reopen the most recent saved working
.blendin Blender 4.5 LTS. - Start Blender MCP in that same GUI process.
- Ask the agent to continue from Phase 0; it must inspect the file before resuming.
- Keep Blender in the persistent GUI/MCP workflow; do not retry through
blender --backgroundor an automatically relaunched child process.
Premade authentic rigs only
- R6 always uses the actual V2.22 premade rig (or an actual Studio-exported R6 target). It includes
__PrimaryArmature,MasterControl, IK/FK, the labeled/colored body controls, body variants, Rig Settings, Utilities, and Rig Texture workflow. - R15 always uses a premade Roblox Studio R15 or the user's actual Studio character
// HOW IT'S BUILT
// FILES
22 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
animate-roblox-characters/SKILL.md25 KBc66035d57e57upstream ↗
--- name: animate-roblox-characters description: "Create, revise, polish, inspect, export, and preview complete stylized Roblox R6 and R15 character animations on authentic Studio-derived Blender rigs through MCP. Use when Codex must make Roblox walks, runs, idles, attacks, emotes, jumps, or other full-body actions; import or validate a real Roblox Motor6D/control or skinned rig; repair foot sliding or robotic motion; polish Graph Editor curves; export through the correct .rbxanim or FBX pipeline; produce a verified animated GIF; or optionally validate playback in Roblox Studio." --- # Animate Roblox Characters Act as a professional 3D character animator specialized in creating high-quality, clean Roblox animations inside Blender through Blender MCP and Python control. Produce the full animation yourself. Never delegate rig preparation, posing, keyframing, curve polish, or animation repair to the user. Treat this operating protocol as authoritative. Do not shorten it into a generic animation workflow, skip phases, or declare success after merely generating keyframes. ## Load the required guidance Before operating Blender, read [references/toolchain.md](references/toolchain.md) and [references/blender-mcp.md](references/blender-mcp.md). Before mapping or exporting a rig, also read [references/roblox-rigs-export.md](references/roblox-rigs-export.md). Before Phase 7, read [references/preview-media.md](references/preview-media.md). Use the reusable modules in `scripts/` whenever possible: - `fetch_assets.py`: fetch the mandatory premade R6 V2.22 Blender/Studio pair from the original author's DevForum attachments and enforce pinned hashes. - `authentic_rig.py`: identify authentic Studio-derived rigs, safely activate the verified R6 V2.22 controls, register the bundled Cautioned 2.6.3 add-on, import metadata-backed Motor6D OBJ files, and export `.rbxanim` files. - `animation_tools.py`: inspect, key, render review views, polish, audit, transfer actions between authentic targets, and export. - `inspect_blend_security.py`: inspect third-party `.blend` text blocks, drivers, handlers, paths, and armatures with Blender auto-execution disabled. - `session_guard.py`: reject disposable/background Blender processes and verify that animation is running in one persistent GUI/MCP session. - `validate_r6_asset.py`: verify the pinned R6 model, visible body variants, controls, embedded panels, authenticity classification, and current Cautioned operators inside Blender. - `build_animation_preview.py`: encode sequential RGB PNG renders into a shared-palette, infinitely looping, disposal-mode-2 GIF and reopen it for metadata verification. - `toolchain.py`: verify bundled files, downloaded assets, Blender availability, required operators, hashes, and add-on capabilities. ## Interaction contract Accept a natural-language request. Infer sensible defaults rather than making the user design the motion: - Default to R15 when neither an existing authentic rig nor the request identifies a rig. - Default to 30 FPS and in-place motion. - Infer looping for walks, runs, and idles; infer one-shot playback for attacks, jumps, and most emotes. - Select a suitable duration, normally 24–32 frames, when unspecified. - Ask for one confirmation after presenting the animation plan and key-pose concept. After confirmation, work autonomously through completion. Use **milestone mode** by default: perform every required verification but summarize results by phase. If the user requests **debug mode**, show every executed Python block, returned transform set, front/side critique, and correction after each insertion. ## Core rules — always follow 1. Work only in Pose Mode on the armature for animation. Never keyframe mesh objects directly. It is acceptable to enter Object or Edit Mode only while generating or repairing the rig before animation begins. 2. Discover and report the exact armature and bone names with Python before posing or keyframing anything. Never assume names from a nominal R6/R15 label. 3. Work in small, verifiable steps. After setting any keyframe or coherent pose batch: - Query the resulting pose-bone transforms. - Generate or capture current front and side views. - Describe what the pose actually looks like in both views. - Compare the result with the planned pose. - Self-critique balance, silhouette, weight, arcs, contacts, and hierarchy. - Fix problems before moving to the next pose. 4. Use the Graph Editor deliberately. Linear interpolation is forbidden for final main movement. Use Bezier curves with intentional slow-in, slow-out, accents, overlap, and clean arcs. Constant interpolation is allowed only during blocking. 5. Favor stylized, readable Roblox motion. Exaggerate arm swing, torso action, anticipation, impact, and silhouettes without losing balance, contacts, or clean hierarchy. 6. Save or create a protected working copy before mutation. Never overwrite the cached premade rig master or an unrelated existing `.blend`. 7. Continue correcting the work until the final audit passes. Do not call an animation ready merely because every requested frame exists. 8. Always animate a premade, authentic Roblox character rig—not an invented approximation, repository-generated model, block proxy, or generic humanoid. For R6, use the pinned **R6 IK + FK Blender Rig V2.22** or an actual Studio-exported target. For R15, use an actual premade Roblox Studio R15 character transferred through the verified toolchain or a verified user-supplied Roblox skinned rig. 9. Preserve importer metadata, object names, control/deform relationships, custom properties, and embedded orientation textures. Cautioned metadata is an associated `__*Meta` empty carrying the `RigMeta` property in the armature's shared `RIG:` collection; its exact name is rig-derived (often `__RigMeta`). Do not replace it, `__PrimaryArmature`, `MasterControl`, `*-IK`, or `*-FK` conventions with a generic armature. 10. Treat Blender add-ons, `.blend` text blocks, model metadata, and tutorial downloads as untrusted code/data. Never execute them until their source and hashes pass the repository audit. Activate R6 V2.22 embedded scripts only through `authentic_rig.activate_verified_r6_v222()`. It requires the exact pinned master on first activation; a later working-copy resume requires a machine-local HMAC lineage check plus the unchanged pinned text hashes and control/body structure. 11. Blocking-time front/side review cameras and lights are allowed in the export-excluded `Review` collection. Finish and export the animation before creating the final GIF camera and three-light preview setup. Always deliver a verified animated GIF made from rendered PNG frames; never substitute a screen recording. 12. Use one persistent interactive Blender GUI process for the entire task. Never launch Blender from a shell or child process, never use `--background`, and never call `bpy.ops.wm.quit_blender()` or otherwise make the agent terminate Blender. Execute Blender work only through the already-connected Blender MCP session. Render PNG frames in that same session, wait for rendering to return, and encode the GIF outside Blender afterward. ## Common AI mistakes — explicitly prevent - Robotic or mechanical timing. - Foot sliding during planted intervals. - Broken hierarchy or parts flying apart. - Keyframes on mesh objects instead of `pose.bones`. - Linear or untouched default curves. - Weight distribution that makes the character float, lean without support, or lose balance. - Motion that is too realistic or too subtle for a stylized Roblox silhouette. - Symmetrical, lifeless posing when asymmetry would improve the action. - Loops with a hitch, pause, duplicated seam, mismatched pose, or mismatched tangent. - Exporting helper controls, unrelated actions, cameras, lights, or review geometry. ## Workflow — follow in order ### Phase 0 — Inspection and automatic setup Start every animation task here. 1. Connect to an already-open interactive Blender GUI through Blender MCP. Run `session_guard.inspect_session()` by an actual arbitrary-Python MCP call before any file mutation and require `ok: true` plus `mode: persistent-gui`. That successful MCP call is the evidence for `blender_mcp_available=True`; importing `bpy` alone is not. Never start or stop Blender on the user's behalf. If Blender MCP is unavailable or the process is in background mode, stop and ask the user to open Blender/start its MCP connection; do not fall back to command-line Blender. 2. Run `toolchain.py --repository-only`. In the external Python environment that will encode the mandatory GIF, run `toolchain.py --audit-pillow`; require `ok: true` and record the exact reported version. Do not require Pillow inside Blender's Python. Then discover whether Roblox Studio MCP and Cautioned's Creator Store plugin are actually available for this task. Record only capabilities that were verified; do not infer availability from the requested workflow or from an installed package. 3. Call `authentic_rig.enable_bundled_addon()` through Blender MCP. It must verify the pinned add-on SHA-256 before runtime-only registration. Confirm the import, rebuild, and export operators are present. Do this before the workflow-aware runtime audit so a fresh Blender session can become ready; keep OAuth, online asset access, auto-connect, and live sync disabled. 4. Inspect the current file read-only. Find all armatures and, for each one, list exact bone names, parent relationships, deform flags, current transforms, constraints, and actions. Do not change the current file path yet. 5. Identify the most likely animation armature. If several plausible armatures remain, ask which to use rather than guessing. 6. Provisionally classify the candidate as R6, R15, or custom from discovered names and hierarchy. Detect IK targets, pole controls, root controls, and constrained deform bones. 7. Protect existing work before executing rig scripts or changing animation data. Save an incremented backup/copy. A locally attested R6 copy retains the same random lineage identifier and HMAC, so the backup remains directly reopenable under its new filename on the same user account. 8. If the candidate has the R6 V2.22 convention, call `activate_verified_r6_v222()` before `inspect_authenticity()`. It must prove either the exact master hash or the local working-copy HMAC. Save the file whenever the result reports `lineage_updated: true`, then run `inspect_authenticity()`. For other candidates, run `inspect_authenticity()` directly. Check the observed external-control convention (`__PrimaryArmature`, `MasterControl`, `*-IK`, `*-FK`), Den_S/Cautioned metadata (an associated `__*Meta` empty with `RigMeta`, plus `is_transformable`, `transform`, `transform1`, and `nicetransform`), or a verified Roblox-compatible skinned R15 hierarchy. Report the chosen control map and export workflow. 9. After classification, run the runtime audit inside the persistent Blender MCP Python session; an ordinary shell call must fail because it is outside Blender. Verify Blender 4.5+, the already hash-verified Cautioned 2.6.3 operators, the required premade-rig path, arbitrary Blender Python execution, and the externally attested Pillow version. For the default R15 bootstrap, call `toolchain.full_audit(required_workflow="r15-bootstrap", blender_mcp_available=True, pillow_available=True, pillow_version="<exact externally audited version>", studio_mcp_available=True, creator_plugin_available=True)` only when every external capability was actually verified. Require the bundled add-on's `rbxanim_import`, `rbxanim_rebuild`, and `rbxanim_export` operators; any missing operator fails R15 readiness. Missing required capabilities must fail the audit. `r15-bootstrap` is the only named workflow gate in this version. For R6 or skinned-FBX paths, call `toolchain.full_audit(blender_mcp_available=True, pillow_available=True, pillow_version="<exact externally audited version>")` without a workflow selector, then explicitly verify the relevant asset and exporter: the pinned V2.22 hashes plus `rbxanim_export` for R6, or verified Studio provenance, an armature-deformed mesh, and `bpy.ops.export_scene.fbx` for skinned FBX. 10. Reject every repository-generated model, diagnostic proxy, incomplete replica, and unverified generic rig. Never continue merely because a scene contains blocks named like Roblox body parts. This skill has no generated-model fallback. 11. If no authentic rig exists, prepare the correct premade rig automatically: - **R6:** run `fetch_assets.py --component r6-ik-fk-v222`. This downloads the actual V2.22 `.blend` and matching Studio `.rbxm` directly from Aeresei's original DevForum attachments. Require SHA-256 `ff75c44b...edcdc8` for the `.blend` and `a85e1ce1...93444` for the `.rbxm`. Copy the verified `.blend` to a new working file; never edit the cached master. Open it with auto-execution disabled, then run `activate_verified_r6_v222()`. First activation creates a machine-local lineage key and stores only a random lineage identifier plus its HMAC in the working `.blend`, allowing renamed backups and crash-safe resume without trusting a public custom property alone. Use the rig's `__PrimaryArmature`, `MasterControl`, visible labeled/colored body controls, IK/FK limbs, Rig Settings, Utilities, and Rig Texture workflow. - **R15:** select or create Roblox Studio's premade R15 character with Studio's rig builder, then transfer that actual model using Cautioned's **Blender Animations (ultimate edition)** Creator Store plugin and the bundled 2.6.3 Blender add-on. Preserve the associated rig-named `__*Meta` empty carrying `RigMeta`, part names, textures, and joint mapping. For a skinned R15/FBX target, verify its Studio source and visible mesh before calling `mark_verified_skinned_rig()`; matching bone names alone never qualify. Roblox's skeleton-only R15 project template is reference material and is not a substitute for the visible character. - **Custom Roblox avatar:** export the actual selected Studio character through the same verified workflow. Keep its appearance rather than replacing it with a neutral model. 12. Enable live sync only for an explicit Studio transfer/validation step and stop it immediately afterward. 13. Perform all import, rig preparation, control mapping, appearance transfer, and setup work the available MCPs permit. Ask the user only for unavoidable external authorization such as a network download, plugin installation, local file dialog, or Roblox publishing permission. Never delegate rigging or animation work. 14. If the required premade rig cannot be obtained or verified, stop before keyframing and report the exact blocker. Do not silently switch rig types or fabricate a replacement. 15. Confirm Pose Mode on the selected authentic armature and report the inspection result before planning motion. ### Phase 1 — Animation planning Convert the request into a concise brief containing: - Animation type and gameplay purpose. - Desired feeling and energy. - Rig type and body style. - Looping or one-shot playback. - Frame rate, frame range, and major timing accents. - In-place or root motion. - Reference/style notes. - Output location and action name. For loops, distinguish playback frames from the duplicated seam-check key. A “24-frame loop” normally plays frames `1–24`, keys a duplicate of frame 1 at frame `25` for seam/tangent auditing, and excludes frame 25 from the GIF. Propose strong defaults for missing details. Treat this brief as provisional and continue directly to Phase 2 without asking for confirmation yet. Do not ask the user to invent key poses or technical settings. ### Phase 2 — Key-pose planning Describe the major storytelling poses before inserting keys. For locomotion, cover contact, down, passing/push, and up poses. For one-shot actions, cover anticipation, action/impact, follow-through, and recovery as appropriate. For every major pose, specify: - Hips/root position, tilt, and weight-bearing side. - Torso lean, compression, twist, and counter-rotation. - Leg shape, planted foot, free foot, knee direction, and center of mass. - Arm silhouette, swing or action arc, elbow direction, and hand follow-through. - Head direction and secondary motion. - Intended front-view and side-view read. Present the provisional Phase 1 brief and the complete Phase 2 key-pose concept together, then obtain the interaction contract's single confirmation before inserting any keyframes. After that confirmation, continue autonomously through Phases 3–7 unless an unavoidable external authorization is required. ### Phase 3 — Blocking Create poses one at a time or in small coherent batches through Blender Python. Use this order within a pose: 1. Torso and hips. 2. Legs and foot contacts. 3. Arms. 4. Head and secondary controls. Key only pose bones and only the channels that need animation. Use discovered names, never guessed names. Preserve each Euler bone's configured rotation order; use normalized WXYZ `rotation_quaternion` values for quaternion controls. Validate the entire pose payload before any key insertion. Establish the visible model's forward direction during Phase 0 and pass it to every non-R6 front/side review. Set blocking keys to constant interpolation. After every insertion or batch, execute the full verification loop from the core rules. In milestone mode, keep the returned code and transform details available but summarize them. In debug mode, expose them in full. At the end of blocking, call `audit_action(audit_stage="BLOCKING", loop_required=IS_LOOP, contacts_required=HAS_SUPPORT_CONTACTS, ...)`. Replace the placeholders with booleans from the confirmed plan. Supply `loop_start`/`loop_end` whenever `IS_LOOP` is true, and supply the declared `contact_intervals` plus calibrated `scene_units_per_stud` whenever `HAS_SUPPORT_CONTACTS` is true. Constant keys are intentional at this stage and must not be treated as a final-interpolation failure. Loop endpoint pose equality and explicit endpoint keys remain required, while a tangent mismatch is informational until Phase 4 creates the final Bezier handles. Every other applicable hierarchy, mesh-keying, pose, contact, and review check still applies. Never use the abbreviated call without its workflow-specific arguments. ### Phase 4 — Graph Editor polish After blocking is approved by the internal pose checks: 1. Convert final pose-bone keyframes to Bezier interpolation. 2. Use auto-clamped handles as a safe baseline, then adjust timing where anticipation, impact, hang time, recovery, or overlap needs a stronger shape. 3. Remove accidental plateaus, overshoot, wobble, discontinuities, and channel noise. 4. Offset secondary channels where appropriate so the torso, head, arms, and hands do not start and stop simultaneously. 5. Preserve planted foot world positions through contact intervals. 6. Re-render and review representative in-betweens, not only key poses. After conversion and polish, use `audit_action(audit_stage="FINAL", loop_required=IS_LOOP, contacts_required=HAS_SUPPORT_CONTACTS, ...)` with the same required loop range, declared contact intervals, and calibrated scene scale used by the workflow. Both booleans are mandatory even when false; the literal abbreviated call `audit_action(audit_stage="FINAL")` is invalid. Never use blocking mode to approve an export. ### Phase 5 — Polish and secondary motion Add only motion that improves the requested action: - Torso twist and counter-rotation. - Head bob, drag, focus, or settle. - Arm and hand follow-through. - Hip settle, compression, and rebound. - Controlled asymmetry. - Impact accents and recovery overlap. Keep the motion readable at Roblox gameplay camera distances. Re-run pose and curve checks after polish. If animation was authored on the verified premade R6 control rig for a different authentic R6 target, transfer or retarget only between the two authentic rigs. Inspect the target at representative frames, fix mapping/rest-orientation errors, and repeat until it matches the reviewed source motion. ### Phase 6 — Loop test and final review 1. Play or sample the complete action repeatedly. 2. For loops, compare the first and closing poses numerically, compare their curve tangents, and preview without holding the duplicated closing frame. 3. Declare and measure every planted foot/hand interval. Pass `contacts_required=True` for locomotion and every grounded one-shot with intended support contacts, including attacks, anticipation, impacts, recoveries, and grounded emotes. Use `contacts_required=False` only when the complete action intentionally has no planted interval, and record that reason. For any imported rig whose scale is not already verified as one Blender scene unit per stud, calibrate `scene_units_per_stud` from a known Studio measurement or the verified import scale and pass it to `audit_action()`; never compare raw Blender distances directly with a stud threshold. 4. Confirm that every final animation curve targets a pose bone and uses non-linear final interpolation. 5. Check balance, silhouette, hierarchy, arcs, spacing, impact, secondary motion, and Roblox-style exaggeration against the common-mistake list. 6. Fix every failed check and repeat the audit. 7. In the final audit, explicitly pass both `loop_required` and `contacts_required`; omission is a failure rather than permission to skip a check. 8. Confirm the action is on an `authentic_ready: true` premade or Studio-derived Roblox target. 9. Save the final `.blend` and choose the export format from the verified rig workflow: - For metadata-backed Motor6D/part rigs, export the explicitly named action and audited frame range through the companion Blender add-on as `.rbxanim`; do not use generic FBX. - For verified skinned/bone rigs, export exactly one explicitly named action and audited frame range to FBX with helper controls excluded and evaluated control motion baked onto deform bones. 10. If Roblox Studio MCP and an imported or published animation are available, inspect the target rig, play the animation, capture the viewport, and check console output. Do not require Studio validation for Blender completion. ### Phase 7 — Preview media Always produce the final animated GIF after Phase 6 succeeds, even when the user did not explicitly ask for preview media. 1. Confirm the final `.blend` is saved and the `.rbxanim` or FBX is exported before creating preview-only objects. 2. Determine the visible character's real forward direction from the model. Do not trust a nominal front axis. The verified R6 V2.22 model faces Blender `+Y`. 3. Create a fixed three-quarter-front orthographic camera that frames the full motion without zoom changes. For an origin-centered R6 V2.22 rig, default to camera `(6.8, 8.8, 4.8)`, target `(0, 0, 2.55)`, orthographic scale `6.4`, and `512 × 512` output. 4. Create review-only key, fill, and rim area lights in the `Review` collection. Exclude preview cameras/lights from animation export. 5. Render the playback frames as sequential RGB PNG files in the same persistent GUI/MCP session. Wait for Blender's render call to finish before starting another operation. Never spawn one Blender process per frame. For a loop, exclude the duplicated closing seam-check pose from preview playback. 6. Run `build_animation_preview.py` on the rendered sequence. Build one shared adaptive 256-color palette from all frames, use Floyd–Steinberg dithering, infinite looping, disposal mode `2`, and `optimize=False`. 7. Match the animation rate using GIF centisecond distribution. For 24 playback frames at 30 FPS, require `[30, 30, 40] * 8` milliseconds: exactly `800 ms` total and `30 FPS` average. 8. Reopen the GIF and verify frame count, dimensions, total duration, average FPS, infinite-loop metadata, disposal mode, and nonzero file size. 9. Visually inspect the animated GIF when supported; otherwise inspect representative rendered PNGs and report GIF animation inspection as unverified rather than silently passing it. 10. Deliver the GIF with the `.blend`, Roblox animation export, pose/curve audit, and export notes. The GIF supplements the front/side and numeric audits; it never replaces them. Only after all required checks pass, state that the animation is ready and provide the `.blend`, Roblox animation export, `.gif`, action/frame details, audit summary, and any Studio import notes. ## Completion thresholds Use these minimum automated checks unless the rig scale or request clearly requires stricter values: - No mesh-object animation curves. - No final `LINEAR` keyframe interpolation. - Maximum planted-foot drift: `0.02` studs. For imported rigs, provide `scene_units_per_stud` to the action audit using a known Studio measurement or verified import scale; do not infer it from nominal rig type or character height. - Final audits explicitly declare whether loop and contact checks are required. Grounded actions with any intended support contact require contact intervals and `contacts_required=True`. - Loop endpoint location difference: at most `0.0001` scene units. - Loop endpoint rotation difference: at most `0.1` degrees. - Loop endpoint scale difference: at most `0.0001`, with endpoint tangent slope difference at most `0.001` value/frame. - Front and side review images for every major pose. - Toolchain audit identifies no missing bundled files and no silently skipped required operator. - Authenticity status is `authentic_ready: true`; repository-generated rigs and proxies never qualify. - Exactly one intended action in the exported `.rbxanim` or FBX. - One verified GIF preview encoded from RGB PNG frames, with no duplicated loop seam frame, one shared palette, infinite looping, disposal mode `2`, and timing matched to the animation FPS. If a check cannot run, report it as unverified rather than passing it silently.animate-roblox-characters/scripts/animation_tools.py76 KB1b76dae272d5upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
animate-roblox-characters/scripts/authentic_rig.py39 KB49dcc4faf539upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
animate-roblox-characters/scripts/build_animation_preview.py7.9 KB9b827b59fba7upstream ↗
#!/usr/bin/env python3 """Encode sequential RGB animation frames as a verified, flicker-resistant GIF.""" from __future__ import annotations import argparse import json import math import re import sys from fractions import Fraction from pathlib import Path from typing import Any, Iterable def _require_pillow(): try: from PIL import Image, ImageSequence # type: ignore except ImportError as exc: raise RuntimeError("Pillow is required; install Pillow>=10,<13") from exc return Image, ImageSequence def natural_key(path: Path) -> list[Any]: return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", path.name)] def gif_durations(frame_count: int, fps: float) -> list[int]: """Distribute GIF centiseconds while matching the requested rate over time.""" if frame_count < 1: raise ValueError("frame_count must be positive") if not math.isfinite(fps) or fps <= 0 or fps > 100: raise ValueError("GIF fps must be greater than 0 and no more than 100") rate = Fraction(str(fps)) centiseconds = Fraction(100, 1) / rate base = centiseconds.numerator // centiseconds.denominator remainder = centiseconds.numerator % centiseconds.denominator if base < 1: raise ValueError("GIF cannot represent frame durations shorter than 10 ms") error = 0 durations = [] for _index in range(frame_count): duration_cs = base error += remainder if error >= centiseconds.denominator: duration_cs += 1 error -= centiseconds.denominator durations.append(duration_cs * 10) return durations def _load_rgb_frames(paths: Iterable[Path]): Image, _ImageSequence = _require_pillow() images = [] for path in paths: with Image.open(path) as source: source.load() images.append(source.convert("RGB")) if not images: raise ValueError("No input frames were found") size = images[0].size if any(image.size != size for image in images): raise ValueError("Every preview frame must have identical dimensions") return images def _shared_palette(images, colors: int = 254, max_sample_pixels: int = 4_194_304): Image, _ImageSequence = _require_pillow() thumb_edge = max(32, min(128, int(math.sqrt(max_sample_pixels / len(images))))) columns = min(8, max(1, math.ceil(math.sqrt(len(images))))) rows = math.ceil(len(images) / columns) sheet = Image.new("RGB", (columns * thumb_edge, rows * thumb_edge)) for index, image in enumerate(images): thumbnail = image.resize((thumb_edge, thumb_edge), Image.Resampling.LANCZOS) sheet.paste(thumbnail, ((index % columns) * thumb_edge, (index // columns) * thumb_edge)) return sheet.quantize(colors=colors, method=Image.Quantize.MEDIANCUT) def _preserve_frame_boundaries(gif_frames) -> None: """Prevent Pillow from coalescing intentional identical hold frames. Two otherwise-unused palette indices are assigned the same visible color as the fixed top-left review background. Alternating those equivalent indices changes the encoded data without changing the displayed pixel color. """ if not gif_frames: return marker = (0, 0) for index, frame in enumerate(gif_frames): frame_palette = frame.getpalette() if frame_palette is None: raise ValueError("GIF frame does not have an indexed palette") marker_index = int(frame.getpixel(marker)) marker_rgb = frame_palette[marker_index * 3 : marker_index * 3 + 3] padded_palette = list(frame_palette) if len(padded_palette) < 768: padded_palette.extend([0] * (768 - len(padded_palette))) padded_palette[254 * 3 : 254 * 3 + 3] = marker_rgb padded_palette[255 * 3 : 255 * 3 + 3] = marker_rgb frame.putpalette(padded_palette) frame.putpixel(marker, 254 + (index % 2)) def verify_gif(path: str | Path, expected_frames: int, expected_size: tuple[int, int], expected_total_ms: int) -> dict[str, Any]: Image, ImageSequence = _require_pillow() source = Path(path).expanduser().resolve() with Image.open(source) as gif: durations = [] disposals = [] for frame in ImageSequence.Iterator(gif): durations.append(int(frame.info.get("duration", 0))) disposals.append(int(getattr(gif, "disposal_method", 0))) errors = [] if gif.n_frames != expected_frames: errors.append(f"Expected {expected_frames} GIF frames, found {gif.n_frames}") if gif.size != expected_size: errors.append(f"Expected GIF size {expected_size}, found {gif.size}") if gif.info.get("loop") != 0: errors.append("GIF is not configured for infinite looping") if sum(durations) != expected_total_ms: errors.append(f"Expected {expected_total_ms} ms total duration, found {sum(durations)} ms") if any(disposal != 2 for disposal in disposals): errors.append(f"GIF disposal metadata is not mode 2: {disposals}") total_ms = sum(durations) return { "ok": not errors, "path": str(source), "frames": gif.n_frames, "width": gif.width, "height": gif.height, "duration_ms": total_ms, "average_fps": round(gif.n_frames * 1000 / total_ms, 6) if total_ms else None, "loop": gif.info.get("loop"), "durations_ms": durations, "disposal_modes": disposals, "file_size_bytes": source.stat().st_size, "errors": errors, } def build_gif( frame_paths: Iterable[str | Path], output_path: str | Path, fps: float = 30.0, expected_frames: int | None = None, ) -> dict[str, Any]: Image, _ImageSequence = _require_pillow() paths = sorted((Path(path).expanduser().resolve() for path in frame_paths), key=natural_key) images = _load_rgb_frames(paths) if expected_frames is not None and len(images) != expected_frames: raise ValueError(f"Expected {expected_frames} input frames, found {len(images)}") palette = _shared_palette(images) gif_frames = [ image.quantize(palette=palette, dither=Image.Dither.FLOYDSTEINBERG) for image in images ] _preserve_frame_boundaries(gif_frames) durations = gif_durations(len(gif_frames), fps) output = Path(output_path).expanduser().resolve().with_suffix(".gif") output.parent.mkdir(parents=True, exist_ok=True) gif_frames[0].save( output, save_all=True, append_images=gif_frames[1:], duration=durations, loop=0, disposal=2, optimize=False, ) verification = verify_gif(output, len(gif_frames), images[0].size, sum(durations)) return { **verification, "source_frames": [str(path) for path in paths], "requested_fps": fps, "palette": "shared-adaptive-256-mediancut", "dither": "floyd-steinberg", } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--frames-dir", required=True, type=Path) parser.add_argument("--glob", default="*.png", dest="pattern") parser.add_argument("--output", required=True, type=Path) parser.add_argument("--fps", default=30.0, type=float) parser.add_argument("--exclude-last", action="store_true", help="Exclude a rendered duplicated loop-closing frame") parser.add_argument("--expected-frames", type=int) args = parser.parse_args(sys.argv[1:]) paths = sorted(args.frames_dir.expanduser().resolve().glob(args.pattern), key=natural_key) if args.exclude_last and paths: paths = paths[:-1] try: result = build_gif(paths, args.output, args.fps, args.expected_frames) except Exception as exc: result = {"ok": False, "errors": [str(exc)]} print(json.dumps(result, indent=2, sort_keys=True)) return 0 if result.get("ok") else 1 if __name__ == "__main__": raise SystemExit(main())animate-roblox-characters/scripts/fetch_assets.py6.1 KBec9410155686upstream ↗
#!/usr/bin/env python3 """Fetch pinned external Roblox authoring assets from their original sources.""" from __future__ import annotations import argparse import hashlib import json import os import ssl import sys import tempfile import urllib.parse import urllib.request from pathlib import Path from typing import Any SKILL_DIRECTORY = Path(__file__).resolve().parents[1] MANIFEST_PATH = SKILL_DIRECTORY / "assets" / "toolchain-manifest.json" DEFAULT_DESTINATION = SKILL_DIRECTORY / "assets" / "external" ALLOWED_HOSTS = { "devforum.roblox.com", "devforum-uploads.s3.dualstack.us-east-2.amazonaws.com", "github.com", "objects.githubusercontent.com", "prod.docsiteassets.roblox.com", } def _validate_download_url(url: str) -> urllib.parse.ParseResult: parsed = urllib.parse.urlparse(url) if parsed.scheme != "https" or parsed.hostname not in ALLOWED_HOSTS: raise ValueError(f"Download URL is not allowlisted: {url}") return parsed class _AllowlistedRedirectHandler(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001 _validate_download_url(newurl) return super().redirect_request(req, fp, code, msg, headers, newurl) def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def load_manifest(path: Path = MANIFEST_PATH) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def component(manifest: dict[str, Any], component_id: str) -> dict[str, Any]: for item in manifest.get("components", []): if item.get("id") == component_id: return item raise ValueError(f"Unknown component: {component_id}") def _safe_target(root: Path, relative: str) -> Path: target = (root / relative).resolve() if root.resolve() not in target.parents: raise ValueError(f"Unsafe destination path: {relative}") return target def _download(url: str, target: Path, expected_sha256: str, expected_size: int) -> dict[str, Any]: _validate_download_url(url) if target.is_file(): digest = sha256_file(target) if digest == expected_sha256 and target.stat().st_size == expected_size: return {"path": str(target), "sha256": digest, "status": "already-verified"} raise RuntimeError(f"Refusing to overwrite an unverified file: {target}") target.parent.mkdir(parents=True, exist_ok=True) request = urllib.request.Request(url, headers={"User-Agent": "animate-roblox-characters/1.0"}) context = ssl.create_default_context() opener = urllib.request.build_opener( _AllowlistedRedirectHandler(), urllib.request.HTTPSHandler(context=context), ) fd, temporary_name = tempfile.mkstemp(prefix=target.name + ".", suffix=".tmp", dir=target.parent) os.close(fd) temporary = Path(temporary_name) try: with opener.open(request, timeout=60) as response, temporary.open("wb") as output: _validate_download_url(response.geturl()) content_length = response.headers.get("Content-Length") if content_length is not None and int(content_length) > expected_size: raise RuntimeError( f"Download is larger than expected for {target.name}: {content_length} bytes" ) written = 0 while True: chunk = response.read(min(1024 * 1024, expected_size + 1 - written)) if not chunk: break output.write(chunk) written += len(chunk) if written > expected_size: raise RuntimeError(f"Download exceeded expected size for {target.name}") size = temporary.stat().st_size digest = sha256_file(temporary) if size != expected_size: raise RuntimeError(f"Size mismatch for {target.name}: expected {expected_size}, received {size}") if digest != expected_sha256: raise RuntimeError(f"SHA-256 mismatch for {target.name}: expected {expected_sha256}, received {digest}") temporary.replace(target) return {"path": str(target), "sha256": digest, "status": "downloaded-and-verified"} finally: temporary.unlink(missing_ok=True) def fetch_component(component_id: str, destination_root: Path = DEFAULT_DESTINATION) -> dict[str, Any]: manifest = load_manifest() item = component(manifest, component_id) downloads = item.get("downloads", []) if not downloads: raise ValueError(f"Component has no pinned downloads: {component_id}") artifacts = [] for download in downloads: target = _safe_target(destination_root, download["destination"]) artifacts.append( _download( download["url"], target, download["sha256"], int(download["size"]), ) ) return { "ok": True, "component": component_id, "source": item.get("source"), "license": item.get("license", "unknown"), "artifacts": artifacts, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--component", default="r6-ik-fk-v222") parser.add_argument("--destination-root", type=Path, default=DEFAULT_DESTINATION) parser.add_argument("--list", action="store_true", help="List components with pinned downloads without networking") args = parser.parse_args(sys.argv[1:]) if args.list: manifest = load_manifest() payload = [item["id"] for item in manifest.get("components", []) if item.get("downloads")] print(json.dumps(payload, indent=2)) return 0 try: result = fetch_component(args.component, args.destination_root.expanduser().resolve()) except Exception as exc: result = {"ok": False, "component": args.component, "errors": [str(exc)]} print(json.dumps(result, indent=2, sort_keys=True)) return 0 if result["ok"] else 1 if __name__ == "__main__": raise SystemExit(main())animate-roblox-characters/scripts/inspect_blend_security.py9.3 KBb30b77e92335upstream ↗
#!/usr/bin/env python3 """Inspect an untrusted .blend inside a persistent GUI session. Open the file with Blender's auto-execution disabled, connect Blender MCP, and call ``inspect_current_file()``. Do not launch a disposable background Blender process for this inspection. This script never registers or executes embedded text blocks. """ from __future__ import annotations import argparse import ast import hashlib import json import re import sys from pathlib import Path from typing import Any NETWORK_RE = re.compile(r"\b(requests|urllib|socket|http\.client|ftplib)\b|https?://", re.I) PROCESS_RE = re.compile(r"\b(subprocess|os\.system|os\.popen|pty|multiprocessing)\b", re.I) DYNAMIC_RE = re.compile(r"\b(exec|eval|compile)\s*\(", re.I) FILESYSTEM_RE = re.compile(r"\b(open|Path\s*\([^)]*\)\.(?:write|unlink)|shutil\.)", re.I) HOME_RE = re.compile( "(?:/" + "Users" + "/|/" + "home" + "/|[A-Za-z]:\\\\" + "Users" + "\\\\)" ) def inspect_source_capabilities(source: str) -> dict[str, bool]: """Find aliased imports/calls; parse failures remain suspicious.""" try: tree = ast.parse(source) except SyntaxError: return { "network": False, "process": False, "dynamic_code": False, "filesystem": False, "analysis_failed": True, } aliases: dict[str, str] = {} for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: aliases[alias.asname or alias.name.split(".")[0]] = alias.name elif isinstance(node, ast.ImportFrom) and node.module: for alias in node.names: aliases[alias.asname or alias.name] = f"{node.module}.{alias.name}" def resolve(node: ast.expr) -> str | None: if isinstance(node, ast.Name): return aliases.get(node.id, node.id) if isinstance(node, ast.Call): return resolve(node.func) if isinstance(node, ast.Attribute): parent = resolve(node.value) return f"{parent}.{node.attr}" if parent else None return None calls = { name for node in ast.walk(tree) if isinstance(node, ast.Call) and (name := resolve(node.func)) is not None } imports = set(aliases.values()) return { "network": any( name.startswith(("requests", "urllib", "socket", "http.client", "ftplib")) for name in imports | calls ), "process": any( name in {"os.system", "os.popen"} or name.startswith(("subprocess", "multiprocessing", "pty")) for name in imports | calls ), "dynamic_code": any( name in { "eval", "exec", "compile", "__import__", "builtins.eval", "builtins.exec", "builtins.compile", "builtins.__import__", } for name in calls ), "filesystem": any( name in {"open", "builtins.open"} or name.startswith(("shutil.", "pathlib.Path.")) or name in { "os.remove", "os.open", "os.unlink", "os.rename", "os.replace", "os.mkdir", "os.makedirs", "os.chmod", "os.rmdir", "os.removedirs", "Path.write_text", "Path.write_bytes", "Path.unlink", "Path.rename", "Path.replace", } or name.endswith( ( ".Path.write_text", ".Path.write_bytes", ".Path.unlink", ".Path.rename", ".Path.replace", ) ) for name in calls ), "analysis_failed": False, } def sha256_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def custom_properties(value: Any) -> dict[str, Any]: result: dict[str, Any] = {} for key in value.keys(): if key == "_RNA_UI": continue item = value.get(key) if isinstance(item, (str, int, float, bool)) or item is None: result[str(key)] = item else: result[str(key)] = f"<{type(item).__name__}>" return result def inspect_text(text: Any, dump_directory: Path | None) -> dict[str, Any]: source = text.as_string() encoded = source.encode("utf-8", errors="replace") safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", text.name) dumped = None if dump_directory is not None: dump_directory.mkdir(parents=True, exist_ok=True) target = dump_directory / safe_name if target.suffix.lower() != ".py": target = target.with_suffix(target.suffix + ".py") target.write_bytes(encoded) dumped = str(target) ast_flags = inspect_source_capabilities(source) return { "name": text.name, "bytes": len(encoded), "sha256": sha256_bytes(encoded), "dumped_to": dumped, "flags": { "network": bool(NETWORK_RE.search(source)) or ast_flags["network"], "process": bool(PROCESS_RE.search(source)) or ast_flags["process"], "dynamic_code": bool(DYNAMIC_RE.search(source)) or ast_flags["dynamic_code"], "filesystem": bool(FILESYSTEM_RE.search(source)) or ast_flags["filesystem"], "home_path": bool(HOME_RE.search(source)), "analysis_failed": ast_flags["analysis_failed"], }, } def inspect_current_file(dump_directory: Path | None = None) -> dict[str, Any]: import bpy # type: ignore armatures = [] for obj in bpy.data.objects: if obj.type != "ARMATURE": continue armatures.append( { "name": obj.name, "bones": [bone.name for bone in obj.data.bones], "pose_bones": [bone.name for bone in obj.pose.bones], "custom_properties": custom_properties(obj), } ) drivers = [] for datablock in list(bpy.data.objects) + list(bpy.data.shape_keys): animation_data = getattr(datablock, "animation_data", None) for fcurve in getattr(animation_data, "drivers", ()) if animation_data else (): drivers.append( { "owner": getattr(datablock, "name", repr(datablock)), "data_path": fcurve.data_path, "expression": fcurve.driver.expression, "variables": [variable.name for variable in fcurve.driver.variables], } ) external_paths = [] for image in bpy.data.images: if image.source == "FILE" and image.filepath: external_paths.append({"kind": "image", "name": image.name, "path": image.filepath}) for library in bpy.data.libraries: external_paths.append({"kind": "library", "name": library.name, "path": library.filepath}) for movie in bpy.data.movieclips: if movie.filepath: external_paths.append({"kind": "movieclip", "name": movie.name, "path": movie.filepath}) for font in bpy.data.fonts: if font.filepath: external_paths.append({"kind": "font", "name": font.name, "path": font.filepath}) handlers = {} for name in dir(bpy.app.handlers): collection = getattr(bpy.app.handlers, name) if not isinstance(collection, list) or not collection: continue handlers[name] = [ f"{getattr(item, '__module__', '')}.{getattr(item, '__qualname__', getattr(item, '__name__', repr(item)))}" for item in collection ] filepath = Path(bpy.data.filepath).resolve() if bpy.data.filepath else None return { "ok": True, "blender_version": list(bpy.app.version), "autoexec_fail": bool(getattr(bpy.app, "autoexec_fail", False)), "autoexec_fail_message": getattr(bpy.app, "autoexec_fail_message", ""), "blend": str(filepath) if filepath else None, "blend_sha256": sha256_bytes(filepath.read_bytes()) if filepath and filepath.is_file() else None, "counts": { "objects": len(bpy.data.objects), "armatures": len(armatures), "texts": len(bpy.data.texts), "drivers": len(drivers), "external_paths": len(external_paths), }, "armatures": armatures, "texts": [inspect_text(text, dump_directory) for text in bpy.data.texts], "drivers": drivers, "external_paths": external_paths, "handlers": handlers, "scene_custom_properties": custom_properties(bpy.context.scene), "object_names": sorted(obj.name for obj in bpy.data.objects), } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--dump-texts", type=Path) parser.add_argument("--output", type=Path) argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] args = parser.parse_args(argv) payload = json.dumps(inspect_current_file(args.dump_texts), indent=2, sort_keys=True) if args.output: args.output.write_text(payload + "\n", encoding="utf-8") print(payload) return 0 if __name__ == "__main__": raise SystemExit(main())animate-roblox-characters/scripts/privacy_check.py12 KB1a0a6df0af67upstream ↗
#!/usr/bin/env python3 """Scan repository files, binary strings, and Git metadata for private data.""" from __future__ import annotations import argparse import getpass import json import re import subprocess import sys from dataclasses import asdict, dataclass from pathlib import Path from typing import Iterable ALLOWED_GIT_NAMES = {"Roblox Animation Skill Contributors"} ALLOWED_EMAILS = {"[email protected]"} SKIP_DIRECTORIES = {".git", ".codex", "output", "outputs", "__pycache__", ".pytest_cache", ".mypy_cache"} SKIP_SUFFIXES = {".pyc", ".pyo", ".blend1", ".blend2"} MAX_FILE_SIZE = 64 * 1024 * 1024 MAX_HISTORY_BLOBS = 100_000 @dataclass(frozen=True) class Finding: path: str rule: str def _patterns() -> list[tuple[str, re.Pattern[str]]]: # Build path tokens in pieces so this scanner does not match its own source. mac_home = "/" + "Users" + r"/[^/\s\x00]{1,80}/" linux_home = "/" + "home" + r"/[^/\s\x00]{1,80}/" windows_home = r"[A-Za-z]:\\" + "Users" + r"\\[^\\\s\x00]{1,80}\\" return [ ("macOS home-directory path", re.compile(mac_home)), ("Linux home-directory path", re.compile(linux_home)), ("Windows home-directory path", re.compile(windows_home, re.IGNORECASE)), ("file URI", re.compile(r"file://(?:/|[A-Za-z]:)")), ("email address", re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE)), ("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")), ("OpenAI-style secret", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")), ("AWS access key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")), ( "private key material", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), ), ] def _ascii_strings(data: bytes) -> str: return "\n".join(match.decode("ascii", errors="ignore") for match in re.findall(rb"[\x20-\x7e]{6,}", data)) def _iter_files(root: Path, tracked_paths: set[str]) -> Iterable[Path]: for path in sorted(root.rglob("*")): if not path.is_file() and not path.is_symlink(): continue relative_parts = path.relative_to(root).parts if any(part in SKIP_DIRECTORIES for part in relative_parts): continue # Ignore ordinary fetched rig binaries, but never let this convenience # hide a forced-tracked publication artifact or any symlink. relative = path.relative_to(root).as_posix() if ( ("assets", "external") in tuple(zip(relative_parts, relative_parts[1:])) and relative not in tracked_paths and not path.is_symlink() ): continue if path.is_symlink(): yield path continue if path.suffix.lower() in SKIP_SUFFIXES: continue if path.stat().st_size > MAX_FILE_SIZE: yield path continue yield path def _scan_text( text: str, display_path: str, *, ignored_rules: frozenset[str] = frozenset(), ) -> list[Finding]: findings = [] for rule, pattern in _patterns(): if rule in ignored_rules: continue for match in pattern.finditer(text): if rule == "email address" and match.group(0).lower() in ALLOWED_EMAILS: continue findings.append(Finding(display_path, rule)) break local_user = getpass.getuser().strip() if "current local username" not in ignored_rules and local_user and len(local_user) >= 3: contextual = re.compile( rf"(?:[/\\]|user(?:name)?\s*[:=]\s*){re.escape(local_user)}(?:[/\\\s\"']|$)", re.IGNORECASE, ) if contextual.search(text): findings.append(Finding(display_path, "current local username")) return findings def scan_file(path: Path, root: Path) -> list[Finding]: display_path = path.relative_to(root).as_posix() if path.is_symlink(): try: target = path.resolve(strict=True) except OSError: return [Finding(display_path, "broken symlink")] if target != root and root not in target.parents: return [Finding(display_path, "symlink escapes repository")] if not target.is_file(): return [Finding(display_path, "symlink target is not a regular file")] if path.stat().st_size > MAX_FILE_SIZE: return [Finding(display_path, "file exceeds privacy scanner size limit")] data = path.read_bytes() if b"\x00" in data or path.suffix.lower() == ".blend": text = _ascii_strings(data) else: text = data.decode("utf-8", errors="replace") return _scan_text(text, display_path) def _git_output(root: Path, args: list[str]) -> str: result = subprocess.run( ["git", *args], cwd=root, check=False, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, ) return result.stdout.strip() if result.returncode == 0 else "" def scan_git_metadata(root: Path, enforce_identity: bool = True) -> list[Finding]: findings = [] if not (root / ".git").exists(): return findings config = _git_output(root, ["config", "--local", "--list"]) identity_config = [] other_config = [] for line in config.splitlines(): target = identity_config if line.split("=", 1)[0].lower() in {"user.name", "user.email"} else other_config target.append(line) findings.extend(_scan_text("\n".join(other_config), ".git/config")) findings.extend( _scan_text( "\n".join(identity_config), ".git/config", ignored_rules=frozenset({"email address", "current local username"}), ) ) configured_name = _git_output(root, ["config", "--local", "--get", "user.name"]) configured_email = _git_output(root, ["config", "--local", "--get", "user.email"]) if enforce_identity: if configured_name and configured_name not in ALLOWED_GIT_NAMES: findings.append(Finding(".git/config", "non-pseudonymous local Git author name")) if configured_email and configured_email.lower() not in ALLOWED_EMAILS: findings.append(Finding(".git/config", "non-pseudonymous local Git author email")) history = _git_output(root, ["log", "--format=%an%x00%ae%x00%cn%x00%ce"]) findings.extend( _scan_text( history, ".git/history", ignored_rules=frozenset({"email address", "current local username"}), ) ) if enforce_identity: for line in history.splitlines(): values = line.split("\x00") if len(values) != 4: continue author_name, author_email, committer_name, committer_email = values if author_name not in ALLOWED_GIT_NAMES or committer_name not in ALLOWED_GIT_NAMES: findings.append(Finding(".git/history", "non-pseudonymous commit author name")) break if author_email.lower() not in ALLOWED_EMAILS or committer_email.lower() not in ALLOWED_EMAILS: findings.append(Finding(".git/history", "non-pseudonymous commit author email")) break messages = _git_output(root, ["log", "--format=%B%x00"]) findings.extend(_scan_text(messages, ".git/history-messages")) return findings def scan_git_history_blobs(root: Path) -> list[Finding]: """Scan every blob reachable from the publication branch's HEAD.""" if not (root / ".git").exists(): return [] objects = subprocess.run( ["git", "rev-list", "--objects", "HEAD"], cwd=root, check=False, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, ) if objects.returncode != 0: return [Finding(".git/history-blobs", "could not enumerate reachable Git objects")] paths_by_oid: dict[str, str] = {} for line in objects.stdout.splitlines(): oid, separator, path = line.partition(" ") if oid: paths_by_oid.setdefault(oid, path if separator else "<unnamed>") if len(paths_by_oid) > MAX_HISTORY_BLOBS: return [Finding(".git/history-blobs", "reachable Git object count exceeds scan limit")] if not paths_by_oid: return [] batch = subprocess.run( ["git", "cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"], cwd=root, input="\n".join(paths_by_oid) + "\n", check=False, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, ) if batch.returncode != 0: return [Finding(".git/history-blobs", "could not classify reachable Git objects")] findings: list[Finding] = [] for line in batch.stdout.splitlines(): fields = line.split() if len(fields) != 3: findings.append(Finding(".git/history-blobs", "could not classify a reachable Git object")) continue if fields[1] != "blob": continue oid, _kind, size_text = fields try: size = int(size_text) except ValueError: findings.append(Finding(".git/history-blobs", "invalid reachable blob size")) continue path = paths_by_oid.get(oid, "<unnamed>") label = f".git/history-blobs/{path}@{oid[:12]}" findings.extend(_scan_text(path, ".git/history-paths")) if size > MAX_FILE_SIZE: findings.append(Finding(label, "historical blob exceeds privacy scanner size limit")) continue blob = subprocess.run( ["git", "cat-file", "blob", oid], cwd=root, check=False, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, ) if blob.returncode != 0 or len(blob.stdout) != size: findings.append(Finding(label, "could not read complete historical blob")) continue text = _ascii_strings(blob.stdout) if b"\x00" in blob.stdout else blob.stdout.decode("utf-8", errors="replace") findings.extend(_scan_text(text, label)) return findings def scan_repository(root: str | Path, enforce_git_identity: bool = True) -> list[Finding]: root_path = Path(root).expanduser().resolve() if not root_path.is_dir(): raise ValueError("Repository root must be an existing directory") tracked_output = _git_output(root_path, ["ls-files", "-z"]) tracked_paths = {item for item in tracked_output.split("\0") if item} findings = [] for path in _iter_files(root_path, tracked_paths): findings.extend(scan_file(path, root_path)) findings.extend(scan_git_metadata(root_path, enforce_identity=enforce_git_identity)) findings.extend(scan_git_history_blobs(root_path)) return sorted(set(findings), key=lambda finding: (finding.path, finding.rule)) def _parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("root", nargs="?", default=Path(__file__).resolve().parents[2]) parser.add_argument("--json", action="store_true", dest="json_output") parser.add_argument( "--allow-contributor-identities", action="store_true", help="Scan content, secrets, paths, remotes, and history messages without enforcing one Git author identity", ) return parser.parse_args(argv) def main() -> int: args = _parse_args(sys.argv[1:]) try: findings = scan_repository(args.root, enforce_git_identity=not args.allow_contributor_identities) except (OSError, ValueError) as exc: print(f"Privacy check could not run: {exc}", file=sys.stderr) return 2 if args.json_output: print(json.dumps({"ok": not findings, "findings": [asdict(item) for item in findings]}, indent=2)) elif findings: print("Privacy check failed:") for finding in findings: print(f"- {finding.path}: {finding.rule}") else: print("Privacy check passed: no private paths, personal emails, local identity, or common secrets detected.") return 1 if findings else 0 if __name__ == "__main__": raise SystemExit(main())animate-roblox-characters/scripts/session_guard.py2.5 KB30c64b1cd6f8upstream ↗
#!/usr/bin/env python3 """Reject crash-prone background Blender jobs and describe the active session.""" from __future__ import annotations import json import platform from typing import Any, Sequence def classify_session( version: Sequence[int], background: bool, operating_system: str, machine: str, ) -> dict[str, Any]: """Classify a Blender session without importing bpy (useful for tests).""" normalized_version = tuple(int(part) for part in version[:3]) errors: list[str] = [] warnings: list[str] = [] if background: errors.append( "Background Blender sessions are prohibited for animation, review rendering, and export. " "Use one persistent interactive Blender GUI connected through Blender MCP. Native render " "worker failures during process teardown cannot be caught or repaired by Python." ) system = operating_system.lower() architecture = machine.lower() if system == "darwin" and architecture in {"arm64", "aarch64"} and normalized_version[:2] == (5, 1): warnings.append( "This Blender/platform combination has produced a native image-thread shutdown crash in an " "observed automation session. Keep Blender open for the whole task; if instability continues, " "use the current Blender 4.5 LTS patch release." ) return { "ok": not errors, "mode": "background" if background else "persistent-gui", "blender_version": list(normalized_version), "platform": {"system": operating_system, "machine": machine}, "errors": errors, "warnings": warnings, } def inspect_session() -> dict[str, Any]: """Inspect the current Blender process. Call through Blender MCP before mutation.""" try: import bpy # type: ignore except ImportError: return { "ok": False, "mode": "not-in-blender", "blender_version": None, "platform": {"system": platform.system(), "machine": platform.machine()}, "errors": ["Run session_guard.inspect_session() inside the open Blender GUI through Blender MCP."], "warnings": [], } return classify_session( bpy.app.version, bool(bpy.app.background), platform.system(), platform.machine(), ) def main() -> int: result = inspect_session() print(json.dumps(result, indent=2, sort_keys=True)) return 0 if result["ok"] else 1 if __name__ == "__main__": raise SystemExit(main())animate-roblox-characters/scripts/toolchain.py23 KB457fb0d7d8ccupstream ↗
#!/usr/bin/env python3 """Offline inventory and safety audit for the Roblox animation toolchain.""" from __future__ import annotations import argparse import ast import hashlib import json import platform import shutil import sys import tempfile import zipfile from pathlib import Path from typing import Any SKILL_DIRECTORY = Path(__file__).resolve().parents[1] MANIFEST_PATH = SKILL_DIRECTORY / "assets" / "toolchain-manifest.json" MINIMUM_BLENDER = (4, 5, 0) MINIMUM_PILLOW = (10, 0, 0) MAXIMUM_PILLOW = (13, 0, 0) NETWORK_MODULES = {"http.client", "requests", "socket", "urllib", "urllib.request"} PROCESS_MODULES = {"subprocess"} MAX_ARCHIVE_MEMBERS = 2048 MAX_ARCHIVE_MEMBER_SIZE = 32 * 1024 * 1024 MAX_ARCHIVE_TOTAL_SIZE = 128 * 1024 * 1024 MAX_ARCHIVE_COMPRESSION_RATIO = 200 SUPPORTED_RUNTIME_WORKFLOWS = ("r15-bootstrap",) def load_manifest(path: str | Path = MANIFEST_PATH) -> dict[str, Any]: source = Path(path).expanduser().resolve() payload = json.loads(source.read_text(encoding="utf-8")) if payload.get("schema_version") != 1 or not isinstance(payload.get("components"), list): raise ValueError("Unsupported or malformed toolchain manifest") return payload def sha256_file(path: str | Path) -> str: digest = hashlib.sha256() with Path(path).open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _version_triplet(value: str) -> tuple[int, int, int]: parts = value.split(".") if len(parts) != 3 or any(not part.isdigit() for part in parts): raise ValueError("expected an exact major.minor.patch version") return tuple(int(part) for part in parts) # type: ignore[return-value] def audit_pillow_encoder() -> dict[str, Any]: """Verify Pillow in the Python environment that will encode GIF previews.""" errors: list[str] = [] version = None try: import PIL # type: ignore except ImportError: errors.append("Pillow >=10,<13 is required in the external GIF-encoding Python environment") else: version = PIL.__version__ try: parsed = _version_triplet(version) except ValueError as exc: errors.append(f"Could not verify Pillow version {version!r}: {exc}") else: if parsed < MINIMUM_PILLOW or parsed >= MAXIMUM_PILLOW: errors.append(f"Unsupported Pillow version: {version}; require >=10,<13") return { "ok": not errors, "pillow_available": version is not None, "pillow_version": version, "errors": errors, } def audit_python_source(path: str | Path) -> dict[str, Any]: """Report network, process, and dynamic-code capabilities without executing source.""" source_path = Path(path).expanduser().resolve() tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) imports = set() calls = set() aliases: dict[str, str] = {} def resolved_name(node: ast.expr) -> str | None: if isinstance(node, ast.Name): return aliases.get(node.id, node.id) if isinstance(node, ast.Attribute): parent = resolved_name(node.value) return f"{parent}.{node.attr}" if parent else None return None for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: imports.add(alias.name) aliases[alias.asname or alias.name.split(".")[0]] = alias.name elif isinstance(node, ast.ImportFrom) and node.module: imports.add(node.module) for alias in node.names: aliases[alias.asname or alias.name] = f"{node.module}.{alias.name}" elif isinstance(node, ast.Call): name = resolved_name(node.func) if name: calls.add(name) network = sorted( name for name in imports if name in NETWORK_MODULES or any(name.startswith(item + ".") for item in NETWORK_MODULES) ) processes = sorted( name for name in imports if name in PROCESS_MODULES or any(name.startswith(item + ".") for item in PROCESS_MODULES) ) dynamic_calls = sorted( name for name in calls if name in {"eval", "exec", "compile", "builtins.eval", "builtins.exec", "builtins.compile"} ) process_calls = sorted( name for name in calls if name in {"os.system", "os.popen"} or name.startswith(("subprocess.", "multiprocessing.", "pty.")) ) network_calls = sorted( name for name in calls if name.startswith(("requests.", "urllib.", "socket.", "http.client.")) ) return { "path": str(source_path), "sha256": sha256_file(source_path), "imports": sorted(imports), "network_modules": network, "process_modules": processes, "process_calls": process_calls, "dynamic_code_calls": dynamic_calls, "network_calls": network_calls, "review_required": bool(network or processes or process_calls or dynamic_calls or network_calls), } def _archive_audit_result( archive_path: Path, archive_sha256: str | None, reports: list[dict[str, Any]], errors: list[str], ) -> dict[str, Any]: """Return the complete public archive-audit schema for every exit path.""" return { "path": str(archive_path), "sha256": archive_sha256, "python_files": len(reports), "network_files": [ item["path"] for item in reports if item["network_modules"] or item["network_calls"] ], "process_files": [ item["path"] for item in reports if item["process_modules"] or item["process_calls"] ], "dynamic_code_files": [item["path"] for item in reports if item["dynamic_code_calls"]], "reports": reports, "errors": errors, } def audit_python_archive(path: str | Path) -> dict[str, Any]: """Audit every Python file in a zip without importing or executing it.""" archive_path = Path(path).expanduser().resolve() reports: list[dict[str, Any]] = [] errors: list[str] = [] try: archive_sha256 = sha256_file(archive_path) if archive_path.is_file() else None except OSError as exc: archive_sha256 = None errors.append(f"Could not hash Python archive: {exc}") try: opened_archive = zipfile.ZipFile(archive_path) except (OSError, zipfile.BadZipFile) as exc: errors.append(f"Could not open Python archive: {exc}") return _archive_audit_result(archive_path, archive_sha256, reports, errors) with tempfile.TemporaryDirectory() as directory, opened_archive as archive: root = Path(directory).resolve() members = archive.infolist() if len(members) > MAX_ARCHIVE_MEMBERS: errors.append(f"Archive contains too many members: {len(members)}") if sum(member.file_size for member in members) > MAX_ARCHIVE_TOTAL_SIZE: errors.append("Archive exceeds the maximum total uncompressed size") for member in members: target = (root / member.filename).resolve() if root not in target.parents and target != root: errors.append(f"Unsafe archive member: {member.filename}") if member.file_size > MAX_ARCHIVE_MEMBER_SIZE: errors.append(f"Archive member is too large: {member.filename}") if member.flag_bits & 0x1: errors.append(f"Encrypted archive member is not auditable: {member.filename}") ratio = member.file_size / max(member.compress_size, 1) if ratio > MAX_ARCHIVE_COMPRESSION_RATIO: errors.append(f"Suspicious compression ratio for archive member: {member.filename}") file_type = (member.external_attr >> 16) & 0o170000 if file_type == 0o120000: errors.append(f"Archive symlink is not allowed: {member.filename}") if errors: return _archive_audit_result(archive_path, archive_sha256, reports, errors) try: archive.extractall(root) except (OSError, RuntimeError, zipfile.BadZipFile) as exc: errors.append(f"Could not safely extract Python archive: {exc}") return _archive_audit_result(archive_path, archive_sha256, reports, errors) for source in sorted(root.rglob("*.py")): try: report = audit_python_source(source) except (OSError, SyntaxError, UnicodeDecodeError) as exc: errors.append(f"Could not audit {source.relative_to(root)}: {exc}") continue report["path"] = str(source.relative_to(root)) reports.append(report) return _archive_audit_result(archive_path, archive_sha256, reports, errors) def _blender_executable() -> str | None: found = shutil.which("blender") if found: return found mac_path = Path("/Applications/Blender.app/Contents/MacOS/Blender") return str(mac_path) if mac_path.is_file() else None def audit_repository(skill_directory: str | Path = SKILL_DIRECTORY) -> dict[str, Any]: root = Path(skill_directory).expanduser().resolve() manifest = load_manifest(root / "assets" / "toolchain-manifest.json") components = [] errors = [] warnings = [] for component in manifest["components"]: item = { "id": component["id"], "bundled": bool(component.get("bundled", False)), "license": component.get("license", "unknown"), "source": component.get("source"), "files": [], } for relative in component.get("files", []): path = root / relative exists = path.is_file() record = {"path": relative, "exists": exists} if exists: record["sha256"] = sha256_file(path) expected = component.get("sha256") if len(component.get("files", [])) == 1 else None if expected: record["expected_sha256"] = expected record["integrity_ok"] = record["sha256"] == expected if not record["integrity_ok"]: errors.append(f"Bundled component failed SHA-256 verification: {relative}") else: errors.append(f"Missing bundled component file: {relative}") item["files"].append(record) license_file = component.get("license_file") if license_file and not (root / license_file).is_file(): errors.append(f"Missing third-party license file: {license_file}") if component.get("downloads"): installed = [] for download in component["downloads"]: path = root / "assets" / "external" / download["destination"] record = {"path": str(path.relative_to(root)), "exists": path.is_file()} if path.is_file(): record["sha256"] = sha256_file(path) record["integrity_ok"] = ( record["sha256"] == download["sha256"] and path.stat().st_size == int(download["size"]) ) if not record["integrity_ok"]: errors.append(f"External asset failed verification: {record['path']}") else: warnings.append( f"Pinned external asset is not installed: {record['path']}; run fetch_assets.py for {component['id']}" ) installed.append(record) item["downloads"] = installed if component["id"] == "cautioned-blender-addon" and item["files"] and item["files"][0]["exists"]: archive_audit = audit_python_archive(root / item["files"][0]["path"]) item["archive_audit"] = { key: archive_audit[key] for key in ("python_files", "network_files", "process_files", "dynamic_code_files", "errors") } errors.extend(archive_audit["errors"]) components.append(item) return { "ok": not errors, "skill_directory": str(root), "components": components, "errors": errors, "warnings": warnings, } def _external_capability_status( available: bool | None, *, required: bool, default_unverified: str, ) -> str: if available is True: return "verified-by-external-discovery" if available is False: return "unavailable-by-external-discovery" return "required-unverified" if required else default_unverified def audit_runtime( required_workflow: str | None = None, studio_mcp_available: bool | None = None, creator_plugin_available: bool | None = None, blender_mcp_available: bool | None = None, pillow_available: bool | None = None, pillow_version: str | None = None, ) -> dict[str, Any]: if required_workflow not in (None, *SUPPORTED_RUNTIME_WORKFLOWS): raise ValueError( f"Unsupported runtime workflow: {required_workflow}; " f"expected one of {', '.join(SUPPORTED_RUNTIME_WORKFLOWS)}" ) for name, value in ( ("blender_mcp_available", blender_mcp_available), ("pillow_available", pillow_available), ("studio_mcp_available", studio_mcp_available), ("creator_plugin_available", creator_plugin_available), ): if value is not None and not isinstance(value, bool): raise TypeError(f"{name} must be True, False, or None") if pillow_version is not None and not isinstance(pillow_version, str): raise TypeError("pillow_version must be a major.minor.patch string or None") r15_bootstrap_required = required_workflow == "r15-bootstrap" blender = _blender_executable() errors = [] warnings = [] runtime: dict[str, Any] = { "required_workflow": required_workflow, "blender_executable": blender, "uvx_executable": shutil.which("uvx"), "inside_blender": False, "background": None, "session_mode": "not-in-blender", "blender_version": None, "pillow_version": pillow_version, "operators": {}, "agent_checks": { "blender_mcp_python_execution": _external_capability_status( blender_mcp_available, required=True, default_unverified="required-unverified", ), "blender_python_in_process": "unverified", "pillow_gif_encoder": _external_capability_status( pillow_available, required=True, default_unverified="required-unverified", ), "roblox_studio_mcp": _external_capability_status( studio_mcp_available, required=r15_bootstrap_required, default_unverified="optional-unverified", ), "creator_store_animation_plugin": _external_capability_status( creator_plugin_available, required=r15_bootstrap_required, default_unverified="workflow-dependent-unverified", ), }, } if blender_mcp_available is not True: errors.append( "Runtime readiness requires arbitrary-Python Blender MCP capability to be verified by external " "capability discovery" ) if pillow_available is not True: errors.append( "Runtime readiness requires Pillow in the external GIF-encoding Python environment to be verified " "by external capability discovery" ) elif pillow_version is None: runtime["agent_checks"]["pillow_gif_encoder"] = "available-version-unverified" errors.append("Runtime readiness requires an exact externally verified Pillow version") else: try: parsed_pillow_version = _version_triplet(pillow_version) except ValueError as exc: runtime["agent_checks"]["pillow_gif_encoder"] = "available-invalid-version-attestation" errors.append(f"Could not verify externally attested Pillow version {pillow_version!r}: {exc}") else: if parsed_pillow_version < MINIMUM_PILLOW or parsed_pillow_version >= MAXIMUM_PILLOW: runtime["agent_checks"]["pillow_gif_encoder"] = "available-unsupported-version" errors.append(f"Unsupported externally attested Pillow version: {pillow_version}; require >=10,<13") if r15_bootstrap_required and studio_mcp_available is not True: errors.append( "The r15-bootstrap workflow requires Roblox Studio MCP to be verified by external capability discovery" ) if r15_bootstrap_required and creator_plugin_available is not True: errors.append( "The r15-bootstrap workflow requires the Creator Store animation plugin to be verified by external " "capability discovery" ) try: import bpy # type: ignore except ImportError: if blender is None: errors.append("Blender executable was not found") errors.append( "Runtime readiness is unverified outside Blender; run this audit through the persistent Blender GUI/MCP session" ) else: runtime["inside_blender"] = True runtime["agent_checks"]["blender_python_in_process"] = "verified-by-bpy-import" runtime["blender_version"] = list(bpy.app.version) runtime["background"] = bool(bpy.app.background) runtime["session_mode"] = "background" if bpy.app.background else "persistent-gui" if bpy.app.background: errors.append( "Background Blender sessions are prohibited for animation work; use one persistent " "interactive Blender GUI connected through Blender MCP" ) if ( platform.system().lower() == "darwin" and platform.machine().lower() in {"arm64", "aarch64"} and tuple(bpy.app.version)[:2] == (5, 1) ): warnings.append( "This Blender/platform combination has produced a native image-thread shutdown crash in " "an observed automation session; keep the GUI process open or use Blender 4.5 LTS" ) for name, operator_name in ( ("rbxanim_import", "rbxanims_importmodel"), ("rbxanim_rebuild", "rbxanims_genrig"), ("rbxanim_export", "rbxanims_bake_file"), ): try: operator = getattr(bpy.ops.object, operator_name) operator.get_rna_type() available = True except (AttributeError, KeyError, RuntimeError, TypeError): available = False runtime["operators"][name] = available if tuple(bpy.app.version) < MINIMUM_BLENDER: errors.append("Blender 4.5 or newer is required") if r15_bootstrap_required: for name in ("rbxanim_import", "rbxanim_rebuild", "rbxanim_export"): if not runtime["operators"].get(name): errors.append(f"The r15-bootstrap workflow requires the bundled add-on operator: {name}") elif not runtime["operators"].get("rbxanim_export"): warnings.append("Motor6D export add-on is not registered in this Blender session") return {"ok": not errors, "runtime": runtime, "errors": errors, "warnings": warnings} def full_audit( skill_directory: str | Path = SKILL_DIRECTORY, required_workflow: str | None = None, studio_mcp_available: bool | None = None, creator_plugin_available: bool | None = None, blender_mcp_available: bool | None = None, pillow_available: bool | None = None, pillow_version: str | None = None, ) -> dict[str, Any]: repository = audit_repository(skill_directory) runtime = audit_runtime( required_workflow=required_workflow, studio_mcp_available=studio_mcp_available, creator_plugin_available=creator_plugin_available, blender_mcp_available=blender_mcp_available, pillow_available=pillow_available, pillow_version=pillow_version, ) return { "ok": repository["ok"] and runtime["ok"], "repository": repository, "runtime": runtime, "errors": repository["errors"] + runtime["errors"], "warnings": repository["warnings"] + runtime["warnings"], } def _parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--skill-directory", default=SKILL_DIRECTORY) parser.add_argument("--audit-addon", help="Statically audit a Blender Python add-on without running it") parser.add_argument("--audit-archive", help="Statically audit every Python file in an add-on zip") parser.add_argument( "--audit-pillow", action="store_true", help="Verify Pillow in the external Python environment used to encode GIF previews", ) parser.add_argument("--repository-only", action="store_true", help="Skip host runtime checks (for CI)") parser.add_argument( "--workflow", choices=SUPPORTED_RUNTIME_WORKFLOWS, help="Require workflow-specific capabilities during the runtime audit", ) parser.add_argument( "--blender-mcp-available", action=argparse.BooleanOptionalAction, default=None, help="Record whether arbitrary-Python Blender MCP was verified by external capability discovery", ) parser.add_argument( "--studio-mcp-available", action=argparse.BooleanOptionalAction, default=None, help="Record whether Roblox Studio MCP was verified by external capability discovery", ) parser.add_argument( "--creator-plugin-available", action=argparse.BooleanOptionalAction, default=None, help="Record whether the Creator Store animation plugin was verified by external capability discovery", ) parser.add_argument( "--pillow-available", action=argparse.BooleanOptionalAction, default=None, help="Record whether Pillow was verified in the external GIF-encoding Python environment", ) parser.add_argument( "--pillow-version", help="Exact externally verified Pillow major.minor.patch version", ) return parser.parse_args(argv) def main() -> int: args = _parse_args(sys.argv[1:]) if args.audit_addon: result = audit_python_source(args.audit_addon) elif args.audit_archive: result = audit_python_archive(args.audit_archive) result["ok"] = not result["errors"] elif args.audit_pillow: result = audit_pillow_encoder() elif args.repository_only: result = audit_repository(args.skill_directory) else: result = full_audit( args.skill_directory, required_workflow=args.workflow, studio_mcp_available=args.studio_mcp_available, creator_plugin_available=args.creator_plugin_available, blender_mcp_available=args.blender_mcp_available, pillow_available=args.pillow_available, pillow_version=args.pillow_version, ) print(json.dumps(result, indent=2, sort_keys=True)) return 0 if result.get("ok", not result.get("review_required", False)) else 1 if __name__ == "__main__": raise SystemExit(main())animate-roblox-characters/scripts/validate_r6_asset.py3.3 KB7c2d28e33f49upstream ↗
#!/usr/bin/env python3 """Validate the installed authentic R6 V2.22 rig inside Blender.""" from __future__ import annotations import json import sys from pathlib import Path SCRIPT_DIRECTORY = Path(__file__).resolve().parent if str(SCRIPT_DIRECTORY) not in sys.path: sys.path.insert(0, str(SCRIPT_DIRECTORY)) from authentic_rig import activate_verified_r6_v222, enable_bundled_addon, inspect_authenticity # noqa: E402 REQUIRED_CONTROLS = { "MasterControl", "LowerTorso-FK", "LeftLeg-IK", "RightLeg-IK", "LeftArm-IK", "RightArm-IK", "LeftArm_FK", "RightArm_FK", "LookToPoint", } REQUIRED_BODY_OBJECTS = {"Head", "Torso", "Left Arm", "Right Arm", "Left Leg", "Right Leg"} def validate() -> dict: import bpy # type: ignore activation = activate_verified_r6_v222() addon = enable_bundled_addon() errors = list(activation.get("errors", [])) + list(addon.get("errors", [])) armature = bpy.data.objects.get("__PrimaryArmature") internal = bpy.data.objects.get("InternalArmature") if armature is None or armature.type != "ARMATURE": errors.append("Missing __PrimaryArmature") if internal is None or internal.type != "ARMATURE": errors.append("Missing InternalArmature") valid_armature = armature is not None and armature.type == "ARMATURE" control_names = set(armature.pose.bones.keys()) if valid_armature else set() object_names = set(bpy.data.objects.keys()) missing_controls = sorted(REQUIRED_CONTROLS - control_names) missing_body = sorted(REQUIRED_BODY_OBJECTS - object_names) if missing_controls: errors.append(f"Missing R6 controls: {', '.join(missing_controls)}") if missing_body: errors.append(f"Missing visible R6 body objects: {', '.join(missing_body)}") body_variants = sorted(name for name in object_names if name.endswith(("_F2012", "_F2016", "_FBlocky", "_M2012", "_M2016", "_MBlocky"))) if len(body_variants) < 20: errors.append("Expected premade colored/labeled R6 body variants were not found") authenticity = inspect_authenticity(armature) if valid_armature else None if not authenticity or not authenticity["authentic_ready"]: errors.append("R6 asset did not classify as authentic-ready") rig_texture = bpy.data.images.get("R6rig1_diff.png") texture_packed = bool(rig_texture and (rig_texture.packed_file or rig_texture.packed_files)) texture_resolved = bool(rig_texture and bpy.path.abspath(rig_texture.filepath) and Path(bpy.path.abspath(rig_texture.filepath)).is_file()) if rig_texture is None or not (texture_packed or texture_resolved): errors.append("The labeled/orientation R6 rig texture is missing or unresolved") return { "ok": not errors, "activation": activation, "addon": addon, "authenticity": authenticity, "controls": sorted(control_names), "body_objects": sorted(REQUIRED_BODY_OBJECTS & object_names), "body_variant_count": len(body_variants), "rig_texture": { "name": rig_texture.name if rig_texture else None, "packed": texture_packed, "resolved_external_file": texture_resolved, }, "errors": errors, } if __name__ == "__main__": payload = validate() print(json.dumps(payload, indent=2, sort_keys=True)) raise SystemExit(0 if payload["ok"] else 1)animate-roblox-characters/references/blender-mcp.md6.9 KBb46b11e1bab1upstream ↗
# Blender MCP Operations Read this file before sending Blender commands. ## Capability contract Require an MCP tool that can execute arbitrary Python inside the open Blender process. Common compatible shapes include `execute_blender_code` and `blender_python_exec`. Tool names are not stable across MCP implementations, so discover capabilities rather than hard-coding one server name. Use exactly one persistent interactive Blender GUI process for the complete task. Before mutation, call `session_guard.inspect_session()` through MCP and require `ok: true` and `mode: persistent-gui`. Do not start Blender through a shell, `subprocess`, OS application launcher, or an MCP helper that creates a disposable process. Do not use `--background`, call `bpy.ops.wm.quit_blender()`, or terminate Blender after export. A native renderer/image-library crash during process teardown cannot be recovered by Python. Viewport screenshots are useful but optional. When no screenshot tool exists, use `animation_tools.render_pose_views()` to render deterministic front and side review images through Blender itself. Pose-review cameras/lights may be created during blocking only inside the export-excluded `Review` collection. The final GIF camera and three-light setup remain Phase 7 work after export. Do not enable Blender MCP asset-download features, API keys, or external model services. Use only the pinned `fetch_assets.py` path for the mandatory R6 asset. Bind Blender MCP to localhost, pin the manifest version, and disable all prompt, code, screenshot, and usage telemetry. Arbitrary Python execution is equivalent to local code execution with Blender's user privileges. ## Calling the bundled modules Resolve the installed skill directory at runtime; never paste a developer's absolute path into generated files or documentation. For MCPs that accept inline Python, use the equivalent of: ```python import sys sys.path.insert(0, SKILL_SCRIPTS_DIRECTORY) from animation_tools import inspect_scene __result__ = inspect_scene() ``` For MCPs that accept a script path, call the bundled module directly if the skill directory is an approved script root. If safe mode restricts script paths, copy the module into the animation project's working directory first. All public functions return JSON-serializable dictionaries with these stable top-level keys when relevant: - `ok` - `rig_type` - `armature` - `bones` - `metrics` - `artifacts` - `warnings` - `errors` Treat `ok: false`, non-empty `errors`, or a required `unverified` metric as a failed step. ## Safe transaction order 1. Run `session_guard.inspect_session()` and reject background/not-in-Blender modes. 2. Run `inspect_scene()` before mutation. 3. If the scene contains unrelated work, save an incremented working copy. 4. Run `authentic_rig.inspect_authenticity()` on the selected armature. Animate only when `authentic_ready` is true. 5. If no authentic rig exists, install/copy the pinned premade R6 rig or transfer a premade Studio R15 as described in `roblox-rigs-export.md`. Never generate a substitute. 6. Run `apply_pose()` with a single pose or coherent body-group batch. 7. Require the returned transforms and review renders before the next batch. 8. Run `polish_curves()` only after blocking. 9. Run `audit_action(audit_stage="BLOCKING")` after blocking so intentional constant keys are permitted while hierarchy, mesh-keying, and other supplied checks still run. After polish and immediately before export, run `audit_action(audit_stage="FINAL", loop_required=..., contacts_required=...)`; final mode requires both booleans explicitly and rejects every constant or linear key. 10. Retarget only between two verified authentic Roblox rigs, then visually verify the target. 11. Save the working `.blend`, then use `export_rbxanim()` for Motor6D rigs or `export_fbx()` for verified skinned rigs. Do not send a long monolithic script that creates the whole animation without checkpoints. A tool call may insert several coordinated keys for one pose, but it must return and be inspected before continuing. ## Pose payload `apply_pose()` accepts a dictionary shaped like: ```python { "armature": "Roblox_R15_Armature", "frame": 1, "interpolation": "CONSTANT", "bones": { "LowerTorso": {"rotation_degrees": [0, 0, -5]}, "LeftUpperLeg": {"rotation_degrees": [18, 0, 0]}, "CTRL_LeftFoot_IK": {"location": [0, 0, 0]}, "QuaternionControl": {"rotation_quaternion": [1, 0, 0, 0]} }, "render_directory": OUTPUT_REVIEW_DIRECTORY, "pose_label": "contact-left", "review_front_direction": [0, -1, 0] } ``` Locations are local pose-bone offsets. Euler rotations use the bone's configured rotation order unless an explicit valid Euler `rotation_mode` is supplied. Quaternion rotations use normalized WXYZ order. Never include a name that was not returned by inspection. R6 V2.22 defaults to visible forward `+Y`; every other rig requires `review_front_direction` established from visual inspection. ## Visual critique requirements Transforms alone cannot establish pose quality. Inspect both returned review images and explicitly evaluate: - Front view: support base, center of mass, hip/shoulder counter-angle, symmetry, negative space, limb separation, and readable silhouette. - Side view: forward lean, spine curve, heel/toe contact, knee direction, arm arc, head lead/drag, and whether the body appears grounded. If image inspection and numerical transforms disagree, trust the evaluated image and correct the pose. ## Existing rigs Never rename an existing user's bones merely to match a template. Build a role map from discovered names and hierarchy. Preserve `__PrimaryArmature`, the associated rig-named `__*Meta` empty carrying `RigMeta` in the shared `RIG:` collection, importer custom properties, orientation textures, and control shapes. If constraints exist, key controls; if a verified FK rig has no controls, key the deform pose bones directly. Never generate or substitute a character model. ## Failures - Missing Python execution: stop before mutation and explain that Blender MCP is required. - Background or disposable Blender process: stop before mutation and request an open GUI/MCP session. Never retry by launching another Blender process. - Blender disappears or MCP disconnects during render: preserve completed files, do not auto-relaunch Blender, and ask the user to reopen the last saved working copy. Retry with Blender 4.5 LTS if the persistent GUI session remains unstable. - Missing premade authentic target: obtain the pinned R6 or Studio R15; if it cannot be obtained or verified, stop before keyframing. - Multiple plausible armatures: report exact names and request the target. - Missing review render: retry once with Blender-rendered views, then mark visual review unverified. - Export operator error: preserve the `.blend`, report the operator error, and do not claim an `.rbxanim` or FBX exists. - Studio unavailable: complete Blender output and provide import notes without treating Studio as a failure.animate-roblox-characters/references/preview-media.md2.5 KB5f2e7c4fe66fupstream ↗
# Animated GIF Preview Read this before Phase 7. The GIF is a required final artifact for every completed animation. ## Order of operations 1. Finish the Phase 6 audit. 2. Save the final `.blend` and export `.rbxanim` or FBX. 3. Add preview-only camera/lights in `Review`. 4. Render sequential RGB PNG frames. 5. Encode and verify the GIF. 6. Deliver all artifacts together. This ordering prevents review objects from entering the Roblox export. ## Camera and lighting Inspect the visible model to establish forward direction. R6 V2.22 faces Blender `+Y`; the older nominal front camera at `-Y` shows its back. For an origin-centered R6 V2.22 character, use: - Resolution: `512 × 512`. - Orthographic camera: `(6.8, 8.8, 4.8)`. - Look target: `(0, 0, 2.55)`. - Orthographic scale: `6.4`. - Fixed framing for the entire animation. - Three review-only area lights: key, fill, and rim. Use `animation_tools.render_animation_preview_frames()` or the `render-preview` operation. It applies these R6 defaults, renders RGB PNGs, and requires explicit visually chosen camera coordinates for other rigs. ## Loop frames Keep the duplicated closing frame for numerical seam/export checks, but do not include it in the preview. A cycle keyed at frames `1–25` with frame 25 duplicating frame 1 renders GIF playback frames `1–24`. Use natural, zero-padded filenames such as `walk_0001.png` through `walk_0024.png`. ## Encoding Run: ```sh python3 scripts/build_animation_preview.py \ --frames-dir <preview-frames> \ --glob 'walk_*.png' \ --output <animation-preview.gif> \ --fps 30 \ --expected-frames 24 ``` The encoder: - Converts every frame to RGB. - Requires identical dimensions. - Builds one adaptive 256-color median-cut palette from thumbnails of every frame. - Quantizes every frame against that shared palette with Floyd–Steinberg dithering. - Saves with `loop=0`, `disposal=2`, and `optimize=False`. - Distributes GIF centiseconds across frames instead of rounding each frame independently. - Reopens the result and verifies metadata and duration. At 24 frames and 30 FPS, the exact duration list is `[30, 30, 40] * 8`, totaling `800 ms`. ## Visual verification Inspect the animated result when the environment can display GIFs. Check framing, front direction, lighting, palette stability, trails, loop seam, foot contacts, and whether the motion reads like the final Blender action. If animated playback cannot be inspected, inspect representative PNGs and mark animated playback unverified.animate-roblox-characters/references/roblox-rigs-export.md8.5 KB0c20ca319eb4upstream ↗
# Authentic Roblox Rigs and Export Read this before importing, selecting, or exporting a character. ## R6: the colored/labeled workflow The intended premade rig is Aeresei's [R6 IK + FK Blender Rig V2.22](https://devforum.roblox.com/t/r6-ik-fk-blender-rig-v222/3586405), not a six-box approximation. Verified identifiers include: - `__PrimaryArmature` with `_Rbx_R6_Rig_` marker. - `InternalArmature` with Roblox parts `HumanoidRootPart`, `Torso`, `Head`, `Left Arm`, `Right Arm`, `Left Leg`, and `Right Leg`. - `MasterControl`, `LowerTorso-FK`, limb `*-IK`/`*-FK` controls, pole targets, grab points, and head target. - Custom viewport control shapes and body-type variants. - Orientation-colored/labeled body surfaces and the Rig Texture utility. - Active Rig, Rig Settings, Utilities, Accessories, and weapon-sync scripts. Install it with `fetch_assets.py`, copy the cached master, open with script auto-execution disabled, and call `activate_verified_r6_v222()`. The first activation requires the exact pinned master and creates a machine-local lineage key; the working `.blend` stores only a random lineage identifier and its HMAC. Renamed backups remain verifiable on the same user account. A reopened working copy must pass that HMAC plus pinned text-hash and structure checks before a new same-session receipt is issued. Animate `__PrimaryArmature` controls only. The matching Studio `.rbxm` is part of the pinned download and contains no Script/LocalScript/ModuleScript instances. ## R15 and custom avatars For R15, use the actual premade Roblox Studio R15 or the requested player's existing character: 1. Discover the Studio MCP and Creator Store plugin capabilities. Report a missing capability as a blocker; do not claim it exists merely because the workflow expects it. 2. Hash-verify and register the bundled Cautioned 2.6.3 Blender extension, with its online features and live sync disabled. Confirm its import, rebuild, and export operators, then run the in-Blender workflow-aware toolchain audit for `r15-bootstrap`. 3. Through Roblox Studio MCP, create a stock premade R15 with Studio's rig builder when no target exists, or select the user's actual R15 character. Inspect the selected model's hierarchy before transfer. 4. Use Cautioned's [Blender Animations (ultimate edition)](https://create.roblox.com/store/asset/16708835782/Blender-Animations-ultimate-edition) from the Creator Store. 5. Prefer a temporary live-sync transfer. Start the Cautioned localhost server only for this transfer, initiate transfer of the selected Studio character through the installed Creator Store plugin, and wait until Blender contains the reconstructed armature and exactly one associated rig-named `__*Meta` empty carrying the `RigMeta` property in the same `RIG:` master collection (often `__RigMeta`). Stop the live-sync server immediately after the transfer finishes or fails. 6. If live sync cannot complete, use the Creator Store plugin's file-based OBJ transfer path. A local file dialog may require one user click, but the AI chooses and validates the selected Studio character, then passes the resulting OBJ to `authentic_rig.import_motor6d_obj(path)` in the persistent Blender MCP session. Require the reconstructed armature and its unambiguous associated `__*Meta` empty before continuing. 7. Preserve the associated metadata empty, object names, textures, accessories, and bone properties such as `is_transformable`, `transform`, `transform1`, and `nicetransform`. 8. Inspect and classify the imported armature before the first keyframe. Use `.rbxanim` for this metadata-backed Motor6D path. Do not invent plugin button names or claim a successful transfer from a command invocation alone; verify the resulting Blender objects and metadata. The AI performs all importing, control mapping, posing, keyframing, polishing, auditing, and export work. Never delegate animation work to the user. For the skinned/FBX route, export or receive the actual Studio R15 rather than substituting a generic humanoid. Import it with auto-execution disabled, inspect the visible character, confirm an armature-deformed mesh and the expected hierarchy, and establish its Studio origin. Matching R15 bone names and a mesh are not sufficient proof. Only after those checks, call `mark_verified_skinned_rig(armature_name, source)` with `studio-rig-builder`, `studio-export`, or `user-verified-studio-export`. Do not set the provenance property directly. This route exports FBX and does not use Motor6D mapping. Do not use Roblox's skeleton-only project template as the visible character. Do not generate replacement body geometry. ## Stud-to-scene-unit calibration Contact drift is specified in Roblox studs, while Blender reports scene-space distances. Before auditing planted contacts on an imported rig, determine `scene_units_per_stud` from evidence tied to that exact import: 1. Measure a known distance on the selected character or a temporary reference in Studio and record it in studs. 2. Measure the corresponding distance in the imported Blender scene, or use an explicitly known uniform import scale. 3. Compute `scene_units_per_stud = blender_scene_distance / studio_distance_studs`. Require positive, finite values and repeat the measurement if the axes or import scaling are ambiguous. 4. Pass that value to `animation_tools.audit_action()` whenever contact intervals are audited. The audit converts the `0.02`-stud contact limit to scene units and should report both quantities. For locomotion, make the required checks explicit rather than allowing omitted loop/contact inputs to pass: ```python animation_tools.audit_action( armature_name=armature_name, action_name=action_name, loop_start=loop_start, loop_end=loop_end, contact_intervals=contact_intervals, contact_drift_threshold=0.02, # studs scene_units_per_stud=scene_units_per_stud, loop_required=True, contacts_required=True, ) ``` For a grounded one-shot, pass `loop_required=False`, `contacts_required=True`, and every intended support-contact interval. Pass `contacts_required=False` only for an action with no planted interval at all. Every final audit must provide both booleans explicitly; omission fails. Do not infer this conversion from the label “R15,” from character height, or from a visual guess. Recalibrate after any import-scale change. ## Motor6D part rigs: `.rbxanim` Use `.rbxanim` for classic R6/R15 part rigs and the R6 V2.22 workflow: 1. Preserve the Cautioned/Roblox mapping metadata. 2. Animate only verified pose controls. 3. Keep the internal/deform armature constrained to the external controls. 4. Export the explicitly named action and audited frame range with `authentic_rig.export_rbxanim()` through `object.rbxanims_bake_file`. 5. Import/play the result through the matching Studio plugin. Generic FBX export is not a substitute for Motor6D mapping. ## Skinned Roblox rigs: FBX Use FBX only after inspection verifies an armature-deformed Roblox skinned character and its expected R15 hierarchy. Follow Roblox's [Blender guidance](https://create.roblox.com/docs/art/blender): - Unit system `None`; rotation in degrees. - `Z Forward`, `Y Up`. - Select only the authentic armature and driven meshes. - Disable leaf bones. - Bake at one sample per frame with no simplification. - Export exactly one active action; disable NLA-strip/all-action export. - Export deform bones only, with evaluated control motion baked onto them. ## Authenticity results `authentic_rig.inspect_authenticity()` recognizes: - `community-r6-ik-fk-control-rig`: usable only after verified activation, full control/body structure, embedded-script hashes, and local working-copy lineage checks pass; Motor6D/`.rbxanim`. - `studio-motor6d-import`: usable only with associated Cautioned/Studio metadata and verified operators; Motor6D/`.rbxanim`. - `studio-or-fbx-skinned-r15`: usable only after visible-mesh inspection and explicit Studio-source verification; FBX. - `generated-or-repository-proxy`: forbidden. - `unknown` or `verify-before-export`: stop and verify. Exact bone names must still be discovered at runtime. ## Studio validation When Studio MCP and the imported animation are available: 1. Confirm the target hierarchy and rig type. 2. Load the animation on the target `Animator`. 3. Play several repetitions for loops. 4. Capture the viewport and inspect console output. 5. Fix ownership, permission, hierarchy, mapping, or playback errors before completion. A local file dialog or publish permission may require one user authorization. Never delegate posing, keyframing, rigging, or curve polish.animate-roblox-characters/references/toolchain.md9.1 KBa208ac2630d0upstream ↗
# Roblox–Blender Toolchain Read this during Phase 0. The machine-readable source and hash inventory is `assets/toolchain-manifest.json`. ## Required stack | Component | Purpose | Policy | |---|---|---| | Blender 4.5+ | Pose Mode, Graph Editor, rendered review, export | Install from Blender.org. Blender 4.5 LTS is recommended because the R6 asset targets that line and a persistent LTS GUI avoids disposable-process teardown instability. | | Blender MCP 1.6.4 | AI control through Blender Python | External MIT package. Localhost only; telemetry and remote asset integrations off. | | Pillow `>=10,<13` | Shared-palette GIF encoding and verification | External HPND Python library. Read local PNG renders and write the preview GIF only. | | R6 IK + FK Blender Rig V2.22 | Premade visible R6 model, colored/labeled controls, IK/FK, panels | Mandatory for generic R6 work. Fetch only from Aeresei's original DevForum attachments with `fetch_assets.py`. Do not mirror it in Git because no redistribution license is stated. | | Premade Studio R15 | Actual visible R15 target | Mandatory for R15. Use Studio's rig builder or the user's existing character, then export it. Never replace it with generated geometry. | | Cautioned Blender Animations 2.6.3 | Import/export, mapping, IK/control support, `.rbxanim` | Bundled unmodified signed-release zip under GPL-3.0-or-later; SHA-256 verified. | | Blender Animations (ultimate edition) | Studio side of the Motor6D transfer | Install only from Cautioned's Creator Store listing. | | Roblox Studio MCP | R15 bootstrap and optional playback validation | Use Roblox's built-in MCP. | ## Premade-model rule There is no repository-generated Roblox model and no block-proxy fallback. - R6 begins from the actual V2.22 author rig. It contains `__PrimaryArmature`, `InternalArmature`, `MasterControl`, IK/FK limb controls, custom control shapes, body variants, colored orientation faces, and the Active Rig/Rig Settings/Utilities/Rig Texture panels seen in the reference workflow. - R15 begins from a premade Roblox Studio character. Cautioned's Studio and Blender tools reconstruct the actual target and preserve Motor6D mapping. - A custom avatar begins from that avatar's Studio export. Preserve appearance, accessories, joint metadata, and texture mapping. - Roblox's official R15 “rig and attachments” project is skeleton/reference material only. It is not a visible-player substitute. If the requested premade rig cannot be obtained or verified, stop before keyframing. ## Pinned R6 installation Run with network authorization: ```sh python3 animate-roblox-characters/scripts/fetch_assets.py --component r6-ik-fk-v222 ``` The downloader accepts only allowlisted HTTPS hosts, writes through a temporary file, checks byte size and SHA-256, and refuses to overwrite an unverified local file. It installs the asset into the ignored `assets/external/` cache. Always copy the cached master into the animation project before editing. Open the copied exact master with Blender script auto-execution disabled. Then call `authentic_rig.activate_verified_r6_v222()`. First activation verifies the complete pinned `.blend` and every embedded text block before registering the UI/control scripts. It also creates a random 32-byte key in the version-independent per-user `.animate-roblox-characters` configuration directory and stores only a random lineage identifier plus its HMAC in the working `.blend`. Blender 4.5 and newer releases on the same user account share this key. On crash recovery, the helper requires that local HMAC and revalidates the pinned scripts and control/body structure before issuing a same-session receipt. The key is never bundled, logged, or written into an animation file. Never click “Allow Execution” on an unverified copy. The HMAC is bound to the random lineage identifier rather than a filename. Save after any activation result with `lineage_updated: true`; afterward, incremented backups and same-session Save As copies remain directly verifiable on the same user account. If the per-user lineage key is unavailable after a machine/account migration, do not bypass the check or copy custom properties. Open the old file with script auto-execution disabled, inspect it, load only its named armature Action datablock into a fresh exact-master working copy, activate that fresh copy, and repeat the pose/curve/audit/export checks before delivery. ## Cautioned 2.6.3 security boundary The bundled `rbx_anims_v2.6.3.zip` is the unmodified current GitHub release. Static review found: - Roblox OAuth and asset-delivery networking. - A live-sync HTTP server bound to `127.0.0.1`. - Permissive CORS on that optional local server. - No process-spawning imports in the release. `authentic_rig.enable_bundled_addon()` verifies the zip and registers it without starting OAuth or the live-sync server. Keep online access, auto-connect, and live sync off during ordinary authoring. When Studio transfer requires live sync, start it only for that operation, do not browse untrusted sites while it is running, and stop it immediately afterward. ## Blender MCP boundary Blender MCP intentionally allows arbitrary local Python execution: 1. Bind it to loopback only. 2. Verify the pinned package hash from the manifest. 3. Disable telemetry and third-party asset download integrations. 4. Open one interactive Blender GUI and keep that process alive throughout inspection, animation, rendering, export, and saving. 5. Run `session_guard.inspect_session()` through MCP and require `mode: persistent-gui` before mutation. 6. Never invoke Blender with `--background`, spawn it as a child process, or call Blender's quit operator. 7. Save an incremented working copy before mutation. 8. Execute repository modules or short reviewed pose blocks only. 9. Never expose Blender MCP or the Cautioned live-sync port to a LAN or the internet. ## Audits ```sh python3 animate-roblox-characters/scripts/toolchain.py --repository-only python3 animate-roblox-characters/scripts/toolchain.py --audit-pillow python3 animate-roblox-characters/scripts/toolchain.py --audit-archive <addon.zip> python3 animate-roblox-characters/scripts/toolchain.py --audit-addon <candidate.py> ``` Run the first audit for offline repository readiness. Run `--audit-pillow` with the external Python environment that will execute `build_animation_preview.py`; require `ok: true` and record its exact `pillow_version`. Pillow is deliberately verified outside Blender because GIF encoding is external and Blender's bundled Python need not contain Pillow. After discovering external capabilities and classifying the rig, run the full audit through the persistent Blender MCP Python session. Pass `blender_mcp_available=True` only after an actual Blender MCP arbitrary-Python call succeeds; importing `bpy` by itself does not prove MCP connectivity. Runtime readiness requires both this external MCP attestation and an in-process persistent-GUI Blender check. Do not run the workflow-aware readiness audit as an ordinary shell command: a shell process is outside Blender and must fail the in-process check. The CLI keeps workflow and capability switches for controlled embedding and test harnesses, but the in-process call below is the sole runtime-readiness procedure. `r15-bootstrap` is the only named workflow gate in this version. It additionally requires Studio MCP, the Creator Store animation plugin, and all three bundled add-on operators (`rbxanim_import`, `rbxanim_rebuild`, and `rbxanim_export`); an unavailable operator is an error. Pass each availability boolean as true only after that capability has actually been verified. A missing attestation is a failed workflow requirement, not an optional warning. The audit verifies availability claims—it does not replace object and metadata inspection after transfer. For R6 or skinned-FBX paths, omit `required_workflow`, retain the verified Blender MCP and Pillow attestations, and explicitly verify the relevant source and export path: the pinned V2.22 hashes plus the `rbxanim_export` operator for R6, or verified Studio provenance, an armature-deformed mesh, and `bpy.ops.export_scene.fbx` for skinned FBX. The in-process equivalent for the default R15 path is: ```python toolchain.full_audit( required_workflow="r15-bootstrap", blender_mcp_available=True, pillow_available=True, pillow_version="12.3.0", # Use the exact version reported by --audit-pillow. studio_mcp_available=True, creator_plugin_available=True, ) ``` Pass `blender_mcp_available=True` only from code already executing through the discovered Blender MCP connection. Pass the Pillow values only after the separate external audit succeeds; the in-Blender audit consumes that attestation and does not import Pillow. For a non-R15 path, call `toolchain.full_audit(blender_mcp_available=True, pillow_available=True, pillow_version="12.3.0")` through that connection using the exact reported version, then perform the explicit rig and exporter checks described above. For an untrusted `.blend`, disable Blender's script auto-execution, open the file in the persistent GUI, and call `inspect_blend_security.inspect_current_file()` through MCP. Static review reduces risk but does not prove arbitrary third-party code safe.animate-roblox-characters/assets/blender-addons/Cautioned-Blender-Animations-Plugin-LICENSE.txt34 KB01696112c818upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
animate-roblox-characters/assets/blender-addons/rbx_anims_v2.6.3.zip204 KB1c061b2107b9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
animate-roblox-characters/assets/external/.gitkeep1 B8b137891791fupstream ↗
animate-roblox-characters/assets/toolchain-manifest.json6.7 KB1d6a90c5e639upstream ↗
{ "schema_version": 1, "components": [ { "id": "blender", "name": "Blender", "kind": "application", "required_for": ["authoring", "motor6d", "skinned-fbx"], "bundled": false, "version": ">=4.5", "tested_version": "5.1.2", "source": "https://www.blender.org/download/", "license": "GPL-3.0-or-later", "security": "Install only from blender.org or an operating-system package manager." }, { "id": "blender-mcp", "name": "Blender MCP", "kind": "mcp-server-and-blender-addon", "required_for": ["ai-control"], "bundled": false, "version": "1.6.4", "source": "https://github.com/ahujasid/blender-mcp", "package": "blender-mcp==1.6.4", "pypi_wheel_sha256": "2e935cc3f78df5d2be12192b970f92753a00b3a50c442b6338c3100102a133c5", "license": "MIT", "security": "Arbitrary Python execution is intended. Bind to localhost only, disable telemetry, and keep remote asset-download integrations disabled." }, { "id": "pillow", "name": "Pillow", "kind": "python-library", "required_for": ["gif-preview"], "bundled": false, "version": ">=10,<13", "current_version": "12.3.0", "tested_version": "11.3.0", "source": "https://pypi.org/project/pillow/", "license": "HPND", "security": "Install from PyPI or an operating-system package manager. The encoder reads only locally rendered PNG frames and writes one GIF." }, { "id": "roblox-studio-mcp", "name": "Roblox Studio MCP", "kind": "built-in-studio-mcp", "required_for": ["studio-validation", "r15-bootstrap"], "bundled": false, "version": "current Studio release", "source": "https://create.roblox.com/docs/studio/mcp", "license": "Roblox platform terms", "security": "Use Roblox's built-in Studio MCP and verify the active place before executing Luau." }, { "id": "cautioned-studio-plugin", "name": "Blender Animations (ultimate edition)", "kind": "roblox-studio-plugin", "required_for": ["motor6d", "r15-bootstrap"], "bundled": false, "version": "current Creator Store release", "source": "https://create.roblox.com/store/asset/16708835782/Blender-Animations-ultimate-edition", "license": "Roblox Creator Store terms", "security": "Install only from the Creator Store listing. Keep auto-connect and live sync off unless Studio transfer is actively required." }, { "id": "cautioned-blender-addon", "name": "Roblox Animations Importer/Exporter", "kind": "blender-extension-zip", "required_for": ["motor6d", "r6-authoring", "r15-bootstrap"], "bundled": true, "version": "2.6.3", "release_commit": "6ed4dca", "source": "https://github.com/Cautioned/Blender-Animations-Plugin/releases/tag/v2.6.3", "files": ["assets/blender-addons/rbx_anims_v2.6.3.zip"], "sha256": "218b5e43e414fe3fa5d8a42cc5fd162b70e66f4cb82efd73ac5006b63895769a", "license": "GPL-3.0-or-later", "license_file": "assets/blender-addons/Cautioned-Blender-Animations-Plugin-LICENSE.txt", "security": "Unmodified signed-release asset. Static audit found optional Roblox OAuth/asset network access and a localhost live-sync server. Registration does not start the server. Keep OAuth, online access, auto-connect, and live sync disabled unless explicitly needed." }, { "id": "r6-ik-fk-v222", "name": "R6 IK + FK Blender Rig V2.22", "kind": "mandatory-premade-r6-authoring-rig", "required_for": ["r6-authoring"], "bundled": false, "version": "2.22", "source": "https://devforum.roblox.com/t/r6-ik-fk-blender-rig-v222/3586405", "license": "usage shared by author; redistribution license not stated", "installation": "Fetch automatically from the original author's DevForum attachments and verify every byte before use.", "downloads": [ { "name": "Blender R6 Rig.blend", "url": "https://devforum.roblox.com/secure-uploads/uploads/original/5X/1/d/f/7/1df71ef414c374be5577f41841ec913d509bf70f.blend", "destination": "r6-ik-fk-v222/Blender R6 Rig.blend", "size": 4059368, "sha256": "ff75c44b572d32328b62095141c6ce6255c4e9b772ee63d46d92280de1edcdc8" }, { "name": "Studio R6 Rig.rbxm", "url": "https://devforum.roblox.com/uploads/short-url/wVMj4ZTnEU4Ks2dBlo9dbQphSBG.rbxm", "destination": "r6-ik-fk-v222/Studio R6 Rig.rbxm", "size": 9434, "sha256": "a85e1ce13b6cb15c2094be8afcf6bd66ab2017e10f42be6ecb95faa20dd93444" } ], "embedded_text_sha256": { "RigBootstrap.py": "2e87eae515b129b0eac2db0138cd2ec215f101e45c975fe7b1eeaabfe38033e7", "RigEvents.py": "01731b7ad8c77ac4160f76a513e740c965aa78a7aa5e4072101024a4783e0211", "RigSelector.py": "7deaa1d7c3480896b90b148cde7fc24fa974865de5cff691208d537187194e9f", "RigSettings.py": "e9b334ee31ebc87af15b3e75242fea8249b7ff841a7487f1e4c2221cce590edd", "RigUtilities.py": "dc96f3453e5cdb0bea7a3af3f4c8823a06492c81d90ae5f34190e7ef5332760a" }, "security": "Audited with Blender auto-execution disabled. Five embedded scripts contain no network or process-launch code. The matching RBXM contains no Script, LocalScript, or ModuleScript instance. Activate embedded scripts only after both the blend hash and every text-block hash match." }, { "id": "studio-premade-r15", "name": "Premade Roblox Studio R15 character", "kind": "studio-generated-authentic-rig", "required_for": ["r15-authoring"], "bundled": false, "version": "current Roblox Studio", "source": "https://create.roblox.com/docs/studio/rig-builder", "license": "Roblox platform terms", "security": "Create or select the stock premade R15 character inside Studio, then transfer it through Cautioned's Creator Store plugin. Never replace it with repository-generated geometry." }, { "id": "roblox-official-r15-project-files", "name": "Roblox official R15 rig and attachments project files", "kind": "official-reference-only", "required_for": [], "bundled": false, "version": "2025-10-22 archive", "source": "https://create.roblox.com/docs/art/modeling/project-files", "archive_sha256": "e96e8483143716b49369e8692f2c2c2c7ae0d3a3d936b58cf3b5dae73148ff7b", "license": "Roblox platform terms", "security": "The official archive was inspected with auto-execution disabled and contains no embedded Blender text, drivers, or external paths. It is a skeleton/attachment reference, not the visible premade authoring character, so do not substitute it for the Studio R15 workflow." } ] }animate-roblox-characters/agents/openai.yaml294 B0d7f861a8091upstream ↗
interface: display_name: "Animate Roblox Characters" short_description: "Animate premade Roblox rigs with GIF previews" default_prompt: "Use $animate-roblox-characters to load the verified premade Roblox rig, create and export a polished animation, and deliver its animated GIF preview."animate-roblox-characters/LICENSE1.1 KBe5a2a79b0f0bupstream ↗
MIT License Copyright (c) 2026 Roblox Animation Skill Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.animate-roblox-characters/THIRD_PARTY_NOTICES.md2.4 KB9053b6116252upstream ↗
# Third-Party Components Repository-authored code is MIT. Third-party components retain their own terms. ## Bundled **Roblox Animations Importer/Exporter 2.6.3** by Cautioned is bundled unmodified as `assets/blender-addons/rbx_anims_v2.6.3.zip` under GPL-3.0-or-later. Its license is included at `assets/blender-addons/Cautioned-Blender-Animations-Plugin-LICENSE.txt`. - Source: <https://github.com/Cautioned/Blender-Animations-Plugin> - Release: <https://github.com/Cautioned/Blender-Animations-Plugin/releases/tag/v2.6.3> - Release commit: `6ed4dca` (GitHub verified signature) - Release SHA-256: `218b5e43e414fe3fa5d8a42cc5fd162b70e66f4cb82efd73ac5006b63895769a` The extension includes optional Roblox OAuth/asset networking and a localhost live-sync server. The skill keeps those features off during ordinary authoring. ## Mandatory source-pinned external rig **R6 IK + FK Blender Rig V2.22** and its matching Studio model are downloaded from Aeresei's original Roblox DevForum attachments by `fetch_assets.py`. - Source: <https://devforum.roblox.com/t/r6-ik-fk-blender-rig-v222/3586405> - Blender SHA-256: `ff75c44b572d32328b62095141c6ce6255c4e9b772ee63d46d92280de1edcdc8` - Studio RBXM SHA-256: `a85e1ce13b6cb15c2094be8afcf6bd66ab2017e10f42be6ecb95faa20dd93444` The author shares the files for use but does not state a redistribution license. The public Git repository does not mirror the source `.blend` or `.rbxm`; the installer downloads them from the original attachments and verifies them locally. The README preview is a rendered documentation example made with this source-pinned rig. It does not contain the editable rig or Studio model. The original rig remains subject to its author's terms. ## External integrations - Blender — GPL-3.0-or-later; <https://www.blender.org/> - Blender MCP — MIT; <https://github.com/ahujasid/blender-mcp> - Pillow — HPND; <https://pypi.org/project/pillow/> - Blender Animations (ultimate edition) Studio plugin — Roblox Creator Store terms; <https://create.roblox.com/store/asset/16708835782/Blender-Animations-ultimate-edition> - Roblox Studio MCP — Roblox platform terms; <https://create.roblox.com/docs/studio/mcp> - Roblox official project files — Roblox platform terms; <https://create.roblox.com/docs/art/modeling/project-files> Tutorial mirrors, copied textures, alternate faces, and Lazy Viewport are not redistributed or used by the skill.README.mdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/dillydog580-animate-roblox-characters-animate-roblox-characters.md