diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index af5b4d7ad3..5a7ed4936e 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -263,6 +263,27 @@ macro_rules! tagged_value { } } + /// The bridge rows for every wire type a value can carry, spliced + /// while plain and record worlds coexist. + pub fn record_bridge_entries() -> Vec<(core_types::ProtoNodeIdentifier, core_types::registry::RegistryEntry)> { + let mut entries = Vec::new(); + entries.extend(core_types::registry::record_bridge_rows::<()>()); + entries.extend(core_types::registry::record_bridge_rows::>()); + entries.extend(core_types::registry::record_bridge_rows::>()); + entries.extend(core_types::registry::record_bridge_rows::>()); + entries.extend(core_types::registry::record_bridge_rows::>()); + entries.extend(core_types::registry::record_bridge_rows::()); + entries.extend(core_types::registry::record_bridge_rows::>()); + entries.extend(core_types::registry::record_bridge_rows::()); + entries.extend(core_types::registry::record_bridge_rows::()); + entries.extend(core_types::registry::record_bridge_rows::>()); + entries.extend(core_types::registry::record_bridge_rows::()); + $( + entries.extend(core_types::registry::record_bridge_rows::<$ty>()); + )* + entries + } + /// Materializes the value as [`Self::to_dynany`] does, wrapped in a `ClonedNode` edge typed by [`Self::ty`]. pub fn to_edge(self) -> Result { match self { @@ -389,8 +410,8 @@ macro_rules! tagged_value { pub fn from_type(input: &Type) -> Option { match input { Type::Generic(_) => None, - Type::Ref(_) => None, - Type::Record(_) => None, + Type::Ref(inner) => Self::from_type(inner), + Type::Record(inner) => Self::from_type(inner), Type::Concrete(concrete_type) => { let name = concrete_type.name.as_ref(); // TODO: Add default implementations for types such as TaggedValue::Subpaths, and use the defaults here and in document_node_types diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index d1c40cb725..fcdaf84ef6 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -934,6 +934,8 @@ fn ref_adapter(proposed: &Type, wanted: &Type) -> Option { (proposed_output @ Type::Concrete(_), Type::Ref(inner)) if valid_type(proposed_output, inner) => Some(ProtoNodeIdentifier::new("graphene_core::memo::LendNode")), (Type::Record(inner), wanted_output @ Type::Concrete(_)) if valid_type(inner, wanted_output) => Some(ProtoNodeIdentifier::new("core_types::record::RecordExtractNode")), (proposed_output @ Type::Concrete(_), Type::Record(inner)) if valid_type(proposed_output, inner) => Some(ProtoNodeIdentifier::new("core_types::record::RecordLiftNode")), + (Type::Ref(inner), Type::Record(wanted_inner)) if valid_type(inner, wanted_inner) => Some(ProtoNodeIdentifier::new("core_types::record::RecordLiftLendNode")), + (Type::Record(inner), Type::Ref(wanted_inner)) if valid_type(inner, wanted_inner) => Some(ProtoNodeIdentifier::new("core_types::record::RecordExtractLendNode")), _ => None, } } diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index e7523fc6ea..66ce9270af 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -677,6 +677,9 @@ fn node_registry() -> HashMap> { .flatten(), ); + node_types.extend(graph_craft::document::value::TaggedValue::record_bridge_entries()); + node_types.extend(core_types::registry::record_bridge_rows::()); + let mut map: HashMap> = HashMap::new(); let insert = |map: &mut HashMap>, id: ProtoNodeIdentifier, entry: RegistryEntry| { let rows = map.entry(id).or_default(); diff --git a/node-graph/libraries/core-types/src/record.rs b/node-graph/libraries/core-types/src/record.rs index 4118d43315..a865975aee 100644 --- a/node-graph/libraries/core-types/src/record.rs +++ b/node-graph/libraries/core-types/src/record.rs @@ -693,6 +693,111 @@ where } } +/// Lifts a lending producer onto a record wire: a parked element carries the +/// lent reference directly, a byte-carried one copies out of the borrow. +pub struct RecordLiftLend { + edge: N, + layout: Layout, + _marker: std::marker::PhantomData El>, +} + +impl RecordLiftLend { + pub fn new(edge: N) -> Self { + Self { + edge, + layout: Layout::default().with_writes(0, element_dims::(), &[]), + _marker: std::marker::PhantomData, + } + } +} + +impl<'e, C, El, N> Node for RecordLiftLend +where + C: crate::context::ExtractArena, + El: Send + Sync + 'static, + N: Node, +{ + type Output = RecordValue<'e>; + + fn eval(&self, input: &C) -> GPoll> { + let build = |element: &'e El| { + let write = |dst: *mut u8| match element_parked::() { + true => unsafe { dst.cast::<&El>().write(element) }, + false => unsafe { std::ptr::copy_nonoverlapping((element as *const El).cast::(), dst, size_of::()) }, + }; + if self.layout.is_inline() { + let mut value = RecordValue::zeroed(); + write(value.as_mut_ptr()); + value + } else { + let dst = stack::push(self.layout.frame_bytes()); + write(dst); + stack::pop(dst); + RecordValue::spilled(unsafe { Rec::new(dst.cast_const()) }) + } + }; + self.edge.eval(input).map(build) + } + + fn layout(&self) -> Option<&Layout> { + Some(&self.layout) + } +} + +/// Lends a record wire's element: a parked element lends its arena-backed +/// reference directly, a byte-carried one parks a copy so the borrow +/// outlives the record. +pub struct RecordExtractLend { + edge: N, + layout: Layout, + _marker: std::marker::PhantomData El>, +} + +impl RecordExtractLend { + pub fn new(edge: N, layout: &Layout) -> Self { + Self { + edge, + layout: layout.clone(), + _marker: std::marker::PhantomData, + } + } +} + +impl<'e, C, El, N> Node for RecordExtractLend +where + C: crate::context::ExtractArena, + El: Clone + Send + Sync + 'static, + N: Node>, +{ + type Output = &'e El; + + fn eval(&self, input: &C) -> GPoll<&'e El> { + let exhausted = || { + GPoll::Error(Box::new(crate::gpoll::GraphError { + kind: crate::gpoll::ErrorKind::ArenaExhausted, + trace: Vec::new(), + })) + }; + let lend = |value: RecordValue<'e>| { + let rec = self.layout.rec(&value); + match element_parked::() { + true => Some(unsafe { borrow_element::(rec) }), + false => input.arena().alloc(unsafe { read_element::(rec) }).map(|(parked, _)| parked), + } + }; + match self.edge.eval(input) { + GPoll::Final(value) => lend(value).map_or_else(exhausted, GPoll::Final), + GPoll::Partial(value) => lend(value).map_or_else(exhausted, GPoll::Partial), + GPoll::Fallback(boxed) => { + let (value, error) = *boxed; + lend(value).map_or_else(exhausted, |element| GPoll::Fallback(Box::new((element, error)))) + } + GPoll::Pending => GPoll::Pending, + GPoll::Error(error) => GPoll::Error(error), + } + } +} + /// Extracts the element from a record wire for a plain consumer, cloning out /// of the parked reference when the element carries drop glue. pub struct RecordExtract { diff --git a/node-graph/libraries/core-types/src/registry.rs b/node-graph/libraries/core-types/src/registry.rs index 4c97fb92b0..5e1093f748 100644 --- a/node-graph/libraries/core-types/src/registry.rs +++ b/node-graph/libraries/core-types/src/registry.rs @@ -290,6 +290,83 @@ pub struct RegistryEntry { pub constructor: NodeConstructor, } +/// The four bridge rows of `T`: plain and lend producers onto record wires, +/// record wires into plain and lend consumers. One set exists per wire type +/// while the worlds coexist. +pub fn record_bridge_rows() -> [(crate::ProtoNodeIdentifier, RegistryEntry); 4] { + [ + (crate::ProtoNodeIdentifier::new("core_types::record::RecordLiftNode"), record_lift_entry::()), + (crate::ProtoNodeIdentifier::new("core_types::record::RecordExtractNode"), record_extract_entry::()), + (crate::ProtoNodeIdentifier::new("core_types::record::RecordLiftLendNode"), record_lift_lend_entry::()), + (crate::ProtoNodeIdentifier::new("core_types::record::RecordExtractLendNode"), record_extract_lend_entry::()), + ] +} + +/// The lift bridge row for `T`: a plain producer onto a record wire. One +/// exists per wire type while plain and record worlds coexist. +pub fn record_lift_entry() -> RegistryEntry { + RegistryEntry { + io: NodeIOTypes::new(concrete!(Context), record_type::(), vec![edge_type::()]), + constructor: |inputs| { + if inputs.len() != 1 { + return Err(ConstructionError::Arity { expected: 1, got: inputs.len() }); + } + let mut inputs = inputs.into_iter(); + let node = crate::record::RecordLift::::new(inputs.next().unwrap().downcast::()?); + Ok(EdgeHandle::new_record::(std::sync::Arc::new(node) as std::sync::Arc)) + }, + } +} + +/// The extract bridge row for `T`: a record wire into a plain consumer. +pub fn record_extract_entry() -> RegistryEntry { + RegistryEntry { + io: NodeIOTypes::new(concrete!(Context), concrete!(T), vec![record_edge_type::()]), + constructor: |inputs| { + if inputs.len() != 1 { + return Err(ConstructionError::Arity { expected: 1, got: inputs.len() }); + } + let mut inputs = inputs.into_iter(); + let edge = inputs.next().unwrap(); + let layout = edge.layout().ok_or(ConstructionError::MissingLayout)?.clone(); + let node = crate::record::RecordExtract::::new(edge.downcast_record::()?, &layout); + Ok(EdgeHandle::new(std::sync::Arc::new(node) as std::sync::Arc>)) + }, + } +} + +/// The lend-lift bridge row for `T`: a lending producer onto a record wire. +pub fn record_lift_lend_entry() -> RegistryEntry { + RegistryEntry { + io: NodeIOTypes::new(concrete!(Context), record_type::(), vec![lend_edge_type::()]), + constructor: |inputs| { + if inputs.len() != 1 { + return Err(ConstructionError::Arity { expected: 1, got: inputs.len() }); + } + let mut inputs = inputs.into_iter(); + let node = crate::record::RecordLiftLend::::new(inputs.next().unwrap().downcast_lend::()?); + Ok(EdgeHandle::new_record::(std::sync::Arc::new(node) as std::sync::Arc)) + }, + } +} + +/// The lend-extract bridge row for `T`: a record wire into a lend consumer. +pub fn record_extract_lend_entry() -> RegistryEntry { + RegistryEntry { + io: NodeIOTypes::new(concrete!(Context), ref_type::(), vec![record_edge_type::()]), + constructor: |inputs| { + if inputs.len() != 1 { + return Err(ConstructionError::Arity { expected: 1, got: inputs.len() }); + } + let mut inputs = inputs.into_iter(); + let edge = inputs.next().unwrap(); + let layout = edge.layout().ok_or(ConstructionError::MissingLayout)?.clone(); + let node = crate::record::RecordExtractLend::::new(edge.downcast_record::()?, &layout); + Ok(EdgeHandle::new_ref(std::sync::Arc::new(node) as std::sync::Arc>)) + }, + } +} + pub fn construct(entry: &RegistryEntry, inputs: Vec) -> Result { if inputs.len() != entry.io.inputs.len() { return Err(ConstructionError::Arity { diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 05ace45664..580cb3bc88 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -41,6 +41,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let record = record_shape(parsed); let routing = routing_io(parsed); + let flip = record_flip(parsed); let record_skips_carrier = record.as_ref().is_some_and(|shape| shape.skips_carrier()); // Record nodes with a `_: ()` primary input have no carrier edge; the unit // field stays visible in the metadata but claims no struct field. @@ -139,6 +140,14 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn state } None if routing.is_some() => vec![quote!(pub(super) __layout: gcore::record::Layout)], + None if flip => { + let mut state = vec![quote!(pub(super) __layout: gcore::record::Layout), quote!(pub(super) __frame_bytes: usize)]; + state.extend((0..struct_regular_fields.len()).map(|index| { + let slot = format_ident!("__in_{index}"); + quote!(pub(super) #slot: gcore::record::Layout) + })); + state + } None => Vec::new(), }; @@ -260,7 +269,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let all_field_inits = data_inits.chain(regular_inits).chain(slot_init); // Data fields may not implement Copy, PartialEq, etc., so only derive Debug and Clone - let struct_derives = if record.is_some() || routing.is_some() { + let struct_derives = if record.is_some() || routing.is_some() || flip { quote!(#[derive(Debug, Clone)]) } else if data_fields.is_empty() && !async_source { quote!(#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]) @@ -289,16 +298,46 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn // offsets from the carrier layout; `new` cannot fill that state. let routing_layout_param = routing.is_some().then(|| quote!(__layout: &gcore::record::Layout,)).into_iter(); let routing_layout_init = routing.is_some().then(|| quote!(__layout: __layout.clone(),)).into_iter(); + let flip_layout_params = flip + .then(|| { + (0..struct_regular_fields.len()).map(|index| { + let slot = format_ident!("__in_{index}"); + quote!(#slot: &gcore::record::Layout,) + }) + }) + .into_iter() + .flatten(); + let flip_layout_inits = flip + .then(|| { + (0..struct_regular_fields.len()).map(|index| { + let slot = format_ident!("__in_{index}"); + quote!(#slot: #slot.clone(),) + }) + }) + .into_iter() + .flatten(); + let flip_prelude = flip + .then(|| { + quote! { + let __layout = gcore::record::Layout::default().with_writes(0, gcore::record::element_dims::<#slot_value_type>(), &[]); + let __frame_bytes = __layout.frame_bytes(); + } + }) + .into_iter(); + let flip_output_inits = flip.then(|| quote!(__layout, __frame_bytes,)).into_iter(); let new_impl = match record.is_none() { true => quote! { #[automatically_derived] impl<'n, #(#struct_generic_params,)*> #struct_name<#(#struct_type_params,)*> { #[allow(clippy::too_many_arguments)] - pub fn new(#(#new_args,)* #(#routing_layout_param)*) -> Self { + pub fn new(#(#new_args,)* #(#routing_layout_param)* #(#flip_layout_params)*) -> Self { + #(#flip_prelude)* Self { #(#all_field_inits,)* #(#routing_layout_init)* + #(#flip_layout_inits)* + #(#flip_output_inits)* } } } @@ -662,6 +701,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }; let skips_carrier = record.as_ref().is_some_and(|shape| shape.skips_carrier()); let routing = routing_io(parsed); + let flip = record_flip(parsed); let snapshot_ctx = async_fn && matches!(&parsed.input.ty, Type::Path(path) if path.path.segments.last().is_some_and(|segment| segment.ident == "CtxSnapshot")); let mut ctx_bounds: Vec = match ctx_param { @@ -755,7 +795,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn generics.insert(0, quote!(#lifetime)); impl_generics.insert(0, quote!(#lifetime)); } - if routing.is_some() || record.is_some() { + if routing.is_some() || record.is_some() || flip { impl_generics.insert(0, quote!('__record)); } if derive_routing { @@ -768,6 +808,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let output_type = &parsed.output_type; let trait_output = match (&record, &routing) { (Some(_), _) | (None, Some(_)) => syn::parse_quote!(#core_types::record::RecordValue<'__record>), + (None, None) if flip => syn::parse_quote!(#core_types::record::RecordValue<'__record>), (None, None) => slot_value_type(&parsed.output_type), }; let raw_lazy = matches!(kernel_kind(&parsed.output_type), KernelKind::Poll(_)); @@ -868,6 +909,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn ParsedFieldType::Regular(RegularParsedField { ty, .. }) if routing_source(ty) => { quote!(#node_generic: #core_types::node::Node<#ctx_ident, Output = #record_value_ty>) } + ParsedFieldType::Regular(_) if flip => quote!(#node_generic: #core_types::node::Node<#ctx_ident, Output = #record_value_ty>), ParsedFieldType::Regular(RegularParsedField { ty, .. }) => quote!(#node_generic: #core_types::node::Node<#ctx_ident, Output = #ty>), ParsedFieldType::Node(NodeParsedField { output_type, .. }) if routing_source(output_type) => match derives { true => quote!(#node_generic: for<'__derived> #core_types::record::DerivedRecordEdge<'__derived, #core_types::context::Derived<'__derived, #ctx_ident>>), @@ -933,6 +975,16 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let name = &field.pat_ident.ident; match &field.ty { ParsedFieldType::Regular(_) if record.is_some() && !skips_carrier && index == 0 => quote!(), + ParsedFieldType::Regular(RegularParsedField { ty, .. }) if flip => { + let slot = format_ident!("__in_{index}"); + quote! { + let #name = match __cell.eval_input(#index, &self.#name, __input) { + Ok(value) => value, + Err(interrupt) => return interrupt.into(), + }; + let #name: #ty = unsafe { #core_types::record::read_element(self.#slot.rec(&#name)) }; + } + } ParsedFieldType::Regular(_) => quote! { let #name = match __cell.eval_input(#index, &self.#name, __input) { Ok(value) => value, @@ -1223,10 +1275,41 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn __cell.finish(__value) } }); + let flip_tail = flip.then(|| { + let kernel_value = match kernel_kind(&parsed.output_type) { + KernelKind::Interrupt(_) => quote! { + match #kernel_call { + Ok(value) => value, + Err(interrupt) => return interrupt.into(), + } + }, + _ => quote!(#kernel_call), + }; + quote! { + let __kernel_value = #kernel_value; + let mut __value = #core_types::record::RecordValue::zeroed(); + let __dst = match self.__frame_bytes { + 0 => __value.as_mut_ptr(), + __bytes => #core_types::record::stack::push(__bytes), + }; + let __written = unsafe { #core_types::record::write_element(__dst, __kernel_value, #core_types::context::ExtractArena::arena(__input)) }; + if self.__frame_bytes != 0 { + #core_types::record::stack::pop(__dst); + __value = #core_types::record::RecordValue::spilled(unsafe { #core_types::record::Rec::new(__dst.cast_const()) }); + } + match __written { + Some(()) => __cell.finish(__value), + None => #core_types::gpoll::GPoll::Error(::std::boxed::Box::new(#core_types::gpoll::GraphError { + kind: #core_types::gpoll::ErrorKind::ArenaExhausted, + trace: ::std::vec::Vec::new(), + })), + } + } + }); let eval_tail = match (async_fn, future_kernel) { (false, false) => match record_tail { Some(tail) => tail, - None => lift, + None => flip_tail.unwrap_or(lift), }, (true, _) => { let kernel_value_names: Vec<&Ident> = kernel_fields.iter().map(|field| &field.pat_ident.ident).collect(); @@ -1280,13 +1363,13 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn Some(shape) if shape.skips_carrier() => { vec![quote!(#ctx_ident: #core_types::context::ExtractArena)] } - None if derive_routing => { + None if derive_routing || flip => { vec![quote!(#ctx_ident: #core_types::context::ExtractArena)] } _ => Vec::new(), }; - let record_layout_impl = match record.is_some() || routing.is_some() { + let record_layout_impl = match record.is_some() || routing.is_some() || flip { true => quote! { fn layout(&self) -> Option<&#core_types::record::Layout> { Some(&self.__layout) @@ -1615,6 +1698,41 @@ pub(crate) struct RoutingIo { pub(crate) generic: Ident, } +/// Whether a plain node's lowering flips onto record wires: sync, +/// fully-concrete value-input nodes in this cut; batch, shader, async, lend, +/// lazy, and generic nodes keep the plain lowering until their record forms +/// land. +pub(crate) fn record_flip(parsed: &ParsedNodeFn) -> bool { + if record_shape(parsed).is_some() || has_record_io(parsed) || routing_io(parsed).is_some() { + return false; + } + if parsed.is_async || is_source_kernel(&parsed.output_type) { + return false; + } + if parsed.attributes.batch.is_some() || parsed.attributes.shader_node.is_some() { + return false; + } + if matches!(kernel_kind(&parsed.output_type), KernelKind::Poll(_)) { + return false; + } + if matches!(slot_value_type(&parsed.output_type), Type::Reference(_)) { + return false; + } + let ctx_ident = context_param(parsed).map(|ctx| ctx.ident.clone()); + let concrete = parsed.fn_generics.iter().all(|param| match param { + GenericParam::Type(type_param) => Some(&type_param.ident) == ctx_ident.as_ref(), + GenericParam::Lifetime(_) => false, + GenericParam::Const(_) => false, + }); + if !concrete { + return false; + } + parsed.fields.iter().all(|field| match &field.ty { + ParsedFieldType::Regular(RegularParsedField { lend, .. }) => lend.is_none(), + ParsedFieldType::Node(_) => false, + }) +} + pub(crate) fn routing_io(parsed: &ParsedNodeFn) -> Option { if has_record_io(parsed) || parsed.is_async { return None; @@ -1821,6 +1939,9 @@ fn entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, data_field_generic if routing_io(parsed).is_some() { return routing_entries_tokens(parsed, struct_name, regular_fields); } + if record_flip(parsed) { + return flip_entries_tokens(parsed, struct_name, regular_fields); + } let Some(rows) = implementation_rows(parsed, regular_fields) else { return quote!(); }; @@ -1903,6 +2024,71 @@ fn entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, data_field_generic } } +/// The registry rows of a flipped plain node: every wire is a record wire, +/// inputs resolve their layouts off the claimed handles, and the output is an +/// element-only record of the kernel's return type. +fn flip_entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, regular_fields: &[&ParsedField]) -> TokenStream2 { + let Some(rows) = implementation_rows(parsed, regular_fields) else { + return quote!(); + }; + let rows: Vec<&Vec> = rows.iter().filter(|row| row.iter().all(|ty| !type_disqualifies(ty))).collect(); + if rows.is_empty() { + return quote!(); + } + let output = slot_value_type(&parsed.output_type); + if type_disqualifies(&output) { + return quote!(); + } + + let fn_name = &parsed.fn_name; + let entries_name = format_ident!("{}_entries", fn_name); + let arity = regular_fields.len(); + let names: Vec<&Ident> = regular_fields.iter().map(|field| &field.pat_ident.ident).collect(); + + let entries = rows.iter().map(|row| { + let input_types = row.iter().map(|ty| quote!(gcore::registry::record_edge_type::<#ty>())); + let downcasts = names.iter().zip(row.iter()).enumerate().map(|(index, (name, ty))| { + let handle = format_ident!("__handle_{index}"); + let layout = format_ident!("__layout_{index}"); + quote! { + let #handle = inputs.next().unwrap(); + let Some(#layout) = #handle.layout().cloned() else { + return Err(gcore::registry::ConstructionError::MissingLayout); + }; + let #name = #handle.downcast_record::<#ty>()?; + } + }); + let layout_args = (0..arity).map(|index| { + let layout = format_ident!("__layout_{index}"); + quote!(&#layout,) + }); + quote! { + gcore::registry::RegistryEntry { + io: gcore::registry::NodeIOTypes::new( + gcore::concrete!(gcore::context::ContextImpl<'static>), + gcore::registry::record_type::<#output>(), + vec![#(#input_types),*], + ), + constructor: |inputs| { + if inputs.len() != #arity { + return Err(gcore::registry::ConstructionError::Arity { expected: #arity, got: inputs.len() }); + } + let mut inputs = inputs.into_iter(); + #(#downcasts)* + let __node = #struct_name::new(#(#names,)* #(#layout_args)*); + Ok(gcore::registry::EdgeHandle::new_record::<#output>(::std::sync::Arc::new(__node) as ::std::sync::Arc)) + }, + } + } + }); + + quote! { + pub fn #entries_name() -> ::std::vec::Vec { + vec![#(#entries),*] + } + } +} + /// The registry row of a routing node: one instance covers every element, /// sources claim generic record edges, and the constructor wraps each source /// in its union translation and stores the union as the node's layout.