From af96b5efa2bc60cdb9537541f567212e1213fa1a Mon Sep 17 00:00:00 2001 From: seth Date: Sat, 10 May 2025 10:28:15 -0400 Subject: [PATCH 1/3] Integrate parsing Quake II maps --- src/qmap/lexer.rs | 7 +++++ src/qmap/parser.rs | 50 +++++++++++++++++++++++++++++---- src/qmap/parser_test.rs | 55 ++++++++++++++++++++++++++++++++++--- src/qmap/repr.rs | 61 +++++++++++++++++++++++++++++++++++++++++ src/qmap/repr_test.rs | 5 ++++ 5 files changed, 169 insertions(+), 9 deletions(-) diff --git a/src/qmap/lexer.rs b/src/qmap/lexer.rs index fa06cdc..86d2f24 100644 --- a/src/qmap/lexer.rs +++ b/src/qmap/lexer.rs @@ -33,6 +33,13 @@ impl Token { && self.text.last() == Some(&b'"'.try_into().unwrap()) } + pub fn starts_numeric(&self) -> bool { + !self.text.is_empty() && { + let first_byte = self.text[0].get(); + first_byte == b'-' || first_byte.is_ascii_digit() + } + } + pub fn text_as_string(&self) -> String { self.text .iter() diff --git a/src/qmap/parser.rs b/src/qmap/parser.rs index 426d1bf..e6c7042 100644 --- a/src/qmap/parser.rs +++ b/src/qmap/parser.rs @@ -5,7 +5,10 @@ use std::{io::Read, iter::Peekable, num::NonZeroU8, str::FromStr, vec::Vec}; use crate::{common, qmap, TextParseError, TextParseResult}; use common::CellOptionExt; use qmap::lexer::{Token, TokenIterator}; -use qmap::repr::{Alignment, Brush, Edict, Entity, Point, QuakeMap, Surface}; +use qmap::repr::{ + Alignment, Brush, Edict, Entity, Point, Quake2SurfaceExtension, QuakeMap, + Surface, +}; type TokenPeekable = Peekable>; @@ -26,10 +29,9 @@ const MIN_BRUSH_SURFACES: usize = 4; /// Parses a Quake source map /// -/// Maps must be in the Quake 1 format (Quake 2 surface flags and Quake 3 -/// `brushDef`s/`patchDef`s are not presently supported) but may have texture -/// alignment in either "Valve220" format or the "legacy" predecessor (i.e. -/// without texture axes) +/// Maps must be in the Quake 1 or 2 format (Quake 3 `brushDef`s/`patchDef`s are +/// not presently supported) but may have texture alignment in either "Valve220" +/// format or the "legacy" predecessor (i.e. without texture axes) pub fn parse(reader: &mut R) -> TextParseResult { let mut entities: Vec = Vec::new(); let mut peekable_tokens = TokenIterator::new(reader).peekable(); @@ -143,10 +145,21 @@ fn parse_surface( return Err(TextParseError::eof()); }; + let q2ext = if let Some(tok_res) = tokens.peek() { + if tok_res.as_ref().map_err(|e| e.steal())?.starts_numeric() { + parse_q2_ext(tokens)? + } else { + Default::default() + } + } else { + return Err(TextParseError::eof()); + }; + Ok(Surface { half_space, texture, alignment, + q2ext, }) } @@ -179,6 +192,20 @@ fn parse_legacy_alignment( }) } +fn parse_q2_ext( + tokens: &mut TokenPeekable, +) -> TextParseResult { + let content_flags = expect_int(&tokens.extract()?)?; + let surface_flags = expect_int(&tokens.extract()?)?; + let surface_value = expect_float(&tokens.extract()?)?; + + Ok(Quake2SurfaceExtension { + content_flags, + surface_flags, + surface_value, + }) +} + fn parse_valve_alignment( tokens: &mut TokenPeekable, ) -> TextParseResult { @@ -276,6 +303,19 @@ fn expect_float(token: &Option) -> TextParseResult { } } +fn expect_int(token: &Option) -> TextParseResult { + match token.as_ref() { + Some(payload) => match i32::from_str(&payload.text_as_string()) { + Ok(num) => Ok(num), + Err(_) => Err(TextParseError::from_parser( + format!("Expected integer, got `{}`", payload.text_as_string()), + payload.line_number, + )), + }, + None => Err(TextParseError::eof()), + } +} + fn strip_quoted(quoted_text: &[NonZeroU8]) -> &[NonZeroU8] { "ed_text[1..quoted_text.len() - 1] } diff --git a/src/qmap/parser_test.rs b/src/qmap/parser_test.rs index 90f9eaf..cf7e280 100644 --- a/src/qmap/parser_test.rs +++ b/src/qmap/parser_test.rs @@ -94,6 +94,7 @@ fn parse_standard_brush_entity() { assert_eq!(surface.alignment.offset, [0.0, 0.0]); assert_eq!(surface.alignment.rotation, 0.0); assert_eq!(surface.alignment.scale, [1.0, 1.0]); + assert!(surface.q2ext.is_zeroed()); } #[test] @@ -127,6 +128,7 @@ fn parse_valve_brush_entity() { surface.alignment.axes, Some([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]), ); + assert!(surface.q2ext.is_zeroed()); } #[test] @@ -189,6 +191,35 @@ fn parse_weird_textures() { assert_eq!(surface3.texture, CString::new(r#"silly"example"#).unwrap()); } +#[test] +fn parse_q2_surface() { + let map = parse( + &mut &br#" + { { + ( 1 2 3 ) ( 4 5 6 ) ( 7 8 9 ) + T + 0 0 0 1 1 -97 9 255 + + ( 2 5 7 ) ( 8 9 7 ) ( 4 3 6 ) + T + 0 0 0 1 1 0 -9 -13.7 + } } + "#[..], + ) + .unwrap(); + + let std_ext = &map.entities[0].brushes[0][0].q2ext; + let valve_ext = &map.entities[0].brushes[0][1].q2ext; + assert!(!std_ext.is_zeroed()); + assert_eq!(std_ext.content_flags, -97); + assert_eq!(std_ext.surface_flags, 9); + assert_eq!(std_ext.surface_value, 255.0); + assert!(!valve_ext.is_zeroed()); + assert_eq!(valve_ext.content_flags, 0); + assert_eq!(valve_ext.surface_flags, -9); + assert_eq!(valve_ext.surface_value, -13.7); +} + // Parse errors #[test] @@ -269,10 +300,26 @@ fn parse_unclosed_surface() { { "#; let err = parse(&mut &map_text[..]).err().unwrap(); - if let error::TextParse::Parser(line_err) = err { - let (pfx, _) = line_err.message.split_once("got").unwrap(); - assert!(pfx.contains("`}`")); - assert!(pfx.contains("`(`")); + if let error::TextParse::Parser(ref line_err) = err { + assert!(line_err.message.contains("`}`")); + assert!(line_err.message.contains("`(`")); + } else { + panic_unexpected_variant(err); + } +} + +#[test] +fn parse_incomplete_q2_extension() { + let map_text = br#" + { { + ( 1 2 3 ) ( 4 5 6 ) ( 7 8 9 ) t 0 0 0 1 1 -2 + } } + "#; + + let err = parse(&mut &map_text[..]).err().unwrap(); + if let error::TextParse::Parser(ref line_err) = err { + assert!(line_err.message.contains("integer")); + assert!(line_err.message.contains("`}`")); } else { panic_unexpected_variant(err); } diff --git a/src/qmap/repr.rs b/src/qmap/repr.rs index 58853e2..d750000 100644 --- a/src/qmap/repr.rs +++ b/src/qmap/repr.rs @@ -174,11 +174,15 @@ impl CheckWritable for Brush { } /// Brush face +/// +/// Set `q2ext` to its default (`Default::default()`) value to create a surface +/// compatible for Quake 1 tools #[derive(Clone, Debug)] pub struct Surface { pub half_space: HalfSpace, pub texture: CString, pub alignment: Alignment, + pub q2ext: Quake2SurfaceExtension, } impl Surface { @@ -189,6 +193,11 @@ impl Surface { write_texture_to(&self.texture, writer)?; writer.write_all(b" ").map_err(WriteError::Io)?; self.alignment.write_to(writer)?; + + if !self.q2ext.is_zeroed() { + self.q2ext.write_to(writer)?; + } + Ok(()) } } @@ -268,6 +277,58 @@ impl Alignment { } } +/// Quake II Surface Extension +/// +/// Additional fields for surfaces to support Quake II maps. Contains two +/// bit fields (up to 31 bits in each; negative values are non-standard, but +/// a signed type is used for consistency with existing tools) and a floating- +/// point value (_ought_ to be an integer, but TrenchBroom allows writing +/// floats). +#[derive(Clone, Copy, Debug)] +pub struct Quake2SurfaceExtension { + /// Flags describing contents of the brush + pub content_flags: i32, + + /// Flags describing the surface + pub surface_flags: i32, + + /// Value associated with surface, e.g. light value for emissive surfaces + pub surface_value: f64, +} + +impl Quake2SurfaceExtension { + /// Returns true if all fields are 0, otherwise false. Behavior is + /// undefined if `surface_value` is NaN (read: behavior may change between + /// revisions without remark). + pub fn is_zeroed(&self) -> bool { + self.content_flags == 0 + && self.surface_flags == 0 + && self.surface_value == 0.0 + } + + #[cfg(feature = "std")] + fn write_to(&self, writer: &mut W) -> WriteAttempt { + write!( + writer, + "{} {} {}", + self.content_flags, self.surface_flags, self.surface_value, + ) + .map_err(WriteError::Io)?; + + Ok(()) + } +} + +impl Default for Quake2SurfaceExtension { + fn default() -> Self { + Self { + content_flags: 0, + surface_flags: 0, + surface_value: 0.0, + } + } +} + impl CheckWritable for Alignment { fn check_writable(&self) -> ValidationResult { check_writable_array(self.offset)?; diff --git a/src/qmap/repr_test.rs b/src/qmap/repr_test.rs index e03ad02..ad59d9a 100644 --- a/src/qmap/repr_test.rs +++ b/src/qmap/repr_test.rs @@ -79,6 +79,7 @@ fn simple_surface() -> Surface { half_space: GOOD_HALF_SPACE, texture: CString::new("{FENCE").unwrap(), alignment: GOOD_ALIGNMENT, + q2ext: Default::default(), } } @@ -119,6 +120,7 @@ fn entity_with_texture(texture: &CStr) -> Entity { half_space: GOOD_HALF_SPACE, texture: CString::from(texture), alignment: GOOD_ALIGNMENT, + q2ext: Default::default(), }]], } } @@ -196,6 +198,7 @@ fn check_bad_surface_half_space() { half_space: BAD_HALF_SPACE, texture: CString::new("butts").unwrap(), alignment: GOOD_ALIGNMENT, + q2ext: Default::default(), }; expect_err_containing(surf.check_writable(), "finite"); @@ -207,6 +210,7 @@ fn check_bad_surface_alignment() { half_space: GOOD_HALF_SPACE, texture: CString::new("potato").unwrap(), alignment: BAD_ALIGNMENT_ROTATION, + q2ext: Default::default(), }; assert!(surf.check_writable().is_err()); @@ -370,6 +374,7 @@ mod write { scale: BAD_VEC2, axes: Some(GOOD_AXES), }, + q2ext: Default::default(), }]], }); let res = qmap.write_to(&mut dest); From c6e7a63e16b74887a4d45dffcaa0ce1453681b38 Mon Sep 17 00:00:00 2001 From: seth Date: Sat, 10 May 2025 10:35:05 -0400 Subject: [PATCH 2/3] Version bump + changelog --- Cargo.lock | 2 +- Cargo.toml | 2 +- examples/changelog.md | 8 ++++++++ src/qmap/repr_test.rs | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 examples/changelog.md diff --git a/Cargo.lock b/Cargo.lock index 1abfbd2..3255768 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ dependencies = [ [[package]] name = "quake-util" -version = "0.3.2" +version = "0.4.0" dependencies = [ "benchmarking", "png", diff --git a/Cargo.toml b/Cargo.toml index 58b2420..66c846f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "quake-util" -version = "0.3.2" +version = "0.4.0" authors = ["Seth Rader"] edition = "2021" description = "A utility library for using Quake file formats" diff --git a/examples/changelog.md b/examples/changelog.md new file mode 100644 index 0000000..0670e37 --- /dev/null +++ b/examples/changelog.md @@ -0,0 +1,8 @@ +## Changelog + +### 0.4.0 + +* Implemented support for reading & writing Quake II map files + +* Replaced `HashMap` with `Vec<(CString, CString)>` for entity +key/values (breaking) diff --git a/src/qmap/repr_test.rs b/src/qmap/repr_test.rs index ad59d9a..d292a2b 100644 --- a/src/qmap/repr_test.rs +++ b/src/qmap/repr_test.rs @@ -47,6 +47,12 @@ const BAD_ALIGNMENT_ROTATION: Alignment = Alignment { axes: Some(GOOD_AXES), }; +const Q2_EXTENSION: Quake2SurfaceExtension = Quake2SurfaceExtension { + content_flags: 1237, + surface_flags: -101, + surface_value: 300.0, +}; + fn expect_err_containing(res: ValidationResult, text: &str) { if let Err(e) = res { assert!(e.contains(text), "Expected {:?} to contain '{}'", e, text); @@ -83,6 +89,15 @@ fn simple_surface() -> Surface { } } +fn q2_surface() -> Surface { + Surface { + half_space: GOOD_HALF_SPACE, + texture: CString::new("T").unwrap(), + alignment: GOOD_ALIGNMENT, + q2ext: Q2_EXTENSION, + } +} + fn simple_brush() -> Brush { vec![ simple_surface(), @@ -92,6 +107,10 @@ fn simple_brush() -> Brush { ] } +fn q2_brush() -> Brush { + vec![q2_surface(), q2_surface(), q2_surface(), q2_surface()] +} + fn simple_brush_entity() -> Entity { Entity { edict: simple_edict(), @@ -99,6 +118,13 @@ fn simple_brush_entity() -> Entity { } } +fn q2_brush_entity() -> Entity { + Entity { + edict: simple_edict(), + brushes: vec![q2_brush()], + } +} + fn simple_point_entity() -> Entity { Entity { edict: simple_edict(), @@ -292,6 +318,16 @@ mod write { assert!(str::from_utf8(&dest).unwrap().contains(" {FENCE ")); } + #[test] + fn write_q2_map() { + let mut dest = Vec::::new(); + assert!(q2_brush_entity().write_to(&mut dest).is_ok()); + let text = str::from_utf8(&dest).unwrap(); + eprintln!("{}", &text); + assert!(text.contains("0 1 1 1237 -101 300")); + assert!(!text.contains("300.")); + } + #[test] fn write_simple_entity() { let mut dest = Vec::::new(); From 9d3a252a8e6d0d2e4d1aa33cf0ba1c9817bdc0cd Mon Sep 17 00:00:00 2001 From: seth Date: Sat, 10 May 2025 11:19:04 -0400 Subject: [PATCH 3/3] Fix missing space when writing q2 maps --- src/qmap/repr.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/qmap/repr.rs b/src/qmap/repr.rs index d750000..1d2cabb 100644 --- a/src/qmap/repr.rs +++ b/src/qmap/repr.rs @@ -195,6 +195,7 @@ impl Surface { self.alignment.write_to(writer)?; if !self.q2ext.is_zeroed() { + writer.write_all(b" ").map_err(WriteError::Io)?; self.q2ext.write_to(writer)?; }