diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 440205a..41e78af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,12 +3,12 @@ name: Build and Publish to PyPI on: push: tags: - # Publish on any tag starting with a `v`, e.g., v0.1.0 - v* jobs: - run: - runs-on: ubuntu-latest + build-wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} environment: name: pypi @@ -16,25 +16,72 @@ jobs: permissions: id-token: write contents: read - + + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - - name: Install uv - uses: astral-sh/setup-uv@v7 + - name: Build wheels + uses: PyO3/maturin-action@v1 with: - enable-cache: true + command: build + args: --release --out dist + # manylinux auto-selects the correct manylinux image on Linux + manylinux: auto + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.os }} + path: dist + + build-sdist: + name: Build source distribution + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 - - name: Install Python - run: uv python install + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist - - name: Install the project - run: uv sync --locked --all-extras --dev + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: sdist + path: dist - # Probably should run tests here + publish: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: [build-wheels, build-sdist] - - name: Build - run: uv build + environment: + name: pypi + + permissions: + id-token: write + contents: read + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + pattern: wheels-* + merge-multiple: true + path: dist + + - name: Download sdist + uses: actions/download-artifact@v4 + with: + name: sdist + path: dist - - name: Publish - run: uv publish + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index eef40ce..fe8ba20 100644 --- a/.gitignore +++ b/.gitignore @@ -206,6 +206,15 @@ marimo/_static/ marimo/_lsp/ __marimo__/ +# Rust / Cargo +target/ +Cargo.lock + +# Maturin build artifacts +*.pyd +*.pyi.bak +*.pdb + # Custom config.json *output*/ @@ -215,4 +224,4 @@ test_saves/ entries_generator/ test_json.json extracted/ -**/*.code-workspace \ No newline at end of file +**/*.code-workspace diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bec3d33 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "_mio_decomp" +version = "0.3.2" +edition = "2021" + +[lib] +name = "mio_decomp" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.28.2", features = ["extension-module"] } +bitflags = "2" +lz4_flex = "0.11" +zstd = "0.13" +rayon = "1" +serde_json = "1" +memmap2 = "0.9" +jwalk = "0.8" \ No newline at end of file diff --git a/README.md b/README.md index fa6f2cf..1a94062 100644 --- a/README.md +++ b/README.md @@ -19,5 +19,3 @@ mio-decomp config set game_dir "" # Defaults to "C:\Pr mio-decomp decompile gin1.gin ./path/to/another/gin.gin -o output # Decompile .gins mio-decomp parse ./path/to/save.save -o save.json # Convert save file to JSON ``` - -NOTE: Decompilation of assets.gin takes a really long time! It probably isn't stuck, but can take over 20 minutes, depending on your machine. Also, they don't have a filepath in their binary like all the other extracted .gin files, so if you decompile to structure then they will end up in "ship/decomp_assets". \ No newline at end of file diff --git a/mio_decomp/_mio_decomp.pyi b/mio_decomp/_mio_decomp.pyi new file mode 100644 index 0000000..742034e --- /dev/null +++ b/mio_decomp/_mio_decomp.pyi @@ -0,0 +1,26 @@ +from os import PathLike +from pathlib import Path + +_AnyPath = str | PathLike[str] | Path + +class GinDecompiler: + def __init__(self, silent: bool = True) -> None: ... + def check_if_gin_file(self, file_path: _AnyPath) -> bool: ... + def decompile_file( + self, + file_path: _AnyPath, + output_dir: _AnyPath, + file_count_offset: int = 0, + include_number_prefix: bool = True, + ) -> list[str]: ... + def decompile_multi( + self, + input_paths: list[_AnyPath], + output_dir: _AnyPath, + include_number_prefix: bool = True, + ) -> list[str]: ... + def decompile_to_structure( + self, + input_paths: list[_AnyPath], + output_dir: _AnyPath, + ) -> None: ... diff --git a/mio_decomp/mio_decomp.pdb b/mio_decomp/mio_decomp.pdb new file mode 100644 index 0000000..4992668 Binary files /dev/null and b/mio_decomp/mio_decomp.pdb differ diff --git a/mio_decomp/src/commands/check.py b/mio_decomp/src/commands/check.py index 1ed319a..477e1fc 100644 --- a/mio_decomp/src/commands/check.py +++ b/mio_decomp/src/commands/check.py @@ -4,7 +4,7 @@ import typer from rich import print -from mio_decomp.src.libraries.decompiler.decompiler import GinDecompiler +from ..._mio_decomp import GinDecompiler app = typer.Typer() diff --git a/mio_decomp/src/commands/decompile.py b/mio_decomp/src/commands/decompile.py index dd4085d..a805256 100644 --- a/mio_decomp/src/commands/decompile.py +++ b/mio_decomp/src/commands/decompile.py @@ -5,7 +5,9 @@ from rich import print from mio_decomp.src.config import config -from mio_decomp.src.libraries.decompiler.decompiler import GinDecompiler + +# from mio_decomp.src.libraries.decompiler.decompiler import GinDecompiler +from ..._mio_decomp import GinDecompiler app = typer.Typer() @@ -89,9 +91,10 @@ def decompile( decompiler: GinDecompiler = GinDecompiler(silent=not debug) if structure: decompiler.decompile_to_structure( - input_paths=final_input_paths, output_dir=output_dir + input_paths=final_input_paths, # ty:ignore[invalid-argument-type] + output_dir=output_dir, ) else: - decompiler.decompile_multi(input_paths=final_input_paths, output_dir=output_dir) + decompiler.decompile_multi(input_paths=final_input_paths, output_dir=output_dir) # ty:ignore[invalid-argument-type] print("Done!") diff --git a/mio_decomp/src/libraries/decompiler/decompiler.py b/mio_decomp/src/libraries/decompiler/decompiler.py deleted file mode 100644 index 4100f57..0000000 --- a/mio_decomp/src/libraries/decompiler/decompiler.py +++ /dev/null @@ -1,454 +0,0 @@ -# Huge thanks to @mistwreathed for creating the original version of this tool. -# -import json -import os -import shutil -import struct -import sys -from pathlib import Path - -import lz4.block -import pathvalidate -import typer -from rich import print -from zstandard import ZstdDecompressor - -from .constants import FLAGS, GIN_MAGIC_NUMBER - - -class GinDecompiler: - """A decompiler for the .gin files in MIO: Memories in Orbit.""" - - def __init__(self, silent: bool = True) -> None: - self.silent: bool = silent - if not "win32" == sys.platform: - print(f"OS '{sys.platform}' is not supported currently.") - sys.exit(1) - - def __print(self, *args, **kwargs) -> None: - """Wrapper for print.""" - if not self.silent: - print(*args, **kwargs) - - def __ensure_dir(self, directory: Path) -> None: - directory.mkdir(parents=True, exist_ok=True) - - def __decompress_data( - self, data: bytes, flags: int, original_size: int - ) -> bytes | None: - """Handles decompression based on section flags. - - Args: - data (bytes): The data to decompress. - flags (_type_): _description_ - original_size (_type_): _description_ - - Returns: - bytes | None: The decompressed data. The value is None if decompression fails. - """ - try: - if flags & FLAGS.ZSTD: - # ZSTD Decompression - dctx: ZstdDecompressor = ZstdDecompressor() - return dctx.decompress(data, max_output_size=original_size) - elif flags & FLAGS.LZ4: - # LZ4 Block Decompression - return lz4.block.decompress(data, uncompressed_size=original_size) - else: - # Raw Data (No Compression) - return data - - except Exception as e: - self.__print(f" [!] Decompression failed: {e}") - return None - - def check_if_gin_file(self, file_path: Path) -> bool: - """Checks if a file's magic number matches a .gin's. - - Args: - file_path (Path): The input file's path. - - Returns: - bool: True if the file is a .gin file. - - Raises: - FileNotFoundError: The input file doesn't exist. - """ - if not file_path.exists(): # File should always exist, but just to make sure - raise FileNotFoundError - - with file_path.open("rb") as f: - # Structure: u32 magic - header_fmt = " list[Path]: - """Decompiles a single .gin file. - - Args: - file_path (Path): The path to the .gin file to decompile. - output_dir (Path): The directory to output to. - file_count_offset (int): The offset for the number prefixing the filenames. - include_number_prefix (bool): Include a unique number at the start of the filenames. - - Returns: - list[Path]: The output file paths. - """ - if not file_path.exists(): # File should always exist, but just to make sure - print("The selected file doesn't exist.") - typer.Abort() - - output: list[Path] = [] - - with file_path.open("rb") as f: - # --- 1. Read Main Header --- - # Structure: u32 magic, u32 ver, u32 res[2], char id[16], u32 res2, char path[256], u32 count, u64 check[2] - header_fmt = " 0) else size_uncompressed - ) - - # Go to data offset - current_pos: int = f.tell() - f.seek(offset) - raw_data: bytes = f.read(read_size) - f.seek(current_pos) # Return to table index just in case - - # Decompress - final_data: bytes | None = self.__decompress_data( - raw_data, flags, size_uncompressed - ) - - if final_data: - # Construct output filename - # We add the index to avoid overwriting if names are duplicate - safe_name: str = "".join( - [c for c in raw_name if c.isalnum() or c in ("_", "-", ".")] - ) - if include_number_prefix: - out_name: str = f"{(i + file_count_offset):04d}_{safe_name}" - else: - out_name: str = f"{safe_name}" - out_path: Path = output_dir / out_name - - with out_path.open("wb") as out_f: - out_f.write(final_data) - - output.append(output_dir / out_name) - - comp_tag: str = ( - "[ZSTD]" - if (flags & FLAGS["ZSTD"]) - else ("[LZ4]" if (flags & FLAGS["LZ4"]) else "[RAW]") - ) - self.__print( - f"Extracted: {out_name} {comp_tag} ({len(final_data)} bytes)" - ) - - return output - - def decompile_multi( - self, - input_paths: list[Path], - output_dir: Path, - include_number_prefix: bool = True, - ) -> list[Path]: - """Decompiles multiple .gin files. - - Args: - input_paths (list[Path]): A list of all of the paths to decompile. - output_dir (Path): The directory to output all of the decompiled files to. - include_number_prefix (bool): Include a unique number at the start of the filenames. - - Returns: - list[Path]: The output file paths. - """ - if output_dir.exists(): - shutil.rmtree(output_dir) - output_dir.mkdir(parents=True, exist_ok=False) - - file_paths: list[Path] = [] - current_file_count_offset: int = 0 - - for file_path in input_paths: - if not os.access(file_path, os.R_OK): - self.__print( - f'Unable to read path "{file_path}". Check your permissions! Skipping...' - ) - continue - - if file_path.is_dir(): - self.__print(f'Path "{file_path}" is a directory. Skipping...') - continue - - if not self.check_if_gin_file(file_path): - self.__print(f'Path "{file_path}" is not a .gin file. Skipping...') - continue - - file_paths.append(file_path) - - output_paths: list[Path] = [] - - if len(file_paths) == 0: - print("No .gin files found. Please select at least one .gin file.") - typer.Abort() - - for file in file_paths: - file_output_dir: Path = output_dir / file.stem - file_output_dir.mkdir(777) - print(f'Decompiling "{file}"..') - paths: list[Path] = self.decompile_file( - file_path=file, - output_dir=file_output_dir, - file_count_offset=current_file_count_offset, - include_number_prefix=include_number_prefix, - ) - for p in paths: - output_paths.append(p) - current_file_count_offset += len(paths) - - return output_paths - - def __walk_dir(self, dir: Path) -> list[Path]: - output: list[Path] = [] - for dirpath, dirnames, filenames in os.walk(dir): - for filename in filenames: - path: Path = Path(dirpath) / filename - path: Path = path.resolve() - output.append(path) - - return output - - def __read_until_zero_byte(self, input_file: Path, start_offset: int = 0) -> bytes: - output: bytes = b"" - with input_file.open("rb") as f: - f.seek(start_offset) - while True: - new_byte = f.read(1) - if new_byte == b"\x00": - break - output += new_byte - - return output - - def __read_gin_file_path_from_binary( - self, input_file: Path, start_offset: int = 0 - ) -> bytes: - output: bytes = b"" - with input_file.open("rb") as f: - f.seek(start_offset) - while True: - new_byte = f.read(1) - if new_byte == b"\x00" or output.decode("utf-8").endswith(".gin"): - break - output += new_byte - - return output - - def __remove_all_suffixes(self, p: Path) -> Path: - """Removes all file extensions from a pathlib.Path object.""" - while p.suffix: - p = p.with_suffix("") - return p - - def __remove_suffix_until_gin(self, p: Path) -> Path: - while p.suffix and not p.suffix == ".gin": - p = p.with_suffix("") - return p - - def __get_suffixes_after_gin(self, p: Path) -> list[str]: - suffixes: list[str] = [] - while p.suffix and not p.suffix == ".gin": - suffixes.append(p.suffix) - p = p.with_suffix("") - return reversed(suffixes) - - def decompile_to_structure(self, input_paths: list[Path], output_dir: Path) -> None: - if output_dir.exists(): - shutil.rmtree(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - temp_dir: Path = output_dir / "decompiled" - temp_dir: Path = temp_dir.resolve() - temp_dir.mkdir(parents=True, exist_ok=True) - - ship_dir: Path = output_dir / "ship" - ship_dir: Path = ship_dir.resolve() - ship_dir.mkdir(parents=True, exist_ok=True) - - mappings_file: Path = output_dir / "mappings.json" - mappings_file: Path = mappings_file.resolve() - if mappings_file.exists(): - mappings_file.unlink() - mappings_file.touch() - - self.decompile_multi( - input_paths, - output_dir=temp_dir, - include_number_prefix=False, - ) - - decompiled_paths: list[Path] = self.__walk_dir(temp_dir) - - print("Structuring files..") - - skipped_paths: list[Path] = [] - structure_mappings: dict[Path, Path] = {} - - for path in decompiled_paths: - if ( - path.suffix[1:] in ["reloc", "alloc", "assets"] - and not path.parent.name == "assets" - ): - skipped_paths.append(path) - else: - try: - if path.parent.name == "assets": - new_path: Path = ship_dir / "decomp_assets" / path.name - new_path: Path = new_path.resolve() - elif path.suffix[1:] in [ - "csv", - "otf", - "ttf", - ]: # filter out font files - new_path: Path = ship_dir / "fonts" / path.name - new_path: Path = new_path.resolve() - else: - new_path: Path = ( - ship_dir - / self.__read_gin_file_path_from_binary(path, 20).decode( - "utf-8" - ) - ) - new_path: Path = new_path.resolve() - except UnicodeDecodeError as _: - new_path: Path = ship_dir / path.name - new_path: Path = new_path.resolve() - - if pathvalidate.is_valid_filepath( - str(new_path), platform="windows" - ) and not str(new_path) == str(ship_dir): - new_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(path, new_path) - structure_mappings[path] = new_path - else: - skipped_paths.append(path) - - for path in skipped_paths: - no_ext_path: Path = ( - path.parent / f"{self.__remove_suffix_until_gin(path).resolve().stem}" - ) - no_ext_path: Path = no_ext_path.resolve() - - gin_path: Path = ( - path.parent - / f"{self.__remove_suffix_until_gin(path).resolve().stem}.gin" - ) - gin_path: Path = gin_path.resolve() - - if gin_path in structure_mappings.keys(): - # print(f"GIN FOUND! {gin_path}") - matched_path: Path = gin_path.resolve() - file_suffixes: list[str] = [ - ".gin", - *self.__get_suffixes_after_gin(path), - ] - elif no_ext_path in structure_mappings.keys(): - # print(f"NO_EXT FOUND! {no_ext_path}") - matched_path: Path = no_ext_path.resolve() - file_suffixes: list[str] = self.__get_suffixes_after_gin(path) - elif ( - path.name - == "ST_factory_factory_pearl.ST_factory_turning_stop_pearl_inverted" # this specific file breaks stuff, so we make an exception for it here - ): - # print(f"INVERTED PEARL BYPASS HIT!\n\tpath.name: {path.name}") - matched_path: Path = ( - path.parent - / "ST_factory_factory_pearl.ST_factory_turning_stop_pearl.gin" - ) - ending_extension: str = "".join( - [".ST_factory_turning_stop_pearl_inverted"] - ) - dest_path: Path = ( - structure_mappings[matched_path].resolve().parent - / f"{structure_mappings[matched_path].resolve().with_suffix('').stem}{ending_extension}" - ) - dest_path: Path = dest_path.resolve() - shutil.copy(path, dest_path) - structure_mappings[path] = dest_path - continue - else: - print(f"NO MATCH FOUND. {path}") - print(f" UNMATCHED NO_EXT: {no_ext_path}") - print(f" path.name: {path.name}") - continue - - ending_extension: str = "".join(file_suffixes) - - dest_path: Path = ( - structure_mappings[matched_path].resolve().parent - / f"{structure_mappings[matched_path].resolve().stem}{ending_extension}" - ) - dest_path: Path = dest_path.resolve() - shutil.copy(path, dest_path) - structure_mappings[path] = dest_path - - structure_mappings_str: dict[str, str] = { - str(path): str(dest_path) for path, dest_path in structure_mappings.items() - } - structure_mappings_final: dict[str, str] = { - key: value for key, value in sorted(structure_mappings_str.items()) - } # sort alphabetically to group .gin files with their related files - - with mappings_file.open("w") as f: - json.dump(structure_mappings_final, f, indent=4) diff --git a/pyproject.toml b/pyproject.toml index f99a053..e4b51d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ authors = [ {name = "ShackledMars261"} ] license = {text = "MIT"} -version = "0.3.1" +version = "0.3.2" description = "A CLI for decompiling the .gin files from MIO: Memories in Orbit." readme = "README.md" requires-python = ">=3.13" @@ -19,16 +19,26 @@ dependencies = [ keywords = ["decompile", "decompiler", "mio", "memories-in-orbit"] [project.urls] -Homepage = "https://github.com/ShackledMars261/mio-decomp-cli" -"Bug Tracker" = "https://github.com/ShackledMars261/mio-decomp-cli/issues" +Homepage = "https://github.com/MIO-Modding/mio-decomp-cli" +"Bug Tracker" = "https://github.com/MIO-Modding/mio-decomp-cli/issues" [project.scripts] mio-decomp = "mio_decomp.main:app" [build-system] -requires = ["uv_build>=0.9.17,<0.10.0"] -build-backend = "uv_build" +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[tool.maturin] +python-source = "." +module-name = "mio_decomp._mio_decomp" +features = ["pyo3/extension-module"] [tool.uv.build-backend] module-name = "mio_decomp" module-root = "" + +[dependency-groups] +dev = [ + "maturin>=1.12.6", +] diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..9727742 --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,23 @@ +pub const MAX_UINT64: u64 = u64::MAX; + +pub const GIN_MAGIC_NUMBER: u32 = 0x004E4947; // Little endian ASCII: "GIN\0" +pub const GIN_VERSION: u32 = 2; + +pub const GIN_SECTION_NAME_SIZE: usize = 64; +pub const GIN_SECTION_PARAM_COUNT: usize = 4; + +pub const GIN_MAX_PATH: usize = 256; + +pub const GIN_SECTION_DUMMY_ID: u64 = MAX_UINT64; // for non-queryable sections (ex: referenced by other sections, .reloc & co) + +bitflags::bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct Flags: u32 { + const SERIALIZED = 1 << 0; + const RELOC = 1 << 1; + const ALLOC = 1 << 2; + const SCHEMA = 1 << 3; + const ZSTD = 1 << 4; + const LZ4 = 1 << 5; + } +} diff --git a/src/decompiler.rs b/src/decompiler.rs new file mode 100644 index 0000000..4e9e17d --- /dev/null +++ b/src/decompiler.rs @@ -0,0 +1,791 @@ +use std::collections::HashSet; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::sync::Mutex; +use std::thread; + +use memmap2::Mmap; +use pyo3::exceptions::{PyFileNotFoundError, PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use rayon::prelude::*; + +use crate::constants::{Flags, GIN_MAGIC_NUMBER}; + +// ─── Binary layout constants ────────────────────────────────────────────────── + +// Main header: u32 magic, u32 ver, u8 res[8], u8 id[16], u32 res2, u8 path[256], u32 count, u8 check[16] +const HEADER_MAGIC_OFFSET: usize = 0; +const HEADER_SECTION_COUNT_OFFSET: usize = 4 + 4 + 8 + 16 + 4 + 256; // = 292 +const HEADER_SIZE: usize = HEADER_SECTION_COUNT_OFFSET + 4 + 16; // = 312 + +// Section entry: u8 name[64], u64 offset, u32 size, u32 c_size, u32 flags, u8 id[16], u32 ver, u8 id2[16], u8 id3[16] +const SECT_NAME_SIZE: usize = 64; +const SECT_OFFSET_OFF: usize = SECT_NAME_SIZE; // 64 +const SECT_SIZE_OFF: usize = SECT_OFFSET_OFF + 8; // 72 +const SECT_CSIZE_OFF: usize = SECT_SIZE_OFF + 4; // 76 +const SECT_FLAGS_OFF: usize = SECT_CSIZE_OFF + 4; // 80 +const SECT_ENTRY_SIZE: usize = SECT_FLAGS_OFF + 4 + 16 + 4 + 16 + 16; // = 136 + +// Offset within a section's decompressed data where the embedded .gin path starts +const GIN_PATH_OFFSET: usize = 20; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +fn read_u32_le(buf: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap()) +} + +fn read_u64_le(buf: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(buf[offset..offset + 8].try_into().unwrap()) +} + +/// Read a null-terminated UTF-8 string from a fixed-size byte slice. +fn read_cstr(buf: &[u8]) -> String { + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(&buf[..end]).into_owned() +} + +/// Sanitise a section name so it is safe to use as a filename. +fn safe_filename(raw: &str) -> String { + raw.chars() + .filter(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.')) + .collect() +} + +/// Decompress section data according to its flags. +fn decompress_data(data: &[u8], flags: Flags, original_size: usize) -> Option> { + if flags.contains(Flags::ZSTD) { + zstd::decode_all(data).ok() + } else if flags.contains(Flags::LZ4) { + lz4_flex::decompress(data, original_size).ok() + } else { + Some(data.to_vec()) + } +} + +/// Extract the embedded `.gin` path from an already-mapped byte slice. +/// +/// Called while the file is already mmap'd in `decompile_file_inner`, so no +/// second file open is needed. Also used standalone during classification for +/// files whose path was not captured at decompile time. +fn extract_gin_path_from_slice(data: &[u8]) -> Option { + if data.len() <= GIN_PATH_OFFSET { + return None; + } + let buf = &data[GIN_PATH_OFFSET..]; + let mut end = buf.len().min(512); + for i in 0..end { + if buf[i] == 0 { + end = i; + break; + } + if i >= 3 && &buf[i - 3..=i] == b".gin" { + end = i + 1; + break; + } + } + String::from_utf8(buf[..end].to_vec()).ok() +} + +/// Strip the Windows extended-length path prefix (`\\?\`) that +/// `canonicalize()` adds on Windows, so paths written to mappings.json are +/// clean `C:\...` paths. +fn strip_unc_prefix(p: PathBuf) -> PathBuf { + let s = p.to_string_lossy(); + if let Some(stripped) = s.strip_prefix(r"\\?\") { + PathBuf::from(stripped) + } else { + p + } +} + +/// Remove all extensions from a path stem. +fn remove_all_suffixes(p: &Path) -> PathBuf { + let mut p = p.to_path_buf(); + while p.extension().is_some() { + p = p.with_extension(""); + } + p +} + +/// Strip extensions until `.gin` is the current extension. +fn remove_suffix_until_gin(p: &Path) -> PathBuf { + let mut p = p.to_path_buf(); + while p.extension().is_some() && p.extension().unwrap() != "gin" { + p = p.with_extension(""); + } + p +} + +/// Collect all extensions that come *after* `.gin` in a multi-extension name, +/// returned in forward order (e.g. `foo.gin.bar.baz` → `[".bar", ".baz"]`). +fn get_suffixes_after_gin(p: &Path) -> Vec { + let mut suffixes: Vec = Vec::new(); + let mut p = p.to_path_buf(); + while p.extension().is_some() && p.extension().unwrap() != "gin" { + suffixes.push(format!(".{}", p.extension().unwrap().to_string_lossy())); + p = p.with_extension(""); + } + suffixes.reverse(); + suffixes +} + +/// Parallel recursive directory walk using `jwalk`. +fn walk_dir(dir: &Path) -> Vec { + jwalk::WalkDir::new(dir) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .map(|e| strip_unc_prefix(e.path())) + .collect() +} + +/// Returns true if `path` is a valid Windows file path and is not equal to +/// `base_dir`. Replaces the `pathvalidate` Python dependency. +fn is_valid_windows_path(path: &Path, base_dir: &Path) -> bool { + if path == base_dir { + return false; + } + let s = path.to_string_lossy(); + const ILLEGAL: &[char] = &['"', '<', '>', '|', '?', '*']; + !s.chars().any(|c| ILLEGAL.contains(&c)) +} + +/// Build a destination path from a ship directory and a raw gin path string, +/// without calling `canonicalize()`. +/// +/// `canonicalize()` makes a syscall per file to verify the path exists on +/// disk, which is 150 k unnecessary syscalls. Since we construct paths from +/// known-absolute base directories we can just join and normalise manually. +fn build_dest_path(base: &Path, relative: &str) -> PathBuf { + // Normalise any forward-slash separators the embedded path may contain. + let normalised = relative.replace('/', std::path::MAIN_SEPARATOR_STR); + base.join(normalised) +} + +// ─── Section descriptor ─────────────────────────────────────────────────────── + +struct SectionInfo { + name: String, + offset: u64, + size_uncompressed: usize, + size_compressed: usize, + flags: Flags, +} + +// ─── Write task sent from worker threads to the I/O thread ─────────────────── + +struct WriteTask { + path: PathBuf, + data: Vec, +} + +// ─── Core decompiler ───────────────────────────────────────────────────────── + +/// Parse and decompile a single `.gin` file. +/// +/// Optimisations applied here: +/// - `memmap2`: file is memory-mapped rather than heap-allocated, letting the +/// OS page data in on demand and reducing peak RSS when many files are +/// decompiled in parallel. +/// - Parallel section extraction: decompression (CPU-bound) runs on Rayon +/// worker threads; completed `WriteTask`s are sent to a dedicated I/O +/// thread via a channel, decoupling decompression from write latency. +/// - The embedded gin path is read from the mmap'd data directly, so the +/// classification step in `decompile_to_structure` does not need to +/// re-open the file. +/// +/// Returns `(output_paths, gin_path_map)` where `gin_path_map` maps each +/// output `PathBuf` to the embedded gin path string found inside that section +/// (if any). This is consumed by `decompile_to_structure` to avoid a second +/// file open per output file. +pub fn decompile_file_inner( + file_path: &Path, + output_dir: &Path, + file_count_offset: usize, + include_number_prefix: bool, + silent: bool, +) -> PyResult<(Vec, Vec>)> { + // --- Memory-map the input file --- + let file = + fs::File::open(file_path).map_err(|e| PyFileNotFoundError::new_err(e.to_string()))?; + + // SAFETY: we treat the mapping as read-only and do not truncate the file. + let data = unsafe { Mmap::map(&file) }.map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + + if data.len() < HEADER_SIZE { + return Err(PyValueError::new_err("File too small to be a .gin file")); + } + + // --- 1. Validate magic --- + let magic = read_u32_le(&data, HEADER_MAGIC_OFFSET); + if magic != GIN_MAGIC_NUMBER { + return Err(PyValueError::new_err("Not a .gin file (bad magic number)")); + } + + // --- 2. Read section count --- + let section_count = read_u32_le(&data, HEADER_SECTION_COUNT_OFFSET) as usize; + if !silent { + println!("Found {} sections. Starting extraction...\n", section_count); + } + + // --- 3. Parse section table --- + let table_start = HEADER_SIZE; + if data.len() < table_start + section_count * SECT_ENTRY_SIZE { + return Err(PyValueError::new_err("File truncated in section table")); + } + + let sections: Vec = (0..section_count) + .map(|i| { + let base = table_start + i * SECT_ENTRY_SIZE; + SectionInfo { + name: read_cstr(&data[base..base + SECT_NAME_SIZE]), + offset: read_u64_le(&data, base + SECT_OFFSET_OFF), + size_uncompressed: read_u32_le(&data, base + SECT_SIZE_OFF) as usize, + size_compressed: read_u32_le(&data, base + SECT_CSIZE_OFF) as usize, + flags: Flags::from_bits_truncate(read_u32_le(&data, base + SECT_FLAGS_OFF)), + } + }) + .collect(); + + // --- 4. Extract sections (parallel decompress + pipelined I/O) --- + fs::create_dir_all(output_dir).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + + // Spawn a dedicated I/O thread that drains the channel and writes files. + // This decouples CPU-bound decompression from disk write latency: Rayon + // workers never block waiting for a write to complete. + let (tx, rx) = mpsc::channel::(); + let io_thread = thread::spawn(move || { + let mut write_errors: Vec = Vec::new(); + for task in rx { + if let Err(e) = fs::write(&task.path, &task.data) { + write_errors.push(format!("{}: {}", task.path.display(), e)); + } + } + write_errors + }); + + // Decompress all sections in parallel. Each section produces either a + // WriteTask (sent to the I/O thread) or nothing (on error / OOB). + // We collect (index, out_path, gin_path) for every successfully queued + // section so we can return a stable ordered list. + let section_results: Vec)>> = sections + .par_iter() + .enumerate() + .map(|(i, section)| { + let read_size = if section.size_compressed > 0 { + section.size_compressed + } else { + section.size_uncompressed + }; + + let start = section.offset as usize; + let end = start + read_size; + + if end > data.len() { + if !silent { + eprintln!(" [!] Section {} data out of bounds, skipping", i); + } + return None; + } + + let raw_data = &data[start..end]; + + let final_data = + match decompress_data(raw_data, section.flags, section.size_uncompressed) { + Some(d) => d, + None => { + if !silent { + eprintln!(" [!] Decompression failed for section {}", i); + } + return None; + } + }; + + // Extract the embedded gin path from the decompressed data while + // we already have it in memory, avoiding a second file open in + // the structuring step. + let gin_path = extract_gin_path_from_slice(&final_data); + + let safe_name = safe_filename(§ion.name); + let out_name = if include_number_prefix { + format!("{:04}_{}", i + file_count_offset, safe_name) + } else { + safe_name + }; + + if !silent { + let comp_tag = if section.flags.contains(Flags::ZSTD) { + "[ZSTD]" + } else if section.flags.contains(Flags::LZ4) { + "[LZ4]" + } else { + "[RAW]" + }; + println!( + "Extracted: {} {} ({} bytes)", + out_name, + comp_tag, + final_data.len() + ); + } + + let out_path = output_dir.join(&out_name); + Some((i, out_path, gin_path, final_data)) + }) + // Send write tasks to the I/O thread and strip the data field. + .map(|opt| { + opt.map(|(i, out_path, gin_path, final_data)| { + tx.send(WriteTask { + path: out_path.clone(), + data: final_data, + }) + .ok(); // I/O thread dropped = channel closed; ignore + (i, out_path, gin_path) + }) + }) + .collect(); + + // Drop the sender so the I/O thread's recv loop terminates. + drop(tx); + + // Wait for all writes to finish and surface any errors. + let write_errors = io_thread.join().unwrap_or_default(); + if !write_errors.is_empty() { + return Err(PyRuntimeError::new_err(write_errors.join("\n"))); + } + + // Collect results in section order (parallel iter may reorder). + let mut indexed: Vec<(usize, PathBuf, Option)> = + section_results.into_iter().flatten().collect(); + indexed.sort_unstable_by_key(|(i, _, _)| *i); + + let output_paths: Vec = indexed.iter().map(|(_, p, _)| p.clone()).collect(); + let gin_paths: Vec> = indexed.into_iter().map(|(_, _, g)| g).collect(); + + Ok((output_paths, gin_paths)) +} + +// ─── PyO3 wrappers ──────────────────────────────────────────────────────────── + +#[pyclass] +pub struct GinDecompiler { + silent: bool, +} + +#[pymethods] +impl GinDecompiler { + #[new] + #[pyo3(signature = (silent = true))] + fn new(silent: bool) -> Self { + GinDecompiler { silent } + } + + fn check_if_gin_file(&self, file_path: PathBuf) -> PyResult { + if !file_path.exists() { + return Err(PyFileNotFoundError::new_err(format!( + "File not found: {}", + file_path.display() + ))); + } + let mut f = + fs::File::open(&file_path).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + let mut buf = [0u8; 4]; + if f.read_exact(&mut buf).is_err() { + return Ok(false); + } + Ok(u32::from_le_bytes(buf) == GIN_MAGIC_NUMBER) + } + + #[pyo3(signature = (file_path, output_dir, file_count_offset = 0, include_number_prefix = true))] + fn decompile_file( + &self, + file_path: PathBuf, + output_dir: PathBuf, + file_count_offset: usize, + include_number_prefix: bool, + ) -> PyResult> { + let (paths, _) = decompile_file_inner( + &file_path, + &output_dir, + file_count_offset, + include_number_prefix, + self.silent, + )?; + Ok(paths + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect()) + } + + #[pyo3(signature = (input_paths, output_dir, include_number_prefix = true))] + fn decompile_multi( + &self, + input_paths: Vec, + output_dir: PathBuf, + include_number_prefix: bool, + ) -> PyResult> { + if output_dir.exists() { + fs::remove_dir_all(&output_dir).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + } + fs::create_dir_all(&output_dir).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + + let mut valid_paths: Vec = input_paths + .into_iter() + .filter(|p| { + if !p.exists() || !p.is_file() { + if !self.silent { + eprintln!("Skipping \"{}\": not a readable file", p.display()); + } + return false; + } + let mut buf = [0u8; 4]; + let ok = fs::File::open(p) + .and_then(|mut f| f.read_exact(&mut buf)) + .map(|_| u32::from_le_bytes(buf) == GIN_MAGIC_NUMBER) + .unwrap_or(false); + if !ok && !self.silent { + eprintln!("Skipping \"{}\": not a .gin file", p.display()); + } + ok + }) + .collect(); + + if valid_paths.is_empty() { + return Err(PyValueError::new_err( + "No .gin files found. Please select at least one .gin file.", + )); + } + + valid_paths.sort(); + + for file in &valid_paths { + let dir = output_dir.join(file.file_stem().unwrap_or_default()); + fs::create_dir_all(&dir).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + } + + let silent = self.silent; + + if include_number_prefix { + // Sequential: offsets must be stable. + let mut offset = 0usize; + let mut all: Vec = Vec::new(); + for file in &valid_paths { + let dir = output_dir.join(file.file_stem().unwrap_or_default()); + if !silent { + println!("Decompiling \"{}\"...", file.display()); + } + let (paths, _) = decompile_file_inner(file, &dir, offset, true, silent)?; + offset += paths.len(); + all.extend(paths.into_iter().map(|p| p.to_string_lossy().into_owned())); + } + Ok(all) + } else { + // Fully parallel. + let errors: Mutex> = Mutex::new(Vec::new()); + + let results: Vec> = valid_paths + .par_iter() + .map(|file| { + let dir = output_dir.join(file.file_stem().unwrap_or_default()); + if !silent { + println!("Decompiling \"{}\"...", file.display()); + } + match decompile_file_inner(file, &dir, 0, false, silent) { + Ok((paths, _)) => paths + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(), + Err(e) => { + errors.lock().unwrap().push(e.to_string()); + vec![] + } + } + }) + .collect(); + + let errs = errors.into_inner().unwrap(); + if !errs.is_empty() { + return Err(PyRuntimeError::new_err(errs.join("\n"))); + } + + Ok(results.into_iter().flatten().collect()) + } + } + + #[pyo3(signature = (input_paths, output_dir))] + fn decompile_to_structure( + &self, + input_paths: Vec, + output_dir: PathBuf, + ) -> PyResult<()> { + if output_dir.exists() { + fs::remove_dir_all(&output_dir).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + } + fs::create_dir_all(&output_dir).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + + let temp_dir = output_dir.join("decompiled"); + let ship_dir = output_dir.join("ship"); + let mappings_file = output_dir.join("mappings.json"); + + fs::create_dir_all(&temp_dir).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + fs::create_dir_all(&ship_dir).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + + // --- Step 1: Parallel decompile, capturing embedded gin paths --- + // We run decompile_multi manually here so we can harvest the gin path + // map from decompile_file_inner without a second file open. + let mut valid_paths: Vec = input_paths + .into_iter() + .filter(|p| { + if !p.exists() || !p.is_file() { + return false; + } + let mut buf = [0u8; 4]; + fs::File::open(p) + .and_then(|mut f| f.read_exact(&mut buf)) + .map(|_| u32::from_le_bytes(buf) == GIN_MAGIC_NUMBER) + .unwrap_or(false) + }) + .collect(); + + if valid_paths.is_empty() { + return Err(PyValueError::new_err( + "No .gin files found. Please select at least one .gin file.", + )); + } + + valid_paths.sort(); + + for file in &valid_paths { + fs::create_dir_all(temp_dir.join(file.file_stem().unwrap_or_default())).ok(); + } + + let silent = self.silent; + + // Collect (output_path, embedded_gin_path) for every extracted section. + // This map is used in the classification step below so we never need to + // re-open any output file. + let decompile_results: Vec<(PathBuf, Option)> = valid_paths + .par_iter() + .flat_map(|file| { + let dir = temp_dir.join(file.file_stem().unwrap_or_default()); + match decompile_file_inner(file, &dir, 0, false, silent) { + Ok((paths, gin_paths)) => paths + .into_iter() + .zip(gin_paths.into_iter()) + .collect::>(), + Err(_) => vec![], + } + }) + .collect(); + + println!("Structuring {} files...", decompile_results.len()); + + // --- Step 2: Classify each file in parallel --- + // Using the pre-captured gin paths avoids re-opening every file. + // + // dest paths are built with `build_dest_path` instead of + // `canonicalize()`, saving one syscall per file. + #[derive(Debug)] + enum Classification { + Skip, + Place(PathBuf), + } + + let ship_dir_ref = &ship_dir; + + let classified: Vec<(PathBuf, Classification)> = decompile_results + .par_iter() + .map(|(path, embedded_gin_path)| { + let ext = path + .extension() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + let is_metadata_ext = matches!(ext.as_str(), "reloc" | "alloc" | "assets"); + let in_assets_dir = path + .parent() + .map(|p| p.file_name().unwrap_or_default() == "assets") + .unwrap_or(false); + + if is_metadata_ext && !in_assets_dir { + return (path.clone(), Classification::Skip); + } + + let dest: PathBuf = if in_assets_dir { + ship_dir_ref + .join("decomp_assets") + .join(path.file_name().unwrap_or_default()) + } else if matches!(ext.as_str(), "csv" | "otf" | "ttf") { + ship_dir_ref + .join("fonts") + .join(path.file_name().unwrap_or_default()) + } else { + match embedded_gin_path { + Some(gin_path) => build_dest_path(ship_dir_ref, gin_path), + None => ship_dir_ref.join(path.file_name().unwrap_or_default()), + } + }; + + if is_valid_windows_path(&dest, ship_dir_ref) { + (path.clone(), Classification::Place(dest)) + } else { + (path.clone(), Classification::Skip) + } + }) + .collect(); + + // --- Step 3: Partition, deduplicate dirs, parallel copy --- + let mut skipped_paths: Vec = Vec::new(); + + let (to_place, to_skip): (Vec<_>, Vec<_>) = classified + .into_iter() + .partition(|(_, c)| matches!(c, Classification::Place(_))); + + for (path, _) in to_skip { + skipped_paths.push(path); + } + + // Deduplicate parent directories before creating them. + let unique_dirs: HashSet = to_place + .iter() + .filter_map(|(_, c)| { + if let Classification::Place(dest) = c { + dest.parent().map(PathBuf::from) + } else { + None + } + }) + .collect(); + + for dir in &unique_dirs { + fs::create_dir_all(dir).ok(); + } + + // Parallel copy — collect (src, dest, ok) triples. + let copy_results: Vec<(PathBuf, PathBuf, bool)> = to_place + .par_iter() + .map(|(src, classification)| { + if let Classification::Place(dest) = classification { + let ok = fs::copy(src, dest).is_ok(); + (src.clone(), dest.clone(), ok) + } else { + unreachable!() + } + }) + .collect(); + + // Build structure_mappings as a sorted Vec for cache-friendly binary + // search in Step 4, rather than a HashMap. + let mut structure_mappings: Vec<(PathBuf, PathBuf)> = copy_results + .into_iter() + .filter_map(|(src, dest, ok)| if ok { Some((src, dest)) } else { None }) + .collect(); + structure_mappings.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); + + // Binary search helper. + let find_mapping = |key: &Path| -> Option<&PathBuf> { + structure_mappings + .binary_search_by(|(k, _)| k.as_path().cmp(key)) + .ok() + .map(|idx| &structure_mappings[idx].1) + }; + + // --- Step 4: Resolve skipped (reloc/alloc) files in parallel --- + // Each skipped file is matched against the sorted mappings via binary + // search, then copied to its final destination. + let skip_results: Vec> = skipped_paths + .par_iter() + .map(|path| { + // Special-case for the one known-broken filename. + if path.file_name().unwrap_or_default() + == "ST_factory_factory_pearl.ST_factory_turning_stop_pearl_inverted" + { + let matched = path + .parent() + .unwrap() + .join("ST_factory_factory_pearl.ST_factory_turning_stop_pearl.gin"); + if let Some(mapped) = find_mapping(&matched) { + let dest = mapped.parent().unwrap().join(format!( + "{}.ST_factory_turning_stop_pearl_inverted", + mapped + .with_extension("") + .file_name() + .unwrap() + .to_string_lossy() + )); + fs::copy(path, &dest).ok(); + return Some((path.clone(), dest)); + } + return None; + } + + let no_ext_path = remove_all_suffixes(path); + let gin_path = { + let mut p = remove_suffix_until_gin(path); + if p.extension().unwrap_or_default() != "gin" { + p.set_extension("gin"); + } + p + }; + + let (matched_path, file_suffixes): (PathBuf, Vec) = + if find_mapping(&gin_path).is_some() { + let mut s = vec![".gin".to_string()]; + s.extend(get_suffixes_after_gin(path)); + (gin_path, s) + } else if find_mapping(&no_ext_path).is_some() { + (no_ext_path, get_suffixes_after_gin(path)) + } else { + println!("NO MATCH FOUND. {}", path.display()); + return None; + }; + + if let Some(mapped) = find_mapping(&matched_path) { + let dest = mapped.parent().unwrap().join(format!( + "{}{}", + mapped.file_stem().unwrap().to_string_lossy(), + file_suffixes.join("") + )); + fs::copy(path, &dest).ok(); + Some((path.clone(), dest)) + } else { + None + } + }) + .collect(); + + // Merge skip results into structure_mappings and re-sort. + for entry in skip_results.into_iter().flatten() { + structure_mappings.push(entry); + } + structure_mappings.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); + + // --- Step 5: Write mappings.json --- + // structure_mappings is already sorted, so we just iterate it. + let json_obj = serde_json::Value::Object( + structure_mappings + .into_iter() + .map(|(k, v)| { + ( + strip_unc_prefix(k).to_string_lossy().into_owned(), + serde_json::Value::String( + strip_unc_prefix(v).to_string_lossy().into_owned(), + ), + ) + }) + .collect(), + ); + + fs::write( + &mappings_file, + serde_json::to_string_pretty(&json_obj).unwrap(), + ) + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0e2431f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,10 @@ +mod constants; +mod decompiler; + +use pyo3::prelude::*; + +#[pymodule] +fn _mio_decomp(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} diff --git a/uv.lock b/uv.lock index 9ed1aa4..ec305d4 100644 --- a/uv.lock +++ b/uv.lock @@ -76,6 +76,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "maturin" +version = "1.12.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/18/8b2eebd3ea086a5ec73d7081f95ec64918ceda1900075902fc296ea3ad55/maturin-1.12.6.tar.gz", hash = "sha256:d37be3a811a7f2ee28a0fa0964187efa50e90f21da0c6135c27787fa0b6a89db", size = 269165, upload-time = "2026-03-01T14:54:04.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/8b/9ddfde8a485489e3ebdc50ee3042ef1c854f00dfea776b951068f6ffe451/maturin-1.12.6-py3-none-linux_armv6l.whl", hash = "sha256:6892b4176992fcc143f9d1c1c874a816e9a041248eef46433db87b0f0aff4278", size = 9789847, upload-time = "2026-03-01T14:54:09.172Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e8/5f7fd3763f214a77ac0388dbcc71cc30aec5490016bd0c8e6bd729fc7b0a/maturin-1.12.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c0c742beeeef7fb93b6a81bd53e75507887e396fd1003c45117658d063812dad", size = 19023833, upload-time = "2026-03-01T14:53:46.743Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7f/706ff3839c8b2046436d4c2bc97596c558728264d18abc298a1ad862a4be/maturin-1.12.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2cb41139295eed6411d3cdafc7430738094c2721f34b7eeb44f33cac516115dc", size = 9821620, upload-time = "2026-03-01T14:54:12.04Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9c/70917fb123c8dd6b595e913616c9c72d730cbf4a2b6cac8077dc02a12586/maturin-1.12.6-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:351f3af1488a7cbdcff3b6d8482c17164273ac981378a13a4a9937a49aec7d71", size = 9849107, upload-time = "2026-03-01T14:53:48.971Z" }, + { url = "https://files.pythonhosted.org/packages/59/ea/f1d6ad95c0a12fbe761a7c28a57540341f188564dbe8ad730a4d1788cd32/maturin-1.12.6-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:6dbddfe4dc7ddee60bbac854870bd7cfec660acb54d015d24597d59a1c828f61", size = 10242855, upload-time = "2026-03-01T14:53:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/93/1b/2419843a4f1d2fb4747f3dc3d9c4a2881cd97a3274dd94738fcdf0835e79/maturin-1.12.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:8fdb0f63e77ee3df0f027a120e9af78dbc31edf0eb0f263d55783c250c33b728", size = 9674972, upload-time = "2026-03-01T14:53:52.763Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b60ab2fc996d904b40e55bd475599dcdccd8f7ad3e649bf95e87970df466/maturin-1.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fa84b7493a2e80759cacc2e668fa5b444d55b9994e90707c42904f55d6322c1e", size = 9645755, upload-time = "2026-03-01T14:53:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/a4/96/03f2b55a8c226805115232fc23c4a4f33f0c9d39e11efab8166dc440f80d/maturin-1.12.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:e90dc12bc6a38e9495692a36c9e231c4d7e0c9bfde60719468ab7d8673db3c45", size = 12737612, upload-time = "2026-03-01T14:54:05.393Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c2/648667022c5b53cdccefa67c245e8a984970f3045820f00c2e23bdb2aff4/maturin-1.12.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06fc8d089f98623ce924c669b70911dfed30f9a29956c362945f727f9abc546b", size = 10455028, upload-time = "2026-03-01T14:54:07.349Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/5b5efe3ca0c043357ed3f8d2b2d556169fdbf1ff75e50e8e597708a359d2/maturin-1.12.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:75133e56274d43b9227fd49dca9a86e32f1fd56a7b55544910c4ce978c2bb5aa", size = 10014531, upload-time = "2026-03-01T14:53:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/68/d5/39c594c27b1a8b32a0cb95fff9ad60b888c4352d1d1c389ac1bd20dc1e16/maturin-1.12.6-py3-none-win32.whl", hash = "sha256:3f32e0a3720b81423c9d35c14e728cb1f954678124749776dc72d533ea1115e8", size = 8553012, upload-time = "2026-03-01T14:53:50.706Z" }, + { url = "https://files.pythonhosted.org/packages/94/66/b262832a91747e04051e21f986bd01a8af81fbffafacc7d66a11e79aab5f/maturin-1.12.6-py3-none-win_amd64.whl", hash = "sha256:977290159d252db946054a0555263c59b3d0c7957135c69e690f4b1558ee9983", size = 9890470, upload-time = "2026-03-01T14:53:56.659Z" }, + { url = "https://files.pythonhosted.org/packages/e3/47/76b8ca470ddc8d7d36aa8c15f5a6aed1841806bb93a0f4ead8ee61e9a088/maturin-1.12.6-py3-none-win_arm64.whl", hash = "sha256:bae91976cdc8148038e13c881e1e844e5c63e58e026e8b9945aa2d19b3b4ae89", size = 8606158, upload-time = "2026-03-01T14:54:02.423Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -87,7 +108,7 @@ wheels = [ [[package]] name = "mio-decomp" -version = "0.3.1" +version = "0.3.2" source = { editable = "." } dependencies = [ { name = "lz4" }, @@ -98,6 +119,11 @@ dependencies = [ { name = "zstandard" }, ] +[package.dev-dependencies] +dev = [ + { name = "maturin" }, +] + [package.metadata] requires-dist = [ { name = "lz4", specifier = ">=4.4.5" }, @@ -108,6 +134,9 @@ requires-dist = [ { name = "zstandard", specifier = ">=0.25.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "maturin", specifier = ">=1.12.6" }] + [[package]] name = "pathvalidate" version = "3.3.1"