Skip to content
View in the app

A better way to browse. Learn more.

ResHax

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.
Help us keep the site running.

[PC] Europe Racer .img / .ind

Featured Replies

Solved by zbirow

  • 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())

 

  • 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 by wattostudios

  • 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?

  • 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

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.