Last active
April 12, 2026 15:03
-
-
Save developerfromjokela/9c1ceb0a9e448460d0ad7a12cf716e1c to your computer and use it in GitHub Desktop.
Binary protocol diff tool, helps analyzing unknown binary protocols based on multiple capture datas
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
| import tkinter as tk | |
| from tkinter import ttk, filedialog | |
| import os | |
| import colorsys | |
| FILE_HUES = [0.60, 0.05, 0.35, 0.15, 0.75, 0.50, 0.90, 0.28] | |
| FONT_FAMILY = "Courier New" | |
| FONT_SIZE = 11 | |
| LABEL_WIDTH = 180 | |
| MATCH_FG = "#888888" | |
| def hue_fg(hue): | |
| r, g, b = colorsys.hls_to_rgb(hue, 0.28, 0.85) | |
| return "#{:02x}{:02x}{:02x}".format(int(r*255), int(g*255), int(b*255)) | |
| def hue_bg(hue): | |
| r, g, b = colorsys.hls_to_rgb(hue, 0.85, 0.80) | |
| return "#{:02x}{:02x}{:02x}".format(int(r*255), int(g*255), int(b*255)) | |
| class BinaryDiffViewer(tk.Tk): | |
| def __init__(self): | |
| super().__init__() | |
| self.title("Binary Diff Viewer") | |
| self.geometry("1100x520") | |
| self.minsize(700, 300) | |
| self.files: list[tuple[str, bytes]] = [] | |
| self._text_widgets: list[tk.Text] = [] | |
| self.view_mode = tk.StringVar(value="byte") # "byte" or "bit" | |
| self._build_ui() | |
| def _build_ui(self): | |
| # Toolbar | |
| top = ttk.Frame(self, padding=(8, 6)) | |
| top.pack(fill="x", side="top") | |
| ttk.Button(top, text="Add Files…", command=self._add_files).pack(side="left", padx=2) | |
| ttk.Button(top, text="Clear", command=self._clear).pack(side="left", padx=2) | |
| ttk.Separator(top, orient="vertical").pack(side="left", fill="y", padx=6) | |
| ttk.Button(top, text="Refresh", command=self._render).pack(side="left", padx=2) | |
| ttk.Separator(top, orient="vertical").pack(side="left", fill="y", padx=6) | |
| # View mode toggle | |
| ttk.Label(top, text="View:").pack(side="left", padx=(4, 2)) | |
| ttk.Radiobutton(top, text="Byte", variable=self.view_mode, | |
| value="byte", command=self._render).pack(side="left", padx=2) | |
| ttk.Radiobutton(top, text="Bit", variable=self.view_mode, | |
| value="bit", command=self._render).pack(side="left", padx=2) | |
| ttk.Separator(self).pack(fill="x") | |
| # Status bar | |
| bot = ttk.Frame(self, padding=(8, 3)) | |
| bot.pack(fill="x", side="bottom") | |
| ttk.Separator(self).pack(fill="x", side="bottom") | |
| self.status_var = tk.StringVar(value="No files loaded.") | |
| ttk.Label(bot, textvariable=self.status_var).pack(side="left") | |
| # Shared horizontal scrollbar | |
| self.hscroll = ttk.Scrollbar(self, orient="horizontal") | |
| self.hscroll.pack(side="bottom", fill="x") | |
| # Main area: canvas + vscroll | |
| body = ttk.Frame(self) | |
| body.pack(fill="both", expand=True) | |
| body.columnconfigure(0, weight=1) | |
| body.rowconfigure(0, weight=1) | |
| vscroll = ttk.Scrollbar(body, orient="vertical") | |
| vscroll.grid(row=0, column=1, sticky="ns") | |
| self.canvas = tk.Canvas(body, highlightthickness=0, | |
| yscrollcommand=vscroll.set, borderwidth=0) | |
| self.canvas.grid(row=0, column=0, sticky="nsew") | |
| vscroll.config(command=self.canvas.yview) | |
| # Inner frame: each row is label + data side by side | |
| self.inner = tk.Frame(self.canvas) | |
| self._cwin = self.canvas.create_window(0, 0, anchor="nw", window=self.inner) | |
| self.inner.columnconfigure(1, weight=1) # data column expands | |
| self.canvas.bind("<Configure>", | |
| lambda e: self.canvas.itemconfig(self._cwin, width=e.width)) | |
| self.inner.bind("<Configure>", | |
| lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))) | |
| self.canvas.bind("<MouseWheel>", self._wheel_y) | |
| self.canvas.bind("<Button-4>", self._wheel_y) | |
| self.canvas.bind("<Button-5>", self._wheel_y) | |
| self._show_placeholder() | |
| def _add_files(self): | |
| paths = filedialog.askopenfilenames( | |
| title="Select binary files", | |
| filetypes=[("All files", "*.*"), | |
| ("Binary", "*.bin *.exe *.dat *.rom *.img *.raw")]) | |
| added = False | |
| for p in paths: | |
| if not any(p == f[0] for f in self.files): | |
| with open(p, "rb") as fh: | |
| self.files.append((p, fh.read())) | |
| added = True | |
| if added: | |
| self._render() | |
| def _clear(self): | |
| self.files.clear() | |
| self._render() | |
| def _remove(self, idx): | |
| self.files.pop(idx) | |
| self._render() | |
| def _show_placeholder(self): | |
| for w in self.inner.winfo_children(): | |
| w.destroy() | |
| ttk.Label(self.inner, | |
| text="\n Open files with 'Add Files…' to begin.\n").grid( | |
| row=0, column=0, columnspan=2, sticky="w", padx=30, pady=40) | |
| def _render(self): | |
| if self.view_mode.get() == "bit": | |
| self._render_bit() | |
| else: | |
| self._render_byte() | |
| def _render_byte(self): | |
| """Original byte-level rendering""" | |
| self._text_widgets = [] | |
| for w in self.inner.winfo_children(): | |
| w.destroy() | |
| if not self.files: | |
| self._show_placeholder() | |
| self.status_var.set("No files loaded.") | |
| self.hscroll.config(command=lambda *a: None) | |
| return | |
| sysbg = self.cget("bg") | |
| n = len(self.files) | |
| max_len = max(len(d) for _, d in self.files) | |
| # Per-position agreement mask | |
| agree = [] | |
| for i in range(max_len): | |
| vals = set() | |
| for _, d in self.files: | |
| vals.add(d[i] if i < len(d) else None) | |
| agree.append(len(vals) == 1) | |
| row = 0 # grid row counter | |
| ttk.Label(self.inner, text=" Offset →", | |
| font=(FONT_FAMILY, FONT_SIZE - 1, "bold"), | |
| anchor="w", width=22).grid( | |
| row=row, column=0, sticky="nsew", padx=(4, 0), pady=(2, 0)) | |
| ruler = tk.Text(self.inner, font=(FONT_FAMILY, FONT_SIZE - 1), | |
| wrap="none", relief="flat", bd=0, | |
| height=1, padx=8, cursor="arrow", | |
| spacing1=0, spacing3=0, bg=sysbg) | |
| ruler.grid(row=row, column=1, sticky="ew", pady=(2, 0)) | |
| ruler.tag_config("addr", foreground="#555555") | |
| ruler.tag_config("dim", foreground="#bbbbbb") | |
| for i in range(0, max_len, 16): | |
| lbl = f"0x{i:06X}" | |
| ruler.insert("end", lbl, "addr") | |
| ruler.insert("end", " " * (16 * 3 - len(lbl)), "dim") | |
| ruler.config(state="disabled") | |
| self._text_widgets.append(ruler) | |
| row += 1 | |
| # Separator | |
| ttk.Separator(self.inner, orient="horizontal").grid( | |
| row=row, column=0, columnspan=2, sticky="ew") | |
| row += 1 | |
| for fi, (path, data) in enumerate(self.files): | |
| hue = FILE_HUES[fi % len(FILE_HUES)] | |
| fg_c = hue_fg(hue) | |
| bg_c = hue_bg(hue) | |
| name = os.path.basename(path) | |
| size = f"{len(data):,} B" | |
| # Label cell — same grid row as the hex Text | |
| lf = tk.Frame(self.inner, bg=bg_c) | |
| lf.grid(row=row, column=0, sticky="nsew") | |
| tk.Label(lf, text=f" {name}", bg=bg_c, fg=fg_c, | |
| font=(FONT_FAMILY, FONT_SIZE, "bold"), | |
| anchor="w").pack(side="left", padx=(6, 0)) | |
| tk.Label(lf, text=size, bg=bg_c, fg="#666666", | |
| font=(FONT_FAMILY, 8), anchor="w").pack(side="left", padx=4) | |
| rm = tk.Label(lf, text="✕", bg=bg_c, fg="#999999", | |
| font=(FONT_FAMILY, 9), cursor="hand2") | |
| rm.pack(side="right", padx=6) | |
| rm.bind("<Button-1>", lambda e, i=fi: self._remove(i)) | |
| rm.bind("<Enter>", lambda e, w=rm: w.config(fg="#cc0000")) | |
| rm.bind("<Leave>", lambda e, w=rm, c=bg_c: w.config(fg="#999999")) | |
| # Hex Text — same grid row | |
| txt = tk.Text(self.inner, font=(FONT_FAMILY, FONT_SIZE), | |
| wrap="none", relief="flat", bd=0, | |
| height=1, padx=8, | |
| cursor="arrow", spacing1=0, spacing3=0, bg=sysbg) | |
| txt.grid(row=row, column=1, sticky="ew") | |
| txt.tag_config("match", foreground=MATCH_FG) | |
| txt.tag_config("diff", foreground=fg_c, background=bg_c) | |
| txt.tag_config("absent", foreground="#cccccc") | |
| for i in range(max_len): | |
| if i < len(data): | |
| tag = "match" if agree[i] else "diff" | |
| txt.insert("end", f"{data[i]:02X} ", tag) | |
| else: | |
| txt.insert("end", " ", "absent") | |
| txt.config(state="disabled") | |
| self._text_widgets.append(txt) | |
| row += 1 | |
| ttk.Separator(self.inner, orient="horizontal").grid( | |
| row=row, column=0, columnspan=2, sticky="ew") | |
| row += 1 | |
| self._setup_scrolling() | |
| diff_count = sum(1 for a in agree if not a) | |
| pct = 100 * (max_len - diff_count) / max_len if max_len else 100 | |
| self.status_var.set( | |
| f"{n} file(s) · {max_len:,} bytes max · " | |
| f"{diff_count:,} differing · {pct:.1f}% identical" | |
| ) | |
| self.update_idletasks() | |
| self.canvas.configure(scrollregion=self.canvas.bbox("all")) | |
| def _render_bit(self): | |
| """Bit-level rendering""" | |
| self._text_widgets = [] | |
| for w in self.inner.winfo_children(): | |
| w.destroy() | |
| if not self.files: | |
| self._show_placeholder() | |
| self.status_var.set("No files loaded.") | |
| self.hscroll.config(command=lambda *a: None) | |
| return | |
| sysbg = self.cget("bg") | |
| n = len(self.files) | |
| max_len = max(len(d) for _, d in self.files) | |
| max_bits = max_len * 8 | |
| # Per-position agreement mask (at bit level) | |
| agree = [] | |
| for bit_idx in range(max_bits): | |
| byte_idx = bit_idx // 8 | |
| bit_pos = 7 - (bit_idx % 8) # MSB first | |
| vals = set() | |
| for _, d in self.files: | |
| if byte_idx < len(d): | |
| bit_val = (d[byte_idx] >> bit_pos) & 1 | |
| vals.add(bit_val) | |
| else: | |
| vals.add(None) | |
| agree.append(len(vals) == 1) | |
| row = 0 # grid row counter | |
| ttk.Label(self.inner, text=" Bit Offset →", | |
| font=(FONT_FAMILY, FONT_SIZE - 1, "bold"), | |
| anchor="w", width=22).grid( | |
| row=row, column=0, sticky="nsew", padx=(4, 0), pady=(2, 0)) | |
| ruler = tk.Text(self.inner, font=(FONT_FAMILY, FONT_SIZE - 1), | |
| wrap="none", relief="flat", bd=0, | |
| height=1, padx=8, cursor="arrow", | |
| spacing1=0, spacing3=0, bg=sysbg) | |
| ruler.grid(row=row, column=1, sticky="ew", pady=(2, 0)) | |
| ruler.tag_config("addr", foreground="#555555") | |
| ruler.tag_config("dim", foreground="#bbbbbb") | |
| ruler.tag_config("byte_sep", foreground="#cccccc") | |
| # Show byte boundaries in ruler | |
| for byte_idx in range(max_len): | |
| bit_start = byte_idx * 8 | |
| lbl = f"{bit_start}" | |
| ruler.insert("end", lbl, "addr") | |
| # Pad to align with 8 bits + space | |
| padding = " " * (9 - len(lbl)) | |
| ruler.insert("end", padding, "dim") | |
| ruler.config(state="disabled") | |
| self._text_widgets.append(ruler) | |
| row += 1 | |
| # Separator | |
| ttk.Separator(self.inner, orient="horizontal").grid( | |
| row=row, column=0, columnspan=2, sticky="ew") | |
| row += 1 | |
| for fi, (path, data) in enumerate(self.files): | |
| hue = FILE_HUES[fi % len(FILE_HUES)] | |
| fg_c = hue_fg(hue) | |
| bg_c = hue_bg(hue) | |
| name = os.path.basename(path) | |
| size = f"{len(data):,} B ({len(data) * 8:,} bits)" | |
| # Label cell — same grid row as the bit Text | |
| lf = tk.Frame(self.inner, bg=bg_c) | |
| lf.grid(row=row, column=0, sticky="nsew") | |
| tk.Label(lf, text=f" {name}", bg=bg_c, fg=fg_c, | |
| font=(FONT_FAMILY, FONT_SIZE, "bold"), | |
| anchor="w").pack(side="left", padx=(6, 0)) | |
| tk.Label(lf, text=size, bg=bg_c, fg="#666666", | |
| font=(FONT_FAMILY, 8), anchor="w").pack(side="left", padx=4) | |
| rm = tk.Label(lf, text="✕", bg=bg_c, fg="#999999", | |
| font=(FONT_FAMILY, 9), cursor="hand2") | |
| rm.pack(side="right", padx=6) | |
| rm.bind("<Button-1>", lambda e, i=fi: self._remove(i)) | |
| rm.bind("<Enter>", lambda e, w=rm: w.config(fg="#cc0000")) | |
| rm.bind("<Leave>", lambda e, w=rm, c=bg_c: w.config(fg="#999999")) | |
| # Bit Text — same grid row | |
| txt = tk.Text(self.inner, font=(FONT_FAMILY, FONT_SIZE), | |
| wrap="none", relief="flat", bd=0, | |
| height=1, padx=8, | |
| cursor="arrow", spacing1=0, spacing3=0, bg=sysbg) | |
| txt.grid(row=row, column=1, sticky="ew") | |
| txt.tag_config("match", foreground=MATCH_FG) | |
| txt.tag_config("diff", foreground=fg_c, background=bg_c) | |
| txt.tag_config("absent", foreground="#cccccc") | |
| txt.tag_config("byte_sep", foreground="#dddddd") | |
| for bit_idx in range(max_bits): | |
| byte_idx = bit_idx // 8 | |
| bit_pos = 7 - (bit_idx % 8) # MSB first | |
| if byte_idx < len(data): | |
| bit_val = (data[byte_idx] >> bit_pos) & 1 | |
| tag = "match" if agree[bit_idx] else "diff" | |
| txt.insert("end", str(bit_val), tag) | |
| else: | |
| txt.insert("end", "·", "absent") | |
| # Add space after each byte (every 8 bits) | |
| if (bit_idx + 1) % 8 == 0: | |
| txt.insert("end", " ", "byte_sep") | |
| txt.config(state="disabled") | |
| self._text_widgets.append(txt) | |
| row += 1 | |
| ttk.Separator(self.inner, orient="horizontal").grid( | |
| row=row, column=0, columnspan=2, sticky="ew") | |
| row += 1 | |
| self._setup_scrolling() | |
| diff_count = sum(1 for a in agree if not a) | |
| pct = 100 * (max_bits - diff_count) / max_bits if max_bits else 100 | |
| self.status_var.set( | |
| f"{n} file(s) · {max_bits:,} bits max · " | |
| f"{diff_count:,} differing · {pct:.1f}% identical" | |
| ) | |
| self.update_idletasks() | |
| self.canvas.configure(scrollregion=self.canvas.bbox("all")) | |
| def _setup_scrolling(self): | |
| """Setup shared scrolling for all text widgets""" | |
| tw = self._text_widgets | |
| def _on_xscroll(first, last): | |
| self.hscroll.set(first, last) | |
| for t in tw: | |
| t.xview_moveto(first) | |
| for t in tw: | |
| t.config(xscrollcommand=_on_xscroll) | |
| self.hscroll.config(command=lambda *a: [t.xview(*a) for t in tw]) | |
| def _wx(event): | |
| d = (-5 if event.num == 6 else 5 if event.num == 7 | |
| else -int(event.delta / 40) if event.delta else 0) | |
| for t in tw: | |
| t.xview_scroll(d, "units") | |
| return "break" | |
| def _wy(event): | |
| d = (-3 if event.num == 4 else 3 if event.num == 5 | |
| else -int(event.delta / 40) if event.delta else 0) | |
| self.canvas.yview_scroll(d, "units") | |
| return "break" | |
| for t in tw: | |
| t.bind("<MouseWheel>", _wy) | |
| t.bind("<Button-4>", _wy) | |
| t.bind("<Button-5>", _wy) | |
| t.bind("<Shift-MouseWheel>", _wx) | |
| t.bind("<Button-6>", _wx) | |
| t.bind("<Button-7>", _wx) | |
| def _wheel_y(self, event): | |
| d = (-3 if event.num == 4 else 3 if event.num == 5 | |
| else -int(event.delta / 120) if event.delta else 0) | |
| self.canvas.yview_scroll(d, "units") | |
| if __name__ == "__main__": | |
| BinaryDiffViewer().mainloop() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment