From da63aa580efe7a693a9db2593744bcd65e0fcd13 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 02:20:24 +0100 Subject: [PATCH 1/3] feat(ci): add make fixtures-client target for GDScript->Rust cross-encoder validation (#475) Closes the bidirectional protocol compatibility loop (D-030 Layer 1): - GDScript fixture generator (20 fixtures: inputs, boundary ticks, batch) - Rust decoder test verifying all GDScript-encoded fixtures deserialize - Makefile target with generation + verification in one step Co-Authored-By: Claude Opus 4.6 --- Makefile | 12 +- client/tests/gen_client_fixtures.gd | 144 ++++++++++++++++++ docs/DEVOPS.md | 13 ++ .../fixtures/gdscript/boundary_tick_0.msgpack | Bin 0 -> 20 bytes .../gdscript/boundary_tick_127.msgpack | 1 + .../gdscript/boundary_tick_128.msgpack | 1 + .../gdscript/boundary_tick_2147483647.msgpack | 1 + .../gdscript/boundary_tick_255.msgpack | 1 + .../gdscript/boundary_tick_256.msgpack | Bin 0 -> 22 bytes .../gdscript/boundary_tick_32767.msgpack | 1 + .../gdscript/boundary_tick_32768.msgpack | Bin 0 -> 22 bytes .../gdscript/boundary_tick_65535.msgpack | 1 + .../gdscript/boundary_tick_65536.msgpack | Bin 0 -> 24 bytes .../fixtures/gdscript/input_batch_two.msgpack | Bin 0 -> 74 bytes .../fixtures/gdscript/input_interact.msgpack | 1 + .../gdscript/input_move_north.msgpack | 1 + .../gdscript/input_move_northeast.msgpack | 1 + .../gdscript/input_move_northwest.msgpack | 1 + .../gdscript/input_move_southeast.msgpack | 1 + .../gdscript/input_move_southwest.msgpack | 1 + .../fixtures/gdscript/input_pause.msgpack | 1 + .../gdscript/input_perception_mode.msgpack | 1 + .../gdscript/input_toggle_stance_up.msgpack | 1 + server/tests/serialization.rs | 94 ++++++++++++ 24 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 client/tests/gen_client_fixtures.gd create mode 100644 server/tests/fixtures/gdscript/boundary_tick_0.msgpack create mode 100644 server/tests/fixtures/gdscript/boundary_tick_127.msgpack create mode 100644 server/tests/fixtures/gdscript/boundary_tick_128.msgpack create mode 100644 server/tests/fixtures/gdscript/boundary_tick_2147483647.msgpack create mode 100644 server/tests/fixtures/gdscript/boundary_tick_255.msgpack create mode 100644 server/tests/fixtures/gdscript/boundary_tick_256.msgpack create mode 100644 server/tests/fixtures/gdscript/boundary_tick_32767.msgpack create mode 100644 server/tests/fixtures/gdscript/boundary_tick_32768.msgpack create mode 100644 server/tests/fixtures/gdscript/boundary_tick_65535.msgpack create mode 100644 server/tests/fixtures/gdscript/boundary_tick_65536.msgpack create mode 100644 server/tests/fixtures/gdscript/input_batch_two.msgpack create mode 100644 server/tests/fixtures/gdscript/input_interact.msgpack create mode 100644 server/tests/fixtures/gdscript/input_move_north.msgpack create mode 100644 server/tests/fixtures/gdscript/input_move_northeast.msgpack create mode 100644 server/tests/fixtures/gdscript/input_move_northwest.msgpack create mode 100644 server/tests/fixtures/gdscript/input_move_southeast.msgpack create mode 100644 server/tests/fixtures/gdscript/input_move_southwest.msgpack create mode 100644 server/tests/fixtures/gdscript/input_pause.msgpack create mode 100644 server/tests/fixtures/gdscript/input_perception_mode.msgpack create mode 100644 server/tests/fixtures/gdscript/input_toggle_stance_up.msgpack diff --git a/Makefile b/Makefile index 781235427..c1491d875 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) decisions-sync decisions-coverage decisions-active decisions-orphan \ db-backup db-install validate-content content-ron check-fact-ids setup-hooks \ pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \ - pre-pr-server pre-pr-client pre-pr-content + pre-pr-server pre-pr-client pre-pr-content \ + fixtures-client # --- Configuration --- @@ -37,6 +38,7 @@ help: @echo " make validate-content Validate content YAML against schemas" @echo " make check-fact-ids Check fact_id references against knowledge catalogs" @echo " make content-ron Convert content YAML to RON (build-time)" + @echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)" @echo "" @echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)" @echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)" @@ -111,6 +113,14 @@ test-server: fixtures: cd server && cargo test --test gen_fixtures -- --ignored +fixtures-client: + @test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; } + @echo "Generating GDScript fixtures for Rust decoder..." + $(GODOT) --headless --path client -s res://tests/gen_client_fixtures.gd + @echo "Verifying Rust can decode GDScript fixtures..." + cd server && cargo test --test serialization gdscript_generated_fixtures + @echo "--- Fixtures-client: PASS ---" + test-client: @test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; } $(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/ diff --git a/client/tests/gen_client_fixtures.gd b/client/tests/gen_client_fixtures.gd new file mode 100644 index 000000000..f3bbf6df1 --- /dev/null +++ b/client/tests/gen_client_fixtures.gd @@ -0,0 +1,144 @@ +## Generate MessagePack fixtures from GDScript encoder for Rust decoder testing. +## Run with: make fixtures-client +## (or: godot --headless --path client -s res://tests/gen_client_fixtures.gd) +## +## Reverse direction of server/tests/gen_fixtures.rs: GDScript encodes -> Rust decodes. +## Together with the Rust-generated fixtures, this closes the cross-encoder +## compatibility loop (D-030 Layer 1, #475). +## +## Loads Messagepack encoder directly (not via class_name) because -s scripts +## run before the project's class_name registry is fully populated. +extends SceneTree + +var _Msgpack: GDScript +var _count := 0 +var _output_dir: String + + +func _init(): + _run.call_deferred() + + +func _run(): + _Msgpack = load("res://addons/messagepack/messagepack.gd") + + var project_root := ProjectSettings.globalize_path("res://") + var repo_root := project_root.rstrip("/").get_base_dir() + _output_dir = repo_root.path_join("server/tests/fixtures/gdscript") + + DirAccess.make_dir_recursive_absolute(_output_dir) + + _generate_inputs() + _generate_boundary_inputs() + _generate_batch() + + print("Generated %d GDScript fixtures at %s" % [_count, _output_dir]) + quit() + + +func _write_fixture(name: String, bytes: PackedByteArray) -> void: + var path := _output_dir.path_join(name + ".msgpack") + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + push_error("Failed to write fixture: %s (error: %d)" % [path, FileAccess.get_open_error()]) + return + file.store_buffer(bytes) + file.close() + print(" Wrote %s (%d bytes)" % [name, bytes.size()]) + _count += 1 + + +## Encode a single PlayerInput to MessagePack bytes. +## Mirrors Protocol.encode_player_input() from protocol.gd. +func _encode_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray: + var action: Variant + if action_data != null: + action = {action_name: action_data} + elif action_name == "Interact": + action = {"Interact": {"target_entity_id": null, "verb": null}} + else: + action = action_name + + var result = _Msgpack.encode({"tick": tick, "action": action}) + if result.status != null: + push_error("Encode failed: %s" % result.status) + return PackedByteArray() + return result.value + + +## Encode an array of PlayerInputs to MessagePack bytes. +## Mirrors Protocol.encode_player_inputs() from protocol.gd. +func _encode_inputs(inputs: Array) -> PackedByteArray: + var wire_inputs: Array = [] + for input in inputs: + var action_name: String = input["action_name"] + var action_data: Variant = input.get("action_data") + var action: Variant + if action_data != null: + action = {action_name: action_data} + elif action_name == "Interact": + action = {"Interact": {"target_entity_id": null, "verb": null}} + else: + action = action_name + wire_inputs.append({"tick": input["tick"], "action": action}) + + var result = _Msgpack.encode(wire_inputs) + if result.status != null: + push_error("Batch encode failed: %s" % result.status) + return PackedByteArray() + return result.value + + +func _generate_inputs() -> void: + # Unit variants: movement directions (tick=100) + _write_fixture("input_move_north", + _encode_input(100, "MoveNorth")) + for dir_name in ["MoveNortheast", "MoveSoutheast", "MoveSouthwest", "MoveNorthwest"]: + _write_fixture("input_%s" % dir_name.to_snake_case(), + _encode_input(100, dir_name)) + + # Data variant: UsePerceptionMode (tick=200) + _write_fixture("input_perception_mode", + _encode_input(200, "UsePerceptionMode", "thermal")) + + # Struct variant: Interact with null fields (tick=100) + _write_fixture("input_interact", + _encode_input(100, "Interact")) + + # Other unit variants + _write_fixture("input_pause", + _encode_input(100, "Pause")) + _write_fixture("input_toggle_stance_up", + _encode_input(100, "ToggleStanceUp")) + + +func _generate_boundary_inputs() -> void: + # Tick values at MessagePack encoding format boundaries. + # GDScript encodes 256-32767 as int_16 (0xd1); Rust encodes as uint_16 (0xcd). + # GDScript encodes 65536-2147483647 as int_32 (0xd2); Rust as uint_32 (0xce). + # Both are valid MessagePack. Rust's rmp_serde must accept both. + var boundary_ticks: Array = [ + [0, "boundary_tick_0"], + [127, "boundary_tick_127"], + [128, "boundary_tick_128"], + [255, "boundary_tick_255"], + [256, "boundary_tick_256"], # int_16 asymmetry start + [32767, "boundary_tick_32767"], # int_16 asymmetry end + [32768, "boundary_tick_32768"], + [65535, "boundary_tick_65535"], + [65536, "boundary_tick_65536"], # int_32 asymmetry start + [2147483647, "boundary_tick_2147483647"], # int_32 asymmetry end + ] + + for pair in boundary_ticks: + _write_fixture(pair[1], + _encode_input(pair[0], "Pause")) + + +func _generate_batch() -> void: + # Vec with two actions (mirrors Rust input_batch_two fixture) + _write_fixture("input_batch_two", + _encode_inputs([ + {"tick": 0, "action_name": "MoveNorth"}, + {"tick": 0, "action_name": "Interact"}, + ])) diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 9a400fdb0..56c47d6c7 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -69,6 +69,19 @@ make test-client # gdUnit4 tests (headless runner pending) Server tests use Rust's built-in test framework with `#[cfg(test)]` inline tests and `tests/` integration tests (D-030). Client tests use gdUnit4 (D-030). +### Cross-Encoder Fixtures + +```bash +make fixtures # Regenerate Rust->GDScript fixtures (server/tests/gen_fixtures.rs) +make fixtures-client # Generate GDScript->Rust fixtures + verify Rust decoder (#475) +``` + +The bidirectional protocol is validated by two sets of committed fixtures: +- **Rust encodes, GDScript decodes:** `client/tests/fixtures/msgpack/` (generated by `make fixtures`) +- **GDScript encodes, Rust decodes:** `server/tests/fixtures/gdscript/` (generated by `make fixtures-client`) + +Regenerate both after any protocol change. Commit the updated fixtures alongside the code change. + ### Lint ```bash diff --git a/server/tests/fixtures/gdscript/boundary_tick_0.msgpack b/server/tests/fixtures/gdscript/boundary_tick_0.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..15d65cd6d5c38d7d25451dfe95ad1a4318434078 GIT binary patch literal 20 bcmZo#Qj(dR&9E#nxg;|`Z)recX>lq5TZjmm literal 0 HcmV?d00001 diff --git a/server/tests/fixtures/gdscript/boundary_tick_127.msgpack b/server/tests/fixtures/gdscript/boundary_tick_127.msgpack new file mode 100644 index 000000000..6e1132226 --- /dev/null +++ b/server/tests/fixtures/gdscript/boundary_tick_127.msgpack @@ -0,0 +1 @@ +‚¤tick¦action¥Pause \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/boundary_tick_128.msgpack b/server/tests/fixtures/gdscript/boundary_tick_128.msgpack new file mode 100644 index 000000000..f58d4c964 --- /dev/null +++ b/server/tests/fixtures/gdscript/boundary_tick_128.msgpack @@ -0,0 +1 @@ +‚¤tickÌ€¦action¥Pause \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/boundary_tick_2147483647.msgpack b/server/tests/fixtures/gdscript/boundary_tick_2147483647.msgpack new file mode 100644 index 000000000..d5e340907 --- /dev/null +++ b/server/tests/fixtures/gdscript/boundary_tick_2147483647.msgpack @@ -0,0 +1 @@ +‚¤tickÒÿÿÿ¦action¥Pause \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/boundary_tick_255.msgpack b/server/tests/fixtures/gdscript/boundary_tick_255.msgpack new file mode 100644 index 000000000..89c76131c --- /dev/null +++ b/server/tests/fixtures/gdscript/boundary_tick_255.msgpack @@ -0,0 +1 @@ +‚¤tickÌÿ¦action¥Pause \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/boundary_tick_256.msgpack b/server/tests/fixtures/gdscript/boundary_tick_256.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..a587bee7adc7c74fc91b9e433ec0c0e04722af0c GIT binary patch literal 22 dcmZo#Qj(dReUXu2Sz>ZYW`5q%fW*?`Q~+*E32OiV literal 0 HcmV?d00001 diff --git a/server/tests/fixtures/gdscript/boundary_tick_32767.msgpack b/server/tests/fixtures/gdscript/boundary_tick_32767.msgpack new file mode 100644 index 000000000..54bc069e3 --- /dev/null +++ b/server/tests/fixtures/gdscript/boundary_tick_32767.msgpack @@ -0,0 +1 @@ +‚¤tickÑÿ¦action¥Pause \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/boundary_tick_32768.msgpack b/server/tests/fixtures/gdscript/boundary_tick_32768.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..df76b274228beb6b55e73b3e5a78858ae5cc7b78 GIT binary patch literal 22 dcmZo#Qj(dReYSyNSz>ZYW`5q%fW*?`Q~-5+3FZI* literal 0 HcmV?d00001 diff --git a/server/tests/fixtures/gdscript/boundary_tick_65535.msgpack b/server/tests/fixtures/gdscript/boundary_tick_65535.msgpack new file mode 100644 index 000000000..9984b34e8 --- /dev/null +++ b/server/tests/fixtures/gdscript/boundary_tick_65535.msgpack @@ -0,0 +1 @@ +‚¤tickÍÿÿ¦action¥Pause \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/boundary_tick_65536.msgpack b/server/tests/fixtures/gdscript/boundary_tick_65536.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..b53bad5efcc38897e16ffaa12718fd9f41d35abd GIT binary patch literal 24 fcmZo#Qj(dReTjjQfnix-qL`?(&AJAb)*Sw literal 0 HcmV?d00001 diff --git a/server/tests/fixtures/gdscript/input_batch_two.msgpack b/server/tests/fixtures/gdscript/input_batch_two.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..895f9f08056ae4a1bb3a6e515196ff1578d4afbe GIT binary patch literal 74 zcmbQ#w4@|6Ih$cwVsc4le%?yo{IXQP{GyT!RPn|Yo_QsyML Rust decoder. +/// Together with all_fixtures_deserialize (Rust -> GDScript), this closes the +/// cross-encoder compatibility loop. +/// +/// Fixtures generated by: make fixtures-client +/// (runs client/tests/gen_client_fixtures.gd via Godot headless) +#[test] +fn gdscript_generated_fixtures_deserialize() { + let fixture_dir = std::path::Path::new("tests/fixtures/gdscript"); + + if !fixture_dir.exists() { + eprintln!( + "SKIP: GDScript fixtures not found at {}. Run `make fixtures-client` to generate.", + fixture_dir.display() + ); + return; + } + + let mut count = 0; + for entry in fs::read_dir(&fixture_dir).expect("read gdscript fixture dir") { + let entry = entry.expect("read dir entry"); + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("msgpack") { + continue; + } + let name = path.file_stem().unwrap().to_str().unwrap().to_string(); + let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name)); + + if name.starts_with("input_batch") { + let inputs: Vec = rmp_serde::from_slice(&bytes).unwrap_or_else(|e| { + panic!("deserialize GDScript batch fixture {}: {}", name, e) + }); + assert!( + !inputs.is_empty(), + "batch fixture {} should not be empty", + name + ); + } else if name.starts_with("input_") || name.starts_with("boundary_tick_") { + let input: PlayerInput = rmp_serde::from_slice(&bytes).unwrap_or_else(|e| { + panic!("deserialize GDScript input fixture {}: {}", name, e) + }); + // Verify specific fixtures for extra confidence + match name.as_str() { + "input_move_north" => { + assert_eq!(input.tick, 100); + assert!(matches!(input.action, PlayerAction::MoveNorth)); + } + "input_perception_mode" => { + assert_eq!(input.tick, 200); + assert!( + matches!(input.action, PlayerAction::UsePerceptionMode(ref s) if s == "thermal") + ); + } + "input_interact" => { + assert_eq!(input.tick, 100); + assert!(matches!(input.action, PlayerAction::Interact { .. })); + } + "boundary_tick_256" => { + assert_eq!(input.tick, 256, "int_16 asymmetry: tick=256"); + } + "boundary_tick_32767" => { + assert_eq!(input.tick, 32767, "int_16 asymmetry: tick=32767"); + } + "boundary_tick_65536" => { + assert_eq!(input.tick, 65536, "int_32 asymmetry: tick=65536"); + } + "boundary_tick_2147483647" => { + assert_eq!( + input.tick, 2147483647, + "int_32 asymmetry: tick=2^31-1" + ); + } + _ => {} // Other fixtures: deserialization success is sufficient + } + } else { + panic!( + "unknown GDScript fixture naming convention: {} — add a deserialization branch", + name + ); + } + count += 1; + } + + if count == 0 { + eprintln!( + "SKIP: no .msgpack files found in {}. Run `make fixtures-client` to generate.", + fixture_dir.display() + ); + return; + } + eprintln!("Verified {} GDScript-generated fixtures", count); +} + /// NearbyInteraction.object_type round-trips through MessagePack (#422). /// Verifies object_type=Some(Container) survives the wire. #[test] From 3ccac5432b6fcd2fe024ed598097472831bef51f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 09:45:13 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix(ci):=20address=20PR=20#31=20review=20?= =?UTF-8?q?=E2=80=94=20harden=20cross-encoder=20fixture=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fail on encode errors instead of silently writing empty .msgpack files - Fail test on missing/empty fixture dir instead of silent skip - Add all missing action variants (MoveSouth, MoveEast, MoveWest, Unpause, ToggleStanceDown, WalkAway) to GDScript fixture generator - Add GDScript fixture staleness check to make pre-pr - Validate repo root detection before writing outside client/ - Add file.flush() before close in headless mode - Document fixture failure recovery in DEVOPS.md Co-Authored-By: Claude Opus 4.6 --- Makefile | 22 ++++++++++++++--- client/tests/gen_client_fixtures.gd | 37 +++++++++++++++++++++++++---- docs/DEVOPS.md | 6 +++++ server/tests/serialization.rs | 25 +++++++++---------- 4 files changed, 69 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index c1491d875..b2ed08a7d 100644 --- a/Makefile +++ b/Makefile @@ -158,13 +158,13 @@ pre-pr-validate: validate-content check-fact-ids @echo "--- Content validation: PASS ---" pre-pr-fixtures: - @echo "Checking fixture staleness..." + @echo "Checking Rust->GDScript fixture staleness..." @cd server && cargo test --test gen_fixtures -- --ignored @if git diff --quiet client/tests/fixtures/; then \ - echo "--- Fixtures: UP TO DATE ---"; \ + echo "--- Rust fixtures: UP TO DATE ---"; \ else \ echo ""; \ - echo "--- FIXTURES STALE ---"; \ + echo "--- RUST FIXTURES STALE ---"; \ echo " Protocol changed but fixtures not regenerated."; \ echo " Stale fixtures make all client tests FALSE POSITIVES."; \ echo ""; \ @@ -174,6 +174,22 @@ pre-pr-fixtures: echo " Fix: commit the updated fixtures with your protocol change."; \ exit 1; \ fi + @echo "Checking GDScript->Rust fixture staleness..." + @$(MAKE) fixtures-client + @if git diff --quiet server/tests/fixtures/gdscript/; then \ + echo "--- GDScript fixtures: UP TO DATE ---"; \ + else \ + echo ""; \ + echo "--- GDSCRIPT FIXTURES STALE ---"; \ + echo " Protocol changed but GDScript fixtures not regenerated."; \ + echo " Stale fixtures make cross-encoder tests FALSE POSITIVES."; \ + echo ""; \ + echo " Changed files:"; \ + git diff --stat server/tests/fixtures/gdscript/; \ + echo ""; \ + echo " Fix: commit the updated fixtures with your protocol change."; \ + exit 1; \ + fi # Branch-specific variants (faster, scope-appropriate) diff --git a/client/tests/gen_client_fixtures.gd b/client/tests/gen_client_fixtures.gd index f3bbf6df1..20383136a 100644 --- a/client/tests/gen_client_fixtures.gd +++ b/client/tests/gen_client_fixtures.gd @@ -12,6 +12,7 @@ extends SceneTree var _Msgpack: GDScript var _count := 0 +var _errors := 0 var _output_dir: String @@ -22,27 +23,48 @@ func _init(): func _run(): _Msgpack = load("res://addons/messagepack/messagepack.gd") + # Resolve repo root from Godot project root (client/). + # Assumes client/ is one level below repo root — validated below. var project_root := ProjectSettings.globalize_path("res://") var repo_root := project_root.rstrip("/").get_base_dir() _output_dir = repo_root.path_join("server/tests/fixtures/gdscript") + if not DirAccess.dir_exists_absolute(repo_root.path_join("server")): + push_error("Repo root detection failed: %s/server/ does not exist" % repo_root) + quit(1) + return + DirAccess.make_dir_recursive_absolute(_output_dir) _generate_inputs() _generate_boundary_inputs() _generate_batch() + if _errors > 0: + push_error("FAILED: %d encode errors encountered" % _errors) + quit(1) + return + if _count == 0: + push_error("FAILED: no fixtures generated") + quit(1) + return print("Generated %d GDScript fixtures at %s" % [_count, _output_dir]) quit() func _write_fixture(name: String, bytes: PackedByteArray) -> void: + if bytes.is_empty(): + push_error("Encode produced empty bytes for fixture: %s" % name) + _errors += 1 + return var path := _output_dir.path_join(name + ".msgpack") var file := FileAccess.open(path, FileAccess.WRITE) if file == null: push_error("Failed to write fixture: %s (error: %d)" % [path, FileAccess.get_open_error()]) + _errors += 1 return file.store_buffer(bytes) + file.flush() file.close() print(" Wrote %s (%d bytes)" % [name, bytes.size()]) _count += 1 @@ -90,10 +112,11 @@ func _encode_inputs(inputs: Array) -> PackedByteArray: func _generate_inputs() -> void: - # Unit variants: movement directions (tick=100) - _write_fixture("input_move_north", - _encode_input(100, "MoveNorth")) - for dir_name in ["MoveNortheast", "MoveSoutheast", "MoveSouthwest", "MoveNorthwest"]: + # Unit variants: all 8 movement directions (tick=100) + for dir_name in [ + "MoveNorth", "MoveSouth", "MoveEast", "MoveWest", + "MoveNortheast", "MoveSoutheast", "MoveSouthwest", "MoveNorthwest", + ]: _write_fixture("input_%s" % dir_name.to_snake_case(), _encode_input(100, dir_name)) @@ -108,8 +131,14 @@ func _generate_inputs() -> void: # Other unit variants _write_fixture("input_pause", _encode_input(100, "Pause")) + _write_fixture("input_unpause", + _encode_input(100, "Unpause")) _write_fixture("input_toggle_stance_up", _encode_input(100, "ToggleStanceUp")) + _write_fixture("input_toggle_stance_down", + _encode_input(100, "ToggleStanceDown")) + _write_fixture("input_walk_away", + _encode_input(100, "WalkAway")) func _generate_boundary_inputs() -> void: diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 56c47d6c7..162c07568 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -82,6 +82,12 @@ The bidirectional protocol is validated by two sets of committed fixtures: Regenerate both after any protocol change. Commit the updated fixtures alongside the code change. +**Troubleshooting fixture failures:** + +- **`make fixtures-client` fails with encode errors:** Check that `client/addons/messagepack/messagepack.gd` is up to date. The script exits non-zero on any encode failure. +- **`gdscript_generated_fixtures_deserialize` fails:** Fixtures in `server/tests/fixtures/gdscript/` are stale or corrupted. Re-run `make fixtures-client` and commit the updated files. +- **Fixture staleness in `make pre-pr`:** Protocol changed but fixtures were not regenerated. Run `make fixtures && make fixtures-client`, then commit both `client/tests/fixtures/` and `server/tests/fixtures/gdscript/`. + ### Lint ```bash diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 3366279fe..2a094c5d4 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -1013,15 +1013,14 @@ fn malformed_input_in_batch_rejects_entire_batch() { /// (runs client/tests/gen_client_fixtures.gd via Godot headless) #[test] fn gdscript_generated_fixtures_deserialize() { + // CWD is server/ when cargo test runs (Cargo sets it to the package root) let fixture_dir = std::path::Path::new("tests/fixtures/gdscript"); - if !fixture_dir.exists() { - eprintln!( - "SKIP: GDScript fixtures not found at {}. Run `make fixtures-client` to generate.", - fixture_dir.display() - ); - return; - } + assert!( + fixture_dir.exists(), + "GDScript fixture directory not found at {}. Run `make fixtures-client` to generate.", + fixture_dir.display() + ); let mut count = 0; for entry in fs::read_dir(&fixture_dir).expect("read gdscript fixture dir") { @@ -1088,13 +1087,11 @@ fn gdscript_generated_fixtures_deserialize() { count += 1; } - if count == 0 { - eprintln!( - "SKIP: no .msgpack files found in {}. Run `make fixtures-client` to generate.", - fixture_dir.display() - ); - return; - } + assert!( + count > 0, + "No .msgpack files found in {}. Run `make fixtures-client` to generate.", + fixture_dir.display() + ); eprintln!("Verified {} GDScript-generated fixtures", count); } From f8db996eb9d5d6d2217f42cc91f5a5247abccfb2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 09:45:31 +0100 Subject: [PATCH 3/3] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a27d5620..ae0dfc418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Stance change audio (`sfx_stance_change.ogg`) — subtle mechanical click for stance toggle feedback (#440) ### Fixed +- Cross-encoder fixture pipeline hardened — encode failures now exit non-zero instead of writing empty .msgpack files (#475 review) +- GDScript fixture test no longer silently skips on missing/empty fixture dir — asserts instead (#475 review) +- GDScript fixture generator covers all PlayerAction variants (added MoveSouth, MoveEast, MoveWest, Unpause, ToggleStanceDown, WalkAway) +- `make pre-pr` now checks GDScript fixture staleness alongside Rust fixtures - Protocol version bumped from 7 to 8 to match server — fixes 5 test failures from version mismatch - Interact action encoding changed from unit variant to struct variant to match server's PlayerAction::Interact { target_entity_id, verb } - Monologue duplication test (test_monologue_not_duplicated_after_consumption) fixed — was using poll_snapshot() which doesn't consume _last_snapshot in test mode