Files
MagnetismHeroes/tools/android/test-playground-contracts.ps1

426 lines
46 KiB
PowerShell

param([Parameter(Mandatory)][string]$EvidenceDir,[string]$FinalSource=(Join-Path $PSScriptRoot 'exercise-final.ps1'),[string]$PlaygroundSource=(Join-Path $PSScriptRoot 'exercise-playground.ps1'),[string]$PreservationFixtureDir,[string]$BoardReadyPng,[string]$HomePng,[string]$ReentryFixtureDir,[string]$DepthFixtureDir,[string]$PauseFixtureDir,[string]$BlueFriendsPng)
$ErrorActionPreference='Stop'
New-Item -ItemType Directory -Path $EvidenceDir -Force|Out-Null
$checks=[Collections.Generic.List[object]]::new()
function Probe([string]$Id,[bool]$Passed,$Actual){$checks.Add(@{id=$Id;passed=$Passed;actual=$Actual})}
function Check([string]$Id,[bool]$Passed,[string]$Actual){if(!$Passed){throw "$Id : $Actual"}}
try {
$ast=[Management.Automation.Language.Parser]::ParseFile($FinalSource,[ref]$null,[ref]$null)
$loop=$ast.Find({param($node)$node-is[Management.Automation.Language.ForEachStatementAst]-and$node.Variable.VariablePath.UserPath-eq'suite'},$true)
$suites=@(& ([scriptblock]::Create($loop.Condition.Extent.Text)))
Probe 'actual-final-includes-playground' (@($suites|Where-Object {$_.name-eq'PlaygroundInteraction'-and$_.script-eq'exercise-playground.ps1'-and$_.report-eq'android-playground-evidence.json'}).Count-eq1) $suites
$gate=@(Get-Content -LiteralPath $FinalSource|Where-Object {$_-match '^\s*Check "suite-\$\(\$suite.name\)-actual-exit-status-checks"'})[0]
if(!$gate){throw 'Missing actual Final child assertion'}
$record=@{sourceCommit='same-source';apkSha256='same-apk'};$suite=@{name='PlaygroundInteraction'}
foreach($kind in @('valid','child-exit','empty','failed-status','failed-check','pending','skipped','stale-source','changed-source-end','dirty','dirty-end','changed-apk')){
$actualExit=0;$child=[pscustomobject]@{status='passed';sourceCommit='same-source';sourceCommitEnd='same-source';sourceDirty=$false;sourceDirtyEnd=$false;apkSha256='same-apk';checks=@(@{id='evaluated';passed=$true});pending=@();skipped=@()}
switch($kind){'child-exit'{$actualExit=37}'empty'{$child.checks=@()}'failed-status'{$child.status='failed'}'failed-check'{$child.checks[0].passed=$false}'pending'{$child.pending=@('native-unobserved')}'skipped'{$child.skipped=@('native-unobserved')}'stale-source'{$child.sourceCommit='old-source'}'changed-source-end'{$child.sourceCommitEnd='changed'}'dirty'{$child.sourceDirty=$true}'dirty-end'{$child.sourceDirtyEnd=$true}'changed-apk'{$child.apkSha256='old-apk'}}
$accepted=$false;try{& ([scriptblock]::Create($gate));$accepted=$true}catch{}
Probe "actual-final-gate-$kind" ($accepted-eq($kind-eq'valid')) @{accepted=$accepted;actualExit=$actualExit;statement=$gate}
}
$controlRoot=$EvidenceDir;$sandbox=Join-Path $EvidenceDir 'actual-loop-sandbox';New-Item -ItemType Directory -Path $sandbox -Force|Out-Null
# Create() has no file-owned PSScriptRoot; substitute only the sandbox path binding.
$actualLoop=$loop.Extent.Text.Replace('$PSScriptRoot','$sandbox')
foreach($dispatchExit in @(0,37)){
$EvidenceDir=Join-Path $sandbox "child-$dispatchExit";New-Item -ItemType Directory -Path $EvidenceDir -Force|Out-Null
'{}'|Set-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json')
foreach($definition in $suites){
$stub='param([string]$ApkPath,[string]$EvidenceDir,[string]$Serial)' + "`n" + '$exitCode=' + $(if($definition.name-eq'PlaygroundInteraction'){$dispatchExit}else{0}) + "`n" + '$report=''' + $definition.report + "'`n" + @'
$checks=@(1..30|ForEach-Object {@{id="stage-$_-actual-success";passed=$true}})
@{status='passed';sourceCommit='same-source';sourceCommitEnd='same-source';sourceDirty=$false;sourceDirtyEnd=$false;apkSha256='same-apk';checks=$checks;commands=@();pending=@();skipped=@();actualArguments=@{apk=$ApkPath;serial=$Serial}}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath (Join-Path $EvidenceDir $report)
exit $exitCode
'@
Set-Content -LiteralPath (Join-Path $sandbox $definition.script) -Value $stub
}
$ApkPath='sentinel-final.apk';$Serial='sentinel-device';$children=[Collections.Generic.List[object]]::new();$accepted=$false
$dispatchError=$null;try{& ([scriptblock]::Create($actualLoop));$accepted=$true}catch{$dispatchError=$_.Exception.Message}
$dispatched=@($children|Where-Object suite -eq 'PlaygroundInteraction')
Probe "actual-final-loop-dispatch-exit-$dispatchExit" ($accepted-eq($dispatchExit-eq0)-and$dispatched.Count-eq1-and$dispatched[0].exitCode-eq$dispatchExit-and$dispatched[0].report.actualArguments.apk-eq'sentinel-final.apk'-and$dispatched[0].report.actualArguments.serial-eq'sentinel-device') @{accepted=$accepted;actualChild=$dispatched;error=$dispatchError;scope='Actual Final loop with sandbox child scripts, never native game evidence'}
}
$EvidenceDir=$controlRoot
if(!(Test-Path -LiteralPath $PlaygroundSource)){Probe 'actual-playground-gesture-present' $false 'Missing suite'}else{
$gestureAst=[Management.Automation.Language.Parser]::ParseFile($PlaygroundSource,[ref]$null,[ref]$null)
$native=$gestureAst.Find({param($node)$node-is[Management.Automation.Language.FunctionDefinitionAst]-and$node.Name-eq'NativeTouch'},$true)
if(!$native){throw 'Missing actual NativeTouch function'}
. ([scriptblock]::Create($native.Extent.Text))
$commands=[Collections.Generic.List[object]]::new()
$helper=Join-Path $EvidenceDir 'touch-stub.ps1'
@'
param([int[]]$Contacts)
$Contacts|ConvertTo-Json|Set-Content -LiteralPath $env:PLAYGROUND_CONTROL_TOUCH_FILE
exit ([int]$env:PLAYGROUND_CONTROL_TOUCH_EXIT)
'@|Set-Content -LiteralPath $helper
foreach($exitCode in @(0,37)){
$env:PLAYGROUND_CONTROL_TOUCH_EXIT=[string]$exitCode;$env:PLAYGROUND_CONTROL_TOUCH_FILE=Join-Path $EvidenceDir "touch-observed-$exitCode.json";$rejected=$false;$errorText=$null
try{NativeTouch @(1200,500,71,1024,1300,500,72,1024)}catch{$rejected=$true;$errorText=$_.Exception.Message}
$observedContacts=Get-Content -LiteralPath $env:PLAYGROUND_CONTROL_TOUCH_FILE -Raw|ConvertFrom-Json
Probe "native-gesture-actual-child-exit-$exitCode" ($rejected-eq($exitCode-ne0)-and($observedContacts-join',')-eq'1200,500,71,1024,1300,500,72,1024'-and$commands[-1].exitCode-eq$exitCode) @{rejected=$rejected;contacts=$observedContacts;actualExit=$commands[-1].exitCode;error=$errorText}
}
foreach($name in @('Clone','Float32Record','Same','SameBalls','SameMembership','InBounds','FixtureState','Slot','BlueFriends','PreservesOtherSlots')){
$function=$gestureAst.Find({param($node)$node-is[Management.Automation.Language.FunctionDefinitionAst]-and$node.Name-eq$name},$true)
if($function){. ([scriptblock]::Create($function.Extent.Text))}
elseif($name-eq'Same'){
$commonAst=[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'final-common.ps1'),[ref]$null,[ref]$null)
$commonSame=$commonAst.Find({param($node)$node-is[Management.Automation.Language.FunctionDefinitionAst]-and$node.Name-eq'Same'},$true)
. ([scriptblock]::Create($commonSame.Extent.Text))
}
}
$floatSource=('{"schemaVersion":1,"completedStageIds":[1,2,3],"seenTutorialIds":["merge","split"],"puzzleStates":[{"stageId":2,"balls":[{"id":"float-a","position":{"x":0.0,"y":0.800000011920929,"z":0.0}},{"id":"float-b","position":{"x":1.0,"y":0.800000011920929,"z":0.0}}],"groups":[{"id":"float-group","ballIds":["float-a","float-b"]}],"collectedBallIds":["float-a"],"countedBallIds":["float-a"],"camera":{"target":{"x":0.0,"y":0.6000000238418579,"z":0.0},"rotation":{"x":0.258819043636322,"y":0.0,"z":0.0,"w":0.9659258127212524},"distance":8.123456001281738}}],"latestFreePlay":{"sourceStageId":6,"balls":[{"id":"free-a","position":{"x":0.0,"y":0.800000011920929,"z":0.0}}],"groups":[{"id":"free-group","ballIds":["free-a"]}],"camera":{"target":{"x":0.0,"y":0.6000000238418579,"z":0.0},"rotation":{"x":0.258819043636322,"y":0.0,"z":0.0,"w":0.9659258127212524},"distance":8.123456001281738}}}'|ConvertFrom-Json)
$floatProduction=Clone $floatSource
foreach($board in @($floatProduction.puzzleStates)+@($floatProduction.latestFreePlay)){
foreach($ball in $board.balls){$ball.position.y=.8}
$board.camera.target.y=.6;$board.camera.rotation.x=.25881904;$board.camera.rotation.w=.9659258;$board.camera.distance=8.123456
}
foreach($kind in @('same-float32','position-one-ulp','camera-one-ulp','distance-one-ulp','changed-id','changed-group','changed-source-stage','changed-count','changed-collect','changed-tutorial','changed-integer','wrong-number','array-order','field-case','string-number','signed-zero','nonfinite-number')){
$actual=Clone $floatProduction
switch($kind){
'position-one-ulp'{$actual.puzzleStates[0].balls[0].position.y=[double][MathF]::BitIncrement([single]0.8)}
'camera-one-ulp'{$actual.latestFreePlay.camera.rotation.w=[double][MathF]::BitIncrement([single]0.9659258)}
'distance-one-ulp'{$actual.latestFreePlay.camera.distance=[double][MathF]::BitIncrement([single]8.123456)}
'changed-id'{$actual.puzzleStates[0].balls[0].id='FLOAT-A'}
'changed-group'{$actual.puzzleStates[0].groups[0].id='FLOAT-GROUP'}
'changed-source-stage'{$actual.latestFreePlay.sourceStageId=7}
'changed-count'{$actual.puzzleStates[0].countedBallIds=@('float-b')}
'changed-collect'{$actual.puzzleStates[0].collectedBallIds=@('float-b')}
'changed-tutorial'{$actual.seenTutorialIds[0]='MERGE'}
'changed-integer'{$actual.schemaVersion=1.00000001}
'wrong-number'{$actual.puzzleStates[0].balls=@($actual.puzzleStates[0].balls[0])}
'array-order'{$actual.completedStageIds=@(3,2,1)}
'field-case'{$position=$actual.puzzleStates[0].balls[0].position;$actual.puzzleStates[0].balls[0].position=[pscustomobject][ordered]@{x=$position.x;Y=$position.y;z=$position.z}}
'string-number'{$actual.puzzleStates[0].balls[0].position.y='0.8'}
'signed-zero'{$actual.puzzleStates[0].balls[0].position.x=[BitConverter]::Int64BitsToDouble([long]::MinValue)}
'nonfinite-number'{$actual.puzzleStates[0].balls[0].position.y=[double]::PositiveInfinity}
}
$accepted=if($kind-eq'same-float32'){Same $floatSource $actual}else{Same $floatProduction $actual}
Probe "actual-preservation-float32-$kind" ($accepted-eq($kind-eq'same-float32')) @{accepted=$accepted;expectedPass=$kind-eq'same-float32'}
}
Probe 'actual-manual-camera-float32-exact' (Same $floatSource.latestFreePlay.camera $floatProduction.latestFreePlay.camera) 'Same float32 camera components and distance, different JSON decimal representation'
$depthCamera=$gestureAst.Find({param($node)$node-is[Management.Automation.Language.AssignmentStatementAst]-and$node.Left.Extent.Text-ceq'$depth.camera'},$true)
if(!$depthCamera){throw 'Missing actual depth-fixture camera assignment'}
$depth=Clone $floatSource.latestFreePlay;$depthBefore=Clone $depth
& ([scriptblock]::Create($depthCamera.Extent.Text))
$canonicalCamera='{"target":{"x":0,"y":2.35,"z":0},"rotation":{"x":0,"y":0,"z":0,"w":1},"distance":14}'|ConvertFrom-Json
Probe 'actual-depth-fixture-camera-canonical-dto' (Same $depth.camera $canonicalCamera) @{assignment=$depthCamera.Extent.Text;camera=$depth.camera;scope='Execute actual fixture assignment, then strict DTO comparison'}
Probe 'actual-depth-fixture-camera-property-order' (($depth.camera.PSObject.Properties.Name-join',')-ceq'target,rotation,distance'-and($depth.camera.target.PSObject.Properties.Name-join',')-ceq'x,y,z'-and($depth.camera.rotation.PSObject.Properties.Name-join',')-ceq'x,y,z,w') 'Fixture uses the exact serialized Unity camera property sequence'
$depthUnchanged=Clone $depth;$depthUnchanged.camera=$depthBefore.camera
Probe 'actual-depth-fixture-assignment-only-camera' (Same $depthBefore $depthUnchanged) 'Executing the camera assignment leaves balls, groups, IDs and source unchanged'
foreach($kind in @('target-one-ulp','rotation-one-ulp','distance-one-ulp','changed-rotation','changed-distance')){
$changed=Clone $canonicalCamera
switch($kind){
'target-one-ulp'{$changed.target.y=[double][MathF]::BitIncrement([single]$changed.target.y)}
'rotation-one-ulp'{$changed.rotation.w=[double][MathF]::BitIncrement([single]$changed.rotation.w)}
'distance-one-ulp'{$changed.distance=[double][MathF]::BitIncrement([single]$changed.distance)}
'changed-rotation'{$changed.rotation.y=.1}
'changed-distance'{$changed.distance=15}
}
Probe "actual-depth-fixture-camera-rejects-$kind" (!(Same $depth.camera $changed)) 'Strict Same must reject camera value mutations against the actual fixture'
}
if($DepthFixtureDir){
$enteredPath=Join-Path $DepthFixtureDir 'stationary-150-entered.json';$droppedPath=Join-Path $DepthFixtureDir 'stationary-150-dropped.json'
$entered=Get-Content -LiteralPath $enteredPath -Raw|ConvertFrom-Json;$dropped=Get-Content -LiteralPath $droppedPath -Raw|ConvertFrom-Json
Probe 'actual-native-depth-fixture-camera-exact' (Same $depth.camera $dropped.latestFreePlay.camera) @{assignment=$depthCamera.Extent.Text;dropped=$droppedPath;droppedSha256=(Get-FileHash -LiteralPath $droppedPath).Hash;scope='Actual assignment versus NativePreflight7 raw save; replay only, no live native run'}
Probe 'actual-native-depth-entry-drop-camera-exact' (Same $entered.latestFreePlay.camera $dropped.latestFreePlay.camera) @{entered=$enteredPath;enteredSha256=(Get-FileHash -LiteralPath $enteredPath).Hash;dropped=$droppedPath;droppedSha256=(Get-FileHash -LiteralPath $droppedPath).Hash}
}
$depthSetup=@($gestureAst.FindAll({param($node)$node-is[Management.Automation.Language.AssignmentStatementAst]-and$node.Left.Extent.Text-cin@('$depth','$depth.sourceStageId','$depth.camera','$depth.balls','$depth.groups')},$true)|Sort-Object {$_.Extent.StartOffset})
if($depthSetup.Count-ne5){throw 'Missing exact five depth-fixture setup assignments'}
$setupText=($depthSetup.Extent.Text-join"`n");$fixtures=@{restores=@(@{freeSource=Clone $floatSource.latestFreePlay})}
. ([scriptblock]::Create($setupText))
Probe 'actual-depth-producer-ordinal-ball-group-order' (($depth.balls.id-join',')-ceq'native-depth-back,native-depth-front'-and($depth.groups.id-join',')-ceq'native-depth-back-group,native-depth-front-group') @{statements=$setupText;balls=@($depth.balls.id);groups=@($depth.groups.id);scope='Actual full fixture setup AST, no mirrored producer'}
$front=@($depth.balls|Where-Object {$_.id-ceq'native-depth-front'})[0];$back=@($depth.balls|Where-Object {$_.id-ceq'native-depth-back'})[0]
Probe 'actual-depth-producer-original-id-positions' ($depth.sourceStageId-eq1-and$front.position.x-eq-1-and$front.position.y-eq3-and$front.position.z-eq0-and$back.position.x-eq1-and$back.position.y-eq3-and$back.position.z-eq4) 'Original world positions remain attached to their exact IDs'
$positionAssignments=@($gestureAst.FindAll({param($node)$node-is[Management.Automation.Language.AssignmentStatementAst]-and$node.Left.Extent.Text.Contains('$depth.balls')-and$node.Left.Extent.Text.EndsWith('.position')},$true)|Sort-Object {$_.Extent.StartOffset})
if($positionAssignments.Count-ne2){throw 'Missing exact two deep-contact position assignments'}
$positionText=($positionAssignments.Extent.Text-join"`n");$producer=Clone $depth
foreach($reverse in @($false,$true)){
$depth=Clone $producer;if($reverse){$depth.balls=@($depth.balls[1],$depth.balls[0])}
. ([scriptblock]::Create($positionText))
$front=@($depth.balls|Where-Object {$_.id-ceq'native-depth-front'})[0];$back=@($depth.balls|Where-Object {$_.id-ceq'native-depth-back'})[0]
Probe "actual-deep-contact-position-assignments-reverse-$reverse" ($front.position.x-eq-1.5-and$front.position.y-eq2-and$front.position.z-eq-1-and$back.position.x-eq.5-and$back.position.y-eq2-and$back.position.z-eq2.2) @{statements=$positionText;scope='Actual position assignment AST must address IDs independently of array order'}
}
if($PauseFixtureDir){
$enteredPath=Join-Path $PauseFixtureDir 'pause-cancel-entered.json';$droppedPath=Join-Path $PauseFixtureDir 'pause-cancel-dropped.json'
$entered=Get-Content -LiteralPath $enteredPath -Raw|ConvertFrom-Json;$dropped=Get-Content -LiteralPath $droppedPath -Raw|ConvertFrom-Json
$fixtures=@{restores=@(@{freeSource=Clone $entered.latestFreePlay})};. ([scriptblock]::Create($setupText))
Probe 'actual-native-pause-canonical-producer-exact' (Same $depth $dropped.latestFreePlay) @{enteredSha256=(Get-FileHash -LiteralPath $enteredPath).Hash;droppedSha256=(Get-FileHash -LiteralPath $droppedPath).Hash;scope='Actual full producer AST against raw NativeLifecycle1 pause save; replay only'}
Probe 'actual-native-original-pause-array-order-still-rejected' (!(Same $entered $dropped)) 'Strict Same retains the original native failure; no array normalization'
$pauseCheck=$gestureAst.Find({param($node)$node-is[Management.Automation.Language.CommandAst]-and$node.GetCommandName()-eq'Check'-and$node.Extent.Text-like"*'pause-cancel-restores-board-manual-camera'*"},$true)
if(!$pauseCheck){throw 'Missing actual pause cancellation Check'}
$before=Clone $entered;$before.latestFreePlay=Clone $depth
foreach($kind in @('valid','position-one-ulp','group-id','ball-id','membership','balls-order','groups-order','camera-one-ulp')){
$after=Clone $dropped
switch($kind){
'position-one-ulp'{$after.latestFreePlay.balls[0].position.x=[double][MathF]::BitIncrement([single]$after.latestFreePlay.balls[0].position.x)}
'group-id'{$after.latestFreePlay.groups[0].id='changed-group'}
'ball-id'{$after.latestFreePlay.balls[0].id='NATIVE-DEPTH-BACK'}
'membership'{$after.latestFreePlay.groups[0].ballIds=@('native-depth-front')}
'balls-order'{$after.latestFreePlay.balls=@($after.latestFreePlay.balls[1],$after.latestFreePlay.balls[0])}
'groups-order'{$after.latestFreePlay.groups=@($after.latestFreePlay.groups[1],$after.latestFreePlay.groups[0])}
'camera-one-ulp'{$after.latestFreePlay.camera.distance=[double][MathF]::BitIncrement([single]$after.latestFreePlay.camera.distance)}
}
$accepted=$false;try{& ([scriptblock]::Create($pauseCheck.Extent.Text));$accepted=$true}catch{}
Probe "actual-pause-cancellation-check-$kind" ($accepted-eq($kind-eq'valid')) @{accepted=$accepted;statement=$pauseCheck.Extent.Text;scope='Actual production cancellation Check with actual raw save and producer board; no native rerun'}
}
}
$approach=$gestureAst.Find({param($node)$node-is[Management.Automation.Language.FunctionDefinitionAst]-and$node.Name-eq'ApproachDepth'},$true)
$pointerAssignment=$approach.Body.Find({param($node)$node-is[Management.Automation.Language.AssignmentStatementAst]-and$node.Left.Extent.Text-ceq'$pointer'},$true)
if(!$pointerAssignment){throw 'Missing actual deep-contact pointer assignment'}
$point=@([int]1096,[int]609);$pointer=$null;$pointerError=$null
try{. ([scriptblock]::Create($pointerAssignment.Extent.Text))}catch{$pointerError=$_.Exception.Message}
Probe 'actual-deep-contact-pointer-exact-native-point' ($null-eq$pointerError-and$pointer-is[array]-and$pointer.Count-eq2-and$pointer[0]-is[int]-and$pointer[1]-is[int]-and$pointer[0]-eq1116-and$pointer[1]-eq609) @{statement=$pointerAssignment.Extent.Text;actualPoint=$point;pointer=$pointer;error=$pointerError;scope='Actual ApproachDepth assignment with observed native first-hit point; no native input'}
$commonAst=[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'final-common.ps1'),[ref]$null,[ref]$null)
$worldSafe=$commonAst.Find({param($node)$node-is[Management.Automation.Language.FunctionDefinitionAst]-and$node.Name-eq'WorldSafe'},$true)
function RunApproachControl($Case,[string]$Mutation='valid'){
& {
param($Definition,$SafeDefinition,$Source,$Case,$Mutation)
. ([scriptblock]::Create($Definition));. ([scriptblock]::Create($SafeDefinition))
$commands=[Collections.Generic.List[object]]::new();$contactLog=[Collections.Generic.List[object]]::new();$vision=@{calls=0}
$fixtures=@{stages=@(@{stage=1;min=@{x=-8;y=0;z=-6};max=@{x=8;y=6;z=6}})}
function FixtureState($Board,[string]$Type,[int]$Stage){$state=Clone $floatProduction;$state.latestFreePlay=Clone $Board;return $state}
function LoadFixture([string]$Name,$State,[string]$Type,[int]$Stage){return Clone $State}
function BallPoint($Board,[string]$Id){return @(1096,609)}
function NativeTouch([int[]]$Contacts){$contactLog.Add([object]$Contacts)}
function Start-Sleep([int]$Milliseconds){}
function Step([string]$Name,[string[]]$Arguments=@(),[int]$Delay=500){}
function BlueFriends([string]$Path){$vision.calls++;if($vision.calls-eq1){return @(@{x=$Case.sourceX;y=$Case.sourceY},@{x=$Case.targetX;y=$Case.targetY})};return @()}
$controlAfter=FixtureState $Source 'free' 1
$controlAfter.latestFreePlay.balls[0].position=[pscustomobject]@{x=0;y=3;z=0};$controlAfter.latestFreePlay.balls[1].position=[pscustomobject]@{x=1;y=3;z=0}
$controlAfter.latestFreePlay.groups=@([pscustomobject]@{id='steering-control-merged';ballIds=@($Source.balls.id)})
switch($Mutation){
'wrong-id'{$controlAfter.latestFreePlay.balls[0].id='wrong-id'}
'wrong-membership'{$controlAfter.latestFreePlay.groups[0].ballIds=@($Source.balls[0].id)}
'camera-one-ulp'{$controlAfter.latestFreePlay.camera.distance=[double][MathF]::BitIncrement([single]$controlAfter.latestFreePlay.camera.distance)}
'outside-bounds'{$controlAfter.latestFreePlay.balls[0].position.x=100;$controlAfter.latestFreePlay.balls[1].position.x=101}
'wrong-contact'{$controlAfter.latestFreePlay.balls[1].position.x=2}
}
function Save([string]$Name){return Clone $controlAfter}
$accepted=$false;$message=$null;try{ApproachDepth 'steering-control' $Source;$accepted=$true}catch{$message=$_.Exception.Message}
return @{accepted=$accepted;contacts=$contactLog.ToArray();observations=$vision.calls;error=$message}
} $approach.Extent.Text $worldSafe.Extent.Text $producer $Case $Mutation
}
$steeringCases=@(
@{name='actual-native-far';sourceX=1143.320388349515;sourceY=609.5533980582524;targetX=1384.984126984127;targetY=609.6190476190476;expected=@(1128,609)},
@{name='mid-right';sourceX=1116;sourceY=609;targetX=1140;targetY=609;expected=@(1128,609)},
@{name='far-left';sourceX=1116;sourceY=609;targetX=996;targetY=609;expected=@(1104,609)},
@{name='far-up';sourceX=1116;sourceY=609;targetX=1116;targetY=489;expected=@(1116,597)},
@{name='mid-down';sourceX=1116;sourceY=609;targetX=1116;targetY=633;expected=@(1116,621)},
@{name='near-left';sourceX=1116;sourceY=609;targetX=1111;targetY=609;expected=@(1111,609)},
@{name='near-diagonal';sourceX=1116;sourceY=609;targetX=1119;targetY=613;expected=@(1119,613)}
)
foreach($case in $steeringCases){
$run=RunApproachControl $case;$moves=@($run.contacts|Where-Object {$_[3]-eq1024});$next=if($moves.Count-eq3){$moves[2]}else{$null}
$correct=$run.accepted-and$run.observations-eq2-and$moves.Count-eq3-and$next[0]-eq$case.expected[0]-and$next[1]-eq$case.expected[1]-and([Math]::Pow($next[0]-1116,2)+[Math]::Pow($next[1]-609,2)-le144)
Probe "actual-approach-depth-steering-$($case.name)" $correct @{case=$case;run=$run;scope='Actual full ApproachDepth AST with PNG/touch/save transport doubles; expected exact direction and maximum12px step, no native verdict'}
}
foreach($mutation in @('wrong-id','wrong-membership','camera-one-ulp','outside-bounds','wrong-contact')){
$run=RunApproachControl $steeringCases[0] $mutation
Probe "actual-approach-depth-contact-rejects-$mutation" (!$run.accepted-and$run.error-like'*actual-id-contact-merge*') @{run=$run;scope='Actual full ApproachDepth real3D contact Check rejects invalid saved result; transport replay only'}
}
$reentry=$gestureAst.Find({param($node)$node-is[Management.Automation.Language.FunctionDefinitionAst]-and$node.Name-eq'SameProgress'},$true)
if($reentry){. ([scriptblock]::Create($reentry.Extent.Text))}
Probe 'actual-reentry-comparator-present' ($null-ne$reentry) 'Only outer puzzleStates order may be canonicalized; Same remains strict'
$progress=Clone $floatProduction
$progress.completedStageIds=@(1..30);$progress.latestFreePlay.sourceStageId=6
$progress.puzzleStates[0].collectedBallIds=@('float-a','float-b');$progress.puzzleStates[0].countedBallIds=@('float-a','float-b')
$third=Clone $progress.puzzleStates[0];$third.stageId=3
$ball=Clone $third.balls[0];$ball.id='float-c';$third.balls=@($third.balls)+@($ball)
$third.groups=@($third.balls|ForEach-Object {[pscustomobject]@{id=('group-'+$_.id);ballIds=@($_.id)}})
$third.collectedBallIds=@();$third.countedBallIds=@()
$sixth=Clone $progress.puzzleStates[0];$sixth.stageId=6;$sixth.collectedBallIds=@();$sixth.countedBallIds=@()
$progress.puzzleStates=@($progress.puzzleStates[0],$third,$sixth)
$progressBefore=$progress|ConvertTo-Json -Depth 20 -Compress
foreach($order in @(@(0,1,2),@(0,2,1),@(1,0,2),@(1,2,0),@(2,0,1),@(2,1,0))){
$actual=Clone $progress;$actual.puzzleStates=@($order|ForEach-Object {$actual.puzzleStates[$_]})
$accepted=if($reentry){SameProgress $progress $actual}else{Same $progress $actual}
Probe ('actual-reentry-slot-permutation-'+($order-join'')) $accepted @{stageIds=@($actual.puzzleStates.stageId);scope='Actual comparator, synthetic valid stage-keyed slots'}
Probe ('actual-same-retains-slot-order-'+($order-join'')) ((Same $progress $actual)-eq(($order-join'')-eq'012')) 'Generic Same still rejects reordered arrays'
}
$empty=Clone $progress;$empty.puzzleStates=@()
Probe 'actual-reentry-empty-slots-valid' $(if($reentry){SameProgress $empty $empty}else{Same $empty $empty}) 'Empty puzzle slot array is valid'
foreach($kind in @('position-one-ulp','other-camera-one-ulp','free-camera-one-ulp','id-case','added-ball','lost-ball','membership','collect','count','source-stage','tutorial','completion','schema','balls-order','groups-order','members-order','collect-order','count-order','tutorial-order','completion-order','slot-added','slot-lost')){
$actual=Clone $progress
switch($kind){
'position-one-ulp'{$actual.puzzleStates[0].balls[0].position.y=[double][MathF]::BitIncrement([single]$actual.puzzleStates[0].balls[0].position.y)}
'other-camera-one-ulp'{$actual.puzzleStates[2].camera.distance=[double][MathF]::BitIncrement([single]$actual.puzzleStates[2].camera.distance)}
'free-camera-one-ulp'{$actual.latestFreePlay.camera.rotation.w=[double][MathF]::BitIncrement([single]$actual.latestFreePlay.camera.rotation.w)}
'id-case'{$actual.puzzleStates[0].balls[0].id='FLOAT-A'}
'added-ball'{$actual.puzzleStates[0].balls=@($actual.puzzleStates[0].balls)+@(Clone $ball)}
'lost-ball'{$actual.puzzleStates[0].balls=@($actual.puzzleStates[0].balls[0])}
'membership'{$actual.puzzleStates[0].groups[0].ballIds=@('float-a')}
'collect'{$actual.puzzleStates[0].collectedBallIds=@('float-a')}
'count'{$actual.puzzleStates[0].countedBallIds=@('float-a')}
'source-stage'{$actual.latestFreePlay.sourceStageId=7}
'tutorial'{$actual.seenTutorialIds[0]='MERGE'}
'completion'{$actual.completedStageIds=@(1..29)}
'schema'{$actual.schemaVersion=2}
'balls-order'{$actual.puzzleStates[0].balls=@($actual.puzzleStates[0].balls[1],$actual.puzzleStates[0].balls[0])}
'groups-order'{$actual.puzzleStates[1].groups=@($actual.puzzleStates[1].groups[2],$actual.puzzleStates[1].groups[1],$actual.puzzleStates[1].groups[0])}
'members-order'{$actual.puzzleStates[0].groups[0].ballIds=@('float-b','float-a')}
'collect-order'{$actual.puzzleStates[0].collectedBallIds=@('float-b','float-a')}
'count-order'{$actual.puzzleStates[0].countedBallIds=@('float-b','float-a')}
'tutorial-order'{$actual.seenTutorialIds=@('split','merge')}
'completion-order'{$actual.completedStageIds=@(30..1)}
'slot-added'{$slot=Clone $actual.puzzleStates[0];$slot.stageId=4;$actual.puzzleStates=@($actual.puzzleStates)+@($slot)}
'slot-lost'{$actual.puzzleStates=@($actual.puzzleStates[0],$actual.puzzleStates[1])}
}
$accepted=if($reentry){SameProgress $progress $actual}else{Same $progress $actual}
Probe "actual-reentry-rejects-$kind" (!$accepted) @{accepted=$accepted}
}
foreach($kind in @('duplicate','missing-list','null-list','scalar-list','object-list','null-slot','scalar-slot','missing-stage','case-stage','null-stage','zero-stage','high-stage','string-stage','decimal-stage','fraction-stage','boolean-stage','too-many-slots')){
$invalid=Clone $progress
switch($kind){
'duplicate'{$invalid.puzzleStates[1].stageId=2}
'missing-list'{$invalid.PSObject.Properties.Remove('puzzleStates')}
'null-list'{$invalid.puzzleStates=$null}
'scalar-list'{$invalid.puzzleStates=2}
'object-list'{$invalid.puzzleStates=$invalid.puzzleStates[0]}
'null-slot'{$invalid.puzzleStates[0]=$null}
'scalar-slot'{$invalid.puzzleStates[0]=2}
'missing-stage'{$invalid.puzzleStates[0].PSObject.Properties.Remove('stageId')}
'case-stage'{$slot=$invalid.puzzleStates[0];$slot.PSObject.Properties.Remove('stageId');$slot|Add-Member -NotePropertyName StageId -NotePropertyValue 2}
'null-stage'{$invalid.puzzleStates[0].stageId=$null}
'zero-stage'{$invalid.puzzleStates[0].stageId=0}
'high-stage'{$invalid.puzzleStates[0].stageId=31}
'string-stage'{$invalid.puzzleStates[0].stageId='2'}
'decimal-stage'{$invalid.puzzleStates[0].stageId=[double]2.0}
'fraction-stage'{$invalid.puzzleStates[0].stageId=2.5}
'boolean-stage'{$invalid.puzzleStates[0].stageId=$true}
'too-many-slots'{$invalid.puzzleStates=@(1..31|ForEach-Object {$slot=Clone $progress.puzzleStates[0];$slot.stageId=$_;$slot})}
}
$accepted=if($reentry){SameProgress $invalid $invalid}else{Same $invalid $invalid}
Probe "actual-reentry-invalid-slots-$kind" (!$accepted) @{accepted=$accepted;scope='Even identical invalid slot lists must reject'}
}
Probe 'actual-reentry-comparison-does-not-mutate-record' (($progress|ConvertTo-Json -Depth 20 -Compress)-ceq$progressBefore) 'Canonicalization touches detached copies only'
if($ReentryFixtureDir){
$enteredPath=Join-Path $ReentryFixtureDir 'counting-position-only-entered.json';$reenteredPath=Join-Path $ReentryFixtureDir 'counting-position-only-reentered.json'
$entered=Get-Content -LiteralPath $enteredPath -Raw|ConvertFrom-Json;$reentered=Get-Content -LiteralPath $reenteredPath -Raw|ConvertFrom-Json
$accepted=if($reentry){SameProgress $entered $reentered}else{Same $entered $reentered}
Probe 'actual-native-reentry-raw-records' $accepted @{entered=$enteredPath;enteredSha256=(Get-FileHash -LiteralPath $enteredPath).Hash;reentered=$reenteredPath;reenteredSha256=(Get-FileHash -LiteralPath $reenteredPath).Hash;beforeOrder=@($entered.puzzleStates.stageId);afterOrder=@($reentered.puzzleStates.stageId);scope='Actual native JSON replay only; no live native rerun'}
Probe 'actual-native-reentry-generic-same-remains-strict' (!(Same $entered $reentered)) 'Original Same rejects sole outer puzzle slot order change'
$reentryCheck=$gestureAst.Find({param($node)$node-is[Management.Automation.Language.CommandAst]-and$node.GetCommandName()-eq'Check'-and$node.Extent.Text-like'*reentry-exact*'},$true)
if(!$reentryCheck){throw 'Missing actual native reentry assertion'}
$fixture=@{name='raw-replay'};$after=$entered;$controlReentryState=$reentered
function Save([string]$Name){return Clone $controlReentryState}
$accepted=$false;try{& ([scriptblock]::Create($reentryCheck.Extent.Text));$accepted=$true}catch{}
Probe 'actual-native-reentry-assertion-replay' $accepted @{statement=$reentryCheck.Extent.Text;scope='Execute exact suite Check with raw native saves, transport replay only'}
}
if($PreservationFixtureDir){
$injectedPath=Join-Path $PreservationFixtureDir 'safe-free-injected.json';$enteredPath=Join-Path $PreservationFixtureDir 'safe-free-entered.json'
$injected=Get-Content -LiteralPath $injectedPath -Raw|ConvertFrom-Json;$entered=Get-Content -LiteralPath $enteredPath -Raw|ConvertFrom-Json
$accepted=$false;try{PreservesOtherSlots 'actual-native-failure' $injected $entered 'free' 1;$accepted=$true}catch{}
Probe 'actual-native-safe-free-other-puzzles-float32' $accepted @{injected=$injectedPath;injectedSha256=(Get-FileHash -LiteralPath $injectedPath).Hash;entered=$enteredPath;enteredSha256=(Get-FileHash -LiteralPath $enteredPath).Hash;scope='Replay actual failed preservation assertion only; native game still unevaluated'}
Probe 'actual-native-safe-free-whole-record-float32' (Same $injected $entered) 'Full actual saved record: canonical float32 values with exact keys, arrays and non-float values'
$changed=Clone $entered;$changed.puzzleStates[0].balls[0].position.y=[double][MathF]::BitIncrement([single]$changed.puzzleStates[0].balls[0].position.y)
$rejected=$false;try{PreservesOtherSlots 'actual-one-ulp-change' $injected $changed 'free' 1}catch{$rejected=$true}
Probe 'actual-native-other-puzzle-one-ulp-rejected' $rejected 'Same actual native record with one float32 ULP changed must fail preservation'
}
$fixtures=@{stages=@(@{stage=1;min=@{x=-8;y=0;z=-6};max=@{x=8;y=6;z=6}},@{stage=3;initial=@{stageId=3}},@{stage=6;initial=@{stageId=6}})}
$original=@{schemaVersion=1;completedStageIds=@(1..30);seenTutorialIds=@('merge','split');puzzleStates=@();latestFreePlay=@{sourceStageId=30}}
$first=@{balls=@(@{id='whole-id-a';position=@{x=0;y=1;z=0}},@{id='whole-id-b';position=@{x=1;y=1;z=0}});groups=@(@{id='whole-group';ballIds=@('whole-id-a','whole-id-b')})}
foreach($kind in @('valid','changed-id','duplicate-id','changed-position','changed-membership','outside-boundary')){
$second=Clone $first
switch($kind){'changed-id'{$second.balls[0].id='w'}'duplicate-id'{$second.balls[0].id='whole-id-b'}'changed-position'{$second.balls[0].position.x=.1}'changed-membership'{$second.groups[0].ballIds=@('whole-id-a')}'outside-boundary'{$second.balls[0].position.x=7.6}}
$accepted=(SameBalls $first $second)-and(SameMembership $first $second)-and(InBounds $second 1)
Probe "actual-native-board-oracle-$kind" ($accepted-eq($kind-eq'valid')) @{accepted=$accepted}
}
$injected=FixtureState $first 'free' 1
Probe 'fixture-retains-original-completion-tutorial-schema' ((Same $injected.completedStageIds $original.completedStageIds)-and(Same $injected.seenTutorialIds $original.seenTutorialIds)-and$injected.schemaVersion-eq1-and($injected.puzzleStates.stageId-join',')-eq'3,6') $injected
Add-Type -AssemblyName System.Drawing
foreach($kind in @('two-blue-friends','sky-only','green-foliage')){
$bitmap=[Drawing.Bitmap]::new(2400,1080);$graphics=[Drawing.Graphics]::FromImage($bitmap)
try{
$graphics.Clear([Drawing.Color]::FromArgb(147,211,218))
$color=if($kind-eq'two-blue-friends'){[Drawing.Color]::FromArgb(30,130,190)}else{[Drawing.Color]::FromArgb(100,150,110)}
if($kind-ne'sky-only'){$brush=[Drawing.SolidBrush]::new($color);try{$graphics.FillEllipse($brush,1000,470,60,60);$graphics.FillEllipse($brush,1450,470,60,60)}finally{$brush.Dispose()}}
$path=Join-Path $EvidenceDir "$kind.png";$bitmap.Save($path,[Drawing.Imaging.ImageFormat]::Png)
}finally{$graphics.Dispose();$bitmap.Dispose()}
$friends=@(BlueFriends $path);$expectedCount=if($kind-eq'two-blue-friends'){2}else{0}
Probe "actual-live-steering-vision-$kind" ($friends.Count-eq$expectedCount) @{friends=$friends;expectedCount=$expectedCount;scope='Synthetic semantic control, not native screenshot verdict'}
}
foreach($kind in @('top-edge','bottom-edge','left-edge','right-edge','partial-and-one-friend','hill-edge-and-two-friends','one-interior-friend','near-edge-interior-friend')){
$bitmap=[Drawing.Bitmap]::new(2400,1080);$graphics=[Drawing.Graphics]::FromImage($bitmap);$brush=[Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(30,130,190))
try{
$graphics.Clear([Drawing.Color]::FromArgb(147,211,218))
switch($kind){
'top-edge'{$graphics.FillRectangle($brush,900,260,200,100)}
'bottom-edge'{$graphics.FillRectangle($brush,900,700,200,100)}
'left-edge'{$graphics.FillRectangle($brush,500,400,100,100)}
'right-edge'{$graphics.FillRectangle($brush,1800,400,100,100)}
'partial-and-one-friend'{$graphics.FillEllipse($brush,1000,250,60,60);$graphics.FillEllipse($brush,1450,470,60,60)}
'hill-edge-and-two-friends'{$graphics.FillRectangle($brush,900,200,300,140);$graphics.FillEllipse($brush,1000,470,60,60);$graphics.FillEllipse($brush,1450,470,60,60)}
'one-interior-friend'{$graphics.FillEllipse($brush,1450,470,60,60)}
'near-edge-interior-friend'{$graphics.FillRectangle($brush,534,400,60,60)}
}
$path=Join-Path $EvidenceDir "vision-border-$kind.png";$bitmap.Save($path,[Drawing.Imaging.ImageFormat]::Png)
}finally{$brush.Dispose();$graphics.Dispose();$bitmap.Dispose()}
$friends=@(BlueFriends $path);$expectedCount=if($kind-eq'hill-edge-and-two-friends'){2}elseif($kind-in@('partial-and-one-friend','one-interior-friend','near-edge-interior-friend')){1}else{0}
Probe "actual-live-steering-border-$kind" ($friends.Count-eq$expectedCount-and@($friends|Where-Object {$_.y-lt400-or$_.y-gt600}).Count-eq0) @{friends=$friends;expectedCount=$expectedCount;scope='Actual BlueFriends AST on synthetic crop-edge and no-pair controls; no native verdict'}
}
if($BlueFriendsPng){
$friends=@(BlueFriends $BlueFriendsPng|Sort-Object x)
$correct=$friends.Count-eq2-and$friends[0].sampledBluePixels-eq103-and$friends[1].sampledBluePixels-eq126-and[Math]::Abs($friends[0].x-1143.320388349515)-lt.001-and[Math]::Abs($friends[0].y-609.5533980582524)-lt.001-and[Math]::Abs($friends[1].x-1384.984126984127)-lt.001-and[Math]::Abs($friends[1].y-609.6190476190476)-lt.001
Probe 'actual-native-blue-friend-regions-exclude-border-hill' $correct @{png=$BlueFriendsPng;sha256=(Get-FileHash -LiteralPath $BlueFriendsPng).Hash;friends=$friends;scope='Actual BlueFriends AST on NativeDepth3 live0/14 PNG: exact103/126 blue samples and known friend centroids; no live gesture/contact verdict'}
}
$boardReader=$gestureAst.Find({param($node)$node-is[Management.Automation.Language.FunctionDefinitionAst]-and$node.Name-eq'BoardScreen'},$true)
$boardWait=$gestureAst.Find({param($node)$node-is[Management.Automation.Language.FunctionDefinitionAst]-and$node.Name-eq'WaitBoard'},$true)
Probe 'actual-board-readiness-functions-present' ($null-ne$boardReader-and$null-ne$boardWait) 'Actual suite screen predicate and bounded wait'
if($boardReader-and$boardWait){
. ([scriptblock]::Create($boardReader.Extent.Text));. ([scriptblock]::Create($boardWait.Extent.Text))
foreach($kind in @('board','home','black','black-with-ui','partial-ui','wrong-size')){
$width=if($kind-eq'wrong-size'){1200}else{2400};$height=if($kind-eq'wrong-size'){540}else{1080}
$bitmap=[Drawing.Bitmap]::new($width,$height);$graphics=[Drawing.Graphics]::FromImage($bitmap)
try{
$background=if($kind-in@('black','black-with-ui')){[Drawing.Color]::Black}else{[Drawing.Color]::FromArgb(147,211,223)}
$graphics.Clear($background)
if($kind-in@('board','black-with-ui','partial-ui','wrong-size')){
$brush=[Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(245,196,72))
try{$graphics.FillRectangle($brush,58,59,$(if($kind-eq'partial-ui'){100}else{365}),99)}finally{$brush.Dispose()}
}
$path=Join-Path $EvidenceDir "readiness-$kind.png";$bitmap.Save($path,[Drawing.Imaging.ImageFormat]::Png)
}finally{$graphics.Dispose();$bitmap.Dispose()}
$screen=BoardScreen $path
Probe "actual-board-screen-$kind" ($screen.ready-eq($kind-eq'board')) $screen
}
if($BoardReadyPng-and$HomePng){
foreach($control in @(@{name='actual-free';path=$BoardReadyPng;expected=$true},@{name='actual-home';path=$HomePng;expected=$false})){
$screen=BoardScreen $control.path
Probe "actual-board-screen-$($control.name)" ($screen.ready-eq$control.expected) @{path=$control.path;sha256=(Get-FileHash -LiteralPath $control.path).Hash;screen=$screen;scope='Actual captured PNG replay; no live native readiness claim'}
}
}
}
# Execute the actual LoadFixture path with capture/save transport doubles only; no ADB.
$load=$gestureAst.Find({param($node)$node-is[Management.Automation.Language.FunctionDefinitionAst]-and$node.Name-eq'LoadFixture'},$true)
. ([scriptblock]::Create($load.Extent.Text))
function Adb([string[]]$Arguments){}
function StartHome([string]$Name){}
function SelectStage([string]$Name,[int]$Stage){Step "$Name-stage" @() 0}
function Step([string]$Name,[string[]]$Arguments=@(),[int]$Delay=500){
$controlSteps.Add(@{name=$Name;delay=$Delay})
if($Name-like'*-board-ready-*'){
$capture=if($controlHomeOnly-or$controlAttempt-eq0){$controlHome}else{$controlBoard}
Copy-Item -LiteralPath $capture -Destination (Join-Path $EvidenceDir "$Name.png")
$script:controlAttempt++
}
}
function Save([string]$Name){Copy-Item -LiteralPath $originalPath -Destination (Join-Path $EvidenceDir "$Name.json");return Clone $original}
$originalPath=Join-Path $EvidenceDir 'readiness-original.json'
if($PreservationFixtureDir){Copy-Item -LiteralPath (Join-Path $PreservationFixtureDir 'safe-free-injected.json') -Destination $originalPath;$original=Get-Content -LiteralPath $originalPath -Raw|ConvertFrom-Json}else{$original|ConvertTo-Json -Depth 20|Set-Content -LiteralPath $originalPath}
$originalHash=(Get-FileHash -LiteralPath $originalPath).Hash
$controlBoard=if($BoardReadyPng){$BoardReadyPng}else{Join-Path $EvidenceDir 'readiness-board.png'}
$controlHome=if($HomePng){$HomePng}else{Join-Path $EvidenceDir 'readiness-home.png'}
foreach($type in @('free','puzzle')){foreach($controlHomeOnly in @($false,$true)){
$script:controlAttempt=0;$controlSteps=[Collections.Generic.List[object]]::new();$accepted=$false;$message=$null
$label="readiness-$type-$(if($controlHomeOnly){'timeout'}else{'home-to-board'})"
try{$loaded=LoadFixture $label $original $type 1;$accepted=$true}catch{$message=$_.Exception.Message}
$polls=@($controlSteps|Where-Object name -like '*-board-ready-*')
$reportPath=Join-Path $EvidenceDir "$label-board-readiness.json";$report=if(Test-Path -LiteralPath $reportPath){Get-Content -LiteralPath $reportPath -Raw|ConvertFrom-Json}else{$null}
$expectedPolls=if($controlHomeOnly){30}else{2}
Probe "actual-entry-$type-$(if($controlHomeOnly){'home-timeout-rejects-unchanged-save'}else{'waits-for-ui-despite-unchanged-save'})" ($accepted-eq!$controlHomeOnly-and$polls.Count-eq$expectedPolls-and@($polls|Where-Object delay -ne 500).Count-eq0-and$report.observations.Count-eq$expectedPolls-and$report.ready-eq!$controlHomeOnly) @{accepted=$accepted;polls=$polls.Count;observations=$report.observations.Count;error=$message;scope='Actual suite LoadFixture/WaitBoard, transport doubles replay actual PNGs and unchanged JSON; no native run'}
$enteredPath=Join-Path $EvidenceDir "$label-entered.json"
Probe "actual-entry-$type-$(if($controlHomeOnly){'timeout'}else{'ready'})-raw-save-preserved" ($report.observations.Count-eq$expectedPolls-and@($report.observations|Where-Object saveSha256 -ne $originalHash).Count-eq0-and$(if($accepted){(Get-FileHash -LiteralPath $enteredPath).Hash-eq$originalHash-and(Same $loaded $original)}else{!(Test-Path -LiteralPath $enteredPath)})) @{original=$originalPath;sha256=$originalHash;observations=$report.observations.Count;enteredExists=(Test-Path -LiteralPath $enteredPath)}
}}
}
}catch{Probe 'control-execution' $false $_.Exception.Message}
$passed=$checks.Count-gt0-and@($checks|Where-Object passed -ne $true).Count-eq0
@{status=if($passed){'passed'}else{'failed'};scope='Tool semantic controls only; no native game verdict';sourceCommit=(& git rev-parse HEAD);sourceDirty=[bool](& git status --porcelain);startedSource=$FinalSource;finishedUtc=[DateTime]::UtcNow.ToString('o');checks=$checks.ToArray()}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath (Join-Path $EvidenceDir 'playground-controls.json') -Encoding utf8
if(!$passed){exit 1};exit 0