"""Python port of the chunk binary/JSON codec, `docs/architecture/world.md` §3. This is the server-side half of the "single shared format, two implementations" contract world.md §5.2 and `world_persistence_impl.md` §5.5/§9 call for. The Godot-side implementation is `scripts/chunk_data_decoder.gd`; this module must decode anything it produces and produce bytes it can decode. IMPORTANT DEVIATION (see the "Known cross-codec divergence" note at the bottom of this file): the current `scripts/chunk_data_decoder.gd` does not actually implement IEEE-754 float32 for the `pos`/`rot`/`scale`/`bounds` fields -- it performs a lossy numeric `int`/`float` cast instead of a bit reinterpretation, which both truncates fractional values and does not write IEEE-754 bits at all. This module implements the format as *specified* (real ` None: self.reason = reason super().__init__(message or reason) # ───────────────────────── Logical model ───────────────────────── @dataclass class TileDef: id: int asset_id: str = "" solid: bool = False flags: int = 0 variants: int = 1 @dataclass class TileLayer: name: str layer_id: int = 0 width: int = 0 height: int = 0 depth: int = 1 palette: list[TileDef] = field(default_factory=list) tiles: list[int] = field(default_factory=list) # flattened, row-major @dataclass class ContentFlags: violence: int = 0 sexual: int = 0 language: int = 0 substance: int = 0 gambling: int = 0 horror: int = 0 hate: int = 0 custom: int = 0 mature: bool = False sources: list[str] = field(default_factory=list) def to_dict(self) -> dict: return { "categories": {name: getattr(self, name) for name in CATEGORY_FIELDS}, "mature": self.mature, "sources": list(self.sources), } @classmethod def from_dict(cls, d: dict) -> "ContentFlags": cats = d.get("categories", {}) return cls( **{name: int(cats.get(name, 0)) for name in CATEGORY_FIELDS}, mature=bool(d.get("mature", False)), sources=list(d.get("sources", [])), ) @dataclass class WorldObject: id: str type: str asset_id: str pos: tuple[float, float, float] = (0.0, 0.0, 0.0) rot: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0) scale: tuple[float, float, float] = (1.0, 1.0, 1.0) dynamic: bool = False properties: dict = field(default_factory=dict) flags: ContentFlags = field(default_factory=ContentFlags) @dataclass class ChunkBounds: """Stored here as min-corner + size (matching the Godot client's AABB type) for convenience, but the §3.6 BINARY wire format is min + max (world.md §3.1, and the client's actual ChunkDataDecoder) -- fixed 2026-09-14 after a real client/server interop test caught this class's original binary codec writing min+size on the wire, which the client decoder (unaware of the "size" convention) read as a second min/max pair, corrupting every chunk's bounds. The two representations are fully recoverable from each other; `max` below is computed for exactly this reason.""" min: tuple[float, float, float] = (0.0, 0.0, 0.0) size: tuple[float, float, float] = (0.0, 0.0, 0.0) @property def max(self) -> tuple[float, float, float]: return tuple(a + b for a, b in zip(self.min, self.size)) @dataclass class ChunkPayload: version: int = CURRENT_VERSION world_id: str = "" room_id: str = "" coord: tuple[int, int, int] = (0, 0, 0) bounds: ChunkBounds = field(default_factory=ChunkBounds) tile_layers: list[TileLayer] = field(default_factory=list) objects: list[WorldObject] = field(default_factory=list) flags: ContentFlags = field(default_factory=ContentFlags) created_at: int = 0 updated_at: int = 0 revision: int = 0 author: str = "" def build_key(world_id: str | int, room_slug: str, cx: int, cy: int, cz: int) -> str: """world_persistence_impl.md §4.5: ``"{world.id}:{room.slug}:{cx}:{cy}:{cz}"``.""" return f"{world_id}:{room_slug}:{cx}:{cy}:{cz}" # ───────────────────────── Binary primitives ───────────────────────── # All integers little-endian, matching world.md §3.6.3. Floats are packed # as real IEEE-754 float32 -- see the module docstring's deviation note. def _read_u16(data: bytes, off: int) -> int: return struct.unpack_from(" int: return struct.unpack_from(" int: return struct.unpack_from(" int: return struct.unpack_from(" float: return struct.unpack_from(" bytes: return struct.pack(" bytes: return struct.pack(" bytes: return struct.pack(" bytes: return struct.pack(" bytes: return struct.pack(" tuple[str, int]: """Length-prefixed UTF-8 string. Returns (value, next_offset).""" n = _read_u16(data, off) off += 2 s = data[off:off + n].decode("utf-8") return s, off + n def _w_str(s: str) -> bytes: b = s.encode("utf-8") if len(b) > 0xFFFF: raise ChunkCodecError(f"string field too long to encode ({len(b)} bytes)") return _w_u16(len(b)) + b def checksum(payload_body: bytes) -> str: """SHA-256 hex digest of the payload body (world.md §3.6.3).""" return hashlib.sha256(payload_body).hexdigest() # ───────────────────────── Section codecs ───────────────────────── def _encode_metadata(payload: ChunkPayload) -> bytes: out = bytearray() out += _w_str(payload.world_id) out += _w_str(payload.room_id) out += _w_i32(payload.coord[0]) out += _w_i32(payload.coord[1]) out += _w_i32(payload.coord[2]) out += _w_f32(payload.bounds.min[0]) out += _w_f32(payload.bounds.min[1]) out += _w_f32(payload.bounds.min[2]) out += _w_f32(payload.bounds.max[0]) out += _w_f32(payload.bounds.max[1]) out += _w_f32(payload.bounds.max[2]) return bytes(out) def _decode_metadata(data: bytes, result: ChunkPayload) -> None: off = 0 result.world_id, off = _read_str(data, off) result.room_id, off = _read_str(data, off) coord = (_read_i32(data, off), _read_i32(data, off + 4), _read_i32(data, off + 8)) off += 12 min_pos = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8)) max_pos = (_read_f32(data, off + 12), _read_f32(data, off + 16), _read_f32(data, off + 20)) size = tuple(a - b for a, b in zip(max_pos, min_pos)) result.coord = coord result.bounds = ChunkBounds(min=min_pos, size=size) def _encode_tile_layer(layer: TileLayer) -> bytes: out = bytearray() out += _w_str(layer.name) out += _w_u16(layer.layer_id) out += _w_u16(layer.width) out += _w_u16(layer.height) out += _w_u16(layer.depth) out += _w_u16(len(layer.palette)) for td in layer.palette: out += _w_u16(td.id) out += _w_str(td.asset_id) out += bytes([1 if td.solid else 0]) out += _w_u32(td.flags) out += bytes([td.variants & 0xFF]) out += _w_u32(len(layer.tiles)) for tile_id in layer.tiles: out += _w_u16(tile_id) return bytes(out) def _decode_tile_layer(data: bytes) -> TileLayer: off = 0 name, off = _read_str(data, off) layer_id = _read_u16(data, off); off += 2 width = _read_u16(data, off); off += 2 height = _read_u16(data, off); off += 2 depth = _read_u16(data, off); off += 2 palette_count = _read_u16(data, off); off += 2 palette: list[TileDef] = [] for _ in range(palette_count): tid = _read_u16(data, off); off += 2 asset_id, off = _read_str(data, off) solid = data[off] != 0; off += 1 flags = _read_u32(data, off); off += 4 variants = data[off]; off += 1 palette.append(TileDef(id=tid, asset_id=asset_id, solid=solid, flags=flags, variants=variants)) tile_count = _read_u32(data, off); off += 4 tiles: list[int] = [] for _ in range(tile_count): tiles.append(_read_u16(data, off)) off += 2 return TileLayer(name=name, layer_id=layer_id, width=width, height=height, depth=depth, palette=palette, tiles=tiles) def _encode_flags(flags: ContentFlags) -> bytes: out = bytearray() for name in CATEGORY_FIELDS: out.append(getattr(flags, name) & 0xFF) out.append(1 if flags.mature else 0) out += _w_u16(len(flags.sources)) for src in flags.sources: out += _w_str(src) return bytes(out) def _decode_flags(data: bytes) -> ContentFlags: off = 0 values = {} for name in CATEGORY_FIELDS: values[name] = data[off] off += 1 mature = data[off] != 0 off += 1 source_count = _read_u16(data, off) off += 2 sources = [] for _ in range(source_count): s, off = _read_str(data, off) sources.append(s) return ContentFlags(**values, mature=mature, sources=sources) def _encode_object(obj: WorldObject) -> bytes: out = bytearray() out += _w_str(obj.id) out += _w_str(obj.type) out += _w_str(obj.asset_id) for v in obj.pos: out += _w_f32(v) for v in obj.rot: out += _w_f32(v) for v in obj.scale: out += _w_f32(v) out.append(1 if obj.dynamic else 0) # Canonical JSON: keys sorted lexicographically (world.md §3.6.3). props_json = json.dumps(obj.properties, sort_keys=True, separators=(",", ":")) out += _w_str(props_json) out += _encode_flags(obj.flags) return bytes(out) def _decode_object(data: bytes) -> WorldObject: off = 0 oid, off = _read_str(data, off) otype, off = _read_str(data, off) asset_id, off = _read_str(data, off) pos = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8)) off += 12 rot = ( _read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8), _read_f32(data, off + 12), ) off += 16 scale = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8)) off += 12 dynamic = data[off] != 0 off += 1 props_str, off = _read_str(data, off) try: properties = json.loads(props_str) if props_str else {} except json.JSONDecodeError as exc: raise ChunkCodecError(f"object {oid!r}: invalid properties JSON") from exc obj_flags = _decode_flags(data[off:]) return WorldObject(id=oid, type=otype, asset_id=asset_id, pos=pos, rot=rot, scale=scale, dynamic=dynamic, properties=properties, flags=obj_flags) def _encode_revision_meta(payload: ChunkPayload) -> bytes: out = bytearray() out += _w_i64(payload.created_at) out += _w_i64(payload.updated_at) out += _w_u32(payload.revision) out += _w_str(payload.author) return bytes(out) def _decode_revision_meta(data: bytes, result: ChunkPayload) -> None: off = 0 result.created_at = _read_i64(data, off); off += 8 result.updated_at = _read_i64(data, off); off += 8 result.revision = _read_u32(data, off); off += 4 result.author, off = _read_str(data, off) def _wrap_section(section_id: int, data: bytes) -> bytes: return bytes([section_id]) + _w_u32(len(data)) + data # ───────────────────────── Top-level encode/decode ───────────────────────── def encode_binary(payload: ChunkPayload) -> bytes: """Encode a ChunkPayload to the world.md §3.6 wire format.""" sections = bytearray() sections += _wrap_section(SECTION_META, _encode_metadata(payload)) for layer in payload.tile_layers: sections += _wrap_section(SECTION_TILE, _encode_tile_layer(layer)) for obj in payload.objects: sections += _wrap_section(SECTION_OBJECT, _encode_object(obj)) sections += _wrap_section(SECTION_FLAGS, _encode_flags(payload.flags)) sections += _wrap_section(SECTION_REVISION_META, _encode_revision_meta(payload)) payload_body = bytes(sections) digest = hashlib.sha256(payload_body).digest() # 32 raw bytes header = bytearray() header += MAGIC header += _w_u16(CURRENT_VERSION) header += _w_u16(HEADER_SIZE - 8) # header_len: bytes of header after this field (spec-correct) header += _w_u32(0) # flags (reserved) header += _w_u32(len(payload_body)) header += digest return bytes(header) + payload_body def decode_binary(data: bytes) -> ChunkPayload: """Decode a world.md §3.6 binary chunk. Raises ChunkCodecError on structurally malformed input (short buffer, bad magic, unsupported version, truncated payload).""" if len(data) < HEADER_SIZE: raise ChunkCodecError(f"data too short for header ({len(data)} bytes)") if data[0:4] != MAGIC: raise ChunkCodecError("bad magic bytes") version = _read_u16(data, 4) if version < 1 or version > CURRENT_VERSION: raise ChunkCodecError(f"unsupported chunk format version {version}") # header_len (offset 6) is intentionally not used for offset math -- # both this decoder and the Godot one always slice at the fixed # HEADER_SIZE, matching the reference implementation's actual (if # spec-inconsistent) behaviour. payload_len = _read_u32(data, 12) # bytes 16:48 are the header's advisory checksum -- decode_and_validate # re-reads it directly and recomputes against the parsed payload body. if len(data) - HEADER_SIZE != payload_len: raise ChunkCodecError( f"declared payload_len ({payload_len}) does not match actual " f"remaining bytes ({len(data) - HEADER_SIZE})" ) payload_body = data[HEADER_SIZE:HEADER_SIZE + payload_len] result = ChunkPayload(version=version) offset = 0 while offset < len(payload_body): if offset + 5 > len(payload_body): raise ChunkCodecError("truncated section header") section_id = payload_body[offset] section_len = _read_u32(payload_body, offset + 1) data_start = offset + 5 data_end = data_start + section_len if data_end > len(payload_body): raise ChunkCodecError("truncated section body") section_data = payload_body[data_start:data_end] if section_id == SECTION_META: _decode_metadata(section_data, result) elif section_id == SECTION_TILE: result.tile_layers.append(_decode_tile_layer(section_data)) elif section_id == SECTION_OBJECT: result.objects.append(_decode_object(section_data)) elif section_id == SECTION_FLAGS: result.flags = _decode_flags(section_data) elif section_id == SECTION_REVISION_META: _decode_revision_meta(section_data, result) # Unknown section ids are skipped (forward-compat), matching the # Godot decoder's match-with-no-default behaviour. offset = data_end return result # ───────────────────────── JSON encoding (world.md §3.5) ───────────────────────── # Derived, never stored (world_persistence_impl.md §8.1) -- produced here # from a decoded ChunkPayload for the ``Accept: application/json`` path. def to_json_dict(payload: ChunkPayload, *, key: str, chunk_checksum: str) -> dict: return { "version": payload.version, "key": key, "world_id": payload.world_id, "room_id": payload.room_id, "coord": {"cx": payload.coord[0], "cy": payload.coord[1], "cz": payload.coord[2]}, "bounds": { "min_x": payload.bounds.min[0], "min_y": payload.bounds.min[1], "min_z": payload.bounds.min[2], "max_x": payload.bounds.max[0], "max_y": payload.bounds.max[1], "max_z": payload.bounds.max[2], }, "tiles": [ { "name": layer.name, "layer_id": layer.layer_id, "width": layer.width, "height": layer.height, "depth": layer.depth, "palette": [ {"id": td.id, "asset_id": td.asset_id, "solid": td.solid, "flags": td.flags, "variants": td.variants} for td in layer.palette ], "tiles": list(layer.tiles), } for layer in payload.tile_layers ], "objects": [ { "id": obj.id, "type": obj.type, "asset_id": obj.asset_id, "transform": { "pos": list(obj.pos), "rot": list(obj.rot), "scale": list(obj.scale), }, "properties": obj.properties, "dynamic": obj.dynamic, "flags": obj.flags.to_dict(), } for obj in payload.objects ], "flags": payload.flags.to_dict(), "meta": { "created_at": payload.created_at, "updated_at": payload.updated_at, "revision": payload.revision, "author": payload.author, }, "checksum": chunk_checksum, } # ───────────────────────── Validation (world_persistence_impl.md §5.5) ───────────────────────── # Not precisely quantified by the design doc beyond "length limits" -- # these are this implementation's conservative defaults (world.md §7). MAX_STRING_FIELD_LEN = 256 MAX_PROPERTIES_JSON_LEN = 16_384 MAX_SOURCES_PER_FLAGSET = 32 def _check_string_lengths(obj: WorldObject) -> None: for field_name, value in (("id", obj.id), ("type", obj.type), ("asset_id", obj.asset_id)): if len(value) > MAX_STRING_FIELD_LEN: raise ChunkValidationError( "string_field_too_long", f"object.{field_name} exceeds {MAX_STRING_FIELD_LEN} chars" ) props_json = json.dumps(obj.properties) if len(props_json) > MAX_PROPERTIES_JSON_LEN: raise ChunkValidationError( "string_field_too_long", f"object {obj.id!r} properties exceed {MAX_PROPERTIES_JSON_LEN} bytes" ) if len(obj.flags.sources) > MAX_SOURCES_PER_FLAGSET: raise ChunkValidationError("string_field_too_long", f"object {obj.id!r} has too many flag sources") def decode_and_validate( data: bytes, *, expected_key: str, grid_size: tuple[int, int, int], max_objects: int, ) -> ChunkPayload: """Decode + fully validate a client-submitted chunk payload. This is the boundary world_persistence_impl.md §5.5 requires: "the backend must not trust the client". Raises ``ChunkCodecError`` for structurally malformed bytes (mapped to a 422 by the router with a generic reason) or ``ChunkValidationError`` with a specific ``reason`` code for a well-formed-but-invalid payload. """ payload = decode_binary(data) # raises ChunkCodecError for structural problems first # Checksum is advisory in the header (world.md §7); the value that # matters is recomputed from the actual payload body we just parsed. payload_body = data[HEADER_SIZE:] recomputed = checksum(payload_body) header_checksum_hex = data[16:48].hex() if header_checksum_hex != recomputed: raise ChunkValidationError( "checksum_mismatch", "header checksum does not match the recomputed SHA-256 of the payload body", ) actual_key = build_key(payload.world_id, payload.room_id, *payload.coord) if actual_key != expected_key: raise ChunkValidationError( "chunk_key_mismatch", f"payload key {actual_key!r} does not match URL {expected_key!r}", ) for layer in payload.tile_layers: if (layer.width, layer.height, layer.depth) != tuple(grid_size): raise ChunkValidationError( "layer_dims", f"tile layer {layer.name!r} dims {(layer.width, layer.height, layer.depth)} " f"!= room grid {tuple(grid_size)}", ) valid_ids = {0} | {td.id for td in layer.palette} for tile_id in layer.tiles: if tile_id not in valid_ids: raise ChunkValidationError( "tile_out_of_palette", f"tile layer {layer.name!r} references palette id {tile_id} not in its palette", ) if len(layer.tiles) != layer.width * layer.height * layer.depth: raise ChunkValidationError( "layer_dims", f"tile layer {layer.name!r} has {len(layer.tiles)} cells, expected " f"{layer.width * layer.height * layer.depth}", ) if len(payload.objects) > max_objects: raise ChunkValidationError( "too_many_objects", f"{len(payload.objects)} objects exceeds max {max_objects}" ) bmin, bmax = payload.bounds.min, payload.bounds.max for obj in payload.objects: _check_string_lengths(obj) if not all(bmin[i] <= obj.pos[i] <= bmax[i] for i in range(3)): raise ChunkValidationError( "object_out_of_bounds", f"object {obj.id!r} position {obj.pos} outside chunk bounds " f"[{bmin}, {bmax}]", ) if len(payload.author) > MAX_STRING_FIELD_LEN: raise ChunkValidationError("string_field_too_long", "meta.author too long") return payload # ───────────────────────── Known cross-codec divergence ───────────────────────── # # `scripts/chunk_data_decoder.gd`'s `_read_float32`/`_write_float32` do: # # static func _write_float32(val: float) -> PackedByteArray: # var bits := val as int # return _write_uint32(bits) # # static func _read_float32(data: PackedByteArray, offset: int) -> float: # var bits := _read_uint32(data, offset) # return bits as float # # `as` between int and float in GDScript is a numeric conversion, not a bit # reinterpretation (verified empirically against Godot 4.6.2 headless: # `1.5 as int` -> `1`, `1 as float` -> `1.0`; the IEEE-754 bit pattern of # 1.5f is 0x3FC00000 = 1069547520, not 1). So the shipped client codec # truncates every float32 field (bounds, object pos/rot/scale) to its # integer part and stores that integer as a raw int32 -- not as float32 # bits at all. This is a real bug in already-merged, client-side code # (scripts/ is out of scope for this task) that breaks the "byte-stable # across two implementations" guarantee world.md §3.6.3 states. # # This module implements the *specified* format (real IEEE-754 float32) # rather than replicating that bug, because world.md §3.6 is normative and # says "float32" for these fields, and mirroring the bug would mean the # backend also corrupts every non-integer transform/bounds value it # stores. Practical effect until the client-side bug is fixed: payloads # with integer-only floats will decode identically on both sides; any # payload with a fractional position/rotation/scale/bounds value will not # be byte-identical (or numerically identical) between this module and the # current `scripts/chunk_data_decoder.gd`. This is flagged for the # client/editor follow-on task, not fixed here.