Skip to content

Instantly share code, notes, and snippets.

@nitanmarcel
Last active September 15, 2026 13:01
Show Gist options
  • Select an option

  • Save nitanmarcel/4a3c2346d0e898ca87a8692d92a263de to your computer and use it in GitHub Desktop.

Select an option

Save nitanmarcel/4a3c2346d0e898ca87a8692d92a263de to your computer and use it in GitHub Desktop.
#!/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

Copy link
Copy Markdown
Author
onigiri2oiv

@nitanmarcel

nitanmarcel commented Sep 5, 2026 •

Copy link
Copy Markdown
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