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, soalc.plugins.3dsmaxandalc.plugins.3ds-maxare both syntax errors — which is why 3ds Max ismax. - 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.
maxdoes. The footgun is real and documented at the top ofalc/plugins/max/__init__.py: never writefrom alc.plugins import maxin a module that also callsmax(). Useimport alc.plugins.max as max_plugin, or importScenedirectly.
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 whenSoftwareRegistry.load()throws, but that is exactly when you want it right. Max has no entry.{cookbook}/config_files/util.yaml—ext_map, read byget_file_type()(alc/path/path.pyandalc/file/core.py). Without an entry, every copy of a scene file logsERROR: No file type found for .{ext}— which is how the missing.maxentry surfaced during the #3858 spine run. Note theFileTypeenum inalc/file/core.pyis a separate list and.blend/.maxare deliberately not members: anext_mapvalue with no enum member classifies asFileType.OTHER, which is the established convention for DCC scenes.{cookbook}/config_files/production_tasks.json— add the id to thesoftwarelist 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) importsalc.plugins.{id}.tasks.{task}and finds the loneSmartTasksubclass in it.SmartTask.sceneimportsalc.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 assignsresult; 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 (aQTimerowned 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_REUSEADDRdoes 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 forSO_EXCLUSIVEADDRUSEinstead, 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 ifalc/plugins/{dcc}/tasks/{task}/{verb}.pyexists. So adding your id to_DCCScannot fail the suite by itself — an unimplemented DCC contributes zero cases. Equally, a DCC absent from_DCCSgets zero enforcement no matter how complete itstasks/tree is._DCCSis 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.5vs0.50is 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:
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 seed000.000fromdefault_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 answermdl.query.membersidentically. - render — the turntable. Ends in a movie plus a thumbnail through the shared oiio/ffmpeg media service.
- review — inherit it. There is no
{id}/reviewrecipe and there should not be:Recipe.resolvefalls through toalchemy/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__.pyif 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 replacespublish/latestfor 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:
startresolves to{id}/start/default;- whose delegating step would run
alchemy/start/default→CopyDefaultFiles→ the version's.{ext}; - but the DCC
Scenegateway 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_taskhard-passes its owndeadlineargument down toRecipe.run, so with the defaultdeadline=Falseevery 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.partialand 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
ActionRemovedevent 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'sQMenuchildren), 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 throughsetup.run_recipe(which already picks GUI vs batch fromrequires_display); analchemy-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.
python -m alc.plugins.{id}.setup— every install resolves, every exe exists.python -m alc.services.ai.{id}_bridge.verify— plus each--simulateflag.pytest alc/plugins/blender/tasks/tests/test_parity.py— with your id in_DCCS.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.- 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¶
- Plugins — Software Bridges — the per-DCC status table and the bridge-verification rules.
- Cookbook IDE & run_task — recipe authoring and the resolution chain.
alc/plugins/CLAUDE.md— the addressing scheme and thesmart_taskrules, in full.