concinnity-dev 0.19.119

The Concinnity dev tooling library: world authoring, the in-engine editor, the debug server, docs and packaging
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
//! Shared in-memory build orchestration

use std::path::Path;

use concinnity_cook::WorldSource;
use concinnity_cook::authoring::registry::RegisteredType;
pub(crate) use concinnity_cook::build_compiled;
use concinnity_cook::build_only::LoadedWorld;
use concinnity_core::ecs::{ComponentAsset, World};
use concinnity_engine::ecs::BlobMeshBounds;
use concinnity_engine::ecs::BlobSceneGroups;
use concinnity_engine::resource::ColorLutSources;
use concinnity_engine::resource::EnvironmentMapSourceInfo;
use concinnity_engine::resource::EnvironmentMapSources;
use concinnity_engine::resource::MaterialNames;
use concinnity_engine::resource::MeshSource;
use concinnity_engine::resource::MeshSources;
use concinnity_engine::resource::TextureSource;
use concinnity_engine::resource::TextureSources;
use concinnity_engine::resource::install_resource_tables;
use concinnity_host::store::blob::BlobData;

use crate::build_status::{BuildStatus, Verbosity};

// Load, validate, and (when server credentials are present) fetch the missing
// source files for a world. The returned LoadedWorld has passed the full
// validation front half and is ready for concinnity_cook::build_compiled.
//
// This is the shared front half of every in-memory build: `build_world_from_path`
// (the interpreted `cn debug` run) funnels through here so
// validation and asset fetching behave identically. The `cn build` blob path
// prepares through concinnity_cook directly and does not use this.
pub(crate) fn prepare(source: WorldSource<'_>) -> std::io::Result<LoadedWorld> {
    let loaded = concinnity_cook::prepare_world(source, crate::project::assets_dir().as_deref())
        .map_err(|errs| crate::authoring::report_validation_errors(&errs))?;

    Ok(loaded)
}

// The first declared ColorLut's authored `source` path (non-empty), or `None`.
// Dev-only; feeds the hot-reload watcher.
fn scan_color_lut_source(
    assets: &[concinnity_cook::authoring::world::WorldJsonlAsset],
) -> Option<String> {
    assets
        .iter()
        .find(|a| a.asset_type == RegisteredType::ColorLut)
        .and_then(|a| a.args.get("source").and_then(|v| v.as_str()))
        .filter(|s| !s.is_empty())
        .map(str::to_string)
}

// The first declared file-backed EnvironmentMap's re-bake inputs, or `None` (a
// procedural `generator` has no file to watch). The face-size / sample defaults
// mirror the EnvironmentMap schema defaults in `concinnity-core/src/components/environment_map.rs`.
fn scan_environment_map_source(
    assets: &[concinnity_cook::authoring::world::WorldJsonlAsset],
) -> Option<EnvironmentMapSourceInfo> {
    let a = assets
        .iter()
        .find(|a| a.asset_type == RegisteredType::EnvironmentMap)?;
    let generator = a
        .args
        .get("generator")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let source = a.args.get("source").and_then(|v| v.as_str()).unwrap_or("");
    if !generator.is_empty() || source.is_empty() {
        return None;
    }
    let u32_arg = |key: &str, default: u32| {
        a.args
            .get(key)
            .and_then(|v| v.as_u64())
            .map(|v| v as u32)
            .unwrap_or(default)
    };
    Some(EnvironmentMapSourceInfo {
        source: source.to_string(),
        prefilter_face_size: u32_arg("prefilter_face_size", 512),
        irradiance_face_size: u32_arg("irradiance_face_size", 8),
        prefilter_samples: u32_arg("prefilter_samples", 1024),
        prefilter_clamp: a
            .args
            .get("prefilter_clamp")
            .and_then(|v| v.as_f64())
            .map(|v| v as f32)
            .unwrap_or(12.0),
    })
}

/// Compile a prepared world and assemble it into an in-memory World, ready to
/// run without touching any blob files on disk.
fn world_from_loaded(loaded: LoadedWorld) -> std::io::Result<World> {
    // Capture the dev-only hot-reload source info for the singleton ColorLut and
    // EnvironmentMap resources BEFORE `build_compiled` consumes the asset list.
    // These kinds are authored (never injected by an expansion pass), so the raw
    // world list carries every one; the renderer's `capture_sources` path reads
    // these to seed the file-reload watcher now that the drained `source` field is
    // gone. Only the first of each is used (the runtime uses handle 0).
    let color_lut_source = scan_color_lut_source(&loaded.assets);
    let environment_map_source = scan_environment_map_source(&loaded.assets);

    let mut result = build_compiled(
        loaded.assets,
        crate::project::assets_dir().as_deref(),
        crate::cook_platform(),
    )?;

    // The material name catalog, read before the result is taken apart below.
    let material_names =
        MaterialNames(result.resource_names(concinnity_core::blob::ResourceKind::Material));

    let payload_sections: Vec<Option<Vec<u8>>> = result.payloads.into_iter().map(Some).collect();
    let mut world = concinnity_engine::blob::world_from(BlobData::new(payload_sections));
    // Identify every entity as it is minted, matching the shipped runtime's
    // `load_blob`, so any asset id resolves to its entity.
    for def in &result.defs {
        let mut component = ComponentAsset::from_baked(def).map_err(|e| {
            std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                format!("Asset construction failed: {e}"),
            )
        })?;
        if let Some(locator) = &def.payload {
            component.inject_locator(locator.clone());
        }
        world.add(component, def.name);
    }
    // Load the compiled resource stream into its per-kind tables, exactly as the
    // shipped runtime's `load_blob` does, so the in-memory `cn debug` world reads
    // audio clips and textures by handle too.
    install_resource_tables(&mut world, &mut result.resources);
    world.insert_resource(BlobSceneGroups(result.scene_groups));
    world.insert_resource(BlobMeshBounds(result.mesh_bounds));
    if let Some(budget) = result.physics_budget {
        world.insert_resource(concinnity_core::ecs::WorldPhysicsBudget(budget));
    }
    // Dev-only source catalogs for the hot-reload watcher (see the scan above).
    world.insert_resource(ColorLutSources(color_lut_source));
    world.insert_resource(EnvironmentMapSources(environment_map_source));
    // Dev-only: the texture source catalog, so the renderer's hot-reload
    // capture and the runtime spawn-by-name path can map a texture handle back to
    // its file / name. Not present in the shipped `load_blob` path.
    world.insert_resource(TextureSources(
        result
            .texture_sources
            .iter()
            .map(|t| TextureSource {
                name_id: t.name_id,
                source: t.source.clone(),
                image_index: t.image_index,
            })
            .collect(),
    ));
    // Dev-only: the material name catalog, so the editor's live draw seam can
    // resolve a material an edit names to the handle it was compiled at.
    world.insert_resource(material_names);
    // Dev-only: the mesh source catalog, so the renderer's hot-reload capture
    // can map a mesh handle back to the `.glb`/`.fbx` that backs it.
    world.insert_resource(MeshSources(
        result
            .mesh_sources
            .iter()
            .map(|m| MeshSource {
                source: m.source.clone(),
                primitive_index: m.primitive_index,
                lod_levels: m.lod_levels,
                lod_distances: m.lod_distances.clone(),
            })
            .collect(),
    ));
    Ok(world)
}

/// Run the full in-memory pipeline on a world.jsonl string, returning a
/// ready-to-run World without touching any blob files on disk. The editor uses
/// this to boot an empty (or otherwise non-renderable) world from a seeded
/// Window so one still opens.
pub(crate) fn build_world_from_str<'a>(
    source: impl Into<WorldSource<'a>>,
) -> std::io::Result<World> {
    Ok(build_world_and_shadows(source)?.0)
}

/// `build_world_from_str`, plus the pre-merge args of every generated asset the
/// world patches. An authored line over a generated asset is a sparse patch, so
/// a tool that re-derives one asset's effective args from its line alone needs
/// the baseline the patch merges over.
pub(crate) fn build_world_and_shadows<'a>(
    source: impl Into<WorldSource<'a>>,
) -> std::io::Result<(World, Vec<concinnity_cook::build_only::ShadowedAsset>)> {
    let loaded = prepare(source.into())?;
    let shadowed = loaded.shadowed.clone();
    Ok((world_from_loaded(loaded)?, shadowed))
}

/// Read a world.jsonl file from disk and run the full in-memory pipeline on it,
/// returning a ready-to-run World. The interpreted `run` (in the CLI crate)
/// loads its world through here; it is the file-backed counterpart of `prepare`
/// + `world_from_loaded`.
pub(crate) fn build_world_from_path(world_path: &str) -> std::io::Result<World> {
    let content = std::fs::read_to_string(world_path)?;
    build_world_from_str(WorldSource::file(&content, Path::new(world_path)))
}

// Compile the world file at `json_path` into the project's state tree, showing
// the build's status as it runs and printing every validation error before
// failing. The CLI commands that rebuild a world file go through here.
pub(crate) fn build_world_file(json_path: &str) -> std::io::Result<()> {
    build_world_file_as(json_path, json_path, Verbosity::Normal)
}

// `build_world_file` with the world named `shown` in the status, for a build
// of a staged copy that stands in for it.
pub(crate) fn build_world_file_as(
    json_path: &str,
    shown: &str,
    verbosity: Verbosity,
) -> std::io::Result<()> {
    let tree = crate::project::require()?;
    let content = std::fs::read_to_string(json_path)?;
    let platform = crate::cook_platform();
    let status = BuildStatus::start(shown, platform, verbosity);
    let source = WorldSource::file(&content, Path::new(json_path));
    let loaded = match concinnity_cook::prepare_world(source, Some(&tree.assets_dir())) {
        Ok(loaded) => loaded,
        Err(errs) => {
            status.fail_validation(&errs);
            return Err(crate::authoring::validation_failed(errs.len()));
        }
    };
    status.loaded(&loaded);
    let report = |p: concinnity_cook::BuildProgress<'_>| status.progress(p);
    match concinnity_cook::build_loaded(&tree, loaded, platform, Some(&report)) {
        Ok(built) => {
            status.finish(&built);
            Ok(())
        }
        Err(e) => {
            status.fail();
            Err(e)
        }
    }
}

// Compile world content and write the blobs + world-lock.json to the open
// project's build root, without reading (or writing) a world.jsonl, so the
// editor's cook workers compile the in-memory entries as they stand, saved or
// not. `progress` feeds their operation card.
pub(crate) fn build_world_str_to_disk(
    content: &str,
    progress: Option<concinnity_cook::ProgressFn<'_>>,
) -> std::io::Result<()> {
    let loaded = prepare(content.into())?;
    let result = concinnity_cook::build_compiled_with_progress(
        loaded.assets,
        crate::project::assets_dir().as_deref(),
        crate::cook_platform(),
        progress,
    )?;
    concinnity_cook::write_build_outputs(
        &crate::project::require()?,
        &result,
        &loaded.injected,
        &loaded.shadowed,
        progress,
    )?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use concinnity_host::thread::asset_id;

    #[test]
    fn prepare_accepts_a_valid_world() {
        let loaded = prepare("[\"PhysicsConfig\",{\"$id\":\"phys\"}]\n".into()).unwrap();
        assert!(loaded.assets.iter().any(|a| a.id == "phys"));
        assert!(loaded.authored.contains(&"phys".to_string()));
    }

    #[test]
    fn prepare_rejects_an_invalid_world() {
        assert!(prepare("[\"NotARealAssetType\",{\"$id\":\"odd\"}]\n".into()).is_err());
        assert!(prepare("{ not json\n".into()).is_err());
    }

    fn asset(json: serde_json::Value) -> concinnity_cook::authoring::world::WorldJsonlAsset {
        concinnity_cook::authoring::world::WorldJsonlAsset::from_value(&json).expect("typed asset")
    }

    #[test]
    fn the_lut_scan_takes_the_first_source() {
        let assets = [asset(
            serde_json::json!({"type":"ColorLut","args":{"$id":"grade","source":"luts/warm.cube"}}),
        )];
        assert_eq!(
            scan_color_lut_source(&assets),
            Some("luts/warm.cube".to_string())
        );

        // Only the first is used: the runtime binds handle 0.
        let assets = [
            asset(serde_json::json!({"type":"ColorLut","args":{"$id":"a","source":"first.cube"}})),
            asset(serde_json::json!({"type":"ColorLut","args":{"$id":"b","source":"second.cube"}})),
        ];
        assert_eq!(
            scan_color_lut_source(&assets),
            Some("first.cube".to_string())
        );
    }

    // Nothing to watch: no LUT at all, or one with no authored source path.
    #[test]
    fn the_lut_scan_yields_nothing_without_a_source() {
        assert_eq!(scan_color_lut_source(&[]), None);
        let no_source = [asset(
            serde_json::json!({"type":"ColorLut","args":{"$id":"grade"}}),
        )];
        assert_eq!(scan_color_lut_source(&no_source), None);
        let empty = [asset(
            serde_json::json!({"type":"ColorLut","args":{"$id":"grade","source":""}}),
        )];
        assert_eq!(scan_color_lut_source(&empty), None);
        let other_kind = [asset(
            serde_json::json!({"type":"EnvironmentMap","args":{"$id":"sky","source":"x.hdr"}}),
        )];
        assert_eq!(scan_color_lut_source(&other_kind), None);
    }

    // A file-backed environment map carries its re-bake inputs so the watcher
    // can reproduce the original bake; unset ones fall back to the schema
    // defaults rather than zero.
    #[test]
    fn the_environment_map_scan_defaults_the_unset_bake_inputs() {
        let assets = [asset(
            serde_json::json!({"type":"EnvironmentMap","args":{"$id":"sky","source":"studio.hdr"}}),
        )];
        let info = scan_environment_map_source(&assets).expect("a file-backed map");
        assert_eq!(info.source, "studio.hdr");
        assert_eq!(info.prefilter_face_size, 512);
        assert_eq!(info.irradiance_face_size, 8);
        assert_eq!(info.prefilter_samples, 1024);
        assert_eq!(info.prefilter_clamp, 12.0);
    }

    #[test]
    fn the_environment_map_scan_carries_the_authored_bake_inputs() {
        let assets = [asset(serde_json::json!({
            "type":"EnvironmentMap","args":{
                "$id":"sky",
                "source":"studio.hdr",
                "prefilter_face_size": 256,
                "irradiance_face_size": 16,
                "prefilter_samples": 64,
                "prefilter_clamp": 4.5
            }
        }))];
        let info = scan_environment_map_source(&assets).expect("a file-backed map");
        assert_eq!(info.prefilter_face_size, 256);
        assert_eq!(info.irradiance_face_size, 16);
        assert_eq!(info.prefilter_samples, 64);
        assert_eq!(info.prefilter_clamp, 4.5);
    }

    // A procedural map has no file behind it, so there is nothing to watch --
    // even when a stale `source` is still authored alongside the generator.
    #[test]
    fn the_environment_map_scan_skips_a_procedural_map() {
        let generated = [asset(serde_json::json!({
            "type":"EnvironmentMap","args":{"$id":"sky","generator":"sky"}
        }))];
        assert!(scan_environment_map_source(&generated).is_none());

        let both = [asset(serde_json::json!({
            "type":"EnvironmentMap","args":{"$id":"sky","generator":"sky","source":"studio.hdr"}
        }))];
        assert!(scan_environment_map_source(&both).is_none());

        assert!(scan_environment_map_source(&[]).is_none());
        let no_source = [asset(
            serde_json::json!({"type":"EnvironmentMap","args":{"$id":"sky"}}),
        )];
        assert!(scan_environment_map_source(&no_source).is_none());
    }

    // The assembled world publishes both dev-only source catalogs, which is
    // what seeds the hot-reload watcher.
    #[test]
    fn the_assembled_world_publishes_the_watcher_source_catalogs() {
        let loaded = prepare("[\"PhysicsConfig\",{\"$id\":\"phys\"}]\n".into()).unwrap();
        let world = world_from_loaded(loaded).unwrap();
        assert!(
            world
                .resource::<ColorLutSources>()
                .is_some_and(|s| s.0.is_none()),
            "a world with no LUT publishes an empty catalog, not none at all"
        );
        assert!(world.resource::<EnvironmentMapSources>().is_some());
    }

    #[test]
    fn world_from_loaded_assembles_an_in_memory_world() {
        let loaded = prepare("[\"PhysicsConfig\",{\"$id\":\"phys\"}]\n".into()).unwrap();
        let expanded = loaded.assets.len();
        let world = world_from_loaded(loaded).unwrap();
        // Every expanded asset landed as a component on an entity of its own,
        // identified so its id resolves to it; nothing was dropped on the way
        // through compile + assembly.
        let identified = world
            .query::<concinnity_core::components::Identity>()
            .count();
        assert_eq!(identified, expanded);
        assert_eq!(world.component_count(), 2 * expanded);
        let index = world
            .resource::<concinnity_core::ecs::EntityById>()
            .expect("assembly identifies every entity");
        assert_eq!(index.len(), expanded);
    }

    #[test]
    fn build_world_from_str_assembles_an_in_memory_world() {
        // The string path is what the editor uses to seed an empty world; it
        // must produce the same assembled world as the file-backed path.
        let world = build_world_from_str("[\"PhysicsConfig\",{\"$id\":\"phys\"}]\n").unwrap();
        assert!(world.component_count() >= 1);
    }

    #[test]
    fn build_world_from_missing_path_is_not_found() {
        let err = build_world_from_path("/no/such/concinnity-world-xyz.jsonl")
            .expect_err("a missing world path must error");
        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
    }

    // Closes the session's project on the way out, for the same reason.
    struct ProjectGuard;
    impl Drop for ProjectGuard {
        fn drop(&mut self) {
            crate::project::close();
        }
    }

    // The in-memory build records each compiled Material's identity, dense by
    // the handle cook assigned it, so the live draw seam can resolve a
    // material an edit names against the running world.
    #[test]
    fn an_in_memory_build_records_its_material_identities() {
        let _guard = crate::test_support::lock();
        crate::test_support::isolate_state_dir();
        let world = build_world_from_str(concat!(
            "[\"Material\",{\"$id\":\"steel\",\"roughness\":0.4}]\n",
            "[\"Material\",{\"$id\":\"glass\",\"transparent\":true}]\n",
        ))
        .expect("a material-only world compiles");
        let names = world
            .resource::<MaterialNames>()
            .expect("the catalog is installed");
        assert_eq!(
            names.0,
            vec![asset_id::intern("steel").0, asset_id::intern("glass").0,],
            "declaration order is handle order"
        );
    }

    // build_world_str_to_disk compiles world content and writes the blobs + lock to
    // the open project's build root, exactly as `cn build` does. Uses a
    // payload-free world (PhysicsConfig) so it needs no source files or shader
    // compilation.
    #[test]
    fn build_world_str_to_disk_writes_blobs_and_lock() {
        // Opening the session's project is a process-global write.
        let _guard = crate::test_support::lock();
        let dir = concinnity_testing::TempTree::new();
        let build_root = dir.path().join(".concinnity");
        crate::project::open(
            concinnity_host::store::paths::StateTree::at(dir.path()).with_build(&build_root),
        );
        let _project = ProjectGuard;

        let world = dir.path().join("worlds").join("world.jsonl");
        std::fs::create_dir_all(world.parent().unwrap()).unwrap();
        std::fs::write(&world, "[\"PhysicsConfig\",{\"$id\":\"phys\"}]\n").unwrap();

        let content = std::fs::read_to_string(&world).unwrap();
        build_world_str_to_disk(&content, None).expect("compile + write should succeed");

        // The primary blob (data/0) and the provenance lock both land under the
        // build root, not beside the authored world.
        assert!(
            concinnity_host::store::blob::primary_in(
                &crate::project::data_dir().expect("the test opened a project")
            )
            .exists(),
            "data/0 blob written"
        );
        assert!(
            build_root.join("world-lock.json").exists(),
            "world-lock.json written under the build root"
        );
        assert!(
            !dir.path().join("world-lock.json").exists(),
            "the lock must not land beside the authored world"
        );
    }
}