diff --git a/Cargo.lock b/Cargo.lock
index d3179e6..c87e690 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -142,6 +142,17 @@ version = "0.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
 
+[[package]]
+name = "binrw"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173901312e9850391d4d7c1318c4e099fdc037d61870fca427429830efdb4e5f"
+dependencies = [
+ "array-init",
+ "binrw_derive 0.13.3",
+ "bytemuck",
+]
+
 [[package]]
 name = "binrw"
 version = "0.14.1"
@@ -149,10 +160,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7d4bca59c20d6f40c2cc0802afbe1e788b89096f61bdf7aeea6bf00f10c2909b"
 dependencies = [
  "array-init",
- "binrw_derive",
+ "binrw_derive 0.14.1",
  "bytemuck",
 ]
 
+[[package]]
+name = "binrw_derive"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb515fdd6f8d3a357c8e19b8ec59ef53880807864329b1cb1cba5c53bf76557e"
+dependencies = [
+ "either",
+ "owo-colors",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
 [[package]]
 name = "binrw_derive"
 version = "0.14.1"
@@ -1955,11 +1979,11 @@ dependencies = [
 
 [[package]]
 name = "rbx_mesh"
-version = "0.1.2"
+version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "864ead0e98afce28c960f653d6203483834890d07f87b60e2f01415530a2fe9d"
+checksum = "36372fd7feb6d3c5780d2ada39d1397be9e196ddfbb23ba1d84e7a75cf790adb"
 dependencies = [
- "binrw",
+ "binrw 0.14.1",
  "lazy-regex",
 ]
 
@@ -2332,6 +2356,7 @@ dependencies = [
  "strafesnet_deferred_loader",
  "vbsp",
  "vmdl",
+ "vpk",
 ]
 
 [[package]]
@@ -2422,7 +2447,7 @@ dependencies = [
 name = "strafesnet_snf"
 version = "0.2.0"
 dependencies = [
- "binrw",
+ "binrw 0.14.1",
  "id",
  "strafesnet_common",
 ]
@@ -2704,7 +2729,7 @@ checksum = "f14a5685e0bb386aac9b9c6046a05152a46a0bc58d53afb3fbe577f1a1c2bb05"
 dependencies = [
  "ahash",
  "arrayvec",
- "binrw",
+ "binrw 0.14.1",
  "bitflags 2.8.0",
  "bv",
  "cgmath",
@@ -2755,6 +2780,17 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "vpk"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60ec10e731515f58d5494d472f027d9c6fc8500fcb790ff55751031bcad87b6b"
+dependencies = [
+ "ahash",
+ "binrw 0.13.3",
+ "thiserror 1.0.69",
+]
+
 [[package]]
 name = "walkdir"
 version = "2.5.0"
diff --git a/lib/bsp_loader/Cargo.toml b/lib/bsp_loader/Cargo.toml
index 971cc20..60b3dfe 100644
--- a/lib/bsp_loader/Cargo.toml
+++ b/lib/bsp_loader/Cargo.toml
@@ -15,3 +15,4 @@ strafesnet_common = { path = "../common", registry = "strafesnet" }
 strafesnet_deferred_loader = { path = "../deferred_loader" }
 vbsp = "0.6.0"
 vmdl = "0.2.0"
+vpk = "0.2.0"
diff --git a/lib/bsp_loader/src/bsp.rs b/lib/bsp_loader/src/bsp.rs
index 12bd173..ca203c5 100644
--- a/lib/bsp_loader/src/bsp.rs
+++ b/lib/bsp_loader/src/bsp.rs
@@ -7,6 +7,31 @@ use strafesnet_deferred_loader::texture::{RenderConfigs,Texture};
 
 use crate::valve_transform;
 
+fn ingest_vertex(
+	mb:&mut model::MeshBuilder,
+	world_position:vbsp::Vector,
+	texture_transform_u:glam::Vec4,
+	texture_transform_v:glam::Vec4,
+	normal:model::NormalId,
+	color:model::ColorId,
+)->model::VertexId{
+	//world_model.origin seems to always be 0,0,0
+	let vertex_xyz=world_position.into();
+	let pos=mb.acquire_pos_id(valve_transform(vertex_xyz));
+
+	//calculate texture coordinates
+	let pos_4d=glam::Vec3::from_array(vertex_xyz).extend(1.0);
+	let tex=glam::vec2(texture_transform_u.dot(pos_4d),texture_transform_v.dot(pos_4d));
+	let tex=mb.acquire_tex_id(tex);
+
+	mb.acquire_vertex_id(model::IndexedVertex{
+		pos,
+		tex,
+		normal,
+		color,
+	})
+}
+
 pub fn convert<'a>(
 	bsp:&'a crate::Bsp,
 	render_config_deferred_loader:&mut RenderConfigDeferredLoader<Cow<'a,str>>,
@@ -48,11 +73,9 @@ pub fn convert<'a>(
 	//the generated MeshIds in here will collide with the Loader Mesh Ids
 	//but I can't think of a good workaround other than just remapping one later.
 	let world_meshes:Vec<model::Mesh>=bsp.models().map(|world_model|{
-		//non-deduplicated
-		let mut spam_pos=Vec::new();
-		let mut spam_tex=Vec::new();
-		let mut spam_normal=Vec::new();
-		let mut spam_vertices=Vec::new();
+		let mut mb=model::MeshBuilder::new();
+
+		let color=mb.acquire_color_id(glam::Vec4::ONE);
 		let mut graphics_groups=Vec::new();
 		let mut physics_group=model::IndexedPhysicsGroup::default();
 		let polygon_groups=world_model.faces().enumerate().map(|(polygon_group_id,face)|{
@@ -68,36 +91,20 @@ pub fn convert<'a>(
 			let render_id=render_config_deferred_loader.acquire_render_config_id(Some(Cow::Borrowed(face_texture_data.name())));
 
 			//normal
-			let normal=face.normal();
-			let normal_idx=spam_normal.len() as u32;
-			spam_normal.push(valve_transform(normal.into()));
-			let mut polygon_iter=face.vertex_positions().map(|vertex_position|{
-				//world_model.origin seems to always be 0,0,0
-				let vertex_xyz=(world_model.origin+vertex_position).into();
-				let pos_idx=spam_pos.len();
-				spam_pos.push(valve_transform(vertex_xyz));
-
-				//calculate texture coordinates
-				let pos=glam::Vec3::from_array(vertex_xyz).extend(1.0);
-				let tex=glam::vec2(texture_transform_u.dot(pos),texture_transform_v.dot(pos));
-				let tex_idx=spam_tex.len() as u32;
-				spam_tex.push(tex);
-
-				let vertex_id=model::VertexId::new(spam_vertices.len() as u32);
-				spam_vertices.push(model::IndexedVertex{
-					pos:model::PositionId::new(pos_idx as u32),
-					tex:model::TextureCoordinateId::new(tex_idx as u32),
-					normal:model::NormalId::new(normal_idx),
-					color:model::ColorId::new(0),
-				});
-				vertex_id
-			});
+			let normal=mb.acquire_normal_id(valve_transform(face.normal().into()));
+			let mut polygon_iter=face.vertex_positions().map(|vertex_position|
+				world_model.origin+vertex_position
+			);
 			let polygon_list=std::iter::from_fn(move||{
 				match (polygon_iter.next(),polygon_iter.next(),polygon_iter.next()){
-					(Some(v1),Some(v2),Some(v3))=>Some(vec![v1,v2,v3]),
+					(Some(v1),Some(v2),Some(v3))=>Some([v1,v2,v3]),
 					//ignore extra vertices, not sure what to do in this case, failing the whole conversion could be appropriate
 					_=>None,
 				}
+			}).map(|triplet|{
+				triplet.map(|world_position|
+					ingest_vertex(&mut mb,world_position,texture_transform_u,texture_transform_v,normal,color)
+				).to_vec()
 			}).collect();
 			if face.is_visible(){
 				//TODO: deduplicate graphics groups by render id
@@ -109,16 +116,8 @@ pub fn convert<'a>(
 			physics_group.groups.push(polygon_group_id);
 			model::PolygonGroup::PolygonList(model::PolygonList::new(polygon_list))
 		}).collect();
-		model::Mesh{
-			unique_pos:spam_pos,
-			unique_tex:spam_tex,
-			unique_normal:spam_normal,
-			unique_color:vec![glam::Vec4::ONE],
-			unique_vertices:spam_vertices,
-			polygon_groups,
-			graphics_groups,
-			physics_groups:vec![physics_group],
-		}
+
+		mb.build(polygon_groups,graphics_groups,vec![physics_group])
 	}).collect();
 
 	let world_models:Vec<model::Model>=
diff --git a/lib/bsp_loader/src/lib.rs b/lib/bsp_loader/src/lib.rs
index c4e9f19..64d83ed 100644
--- a/lib/bsp_loader/src/lib.rs
+++ b/lib/bsp_loader/src/lib.rs
@@ -62,7 +62,7 @@ impl Bsp{
 	pub const fn new(value:vbsp::Bsp)->Self{
 		Self(value)
 	}
-	pub fn to_snf(&self,failure_mode:LoadFailureMode)->Result<strafesnet_common::map::CompleteMap,LoadError>{
+	pub fn to_snf(&self,failure_mode:LoadFailureMode,vpk_list:&[vpk::VPK])->Result<strafesnet_common::map::CompleteMap,LoadError>{
 		let mut texture_deferred_loader=RenderConfigDeferredLoader::new();
 		let mut mesh_deferred_loader=MeshDeferredLoader::new();
 
@@ -72,7 +72,7 @@ impl Bsp{
 			&mut mesh_deferred_loader,
 		);
 
-		let mut mesh_loader=loader::MeshLoader::new(self,&mut texture_deferred_loader);
+		let mut mesh_loader=loader::MeshLoader::new(loader::BspFinder{bsp:self,vpks:vpk_list},&mut texture_deferred_loader);
 		let prop_meshes=mesh_deferred_loader.into_meshes(&mut mesh_loader,failure_mode).map_err(LoadError::Mesh)?;
 
 		let map_step2=map_step1.add_prop_meshes(prop_meshes);
diff --git a/lib/bsp_loader/src/loader.rs b/lib/bsp_loader/src/loader.rs
index 6f69fe4..9117bd4 100644
--- a/lib/bsp_loader/src/loader.rs
+++ b/lib/bsp_loader/src/loader.rs
@@ -3,7 +3,7 @@ use std::{borrow::Cow, io::Read};
 use strafesnet_common::model::Mesh;
 use strafesnet_deferred_loader::{loader::Loader,texture::Texture};
 
-use crate::{mesh::ModelData, Bsp};
+use crate::Bsp;
 
 #[allow(dead_code)]
 #[derive(Debug)]
@@ -73,25 +73,56 @@ impl From<vbsp::BspError> for MeshError{
 	}
 }
 
-pub struct MeshLoader<'a,'b>{
-	bsp:&'a Bsp,
-	deferred_loader:&'b mut strafesnet_deferred_loader::deferred_loader::RenderConfigDeferredLoader<Cow<'a,str>>,
+#[derive(Clone,Copy)]
+pub struct BspFinder<'bsp,'vpk>{
+	pub bsp:&'bsp Bsp,
+	pub vpks:&'vpk [vpk::VPK],
 }
-impl MeshLoader<'_,'_>{
-	pub fn new<'a,'b>(
-		bsp:&'a Bsp,
-		deferred_loader:&'b mut strafesnet_deferred_loader::deferred_loader::RenderConfigDeferredLoader<Cow<'a,str>>,
-	)->MeshLoader<'a,'b>{
-		MeshLoader{
-			bsp,
-			deferred_loader,
+impl<'bsp,'vpk> BspFinder<'bsp,'vpk>{
+	pub fn find<'a>(&self,path:&str)->Result<Option<Cow<'a,[u8]>>,vbsp::BspError>
+		where
+			'bsp:'a,
+			'vpk:'a,
+	{
+		// search bsp
+		if let Some(data)=self.bsp.as_ref().pack.get(path)?{
+			return Ok(Some(Cow::Owned(data)));
+		}
+
+		//search each vpk
+		for vpk in self.vpks{
+			if let Some(vpk_entry)=vpk.tree.get(path){
+				return Ok(Some(vpk_entry.get()?));
+			}
+		}
+
+		Ok(None)
+	}
+}
+
+pub struct ModelLoader<'bsp,'vpk,'a>{
+	finder:BspFinder<'bsp,'vpk>,
+	life:core::marker::PhantomData<&'a ()>,
+}
+impl ModelLoader<'_,'_,'_>{
+	#[inline]
+	pub const fn new<'bsp,'vpk,'a>(
+		finder:BspFinder<'bsp,'vpk>,
+	)->ModelLoader<'bsp,'vpk,'a>{
+		ModelLoader{
+			finder,
+			life:core::marker::PhantomData,
 		}
 	}
 }
-impl<'a> Loader for MeshLoader<'a,'_>{
+impl<'bsp,'vpk,'a> Loader for ModelLoader<'bsp,'vpk,'a>
+	where
+		'bsp:'a,
+		'vpk:'a,
+{
 	type Error=MeshError;
 	type Index=&'a str;
-	type Resource=Mesh;
+	type Resource=vmdl::Model;
 	fn load(&mut self,index:Self::Index)->Result<Self::Resource,Self::Error>{
 		let mdl_path_lower=index.to_lowercase();
 		//.mdl, .vvd, .dx90.vtx
@@ -101,12 +132,44 @@ impl<'a> Loader for MeshLoader<'a,'_>{
 		vvd_path.set_extension("vvd");
 		vtx_path.set_extension("dx90.vtx");
 		// TODO: search more packs, possibly using an index of multiple packs
-		let bsp=self.bsp.as_ref();
-		let mdl=bsp.pack.get(mdl_path_lower.as_str())?.ok_or(MeshError::MissingMdl)?;
-		let vtx=bsp.pack.get(vtx_path.as_os_str().to_str().unwrap())?.ok_or(MeshError::MissingVtx)?;
-		let vvd=bsp.pack.get(vvd_path.as_os_str().to_str().unwrap())?.ok_or(MeshError::MissingVvd)?;
-		let model=ModelData{mdl,vtx,vvd};
-		let mesh=model.convert_mesh(&mut self.deferred_loader)?;
+		let mdl=self.finder.find(mdl_path_lower.as_str())?.ok_or(MeshError::MissingMdl)?;
+		let vtx=self.finder.find(vtx_path.as_os_str().to_str().unwrap())?.ok_or(MeshError::MissingVtx)?;
+		let vvd=self.finder.find(vvd_path.as_os_str().to_str().unwrap())?.ok_or(MeshError::MissingVvd)?;
+		Ok(vmdl::Model::from_parts(
+			vmdl::mdl::Mdl::read(mdl.as_ref())?,
+			vmdl::vtx::Vtx::read(vtx.as_ref())?,
+			vmdl::vvd::Vvd::read(vvd.as_ref())?,
+		))
+	}
+}
+
+pub struct MeshLoader<'bsp,'vpk,'load,'a>{
+	finder:BspFinder<'bsp,'vpk>,
+	deferred_loader:&'load mut strafesnet_deferred_loader::deferred_loader::RenderConfigDeferredLoader<Cow<'a,str>>,
+}
+impl MeshLoader<'_,'_,'_,'_>{
+	#[inline]
+	pub const fn new<'bsp,'vpk,'load,'a>(
+		finder:BspFinder<'bsp,'vpk>,
+		deferred_loader:&'load mut strafesnet_deferred_loader::deferred_loader::RenderConfigDeferredLoader<Cow<'a,str>>,
+	)->MeshLoader<'bsp,'vpk,'load,'a>{
+		MeshLoader{
+			finder,
+			deferred_loader
+		}
+	}
+}
+impl<'bsp,'vpk,'load,'a> Loader for MeshLoader<'bsp,'vpk,'load,'a>
+	where
+		'bsp:'a,
+		'vpk:'a,
+{
+	type Error=MeshError;
+	type Index=&'a str;
+	type Resource=Mesh;
+	fn load(&mut self,index:Self::Index)->Result<Self::Resource,Self::Error>{
+		let model=ModelLoader::new(self.finder).load(index)?;
+		let mesh=crate::mesh::convert_mesh(model,&mut self.deferred_loader);
 		Ok(mesh)
 	}
 }
diff --git a/lib/bsp_loader/src/mesh.rs b/lib/bsp_loader/src/mesh.rs
index d6a3339..b1ebd3f 100644
--- a/lib/bsp_loader/src/mesh.rs
+++ b/lib/bsp_loader/src/mesh.rs
@@ -5,85 +5,74 @@ use strafesnet_deferred_loader::deferred_loader::RenderConfigDeferredLoader;
 
 use crate::valve_transform;
 
-pub struct ModelData{
-	pub mdl:Vec<u8>,
-	pub vtx:Vec<u8>,
-	pub vvd:Vec<u8>,
+fn ingest_vertex(mb:&mut model::MeshBuilder,vertex:&vmdl::vvd::Vertex,color:model::ColorId)->model::VertexId{
+	let pos=mb.acquire_pos_id(valve_transform(vertex.position.into()));
+	let normal=mb.acquire_normal_id(valve_transform(vertex.normal.into()));
+	let tex=mb.acquire_tex_id(glam::Vec2::from_array(vertex.texture_coordinates));
+	mb.acquire_vertex_id(model::IndexedVertex{
+		pos,
+		tex,
+		normal,
+		color,
+	})
 }
-impl ModelData{
-	fn read_model(&self)->Result<vmdl::Model,vmdl::ModelError>{
-		Ok(vmdl::Model::from_parts(
-			vmdl::mdl::Mdl::read(self.mdl.as_ref())?,
-			vmdl::vtx::Vtx::read(self.vtx.as_ref())?,
-			vmdl::vvd::Vvd::read(self.vvd.as_ref())?,
+
+pub fn convert_mesh(model:vmdl::Model,deferred_loader:&mut RenderConfigDeferredLoader<Cow<str>>)->model::Mesh{
+	let texture_paths=model.texture_directories();
+	if texture_paths.len()!=1{
+		println!("WARNING: multiple texture paths");
+	}
+	let skin=model.skin_tables().nth(0).unwrap();
+
+	let mut mb=model::MeshBuilder::new();
+
+	let color=mb.acquire_color_id(glam::Vec4::ONE);
+
+	let model_vertices=model.vertices();
+
+	let mut graphics_groups=Vec::new();
+	let mut physics_groups=Vec::new();
+	let polygon_groups=model.meshes().enumerate().map(|(polygon_group_id,mesh)|{
+		let polygon_group_id=model::PolygonGroupId::new(polygon_group_id as u32);
+
+		let render_id=if let (Some(texture_path),Some(texture_name))=(texture_paths.get(0),skin.texture(mesh.material_index())){
+			let mut path=std::path::PathBuf::from(texture_path.as_str());
+			path.push(texture_name);
+			let index=path.as_os_str().to_str().map(|s|Cow::Owned(s.to_owned()));
+			deferred_loader.acquire_render_config_id(index)
+		}else{
+			deferred_loader.acquire_render_config_id(None)
+		};
+
+		graphics_groups.push(model::IndexedGraphicsGroup{
+			render:render_id,
+			groups:vec![polygon_group_id],
+		});
+		physics_groups.push(model::IndexedPhysicsGroup{
+			groups:vec![polygon_group_id],
+		});
+		model::PolygonGroup::PolygonList(model::PolygonList::new(
+			//looking at the code, it would seem that the strips are pre-deindexed into triangle lists when calling this function
+			mesh.vertex_strip_indices().flat_map(|mut strip|{
+				std::iter::from_fn(move ||{
+					match (strip.next(),strip.next(),strip.next()){
+						(Some(v1),Some(v2),Some(v3))=>Some([v1,v2,v3]),
+						//ignore extra vertices, not sure what to do in this case, failing the whole conversion could be appropriate
+						_=>None,
+					}
+				})
+			}).flat_map(|[v1,v2,v3]|{
+				// this should probably be a fatal error :D
+				let v1=model_vertices.get(v1)?;
+				let v2=model_vertices.get(v2)?;
+				let v3=model_vertices.get(v3)?;
+				Some(vec![
+					ingest_vertex(&mut mb,v1,color),
+					ingest_vertex(&mut mb,v2,color),
+					ingest_vertex(&mut mb,v3,color),
+				])
+			}).collect()
 		))
-	}
-	pub fn convert_mesh<'a>(self,deferred_loader:&mut RenderConfigDeferredLoader<Cow<'a,str>>)->Result<model::Mesh,vmdl::ModelError>{
-		let model=self.read_model()?;
-		let texture_paths=model.texture_directories();
-		if texture_paths.len()!=1{
-			println!("WARNING: multiple texture paths");
-		}
-		let skin=model.skin_tables().nth(0).unwrap();
-
-		let mut spam_pos=Vec::with_capacity(model.vertices().len());
-		let mut spam_normal=Vec::with_capacity(model.vertices().len());
-		let mut spam_tex=Vec::with_capacity(model.vertices().len());
-		let mut spam_vertices=Vec::with_capacity(model.vertices().len());
-		for (i,vertex) in model.vertices().iter().enumerate(){
-			spam_pos.push(valve_transform(vertex.position.into()));
-			spam_normal.push(valve_transform(vertex.normal.into()));
-			spam_tex.push(glam::Vec2::from_array(vertex.texture_coordinates));
-			spam_vertices.push(model::IndexedVertex{
-				pos:model::PositionId::new(i as u32),
-				tex:model::TextureCoordinateId::new(i as u32),
-				normal:model::NormalId::new(i as u32),
-				color:model::ColorId::new(0),
-			});
-		}
-		let mut graphics_groups=Vec::new();
-		let mut physics_groups=Vec::new();
-		let polygon_groups=model.meshes().enumerate().map(|(polygon_group_id,mesh)|{
-			let polygon_group_id=model::PolygonGroupId::new(polygon_group_id as u32);
-
-			let render_id=if let (Some(texture_path),Some(texture_name))=(texture_paths.get(0),skin.texture(mesh.material_index())){
-				let mut path=std::path::PathBuf::from(texture_path.as_str());
-				path.push(texture_name);
-				let index=path.as_os_str().to_str().map(|s|Cow::Owned(s.to_owned()));
-				deferred_loader.acquire_render_config_id(index)
-			}else{
-				deferred_loader.acquire_render_config_id(None)
-			};
-
-			graphics_groups.push(model::IndexedGraphicsGroup{
-				render:render_id,
-				groups:vec![polygon_group_id],
-			});
-			physics_groups.push(model::IndexedPhysicsGroup{
-				groups:vec![polygon_group_id],
-			});
-			model::PolygonGroup::PolygonList(model::PolygonList::new(
-				//looking at the code, it would seem that the strips are pre-deindexed into triangle lists when calling this function
-				mesh.vertex_strip_indices().flat_map(|mut strip|
-					std::iter::from_fn(move||{
-						match (strip.next(),strip.next(),strip.next()){
-							(Some(v1),Some(v2),Some(v3))=>Some([v1,v2,v3].map(|vertex_id|model::VertexId::new(vertex_id as u32)).to_vec()),
-							//ignore extra vertices, not sure what to do in this case, failing the whole conversion could be appropriate
-							_=>None,
-						}
-					})
-				).collect()
-			))
-		}).collect();
-		Ok(model::Mesh{
-			unique_pos:spam_pos,
-			unique_normal:spam_normal,
-			unique_tex:spam_tex,
-			unique_color:vec![glam::Vec4::ONE],
-			unique_vertices:spam_vertices,
-			polygon_groups,
-			graphics_groups,
-			physics_groups,
-		})
-	}
+	}).collect();
+	mb.build(polygon_groups,graphics_groups,physics_groups)
 }
diff --git a/lib/common/src/model.rs b/lib/common/src/model.rs
index 3a9980b..41d981e 100644
--- a/lib/common/src/model.rs
+++ b/lib/common/src/model.rs
@@ -1,3 +1,5 @@
+use std::collections::HashMap;
+
 use crate::integer::{Planar64Vec3,Planar64Affine3};
 use crate::gameplay_attributes;
 
@@ -123,6 +125,87 @@ pub struct Mesh{
 	pub physics_groups:Vec<IndexedPhysicsGroup>,
 }
 
+#[derive(Default)]
+pub struct MeshBuilder{
+	unique_pos:Vec<Planar64Vec3>,//Unit32Vec3
+	unique_normal:Vec<Planar64Vec3>,//Unit32Vec3
+	unique_tex:Vec<TextureCoordinate>,
+	unique_color:Vec<Color4>,
+	unique_vertices:Vec<IndexedVertex>,
+	pos_id_from:HashMap<Planar64Vec3,PositionId>,//Unit32Vec3
+	normal_id_from:HashMap<Planar64Vec3,NormalId>,//Unit32Vec3
+	tex_id_from:HashMap<[u32;2],TextureCoordinateId>,
+	color_id_from:HashMap<[u32;4],ColorId>,
+	vertex_id_from:HashMap<IndexedVertex,VertexId>,
+}
+impl MeshBuilder{
+	pub fn new()->Self{
+		Self::default()
+	}
+	pub fn build(
+		self,
+		polygon_groups:Vec<PolygonGroup>,
+		graphics_groups:Vec<IndexedGraphicsGroup>,
+		physics_groups:Vec<IndexedPhysicsGroup>,
+	)->Mesh{
+		let MeshBuilder{
+			unique_pos,
+			unique_normal,
+			unique_tex,
+			unique_color,
+			unique_vertices,
+			..
+		}=self;
+		Mesh{
+			unique_pos,
+			unique_normal,
+			unique_tex,
+			unique_color,
+			unique_vertices,
+			polygon_groups,
+			graphics_groups,
+			physics_groups,
+		}
+	}
+	pub fn acquire_pos_id(&mut self,pos:Planar64Vec3)->PositionId{
+		*self.pos_id_from.entry(pos).or_insert_with(||{
+			let pos_id=PositionId::new(self.unique_pos.len() as u32);
+			self.unique_pos.push(pos);
+			pos_id
+		})
+	}
+	pub fn acquire_normal_id(&mut self,normal:Planar64Vec3)->NormalId{
+		*self.normal_id_from.entry(normal).or_insert_with(||{
+			let normal_id=NormalId::new(self.unique_normal.len() as u32);
+			self.unique_normal.push(normal);
+			normal_id
+		})
+	}
+	pub fn acquire_tex_id(&mut self,tex:TextureCoordinate)->TextureCoordinateId{
+		let h=tex.to_array().map(f32::to_bits);
+		*self.tex_id_from.entry(h).or_insert_with(||{
+			let tex_id=TextureCoordinateId::new(self.unique_tex.len() as u32);
+			self.unique_tex.push(tex);
+			tex_id
+		})
+	}
+	pub fn acquire_color_id(&mut self,color:Color4)->ColorId{
+		let h=color.to_array().map(f32::to_bits);
+		*self.color_id_from.entry(h).or_insert_with(||{
+			let color_id=ColorId::new(self.unique_color.len() as u32);
+			self.unique_color.push(color);
+			color_id
+		})
+	}
+	pub fn acquire_vertex_id(&mut self,vertex:IndexedVertex)->VertexId{
+		*self.vertex_id_from.entry(vertex.clone()).or_insert_with(||{
+			let vertex_id=VertexId::new(self.unique_vertices.len() as u32);
+			self.unique_vertices.push(vertex);
+			vertex_id
+		})
+	}
+}
+
 #[derive(Debug,Clone,Copy,Hash,id::Id,Eq,PartialEq)]
 pub struct ModelId(u32);
 pub struct Model{
diff --git a/lib/deferred_loader/src/deferred_loader.rs b/lib/deferred_loader/src/deferred_loader.rs
index 83a5c76..30597aa 100644
--- a/lib/deferred_loader/src/deferred_loader.rs
+++ b/lib/deferred_loader/src/deferred_loader.rs
@@ -42,10 +42,10 @@ impl<H:core::hash::Hash+Eq> RenderConfigDeferredLoader<H>{
 			render_id
 		})
 	}
-	pub fn indices(&self)->impl Iterator<Item=&H>{
-		self.render_config_id_from_asset_id.keys().flatten()
+	pub fn into_indices(self)->impl Iterator<Item=H>{
+		self.render_config_id_from_asset_id.into_keys().flatten()
 	}
-	pub fn into_render_configs<L:Loader<Index=H,Resource=Texture>>(mut self,loader:&mut L,failure_mode:LoadFailureMode)->Result<RenderConfigs,L::Error>{
+	pub fn into_render_configs<L:Loader<Resource=Texture,Index=H>>(mut self,loader:&mut L,failure_mode:LoadFailureMode)->Result<RenderConfigs,L::Error>{
 		let mut sorted_textures=vec![None;self.texture_count as usize];
 		for (index_option,render_config_id) in self.render_config_id_from_asset_id{
 			let render_config=&mut self.render_configs[render_config_id.get() as usize];
@@ -90,10 +90,10 @@ impl<H:core::hash::Hash+Eq> MeshDeferredLoader<H>{
 		let mesh_id=MeshId::new(self.mesh_id_from_asset_id.len() as u32);
 		*self.mesh_id_from_asset_id.entry(index).or_insert(mesh_id)
 	}
-	pub fn indices(&self)->impl Iterator<Item=&H>{
-		self.mesh_id_from_asset_id.keys()
+	pub fn into_indices(self)->impl Iterator<Item=H>{
+		self.mesh_id_from_asset_id.into_keys()
 	}
-	pub fn into_meshes<L:Loader<Index=H,Resource=Mesh>>(self,loader:&mut L,failure_mode:LoadFailureMode)->Result<Meshes,L::Error>{
+	pub fn into_meshes<L:Loader<Resource=Mesh,Index=H>>(self,loader:&mut L,failure_mode:LoadFailureMode)->Result<Meshes,L::Error>{
 		let mut mesh_list=vec![None;self.mesh_id_from_asset_id.len()];
 		for (index,mesh_id) in self.mesh_id_from_asset_id{
 			let resource_result=loader.load(index);
diff --git a/lib/rbx_loader/Cargo.toml b/lib/rbx_loader/Cargo.toml
index 241805c..fb101f0 100644
--- a/lib/rbx_loader/Cargo.toml
+++ b/lib/rbx_loader/Cargo.toml
@@ -15,7 +15,7 @@ glam = "0.29.0"
 lazy-regex = "3.1.0"
 rbx_binary = { version = "0.7.4", registry = "strafesnet" }
 rbx_dom_weak = { version = "2.7.0", registry = "strafesnet" }
-rbx_mesh = "0.1.2"
+rbx_mesh = "0.3.1"
 rbx_reflection_database = { version = "0.2.10", registry = "strafesnet" }
 rbx_xml = { version = "0.13.3", registry = "strafesnet" }
 rbxassetid = { version = "0.1.0", path = "../rbxassetid" }
diff --git a/lib/rbx_loader/src/lib.rs b/lib/rbx_loader/src/lib.rs
index 7a9de02..4f320b1 100644
--- a/lib/rbx_loader/src/lib.rs
+++ b/lib/rbx_loader/src/lib.rs
@@ -4,6 +4,7 @@ use strafesnet_deferred_loader::deferred_loader::{LoadFailureMode,MeshDeferredLo
 
 mod rbx;
 mod mesh;
+mod union;
 pub mod loader;
 mod primitives;
 
diff --git a/lib/rbx_loader/src/loader.rs b/lib/rbx_loader/src/loader.rs
index 099a080..9cfa8ab 100644
--- a/lib/rbx_loader/src/loader.rs
+++ b/lib/rbx_loader/src/loader.rs
@@ -4,6 +4,14 @@ use strafesnet_common::model::Mesh;
 use strafesnet_deferred_loader::{loader::Loader,texture::Texture};
 
 use crate::data::RobloxMeshBytes;
+use crate::rbx::RobloxFaceTextureDescription;
+
+fn read_entire_file(path:impl AsRef<std::path::Path>)->Result<Vec<u8>,std::io::Error>{
+	let mut file=std::fs::File::open(path)?;
+	let mut data=Vec::new();
+	file.read_to_end(&mut data)?;
+	Ok(data)
+}
 
 #[allow(dead_code)]
 #[derive(Debug)]
@@ -41,9 +49,7 @@ impl<'a> Loader for TextureLoader<'a>{
 	fn load(&mut self,index:Self::Index)->Result<Self::Resource,Self::Error>{
 		let RobloxAssetId(asset_id)=index.parse()?;
 		let file_name=format!("textures/{}.dds",asset_id);
-		let mut file=std::fs::File::open(file_name)?;
-		let mut data=Vec::new();
-		file.read_to_end(&mut data)?;
+		let data=read_entire_file(file_name)?;
 		Ok(Texture::ImageDDS(data))
 	}
 }
@@ -53,8 +59,11 @@ impl<'a> Loader for TextureLoader<'a>{
 pub enum MeshError{
 	Io(std::io::Error),
 	RobloxAssetIdParse(RobloxAssetIdParseErr),
-	Mesh(crate::mesh::Error)
-
+	Mesh(crate::mesh::Error),
+	Union(crate::union::Error),
+	DecodeBinary(rbx_binary::DecodeError),
+	OneChildPolicy,
+	MissingInstance,
 }
 impl std::fmt::Display for MeshError{
 	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
@@ -77,6 +86,57 @@ impl From<crate::mesh::Error> for MeshError{
 		Self::Mesh(value)
 	}
 }
+impl From<crate::union::Error> for MeshError{
+	fn from(value:crate::union::Error)->Self{
+		Self::Union(value)
+	}
+}
+impl From<rbx_binary::DecodeError> for MeshError{
+	fn from(value:rbx_binary::DecodeError)->Self{
+		Self::DecodeBinary(value)
+	}
+}
+
+#[derive(Hash,Eq,PartialEq)]
+pub enum MeshType<'a>{
+	FileMesh,
+	Union{
+		mesh_data:&'a [u8],
+		physics_data:&'a [u8],
+		size_float_bits:[u32;3],
+		part_texture_description:[Option<RobloxFaceTextureDescription>;6],
+	},
+}
+#[derive(Hash,Eq,PartialEq)]
+pub struct MeshIndex<'a>{
+	mesh_type:MeshType<'a>,
+	content:&'a str,
+}
+impl MeshIndex<'_>{
+	pub fn file_mesh(content:&str)->MeshIndex{
+		MeshIndex{
+			mesh_type:MeshType::FileMesh,
+			content,
+		}
+	}
+	pub fn union<'a>(
+		content:&'a str,
+		mesh_data:&'a [u8],
+		physics_data:&'a [u8],
+		size:&rbx_dom_weak::types::Vector3,
+		part_texture_description:crate::rbx::RobloxPartDescription,
+	)->MeshIndex<'a>{
+		MeshIndex{
+			mesh_type:MeshType::Union{
+				mesh_data,
+				physics_data,
+				size_float_bits:[size.x.to_bits(),size.y.to_bits(),size.z.to_bits()],
+				part_texture_description,
+			},
+			content,
+		}
+	}
+}
 
 pub struct MeshLoader<'a>(std::marker::PhantomData<&'a ()>);
 impl MeshLoader<'_>{
@@ -86,17 +146,46 @@ impl MeshLoader<'_>{
 }
 impl<'a> Loader for MeshLoader<'a>{
 	type Error=MeshError;
-	type Index=&'a str;
+	type Index=MeshIndex<'a>;
 	type Resource=Mesh;
 	fn load(&mut self,index:Self::Index)->Result<Self::Resource,Self::Error>{
-		let RobloxAssetId(asset_id)=index.parse()?;
-		let file_name=format!("meshes/{}",asset_id);
-		let mut file=std::fs::File::open(file_name)?;
-		// reading the entire file is way faster than
-		// round tripping to disk every read from the parser
-		let mut data=Vec::new();
-		file.read_to_end(&mut data)?;
-		let mesh=crate::mesh::convert(RobloxMeshBytes::new(data))?;
+		let mesh=match index.mesh_type{
+			MeshType::FileMesh=>{
+				let RobloxAssetId(asset_id)=index.content.parse()?;
+				let file_name=format!("meshes/{}",asset_id);
+				let data=read_entire_file(file_name)?;
+				crate::mesh::convert(RobloxMeshBytes::new(data))?
+			},
+			MeshType::Union{mut physics_data,mut mesh_data,size_float_bits,part_texture_description}=>{
+				// decode asset
+				let size=glam::Vec3::from_array(size_float_bits.map(f32::from_bits));
+				if !index.content.is_empty()&&(physics_data.is_empty()||mesh_data.is_empty()){
+					let RobloxAssetId(asset_id)=index.content.parse()?;
+					let file_name=format!("unions/{}",asset_id);
+					let data=read_entire_file(file_name)?;
+					let dom=rbx_binary::from_reader(std::io::Cursor::new(data))?;
+					let &[referent]=dom.root().children()else{
+						return Err(MeshError::OneChildPolicy);
+					};
+					let Some(instance)=dom.get_by_ref(referent)else{
+						return Err(MeshError::MissingInstance);
+					};
+					if physics_data.is_empty(){
+						if let Some(rbx_dom_weak::types::Variant::BinaryString(data))=instance.properties.get("PhysicsData"){
+							physics_data=data.as_ref();
+						}
+					}
+					if mesh_data.is_empty(){
+						if let Some(rbx_dom_weak::types::Variant::BinaryString(data))=instance.properties.get("MeshData"){
+							mesh_data=data.as_ref();
+						}
+					}
+					crate::union::convert(physics_data,mesh_data,size,part_texture_description)?
+				}else{
+					crate::union::convert(physics_data,mesh_data,size,part_texture_description)?
+				}
+			},
+		};
 		Ok(mesh)
 	}
 }
diff --git a/lib/rbx_loader/src/mesh.rs b/lib/rbx_loader/src/mesh.rs
index 4b18f56..2b3bb60 100644
--- a/lib/rbx_loader/src/mesh.rs
+++ b/lib/rbx_loader/src/mesh.rs
@@ -1,7 +1,7 @@
 use std::collections::HashMap;
 
-use rbx_mesh::mesh::{Vertex2, Vertex2Truncated};
-use strafesnet_common::{integer::vec3,model::{self, ColorId, IndexedVertex, NormalId, PolygonGroup, PolygonList, PositionId, TextureCoordinateId, VertexId}};
+use rbx_mesh::mesh::{Vertex2,Vertex2Truncated};
+use strafesnet_common::{integer::vec3,model::{self,ColorId,IndexedVertex,NormalId,PolygonGroup,PolygonList,PositionId,RenderConfigId,TextureCoordinateId,VertexId}};
 
 #[allow(dead_code)]
 #[derive(Debug)]
@@ -205,7 +205,13 @@ pub fn convert(roblox_mesh_bytes:crate::data::RobloxMeshBytes)->Result<model::Me
 		unique_vertices,
 		polygon_groups,
 		//these should probably be moved to the model...
-		graphics_groups:Vec::new(),
+		//but what if models want to use the same texture
+		graphics_groups:vec![model::IndexedGraphicsGroup{
+			render:RenderConfigId::new(0),
+			//the lowest lod is highest quality
+			groups:vec![model::PolygonGroupId::new(0)]
+		}],
+		//disable physics
 		physics_groups:Vec::new(),
 	})
 }
diff --git a/lib/rbx_loader/src/rbx.rs b/lib/rbx_loader/src/rbx.rs
index bd5ff9a..8162662 100644
--- a/lib/rbx_loader/src/rbx.rs
+++ b/lib/rbx_loader/src/rbx.rs
@@ -1,5 +1,7 @@
 use std::collections::HashMap;
+use crate::loader::MeshIndex;
 use crate::primitives;
+use strafesnet_common::aabb::Aabb;
 use strafesnet_common::map;
 use strafesnet_common::model;
 use strafesnet_common::gameplay_modes;
@@ -344,58 +346,103 @@ fn get_attributes(name:&str,can_collide:bool,velocity:Planar64Vec3,model_id:mode
 	}
 }
 
-#[derive(Clone,Copy,PartialEq)]
-struct RobloxTextureTransform{
-	offset_u:f32,
-	offset_v:f32,
-	scale_u:f32,
-	scale_v:f32,
+#[derive(Clone,Copy)]
+pub struct RobloxTextureTransform{
+	offset_studs_u:f32,
+	offset_studs_v:f32,
+	studs_per_tile_u:f32,
+	studs_per_tile_v:f32,
+	size_u:f32,
+	size_v:f32,
 }
-impl std::cmp::Eq for RobloxTextureTransform{}//????
-impl std::default::Default for RobloxTextureTransform{
-    fn default()->Self{
-        Self{offset_u:0.0,offset_v:0.0,scale_u:1.0,scale_v:1.0}
-    }
+#[derive(Clone,Copy,Hash,Eq,PartialEq)]
+pub struct RobloxTextureTransformBits{
+	offset_studs_u:u32,
+	offset_studs_v:u32,
+	studs_per_tile_u:u32,
+	studs_per_tile_v:u32,
+	size_u:u32,
+	size_v:u32,
 }
-impl std::hash::Hash for RobloxTextureTransform{
-	fn hash<H:std::hash::Hasher>(&self,state:&mut H) {
-		self.offset_u.to_ne_bytes().hash(state);
-		self.offset_v.to_ne_bytes().hash(state);
-		self.scale_u.to_ne_bytes().hash(state);
-		self.scale_v.to_ne_bytes().hash(state);
+impl RobloxTextureTransform{
+	fn identity()->Self{
+		Self{
+			offset_studs_u:0.0,
+			offset_studs_v:0.0,
+			studs_per_tile_u:1.0,
+			studs_per_tile_v:1.0,
+			size_u:1.0,
+			size_v:1.0,
+		}
+	}
+	pub fn to_bits(self)->RobloxTextureTransformBits{
+		RobloxTextureTransformBits{
+			offset_studs_u:self.offset_studs_u.to_bits(),
+			offset_studs_v:self.offset_studs_v.to_bits(),
+			studs_per_tile_u:self.studs_per_tile_u.to_bits(),
+			studs_per_tile_v:self.studs_per_tile_v.to_bits(),
+			size_u:self.size_u.to_bits(),
+			size_v:self.size_v.to_bits(),
+		}
+	}
+	pub fn affine(&self)->glam::Affine2{
+		glam::Affine2::from_translation(
+			glam::vec2(self.offset_studs_u/self.studs_per_tile_u,self.offset_studs_v/self.studs_per_tile_v)
+		)
+		*glam::Affine2::from_scale(
+			glam::vec2(self.size_u/self.studs_per_tile_u,self.size_v/self.studs_per_tile_v)
+		)
+	}
+	pub fn set_size(&mut self,size_u:f32,size_v:f32){
+		self.size_u=size_u;
+		self.size_v=size_v;
 	}
 }
-#[derive(Clone,PartialEq)]
-struct RobloxFaceTextureDescription{
-	render:RenderConfigId,
-	color:glam::Vec4,
-	transform:RobloxTextureTransform,
+impl core::hash::Hash for RobloxTextureTransform{
+	fn hash<H:core::hash::Hasher>(&self,state:&mut H){
+		self.to_bits().hash(state);
+	}
 }
-impl std::cmp::Eq for RobloxFaceTextureDescription{}//????
-impl std::hash::Hash for RobloxFaceTextureDescription{
-	fn hash<H:std::hash::Hasher>(&self,state:&mut H){
-		self.render.hash(state);
-		self.transform.hash(state);
-        for &el in self.color.as_ref().iter(){
-            el.to_ne_bytes().hash(state);
-        }
-    }
+#[derive(Clone,Copy,Hash,Eq,PartialEq)]
+pub struct RobloxFaceTextureDescriptionBits{
+	render:RenderConfigId,
+	color:[u32;4],
+	transform:RobloxTextureTransformBits,
+}
+#[derive(Clone,Copy)]
+pub struct RobloxFaceTextureDescription{
+	pub render:RenderConfigId,
+	pub color:glam::Vec4,
+	pub transform:RobloxTextureTransform,
+}
+impl core::cmp::PartialEq for RobloxFaceTextureDescription{
+	fn eq(&self,other:&Self)->bool{
+		self.to_bits().eq(&other.to_bits())
+	}
+}
+impl core::cmp::Eq for RobloxFaceTextureDescription{}
+impl core::hash::Hash for RobloxFaceTextureDescription{
+	fn hash<H:core::hash::Hasher>(&self,state:&mut H){
+		self.to_bits().hash(state);
+	}
 }
 impl RobloxFaceTextureDescription{
-	fn to_face_description(&self)->primitives::FaceDescription{
+	pub fn to_bits(self)->RobloxFaceTextureDescriptionBits{
+		RobloxFaceTextureDescriptionBits{
+			render:self.render,
+			color:self.color.to_array().map(f32::to_bits),
+			transform:self.transform.to_bits(),
+		}
+	}
+	pub fn to_face_description(&self)->primitives::FaceDescription{
 		primitives::FaceDescription{
 			render:self.render,
-			transform:glam::Affine2::from_translation(
-				glam::vec2(self.transform.offset_u,self.transform.offset_v)
-			)
-			*glam::Affine2::from_scale(
-				glam::vec2(self.transform.scale_u,self.transform.scale_v)
-			),
+			transform:self.transform.affine(),
 			color:self.color,
 		}
 	}
 }
-type RobloxPartDescription=[Option<RobloxFaceTextureDescription>;6];
+pub type RobloxPartDescription=[Option<RobloxFaceTextureDescription>;6];
 type RobloxWedgeDescription=[Option<RobloxFaceTextureDescription>;5];
 type RobloxCornerWedgeDescription=[Option<RobloxFaceTextureDescription>;5];
 #[derive(Clone,Eq,Hash,PartialEq)]
@@ -406,41 +453,128 @@ enum RobloxBasePartDescription{
 	Wedge(RobloxWedgeDescription),
 	CornerWedge(RobloxCornerWedgeDescription),
 }
+fn get_texture_description<'a>(
+	temp_objects:&mut Vec<rbx_dom_weak::types::Ref>,
+	render_config_deferred_loader:&mut RenderConfigDeferredLoader<&'a str>,
+	dom:&'a rbx_dom_weak::WeakDom,
+	object:&rbx_dom_weak::Instance,
+	size:&rbx_dom_weak::types::Vector3,
+)->RobloxPartDescription{
+	//use the biggest one and cut it down later...
+	let mut part_texture_description:RobloxPartDescription=[None,None,None,None,None,None];
+	temp_objects.clear();
+	recursive_collect_superclass(temp_objects,&dom,object,"Decal");
+	for &mut decal_ref in temp_objects{
+	if let Some(decal)=dom.get_by_ref(decal_ref){
+		if let (
+			Some(rbx_dom_weak::types::Variant::Content(content)),
+			Some(rbx_dom_weak::types::Variant::Enum(normalid)),
+			Some(rbx_dom_weak::types::Variant::Color3(decal_color3)),
+			Some(rbx_dom_weak::types::Variant::Float32(decal_transparency)),
+		) = (
+			decal.properties.get("Texture"),
+			decal.properties.get("Face"),
+			decal.properties.get("Color3"),
+			decal.properties.get("Transparency"),
+		) {
+			let render_id=render_config_deferred_loader.acquire_render_config_id(Some(content.as_ref()));
+				let normal_id=normalid.to_u32();
+				if normal_id<6{
+					let (roblox_texture_color,roblox_texture_transform)=if decal.class=="Texture"{
+						//generate tranform
+						if let (
+								Some(&rbx_dom_weak::types::Variant::Float32(offset_studs_u)),
+								Some(&rbx_dom_weak::types::Variant::Float32(offset_studs_v)),
+								Some(&rbx_dom_weak::types::Variant::Float32(studs_per_tile_u)),
+								Some(&rbx_dom_weak::types::Variant::Float32(studs_per_tile_v)),
+							) = (
+								decal.properties.get("OffsetStudsU"),
+								decal.properties.get("OffsetStudsV"),
+								decal.properties.get("StudsPerTileU"),
+								decal.properties.get("StudsPerTileV"),
+							)
+						{
+							let (size_u,size_v)=match normal_id{
+								0=>(size.z,size.y),//right
+								1=>(size.x,size.z),//top
+								2=>(size.x,size.y),//back
+								3=>(size.z,size.y),//left
+								4=>(size.x,size.z),//bottom
+								5=>(size.x,size.y),//front
+								_=>unreachable!(),
+							};
+							(
+								glam::vec4(decal_color3.r,decal_color3.g,decal_color3.b,1.0-*decal_transparency),
+								RobloxTextureTransform{
+									offset_studs_u,
+									offset_studs_v,
+									studs_per_tile_u,
+									studs_per_tile_v,
+									size_u,
+									size_v,
+								}
+							)
+						}else{
+							(glam::Vec4::ONE,RobloxTextureTransform::identity())
+						}
+					}else{
+						(glam::Vec4::ONE,RobloxTextureTransform::identity())
+					};
+					part_texture_description[normal_id as usize]=Some(RobloxFaceTextureDescription{
+						render:render_id,
+						color:roblox_texture_color,
+						transform:roblox_texture_transform,
+					});
+				}else{
+					println!("NormalId={} is invalid",normal_id);
+				}
+			}
+		}
+	}
+	part_texture_description
+}
 enum Shape{
 	Primitive(primitives::Primitives),
 	MeshPart,
+	PhysicsData,
 }
 enum MeshAvailability{
 	Immediate,
-	Deferred(RenderConfigId),
+	DeferredMesh(RenderConfigId),
+	DeferredUnion(RobloxPartDescription),
 }
-struct DeferredModelDeferredAttributes{
+struct DeferredModelDeferredAttributes<'a>{
 	render:RenderConfigId,
-	model:ModelDeferredAttributes,
+	model:ModelDeferredAttributes<'a>,
 }
-struct ModelDeferredAttributes{
+struct ModelDeferredAttributes<'a>{
 	mesh:model::MeshId,
-	deferred_attributes:GetAttributesArgs,
+	deferred_attributes:GetAttributesArgs<'a>,
 	color:model::Color4,//transparency is in here
 	transform:Planar64Affine3,
 }
+struct DeferredUnionDeferredAttributes<'a>{
+	render:RobloxPartDescription,
+	model:ModelDeferredAttributes<'a>,
+}
 struct ModelOwnedAttributes{
 	mesh:model::MeshId,
 	attributes:attr::CollisionAttributes,
 	color:model::Color4,//transparency is in here
 	transform:Planar64Affine3,
 }
-struct GetAttributesArgs{
-	name:Box<str>,
+struct GetAttributesArgs<'a>{
+	name:&'a str,
 	can_collide:bool,
 	velocity:Planar64Vec3,
 }
 pub fn convert<'a>(
 	dom:&'a rbx_dom_weak::WeakDom,
 	render_config_deferred_loader:&mut RenderConfigDeferredLoader<&'a str>,
-	mesh_deferred_loader:&mut MeshDeferredLoader<&'a str>,
-)->PartialMap1{
+	mesh_deferred_loader:&mut MeshDeferredLoader<MeshIndex<'a>>,
+)->PartialMap1<'a>{
 	let mut deferred_models_deferred_attributes=Vec::new();
+	let mut deferred_unions_deferred_attributes=Vec::new();
 	let mut primitive_models_deferred_attributes=Vec::new();
 	let mut primitive_meshes=Vec::new();
 	let mut mesh_id_from_description=HashMap::new();
@@ -501,6 +635,7 @@ pub fn convert<'a>(
 					"WedgePart"=>Shape::Primitive(primitives::Primitives::Wedge),
 					"CornerWedgePart"=>Shape::Primitive(primitives::Primitives::CornerWedge),
 					"MeshPart"=>Shape::MeshPart,
+					"UnionOperation"=>Shape::PhysicsData,
 					_=>{
 						println!("Unsupported BasePart ClassName={}; defaulting to cube",object.class);
 						Shape::Primitive(primitives::Primitives::Cube)
@@ -509,74 +644,8 @@ pub fn convert<'a>(
 
 				let (availability,mesh_id)=match shape{
 					Shape::Primitive(primitive_shape)=>{
-						//TODO: TAB TAB
-				//use the biggest one and cut it down later...
-				let mut part_texture_description:RobloxPartDescription=[None,None,None,None,None,None];
-				temp_objects.clear();
-				recursive_collect_superclass(&mut temp_objects, &dom, object,"Decal");
-				for &decal_ref in &temp_objects{
-					if let Some(decal)=dom.get_by_ref(decal_ref){
-						if let (
-							Some(rbx_dom_weak::types::Variant::Content(content)),
-							Some(rbx_dom_weak::types::Variant::Enum(normalid)),
-							Some(rbx_dom_weak::types::Variant::Color3(decal_color3)),
-							Some(rbx_dom_weak::types::Variant::Float32(decal_transparency)),
-						) = (
-							decal.properties.get("Texture"),
-							decal.properties.get("Face"),
-							decal.properties.get("Color3"),
-							decal.properties.get("Transparency"),
-						) {
-							let render_id=render_config_deferred_loader.acquire_render_config_id(Some(content.as_ref()));
-								let normal_id=normalid.to_u32();
-								if normal_id<6{
-									let (roblox_texture_color,roblox_texture_transform)=if decal.class=="Texture"{
-										//generate tranform
-										if let (
-												Some(rbx_dom_weak::types::Variant::Float32(ox)),
-												Some(rbx_dom_weak::types::Variant::Float32(oy)),
-												Some(rbx_dom_weak::types::Variant::Float32(sx)),
-												Some(rbx_dom_weak::types::Variant::Float32(sy)),
-											) = (
-												decal.properties.get("OffsetStudsU"),
-												decal.properties.get("OffsetStudsV"),
-												decal.properties.get("StudsPerTileU"),
-												decal.properties.get("StudsPerTileV"),
-											)
-										{
-											let (size_u,size_v)=match normal_id{
-												0=>(size.z,size.y),//right
-												1=>(size.x,size.z),//top
-												2=>(size.x,size.y),//back
-												3=>(size.z,size.y),//left
-												4=>(size.x,size.z),//bottom
-												5=>(size.x,size.y),//front
-												_=>unreachable!(),
-											};
-											(
-												glam::vec4(decal_color3.r,decal_color3.g,decal_color3.b,1.0-*decal_transparency),
-												RobloxTextureTransform{
-													offset_u:*ox/(*sx),offset_v:*oy/(*sy),
-													scale_u:size_u/(*sx),scale_v:size_v/(*sy),
-												}
-											)
-										}else{
-											(glam::Vec4::ONE,RobloxTextureTransform::default())
-										}
-									}else{
-										(glam::Vec4::ONE,RobloxTextureTransform::default())
-									};
-									part_texture_description[normal_id as usize]=Some(RobloxFaceTextureDescription{
-										render:render_id,
-										color:roblox_texture_color,
-										transform:roblox_texture_transform,
-									});
-								}else{
-									println!("NormalId={} unsupported for shape={:?}",normal_id,primitive_shape);
-								}
-						}
-					}
-				}
+				//TODO: TAB TAB
+				let part_texture_description=get_texture_description(&mut temp_objects,render_config_deferred_loader,dom,object,size);
 				//obscure rust syntax "slice pattern"
 				let [
 					f0,//Cube::Right
@@ -593,7 +662,7 @@ pub fn convert<'a>(
 					//use front face texture first and use top face texture as a fallback
 					primitives::Primitives::Wedge=>RobloxBasePartDescription::Wedge([
 						f0,//Cube::Right->Wedge::Right
-						if f5.is_some(){f5}else{f1},//Cube::Front|Cube::Top->Wedge::TopFront
+						f5.or(f1),//Cube::Front|Cube::Top->Wedge::TopFront
 						f2,//Cube::Back->Wedge::Back
 						f3,//Cube::Left->Wedge::Left
 						f4,//Cube::Bottom->Wedge::Bottom
@@ -601,8 +670,8 @@ pub fn convert<'a>(
 					//TODO: fix Left+Back texture coordinates to match roblox when not overwridden by Top
 					primitives::Primitives::CornerWedge=>RobloxBasePartDescription::CornerWedge([
 						f0,//Cube::Right->CornerWedge::Right
-						if f2.is_some(){f2}else{f1.clone()},//Cube::Back|Cube::Top->CornerWedge::TopBack
-						if f3.is_some(){f3}else{f1},//Cube::Left|Cube::Top->CornerWedge::TopLeft
+						f2.or(f1.clone()),//Cube::Back|Cube::Top->CornerWedge::TopBack
+						f3.or(f1),//Cube::Left|Cube::Top->CornerWedge::TopLeft
 						f4,//Cube::Bottom->CornerWedge::Bottom
 						f5,//Cube::Front->CornerWedge::Front
 					]),
@@ -689,29 +758,51 @@ pub fn convert<'a>(
 						object.properties.get("TextureID"),
 					){
 						(
-							MeshAvailability::Deferred(render_config_deferred_loader.acquire_render_config_id(Some(texture_asset_id.as_ref()))),
-							mesh_deferred_loader.acquire_mesh_id(mesh_asset_id.as_ref()),
+							MeshAvailability::DeferredMesh(render_config_deferred_loader.acquire_render_config_id(Some(texture_asset_id.as_ref()))),
+							mesh_deferred_loader.acquire_mesh_id(MeshIndex::file_mesh(mesh_asset_id.as_ref())),
 						)
 					}else{
 						panic!("Mesh has no Mesh or Texture");
 					},
+					Shape::PhysicsData=>{
+						let mut content="";
+						let mut mesh_data:&[u8]=&[];
+						let mut physics_data:&[u8]=&[];
+						if let Some(rbx_dom_weak::types::Variant::Content(asset_id))=object.properties.get("AssetId"){
+							content=asset_id.as_ref();
+						}
+						if let Some(rbx_dom_weak::types::Variant::BinaryString(data))=object.properties.get("MeshData"){
+							mesh_data=data.as_ref();
+						}
+						if let Some(rbx_dom_weak::types::Variant::BinaryString(data))=object.properties.get("PhysicsData"){
+							physics_data=data.as_ref();
+						}
+						let part_texture_description=get_texture_description(&mut temp_objects,render_config_deferred_loader,dom,object,size);
+						let mesh_index=MeshIndex::union(content,mesh_data,physics_data,size,part_texture_description.clone());
+						let mesh_id=mesh_deferred_loader.acquire_mesh_id(mesh_index);
+						(MeshAvailability::DeferredUnion(part_texture_description),mesh_id)
+					},
 				};
 				let model_deferred_attributes=ModelDeferredAttributes{
 					mesh:mesh_id,
 					transform:model_transform,
 					color:glam::vec4(color3.r as f32/255f32, color3.g as f32/255f32, color3.b as f32/255f32, 1.0-*transparency),
 					deferred_attributes:GetAttributesArgs{
-						name:object.name.as_str().into(),
+						name:object.name.as_str(),
 						can_collide:*can_collide,
 						velocity:vec3::try_from_f32_array([velocity.x,velocity.y,velocity.z]).unwrap(),
 					},
 				};
 				match availability{
 					MeshAvailability::Immediate=>primitive_models_deferred_attributes.push(model_deferred_attributes),
-					MeshAvailability::Deferred(render)=>deferred_models_deferred_attributes.push(DeferredModelDeferredAttributes{
+					MeshAvailability::DeferredMesh(render)=>deferred_models_deferred_attributes.push(DeferredModelDeferredAttributes{
 						render,
 						model:model_deferred_attributes
 					}),
+					MeshAvailability::DeferredUnion(part_texture_description)=>deferred_unions_deferred_attributes.push(DeferredUnionDeferredAttributes{
+						render:part_texture_description,
+						model:model_deferred_attributes,
+					}),
 				}
 			}
 		}
@@ -720,18 +811,68 @@ pub fn convert<'a>(
 		primitive_meshes,
 		primitive_models_deferred_attributes,
 		deferred_models_deferred_attributes,
+		deferred_unions_deferred_attributes,
 	}
 }
 struct MeshWithAabb{
 	mesh:model::Mesh,
-	aabb:strafesnet_common::aabb::Aabb,
+	aabb:Aabb,
 }
-pub struct PartialMap1{
+fn acquire_mesh_id_from_render_config_id<'a>(
+	primitive_meshes:&mut Vec<model::Mesh>,
+	mesh_id_from_render_config_id:&mut HashMap<model::MeshId,HashMap<RenderConfigId,model::MeshId>>,
+	loaded_meshes:&'a HashMap<model::MeshId,MeshWithAabb>,
+	old_mesh_id:model::MeshId,
+	render:RenderConfigId,
+)->Option<(model::MeshId,&'a Aabb)>{
+	//ignore meshes that fail to load completely for now
+	loaded_meshes.get(&old_mesh_id).map(|mesh_with_aabb|(
+		*mesh_id_from_render_config_id.entry(old_mesh_id).or_insert_with(||HashMap::new())
+		.entry(render).or_insert_with(||{
+			let mesh_id=model::MeshId::new(primitive_meshes.len() as u32);
+			let mut mesh_clone=mesh_with_aabb.mesh.clone();
+			//set the render group lool
+			if let Some(graphics_group)=mesh_clone.graphics_groups.first_mut(){
+				graphics_group.render=render;
+			}
+			primitive_meshes.push(mesh_clone);
+			mesh_id
+		}),
+		&mesh_with_aabb.aabb,
+	))
+}
+fn acquire_union_id_from_render_config_id<'a>(
+	primitive_meshes:&mut Vec<model::Mesh>,
+	union_id_from_render_config_id:&mut HashMap<model::MeshId,HashMap<RobloxPartDescription,model::MeshId>>,
+	loaded_meshes:&'a HashMap<model::MeshId,MeshWithAabb>,
+	old_union_id:model::MeshId,
+	part_texture_description:RobloxPartDescription,
+)->Option<(model::MeshId,&'a Aabb)>{
+	//ignore uniones that fail to load completely for now
+	loaded_meshes.get(&old_union_id).map(|union_with_aabb|(
+		*union_id_from_render_config_id.entry(old_union_id).or_insert_with(||HashMap::new())
+		.entry(part_texture_description.clone()).or_insert_with(||{
+			let union_id=model::MeshId::new(primitive_meshes.len() as u32);
+			let mut union_clone=union_with_aabb.mesh.clone();
+			//set the render groups
+			for (graphics_group,maybe_face_texture_description) in union_clone.graphics_groups.iter_mut().zip(part_texture_description){
+				if let Some(face_texture_description)=maybe_face_texture_description{
+					graphics_group.render=face_texture_description.render;
+				}
+			}
+			primitive_meshes.push(union_clone);
+			union_id
+		}),
+		&union_with_aabb.aabb,
+	))
+}
+pub struct PartialMap1<'a>{
 	primitive_meshes:Vec<model::Mesh>,
-	primitive_models_deferred_attributes:Vec<ModelDeferredAttributes>,
-	deferred_models_deferred_attributes:Vec<DeferredModelDeferredAttributes>,
+	primitive_models_deferred_attributes:Vec<ModelDeferredAttributes<'a>>,
+	deferred_models_deferred_attributes:Vec<DeferredModelDeferredAttributes<'a>>,
+	deferred_unions_deferred_attributes:Vec<DeferredUnionDeferredAttributes<'a>>,
 }
-impl PartialMap1{
+impl PartialMap1<'_>{
 	pub fn add_meshpart_meshes_and_calculate_attributes(
 		mut self,
 		meshpart_meshes:Meshes,
@@ -758,32 +899,21 @@ impl PartialMap1{
 			})
 		}).collect();
 
+		// SAFETY: I have no idea what I'm doing and this is definitely unsound in some subtle way
+		// I just want to chain iterators together man
+		let aint_no_way=core::cell::UnsafeCell::new(&mut self.primitive_meshes);
+
 		let mut mesh_id_from_render_config_id=HashMap::new();
-		//ignore meshes that fail to load completely for now
-		let mut acquire_mesh_id_from_render_config_id=|old_mesh_id,render|{
-			loaded_meshes.get(&old_mesh_id).map(|mesh_with_aabb|(
-				*mesh_id_from_render_config_id.entry(old_mesh_id).or_insert_with(||HashMap::new())
-				.entry(render).or_insert_with(||{
-					let mesh_id=model::MeshId::new(self.primitive_meshes.len() as u32);
-					let mut mesh_clone=mesh_with_aabb.mesh.clone();
-					//add a render group lool
-					mesh_clone.graphics_groups.push(model::IndexedGraphicsGroup{
-						render,
-						//the lowest lod is highest quality
-						groups:vec![model::PolygonGroupId::new(0)]
-					});
-					self.primitive_meshes.push(mesh_clone);
-					mesh_id
-				}),
-				&mesh_with_aabb.aabb,
-			))
-		};
+		let mut union_id_from_render_config_id=HashMap::new();
 		//now that the meshes are loaded, these models can be generated
 		let models_owned_attributes:Vec<ModelOwnedAttributes>=
 		self.deferred_models_deferred_attributes.into_iter().flat_map(|deferred_model_deferred_attributes|{
 			//meshes need to be cloned from loaded_meshes with a new id when they are used with a new render_id
 			//insert into primitive_meshes
 			let (mesh,aabb)=acquire_mesh_id_from_render_config_id(
+				unsafe{*aint_no_way.get()},
+				&mut mesh_id_from_render_config_id,
+				&loaded_meshes,
 				deferred_model_deferred_attributes.model.mesh,
 				deferred_model_deferred_attributes.render
 			)?;
@@ -801,7 +931,32 @@ impl PartialMap1{
 					deferred_model_deferred_attributes.model.transform.translation
 				),
 			})
-		}).chain(self.primitive_models_deferred_attributes.into_iter())
+		}).chain(self.deferred_unions_deferred_attributes.into_iter().flat_map(|deferred_union_deferred_attributes|{
+			//meshes need to be cloned from loaded_meshes with a new id when they are used with a new render_id
+			//insert into primitive_meshes
+			let (mesh,aabb)=acquire_union_id_from_render_config_id(
+				unsafe{*aint_no_way.get()},
+				&mut union_id_from_render_config_id,
+				&loaded_meshes,
+				deferred_union_deferred_attributes.model.mesh,
+				deferred_union_deferred_attributes.render
+			)?;
+			let size=aabb.size();
+			Some(ModelDeferredAttributes{
+				mesh,
+				deferred_attributes:deferred_union_deferred_attributes.model.deferred_attributes,
+				color:deferred_union_deferred_attributes.model.color,
+				transform:Planar64Affine3::new(
+					Planar64Mat3::from_cols([
+						(deferred_union_deferred_attributes.model.transform.matrix3.x_axis*2/size.x).divide().fix_1(),
+						(deferred_union_deferred_attributes.model.transform.matrix3.y_axis*2/size.y).divide().fix_1(),
+						(deferred_union_deferred_attributes.model.transform.matrix3.z_axis*2/size.z).divide().fix_1()
+					]),
+					deferred_union_deferred_attributes.model.transform.translation
+				),
+			})
+		}))
+		.chain(self.primitive_models_deferred_attributes.into_iter())
 		.enumerate().map(|(model_id,model_deferred_attributes)|{
 			let model_id=model::ModelId::new(model_id as u32);
 			ModelOwnedAttributes{
diff --git a/lib/rbx_loader/src/union.rs b/lib/rbx_loader/src/union.rs
new file mode 100644
index 0000000..7c9bb09
--- /dev/null
+++ b/lib/rbx_loader/src/union.rs
@@ -0,0 +1,177 @@
+use rbx_mesh::mesh_data::NormalId2 as MeshDataNormalId2;
+use strafesnet_common::model::{self,IndexedVertex,PolygonGroup,PolygonGroupId,PolygonList,RenderConfigId};
+use strafesnet_common::integer::vec3;
+
+#[allow(dead_code)]
+#[derive(Debug)]
+pub enum Error{
+	Block,
+	MissingVertexId(u32),
+	Planar64Vec3(strafesnet_common::integer::Planar64TryFromFloatError),
+	RobloxPhysicsData(rbx_mesh::physics_data::Error),
+	RobloxMeshData(rbx_mesh::mesh_data::Error),
+}
+impl std::fmt::Display for Error{
+	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
+		write!(f,"{self:?}")
+	}
+}
+
+// wacky state machine to make sure all vertices in a face agree upon what NormalId to use.
+// Roblox duplicates this information per vertex when it should only exist per-face.
+enum MeshDataNormalStatus{
+	Agree(MeshDataNormalId2),
+	Conflicting,
+}
+struct MeshDataNormalChecker{
+	status:Option<MeshDataNormalStatus>,
+}
+impl MeshDataNormalChecker{
+	fn new()->Self{
+		Self{status:None}
+	}
+	fn check(&mut self,normal:MeshDataNormalId2){
+		self.status=match self.status.take(){
+			None=>Some(MeshDataNormalStatus::Agree(normal)),
+			Some(MeshDataNormalStatus::Agree(old_normal))=>{
+				if old_normal==normal{
+					Some(MeshDataNormalStatus::Agree(old_normal))
+				}else{
+					Some(MeshDataNormalStatus::Conflicting)
+				}
+			},
+			Some(MeshDataNormalStatus::Conflicting)=>Some(MeshDataNormalStatus::Conflicting),
+		};
+	}
+	fn into_agreed_normal(self)->Option<MeshDataNormalId2>{
+		self.status.and_then(|status|match status{
+			MeshDataNormalStatus::Agree(normal)=>Some(normal),
+			MeshDataNormalStatus::Conflicting=>None,
+		})
+	}
+}
+
+impl std::error::Error for Error{}
+pub fn convert(
+	roblox_physics_data:&[u8],
+	roblox_mesh_data:&[u8],
+	size:glam::Vec3,
+	part_texture_description:crate::rbx::RobloxPartDescription,
+)->Result<model::Mesh,Error>{
+	const NORMAL_FACES:usize=6;
+	let mut polygon_groups_normal_id=vec![Vec::new();NORMAL_FACES];
+
+	// build graphics and physics meshes
+	let mut mb=strafesnet_common::model::MeshBuilder::new();
+	// graphics
+	let graphics_groups=if !roblox_mesh_data.is_empty(){
+		// create per-face texture coordinate affine transforms
+		let cube_face_description=part_texture_description.map(|opt|opt.map(|mut t|{
+			t.transform.set_size(1.0,1.0);
+			t.to_face_description()
+		}));
+
+		let mesh_data=rbx_mesh::read_mesh_data_versioned(
+			std::io::Cursor::new(roblox_mesh_data)
+		).map_err(Error::RobloxMeshData)?;
+		let graphics_mesh=match mesh_data{
+			rbx_mesh::mesh_data::MeshData::CSGK(_)=>return Err(Error::Block),
+			rbx_mesh::mesh_data::MeshData::CSGMDL(rbx_mesh::mesh_data::CSGMDL::CSGMDL2(mesh_data2))=>mesh_data2.mesh,
+			rbx_mesh::mesh_data::MeshData::CSGMDL(rbx_mesh::mesh_data::CSGMDL::CSGMDL4(mesh_data4))=>mesh_data4.mesh,
+		};
+		for [vertex_id0,vertex_id1,vertex_id2] in graphics_mesh.faces{
+			let face=[
+				graphics_mesh.vertices.get(vertex_id0.0 as usize).ok_or(Error::MissingVertexId(vertex_id0.0))?,
+				graphics_mesh.vertices.get(vertex_id1.0 as usize).ok_or(Error::MissingVertexId(vertex_id1.0))?,
+				graphics_mesh.vertices.get(vertex_id2.0 as usize).ok_or(Error::MissingVertexId(vertex_id2.0))?,
+			];
+			let mut normal_agreement_checker=MeshDataNormalChecker::new();
+			let face=face.into_iter().map(|vertex|{
+				normal_agreement_checker.check(vertex.normal_id);
+				let pos=mb.acquire_pos_id(vec3::try_from_f32_array(vertex.pos)?);
+				let normal=mb.acquire_normal_id(vec3::try_from_f32_array(vertex.norm)?);
+				let tex_coord=glam::Vec2::from_array(vertex.tex);
+				let maybe_face_description=&cube_face_description[vertex.normal_id as usize-1];
+				let (tex,color)=match maybe_face_description{
+					Some(face_description)=>{
+						// transform texture coordinates and set decal color
+						let tex=mb.acquire_tex_id(face_description.transform.transform_point2(tex_coord));
+						let color=mb.acquire_color_id(face_description.color);
+						(tex,color)
+					},
+					None=>{
+						// texture coordinates don't matter and pass through mesh vertex color
+						let tex=mb.acquire_tex_id(tex_coord);
+						let color=mb.acquire_color_id(glam::Vec4::from_array(vertex.color.map(|f|f as f32/255.0f32)));
+						(tex,color)
+					},
+				};
+				Ok(mb.acquire_vertex_id(IndexedVertex{pos,tex,normal,color}))
+			}).collect::<Result<Vec<_>,_>>().map_err(Error::Planar64Vec3)?;
+			if let Some(normal_id)=normal_agreement_checker.into_agreed_normal(){
+				polygon_groups_normal_id[normal_id as usize-1].push(face);
+			}else{
+				panic!("Empty face!");
+			}
+		}
+		(0..NORMAL_FACES).map(|polygon_group_id|{
+			model::IndexedGraphicsGroup{
+				render:cube_face_description[polygon_group_id].as_ref().map_or(RenderConfigId::new(0),|face_description|face_description.render),
+				groups:vec![PolygonGroupId::new(polygon_group_id as u32)]
+			}
+		}).collect()
+	}else{
+		Vec::new()
+	};
+
+	//physics
+	let physics_convex_meshes=if !roblox_physics_data.is_empty(){
+		let physics_data=rbx_mesh::read_physics_data_versioned(
+			std::io::Cursor::new(roblox_physics_data)
+		).map_err(Error::RobloxPhysicsData)?;
+		let physics_convex_meshes=match physics_data{
+			rbx_mesh::physics_data::PhysicsData::CSGK(_)
+			// have not seen this format in practice
+			|rbx_mesh::physics_data::PhysicsData::CSGPHS(rbx_mesh::physics_data::CSGPHS::Block)
+			=>return Err(Error::Block),
+			rbx_mesh::physics_data::PhysicsData::CSGPHS(rbx_mesh::physics_data::CSGPHS::Meshes(meshes))
+			=>meshes.meshes,
+			rbx_mesh::physics_data::PhysicsData::CSGPHS(rbx_mesh::physics_data::CSGPHS::PhysicsInfoMesh(pim))
+			=>vec![pim.mesh],
+		};
+		physics_convex_meshes
+	}else{
+		Vec::new()
+	};
+	let polygon_groups:Vec<PolygonGroup>=polygon_groups_normal_id.into_iter().map(|faces|
+		// graphics polygon groups (to be rendered)
+		Ok(PolygonGroup::PolygonList(PolygonList::new(faces)))
+	).chain(physics_convex_meshes.into_iter().map(|mesh|{
+		// this can be factored out of the loop but I am lazy
+		let color=mb.acquire_color_id(glam::Vec4::ONE);
+		let tex=mb.acquire_tex_id(glam::Vec2::ZERO);
+		// physics polygon groups (to do physics)
+		Ok(PolygonGroup::PolygonList(PolygonList::new(mesh.faces.into_iter().map(|[vertex_id0,vertex_id1,vertex_id2]|{
+			let face=[
+				mesh.vertices.get(vertex_id0.0 as usize).ok_or(Error::MissingVertexId(vertex_id0.0))?,
+				mesh.vertices.get(vertex_id1.0 as usize).ok_or(Error::MissingVertexId(vertex_id1.0))?,
+				mesh.vertices.get(vertex_id2.0 as usize).ok_or(Error::MissingVertexId(vertex_id2.0))?,
+			].map(|v|glam::Vec3::from_slice(v)/size);
+			let vertex_norm=(face[1]-face[0])
+			          .cross(face[2]-face[0]);
+			let normal=mb.acquire_normal_id(vec3::try_from_f32_array(vertex_norm.to_array()).map_err(Error::Planar64Vec3)?);
+			face.into_iter().map(|vertex_pos|{
+				let pos=mb.acquire_pos_id(vec3::try_from_f32_array(vertex_pos.to_array()).map_err(Error::Planar64Vec3)?);
+				Ok(mb.acquire_vertex_id(IndexedVertex{pos,tex,normal,color}))
+			}).collect()
+		}).collect::<Result<_,_>>()?)))
+	})).collect::<Result<_,_>>()?;
+	let physics_groups=(NORMAL_FACES..polygon_groups.len()).map(|id|model::IndexedPhysicsGroup{
+		groups:vec![PolygonGroupId::new(id as u32)]
+	}).collect();
+	Ok(mb.build(
+		polygon_groups,
+		graphics_groups,
+		physics_groups,
+	))
+}
diff --git a/strafe-client/src/file.rs b/strafe-client/src/file.rs
index 6bec746..d27d70a 100644
--- a/strafe-client/src/file.rs
+++ b/strafe-client/src/file.rs
@@ -1,5 +1,8 @@
 use std::io::Read;
 
+#[cfg(any(feature="roblox",feature="source"))]
+use strafesnet_deferred_loader::deferred_loader::LoadFailureMode;
+
 #[allow(dead_code)]
 #[derive(Debug)]
 pub enum ReadError{
@@ -102,7 +105,7 @@ pub fn load<P:AsRef<std::path::Path>>(path:P)->Result<LoadFormat,LoadError>{
 		},
 		#[cfg(feature="source")]
 		ReadFormat::Source(bsp)=>Ok(LoadFormat::Map(
-			bsp.to_snf(LoadFailureMode::DefaultToNone).map_err(LoadError::LoadSource)?
+			bsp.to_snf(LoadFailureMode::DefaultToNone,&[]).map_err(LoadError::LoadSource)?
 		)),
 	}
 }