Skip to content

Commit 026c87d

Browse files
committed
feat: experimental modpkg builder
1 parent 4e71cc5 commit 026c87d

File tree

6 files changed

+452
-52
lines changed

6 files changed

+452
-52
lines changed

crates/league-modpkg/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@ byteorder = "1.5.0"
99
io-ext = { path = "../io-ext" }
1010
serde = { version = "1.0", features = ["derive"] }
1111
rmp-serde = "1.3.0"
12-
twox-hash = { version = "2.0.1", features = ["xxhash64"] }
1312
binrw = "0.14.1"
1413
itertools = "0.14.0"
1514
proptest = "1.6.0"
15+
zstd = "0.13"
16+
17+
[dependencies.xxhash-rust]
18+
version = "0.8.15"
19+
features = ["xxh3", "xxh64"]
1620

1721
[dev-dependencies]
1822
proptest-derive = "0.5.1"
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
use binrw::BinWrite;
2+
use byteorder::{WriteBytesExt, LE};
3+
use io_ext::WriterExt;
4+
use std::borrow::Cow;
5+
use std::collections::HashMap;
6+
use std::io::{self, BufWriter, Cursor, Read, Seek, SeekFrom, Write};
7+
use xxhash_rust::xxh3::xxh3_64;
8+
use xxhash_rust::xxh64::xxh64;
9+
10+
use crate::{chunk::ModpkgChunk, metadata::ModpkgMetadata, Modpkg, ModpkgCompression};
11+
12+
#[derive(Debug, thiserror::Error)]
13+
pub enum ModpkgBuilderError {
14+
#[error("io error")]
15+
IoError(#[from] io::Error),
16+
17+
#[error("binrw error")]
18+
BinWriteError(#[from] binrw::Error),
19+
20+
#[error("unsupported compression type: {0:?}")]
21+
UnsupportedCompressionType(ModpkgCompression),
22+
23+
#[error("missing base layer")]
24+
MissingBaseLayer,
25+
26+
#[error("layer not found: {0}")]
27+
LayerNotFound(String),
28+
}
29+
30+
#[derive(Debug, Clone, Default)]
31+
pub struct ModpkgBuilder<'builder> {
32+
metadata: ModpkgMetadata,
33+
chunks: Vec<ModpkgChunkBuilder<'builder>>,
34+
layers: Vec<ModpkgLayerBuilder>,
35+
}
36+
37+
#[derive(Debug, Clone, Default)]
38+
pub struct ModpkgChunkBuilder<'builder> {
39+
path_hash: u64,
40+
pub path: Cow<'builder, str>,
41+
pub compression: ModpkgCompression,
42+
pub layer: Cow<'builder, str>,
43+
}
44+
45+
#[derive(Debug, Clone, Default)]
46+
pub struct ModpkgLayerBuilder {
47+
pub name: String,
48+
pub priority: i32,
49+
}
50+
51+
impl<'builder> ModpkgBuilder<'builder> {
52+
pub fn with_metadata(mut self, metadata: ModpkgMetadata) -> Self {
53+
self.metadata = metadata;
54+
self
55+
}
56+
57+
pub fn with_layer(mut self, layer: ModpkgLayerBuilder) -> Self {
58+
self.layers.push(layer);
59+
self
60+
}
61+
62+
pub fn with_chunk(mut self, chunk: ModpkgChunkBuilder<'builder>) -> Self {
63+
self.chunks.push(chunk);
64+
self
65+
}
66+
67+
/// Build the Modpkg file and write it to the given writer.
68+
///
69+
/// * `writer` - The writer to write the Modpkg file to.
70+
/// * `provide_chunk_data` - A function that provides the raw data for each chunk.
71+
pub fn build_to_writer<
72+
TWriter: io::Write + io::Seek,
73+
TChunkDataProvider: Fn(&str, &mut Cursor<Vec<u8>>) -> Result<(), ModpkgBuilderError>,
74+
>(
75+
self,
76+
writer: &mut TWriter,
77+
provide_chunk_data: TChunkDataProvider,
78+
) -> Result<(), ModpkgBuilderError> {
79+
let mut writer = BufWriter::new(writer);
80+
81+
// Collect all unique paths and layers
82+
let (chunk_paths, chunk_path_indices) = Self::collect_unique_paths(&self.chunks);
83+
let (layers, _) = Self::collect_unique_layers(&self.chunks);
84+
85+
Self::validate_layers(&self.layers, &layers)?;
86+
87+
// Write the magic header
88+
writer.write_all(b"_modpkg_")?;
89+
90+
// Write version (1)
91+
writer.write_u32::<LE>(1)?;
92+
93+
// Write placeholder for signature size and chunk count
94+
writer.write_u32::<LE>(0)?; // Placeholder for signature size
95+
writer.write_u32::<LE>(self.chunks.len() as u32)?;
96+
97+
// Write signature (empty for now)
98+
let signature = Vec::new();
99+
writer.write_all(&signature)?;
100+
101+
// Write layers
102+
writer.write_u32::<LE>(self.layers.len() as u32)?;
103+
for layer in &self.layers {
104+
writer.write_u32::<LE>(layer.name.len() as u32)?;
105+
writer.write_all(layer.name.as_bytes())?;
106+
writer.write_i32::<LE>(layer.priority)?;
107+
}
108+
109+
// Write chunk paths
110+
writer.write_u32::<LE>(chunk_paths.len() as u32)?;
111+
for path in &chunk_paths {
112+
writer.write_all(path.as_bytes())?;
113+
writer.write_all(&[0])?; // Null terminator
114+
}
115+
116+
// Write metadata
117+
self.metadata.write(&mut writer)?;
118+
119+
// Align to 8 bytes for chunks
120+
let current_pos = writer.stream_position()?;
121+
let padding = (8 - (current_pos % 8)) % 8;
122+
for _ in 0..padding {
123+
writer.write_all(&[0])?;
124+
}
125+
126+
// Write dummy chunk data
127+
let chunk_toc_offset = writer.stream_position()?;
128+
writer.write_all(&vec![0; self.chunks.len() * ModpkgChunk::size_of()])?;
129+
130+
// Process chunks
131+
let final_chunks = Self::process_chunks(
132+
&self.chunks,
133+
&mut writer,
134+
provide_chunk_data,
135+
&chunk_path_indices,
136+
)?;
137+
138+
// Write chunks
139+
writer.seek(SeekFrom::Start(chunk_toc_offset))?;
140+
for chunk in &final_chunks {
141+
chunk.write(&mut writer)?;
142+
}
143+
144+
Ok(())
145+
}
146+
147+
fn compress_chunk_data(
148+
data: &[u8],
149+
compression: ModpkgCompression,
150+
) -> Result<(Vec<u8>, ModpkgCompression), ModpkgBuilderError> {
151+
let mut compressed_data = Vec::new();
152+
match compression {
153+
ModpkgCompression::None => {
154+
compressed_data = data.to_vec();
155+
}
156+
ModpkgCompression::Zstd => {
157+
let mut encoder = zstd::Encoder::new(BufWriter::new(&mut compressed_data), 3)?;
158+
encoder.write_all(data)?;
159+
encoder.finish()?;
160+
}
161+
};
162+
163+
Ok((compressed_data, compression))
164+
}
165+
166+
fn collect_unique_layers(
167+
chunks: &[ModpkgChunkBuilder<'builder>],
168+
) -> (Vec<Cow<'builder, str>>, HashMap<u64, u32>) {
169+
let mut layers = Vec::new();
170+
let mut layer_indices = HashMap::new();
171+
for chunk in chunks {
172+
let hash = xxh64(chunk.layer.as_bytes(), 0);
173+
174+
if !layer_indices.contains_key(&hash) {
175+
layer_indices.insert(hash, layers.len() as u32);
176+
layers.push(chunk.layer.clone());
177+
}
178+
}
179+
180+
(layers, layer_indices)
181+
}
182+
183+
fn collect_unique_paths(
184+
chunks: &[ModpkgChunkBuilder<'builder>],
185+
) -> (Vec<Cow<'builder, str>>, HashMap<u64, u32>) {
186+
let mut paths = Vec::new();
187+
let mut path_indices = HashMap::new();
188+
189+
for chunk in chunks {
190+
path_indices.entry(chunk.path_hash).or_insert_with(|| {
191+
let index = paths.len();
192+
paths.push(chunk.path.clone());
193+
index as u32
194+
});
195+
}
196+
197+
(paths, path_indices)
198+
}
199+
200+
fn validate_layers(
201+
defined_layers: &[ModpkgLayerBuilder],
202+
unique_layers: &[Cow<'builder, str>],
203+
) -> Result<(), ModpkgBuilderError> {
204+
// Check if defined layers have base layer
205+
if !defined_layers.iter().any(|layer| layer.name == "base") {
206+
return Err(ModpkgBuilderError::MissingBaseLayer);
207+
}
208+
209+
// Check if all unique layers are defined
210+
for layer in unique_layers {
211+
if !defined_layers.iter().any(|l| l.name == layer.as_ref()) {
212+
return Err(ModpkgBuilderError::LayerNotFound(
213+
layer.as_ref().to_string(),
214+
));
215+
}
216+
}
217+
218+
Ok(())
219+
}
220+
221+
fn process_chunks<
222+
TWriter: io::Write + io::Seek,
223+
TChunkDataProvider: Fn(&str, &mut Cursor<Vec<u8>>) -> Result<(), ModpkgBuilderError>,
224+
>(
225+
chunks: &[ModpkgChunkBuilder<'builder>],
226+
writer: &mut BufWriter<TWriter>,
227+
provide_chunk_data: TChunkDataProvider,
228+
chunk_path_indices: &HashMap<u64, u32>,
229+
) -> Result<Vec<ModpkgChunk>, ModpkgBuilderError> {
230+
let mut final_chunks = Vec::new();
231+
for chunk_builder in chunks {
232+
let mut data_writer = Cursor::new(Vec::new());
233+
provide_chunk_data(&chunk_builder.path, &mut data_writer)?;
234+
235+
let uncompressed_data = data_writer.get_ref();
236+
let uncompressed_size = uncompressed_data.len();
237+
let uncompressed_checksum = xxh3_64(uncompressed_data);
238+
239+
let (compressed_data, compression) =
240+
Self::compress_chunk_data(uncompressed_data, chunk_builder.compression)?;
241+
242+
let compressed_size = compressed_data.len();
243+
let compressed_checksum = xxh3_64(&compressed_data);
244+
245+
let data_offset = writer.stream_position()?;
246+
writer.write_all(&compressed_data)?;
247+
248+
let path_hash = xxh64(chunk_builder.path.as_bytes(), 0);
249+
250+
let chunk = ModpkgChunk {
251+
path_hash,
252+
data_offset,
253+
compression,
254+
compressed_size: compressed_size as u64,
255+
uncompressed_size: uncompressed_size as u64,
256+
compressed_checksum,
257+
uncompressed_checksum,
258+
path_index: *chunk_path_indices.get(&path_hash).unwrap_or(&0),
259+
layer_hash: xxh3_64(chunk_builder.layer.as_bytes()),
260+
};
261+
262+
final_chunks.push(chunk);
263+
}
264+
265+
Ok(final_chunks)
266+
}
267+
}
268+
269+
impl<'builder> ModpkgChunkBuilder<'builder> {
270+
const DEFAULT_LAYER: &'static str = "base";
271+
272+
pub fn new(path: &'builder str) -> Self {
273+
Self {
274+
path_hash: xxh64(path.as_bytes(), 0),
275+
path: Cow::Borrowed(path),
276+
compression: ModpkgCompression::None,
277+
layer: Cow::Borrowed(Self::DEFAULT_LAYER),
278+
}
279+
}
280+
281+
pub fn with_path(mut self, path: &'builder str) -> Self {
282+
self.path = Cow::Borrowed(path);
283+
self.path_hash = xxh64(path.as_bytes(), 0);
284+
self
285+
}
286+
287+
pub fn with_compression(mut self, compression: ModpkgCompression) -> Self {
288+
self.compression = compression;
289+
self
290+
}
291+
292+
pub fn with_layer(mut self, layer: &'builder str) -> Self {
293+
self.layer = Cow::Borrowed(layer);
294+
self
295+
}
296+
}
297+
298+
impl ModpkgLayerBuilder {
299+
pub fn new(name: impl AsRef<str>) -> Self {
300+
Self {
301+
name: name.as_ref().to_string(),
302+
priority: 0,
303+
}
304+
}
305+
306+
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
307+
self.name = name.as_ref().to_string();
308+
self
309+
}
310+
311+
pub fn with_priority(mut self, priority: i32) -> Self {
312+
self.priority = priority;
313+
self
314+
}
315+
}
316+
317+
#[cfg(test)]
318+
mod tests {
319+
use super::*;
320+
use binrw::BinRead;
321+
use std::io::Cursor;
322+
323+
#[test]
324+
fn test_modpkg_builder() {
325+
let scratch = Vec::new();
326+
let mut cursor = Cursor::new(scratch);
327+
328+
let builder = ModpkgBuilder::default()
329+
.with_metadata(ModpkgMetadata::default())
330+
.with_layer(ModpkgLayerBuilder::new("base").with_priority(0))
331+
.with_chunk(
332+
ModpkgChunkBuilder::new("test.png")
333+
.with_compression(ModpkgCompression::Zstd)
334+
.with_layer("base"),
335+
);
336+
337+
builder
338+
.build_to_writer(&mut cursor, |_path, cursor| {
339+
cursor.write_all(&[0xAA; 100])?;
340+
Ok(())
341+
})
342+
.expect("Failed to build Modpkg");
343+
344+
// Reset cursor and verify the file was created
345+
cursor.set_position(0);
346+
347+
let modpkg = Modpkg::<Cursor<Vec<u8>>>::read(&mut cursor).unwrap();
348+
349+
assert_eq!(modpkg.chunks().len(), 1);
350+
assert_eq!(
351+
modpkg
352+
.chunks()
353+
.get(&xxh64("test.png".as_bytes(), 0))
354+
.unwrap()
355+
.compression,
356+
ModpkgCompression::Zstd
357+
);
358+
}
359+
}

0 commit comments

Comments
 (0)