diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/VisualUpgradePlayTests.cs b/Assets/MagnetismHeroes/Tests/PlayMode/VisualUpgradePlayTests.cs index d47b45b..a85d1ea 100644 --- a/Assets/MagnetismHeroes/Tests/PlayMode/VisualUpgradePlayTests.cs +++ b/Assets/MagnetismHeroes/Tests/PlayMode/VisualUpgradePlayTests.cs @@ -35,7 +35,95 @@ namespace MagnetismHeroes.Tests public Quaternion cameraRotation; } private GameApp _app; + [Serializable] private sealed class NativeFixtures + { + public string sourceCommit, sourceDirty, utc, origin; + public NativeStage[] stages; + public NativeRestore[] restores; + public NativeDrag[] drags; + } + [Serializable] private sealed class NativeStage + { + public int stage; + public string theme; + public Vector3 min, max; + public PuzzleState initial; + } + [Serializable] private sealed class NativeRestore + { + public string name, slot, origin; + public int stage; + public PuzzleState puzzleSource, puzzleExpected; + public FreePlayState freeSource, freeExpected; + public float editorEntryMilliseconds; + } + [Serializable] private sealed class NativeDrag + { + public string name, ballId, kind = "boundary"; + public FreePlayState source, expected; + public Vector3 requested; + public Vector2 start, end; + } private Store _store; + [Serializable] private sealed class MatchedBenchmark + { + public string sourceCommit, sourceDirty, utc, method, loadOrder; + public int stage, balls, groups, activeProps, width = 2400, height = 1080, warmups = 8, sampleCount = 30; + public Vector3 cameraPosition; + public Quaternion cameraRotation; + public float fieldOfView; + public double[] renderCpuMilliseconds; + public long allocatedMemory, textureMemory; + } + [Serializable] private sealed class BenchmarkSceneryScan { public int stage, activeProps, renderers, residentTriangles; } + [Serializable] private sealed class BenchmarkSceneryReport { public string sourceCommit, utc; public BenchmarkSceneryScan[] stages; public int[] maximumPropStages; } + + [UnityTest] public IEnumerator MatchedPlaygroundResourceBenchmark() + { + string directory = Environment.GetEnvironmentVariable("MAGNETISM_BENCHMARK_EVIDENCE") ?? Path.GetFullPath("artifacts/verification/PlaygroundInteraction/EditorBenchmark"); + Directory.CreateDirectory(directory); + var scans = new System.Collections.Generic.List(); + foreach (int stage in Enumerable.Range(1, 30)) + { + _app.StartStage(stage); _app.Voice.Clear(); yield return null; yield return null; + var filters = _app.Environment.GetComponentsInChildren(); + scans.Add(new BenchmarkSceneryScan { stage = stage, + activeProps = _app.Environment.transform.Cast().Count(child => child.gameObject.activeInHierarchy && child.name != "PlacementSafetySphere"), + renderers = _app.Environment.GetComponentsInChildren().Count(value => value.enabled), + residentTriangles = filters.Sum(value => Enumerable.Range(0, value.sharedMesh.subMeshCount).Sum(index => (int)value.sharedMesh.GetIndexCount(index)) / 3) }); + } + var maximumPropStages = scans.Where(value => value.activeProps == scans.Max(item => item.activeProps)).Select(value => value.stage).ToArray(); + File.WriteAllText(Path.Combine(directory, "actual-thirty-scenery-scan.json"), JsonUtility.ToJson(new BenchmarkSceneryReport { sourceCommit = Environment.GetEnvironmentVariable("MAGNETISM_PROOF_COMMIT"), utc = DateTime.UtcNow.ToString("O"), stages = scans.ToArray(), maximumPropStages = maximumPropStages }, true)); + Assert.That(maximumPropStages.Intersect(new[] { 1, 16, 21 }), Is.Not.Empty, "Fixed matched stages must include an actually observed maximum-prop scene."); + var camera = _app.Presenter.Camera; var canvas = _app.GetComponentInChildren(); + var target = RenderTexture.GetTemporary(2400, 1080, 24); + var previousTarget = camera.targetTexture; var previousMode = canvas.renderMode; var previousCamera = canvas.worldCamera; float previousPlane = canvas.planeDistance, previousAspect = camera.aspect; + try + { + camera.targetTexture = target; camera.aspect = 2400f / 1080; canvas.renderMode = RenderMode.ScreenSpaceCamera; canvas.worldCamera = camera; canvas.planeDistance = .5f; + foreach (int stage in new[] { 1, 16, 21 }) foreach (bool singles in new[] { true, false }) + { + _app.StartStage(stage); _app.Voice.Clear(); yield return null; yield return null; + var balls = Enumerable.Range(0, 10).Select(index => new BallState { id = "benchmark-" + index, position = new Position3((index % 5 - 2) * 1.6f, .8f + index / 5 * 1.8f, 0) }).ToArray(); + var groups = singles ? balls.Select(ball => new GroupState { id = ball.id, ballIds = new[] { ball.id } }).ToArray() : new[] { new GroupState { id = "benchmark-ten", ballIds = balls.Select(ball => ball.id).ToArray() } }; + _app.Presenter.Render(BoardRules.CreateFreePlay(new FreePlayState { sourceStageId = stage, balls = balls, groups = groups })); + var rotation = Quaternion.Euler(25, 30, 0); _app.Orbit.Apply(new CameraState { target = new Position3(0, .6f, 0), distance = 12, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) }); + yield return null; yield return null; Canvas.ForceUpdateCanvases(); + for (int index = 0; index < 8; index++) { camera.Render(); yield return null; } + var samples = new double[30]; + for (int index = 0; index < samples.Length; index++) { var watch = System.Diagnostics.Stopwatch.StartNew(); camera.Render(); watch.Stop(); samples[index] = watch.Elapsed.TotalMilliseconds; yield return null; } + Assert.That(samples.All(value => value > 0), Is.True); Assert.That(_app.Presenter.Balls.Count, Is.EqualTo(10)); + File.WriteAllText(Path.Combine(directory, "stage" + stage + (singles ? "-ten-singles" : "-ten-group") + ".json"), JsonUtility.ToJson(new MatchedBenchmark { + sourceCommit = Environment.GetEnvironmentVariable("MAGNETISM_PROOF_COMMIT"), sourceDirty = Environment.GetEnvironmentVariable("MAGNETISM_PROOF_DIRTY"), utc = DateTime.UtcNow.ToString("O"), + method = "Standalone matched PlayMode filter; actual Main scene. 8 warmups and 30 Camera.Render CPU wall samples separated by frames; allocated/texture memory includes Editor. These values are not Android frame timing, GPU time or real-device performance.", + loadOrder = "Main, scan original stages1..30, stage1 singles/group, stage16 singles/group, stage21 singles/group; actual-thirty-scenery-scan.json proves maximum-prop coverage", + stage = stage, balls = balls.Length, groups = groups.Length, activeProps = _app.Environment.transform.Cast().Count(child => child.gameObject.activeInHierarchy && child.name != "PlacementSafetySphere"), + cameraPosition = camera.transform.position, cameraRotation = camera.transform.rotation, fieldOfView = camera.fieldOfView, renderCpuMilliseconds = samples, + allocatedMemory = Profiler.GetTotalAllocatedMemoryLong(), textureMemory = Resources.FindObjectsOfTypeAll().Sum(item => Profiler.GetRuntimeMemorySizeLong(item)) }, true)); + } + } + finally { camera.targetTexture = previousTarget; camera.aspect = previousAspect; canvas.renderMode = previousMode; canvas.worldCamera = previousCamera; canvas.planeDistance = previousPlane; RenderTexture.ReleaseTemporary(target); } + } private string _evidence; [UnitySetUp] public IEnumerator SetUp() { @@ -51,6 +139,165 @@ namespace MagnetismHeroes.Tests } [UnityTearDown] public IEnumerator TearDown() { Object.Destroy(_app.gameObject); yield return null; } + [UnityTest] public IEnumerator ExportActualMainPlaygroundNativeBoundaryFixtures() + { + var stages = new System.Collections.Generic.List(); + var restores = new System.Collections.Generic.List(); + var drags = new System.Collections.Generic.List(); + foreach (int stage in Enumerable.Range(1, 30)) + { + _app.StartStage(stage); _app.Voice.Clear(); yield return null; + stages.Add(new NativeStage { stage = stage, theme = _app.Environment.Theme, + min = _app.Environment.PlaygroundBounds.min, max = _app.Environment.PlaygroundBounds.max, + initial = new BoardRules(_app.Session.Definitions.Single(value => value.Id == stage)).ToPuzzleState(GameSession.DefaultCamera()) }); + if (new[] {1, 2, 6, 7, 11, 12, 16, 17, 21, 22, 26, 27}.Contains(stage)) + CaptureViews("playground-stage" + stage, "Actual Main initial board; labelled unlocked-stage fixture, not normal progression"); + } + foreach (var entry in new[] { (name: "safe-free", stage: 1, point: new Vector3(0, 4, 0)), + (name: "boundary-free", stage: 1, point: new Vector3(30, 4, 0)), + (name: "tree-interior", stage: 6, point: Vector3.zero), (name: "rock-interior", stage: 21, point: Vector3.zero) }) + { + _app.StartStage(entry.stage); yield return null; Physics.SyncTransforms(); + Vector3 point = entry.point; + if (entry.point == Vector3.zero) + { + string asset = entry.name.StartsWith("tree") ? "RoundCanopyTree" : "SlateBoulder"; + var meshes = _app.Environment.GetComponentsInChildren().Where(value => value.enabled && value.transform.GetComponentsInParent().Any(parent => parent.name.Contains(asset))).ToArray(); + Assert.That(meshes, Is.Not.Empty, "Native interior fixture must use an actual scenery collider."); + var collider = meshes.OrderByDescending(value => value.bounds.size.sqrMagnitude).First(); + Assert.That(collider.sharedMesh.isReadable, Is.False); + point = collider.bounds.center; + Assert.That(_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point)), Is.False, "Actual approved mesh interior starts unsafe."); + } + var source = new FreePlayState { sourceStageId = entry.stage, camera = GameSession.DefaultCamera(), + balls = new[] { new BallState { id = "native-" + entry.name, position = BoardPresenter.ToPosition(point) } }, + groups = new[] { new GroupState { id = "native-group", ballIds = new[] { "native-" + entry.name } } } }; + _store.Saved.latestFreePlay = source; _app.Initialize(_store); + var watch = System.Diagnostics.Stopwatch.StartNew(); _app.ContinueFreePlay(); watch.Stop(); yield return null; + Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True); + restores.Add(new NativeRestore { name = entry.name, stage = entry.stage, slot = "free", freeSource = source, + freeExpected = _app.Session.Board.ToFreePlayState(_app.Orbit.Snapshot()), editorEntryMilliseconds = (float)watch.Elapsed.TotalMilliseconds, + origin = "Actual unchanged Main scenery; injected position only; original imports remain unreadable; expected result observed through GameApp entry" }); + } + var legacy = new FreePlayState { sourceStageId = 9, + camera = new CameraState { target = new Position3(0, .6f, 0), distance = 11, rotation = new Rotation4(-.022557568f, .96225017f, .08418597f, .2578342f) }, + balls = new[] { new BallState { id = "s09-b01", position = new Position3(-1.0351909f, .8f, .60371524f) }, + new BallState { id = "s09-b02", position = new Position3(-.03519098f, .8f, .60371524f) }, + new BallState { id = "s09-b03", position = new Position3(-3.9800465f, .8f, 1.649168f) }, + new BallState { id = "s09-b04", position = new Position3(.964809f, .8f, .60371524f) }, + new BallState { id = "s09-b05", position = new Position3(-2.9800465f, .8f, 1.649168f) } }, + groups = new[] { new GroupState { id = "s09-g01", ballIds = new[] { "s09-b01", "s09-b02", "s09-b04" } }, new GroupState { id = "s09-g01-split", ballIds = new[] { "s09-b03", "s09-b05" } } } }; + var wide = new FreePlayState { sourceStageId = 6, camera = GameSession.DefaultCamera(), + balls = new[] { new BallState { id = "wide-a", position = new Position3(-30, 4, 0) }, new BallState { id = "wide-b", position = new Position3(30, 4, 0) } }, + groups = new[] { new GroupState { id = "wide-group", ballIds = new[] { "wide-a", "wide-b" } } } }; + var multi = JsonUtility.FromJson(JsonUtility.ToJson(wide)); + multi.sourceStageId = 1; multi.groups = new[] { new GroupState { id = "left", ballIds = new[] { "wide-a" } }, new GroupState { id = "right", ballIds = new[] { "wide-b" } } }; + foreach (var entry in new[] { (name: "legacy-nine", source: legacy), (name: "impossible-free", source: wide), (name: "multiple-repairs", source: multi) }) + { + _store.Saved.latestFreePlay = entry.source; _app.Initialize(_store); + var watch = System.Diagnostics.Stopwatch.StartNew(); _app.ContinueFreePlay(); watch.Stop(); yield return null; + Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True); + restores.Add(new NativeRestore { name = entry.name, stage = entry.source.sourceStageId, slot = "free", freeSource = entry.source, + freeExpected = _app.Session.Board.ToFreePlayState(_app.Orbit.Snapshot()), editorEntryMilliseconds = (float)watch.Elapsed.TotalMilliseconds, + origin = entry.name == "legacy-nine" ? "Exact original Content stage9 3+2 record and underground camera; original failure retained in T4 integration-fix" : "Separate injected boundary record; impossible variant exceeds rigid domain, multiple variant has two independent repairable friends" }); + } + var puzzle = new PuzzleState { stageId = 2, camera = GameSession.DefaultCamera(), + balls = new[] { new BallState { id = "native-count-a", position = new Position3(30, 4, 0) }, new BallState { id = "native-count-b", position = new Position3(31, 4, 0) } }, + groups = new[] { new GroupState { id = "native-count-group", ballIds = new[] { "native-count-a", "native-count-b" } } }, + collectedBallIds = new[] { "native-count-a" }, countedBallIds = new[] { "native-count-a" } }; + foreach (bool impossible in new[] { false, true }) + { + if (impossible) { puzzle = JsonUtility.FromJson(JsonUtility.ToJson(puzzle)); puzzle.balls[0].position.x = -30; puzzle.balls[1].position.x = 30; } + _store.Saved.puzzleStates = new[] { puzzle }; _app.Initialize(_store); _app.StartStage(2); yield return null; + Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle)); Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True); + restores.Add(new NativeRestore { name = impossible ? "impossible-puzzle" : "counting-position-only", stage = 2, slot = "puzzle", puzzleSource = puzzle, + puzzleExpected = _app.Session.Board.ToPuzzleState(_app.Orbit.Snapshot()), origin = "Separate injected two-ball Counting record; preserved partial collect/count; impossible variant changes x span to 60" }); + } + _store.Saved.puzzleStates = Array.Empty(); + foreach (int count in new[] { 1, 10 }) foreach (int axis in Enumerable.Range(0, 3)) foreach (int sign in new[] { -1, 1 }) + { + _app.StartStage(1); yield return null; + var bounds = _app.Environment.PlaygroundBounds; + var points = Enumerable.Range(0, count).Select(index => new Vector3((index % 5 - (count == 1 ? 0 : 2)), 4, index / 5)).ToArray(); + var lower = points.Aggregate(Vector3.Min); var upper = points.Aggregate(Vector3.Max); + float offset = sign < 0 ? bounds.min[axis] + .5f + 1.2f - lower[axis] : bounds.max[axis] - .5f - 1.2f - upper[axis]; + var translation = Vector3.zero; translation[axis] = offset; + points = points.Select(point => point + translation).ToArray(); + Assert.That(points.All(point => _app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point))), Is.True); + string id = "native-drag-0"; var rotation = Quaternion.Euler(0, axis == 2 ? 90 : 0, 0); + var focus = points[0] - Vector3.up * .65f; + var camera = new CameraState { target = BoardPresenter.ToPosition(focus), distance = 14, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) }; + var source = new FreePlayState { sourceStageId = 1, camera = camera, + balls = points.Select((point, index) => new BallState { id = "native-drag-" + index, position = BoardPresenter.ToPosition(point) }).ToArray(), + groups = new[] { new GroupState { id = "native-drag-group", ballIds = Enumerable.Range(0, count).Select(index => "native-drag-" + index).ToArray() } } }; + _store.Saved.latestFreePlay = source; _app.Initialize(_store); _app.ContinueFreePlay(); yield return null; + _app.Orbit.Camera.aspect = 2400f / 1080; + var requested = Vector3.zero; requested[axis] = sign * 3; + var start = _app.Orbit.Camera.WorldToViewportPoint(points[0]); var end = _app.Orbit.Camera.WorldToViewportPoint(points[0] + requested); + var actual = _app.Environment.ConstrainTranslation(points, requested, _app.Presenter.transform); + Assert.That(actual.magnitude, Is.InRange(1.19f, 1.21f), "Actual boundary clipping must stop at the .5 sphere margin."); + _app.CommitMove("native-drag-group", BoardPresenter.ToPosition(actual)); yield return null; + drags.Add(new NativeDrag { name = "boundary-" + count + "-" + axis + "-" + sign, ballId = id, source = source, + expected = _app.Session.Board.ToFreePlayState(_app.Orbit.Snapshot()), requested = requested, + start = new Vector2(start.x * 2400, (1 - start.y) * 1080), end = new Vector2(end.x * 2400, (1 - end.y) * 1080) }); + } + _app.StartStage(1); yield return null; Physics.SyncTransforms(); + var arch = _app.Environment.GetComponentsInChildren().First(value => value.enabled && value.GetComponentsInParent().Any(parent => parent.name.Contains("OpenStoneArch"))); + var foliage = _app.Environment.transform.Cast().Where(value => value.gameObject.activeInHierarchy && value.name.Contains("GrassTuftMint") && value.GetComponentsInChildren().Length == 0).ToArray(); + Assert.That(foliage, Is.Not.Empty); + foreach (string kind in new[] { "thin-obstacle", "foliage-pass", "two-plus-three" }) + { + var sourcePoint = new Vector3(-3, 3, 0); var requested = Vector3.right * 4; + if (kind == "thin-obstacle") { sourcePoint = new Vector3(arch.bounds.min.x - 1.5f, 1.5f, arch.bounds.center.z); requested = Vector3.right * (arch.bounds.size.x + 3); } + if (kind == "foliage-pass") + { + bool found = false; + foreach (var plant in foliage) + { + var center = plant.GetComponentInChildren().bounds.center; sourcePoint = new Vector3(center.x - 2, .8f, center.z); + if (!_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(sourcePoint))) continue; + if (Vector3.Distance(_app.Environment.ConstrainTranslation(new[] { sourcePoint }, requested, _app.Presenter.transform), requested) > .001f) continue; + found = true; break; + } + Assert.That(found, Is.True, "Actual small foliage path must cross visible plant and remain unobstructed."); + } + var balls = new System.Collections.Generic.List { new BallState { id = "native-contact-a", position = BoardPresenter.ToPosition(sourcePoint) } }; + var groups = new System.Collections.Generic.List { new GroupState { id = "native-contact-source", ballIds = new[] { "native-contact-a" } } }; + if (kind == "thin-obstacle") + { + balls.Add(new BallState { id = "native-contact-b", position = BoardPresenter.ToPosition(sourcePoint + requested) }); + groups.Add(new GroupState { id = "native-contact-target", ballIds = new[] { "native-contact-b" } }); + } + if (kind == "two-plus-three") + { + balls = Enumerable.Range(0, 5).Select(index => new BallState { id = "native-contact-" + index, position = new Position3(index - 3, 3, 0) }).ToList(); + groups = new System.Collections.Generic.List { new GroupState { id = "native-contact-source", ballIds = balls.Take(2).Select(ball => ball.id).ToArray() }, new GroupState { id = "native-contact-target", ballIds = balls.Skip(2).Select(ball => ball.id).ToArray() } }; + // Source/target begin separated rather than already touching. + foreach (var ball in balls.Skip(2)) ball.position.x += 1; + } + var camera = new CameraState { target = BoardPresenter.ToPosition(sourcePoint - Vector3.up * .65f), distance = 14 }; + var source = new FreePlayState { sourceStageId = 1, camera = camera, balls = balls.ToArray(), groups = groups.ToArray() }; + _store.Saved.latestFreePlay = source; _app.Initialize(_store); _app.ContinueFreePlay(); yield return null; + Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True); _app.Orbit.Camera.aspect = 2400f / 1080; + var start = _app.Orbit.Camera.WorldToViewportPoint(sourcePoint); var end = _app.Orbit.Camera.WorldToViewportPoint(sourcePoint + requested); + _app.Presenter.PreviewGroup("native-contact-source", requested); + var actual = _app.Presenter.DropDelta; var merge = _app.Presenter.MergeCandidate; + if (kind == "foliage-pass") Assert.That(Vector3.Distance(actual, requested), Is.LessThan(.001f)); + if (kind == "thin-obstacle") { Assert.That(actual.x, Is.GreaterThan(.1f).And.LessThan(requested.x - 1)); Assert.That(merge, Is.Null); } + if (kind == "two-plus-three") Assert.That(merge, Is.EqualTo("native-contact-target")); + _app.CommitMove("native-contact-source", BoardPresenter.ToPosition(actual), merge); yield return null; + drags.Add(new NativeDrag { name = kind, kind = kind, ballId = source.balls[0].id, source = source, expected = _app.Session.Board.ToFreePlayState(_app.Orbit.Snapshot()), requested = requested, + start = new Vector2(start.x * 2400, (1 - start.y) * 1080), end = new Vector2(end.x * 2400, (1 - end.y) * 1080) }); + } + var directory = Environment.GetEnvironmentVariable("MAGNETISM_NATIVE_FIXTURES") ?? Path.GetFullPath("artifacts/verification/PlaygroundInteraction/NativeFixtures"); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "native-fixtures.json"), JsonUtility.ToJson(new NativeFixtures { + sourceCommit = Environment.GetEnvironmentVariable("MAGNETISM_PROOF_COMMIT"), sourceDirty = Environment.GetEnvironmentVariable("MAGNETISM_PROOF_DIRTY"), + utc = DateTime.UtcNow.ToString("O"), origin = "Actual Main scene, unchanged geometry, separately labelled boundary fixtures. Editor oracle is not Android pass.", + stages = stages.ToArray(), restores = restores.ToArray(), drags = drags.ToArray() }, true)); + Assert.That(stages.Count, Is.EqualTo(30)); Assert.That(restores.Count, Is.EqualTo(9)); Assert.That(drags.Count, Is.EqualTo(15)); + } + private void AssertEquipmentFollowsRepresentatives(BoardRules board) { _app.Presenter.SendMessage("LateUpdate"); diff --git a/tools/android/capture-playground-observation.ps1 b/tools/android/capture-playground-observation.ps1 new file mode 100644 index 0000000..b1b805f --- /dev/null +++ b/tools/android/capture-playground-observation.ps1 @@ -0,0 +1,44 @@ +param([Parameter(Mandatory)][string]$ApkPath,[Parameter(Mandatory)][string]$EvidenceDir,[Parameter(Mandatory)][string]$Name,[ValidateSet('Phone','Tablet')][string]$Viewport='Phone',[int]$ObserveSeconds=0,[string]$Serial='emulator-5554',[string]$ApkSourceCommit) +$ErrorActionPreference='Stop' +if($Serial-ne'emulator-5554'){throw 'Only the authorized emulator-5554 may be observed'} +if($Name-notmatch'^[a-zA-Z0-9_-]+$'){throw 'Observation name must be a simple artifact label'} +New-Item -ItemType Directory -Path $EvidenceDir -Force|Out-Null +$adb=Join-Path $env:LOCALAPPDATA 'Android/Sdk/platform-tools/adb.exe';$package='com.magnetismheroes.game' +$workspaceCommit=(& git rev-parse HEAD);if(!$ApkSourceCommit){$ApkSourceCommit=$workspaceCommit} +$commands=[Collections.Generic.List[object]]::new();$result=[ordered]@{status='failed';scope='Actual AVD capture/resource observation only; direct visual judgement and game behavior are separate. APK source attribution must be linked to build or baseline-product-equivalence evidence by the observation owner.';name=$Name;serial=$Serial;viewport=$Viewport;sourceCommit=$ApkSourceCommit;workspaceCommit=$workspaceCommit;sourceDirty=[bool](& git status --porcelain);apkSha256=(Get-FileHash -LiteralPath $ApkPath).Hash;startedUtc=[DateTime]::UtcNow.ToString('o')} +function Adb([string[]]$Arguments){$output=& $adb -s $Serial @Arguments 2>&1;$code=$LASTEXITCODE;$commands.Add(@{command='adb -s '+$Serial+' '+($Arguments-join' ');exitCode=$code;utc=[DateTime]::UtcNow.ToString('o');output=$output-join"`n"});if($code-ne0){throw "ADB observation exit $code"};return ($output-join"`n").Trim()} +$sizeBefore=$null;$statsEnabled=$false +try{ + if($result.sourceDirty){throw 'Observation source is dirty'} + if((Adb @('emu','avd','name'))-notmatch'^Medium_Phone_API_37\.0'){throw 'Unexpected AVD'} + $remoteApk=(Adb @('shell','pm','path',$package)).Replace('package:','').Split("`n")[0].Trim() + $installed=Join-Path $EvidenceDir "$Name-installed-base.apk";Adb @('pull',$remoteApk,$installed)|Out-Null + if((Get-FileHash -LiteralPath $installed).Hash-ne$result.apkSha256){throw 'Installed APK differs from supplied observation APK'} + $sizeBefore=Adb @('shell','wm','size');$result.sizeBefore=$sizeBefore + if($Viewport-eq'Tablet'){Adb @('shell','wm','size','1000x1600')|Out-Null}else{Adb @('shell','wm','size','reset')|Out-Null} + Start-Sleep -Milliseconds 1500 + $png=Join-Path $EvidenceDir "$Name.png";Adb @('shell','screencap','-p',"/sdcard/$Name.png")|Out-Null;Adb @('pull',"/sdcard/$Name.png",$png)|Out-Null + Add-Type -AssemblyName System.Drawing;$image=[Drawing.Bitmap]::new($png) + try{$result.width=$image.Width;$result.height=$image.Height}finally{$image.Dispose()} + $expected=if($Viewport-eq'Tablet'){@(1600,1000)}else{@(2400,1080)} + if($result.width-ne$expected[0]-or$result.height-ne$expected[1]){throw 'Actual PNG dimensions differ from requested landscape viewport'} + $result.pngSha256=(Get-FileHash -LiteralPath $png).Hash + $save=Join-Path $EvidenceDir "$Name-progress.json";Adb @('pull',"/sdcard/Android/data/$package/files/progress.json",$save)|Out-Null;$result.progressSha256=(Get-FileHash -LiteralPath $save).Hash + if($ObserveSeconds-gt0){ + if($ObserveSeconds-lt5-or$ObserveSeconds-gt60){throw 'Resource sample must be between 5 and 60 seconds'} + Adb @('shell','dumpsys','SurfaceFlinger','--timestats','-clear','-enable')|Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-timestats-start.txt");$statsEnabled=$true + Start-Sleep -Seconds $ObserveSeconds + Adb @('shell','dumpsys','SurfaceFlinger','--timestats','-dump')|Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-timestats.txt") + Adb @('shell','dumpsys','meminfo',$package)|Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-meminfo.txt") + $result.observeSeconds=$ObserveSeconds;$result.resourceMethod='Actual same-AVD SurfaceView presentToPresent histograms and total process PSS raw dumps; root must compare matching camera/viewport/board/load order/warmup/sample duration. AVD observation is not real-device FPS.' + } + $result.status='passed' +}catch{$result.error=$_.Exception.Message} +finally{ + try{ + if($statsEnabled){Adb @('shell','dumpsys','SurfaceFlinger','--timestats','-disable')|Out-Null} + if($sizeBefore){if($sizeBefore-match'Override size: (\d+x\d+)'){Adb @('shell','wm','size',$Matches[1])|Out-Null}else{Adb @('shell','wm','size','reset')|Out-Null};$result.sizeAfter=Adb @('shell','wm','size');if($result.sizeAfter-ne$sizeBefore){throw 'Original display size was not restored'}} + }catch{$result.status='failed';$result.restoreError=$_.Exception.Message} + $result.commands=$commands.ToArray();$result.finishedUtc=[DateTime]::UtcNow.ToString('o');$result|ConvertTo-Json -Depth 8|Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-observation.json") -Encoding utf8 +} +if($result.status-ne'passed'){Write-Host $result.error;exit 1};exit 0 diff --git a/tools/android/exercise-final.ps1 b/tools/android/exercise-final.ps1 index f73718b..7d91c72 100644 --- a/tools/android/exercise-final.ps1 +++ b/tools/android/exercise-final.ps1 @@ -34,14 +34,14 @@ try { Check "apk-notice-exact-bytes-$name" ($sha-eq(Get-FileHash -LiteralPath (Join-Path $projectRoot ('Assets/StreamingAssets/Notices/'+$name))).Hash) $sha } }finally{$zip.Dispose()} - foreach($suite in @(@{name='Fresh30';script='exercise-final-progression.ps1';report='android-final-progression-evidence.json'},@{name='FinalBoundary';script='exercise-final-boundaries.ps1';report='android-final-boundary-evidence.json'},@{name='RapidBoundary';script='exercise-final-rapid.ps1';report='android-final-rapid-evidence.json'},@{name='ContentBoundary';script='exercise-content.ps1';report='android-content-evidence.json'})){ + foreach($suite in @(@{name='Fresh30';script='exercise-final-progression.ps1';report='android-final-progression-evidence.json'},@{name='FinalBoundary';script='exercise-final-boundaries.ps1';report='android-final-boundary-evidence.json'},@{name='RapidBoundary';script='exercise-final-rapid.ps1';report='android-final-rapid-evidence.json'},@{name='ContentBoundary';script='exercise-content.ps1';report='android-content-evidence.json'},@{name='PlaygroundInteraction';script='exercise-playground.ps1';report='android-playground-evidence.json'})){ $childDir=Join-Path $EvidenceDir $suite.name;New-Item -ItemType Directory -Path $childDir -Force|Out-Null Copy-Item -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Destination (Join-Path $childDir 'androidbuild-evidence.json') $log=Join-Path $childDir 'outer.log';$command="pwsh -NoProfile -File tools/android/$($suite.script) -ApkPath $ApkPath -EvidenceDir $childDir -Serial $Serial" & pwsh -NoProfile -File (Join-Path $PSScriptRoot $suite.script) -ApkPath $ApkPath -EvidenceDir $childDir -Serial $Serial *> $log $actualExit=$LASTEXITCODE;$child=Get-Content -LiteralPath (Join-Path $childDir $suite.report) -Raw|ConvertFrom-Json $children.Add(@{suite=$suite.name;command=$command;exitCode=$actualExit;log=$log;report=$child}) - Check "suite-$($suite.name)-actual-exit-status-checks" ($actualExit-eq0-and$child.status-eq'passed'-and$child.sourceCommit-eq$record.sourceCommit-and!$child.sourceDirty-and$child.apkSha256-eq$record.apkSha256-and$child.checks.Count-gt0-and@($child.checks|Where-Object passed -ne $true).Count-eq0) "$actualExit / $($child.checks.Count) checks" + Check "suite-$($suite.name)-actual-exit-status-checks" ($actualExit-eq0-and$child.status-eq'passed'-and$child.sourceCommit-eq$record.sourceCommit-and!$child.sourceDirty-and$child.apkSha256-eq$record.apkSha256-and$child.checks.Count-gt0-and@($child.checks|Where-Object passed -ne $true).Count-eq0-and@($child.pending|Where-Object {$null-ne$_}).Count-eq0-and@($child.skipped|Where-Object {$null-ne$_}).Count-eq0-and($suite.name-ne'PlaygroundInteraction'-or($child.sourceCommitEnd-eq$record.sourceCommit-and!$child.sourceDirtyEnd))) "$actualExit / $($child.checks.Count) checks" if($suite.name-eq'Fresh30'){ foreach($stage in 1..30){Check "fresh30-required-success-$stage" (@($child.checks|Where-Object {$_.id-eq"stage-$stage-actual-success"-and$_.passed}).Count-eq1) 'Native sequential checkpoint present'} Check 'fresh30-native-commands-no-fixture-write' (@($child.commands|Where-Object {$_.command-match'\bpush\b'}).Count-eq0) 'Native progression never pushes save data' diff --git a/tools/android/exercise-playground.ps1 b/tools/android/exercise-playground.ps1 new file mode 100644 index 0000000..d7454c2 --- /dev/null +++ b/tools/android/exercise-playground.ps1 @@ -0,0 +1,199 @@ +param([Parameter(Mandatory)][string]$ApkPath,[Parameter(Mandatory)][string]$EvidenceDir,[string]$Serial='emulator-5554') +. (Join-Path $PSScriptRoot 'final-common.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir -Serial $Serial +$result.milestone='PlaygroundInteraction';$result.startedUtc=[DateTime]::UtcNow.ToString('o') +$result.scope='Native boundary fixtures after unmodified Fresh30; Editor oracle is compared with actual player saves, never accepted as native pass' +$helper=Join-Path $PSScriptRoot 'send-composition-touch.ps1' +$fixturePath=Join-Path $projectRoot 'artifacts/verification/PlaygroundInteraction/NativeFixtures/native-fixtures.json' +$observationPath=Join-Path $projectRoot 'artifacts/verification/PlaygroundInteraction/native-observation.json' +$pending=[Collections.Generic.List[string]]::new() +$original=$null +function NativeTouch([int[]]$Contacts) { + & pwsh -NoProfile -CommandWithArgs '$ErrorActionPreference="Stop"; $touchHelper=$args[0]; $touchContacts=@($args[1..($args.Count-1)]|ForEach-Object {[int]$_}); & $touchHelper -Contacts $touchContacts; exit $LASTEXITCODE' $helper @Contacts + $actualExit=$LASTEXITCODE + $commands.Add(@{command='EmulatorController.sendTouch';contacts=$Contacts;exitCode=$actualExit;utc=[DateTime]::UtcNow.ToString('o')}) + if($actualExit-ne0){throw "Actual native gesture failed: $actualExit"} +} +function Clone($Value){return $Value|ConvertTo-Json -Depth 20|ConvertFrom-Json} +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} + foreach($ball in $First.balls){$match=@($Second.balls|Where-Object id -eq $ball.id);if($match.Count-ne1){return $false};$point=$match[0].position + if([Math]::Abs($ball.position.x-$point.x)-gt$Tolerance-or[Math]::Abs($ball.position.y-$point.y)-gt$Tolerance-or[Math]::Abs($ball.position.z-$point.z)-gt$Tolerance){return $false}} + return $true +} +function SameMembership($First,$Second){ + foreach($board in @($First,$Second)){ + $ids=@($board.balls.id);$members=@($board.groups|ForEach-Object {$_.ballIds}) + if(@($ids|Sort-Object -Unique).Count-ne$ids.Count-or@($members|Sort-Object -Unique).Count-ne$members.Count-or(($ids|Sort-Object)-join',')-ne(($members|Sort-Object)-join',')){return $false} + } + return Same @($First.groups|Sort-Object id|ForEach-Object {[ordered]@{id=$_.id;ids=@($_.ballIds|Sort-Object)}}) @($Second.groups|Sort-Object id|ForEach-Object {[ordered]@{id=$_.id;ids=@($_.ballIds|Sort-Object)}}) +} +function InBounds($Board,[int]$Stage){ + $bounds=@($fixtures.stages|Where-Object stage -eq $Stage)[0] + foreach($ball in $Board.balls){foreach($axis in @('x','y','z')){if($ball.position.$axis-lt$bounds.min.$axis+.499-or$ball.position.$axis-gt$bounds.max.$axis-.499){return $false}}} + return $true +} +function FixtureState($Board,[string]$Type,[int]$Stage){ + $state=Clone $original + # These untouched independent slots are labelled fixtures, not sequential completion evidence. + $state.puzzleStates=@(3,6|ForEach-Object {Clone (@($fixtures.stages|Where-Object stage -eq $_)[0].initial)}) + if($Type-eq'free'){$state.latestFreePlay=Clone $Board}else{$state.puzzleStates=@($state.puzzleStates|Where-Object stageId -ne $Stage)+@(Clone $Board)} + return $state +} +function LoadFixture([string]$Name,$State,[string]$Type,[int]$Stage){ + Adb @('shell','am','force-stop',$package)|Out-Null + $path=Join-Path $EvidenceDir "$Name-injected.json";$State|ConvertTo-Json -Depth 20|Set-Content -LiteralPath $path -Encoding utf8 + @{name=$Name;origin='Actual Fresh30/thirty-completed.json; explicitly labelled injected boundary case';originalFile=$originalPath;originalSha256=(Get-FileHash -LiteralPath $originalPath).Hash;fixtureSha256=(Get-FileHash -LiteralPath $path).Hash;changedFields=@('puzzleStates','latestFreePlay');preservedFields=@('schemaVersion','completedStageIds','seenTutorialIds');utc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 5|Set-Content -LiteralPath (Join-Path $EvidenceDir "$Name-origin.json") + Adb @('push',$path,$remote)|Out-Null;Adb @('push',$path,($remote+'.bak'))|Out-Null + StartHome "$Name-home" + if($Type-eq'free'){Step "$Name-enter-free" @('shell','input','tap','1200','810') 1500}else{SelectStage "$Name-enter-puzzle" $Stage} + return Save "$Name-entered" +} +function PreservesOtherSlots([string]$Name,$Before,$After,[string]$Type,[int]$Stage){ + Check "$Name-progress-preserved" ((Same $Before.completedStageIds $After.completedStageIds)-and(Same $Before.seenTutorialIds $After.seenTutorialIds)-and$After.schemaVersion-eq1) 'Completion/unlock/tutorial/schema unchanged' + $otherBefore=if($Type-eq'free'){@($Before.puzzleStates)}else{@($Before.puzzleStates|Where-Object stageId -ne $Stage)} + $otherAfter=if($Type-eq'free'){@($After.puzzleStates)}else{@($After.puzzleStates|Where-Object stageId -ne $Stage)} + Check "$Name-other-puzzles-preserved" (Same $otherBefore $otherAfter) 'Two unrelated puzzle slots remain exact' + if($Type-eq'puzzle'){Check "$Name-unrelated-free-preserved" (Same $Before.latestFreePlay $After.latestFreePlay) 'Unrelated Free record remains exact'} +} +function HoldDrag([string]$Name,$Board,[string]$Id,[int]$EndX,[int]$EndY,[int]$Hold,[switch]$Cancel,[int]$ReversePixels=0){ + $point=BallPoint $Board $Id + Check "$Name-safe-start-end" ((WorldSafe $point)-and(WorldSafe @($EndX,$EndY))) 'Documented phone UI-safe coordinates; actual whole ID projected exactly once' + NativeTouch @($point[0],$point[1],71,1024);Start-Sleep -Milliseconds 100 + NativeTouch @($EndX,$EndY,71,1024);Start-Sleep -Milliseconds $Hold + Step "$Name-during-live-camera" @() 0 + $during=Save "$Name-during-save" + Check "$Name-auto-preview-not-saved" (Same $Board $during.latestFreePlay) 'Live auto camera and preview never overwrite confirmed board/manual camera' + if($ReversePixels-gt0){$EndX-=$ReversePixels;NativeTouch @($EndX,$EndY,71,1024);Step "$Name-immediate-reverse" @() 100} + if($Cancel){Adb @('shell','input','keyevent','3')|Out-Null;Start-Sleep -Milliseconds 250} + NativeTouch @($EndX,$EndY,71,0) + if($Cancel){Adb @('shell','am','start','-W','-n',$activity)|Out-Null} + Step "$Name-after-return" @() 2500 + return Save "$Name-dropped" +} +function BlueFriends([string]$Path){ + # Targeted live observation for two isolated blue friends. It is never used as an ID/count verdict. + Add-Type -AssemblyName System.Drawing;$bitmap=[Drawing.Bitmap]::new($Path) + try{ + if($bitmap.Width-ne2400-or$bitmap.Height-ne1080){throw 'Live friend vision requires actual phone viewport'} + $width=331;$height=119;$mask=[bool[]]::new($width*$height) + for($y=0;$y-lt$height;$y++){for($x=0;$x-lt$width;$x++){$pixel=$bitmap.GetPixel(530+$x*4,280+$y*4);$mask[$y*$width+$x]=$pixel.R-lt135-and$pixel.B-ge100-and($pixel.B-$pixel.R)-gt45-and($pixel.G-$pixel.R)-gt20}} + $friends=[Collections.Generic.List[object]]::new();$queue=[Collections.Generic.Queue[int]]::new() + for($index=0;$index-lt$mask.Length;$index++){ + if(!$mask[$index]){continue};$mask[$index]=$false;$queue.Enqueue($index);$count=0;$sumX=0;$sumY=0 + while($queue.Count){$cell=$queue.Dequeue();$x=$cell%$width;$y=[int][Math]::Floor($cell/$width);$count++;$sumX+=$x;$sumY+=$y + foreach($neighbor in @(($cell-1),($cell+1),($cell-$width),($cell+$width))){if($neighbor-lt0-or$neighbor-ge$mask.Length){continue};if([Math]::Abs(($neighbor%$width)-$x)+[Math]::Abs([int][Math]::Floor($neighbor/$width)-$y)-ne1){continue};if($mask[$neighbor]){$mask[$neighbor]=$false;$queue.Enqueue($neighbor)}}} + if($count-ge30){$friends.Add(@{x=530+4*$sumX/$count;y=280+4*$sumY/$count;sampledBluePixels=$count})} + } + return @($friends|Sort-Object sampledBluePixels -Descending|Select-Object -First 2) + }finally{$bitmap.Dispose()} +} +function ApproachDepth([string]$Name,$Source){ + $state=FixtureState $Source 'free' 1;$before=LoadFixture $Name $state 'free' 1 + $initial=Clone $before.latestFreePlay + Check "$Name-real-initial-depth" ([Math]::Abs($initial.balls[0].position.z-$initial.balls[1].position.z)-gt3) 'Manual identity camera: original same-ID friends differ by more than three actual world depth units' + for($attempt=0;$attempt-lt5;$attempt++){ + $current=if($attempt-eq0){$before}else{Save "$Name-before-attempt-$attempt"};$board=$current.latestFreePlay + $point=BallPoint $board 'native-depth-front';Check "$Name-first-hit-safe-$attempt" (WorldSafe $point) ($point-join',') + $pointer=@($point[0]+20,$point[1]);NativeTouch @($point[0],$point[1],73,1024);NativeTouch @($pointer[0],$pointer[1],73,1024);Start-Sleep -Milliseconds 800 + for($step=0;$step-lt45;$step++){ + $label="$Name-live-$attempt-$step";Step $label @() 0;$friends=@(BlueFriends (Join-Path $EvidenceDir "$label.png")) + $commands.Add(@{command='Observe actual live blue friends for input steering';png="$label.png";friends=$friends;pointer=$pointer;utc=[DateTime]::UtcNow.ToString('o')}) + if($friends.Count-lt2){break} + $sourcePoint=@($friends|Sort-Object {[Math]::Pow($_.x-$pointer[0],2)+[Math]::Pow($_.y-$pointer[1],2)})[0] + $targetPoint=@($friends|Where-Object {$_-ne$sourcePoint})[0] + $dx=$targetPoint.x-$sourcePoint.x;$dy=$targetPoint.y-$sourcePoint.y;$length=[Math]::Sqrt($dx*$dx+$dy*$dy) + if($length-lt2){break};$scale=[Math]::Min(1,12/$length) + $next=@([int][Math]::Round($pointer[0]+$dx*$scale),[int][Math]::Round($pointer[1]+$dy*$scale)) + if(!(WorldSafe $next)){throw 'Live depth approach leaves UI-safe input region'} + $pointer=$next;NativeTouch @($pointer[0],$pointer[1],73,1024) + } + NativeTouch @($pointer[0],$pointer[1],73,0);Step "$Name-drop-$attempt" @() 2500;$after=Save "$Name-after-$attempt" + if($after.latestFreePlay.groups.Count-eq1){ + $actual=$after.latestFreePlay;$first=$actual.balls[0].position;$second=$actual.balls[1].position;$distance=[Math]::Sqrt([Math]::Pow($first.x-$second.x,2)+[Math]::Pow($first.y-$second.y,2)+[Math]::Pow($first.z-$second.z,2)) + Check "$Name-actual-id-contact-merge" ((($actual.balls.id|Sort-Object)-join',')-eq(($initial.balls.id|Sort-Object)-join',')-and(($actual.groups[0].ballIds|Sort-Object)-join',')-eq(($initial.balls.id|Sort-Object)-join',')-and$distance-ge.998-and$distance-le1.002-and(InBounds $actual 1)-and(Same $actual.camera $initial.camera)) "Actual exact two IDs, real3D contact distance=$distance, saved manual camera unchanged" + PreservesOtherSlots $Name $state $after 'free' 1;return + } + } + throw 'Actual deep friend native merge was not achieved within bounded live-screen steering; all PNG/input/3D drop attempts retained' +} +try { + Check 'clean-source' (!$result.sourceDirty) $result.sourceCommit + Check 'designated-avd-only' ($Serial-eq'emulator-5554'-and(Adb @('emu','avd','name'))-match'^Medium_Phone_API_37\.0') 'Sole authorized AVD' + $size=Adb @('shell','wm','size');Check 'phone-touch-physical-resolution' ($size-match'Physical size: 1080x2400'-and$size-notmatch'Override size:') $size + $build=Get-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw|ConvertFrom-Json + Check 'exact-final-build' ($build.status-eq'passed'-and$build.exitCode-eq0-and$build.sourceCommit-eq$result.sourceCommit-and$build.sourceCommitEnd-eq$result.sourceCommit-and!$build.sourceDirty-and!$build.sourceDirtyEnd-and$build.apkSha256-eq$result.apkSha256) $result.apkSha256 + $installed=(Adb @('shell','pm','path',$package)).Replace('package:','').Split("`n")[0].Trim();Adb @('pull',$installed,(Join-Path $EvidenceDir 'installed-base.apk'))|Out-Null + Check 'installed-exact-final-apk' ((Get-FileHash -LiteralPath (Join-Path $EvidenceDir 'installed-base.apk')).Hash-eq$result.apkSha256) $result.apkSha256 + $fixtures=Get-Content -LiteralPath $fixturePath -Raw|ConvertFrom-Json + Check 'same-source-actual-editor-fixtures' ($fixtures.sourceCommit-eq$result.sourceCommit-and$fixtures.sourceDirty-eq'False'-and$fixtures.stages.Count-eq30-and$fixtures.restores.Count-eq9-and$fixtures.drags.Count-eq15) 'Actual Main oracle from this final PlayMode source, native evaluation follows separately' + Copy-Item -LiteralPath $fixturePath -Destination (Join-Path $EvidenceDir 'native-fixtures.json') + $originalPath=Join-Path (Split-Path $EvidenceDir -Parent) 'Fresh30/thirty-completed.json' + $original=Get-Content -LiteralPath $originalPath -Raw|ConvertFrom-Json + Check 'actual-fresh30-origin' ($original.completedStageIds.Count-eq30-and(($original.completedStageIds|Sort-Object)-join',')-eq(1..30-join',')-and$original.latestFreePlay.sourceStageId-eq30) 'No completion injection in normal progression; boundary fixtures use its retained exact records' + foreach($fixture in $fixtures.restores){ + $source=if($fixture.slot-eq'free'){$fixture.freeSource}else{$fixture.puzzleSource};$expected=if($fixture.slot-eq'free'){$fixture.freeExpected}else{$fixture.puzzleExpected} + $state=FixtureState $source $fixture.slot $fixture.stage + $watch=[Diagnostics.Stopwatch]::StartNew();$after=LoadFixture $fixture.name $state $fixture.slot $fixture.stage;$watch.Stop() + $actual=Slot $after $fixture.slot $fixture.stage + Check "$($fixture.name)-source-stage-retained" ($(if($fixture.slot-eq'free'){$actual.sourceStageId}else{$actual.stageId})-eq$fixture.stage) 'Affected slot retains exact puzzle/source stage connection' + Check "$($fixture.name)-actual-player-result" ((SameBalls $expected $actual)-and(SameMembership $expected $actual)-and(InBounds $actual $fixture.stage)) 'Actual saved player result equals actual Unity oracle within .004 units; no ID or membership weakening' + if($fixture.slot-eq'puzzle'){Check "$($fixture.name)-count-collect-preserved-or-exact-reset" ((Same $expected.collectedBallIds $actual.collectedBallIds)-and(Same $expected.countedBallIds $actual.countedBallIds)) 'Expected position-only repair retains exact lists; impossible reset uses exact source initial lists'} + PreservesOtherSlots $fixture.name $state $after $fixture.slot $fixture.stage + if($fixture.name-eq'multiple-repairs'){ + Adb @('pull',($remote+'.bak'),(Join-Path $EvidenceDir 'multiple-repairs-backup.json'))|Out-Null + $backup=Get-Content -LiteralPath (Join-Path $EvidenceDir 'multiple-repairs-backup.json') -Raw|ConvertFrom-Json + Check 'multiple-repairs-one-disk-write' ((Same $state $backup)-and!(Same $state $after)) 'Two repaired groups: backup is injected original and primary is complete repaired record; second write would replace backup' + } + @{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' + } + foreach($fixture in $fixtures.drags){ + $state=FixtureState $fixture.source 'free' 1;$before=LoadFixture $fixture.name $state 'free' 1;$board=$before.latestFreePlay + Check "$($fixture.name)-whole-group-input" (Same $fixture.source $board) 'No entry correction of native drag source' + $after=HoldDrag $fixture.name $board $fixture.ballId ([int][Math]::Round($fixture.end.x)) ([int][Math]::Round($fixture.end.y)) 500 + Check "$($fixture.name)-actual-stop-all-ids" ((SameBalls $fixture.expected $after.latestFreePlay)-and(SameMembership $fixture.expected $after.latestFreePlay)-and(($board.balls.id|Sort-Object)-join',')-eq(($after.latestFreePlay.balls.id|Sort-Object)-join',')-and(InBounds $after.latestFreePlay 1)-and(Same $board.camera $after.latestFreePlay.camera)) 'Actual whole-ID player positions/membership equal expected boundary/foliage/blocked contact/safe2+3 result; saved manual camera unchanged' + PreservesOtherSlots $fixture.name $state $after 'free' 1 + } + $thin=@($fixtures.drags|Where-Object kind -eq 'thin-obstacle')[0] + $before=LoadFixture 'thin-fast-reverse' (FixtureState $thin.source 'free' 1) 'free' 1 + $after=HoldDrag 'thin-fast-reverse' $before.latestFreePlay $thin.ballId ([int][Math]::Round($thin.end.x)) ([int][Math]::Round($thin.end.y)) 300 -ReversePixels 80 + $stopped=@($thin.expected.balls|Where-Object id -eq $thin.ballId)[0].position;$reversed=@($after.latestFreePlay.balls|Where-Object id -eq $thin.ballId)[0].position + Check 'thin-fast-reverse-opposite-immediate-safe-drop' ($reversed.x-lt$stopped.x-.05-and(SameMembership $thin.source $after.latestFreePlay)-and(InBounds $after.latestFreePlay 1)-and(Same $thin.source.camera $after.latestFreePlay.camera)) 'Actual opposite move from blocked last-safe point reacts immediately and drops with both exact groups preserved' + $depth=Clone $fixtures.restores[0].freeSource;$depth.sourceStageId=1;$depth.camera=[pscustomobject]@{target=@{x=0;y=2.35;z=0};distance=14;rotation=@{x=0;y=0;z=0;w=1}} + $depth.balls=@(@{id='native-depth-front';position=@{x=-1;y=3;z=0}},@{id='native-depth-back';position=@{x=1;y=3;z=4}}) + $depth.groups=@(@{id='native-depth-front-group';ballIds=@('native-depth-front')},@{id='native-depth-back-group';ballIds=@('native-depth-back')}) + $short=$null + foreach($hold in @(150,2500)){ + $state=FixtureState $depth 'free' 1;$before=LoadFixture "stationary-$hold" $state 'free' 1;$point=BallPoint $before.latestFreePlay 'native-depth-front' + $after=HoldDrag "stationary-$hold" $before.latestFreePlay 'native-depth-front' ($point[0]+80) $point[1] $hold + Check "stationary-$hold-real-drag" (!(SameBalls $before.latestFreePlay $after.latestFreePlay)-and(SameMembership $depth $after.latestFreePlay)-and(InBounds $after.latestFreePlay 1)-and(Same $depth.camera $after.latestFreePlay.camera)) 'Actual initial pointer movement changes friend, hold/camera does not save automatic pose' + if($hold-eq150){$short=$after}else{Check 'stationary-short-long-identical-3d-drop' (SameBalls $short.latestFreePlay $after.latestFreePlay .002) 'Same initial input followed by short/long stationary hold: actual whole-ID 3D drop equal'} + } + $before=LoadFixture 'pause-cancel' (FixtureState $depth 'free' 1) 'free' 1;$point=BallPoint $before.latestFreePlay 'native-depth-front' + $after=HoldDrag 'pause-cancel' $before.latestFreePlay 'native-depth-front' ($point[0]+80) $point[1] 1200 -Cancel + Check 'pause-cancel-restores-board-manual-camera' (Same $before $after) 'Native HOME interrupts actual assisted drag, board and manual camera restored' + $depth.balls[0].position=@{x=-1.5;y=2;z=-1};$depth.balls[1].position=@{x=.5;y=2;z=2.2} + ApproachDepth 'actual-deep-contact' $depth + $required=@('six-scenes-ground-prop-feet-arch-hole','front-prop-occlusion-all-original-ids-search','stable-candidate-display-live-assist-and-visible-manual-return','six-scenes-variants-all-five-boundaries-free-source','phone-tablet-readable-ui-numbers-gaps','matched-baseline-native-presentation-memory-observation') + if(Test-Path -LiteralPath $observationPath){ + $observed=Get-Content -LiteralPath $observationPath -Raw|ConvertFrom-Json + Check 'actual-observation-provenance' ($observed.sourceCommit-eq$result.sourceCommit-and$observed.apkSha256-eq$result.apkSha256-and$observed.serial-eq$Serial-and$observed.status-eq'passed'-and$observed.startedUtc-and$observed.finishedUtc) 'Directly observed live camera/scene/UI and matched baseline measurements from this APK' + foreach($id in $required){$entry=@($observed.checks|Where-Object id -eq $id);if($entry.Count-ne1-or!$entry[0].passed-or!$entry[0].actual-or!$entry[0].command-or$entry[0].exitCode-ne0-or$entry[0].artifacts.Count-eq0){$pending.Add($id);continue} + foreach($artifact in $entry[0].artifacts){$path=Join-Path $projectRoot $artifact.path;Check "observed-$id-evidence-sha" ((Get-FileHash -LiteralPath $path).Hash-eq$artifact.sha256) $artifact.path} + Check "observed-$id" $true $entry[0].actual + } + }else{foreach($id in $required){$pending.Add($id)}} + Check 'r08-all-required-native-observations-evaluated' ($pending.Count-eq0) ($pending-join', ') + $result.sourceCommitEnd=(& git rev-parse HEAD);$result.sourceDirtyEnd=[bool](& git status --porcelain) + Check 'unchanged-source-end' (!$result.sourceDirtyEnd-and$result.sourceCommitEnd-eq$result.sourceCommit) $result.sourceCommitEnd + $result.status='passed' +}catch{$result.error=$_.Exception.Message;Write-Host $result.error -ForegroundColor Red} +finally { + if($original){try{Adb @('shell','am','force-stop',$package)|Out-Null;Adb @('push',$originalPath,$remote)|Out-Null;Adb @('push',$originalPath,($remote+'.bak'))|Out-Null;Check 'actual-fresh30-original-restored' (Same $original (Save 'original-progress-restored')) 'All original exact records restored after separate fixtures'}catch{$result.status='failed';$result.restoreError=$_.Exception.Message}} + $result.pending=$pending.ToArray();$result.skipped=@();$result.checks=$checks.ToArray();$result.commands=$commands.ToArray();$result.finishedUtc=[DateTime]::UtcNow.ToString('o') + $result|ConvertTo-Json -Depth 20|Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-playground-evidence.json') -Encoding utf8 +} +if($result.status-ne'passed'){exit 1};exit 0 diff --git a/tools/android/test-playground-contracts.ps1 b/tools/android/test-playground-contracts.ps1 new file mode 100644 index 0000000..1f3dfdb --- /dev/null +++ b/tools/android/test-playground-contracts.ps1 @@ -0,0 +1,91 @@ +param([Parameter(Mandatory)][string]$EvidenceDir,[string]$FinalSource=(Join-Path $PSScriptRoot 'exercise-final.ps1'),[string]$PlaygroundSource=(Join-Path $PSScriptRoot 'exercise-playground.ps1')) +$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','SameBalls','SameMembership','InBounds','FixtureState','Slot','BlueFriends')){ + $function=$gestureAst.Find({param($node)$node-is[Management.Automation.Language.FunctionDefinitionAst]-and$node.Name-eq$name},$true) + . ([scriptblock]::Create($function.Extent.Text)) + } + function Same($First,$Second){return ($First|ConvertTo-Json -Depth 12 -Compress)-eq($Second|ConvertTo-Json -Depth 12 -Compress)} + $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'} + } + } +}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 diff --git a/tools/verify.ps1 b/tools/verify.ps1 index 5d4c284..e7ae258 100644 --- a/tools/verify.ps1 +++ b/tools/verify.ps1 @@ -19,6 +19,8 @@ try { $record.packages = (Get-Content 'Packages/manifest.json' -Raw | ConvertFrom-Json).dependencies $apkPath = Join-Path $projectRoot "artifacts/android/$Milestone/MagnetismHeroes.apk" if ($Phase -in @('EditMode','PlayMode')) { + $env:MAGNETISM_PROOF_COMMIT = $record.sourceCommit + $env:MAGNETISM_PROOF_DIRTY = [string]$record.sourceDirty $xmlPath = Join-Path $evidenceDir "$phaseName.xml" if (Test-Path -LiteralPath $xmlPath) { Remove-Item -LiteralPath $xmlPath } $cliArgs = @('test', $projectRoot, '--mode', $Phase, '--output', $xmlPath, '--timeout', '900', '--', '-logFile', $logPath)