thread-manifest 0.1.0

World Manifest — the open document format of the Thread (renderer-agnostic spatial-web content)
Documentation
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
//! # World Manifest — the document format of the Thread
//!
//! The Thread is the open, spatial, present, time-aware successor to the web:
//! worlds are *pearls* strung along one Thread, linked by **portals** (veils you
//! step through). This crate defines the **World Manifest** — the "HTML" of the
//! Thread: an open, **renderer-agnostic** description of a place that *any*
//! browser (Infinite is the first) can resolve and render.
//!
//! Design principles (mirroring why the web won):
//! - **Narrow waist.** Every browser implements this format or nothing interops.
//! - **Renderer-agnostic.** Geometry is glTF + prefab refs + a standard material
//!   model — never a specific engine's internals.
//! - **Static-first, presence-optional.** A world is just files (host it
//!   anywhere). Presence (shared, multi-user) is an *upgrade* you opt into by
//!   naming a relay; without one, the world gracefully degrades to solo.
//! - **Meaning is native.** Anything can link to a canonical [`Codex`] entry.
//! - **Time is an axis.** A world declares a `year`; the same place is
//!   addressable across time.
//!
//! See `docs/spec/world-manifest-v0.1.md` for the normative specification.

mod locator;
pub use locator::{well_known_url, Locator, SCHEME};

use thread_id::StructuredId;
use serde::{Deserialize, Serialize};

/// The format version tag carried in every manifest's `thread` field.
pub const THREAD_VERSION: &str = "thread/0.1";

/// A World Manifest — one place on the Thread.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorldManifest {
    /// Format version, e.g. `"thread/0.1"`.
    pub thread: String,
    pub world: WorldMeta,
    #[serde(default)]
    pub environment: Environment,
    /// Where visitors arrive. The first is the default; others are `#place` anchors.
    #[serde(default)]
    pub spawns: Vec<Spawn>,
    /// External content referenced by prefabs/behaviors (glTF, textures, WASM).
    #[serde(default)]
    pub assets: Vec<Asset>,
    /// Deduplicated renderable prefabs, each keyed by a [`StructuredId`].
    #[serde(default)]
    pub prefabs: Vec<Prefab>,
    /// Instances of prefabs placed in the world.
    #[serde(default)]
    pub placements: Vec<Placement>,
    /// Veils to other worlds — the hyperlinks of the Thread.
    #[serde(default)]
    pub portals: Vec<Portal>,
    /// Sandboxed behavior modules (WASM) the world runs.
    #[serde(default)]
    pub behaviors: Vec<Behavior>,
    /// Presence policy. Absent → the world is solo (static-first).
    #[serde(default)]
    pub presence: Option<Presence>,
}

/// Identity + descriptive metadata for the world.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorldMeta {
    /// Stable id, unique within the host (the last path segment of its Locator).
    pub id: String,
    pub title: String,
    #[serde(default)]
    pub description: String,
    /// Provider-agnostic author identity (DID-style), if declared.
    #[serde(default)]
    pub author: Option<Identity>,
    /// Codex slugs describing this world (canonical meaning).
    #[serde(default)]
    pub codex: Vec<String>,
    #[serde(default)]
    pub license: Option<String>,
}

/// A portable, provider-agnostic identity (the open form of the Passport).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Identity {
    /// Decentralized-identifier-style id (`did:pixygon:…`, etc.).
    pub id: String,
    #[serde(default)]
    pub name: String,
}

/// Environmental setup: time, sky, bounds.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Environment {
    /// Timeline year of this world (the `@when` axis). `None` = the present.
    #[serde(default)]
    pub year: Option<i64>,
    #[serde(default)]
    pub sky: Option<Sky>,
    #[serde(default)]
    pub bounds: Option<Bounds>,
}

/// A simple gradient sky (zenith→horizon) with an optional sun direction.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Sky {
    pub zenith: [f32; 3],
    pub horizon: [f32; 3],
    #[serde(default)]
    pub sun_dir: Option<[f32; 3]>,
}

/// Axis-aligned world bounds (advisory).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bounds {
    pub min: [f32; 3],
    pub max: [f32; 3],
}

/// An arrival point.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Spawn {
    /// Anchor name (the `#place` in a Locator); the first spawn is the default.
    #[serde(default)]
    pub name: String,
    pub position: [f32; 3],
    #[serde(default)]
    pub yaw: f32,
}

/// External content referenced by id.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Asset {
    pub id: String,
    /// Where to fetch it — a relative path, absolute URL, or `ipfs://…`.
    pub uri: String,
    pub kind: AssetKind,
}

/// What an [`Asset`] is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AssetKind {
    Gltf,
    Texture,
    Wasm,
    Audio,
    Other,
}

/// A unique renderable prefab (mesh + optional material), keyed by [`StructuredId`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Prefab {
    pub id: StructuredId,
    pub mesh: MeshRef,
    #[serde(default)]
    pub material: Option<MaterialRef>,
}

/// How a prefab's geometry is sourced. Exactly one field should be set.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MeshRef {
    /// The id of a glTF [`Asset`].
    #[serde(default)]
    pub asset: Option<String>,
    /// A built-in primitive: `cube`, `sphere`, `cylinder`, `capsule`, `plane`, `quad`.
    #[serde(default)]
    pub builtin: Option<String>,
}

/// Standard PBR material (renderer-agnostic). Texture fields are [`Asset`] ids.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MaterialRef {
    #[serde(default)]
    pub base_color_texture: Option<String>,
    /// Packed occlusion-roughness-metallic (glTF convention).
    #[serde(default)]
    pub orm_texture: Option<String>,
    #[serde(default)]
    pub normal_texture: Option<String>,
    #[serde(default = "white")]
    pub base_color: [f32; 4],
    #[serde(default)]
    pub metallic: f32,
    #[serde(default = "one")]
    pub roughness: f32,
}

/// One placed instance of a prefab.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Placement {
    pub prefab: StructuredId,
    #[serde(default)]
    pub name: String,
    pub position: [f32; 3],
    #[serde(default = "unit_quat")]
    pub rotation: [f32; 4],
    #[serde(default = "unit_scale")]
    pub scale: [f32; 3],
    /// Canonical Codex slug for this object — "inspect" surfaces its lore.
    #[serde(default)]
    pub codex: Option<String>,
    /// Id of a [`Behavior`] bound to this placement.
    #[serde(default)]
    pub behavior: Option<String>,
    /// Arbitrary per-placement data (e.g. a product id + price for commerce).
    #[serde(default)]
    pub data: serde_json::Value,
}

/// A veil to another world — the hyperlink of the Thread.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Portal {
    pub id: String,
    pub position: [f32; 3],
    #[serde(default = "unit_quat")]
    pub rotation: [f32; 4],
    #[serde(default = "unit_scale")]
    pub scale: [f32; 3],
    /// Destination [`Locator`] (`thread://…`), possibly on another host.
    pub to: String,
    #[serde(default)]
    pub label: String,
    /// How much of the far side to show before stepping through.
    #[serde(default)]
    pub preview: PreviewPolicy,
}

/// Portal preview fidelity.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PreviewPolicy {
    /// No preview — an opaque doorway.
    None,
    /// A still image of the destination.
    #[default]
    Static,
    /// A live window into the destination (crowd, weather, time-of-day).
    Live,
}

/// A sandboxed WASM behavior module the world runs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Behavior {
    pub id: String,
    /// The id of a WASM [`Asset`].
    pub wasm: String,
    /// Event names this module handles (e.g. `interact`, `tick`).
    #[serde(default)]
    pub on: Vec<String>,
}

/// Presence policy — the (optional) upgrade from a solo world to a shared one.
///
/// Presence on the Thread is **federated**: a world names its *own* relay(s), so
/// there is no global chokepoint. Prefer [`relays`](Presence::relays) — an ordered
/// list of interchangeable, conformant relays (primary first, then fallbacks) — so
/// no single relay URL is a point of failure. The singular [`relay`](Presence::relay)
/// is retained for backward compatibility; [`relay_list`](Presence::relay_list)
/// returns the effective, de-duplicated order to try.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Presence {
    /// Presence tier: `"solo"`, `"p2p"`, or `"relay"` (see presence-topology-v0.1).
    /// Absent → inferred from whether any relay is named. Forward-looking; a browser
    /// that only implements the relay tier treats `"p2p"` as a hint it may ignore.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mode: Option<String>,
    /// A single presence relay (legacy / old-browser compatibility). Absent → solo.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub relay: Option<String>,
    /// Interchangeable relays to try in order (primary first, then fallbacks). Any
    /// conformant relay works; if one is unreachable the browser tries the next.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub relays: Vec<String>,
    #[serde(default)]
    pub max_occupants: Option<u32>,
    #[serde(default)]
    pub voice: bool,
}

impl Presence {
    /// The effective, ordered, de-duplicated list of relays to try: `relays` first,
    /// then the legacy singular `relay` if it isn't already present. Empty → solo.
    pub fn relay_list(&self) -> Vec<String> {
        let mut out = self.relays.clone();
        if let Some(r) = &self.relay {
            if !out.contains(r) {
                out.push(r.clone());
            }
        }
        out
    }
}

// --- serde defaults ---
fn white() -> [f32; 4] { [1.0, 1.0, 1.0, 1.0] }
fn one() -> f32 { 1.0 }
fn unit_quat() -> [f32; 4] { [0.0, 0.0, 0.0, 1.0] }
fn unit_scale() -> [f32; 3] { [1.0, 1.0, 1.0] }

/// An error parsing or validating a manifest.
#[derive(Debug)]
pub enum ManifestError {
    Json(serde_json::Error),
    /// One or more validation problems (each a human-readable message).
    Invalid(Vec<String>),
}

impl std::fmt::Display for ManifestError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ManifestError::Json(e) => write!(f, "manifest JSON error: {e}"),
            ManifestError::Invalid(errs) => write!(f, "invalid manifest: {}", errs.join("; ")),
        }
    }
}
impl std::error::Error for ManifestError {}

impl WorldManifest {
    /// Parse and validate a manifest from JSON text.
    pub fn from_json(text: &str) -> Result<Self, ManifestError> {
        let m: WorldManifest = serde_json::from_str(text).map_err(ManifestError::Json)?;
        m.validate()?;
        Ok(m)
    }

    /// Serialize to pretty JSON.
    pub fn to_json(&self) -> String {
        serde_json::to_string_pretty(self).unwrap_or_default()
    }

    /// The default arrival point (first spawn, or the origin).
    pub fn default_spawn(&self) -> Spawn {
        self.spawns.first().cloned().unwrap_or(Spawn {
            name: String::new(),
            position: [0.0, 0.0, 0.0],
            yaw: 0.0,
        })
    }

    /// Resolve a `#place` anchor to a spawn or a portal position.
    pub fn anchor(&self, name: &str) -> Option<[f32; 3]> {
        if let Some(s) = self.spawns.iter().find(|s| s.name == name) {
            return Some(s.position);
        }
        self.portals.iter().find(|p| p.id == name).map(|p| p.position)
    }

    /// Validate structural integrity. A **conformant** manifest passes this: the
    /// version is recognized, every reference resolves, and portals address the
    /// Thread. Returns all problems at once.
    pub fn validate(&self) -> Result<(), ManifestError> {
        let mut errs = Vec::new();

        if !self.thread.starts_with("thread/") {
            errs.push(format!("unknown format tag '{}' (expected 'thread/…')", self.thread));
        }
        if self.world.id.trim().is_empty() {
            errs.push("world.id must not be empty".into());
        }

        let asset_ids: std::collections::HashSet<&str> =
            self.assets.iter().map(|a| a.id.as_str()).collect();
        let prefab_ids: std::collections::HashSet<StructuredId> =
            self.prefabs.iter().map(|p| p.id).collect();
        let behavior_ids: std::collections::HashSet<&str> =
            self.behaviors.iter().map(|b| b.id.as_str()).collect();

        for p in &self.prefabs {
            match (&p.mesh.asset, &p.mesh.builtin) {
                (None, None) => errs.push(format!("prefab {} has neither mesh.asset nor mesh.builtin", p.id)),
                (Some(_), Some(_)) => errs.push(format!("prefab {} sets both mesh.asset and mesh.builtin", p.id)),
                (Some(a), None) if !asset_ids.contains(a.as_str()) => {
                    errs.push(format!("prefab {} references unknown asset '{a}'", p.id))
                }
                _ => {}
            }
        }

        for (i, pl) in self.placements.iter().enumerate() {
            if !prefab_ids.contains(&pl.prefab) {
                errs.push(format!("placement[{i}] references unknown prefab {}", pl.prefab));
            }
            if let Some(b) = &pl.behavior {
                if !behavior_ids.contains(b.as_str()) {
                    errs.push(format!("placement[{i}] references unknown behavior '{b}'"));
                }
            }
        }

        for b in &self.behaviors {
            if !asset_ids.contains(b.wasm.as_str()) {
                errs.push(format!("behavior '{}' references unknown wasm asset '{}'", b.id, b.wasm));
            }
        }

        for p in &self.portals {
            if Locator::parse(&p.to).is_none() {
                errs.push(format!("portal '{}' has an invalid destination Locator '{}'", p.id, p.to));
            }
        }

        if errs.is_empty() {
            Ok(())
        } else {
            Err(ManifestError::Invalid(errs))
        }
    }
}

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

    /// The two shipped example worlds must parse and validate — they are the
    /// conformance fixtures (the "two linked pages" of the Thread).
    fn load(rel: &str) -> Result<WorldManifest, ManifestError> {
        let path = format!("{}/../../worlds/{rel}", env!("CARGO_MANIFEST_DIR"));
        let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
        WorldManifest::from_json(&text)
    }

    #[test]
    fn example_archive_is_conformant() {
        let m = load("codex-archive/world.json").unwrap();
        assert_eq!(m.world.id, "codex-archive");
        assert!(m.placements.iter().any(|p| p.codex.is_some()), "archive links Codex entries");
        assert!(!m.portals.is_empty(), "archive links onward");
    }

    #[test]
    fn example_market_is_conformant_and_links_back() {
        let m = load("market/world.json").unwrap();
        assert_eq!(m.world.id, "market");
        // A product placement carries commerce data.
        assert!(m.placements.iter().any(|p| p.data.get("price").is_some()));
        // It portals back to the archive — the two worlds form a linked web.
        let back = m.portals.iter().find_map(|p| Locator::parse(&p.to));
        assert_eq!(back.map(|l| l.path), Some("codex-archive".to_string()));
    }

    /// Every world the main agent hosts (committed under `worlds/`) must parse
    /// and validate through this reference implementation — the cross-check that
    /// the server's generator and the browser's format agree.
    #[test]
    fn all_hosted_world_fixtures_are_conformant() {
        let dir = format!("{}/../../worlds", env!("CARGO_MANIFEST_DIR"));
        let mut n = 0;
        for entry in std::fs::read_dir(&dir).unwrap().flatten() {
            let wj = entry.path().join("world.json");
            if wj.exists() {
                let text = std::fs::read_to_string(&wj).unwrap();
                WorldManifest::from_json(&text).unwrap_or_else(|e| panic!("{}: {e}", wj.display()));
                n += 1;
            }
        }
        assert!(n >= 8, "expected the hosted constellation fixtures, found {n}");
    }

    #[test]
    fn rejects_dangling_prefab_reference() {
        let bad = r#"{
            "thread": "thread/0.1",
            "world": { "id": "x", "title": "X" },
            "placements": [{ "prefab": "60000001", "position": [0,0,0] }]
        }"#;
        match WorldManifest::from_json(bad) {
            Err(ManifestError::Invalid(errs)) => assert!(errs.iter().any(|e| e.contains("unknown prefab"))),
            other => panic!("expected Invalid, got {other:?}"),
        }
    }

    #[test]
    fn roundtrips_json() {
        let m = load("market/world.json").unwrap();
        let back = WorldManifest::from_json(&m.to_json()).unwrap();
        assert_eq!(back.world.id, m.world.id);
        assert_eq!(back.placements.len(), m.placements.len());
    }

    #[test]
    fn presence_relay_list_prefers_relays_and_stays_backward_compatible() {
        // Legacy world: only the singular `relay` → a one-element list.
        let legacy: Presence = serde_json::from_str(r#"{ "relay": "wss://a/thread/w" }"#).unwrap();
        assert_eq!(legacy.relay_list(), ["wss://a/thread/w"]);

        // New world: a `relays` list with fallbacks.
        let modern: Presence =
            serde_json::from_str(r#"{ "relays": ["wss://a/thread/w", "wss://b/thread/w"] }"#).unwrap();
        assert_eq!(modern.relay_list(), ["wss://a/thread/w", "wss://b/thread/w"]);

        // Both set (a world serving old + new browsers): merged, de-duplicated.
        let both: Presence = serde_json::from_str(
            r#"{ "relay": "wss://a/thread/w", "relays": ["wss://a/thread/w", "wss://b/thread/w"] }"#,
        )
        .unwrap();
        assert_eq!(both.relay_list(), ["wss://a/thread/w", "wss://b/thread/w"]);

        // Neither → solo.
        let solo = Presence::default();
        assert!(solo.relay_list().is_empty());
    }
}