Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.5 KiB
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)
- Never
Thread.Sleep/ sleep-poll insideexecute_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. - The bridge's game-view capture PAUSES the editor (
EditorApplication.Step). CheckisPausedfirst, unpause after any bridge capture. PreferScreenCapture.CaptureScreenshot(path)insideexecute_code— async, lands at frame end; Read the PNG on the NEXT MCP call (the round-trip covers it). - 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.runInBackgroundonly helps in Play mode. - Play-enter resets static presentation configs via
[RuntimeInitializeOnLoadMethod]— a value poked viaexecute_codedoes 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
usingdirectives (parsed as statements) — fully-qualify every type. - Identify worlds by
world.Name == "ServerWorld"/"ClientWorld"(flags overlap a sharedGamebit). Type.GetMethod(name, flags)throws AmbiguousMatchException on overloaded methods (EntityManager generics, netcode statics) — iterateGetMethods()and filter by name/param-count, or call concrete generics directly in compiled code.- Rukhanka
ParameterValueis a union — readfloatValue/boolValueper 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:
- Spawn (server world): query the ghost prefab (
ComponentType+EntityQueryOptions.IncludePrefab, pick the one withPrefab),Instantiate, thenem.GetComponentData<LocalTransform>(prefab).WithPosition(playerPos + offset)— neverLocalTransform.FromPosition(resets Scale=1; Scale replicates → consistent-but-wrong). Find the player viaKinematicCharacterBody+LocalTransform. - 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. - Inject state changes server-side (
SetComponentDataon the replicated component) in one call; read the client in the NEXT call (round-trip ≥ several ticks — replication has happened). - Assert numerically, then screenshot. e.g. client
PostTransformMatrix.c0.xvs the expectedlerp(min, 1, frac)and childLocalToWorldscale (math.length(ltw.c0.xyz)) vsbakedScale × 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)
The specific nouns below (
PlayerReady, expedition launch, respawn-reset) are co-op-Hades-era and become salvage under the LANTERN pivot (DR-048_Lantern_Adoption_Full_Pivot) — they will be re-meaned to the descent lifecycle (muster → Bell → pockets) when that lands. The harness patterns (server-inject, theEditorApplication.updateguardian, capture-in-the-same-call) are direction-agnostic and stay valid.
- Launch by setting server
PlayerReady=1from 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
Healthresets to the class value on respawn — aSetComponentDataHP 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_codecall that first detects the condition (e.g.enemies>0) — a separate "then screenshot" round-trip loses the window. - Multi-sample observations (movement deltas, before/after) that can't fit one call: install an
EditorApplication.updateguardian delegate (proven 2026-07-09, enemy-backstop smoke). The closure runs every editor frame: it can out-race damage (Health.Current = Maxper frame — beats the respawn-reset trap above), park the player, take timed snapshots, and stash results inEditorPrefs(survives acrossexecute_codecalls — statics do NOT). Always: wrap the body in try/catch that UNSUBSCRIBES on error, add a hard timeout unsubscribe (~90 s), self-unsubscribe when done, and delete theEditorPrefskeys after reading them. - 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 beforeGetColor/GetFloat/GetTexture(e.g.S_General._BaseColorMultiplyis a float;GetColorreturns 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
URPMaterialPropertyBaseColoroverride 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.
Input-path validation (07-21, the auto-recast false negative)
- A guardian window must OUTLIVE the period it judges. Frames ≠ ticks: an unfocused editor can run FASTER than 60 fps, so a 500-frame watch can end BEFORE a 7 s cooldown does. The morning "sim is clean" verdict was a false negative for exactly this; the 2000-frame re-run caught a recast at every cooldown reopen. Size the window in TICKS of the judged period × 2, converted generously.
DebugInputInjectionSystemwrites the COMPONENT — it can never reproduce wire-semantics bugs. To exercise the real gather → copy-layer → command-buffer path headless, queue synthetic device events:StateEvent.From(Keyboard.current, out var ev); InputControlExtensions.WriteValueIntoEvent<float>(kb.digit1Key, 1f, ev); InputSystem.QueueEvent(ev);(+ a 0f release event). Works unfocused with runInBackground.- Wire truth:
InputEventcounts ACCUMULATE across commands (the per-frame gather reset never reaches the buffer). Test fixtures that pushInputBufferData<PlayerInput>must model that: press = count STEP at one tick, and the stepped count PERSISTS in every later entry — a "release = count 0" entry is a wire-impossible shape that hides the ever-pressed class of bug (and double-fires a delta gate).
EditMode fixtures for prediction-gated systems (07-21, AbilityFireSystemConeTests)
NetworkTime.Flagsis INTERNAL — a system gated onIsFirstTimeFullyPredictingTicksilently no-ops in any plain test world (default Flags = 0). Fixture fix: box the struct +typeof(NetworkTime).GetField("Flags", NonPublic|Instance).SetValue(boxed, NetworkTimeFlags.IsInPredictionLoop | IsFirstTimeFullyPredictingTick). Test-only; never reflect internals in shipped code.- Input-buffer presses: an
InputBufferData<PlayerInput>entry with a setInputEventkeeps answeringGetDataAtTickfor every later tick — it re-fires the moment a cooldown re-opens (the MeleeComboTests C6 lesson, buffer-shaped). Always push a press at T and a release (default command) at T+1. - Damage paths gated
WorldUnmanaged.IsServer()neednew World(name, WorldFlags.Game | WorldFlags.GameServer).
Test-run mechanics
run_tests(mode="EditMode", assembly_names=["ProjectM.Tests.EditMode"])→ pollget_test_job(wait_timeout=60). Unfocused editor:init_timeout=120000and retry once before diagnosing.- A test count LOWER than expected with zero failures = tests not discovered (raw-written
.cswithoutrefresh_unity scope=all mode=force), not a pass.