From 41840237694245699467b2557858cd71a19026a3 Mon Sep 17 00:00:00 2001 From: NINI Date: Sat, 10 Oct 2026 06:56:53 +0900 Subject: [PATCH] Fix native reentry proof for stage-keyed progress slots --- tools/android/exercise-playground.ps1 | 17 +++- tools/android/test-playground-contracts.ps1 | 90 ++++++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/tools/android/exercise-playground.ps1 b/tools/android/exercise-playground.ps1 index 48858d3..77a2c7e 100644 --- a/tools/android/exercise-playground.ps1 +++ b/tools/android/exercise-playground.ps1 @@ -38,6 +38,21 @@ function Same($First,$Second){ return $records[0]-ceq$records[1] }catch{return $false} } +function SameProgress($First,$Second){ + # Save slots are keyed by unique stageId; preserve every other array's strict order. + try{ + $firstCopy=Clone $First;$secondCopy=Clone $Second + foreach($copy in @($firstCopy,$secondCopy)){ + if($copy-isnot[pscustomobject]-or$copy.PSObject.Properties.Name-cnotcontains'puzzleStates'-or$copy.puzzleStates-isnot[array]-or$copy.puzzleStates.Count-gt30){return $false} + $ids=[Collections.Generic.HashSet[long]]::new() + foreach($slot in $copy.puzzleStates){ + if($slot-isnot[pscustomobject]-or$slot.PSObject.Properties.Name-cnotcontains'stageId'-or($slot.stageId-isnot[long]-and$slot.stageId-isnot[int])-or$slot.stageId-lt1-or$slot.stageId-gt30-or!$ids.Add([long]$slot.stageId)){return $false} + } + $copy.puzzleStates=@($copy.puzzleStates|Sort-Object stageId) + } + return Same $firstCopy $secondCopy + }catch{return $false} +} function Slot($State,[string]$Type,[int]$Stage){if($Type-eq'free'){return $State.latestFreePlay};return @($State.puzzleStates|Where-Object stageId -eq $Stage)[0]} function SameBalls($First,$Second,[double]$Tolerance=.004){ if((($First.balls.id|Sort-Object)-join',')-ne(($Second.balls.id|Sort-Object)-join',')){return $false} @@ -204,7 +219,7 @@ try { @{name=$fixture.name;entryObservationMilliseconds=$watch.Elapsed.TotalMilliseconds;editorEntryMilliseconds=$fixture.editorEntryMilliseconds;method='Native wall time includes process launch/home/readiness/UI/ADB/save pull; does not isolate GameApp entry CPU';utc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $EvidenceDir "$($fixture.name)-timing.json") Step "$($fixture.name)-home-again" @('shell','input','tap','312','108') if($fixture.slot-eq'free'){Step "$($fixture.name)-reenter" @('shell','input','tap','1200','810')}else{SelectStage "$($fixture.name)-reenter" $fixture.stage} - Check "$($fixture.name)-reentry-exact" (Same $after (Save "$($fixture.name)-reentered")) 'Repair/reset is stable and cannot repeatedly shift or change other slots' + Check "$($fixture.name)-reentry-exact" (SameProgress $after (Save "$($fixture.name)-reentered")) 'Repair/reset is stable; only unique stage-keyed puzzle slot order may differ, all nested values and arrays remain exact' } foreach($fixture in $fixtures.drags){ $state=FixtureState $fixture.source 'free' 1;$before=LoadFixture $fixture.name $state 'free' 1;$board=$before.latestFreePlay diff --git a/tools/android/test-playground-contracts.ps1 b/tools/android/test-playground-contracts.ps1 index 0c31fdb..06ccfe8 100644 --- a/tools/android/test-playground-contracts.ps1 +++ b/tools/android/test-playground-contracts.ps1 @@ -1,4 +1,4 @@ -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) +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) $ErrorActionPreference='Stop' New-Item -ItemType Directory -Path $EvidenceDir -Force|Out-Null $checks=[Collections.Generic.List[object]]::new() @@ -95,6 +95,94 @@ exit ([int]$env:PLAYGROUND_CONTROL_TOUCH_EXIT) 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' + $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