June 7Jun 7 Localization 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 June 7Jun 7 by mrmaller1905
June 7Jun 7 Localization Solution 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())
June 8Jun 8 Localization Specs as requested: +----------------------------+ | Europe Racer *.IMG + *.IND | +----------------------------+ // Type 1 (contains filenames) // The IMG file contains the file data, the IND file contains the directory. // Uses ZLib Compression // IND FILE // HEADER 2 - Number of Files // DETAILS DIRECTORY // for each file 20 - Filename (null terminated, filled with nulls) 4 - File Offset // IMG FILE // FILE DATA // for each file 4 - Decompressed File Length X - File Data (ZLib) +----------------------------+ | Europe Racer *.IMG + *.IND | +----------------------------+ // Type 2 (no filenames) // The IMG file contains the file data, the IND file contains the directory. // IND FILE // HEADER 2 - Number of Files // DETAILS DIRECTORY // for each file 4 - File Offset // IMG FILE // HEADER 2 - Number of Files // FILE DATA // for each file X - File Data All the files in the RC.IMG file you posted appear to be paletted images (although not sure what palette), with the format: +------------------------+ | Europe Racer *.img_tex | +------------------------+ // HEADER 2 - Image Width 2 - Image Height 2 - Unknown 2 - Unknown // IMAGE DATA // for each pixel 1 - Palette Index Approx D3D mesh format as well, for completeness: +--------------------+ | Europe Racer *.d3d | +--------------------+ // HEADER 4 - Unknown (-1) 4 - Number of Vertices 4 - Number of Faces // VERTICES // for each vertex (49 bytes per entry) 4 - Point X (float) 4 - Point Y (float) 4 - Point Z (float) 4 - Normal X (float) 4 - Normal Y (float) 4 - Normal Z (float) 4 - Tex Co-ord U (float) 4 - Tex Co-ord V (float) 4 - Unknown Float 4 - Unknown Float 4 - Unknown Float 4 - Unknown Float 1 - Unknown (136) // FACES // for each face (9 bytes per entry) 2 - Vertex 1 2 - Vertex 2 2 - Vertex 3 2 - Unknown (104) 1 - Unknown (225) Edited June 8Jun 8 by wattostudios
June 8Jun 8 Author Localization 3 hours ago, wattostudios said: Specs as requested: +----------------------------+ | Europe Racer *.IMG + *.IND | +----------------------------+ // Type 1 (contains filenames) // The IMG file contains the file data, the IND file contains the directory. // Uses ZLib Compression // IND FILE // HEADER 2 - Number of Files // DETAILS DIRECTORY // for each file 20 - Filename (null terminated, filled with nulls) 4 - File Offset // IMG FILE // FILE DATA // for each file 4 - Decompressed File Length X - File Data (ZLib) +----------------------------+ | Europe Racer *.IMG + *.IND | +----------------------------+ // Type 2 (no filenames) // The IMG file contains the file data, the IND file contains the directory. // IND FILE // HEADER 2 - Number of Files // DETAILS DIRECTORY // for each file 4 - File Offset // IMG FILE // HEADER 2 - Number of Files // FILE DATA // for each file X - File Data All the files in the RC.IMG file you posted appear to be paletted images (although not sure what palette), with the format: +------------------------+ | Europe Racer *.img_tex | +------------------------+ // HEADER 2 - Image Width 2 - Image Height 2 - Unknown 2 - Unknown // IMAGE DATA // for each pixel 1 - Palette Index Approx D3D mesh format as well, for completeness: +--------------------+ | Europe Racer *.d3d | +--------------------+ // HEADER 4 - Unknown (-1) 4 - Number of Vertices 4 - Number of Faces // VERTICES // for each vertex (49 bytes per entry) 4 - Point X (float) 4 - Point Y (float) 4 - Point Z (float) 4 - Normal X (float) 4 - Normal Y (float) 4 - Normal Z (float) 4 - Tex Co-ord U (float) 4 - Tex Co-ord V (float) 4 - Unknown Float 4 - Unknown Float 4 - Unknown Float 4 - Unknown Float 1 - Unknown (136) // FACES // for each face (9 bytes per entry) 2 - Vertex 1 2 - Vertex 2 2 - Vertex 3 2 - Unknown (104) 1 - Unknown (225) Are you new to writing 3D model and bone file format specs?
June 8Jun 8 Localization 1 hour ago, mrmaller1905 said: Are you new to writing 3D model and bone file format specs? I'm a Novice. I understand the structure of vertices, faces, and texture co-ordinates. I understand bones/skeletons/animations, but not how they appear in data files. That's good enough for me, especially when there are many other people who understand 3D formats a lot more.
Create an account or sign in to comment