Skip to main content

concinnity_cook/authoring/registry/
mod.rs

1//! The authoring metadata registry: `RegisteredType`, the enum of every asset
2//! type paired with its on-disk discriminant, plus the authoring-only operations
3//! over it (name parsing, arg reserialization, enum-field probing, and
4//! reference-field listing). Consumed by the build pipeline and the in-engine
5//! editor; never by the runtime ECS, which loads components straight from their
6//! blob discriminants (`concinnity_core::ecs::ComponentAsset::from_baked`).
7//!
8//! The vocabulary arrives in three groups. The two the runtime can reach -- the
9//! components a world stores and the resources the cook compiles into the blob
10//! -- are the single source of truth in `concinnity_core::ecs::registry` (the
11//! `for_each_component!` macro). The third, the authoring-only types the cook
12//! expands away, is this crate's own: it lives in [`build_only`], and
13//! `for_each_authored_type!` composes the two so `RegisteredType` spans the
14//! whole declarable vocabulary from one enum.
15
16pub mod build_only;
17
18use crate::result::CnResult;
19
20pub use build_only::BuildOnlyAsset;
21pub use concinnity_core::ecs::{AssetOrigin, AssetPayload};
22
23/// Static authoring metadata for an asset type: how it is declared, whether it
24/// compiles a payload, and its default args JSON. Derived from the registry
25/// entry's metadata block -- the runtime `Component` trait carries none of it
26/// (blob records carry everything a shipped game loads).
27#[derive(Debug, Clone, serde::Serialize)]
28pub struct Registration {
29    /// The asset type's registry name.
30    pub type_name: &'static str,
31    /// Where the asset comes from and whether it persists.
32    pub origin: AssetOrigin,
33    /// Whether the asset compiles a binary payload.
34    pub payload: AssetPayload,
35    /// Default args JSON, for types that declare one.
36    pub default_args: Option<serde_json::Value>,
37}
38
39impl Registration {
40    /// Whether a world may declare this type directly.
41    pub fn addable(&self) -> bool {
42        self.origin == AssetOrigin::External
43    }
44
45    /// Whether the build must compile a payload for this type.
46    pub(crate) fn needs_compilation(&self) -> bool {
47        self.payload == AssetPayload::Compiled
48    }
49}
50
51/// What a component name denotes at tick time, for the behavior `scope` and
52/// `queries` checks.
53///
54/// A world may declare far more types than a running world holds in a column:
55/// the build expands some away, compiles others into the resource stream, and a
56/// load-time pass drains the rest during `World::start`. Only [`Self::Column`]
57/// can be matched against entities once the world is running.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ScopeResolution {
60    /// A column that still holds entities at tick time. Carries the type the
61    /// name resolves to, which differs from the name itself where a load-time
62    /// pass leaves a runtime marker behind (`Prop` resolves to `PropInstance`).
63    Column(RegisteredType),
64    /// A load-time pass drains this column during `World::start`, leaving
65    /// nothing to match.
66    Consumed,
67    /// The build expands this type into the components it stands for; no
68    /// record of it reaches a world.
69    Expanded,
70    /// Compiled into the blob's resource stream and reached by handle rather
71    /// than stored in a column.
72    Resource,
73}
74
75// Extract the allowed enum variants from a serde "unknown variant" error
76// message, coping with the count-dependent phrasing: "expected one of `a`, `b`,
77// `c`" (3+), "expected `a` or `b`" (2), and "expected `a`" (1). Collects every
78// backtick-quoted token that appears after the `expected` keyword (so the
79// offending value, quoted before it, is skipped). Returns `None` for any other
80// error (a type mismatch, a non-enum field), so callers fall back to treating
81// the field as free text.
82pub(crate) fn parse_expected_variants(msg: &str) -> Option<Vec<String>> {
83    let after = msg.split_once("expected")?.1;
84    let mut out = Vec::new();
85    let mut rest = after;
86    while let Some(open) = rest.find('`') {
87        let tail = &rest[open + 1..];
88        let close = tail.find('`')?;
89        out.push(tail[..close].to_string());
90        rest = &tail[close + 1..];
91    }
92    (!out.is_empty()).then_some(out)
93}
94
95// The empty args schema of a runtime-only component: never authored, so its
96// registration carries an empty default and its reserialize accepts `{}`.
97#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
98pub(crate) struct NoArgs {}
99
100// Metadata scanners over a registry entry's `{ ... }` flag tokens. Each walks
101// the token stream for its key and falls back to a default when absent; the
102// generic `$t:tt` arm skips unrecognized tokens (other flags, `,`, `:`, and
103// bracketed lists are each one token tree).
104
105// The authoring origin of a stored entry: `external` / `runtime` (RuntimeOnly is
106// also the fallback for entries with no origin flag). The other two groups take
107// theirs from the group itself, in `__group_origin`.
108macro_rules! __meta_origin {
109    () => { AssetOrigin::RuntimeOnly };
110    (external $($r:tt)*) => { AssetOrigin::External };
111    (runtime $($r:tt)*) => { AssetOrigin::RuntimeOnly };
112    ($t:tt $($r:tt)*) => { __meta_origin!($($r)*) };
113}
114
115// Whether the type compiles a blob payload (`compiled`).
116macro_rules! __meta_payload {
117    () => { AssetPayload::None };
118    (compiled $($r:tt)*) => { AssetPayload::Compiled };
119    ($t:tt $($r:tt)*) => { __meta_payload!($($r)*) };
120}
121
122// The authored args schema TYPE: the component itself by default, `NoArgs` for
123// runtime-only entries, or the authoring form of the asset the `args: <Asset>`
124// override names, for the types whose authored shape diverges from the runtime
125// component.
126macro_rules! __meta_args_ty {
127    ($default:path;) => { $default };
128    ($default:path; runtime $($r:tt)*) => { NoArgs };
129    ($default:path; args: $a:ident $($r:tt)*) => { concinnity_core::components::cook::$a };
130    ($default:path; $t:tt $($r:tt)*) => { __meta_args_ty!($default; $($r)*) };
131}
132
133// The args schema's NAME, for the docs pipeline (which renders the args
134// struct's fields, keyed by the struct's own name). A divergent asset's schema
135// is declared as `<Asset>Args` and exposed under the asset's name in `cook`, so
136// the entry's `args: <Asset>` yields both.
137macro_rules! __meta_args_name {
138    ($default:ident;) => { stringify!($default) };
139    ($default:ident; args: $a:ident $($r:tt)*) => { concat!(stringify!($a), "Args") };
140    ($default:ident; $t:tt $($r:tt)*) => { __meta_args_name!($default; $($r)*) };
141}
142
143// Apply the entry's bake-time validator (`validate: <fn>`, from
144// `crate::authoring::validate`) to a typed value; identity when the entry
145// declares none.
146macro_rules! __meta_validate {
147    ($val:expr;) => { $val };
148    ($val:expr; validate: $f:ident $($r:tt)*) => {
149        crate::authoring::validate::$f($val)
150    };
151    ($val:expr; $t:tt $($r:tt)*) => { __meta_validate!($val; $($r)*) };
152}
153
154// The `refs: [ ... ]` reference-field list; empty when absent.
155macro_rules! __meta_refs {
156    () => { &[] };
157    (refs: [ $( ($fld:literal, $tgt:literal) ),+ $(,)? ] $($r:tt)*) => { &[ $( ($fld, $tgt) ),+ ] };
158    ($t:tt $($r:tt)*) => { __meta_refs!($($r)*) };
159}
160
161// The bare structural flags: `singleton` (at most one instance belongs to a
162// world), `useful_blank` (meaningful when declared with only default args, so
163// authoring tools offer a plain add), and `renders` (presence implies the
164// world renders). `__meta_useful_blank` / `__meta_renders` are shared with the
165// resource-asset registry in `resource_type`.
166// The recursive arms are path-qualified so the shared scanners also expand
167// from other modules (`resource_type` invokes them by path).
168macro_rules! __meta_singleton {
169    () => { false };
170    (singleton $($r:tt)*) => { true };
171    ($t:tt $($r:tt)*) => { crate::authoring::registry::__meta_singleton!($($r)*) };
172}
173macro_rules! __meta_useful_blank {
174    () => { false };
175    (useful_blank $($r:tt)*) => { true };
176    ($t:tt $($r:tt)*) => { crate::authoring::registry::__meta_useful_blank!($($r)*) };
177}
178macro_rules! __meta_renders {
179    () => { false };
180    (renders $($r:tt)*) => { true };
181    ($t:tt $($r:tt)*) => { crate::authoring::registry::__meta_renders!($($r)*) };
182}
183macro_rules! __meta_live {
184    () => { false };
185    (live $($r:tt)*) => { true };
186    ($t:tt $($r:tt)*) => { crate::authoring::registry::__meta_live!($($r)*) };
187}
188pub(crate) use {__meta_live, __meta_renders, __meta_singleton, __meta_useful_blank};
189
190// The `consumed` flag: whether a load-time pass drains this column during
191// `World::start`, and the runtime type that survives in its place when one
192// does. The `consumed: <Type>` arm must precede the bare one, which would
193// otherwise absorb the substitute.
194macro_rules! __meta_surviving {
195    ($variant:ident;) => { ScopeResolution::Column(RegisteredType::$variant) };
196    ($variant:ident; consumed: $surviving:ident $($r:tt)*) => {
197        ScopeResolution::Column(RegisteredType::$surviving)
198    };
199    ($variant:ident; consumed $($r:tt)*) => { ScopeResolution::Consumed };
200    ($variant:ident; $t:tt $($r:tt)*) => { crate::authoring::registry::__meta_surviving!($variant; $($r)*) };
201}
202pub(crate) use __meta_surviving;
203
204// Keyed on group: only a stored entry can name a column, so the other two
205// groups answer from their group alone.
206macro_rules! __group_surviving {
207    (stored; $variant:ident; $($meta:tt)*) => { __meta_surviving!($variant; $($meta)*) };
208    (build_only; $variant:ident; $($meta:tt)*) => { ScopeResolution::Expanded };
209    (resource; $variant:ident; $($meta:tt)*) => { ScopeResolution::Resource };
210}
211
212// The three below are keyed on which group of the registry list an entry came
213// from rather than on its flags, because group membership is what decides them.
214
215// The blob tag: the stored group's `ComponentTag` position. A build-only type is
216// expanded away before any record is written, and a resource is addressed by a
217// handle into the resource stream, so neither carries a component tag.
218macro_rules! __group_discriminant {
219    (stored; $variant:ident) => {
220        Some(crate::ecs::ComponentTag::$variant as u8)
221    };
222    (build_only; $variant:ident) => {
223        None
224    };
225    (resource; $variant:ident) => {
226        None
227    };
228}
229
230// The authoring origin: read from the entry's flags for a stored type (which is
231// `external` or `runtime`), fixed by the group for the other two.
232macro_rules! __group_origin {
233    (stored; $($meta:tt)*) => { __meta_origin!($($meta)*) };
234    (build_only; $($meta:tt)*) => { AssetOrigin::BuildOnly };
235    (resource; $($meta:tt)*) => { AssetOrigin::External };
236}
237
238// Whether the build compiles something for this type. A resource always does,
239// including the `data` ones, whose compiled bytes ride inline in the record
240// rather than in a payload section it points at.
241macro_rules! __group_payload {
242    (stored; $($meta:tt)*) => { __meta_payload!($($meta)*) };
243    (build_only; $($meta:tt)*) => { __meta_payload!($($meta)*) };
244    (resource; $($meta:tt)*) => { AssetPayload::Compiled };
245}
246
247// The dense per-kind handle space a resource is assigned into, from its
248// `resource: <ResourceKind>` flag; `None` for anything outside that group.
249macro_rules! __meta_resource_kind {
250    () => { None };
251    (resource: $kind:ident $($r:tt)*) => { Some(crate::ecs::ResourceKind::$kind) };
252    ($t:tt $($r:tt)*) => { __meta_resource_kind!($($r)*) };
253}
254
255// Whether a resource's compiled bytes ride inline in its record (`data`) rather
256// than in a payload section the record points at.
257macro_rules! __meta_is_data {
258    () => { false };
259    (data $($r:tt)*) => { true };
260    ($t:tt $($r:tt)*) => { __meta_is_data!($($r)*) };
261}
262
263// Hand a callback the whole declarable vocabulary: concinnity-core's `stored`
264// and `resource` groups plus the `build_only` group this crate owns, in one
265// invocation shaped like a single list.
266//
267// Core's list is the outer one (it cannot name the group this crate holds), so
268// the composition goes through an adapter: core passes its groups to
269// `__append_build_only`, which forwards them as the prefix of
270// `for_each_build_only_type!`, which appends its own group and calls the real
271// callback.
272macro_rules! __append_build_only {
273    ($cb:ident; $($core_groups:tt)*) => {
274        $crate::for_each_build_only_type!($cb, $($core_groups)*);
275    };
276}
277
278macro_rules! for_each_authored_type {
279    ($cb:ident) => {
280        concinnity_core::for_each_component!(__append_build_only; $cb;);
281    };
282}
283
284// Generate `RegisteredType` and its authoring methods from the composed
285// vocabulary. Invoked once, below, via `for_each_authored_type!`. All authoring
286// metadata (origin, payload, args schema, validators, reference fields) derives
287// from each entry's `{ ... }` metadata block; the runtime `Component` trait
288// carries none of it.
289macro_rules! define_registered_type {
290    // Every registered type is here, whichever group it came from: one registry
291    // means one `parse`, so a caller asking "what type is this?" cannot miss a
292    // category. The groups are merged into one list tagged by group, so every
293    // method below stays a single repetition; what group membership decides
294    // reaches them through the `__group_*!` helpers.
295    (
296        stored: { $( $variant:ident => $ty:path { $($meta:tt)* } ),+ $(,)? },
297        resource: { $( $rvariant:ident => $rty:path { $($rmeta:tt)* } ),+ $(,)? },
298        build_only: { $( $bvariant:ident => $bty:path { $($bmeta:tt)* } ),+ $(,)? } $(,)?
299    ) => {
300        define_registered_type!(@all
301            $( $variant => $ty { $($meta)* } [stored] ),+ ,
302            $( $bvariant => $bty { $($bmeta)* } [build_only] ),+ ,
303            $( $rvariant => $rty { $($rmeta)* } [resource] ),+
304        );
305    };
306
307    (@all $( $variant:ident => $ty:path { $($meta:tt)* } [$group:ident] ),+ $(,)? ) => {
308        // One variant per registered component type, named for that type.
309        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
310        #[expect(missing_docs, reason = "one variant per registered component type, named for that type")]
311        pub enum RegisteredType {
312            $( $variant ),+
313        }
314
315        impl RegisteredType {
316            /// The type's registry name.
317            pub fn as_str(self) -> &'static str {
318                match self { $( Self::$variant => stringify!($variant) ),+ }
319            }
320            /// The on-disk blob tag / in-memory `ComponentId`, derived from the
321            /// shared `ComponentTag` enum (list position) so it matches the
322            /// runtime loader exactly.
323            ///
324            /// `None` for a build-only type: the cook expands it into the
325            /// components it stands for, so no record of it is ever written and
326            /// it has no tag to carry.
327            pub fn discriminant(self) -> Option<u8> {
328                match self {
329                    $( Self::$variant => __group_discriminant!($group; $variant) ),+
330                }
331            }
332            /// The type carrying a blob discriminant, or `None` if unknown. Only
333            /// a stored type has one.
334            pub(crate) fn from_discriminant(val: u8) -> Option<Self> {
335                $(
336                    if __group_discriminant!($group; $variant) == Some(val) {
337                        return Some(Self::$variant);
338                    }
339                )+
340                None
341            }
342            /// What this type denotes at tick time: the column a behavior can
343            /// scope or query against, or why there is none.
344            ///
345            /// Resolves through the substitute where a load-time pass leaves a
346            /// runtime marker behind, so `Prop` answers
347            /// `Column(RegisteredType::PropInstance)`.
348            pub fn scope_resolution(self) -> ScopeResolution {
349                match self {
350                    $( Self::$variant => __group_surviving!($group; $variant; $($meta)*) ),+
351                }
352            }
353
354            /// A name either matches a known type or it does not; callers that
355            /// want a message supply their own via `ok_or`/`ok_or_else`.
356            pub fn parse(s: &str) -> Option<Self> {
357                $(
358                    if s == stringify!($variant) { return Some(Self::$variant); }
359                )+
360                None
361            }
362            /// The name of this type's authored args schema struct: the
363            /// component itself for pass-through types, the `args:` override
364            /// for the divergent ones. The docs pipeline renders that struct's
365            /// fields as the asset's parameters.
366            pub fn args_struct_name(self) -> &'static str {
367                match self {
368                    $( Self::$variant => __meta_args_name!($variant; $($meta)*) ),+
369                }
370            }
371            /// This type's static authoring metadata.
372            pub fn registration(self) -> Registration {
373                match self {
374                    $(
375                        Self::$variant => Registration {
376                            type_name: stringify!($variant),
377                            origin: __group_origin!($group; $($meta)*),
378                            payload: __group_payload!($group; $($meta)*),
379                            default_args: serde_json::to_value(
380                                <__meta_args_ty!($ty; $($meta)*) as Default>::default(),
381                            )
382                            .ok(),
383                        }
384                    ),+
385                }
386            }
387            /// Bake a JSON args value into the blob record's component bytes:
388            /// deserialize through the typed args schema (interning name-string
389            /// cross-references), apply the type's bake-time validator, and
390            /// serialize the runtime component as postcard. For a pass-through
391            /// type the args ARE the component; a divergent type (`args:`
392            /// metadata) routes through its `bake` translation in
393            /// `bake_divergent`.
394            pub fn reserialize_args(self, args: &serde_json::Value) -> Result<Vec<u8>, CnResult> {
395                // Deserializing the args interns any name-string cross-reference,
396                // which needs the name resolver installed. The build pipeline
397                // resets the interner before it gets here; installing it again is
398                // a cheap no-op and lets standalone callers (e.g. `cn check`
399                // validation) deserialize without doing their own setup.
400                crate::ecs::asset_id::ensure_name_resolver();
401                match self {
402                    $(
403                        Self::$variant => {
404                            let typed = serde_json::from_value::<__meta_args_ty!($ty; $($meta)*)>(
405                                args.clone(),
406                            )
407                            .map_err(json_args_err)?;
408                            Ok(postcard::to_allocvec(
409                                &__meta_validate!(typed; $($meta)*),
410                            )?)
411                        }
412                    ),+
413                }
414            }
415            /// Normalize a JSON args value through the typed args schema: the
416            /// same deserialize + validate as `reserialize_args`, but back to
417            /// JSON with defaults filled and references resolved. Authoring
418            /// tools (`cn add`, the editor form) write this into world.jsonl;
419            /// the baked postcard bytes cannot round-trip to JSON.
420            pub fn normalized_args(
421                self,
422                args: &serde_json::Value,
423            ) -> Result<serde_json::Value, CnResult> {
424                crate::ecs::asset_id::ensure_name_resolver();
425                match self {
426                    $(
427                        Self::$variant => {
428                            let typed = serde_json::from_value::<__meta_args_ty!($ty; $($meta)*)>(
429                                args.clone(),
430                            )
431                            .map_err(json_args_err)?;
432                            serde_json::to_value(&__meta_validate!(typed; $($meta)*))
433                                .map_err(json_args_err)
434                        }
435                    ),+
436                }
437            }
438            /// The allowed values of a string-enum args field (in declaration
439            /// order), or `None` if `field` is a free-form string / absent / not a
440            /// string-enum. Probes the typed args by deserializing the defaults
441            /// with `field` set to a sentinel: a string-enum yields serde's
442            /// "unknown variant ..., expected ..." which `parse_expected_variants`
443            /// reads; a free string accepts the sentinel and yields `None`. Used by
444            /// authoring tools to offer a picker instead of a free text box; it
445            /// degrades to `None` (free text) if serde's phrasing ever changes.
446            pub fn field_enum_variants(self, field: &str) -> Option<Vec<String>> {
447                const SENTINEL: &str = "\u{0}__cn_enum_probe_sentinel__";
448                match self {
449                    $(
450                        Self::$variant => {
451                            let mut probe = match serde_json::to_value(
452                                <__meta_args_ty!($ty; $($meta)*) as Default>::default(),
453                            ) {
454                                Ok(serde_json::Value::Object(m)) => m,
455                                _ => return None,
456                            };
457                            probe.get(field)?;
458                            probe.insert(
459                                field.to_string(),
460                                serde_json::Value::String(SENTINEL.to_string()),
461                            );
462                            match serde_json::from_value::<__meta_args_ty!($ty; $($meta)*)>(
463                                serde_json::Value::Object(probe),
464                            ) {
465                                Ok(_) => None,
466                                Err(e) => parse_expected_variants(&e.to_string()),
467                            }
468                        }
469                    ),+
470                }
471            }
472            /// The dense per-kind handle space this asset is assigned into, or
473            /// `None` if it is not a resource asset. Cook assigns the handle;
474            /// the runtime addresses the resource by it.
475            pub(crate) fn resource_kind(self) -> Option<crate::ecs::ResourceKind> {
476                match self {
477                    $( Self::$variant => __meta_resource_kind!($($meta)*) ),+
478                }
479            }
480            /// Whether this type is a resource asset: compiled into the blob's
481            /// resource stream and reached by a handle, rather than stored in a
482            /// component column.
483            pub fn is_resource(self) -> bool {
484                self.resource_kind().is_some()
485            }
486            /// Whether this resource's compiled bytes ride inline in its record
487            /// rather than in a payload section the record points at. False for
488            /// everything that is not a resource asset.
489            pub(crate) fn is_data(self) -> bool {
490                match self {
491                    $( Self::$variant => __meta_is_data!($($meta)*) ),+
492                }
493            }
494            /// The asset-reference fields of this type, as (field, target_type),
495            /// from the entry's `refs:` metadata.
496            pub fn ref_fields(self) -> &'static [(&'static str, &'static str)] {
497                match self {
498                    $(
499                        Self::$variant => __meta_refs!($($meta)*)
500                    ),+
501                }
502            }
503            /// The structural flags, from the entry's metadata: `singleton`
504            /// (at most one instance belongs to a world; authoring tools use an
505            /// edit-or-add flow), `useful_blank` (meaningful when declared with
506            /// only default args, so authoring tools offer a plain add), and
507            /// `renders` (presence implies the world renders; drives the
508            /// GraphicsConfig companion injection at build time).
509            pub fn singleton(self) -> bool {
510                match self {
511                    $( Self::$variant => __meta_singleton!($($meta)*) ),+
512                }
513            }
514            /// Whether declaring the type with no args still does something useful.
515            pub fn useful_blank(self) -> bool {
516                match self {
517                    $( Self::$variant => __meta_useful_blank!($($meta)*) ),+
518                }
519            }
520            /// Whether declaring the type implies the world renders.
521            pub fn renders(self) -> bool {
522                match self {
523                    $( Self::$variant => __meta_renders!($($meta)*) ),+
524                }
525            }
526            /// Whether the running world re-reads this type's column every
527            /// frame. An editing tool holding a live world can overwrite such
528            /// a component in place and see the change on the next draw,
529            /// instead of reloading the world to apply it.
530            ///
531            /// Flagging a type asserts two things: its column still holds
532            /// entities at tick time and some system reads them afresh, AND no
533            /// build-time expansion reads its args -- an in-place write never
534            /// runs the expansion, so a type another asset is generated from
535            /// would leave that generated asset standing on the old values.
536            ///
537            /// An expansion is not the only thing that reads args at build
538            /// time; the reference graph that decides how payloads pack and the
539            /// cross-asset validator do too. A type carrying one of those can
540            /// still be flagged, so long as the writer declines the edits that
541            /// would move it.
542            pub fn live(self) -> bool {
543                match self {
544                    $( Self::$variant => __meta_live!($($meta)*) ),+
545                }
546            }
547            /// Whether a world may declare this type directly.
548            pub fn addable(self) -> bool {
549                self.registration().addable()
550            }
551            /// Every registered component type, in list order.
552            pub fn all() -> &'static [RegisteredType] {
553                &[ $( Self::$variant ),+ ]
554            }
555            /// Every type a world may declare, with its registration metadata.
556            pub fn addable_types() -> impl Iterator<Item = (RegisteredType, Registration)> {
557                Self::all()
558                    .iter()
559                    .map(|t| (*t, t.registration()))
560                    .filter(|(_, reg)| reg.addable())
561            }
562        }
563    };
564}
565
566for_each_authored_type!(define_registered_type);
567
568/// The authored-value trait: the bridge from a typed authoring struct to the
569/// world line that declares it. Implemented for every declarable asset's args
570/// schema (the `args:` override where the authored form diverges from the
571/// component, the component itself where it does not) and for every resource
572/// asset, so a caller hands the cook a typed value instead of a name/type/args
573/// triple assembled by hand.
574pub trait Authored: serde::Serialize {
575    /// The registered asset type, as it appears in a world line's `type`.
576    const TYPE: &'static str;
577}
578
579// Runtime-only entries are never authored, so they get no impl; every other
580// entry (including the `manual` ones, whose hand-written `Component` impl is
581// unrelated to authoring) resolves its authored type through `__meta_args_ty`.
582macro_rules! __authored_component {
583    (
584        stored: { $( $variant:ident => $ty:path { $($meta:tt)* } ),+ $(,)? },
585        resource: { $( $rvariant:ident => $rty:path { $($rmeta:tt)* } ),+ $(,)? },
586        build_only: { $( $bvariant:ident => $bty:path { $($bmeta:tt)* } ),+ $(,)? } $(,)?
587    ) => {
588        $( __authored_component!(@one $variant $ty { $($meta)* }); )+
589        $( __authored_component!(@one $rvariant $rty { $($rmeta)* }); )+
590        $( __authored_component!(@one $bvariant $bty { $($bmeta)* }); )+
591    };
592    (@one $variant:ident $ty:path { runtime $($rest:tt)* }) => {};
593    (@one $variant:ident $ty:path { $($meta:tt)* }) => {
594        impl Authored for __meta_args_ty!($ty; $($meta)*) {
595            const TYPE: &'static str = stringify!($variant);
596        }
597    };
598}
599
600for_each_authored_type!(__authored_component);
601
602/// Serialize one authored asset into the world line that declares it, newline
603/// included. The caller never names a JSON type: the line is finished text.
604pub fn asset_line<T: Authored>(name: &str, value: &T) -> std::io::Result<String> {
605    let bad =
606        |e: serde_json::Error| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string());
607    let args = serde_json::to_value(value).map_err(bad)?;
608    let line = serde_json::json!({ "name": name, "type": T::TYPE, "args": args });
609    let mut out = serde_json::to_string(&line).map_err(bad)?;
610    out.push('\n');
611    Ok(out)
612}
613
614/// Write a reference into an already-serialized asset line: `field` is set to
615/// the asset name `target`, the form the compile resolves to a handle. A
616/// typed authored value cannot carry the name itself (a reference field holds
617/// the resolved handle), so a builder names it after the fact.
618pub fn set_reference(line: &str, field: &str, target: &str) -> std::io::Result<String> {
619    let bad = |msg: String| std::io::Error::new(std::io::ErrorKind::InvalidData, msg);
620    let mut value: serde_json::Value =
621        serde_json::from_str(line).map_err(|e| bad(format!("asset line: {e}")))?;
622    value
623        .get_mut("args")
624        .and_then(|args| args.as_object_mut())
625        .ok_or_else(|| bad(format!("asset line has no args to hold '{field}'")))?
626        .insert(
627            field.to_string(),
628            serde_json::Value::String(target.to_string()),
629        );
630    let mut out = serde_json::to_string(&value).map_err(|e| bad(e.to_string()))?;
631    out.push('\n');
632    Ok(out)
633}
634
635#[cfg(test)]
636mod authored_tests {
637    use super::*;
638
639    #[test]
640    fn set_reference_names_a_field_the_typed_value_cannot_carry() {
641        let line = asset_line("hero_shape", &crate::components::CharacterShape::default())
642            .expect("serializes");
643        let patched = set_reference(&line, "target", "hero").expect("patched");
644        assert!(patched.ends_with('\n'));
645        let value: serde_json::Value = serde_json::from_str(&patched).expect("parses");
646        assert_eq!(value["args"]["target"], "hero");
647        assert_eq!(value["name"], "hero_shape");
648        assert_eq!(value["type"], "CharacterShape");
649        // A line that is not an asset declaration is refused, not mangled.
650        let err = set_reference("7", "target", "hero").expect_err("not an asset line");
651        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
652        let err = set_reference(r#"{"name":"x"}"#, "target", "hero").expect_err("no args");
653        assert!(err.to_string().contains("no args"), "{err}");
654    }
655
656    // The three shapes the generation has to cover: an args-schema override, a
657    // pass-through component, and a resource asset.
658    #[test]
659    fn authored_types_report_their_registered_name() {
660        assert_eq!(
661            <concinnity_core::components::cook::Room as Authored>::TYPE,
662            "Room"
663        );
664        assert_eq!(
665            <crate::components::DirectionalLight as Authored>::TYPE,
666            "DirectionalLight"
667        );
668        assert_eq!(<crate::components::Texture as Authored>::TYPE, "Texture");
669        assert_eq!(
670            <crate::components::EnvironmentMap as Authored>::TYPE,
671            "EnvironmentMap"
672        );
673    }
674
675    // Every Authored type names a type the registry can actually parse, so a
676    // value handed to the cook always lands on a declarable asset.
677    #[test]
678    fn the_reported_name_round_trips_through_the_registry() {
679        for name in [
680            <concinnity_core::components::cook::Room as Authored>::TYPE,
681            <concinnity_core::components::cook::Camera3D as Authored>::TYPE,
682            <crate::components::DirectionalLight as Authored>::TYPE,
683        ] {
684            assert!(
685                RegisteredType::parse(name).is_some(),
686                "{name} is not a registered component type"
687            );
688        }
689    }
690}
691
692// JSON args that fail the typed schema are an authoring error. Core dropped
693// its `From<serde_json::Error>` conversion along with runtime JSON parsing,
694// so the build side maps the error here.
695fn json_args_err(e: serde_json::Error) -> CnResult {
696    tracing::error!("JSON args error: {}", e);
697    CnResult::InvalidArgument
698}
699
700/// Bake the runtime component for the asset types whose baked form diverges
701/// from their authored args (the entries with `args:` metadata): run the type's
702/// `bake` translation at build time and serialize the component itself, which
703/// the type's `from_baked` deserializes at load. Pass-through types return
704/// `None`; cook reserializes their args (which ARE the component).
705pub fn bake_divergent(
706    ct: RegisteredType,
707    args: &serde_json::Value,
708) -> Result<Option<Vec<u8>>, CnResult> {
709    // Deserializing the args interns name-string cross-references, exactly as
710    // `reserialize_args` does.
711    crate::ecs::asset_id::ensure_name_resolver();
712    macro_rules! bake {
713        ($ty:ty, $args_ty:ty) => {{
714            let typed = serde_json::from_value::<$args_ty>(args.clone()).map_err(json_args_err)?;
715            Ok(Some(postcard::to_allocvec(&<$ty>::bake(typed))?))
716        }};
717    }
718    match ct {
719        RegisteredType::Camera3D => {
720            bake!(
721                crate::components::Camera3D,
722                concinnity_core::components::cook::Camera3D
723            )
724        }
725        RegisteredType::Room => bake!(
726            crate::components::Room,
727            concinnity_core::components::cook::Room
728        ),
729        RegisteredType::File => bake!(
730            crate::components::File,
731            concinnity_core::components::cook::File
732        ),
733        RegisteredType::Spawner => {
734            bake!(
735                crate::components::Spawner,
736                concinnity_core::components::cook::Spawner
737            )
738        }
739        RegisteredType::AppConfig => {
740            bake!(
741                crate::components::AppConfig,
742                concinnity_core::components::cook::AppConfig
743            )
744        }
745        _ => Ok(None),
746    }
747}
748
749/// Whether an asset type's presence implies the world renders: the registry's
750/// `renders` flag, across both the component and resource registries. Matches
751/// by normalized name (case-insensitive, underscores stripped) so cook's
752/// companion pass and authoring tools classify the same way.
753pub fn type_renders(asset_type: &str) -> bool {
754    let norm: String = asset_type.chars().filter(|c| *c != '_').collect();
755    let matches = |name: &str| name.eq_ignore_ascii_case(&norm);
756    RegisteredType::all()
757        .iter()
758        .any(|t| t.renders() && matches(t.as_str()))
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    #[test]
766    fn registration_predicates_follow_origin_and_payload() {
767        let reg = |origin, payload| Registration {
768            type_name: "T",
769            origin,
770            payload,
771            default_args: None,
772        };
773        let external = reg(AssetOrigin::External, AssetPayload::Compiled);
774        assert!(external.addable());
775        assert!(external.needs_compilation());
776
777        let runtime = reg(AssetOrigin::RuntimeOnly, AssetPayload::None);
778        assert!(!runtime.addable());
779        assert!(!runtime.needs_compilation());
780
781        let build = reg(AssetOrigin::BuildOnly, AssetPayload::None);
782        assert!(!build.addable());
783        assert!(!build.needs_compilation());
784    }
785
786    // The divergent bake produces bytes the type's `from_baked` reconstructs
787    // to the same component `from_args` builds at runtime: the baked path and
788    // the authored path converge on identical components.
789    #[test]
790    fn bake_divergent_round_trips_through_from_baked() {
791        use crate::ecs::Component;
792        use crate::ecs::asset_id;
793
794        let args = serde_json::json!({"size": [16.0, 20.0, 3.5]});
795        let bytes = bake_divergent(RegisteredType::Room, &args)
796            .unwrap()
797            .expect("Room bakes divergently");
798        let baked = crate::components::Room::from_baked(&bytes).unwrap();
799        // The size shorthand resolved at bake time.
800        assert_eq!(baked.half_width, 8.0);
801        assert_eq!(baked.half_depth, 10.0);
802        assert_eq!(baked.ceiling_height, 3.5);
803
804        let args = serde_json::json!({"position": [1.0, 2.0, 3.0], "yaw": 0.5});
805        let bytes = bake_divergent(RegisteredType::Camera3D, &args)
806            .unwrap()
807            .expect("Camera3D bakes divergently");
808        let baked = crate::components::Camera3D::from_baked(&bytes).unwrap();
809        assert_eq!(baked.position, [1.0, 2.0, 3.0]);
810        // The view matrix composed at bake time.
811        let expected = crate::components::Camera3D::bake(
812            serde_json::from_value(serde_json::json!({"position": [1.0, 2.0, 3.0], "yaw": 0.5}))
813                .unwrap(),
814        );
815        assert_eq!(baked.view_matrix, expected.view_matrix);
816
817        let args = serde_json::json!({"path": "tri.obj"});
818        let bytes = bake_divergent(RegisteredType::File, &args)
819            .unwrap()
820            .expect("File bakes divergently");
821        let baked = crate::components::File::from_baked(&bytes).unwrap();
822        // The kind derived from the extension at bake time.
823        assert!(baked.kind.is_some());
824
825        asset_id::reset_interner();
826        let args = serde_json::json!({"template": "crate", "interval": -1.0, "lifetime": 2.0});
827        let bytes = bake_divergent(RegisteredType::Spawner, &args)
828            .unwrap()
829            .expect("Spawner bakes divergently");
830        let baked = crate::components::Spawner::from_baked(&bytes).unwrap();
831        // The interval clamped and the runtime counters zeroed at bake time.
832        assert_eq!(baked.interval, 0.0);
833        assert_eq!(baked.elapsed, 0.0);
834        assert_eq!(baked.count, 0);
835
836        // A pass-through type does not bake divergently.
837        assert!(
838            bake_divergent(RegisteredType::PointLight, &serde_json::json!({}))
839                .unwrap()
840                .is_none()
841        );
842    }
843
844    #[test]
845    fn component_types_round_trip_name_and_discriminant() {
846        for &ty in RegisteredType::all() {
847            assert_eq!(RegisteredType::parse(ty.as_str()), Some(ty));
848            // Only a stored type carries a tag, so only those round trip
849            // through one. That the rest have none is the assertion for them.
850            match ty.discriminant() {
851                Some(d) => assert_eq!(RegisteredType::from_discriminant(d), Some(ty)),
852                None => assert!(
853                    ty.registration().origin == AssetOrigin::BuildOnly || ty.is_resource(),
854                    "{} has no discriminant but is neither build-only nor a resource",
855                    ty.as_str()
856                ),
857            }
858        }
859        assert_eq!(RegisteredType::parse("NotARealComponent"), None);
860        assert_eq!(RegisteredType::from_discriminant(255), None);
861    }
862
863    // Exactly one group has a column. A build-only type is expanded away by the
864    // cook and a resource compiles into the resource stream, so neither may be
865    // handed a component tag; the facts must not drift apart.
866    #[test]
867    fn only_stored_types_carry_a_discriminant() {
868        for &ty in RegisteredType::all() {
869            let stored = !(ty.registration().origin == AssetOrigin::BuildOnly || ty.is_resource());
870            assert_eq!(
871                ty.discriminant().is_some(),
872                stored,
873                "{} disagrees about whether it is stored in a column",
874                ty.as_str()
875            );
876        }
877    }
878
879    // On-disk discriminants must stay unique and inside the component range; the
880    // only iterator over the full list is `RegisteredType::all`, so the invariant
881    // is checked here even though the discriminants are a runtime/blob concern.
882    #[test]
883    fn component_discriminants_are_unique_and_in_range() {
884        let mut seen = std::collections::HashSet::new();
885        for &ty in RegisteredType::all() {
886            let Some(d) = ty.discriminant() else { continue };
887            assert!(
888                d < 128,
889                "{} discriminant {} outside the component range",
890                ty.as_str(),
891                d
892            );
893            assert!(seen.insert(d), "duplicate discriminant {d}");
894        }
895    }
896
897    // The docs pipeline renders an asset's parameters from the fields of the
898    // struct this names, looked up by the struct's own name in the extracted
899    // schema. A divergent asset's registry entry names the asset (`args: Room`)
900    // and its schema is declared as `RoomArgs`, so the two are
901    // bridged by that naming convention; a rename on either side that broke it
902    // would silently render an empty parameter table.
903    #[test]
904    fn a_divergent_asset_names_the_schema_struct_the_docs_render() {
905        assert_eq!(RegisteredType::Room.args_struct_name(), "RoomArgs");
906        assert_eq!(RegisteredType::Camera3D.args_struct_name(), "Camera3DArgs");
907        assert_eq!(RegisteredType::File.args_struct_name(), "FileArgs");
908        assert_eq!(RegisteredType::Spawner.args_struct_name(), "SpawnerArgs");
909        assert_eq!(
910            RegisteredType::AppConfig.args_struct_name(),
911            "AppConfigArgs"
912        );
913        // A pass-through asset's schema is the asset itself, whichever group it
914        // is in.
915        assert_eq!(RegisteredType::PointLight.args_struct_name(), "PointLight");
916        assert_eq!(RegisteredType::Prefab.args_struct_name(), "Prefab");
917        assert_eq!(RegisteredType::Texture.args_struct_name(), "Texture");
918    }
919
920    #[test]
921    fn reserialize_args_round_trips_and_rejects_bad_types() {
922        let ty = RegisteredType::parse("ProceduralMesh").unwrap();
923        let bytes = ty
924            .reserialize_args(&serde_json::json!({ "source": "a.glb" }))
925            .unwrap();
926        let back: crate::components::ProceduralMesh = postcard::from_bytes(&bytes).unwrap();
927        assert_eq!(back.source.as_deref(), Some("a.glb"));
928        assert_eq!(
929            ty.reserialize_args(&serde_json::json!({ "source": 42 }))
930                .unwrap_err(),
931            CnResult::InvalidArgument
932        );
933    }
934
935    #[test]
936    fn normalized_args_fills_defaults_and_rejects_bad_types() {
937        let ty = RegisteredType::parse("ProceduralMesh").unwrap();
938        let back = ty
939            .normalized_args(&serde_json::json!({ "generator": "box" }))
940            .unwrap();
941        assert_eq!(back["generator"], "box");
942        assert!(back.get("half_width").is_some(), "defaults fill in");
943        assert_eq!(
944            ty.normalized_args(&serde_json::json!({ "generator": 42 }))
945                .unwrap_err(),
946            CnResult::InvalidArgument
947        );
948    }
949
950    // Convention guard for the asset-reference contract: a user-declarable
951    // asset's `args` is its public JSON schema: always a JSON object of common
952    // types, never a bare scalar or enum. `Component::Args` must therefore
953    // serialize to a JSON object, and its `Default` must construct and serialize
954    // cleanly. Internal/runtime-only assets (e.g. command enums) are exempt.
955    #[test]
956    fn declarable_assets_have_object_args_schemas() {
957        for &ty in RegisteredType::all() {
958            let reg = ty.registration();
959            if !reg.addable() {
960                continue;
961            }
962            let default_args = reg.default_args.as_ref().unwrap_or_else(|| {
963                panic!(
964                    "{}: Args::default() failed to serialize to JSON",
965                    ty.as_str()
966                )
967            });
968            assert!(
969                default_args.is_object(),
970                "{}: args schema is not a JSON object (got {default_args}). A declarable \
971                 asset's args must be a JSON object of common types.",
972                ty.as_str()
973            );
974        }
975    }
976
977    // `field_enum_variants` returns a string-enum field's allowed values (in
978    // declaration order) and `None` for a free-form string or a non-enum field.
979    #[test]
980    fn field_enum_variants_reports_string_enum_values() {
981        assert_eq!(
982            RegisteredType::Sprite.field_enum_variants("fit"),
983            Some(vec!["fit".into(), "cover".into(), "bottom".into()])
984        );
985        assert_eq!(
986            RegisteredType::TextLabel.field_enum_variants("align"),
987            Some(vec!["left".into(), "center".into(), "right".into()])
988        );
989        // A two-variant enum uses serde's "expected `a` or `b`" phrasing.
990        assert_eq!(
991            RegisteredType::AudioCue.field_enum_variants("kind"),
992            Some(vec!["music".into(), "sound".into()])
993        );
994        // A free-form string field is not an enum.
995        assert_eq!(
996            RegisteredType::HitRegion.field_enum_variants("action"),
997            None
998        );
999        assert_eq!(RegisteredType::KeyBinding.field_enum_variants("key"), None);
1000        // An absent field yields None (not a panic).
1001        assert_eq!(RegisteredType::Sprite.field_enum_variants("nope"), None);
1002        // A non-string field (probing it errors on type, not "unknown variant").
1003        assert_eq!(RegisteredType::Sprite.field_enum_variants("x"), None);
1004    }
1005
1006    // `ref_fields` reports each type's asset-reference fields and their targets;
1007    // every referenced target must itself be a real component type.
1008    #[test]
1009    fn ref_fields_name_real_target_types() {
1010        assert_eq!(
1011            RegisteredType::Decal.ref_fields(),
1012            &[("texture", "Texture")]
1013        );
1014        assert_eq!(
1015            RegisteredType::AudioEmitter.ref_fields(),
1016            &[("clip", "AudioClip"), ("prop", "Prop")]
1017        );
1018        // A type without references reports none.
1019        assert!(RegisteredType::PointLight.ref_fields().is_empty());
1020        // Every declared ref field names an existing arg key and a real target
1021        // type -- either a component or a resource-only asset (e.g. AudioClip,
1022        // which has left the component registry).
1023        for &ty in RegisteredType::all() {
1024            let default_args = ty.registration().default_args;
1025            for &(field, target) in ty.ref_fields() {
1026                assert!(
1027                    RegisteredType::parse(target).is_some(),
1028                    "{}.{field} targets unknown type {target}",
1029                    ty.as_str()
1030                );
1031                if let Some(serde_json::Value::Object(m)) = &default_args {
1032                    assert!(
1033                        m.contains_key(field),
1034                        "{}.{field} is not an arg of {}",
1035                        ty.as_str(),
1036                        ty.as_str()
1037                    );
1038                }
1039            }
1040        }
1041    }
1042
1043    // The structural flags mark the curated sets: the world-config singletons,
1044    // the render-implying types (which must match the companion pass's
1045    // GraphicsConfig triggers), and the blank-useful addables. Flag rules: a
1046    // flagged type must be declarable (singletons and blank-addables are
1047    // authored), and the two picker sets stay disjoint (a singleton uses the
1048    // edit-or-add flow, never the plain add).
1049    #[test]
1050    fn structural_flags_mark_the_curated_sets() {
1051        let flagged = |f: fn(RegisteredType) -> bool| -> Vec<&'static str> {
1052            RegisteredType::all()
1053                .iter()
1054                .copied()
1055                .filter(|&t| f(t))
1056                .map(RegisteredType::as_str)
1057                .collect()
1058        };
1059        assert_eq!(
1060            flagged(RegisteredType::singleton),
1061            [
1062                "Window",
1063                "GraphicsConfig",
1064                "PostProcessConfig",
1065                "StreamingConfig",
1066                "PhysicsConfig",
1067                "AppConfig",
1068                "Variables",
1069                "LoadingOverlay",
1070                "EngineDefaults",
1071                "SkyRotation",
1072            ]
1073        );
1074        assert_eq!(
1075            flagged(RegisteredType::renders),
1076            [
1077                "GraphicsConfig",
1078                "Prop",
1079                "TextLabel",
1080                "InstancedProp",
1081                "VoxelWorld",
1082                "Sprite",
1083                "WaterSurface",
1084                "SdfVolume",
1085                "LayoutContainer",
1086                "StatHud",
1087                "DebugHud",
1088                "TextInput",
1089                "LoadingOverlay",
1090                // The build-only group sorts after the stored one, and the
1091                // resource group after that.
1092                "MainMenu",
1093                "EnvironmentMap",
1094                "SkinnedMesh",
1095            ]
1096        );
1097        for &ty in RegisteredType::all() {
1098            if ty.useful_blank() {
1099                assert!(
1100                    ty.addable(),
1101                    "{} is offered for a plain add but is not External",
1102                    ty.as_str()
1103                );
1104            }
1105            if ty.singleton() {
1106                assert!(
1107                    ty.registration().origin != AssetOrigin::RuntimeOnly,
1108                    "{} is a singleton but never declarable",
1109                    ty.as_str()
1110                );
1111            }
1112            assert!(
1113                !(ty.singleton() && ty.useful_blank()),
1114                "{} cannot be both a singleton and a plain addable",
1115                ty.as_str()
1116            );
1117        }
1118    }
1119
1120    // The two-registry render classifier: exact names, forgiving spellings,
1121    // resource-registry types, and non-renderers.
1122    #[test]
1123    fn type_renders_spans_both_registries() {
1124        assert!(type_renders("TextLabel"));
1125        assert!(type_renders("text_label"));
1126        assert!(type_renders("GraphicsConfig"));
1127        assert!(type_renders("EnvironmentMap"));
1128        // A skinned mesh is placed directly and rendered, so its presence
1129        // renders even without any static Mesh/Prop in the world.
1130        assert!(type_renders("SkinnedMesh"));
1131        assert!(type_renders("skinned_mesh"));
1132        assert!(!type_renders("Window"));
1133        // A raw Mesh is inert geometry (rendered only through a Prop/Model), so
1134        // unlike SkinnedMesh it does not by itself render.
1135        assert!(!type_renders("Mesh"));
1136        assert!(!type_renders("NotARealType"));
1137    }
1138
1139    #[test]
1140    fn parse_expected_variants_handles_serde_phrasings() {
1141        assert_eq!(
1142            parse_expected_variants("unknown variant `z`, expected one of `a`, `b`, `c`"),
1143            Some(vec!["a".into(), "b".into(), "c".into()])
1144        );
1145        assert_eq!(
1146            parse_expected_variants("unknown variant `z`, expected `a` or `b`"),
1147            Some(vec!["a".into(), "b".into()])
1148        );
1149        assert_eq!(
1150            parse_expected_variants("unknown variant `z`, expected `only`"),
1151            Some(vec!["only".into()])
1152        );
1153        // A type-mismatch error has no backtick list after `expected`.
1154        assert_eq!(
1155            parse_expected_variants("invalid type: string \"z\", expected u32"),
1156            None
1157        );
1158    }
1159
1160    // The per-instance components an entity is composed from are RuntimeOnly:
1161    // never authored in a world, never in the asset reference, and exempt from
1162    // the declarable-args contract above. Guard that they stay that way so a
1163    // stray `External` origin can't leak one into the authoring surface.
1164    #[test]
1165    fn per_instance_components_are_runtime_only() {
1166        for ty in [
1167            RegisteredType::Transform,
1168            RegisteredType::MeshRenderer,
1169            RegisteredType::ModelRenderer,
1170            RegisteredType::Collider,
1171            RegisteredType::Interactable,
1172            RegisteredType::Pickup,
1173            RegisteredType::Parent,
1174            RegisteredType::Children,
1175            RegisteredType::SceneMember,
1176            RegisteredType::GlobalTransform,
1177            RegisteredType::RenderHandle,
1178            RegisteredType::Held,
1179        ] {
1180            assert!(
1181                !ty.registration().addable(),
1182                "{} must be RuntimeOnly (not declarable)",
1183                ty.as_str()
1184            );
1185        }
1186    }
1187}