diff --git a/Cargo.lock b/Cargo.lock index 40c7b68a..17e785e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -731,10 +731,19 @@ checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" name = "libeq" version = "0.5.0" dependencies = [ + "libeq_eqg", "libeq_pfs", "libeq_wld", ] +[[package]] +name = "libeq_eqg" +version = "0.5.0" +dependencies = [ + "libeq_pfs", + "thiserror 2.0.18", +] + [[package]] name = "libeq_pfs" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 14c059e6..c7971e3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ license = "MIT" members = ["crates/*", "tools/*"] [workspace.dependencies] +libeq_eqg = { path = "crates/libeq_eqg", version = "0.5.0" } libeq_wld = { path = "crates/libeq_wld", version = "0.5.0" } libeq_pfs = { path = "crates/libeq_pfs", version = "0.5.0" } nom = "8.0.0" @@ -22,6 +23,7 @@ serde = { version = "1", features = ["derive"] } thiserror = "2" [dependencies] +libeq_eqg = { workspace = true, optional = true } libeq_wld = { workspace = true, optional = true } libeq_pfs = { workspace = true, optional = true } @@ -29,3 +31,4 @@ libeq_pfs = { workspace = true, optional = true } default = ["wld", "pfs"] wld = ["libeq_wld"] pfs = ["libeq_pfs"] +eqg = ["libeq_eqg"] diff --git a/crates/libeq_eqg/Cargo.toml b/crates/libeq_eqg/Cargo.toml new file mode 100644 index 00000000..260f035f --- /dev/null +++ b/crates/libeq_eqg/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "libeq_eqg" +version = "0.5.0" +edition = "2024" +description = "Read EverQuest EQG resource formats" +license = "MIT" + +[dependencies] +thiserror.workspace = true + +[dev-dependencies] +libeq_pfs.workspace = true diff --git a/crates/libeq_eqg/README.md b/crates/libeq_eqg/README.md new file mode 100644 index 00000000..d601e5ba --- /dev/null +++ b/crates/libeq_eqg/README.md @@ -0,0 +1,67 @@ +# libeq_eqg + +Resource identification and raw format readers for EverQuest EQG data. Archive +extraction belongs to `libeq_pfs`; resource-provider selection, coordinate +conversion, rendering, and collision policy belong to the consuming application. + +## Supported operations + +- `identify`: recognize EQGZ, EQGT, EQGM, EQTZP, and EQOBG signatures. Recognition + alone does not validate a resource body or establish support for its version. +- `zone::parse`: read binary EQGZ versions 1 and 2, including the string table, + nullable model references, placements, variable v2 extension data, regions, + lights, and any trailing bytes. +- `mesh::parse`: read EQGT terrain and EQGM model geometry at versions 1, 2, + and 3, retaining materials, raw properties, vertex attributes, triangles, + version-2 secondary UV data, and any trailing bytes. + +The zone parser checks byte bounds, declared record counts, model indices, and +terminated string references. Names remain bytes rather than assuming an encoding. +Transforms remain in source coordinates. Placement name offsets are retained as +stored; the parser does not promise they reproduce runtime actor naming in both +versions. Region/light words and placement extension data retain their original +values without assigning unverified gameplay meanings. Raw floating-point values +are preserved; consumers must validate suitability for rendering or physics. + +`Zone::trailing_data` exposes any bytes after the counted records. An empty slice +means those records consumed the input; a nonempty slice is not silently discarded +or interpreted as an extension. Resource selection, model loading, and scene +assembly are outside this parser's scope. + +The mesh reader validates vertex indices and terminated material/property string +references. Version 1 and 2 vertices use the 32-byte layout; version 3 uses +44 bytes with inline color and a second UV pair. Version 2 stores an additional +UV marker after triangles: marker 1 supplies secondary UVs for both kinds, +and marker 2 does so only for terrain. Missing attributes remain optional. +Triangle material values and flags are retained without assigning rendering or +collision policy. Material property types and values remain raw words; type 2 +values are validated as string offsets. EQGM bone counts and trailing bytes +are exposed, but skeletal records and animation are not decoded. + +## Tests + +Ordinary tests construct synthetic byte fixtures and require no game installation: + +```sh +cargo test -p libeq_eqg +``` + +An optional corpus test reads loose and archived descriptors from a supplied +installation and requires both binary versions. It fails if inputs are missing; +it does not turn a requested corpus run into a successful skip. Known RoF2 fixture +counts additionally check variable record boundaries and the region/light order. +Native assets are not distributed with this crate. + +```sh +LIBEQ_TEST_RAW_DIR=/path/to/client cargo test -p libeq_eqg --test native_corpus -- --ignored --nocapture +``` + +A separate mesh corpus test scans all terrain members and models in six fixture +archives (`crescent`, `guildhall`, `anguish`, `row`, `shi`, and `arcstone`). It +requires both mesh kinds at all three supported versions, version-2 secondary +UV data, and opaque skeletal suffixes. Known fixture counts check record +boundaries and retention of unassigned triangle materials. + +```sh +LIBEQ_TEST_RAW_DIR=/path/to/client cargo test -p libeq_eqg --test native_mesh_corpus -- --ignored --nocapture +``` diff --git a/crates/libeq_eqg/src/lib.rs b/crates/libeq_eqg/src/lib.rs new file mode 100644 index 00000000..cc27b6a4 --- /dev/null +++ b/crates/libeq_eqg/src/lib.rs @@ -0,0 +1,95 @@ +//! Identify EverQuest EQG resources and parse binary zone descriptors. +//! +//! [`identify`] inspects headers only; [`zone::parse`] validates binary EQGZ +//! version 1 and 2 descriptors. Archive extraction belongs to `libeq_pfs`. + +pub mod zone; + +/// A recognized format header, including the raw version for binary resources. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FormatHeader { + /// Binary zone descriptor (`EQGZ`). + Zone { version: u32 }, + /// Binary terrain mesh (`EQGT`). + Terrain { version: u32 }, + /// Binary model mesh (`EQGM`). + Model { version: u32 }, + /// Text terrain project (`EQTZP`); no binary version is read. + TerrainProject, + /// Text object group (`EQOBG`); no binary version is read. + ObjectGroup, +} + +/// A recognized header prefix whose remaining bytes are missing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("truncated EQG header: expected at least {expected} bytes, got {actual}")] + TruncatedHeader { + /// Minimum total length needed for the signature, or eight bytes once a + /// complete binary signature establishes that a version must follow. + expected: usize, + /// Number of input bytes available. + actual: usize, + }, +} + +/// Identify a resource from its signature at byte zero. +/// +/// Binary versions are little-endian `u32` values and are returned unchanged, +/// including unknown versions. Text signatures consume no version field. +/// Trailing data is ignored; this function does not validate a complete file. +/// +/// Returns `Ok(None)` for empty input or an unknown signature. A nonempty prefix +/// of a known signature, or a binary signature with fewer than four version +/// bytes, returns [`Error::TruncatedHeader`]. Signatures are case-sensitive; +/// whitespace and byte-order marks are not skipped. +/// +/// ``` +/// use libeq_eqg::{FormatHeader, identify}; +/// assert_eq!(identify(b"EQGZ\x02\0\0\0"), +/// Ok(Some(FormatHeader::Zone { version: 2 }))); +/// assert_eq!(identify(b"unrecognized"), Ok(None)); +/// ``` +pub fn identify(input: &[u8]) -> Result, Error> { + if input.is_empty() { + return Ok(None); + } + + const SIGNATURES: [&[u8]; 5] = [b"EQGZ", b"EQGT", b"EQGM", b"EQTZP", b"EQOBG"]; + for magic in SIGNATURES { + if input.len() < magic.len() && magic.starts_with(input) { + return Err(Error::TruncatedHeader { + expected: magic.len(), + actual: input.len(), + }); + } + if !input.starts_with(magic) { + continue; + } + if magic == b"EQTZP" { + return Ok(Some(FormatHeader::TerrainProject)); + } + if magic == b"EQOBG" { + return Ok(Some(FormatHeader::ObjectGroup)); + } + let version_bytes = input.get(4..8).ok_or(Error::TruncatedHeader { + expected: 8, + actual: input.len(), + })?; + let version = u32::from_le_bytes([ + version_bytes[0], + version_bytes[1], + version_bytes[2], + version_bytes[3], + ]); + return Ok(Some(match magic { + b"EQGZ" => FormatHeader::Zone { version }, + b"EQGT" => FormatHeader::Terrain { version }, + _ => FormatHeader::Model { version }, + })); + } + Ok(None) +} + +/// Raw terrain and model geometry. +pub mod mesh; diff --git a/crates/libeq_eqg/src/mesh.rs b/crates/libeq_eqg/src/mesh.rs new file mode 100644 index 00000000..868e4413 --- /dev/null +++ b/crates/libeq_eqg/src/mesh.rs @@ -0,0 +1,276 @@ +//! Raw EQGT terrain and EQGM model geometry, versions 1 through 3. +//! Bone records and other suffix data remain opaque. No coordinate conversion, +//! material interpretation, or skeletal animation is performed. + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ParseError { + #[error("invalid EQGT/EQGM magic")] + InvalidMagic, + #[error("unsupported mesh version {version}")] + UnsupportedVersion { version: u32 }, + #[error("truncated {context} at byte {offset}: need {needed} bytes, have {remaining}")] + Truncated { + context: &'static str, + offset: usize, + needed: usize, + remaining: usize, + }, + #[error("byte count overflow in {context}")] + CountOverflow { context: &'static str }, + #[error("invalid or unterminated string reference {offset}")] + InvalidStringReference { offset: u32 }, + #[error("vertex index {index} is outside vertex count {vertex_count}")] + InvalidVertexReference { index: u32, vertex_count: u32 }, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeshKind { + Terrain, + Model, +} +#[derive(Debug)] +pub struct Mesh<'a> { + pub kind: MeshKind, + pub version: u32, + pub string_table: &'a [u8], + pub materials: Vec, + pub vertices: Vec, + pub triangles: Vec, + /// Model header count only; bone records are not decoded or validated. + pub bone_count: Option, + /// Present only in version 2, including unrecognized marker values. + pub uv_marker: Option, + /// Bytes following geometry, including any model bone records. + pub trailing_data: &'a [u8], +} +impl Mesh<'_> { + /// Resolve a NUL-terminated byte string, excluding the terminator. + /// Non-UTF-8 bytes and suffix references are permitted. + pub fn string(&self, offset: u32) -> Result<&[u8], ParseError> { + let bytes = usize::try_from(offset) + .ok() + .and_then(|n| self.string_table.get(n..)) + .ok_or(ParseError::InvalidStringReference { offset })?; + let end = bytes + .iter() + .position(|&b| b == 0) + .ok_or(ParseError::InvalidStringReference { offset })?; + Ok(&bytes[..end]) + } +} +#[derive(Debug)] +pub struct Material { + pub index: u32, + pub name_offset: u32, + pub shader_offset: u32, + pub properties: Vec, +} +#[derive(Debug)] +pub struct Property { + pub name_offset: u32, + /// Raw type tag. Type 2 values are validated as string offsets. + pub kind: u32, + pub value: u32, +} +/// Floating-point bit patterns are retained, including non-finite values. +#[derive(Debug)] +pub struct Vertex { + pub position: [f32; 3], + pub normal: [f32; 3], + /// Packed version 3 color; absent in versions 1 and 2. + pub color: Option, + pub uv0: [f32; 2], + pub uv1: Option<[f32; 2]>, +} +#[derive(Debug)] +pub struct Triangle { + pub vertex_indices: [u32; 3], + /// Raw material reference, including sentinels and out-of-range values. + pub material_index: u32, + pub flags: u32, +} +struct Reader<'a> { + bytes: &'a [u8], + offset: usize, +} +impl<'a> Reader<'a> { + fn require(&self, needed: usize, context: &'static str) -> Result<(), ParseError> { + let remaining = self.bytes.len() - self.offset; + if needed > remaining { + return Err(ParseError::Truncated { + context, + offset: self.offset, + needed, + remaining, + }); + } + Ok(()) + } + fn take(&mut self, n: usize, context: &'static str) -> Result<&'a [u8], ParseError> { + self.require(n, context)?; + let start = self.offset; + self.offset += n; + Ok(&self.bytes[start..self.offset]) + } + fn word(&mut self, context: &'static str) -> Result { + let b = self.take(4, context)?; + Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + fn words(&mut self, context: &'static str) -> Result<[u32; N], ParseError> { + let mut result = [0; N]; + for v in &mut result { + *v = self.word(context)?; + } + Ok(result) + } +} +fn size(count: u32, stride: usize) -> Result { + usize::try_from(count) + .ok() + .and_then(|n| n.checked_mul(stride)) + .ok_or(ParseError::CountOverflow { + context: "record counts", + }) +} +/// Decode geometry, retaining any suffix without interpreting skeletal records. +/// Counted byte requirements are checked before allocating record vectors. +/// Every string reference must terminate within the table and every triangle +/// vertex reference must be in range. Material references remain uninterpreted. +pub fn parse(input: &[u8]) -> Result, ParseError> { + let mut r = Reader { + bytes: input, + offset: 0, + }; + let kind = match r.take(4, "magic")? { + b"EQGT" => MeshKind::Terrain, + b"EQGM" => MeshKind::Model, + _ => return Err(ParseError::InvalidMagic), + }; + let version = r.word("version")?; + if !(1..=3).contains(&version) { + return Err(ParseError::UnsupportedVersion { version }); + } + let [strings, materials, vertices, triangles] = r.words("header")?; + let bone_count = if kind == MeshKind::Model { + Some(r.word("bone count")?) + } else { + None + }; + let mut minimum = if version == 2 { 4usize } else { 0 }; + for (count, stride) in [ + (strings, 1), + (materials, 16), + (vertices, if version == 3 { 44 } else { 32 }), + (triangles, 20), + ] { + minimum = minimum + .checked_add(size(count, stride)?) + .ok_or(ParseError::CountOverflow { + context: "record counts", + })?; + } + r.require(minimum, "counted records")?; + let string_table = r.take(size(strings, 1)?, "string table")?; + let last_nul = string_table.iter().rposition(|&b| b == 0); + let check = |offset: u32| { + if usize::try_from(offset) + .ok() + .zip(last_nul) + .is_some_and(|(n, last)| n <= last) + { + Ok(()) + } else { + Err(ParseError::InvalidStringReference { offset }) + } + }; + let mut mesh = Mesh { + kind, + version, + string_table, + materials: Vec::new(), + vertices: Vec::new(), + triangles: Vec::new(), + bone_count, + uv_marker: None, + trailing_data: &[], + }; + for _ in 0..materials { + let [index, name_offset, shader_offset, count] = r.words("material")?; + check(name_offset)?; + check(shader_offset)?; + r.require(size(count, 12)?, "material properties")?; + let mut properties = Vec::new(); + for _ in 0..count { + let [name_offset, kind, value] = r.words("property")?; + check(name_offset)?; + if kind == 2 { + check(value)?; + } + properties.push(Property { + name_offset, + kind, + value, + }); + } + mesh.materials.push(Material { + index, + name_offset, + shader_offset, + properties, + }); + } + r.require( + size(vertices, if version == 3 { 44 } else { 32 })?, + "vertices", + )?; + for _ in 0..vertices { + let position = r.words("position")?.map(f32::from_bits); + let normal = r.words("normal")?.map(f32::from_bits); + let color = if version == 3 { + Some(r.word("color")?) + } else { + None + }; + let uv0 = r.words("primary UV")?.map(f32::from_bits); + let uv1 = if version == 3 { + Some(r.words("secondary UV")?.map(f32::from_bits)) + } else { + None + }; + mesh.vertices.push(Vertex { + position, + normal, + color, + uv0, + uv1, + }); + } + r.require(size(triangles, 20)?, "triangles")?; + for _ in 0..triangles { + let vertex_indices = r.words("triangle vertices")?; + for index in vertex_indices { + if index >= vertices { + return Err(ParseError::InvalidVertexReference { + index, + vertex_count: vertices, + }); + } + } + mesh.triangles.push(Triangle { + vertex_indices, + material_index: r.word("triangle material")?, + flags: r.word("triangle flags")?, + }); + } + if version == 2 { + let marker = r.word("UV marker")?; + mesh.uv_marker = Some(marker); + if marker == 1 || (kind == MeshKind::Terrain && marker == 2) { + r.require(size(vertices, 8)?, "secondary UVs")?; + for vertex in &mut mesh.vertices { + vertex.uv1 = Some(r.words("secondary UV")?.map(f32::from_bits)); + } + } + } + mesh.trailing_data = &input[r.offset..]; + Ok(mesh) +} diff --git a/crates/libeq_eqg/src/zone.rs b/crates/libeq_eqg/src/zone.rs new file mode 100644 index 00000000..a0e1ee6e --- /dev/null +++ b/crates/libeq_eqg/src/zone.rs @@ -0,0 +1,249 @@ +//! Raw, borrowed binary EQGZ version 1 and 2 zone descriptors. +//! +//! Transforms, extension words, regions, and lights are preserved without +//! assigning coordinate conventions or interpreting their opaque fields. + +/// A failure to decode a supported binary zone descriptor. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ParseError { + #[error("invalid EQGZ magic")] + InvalidMagic, + #[error("unsupported EQGZ version {version}")] + UnsupportedVersion { version: u32 }, + #[error("truncated {context} at byte {offset}: need {needed} bytes, have {remaining}")] + Truncated { + context: &'static str, + offset: usize, + needed: usize, + remaining: usize, + }, + #[error("byte count overflow in {context}")] + CountOverflow { context: &'static str }, + #[error("invalid or unterminated string reference {offset}")] + InvalidStringReference { offset: u32 }, + #[error("placement model index {index} is outside model count {model_count}")] + InvalidModelReference { index: u32, model_count: usize }, +} + +/// A raw zone descriptor. String offsets address `string_table` bytes. +#[derive(Debug)] +pub struct Zone<'a> { + pub version: u32, + pub string_table: &'a [u8], + /// String offsets, with the all-ones sentinel represented by `None`. + pub models: Vec>, + pub placements: Vec>, + pub regions: Vec, + pub lights: Vec, + /// All bytes following the counted records; retained, not interpreted. + pub trailing_data: &'a [u8], +} + +impl Zone<'_> { + /// Resolve a NUL-terminated byte string, excluding its terminator. + /// Non-UTF-8 bytes and offsets into the middle of a string are permitted. + pub fn string(&self, offset: u32) -> Result<&[u8], ParseError> { + let start = usize::try_from(offset).ok(); + let bytes = start + .and_then(|start| self.string_table.get(start..)) + .ok_or(ParseError::InvalidStringReference { offset })?; + let end = bytes + .iter() + .position(|&byte| byte == 0) + .ok_or(ParseError::InvalidStringReference { offset })?; + Ok(&bytes[..end]) + } +} + +/// Raw placement values; floating-point bit patterns are preserved. +#[derive(Debug)] +pub struct Placement<'a> { + pub model_index: Option, + pub name_offset: u32, + pub position: [f32; 3], + pub rotation: [f32; 3], + pub scale: f32, + /// Counted version 2 words, or an empty slice for version 1. + pub extension_data: &'a [u8], +} +impl Placement<'_> { + pub fn extension_words(&self) -> impl Iterator + '_ { + self.extension_data + .as_chunks::<4>() + .0 + .iter() + .map(|bytes| u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + } +} + +#[derive(Debug)] +pub struct Region { + pub name_offset: u32, + pub data: [u32; 9], +} +#[derive(Debug)] +pub struct Light { + pub name_offset: u32, + pub data: [u32; 7], +} + +struct Reader<'a> { + bytes: &'a [u8], + offset: usize, +} +impl<'a> Reader<'a> { + fn require(&self, needed: usize, context: &'static str) -> Result<(), ParseError> { + let remaining = self.bytes.len() - self.offset; + if needed > remaining { + return Err(ParseError::Truncated { + context, + offset: self.offset, + needed, + remaining, + }); + } + Ok(()) + } + fn take(&mut self, count: usize, context: &'static str) -> Result<&'a [u8], ParseError> { + self.require(count, context)?; + let start = self.offset; + self.offset += count; + Ok(&self.bytes[start..self.offset]) + } + fn word(&mut self, context: &'static str) -> Result { + let bytes = self.take(4, context)?; + Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + } + fn words(&mut self, context: &'static str) -> Result<[u32; N], ParseError> { + let mut words = [0; N]; + for word in &mut words { + *word = self.word(context)?; + } + Ok(words) + } +} +fn byte_count(count: u32, stride: usize, context: &'static str) -> Result { + usize::try_from(count) + .ok() + .and_then(|count| count.checked_mul(stride)) + .ok_or(ParseError::CountOverflow { context }) +} +fn nullable(value: u32) -> Option { + (value != u32::MAX).then_some(value) +} + +/// Decode a complete counted descriptor, retaining any trailing bytes. +/// +/// Every referenced string must terminate within the string table. Placement +/// model indices must address the model table, but may address a nullable entry. +/// Count-based minimum sizes are checked before allocating record vectors. +/// Unknown versions are rejected rather than parsed using another layout. +pub fn parse(input: &[u8]) -> Result, ParseError> { + let mut reader = Reader { + bytes: input, + offset: 0, + }; + if reader.take(4, "magic")? != b"EQGZ" { + return Err(ParseError::InvalidMagic); + } + let version = reader.word("version")?; + if !matches!(version, 1 | 2) { + return Err(ParseError::UnsupportedVersion { version }); + } + let [strings, models, placements, regions, lights] = reader.words("header")?; + let mut minimum = 0usize; + for (count, stride) in [ + (strings, 1), + (models, 4), + (placements, if version == 1 { 36 } else { 40 }), + (regions, 40), + (lights, 32), + ] { + minimum = minimum + .checked_add(byte_count(count, stride, "record counts")?) + .ok_or(ParseError::CountOverflow { + context: "record counts", + })?; + } + reader.require(minimum, "counted records")?; + let string_table = reader.take(byte_count(strings, 1, "string table")?, "string table")?; + // Any offset at or before the final NUL has a terminator. This validates + // repeated references in constant time after one scan of the string table. + let last_nul = string_table.iter().rposition(|&byte| byte == 0); + let check_string = |offset: u32| -> Result<(), ParseError> { + if usize::try_from(offset) + .ok() + .zip(last_nul) + .is_some_and(|(offset, last)| offset <= last) + { + Ok(()) + } else { + Err(ParseError::InvalidStringReference { offset }) + } + }; + let mut zone = Zone { + version, + string_table, + models: Vec::new(), + placements: Vec::new(), + regions: Vec::new(), + lights: Vec::new(), + trailing_data: &[], + }; + for _ in 0..models { + let offset = nullable(reader.word("model")?); + if let Some(offset) = offset { + check_string(offset)?; + } + zone.models.push(offset); + } + for _ in 0..placements { + let model_index = nullable(reader.word("placement model")?); + if let Some(index) = model_index.filter(|&index| index >= models) { + return Err(ParseError::InvalidModelReference { + index, + model_count: zone.models.len(), + }); + } + let name_offset = reader.word("placement name")?; + check_string(name_offset)?; + let position = reader.words("placement position")?.map(f32::from_bits); + let rotation = reader.words("placement rotation")?.map(f32::from_bits); + let scale = f32::from_bits(reader.word("placement scale")?); + let extension_data = if version == 2 { + let count = reader.word("placement extension count")?; + reader.take( + byte_count(count, 4, "placement extension")?, + "placement extension", + )? + } else { + &[] + }; + zone.placements.push(Placement { + model_index, + name_offset, + position, + rotation, + scale, + extension_data, + }); + } + for _ in 0..regions { + let name_offset = reader.word("region name")?; + check_string(name_offset)?; + zone.regions.push(Region { + name_offset, + data: reader.words("region data")?, + }); + } + for _ in 0..lights { + let name_offset = reader.word("light name")?; + check_string(name_offset)?; + zone.lights.push(Light { + name_offset, + data: reader.words("light data")?, + }); + } + zone.trailing_data = &input[reader.offset..]; + Ok(zone) +} diff --git a/crates/libeq_eqg/tests/identify.rs b/crates/libeq_eqg/tests/identify.rs new file mode 100644 index 00000000..ab72bf25 --- /dev/null +++ b/crates/libeq_eqg/tests/identify.rs @@ -0,0 +1,85 @@ +use libeq_eqg::{Error, FormatHeader, identify}; + +#[test] +fn identifies_binary_versions_without_assuming_support() { + for version in [0, 1, 2, 3, u32::MAX] { + for (magic, expected) in [ + (b"EQGZ", FormatHeader::Zone { version }), + (b"EQGT", FormatHeader::Terrain { version }), + (b"EQGM", FormatHeader::Model { version }), + ] { + let mut input = magic.to_vec(); + input.extend(version.to_le_bytes()); + assert_eq!(identify(&input), Ok(Some(expected))); + input.extend(b"arbitrary body"); + assert_eq!(identify(&input), Ok(Some(expected))); + } + } +} + +#[test] +fn decodes_little_endian_version() { + assert_eq!( + identify(b"EQGZ\x01\x02\x03\x04"), + Ok(Some(FormatHeader::Zone { + version: 0x04030201 + })) + ); +} + +#[test] +fn identifies_text_signatures_without_a_binary_version() { + for (input, expected) in [ + (b"EQTZP".as_slice(), FormatHeader::TerrainProject), + (b"EQOBG".as_slice(), FormatHeader::ObjectGroup), + ] { + assert_eq!(identify(input), Ok(Some(expected))); + let mut body = input.to_vec(); + body.extend(b"\r\n*NAME example\r\n"); + assert_eq!(identify(&body), Ok(Some(expected))); + } +} + +#[test] +fn every_incomplete_nonempty_header_prefix_is_truncated() { + for complete in [ + b"EQGZ\x01\0\0\0".as_slice(), + b"EQGT\x02\0\0\0".as_slice(), + b"EQGM\x03\0\0\0".as_slice(), + b"EQTZP".as_slice(), + b"EQOBG".as_slice(), + ] { + for actual in 1..complete.len() { + assert!( + matches!(identify(&complete[..actual]), + Err(Error::TruncatedHeader { expected, actual: found }) + if expected > actual && found == actual), + "{complete:?} at {actual}" + ); + } + } + assert_eq!( + identify(b"EQGZ\x01"), + Err(Error::TruncatedHeader { + expected: 8, + actual: 5 + }) + ); +} + +#[test] +fn unrecognized_data_is_not_a_truncation_error() { + for input in [ + b"".as_slice(), + b"X", + b"EX", + b"EQX", + b"EQGX", + b"EQTX", + b"EQOBX", + b"eqgz\0\0\0\0", + b"\xef\xbb\xbfEQTZP", + ] { + assert_eq!(identify(input), Ok(None), "{input:?}"); + } +} diff --git a/crates/libeq_eqg/tests/mesh.rs b/crates/libeq_eqg/tests/mesh.rs new file mode 100644 index 00000000..92911ba0 --- /dev/null +++ b/crates/libeq_eqg/tests/mesh.rs @@ -0,0 +1,177 @@ +use libeq_eqg::mesh::{self, MeshKind, ParseError}; +fn word(b: &mut Vec, v: u32) { + b.extend(v.to_le_bytes()); +} +fn fixture(model: bool, version: u32, marker: u32) -> Vec { + let mut b = if model { + b"EQGM".to_vec() + } else { + b"EQGT".to_vec() + }; + for v in [version, 4, 1, 1, 1] { + word(&mut b, v); + } + if model { + word(&mut b, 7); + } + b.extend(b"a\0\xff\0"); + for v in [99, 0, 2, 2, 0, 2, 2, 2, 77, 0xdeadbeef] { + word(&mut b, v); + } + for v in [0x7fc01234, 2, 3, 4, 5, 6] { + word(&mut b, v); + } + if version == 3 { + word(&mut b, 0x12345678); + } + for v in [7, 8] { + word(&mut b, v); + } + if version == 3 { + for v in [9, 10] { + word(&mut b, v); + } + } + for v in [0, 0, 0, u32::MAX, 0xfedcba98] { + word(&mut b, v); + } + if version == 2 { + word(&mut b, marker); + if marker == 1 || (!model && marker == 2) { + for v in [9, 10] { + word(&mut b, v); + } + } + } + b +} +#[test] +fn versions_and_kinds_preserve_raw_fields() { + for model in [false, true] { + for version in 1..=3 { + let mut b = fixture(model, version, 1); + b.extend([123, 45]); + let m = mesh::parse(&b).unwrap(); + assert_eq!( + m.kind, + if model { + MeshKind::Model + } else { + MeshKind::Terrain + } + ); + assert_eq!(m.bone_count, model.then_some(7)); + assert_eq!(m.version, version); + assert_eq!(m.string(2).unwrap(), &[255]); + assert_eq!(m.materials[0].index, 99); + assert_eq!(m.materials[0].properties[1].value, 0xdeadbeef); + assert_eq!(m.materials[0].properties[1].kind, 77); + assert_eq!(m.vertices[0].position[0].to_bits(), 0x7fc01234); + assert_eq!(m.vertices[0].color, (version == 3).then_some(0x12345678)); + assert_eq!(m.vertices[0].uv0[0].to_bits(), 7); + assert_eq!( + m.vertices[0].uv1.map(|v| v.map(f32::to_bits)), + (version >= 2).then_some([9, 10]) + ); + assert_eq!(m.triangles[0].material_index, u32::MAX); + assert_eq!(m.triangles[0].flags, 0xfedcba98); + assert_eq!(m.trailing_data, [123, 45]); + } + } +} +#[test] +fn version_two_marker_distinguishes_terrain_and_model() { + for model in [false, true] { + for marker in [0, 1, 2, 99] { + let mut b = fixture(model, 2, marker); + b.extend([1, 2, 3]); + let m = mesh::parse(&b).unwrap(); + assert_eq!(m.uv_marker, Some(marker)); + assert_eq!( + m.vertices[0].uv1.is_some(), + marker == 1 || (!model && marker == 2) + ); + assert_eq!(m.trailing_data, [1, 2, 3]); + } + } +} +#[test] +fn truncations_and_invalid_references_are_rejected() { + for model in [false, true] { + for version in 1..=3 { + let b = fixture(model, version, 1); + for n in 0..b.len() { + assert!(mesh::parse(&b[..n]).is_err(), "{model}/{version}/{n}"); + } + } + } + let b = fixture(false, 1, 0); + for offset in [8, 12, 16, 20, 40] { + let mut bad = b.clone(); + bad[offset..offset + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(mesh::parse(&bad).is_err(), "count {offset}"); + } + for offset in [32, 36, 44, 52] { + let mut bad = b.clone(); + bad[offset..offset + 4].copy_from_slice(&4u32.to_le_bytes()); + assert!( + matches!( + mesh::parse(&bad), + Err(ParseError::InvalidStringReference { .. }) + ), + "string {offset}" + ); + } + let mut bad = b.clone(); + bad[100..104].copy_from_slice(&1u32.to_le_bytes()); + assert!(matches!( + mesh::parse(&bad), + Err(ParseError::InvalidVertexReference { .. }) + )); + let mut bad = b.clone(); + bad[4..8].copy_from_slice(&4u32.to_le_bytes()); + assert!(matches!( + mesh::parse(&bad), + Err(ParseError::UnsupportedVersion { version: 4 }) + )); + let mut bad = b; + bad[0] = 0; + assert!(matches!(mesh::parse(&bad), Err(ParseError::InvalidMagic))); +} + +#[test] +fn empty_meshes_and_string_suffixes() { + for model in [false, true] { + for version in 1..=3 { + let mut b = if model { + b"EQGM".to_vec() + } else { + b"EQGT".to_vec() + }; + for v in [version, 0, 0, 0, 0] { + word(&mut b, v); + } + if model { + word(&mut b, 0); + } + if version == 2 { + word(&mut b, 1); + } + let m = mesh::parse(&b).unwrap(); + assert!(m.vertices.is_empty()); + assert!(m.trailing_data.is_empty()); + assert!(m.string(0).is_err()); + } + } + let mut b = fixture(false, 1, 0); + b[32..36].copy_from_slice(&1u32.to_le_bytes()); + b[112..116].copy_from_slice(&123u32.to_le_bytes()); + let m = mesh::parse(&b).unwrap(); + assert_eq!(m.string(m.materials[0].name_offset).unwrap(), b""); + assert_eq!(m.triangles[0].material_index, 123); + b[27] = 1; + assert!(matches!( + mesh::parse(&b), + Err(ParseError::InvalidStringReference { .. }) + )); +} diff --git a/crates/libeq_eqg/tests/native_corpus.rs b/crates/libeq_eqg/tests/native_corpus.rs new file mode 100644 index 00000000..c2247321 --- /dev/null +++ b/crates/libeq_eqg/tests/native_corpus.rs @@ -0,0 +1,108 @@ +//! Opt-in validation against a supplied client installation; no game data is bundled. +use libeq_eqg::{FormatHeader, identify, zone}; +use libeq_pfs::PfsReader; +use std::fs::{self, File}; + +fn check(name: &str, bytes: &[u8], versions: &mut [usize; 2]) { + match identify(bytes).expect("read zone header") { + Some(FormatHeader::TerrainProject) => return, + Some(FormatHeader::Zone { .. }) => {} + other => panic!("{name}: expected a zone descriptor, got {other:?}"), + } + let parsed = zone::parse(bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert!( + parsed.trailing_data.is_empty(), + "{name}: unaccounted trailing data" + ); + versions[(parsed.version - 1) as usize] += 1; + for offset in parsed.models.iter().flatten() { + parsed.string(*offset).unwrap(); + } + for placement in &parsed.placements { + parsed.string(placement.name_offset).unwrap(); + assert_eq!(placement.extension_data.len() % 4, 0); + } + // Independent fixture counts pin the region/light order and variable records. + let expected = match name { + "crescent.zon" => Some((2, 176, 2342, 58, 0, 646120)), + "anguish.eqg:anguish.zon" => Some((1, 215, 696, 2, 452, 0)), + "guildhall.zon" => Some((2, 51, 87, 0, 46, 76293)), + _ => None, + }; + if let Some(expected) = expected { + let extensions: usize = parsed + .placements + .iter() + .map(|p| p.extension_words().count()) + .sum(); + assert_eq!( + ( + parsed.version, + parsed.models.len(), + parsed.placements.len(), + parsed.regions.len(), + parsed.lights.len(), + extensions + ), + expected, + "{name}: reference fixture changed" + ); + } +} + +#[test] +#[ignore = "requires LIBEQ_TEST_RAW_DIR containing native binary v1 and v2 zone descriptors"] +fn parses_native_binary_zone_corpus() { + let root = std::env::var_os("LIBEQ_TEST_RAW_DIR") + .expect("set LIBEQ_TEST_RAW_DIR to a client installation"); + let root = std::path::Path::new(&root); + let mut paths: Vec<_> = fs::read_dir(root) + .expect("read client installation") + .map(|entry| entry.expect("read directory entry").path()) + .collect(); + paths.sort(); + let mut versions = [0, 0]; + for path in paths { + let name = path + .file_name() + .unwrap() + .to_str() + .expect("UTF-8 resource name"); + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".zon") { + check( + &lower, + &fs::read(&path).expect("read loose zone descriptor"), + &mut versions, + ); + } else if lower.ends_with(".eqg") { + let mut archive = + PfsReader::open(File::open(&path).expect("open EQG")).expect("read PFS index"); + let mut members = archive.filenames().expect("read PFS directory"); + members.sort(); + for member in members { + if member.to_ascii_lowercase().ends_with(".zon") { + let bytes = archive + .get(&member) + .expect("read descriptor") + .expect("descriptor exists"); + check( + &format!("{lower}:{}", member.to_ascii_lowercase()), + &bytes, + &mut versions, + ); + } + } + } + } + assert!( + versions[0] > 0 && versions[1] > 0, + "corpus must exercise both binary versions: {versions:?}" + ); + eprintln!( + "parsed {} binary zone descriptors: {} v1, {} v2", + versions.iter().sum::(), + versions[0], + versions[1] + ); +} diff --git a/crates/libeq_eqg/tests/native_mesh_corpus.rs b/crates/libeq_eqg/tests/native_mesh_corpus.rs new file mode 100644 index 00000000..fd10d9c8 --- /dev/null +++ b/crates/libeq_eqg/tests/native_mesh_corpus.rs @@ -0,0 +1,157 @@ +//! Opt-in mesh validation; game assets are supplied locally and never bundled. +use libeq_eqg::mesh::{self, MeshKind}; +use libeq_pfs::PfsReader; +use std::fs::{self, File}; + +#[test] +#[ignore = "requires LIBEQ_TEST_RAW_DIR with terrain v1/v2/v3 and selected model archives"] +fn parses_native_mesh_corpus() { + let root = std::env::var_os("LIBEQ_TEST_RAW_DIR") + .expect("set LIBEQ_TEST_RAW_DIR to a client installation"); + let mut paths: Vec<_> = fs::read_dir(root) + .expect("read client installation") + .map(|entry| entry.expect("read directory entry").path()) + .collect(); + paths.sort(); + let mut counts = [[0usize; 3]; 2]; + let mut secondary_uv_files = 0; + let mut skeletal_files = 0; + for path in paths { + let name = path + .file_name() + .unwrap() + .to_string_lossy() + .to_ascii_lowercase(); + if !name.ends_with(".eqg") { + continue; + } + let models = matches!( + name.as_str(), + "crescent.eqg" + | "guildhall.eqg" + | "anguish.eqg" + | "row.eqg" + | "shi.eqg" + | "arcstone.eqg" + ); + let mut archive = + PfsReader::open(File::open(&path).expect("open EQG")).expect("read PFS index"); + let mut members = archive.filenames().expect("read PFS directory"); + members.sort(); + for member in members { + let lower = member.to_ascii_lowercase(); + if !lower.ends_with(".ter") && !(models && lower.ends_with(".mod")) { + continue; + } + let bytes = archive + .get(&member) + .expect("read mesh") + .expect("mesh exists"); + let label = format!("{name}:{lower}"); + let mesh = mesh::parse(&bytes).unwrap_or_else(|e| panic!("{label}: {e}")); + let kind = match mesh.kind { + MeshKind::Terrain => 0, + MeshKind::Model => 1, + }; + counts[kind][(mesh.version - 1) as usize] += 1; + if mesh.bone_count.unwrap_or(0) == 0 { + assert!( + mesh.trailing_data.is_empty(), + "{label}: unexpected static suffix" + ); + } else { + skeletal_files += 1; + assert!( + !mesh.trailing_data.is_empty(), + "{label}: missing skeletal suffix" + ); + } + if mesh.version == 2 && mesh.uv_marker == Some(1) { + secondary_uv_files += 1; + assert!(mesh.vertices.iter().all(|v| v.uv1.is_some()), "{label}"); + } + for material in &mesh.materials { + mesh.string(material.name_offset).unwrap(); + mesh.string(material.shader_offset).unwrap(); + } + // Independently measured fixture counts pin geometry boundaries and sentinels. + let expected = match label.as_str() { + "crescent.eqg:ter_crescent.ter" => Some((3, 49, 83698, 67484, 1854)), + "guildhall.eqg:ter_guildhall.ter" => Some((3, 29, 28584, 13803, 160)), + "fhalls.eqg:ter_temple01.ter" => Some((1, 41, 69459, 41138, 1014)), + "anguish.eqg:ter_island.ter" => Some((2, 35, 149400, 96395, 480)), + "row.eqg:row.mod" => Some((1, 3, 393, 256, 0)), + "anguish.eqg:obj_arch01.mod" => Some((2, 1, 234, 96, 0)), + "arcstone.eqg:obj_arcportal.mod" => Some((3, 5, 96, 166, 0)), + _ => None, + }; + let first_vertex = match label.as_str() { + "crescent.eqg:ter_crescent.ter" => Some(( + [3302307367, 3306407300, 3276396736], + [1063567394, 1032699765, 3202555220], + Some(4252991359), + [1107558399, 3212836864], + Some([1089538037, 3189213624]), + )), + "anguish.eqg:ter_island.ter" => Some(( + [1119447288, 1140247283, 3281090687], + [3205821111, 1048856445, 3208997990], + None, + [1057956895, 3222027149], + None, + )), + "fhalls.eqg:ter_temple01.ter" => Some(( + [0, 2861424279, 3241148416], + [2985713409, 2978374239, 1065353216], + None, + [1056964606, 3204448264], + None, + )), + _ => None, + }; + if let Some(expected) = first_vertex { + let vertex = &mesh.vertices[0]; + assert_eq!( + ( + vertex.position.map(f32::to_bits), + vertex.normal.map(f32::to_bits), + vertex.color, + vertex.uv0.map(f32::to_bits), + vertex.uv1.map(|uv| uv.map(f32::to_bits)) + ), + expected, + "{label}: reference vertex changed" + ); + } + if let Some(expected) = expected { + assert_eq!( + ( + mesh.version, + mesh.materials.len(), + mesh.vertices.len(), + mesh.triangles.len(), + mesh.triangles + .iter() + .filter(|triangle| triangle.material_index == u32::MAX) + .count() + ), + expected, + "{label}: reference fixture changed" + ); + } + } + } + assert!( + counts.iter().flatten().all(|&n| n > 0), + "corpus must exercise both mesh kinds at all three versions: {counts:?}" + ); + assert!(secondary_uv_files > 0, "corpus needs v2 secondary UV data"); + assert!( + skeletal_files > 0, + "corpus needs a model with opaque skeletal data" + ); + eprintln!( + "parsed mesh corpus: terrain v1/v2/v3 {:?}, model v1/v2/v3 {:?}; {secondary_uv_files} v2 secondary-UV files, {skeletal_files} skeletal suffixes", + counts[0], counts[1] + ); +} diff --git a/crates/libeq_eqg/tests/zone.rs b/crates/libeq_eqg/tests/zone.rs new file mode 100644 index 00000000..1fce50e0 --- /dev/null +++ b/crates/libeq_eqg/tests/zone.rs @@ -0,0 +1,183 @@ +use libeq_eqg::zone::{ParseError, parse}; + +fn word(out: &mut Vec, value: u32) { + out.extend(value.to_le_bytes()); +} +fn fixture(version: u32) -> Vec { + let mut out = b"EQGZ".to_vec(); + for value in [version, 9, 2, 2, 1, 1] { + word(&mut out, value); + } + out.extend(b"mesh\0\xffnm\0"); + word(&mut out, 0); + word(&mut out, u32::MAX); + for index in [1, u32::MAX] { + word(&mut out, index); + word(&mut out, 5); + for value in [0x7fc01234, 2, 3, 4, 5, 6, 7] { + word(&mut out, value); + } + if version == 2 { + word(&mut out, if index == 1 { 2 } else { 0 }); + if index == 1 { + word(&mut out, 0x12345678); + word(&mut out, 0xffffffff); + } + } + } + word(&mut out, 1); + for value in 10..19 { + word(&mut out, value); + } + word(&mut out, 8); + for value in 20..27 { + word(&mut out, value); + } + out +} + +#[test] +fn parses_versions_and_preserves_raw_records() { + for version in [1, 2] { + let mut bytes = fixture(version); + bytes.extend(b"tail"); + let zone = parse(&bytes).unwrap(); + assert_eq!(zone.version, version); + assert_eq!(zone.models, [Some(0), None]); + assert_eq!(zone.placements.len(), 2); + let first = &zone.placements[0]; + assert_eq!(first.model_index, Some(1)); + assert_eq!(zone.placements[1].model_index, None); + assert_eq!(first.name_offset, 5); + assert_eq!(first.position.map(f32::to_bits), [0x7fc01234, 2, 3]); + assert_eq!(first.rotation.map(f32::to_bits), [4, 5, 6]); + assert_eq!(first.scale.to_bits(), 7); + assert_eq!( + first.extension_words().collect::>(), + if version == 2 { + vec![0x12345678, u32::MAX] + } else { + vec![] + } + ); + assert!(zone.placements[1].extension_data.is_empty()); + assert_eq!(zone.regions[0].name_offset, 1); + assert_eq!(zone.regions[0].data, [10, 11, 12, 13, 14, 15, 16, 17, 18]); + assert_eq!(zone.lights[0].data, [20, 21, 22, 23, 24, 25, 26]); + assert_eq!(zone.string(1).unwrap(), b"esh"); + assert_eq!(zone.string(5).unwrap(), b"\xffnm"); + assert_eq!(zone.string(8).unwrap(), b""); + assert!(matches!( + zone.string(9), + Err(ParseError::InvalidStringReference { .. }) + )); + assert_eq!(zone.trailing_data, b"tail"); + } +} + +#[test] +fn every_truncated_prefix_is_rejected() { + for version in [1, 2] { + let bytes = fixture(version); + for end in 0..bytes.len() { + assert!( + parse(&bytes[..end]).is_err(), + "version {version}, prefix {end}" + ); + } + } +} + +#[test] +fn rejects_invalid_references() { + let valid = fixture(1); + // Model offset, placement name, region name, light name. + for offset in [37, 49, 117, 157] { + let mut bytes = valid.clone(); + bytes[offset..offset + 4].copy_from_slice(&9u32.to_le_bytes()); + assert!( + matches!( + parse(&bytes), + Err(ParseError::InvalidStringReference { .. }) + ), + "offset {offset}" + ); + } + let mut bytes = valid.clone(); + bytes[45..49].copy_from_slice(&2u32.to_le_bytes()); + assert!(matches!( + parse(&bytes), + Err(ParseError::InvalidModelReference { .. }) + )); + let mut bytes = valid; + bytes[36] = b'x'; + assert!(matches!( + parse(&bytes), + Err(ParseError::InvalidStringReference { .. }) + )); +} + +#[test] +fn validates_magic_versions_and_counts() { + let mut bytes = fixture(1); + bytes[0] = b'x'; + assert!(matches!(parse(&bytes), Err(ParseError::InvalidMagic))); + bytes[0] = b'E'; + bytes[4..8].copy_from_slice(&3u32.to_le_bytes()); + assert!(matches!( + parse(&bytes), + Err(ParseError::UnsupportedVersion { version: 3 }) + )); + for offset in [8, 12, 16, 20, 24] { + let mut bytes = fixture(1); + bytes[offset..offset + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(parse(&bytes).is_err()); + } + let mut bytes = fixture(2); + bytes[81..85].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(parse(&bytes).is_err()); +} + +#[test] +fn empty_zone_and_unreferenced_unterminated_table_are_allowed() { + let mut bytes = b"EQGZ".to_vec(); + for value in [1, 3, 0, 0, 0, 0] { + word(&mut bytes, value); + } + bytes.extend(b"abc"); + let zone = parse(&bytes).unwrap(); + assert!(zone.models.is_empty()); + assert!(zone.string(0).is_err()); +} + +#[test] +fn region_and_light_counts_select_distinct_record_layouts() { + let mut bytes = b"EQGZ".to_vec(); + for value in [1, 1, 0, 0, 2, 1] { + word(&mut bytes, value); + } + bytes.push(0); + for marker in [100, 200] { + word(&mut bytes, 0); + for value in marker..marker + 9 { + word(&mut bytes, value); + } + } + word(&mut bytes, 0); + for value in 300..307 { + word(&mut bytes, value); + } + let zone = parse(&bytes).unwrap(); + assert_eq!(zone.regions.len(), 2); + assert_eq!(zone.lights.len(), 1); + assert_eq!( + zone.regions[0].data, + [100, 101, 102, 103, 104, 105, 106, 107, 108] + ); + assert_eq!( + zone.regions[1].data, + [200, 201, 202, 203, 204, 205, 206, 207, 208] + ); + assert_eq!(zone.lights[0].data, [300, 301, 302, 303, 304, 305, 306]); + assert!(zone.trailing_data.is_empty()); +} diff --git a/src/lib.rs b/src/lib.rs index bf27ac0f..ed4e651a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ //! Libraries and tools for working with EverQuest game data //! //! # Crates +//! * `libeq_eqg` - Identify EQG resources and read binary zones and meshes (optional `eqg` feature). //! * [libeq_wld](crates/libeq_wld) - Load `.wld` files. //! * [libeq_pfs](crates/libeq_pfs) - Create and extract `.s3d` archives. //! @@ -43,3 +44,6 @@ pub use libeq_pfs as pfs; #[cfg(feature = "wld")] pub use libeq_wld as wld; + +#[cfg(feature = "eqg")] +pub use libeq_eqg as eqg;