Minecraft sparar din värld i ett eget filformat som heter .mca (Region File Format).
Och jag har dom senaste 3 åren spenderat väldigt många timmar för att bygga det
perfekta Rust biblioteket för att läsa och skriva dessa filer i högsta hastighet.
Och varning, denna bloggen kommer vara extremt detaljerad om komplicerade saker inom programmering!!
Rust
"Rewrite it in Rust!"
December 2023 så bestämde jag mig för att lära mig Rust med att använda det i Advent of Code.
Kom kanske inte så många dagar in i det men det gav mig den där lilla gnistan att börja med Rust.
Nu i 2026 så är Rust det främsta programmeringsspråket jag föredrar och älskar mest att använda.
Borrow checkern, lifetimes, cargo, type system, error handling, ergonomics, Det är så trevligt o skriva.
Om du aldrig har sett Rust kod innan så kan det se ut som något likannde:
fn main() {
let y = double(5);
println!("Resultat: {y}")
}
fn double(num: i32) -> i32 {
num * 2
}
Detta är ett väldigt simpelt program som multiplicerar valfritt heltal med 2 och skriver det till stdout.
Och om du vill se mer komplicerad kod så oroa dig icke, det kommer mer sen!
Minecraft är också ett spel jag flera tusentals timmar i och jag gillar att leka lite extra med det.
Oooo vad händer ifall du lägger ihop Minecraft med Rust?
Början
"Tillbaka till början!"
Runt mitten av Juli 2024 efter att ha skrivit en del i Rust så ville jag börja läsa Minecraft's region filer i Rust för att sedan använda mig av datan som fanns i dom.
Så som varenda block i en chunk, eller vilka chunks som var genererade osv.
För att allt ska makea sense så ska jag kort förklara hur Region filer är strukturerade
Region filformat
Vad tusan står
mcaför?
I början av filen så finns det 2st 4096 bytes stora headers, vilket innebär att alla region filer är åtminstone 8192 bytes stora i storlek utan någon annan data.
All data i en region fil är i sections av 4096 bytes, inklusiv chunk datan senare.
Första headern är en lista av alla chunks i sin region (varje region har 3232 chunks)*.
Där varje chunk får 4 bytes var som indikerar en offset i filen, samt en längd (båda räknat i sections)
| Offset | Length |
|---|---|
| 0 1 2 | 3 |
Så bytes 1001 indikerar att våran chunk är 3 sections in i filen och 1 section stor.
Och andra headern är en lista av när en chunk vart senaste modifierad, vilket tiden också är 4 bytes per chunk (32*32*4=4096).
När vi har använt våran offset för att hitta vart i filen våran chunk ligger i så kan vi börja läsa lite mer data för just den chunken.
| Length | Compression | Compressed Data |
|---|---|---|
| 0 1 2 3 | 4 | 5.. |
Första 4 bytes är längden av den resterande chunk datan (i bytes, inte sections).
Nästa byte är vilken type av compression som användes för datan.
Detta kan vara allt från Zlib till Lz4 eller inget.
| GZip | 0 |
| Zlib | 1 |
| None | 2 |
| Lz4 | 3 |
Men bilden ovanför ljuger lite för det kan finnas extra data mellan byte 4 och 5 men det kommer vi till senare.
Resterande data är själva chunk datan i ett compressed format.
Så (offset * 4096) + 5 -> (chunk_len - 1) är våran chunk data.
Denna datan kan vi sedan uncompressa och då har vi rå NBT data som vi sedan kan använda något annat bilbiotek för att kunna läsa och modifiera hur vi vill.
Nu när du kan exakt hur formatet går till:
Tillbaka till början?
Jag kollade runt crates.io för att se om ett bilbiotek för att läsa .mca filer redan fanns.
För det brukar för det mesta redan finnas andra som har velat göra samma saker som dig förr.
Och det fanns! Bara en enda dock: mca-parser
Jag skulle snart upptäcka att detta bibliotek har ett stort misstag vilket kickstartade hela mitt intressé i detta formatet och som fick mig att skriva mitt egna bibliotek för det 3 gånger om över dom nästa 3 åren.
För att kunna hitta problemet så kan vi kolla in på deras exempel kod:
// [email protected]
// Create a Region from an open file
let mut file = File::open("r.0.0.mca")?;
let region = Region::from_reader(&mut file)?;
// `chunk` is raw chunk data, so we need to parse it
let chunk = region.get_chunk(0, 0)?;
if let Some(chunk) = chunk {
// Parse the raw chunk data into structured NBT format
let parsed = chunk.parse()?;
println!("{:?}", parsed.status);
} else {
// If the chunk is None, it has not been generated
println!("Chunk has not been generated.");
}
Ser väl exakt ut som det jag ville ha?
Den läser in en buffer som har en region fil i sig och sedan kan du hämta x, z chunk och få dens NBT data.
Perfekt!
Eeelller inte...
Detta biblioteket tvingade dig att använda NBT bibliotek som heter fastnbt.
För den tar och ger dig NBT datan direkt när du hämtar en chunk.
Vill du använda en av dom flera tiotals andra NBT biblioteken i Rust som är snabbare o bättre på alla sätt?
Synd för dig, för du är fast vid fastnbt.
Jag ville använda det snabbaste som fanns då (simdnbt) för jag behövde söka igenom en enorm mängd data.
Och fastnbt var sjukt mycket långsammare och värre på många sätt.
Enligt min åsikt så borde inte en "parser" för "mca" ge dig NBT data.
Allt du ska göra är att ge användaren av ditt bibliotek enkel åtkomst till datan i en region fil.
Inget mer, inget mindre.
Så jag gjorde en fork av mca-parser, döpte den till based-mca och tog bort fastnbt och gjorde så .get_chunk(x, z) gav tillbaka den råa uncompressed datan av en chunk.
Så användaren av biblioteket kunde välja det NBT bibliotek som dom ville.
Mitt egna
Detta "funkade", men jag hade behövt lära mig formatet tidigare och jag ville göra mitt egna för det skulle vara ett roligt projekt.
Ooooch kanske också för inget annan hade tagit namnet mca för ett Rust bibliotek förr.
Mitt bibliotek var väldigt liknande parser-mca i hur dens API design var, vilka funktioner som fanns och hur.
Men som du kanske kunde gissa, så får användaren av mitt bibliotek välja valfri NBT hantering.
Dom bara får datan och that's it.
Här är då hur mitt bibliotek skulle kunna användas:
let mut data = Vec::new();
File::open("r.0.0.mca")?.read_to_end(&mut data)?;
let region = Region::new(&data)?;
let chunk = region.get_chunk(0, 0)?.unwrap();
let decompressed = chunk.decompress()?;
Mitt bibliotek har nästan exakt samma hastighet som mca-parser (så liten skillnad att det går knappt o mäta).
För att läsa några headers, läsa x viss mängd data in i en chunk och uncompressa det har inte riktigt så många utrymmen för göra något snabbare.
Ett bibliotek vid denna grad är en hyfsat simpel grej, läsa 4 bytes, läs 4 bytes igen, läs 1 byte, läs x bytes.
Det blir inte mer än så riktigt.
Den största skillnaden är att mitt bibliotek kunde decompressa chunks i alla format: GZip, Zlib, Lz4 och None.mca-parser kunde bara använda sig av Zlib vilket to be fair, är det formatet som används 99% av tiden.
Men gillade inte att den inte kunde göra allt, mitt kunde använda sig av alla dessa dock.
Mitt bibliotek dock la även till lite mer najs funktioner för att kunna loopa igenom alla chunks i en region:
let region = Region::new(&Vec::default())?;
for chunk in region.iter() {
// Chunk is of Result<Option<T>>
let chunk = chunk?.unwrap();
}
Allt detta är bra o det funkade o enligt mig var ett bättre bibliotek allmänt.
Fast det saknades en sak fortfarande...
Du kunde läsa in region filer och läsa dens data, men du kunde inte modifiera datan och sedan skriva den igen.
Något som inte mca-parser heller kunde göra.
Detta skulle vara något som inget annat fristående .mca bibliotek i Rust hade gjort förr.
På andra sidan så borde det inte vara allt för jobbigt, i mean att läsa är ju väldigt enkelt?
Efter jag släppte första versionen av mitt bibliotek så tog det mig tills 3 veckor senare att släppa en version där du kunde skriva till formatet också.
Att skriva var naturligtvis mer kod och vart lite mer komplicerat för du måste nu hålla koll på vart chunks sätter sig i filen, vilka offets, padding efter varje chunk för att dom ska hamna i korrekt offsets o allt.
Ett väldigt basic exempel hade kunnat se ut såhär:
let mut writer = RegionWriter::new();
writer.push_chunk(&data, 0, 0)?;
let mut buf = vec![];
writer.write(&mut buf)?;
File::create("r.0.0.mca")?.write_all(&buf)?;
Här så skapar vi en speciell RegionWriter som kommer hålla i våran data inner vi skriver det till en region,
sedan så säger vi åt den vilken chunk vi vill ändra och den nya NBT datan som den ska använda.
Efter det så kan vi skriva våran data till en buffer som kommer att formatera det till en faktiskt region.
Och vid denna punkten vart jag nöjd, den kunde använda sig av alla compression format, du kunde både läsa och skriva och den hade extra najs funktioner för att göra ditt liv enklare.
Det var där jag lämnade detta projektet, och under nästa år så skulle ett par antal andra projekt poppa upp som använde sig av mitt mca bibliotek för att läsa region filer.
Vilket är fett coolt, att se sin kod användas i någon annans projekt är en magisk känsla.
Men jag var inte klar, för 1.5 år senare så kom jag tillbaka.
Jag ville göra det snabbare, och det fanns en sista funktion i formatet jag inte har berättat än vilket komplicerar hela kedjen ännu mer, en funktion som knappt ingen använder. Men är tekniskt sätt en funktion som ingen annan har ens lagt märke till.
Rewrite
"DET ÄR RAGEBAIT"
En tanke som jag fick flera antal gånger sen jag gjorde klart mitt bibliotek var att det måste finnas något sätt att göra det snabbare, om det var via simd, byte magic eller vad spelade ingen roll.
Jag skulle hitta ett sätt o göra det snabbare och jag skulle implementera HELA region formatet denna gången.
Jag började från grunden och min första fungerade prototyp var detta:
pub fn get_chunk(&mut self, x: u8, z: u8) -> Option<&[u8]> {
let chunk_offset = Self::header_offset(x, z);
let offset_data = &self.data[chunk_offset..chunk_offset + 4];
if offset_data == [0,0,0,0] {
return None;
}
let offset = u32::from_be_bytes([0, offset_data[0], offset_data[1], offset_data[2]])
as usize
* Self::SECTOR_SIZE;
let data_len =
u32::from_be_bytes(self.data[offset..offset + 4].try_into().unwrap()) as usize;
let compression = self.data[offset + 4];
let data = &self.data[offset + 5..offset + 5 + data_len];
self.inflate_buf.clear();
Self::decompress(data, compression, &mut self.inflate_buf);
Some(&self.inflate_buf)
}
Detta är basically all logik för att läsa en region.
Men redan detta var sjukt mycket snabbare än min förra version och jag hade bara börjat.
Allocation
Den första snabbaste utvecklingen var self.inflate_buf.
När du tog en chunk och decompressade den så skapade jag en ny buffer varenda gång i förra versionen.
I denna så använder jag samma minne jag redan har införskaffat för att lägga datan i.
Detta gav typ en 10-20% boost i hur snabb den var att läsa in en hel fil.
zlib-rs
Nog den största skillnaden är omöjlig att se i koden och är bara synlig i min cargo.toml.
Denna filen bestämmer vilka andra bibliotek du kan använda och hur du anpassar dom.
Min tidigare version använde sig av miniz_oxide för att decompressa/compressa datan.
Men det finns ett annat bibliotek som heter flate2, vilket använder sig av miniz_oxide.
Och jag bytte till flate2 för en väldigt specifik anledning.
Det som tar längst tid när man läser en region by a MILE, är att decompressa datan.
Själva header + offsets o allt tar bara några nanosekunder.
Decompression tar några millisekunder, STOR skillnad.
1 millisekund = 1,000,000 nanosekunder
flate2 hade en feature i sig som var avstängt by default: zlib-rs.
Om du satte på denna featuren så skulle den byta vilket bibliotek den använde för Zlib compression.
Detta bytte ut miniz_oxide till zlib-rs.
Och zlib-rs är en Zlib implementation skriven helt i Rust vilket även är den snabbaste på att läsa och skriva Zlib. Det formatet som mest används för Minecrafts chunks.
Att byta compression backend gav en sådan enorm boost till att läsa regions, vi snackar en 100% boost i hastighet.
Typ dubbelt så snabb blev koden bara av denna lilla raden:
flate2 = { version = "1.1.9", features = ["zlib-rs"], default_features = false }
Förutom dessa två ändringarna så ändrade jag väldigt mycket hur mitt bibliotek hanterade och skicka runt data.
Så den inte skaffade mer minne än absolut nödvändigt. Så mycket data som möjligt vart bara lånad.
Jag skulle även vilja säga att den är enklare att använda nu
// Läsa
let file = read("r.0.0.mca")?;
let mut region = RegionReader::new(&file)?;
let chunk = region.chunk(5, 12)?;
if let Some(chunk) = chunk {
// ...
}
// Skriva
let mut region = RegionWriter::new();
region.set_chunk(5, 12, Vec::new(), Compression::default())?;
let mut file = File::create("r.0.0.mca")?;
region.write(&mut file)?;
Väldigt lika men bara en touch annorlunda för det bättre.
Parallel
Visst, att byta compression backend gjorde det snabbt, men finns ett till trick.
Parallel
Flera bitar kod som kör samtidigt för att sedan samlas ihop.
Att läsa en region fil går inte riktigt o göra i parallelt när du själv använder .chunk(x, z) för att få tag i varje chunk.
Men att skriva region filer... Där så hanterade jag hela processen själv så jag hade möjlighet att då compressa alla chunks du ville modifiera samtidigt i parallel vilket gjorde det SJUKT mycket snabbare.
Att compressa 1024 chunks samtidigt eller en för en är STOR skillnad.190ms -> 59ms för en hel region.
Jag skrev även en exempelkod för biblioteket ifall man även ville läsa chunks i parallelt själv eftersom det kräver lite manuell hantering av en chunks data.
Eftersom .chunk(x, z) tar &mut self och ändrar och håller uncompressed chunk data i sig själv.
Så kan du inte dela den med flera trådar samtidigt för alla kan inte modifiera den där uncompression buffern samtidigt.
Custom Compression
Minns hur jag sa att det fanns en udda funktion i region filer som typ ingen bryr sig om?
Custom Compression
Ifall compression byte är 127 istället för 0-4 så betyder det att chunk datan är compressed via ett format som inte är vanilla i spelet.
Detta är så custom Minecraft servers kan compressa/decompressa chunk data på ett eget sätt.
En quite obscure feature som typ ingen använder för Zlib eller Lz4 är det 99.9% av spelare behöver.
Inget annat bibliotek har support för denna funktionen så "tekniskt" sätt, så saknar alla andra bibliotek full support för formatet och skulle misslyckas om compression byte var 127.
För när compression är custom, så ändras strukturen av datan lite.
| Length | Compression (127) | Compression Algorithm | Compressed Data | |
|---|---|---|---|---|
| 0 1 2 3 | 4 | 5, 6 : string length | 7..len : string | 7+len.. |
Istället för att ha chunk datan direkt efter compression byte så finns det nu en length-prefixed string.
Där nästa 2 bytes blir en unsigned 16 bit siffra som säger längden av texten efter.
Denna text är "custom compression algorithm", vilket säger till servern vilket custom format var använt.
Ifall du ba skulle anta att all data efter compression byte var NBT och du fick en custom compressed chunk så skulle ditt program att misslyckas helt.
Att läsa datan och ändra hur vi läser datan är rätt så enkelt beroende på vad compression är.
Dock, ifall man gör ett bibliotek som ska kunna supporta custom format så måste användaren av ditt bibliotek kunna ge sin egna kod för att compressa/decompressa datan eftersom du kan ju inte gissa dig fram till alla miljontals format en användare hade kunnat använda.
Och här kommer ännu en Rust feature in i bilden som gör detta så lätt.
trait CustomCompression {
fn compress(&self, data: &[u8], id: &str, out: &mut Vec<u8>);
}
struct MyFormat;
impl CustomCompression for MyFormat {
fn compress(&self, data: &[u8], id: &str, out: &mut Vec<u8>) {
todo!()
}
}
let region = RegionWriter::new_with_compression(MyFormat);
traits, en trait i Rust är ett kontrakt på att en struct måste hålla sig till.
En trait säger åt att typ MyFormat måste ha en funktion på sig med exakt samma signatur som CustomCompression har, och själva koden över hur funktionen körs är upp till användaren.
Så vi kan göra 2 traits för CustomCompression och CustomDecompression.
När en användare skapar en ny RegionWriter eller RegionReader så kan dom ge alternativt en struct som håller sig till kontraktet.
struct RegionReader<D: CustomDecompression> {
custom_decompression: D
// ...
}
Vi kan göra våran RegionReader till att ta in en generic typ av D där typen du ger den måste hålla sig till vårat CustomDecompression kontrakt (trait).
Sedan kan vi bara göra detta för att använda koden som våran användare skrev:
// Väldigt simpelt och oklart exempel
match compression {
127 => &self.custom_decompression.decompress(&data, "id", &mut Vec::new()),
_ => todo!()
}
Men vi har ett problem med detta, ni tvingar vi alla att ge oss en typ som håller sig till kontraktet även fast användaren inte behöver använda sig av Custom Compression.
Som tur kan vi ge våran RegionReader en default typ:
struct RegionReader<D: CustomDecompression = ()> {
custom_decompression: D
// ...
}
Där typen är (), vilket är "inget". Så ger användaren inget så blir det automatiskt ().
Dock så hade detta inte varit ett valid Rust program eftersom () inte håller sig till vårat kontrakt.
Så vi kan implementa kontraktet för () i vårat bibliotek.
impl CustomCompression for () {
fn compress(&self, data: &[u8], id: &str, out: &mut Vec<u8>) {
panic!("unsupported")
}
}
Ifall en chunk skulle ha en custom compression och användaren inte gav något av deras egna typ, så den automatiskt går till (). Och koden hade kört så skulle vårat program krasha för vi har inget sätt att compressa/decompressa datan utan att användaren hjälper oss.
Mitt bibliotek kan inte krasha ens program för den har bättre error support men har inte med dom detaljer här för att koden ska vara enklare att läsa och fatta.
Du kanske även undrar varför jag har out som ett argument istället för att obvious:
fn compress(&self, data: &[u8], id: &str, out: &mut Vec<u8>)
// vs
fn compress(&self, data: &[u8], id: &str) -> Vec<u8>
Minns hur jag nämnde den där buffern för att hålla decompressed data i?
Användaren av kontraktet skriver sin data till out vilket i dom flesta tillfällerna är min internal decompression buffer för att det är snabbare och mindre allocations.
Hade jag inte brytt mig om hur snabbt mitt bibliotek var så hade jag gjort det annorlunda.
Här kan du även se ett fullt exempelt av en custom compression implementation för lzma2
Andra nya
Sen jag gjorde version 1 för några år sedan så har det kommit fram några nya bibliotek i Rust för just mca.
Jag måste bara få uttrycka hur otroligt dåliga dom är och hur dom är gjorde bara för att vara ragebait för mig.
I nästa sektion så pratar jag om benchmarks men vi måste först kolla på vad för andra bibliotek som finns och vad vi har att jämnföra med.
du kanske märker att båda dessa har anvil i namnet men technically så är "Anvil" formatet bara hur chunksen i NBT är strukturerade och INTE och själva region strukturen är och där med .mca, innan .mca så var det .mcr men tekniskt sätt så ska region bibliotek kunna supporta båda eftersom båda fortfarande är samma "Region file format"
anvil-nbt
Detta biblioteket har samma problem som jag hade med mca-parser, den har med sig ett NBT bibliotek som är custom made.
Du kan skaffa datan bara men så onödigt att bundla ihop två HELT olika bibliotek in i samma.
Du hade kunnat ha ett för endast region och sedan göra ditt egna NBT bibliotek vid sidan om.
Men sure, sedan så verkar deras readme så jävla AI även om det inte var med mening.
"Built for world editors, servers, and tools that need reliable access to Minecraft world data."
"Reliable", jag hoppas fan att ett bibliotek vilket deras enda funktion är att läsa en region fil ska kunna faktiskt göra sitt jobb och läsa datan.
"High Performance: Manual byte-level parsing for maximum speed (no parser combinator overhead)"
Denna är så udda, visst den har ganska bra performance (vilket vi kommer till sen). Men varför nämna att man inte använda en parser combinator (typ som nom)?.
Det är som att skaparen förväntar sig att alla använder sig av något annat bibliotek och det är the gold standard och sedan bara för dom gör det manuellt (vilket jag hoppas alla gör för filen är så simpel) så ska det vara något extra special??
"Lazy Loading: Memory-mapped Anvil region files via memmap2 load only the chunks you need"
DETTA ÄR SÅ RAGEBAIT, jag kanske bara hatar lite för mycket på denna stackarn och andras kod för jag har en väldigt specifik åsikt på kod för det mesta.
Men denna är sjuk.
Enda sättet att läsa en region fil i anvil-nbt är att ge EN FILE PATH.
EN FILE PATH.
Deras funktion signatur är
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self>
Och detta är det enda sättet att skapa en Region i deras bibliotek.
Har du en in-memory region buffer eller har skapat en region fil internally?
Eller har fått en region fil via en användare? Fuck you.
För du måste ge den en fil. Så du måste spara ner till region till disk bara för att läsa in den igen.
För detta bibliotek är "snällt" och memory mappar din region för att kunna "snabbt" läsa chunks.
- Läser du hela region filen så är detta långsammare
- BIBLIOTEKET GÅR IGENOM HELA HEADERN I SIN CONSTRUCTOR
Jag exempelvis läser headern i början bara när du behöver.
Denna läser hela headern för o spara offsets o sådant för "quick lookups".
Idéen att memory mappa är för att ge "efficient access", men det spelar ju ingen roll om du ändå kör onödig header decoding som kanske aldrig behövs om jag bara läser typ 2 chunks.
"Bit-Perfect Round-trips: Idempotent parsers and encoders preserve data exactly"
Denna är bara en lögn, jag kollade igenom kod en gång och såg att denna "feature" är helt fake.
Mitt bibliotek är inte exakt "Bit-Perfect" och inget bibliotek är det.
Med hur Minecraft paddar chunks, sections och flyttar runt chunks i en region fil så kommer datan nästan aldrig vara "Bit-Perfect" om du läser den och skriver tillbaka den.
Inte bara det, utan denna koden finns i anvil-nbt när dom skriver en region fil:
// Timestamps (just use 0 for now)
for _ in 0..1024 {
self.writer.write_all(&[0u8; 4])?;
}
DU KASTAR BORT MODIFIERADE TIMESTAMPS OCH KALLAR DET FÖR "IDEMPOTENT".
Look, jag kastar också bort dom men jag lägger i alla fall till nya timestamps för så fort en chunk har gått igenom mitt bibliotek så räknar jag den som modifierad anyway och skriver nuvarande tiden.
Dom ba skriver ingen tid alls och claimar att den är idempotent, så jävla sjukt.
Ännu en anledning till att hela grejen känns så sjukt AI.
Sedan så supportar dom bara GZip, Zlib och None, skit i Lz4 och såklart ingen custom compression.
Men det är fair enough, ingen annan förutom jag har någonsin tänkt på det.
Men det jag avskyr är deras funktion för att skriva en region fil.
pub fn write_all_chunks(&mut self, chunks: &[(i32, i32, String, NbtTag)]) -> Result<()>
Du alltså MÅSTE ge den deras NBT data, du kan inte bara ge den rå nbt data eller något.
Du MÅSTE använda deras NBT variant istället för något annat bibliotek.
let mut encoder = ZlibEncoder::new(&mut compressed, Compression::default());
encoder.write_all(&raw_nbt)?;
Samt så är du låst in till att alltid skriva dina region filer i Zlib.
Fuck you om du läste in en None region o vill ha den i None eller i Lz4.
Den kan läsa 3 format men alltid bara skriva en... ???
Någon måste också lära blud om u32::from_be_bytes istället för denna sjuka koden
let timestamp = ((mmap[start] as u32) << 24)
| ((mmap[start + 1] as u32) << 16)
| ((mmap[start + 2] as u32) << 8)
| (mmap[start + 3] as u32);
simple-anvil
Detta biblioteket är ju ba komedi.
Deras Region struct har ba en lifetime i sig för varför inte?
Och skaparen vet inte ens själv?
pub struct Region<'a> {
data: Vec<u8>,
/// I don't remember what this was for.
_marker: marker::PhantomData<Cell<&'a ()>>,
pub filename: String,
}
Nämen svinbra, jag litar på denna koden helt.
Och du kanske såg att den har en filename i sig...
Precis som det förra så är en fil det enda sättet att skapa deras Region.
pub fn from_file(file: String) -> Region<'a>
Det jag inte fattar är att om du bara använder &[u8] som input av datan så kan du memory mappa den om du vill för MemMap kan bli dereferenced till &[u8].
Samt det är bara mer arbete för ett bibliotek att läsa en fil och hantera error ifall filen inte finns eller vad.
hm? oh...
data: fs::read(file.clone()).unwrap()
Dom bara skiter i error handling, ger du den en region fil som inte existerar så kommer ditt program att krasha, svinbra!!!
Jag älskar bibliotek som inte har en enda nivå av error handling och ba krashar så fort det minsta lilla går fel!
Dom har också väldigt bra exempel på för mycket, onödig dokumentation
/// Returns the header size and returns an offset for a particular chunk.
///
/// # Arguments
///
/// * `chunk_x` - The x coordinate of the particular chunk
/// * `chunk_z` - The z coordinate of the particular chunk
fn header_offset(&self, chunk_x: u32, chunk_z: u32) -> u32
Första raden är valid, men varför ha med arguments?
Kanske mest bara är en personlig åsikt men detta är så sjukt sjukt onödigt att ha med.
Säger ingen mer information som användaren inte redan visste om.
Och juste, denna funktionen är inte ens pub. Så den används bara internally.
Ännu mer av en bra anledning o ha med det.
Förutom det så har vi samma problem som alla andra, dom tvingar dig till det NBT bibliotek som skaparen tycker du borde använda. Och i nästan alla scenarion så är det deras egna eller ett random ass NBT bibliotek.
Oh, dom supportar även bara Zlib, inget annat.
let data = Box::new(Blob::from_zlib_reader(&mut compressed_data.as_slice()).unwrap());
Sååå fuck you ifall du har en None compressed chunk eller vad som helst.
Benchmarks
Jag har väldigt många benchmarks för alla funktioner i mitt egna bibliotek men även benchmarks mot alla andra Region läsande bibliotek.
Så med att läsa en hel region fil (1024 chunks) gav mig dessa siffrorna:
| Library | Throughput | Ms (mean) |
|---|---|---|
| mca (2.0.0) | 310.71 MiB/s | 24.440 ms |
| anvil-nbt | 261.26 MiB/s | 29.066 ms |
| mca (1.1.0) | 216.61 MiB/s | 35.057 ms |
| mca-parser | 87.721 MiB/s | 86.567 ms |
| simple-anvil | 13.599 MiB/s | 558.41 ms |
Där mca är mitt bibliotek i version 2 och 1.1.
Allt mitt arbete var värt det! Min kod är det snabbaste som finns inom Rust när det gäller Region filer.
Enligt mig även det bästa för hur mycket du kan anpassa det, stödjer alla compression format någonsin, och ger dig rå NBT data istället för något arbitary jävla bibliotek.
into_writer
Jag har en sista funktion som har lite obscure men är en feature som inget annat bibliotek har.
Du kan göra om en RegionReader till en RegionWriter med min funktion som heter... into_writer.
Denna tar alla chunks i en RegionReader och gör om dom din strukturen inne i en RegionWriter så du kan typ modifiera en existerande region.
Alltså du läser in den, gör om den till en writer, och sedan ändrar datan och skriver den.
Först när jag skrev denna så läste jag alla chunks, decompressade dom och la deras råa NBT bytes i en RegionWriter.
För förr så förvarade jag bara rå NBT data i min writer.
Men detta uncompressade varenda chunk även fast du inte modifiera den.
Så jag skrev hela min internal struktur av RegionWriter. Och nu kan varenda chunk i en writer vara compressed eller uncompressed.
pub enum PendingData<'d> {
Compressed(PendingCompressed<'d>),
Uncompressed(PendingUncompressed<'d>),
}
Så gör du om en reader -> writer så kommer alla chunks vara compressed som dom är i filen.
Och bara när du modifierar en chunk så kommer den on the fly kolla om den är compressed/uncompressed och göra den till uncompressed owned data om den inte redan är det.
Nästa gång du modifierar samma chunk så är den redan uncompressed så inget extra behövs då.
Detta lägger till ett lager extra av logik och checks för att göra om data till uncompressed eller owned o allt. Men det var så värt det för om du bara vill modifiera en enda chunk i en stor region så gör du endast logik på den och INGET extra.
Alla andra chunks har kvar sin exakt samma compression och är helt orörda.
2.0
Allmänt är jag väldigt nöjd med min hela rewrite av mitt bibliotek och hur en "missing" feature i ett bibliotek ledde mig till spendera all denna tiden på att göra det bästa möjliga bibliotek
Om du använder Rust och vill kolla in mitt bibliotek så kan du klicka på bilden nedan.
Älskar att skriva dessa typer av bibliotek och gå så djupt in i att göra dom det minsta lilla snabbare.
Mängden tid jag har spenderat på att skriva om allt, göra om stora delar för att göra en liten sak snabbare.
Och allt detta för mca: Det enda rust biblioteket du behöver för Minecraft region files.
Vem vet, jag kanske skriver en till blog om den gången jag skrev en egen Minecraft server i Rust från grunden (typ), eller hur jag tillsammans med en vän skrev ett bibliotek för att ändra block data enkelt i Rust.
