diff --git a/assets/common/recipe_book.ron b/assets/common/recipe_book.ron
index cb49dea6fd..68b4898466 100644
--- a/assets/common/recipe_book.ron
+++ b/assets/common/recipe_book.ron
@@ -1927,15 +1927,15 @@
         craft_sprite: Some(Anvil),
         is_recycling: false,
     ),
-    "linen": (
-        output: ("common.items.crafting_ing.cloth.linen", 1),
-        inputs: [
-            (Tag(Material(Linen)), 1),
-            (Item("common.items.crafting_tools.sewing_set"), 0),
-        ],
-        craft_sprite: None,
-        is_recycling: true,
-    ),
+    // "linen": (
+    //     output: ("common.items.crafting_ing.cloth.linen", 1),
+    //     inputs: [
+    //         (Tag(Material(Linen)), 1),
+    //         (Item("common.items.crafting_tools.sewing_set"), 0),
+    //     ],
+    //     craft_sprite: None,
+    //     is_recycling: true,
+    // ),
     "wool": (
         output: ("common.items.crafting_ing.cloth.wool", 1),
         inputs: [
diff --git a/client/src/lib.rs b/client/src/lib.rs
index bdbcf94da0..7ddc91b0a6 100644
--- a/client/src/lib.rs
+++ b/client/src/lib.rs
@@ -23,6 +23,7 @@ use common::{
     comp::{
         self,
         chat::{KillSource, KillType},
+        controller::CraftEvent,
         group,
         invite::{InviteKind, InviteResponse},
         skills::Skill,
@@ -1004,7 +1005,7 @@ impl Client {
         if can_craft && has_sprite {
             self.send_msg(ClientGeneral::ControlEvent(ControlEvent::InventoryEvent(
                 InventoryEvent::CraftRecipe {
-                    recipe: recipe.to_string(),
+                    craft_event: CraftEvent::Simple(recipe.to_string()),
                     craft_sprite: craft_sprite.map(|(pos, _)| pos),
                 },
             )));
diff --git a/common/src/comp/controller.rs b/common/src/comp/controller.rs
index a3b9ca9076..61e2af20ab 100644
--- a/common/src/comp/controller.rs
+++ b/common/src/comp/controller.rs
@@ -23,7 +23,7 @@ pub enum InventoryEvent {
     SplitDrop(InvSlotId),
     Sort,
     CraftRecipe {
-        recipe: String,
+        craft_event: CraftEvent,
         craft_sprite: Option<Vec3<i32>>,
     },
 }
@@ -48,7 +48,7 @@ pub enum InventoryManip {
     SplitDrop(Slot),
     Sort,
     CraftRecipe {
-        recipe: String,
+        craft_event: CraftEvent,
         craft_sprite: Option<Vec3<i32>>,
     },
     SwapEquippedWeapons,
@@ -80,16 +80,22 @@ impl From<InventoryEvent> for InventoryManip {
             InventoryEvent::SplitDrop(inv) => Self::SplitDrop(Slot::Inventory(inv)),
             InventoryEvent::Sort => Self::Sort,
             InventoryEvent::CraftRecipe {
-                recipe,
+                craft_event,
                 craft_sprite,
             } => Self::CraftRecipe {
-                recipe,
+                craft_event,
                 craft_sprite,
             },
         }
     }
 }
 
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum CraftEvent {
+    Simple(String),
+    Salvage(InvSlotId),
+}
+
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
 pub enum GroupManip {
     Leave,
diff --git a/common/src/comp/inventory/item/mod.rs b/common/src/comp/inventory/item/mod.rs
index 2eec60db2e..53d3adde18 100644
--- a/common/src/comp/inventory/item/mod.rs
+++ b/common/src/comp/inventory/item/mod.rs
@@ -170,6 +170,41 @@ impl Material {
             | Material::Dragonscale => MaterialKind::Hide,
         }
     }
+
+    pub fn asset_identifier(&self) -> &'static str {
+        match self {
+            Material::Bronze => "common.items.mineral.ingot.bronze",
+            Material::Iron => "common.items.mineral.ingot.iron",
+            Material::Steel => "common.items.mineral.ingot.steel",
+            Material::Cobalt => "common.items.mineral.ingot.cobalt",
+            Material::Bloodsteel => "common.items.mineral.ingot.bloodsteel",
+            Material::Orichalcum => "common.items.mineral.ingot.orichalcum",
+            Material::Wood
+            | Material::Bamboo
+            | Material::Hardwood
+            | Material::Ironwood
+            | Material::Frostwood
+            | Material::Eldwood => unreachable!(),
+            Material::Rock
+            | Material::Granite
+            | Material::Bone
+            | Material::Basalt
+            | Material::Obsidian
+            | Material::Velorite => unreachable!(),
+            Material::Linen => "common.items.crafting_ing.cloth.linen",
+            Material::Wool => "common.items.crafting_ing.cloth.wool",
+            Material::Silk => "common.items.crafting_ing.cloth.silk",
+            Material::Lifecloth => "common.items.crafting_ing.cloth.lifecloth",
+            Material::Moonweave => "common.items.crafting_ing.cloth.moonweave",
+            Material::Sunsilk => "common.items.crafting_ing.cloth.sunsilk",
+            Material::Rawhide => "common.items.crafting_ing.leather.simple_leather",
+            Material::Leather => "common.items.crafting_ing.leather.thick_leather",
+            Material::Scale => "common.items.crafting_ing.leather.scales",
+            Material::Carapace => "common.items.crafting_ing.leather.carapace",
+            Material::Plate => "common.items.crafting_ing.leather.plate",
+            Material::Dragonscale => "common.items.crafting_ing.leather.dragon_scale",
+        }
+    }
 }
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -202,6 +237,7 @@ pub enum ItemTag {
     CraftingTool, // Pickaxe, Craftsman-Hammer, Sewing-Set
     Utility,
     Bag,
+    SalvageInto(Material),
 }
 
 impl TagExampleInfo for ItemTag {
@@ -219,6 +255,7 @@ impl TagExampleInfo for ItemTag {
             ItemTag::CraftingTool => "tool",
             ItemTag::Utility => "utility",
             ItemTag::Bag => "bag",
+            ItemTag::SalvageInto(_) => "salvage",
         }
     }
 
@@ -237,6 +274,7 @@ impl TagExampleInfo for ItemTag {
             ItemTag::CraftingTool => "common.items.tag_examples.placeholder",
             ItemTag::Utility => "common.items.tag_examples.placeholder",
             ItemTag::Bag => "common.items.tag_examples.placeholder",
+            ItemTag::SalvageInto(_) => "common.items.tag_examples.placeholder",
         }
     }
 }
@@ -759,6 +797,43 @@ impl Item {
         }
     }
 
+    pub fn is_salvageable(&self) -> bool {
+        self.item_def
+            .tags
+            .iter()
+            .any(|tag| matches!(tag, ItemTag::SalvageInto(_)))
+    }
+
+    // Attempts to salvage an item, returning the salvaged items if salvageable,
+    // else the original item is not Theoretically supports returning multiple
+    // items, only returns one per tag in the item for now
+    pub fn try_salvage(self) -> Result<Vec<Item>, Item> {
+        if !self.is_salvageable() {
+            return Err(self);
+        }
+
+        let salvaged_items: Vec<_> = self
+            .item_def
+            .tags
+            .iter()
+            .filter_map(|tag| {
+                if let ItemTag::SalvageInto(material) = tag {
+                    Some(material)
+                } else {
+                    None
+                }
+            })
+            .map(|material| material.asset_identifier())
+            .map(|asset| Item::new_from_asset_expect(asset))
+            .collect();
+
+        if salvaged_items.is_empty() {
+            Err(self)
+        } else {
+            Ok(salvaged_items)
+        }
+    }
+
     pub fn name(&self) -> &str { &self.item_def.name }
 
     pub fn description(&self) -> &str { &self.item_def.description }
diff --git a/common/src/comp/mod.rs b/common/src/comp/mod.rs
index 677b5c17e2..4d6411722c 100644
--- a/common/src/comp/mod.rs
+++ b/common/src/comp/mod.rs
@@ -13,7 +13,7 @@ pub mod character_state;
 #[cfg(not(target_arch = "wasm32"))] pub mod combo;
 pub mod compass;
 #[cfg(not(target_arch = "wasm32"))]
-mod controller;
+pub mod controller;
 #[cfg(not(target_arch = "wasm32"))]
 pub mod dialogue;
 #[cfg(not(target_arch = "wasm32"))] mod energy;
diff --git a/common/src/recipe.rs b/common/src/recipe.rs
index 8130e52ea0..821272b28d 100644
--- a/common/src/recipe.rs
+++ b/common/src/recipe.rs
@@ -1,6 +1,7 @@
 use crate::{
     assets::{self, AssetExt, AssetHandle},
     comp::{
+        inventory::slot::InvSlotId,
         item::{modular, tool::AbilityMap, ItemDef, ItemTag, MaterialStatManifest},
         Inventory, Item,
     },
@@ -32,7 +33,7 @@ impl Recipe {
         inv: &mut Inventory,
         ability_map: &AbilityMap,
         msm: &MaterialStatManifest,
-    ) -> Result<Option<(Item, u32)>, Vec<(&RecipeInput, u32)>> {
+    ) -> Result<(Item, u32), Vec<(&RecipeInput, u32)>> {
         // Get ingredient cells from inventory,
         let mut components = Vec::new();
 
@@ -47,15 +48,18 @@ impl Recipe {
                 })
             });
 
-        for i in 0..self.output.1 {
-            let crafted_item =
-                Item::new_from_item_def(Arc::clone(&self.output.0), &components, ability_map, msm);
-            if let Err(item) = inv.push(crafted_item) {
-                return Ok(Some((item, self.output.1 - i)));
-            }
-        }
+        let crafted_item =
+            Item::new_from_item_def(Arc::clone(&self.output.0), &components, ability_map, msm);
 
-        Ok(None)
+        Ok((crafted_item, self.output.1))
+
+        // for i in 0..self.output.1 {
+        //     if let Err(item) = inv.push(crafted_item) {
+        //         return Ok(Some((item, self.output.1 - i)));
+        //     }
+        // }
+
+        // Ok(None)
     }
 
     pub fn inputs(&self) -> impl ExactSizeIterator<Item = (&RecipeInput, u32)> {
@@ -65,6 +69,33 @@ impl Recipe {
     }
 }
 
+pub enum SalvageError {
+    NotSalvageable,
+}
+
+pub fn try_salvage(
+    inv: &mut Inventory,
+    slot: InvSlotId,
+    ability_map: &AbilityMap,
+    msm: &MaterialStatManifest,
+) -> Result<Vec<Item>, SalvageError> {
+    if inv.get(slot).map_or(false, |item| item.is_salvageable()) {
+        let salvage_item = inv
+            .take(slot, ability_map, msm)
+            .expect("Expected item to exist in inventory");
+        match salvage_item.try_salvage() {
+            Ok(items) => Ok(items),
+            Err(item) => {
+                inv.push(item)
+                    .expect("Item taken from inventory just before");
+                Err(SalvageError::NotSalvageable)
+            },
+        }
+    } else {
+        Err(SalvageError::NotSalvageable)
+    }
+}
+
 #[derive(Clone, Debug, Serialize, Deserialize)]
 pub struct RecipeBook {
     recipes: HashMap<String, Recipe>,
diff --git a/server/src/events/inventory_manip.rs b/server/src/events/inventory_manip.rs
index fb27e9354f..b2843f6322 100644
--- a/server/src/events/inventory_manip.rs
+++ b/server/src/events/inventory_manip.rs
@@ -11,7 +11,7 @@ use common::{
         slot::{self, Slot},
     },
     consts::MAX_PICKUP_RANGE,
-    recipe::default_recipe_book,
+    recipe::{self, default_recipe_book},
     trade::Trades,
     uid::Uid,
     util::find_dist::{self, FindDist},
@@ -563,53 +563,91 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv
             drop(inventories);
         },
         comp::InventoryManip::CraftRecipe {
-            recipe,
+            craft_event,
             craft_sprite,
         } => {
+            use comp::controller::CraftEvent;
             let recipe_book = default_recipe_book().read();
-            let craft_result = recipe_book
-                .get(&recipe)
-                .filter(|r| {
-                    if let Some(needed_sprite) = r.craft_sprite {
-                        let sprite = craft_sprite
-                            .filter(|pos| {
-                                let entity_cylinder = get_cylinder(state, entity);
-                                if !within_pickup_range(entity_cylinder, || {
-                                    Some(find_dist::Cube {
-                                        min: pos.as_(),
-                                        side_length: 1.0,
-                                    })
-                                }) {
-                                    debug!(
-                                        ?entity_cylinder,
-                                        "Failed to craft recipe as not within range of required \
-                                         sprite, sprite pos: {}",
-                                        pos
-                                    );
-                                    false
-                                } else {
-                                    true
-                                }
-                            })
-                            .and_then(|pos| state.terrain().get(pos).ok().copied())
-                            .and_then(|block| block.get_sprite());
-                        Some(needed_sprite) == sprite
-                    } else {
-                        true
+            let ability_map = &state.ecs().read_resource::<AbilityMap>();
+            let msm = state.ecs().read_resource::<MaterialStatManifest>();
+
+            let crafted_items = match craft_event {
+                CraftEvent::Simple(recipe) => recipe_book
+                    .get(&recipe)
+                    .filter(|r| {
+                        if let Some(needed_sprite) = r.craft_sprite {
+                            let sprite = craft_sprite
+                                .filter(|pos| {
+                                    let entity_cylinder = get_cylinder(state, entity);
+                                    if !within_pickup_range(entity_cylinder, || {
+                                        Some(find_dist::Cube {
+                                            min: pos.as_(),
+                                            side_length: 1.0,
+                                        })
+                                    }) {
+                                        debug!(
+                                            ?entity_cylinder,
+                                            "Failed to craft recipe as not within range of \
+                                             required sprite, sprite pos: {}",
+                                            pos
+                                        );
+                                        false
+                                    } else {
+                                        true
+                                    }
+                                })
+                                .and_then(|pos| state.terrain().get(pos).ok().copied())
+                                .and_then(|block| block.get_sprite());
+                            Some(needed_sprite) == sprite
+                        } else {
+                            true
+                        }
+                    })
+                    .and_then(|r| {
+                        r.perform(
+                            &mut inventory,
+                            &state.ecs().read_resource::<AbilityMap>(),
+                            &state.ecs().read_resource::<item::MaterialStatManifest>(),
+                        )
+                        .ok()
+                    })
+                    .map(|(crafted_item, amount)| {
+                        let mut crafted_items = Vec::with_capacity(amount as usize);
+                        for _ in 0..amount {
+                            crafted_items.push(crafted_item.duplicate(ability_map, &msm));
+                        }
+                        crafted_items
+                    }),
+                CraftEvent::Salvage(slot) => {
+                    recipe::try_salvage(&mut inventory, slot, ability_map, &msm).ok()
+                },
+            };
+
+            // Attempt to insert items into inventory, dropping them if there is not enough
+            // space
+            let items_were_crafted = if let Some(crafted_items) = crafted_items {
+                for item in crafted_items {
+                    if let Err(item) = inventory.push(item) {
+                        dropped_items.push((
+                            state
+                                .read_component_copied::<comp::Pos>(entity)
+                                .unwrap_or_default(),
+                            state
+                                .read_component_copied::<comp::Ori>(entity)
+                                .unwrap_or_default(),
+                            item.duplicate(ability_map, &msm),
+                        ));
                     }
-                })
-                .and_then(|r| {
-                    r.perform(
-                        &mut inventory,
-                        &state.ecs().read_resource::<AbilityMap>(),
-                        &state.ecs().read_resource::<item::MaterialStatManifest>(),
-                    )
-                    .ok()
-                });
+                }
+                true
+            } else {
+                false
+            };
+
             drop(inventories);
 
             // FIXME: We should really require the drop and write to be atomic!
-            if craft_result.is_some() {
+            if items_were_crafted {
                 let _ = state.ecs().write_storage().insert(
                     entity,
                     comp::InventoryUpdate::new(comp::InventoryUpdateEvent::Craft),
@@ -617,21 +655,22 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv
             }
 
             // Drop the item if there wasn't enough space
-            if let Some(Some((item, amount))) = craft_result {
-                let ability_map = &state.ecs().read_resource::<AbilityMap>();
-                let msm = state.ecs().read_resource::<MaterialStatManifest>();
-                for _ in 0..amount {
-                    dropped_items.push((
-                        state
-                            .read_component_copied::<comp::Pos>(entity)
-                            .unwrap_or_default(),
-                        state
-                            .read_component_copied::<comp::Ori>(entity)
-                            .unwrap_or_default(),
-                        item.duplicate(ability_map, &msm),
-                    ));
-                }
-            }
+            // if let Some(Some((item, amount))) = craft_result {
+            //     let ability_map = &state.ecs().read_resource::<AbilityMap>();
+            //     let msm =
+            // state.ecs().read_resource::<MaterialStatManifest>();
+            //     for _ in 0..amount {
+            //         dropped_items.push((
+            //             state
+            //                 .read_component_copied::<comp::Pos>(entity)
+            //                 .unwrap_or_default(),
+            //             state
+            //                 .read_component_copied::<comp::Ori>(entity)
+            //                 .unwrap_or_default(),
+            //             item.duplicate(ability_map, &msm),
+            //         ));
+            //     }
+            // }
         },
         comp::InventoryManip::Sort => {
             inventory.sort();