Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -22,10 +23,12 @@ 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 }

[features]
default = ["wld", "pfs"]
wld = ["libeq_wld"]
pfs = ["libeq_pfs"]
eqg = ["libeq_eqg"]
12 changes: 12 additions & 0 deletions crates/libeq_eqg/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions crates/libeq_eqg/README.md
Original file line number Diff line number Diff line change
@@ -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
```
95 changes: 95 additions & 0 deletions crates/libeq_eqg/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Option<FormatHeader>, 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;
Loading