diff --git a/.gitignore b/.gitignore
index 86c641b..a29a7c6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,4 +19,8 @@ Cargo.lock
 *.ttf
 *.otf
 
+# Ignore PAK files
+*.PAK
+*.pak
+
 test_files/*
diff --git a/Cargo.toml b/Cargo.toml
index 235240f..8c4d4ca 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,6 +2,7 @@
 resolver = "2"
 members = [
     "cz",
+    "luca_pak",
     "utils",
 ]
 
diff --git a/cz/src/dynamic.rs b/cz/src/dynamic.rs
index 07ac7fd..138f7ac 100644
--- a/cz/src/dynamic.rs
+++ b/cz/src/dynamic.rs
@@ -34,7 +34,7 @@ impl DynamicCz {
     /// The input must begin with the
     /// [magic bytes](https://en.wikipedia.org/wiki/File_format#Magic_number)
     /// of the file
-    fn decode<T: Seek + ReadBytesExt + Read>(input: &mut T) -> Result<Self, CzError> {
+    pub fn decode<T: Seek + ReadBytesExt + Read>(input: &mut T) -> Result<Self, CzError> {
         // Get the header common to all CZ images
         let header_common = CommonHeader::from_bytes(input)?;
         let mut header_extended = None;
diff --git a/luca_pak/Cargo.toml b/luca_pak/Cargo.toml
new file mode 100644
index 0000000..357f375
--- /dev/null
+++ b/luca_pak/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "luca_pak"
+description = """
+A crate for parsing and modifying PAK files from the LUCA System engine by
+Prototype Ltd.
+"""
+version = "0.1.0"
+edition = "2021"
+authors.workspace = true
+
+[dependencies]
+byteorder = "1.5.0"
+thiserror = "1.0.61"
+
+[lints]
+workspace = true
diff --git a/luca_pak/src/entry.rs b/luca_pak/src/entry.rs
new file mode 100644
index 0000000..de5d6b0
--- /dev/null
+++ b/luca_pak/src/entry.rs
@@ -0,0 +1,42 @@
+use std::{error::Error, fs::File, io::{BufWriter, Write}, path::Path};
+
+/// A single file entry in a PAK file
+#[derive(Debug, Clone)]
+pub struct Entry {
+    pub(super) offset: u32,
+    pub(super) length: u32,
+    pub(super) data: Vec<u8>,
+    pub(super) name: String,
+    pub(super) id: u8,
+    pub(super) replace: bool, // TODO: Look into a better way to indicate this
+}
+
+impl Entry {
+    /// Get the name of the [`Entry`]
+    pub fn name(&self) -> &String {
+        &self.name
+    }
+
+    /// Save an [`Entry`] as its underlying data to a file
+    pub fn save<P: ?Sized + AsRef<Path>>(&self, path: &P) -> Result<(), Box<dyn Error>> {
+        let mut path = path.as_ref().to_path_buf();
+        if !path.is_dir() {
+            return Err("Path must be a directory".into());
+        }
+
+        // Save the file to <folder> + <file name>
+        path.push(&self.name);
+
+        let mut out_file = BufWriter::new(File::create(path)?);
+
+        out_file.write_all(&self.data)?;
+        out_file.flush()?;
+
+        Ok(())
+    }
+
+    /// Get the raw byte data of an [`Entry`]
+    pub fn as_bytes(&self) -> &Vec<u8> {
+        &self.data
+    }
+}
diff --git a/luca_pak/src/lib.rs b/luca_pak/src/lib.rs
new file mode 100644
index 0000000..1d68a2f
--- /dev/null
+++ b/luca_pak/src/lib.rs
@@ -0,0 +1,167 @@
+mod entry;
+
+use std::{fs::File, io::{self, BufRead, BufReader, Read, Seek, SeekFrom}, path::Path};
+use byteorder::{LittleEndian, ReadBytesExt};
+use thiserror::Error;
+
+use crate::entry::Entry;
+
+/// A full PAK file with a header and its contents
+#[derive(Debug, Clone)]
+pub struct Pak {
+    header: Header,
+    files: Vec<Entry>,
+
+    file_name: String,
+    rebuild: bool, // TODO: Look into a better way to indicate this
+}
+
+/// The header of a PAK file
+#[derive(Debug, Clone)]
+struct Header {
+    data_offset: u32,
+    file_count: u32,
+    id_start: u32,
+    block_size: u32,
+
+    unknown1: u32,
+    unknown2: u32,
+    unknown3: u32,
+    unknown4: u32,
+
+    flags: u32,
+}
+
+impl Header {
+    pub fn block_size(&self) -> u32 {
+        self.block_size
+    }
+
+    pub fn file_count(&self) -> u32 {
+        self.file_count
+    }
+
+    pub fn data_offset(&self) -> u32 {
+        self.data_offset
+    }
+}
+
+#[derive(Error, Debug)]
+pub enum PakError {
+    #[error("Could not read/write file")]
+    IoError(#[from] io::Error),
+
+    #[error("Expected {} files, got {} in {}", 0, 1, 2)]
+    FileCountMismatch(usize, usize, &'static str),
+
+    #[error("Malformed header information")]
+    HeaderError,
+}
+
+type LE = LittleEndian;
+
+impl Pak {
+    pub fn open<P: ?Sized + AsRef<Path>>(path: &P) -> Result<Self, PakError> {
+        let mut file = File::open(path)?;
+
+        let filename = path.as_ref().file_name().unwrap().to_string_lossy().to_string();
+
+        Pak::decode(&mut file, filename)
+    }
+
+    pub fn decode<T: Seek + ReadBytesExt + Read>(input: &mut T, file_name: String) -> Result<Self, PakError> {
+        let mut input = BufReader::new(input);
+
+        // Read in all the header bytes
+        let header = Header {
+            data_offset: input.read_u32::<LE>().unwrap(),
+            file_count: input.read_u32::<LE>().unwrap(),
+            id_start: input.read_u32::<LE>().unwrap(),
+            block_size: input.read_u32::<LE>().unwrap(),
+            unknown1: input.read_u32::<LE>().unwrap(),
+            unknown2: input.read_u32::<LE>().unwrap(),
+            unknown3: input.read_u32::<LE>().unwrap(),
+            unknown4: input.read_u32::<LE>().unwrap(),
+            flags: input.read_u32::<LE>().unwrap(),
+        };
+        dbg!(&header);
+
+        let first_offset = header.data_offset() / header.block_size();
+
+        // Seek to the end of the header
+        input.seek(io::SeekFrom::Start(0x24))?;
+        while input.stream_position()? < header.data_offset() as u64 {
+            if input.read_u32::<LE>().unwrap() == first_offset {
+                input.seek_relative(-4)?;
+                break;
+            }
+        }
+
+        if input.stream_position()? == header.data_offset() as u64 {
+            return Err(PakError::HeaderError)
+        }
+
+        // Read all the offsets and lengths
+        let mut offsets = Vec::new();
+        for _ in 0..header.file_count() {
+            let offset = input.read_u32::<LE>().unwrap();
+            let length = input.read_u32::<LE>().unwrap();
+
+            dbg!(offset);
+            dbg!(length);
+
+            offsets.push((offset, length));
+        }
+
+        // Read all the file names
+        let mut file_names = Vec::new();
+        let mut buf = Vec::new();
+        for _ in 0..header.file_count() {
+            buf.clear();
+            input.read_until(0x00, &mut buf)?;
+            buf.pop();
+
+            let strbuf = String::from_utf8(buf.clone()).unwrap();
+            file_names.push(strbuf.clone());
+        }
+        dbg!(&file_names);
+
+        let mut entries: Vec<Entry> = Vec::new();
+        for i in 0..header.file_count() as usize {
+            dbg!(i);
+
+            // Seek to and read the entry data
+            input.seek(SeekFrom::Start(offsets[i].0 as u64 * header.block_size() as u64)).unwrap();
+            let mut data = vec![0u8; offsets[i].1 as usize];
+            input.read_exact(&mut data).unwrap();
+
+            // Build the entry from the data we know
+            let entry = Entry {
+                offset: offsets[i].0,
+                length: offsets[i].1,
+                data,
+                name: file_names[i].clone(),
+                id: 0,
+                replace: false,
+            };
+            entries.push(entry);
+        }
+
+        println!("Got entries for {} files", entries.len());
+
+        Ok(Pak {
+            header,
+            files: entries,
+            file_name,
+            rebuild: false,
+        })
+    }
+
+    pub fn get_file(&self, index: u32) -> Option<&Entry> {
+        self.files.get(index as usize)
+    }
+
+    pub fn files(&self) -> &Vec<Entry> {
+        &self.files
+    }
+}
diff --git a/luca_pak/src/main.rs b/luca_pak/src/main.rs
new file mode 100644
index 0000000..38d261d
--- /dev/null
+++ b/luca_pak/src/main.rs
@@ -0,0 +1,11 @@
+use luca_pak::Pak;
+
+fn main() {
+    let pak = Pak::open("PARAM.PAK").unwrap();
+
+    let file = pak.get_file(0).unwrap();
+
+    dbg!(pak.files());
+
+    file.save("test").unwrap();
+}