Skip to main content

thread_manifest/
lint.rs

1//! # World lint — the quality eye that runs before any screenshot
2//!
3//! Conformance (C1–C6) answers "is this world *valid*?"; lint answers "will
4//! this world *look and feel* right?" Every rule here is a bug class that
5//! actually shipped and was caught by a human staring at a screenshot —
6//! floating furniture, a spawn that walks straight into a veil, a text panel
7//! spilling off its board, a portal buried in a wall. The linter is how an
8//! agent authoring a world catches them before the first render.
9//!
10//! Geometry is approximated by axis-aligned boxes from `position`/`scale`
11//! (rotation is ignored — findings are advisory, never build-breaking). Every
12//! finding names its placement so the fix is one search away.
13
14use crate::{Placement, WorldManifest};
15
16/// One advisory finding. Lint never fails a build — it points.
17#[derive(Debug, Clone)]
18pub struct Finding {
19    /// Stable rule id (`L1`…) for filtering and docs.
20    pub rule: &'static str,
21    /// What's wrong and where, human-first.
22    pub message: String,
23}
24
25impl std::fmt::Display for Finding {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        write!(f, "[{}] {}", self.rule, self.message)
28    }
29}
30
31/// A veil fires within this distance (mirrors the browser's enter radius);
32/// spawning closer than this to a portal veilwalks the visitor instantly.
33const PORTAL_ENTER_RADIUS: f32 = 2.2;
34
35/// How much unsupported air under a placement's bottom counts as "floating".
36const FLOAT_TOLERANCE: f32 = 0.25;
37
38/// Run every rule over a manifest.
39pub fn lint(m: &WorldManifest) -> Vec<Finding> {
40    let mut out = Vec::new();
41    // Carved prefabs know their true extents — use them instead of assuming
42    // unit-sized geometry (a lathed 5m column at scale 1 is 5m tall).
43    let shape_bounds: std::collections::HashMap<u32, ([f32; 3], [f32; 3])> = m
44        .prefabs
45        .iter()
46        .filter_map(|p| p.mesh.shape.as_ref().map(|sh| (p.id.0, sh.bounds())))
47        .collect();
48    let boxes: Vec<Aabb> = m
49        .placements
50        .iter()
51        .map(|p| Aabb::of(p, shape_bounds.get(&p.prefab.0)))
52        .collect();
53    floating_placements(m, &boxes, &mut out);
54    spawn_hazards(m, &boxes, &mut out);
55    portal_hazards(m, &boxes, &mut out);
56    text_overflow(m, &mut out);
57    darkness(m, &mut out);
58    degenerate_scales(m, &mut out);
59    doubled_placements(m, &mut out);
60    presence_claim(m, &mut out);
61    out
62}
63
64/// L9 — `presence.mode` claims a tier the addresses don't support. The spec
65/// says the addresses win, so this never breaks a world; it exists because the
66/// disagreement is always a mistake in one of the two, and the author is the
67/// only one who knows which.
68fn presence_claim(m: &WorldManifest, out: &mut Vec<Finding>) {
69    let Some(p) = &m.presence else { return };
70    if p.mode_disagrees() {
71        out.push(Finding {
72            rule: "L9",
73            message: format!(
74                "presence says mode '{}' but names {} — a browser will treat this world as '{}'",
75                p.mode.as_deref().unwrap_or_default(),
76                if p.relay_list().is_empty() {
77                    "no relay".to_string()
78                } else {
79                    format!("{} relay(s)", p.relay_list().len())
80                },
81                p.effective_mode()
82            ),
83        });
84    }
85}
86
87/// Axis-aligned box approximation of a placement (rotation ignored).
88#[derive(Clone, Copy)]
89struct Aabb {
90    min: [f32; 3],
91    max: [f32; 3],
92    solid: bool,
93    /// Whether the box is (near-)axis-aligned — containment tests are only
94    /// trustworthy when it is.
95    axis_aligned: bool,
96}
97
98impl Aabb {
99    fn of(p: &Placement, shape_bounds: Option<&([f32; 3], [f32; 3])>) -> Aabb {
100        // Unit-primitive assumption (centre-origin, extent = scale) — replaced
101        // by the recipe's true bounds for carved prefabs.
102        if let Some((bmin, bmax)) = shape_bounds {
103            let lo = [
104                p.position[0] + bmin[0] * p.scale[0],
105                p.position[1] + bmin[1] * p.scale[1],
106                p.position[2] + bmin[2] * p.scale[2],
107            ];
108            let hi = [
109                p.position[0] + bmax[0] * p.scale[0],
110                p.position[1] + bmax[1] * p.scale[1],
111                p.position[2] + bmax[2] * p.scale[2],
112            ];
113            let [qx, qy, qz, qw] = p.rotation;
114            let upright =
115                qx.abs() < 0.02 && qz.abs() < 0.02 && (qy.abs() < 0.04 || qw.abs() < 0.04);
116            return Aabb {
117                min: lo,
118                max: hi,
119                solid: p.solid.unwrap_or(true),
120                axis_aligned: upright,
121            };
122        }
123        let half = [
124            p.scale[0].abs() / 2.0,
125            p.scale[1].abs() / 2.0,
126            p.scale[2].abs() / 2.0,
127        ];
128        // A meaningfully rotated box is NOT its AABB — containment checks on
129        // it would cry wolf (a ring's rotated wall segments "swallowing" the
130        // veils in their gates). Mark it so point-in-box rules skip it.
131        let [qx, qy, qz, qw] = p.rotation;
132        let upright = qx.abs() < 0.02 && qz.abs() < 0.02 && (qy.abs() < 0.04 || qw.abs() < 0.04);
133        Aabb {
134            min: [
135                p.position[0] - half[0],
136                p.position[1] - half[1],
137                p.position[2] - half[2],
138            ],
139            max: [
140                p.position[0] + half[0],
141                p.position[1] + half[1],
142                p.position[2] + half[2],
143            ],
144            solid: p.solid.unwrap_or(true),
145            axis_aligned: upright,
146        }
147    }
148
149    fn footprint_overlaps(&self, other: &Aabb) -> bool {
150        self.min[0] < other.max[0]
151            && self.max[0] > other.min[0]
152            && self.min[2] < other.max[2]
153            && self.max[2] > other.min[2]
154    }
155
156    fn contains(&self, p: [f32; 3]) -> bool {
157        (0..3).all(|i| p[i] > self.min[i] && p[i] < self.max[i])
158    }
159}
160
161fn name_of(p: &Placement, i: usize) -> String {
162    if !p.name.is_empty() {
163        format!("'{}'", p.name)
164    } else if let Some(k) = &p.kind {
165        format!("<{k}> #{i}")
166    } else {
167        format!("placement #{i} (prefab {})", p.prefab.0)
168    }
169}
170
171/// L1 — a solid placement hanging in the air with nothing under it. Text
172/// panels, lights-only pieces and non-solids are exempt (they hover by
173/// design); anything at ground contact (bottom ≈ 0) is grounded by the floor.
174fn floating_placements(m: &WorldManifest, boxes: &[Aabb], out: &mut Vec<Finding>) {
175    for (i, p) in m.placements.iter().enumerate() {
176        if p.solid == Some(false) || p.text.is_some() || p.light.is_some() {
177            continue;
178        }
179        let b = &boxes[i];
180        if !b.axis_aligned {
181            continue; // a rotated box is not its AABB — don't cry wolf
182        }
183        if b.min[1] <= FLOAT_TOLERANCE {
184            continue; // resting on (or in) the ground plane
185        }
186        let supported = boxes.iter().enumerate().any(|(j, other)| {
187            j != i
188                && other.footprint_overlaps(b)
189                && other.max[1] >= b.min[1] - FLOAT_TOLERANCE
190                && other.min[1] < b.min[1] + FLOAT_TOLERANCE
191        });
192        // A child placement is positioned relative to its parent — skip those
193        // (they were already lifted into their parent's frame).
194        if !supported {
195            out.push(Finding {
196                rule: "L1",
197                message: format!(
198                    "{} floats {:.2}m above ground with nothing under it (at {:?})",
199                    name_of(p, i),
200                    b.min[1],
201                    p.position
202                ),
203            });
204        }
205    }
206}
207
208/// L2 — a spawn buried inside solid geometry, or with no ground beneath it.
209fn spawn_hazards(m: &WorldManifest, boxes: &[Aabb], out: &mut Vec<Finding>) {
210    for s in &m.spawns {
211        let eye = [s.position[0], s.position[1] + 1.0, s.position[2]];
212        for (i, b) in boxes.iter().enumerate() {
213            if b.solid && b.axis_aligned && b.contains(eye) {
214                out.push(Finding {
215                    rule: "L2",
216                    message: format!(
217                        "spawn '{}' is inside solid {} — visitors arrive buried",
218                        s.name,
219                        name_of(&m.placements[i], i)
220                    ),
221                });
222            }
223        }
224    }
225}
226
227/// L3 — portal hazards: a veil so close to a spawn that arrival instantly
228/// fires it (the walk-in bug), or a veil buried inside solid geometry.
229fn portal_hazards(m: &WorldManifest, boxes: &[Aabb], out: &mut Vec<Finding>) {
230    for portal in &m.portals {
231        for s in &m.spawns {
232            let d = dist(portal.position, s.position);
233            if d < PORTAL_ENTER_RADIUS + 0.3 {
234                out.push(Finding {
235                    rule: "L3",
236                    message: format!(
237                        "veil '{}' is {:.1}m from spawn '{}' — inside the trigger radius: visitors arrive standing IN the doorway (it can't fire until they step away and return). Keep veils > {:.1}m from spawns",
238                        portal.id, d, s.name, PORTAL_ENTER_RADIUS
239                    ),
240                });
241            }
242        }
243        for (i, b) in boxes.iter().enumerate() {
244            if b.solid && b.axis_aligned && b.contains(portal.position) {
245                out.push(Finding {
246                    rule: "L3",
247                    message: format!(
248                        "veil '{}' sits inside solid {} — the doorway is walled off",
249                        portal.id,
250                        name_of(&m.placements[i], i)
251                    ),
252                });
253            }
254        }
255    }
256}
257
258/// L4 — text that will not fit its panel. Mirrors the SDF layout's real
259/// numbers (line height ≈ 1.4 × the size fraction, 0.8-glyph margins): copy
260/// past capacity is clipped at the paper's edge — better to know now.
261fn text_overflow(m: &WorldManifest, out: &mut Vec<Finding>) {
262    for (i, p) in m.placements.iter().enumerate() {
263        let Some(t) = &p.text else { continue };
264        let lines = t.content.lines().count() as f32;
265        let capacity = (1.0 - 1.6 * t.size) / (1.4 * t.size);
266        if lines > capacity.max(1.0) {
267            out.push(Finding {
268                rule: "L4",
269                message: format!(
270                    "{}'s text is {} lines but the panel fits ~{:.0} at size {} — shrink the size or trim the copy",
271                    name_of(p, i),
272                    lines as usize,
273                    capacity,
274                    t.size
275                ),
276            });
277        }
278    }
279}
280
281/// L5 — a world with no light source at all: no sky, no placement lights, and
282/// nothing emissive renders as a black hole.
283fn darkness(m: &WorldManifest, out: &mut Vec<Finding>) {
284    let has_sky = m.environment.sky.is_some();
285    let has_light = m.placements.iter().any(|p| p.light.is_some());
286    let has_emissive = m
287        .prefabs
288        .iter()
289        .any(|pf| pf.material.as_ref().is_some_and(|mat| mat.emissive > 0.0));
290    if !has_sky && !has_light && !has_emissive {
291        out.push(Finding {
292            rule: "L5",
293            message: "no sky, no lights, nothing emissive — this world renders dark".to_string(),
294        });
295    }
296}
297
298/// L6 — zero, negative, or absurd scales (a 0-thin wall flickers; a 900 m cube
299/// is almost always a typo'd unit).
300fn degenerate_scales(m: &WorldManifest, out: &mut Vec<Finding>) {
301    for (i, p) in m.placements.iter().enumerate() {
302        if p.scale.iter().any(|s| *s <= 0.0) {
303            out.push(Finding {
304                rule: "L6",
305                message: format!("{} has a zero/negative scale {:?}", name_of(p, i), p.scale),
306            });
307        } else if p.scale.iter().any(|s| *s > 500.0) {
308            out.push(Finding {
309                rule: "L6",
310                message: format!(
311                    "{} is over 500m on a side ({:?}) — metres, not centimetres?",
312                    name_of(p, i),
313                    p.scale
314                ),
315            });
316        }
317    }
318}
319
320/// L8 — two placements at the identical position (a copy-paste double: they
321/// z-fight and double colliders).
322fn doubled_placements(m: &WorldManifest, out: &mut Vec<Finding>) {
323    for i in 0..m.placements.len() {
324        for j in (i + 1)..m.placements.len() {
325            let (a, b) = (&m.placements[i], &m.placements[j]);
326            if a.prefab == b.prefab && a.position == b.position && a.scale == b.scale {
327                out.push(Finding {
328                    rule: "L8",
329                    message: format!(
330                        "{} and {} are identical twins at {:?} — one is probably a paste",
331                        name_of(a, i),
332                        name_of(b, j),
333                        a.position
334                    ),
335                });
336            }
337        }
338    }
339}
340
341fn dist(a: [f32; 3], b: [f32; 3]) -> f32 {
342    let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
343    (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::markup;
350
351    fn world(src: &str) -> WorldManifest {
352        markup::compile(src).unwrap()
353    }
354
355    #[test]
356    fn a_clean_world_lints_clean() {
357        let m = world(
358            r#"<world id="w" title="W" sky="dusk">
359                 <spawn at="0 0 8"/>
360                 <plane at="0 0 0" scale="30 1 30"/>
361                 <cube at="3 0.5 0" scale="1 1 1"/>
362                 <portal at="0 1.4 -6" to="thread://pixygon.io" label="Out"/>
363               </world>"#,
364        );
365        let findings = lint(&m);
366        assert!(findings.is_empty(), "{findings:?}");
367    }
368
369    #[test]
370    fn floating_buried_and_walkin_are_caught() {
371        let m = world(
372            r#"<world id="w" title="W" sky="dusk">
373                 <spawn at="0 0 -5"/>
374                 <plane at="0 0 0" scale="30 1 30"/>
375                 <cube id="hover" at="5 4 5" scale="1 1 1"/>
376                 <cube id="tomb" at="0 1 -5" scale="4 4 4"/>
377                 <portal id="door" at="0 1.4 -6" to="thread://pixygon.io" label="Out"/>
378               </world>"#,
379        );
380        let f = lint(&m);
381        let rules: Vec<&str> = f.iter().map(|x| x.rule).collect();
382        assert!(rules.contains(&"L1"), "hovering cube: {f:?}");
383        assert!(rules.contains(&"L2"), "buried spawn: {f:?}");
384        assert!(rules.contains(&"L3"), "walk-in veil: {f:?}");
385    }
386
387    #[test]
388    fn supported_stacks_do_not_flag() {
389        let m = world(
390            r#"<world id="w" title="W" sky="dusk">
391                 <spawn at="0 0 8"/>
392                 <plane at="0 0 0" scale="30 1 30"/>
393                 <cube id="plinth" at="0 0.5 0" scale="1 1 1"/>
394                 <cube id="statue" at="0 1.5 0" scale="0.8 1 0.8"/>
395               </world>"#,
396        );
397        let f: Vec<_> = lint(&m).into_iter().filter(|x| x.rule == "L1").collect();
398        assert!(f.is_empty(), "stacked statue is supported: {f:?}");
399    }
400
401    #[test]
402    fn overflow_darkness_scale_and_twins_are_caught() {
403        let mut m = world(
404            r#"<world id="w" title="W">
405                 <spawn at="0 0 8"/>
406                 <plane at="0 0 0" scale="30 1 30"/>
407                 <cube at="4 0.5 4"/>
408                 <cube at="4 0.5 4"/>
409                 <cube id="mega" at="9 0.5 0" scale="900 1 1"/>
410               </world>"#,
411        );
412        m.placements[0].text = Some(crate::TextPanel {
413            content: (0..40)
414                .map(|i| format!("line {i}"))
415                .collect::<Vec<_>>()
416                .join("\n"),
417            size: 0.05,
418            color: [0.0; 3],
419            background: [1.0; 3],
420            links: vec![],
421        });
422        let f = lint(&m);
423        let rules: Vec<&str> = f.iter().map(|x| x.rule).collect();
424        assert!(rules.contains(&"L4"), "overflowing text: {f:?}");
425        assert!(rules.contains(&"L5"), "dark world: {f:?}");
426        assert!(rules.contains(&"L6"), "typo scale: {f:?}");
427        assert!(rules.contains(&"L8"), "twins: {f:?}");
428    }
429
430    /// L9. The world stays valid either way — this fires so the author finds
431    /// out which of the two lines they got wrong.
432    #[test]
433    fn a_presence_claim_the_addresses_cannot_keep_is_reported() {
434        let world = |presence: &str| -> WorldManifest {
435            serde_json::from_str(&format!(
436                r#"{{ "thread": "thread/0.1", "world": {{ "id": "w", "title": "W" }},
437                      "spawns": [{{ "name": "e", "position": [0,0,0] }}],
438                      "placements": [{{ "prefab": 60930001, "position": [0,0,0], "scale": [1,1,1],
439                                        "light": {{ "color": [1,1,1], "intensity": 1, "range": 8 }} }}],
440                      "presence": {presence} }}"#
441            ))
442            .expect("manifest")
443        };
444        let claims_a_relay_it_hasnt_got = lint(&world(r#"{ "mode": "relay" }"#));
445        assert!(
446            claims_a_relay_it_hasnt_got.iter().any(|f| f.rule == "L9"),
447            "{claims_a_relay_it_hasnt_got:?}"
448        );
449        assert!(lint(&world(r#"{ "mode": "solo" }"#))
450            .iter()
451            .all(|f| f.rule != "L9"));
452        assert!(
453            lint(&world(r#"{ "mode": "relay", "relays": ["wss://a"] }"#))
454                .iter()
455                .all(|f| f.rule != "L9")
456        );
457        assert!(lint(&world(r#"{ "relays": ["wss://a"] }"#))
458            .iter()
459            .all(|f| f.rule != "L9"));
460    }
461}