Skip to main content

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