The build: one kit, one id per compile
Studio and its plugins share one standard library and one build of the crates they exchange values through, so values cross as plain Rust types and TypeIds agree. .cargo/config.toml builds everything with -C prefer-dynamic, and release builds without LTO. kinogaki-shared (studio/core/shared/src/lib.rs) is a Rust dylib that re-exports the SDK, kinogaki-studio-api, together with its UI layer kinogaki-studio-ui-api, kinogaki-render-api, kinogaki-geometry, kinogaki-edit, kinogaki-viewport-api and wgpu. This build is the kit. The library enables every feature that any crate in the workspace enables on the crates it holds, so each build of it comes out the same; tools/check_architecture.py names any feature it lacks. The app, kinogaki-studio, links it with use kinogaki_shared as _; (studio/apps/studio/src/main.rs) and finds it by @rpath: in the bundle's Frameworks folder, or in the deps folder beside an executable in a build folder (studio/apps/studio/build.rs).
Three names tie a plugin to one compile of the kit:
kinogaki_shared::BUILD, au64made bybuild_id!()from kinogaki-build-id. This procedural macro hashes the standard library's random keys over the current time and the compiler's process id. Cargo compiles kinogaki-shared again exactly when something it is built from changed, so each compile gets a new id.kinogaki_shared::link!(), which writes a#[no_mangle]static namedkinogaki_shared_build(BUILD_SYMBOL) that holds theBUILDthe plugin was compiled against.export_package!(make)(sdk/crates/api/src/entry.rs), which writes the entrykinogaki_package(entry::SYMBOL) of typeentry::Entry, afn() -> std::thread::Result<Package>. The entry catches a panic inmake, so the plugin's own crate keepsunsafe_code = "forbid".
A first-party plugin's dylib crate holds just these two macro calls (plugins/obj/plugin/src/lib.rs: link!(), then export_package!(kinogaki_obj::package)); its code lives in the library crate beside it (plugins/obj).
tools/build_plugins.py runs one cargo build --locked of kinogaki-shared and every crate in its PLUGINS table. Each row gives a kind folder, a file name, the dylib crate, the package id and the ids the package depends on. The script copies each library into the build folder's plugins folder as <kind>/<name>.dylib and writes <name>.prisma beside it. The manifest is a Prism text document: a plugin element with id, kind, version (the crate's version) and one depends child per dependency. The table lists fourteen plugins: the Mesh kernel, three node packages (Mesh, Layout, Comp), eight formats and two render engines (Spectre, Pixel). None of them declares a dependency.
--kit DIR also writes the kit for third parties: a deps folder (the shared dylib, the metadata of every crate it holds, the proc-macro libraries), a sysroot folder with the compiler's standard library, and a README that names the exact rustc and the command line. --examples DIR then builds the SDK's four example plugins with plain rustc against that kit alone. tools/package_macos.py makes the same build with the app added, copies the standard library and kinogaki-shared into the bundle's Frameworks folder and the plugins into its PlugIns folder (when there are any), refuses a binary that links anything outside @rpath and the system, re-signs ad hoc, and writes the kit beside the bundle.
Where the app looks, and reading manifests
kinogaki-plugin-host is the loader. Folders::standard() (studio/core/plugin-host/src/scan.rs) finds two roots. The first is the install's folder: Install::running() treats an executable inside Contents/MacOS as a bundle and uses the bundle's PlugIns folder; any other executable uses the plugins folder beside it, which build_plugins.py fills. The second is the user's folder: on macOS, Application Support/Kinogaki Studio/plugins in the user's Library. sys.rs opens libraries on Unix systems and reports "plugins aren't supported on this platform yet" elsewhere.
scan::scan walks the install's root first, then the user's. Within each root it visits the kind folders in Kind::ALL order (kernels, nodes, formats, renders) and the files in name order. It reads each manifest with Manifest::read (studio/core/plugin-host/src/manifest.rs, over kinogaki-prism) and opens nothing. A library without a manifest, a manifest that doesn't read, a manifest without its library, or a manifest whose kind names another folder becomes an Unavailable with its Why.
Choosing what loads
kinogaki_editor::load_plugins (studio/core/editor/src/packages/plugins.rs, in kinogaki-editor) runs once per process. main.rs calls it after kinogaki_cook::workers::install() and kinogaki_core_package::install(); the second call installs core's own package, kinogaki.core from kinogaki-core-package, before any plugin's. The function first works out which ids to turn off:
- Given
--plugins LIST, or else theKINOGAKI_PLUGINSvariable,choice::only(studio/core/plugin-host/src/choice.rs) keeps the packages the list names, together with everything their manifests depend on, transitively. Everything else is off, and the settings' off list is left unread for that run. A name matches a whole id or its last part (choice::is_named:mesh_kernelnameskinogaki.mesh_kernel), andnonenames nothing. A name that matches no plugin becomes a Console line. - Without a list,
settings::plugins_off(studio/core/editor/src/settings/mod.rs) reads theofflist of the/settings/pluginselement in the user'ssettings.prisma. It reads this before the rest of the settings, because the rest needs the packages. A--screenshotrun has no settings folder, so no off list applies.
resolve::resolve (studio/core/plugin-host/src/resolve.rs) then decides the order. For each id, the first plugin found is kept; later ones are Why::Shadowed, so the install's copy wins over the user's. Ids in the off set are Why::Off. The resolver repeatedly drops any plugin whose dependency is absent: NeedsMissing when that dependency was never found, NeedsUnavailable when it was found but can't load. It orders the rest in rounds. Each round takes, by id, every plugin whose dependencies are already taken, and whatever is left at the end is Why::Cycle. So the load order comes from the manifests' dependencies and the ids alone.
Opening a library and calling its entry
load::load (studio/core/plugin-host/src/load.rs) works through the chosen plugins in order:
- A plugin whose dependency failed to load is
NeedsUnavailable. sys::open(studio/core/plugin-host/src/sys.rs) callsdlopen(RTLD_NOW | RTLD_LOCALon macOS) and looks up the two symbols. It is the one module of Studio's core and app crates allowedunsafeoutside tests (tools/architecture/libraries.toml). As its notes say, a Rust library has no initializers, so opening runs none of the plugin's code.- If the
kinogaki_shared_buildrecord is missing, or differs from theBUILDof the library the app runs with, the plugin isWhy::OtherBuild("it was built against a different Kinogaki build; rebuild plugins") and its entry is never called. So acargo buildthat rebuilds the app and kinogaki-shared without the plugins leaves them refused. - The entry is called once inside
catch_unwind. A panic isWhy::Panicked, a missing entry isWhy::Open, and a package whose id differs from the manifest's isWhy::OtherId. - The package goes to the host's
admit. A refusal isWhy::Refusedwith the registry's reason, and the plugins that need it become unavailable.
Libraries are never closed: their code may be running on any thread, and the vtables and statics they made live on.
Admission: the registry
The editor's admit is kinogaki_network::registry::install (studio/core/network/src/registry.rs, in kinogaki-network). If a package of the same id is already installed and declares the same ids, the call does nothing; any other package of that id is refused. Otherwise the SDK's Registry::register runs Registry::check (sdk/crates/api/src/registry/check.rs) first and keeps nothing on a refusal. The checks:
- Vendor. Every id the package declares (data types, node types, contexts, verbs, object kinds and creations, engines and their prim types, formats, slots, stored values, the contexts its UI gives parts to) starts with its own vendor.
- Duplicates. The package id and every declared id are new, and the process has at most one script engine.
- References. Every data type it names (ports, held deltas, object kinds' results, what formats produce and accept) and every context it names (where node types are offered, what object kinds open into, what nodes hold inside) is its own or an earlier package's, and no contexts hold each other.
- Shapes. No node parameter takes the spares' prefix (
cook::SPARE) or holds an object. A compiler or runner serves a compiled context that has none yet. A context adopts a node type at most once. Icons are well formed, named once, and present wherever they are shown. An engine's standard outputs match the standard ones, none of its settings takes a render output's own name, and every engine maker makes an engine the package describes.
Once a package is admitted, its verbs are offered to the whole process (kernel::offer). Core's table keeps its contexts, its node types (each description read once), its held edits, data types and compilers, and rebuilds the lists of formats and engines in install order. The table only grows.
What a package brings, and where the user meets it
Every offer is read from the registry. Packages::new (studio/core/editor/src/packages/mod.rs) gives each installed package that has a UI its own state, in install order.
| A package brings | Registered with | Offered through |
|---|---|---|
| Network contexts | with_context (NetworkContext) | the networks of objects whose kind names the context; the network pane |
| Node types | with_node_type (NodeType) | Tab in networks whose context offers them (registry::offers: Description::contexts, the context's adopts, Reach), under their listing's section; menus where a library names them |
| Node creations | with_creation | Tab, beside their node type (an engine's material presets) |
| Object kinds | with_object_kind (ObjectKindSpec) | their creations at the object levels: Tab, Create, the A marking menu, the right-click menu |
| Data types, held edits | with_data_type, with_held_edit | values on wires and on the results board, saved through their codecs |
| File formats | with_format (FileFormat) | File ▸ Import and Export, the types a File node reads, Settings ▸ Import |
| Render engines | with_engine (description and maker) | a view's Render item, the Render menu, Settings ▸ Color, a Render Output's engine |
| Verbs | with_verb | any cook, by id: kernel::run, kernel::find |
| Stored values | with_stored | kept in the project, each view or the user's settings; edited on the package's settings pages (Settings ▸ Project for project values) |
| Session slots | with_slot | undoable session state, never saved |
| UI | WithUi::with_ui (UiPackage) | each context's ContextPart and the package's settings pages |
A package can also bring meshers, a script engine, compilers and runners for compiled contexts, and icons; the registry checks icons, and core draws none of a package's icons yet.
- Tab (
studio/core/editor/src/browser.rs). In a network, Tab listsregistry::listedandregistry::creationsfor the context, plus the project's assets of that context. At an object level it listskinds::creations(studio/core/document/src/kinds.rs, in kinogaki-document): core's Part and every installed kind's creations. - Menus, marking menus, shelves (
studio/core/editor/src/packages/library.rs,studio/core/editor/src/frame/offers.rs). At the object levels these come from core's library (studio/core/editor/src/shell/objects.rs), plus what packages offer there (Places::objects,ObjectMenus). Inside an object they come from the open context's library, its own or one it shares. AContextPartholds aLibraryand optionallevels,tools,panels,transforms,display,inspectorandvocabulary(sdk/crates/ui-api/src/package/mod.rs). A context whose library gives no menus, a headless context among them, has no Create, Select, Modify or Transform menus; Tab lists its node types all the same. - File ▸ Import and Export are always in the File menu.
studio/core/editor/src/shell/dialogs.rsadds one filter per installed format that reads (for Import) or writes (for Export).studio/core/editor/src/frame/files.rspicks the reader throughregistry::formats().reader_for. - Rendering.
ImagePipeline::new(studio/core/image-pipeline/src/lib.rs, kinogaki-image-pipeline) collects the engines and their makers. A view's camera menu shows Render (studio/core/editor/src/view/camera_menu.rs), the menu bar shows the Render menu (studio/core/editor/src/commands/menus/mod.rs) and the Settings window shows its Color page only while an engine is installed. A Render Output that names no engine uses the project's Default Engine, or else the first engine installed (studio/core/package/src/default_engine.rs). - Settings ▸ Plugins (
studio/core/editor/src/settings/window.rs) appears when the scan found plugins. It lists each found plugin by id with a toggle that decides whether it loads at the next start; Reset Page turns them all on. The reasons go to the Console: when an editor is made (studio/core/editor/src/frame.rs), each unavailable plugin is an error line under "plugins", including those turned off.
The four kinds
Kind (studio/core/plugin-host/src/manifest.rs) has four values: Kernels, Nodes, Formats and Renders. In code, a kind is the folder a plugin sits in and the kind its manifest states. The scan requires the two to agree, and Kind::ALL orders the folders within a root, which decides which of two plugins with the same id is found first. The load order comes from dependencies and ids, and the registry checks every package by the same rules whatever its kind. The kinds differ in what the first-party packages register:
- Kernels register verbs.
kinogaki.mesh_kerneloffers nine verbs, which other packages run by id. - Nodes register contexts, node types, object kinds, data types and UI. Mesh and Layout each bring a context with its UI. Comp brings node types for core's Comp context, with its compiler and runner. A context plugin ships as a node package.
- Formats each register one
FileFormat: OBJ, MaterialX, PNG, JPEG, OpenEXR, USD, glTF and FBX. - Renders register an engine, its lights as an object kind (Pixel also its fog) and its material presets as node creations (plugins/spectre).
When a plugin is missing
A package that didn't load is absent from the registry, so nothing offers what it brings. A project that uses it still opens and saves without loss (Kino files):
- A node whose type, held delta, kept result or inner network needs the package loads as a
kinogaki_network::Placeholder(studio/core/network/src/node.rs). Its element is kept verbatim, its ports are the ones its wires used, and its parameters show read-only. Its type is the entryregistry::placeholdermakes, whose cook fails withNeeds <package>, so nothing downstream cooks; the network pane draws it grey. - An object of a kind no installed package declares loads as
Body::Placeholder(studio/core/document/src/object/body.rs). The object is kept, its network included, and its row in the outliner is muted. A stored value of the missing package is kept askinogaki_project::Kept. All of these are written back unchanged. - The load's report (
studio/core/project/src/report.rs, in kinogaki-project) gives the Console one information line per missing package, such as "Couldn't load 3 nodes and 1 object: acme.trees isn't installed". - While a kernel is missing,
kernel::runreturns aMissingerror that names the kernel's package, andkernel::findreturnsNone. The Mesh nodes that run the kernel's verbs pass that error on, so their cook fails with it.
