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}