Jump to content

[PC] Europe Racer .img / .ind


Go to solution Solved by zbirow,

Recommended Posts

  • Members
  • Solution
Posted
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())

 

  • Like 1

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 account

Sign in

Already have an account? Sign in here.

Sign In Now
×
×
  • Create New...