Files
Project-M/.claude/skills/dots-dev/references/validation-harness.md
T

6.3 KiB
Raw Blame History

Validation harness — driving Unity Play mode safely from MCP

Consolidates the Play-mode validation lore that used to live only in machine-local memories (screenshot-pause bug, sleep-poll ban, execute_code traps, headless-run flakiness) plus the proven server-inject harness. Load when running Phase 6 (verify ladder) or any live diagnosis. Committed + in-repo: this is the cross-machine home of these rules.

Ground rules (violating these produces MISDIAGNOSES, not just failures)

  1. Never Thread.Sleep / sleep-poll inside execute_code. It runs on the main thread — it freezes the entire sim, and state read "after waiting" is state from a FROZEN world. This manufactured the retracted WasAllReady "save deadlock" (2026-07-06). Spread observation across separate MCP calls instead — each round-trip gives the sim hundreds of ms to advance.
  2. The bridge's game-view capture PAUSES the editor (EditorApplication.Step). Check isPaused first, unpause after any bridge capture. Prefer ScreenCapture.CaptureScreenshot(path) inside execute_code — async, lands at frame end; Read the PNG on the NEXT MCP call (the round-trip covers it).
  3. An unfocused editor throttles Edit mode to near-idle (MCP looks hung; test INIT stalls — pass init_timeout=120000) and can leave a STALE Burst binary after Bursted query-set edits. Ask the operator to focus Unity for compile/test/Burst-heavy phases. Application.runInBackground only helps in Play mode.
  4. Play-enter resets static presentation configs via [RuntimeInitializeOnLoadMethod] — a value poked via execute_code does not survive the next play-enter. Conversely: if a whole presentation slice is silent, FIRST probe its static config (return ProjectM.Client.XFeelConfig.Enabled;) before theorizing structurally — the 2026-07-07 harvest-feedback bug was found by that one read after a plausible transform-hierarchy hypothesis proved wrong.

execute_code traps

  • Runs as a method body: no using directives (parsed as statements) — fully-qualify every type.
  • Identify worlds by world.Name == "ServerWorld"/"ClientWorld" (flags overlap a shared Game bit).
  • Type.GetMethod(name, flags) throws AmbiguousMatchException on overloaded methods (EntityManager generics, netcode statics) — iterate GetMethods() and filter by name/param-count, or call concrete generics directly in compiled code.
  • Rukhanka ParameterValue is a union — read floatValue/boolValue per the parameter type.
  • Return a built string (StringBuilder) — it's the only channel out.

The server-inject harness (validate replicated presentation WITHOUT gameplay driving)

Proven 2026-07-07 (NodeFeedbackSystem). Validates a replicated-state → client-presentation path end-to-end in ~4 MCP calls, no expedition run, no player positioning:

  1. Spawn (server world): query the ghost prefab (ComponentType + EntityQueryOptions.IncludePrefab, pick the one with Prefab), Instantiate, then em.GetComponentData<LocalTransform>(prefab).WithPosition(playerPos + offset)never LocalTransform.FromPosition (resets Scale=1; Scale replicates → consistent-but-wrong). Find the player via KinematicCharacterBody + LocalTransform.
  2. Defeat relevancy: a region-tagged ghost spawned at base gets culled from base players — em.SetComponentData(inst, new RegionTag { Region = 0 /* Base */ }) in the same call.
  3. Inject state changes server-side (SetComponentData on the replicated component) in one call; read the client in the NEXT call (round-trip ≥ several ticks — replication has happened).
  4. Assert numerically, then screenshot. e.g. client PostTransformMatrix.c0.x vs the expected lerp(min, 1, frac) and child LocalToWorld scale (math.length(ltw.c0.xyz)) vs bakedScale × ptm. Numbers prove the math; the screenshot proves it renders (both matter — see visual limits below).

Ghost/LEG inspection idioms: em.GetBuffer<LinkedEntityGroup>(root); render children = LEG entries with MaterialMeshInfo (the root usually has none); parenting = Parent component; a baked child's uniform authoring scale lands in LocalTransform.Scale (non-uniform → its own PostTransformMatrix). A root PostTransformMatrix DOES propagate into parented children's LocalToWorld (verified live on 6.5.0).

Headless expedition-run driving (flaky — know the failure modes)

  • Launch by setting server PlayerReady=1 from clean Staging. Post-abort re-launch is intermittent — a clean Play restart launches reliably on the first ready; re-readying after an abort may not.
  • An AFK player dies to the swarm in ~15 s and Health resets to the class value on respawn — a SetComponentData HP buff does NOT stick across respawn; the run then aborts (0 expedition players → Returning) and tears down the room.
  • Therefore: capture evidence in the SAME execute_code call that first detects the condition (e.g. enemies>0) — a separate "then screenshot" round-trip loses the window.
  • Prefer the server-inject harness above whenever the question doesn't actually require a live run.

Visual/asset verification limits

  • A dark/stylized frame masks material bugs — verify VALUES: shader.GetPropertyType(idx)-guard before GetColor/GetFloat/GetTexture (e.g. S_General._BaseColorMultiply is a float; GetColor returns black).
  • Skinned meshes lie to readers: WorldRenderBounds/bone transforms return the bind-pose AABB, not the deformed pose — deformed-pose visual bugs need the operator's eyes (or a screenshot), not entity reads.
  • Body tint/flash validation: drive the stock EG URPMaterialPropertyBaseColor override on the render (LEG) children; verify on the LOCAL player (persistent, centered) rather than a transient enemy.
  • Forcing an animation pose: disable the drive system, write params via AnimatorParametersAspect; measure ground-contact via feet-Y only on non-deformed geometry.

Test-run mechanics

  • run_tests(mode="EditMode", assembly_names=["ProjectM.Tests.EditMode"]) → poll get_test_job (wait_timeout=60). Unfocused editor: init_timeout=120000 and retry once before diagnosing.
  • A test count LOWER than expected with zero failures = tests not discovered (raw-written .cs without refresh_unity scope=all mode=force), not a pass.