Skip to content

Porting a DCC to Alchemy

A checklist for adding a new host application — Houdini, Nuke, Cinema 4D, MotionBuilder — to Alchemy. It is written from the 3ds Max port (sprint Max · initial plugin build, Aug 2026), which was the second time the shape had to be reconstructed by reading the Maya, Blender and Unreal legs side by side. It is written down here so there is no third time.

Work it top to bottom. Each phase has a gate — a thing you can run that either passes or does not — and you should not start the next phase until the gate is green. The gates are the point: every phase of this port that was declared done without one came back.

What you are actually building

Five separable things, in dependency order: an id, a setup module (how Alchemy starts the app), a bridge (how Alchemy talks to a running session), a task API (the ops, against a shared contract), and cookbook recipes (the five phases). The artist UI is the sixth and it is almost free once the other five exist.


Phase 0 — Choose the id

Everything keys off one short string. Get it wrong and you will rename it in nine places.

  • It must be a legal Python module name. The whole system imports by id: alc.plugins.{id}, alc.plugins.{id}.alchemy, cookbook.{id}.{phase}.{task}, and the {dcc}.{verb}.{task}.{op} etymology address. An identifier cannot start with a digit or contain a hyphen, so alc.plugins.3dsmax and alc.plugins.3ds-max are both syntax errors — which is why 3ds Max is max.
  • Prefer the name the rest of the industry uses (OpenPype/Ayon host names are a good tiebreak). max, maya, blender, unreal, houdini, nuke.
  • Check it does not shadow a builtin. max does. The footgun is real and documented at the top of alc/plugins/max/__init__.py: never write from alc.plugins import max in a module that also calls max(). Use import alc.plugins.max as max_plugin, or import Scene directly.

Phase 1 — The registries that gate dispatch

Nothing in Alchemy discovers a DCC. Every one of these is an explicit entry, and a missing one fails somewhere far from the omission. Do them all at once.

# Where Symbol What to add
1 alc/plugins/smart_task/executor.py _DCC_PROBE "{id}": "{probe module}" — the module that only imports inside the DCC (pymxs, bpy, maya.cmds, unreal, substance_painter). None means "always in-process, never a bridge" (alchemy, comfy, premiere, after_effects).
2 alc/plugins/smart_task/executor.py _client_for() elif software == "{id}": importing alc.services.ai.{id}_bridge.client.get_client. Skip only if the probe is None.
3 {cookbook}/config_files/software.json software dict + active_tools The Software entry — label, category, alchemy_plugin: true, ext, source_extensions, icon_path. Also add the id to active_tools or it is registered but inactive.
4 {cookbook}/registry.json top-level {id} block {task: {category: {recipe_type: relative_path}}}. Written for you by Recipe.update_registry() when you save a recipe from the cookbook IDE, but check it — CFG.get_software_cookbooks() requires both this and a {cookbook}/{id}/ folder on disk.
5 app/static/icons/software/{id}.png (+ svg/{id}.svg) — There is no ext→glyph dict in Python. software_icon() (app/template_helpers.py) resolves entirely through software.json: ext_index() → software_for_ext() → Software.best_icon_url, which defaults to icons/software/{id}.png / icons/software/svg/{id}.svg when icon_path/icon_svg are blank. SVG wins over PNG if both exist.
6 alc/plugins/blender/tasks/tests/test_parity.py _DCCS Add the id. See the gate in Phase 4.
7 alc/cookbook/constants.py SOFTWARE The canonical id list. Nothing imports it today, but it is the list a human reads.

Registries that are easy to miss

These are not required for the spine to run, and each is a real gap if skipped:

  • alc/plugins/alchemy/tools/hdri_picker/library.py — EXT_PREFERENCE_BY_SOFTWARE (which HDRI format this DCC prefers). Falls back sanely.
  • alc/plugins/smart_task/common.py — ACTIVITY_SOURCES (autosave/backup discovery per DCC). No entry = this DCC's crash recovery files are invisible. Max has no entry.
  • alc/models/feature.py — _SOFTWARE_BASELINE, the fallback token set for ticket/area coordinate slugs. Only used when SoftwareRegistry.load() throws, but that is exactly when you want it right. Max has no entry.
  • {cookbook}/config_files/util.yaml — ext_map, read by get_file_type() (alc/path/path.py and alc/file/core.py). Without an entry, every copy of a scene file logs ERROR: No file type found for .{ext} — which is how the missing .max entry surfaced during the #3858 spine run. Note the FileType enum in alc/file/core.py is a separate list and .blend/.max are deliberately not members: an ext_map value with no enum member classifies as FileType.OTHER, which is the established convention for DCC scenes.
  • {cookbook}/config_files/production_tasks.json — add the id to the software list of every task this DCC can perform (mdl's list gained "max").
  • {cookbook}/default_files/{task}/default.{ext} — the seed scene a first version is created from. PathObject.start_version() raises by name if the task has no versions and no default file, which is the correct behaviour; you still have to author the file.

Things that are not registries

Do not go looking for a table for these — they are convention, and they work the moment the module exists at the conventional path:

  • get_task(software, task) (alc/plugins/smart_task/registry.py) imports alc.plugins.{id}.tasks.{task} and finds the lone SmartTask subclass in it.
  • SmartTask.scene imports alc.plugins.{id}.alchemy.Scene.
  • Recipe.resolve() is fully data-driven; no DCC list.
  • Launchers: per-DCC thin wrappers in alc/plugins/{id}/tools/launchers.py. No central table.
  • Bridge ports: there is no central port registry. Each bridge declares its own constant and the "port family" exists only as a repeated comment. Pick the next free one and update the comments. Current family: Blender 9876, Maya 20240, Substance 20260, Premiere 20261, After Effects 20262, 3ds Max 20263.

Gate: CFG.software_registry.software["{id}"] resolves, CFG.get_software_cookbooks() includes the id, and _client_for("{id}") returns your client class. Pin that last one in a test — tests/test_max_bridge_client.py::test_client_for_max_resolves is the template.


Phase 2 — The setup module

alc/plugins/{id}/setup/__init__.py holds class {Name}(LocalSoftwareVersion) (alc/config/software.py). This is the single place that knows where the application lives on disk and how to start it. Nothing downstream may hardcode a version or a path.

The surface you must implement

Method Answers
get_executable_path() The GUI application binary.
get_python_path() The DCC's bundled interpreter — the ABI your wheels must match.
get_batch_exe() Add only if the DCC ships a separate headless script runner. 3dsmaxbatch.exe — Max is the only leg with one today; Blender passes --background to the same binary and Maya shells mayapy.
get_render_exe() Add only if the DCC ships a separate CLI renderer. 3dsmaxcmd.exe; Maya has one too. This is what the farm shells.
get_local_versions() Every install found on disk, oldest first. Filesystem, never a DCC API.
get_startup_scripts_folder() The per-user dir the DCC auto-runs scripts from at launch.
detect() A classmethod returning an instance for the newest install. The single place a version is chosen.
setup() Module-level function calling {Name}.detect().setup(). The inherited LocalSoftwareVersion.setup() → install_requirements() pips setup/requirements.txt into the version-scoped bundle, then deploys userSetup.py.

Plus the module-level entry points every DCC exposes with identical signatures, which is what lets the router stay DCC-agnostic:

def run_recipe(source_file, recipe_path) -> bool: ...
def render_scene(source_file, *, start, end, camera=None, output_dir=None,
                 image_prefix=None, renderer="arnold", width=None, height=None,
                 image_format="exr") -> bool: ...
def setup() -> None: ...

render_scene must stay module-level and picklable by name — deadline_submit ships {"module": func.__module__, "function": func.__name__} and the farm worker re-imports it, so a bound method or closure does not survive the trip. It must return a bool: worker_entry fails the Deadline task on False and treats None as success.

The version-derivation rule

Every path is formatted from self.version. Maya's setup carries the scar: a version-derived site-packages path was silently skipped because the version was not ready when it was read. Derive the version from the filesystem (detect() reads install dirs) or, in the in-session half, from the deployed file's own location — never from a DCC API at import time.

Version-scope the site-packages bundle (<cookbook_root>/../plugins/{id}{version}/site-packages). A cp313 wheel set is not loadable by a DCC on 3.11.

python -m alc.plugins.{id}.setup should print resolved paths for every installed version side by side. Seeing two versions resolve to different exes, different bundled Pythons and different bundles is the check that the resolution is genuinely version-derived rather than hardcoded.

The startup script

alc/plugins/{id}/setup/userSetup.py is deployed by copy_user_python() into get_startup_scripts_folder(). It puts the version-scoped site-packages, the cookbook and the Alchemy repo on the DCC's sys.path, starts the bridge, and builds the Alchemy menu — in that order.

Guard every step individually. This file runs on every launch of the application on the machine, including artist sessions that have nothing to do with Alchemy. A failure in any one step (no cookbook mounted, bridge port already bound, menu recipe malformed) must not block the steps after it and must never stop the DCC from starting. One try/except per step, not one wrapper around the lot — and write a log file beside the script, because a listener nobody read is not a record of a silent skip.

Derive the version from the deployed file's own location where the path encodes it, not from the DCC's API at startup-import time.

requires_display

Recipes declare per-step "requires_display": true. {Name}.requires_display(recipe_path) reads the recipe JSON and returns True if any step wants a viewport; that one flag turns the whole run into a GUI launch. Every DCC has ops that only compute with a real UI — this is the normal shape, not a defeat.

Gate: python -m alc.plugins.{id}.setup lists every install with all executables present, and setup() puts alc on the DCC's sys.path such that import alc succeeds in a cold session.


Phase 3 — The bridge

alc/services/ai/{id}_bridge/ — max_plugin.py (runs inside the DCC), client.py (runs in the app venv), verify.py (the harness).

The contract, byte for byte

  • Length-prefixed both ways. recv [uint32 big-endian][utf-8 code] where the snippet assigns result; send [uint32 big-endian][utf-8 json] of {"ok", "result"|"error"}. Identical across the Substance, Max, Maya and Blender bridges — copy it, do not redesign it.
  • Marshal onto the DCC's main thread. The DCC API is not thread safe. A background thread does the blocking accept/recv and hands the snippet to a drain() method that a main-thread pump (a QTimer owned by the main window in a Qt host, the DCC's own idle/executeDeferred hook otherwise) calls. The socket thread blocks on a reply queue until the exec returns. Keep the socket half plain-stdlib so the wire protocol is testable without the DCC.
  • A colliding bind must fail, loudly. On Windows, SO_REUSEADDR does not mean what it means on POSIX: it lets a second live listener bind an address another socket is already listening on, and the winner of subsequent connections is unspecified. Every DCC on the machine runs the Alchemy startup script, so a headless recipe session or a verifier session would silently shadow the artist's. Ask for SO_EXCLUSIVEADDRUSE instead, and let the startup script log a skip when the bind raises.
  • A session Alchemy spawns never takes the well-known port. The launcher sets ALC_{ID}_BRIDGE_PORT — 0 ("ephemeral, nobody will dial me") for recipe runs, a free port for the verifier. The fixed port belongs to the artist. free_port() (bind to port 0, read the assignment back) is the whole implementation.

Verifying a bridge before you trust it

A bridge answering on its socket is not the same claim as a bridge that works. Write a verify.py that launches its own session on a free port, waits for the bridge the deployed startup script starts, drives real contract ops through the real trampoline, and stops the session it started. Exit 0 only if every check passed.

It must carry simulations — --simulate not-running, --simulate no-startup-script, --simulate op-error — because a harness nobody has watched fail is not evidence. Run one after touching the bridge; if it still goes green when you break it, the green run means nothing either.

The standing rule that falls out of the failures this has already found: assert on scene state read back over a second, independent call — never on the return value of the mutating call. An op can return {"ok": true} while the scene is unchanged because an implicit undo rolled it back out (first seen on the Blender bridge).

Gate: venv\Scripts\python.exe -m alc.services.ai.{id}_bridge.verify exits 0, and each --simulate flag makes it exit non-zero with a message that names the cause.


Phase 4 — The task API, contract-first

Ops live at alc/plugins/{id}/tasks/{task}/{create,edit,query}.py as plain functions — never classes, never methods. Each op is name-in / dict-out: objects are addressed by string name (never a live DCC handle, which cannot cross the bridge) and every op returns a JSON-serialisable dict.

alc/plugins/{id}/tasks/{task}/__init__.py defines exactly one class Task(SmartTask) declaring only software and task (plus msd_data_model if the task carries a publish payload). It must stay DCC-API-free so the app venv can construct the task and validate its MSD.

The contracts are ABCs in alc/plugins/smart_task/{task}.py — common, mdl, cam, tex, ldv, lay, prv, mtn, lib, rig, anm. Read the contract before writing a single op. That is what "contract-first" means here, and it is why the Max ops landed with parity on the first pass while earlier ports needed a reconciliation sweep.

The parity test is the gate — know exactly what it checks

alc/plugins/blender/tasks/tests/test_parity.py (it lives under blender/, but it is the cross-DCC test; _parity_util.py is beside it).

  • Cases are discovered from disk: for each dcc in _DCCS × task × verb, a case is emitted only if alc/plugins/{dcc}/tasks/{task}/{verb}.py exists. So adding your id to _DCCS cannot fail the suite by itself — an unimplemented DCC contributes zero cases. Equally, a DCC absent from _DCCS gets zero enforcement no matter how complete its tasks/ tree is. _DCCS is a pure opt-in gate; add your id on day one so the enforcement grows with the port.
  • Both files are parsed with ast, never imported — the concrete modules import the DCC API at top level and cannot load in the app venv.
  • Gate A, completeness: contract ⊆ concrete. You may add extra DCC-specific ops; you may not omit a contract op.
  • Gate B, signature identity: argument names and order, plus keyword-only names and the literal source text of their defaults (ast.unparse). Type annotations and positional defaults are not compared; 0.5 vs 0.50 is drift.

Gate: pytest alc/plugins/blender/tasks/tests/test_parity.py green with your id in _DCCS, and the per-verb dispatch tests (test_mdl_dispatch.py and siblings) green for your DCC.

The scene gateway

alc/plugins/{id}/alchemy.py — class Scene(SmartScene) with software="{id}", defined there and nowhere else. It resolves a handed-in filepath to this DCC's extension via PathObject.path_at(set_filename=True, ext=".{ext}"), which is what lets Cookbook.run hand it a path built from the task's core software's extension without anything breaking.

Keep it out of the package __init__ and load it lazily through __getattr__, so importing a task module (the trampoline) stays pydantic-only. Copy alc/plugins/max/__init__.py's __getattr__ verbatim.

The legacy root-level verb modules

SmartScene still tries to import alc/plugins/{id}/{create,edit,query}.py and logs WARNING: {id} is missing edit module. three times per phase when they are absent. Max is contract-first and deliberately has none. The warning is noise, not a gap — do not "fix" it by adding empty modules.


Phase 5 — The cookbook recipes: the five phases

{cookbook}/{id}/{phase}/{task}/{task}.json plus one sibling .py per step. Module path cookbook.{id}.{phase}.{task}.{StepName}. The five phases are start → build → render → review → publish.

Recipes COMPOSE by delegation — they do not merge

This is the single most important fact in this document and it is not obvious from the code.

Recipe.resolve(software, phase, task) walks {dcc}/{phase}/{task} → {dcc}/{phase}/default → alchemy/{phase}/{task} → alchemy/{phase}/default and returns the first match. It does not merge.

So the mere existence of {id}/start/default shadows alchemy/start/default outright — and a task in your DCC would silently skip every status change, folder creation and default-file copy that every other task in the studio gets. Not a duplication problem: a regression, and a silent one.

The fix is a delegating step. {id}/start/default carries a RunDefaultStartRecipe step whose whole body is:

run_preflight(dcc="alchemy", task_phase="start", task="default", path_object=po)

alchemy/start/bid and alchemy/start/sch already use this idiom. Your DCC's start recipe should then contain only what needs a live session — open the version's scene, pin the units, pin the frame range, save.

What each phase owns

  • start — CheckSceneSaved (never cost an artist unsaved work), RunDefaultStartRecipe, OpenWorkingFile, VerifySceneUnits, SetFrameRange, SaveWorkingFile. Note that the recipe does not create the version: PathObject.start_version() decides whether to version up from the latest or seed 000.000 from default_files/{task}/, and the caller runs it before the recipe. Recipes configure a coordinate; they do not choose one.
  • build — the task's actual work, ending in the cross-DCC grouping (mdl/hi) and a save. Group primitives differ per DCC (Max: Point helpers, Maya: transform groups, Blender: Empties) and all answer mdl.query.members identically.
  • render — the turntable. Ends in a movie plus a thumbnail through the shared oiio/ffmpeg media service.
  • review — inherit it. There is no {id}/review recipe and there should not be: Recipe.resolve falls through to alchemy/review/default, which is DCC-less. Spawning a whole DCC to run oiio and ffmpeg would be absurd. Create the {cookbook}/{id}/review/ folder with only an __init__.py if you like; leave it empty of recipes.
  • publish — clean the turntable, pivots to origin, delete history, freeze transforms, material groups, save, export every interchange format, then Publish. Publishing replaces publish/latest for the task.

The first-time-into-your-DCC circle

An existing asset moving into your DCC for the first time has no scene file for it, and it cannot make one from inside the DCC. The circle, measured on chr/oswald/mdl going into 3ds Max:

  1. start resolves to {id}/start/default;
  2. whose delegating step would run alchemy/start/default → CopyDefaultFiles → the version's .{ext};
  3. but the DCC Scene gateway refuses to construct until that file already exists, so step 2 is never reached.

Version-up does not close it either: version_up_minor(copy_source_files=True) carries the previous version's files forward, and a task that has only ever been in Blender has no .max to carry.

Cookbook.run therefore seeds path_object.copy_default_files() before the phase loop whenever start is in the pipeline — the same layer that already owns calling start_version() before the recipe runs. It never overwrites an existing working file, so it is a no-op on every later run.

The consequence for your port: author {cookbook}/default_files/{task}/default.{ext} early. Without it the first asset into your DCC cannot start at all, and the failure surfaces as a FileNotFoundError from the Scene gateway naming a file nothing has offered to create.

The artist menu is not affected — its Version Up button can only be pressed from inside a session, which means a scene file already exists to carry forward. Taking an asset into a DCC for the first time is an app-side action by construction.

Farm vs local — know which caller you are

A recipe carries its own deadline flag (alchemy/review/default sets it, so review never blocks an artist), and a run that is already on a Worker (ALC_ON_DEADLINE == "1") never re-submits itself. The canonical expression is SmartScene._run_dccless_recipe: flag = recipe.deadline if deadline is None else bool(deadline) — the cookbook decides unless a caller explicitly overrides.

The two artist-facing callers resolve that differently, on purpose, and you should expect the same split in your DCC:

  • The menu button passes no override, so it honours recipe.deadline. Clicking Review in the DCC submits to the farm and returns immediately — which is the whole reason review carries the flag.
  • Cookbook.run / run_task hard-passes its own deadline argument down to Recipe.run, so with the default deadline=False every phase — review included — runs in-process. That is deliberate: the orchestrator is already executing the arc, and a phase that re-submitted itself would fan out jobs.

So the same review phase produces its media on a Deadline Worker from the menu and in the calling process from run_task. Same recipe, same output, different machine. Do not "fix" one to match the other without reading both comments.

Exercise both. That split is exactly why "run_task passed" does not mean the button works: run_task never touched the farm-submit path, and the submit was broken from inside the DCC (see the PATH row under Measure, don't port). The button looked like it did nothing at all.

Gate: SoftwareCookbook("{id}").run_task(po, ["start","build","render","review","publish"]) returns True against a real asset at a real user coordinate, and the artifacts are on disk where the coordinate says they should be.


Phase 6 — The artist UI

alc/plugins/{id}/custom_menu.py — class Menu(cm.CustomMenu) implementing create_menu(menu), add_button(step), delete_menus(), get_scene_path(). setup/userSetup.py calls build_menu() at startup.

Adding a button is a cookbook edit, not a code change. Every folder under {cookbook}/{id}/menus/{task}/ becomes a section, every step in its .json becomes an entry that imports the step's cookbook module and calls its run() (reloaded per click, so a button edit takes effect without restarting the DCC). Nothing in custom_menu.py should name a button.

The eight buttons the Max menu ships — Build, Create Turntable, Clean Turntable, Render, Review, Publish, Version Up, Refresh — are the shape to copy.

The rules that cost the most to learn

  • Phase buttons run on a worker thread. A recipe spawns a subprocess and blocks for minutes; doing that on the DCC's UI thread freezes the artist's session for the whole build. Save the scene first, on the main thread (the recipe runs against the file on disk, so unsaved work would not be in the build), then hand off. Nothing on the worker may touch the DCC API.
  • Never show a modal dialog from a button. A modal spins its own event loop and blocks the main thread — which also blocks the bridge, so one failed menu click wedges every agent and every verification run against that session. Use a non-modal box with WA_DeleteOnClose, and keep a Python reference to it or it is collected before it is seen.
  • Keep Python references to your handlers. PySide does not keep a plain Python callable alive for a signal connection. A lambda with no other owner is collected and the entry then silently does nothing when clicked. Bind a functools.partial and store it; connect a bound method of a stored QObject, never a closure, for timers.
  • Expect the host to take your menu away, silently. In Max the startup build succeeds — the trace shows the insert landing — and the bar has no Alchemy on it seconds later, with no ActionRemoved event ever delivered. The host repopulates its own bar as it settles. Two things fixed it: parent the menu to the main window, not the bar (the host deletes the bar's QMenu children), and run a cheap keep-alive that re-inserts the action for the life of the session. A bounded "long enough" window measured on one machine is exactly the timing assumption this area punishes.
  • The menu button and the orchestrator must make the same routing decision. A {dcc} recipe goes through setup.run_recipe (which already picks GUI vs batch from requires_display); an alchemy-resolved recipe is never a DCC spawn.

Gate: the menu is on the bar in a cold session; each phase button runs the real recipe and reports PASSED/FAILED; a button edited in the cookbook takes effect without a restart.


Measure, don't port

Every trap in the Max port was a case where another DCC's shape did not transfer. The lesson generalises even though the specifics do not: measure the host, in the host, before you write the step that depends on it. A sentence beginning "Maya does X, so Max presumably…" is a bug report waiting to be filed.

The Max findings, as a worked example of the kind of thing to go and check:

Assumption carried over What was actually measured
Automated mesh ops work headless. Max's ProOptimizer / Retopology only compute while the modifier is current in the modify panel. In a UI-less 3dsmaxbatch they are a silent no-op — the modifier lands on the stack and the mesh comes out unchanged. Hence requires_display.
A headless runner's exit code reports the script's result. 3dsmaxbatch lies. It reports Task Completed Successfully and exit 0 for a script that called sys.exit(7); only an uncaught exception reaches the launcher. The run must write its verdict to a sentinel file the launcher reads.
print() from the script reaches the runner's stdout. It does not — only stderr does, and any stderr write flips 3dsmaxbatch's verdict to "Completed with Error(s)" and a non-zero exit, failing a run whose steps all passed. Tee to a log file and tail it. Its console log is UTF-16LE, where the newline is 0a 00.
The renderer is a CLI flag. 3dsmaxcmd has no renderer flag at all. The renderer is assigned through a one-line -preRenderScript. Its flags are -name:value with a colon; written Maya-style it renders the scene's own settings and reports success — a farm job that passes and delivers the wrong frames.
useEnvironmentMap controls the background. It is the light switch, not a background switch — turning it off to get a clean backdrop also turns off the HDRI lighting, which is what produced silhouette turntables. Max has no way to keep an HDRI lighting the scene while hiding it from camera rays, so the contract's visible_to_camera=False cannot be honoured here at all.
The DCC's menu API is the DCC's menu API. 3ds Max 2025 replaced menuMan with a CUI/.mnx system; on 2027 rt.menuMan is simply gone. The CUI callback cannot be rebuilt in-session and its entries are macroScripts, not callables. Max is a Qt host, so the menu is a QMenu on the real QMenuBar — parented to the main window, not the bar, because Max repopulates the bar by deleting the bar's QMenu children.
Scene-graph helpers exist by the obvious name. rt.getDescendantNodes does not exist. Nor is numsubs the number of sub-materials — it is the count of a node's sub-anims, so a leaf PhysicalMaterial reports 80 (its parameters) where getNumSubMtls reports 0. The right API was there all along; the plausible-looking one was not it.
The host process inherits a normal PATH. 3ds Max replaces PATH in-process with six Autodesk-only entries — neither C:\WINDOWS\System32 nor Deadline's bin survives. It broke two things silently: robocopy (so PathObject.publish() created empty major-version folders and "succeeded") and deadlinecommand (so the menu's Review button submitted nothing, produced no media, and reported only to the Listener). Resolve every external executable absolutely inside a DCC — see alc.cli.cli.robocopy_exe and alc.services.deadline.deadline.deadlinecommand.
An exception the DCC logs but your code catches is harmless. It is not, if the host grades the run on its console. mat[i] on a leaf material made MAXScript log Unknown property: "count" ~80× per mesh; Python caught every one — and the log lines alone flipped 3dsmaxbatch to "Task Completed with Error(s)" and a non-zero exit, so a publish whose eleven steps all passed was reported to the caller as failed. Silence the raise at the source; do not just catch it.
The bundled interpreter has pip. Max's does not (both 2025 and 2027). ensurepip would write into Program Files. Drive pip from Alchemy's own interpreter with --python <dcc python> so it resolves for the DCC's ABI without touching the install.
A .py in the startup folder may need a script-language shim. For Max it does not — a bare .py in the per-user startup dir is auto-run. But that was settled empirically, against the install's own Startup/ folder containing only .ms files, which suggested the opposite.
Popen.wait() returning means the process is gone. A max/render/mdl run passed every step, wrote its sentinel, logged exit 0 — and left a 3dsmax.exe up with the scene open, wedged in shutdown, holding a licence. Reap in a finally, and prove ownership by a token on the survivor's command line (the bootstrapper's temp path), never by image name — the artist's own session has the same name.

When the host cannot express a contract argument

It will happen: an op's contract carries an argument your DCC has no equivalent for. Do the achievable thing, return what actually happened, and warn. Max's set_environment cannot honour visible_to_camera=False, so it leaves the map in use, returns visible_to_camera: True, and puts the reason in a note. It does not silently accept the argument and it does not raise. Losing a flat backdrop is a cosmetic miss; a step that reports success on a setting it ignored is how you get a hundred silhouette turntables before anyone looks.

The shape of a good measurement

  • Run it in the real host, on the real version, and write the date and the version into the docstring next to the finding.
  • Prefer the negative result stated plainly (rt.menuMan → AttributeError) over a workaround with no explanation.
  • When a candidate fix does not reproduce the bug, say so — "removes the candidate rather than fixes a reproduced bug" is more useful to the next person than a confident claim.

The ladder, end to end

Run these in order. Each one is cheap; each one has caught something.

  1. python -m alc.plugins.{id}.setup — every install resolves, every exe exists.
  2. python -m alc.services.ai.{id}_bridge.verify — plus each --simulate flag.
  3. pytest alc/plugins/blender/tasks/tests/test_parity.py — with your id in _DCCS.
  4. SoftwareCookbook("{id}").run_task(po, ["start","build","render","review","publish"]) on a real asset at a real user coordinate, on a version minted for the purpose. Pick an asset that has never been in your DCC — that is the case that exercises default-file seeding, and it is the one that found a bug here.
  5. The same five phases again from the artist menu, in a session you opened yourself. Where the routes differ, that difference is a bug in one of them until you can point at the comment that made it deliberate — today there is exactly one such difference, and it is farm-vs-local on review.

See also