tf_asset_loader/
lib.rs

1//! Utility for loading assets from tf2 data files.
2//!
3//! Supports loading assets like models and textures from the tf2 data directory. The tf2 data directory should be
4//! automatically detected when installed to steam, or you can use the `TF_DIR` environment variable to overwrite the data
5//! directory.
6//!
7//! Supports loading both plain file data and data embedded in `vpk` files.
8//! ```rust,no_run
9//! # use tf_asset_loader::{Loader, LoaderError};
10//! #
11//! fn main() -> Result<(), LoaderError> {
12//!     let loader = Loader::new()?;
13//!     if let Some(model) = loader.load("models/props_gameplay/resupply_locker.mdl")? {
14//!         println!("resupply_locker.mdl is {} bytes large", model.len());
15//!     }
16//!     Ok(())
17//! }
18//! ```
19
20pub mod source;
21
22use path_dedot::ParseDot;
23pub use source::AssetSource;
24use std::borrow::Cow;
25use std::env::var_os;
26use std::fmt::{Debug, Display, Formatter};
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29use steamlocate::SteamDir;
30use thiserror::Error;
31use tracing::warn;
32#[cfg(feature = "bsp")]
33use vbsp::BspError;
34
35#[derive(Debug, Error)]
36pub enum LoaderError {
37    #[error("Failed to find tf2 install location")]
38    Tf2NotFound,
39    #[error(transparent)]
40    Io(#[from] std::io::Error),
41    #[cfg(feature = "zip")]
42    #[error(transparent)]
43    Zip(#[from] zip::result::ZipError),
44    #[error("{0}")]
45    Other(String),
46}
47
48#[cfg(feature = "bsp")]
49impl From<BspError> for LoaderError {
50    fn from(value: BspError) -> Self {
51        match value {
52            BspError::Zip(err) => LoaderError::Zip(err),
53            BspError::IO(err) => LoaderError::Io(err),
54            err => LoaderError::Other(err.to_string()),
55        }
56    }
57}
58
59/// The tf2 asset loader instance
60#[derive(Clone)]
61pub struct Loader {
62    sources: Vec<Arc<dyn AssetSource + Send + Sync>>,
63}
64
65impl Debug for Loader {
66    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("Loader")
68            .field("sources", &self.sources.len())
69            .finish_non_exhaustive()
70    }
71}
72
73impl Loader {
74    /// Create the loader, either auto-detecting the tf2 directory or from the `TF_DIR` environment variable.
75    pub fn new() -> Result<Self, LoaderError> {
76        let tf2_dir = tf2_path()?;
77        Self::with_tf2_dir(tf2_dir)
78    }
79
80    /// Create the loader with the specified tf2 directory.
81    pub fn with_tf2_dir<P: AsRef<Path>>(tf2_dir: P) -> Result<Self, LoaderError> {
82        let tf2_dir = tf2_dir.as_ref();
83
84        let tf_dir = tf2_dir.join("tf");
85        let hl_dir = tf2_dir.join("hl2");
86        let download = tf_dir.join("download");
87
88        #[cfg(feature = "vpk")]
89        let vpks = tf_dir
90            .read_dir()?
91            .chain(hl_dir.read_dir()?)
92            .filter_map(|item| item.ok())
93            .filter_map(|item| Some(item.path().to_str()?.to_string()))
94            .filter(|path| path.ends_with("dir.vpk"))
95            .map(|path| vpk::from_path(&path))
96            .filter_map(|res| {
97                if let Err(e) = &res {
98                    warn!(error = ?e, "error while loading vpk");
99                }
100                res.ok()
101            })
102            .map(|vpk| Arc::new(vpk) as Arc<dyn AssetSource + Send + Sync>);
103
104        #[allow(unused_mut)]
105        let mut sources = vec![
106            Arc::new(tf_dir) as Arc<dyn AssetSource + Send + Sync>,
107            Arc::new(hl_dir),
108        ];
109
110        if download.exists() {
111            sources.push(Arc::new(download));
112        }
113
114        #[cfg(feature = "vpk")]
115        sources.extend(vpks);
116
117        Ok(Loader { sources })
118    }
119
120    /// Add a new source to the loader.
121    ///
122    /// This is intended to be used to add data from bsp files
123    pub fn add_source<S: AssetSource + Send + Sync + 'static>(&mut self, source: S) {
124        self.sources.push(Arc::new(source))
125    }
126
127    /// Check if a file by path exists.
128    #[tracing::instrument(skip(self))]
129    pub fn exists(&self, name: &str) -> Result<bool, LoaderError> {
130        let name = clean_path(name);
131        for source in self.sources.iter() {
132            if source.has(&name)? {
133                return Ok(true);
134            }
135        }
136
137        let lower_name = name.to_ascii_lowercase();
138        if name != lower_name {
139            for source in self.sources.iter() {
140                if source.has(&lower_name)? {
141                    return Ok(true);
142                }
143            }
144        }
145
146        Ok(false)
147    }
148
149    /// Load a file by path.
150    ///
151    /// Returns the file data as `Vec<u8>` or `None` if the path doesn't exist.
152    #[tracing::instrument(skip(self))]
153    pub fn load(&self, name: &str) -> Result<Option<Vec<u8>>, LoaderError> {
154        let name = clean_path(name);
155        for source in self.sources.iter() {
156            if let Some(data) = source.load(&name)? {
157                return Ok(Some(data));
158            }
159        }
160
161        let lower_name = name.to_ascii_lowercase();
162        if name != lower_name {
163            for source in self.sources.iter() {
164                if let Some(data) = source.load(&lower_name)? {
165                    return Ok(Some(data));
166                }
167            }
168        }
169
170        Ok(None)
171    }
172
173    /// Look for a file by name in one or more paths
174    pub fn find_in_paths<S: Display>(&self, name: &str, paths: &[S]) -> Option<String> {
175        for path in paths {
176            let full_path = format!("{}{}", path, name);
177            let full_path = clean_path(&full_path);
178            if self.exists(&full_path).unwrap_or_default() {
179                return Some(full_path.to_string());
180            }
181        }
182
183        let lower_name = name.to_ascii_lowercase();
184        if name != lower_name {
185            for path in paths {
186                let full_path = format!("{}{}", path, lower_name);
187                let full_path = clean_path(&full_path);
188                if self.exists(&full_path).unwrap_or_default() {
189                    return Some(full_path.to_string());
190                }
191            }
192        }
193
194        None
195    }
196}
197
198fn clean_path(path: &str) -> Cow<str> {
199    if path.contains("/../") {
200        let path_buf = PathBuf::from(format!("/{path}"));
201        let Ok(absolute_path) = path_buf.parse_dot_from("/") else {
202            return path.into();
203        };
204        let path = absolute_path.to_str().unwrap().trim_start_matches('/');
205        String::from(path).into()
206    } else {
207        path.into()
208    }
209}
210
211#[test]
212fn test_clean_path() {
213    assert_eq!("foo/bar", clean_path("foo/bar"));
214    assert_eq!("foo/bar", clean_path("foo/asd/../bar"));
215    assert_eq!("../bar", clean_path("../bar"));
216}
217
218fn tf2_path() -> Result<PathBuf, LoaderError> {
219    if let Some(path) = var_os("TF_DIR") {
220        let path: PathBuf = path.into();
221        if path.is_dir() {
222            Ok(path)
223        } else {
224            Err(LoaderError::Tf2NotFound)
225        }
226    } else {
227        let (app, library) = SteamDir::locate()
228            .map_err(|_| LoaderError::Tf2NotFound)?
229            .find_app(440)
230            .map_err(|_| LoaderError::Tf2NotFound)?
231            .ok_or(LoaderError::Tf2NotFound)?;
232        Ok(library.resolve_app_dir(&app))
233    }
234}