Members mrmaller1905 Posted 10 hours ago Members Posted 10 hours ago (edited) I am looking for existing tools and file format specification for Europe Racer IMGs and INDs. The INDs have filenames and offsets. https://mega.nz/file/5PxQwSgZ#6-WLld04WzkiPFe8tZ_gMaI81VA5ys8x_ssuZnvftqk Edited 9 hours ago by mrmaller1905
Members Solution zbirow Posted 4 hours ago Members Solution Posted 4 hours ago 5 hours ago, mrmaller1905 said: I am looking for existing tools and file format specification for Europe Racer IMGs and INDs. The INDs have filenames and offsets. https://mega.nz/file/5PxQwSgZ#6-WLld04WzkiPFe8tZ_gMaI81VA5ys8x_ssuZnvftqk .IMG files have zlib blocks. To extract them correctly with names, you need an .IND file. .IMG = Data .IND = Index You must have .img and .ind files with the same name in the same folder to extract the files. import argparse import re import struct import sys import threading import zlib from dataclasses import dataclass from pathlib import Path from typing import Callable, Iterable ZLIB_HEADERS = {b"\x78\x01", b"\x78\x5e", b"\x78\x9c", b"\x78\xda"} INVALID_NAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]') class ImgIndError(Exception): pass @dataclass class IndexEntry: index: int name: str offset: int end_offset: int = 0 def read_u16le(data: bytes, offset: int) -> int: return struct.unpack_from("<H", data, offset)[0] def read_u32le(data: bytes, offset: int) -> int: return struct.unpack_from("<I", data, offset)[0] def clean_name(raw: bytes) -> str: return raw.split(b"\x00", 1)[0].decode("ascii", "replace").strip() def safe_filename(name: str) -> str: name = INVALID_NAME_CHARS.sub("_", name).strip(" .") return name or "unnamed.bin" def guess_extension(data: bytes) -> str: if data.startswith(b"\xff\xfb") or data.startswith(b"ID3"): return ".mp3" if data.startswith(b"\x0a") and len(data) > 4: return ".pcx" if len(data) >= 18 and data[2] in {1, 2, 3, 9, 10, 11}: width = int.from_bytes(data[12:14], "little") height = int.from_bytes(data[14:16], "little") bpp = data[16] if 0 < width <= 8192 and 0 < height <= 8192 and bpp in {8, 15, 16, 24, 32}: return ".tga" if data.startswith(b"RIFF") and data[8:12] == b"WAVE": return ".wav" if data.startswith(b"DDS "): return ".dds" if data.startswith(b"\x89PNG\r\n\x1a\n"): return ".png" return ".bin" def resolve_img_path(ind_path: Path) -> Path: candidates = [ ind_path.with_suffix(".IMG"), ind_path.with_suffix(".img"), ind_path.with_suffix(".Img"), ] for candidate in candidates: if candidate.exists(): return candidate raise ImgIndError(f"Matching .IMG file not found for {ind_path.name}") def parse_ind(ind_path: Path, img_size: int) -> tuple[str, list[IndexEntry]]: data = ind_path.read_bytes() if len(data) < 2: raise ImgIndError(f"{ind_path.name} is too small for an index") count = read_u16le(data, 0) named_size = 2 + count * 24 offset_only_size = 2 + count * 4 entries: list[IndexEntry] = [] if len(data) == named_size: layout = "named" for index in range(count): pos = 2 + index * 24 name = clean_name(data[pos:pos + 20]) offset = read_u32le(data, pos + 20) entries.append(IndexEntry(index, name, offset)) elif len(data) == offset_only_size: layout = "offsets_only" stem = ind_path.stem for index in range(count): pos = 2 + index * 4 offset = read_u32le(data, pos) entries.append(IndexEntry(index, f"{stem}_{index:04d}", offset)) else: raise ImgIndError( f"{ind_path.name}: unsupported index size {len(data)} for count {count}; " f"expected {named_size} or {offset_only_size}" ) if not entries: return layout, entries ordered = sorted(entries, key=lambda item: item.offset) for i, entry in enumerate(ordered): next_offset = ordered[i + 1].offset if i + 1 < len(ordered) else img_size if entry.offset < 0 or entry.offset > img_size: raise ImgIndError(f"{ind_path.name}: entry {entry.index} offset exceeds IMG size") if next_offset < entry.offset: raise ImgIndError(f"{ind_path.name}: entry offsets are not monotonic") entry.end_offset = next_offset return layout, entries def unique_output_path(out_dir: Path, filename: str, used: set[str]) -> Path: filename = safe_filename(filename) stem = Path(filename).stem suffix = Path(filename).suffix if not suffix: suffix = ".bin" candidate = f"{stem}{suffix}" counter = 1 while candidate.lower() in used: candidate = f"{stem}_{counter:04d}{suffix}" counter += 1 used.add(candidate.lower()) return out_dir / candidate def unpack_block(block: bytes) -> tuple[bytes, int | None, bool, str]: if len(block) >= 6 and block[4:6] in ZLIB_HEADERS: expected = read_u32le(block, 0) try: output = zlib.decompress(block[4:]) except zlib.error as exc: return block, expected, False, f"zlib error: {exc}" status = "ok" if expected != len(output): status = f"size mismatch: expected {expected}, got {len(output)}" return output, expected, True, status return block, None, False, "raw" def extract_pair( ind_path: Path, output_root: Path | None = None, limit: int | None = None, log: Callable[[str], None] | None = None, ) -> Path: ind_path = ind_path.resolve() img_path = resolve_img_path(ind_path).resolve() img_size = img_path.stat().st_size layout, entries = parse_ind(ind_path, img_size) output_root = output_root or ind_path.parent / "_europeracer_imgind_extract" pair_out = output_root / ind_path.stem pair_out.mkdir(parents=True, exist_ok=True) if limit is not None: entries = entries[:limit] if log: log(f"{ind_path.name}: {len(entries)} entries, layout={layout}") used_names: set[str] = set() with img_path.open("rb") as img_file: for entry in entries: packed_size = entry.end_offset - entry.offset img_file.seek(entry.offset) block = img_file.read(packed_size) unpacked, _expected_size, _compressed, status = unpack_block(block) filename = entry.name if not Path(filename).suffix: filename += guess_extension(unpacked) out_path = unique_output_path(pair_out, filename, used_names) out_path.write_bytes(unpacked) if log and (entry.index % 100 == 0 or entry.index == entries[-1].index): log(f" {entry.index + 1}/{len(entries)} {entry.name} -> {out_path.name}") if log and status not in {"ok", "raw"}: log(f" warning: {entry.name}: {status}") if log: log(f"Done: {pair_out}") return pair_out def find_ind_files(input_path: Path) -> list[Path]: if input_path.is_dir(): found: dict[str, Path] = {} for pattern in ("*.IND", "*.ind"): for path in input_path.glob(pattern): found[str(path.resolve()).lower()] = path return sorted(found.values(), key=lambda path: path.name.lower()) if input_path.suffix.lower() == ".ind": return [input_path] raise ImgIndError("Select an .IND file or a folder containing .IND/.IMG pairs") def extract_input( input_path: Path, output_dir: Path | None = None, limit: int | None = None, log: Callable[[str], None] | None = None, ) -> list[Path]: ind_files = find_ind_files(input_path) if not ind_files: raise ImgIndError(f"No .IND files found in {input_path}") output_dir = output_dir or (input_path if input_path.is_dir() else input_path.parent) / "_europeracer_imgind_extract" results = [] for ind_path in ind_files: results.append(extract_pair(ind_path, output_dir, limit=limit, log=log)) return results def run_gui() -> None: import tkinter as tk from tkinter import filedialog, messagebox, ttk root = tk.Tk() root.title("Europe Racer IMG/IND Extractor") root.geometry("760x520") input_var = tk.StringVar() output_var = tk.StringVar() status_var = tk.StringVar(value="Select an .IND file or a folder with .IND/.IMG pairs.") frame = ttk.Frame(root, padding=12) frame.pack(fill="both", expand=True) frame.columnconfigure(1, weight=1) frame.rowconfigure(4, weight=1) ttk.Label(frame, text="Input").grid(row=0, column=0, sticky="w", padx=(0, 8), pady=4) ttk.Entry(frame, textvariable=input_var).grid(row=0, column=1, sticky="ew", pady=4) def choose_file() -> None: path = filedialog.askopenfilename( title="Select IND file", filetypes=[("IND files", "*.ind *.IND"), ("All files", "*.*")], ) if path: input_var.set(path) def choose_folder() -> None: path = filedialog.askdirectory(title="Select folder with IMG/IND pairs") if path: input_var.set(path) ttk.Button(frame, text="IND File", command=choose_file).grid(row=0, column=2, padx=4) ttk.Button(frame, text="Folder", command=choose_folder).grid(row=0, column=3, padx=4) ttk.Label(frame, text="Output").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=4) ttk.Entry(frame, textvariable=output_var).grid(row=1, column=1, sticky="ew", pady=4) def choose_output() -> None: path = filedialog.askdirectory(title="Select output folder") if path: output_var.set(path) ttk.Button(frame, text="Browse", command=choose_output).grid(row=1, column=2, padx=4) progress = ttk.Progressbar(frame, mode="indeterminate") progress.grid(row=2, column=0, columnspan=4, sticky="ew", pady=(10, 4)) ttk.Label(frame, textvariable=status_var).grid(row=3, column=0, columnspan=4, sticky="w", pady=4) log_box = tk.Text(frame, height=18, wrap="word") log_box.grid(row=4, column=0, columnspan=4, sticky="nsew", pady=(8, 0)) scroll = ttk.Scrollbar(frame, orient="vertical", command=log_box.yview) scroll.grid(row=4, column=4, sticky="ns", pady=(8, 0)) log_box.configure(yscrollcommand=scroll.set) def log(message: str) -> None: def append() -> None: log_box.insert("end", message + "\n") log_box.see("end") root.after(0, append) def start_extract() -> None: in_text = input_var.get().strip() if not in_text: messagebox.showerror("Missing input", "Select an .IND file or a folder first.") return input_path = Path(in_text) output_dir = Path(output_var.get().strip()) if output_var.get().strip() else None def worker() -> None: try: root.after(0, progress.start) root.after(0, lambda: status_var.set("Extracting...")) results = extract_input(input_path, output_dir, log=log) root.after(0, lambda: status_var.set(f"Done: {len(results)} pair(s) extracted.")) except Exception as exc: root.after(0, lambda: status_var.set("Error")) root.after(0, lambda: messagebox.showerror("Extraction error", str(exc))) log(f"ERROR: {exc}") finally: root.after(0, progress.stop) threading.Thread(target=worker, daemon=True).start() ttk.Button(frame, text="Extract", command=start_extract).grid(row=5, column=3, sticky="e", pady=10) root.mainloop() def build_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Extract Europe Racer .IMG/.IND resource pairs") parser.add_argument("input", nargs="?", help=".IND file or folder containing .IND/.IMG pairs") parser.add_argument("-o", "--output", help="Output folder") parser.add_argument("--limit", type=int, help="Extract only the first N entries for testing") parser.add_argument("--gui", action="store_true", help="Launch GUI") parser.add_argument("--no-gui", action="store_true", help="Run from command line") return parser def main(argv: Iterable[str] | None = None) -> int: parser = build_arg_parser() args = parser.parse_args(argv) if args.gui or (not args.no_gui and not args.input): run_gui() return 0 if not args.input: parser.error("input is required in --no-gui mode") try: paths = extract_input( Path(args.input), Path(args.output) if args.output else None, limit=args.limit, log=print, ) except Exception as exc: print(f"ERROR: {exc}", file=sys.stderr) return 1 print("Extracted:") for path in paths: print(f" {path}") return 0 if __name__ == "__main__": raise SystemExit(main()) 1
Recommended Posts
Create an account or sign in to comment
You need to be a member in order to leave a comment
Create an account
Sign up for a new account in our community. It's easy!
Register a new accountSign in
Already have an account? Sign in here.
Sign In Now