Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save PrzemekWolw/e093db335e3d8323799006a058581962 to your computer and use it in GitHub Desktop.

Select an option

Save PrzemekWolw/e093db335e3d8323799006a058581962 to your computer and use it in GitHub Desktop.
.rol files ("Role Object Binary") are **scenario entity placement files** used by Sega's Kenzan engine (Yakuza 3-5, Like a Dragon series).

Kenzan .rol File Format Reference

Overview

.rol files ("Role Object Binary") are scenario entity placement files used by Sega's Kenzan engine (Yakuza 3-5, Like a Dragon series). They define where interactive/reactive objects appear in the game world at runtime - things like weapons on ground, vehicles, drink tables, food stalls, and other scene props that need physics or event interaction.

They live inside stage archives under the ARCMN (Area Common) path:

ST_<CITY>.par/
  <CITY>/ARCMN/st_<city>_arcmn_<time>.par/
    <time>/                          # day / eve / ngt variants
      plan_sub_a14_yatai_scn116.rol  # scenario placement file
      st_fukuoka_arm_laa.rol         # large area placement (weapons, items)
      ... more .rol files ...

Each time-of-day variant (day, evening, night) gets its own copy of these files with potentially different entity counts or positions.

File Format

Offset Size Description
0x00 4 bytes Magic: ROPB (ASCII)
0x04 2 bytes Version/format flags (02 01)
0x08-0x0C 4 bytes Unknown header fields
0x10 4 bytes Entity count (big-endian u32)
0x14 4 bytes Secondary count / stride info
0x18 4 bytes Data offset (where entity data starts, big-endian u32)

Two sub-formats exist depending on file size:

Compact Layout (single-entity files, 2 KB each)

Entity data starts immediately at data_offset (always 0x30). Each entity occupies a 128-byte slot but only the first 48 bytes contain real data. The remaining 80 bytes are zero-padded duplicate state copies used by the engine.

Indexed Layout (multi-entity files, up to 50 KB)

An index table sits at offset 0x30, with N entries of 16 bytes each:

Offset within entry Size Description
+0 4 bytes Entity type ID (sequential integer)
+4 4 bytes Placement count for this entity type
+8 4 bytes Block offset (index into entity data, in 48-byte units)
+12 4 bytes Padding (0x00000000)

Entity blocks follow the index table at data_offset, packed at 48 bytes each.

Entity Block Structure (48 bytes)

Every entity block has the same layout regardless of which sub-format:

Offset Size Description
+0 16 bytes Entity code: null-padded ASCII string (e.g. WEECP0076, WEMAP3092)
+16 4 bytes Position X (big-endian float32)
+20 4 bytes Position Y (big-endian float32, up axis)
+24 4 bytes Position Z (big-endian float32, forward axis)
+28 4 bytes Flags/padding (usually 0xFFFFFFFF or 0x00000000)
+32 4 bytes Quaternion X (big-endian float32)
+36 4 bytes Quaternion Y (big-endian float32)
+40 4 bytes Quaternion Z (big-endian float32)
+44 4 bytes Quaternion W (big-endian float32, scalar last)

Quaternions are always unit-length (magnitude ~1.0).

Entity Code Prefixes

The entity code is the closest thing to a model identifier. The engine resolves it internally:

Prefix Category Example Typical Use
ACDEV* Device ACDEV0046, ACDEV0136 Interactive devices, drink tables
WEECP* Environment prop WEECP0076 (yatai cart), WEECP0390 Scene props that need placement
WEMAP* Weapon/item map WEMAP0000, WEMAP3092 (nobori banner) Ground weapons, collectible items, decorative objects
REMAP* Reactor/equipment map REMAP1000, REMAP0080 Physics-reactive equipment, breakables
RESTG* Restage/scene rest RESTG1518 Scene reset trigger points
WEPRG* Program/prg entity WEPRG1000 Traffic/programmed behavior entities

Resolving Entity Codes to .gmd Models

Entity codes do not directly name model files. Resolution works through the reactor.par archive:

# Example resolution chain:
rol entity code:  WEECP0076
  --> (search reactor.par for matching prefix)
reactor GMD:      weecp0076_yatai_car.gmd
                  weecp0076_yatai_car_low.gmd  (LOD variant)

rol entity code:  WEMAP3092
  -->
reactor GMD:      wemap3092_nobori_a.gmd

Rule of thumb: Lowercase the entity code, then search reactor.par for filenames starting with that prefix. The actual GMD name includes a descriptive suffix (model variant) and may have _low LOD variants.

Coordinate System

Same as GMD: +Y up, +Z forward. To convert to Blender/BeamNG (+Z up, +Y forward): negate X, swap Y and Z.

# Kenzan --> BeamNG transform
pos_x = -game_x
pos_y =  game_z
pos_z =  game_y

Decoder Tool: KenzanROL.py

Usage:

# Decode all .rol files in a stage directory to combined JSON
python3 KenzanROL.py /path/to/stage_data -o placements.json

# Also generate BeamNG-style forest4 instancing files per entity code
python3 KenzanROL.py /path/to/stage_data --forest

# With coordinate offsets for world alignment
python3 KenzanROL.py /path/to/stage_data -o out.json --offset-x 10.0 --offset-z 50.0

Output JSON Schema

Each placement entry contains:

  • file - source .rol file path
  • entity_code - engine entity code (e.g. WEECP0076)
  • flags - raw uint32 flags field from binary (usually 0xFFFFFFFF or 0)
  • pos - transformed position [x, y, z] in BeamNG world space
  • quat - transformed quaternion [x, y, z, w] in BeamNG forest4 order

Forest output writes one <entity_code>.forest4.json per unique entity code with line-per-placement format.

Pitfalls

  • Entity codes are not filenames. Always cross-reference with reactor.par for the actual GMD.
  • Duplicate placements across time-of-day archives. Day/eve/night each have their own .rol copies. If you only need unique positions, deduplicate by comparing entity code + position.
  • Compact files use 128-byte slots to avoid reading duplicate state data at +0x50 within each slot. The decoder handles this automatically.
  • Not all entities have geometry. WEPRG* program entities and some REMAP* entries are invisible triggers or behavior anchors, not models you can import.
  • Big-endian throughout. All floats and integers use network byte order (>f, >I in Python struct).
# ##### BEGIN LICENSE BLOCK #####
#
# This program is licensed under The MIT License:
# see LICENSE for the full license text
#
# ##### END LICENSE BLOCK #####
# Kenzan Engine .rol (Role Object Placement Binary) -> JSON decoder
# Decodes entity placement data from Yakuza 5 / Like a Dragon stage files,
# outputs per-file JSON with entity codes, positions, and quaternion orientations.
# Supports both compact (direct) and indexed .rol layouts.
import struct
import os
import json
import math
from pathlib import Path
class Quat:
"""Minimal quaternion for Kenzan -> BeamNG transform."""
__slots__ = ("x", "y", "z", "w")
def __init__(self, x=0, y=0, z=0, w=1):
self.x = x
self.y = y
self.z = z
self.w = w
def to_euler(self):
sinr_cosp = 2 * (self.w * self.x + self.y * self.z)
cosr_cosp = 1 - 2 * (self.x * self.x + self.y * self.y)
roll = math.atan2(sinr_cosp, cosr_cosp)
sinp = 2 * (self.w * self.y - self.z * self.x)
if abs(sinp) >= 1:
pitch = math.copysign(math.pi / 2, sinp)
else:
pitch = math.asin(sinp)
siny_cosp = 2 * (self.w * self.z + self.x * self.y)
cosy_cosp = 1 - 2 * (self.y * self.y + self.z * self.z)
yaw = math.atan2(siny_cosp, cosy_cosp)
return Euler(roll, pitch, yaw)
class Euler:
"""Minimal Euler angles (XYZ order)."""
__slots__ = ("x", "y", "z")
def __init__(self, x=0, y=0, z=0):
self.x = x
self.y = y
self.z = z
def rotate_axis(self, axis, angle):
if axis == "Z":
self.z += angle
elif axis == "Y":
self.y += angle
elif axis == "X":
self.x += angle
def to_quaternion(self):
ax, ay, az = self.x / 2.0, self.y / 2.0, self.z / 2.0
cx, sx = math.cos(ax), math.sin(ax)
cy, sy = math.cos(ay), math.sin(ay)
cz, sz = math.cos(az), math.sin(az)
return Quat(
x=sx * cy * cz - cx * sy * sz,
y=cx * sy * cz + sx * cy * sz,
z=cx * cy * sz - sx * sy * cz,
w=cx * cy * cz + sx * sy * sz,
)
OFFSET_X = 0.0
OFFSET_Y = 0.0
OFFSET_Z = 0.0
def _read_name(data, offset):
chunk = data[offset:offset + 16]
try:
null_idx = chunk.index(0)
except ValueError:
null_idx = 16
return chunk[:null_idx].decode("ascii", errors="replace")
def _read_entity_block(data, offset):
name = _read_name(data, offset)
if not name:
return None
pos_x = struct.unpack(">f", data[offset + 16:offset + 20])[0]
pos_y = struct.unpack(">f", data[offset + 20:offset + 24])[0]
pos_z = struct.unpack(">f", data[offset + 24:offset + 28])[0]
flags = struct.unpack(">I", data[offset + 28:offset + 32])[0]
q_x = struct.unpack(">f", data[offset + 32:offset + 36])[0]
q_y = struct.unpack(">f", data[offset + 36:offset + 40])[0]
q_z = struct.unpack(">f", data[offset + 40:offset + 44])[0]
q_w = struct.unpack(">f", data[offset + 44:offset + 48])[0]
return {
"entity_code": name,
"flags": flags,
"pos_game": [pos_x, pos_y, pos_z],
"quat_game": [q_w, q_x, q_y, q_z],
}
def _transform_position(game_pos):
return [
-game_pos[0] + OFFSET_X,
game_pos[2] + OFFSET_Y,
game_pos[1] + OFFSET_Z,
]
def _transform_quaternion(game_quat):
q = Quat(x=game_quat[1], y=game_quat[2], z=game_quat[3], w=game_quat[0])
euler = q.to_euler()
euler.rotate_axis("Z", math.radians(180))
result = euler.to_quaternion()
return [result.z, -result.y, result.x, result.w]
def decode_rol_file(file_path):
with open(file_path, "rb") as f:
data = f.read()
if len(data) < 0x30:
return []
magic = data[0:4]
if magic != b"ROPB":
print(f"[warn] Bad magic in {file_path}: {magic}")
return []
n_entities = struct.unpack(">I", data[16:20])[0]
data_offset = struct.unpack(">I", data[24:28])[0]
placements = []
file_label = str(file_path)
has_index = data_offset > 0x30
if has_index:
idx_table_end = data_offset
n_idx_entries = (idx_table_end - 0x30) // 16
for i in range(n_idx_entries):
idx_off = 0x30 + i * 16
count = struct.unpack(">I", data[idx_off + 4:idx_off + 8])[0]
block_offset = struct.unpack(">I", data[idx_off + 8:idx_off + 12])[0]
base = idx_table_end + block_offset * 48
for j in range(count):
block = _read_entity_block(data, base + j * 48)
if block is not None:
placements.append({
"file": file_label,
"entity_code": block["entity_code"],
"flags": block["flags"],
"pos": _transform_position(block["pos_game"]),
"quat": _transform_quaternion(block["quat_game"]),
})
else:
for j in range(n_entities):
off = data_offset + j * 128
if off + 48 > len(data):
break
block = _read_entity_block(data, off)
if block is not None:
placements.append({
"file": file_label,
"entity_code": block["entity_code"],
"flags": block["flags"],
"pos": _transform_position(block["pos_game"]),
"quat": _transform_quaternion(block["quat_game"]),
})
return placements
def scan_directory(search_root):
results = []
rol_files = sorted(Path(search_root).rglob("*.rol"))
if not rol_files:
print(f"[warn] No .rol files found under {search_root}")
return results
print(f"[info] Found {len(rol_files)} .rol files in {search_root}")
for fp in rol_files:
try:
placements = decode_rol_file(str(fp))
results.extend(placements)
except (OSError, struct.error) as e:
print(f"[error] Failed to parse {fp}: {e}")
return results
def write_json(placements, output_path):
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
with open(out, "w") as f:
json.dump(placements, f, indent=2)
print(f"[info] Wrote {len(placements)} placements to {out}")
def write_forest_placements(placements, export_base):
forest_dir = Path(export_base) / "forest"
forest_dir.mkdir(parents=True, exist_ok=True)
by_code = {}
for p in placements:
by_code.setdefault(p["entity_code"], []).append(p)
print(f"[info] Writing {len(by_code)} forest4.json files to {forest_dir}")
for code, entries in sorted(by_code.items()):
out_path = forest_dir / (code + ".forest4.json")
with open(out_path, "w") as f:
for e in entries:
json.dump({
"pos": [round(v, 5) for v in e["pos"]],
"quat": [round(v, 6) for v in e["quat"]],
"scale": 1.0,
"type": code,
}, f)
f.write(chr(10))
def main():
import argparse
parser = argparse.ArgumentParser(
description="Decode Kenzan .rol entity placement files to JSON."
)
parser.add_argument(
"input",
nargs="+",
help="Directory(ies) containing .rol files, or individual .rol file paths.",
)
parser.add_argument(
"-o", "--output",
default=None,
help="Output JSON path. Default: <first_input>_rol_placements.json",
)
parser.add_argument(
"--forest",
action="store_true",
help="Also write BeamNG-style per-entity-code forest4.json files.",
)
parser.add_argument(
"--forest-dir",
default=None,
help="Base directory for forest output.",
)
parser.add_argument(
"--offset-x", type=float, default=0.0,
help="X position offset (default: 0)",
)
parser.add_argument(
"--offset-y", type=float, default=0.0,
help="Y position offset (default: 0)",
)
parser.add_argument(
"--offset-z", type=float, default=0.0,
help="Z position offset (default: 0)",
)
args = parser.parse_args()
global OFFSET_X, OFFSET_Y, OFFSET_Z
OFFSET_X = args.offset_x
OFFSET_Y = args.offset_y
OFFSET_Z = args.offset_z
all_placements = []
for inp in args.input:
p = Path(inp)
if p.is_dir():
all_placements.extend(scan_directory(str(p)))
elif p.is_file() and p.suffix == ".rol":
all_placements.extend(decode_rol_file(str(p)))
else:
print(f"[warn] Skipping non-.rol / missing path: {inp}")
if not all_placements:
print("[warn] No placements decoded.")
return
from collections import Counter
prefixes = Counter()
codes = Counter()
for p in all_placements:
codes[p["entity_code"]] += 1
prefixes["".join(c for c in p["entity_code"] if c.isalpha())] += 1
print(f"[info] Total placements: {len(all_placements)}")
print(f"[info] Unique entity codes: {len(codes)}")
for prefix, count in sorted(prefixes.items()):
print(f" {prefix}: {count} placements")
if args.output:
out_path = args.output
else:
first = Path(args.input[0]).stem
out_path = first + "_rol_placements.json"
write_json(all_placements, out_path)
if args.forest:
if args.forest_dir:
forest_base = args.forest_dir
else:
first = Path(args.input[0]).stem
forest_base = first + "_rol_forest"
write_forest_placements(all_placements, forest_base)
print("[done]")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment