Extract real sprites from Transport Tycoon / OpenTTD NewGRF (.grf) files to PNG, with optional renaming from in-GRF aircraft names (Action4 → Action3 → Action2 → Action1).
Verified on Real_Aircrafts_Beta.grf (container v2): 2407 PNGs, 58 aircraft names, 2407/2407 renamed.
- Python 3.10+
- Pillow
pip install pillowpython grf_extract.py
# or double-click:
.\Open GUI.bat| Control | Purpose |
|---|---|
| GRF file | Input .grf |
| Output folder | Where PNGs are written (default <stem>_sprites/) |
| Rename checkbox | Map sprites via Action4 aircraft names |
| Extract | Runs on a background thread |
| Open output folder | Opens Explorer on the result dir |
python grf_extract.py path\to\file.grf
python grf_extract.py path\to\file.grf D:\out\sprites
python grf_extract.py path\to\file.grf --no-rename
python grf_extract.py --guiflowchart LR
GRF[".grf file"] --> DET{Container?}
DET -->|v2 magic| V2[Data section + Sprite section]
DET -->|else| V1[Flat sprite stream]
V2 --> MAP[AircraftNameMapper<br/>Action 8/4/3/2/1]
V2 --> DEC[decompress_sprite LZ77]
V1 --> DEC
DEC --> IMG[sprite_to_image → PNG]
MAP --> NAME["{vid}_{name}__id{sid}_...png"]
IMG --> NAME
Container v2 layout (grf.txt)
[10-byte magic] [DWORD sprite_offs] [BYTE data_compr]
[ data section: pseudo FF + FD sprite refs … ] [DWORD 0]
[ sprite section: id, size, image payloads … ] [DWORD 0]
Magic:
CONTAINER_V2_MAGIC = bytes([0x00, 0x00, 0x47, 0x52, 0x46, 0x82, 0x0D, 0x0A, 0x1A, 0x0A])Data-section entry:
size = u32() # payload length (does NOT include info byte)
info = u8() # 0xFF = pseudo (Action*), 0xFD = sprite-section ref
payload = read(size) # if FD: DWORD sprite_idSame algorithm as OpenTTD DecodeSingleSprite():
def decompress_sprite(cur, target_size: int) -> bytearray:
out = bytearray(target_size)
pos = 0
while pos < target_size:
code = cur.s8() # signed
if code >= 0:
# Verbatim: length = code, or 128 if code == 0
size = 0x80 if code == 0 else code
out[pos:pos + size] = cur.read(size)
pos += size
else:
# Back-reference (may overlap → copy byte-by-byte)
lofs = cur.u8()
offset = ((code & 7) << 8) | lofs
length = -(code >> 3) # arithmetic shift
src = pos - offset
for i in range(length):
out[pos + i] = out[src + i]
pos += length
return outcode |
Meaning |
|---|---|
>= 0 |
Copy next code bytes (or 128 if code == 0) |
< 0 |
Copy length = -(code >> 3) bytes from pos - offset |
Aircraft name mapping (GRFSpecs)
| Action | Feature | Role |
|---|---|---|
| Action8 | — | GRF title / GRFID |
| Action4 | 03 aircraft |
vehicle_id → name |
| Action3 | 03 |
vehicle_id → Action2 set-id |
| Action2 / VA2 | 03 |
set-id → spriteset indices / chain |
| Action1 | 03 |
spriteset → following FD sprite IDs |
Action2 IDs are reused in NewGRFs. OpenTTD stores a group pointer when Action3 is parsed; later redefinitions of the same ID do not rewrite earlier vehicles.
This tool mirrors that by freezing sprite-id lists when Action2 is defined:
class AircraftNameMapper:
def __init__(self):
self.spritesets = {} # set_index -> {ids, nent}
self.a2_sprites = {} # action2_id -> frozen [sprite_id, ...]
self.names = {} # vehicle_id -> sanitized name
self.sprite_owners = defaultdict(set)
def _snapshot_basic(self, idxs):
out = []
for idx in idxs:
block = self.spritesets.get(idx)
if block:
out.extend(block["ids"])
return out
def _snapshot_var(self, payload):
# Variational Action2: union of referenced Action2 snapshots
out, seen = [], set()
for ref in _extract_va2_refs(payload):
if ref not in seen:
seen.add(ref)
out.extend(self.a2_sprites.get(ref, []))
return outOn Action3, copy the current snapshot onto the vehicle. Small Action1 blocks (≤64 sprites) right after an Action4 are also attributed locally (covers purchase/views defined after Action3).
{vid:03d}_{Aircraft Name}__id{sprite_id}_{zoom}_{depth}_{W}x{H}.png
Examples:
041_Bell 206B JetRanger II__id55_normal_32bpp_50x40.png
055_Airbus A320-200__id593_4xin_32bpp_200x120.png
098_Lockheed L-1649A Starliner__id2965_normal_32bpp_96x64.png
Also written: aircraft_sprite_map.json (vehicle ↔ sprite-id map).
from pathlib import Path
from grf_extract import detect_and_extract, build_aircraft_mapper
grf = Path("Real_Aircrafts_Beta.grf")
outdir = Path("out_sprites")
outdir.mkdir(exist_ok=True)
def on_progress(done, total, msg):
print(msg)
n = detect_and_extract(grf, outdir, rename=True, progress=on_progress)
print(f"Extracted {n} PNGs")
mapper = build_aircraft_mapper(grf)
print(mapper.summary()["aircraft_count"], "aircraft")
print(list(mapper.sprite_id_to_label().items())[:3])def worker():
def progress(done, total, msg):
msg_queue.put(("progress", msg))
n = detect_and_extract(grf, out, rename=rename, progress=progress)
msg_queue.put(("done", n, str(out)))
threading.Thread(target=worker, daemon=True).start()GRF extract/
├── grf_extract.py # Core: decompress, container v1/v2, name mapper, CLI
├── grf_extract_gui.py # Tkinter GUI
├── Open GUI.bat # Windows launcher
├── requirements.txt
├── README.md # This file
├── docs/
│ ├── EXTRACT_Real_Aircrafts_Beta.md
│ └── GUI.md
└── .gitignore
Large binaries (.grf, extracted PNG trees, *.bak_*) are not committed.
| Metric | Value |
|---|---|
| Format | Container v2 |
| GRF title | Real Aircrafts set 32 BPP 4X Extrazoom - Version beta |
| Aircraft (Action4) | 58 |
| PNGs | 2407 |
| Renamed | 2407 / 2407 |
More detail: docs/EXTRACT_Real_Aircrafts_Beta.md, docs/GUI.md.