Skip to main content

mnk_vmf/types/
world.rs

1use chumsky::{IterParser, Parser as ChumskyParser};
2use std::collections::HashMap;
3
4use crate::{
5    impl_block_properties_parser,
6    parser::{
7        any_quoted_string, close_block, key_value, key_value_boolean, key_value_numeric,
8        open_block, InternalParser, TokenError, TokenSource,
9    },
10    types::{EditorData, Solid},
11    Parser,
12};
13
14use super::Group;
15
16/// Represents the worldspawn entity in a VMF file
17#[derive(Debug, Default)]
18pub struct World<'src> {
19    pub id: u32,
20    pub mapversion: u32,
21    pub classname: &'src str,
22    pub detailmaterial: Option<&'src str>,
23    pub detailvbsp: Option<&'src str>,
24    pub maxpropscreenwidth: Option<i32>,
25    pub skyname: Option<&'src str>,
26    pub sounds: Option<u32>,
27    pub maxrange: Option<f32>,
28
29    // Game-specific properties
30    pub maxoccludeearea: Option<f32>,
31    pub minoccluderarea: Option<f32>,
32    pub maxoccludeearea_csgo: Option<f32>,
33    pub minoccluderarea_csgo: Option<f32>,
34    pub difficulty_level: Option<u32>,
35    pub hdr_level: Option<u32>,
36
37    // Geometry
38    pub solids: Vec<Solid<'src>>,
39
40    // Entity connections
41    pub targetname: Option<&'src str>,
42    pub target: Option<&'src str>,
43
44    // Custom key-value pairs for world-specific properties
45    pub properties: HashMap<&'src str, &'src str>,
46
47    // Editor data
48    pub hidden: Option<bool>,
49    pub group: Option<Group<'src>>,
50    pub editor: Option<EditorData<'src>>,
51}
52
53/// Internal [`World`] Properties to be used in a parser impl
54#[derive(Debug, Clone)]
55enum WorldProperty<'src> {
56    Id(u32),
57    MapVersion(u32),
58    Classname(&'src str),
59    DetailMaterial(&'src str),
60    DetailVbsp(&'src str),
61    MaxPropScreenWidth(i32),
62    Skyname(&'src str),
63    Sounds(u32),
64    MaxRange(f32),
65    MaxOccludeeArea(f32),
66    MinOccluderArea(f32),
67    MaxOccludeeAreaCsgo(f32),
68    MinOccluderAreaCsgo(f32),
69    DifficultyLevel(u32),
70    HdrLevel(u32),
71    Targetname(&'src str),
72    Target(&'src str),
73    Hidden(bool),
74    Group(Group<'src>),
75    Editor(EditorData<'src>),
76    Solid(Solid<'src>),
77    Custom(&'src str, &'src str),
78}
79
80/// Public parser trait implementation
81impl<'src> Parser<'src> for World<'src> {}
82
83/// InternalParser implementation for World
84impl<'src> InternalParser<'src> for World<'src> {
85    fn parser<I>() -> impl ChumskyParser<'src, I, Self, TokenError<'src>>
86    where
87        I: TokenSource<'src>,
88    {
89        impl_block_properties_parser! {
90            known_properties: WorldProperty<'src> = {
91                p_id                    = key_value_numeric("id")                     => WorldProperty::Id,
92                p_mapversion            = key_value_numeric("mapversion")             => WorldProperty::MapVersion,
93                p_classname             = key_value("classname")                      => |s: &str| WorldProperty::Classname(s),
94                p_detailmaterial        = key_value("detailmaterial")                 => |s: &str| WorldProperty::DetailMaterial(s),
95                p_detailvbsp            = key_value("detailvbsp")                     => |s: &str| WorldProperty::DetailVbsp(s),
96                p_maxpropscreenwidth    = key_value_numeric("maxpropscreenwidth")     => WorldProperty::MaxPropScreenWidth,
97                p_skyname               = key_value("skyname")                        => |s: &str| WorldProperty::Skyname(s),
98                p_sounds                = key_value_numeric("sounds")                 => WorldProperty::Sounds,
99                p_maxrange              = key_value_numeric("maxrange")               => WorldProperty::MaxRange,
100                p_maxoccludeearea       = key_value_numeric("maxoccludeearea")        => WorldProperty::MaxOccludeeArea,
101                p_minoccluderarea       = key_value_numeric("minoccluderarea")        => WorldProperty::MinOccluderArea,
102                p_maxoccludeearea_csgo  = key_value_numeric("maxoccludeearea_csgo")   => WorldProperty::MaxOccludeeAreaCsgo,
103                p_minoccluderarea_csgo  = key_value_numeric("minoccluderarea_csgo")   => WorldProperty::MinOccluderAreaCsgo,
104                p_difficulty_level      = key_value_numeric("difficulty_level")       => WorldProperty::DifficultyLevel,
105                p_hdr_level             = key_value_numeric("hdr_level")              => WorldProperty::HdrLevel,
106                p_targetname            = key_value("targetname")                     => |s: &str| WorldProperty::Targetname(s),
107                p_target                = key_value("target")                         => |s: &str| WorldProperty::Target(s),
108                p_hidden                = key_value_boolean("hidden")                 => WorldProperty::Hidden,
109                p_group                 = Group::parser()                             => WorldProperty::Group,
110            }
111        }
112
113        let editor_parser = EditorData::parser().map(WorldProperty::Editor);
114        let solid_parser = Solid::parser().map(WorldProperty::Solid);
115        let custom_property = any_quoted_string()
116            .then(any_quoted_string())
117            .map(|(key, value): (&str, &str)| WorldProperty::Custom(key, value));
118
119        let any_property = known_properties
120            .or(editor_parser)
121            .or(solid_parser)
122            .or(custom_property);
123
124        open_block("world")
125            .ignore_then(
126                any_property
127                    .repeated()
128                    .collect::<Vec<WorldProperty<'src>>>(),
129            )
130            .then_ignore(close_block())
131            .map(|properties: Vec<WorldProperty<'src>>| {
132                let mut world = World::default();
133                for prop in properties {
134                    match prop {
135                        WorldProperty::Id(val) => world.id = val,
136                        WorldProperty::MapVersion(val) => world.mapversion = val,
137                        WorldProperty::Classname(val) => world.classname = val,
138                        WorldProperty::DetailMaterial(val) => world.detailmaterial = Some(val),
139                        WorldProperty::DetailVbsp(val) => world.detailvbsp = Some(val),
140                        WorldProperty::MaxPropScreenWidth(val) => {
141                            world.maxpropscreenwidth = Some(val)
142                        }
143                        WorldProperty::Skyname(val) => world.skyname = Some(val),
144                        WorldProperty::Sounds(val) => world.sounds = Some(val),
145                        WorldProperty::MaxRange(val) => world.maxrange = Some(val),
146                        WorldProperty::MaxOccludeeArea(val) => world.maxoccludeearea = Some(val),
147                        WorldProperty::MinOccluderArea(val) => world.minoccluderarea = Some(val),
148                        WorldProperty::MaxOccludeeAreaCsgo(val) => {
149                            world.maxoccludeearea_csgo = Some(val)
150                        }
151                        WorldProperty::MinOccluderAreaCsgo(val) => {
152                            world.minoccluderarea_csgo = Some(val)
153                        }
154                        WorldProperty::DifficultyLevel(val) => world.difficulty_level = Some(val),
155                        WorldProperty::HdrLevel(val) => world.hdr_level = Some(val),
156                        WorldProperty::Targetname(val) => world.targetname = Some(val),
157                        WorldProperty::Target(val) => world.target = Some(val),
158                        WorldProperty::Hidden(val) => world.hidden = Some(val),
159                        WorldProperty::Group(val) => world.group = Some(val),
160                        WorldProperty::Editor(val) => world.editor = Some(val),
161                        WorldProperty::Solid(val) => world.solids.push(val),
162                        WorldProperty::Custom(key, value) => {
163                            world.properties.insert(key, value);
164                        }
165                    }
166                }
167                world
168            })
169            .boxed()
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::util::lex;
177
178    #[test]
179    fn test_world_minimal() {
180        let input = r#"
181        world
182        {
183            "id" "1"
184            "mapversion" "16"
185            "classname" "worldspawn"
186        }
187        "#;
188
189        let stream = lex(input);
190        let result = World::parse(stream);
191        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
192
193        let world = result.unwrap();
194        assert_eq!(world.id, 1);
195        assert_eq!(world.mapversion, 16);
196        assert_eq!(world.classname, "worldspawn");
197        assert_eq!(world.solids.len(), 0);
198        assert!(world.editor.is_none());
199    }
200
201    #[test]
202    fn test_world_with_properties() {
203        let input = r#"
204        world
205        {
206            "id" "1"
207            "mapversion" "16"
208            "classname" "worldspawn"
209            "detailmaterial" "detail/detailsprites"
210            "detailvbsp" "detail.vbsp"
211            "maxpropscreenwidth" "-1"
212            "skyname" "sky_day01_01"
213        }
214        "#;
215
216        let stream = lex(input);
217        let result = World::parse(stream);
218        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
219
220        let world = result.unwrap();
221        assert_eq!(world.detailmaterial, Some("detail/detailsprites"));
222        assert_eq!(world.detailvbsp, Some("detail.vbsp"));
223        assert_eq!(world.maxpropscreenwidth, Some(-1));
224        assert_eq!(world.skyname, Some("sky_day01_01"));
225    }
226
227    #[test]
228    fn test_world_with_solids() {
229        let input = r#"
230        world
231        {
232            "id" "1"
233            "mapversion" "16"
234            "classname" "worldspawn"
235            solid
236            {
237                "id" "9"
238                side
239                {
240                    "id" "1"
241                    "plane" "(0 0 0) (1 0 0) (1 1 0)"
242                    "material" "DEV/DEV_MEASUREGENERIC01B"
243                    "uaxis" "[1 0 0 0] 0.25"
244                    "vaxis" "[0 -1 0 0] 0.25"
245                }
246            }
247            solid
248            {
249                "id" "30"
250                side
251                {
252                    "id" "2"
253                    "plane" "(0 0 0) (1 0 0) (1 1 0)"
254                    "material" "DEV/DEV_MEASUREGENERIC01"
255                    "uaxis" "[1 0 0 0] 0.25"
256                    "vaxis" "[0 -1 0 0] 0.25"
257                }
258            }
259        }
260        "#;
261
262        let stream = lex(input);
263        let result = World::parse(stream);
264        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
265
266        let world = result.unwrap();
267        assert_eq!(world.solids.len(), 2);
268        assert_eq!(world.solids[0].id, 9);
269        assert_eq!(world.solids[1].id, 30);
270    }
271
272    #[test]
273    fn test_world_with_multiple_solids() {
274        let input = r#"
275        world
276        {
277            "id" "1"
278            "mapversion" "16"
279            "classname" "worldspawn"
280            "skyname" "sky_day01_01"
281            solid
282            {
283                "id" "1"
284                side
285                {
286                    "id" "1"
287                    "plane" "(0 0 0) (1 0 0) (1 1 0)"
288                    "material" "CONCRETE"
289                    "uaxis" "[1 0 0 0] 0.25"
290                    "vaxis" "[0 -1 0 0] 0.25"
291                }
292            }
293            solid
294            {
295                "id" "2"
296                side
297                {
298                    "id" "2"
299                    "plane" "(0 0 0) (1 0 0) (1 1 0)"
300                    "material" "METAL"
301                    "uaxis" "[1 0 0 0] 0.25"
302                    "vaxis" "[0 -1 0 0] 0.25"
303                }
304            }
305            solid
306            {
307                "id" "3"
308                side
309                {
310                    "id" "3"
311                    "plane" "(0 0 0) (1 0 0) (1 1 0)"
312                    "material" "WOOD"
313                    "uaxis" "[1 0 0 0] 0.25"
314                    "vaxis" "[0 -1 0 0] 0.25"
315                }
316            }
317        }
318        "#;
319
320        let stream = lex(input);
321        let result = World::parse(stream);
322        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
323
324        let world = result.unwrap();
325        assert_eq!(world.solids.len(), 3);
326        assert_eq!(world.solids[0].id, 1);
327        assert_eq!(world.solids[1].id, 2);
328        assert_eq!(world.solids[2].id, 3);
329    }
330
331    #[test]
332    fn test_world_with_custom_properties() {
333        let input = r#"
334        world
335        {
336            "id" "1"
337            "mapversion" "16"
338            "classname" "worldspawn"
339            "customkey1" "customvalue1"
340            "customkey2" "customvalue2"
341            "_light" "255 255 255 200"
342        }
343        "#;
344
345        let stream = lex(input);
346        let result = World::parse(stream);
347        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
348
349        let world = result.unwrap();
350        assert_eq!(world.properties.get("customkey1"), Some(&"customvalue1"));
351        assert_eq!(world.properties.get("customkey2"), Some(&"customvalue2"));
352        assert_eq!(world.properties.get("_light"), Some(&"255 255 255 200"));
353    }
354
355    #[test]
356    fn test_world_properties_out_of_order() {
357        let input = r#"
358        world
359        {
360            "skyname" "sky_day01_01"
361            "classname" "worldspawn"
362            "id" "1"
363            "mapversion" "16"
364            "detailmaterial" "detail/detailsprites"
365        }
366        "#;
367
368        let stream = lex(input);
369        let result = World::parse(stream);
370        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
371
372        let world = result.unwrap();
373        assert_eq!(world.id, 1);
374        assert_eq!(world.mapversion, 16);
375        assert_eq!(world.classname, "worldspawn");
376        assert_eq!(world.skyname, Some("sky_day01_01"));
377        assert_eq!(world.detailmaterial, Some("detail/detailsprites"));
378    }
379
380    #[test]
381    fn test_world_with_editor() {
382        let input = r#"
383        world
384        {
385            "id" "1"
386            "mapversion" "16"
387            "classname" "worldspawn"
388            editor
389            {
390                "color" "255 0 0"
391                "visgroupshown" "1"
392                "visgroupautoshown" "1"
393            }
394        }
395        "#;
396
397        let stream = lex(input);
398        let result = World::parse(stream);
399        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
400
401        let world = result.unwrap();
402        assert!(world.editor.is_some());
403        let editor = world.editor.unwrap();
404        assert_eq!(editor.color.r, 255);
405        assert_eq!(editor.color.g, 0);
406        assert_eq!(editor.color.b, 0);
407    }
408
409    #[test]
410    fn test_world_with_game_specific_properties() {
411        let input = r#"
412        world
413        {
414            "id" "1"
415            "mapversion" "16"
416            "classname" "worldspawn"
417            "maxoccludeearea" "1000.5"
418            "minoccluderarea" "500.25"
419            "maxoccludeearea_csgo" "2000.75"
420            "minoccluderarea_csgo" "750.5"
421            "difficulty_level" "2"
422            "hdr_level" "1"
423        }
424        "#;
425
426        let stream = lex(input);
427        let result = World::parse(stream);
428        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
429
430        let world = result.unwrap();
431        assert_eq!(world.maxoccludeearea, Some(1000.5));
432        assert_eq!(world.minoccluderarea, Some(500.25));
433        assert_eq!(world.maxoccludeearea_csgo, Some(2000.75));
434        assert_eq!(world.minoccluderarea_csgo, Some(750.5));
435        assert_eq!(world.difficulty_level, Some(2));
436        assert_eq!(world.hdr_level, Some(1));
437    }
438
439    #[test]
440    fn test_world_with_entity_connections() {
441        let input = r#"
442        world
443        {
444            "id" "1"
445            "mapversion" "16"
446            "classname" "worldspawn"
447            "targetname" "world_spawn"
448            "target" "some_target"
449        }
450        "#;
451
452        let stream = lex(input);
453        let result = World::parse(stream);
454        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
455
456        let world = result.unwrap();
457        assert_eq!(world.targetname, Some("world_spawn"));
458        assert_eq!(world.target, Some("some_target"));
459    }
460
461    #[test]
462    fn test_world_with_editor_properties() {
463        let input = r#"
464        world
465        {
466            "id" "1"
467            "mapversion" "16"
468            "classname" "worldspawn"
469            "hidden" "1"
470            "group" "5"
471        }
472        "#;
473
474        let stream = lex(input);
475        let result = World::parse(stream);
476        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
477
478        let world = result.unwrap();
479        assert_eq!(world.hidden, Some(true));
480    }
481
482    #[test]
483    fn test_world_with_group_block() {
484        let input = r#"
485        world
486        {
487            "id" "1"
488            "classname" "worldspawn"
489            group
490            {
491                "id" "42"
492                group
493                {
494                    "id" "99"
495                }
496            }
497        }
498        "#;
499
500        let stream = lex(input);
501        let result = World::parse(stream);
502        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
503
504        let world = result.unwrap();
505        let group = world.group.unwrap();
506        assert_eq!(group.id, 42);
507        assert_eq!(group.groups.len(), 1);
508        assert_eq!(group.groups[0].id, 99);
509    }
510
511    #[test]
512    fn test_world_empty_block() {
513        let input = r#"
514        world
515        {
516        }
517        "#;
518
519        let stream = lex(input);
520        let result = World::parse(stream);
521        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
522
523        let world = result.unwrap();
524        assert_eq!(world.id, 0);
525        assert_eq!(world.mapversion, 0);
526        assert_eq!(world.classname, "");
527        assert_eq!(world.solids.len(), 0);
528    }
529
530    #[test]
531    fn test_world_with_all_sound_properties() {
532        let input = r#"
533        world
534        {
535            "id" "1"
536            "mapversion" "16"
537            "classname" "worldspawn"
538            "sounds" "1"
539            "maxrange" "4096.5"
540        }
541        "#;
542
543        let stream = lex(input);
544        let result = World::parse(stream);
545        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
546
547        let world = result.unwrap();
548        assert_eq!(world.sounds, Some(1));
549        assert_eq!(world.maxrange, Some(4096.5));
550    }
551
552    #[test]
553    fn test_world_complete_with_everything() {
554        let input = r#"
555        world
556        {
557            "id" "1"
558            "mapversion" "16"
559            "classname" "worldspawn"
560            "detailmaterial" "detail/detailsprites"
561            "detailvbsp" "detail.vbsp"
562            "maxpropscreenwidth" "-1"
563            "skyname" "sky_day01_01"
564            solid
565            {
566                "id" "9"
567                side
568                {
569                    "id" "1"
570                    "plane" "(0 0 0) (1 0 0) (1 1 0)"
571                    "material" "DEV/DEV_MEASUREGENERIC01B"
572                    "uaxis" "[1 0 0 0] 0.25"
573                    "vaxis" "[0 -1 0 0] 0.25"
574                }
575                editor
576                {
577                    "color" "0 111 152"
578                    "visgroupshown" "1"
579                    "visgroupautoshown" "1"
580                }
581            }
582            editor
583            {
584                "color" "255 255 255"
585                "visgroupshown" "1"
586                "visgroupautoshown" "1"
587            }
588        }
589        "#;
590
591        let stream = lex(input);
592        let result = World::parse(stream);
593        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
594
595        let world = result.unwrap();
596        assert_eq!(world.id, 1);
597        assert_eq!(world.classname, "worldspawn");
598        assert_eq!(world.solids.len(), 1);
599        assert!(world.editor.is_some());
600        assert!(world.solids[0].editor.is_some());
601    }
602
603    #[test]
604    fn test_world_invalid_block_name() {
605        let input = r#"
606        wrongname
607        {
608            "id" "1"
609            "classname" "worldspawn"
610        }
611        "#;
612
613        let stream = lex(input);
614        let result = World::parse(stream);
615        assert!(result.is_err(), "Parser should fail on invalid block name");
616    }
617
618    #[test]
619    fn test_world_missing_closing_brace() {
620        let input = r#"
621        world
622        {
623            "id" "1"
624            "classname" "worldspawn"
625        "#;
626
627        let stream = lex(input);
628        let result = World::parse(stream);
629        assert!(
630            result.is_err(),
631            "Parser should fail on missing closing brace"
632        );
633    }
634
635    #[test]
636    fn test_world_mixed_solids_and_properties() {
637        let input = r#"
638        world
639        {
640            "id" "1"
641            solid
642            {
643                "id" "1"
644                side
645                {
646                    "id" "1"
647                    "plane" "(0 0 0) (1 0 0) (1 1 0)"
648                    "material" "BRICK"
649                    "uaxis" "[1 0 0 0] 0.25"
650                    "vaxis" "[0 -1 0 0] 0.25"
651                }
652            }
653            "classname" "worldspawn"
654            "mapversion" "16"
655            solid
656            {
657                "id" "2"
658                side
659                {
660                    "id" "2"
661                    "plane" "(0 0 0) (1 0 0) (1 1 0)"
662                    "material" "METAL"
663                    "uaxis" "[1 0 0 0] 0.25"
664                    "vaxis" "[0 -1 0 0] 0.25"
665                }
666            }
667            "skyname" "sky_day01_01"
668        }
669        "#;
670
671        let stream = lex(input);
672        let result = World::parse(stream);
673        assert!(result.is_ok(), "Parsing failed: {:?}", result.err());
674
675        let world = result.unwrap();
676        assert_eq!(world.id, 1);
677        assert_eq!(world.classname, "worldspawn");
678        assert_eq!(world.solids.len(), 2);
679        assert_eq!(world.skyname, Some("sky_day01_01"));
680    }
681}