Last active
September 15, 2026 13:01
-
-
Save nitanmarcel/4a3c2346d0e898ca87a8692d92a263de to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| onigiri2oiv.py - convert any "onigiri" mod into an OpenIV package (.oiv). | |
| onigiri\\common -> common:/ -> mods\\update\\update.rpf\\common | |
| onigiri\\platform -> platform:/ -> mods\\update\\update.rpf\\x64 | |
| onigiri\\platform2 -> platform2:/ -> mods\\update\\update2.rpf\\x64 | |
| onigiri\\dlcpacks -> dlcpacks:/ -> mods\\update\\x64\\dlcpacks | |
| onigiri\\<other> -> (not mounted) -> mods\\update\\update.rpf\\<other> (just an assumption, mods like DriveV has an extra dlc_patch folder. | |
| While I couldn't find hints in the .asi on how it's handled, I assumed any unmapped root folders are automatically mapped to update.rpf) | |
| """ | |
| from __future__ import annotations | |
| import queue | |
| import threading | |
| import tkinter as tk | |
| import traceback | |
| import uuid | |
| import zipfile | |
| from pathlib import Path, PurePosixPath | |
| from tkinter import filedialog, messagebox, ttk | |
| from xml.sax.saxutils import escape | |
| ROOTS = { | |
| "common": ("update\\update.rpf", "common"), | |
| "platform": ("update\\update.rpf", "x64"), | |
| "platform2": ("update\\update2.rpf", "x64"), | |
| "dlcpacks": (None, "update\\x64\\dlcpacks"), | |
| } | |
| UPDATE_RPF = "update\\update.rpf" | |
| STORE_EXT = {".rpf", ".ytd", ".ydr", ".ydd", ".yft", ".ybn", ".awc", ".png", ".jpg"} | |
| class Entry: | |
| __slots__ = ("src", "content_rel", "target", "archive", "dest", "size") | |
| def __init__(self, src, content_rel, target, archive, dest, size): | |
| self.src = src | |
| self.content_rel = content_rel | |
| self.target = target | |
| self.archive = archive | |
| self.dest = dest | |
| self.size = size | |
| def human(n): | |
| size = float(n) | |
| for unit in ("B", "KB", "MB", "GB", "TB"): | |
| if size < 1024 or unit == "TB": | |
| return "%d B" % size if unit == "B" else "%.1f %s" % (size, unit) | |
| size /= 1024.0 | |
| def check_onigiri(path): | |
| path = Path(path) | |
| if not any((path / r).is_dir() for r in ROOTS): | |
| raise ValueError("Not an onigiri folder.") | |
| return path | |
| def scan(onigiri): | |
| entries, skipped = [], [] | |
| for child in sorted(onigiri.iterdir(), key=lambda p: p.name.lower()): | |
| key = child.name.lower() | |
| if not child.is_dir(): | |
| skipped.append(child.name) | |
| continue | |
| if key in ROOTS: | |
| archive, prefix = ROOTS[key] | |
| else: | |
| archive, prefix = UPDATE_RPF, child.name | |
| dest = "%s\\%s" % (archive, prefix) if archive else prefix | |
| for path in sorted(child.rglob("*")): | |
| if not path.is_file(): | |
| continue | |
| rel = str(path.relative_to(child)).replace("/", "\\") | |
| entries.append(Entry(path, key + "\\" + rel, prefix + "\\" + rel, | |
| archive, dest, path.stat().st_size)) | |
| return entries, skipped | |
| def build_assembly(entries, name, author, major, minor, description): | |
| by_archive, root_adds = {}, [] | |
| for e in entries: | |
| line = '<add source="%s">%s</add>' % (escape(e.content_rel), escape(e.target)) | |
| if e.archive: | |
| by_archive.setdefault(e.archive, []).append(" " + line) | |
| else: | |
| root_adds.append(" " + line) | |
| pkg_id = "{%s}" % uuid.uuid5(uuid.NAMESPACE_URL, | |
| "onigiri2oiv/%s/%s/%d.%d" % (name, author, major, minor)) | |
| out = [ | |
| '<?xml version="1.0" encoding="UTF-8"?>', | |
| '<package version="2.1" id="%s" target="Five">' % pkg_id, | |
| ' <metadata>', | |
| ' <name>%s</name>' % escape(name), | |
| ' <version>', | |
| ' <major>%d</major>' % major, | |
| ' <minor>%d</minor>' % minor, | |
| ' </version>', | |
| ' <author>', | |
| ' <displayName>%s</displayName>' % escape(author), | |
| ' </author>', | |
| ' <description><![CDATA[%s]]></description>' % description.replace("]]>", ""), | |
| ' </metadata>', | |
| ' <colors>', | |
| ' <headerBackground useBlackTextColor="False">$ff1b1b1b</headerBackground>', | |
| ' <iconBackground>$ff2e7d32</iconBackground>', | |
| ' </colors>', | |
| ' <content>', | |
| ] | |
| for archive, adds in by_archive.items(): | |
| out.append(' <archive path="%s" createIfNotExist="False" type="RPF7">' | |
| % archive) | |
| out += adds | |
| out.append(' </archive>') | |
| out += root_adds | |
| out += [' </content>', '</package>', ''] | |
| return "\n".join(out) | |
| def png_size(path): | |
| data = Path(path).read_bytes()[:24] | |
| if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n": | |
| return None | |
| return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big") | |
| def write_package(out_path, assembly, entries, icon=None, progress=None): | |
| total = sum(e.size for e in entries) | |
| done = 0 | |
| tmp = out_path.with_suffix(out_path.suffix + ".part") | |
| try: | |
| with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED, | |
| allowZip64=True, compresslevel=1) as z: | |
| z.writestr("assembly.xml", assembly) | |
| if icon: | |
| z.write(icon, "icon.png", compress_type=zipfile.ZIP_STORED) | |
| for e in entries: | |
| arc = "content/" + str(PurePosixPath(*e.content_rel.split("\\"))) | |
| store = e.src.suffix.lower() in STORE_EXT | |
| z.write(e.src, arc, compress_type=(zipfile.ZIP_STORED if store | |
| else zipfile.ZIP_DEFLATED)) | |
| done += e.size | |
| if progress: | |
| progress(done, total, arc, e.size) | |
| tmp.replace(out_path) | |
| except BaseException: | |
| tmp.unlink(missing_ok=True) | |
| raise | |
| class App(ttk.Frame): | |
| def __init__(self, master): | |
| super().__init__(master, padding=10) | |
| self.grid(row=0, column=0, sticky="nsew") | |
| master.columnconfigure(0, weight=1) | |
| master.rowconfigure(0, weight=1) | |
| self.columnconfigure(1, weight=1) | |
| self.messages = queue.Queue() | |
| self.entries = [] | |
| self.onigiri = None | |
| self.worker = None | |
| self.src_var = tk.StringVar() | |
| self.out_var = tk.StringVar() | |
| self.name_var = tk.StringVar() | |
| self.author_var = tk.StringVar() | |
| self.major_var = tk.StringVar(value="1") | |
| self.minor_var = tk.StringVar(value="0") | |
| self.icon_var = tk.StringVar() | |
| self.status_var = tk.StringVar(value="Select an onigiri folder.") | |
| row = 0 | |
| ttk.Label(self, text="onigiri folder").grid(row=row, column=0, sticky="w") | |
| ttk.Entry(self, textvariable=self.src_var).grid(row=row, column=1, sticky="ew", | |
| padx=6) | |
| ttk.Button(self, text="Browse...", command=self.pick_source).grid(row=row, | |
| column=2) | |
| row += 1 | |
| ttk.Label(self, text="Output package").grid(row=row, column=0, sticky="w", | |
| pady=(6, 0)) | |
| ttk.Entry(self, textvariable=self.out_var).grid(row=row, column=1, sticky="ew", | |
| padx=6, pady=(6, 0)) | |
| ttk.Button(self, text="Save as...", command=self.pick_output).grid( | |
| row=row, column=2, pady=(6, 0)) | |
| row += 1 | |
| meta = ttk.LabelFrame(self, text="Package metadata", padding=8) | |
| meta.grid(row=row, column=0, columnspan=3, sticky="ew", pady=10) | |
| meta.columnconfigure(1, weight=1) | |
| ttk.Label(meta, text="Name").grid(row=0, column=0, sticky="w") | |
| ttk.Entry(meta, textvariable=self.name_var).grid(row=0, column=1, sticky="ew", | |
| padx=6) | |
| ttk.Label(meta, text="Author").grid(row=1, column=0, sticky="w", pady=(4, 0)) | |
| ttk.Entry(meta, textvariable=self.author_var).grid(row=1, column=1, sticky="ew", | |
| padx=6, pady=(4, 0)) | |
| ver = ttk.Frame(meta) | |
| ver.grid(row=0, column=2, rowspan=2, sticky="e") | |
| ttk.Label(ver, text="Version").grid(row=0, column=0, padx=(10, 4)) | |
| ttk.Entry(ver, textvariable=self.major_var, width=4).grid(row=0, column=1) | |
| ttk.Label(ver, text=".").grid(row=0, column=2) | |
| ttk.Entry(ver, textvariable=self.minor_var, width=4).grid(row=0, column=3) | |
| ttk.Label(meta, text="Icon (optional)").grid(row=2, column=0, sticky="w", | |
| pady=(4, 0)) | |
| ttk.Entry(meta, textvariable=self.icon_var).grid(row=2, column=1, sticky="ew", | |
| padx=6, pady=(4, 0)) | |
| ttk.Button(meta, text="128x128 png...", command=self.pick_icon).grid( | |
| row=2, column=2, sticky="e", pady=(4, 0)) | |
| row += 1 | |
| self.summary = ttk.Label(self, text="", justify="left") | |
| self.summary.grid(row=row, column=0, columnspan=3, sticky="w") | |
| row += 1 | |
| self.bar = ttk.Progressbar(self, maximum=1000) | |
| self.bar.grid(row=row, column=0, columnspan=3, sticky="ew", pady=8) | |
| row += 1 | |
| self.rowconfigure(row, weight=1) | |
| wrap = ttk.Frame(self) | |
| wrap.grid(row=row, column=0, columnspan=3, sticky="nsew") | |
| wrap.columnconfigure(0, weight=1) | |
| wrap.rowconfigure(0, weight=1) | |
| self.log = tk.Text(wrap, height=14, wrap="none", state="disabled") | |
| self.log.grid(row=0, column=0, sticky="nsew") | |
| scroll = ttk.Scrollbar(wrap, orient="vertical", command=self.log.yview) | |
| scroll.grid(row=0, column=1, sticky="ns") | |
| self.log.configure(yscrollcommand=scroll.set) | |
| row += 1 | |
| bottom = ttk.Frame(self) | |
| bottom.grid(row=row, column=0, columnspan=3, sticky="ew", pady=(8, 0)) | |
| bottom.columnconfigure(0, weight=1) | |
| ttk.Label(bottom, textvariable=self.status_var).grid(row=0, column=0, | |
| sticky="w") | |
| self.build_btn = ttk.Button(bottom, text="Build package", | |
| command=self.start_build, state="disabled") | |
| self.build_btn.grid(row=0, column=1, sticky="e") | |
| self.after(100, self.pump) | |
| def write(self, text): | |
| self.log.configure(state="normal") | |
| self.log.insert("end", text + "\n") | |
| self.log.see("end") | |
| self.log.configure(state="disabled") | |
| def pick_source(self): | |
| chosen = filedialog.askdirectory(title="Select the onigiri folder") | |
| if chosen: | |
| self.src_var.set(chosen) | |
| self.rescan() | |
| def pick_output(self): | |
| chosen = filedialog.asksaveasfilename( | |
| title="Save package as", defaultextension=".oiv", | |
| filetypes=[("OpenIV package", "*.oiv"), ("All files", "*.*")], | |
| initialfile=self.out_var.get() or "mod.oiv") | |
| if chosen: | |
| self.out_var.set(chosen) | |
| def pick_icon(self): | |
| chosen = filedialog.askopenfilename(title="Select icon.png", | |
| filetypes=[("PNG image", "*.png")]) | |
| if not chosen: | |
| return | |
| if png_size(chosen) != (128, 128): | |
| messagebox.showerror("Icon", "Must be a 128x128 PNG.") | |
| return | |
| self.icon_var.set(chosen) | |
| def rescan(self): | |
| source = self.src_var.get().strip() | |
| self.entries = [] | |
| self.build_btn.configure(state="disabled") | |
| if not source: | |
| return | |
| try: | |
| self.onigiri = check_onigiri(source) | |
| self.entries, skipped = scan(self.onigiri) | |
| except Exception as exc: | |
| self.summary.configure(text="") | |
| self.status_var.set(str(exc)) | |
| self.write("error: %s" % exc) | |
| return | |
| if not self.entries: | |
| self.status_var.set("Nothing to package.") | |
| return | |
| roots = {} | |
| for e in self.entries: | |
| name = e.content_rel.split("\\")[0] | |
| roots.setdefault(name, [0, 0, e.dest]) | |
| roots[name][0] += 1 | |
| roots[name][1] += e.size | |
| lines = ["onigiri folder: %s" % self.onigiri] | |
| for name in sorted(roots): | |
| count, size, dest = roots[name] | |
| lines.append(" %-10s %4d files %9s -> %s" | |
| % (name, count, human(size), dest)) | |
| total = sum(e.size for e in self.entries) | |
| lines.append(" total %4d files %9s" % (len(self.entries), human(total))) | |
| self.summary.configure(text="\n".join(lines)) | |
| self.write("scanned %s" % self.onigiri) | |
| for name in skipped: | |
| self.write(" %s (skipped)" % name) | |
| mod_root = self.onigiri.parent | |
| if not self.name_var.get(): | |
| self.name_var.set(mod_root.name) | |
| if not self.author_var.get(): | |
| self.author_var.set("Unknown") | |
| if not self.out_var.get(): | |
| self.out_var.set(str(mod_root / (mod_root.name + ".oiv"))) | |
| self.status_var.set("Ready: %d files, %s." % (len(self.entries), human(total))) | |
| self.build_btn.configure(state="normal") | |
| def start_build(self): | |
| if not self.entries: | |
| return | |
| out = self.out_var.get().strip() | |
| if not out: | |
| messagebox.showerror("Output", "Choose an output file.") | |
| return | |
| try: | |
| major, minor = int(self.major_var.get()), int(self.minor_var.get()) | |
| except ValueError: | |
| messagebox.showerror("Version", "Version must be numbers.") | |
| return | |
| name = self.name_var.get().strip() or Path(out).stem | |
| author = self.author_var.get().strip() or "Unknown" | |
| assembly = build_assembly(self.entries, name, author, major, minor, | |
| "Converted from onigiri.") | |
| icon = self.icon_var.get().strip() or None | |
| self.build_btn.configure(state="disabled") | |
| self.bar["value"] = 0 | |
| self.write("") | |
| self.write("packing %d files into %s" % (len(self.entries), out)) | |
| self.worker = threading.Thread( | |
| target=self.run_build, args=(Path(out), assembly, icon), daemon=True) | |
| self.worker.start() | |
| def run_build(self, out, assembly, icon): | |
| def progress(done, total, arc, size): | |
| self.messages.put(("progress", done / total if total else 1.0, | |
| " [%5.1f%%] %s (%s)" | |
| % (done / total * 100 if total else 100.0, arc, | |
| human(size)))) | |
| try: | |
| write_package(out, assembly, self.entries, icon, progress) | |
| self.messages.put(("done", str(out), human(out.stat().st_size))) | |
| except Exception: | |
| self.messages.put(("failed", traceback.format_exc(), None)) | |
| def pump(self): | |
| try: | |
| while True: | |
| kind, a, b = self.messages.get_nowait() | |
| if kind == "progress": | |
| self.bar["value"] = a * 1000 | |
| self.status_var.set("Packing... %.1f%%" % (a * 100)) | |
| self.write(b) | |
| elif kind == "done": | |
| self.bar["value"] = 1000 | |
| self.write("done: %s (%s)" % (a, b)) | |
| self.status_var.set("Done: %s (%s)" % (a, b)) | |
| self.build_btn.configure(state="normal") | |
| messagebox.showinfo("Done", "%s (%s)" % (a, b)) | |
| elif kind == "failed": | |
| self.write(a) | |
| self.status_var.set("Failed.") | |
| self.build_btn.configure(state="normal") | |
| messagebox.showerror("Build failed", a.strip().splitlines()[-1]) | |
| except queue.Empty: | |
| pass | |
| self.after(100, self.pump) | |
| def main(): | |
| root = tk.Tk() | |
| root.title("onigiri -> OIV") | |
| root.geometry("880x640") | |
| root.minsize(700, 520) | |
| App(root) | |
| root.mainloop() | |
| if __name__ == "__main__": | |
| main() |
nitanmarcel
commented
Sep 5, 2026
Author
Author
The self contained exe can be found now on nexusmods. Thanks to the moderators there for building it and validating the false positive on VirusTotal. Can't say the same about the gta5-mods moderators that didn't even bothered reviewing the file. ๐
Self contained exe link - https://drive.google.com/file/d/1crcwWk4vT67Mzp8VbSytA56mB2EhBjE0/view?usp=sharing
Currently waiting for the nexusmods support to review the .exe uploaded there due to the packaging script used triggers some false-positive VirusTotal detections (seems like AI slop hit malware detection too).
If you feel unsafe running the .exe, the .py file is easily readable and runnable as long as you have Python installed in your system.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment