eb39e4393d
checkpoint / checkpoint (push) Successful in 2m13s
No test ever exercised the client ChunkDataDecoder against real byte data
before this (M2 chunk streaming was never fed real content on PC2/PC3).
Added scripts/smoke_test_chunk_codec_interop.gd, decoding the backend's
exact golden fixtures (backend/tests/fixtures/chunks/*.bin) and asserting
byte-identical logical values -- the real cross-language contract this
whole persistence system depends on. It found three bugs on first run:
1. chunk_data_decoder.gd's _read_float32/_write_float32 used `bits as
float`/`val as int`, a numeric cast in GDScript, not an IEEE-754
bitcast -- silently corrupted every non-integer position/rotation/
scale/bounds value (1.5 decoded as 1069547520.0, verified). Fixed with
PackedByteArray.decode_float/encode_float (real bitcast, same
little-endian order as the rest of this format).
2. backend/app/services/chunk_codec.py's binary codec wrote bounds as
min+size, disagreeing with world.md §3's documented min+max wire
format and with the client's actual (correct) decoder -- corrupted
every chunk's bounds on the wire despite the backend's own JSON path
using min+max correctly. Fixed the binary encode/decode to match the
spec; regenerated the golden fixtures.
3. chunk_data_decoder.gd's object-properties parsing used
`JSON.parse_string(props_str) or {}` -- GDScript's `or` is a boolean
operator, not Python/JS-style short-circuit-return, so this silently
evaluated to a bool for any object with real property data, crashing
on assignment to the typed `properties: Dictionary` field. Fixed with
an explicit type check.
Full backend suite (132/132) and 4 sibling client smoke tests re-verified
clean after all three fixes; no other `or {}`/`or []` instances of bug 3's
pattern found elsewhere in the codebase.
677 lines
25 KiB
Python
677 lines
25 KiB
Python
"""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 `<f` IEEE-754 float32, matching every other field in the
|
|
spec and matching what any conformant decoder would assume). See the note
|
|
for what this means for cross-codec byte parity today.
|
|
|
|
Nothing here talks to a database or the FastAPI app; it is a pure,
|
|
side-effect-free codec so it can be golden-file tested in isolation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import struct
|
|
from dataclasses import dataclass, field
|
|
|
|
MAGIC = b"AGPC"
|
|
HEADER_SIZE = 48
|
|
CURRENT_VERSION = 1
|
|
|
|
SECTION_META = 0x01
|
|
SECTION_TILE = 0x02
|
|
SECTION_OBJECT = 0x03
|
|
SECTION_FLAGS = 0x04
|
|
SECTION_REVISION_META = 0x05
|
|
|
|
CATEGORY_FIELDS = (
|
|
"violence", "sexual", "language", "substance",
|
|
"gambling", "horror", "hate", "custom",
|
|
)
|
|
|
|
|
|
class ChunkCodecError(Exception):
|
|
"""Malformed bytes -- the payload isn't shaped like a chunk at all."""
|
|
|
|
|
|
class ChunkValidationError(Exception):
|
|
"""A structurally-valid chunk failed a content/business-rule check.
|
|
|
|
``reason`` is the machine-readable code the API surface (world_persistence_impl.md
|
|
§8.1) puts in the 422 response body's ``reason`` field, e.g.
|
|
``"checksum_mismatch"``, ``"layer_dims"``, ``"object_out_of_bounds"``.
|
|
"""
|
|
|
|
def __init__(self, reason: str, message: str | None = None) -> 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("<H", data, off)[0]
|
|
|
|
|
|
def _read_u32(data: bytes, off: int) -> int:
|
|
return struct.unpack_from("<I", data, off)[0]
|
|
|
|
|
|
def _read_i32(data: bytes, off: int) -> int:
|
|
return struct.unpack_from("<i", data, off)[0]
|
|
|
|
|
|
def _read_i64(data: bytes, off: int) -> int:
|
|
return struct.unpack_from("<q", data, off)[0]
|
|
|
|
|
|
def _read_f32(data: bytes, off: int) -> float:
|
|
return struct.unpack_from("<f", data, off)[0]
|
|
|
|
|
|
def _w_u16(v: int) -> bytes:
|
|
return struct.pack("<H", v & 0xFFFF)
|
|
|
|
|
|
def _w_u32(v: int) -> bytes:
|
|
return struct.pack("<I", v & 0xFFFFFFFF)
|
|
|
|
|
|
def _w_i32(v: int) -> bytes:
|
|
return struct.pack("<i", v)
|
|
|
|
|
|
def _w_i64(v: int) -> bytes:
|
|
return struct.pack("<q", v)
|
|
|
|
|
|
def _w_f32(v: float) -> bytes:
|
|
return struct.pack("<f", v)
|
|
|
|
|
|
def _read_str(data: bytes, off: int) -> 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.
|