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) $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' 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'} } $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