diff --git a/src/simulator/tests/golden/README.md b/src/simulator/tests/golden/README.md index 020a5032..c36eafc9 100644 --- a/src/simulator/tests/golden/README.md +++ b/src/simulator/tests/golden/README.md @@ -81,6 +81,34 @@ Minimum **3 vectors per domain** (happy path, edge case, stress): 4. GDExt consumer: add a case in `src/game/engine/tests/ffi/test_golden_.gd`. 5. Run `./run test:golden` — all three must pass. If any diverge, fix the marshaling / determinism / SOT bug first. +## Cross-consumer marshaling contract (IMPORTANT — affects WASM and GDExt consumers) + +JSON numbers parse as `f64` / `number` in JavaScript and in `JSON.parse_string` in GDScript. Rust's FFI surfaces (`#[wasm_bindgen]` for WASM, `#[godot_api]` / `Variant::try_to::()` for GDExt) expect specific integer widths for fields declared as `i32` / `i64` / `u32` on the Rust side. **Passing a float where an int is expected causes silent zero-default substitution on the GDExt path** (Godot's `Variant` coercion returns `Err` and godot-rust's default handler uses zeroes instead of raising). That manifests as "every output field is zero/true" instead of the actual compute — a class of bug that bypasses any `assert_eq!` because the shape still matches. + +**Contract for all consumers of every vector:** + +| Fixture field type | Rust consumer | WASM consumer (TS) | GDExt consumer (GDScript) | +|---|---|---|---| +| `i32` / `i64` / `u32` field | deserialize direct via serde | `Number.isInteger(v)` guard + cast to `number` (fine — TS numbers are f64 but WASM marshaling accepts `number` for `i32`/`i64`) | `int(raw)` cast **required** before crossing FFI to prevent silent zero-default | +| `f32` / `f64` field | deserialize direct | direct | direct | +| `bool` field | direct | direct | direct | +| String-tagged enum | `#[serde(tag = "...")]` derive | string literal compare | string compare + `match`/`if` ladder | + +**GDScript helper pattern** (established by `gdext-consumer` in `src/game/engine/tests/ffi/golden_vector_loader.gd`): +```gdscript +static func coerce_ints(d: Dictionary, int_keys: Array[String]) -> Dictionary: + var out: Dictionary = d.duplicate() + for k in int_keys: + if out.has(k): + out[k] = int(out[k]) # int() truncates float toward zero + return out +``` +Apply this to every Dictionary crossing the FFI boundary into a GDExt method that expects integer fields. + +**TS/WASM consumer** currently does NOT need the coercion because `wasm-bindgen`'s generated bindings handle `number → i32/i64` conversion at the FFI boundary automatically. The WASM consumer just needs to ensure the input Object literal matches the declared param types in `api-wasm/src/lib.rs`. **But note**: if the fixture sets an integer field as `"hp": 10.0` (JSON with decimal point), Vitest's JSON.parse will store `10.0` as JS `number` (identical to `10`), and `wasm-bindgen` will marshal it cleanly. So as long as fixtures don't use `.5` values for integer-typed fields, the asymmetry is benign on the WASM side. Fixture authors MUST NOT put fractional values in integer-typed fields. + +**Fixture convention**: integer-typed fields should be written without a decimal point (`"hp": 10`, not `"hp": 10.0`). Rust's serde will accept both; GDScript's `JSON.parse_string` normalizes both to float; but keeping the convention reduces review friction. + ## Determinism rules - All randomness MUST take an explicit `seed` parameter; no wall-clock time, no `SystemTime::now()`.