#if UNITY_EDITOR using System; using System.Collections; using System.IO; using System.Linq; using MagnetismHeroes.Core; using MagnetismHeroes.UnityRuntime.Bootstrap; using MagnetismHeroes.UnityRuntime.Presentation; using NUnit.Framework; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.Profiling; using UnityEngine.SceneManagement; using UnityEngine.TestTools; using Object = UnityEngine.Object; namespace MagnetismHeroes.Tests { public sealed class VisualUpgradePlayTests { private sealed class Store : IProgressStore { public ProgressSave Saved = new ProgressSave { completedStageIds = Enumerable.Range(1, 30).ToArray(), seenTutorialIds = new[] { "merge", "split" } }; public bool TryLoad(out ProgressSave save, out string error) { save = Saved; error = null; return true; } public bool TrySave(ProgressSave save, out string error) { Saved = save; error = null; return true; } } [Serializable] private sealed class CaptureRecord { public string name, sourceCommit, sourceDirty, boardOrigin, theme, utc, method; public int width, height, stage, balls, groups, equipmentAssemblies, meshInstances, triangles, sharedMaterials; public long textureMemory, allocatedMemory; public float editorCameraRenderMilliseconds, fieldOfView; public Vector3 cameraPosition; 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, grabOffset, pointerDelta; public string firstHitId; 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() { EditorSceneManager.LoadSceneInPlayMode("Assets/MagnetismHeroes/Scenes/Main.unity", new LoadSceneParameters(LoadSceneMode.Single)); yield return null; _app = Object.FindFirstObjectByType(); Assert.That(_app, Is.Not.Null, "Actual Main scene must initialize its app."); _store = new Store(); _app.Initialize(_store); _app.Presenter.BindEnvironment(_app.Environment); _app.Orbit.BindEnvironment(_app.Environment); _evidence = Environment.GetEnvironmentVariable("MAGNETISM_VISUAL_EVIDENCE") ?? Path.GetFullPath("artifacts/verification/VisualUpgrade/editor-play"); Directory.CreateDirectory(_evidence); yield return null; } [UnityTearDown] public IEnumerator TearDown() { Object.Destroy(_app.gameObject); yield return null; } private Vector2 NativePoint(Vector3 world) { var viewport = _app.Orbit.Camera.WorldToViewportPoint(world); return new Vector2((float)Math.Round(viewport.x * 2400), (float)Math.Round((1 - viewport.y) * 1080)); } private Vector2 PlayerPoint(Vector2 native) => _app.Orbit.Camera.ViewportToScreenPoint(new Vector3(native.x / 2400, 1 - native.y / 1080)); private NativeDrag ExerciseNativeDrag(string name, string kind, FreePlayState source, Vector3 requested) { var id = source.balls[0].id; var origin = BoardPresenter.ToVector(source.balls[0].position); Assert.That(JsonUtility.ToJson(_app.Session.Camera), Is.EqualTo(JsonUtility.ToJson(source.camera)), "Exported camera must be the actual confirmed manual pose."); Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(JsonUtility.ToJson(source.camera))); Assert.That(JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera)), Is.EqualTo(JsonUtility.ToJson(source)), "Entry must preserve every fixture ID, position and group."); var start = NativePoint(origin); Physics.SyncTransforms(); var ray = _app.Orbit.Camera.ScreenPointToRay(PlayerPoint(start)); var nativeRay = _app.Orbit.Camera.ViewportPointToRay(new Vector3(start.x / 2400, 1 - start.y / 1080)); Assert.That(Vector3.Distance(ray.origin, nativeRay.origin), Is.LessThan(.00001f)); Assert.That(Vector3.Distance(ray.direction, nativeRay.direction), Is.LessThan(.00001f), "Editor pixelRect must preserve the same native viewport ray."); Assert.That(Physics.Raycast(ray, out var hit, 100), Is.True); Assert.That(hit.collider.TryGetComponent(out var ball) ? ball.BallId : hit.collider.name, Is.EqualTo(id), name + " actual first hit must select the requested sphere."); var grabOffset = hit.point - origin; var end = NativePoint(origin + requested + grabOffset); foreach (var point in new[] { start, end }) Assert.That(point.x >= 530 && point.x <= 1850 && point.y >= 280 && point.y <= 750, Is.True, name + " integer native pointer must stay in the phone world UI-safe region."); var plane = new Plane(_app.Orbit.Camera.transform.forward, hit.point); var endRay = _app.Orbit.Camera.ScreenPointToRay(PlayerPoint(end)); Assert.That(plane.Raycast(ray, out var from), Is.True); Assert.That(plane.Raycast(endRay, out var to), Is.True); var pointerDelta = endRay.GetPoint(to) - ray.GetPoint(from); var saved = JsonUtility.ToJson(_store.Saved); _app.Input.Begin(PlayerPoint(start), false); _app.Input.Move(PlayerPoint(end)); Assert.That(_app.Orbit.IsAssisting, Is.True, "Actual BoardInput must acquire and move this friend."); Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved), "Preview and assistance cannot save intermediate state."); var actual = _app.Presenter.DropDelta; var merge = _app.Presenter.MergeCandidate; if (kind == "boundary") Assert.That(actual.magnitude, Is.InRange(1.19f, 1.21f), "Actual pointer must stop at the .5 sphere margin."); if (kind == "foliage-pass") { Assert.That(Vector3.Distance(actual, pointerDelta), Is.LessThan(.001f), "The entire actual integer-pointer movement through foliage must pass without clipping."); Assert.That(actual.x, Is.GreaterThan(3.9f)); } 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")); var preview = _app.Presenter.Balls.ToDictionary(pair => pair.Key, pair => pair.Value.transform.position); var members = source.groups.Single(group => group.ballIds.Contains(id)).ballIds; foreach (var value in source.balls) Assert.That(Vector3.Distance(preview[value.id], BoardPresenter.ToVector(value.position) + (members.Contains(value.id) ? actual : Vector3.zero)), Is.LessThan(.001f), "Only the whole grabbed group may translate; all relative offsets and other friends are preserved."); _app.Input.End(PlayerPoint(end)); Assert.That(_app.Session.Board.Balls.Select(value => value.id), Is.EquivalentTo(source.balls.Select(value => value.id))); foreach (var value in _app.Session.Board.Balls) Assert.That(Vector3.Distance(BoardPresenter.ToVector(value.position), preview[value.id]), Is.LessThan(.001f), "Drop must equal the actual input preview for every ID."); Assert.That(JsonUtility.ToJson(_app.Session.Camera), Is.EqualTo(JsonUtility.ToJson(source.camera))); if (kind != "two-plus-three") Assert.That(_app.Session.Board.Groups.Select(group => JsonUtility.ToJson(group)), Is.EqualTo(source.groups.Select(group => JsonUtility.ToJson(group)))); else Assert.That(_app.Session.Board.Groups.Single().ballIds, Is.EquivalentTo(source.balls.Select(value => value.id))); return new NativeDrag { name = name, kind = kind, ballId = id, source = source, requested = requested, start = start, end = end, grabOffset = grabOffset, pointerDelta = pointerDelta, firstHitId = ball.BallId, expected = _app.Session.Board.ToFreePlayState(_app.Orbit.Snapshot()) }; } [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; drags.Add(ExerciseNativeDrag("boundary-" + count + "-" + axis + "-" + sign, "boundary", source, requested)); yield return null; } _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; if (kind == "thin-obstacle") { bool visible = false; foreach (float pitch in new[] { 0f, 15f, 30f, 45f, 60f }) { var rotation = Quaternion.Euler(pitch, 0, 0); camera.rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w); _app.Orbit.Apply(camera); Physics.SyncTransforms(); var ray = _app.Orbit.Camera.ScreenPointToRay(PlayerPoint(NativePoint(sourcePoint))); if (!Physics.Raycast(ray, out var hit, 100) || !hit.collider.TryGetComponent(out var ball) || ball.BallId != source.balls[0].id) continue; Assert.That(Quaternion.Angle(_app.Orbit.Camera.transform.rotation, rotation), Is.LessThan(.001f), "Chosen pitch must already be a valid manual pose."); source.camera = _app.Orbit.Snapshot(); _app.Session.CommitCamera(source.camera); visible = true; break; } Assert.That(visible, Is.True, "The same thin-arch source must have an actually selectable supported pitch."); } Capture(kind + "-native-pointer-source", "Actual Editor-selected first hit and preserved source; integer native pointer oracle, not Android pass", 2400, 1080); drags.Add(ExerciseNativeDrag(kind, kind, source, requested)); yield return null; } 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"); var badges = _app.Presenter.GetComponentsInChildren(); Assert.That(badges.Length, Is.EqualTo(board.Groups.Length)); Assert.That(_app.Presenter.Balls.Keys, Is.EquivalentTo(board.Balls.Select(ball => ball.id))); Assert.That(board.Groups.SelectMany(group => group.ballIds), Is.EquivalentTo(board.Balls.Select(ball => ball.id))); Assert.That(_app.Presenter.GetComponentsInChildren().Length, Is.EqualTo(board.Groups.Length)); Assert.That(_app.Presenter.GetComponentsInChildren().Select(label => label.text), Is.EqualTo(board.Groups.Select(group => group.ballIds.Length.ToString()))); for (int index = 0; index < badges.Length; index++) { var gear = badges[index].transform.Find("KnightEquipment"); Assert.That(gear, Is.Not.Null); var representative = board.Groups[index].ballIds.Select(id => _app.Presenter.Balls[id].transform.position) .OrderBy(point => Vector3.Dot(point - _app.Presenter.Camera.transform.position, _app.Presenter.Camera.transform.forward)).First(); Assert.That(Vector3.Distance(gear.position, representative), Is.LessThan(.0001f)); Assert.That(Quaternion.Angle(gear.rotation, _app.Presenter.Camera.transform.rotation), Is.LessThan(.001f)); Assert.That(gear.GetComponentsInChildren(), Is.Empty); Assert.That(badges[index].transform.Find("RibbonCape"), Is.Not.Null); foreach (string id in board.Groups[index].ballIds) Assert.That(_app.Presenter.Balls[id].GroupId, Is.EqualTo(board.Groups[index].id)); } } private void CaptureViews(string name, string origin) { var saved = _app.Orbit.Snapshot(); Capture(name + "-phone-default", origin, 2400, 1080); Capture(name + "-tablet-default", origin, 1600, 1000); var rotation = Quaternion.Euler(25, 95, 0); _app.Orbit.Apply(new CameraState { target = saved.target, distance = 9, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) }); Capture(name + "-phone-orbitzoom", origin, 2400, 1080); Capture(name + "-tablet-orbitzoom", origin, 1600, 1000); _app.Orbit.Apply(saved); } [UnityTest] public IEnumerator DistantTerrainHasContinuousApronAcrossItsVisibleFootInsteadOfSkyGaps() { var report = new System.Collections.Generic.List(); foreach (int stage in new[] { 1, 11, 21 }) { _app.StartStage(stage); _app.Voice.Clear(); yield return null; CaptureViews("horizon-world" + stage, "Actual Main board, unchanged default/orbitzoom cameras; terrain continuity regression"); var horizon = _app.Environment.GetComponentsInChildren().Where(item => item.name == "FarHorizon(Clone)").ToArray(); var originalPositions = horizon.Select(item => item.position).ToArray(); var savedCamera = _app.Orbit.Snapshot(); var closeRotation = Quaternion.Euler(25, 95, 0); _app.Orbit.Apply(new CameraState { target = savedCamera.target, distance = 9, rotation = new Rotation4(closeRotation.x, closeRotation.y, closeRotation.z, closeRotation.w) }); var camera = _app.Presenter.Camera; float fieldOfView = camera.fieldOfView; try { // Real camera renders of the reproduced (+X, z12) foot gap; no screenshot cropping/editing. camera.fieldOfView = 14; camera.transform.LookAt(new Vector3(28, -.45f, 12)); Capture("horizon-world" + stage + "-foot-current", "Actual Main terrain foot; temporary narrow camera FOV only", 2400, 1080); foreach (var far in horizon) { var direction = new Vector3(far.position.x, 0, far.position.z).normalized; far.position = new Vector3(direction.x * 35, far.position.y, direction.z * 35); } Capture("horizon-world" + stage + "-foot-legacy-radius35", "Same actual Main board, materials and close camera; temporary previous radius35 placement control", 2400, 1080); } finally { for (int index = 0; index < horizon.Length; index++) horizon[index].position = originalPositions[index]; camera.fieldOfView = fieldOfView; _app.Orbit.Apply(savedCamera); } string board = JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Orbit.Snapshot())); var collisionBefore = _app.GetComponentsInChildren(); var probeScene = SceneManager.CreateScene("TerrainGeometryProbe", new CreateSceneParameters(LocalPhysicsMode.Physics3D)); var probes = new System.Collections.Generic.List(); try { var terrain = _app.Environment.GetComponentsInChildren().Where(item => item.name == "MeadowClearing(Clone)" || item.name == "GentleHill(Clone)" || item.name == "DistantRidge(Clone)" || item.name == "FarHorizon(Clone)").ToArray(); foreach (var surface in terrain.SelectMany(item => item.GetComponentsInChildren())) { var probe = new GameObject("GeometryOnly-" + surface.name); SceneManager.MoveGameObjectToScene(probe, probeScene); probe.transform.SetPositionAndRotation(surface.transform.position, surface.transform.rotation); probe.transform.localScale = surface.transform.lossyScale; probes.Add(probe.AddComponent()); probes.Last().sharedMesh = surface.sharedMesh; report.Add($"stage={stage}; terrain={surface.name}; actualRendererBounds={surface.GetComponent().bounds}; meshBounds={surface.sharedMesh.bounds}; rootPosition={surface.transform.position}"); } probeScene.GetPhysicsScene().Simulate(.001f); int missing = 0; foreach (var far in terrain.Where(item => item.name == "FarHorizon(Clone)")) { var direction = new Vector3(far.position.x, 0, far.position.z).normalized; var right = Vector3.Cross(Vector3.up, direction); float distance = new Vector2(far.position.x, far.position.z).magnitude; // Traverse the apron to the far mound crown across its central 24m foot. // A positive gap is real missing geometry, independent of lighting/material colors. for (float lateral = -12; lateral <= 12; lateral += 2) for (float radial = 0; radial <= distance; radial += .25f) { var point = direction * radial + right * lateral; var ray = new Ray(point + Vector3.up * 20, Vector3.down); if (probes.Any(probe => probe.Raycast(ray, out _, 40))) continue; missing++; if (missing <= 24) report.Add($"stage={stage}; unsupportedApron={point}; farCrown={far.position}"); } } report.Add($"stage={stage}; missingApronSamples={missing}; probe=separate local PhysicsScene using actual shared mesh geometry, no product collider edits"); File.WriteAllLines(Path.Combine(_evidence, "horizon-geometry.txt"), report); Assert.That(missing, Is.Zero, "Far terrain foot must join the actual clearing/hills/ridge without open geometry gaps."); } finally { SceneManager.UnloadSceneAsync(probeScene); } yield return null; Assert.That(_app.GetComponentsInChildren(), Is.EqualTo(collisionBefore)); Assert.That(JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Orbit.Snapshot())), Is.EqualTo(board)); } } [UnityTest] public IEnumerator FiveBallMergeDisconnectedSplitAndZeroResupplyKeepActualIdsAcrossAllWorlds() { foreach (int stage in new[] { 9, 11, 21 }) { // Boundary fixture: a completed-stage store with a validated five-ball free board, // not evidence of normal puzzle progression. All operations below use the real app/session. var balls = Enumerable.Range(0, 5).Select(index => new BallState { id = "five-" + index, position = new Position3(index - 2, .8f, 0) }).ToArray(); _store.Saved.latestFreePlay = new FreePlayState { sourceStageId = stage, camera = _app.Orbit.Snapshot(), balls = balls, groups = new[] { new GroupState { id = "two", ballIds = balls.Take(2).Select(ball => ball.id).ToArray() }, new GroupState { id = "three", ballIds = balls.Skip(2).Select(ball => ball.id).ToArray() } } }; _app.Initialize(_store); _app.ContinueFreePlay(); _app.Voice.Clear(); yield return null; var ids = _app.Session.Board.Balls.Select(ball => ball.id).ToArray(); AssertEquipmentFollowsRepresentatives(_app.Session.Board); CaptureViews("world" + stage + "-two-plus-three", "Validated five-ball 2+3 free-play store fixture; actual Main app/session"); var before = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Orbit.Snapshot())); _app.Presenter.PreviewGroup("two", new Vector3(.3f, .2f, 0)); AssertEquipmentFollowsRepresentatives(_app.Session.Board); _app.Presenter.CancelPreview(); Assert.That(JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Orbit.Snapshot())), Is.EqualTo(before)); _app.CommitMove("two", new Position3(.1f, 0, 0), "three"); yield return null; Assert.That(_app.Session.Board.Groups.Single().ballIds.Length, Is.EqualTo(5)); Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EquivalentTo(ids)); AssertEquipmentFollowsRepresentatives(_app.Session.Board); CaptureViews("world" + stage + "-merged-five", "Actual app/session 2+3 merge from labeled boundary fixture"); var selected = new[] { ids[0], ids[4] }; var points = selected.Select(id => BoardPresenter.ToVector(_app.Session.Board.Balls.Single(ball => ball.id == id).position)).ToArray(); Assert.That(Vector3.Distance(points[0], points[1]), Is.GreaterThan(2f), "Selected endpoints are disconnected."); var merged = _app.Session.Board.Groups.Single(); before = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Orbit.Snapshot())); Assert.That(_app.Presenter.PreviewSplit(merged.id, selected, new Vector3(0, 1.5f, 0)), Is.True); AssertEquipmentFollowsRepresentatives(_app.Session.Board); _app.Presenter.CancelPreview(); Assert.That(JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Orbit.Snapshot())), Is.EqualTo(before)); Assert.That(_app.CommitSplit(merged.id, selected, new Position3(0, 1.5f, 0)), Is.True); yield return null; Assert.That(_app.Session.Board.Groups.Select(group => group.ballIds.Length), Is.EquivalentTo(new[] { 2, 3 })); Assert.That(_app.Session.Board.Groups.Single(group => group.ballIds.Length == 2).ballIds, Is.EquivalentTo(selected)); Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EquivalentTo(ids)); AssertEquipmentFollowsRepresentatives(_app.Session.Board); CaptureViews("world" + stage + "-disconnected-split-two-three", "Actual app/session split of disconnected five-group endpoints"); before = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Orbit.Snapshot())); _app.ShowHome(); yield return null; Assert.That(_app.Presenter.GetComponentsInChildren(), Is.Empty); _app.ContinueFreePlay(); yield return null; Assert.That(JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Orbit.Snapshot())), Is.EqualTo(before)); AssertEquipmentFollowsRepresentatives(_app.Session.Board); foreach (var group in _app.Session.Board.Groups) Assert.That(_app.Session.ReturnGroup(group.id).Accepted, Is.True); _app.ContinueFreePlay(); yield return null; Assert.That(_app.Session.Board.BallCount, Is.Zero); AssertEquipmentFollowsRepresentatives(_app.Session.Board); CaptureViews("world" + stage + "-zero", "Actual app/session return of both groups; empty free play"); _app.SpawnFriend(); yield return null; Assert.That(_app.Session.Board.BallCount, Is.EqualTo(1)); AssertEquipmentFollowsRepresentatives(_app.Session.Board); CaptureViews("world" + stage + "-single-resupplied", "Actual app/session resupply after empty board"); } } [UnityTest] public IEnumerator UndergroundViewIsCorrectedAboveGroundWithoutChangingActualBoard() { _app.StartStage(21); _app.Voice.Clear(); yield return null; var balls = _app.Session.Board.Balls.Select(ball => (ball.id, ball.position)).ToArray(); var groups = _app.Session.Board.Groups.Select(group => (group.id, ids: group.ballIds.ToArray())).ToArray(); var rotation = Quaternion.Euler(-45, 210, 0); _app.Orbit.Apply(new CameraState { target = _app.Orbit.Snapshot().target, distance = 15, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) }); yield return null; Physics.SyncTransforms(); Assert.That(_app.Presenter.Camera.transform.position.y, Is.GreaterThan(_app.Environment.PlaygroundBounds.min.y)); Assert.That(_app.Session.Board.Balls.Select(ball => (ball.id, ball.position)), Is.EqualTo(balls)); Assert.That(_app.Session.Board.Groups.Select(group => group.id), Is.EqualTo(groups.Select(group => group.id))); for (int index = 0; index < groups.Length; index++) Assert.That(_app.Session.Board.Groups[index].ballIds, Is.EqualTo(groups[index].ids)); Capture("corrected-underground-view", "Actual stage21; legacy below-ground saved view corrected above clearing; board IDs, groups and positions unchanged"); } [UnityTest] public IEnumerator AllWorldsProduceActualSoftShadowPixelsWithUnchangedBoardAndMaterials() { var sun = _app.transform.Find("Sun").GetComponent(); _app.ShowHome(); yield return null; Capture("home-cyan-readiness", "Actual Main home UI for Android sky readiness predicate", 2400, 1080); foreach (int stage in new[] { 1, 11, 21 }) { _app.StartStage(stage); _app.Voice.Clear(); yield return null; string before = JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Orbit.Snapshot())); Capture("world" + stage + "-soft-shadow", "Actual Main daylight with soft shadows"); try { sun.shadows = LightShadows.None; Capture("world" + stage + "-shadow-off", "Same board/camera/materials; light shadow toggle comparison only"); } finally { sun.shadows = LightShadows.Soft; } var on = new Texture2D(2, 2); var off = new Texture2D(2, 2); try { on.LoadImage(File.ReadAllBytes(Path.Combine(_evidence, "world" + stage + "-soft-shadow.png"))); off.LoadImage(File.ReadAllBytes(Path.Combine(_evidence, "world" + stage + "-shadow-off.png"))); var a = on.GetPixels32(); var b = off.GetPixels32(); int darker = 0; // The central lower ground excludes the board, badge labels, scenery crowns and HUD. for (int y = 140; y < 300; y++) for (int x = 350; x < 1000; x++) { int index = y * on.width + x; if (a[index].r + a[index].g + a[index].b + 12 < b[index].r + b[index].g + b[index].b) darker++; } File.WriteAllText(Path.Combine(_evidence, "world" + stage + "-shadow-pixels.txt"), $"same-board/camera/materials; shadow-on darker ground ROI pixels={darker}; groundROI=350,140,650,160; viewport=1280x720; Editor RT only"); Assert.That(darker, Is.GreaterThan(100), "Actual rendered soft shadows must darken a meaningful ground area."); } finally { Object.Destroy(on); Object.Destroy(off); } Assert.That(JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Orbit.Snapshot())), Is.EqualTo(before)); } } [UnityTest] public IEnumerator ActualMainShowsThreeThemesAndEquipmentFollowsPreviewCancelMergeSplitAndReentry() { foreach (int stage in new[] { 1, 11, 21 }) { _app.StartStage(stage); _app.Voice.Clear(); yield return null; AssertEquipmentFollowsRepresentatives(_app.Session.Board); Capture("stage" + stage.ToString("D2"), "Actual Main GameApp.Session.Board; unlocked-stage fixture store"); } var normalView = _app.Orbit.Snapshot(); foreach (var view in new[] { (name: "orbit95-pitch45-distance6", yaw: 95f, pitch: 45f, distance: 6f), (name: "orbit210-pitch25-distance15", yaw: 210f, pitch: 25f, distance: 15f) }) { var rotation = Quaternion.Euler(view.pitch, view.yaw, 0); _app.Orbit.Apply(new CameraState { target = normalView.target, distance = view.distance, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) }); yield return null; AssertEquipmentFollowsRepresentatives(_app.Session.Board); Capture(view.name, "Actual Main session; camera-only orbit/pitch/distance exploration fixture"); } _app.Orbit.Apply(normalView); _app.StartStage(6); _app.Voice.Clear(); yield return null; var original = _app.Session.Board.Balls.Select(ball => ball.position).ToArray(); var groups = _app.Session.Board.Groups; _app.Presenter.PreviewGroup(groups[0].id, new Vector3(.3f, .2f, 0)); AssertEquipmentFollowsRepresentatives(_app.Session.Board); _app.Presenter.CancelPreview(); AssertEquipmentFollowsRepresentatives(_app.Session.Board); for (int index = 0; index < original.Length; index++) Assert.That(_app.Presenter.Balls[_app.Session.Board.Balls[index].id].transform.position, Is.EqualTo(BoardPresenter.ToVector(original[index]))); _app.Presenter.ShowSelection(new[] { groups[0].ballIds[0] }); yield return null; Capture("selected-two-groups", "Actual Main session, visual selection only"); _app.CommitMove(groups[0].id, new Position3(.1f, 0, 0), groups[1].id); yield return null; Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(1)); AssertEquipmentFollowsRepresentatives(_app.Session.Board); Capture("merged-single-group", "Actual Main session merge -> free play"); var merged = _app.Session.Board.Groups[0]; Assert.That(_app.CommitSplit(merged.id, new[] { merged.ballIds[0] }, new Position3(1.5f, 0, 0)), Is.True); yield return null; Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(2)); AssertEquipmentFollowsRepresentatives(_app.Session.Board); _app.ShowHome(); _app.ContinueFreePlay(); yield return null; Assert.That(_app.Session.Board.BallCount, Is.EqualTo(2)); Assert.That(_app.Environment.Theme, Is.EqualTo("Garden")); AssertEquipmentFollowsRepresentatives(_app.Session.Board); Capture("split-reentered-two-groups", "Actual Main session free play split and home/reentry"); } [UnityTest] public IEnumerator TenSingleGroupsAndOneTenBallGroupRenderActualImportedResourceCounts() { var balls = Enumerable.Range(0, 10).Select(index => new BallState { id = "render-" + index, position = new Position3((index % 5 - 2) * 1.6f, .8f + index / 5 * 1.8f, 0) }).ToArray(); foreach (int stage in new[] { 1, 11, 21 }) { _app.StartStage(stage); _app.Voice.Clear(); yield return null; foreach (bool singles in new[] { true, false }) { var groups = singles ? balls.Select(ball => new GroupState { id = ball.id, ballIds = new[] { ball.id } }).ToArray() : new[] { new GroupState { id = "ten", ballIds = balls.Select(ball => ball.id).ToArray() } }; var board = BoardRules.CreateFreePlay(new FreePlayState { sourceStageId = stage, balls = balls, groups = groups }); _app.Presenter.Render(board); yield return null; AssertEquipmentFollowsRepresentatives(board); CaptureViews("world" + stage + (singles ? "-ten-single-groups" : "-ten-one-group"), "Validated synthetic Core FreePlay render fixture; session UI remains underlying stage " + stage); } } } private void Capture(string name, string origin, int width = 1280, int height = 720) { var camera = _app.Presenter.Camera; var canvas = _app.GetComponentInChildren(); var oldTarget = camera.targetTexture; var oldActive = RenderTexture.active; var oldMode = canvas.renderMode; var oldCamera = canvas.worldCamera; float oldPlane = canvas.planeDistance, oldAspect = camera.aspect; var target = new RenderTexture(width, height, 24); var image = new Texture2D(width, height, TextureFormat.RGB24, false); try { camera.targetTexture = target; camera.aspect = (float)width / height; canvas.renderMode = RenderMode.ScreenSpaceCamera; canvas.worldCamera = camera; canvas.planeDistance = .5f; foreach (var ball in _app.Presenter.Balls.Values) ball.SendMessage("Update"); if (_app.Presenter.gameObject.activeInHierarchy) _app.Presenter.SendMessage("LateUpdate"); Canvas.ForceUpdateCanvases(); var timer = System.Diagnostics.Stopwatch.StartNew(); camera.Render(); timer.Stop(); RenderTexture.active = target; image.ReadPixels(new Rect(0, 0, width, height), 0, 0); image.Apply(); File.WriteAllBytes(Path.Combine(_evidence, name + ".png"), image.EncodeToPNG()); var meshes = _app.GetComponentsInChildren(); var badges = _app.Presenter.GetComponentsInChildren(); var record = new CaptureRecord { name = name, sourceCommit = Environment.GetEnvironmentVariable("MAGNETISM_PROOF_COMMIT"), sourceDirty = Environment.GetEnvironmentVariable("MAGNETISM_PROOF_DIRTY"), utc = DateTime.UtcNow.ToString("o"), boardOrigin = origin, theme = _app.Environment.Theme, stage = _app.Session.Board?.StageId ?? 0, width = width, height = height, balls = _app.Presenter.Balls.Count, groups = badges.Length, equipmentAssemblies = badges.Count(item => item.transform.Find("KnightEquipment") != null), meshInstances = meshes.Length, triangles = meshes.Sum(item => Enumerable.Range(0, item.sharedMesh.subMeshCount).Sum(submesh => (int)item.sharedMesh.GetIndexCount(submesh) / 3)), sharedMaterials = _app.GetComponentsInChildren().SelectMany(item => item.sharedMaterials).Where(item => item != null).Distinct().Count(), editorCameraRenderMilliseconds = (float)timer.Elapsed.TotalMilliseconds, fieldOfView = camera.fieldOfView, textureMemory = Resources.FindObjectsOfTypeAll().Sum(item => Profiler.GetRuntimeMemorySizeLong(item)), allocatedMemory = Profiler.GetTotalAllocatedMemoryLong(), cameraPosition = camera.transform.position, cameraRotation = camera.transform.rotation, method = "Actual Main scene PlayMode Camera.Render to RT, ScreenSpaceCamera canvas; simulated viewport, not Android capture. Triangles are resident index counts for active mesh instances, including the retained floor with its Renderer disabled; they are not submitted GPU triangles. Camera.Render elapsed is Editor CPU wall time, not GPU time or device FPS. Texture memory includes all Editor-loaded textures." }; File.WriteAllText(Path.Combine(_evidence, name + ".json"), JsonUtility.ToJson(record, true)); if (name == "world21-ten-single-groups-phone-default") File.WriteAllLines(Path.Combine(_evidence, "editor-loaded-textures.txt"), Resources.FindObjectsOfTypeAll() .Select(item => $"{AssetDatabase.GetAssetPath(item)};name={item.name};dimensions={item.width}x{item.height};format={(item is Texture2D texture ? texture.format.ToString() : item.GetType().Name)};runtimeBytes={Profiler.GetRuntimeMemorySizeLong(item)}")); } finally { camera.targetTexture = oldTarget; camera.aspect = oldAspect; RenderTexture.active = oldActive; canvas.renderMode = oldMode; canvas.worldCamera = oldCamera; canvas.planeDistance = oldPlane; Object.DestroyImmediate(image); target.Release(); Object.DestroyImmediate(target); if (_app.Presenter.gameObject.activeInHierarchy) _app.Presenter.SendMessage("LateUpdate"); Canvas.ForceUpdateCanvases(); } } } } #endif