Skip to content

Houdini HDA Integration

Protect your Houdini Digital Assets with per-machine license checking baked directly into the HDA. Users activate on first load, and the license state persists across sessions via a cached JWT token.


Overview

The HDA is self-contained. The license client lives inside the asset alongside your tool logic. End users receive a single .hdalc file and a Houdini package JSON.

Why self-contained? Keeps distribution simple. No separate installer, no separate Python package install required. Everything the license system needs travels with the asset.


Architecture Flow

HDA OnCreated / OnLoaded
        |
        v
  [ cg_license client ]
        |
        +---> Cached JWT exists?
        |           |
        |          YES --> verify signature locally
        |           |           |
        |           |         valid --> load tool normally
        |           |           |
        |           |        expired --> grace period check
        |           |                       |
        |           |                    within grace --> load with warning dialog
        |           |                       |
        |           |                    past grace --> block, show reactivation dialog
        |           |
        |          NO
        |           |
        +---> Online? --> YES --> show activation dialog
                    |
                   NO --> block (no cached token, no network)

Distribution Structure

your_tool_package/
├── otls/
│   └── your_tool.hdalc          # compiled/locked HDA
├── vendor/
│   └── cg_license/
│       ├── __init__.py
│       ├── license.py
│       ├── api.py
│       ├── exceptions.py
│       ├── fingerprint.py
│       └── storage.py
└── packages/
    └── your_tool.json           # Houdini package file

Houdini Package File

packages/your_tool.json
{
  "path": "$HOUDINI_PACKAGE_PATH",
  "load_package_once": true,
  "env": [
    {
      "YOUR_TOOL_ROOT": "$HOUDINI_PACKAGE_DIR/.."
    }
  ],
  "hpath": "$YOUR_TOOL_ROOT/otls",
  "path": "$YOUR_TOOL_ROOT/vendor"
}

Place this file in one of:

  • ~/houdini20.5/packages/your_tool.json (user install)
  • $HFS/packages/your_tool.json (system install)

HDA Configuration

Open the HDA in the Type Properties editor and set up these event handlers under the Scripts tab:

Event Handler function
OnCreated hou_event_callbacks.onCreated(kwargs)
OnLoaded hou_event_callbacks.onLoaded(kwargs)
OnDeleted hou_event_callbacks.onDeleted(kwargs)

All logic lives in the PythonModule section.


PythonModule Code

"""
PythonModule for your_tool HDA.
Handles license checking, activation, and deactivation.
"""

import sys
import os
import hou

# ---------------------------------------------------------------------------
# Config (set these at HDA build time)
# ---------------------------------------------------------------------------
PRODUCT_ID   = "prod_abc123"
PRODUCT_SALT = "your-product-salt"
SERVER_URL   = "https://your-domain.com"

# ---------------------------------------------------------------------------
# Vendor path setup
# The vendor folder ships alongside the otls/ dir.
# ---------------------------------------------------------------------------
def _get_vendor_path():
    # Try package env var first
    root = os.environ.get("YOUR_TOOL_ROOT")
    if root:
        return os.path.join(root, "vendor")
    # Fallback: locate via HDA definition path
    defn = hou.nodeType(hou.nodeTypeCategories()["Sop"], "your_tool")
    if defn:
        hda_path = defn.definition().libraryFilePath()
        return os.path.normpath(os.path.join(os.path.dirname(hda_path), "..", "vendor"))
    return None

_vendor = _get_vendor_path()
if _vendor and _vendor not in sys.path:
    sys.path.insert(0, _vendor)

try:
    from cg_license import LicenseClient
    from cg_license.exceptions import (
        LicenseNotFoundError,
        LicenseRevokedError,
        LicenseExpiredError,
        MachineLimitError,
        NetworkError,
        InvalidTokenError,
    )
    _sdk_available = True
except ImportError as e:
    _sdk_available = False
    _sdk_import_error = str(e)

# ---------------------------------------------------------------------------
# License client singleton
# ---------------------------------------------------------------------------
_client = None

def _get_client():
    global _client
    if _client is None:
        _client = LicenseClient(
            server_url=SERVER_URL,
            product_id=PRODUCT_ID,
            product_salt=PRODUCT_SALT,
        )
    return _client

# ---------------------------------------------------------------------------
# Core license functions
# ---------------------------------------------------------------------------
def check_license(node):
    """
    Returns True if the license is valid (online or cached).
    Raises on hard failures. Call this from OnCreated/OnLoaded.
    """
    if not _sdk_available:
        hou.ui.displayMessage(
            f"License SDK failed to load:\n{_sdk_import_error}\n\n"
            "Ensure the vendor/ folder is present alongside the HDA.",
            title="License Error",
            severity=hou.severityType.Error,
        )
        return False

    client = _get_client()

    if client.check_license():
        # check_license() returns True even for degraded status
        error = client.get_error()
        if error and error.code == "degraded":
            hou.ui.displayMessage(
                error.message,
                title="License Warning",
                severity=hou.severityType.Warning,
            )
        return True

    # License check failed, inspect the error
    error = client.get_error()
    if error is None:
        return _show_activation_dialog(node)

    if error.code == "not_activated" or error.code == "invalid_token":
        return _show_activation_dialog(node)
    elif error.code == "revoked":
        hou.ui.displayMessage(
            "Your license has been revoked.\nPlease contact support.",
            title="License Revoked",
            severity=hou.severityType.Error,
        )
    elif error.code == "expired":
        hou.ui.displayMessage(
            "Your license has expired.\nPlease renew at cglounge.com",
            title="License Expired",
            severity=hou.severityType.Error,
        )
    elif error.code == "suspended":
        hou.ui.displayMessage(
            error.message or "License suspended. Contact support.",
            title="License Suspended",
            severity=hou.severityType.Error,
        )
    elif error.code == "network":
        hou.ui.displayMessage(
            "Cannot reach the license server and no cached license found.\n"
            "Please connect to the internet to activate.",
            title="Network Error",
            severity=hou.severityType.Error,
        )
    else:
        hou.ui.displayMessage(
            f"License error: {error.message}",
            title="License Error",
            severity=hou.severityType.Error,
        )
    return False

def _show_activation_dialog(node):
    """Prompt for license key and attempt activation."""
    key = hou.ui.readInput(
        "Enter your license key from cglounge.com/library:",
        title="Activate License",
    )
    if key is None or not key[1].strip():
        return False
    return activate(node, key[1].strip())

def activate(node, key: str) -> bool:
    """Activate a license key on this machine."""
    client = _get_client()
    try:
        info = client.activate(key)
        hou.ui.displayMessage(
            f"License activated!\n"
            f"Machines: {info.get('machinesUsed')}/{info.get('maxMachines')}",
            title="Activated",
        )
        return True
    except LicenseNotFoundError:
        hou.ui.displayMessage("Invalid license key.", title="Activation Failed",
                               severity=hou.severityType.Error)
        return False
    except MachineLimitError as e:
        hou.ui.displayMessage(
            f"Machine limit reached.\n"
            "Deactivate a machine at cglounge.com/library first.",
            title="Activation Failed",
            severity=hou.severityType.Error,
        )
        return False

def deactivate(node):
    """Deactivate this machine and clear cached token."""
    client = _get_client()
    try:
        client.deactivate()
        hou.ui.displayMessage("License deactivated on this machine.", title="Deactivated")
    except NetworkError:
        hou.ui.displayMessage(
            "Cannot reach server. Deactivation requires an internet connection.",
            title="Deactivation Failed",
            severity=hou.severityType.Warning,
        )

def require_license(node):
    """
    Hard gate: delete the node if license is invalid.
    Use this if you want zero tolerance (no grace period loading).
    """
    if not check_license(node):
        hou.ui.displayMessage(
            "Node will be deleted: no valid license.",
            title="License Required",
            severity=hou.severityType.Error,
        )
        node.destroy()

# ---------------------------------------------------------------------------
# HDA event callbacks (wire these in the Scripts tab)
# ---------------------------------------------------------------------------
def onCreated(kwargs):
    node = kwargs["node"]
    check_license(node)

def onLoaded(kwargs):
    node = kwargs["node"]
    check_license(node)

def onDeleted(kwargs):
    # Optional: deactivate on node deletion.
    # Only do this if your license model expects it.
    pass

# ---------------------------------------------------------------------------
# Optional: expose deactivation via node menu button
# Add a Button parameter named "deactivate_license" and call:
#   hou_event_callbacks.on_deactivate_pressed(kwargs)
# ---------------------------------------------------------------------------
def on_deactivate_pressed(kwargs):
    node = kwargs["node"]
    deactivate(node)

Black Box Locking

Black-boxing compiles your HDA's node network so users cannot inspect or extract the internal logic.

How to compile:

  1. Right-click the HDA in the network editor.
  2. Select Type Properties.
  3. Under Options, enable Save Operator Type as Black Box.
  4. Save and accept.

Alternatively, via hscript:

otblockasset your_tool::1.0

Protection Levels

Level What it does How to apply
Black box Hides internal node network Type Properties > Options
.hdalc format Compiled binary, not plain text Save as .hdalc (not .hda)
Code obfuscation Makes PythonModule harder to read Use a Python obfuscator at build time
Signed distribution Detects file tampering Sign with your private key (custom pipeline)

Security Considerations

What is protected

Attack Protected? Notes
Casual inspection of node network Yes Black box hides it
Extraction of PythonModule code Partial .hdalc is binary but not encrypted
Bypassing the license check by editing the HDA No Determined attacker can unpack and patch
Using the tool offline after grace period Yes JWT expires, server re-validation required
Sharing a license key between machines Yes Machine limit enforced server-side
Sharing a cached JWT between machines Yes JWT is bound to machine fingerprint
Cracking the PRODUCT_SALT from the binary Possible with effort Salt is embedded; use build-time injection

Realistic threat model

Most users are not reverse engineers. The goal is:

  1. Make casual copying non-trivial (black box + compiled HDA).
  2. Make license sharing impossible (server-side machine limits).
  3. Make offline abuse time-limited (JWT expiry + grace period).
  4. Accept that a determined attacker with enough time can bypass code-level protection.

The license server enforces the hard rules. The HDA code is a UX layer, not a cryptographic vault.


User Workflow

First-time activation

  1. User drops the HDA into their scene.
  2. OnCreated fires, check_license() runs.
  3. No cached token found: activation dialog appears.
  4. User pastes their key from cglounge.com/library.
  5. Server validates, JWT cached to disk, tool loads.

Subsequent uses (same machine)

  1. OnLoaded fires.
  2. Cached JWT found and valid: silent pass, tool loads instantly.

Offline use

  1. OnLoaded fires.
  2. Cached JWT found, signature valid, not yet expired: silent pass.
  3. If JWT expired but within 7-day grace: loads with a warning dialog.
  4. If past grace: warning dialog, loads anyway (you can change to hard-block via require_license).

Machine limit reached

  1. User tries to activate on a new machine.
  2. Server rejects: MachineLimitError.
  3. Dialog explains the limit and links to cglounge.com/library to deactivate an old machine.

Distribution Checklist

  • [ ] PRODUCT_ID, PRODUCT_SALT, SERVER_URL set to production values
  • [ ] HDA saved as .hdalc (binary format)
  • [ ] Black box enabled in Type Properties
  • [ ] vendor/cg_license/ folder included in the distribution zip
  • [ ] packages/your_tool.json included and tested in a clean Houdini install
  • [ ] onCreated and onLoaded wired to the correct callback functions
  • [ ] Tested activation flow on a machine with no cached token
  • [ ] Tested offline flow (disable network, verify grace period behavior)
  • [ ] Deactivation accessible (Button parameter or Help menu shelf tool)
  • [ ] Support email visible in all error dialogs