test: verify playground native boundaries and preserve final evidence gates

This commit is contained in:
NINI
2026-10-10 05:23:52 +09:00
parent 14f89d1248
commit 401fa6a1a6
6 changed files with 585 additions and 2 deletions
@@ -35,7 +35,95 @@ namespace MagnetismHeroes.Tests
public Quaternion cameraRotation; public Quaternion cameraRotation;
} }
private GameApp _app; 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; 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<BenchmarkSceneryScan>();
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<MeshFilter>();
scans.Add(new BenchmarkSceneryScan { stage = stage,
activeProps = _app.Environment.transform.Cast<Transform>().Count(child => child.gameObject.activeInHierarchy && child.name != "PlacementSafetySphere"),
renderers = _app.Environment.GetComponentsInChildren<Renderer>().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<Canvas>();
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<Transform>().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<Texture>().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; private string _evidence;
[UnitySetUp] public IEnumerator SetUp() [UnitySetUp] public IEnumerator SetUp()
{ {
@@ -51,6 +139,165 @@ namespace MagnetismHeroes.Tests
} }
[UnityTearDown] public IEnumerator TearDown() { Object.Destroy(_app.gameObject); yield return null; } [UnityTearDown] public IEnumerator TearDown() { Object.Destroy(_app.gameObject); yield return null; }
[UnityTest] public IEnumerator ExportActualMainPlaygroundNativeBoundaryFixtures()
{
var stages = new System.Collections.Generic.List<NativeStage>();
var restores = new System.Collections.Generic.List<NativeRestore>();
var drags = new System.Collections.Generic.List<NativeDrag>();
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<MeshCollider>().Where(value => value.enabled && value.transform.GetComponentsInParent<Transform>().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<FreePlayState>(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<PuzzleState>(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<PuzzleState>();
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<MeshCollider>().First(value => value.enabled && value.GetComponentsInParent<Transform>().Any(parent => parent.name.Contains("OpenStoneArch")));
var foliage = _app.Environment.transform.Cast<Transform>().Where(value => value.gameObject.activeInHierarchy && value.name.Contains("GrassTuftMint") && value.GetComponentsInChildren<Collider>().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<Renderer>().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<BallState> { new BallState { id = "native-contact-a", position = BoardPresenter.ToPosition(sourcePoint) } };
var groups = new System.Collections.Generic.List<GroupState> { 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<GroupState> { 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) private void AssertEquipmentFollowsRepresentatives(BoardRules board)
{ {
_app.Presenter.SendMessage("LateUpdate"); _app.Presenter.SendMessage("LateUpdate");
@@ -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
+2 -2
View File
@@ -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 Check "apk-notice-exact-bytes-$name" ($sha-eq(Get-FileHash -LiteralPath (Join-Path $projectRoot ('Assets/StreamingAssets/Notices/'+$name))).Hash) $sha
} }
}finally{$zip.Dispose()} }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 $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') 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" $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 & 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 $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}) $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'){ 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'} 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' Check 'fresh30-native-commands-no-fixture-write' (@($child.commands|Where-Object {$_.command-match'\bpush\b'}).Count-eq0) 'Native progression never pushes save data'
+199
View File
@@ -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
@@ -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
+2
View File
@@ -19,6 +19,8 @@ try {
$record.packages = (Get-Content 'Packages/manifest.json' -Raw | ConvertFrom-Json).dependencies $record.packages = (Get-Content 'Packages/manifest.json' -Raw | ConvertFrom-Json).dependencies
$apkPath = Join-Path $projectRoot "artifacts/android/$Milestone/MagnetismHeroes.apk" $apkPath = Join-Path $projectRoot "artifacts/android/$Milestone/MagnetismHeroes.apk"
if ($Phase -in @('EditMode','PlayMode')) { if ($Phase -in @('EditMode','PlayMode')) {
$env:MAGNETISM_PROOF_COMMIT = $record.sourceCommit
$env:MAGNETISM_PROOF_DIRTY = [string]$record.sourceDirty
$xmlPath = Join-Path $evidenceDir "$phaseName.xml" $xmlPath = Join-Path $evidenceDir "$phaseName.xml"
if (Test-Path -LiteralPath $xmlPath) { Remove-Item -LiteralPath $xmlPath } if (Test-Path -LiteralPath $xmlPath) { Remove-Item -LiteralPath $xmlPath }
$cliArgs = @('test', $projectRoot, '--mode', $Phase, '--output', $xmlPath, '--timeout', '900', '--', '-logFile', $logPath) $cliArgs = @('test', $projectRoot, '--mode', $Phase, '--output', $xmlPath, '--timeout', '900', '--', '-logFile', $logPath)