Skip to main content

fstool/fs/grf/
mod.rs

1//! GRF (Gravity Ragnarok File) — Korean MMO archive format.
2//!
3//! GRF is the on-disk archive format used by *Ragnarok Online*'s
4//! game client to ship art, maps, scripts, and sounds. The original
5//! libgrf implementation (~2003, by the user) is the reference; the
6//! port here lives under the MIT-licensed fstool crate with the
7//! rights holder's permission.
8//!
9//! Three versions are in the wild:
10//!
11//! - **`0x102` / `0x103`**: file table is RAW (not zlib) and each
12//!   filename inside it is encrypted with a fixed-key permutation
13//!   cipher ([`crypt::decode_filename`]). The on-disk `len` /
14//!   `len_aligned` fields carry magic offsets that the v0x102 decoder
15//!   in the `table` module strips. File bodies can also be encrypted
16//!   per-entry via the `MIXCRYPT` / `DES` flags.
17//! - **`0x200`**: file table is zlib-compressed but filenames are
18//!   plain CP949. Magic offsets removed. This is what the writer
19//!   produces today.
20//!
21//! Filenames stored on disk are CP949 (Korean MS codepage); the
22//! [`crate::fs::Filesystem`] surface exposes UTF-8 strings, with
23//! conversion happening once at parse / write time
24//! (see [`encoding`]).
25//!
26//! Layout on disk:
27//!
28//! ```text
29//! offset 0     : 46-byte header
30//! offset 46    : file data blocks (each compressed, optionally encrypted)
31//! offset N     : file table
32//! ```
33//!
34//! `header.table_offset` is relative to the end of the 46-byte
35//! header, so the absolute file position is
36//! `header.table_offset + HEADER_SIZE`. Removing files marks their
37//! data wasted; flush rewrites the table; repacking compacts.
38//! See [`writer`].
39
40pub mod crypt;
41pub mod encoding;
42pub mod header;
43pub mod table;
44pub mod writer;
45
46pub use table::{Entry, GRF_FLAG_DES, GRF_FLAG_FILE, GRF_FLAG_MIXCRYPT};
47
48use std::collections::BTreeMap;
49use std::io::Read;
50
51use crate::Result;
52use crate::block::BlockDevice;
53use crate::fs::{FileMeta, FileSource, MutationCapability};
54
55pub(crate) const HEADER_SIZE: usize = 0x2e;
56
57/// Public format-side options for
58/// [`crate::fs::FilesystemFactory::format`]. The writer always emits
59/// version 0x200 today; older versions are readable but not writeable
60/// (they're rarely useful outside legacy game clients).
61#[derive(Debug, Clone)]
62pub struct FormatOpts {
63    /// GRF version word to write. Only 0x200 is supported by the
64    /// writer right now.
65    pub version: u32,
66    /// zlib compression level (0..=9). 0 = store, 6 = default.
67    pub compression_level: u32,
68}
69
70impl Default for FormatOpts {
71    fn default() -> Self {
72        Self {
73            version: 0x200,
74            compression_level: 6,
75        }
76    }
77}
78
79impl FormatOpts {
80    /// Apply a generic option-bag (CLI `-O key=val` / TOML
81    /// `[filesystem.options]`) on top of these opts. Unknown keys are
82    /// left in the map for the caller to flag.
83    pub fn apply_options(
84        &mut self,
85        map: &mut crate::format_opts::OptionMap,
86    ) -> crate::Result<()> {
87        if let Some(v) = map.take_u32("version")? {
88            self.version = v;
89        }
90        if let Some(n) = map.take_u32("compression_level")? {
91            if n > 9 {
92                return Err(crate::Error::InvalidImage(format!(
93                    "compression_level {n} out of range (0..=9)"
94                )));
95            }
96            self.compression_level = n;
97        }
98        Ok(())
99    }
100}
101
102/// An opened GRF archive.
103pub struct Grf {
104    pub version: u32,
105    pub table_offset: u32,
106    pub seed: u32,
107    pub encrypted_header: bool,
108    /// Entries keyed by their normalised path (`/` prefix stripped).
109    /// On-disk filenames are CP949; this map's keys are UTF-8.
110    pub entries: BTreeMap<String, Entry>,
111    /// First byte past the last file's data — where new data appends
112    /// and where the table will land at flush time.
113    data_end: u64,
114    /// Bytes inside the data area that no longer back any entry
115    /// (accumulated when files are removed). Drives the repack
116    /// decision.
117    wasted_space: u64,
118    /// True if the in-memory state diverges from disk; flush rewrites
119    /// the table + header.
120    dirty: bool,
121    /// `false` until [`Self::format`] or [`Self::open`] finishes. Set
122    /// to mark the handle as a fresh writer (no existing file data
123    /// to preserve).
124    fresh: bool,
125}
126
127impl Grf {
128    /// Build a `Grf` handle that represents a freshly-formatted empty
129    /// archive. The header isn't written until
130    /// [`<Self as crate::fs::Filesystem>::flush`](crate::fs::Filesystem::flush).
131    pub fn format_with(_dev: &mut dyn BlockDevice, opts: &FormatOpts) -> Result<Self> {
132        if opts.version != 0x200 {
133            return Err(crate::Error::Unsupported(format!(
134                "grf: writer only emits v0x200 (asked for {:#x})",
135                opts.version
136            )));
137        }
138        Ok(Self {
139            version: opts.version,
140            table_offset: 0,
141            seed: 0,
142            encrypted_header: false,
143            entries: BTreeMap::new(),
144            data_end: HEADER_SIZE as u64,
145            wasted_space: 0,
146            dirty: true,
147            fresh: true,
148        })
149    }
150
151    /// Open an existing GRF on `dev`. Parses the header + file table
152    /// fully into memory.
153    pub fn open_dev(dev: &mut dyn BlockDevice) -> Result<Self> {
154        let mut head_buf = [0u8; HEADER_SIZE];
155        dev.read_at(0, &mut head_buf)?;
156        let head = header::Header::decode(&head_buf)?;
157
158        let table_abs = head.table_offset as u64 + HEADER_SIZE as u64;
159        let entries = read_table(dev, table_abs, head.version, head.filecount)?;
160
161        // data_end = the maximum (pos + len_aligned) across all
162        // entries, anchored at HEADER_SIZE so an empty archive lays
163        // its first file directly after the header.
164        let mut data_end = HEADER_SIZE as u64;
165        for e in entries.values() {
166            let end = HEADER_SIZE as u64 + e.pos as u64 + e.len_aligned as u64;
167            if end > data_end {
168                data_end = end;
169            }
170        }
171
172        // Wasted space: the table starts at `table_abs` and runs to
173        // the end of the file. If there's a gap between data_end and
174        // table_abs, that gap is wasted (left over from removed
175        // files in a previous lifetime of this archive).
176        let wasted_space = table_abs.saturating_sub(data_end);
177
178        Ok(Self {
179            version: head.version,
180            table_offset: head.table_offset,
181            seed: head.seed,
182            encrypted_header: head.encrypted_header,
183            entries,
184            data_end,
185            wasted_space,
186            dirty: false,
187            fresh: false,
188        })
189    }
190
191    /// Read the body of `entry` into a freshly-allocated buffer.
192    /// Handles per-file MIXCRYPT/DES decryption and zlib inflation.
193    pub fn read_entry(&self, dev: &mut dyn BlockDevice, entry: &Entry) -> Result<Vec<u8>> {
194        let abs = HEADER_SIZE as u64 + entry.pos as u64;
195        let mut comp = vec![0u8; entry.len_aligned as usize];
196        if entry.len_aligned > 0 {
197            dev.read_at(abs, &mut comp)?;
198        }
199        if let Some(cycle) = entry.crypto_cycle() {
200            // flag_type is 0 for MIXCRYPT, 1 for DES — see grf.c
201            // decode_des_etc(..., (cycle==0), cycle).
202            let flag_type = if cycle == 0 { 1 } else { 0 };
203            crypt::decode_des_etc(&mut comp, flag_type, cycle);
204        }
205        let plain = crate::compression::decompress(
206            crate::compression::Algo::Zlib,
207            &comp[..entry.len as usize],
208            entry.size as usize,
209        )?;
210        Ok(plain)
211    }
212
213    /// Total wasted bytes inside the data area. A nonzero value
214    /// means a repack would shrink the archive.
215    pub fn wasted_space(&self) -> u64 {
216        self.wasted_space
217    }
218}
219
220fn read_table(
221    dev: &mut dyn BlockDevice,
222    table_abs: u64,
223    version: u32,
224    filecount: u32,
225) -> Result<BTreeMap<String, Entry>> {
226    if filecount == 0 {
227        return Ok(BTreeMap::new());
228    }
229
230    let dev_size = dev.total_size();
231    if table_abs >= dev_size {
232        return Err(crate::Error::InvalidImage(
233            "grf: table offset past end of file".into(),
234        ));
235    }
236
237    let entries = match version {
238        0x102 | 0x103 => {
239            // v0x102/0x103: the table layout starts with 8 bytes of
240            // posinfo just like v0x200 (libgrf inflates the
241            // remainder), followed by a 4-byte legacy-framing word.
242            // See grf.c lines 826–910 — the only difference from
243            // v0x200 is the extra 4-byte `brokenpos` field after the
244            // compressed payload.
245            let table = read_compressed_table(dev, table_abs, /* legacy_framing = */ true)?;
246            table::decode_v102(&table)?
247        }
248        0x200 => {
249            let table = read_compressed_table(dev, table_abs, /* legacy_framing = */ false)?;
250            table::decode_v200(&table)?
251        }
252        other => {
253            return Err(crate::Error::Unsupported(format!(
254                "grf: cannot read table for version {other:#x}"
255            )));
256        }
257    };
258
259    let mut map = BTreeMap::new();
260    for e in entries {
261        map.insert(normalise_path(&e.name), e);
262    }
263    Ok(map)
264}
265
266fn read_compressed_table(
267    dev: &mut dyn BlockDevice,
268    table_abs: u64,
269    legacy_framing: bool,
270) -> Result<Vec<u8>> {
271    let dev_size = dev.total_size();
272    let mut posinfo = [0u8; 8];
273    if table_abs + 8 > dev_size {
274        return Err(crate::Error::InvalidImage(
275            "grf: table header truncated".into(),
276        ));
277    }
278    dev.read_at(table_abs, &mut posinfo)?;
279    let comp_size = u32::from_le_bytes(posinfo[0..4].try_into().unwrap()) as usize;
280    let uncomp_size = u32::from_le_bytes(posinfo[4..8].try_into().unwrap()) as usize;
281
282    let comp_start = table_abs + 8;
283    if comp_start + comp_size as u64 > dev_size {
284        return Err(crate::Error::InvalidImage(
285            "grf: compressed table payload past end of file".into(),
286        ));
287    }
288    let mut comp = vec![0u8; comp_size];
289    dev.read_at(comp_start, &mut comp)?;
290
291    // Legacy framing — there's an additional 4-byte word after the
292    // compressed payload in v0x102/0x103. We don't use its value
293    // (libgrf calls it `brokenpos` and treats it as opaque).
294    let _ = legacy_framing;
295
296    crate::compression::decompress(crate::compression::Algo::Zlib, &comp, uncomp_size)
297}
298
299/// Strip a leading `/` from a path string so it lines up with the
300/// CP949 names libgrf writes (which never start with `/`).
301fn normalise_path(s: &str) -> String {
302    s.trim_start_matches('/').to_string()
303}
304
305impl crate::fs::FilesystemFactory for Grf {
306    type FormatOpts = FormatOpts;
307
308    fn format(dev: &mut dyn BlockDevice, opts: &Self::FormatOpts) -> Result<Self> {
309        Self::format_with(dev, opts)
310    }
311
312    fn open(dev: &mut dyn BlockDevice) -> Result<Self> {
313        Self::open_dev(dev)
314    }
315}
316
317impl crate::fs::Filesystem for Grf {
318    fn create_file(
319        &mut self,
320        dev: &mut dyn BlockDevice,
321        path: &std::path::Path,
322        src: FileSource,
323        _meta: FileMeta,
324    ) -> Result<()> {
325        let key = normalise_path(
326            path.to_str()
327                .ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?,
328        );
329        writer::add_file(self, dev, key, src)
330    }
331
332    fn create_dir(
333        &mut self,
334        _dev: &mut dyn BlockDevice,
335        _path: &std::path::Path,
336        _meta: FileMeta,
337    ) -> Result<()> {
338        // GRF has no directory entries — paths' parents are implicit
339        // from the slashes. `create_dir` is a no-op so that callers
340        // who emit dirs (e.g. the repack walker) don't error out.
341        Ok(())
342    }
343
344    fn create_symlink(
345        &mut self,
346        _dev: &mut dyn BlockDevice,
347        _path: &std::path::Path,
348        _target: &std::path::Path,
349        _meta: FileMeta,
350    ) -> Result<()> {
351        Err(crate::Error::Unsupported(
352            "grf: symlinks are not part of the archive format".into(),
353        ))
354    }
355
356    fn create_device(
357        &mut self,
358        _dev: &mut dyn BlockDevice,
359        _path: &std::path::Path,
360        _kind: crate::fs::DeviceKind,
361        _major: u32,
362        _minor: u32,
363        _meta: FileMeta,
364    ) -> Result<()> {
365        Err(crate::Error::Unsupported(
366            "grf: device nodes are not part of the archive format".into(),
367        ))
368    }
369
370    fn remove(&mut self, _dev: &mut dyn BlockDevice, path: &std::path::Path) -> Result<()> {
371        let key = normalise_path(
372            path.to_str()
373                .ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?,
374        );
375        writer::remove(self, &key)
376    }
377
378    fn list(
379        &mut self,
380        _dev: &mut dyn BlockDevice,
381        path: &std::path::Path,
382    ) -> Result<Vec<crate::fs::DirEntry>> {
383        let prefix = {
384            let s = path
385                .to_str()
386                .ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?;
387            let trimmed = s.trim_start_matches('/').trim_end_matches('/');
388            if trimmed.is_empty() {
389                String::new()
390            } else {
391                format!("{trimmed}/")
392            }
393        };
394
395        // Collect the immediate children of `prefix`: each unique
396        // first path component after the prefix. Files appear as
397        // Regular, intermediate path components appear as Dir.
398        use std::collections::BTreeMap as B;
399        let mut children: B<String, crate::fs::EntryKind> = B::new();
400        let mut sizes: B<String, u64> = B::new();
401        for (name, entry) in &self.entries {
402            let Some(tail) = name.strip_prefix(&prefix) else {
403                continue;
404            };
405            if tail.is_empty() {
406                continue;
407            }
408            if let Some((leaf, _)) = tail.split_once('/') {
409                children.insert(leaf.to_string(), crate::fs::EntryKind::Dir);
410                sizes.insert(leaf.to_string(), 0);
411            } else {
412                children.insert(tail.to_string(), crate::fs::EntryKind::Regular);
413                sizes.insert(tail.to_string(), entry.size as u64);
414            }
415        }
416        Ok(children
417            .into_iter()
418            .map(|(name, kind)| {
419                let size = *sizes.get(&name).unwrap_or(&0);
420                crate::fs::DirEntry {
421                    name,
422                    inode: 0,
423                    kind,
424                    size,
425                }
426            })
427            .collect())
428    }
429
430    fn read_file<'a>(
431        &'a mut self,
432        dev: &'a mut dyn BlockDevice,
433        path: &std::path::Path,
434    ) -> Result<Box<dyn Read + 'a>> {
435        let key = normalise_path(
436            path.to_str()
437                .ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?,
438        );
439        let entry =
440            self.entries.get(&key).cloned().ok_or_else(|| {
441                crate::Error::InvalidArgument(format!("grf: no entry at {key:?}"))
442            })?;
443        let bytes = self.read_entry(dev, &entry)?;
444        Ok(Box::new(std::io::Cursor::new(bytes)))
445    }
446
447    fn open_file_ro<'a>(
448        &'a mut self,
449        dev: &'a mut dyn BlockDevice,
450        path: &std::path::Path,
451    ) -> Result<Box<dyn crate::fs::FileReadHandle + 'a>> {
452        // GRF stores each file as a single zlib stream (optionally
453        // per-block encrypted). Seek-friendly access requires the
454        // whole inflated body in RAM — the alternative would be
455        // re-decompressing from the start on every backward seek.
456        // For typical GRF assets (sub-MB sprites, sounds, scripts)
457        // this is fine; documented here so callers don't expect
458        // streaming-style memory bounds.
459        let key = normalise_path(
460            path.to_str()
461                .ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?,
462        );
463        let entry =
464            self.entries.get(&key).cloned().ok_or_else(|| {
465                crate::Error::InvalidArgument(format!("grf: no entry at {key:?}"))
466            })?;
467        let bytes = self.read_entry(dev, &entry)?;
468        Ok(Box::new(GrfFileReadHandle {
469            cursor: std::io::Cursor::new(bytes),
470        }))
471    }
472
473    fn flush(&mut self, dev: &mut dyn BlockDevice) -> Result<()> {
474        writer::flush(self, dev)
475    }
476
477    fn mutation_capability(&self) -> MutationCapability {
478        MutationCapability::Mutable
479    }
480}
481
482/// Random-access (`Read + Seek + len`) view of a GRF entry's
483/// inflated body. Holds the decompressed bytes in RAM because GRF
484/// stores each file as a single zlib stream.
485struct GrfFileReadHandle {
486    cursor: std::io::Cursor<Vec<u8>>,
487}
488
489impl Read for GrfFileReadHandle {
490    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
491        self.cursor.read(buf)
492    }
493}
494
495impl std::io::Seek for GrfFileReadHandle {
496    fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
497        self.cursor.seek(pos)
498    }
499}
500
501impl crate::fs::FileReadHandle for GrfFileReadHandle {
502    fn len(&self) -> u64 {
503        self.cursor.get_ref().len() as u64
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::block::MemoryBackend;
511    use crate::fs::{Filesystem, FilesystemFactory};
512
513    #[test]
514    fn empty_round_trip() {
515        let mut dev = MemoryBackend::new(64 * 1024);
516        let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
517        grf.flush(&mut dev).unwrap();
518
519        let reopen = Grf::open(&mut dev).unwrap();
520        assert_eq!(reopen.version, 0x200);
521        assert_eq!(reopen.entries.len(), 0);
522    }
523
524    #[test]
525    fn add_read_round_trip() {
526        let mut dev = MemoryBackend::new(64 * 1024);
527        let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
528
529        let body = b"hello, world!";
530        grf.create_file(
531            &mut dev,
532            std::path::Path::new("/data/info.txt"),
533            FileSource::Reader {
534                reader: Box::new(std::io::Cursor::new(body.to_vec())),
535                len: body.len() as u64,
536            },
537            FileMeta::default(),
538        )
539        .unwrap();
540        grf.flush(&mut dev).unwrap();
541
542        let mut reopen = Grf::open(&mut dev).unwrap();
543        assert_eq!(reopen.entries.len(), 1);
544        let entries = reopen
545            .list(&mut dev, std::path::Path::new("/data"))
546            .unwrap();
547        assert!(entries.iter().any(|e| e.name == "info.txt"));
548        let entry = reopen.entries.get("data/info.txt").cloned().unwrap();
549        let bytes = reopen.read_entry(&mut dev, &entry).unwrap();
550        assert_eq!(bytes, body);
551    }
552
553    #[test]
554    fn open_file_ro_random_seek() {
555        use std::io::{Read, Seek, SeekFrom};
556        let mut dev = MemoryBackend::new(64 * 1024);
557        let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
558        let body: Vec<u8> = (0..1024u32).map(|i| (i & 0xff) as u8).collect();
559        grf.create_file(
560            &mut dev,
561            std::path::Path::new("/blob.bin"),
562            FileSource::Reader {
563                reader: Box::new(std::io::Cursor::new(body.clone())),
564                len: body.len() as u64,
565            },
566            FileMeta::default(),
567        )
568        .unwrap();
569        grf.flush(&mut dev).unwrap();
570
571        let mut grf = Grf::open(&mut dev).unwrap();
572        let mut h = grf
573            .open_file_ro(&mut dev, std::path::Path::new("/blob.bin"))
574            .unwrap();
575        assert_eq!(h.len(), body.len() as u64);
576        // Seek mid-body, read 32 bytes, verify.
577        h.seek(SeekFrom::Start(500)).unwrap();
578        let mut chunk = [0u8; 32];
579        h.read_exact(&mut chunk).unwrap();
580        assert_eq!(&chunk[..], &body[500..532]);
581        // Backward seek and reread.
582        h.seek(SeekFrom::Current(-32)).unwrap();
583        h.read_exact(&mut chunk).unwrap();
584        assert_eq!(&chunk[..], &body[500..532]);
585    }
586
587    #[test]
588    fn hangul_filename_round_trip() {
589        let mut dev = MemoryBackend::new(64 * 1024);
590        let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
591        grf.create_file(
592            &mut dev,
593            std::path::Path::new("/data/한글.txt"),
594            FileSource::Reader {
595                reader: Box::new(std::io::Cursor::new(b"hi".to_vec())),
596                len: 2,
597            },
598            FileMeta::default(),
599        )
600        .unwrap();
601        grf.flush(&mut dev).unwrap();
602
603        let reopen = Grf::open(&mut dev).unwrap();
604        assert!(reopen.entries.contains_key("data/한글.txt"));
605    }
606
607    #[test]
608    fn remove_marks_wasted_space() {
609        let mut dev = MemoryBackend::new(64 * 1024);
610        let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
611        grf.create_file(
612            &mut dev,
613            std::path::Path::new("/a.txt"),
614            FileSource::Reader {
615                reader: Box::new(std::io::Cursor::new(vec![0u8; 4096])),
616                len: 4096,
617            },
618            FileMeta::default(),
619        )
620        .unwrap();
621        grf.create_file(
622            &mut dev,
623            std::path::Path::new("/b.txt"),
624            FileSource::Reader {
625                reader: Box::new(std::io::Cursor::new(vec![0u8; 4096])),
626                len: 4096,
627            },
628            FileMeta::default(),
629        )
630        .unwrap();
631        grf.flush(&mut dev).unwrap();
632
633        let mut reopen = Grf::open(&mut dev).unwrap();
634        reopen
635            .remove(&mut dev, std::path::Path::new("/a.txt"))
636            .unwrap();
637        reopen.flush(&mut dev).unwrap();
638        assert!(reopen.wasted_space() > 0);
639    }
640}