Files
animation/plans/iclone-api-bridge-plan-2026-07-21.md
T

192 lines
12 KiB
Markdown
Raw Normal View History

# Plan: iClone 8 Python API bridge — control iClone from the terminal
**Status:** ready for implementation · **Author:** Fable 5 session 2026-07-21 · **Implementer:** GLM session
## 0. Context (you have no other context — read this fully)
You are on Jeremy's **Windows 11 PC**. This repo (`C:\Users\Jeremy\tinqs\animation`) is the
animation bridge for the ariki-game project: animations are authored in iClone 8, exported
as FBX into `exchange/`, and retargeted with `tools/cc_retarget.py`. Today that pipeline is
entirely manual on the iClone side (Jeremy clicks Export in the UI).
**Goal:** establish a programmatic connection to iClone 8 so terminal agents (Claude, GLM,
scripts) can drive it — load motions, query the scene, trigger FBX exports — without Jeremy
clicking through the UI each time.
**Why a bridge is needed:** iClone 8 has an embedded Python API (`RLPy` module) but **no
external/network API of any kind**. Scripts only run *inside* iClone, loaded either from the
Script menu or as auto-loading plugins. The standard pattern (used by MotionLIVE etc.) is a
plugin that runs a localhost socket server inside iClone and executes commands sent to it.
That is what you will build.
### Verified environment facts (checked 2026-07-21 — trust these)
- iClone 8 install: `A:\Program Files (x86)\iClone 8\` (note: **A: drive**, not C:).
- Plugin auto-load folder: `A:\Program Files (x86)\iClone 8\Bin64\OpenPlugin\`
**verified writable without elevation**. Currently contains only stock plugins
(AIStudio, MotionLIVE, VideoMocap). Do not touch those.
- API stub for reference: `A:\Program Files (x86)\iClone 8\Bin64\RLPy.py` — this file
lists every RLPy class/method with docstrings. **It is your API ground truth**; grep it
before using any RLPy call. Do not trust API names from memory or old forum posts.
- Embedded Python: `Bin64` contains BOTH `python38.dll` and `python310.dll` — do not
assume which one hosts plugins. Write the plugin in **3.8-compatible syntax** (no
`match`, no `X | Y` unions, no 3.9+ stdlib) and report the real version via the bridge
itself (`sys.version`) in your results.
- Qt: iClone embeds Qt 5.15 / PySide2 (`shiboken2` available in-process).
- Client-side Python: 3.12.10 on PATH (`python`). Client must be **stdlib-only**.
- iClone was RUNNING when this plan was written. A restart is required to load the new
plugin — **Jeremy must do the restart himself** (he may have unsaved work). Never kill
the iClone process.
### Reference docs (fetch only if stuck; the RLPy.py stub usually suffices)
- Plugin structure: https://wiki.reallusion.com/IC_Python_API:Your_First_iClone_Python_Plugin
- IC8 API wiki: https://wiki.reallusion.com/IC8_Python_API
- Official samples (QTimer/menu/PySide2 patterns): https://github.com/reallusion/iClone
## 1. Architecture
```
┌─ this repo ──────────────────┐ ┌─ inside iClone 8 (Qt main thread) ─────────┐
│ tools/iclone_bridge.py │ TCP │ OpenPlugin\TinqsBridge\main.py │
│ (client, py3.12, stdlib) │◄──────►│ socket thread: accept + read/write ONLY │
│ │ 127.0. │ queue → QTimer(50ms, main thread) drains, │
│ tools/iclone_bridge/ │ 0.1: │ exec()s code with RLPy in namespace, │
│ TinqsBridge/main.py (source)│ 18800 │ posts JSON reply back to socket thread │
│ tools/install_iclone_bridge │ │ │
│ .ps1 (copies to OpenPlugin) │ └────────────────────────────────────────────┘
└──────────────────────────────┘
```
**Non-negotiable threading rule:** RLPy is not thread-safe and must only be called on
iClone's Qt main thread. The socket thread NEVER touches RLPy. It pushes requests onto a
`queue.Queue`; a PySide2 `QTimer` created in `initialize_plugin()` (which runs on the main
thread) fires every ~50 ms, drains the queue, executes, and hands the response back (a
second queue or per-request `threading.Event` — your choice).
## 2. Wire protocol (keep it this simple)
Newline-delimited JSON over TCP, localhost only, one request/response pair at a time.
- Request: `{"id": 1, "code": "<python source>"}`
- Success: `{"id": 1, "ok": true, "result": <json>, "stdout": "<captured prints>"}`
- Failure: `{"id": 1, "ok": false, "error": "<full traceback>", "stdout": "..."}`
Execution semantics:
- `exec(code, ns, ns)` — one **persistent** namespace dict `ns` shared across all requests
for the life of the iClone session, pre-seeded with `RLPy` imported. Same dict as globals
AND locals (avoids the exec-scoping trap where nested functions can't see top-level names).
- If the code sets a variable named `result`, that is the response `result`
(JSON-serialize; on `TypeError` fall back to `repr()`). Otherwise `result` is null.
Clear `ns["result"]` before each exec so stale values don't leak.
- Capture stdout/stderr during exec (`contextlib.redirect_stdout/stderr`) into `stdout`.
- Any exception → `ok:false` with `traceback.format_exc()`; the bridge itself must survive
and serve the next request.
## 3. Deliverables
1. **`tools/iclone_bridge/TinqsBridge/main.py`** — the plugin (source of truth lives in the
repo; the installer copies it out). Requirements:
- `initialize_plugin()` (mandatory — iClone refuses to load the plugin without it).
Wrap its entire body in try/except that writes `traceback.format_exc()` to the log
file — plugin load failures are otherwise near-silent.
- TCP server on `127.0.0.1:18800`, `SO_REUSEADDR`, port overridable via env var
`TINQS_ICLONE_BRIDGE_PORT` read inside iClone. If the bind fails, log it clearly and
return without crashing the plugin loader.
- Socket-accept loop on a `daemon=True` thread; QTimer executor per §1. Keep
module-level references to the timer and thread (a GC'd QTimer silently stops).
- Log to `%TEMP%\tinqs_iclone_bridge.log` (append, timestamped lines: startup, bind
result, each request id + ok/error, shutdown).
- Optional nice-to-have, skip if it costs you more than ~20 min: a **Plugins ▸ Tinqs
Bridge** menu entry showing status (port, requests served) via `RLPy.RUi.AddMenu` +
`shiboken2.wrapInstance` (see the GitHub samples). The log file is the required
status surface; the menu is garnish.
2. **`tools/iclone_bridge.py`** — client, Python 3.12, stdlib only. API:
`run(code, port=18800, timeout=30.0) -> dict` and `ping(port, timeout) -> dict`.
CLI (match the repo's argparse style):
```
python tools/iclone_bridge.py --ping
python tools/iclone_bridge.py --exec "result = 1 + 1"
python tools/iclone_bridge.py --file some_script.py
python tools/iclone_bridge.py --port 18801 --timeout 120 --exec "..."
```
`--ping` sends a snippet returning `{"product": ..., "version": ..., "python": sys.version}`
— find the real product/version getters by grepping `RLPy.py` (look at `RApplication`
and `RGlobal`; do not guess). Exit 0 on ok:true, 1 on ok:false or connection failure,
with a readable message either way (mention "is iClone running? was it restarted after
install?" on connection-refused).
3. **`tools/install_iclone_bridge.ps1`** — copies `tools/iclone_bridge/TinqsBridge/` →
`A:\Program Files (x86)\iClone 8\Bin64\OpenPlugin\TinqsBridge\` (overwrite), prints
what it did and reminds that iClone must be restarted. Idempotent.
4. **`docs/iclone-bridge.md`** — short usage doc: what it is, install/update procedure,
CLI examples, the `result` convention, the persistent-namespace behavior, the log file
location, the threading rule for anyone extending the plugin, and the "long-running
code freezes the iClone UI" warning.
5. **Results report `plans/iclone-api-bridge-results-2026-07-21.md`**: actual embedded
Python version discovered, RLPy calls used for ping, smoke-test transcript (§5), any
deviations from this plan with reasons, and known limitations.
## 4. Implementation order
1. Grep `A:\Program Files (x86)\iClone 8\Bin64\RLPy.py` for the calls you need
(`RApplication`, `RGlobal`, `RScene`, `RFileIO`) and note exact signatures.
2. Write plugin + client + installer.
3. Run the installer. Verify the files landed in OpenPlugin.
4. **Stop and ask Jeremy to save his iClone work and restart iClone.** Do not proceed to
smoke tests until he confirms. Never kill or restart the process yourself.
5. Run smoke tests (§5). If the plugin didn't load: check the log file first, then
iClone's **Script ▸ Console Log** window (ask Jeremy to read it out if needed).
6. Write docs + results report.
## 5. Acceptance criteria (all must pass, transcript goes in the results report)
1. `python tools/iclone_bridge.py --ping` → ok, prints product name, iClone version, and
embedded Python version.
2. Scene query: `--exec` a snippet listing scene object names via `RScene` (e.g. avatars +
props; exact API from the stub) returns a JSON list without error.
3. Namespace persistence: `--exec "x = 41"` then `--exec "result = x + 1"``42`.
4. Error resilience: `--exec "1/0"``ok:false` with a ZeroDivisionError traceback, AND
a follow-up `--ping` still succeeds.
5. stdout capture: `--exec "print('hi'); result = True"``stdout` contains `hi`.
6. Bridge survives ≥ 2 sequential client connections (each CLI call is a new connection).
## 6. Guardrails — do NOT
- Do not kill, restart, or send input to the running iClone process. Restarts are
Jeremy's job (step 4.4).
- Do not modify anything in `OpenPlugin\` other than creating/updating `TinqsBridge\`.
- Do not modify existing repo tools (`cc_retarget.py`, `loop_qc.py`, `loop_fix.py`,
`dance_profile.py`, etc.), anything in `.claude/`, `docs/` (except the new
`docs/iclone-bridge.md`), or `exchange/`.
- Do not bind to anything other than `127.0.0.1`. This is an arbitrary-code-execution
endpoint by design; localhost-only is the security model.
- Do not install any packages — plugin uses iClone's embedded stdlib + PySide2; client
uses Python 3.12 stdlib.
- Do not commit or push. Leave everything in the working tree for Jeremy's review.
## 7. Known traps
- **RLPy off the main thread** crashes or corrupts iClone. The QTimer pattern in §1 is
mandatory; the socket thread only moves bytes.
- **QTimer lifetime:** keep a module-level reference; also create it *in*
`initialize_plugin()` (main thread), never in the socket thread.
- `initialize_plugin()` missing, misnamed, or raising → plugin silently fails to load.
Hence the try/except-to-logfile around everything.
- **exec scoping:** pass the same dict as globals and locals, or nested
functions/comprehensions in user code will throw NameError on top-level names.
- Commands run synchronously on the Qt main thread → **iClone's UI freezes for the
duration**. Fine for seconds-long operations (an FBX export blocks anyway); document it.
- JSON can't serialize RLPy objects — always try/except the dumps and fall back to repr.
- Stale port from a previous iClone instance: `SO_REUSEADDR` + a clear log line beats a
cryptic bind exception.
- Windows Firewall shouldn't prompt for a 127.0.0.1 bind, but if a prompt appears Jeremy
should allow it (note in docs).
- Socket recv: requests may arrive fragmented — read until `\n`, decode UTF-8.
- Both python38.dll and python310.dll exist in Bin64 — write for 3.8, verify via ping.
- Old wiki pages mix iClone 7 and 8 APIs; several IC7 calls were renamed/removed. The
local `RLPy.py` stub outranks every web source.
- After the bridge is up once, you can iterate on *executor* behavior by exec-ing new
code through the bridge itself (hot-swap) — but changes to `main.py` on disk still need
an iClone restart to load. Batch your plugin edits to minimize restart requests.