"""Shared memory protocol — Python mirror of src/shm_protocol.h.
Layout must match the C++ structs exactly:
BridgeHeader (64 bytes) at offset 0
ObjectEntry[] (32 bytes each) at offset 64
Data blob region after the object table
"""
import mmap
import os
import struct
import numpy as np
# ── BridgeHeader (64 bytes) ──────────────────────────────────────────────────
# char magic[4] 4s
# uint16_t version H
# uint8_t direction B (0=godot→blender, 1=blender→godot)
# uint8_t _pad1 B
# uint64_t sequence Q
# uint32_t object_count I
# uint32_t _pad2 I
# uint64_t frame_number Q
# uint8_t _reserved[32] 32s
HEADER_FMT = "<4sHBBQIIQ32s"
HEADER_SIZE = 64
assert struct.calcsize(HEADER_FMT) == HEADER_SIZE
# ── ObjectEntry (32 bytes) ───────────────────────────────────────────────────
# uint32_t object_id I
# uint8_t object_type B
# uint8_t update_type B
# uint8_t sub_type B (light subtype: 0=POINT, 1=SUN, 2=SPOT)
# uint8_t _pad B
# uint32_t data_offset I
# uint32_t data_size I
# uint32_t sequence I
# uint8_t _reserved[12] 12s
ENTRY_FMT = "<IBBBBIII12s"
ENTRY_SIZE = 32
assert struct.calcsize(ENTRY_FMT) == ENTRY_SIZE
# ── Object / Update type constants ───────────────────────────────────────────
OBJ_MESH = 0
OBJ_LIGHT = 1
OBJ_CAMERA = 2
OBJ_EMPTY = 3
UPDATE_TRANSFORM = 0
UPDATE_VERTEX = 1
UPDATE_MATERIAL = 2
UPDATE_FULL = 3
UPDATE_DELETE = 4
# ── Light subtypes ──────────────────────────────────────────────────────────
LIGHT_POINT = 0
LIGHT_SUN = 1 # DirectionalLight3D ↔ Blender "SUN"
LIGHT_SPOT = 2
# ── Transform size ───────────────────────────────────────────────────────────
TRANSFORM_SIZE = 64 # 4×4 float32 matrix
# ── Region layout ────────────────────────────────────────────────────────────
# SHM is divided into 3 zones:
# Region 0 [0, REGION_SIZE): Godot→Blender
# Region 1 [REGION_SIZE, 2*REGION_SIZE): Blender→Godot
# Tile ring [2*REGION_SIZE, SHM_SIZE): Texture streaming
REGION_SIZE = 8 * 1024 * 1024 # 8 MB per region
class ShmRegion:
"""POSIX shared memory wrapper matching the C++ ShmRegion class.
Supports split-region mode: the SHM is divided into two equal halves.
Region 0 (offset 0): Godot writes, Blender reads
Region 1 (offset size/2): Blender writes, Godot reads
"""
def __init__(self):
self._mm = None
self._fd = -1
self._name = None
self._size = 0
self._region_size = REGION_SIZE # 8 MB per region (fixed)
self._owner = False
# ── Lifecycle ────────────────────────────────────────────────────────
def create(self, name: str, size: int) -> bool:
"""Create a new shared memory region (owner — will unlink on close)."""
self._name = name
path = f"/dev/shm{name}"
try:
self._fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o666)
os.ftruncate(self._fd, size)
self._mm = mmap.mmap(self._fd, size)
self._size = size
self._owner = True
# Zero-fill
self._mm[:] = b"\x00" * size
return True
except OSError:
self.close()
return False
def open(self, name: str, size: int) -> bool:
"""Open an existing shared memory region (reader — will NOT unlink).
Uses the actual file size and derives region_size from layout:
shm = region0 + region1 + 8MB_tile_ring → region_size = (actual - 8MB) / 2
"""
self._name = name
path = f"/dev/shm{name}"
try:
self._fd = os.open(path, os.O_RDWR)
# Use actual file size (handles resize and version mismatch)
actual_size = os.fstat(self._fd).st_size
if actual_size != size:
print(
f"BlenderBridge: SHM size {actual_size} "
f"(expected {size}), using actual."
)
size = actual_size
self._mm = mmap.mmap(self._fd, size)
self._size = size
# Derive region size from layout: 2 regions + 8 MB tile ring
tile_ring = 8 * 1024 * 1024
if size > tile_ring:
self._region_size = (size - tile_ring) // 2
else:
self._region_size = REGION_SIZE
self._owner = False
return True
except OSError:
self.close()
return False
def resize(self, new_size: int, new_region_size: int) -> bool:
"""Grow the shared memory and remap. Returns True on success.
Blender calls this when a mesh blob exceeds the current region.
The fd stays open; we ftruncate then close+reopen the mmap.
"""
if self._fd < 0:
return False
try:
# Grow the backing file
os.ftruncate(self._fd, new_size)
# Close old mapping
if self._mm is not None:
self._mm.close()
# Open new mapping at new size
self._mm = mmap.mmap(self._fd, new_size)
old_size = self._size
self._size = new_size
self._region_size = new_region_size
# Zero-fill the newly added region (old_size..new_size)
self._mm[old_size:new_size] = b"\x00" * (new_size - old_size)
return True
except OSError as e:
print(f"BlenderBridge: SHM resize failed: {e}")
return False
def close(self):
"""Unmap and optionally unlink the shared memory."""
if self._mm is not None:
self._mm.close()
self._mm = None
if self._fd >= 0:
os.close(self._fd)
self._fd = -1
if self._owner and self._name:
try:
os.unlink(f"/dev/shm{self._name}")
except OSError:
pass
self._name = None
self._size = 0
@property
def valid(self) -> bool:
return self._mm is not None
@property
def size(self) -> int:
return self._size
# ── Region helpers ──────────────────────────────────────────────────
def region_base(self, region: int) -> int:
"""Return the absolute byte offset for the start of a region."""
return region * self._region_size
# ── Header read/write ────────────────────────────────────────────────
def read_header(self, region: int = 0) -> dict:
base = self.region_base(region)
data = self._mm[base : base + HEADER_SIZE]
magic, ver, direction, _pad, seq, obj_count, _pad2, frame, _res = (
struct.unpack(HEADER_FMT, data)
)
return {
"magic": magic,
"version": ver,
"direction": direction,
"sequence": seq,
"object_count": obj_count,
"frame_number": frame,
}
def write_header(
self,
region: int = 0,
magic: bytes = b"BGOD",
version: int = 1,
direction: int = 0,
sequence: int = 0,
object_count: int = 0,
frame_number: int = 0,
):
base = self.region_base(region)
data = struct.pack(
HEADER_FMT,
magic,
version,
direction,
0, # _pad1
sequence,
object_count,
0, # _pad2
frame_number,
b"\x00" * 32, # _reserved
)
self._mm[base : base + HEADER_SIZE] = data
# ── Object entry read/write ──────────────────────────────────────────
def read_object_entry(self, index: int, region: int = 0) -> dict:
base = self.region_base(region)
offset = base + HEADER_SIZE + index * ENTRY_SIZE
data = self._mm[offset : offset + ENTRY_SIZE]
obj_id, obj_type, upd_type, sub_type, _pad, data_off, data_sz, seq, _res = (
struct.unpack(ENTRY_FMT, data)
)
return {
"object_id": obj_id,
"object_type": obj_type,
"update_type": upd_type,
"sub_type": sub_type,
"data_offset": data_off,
"data_size": data_sz,
"sequence": seq,
}
def write_object_entry(
self,
index: int,
object_id: int,
object_type: int,
update_type: int,
data_offset: int,
data_size: int,
sequence: int,
sub_type: int = 0,
region: int = 0,
):
base = self.region_base(region)
offset = base + HEADER_SIZE + index * ENTRY_SIZE
data = struct.pack(
ENTRY_FMT,
object_id,
object_type,
update_type,
sub_type,
0, # _pad
data_offset,
data_size,
sequence,
b"\x00" * 12, # _reserved
)
self._mm[offset : offset + ENTRY_SIZE] = data
# ── Blob access ──────────────────────────────────────────────────────
def read_blob(self, offset: int, size: int) -> np.ndarray:
"""Read raw float32 data from the blob region (returns a copy)."""
return np.frombuffer(
self._mm, dtype=np.float32, count=size // 4, offset=offset
).copy()
def write_blob(self, offset: int, data: bytes):
"""Write raw bytes to the blob region."""
self._mm[offset : offset + len(data)] = data
def write_blob_array(self, offset: int, arr: np.ndarray):
"""Write a numpy array to the blob region (preserves dtype)."""
raw = arr.tobytes()
self._mm[offset : offset + len(raw)] = raw
return len(raw)
# ── Layout helpers ───────────────────────────────────────────────────
def data_blob_offset(self, max_objects: int, region: int = 0) -> int:
"""Offset where data blobs start (past header + object table).
Returns an absolute offset into the SHM (region base + header + table).
"""
return self.region_base(region) + HEADER_SIZE + max_objects * ENTRY_SIZE