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}