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.
108 lines
4.9 KiB
GDScript
108 lines
4.9 KiB
GDScript
extends SceneTree
|
|
## Proves the client-side ChunkDataDecoder decodes the backend's exact golden
|
|
## fixture files (backend/tests/fixtures/chunks/*.bin) with byte-identical
|
|
## logical values -- the actual cross-language contract this whole
|
|
## persistence system depends on. Also regression-covers the float32
|
|
## int<->float numeric-cast bug (fixed alongside this test): `bits as float`
|
|
## silently corrupted every non-integer value (1.5 decoded as 1069547520.0).
|
|
##
|
|
## Run: ~/godot/godot --headless --script res://scripts/smoke_test_chunk_codec_interop.gd
|
|
|
|
const FIXTURES_DIR := "res://backend/tests/fixtures/chunks/"
|
|
|
|
var passed := 0
|
|
var failed := 0
|
|
|
|
|
|
func _check(label: String, cond: bool) -> void:
|
|
if cond:
|
|
passed += 1
|
|
print(" PASS: ", label)
|
|
else:
|
|
failed += 1
|
|
print(" FAIL: ", label)
|
|
|
|
|
|
func _init() -> void:
|
|
_test_float32_roundtrip()
|
|
_test_empty_chunk_fixture()
|
|
_test_simple_tile_chunk_fixture()
|
|
_test_chunk_with_objects_fixture()
|
|
|
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
|
quit(0 if failed == 0 else 1)
|
|
|
|
|
|
func _read_fixture(name: String) -> PackedByteArray:
|
|
var f := FileAccess.open(FIXTURES_DIR + name, FileAccess.READ)
|
|
if f == null:
|
|
push_error("Could not open fixture: " + name)
|
|
return PackedByteArray()
|
|
return f.get_buffer(f.get_length())
|
|
|
|
|
|
func _test_float32_roundtrip() -> void:
|
|
print("--- float32 bitcast regression (the actual bug this fix addresses) ---")
|
|
for val in [1.5, -110.25, 0.5, 3.14159, -0.001, 0.0, 100.0]:
|
|
var encoded: PackedByteArray = ChunkDataDecoder._write_float32(val)
|
|
var decoded: float = ChunkDataDecoder._read_float32(encoded, 0)
|
|
_check("float32 round-trip for %s (got %s)" % [val, decoded], is_equal_approx(decoded, val))
|
|
# The specific regression case: 1.5 must NOT decode as 1069547520.0
|
|
# (the old `bits as float` numeric-cast bug's actual observed output).
|
|
var bad_encoded := ChunkDataDecoder._write_float32(1.5)
|
|
var bad_decoded: float = ChunkDataDecoder._read_float32(bad_encoded, 0)
|
|
_check("1.5 does not decode as the old numeric-cast bug's 1069547520.0", not is_equal_approx(bad_decoded, 1069547520.0))
|
|
|
|
|
|
func _test_empty_chunk_fixture() -> void:
|
|
print("--- backend golden fixture: empty_chunk.bin ---")
|
|
var data := _read_fixture("empty_chunk.bin")
|
|
_check("fixture file read (non-empty)", data.size() > 0)
|
|
var payload: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(data)
|
|
_check("decoded without error", payload != null)
|
|
if payload == null:
|
|
return
|
|
_check("world_id matches backend fixture (7)", payload.world_id == "7")
|
|
_check("no tile layers", payload.tile_layers.is_empty())
|
|
_check("no objects", payload.objects.is_empty())
|
|
_check("revision matches backend fixture (1)", payload.revision == 1)
|
|
|
|
|
|
func _test_simple_tile_chunk_fixture() -> void:
|
|
print("--- backend golden fixture: simple_tile_chunk.bin ---")
|
|
var data := _read_fixture("simple_tile_chunk.bin")
|
|
var payload: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(data)
|
|
_check("decoded without error", payload != null)
|
|
if payload == null:
|
|
return
|
|
_check("world_id matches (12)", payload.world_id == "12")
|
|
_check("coord matches (3,-1,0)", payload.coord == Vector3i(3, -1, 0))
|
|
_check("bounds.position matches (96.0,-32.0,0.0) -- non-trivial floats", payload.bounds.position.is_equal_approx(Vector3(96.0, -32.0, 0.0)))
|
|
_check("bounds.size matches (32.0,32.0,1.0)", payload.bounds.size.is_equal_approx(Vector3(32.0, 32.0, 1.0)))
|
|
_check("one tile layer parsed", payload.tile_layers.size() == 1)
|
|
if payload.tile_layers.size() == 1:
|
|
var layer: ChunkDataDecoder.TileLayerData = payload.tile_layers[0]
|
|
_check("tile layer dims 4x4x1", layer.width == 4 and layer.height == 4 and layer.depth == 1)
|
|
_check("tile data matches backend fixture", layer.tiles == PackedInt32Array([0, 1, 1, 0, 1, 2, 2, 1, 1, 2, 2, 1, 0, 1, 1, 0]))
|
|
_check("revision matches (2)", payload.revision == 2)
|
|
|
|
|
|
func _test_chunk_with_objects_fixture() -> void:
|
|
print("--- backend golden fixture: chunk_with_objects.bin (real non-integer floats) ---")
|
|
var data := _read_fixture("chunk_with_objects.bin")
|
|
var payload: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(data)
|
|
_check("decoded without error", payload != null)
|
|
if payload == null:
|
|
return
|
|
_check("one object parsed", payload.objects.size() >= 1)
|
|
if payload.objects.is_empty():
|
|
return
|
|
var obj: ChunkDataDecoder.WorldObjectData = payload.objects[0]
|
|
_check("object id matches (obj_0001)", obj.id == "obj_0001")
|
|
_check("object asset_id matches (props/chest)", obj.asset_id == "props/chest")
|
|
# The core regression check: these are exactly the kind of non-integer
|
|
# values that decoded as garbage under the old `bits as float` bug.
|
|
_check("object position.x matches 101.5 exactly (was the corrupted value)", is_equal_approx(obj.position.x, 101.5))
|
|
_check("object position.y matches -110.25 exactly (was the corrupted value)", is_equal_approx(obj.position.y, -110.25))
|
|
_check("object position.z matches 0.5 exactly (was the corrupted value)", is_equal_approx(obj.position.z, 0.5))
|