using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Reflection; using System.Text; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.InputSystem.LowLevel; using UnityEngine.Profiling; using UnityEngine.Rendering; namespace Ironspore { // Opt-in player QA. Normal launches never instantiate this component. public sealed class LightingValidation : MonoBehaviour { const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; static readonly FieldInfo Yaw = typeof(MechPilot).GetField("yaw", Private); static readonly FieldInfo Pitch = typeof(MechPilot).GetField("pitch", Private); static readonly PropertyInfo View = typeof(MechPilot).GetProperty("FirstPerson"); static readonly Vector3[] Route = { new Vector3(3, 0, -18), new Vector3(26, 0, -18), new Vector3(26, 0, 38), new Vector3(3, 0, 38), new Vector3(3, 0, -18), new Vector3(-26, 0, -18), new Vector3(-26, 0, 38), new Vector3(3, 0, 38) }; readonly FrameTiming[] timing = new FrameTiming[1]; readonly List samples = new List(40000); readonly Report report = new Report(); GameSession game; Keyboard keyboard; Mouse mouse; Camera benchmarkCamera; int renderedFrames; string folder; bool measuring; double previous; ulong lastTiming; int waypoint; int visitedWaypoints, segmentWaypoints; float travelled, segmentTravelled; bool touring; Color32[] captured; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Launch() { if (!Debug.isDebugBuild || Array.IndexOf(Environment.GetCommandLineArgs(), "-lighting-qa") < 0) return; new GameObject("LightingValidation").AddComponent(); } static string Arg(string name, string fallback) { var args = Environment.GetCommandLineArgs(); int index = Array.IndexOf(args, name); return index >= 0 && index + 1 < args.Length ? args[index + 1] : fallback; } IEnumerator Start() { folder = Path.GetFullPath(Arg("-lighting-output", Path.Combine(Application.persistentDataPath, "LightingQA"))); Directory.CreateDirectory(folder); Directory.CreateDirectory(Path.Combine(folder, "Screenshots")); if (!Application.productName.Contains("Lighting tree3")) throw new InvalidOperationException("Lighting QA requires the isolated tree3 product namespace."); Application.runInBackground = true; Screen.SetResolution(1920, 1080, FullScreenMode.FullScreenWindow); InputSystem.settings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus; while (!GameSession.Active) yield return null; game = GameSession.Active; Camera.onPostRender += CameraRendered; yield return null; string quality = Arg("-lighting-quality", "Medium"); int level = Array.IndexOf(QualitySettings.names, quality); if (level < 0) throw new ArgumentException("Unknown quality: " + quality); QualitySettings.SetQualityLevel(level, true); game.World.BuildHangar(); QualitySettings.vSyncCount = 0; Application.targetFrameRate = int.Parse(Arg("-lighting-cap", "-1"), CultureInfo.InvariantCulture); report.tag = Arg("-lighting-tag", "optimized"); report.branch = int.Parse(Arg("-lighting-branch", "0"), CultureInfo.InvariantCulture); report.routeVersion = "perimeter-and-center-v2"; report.startedUtc = DateTime.UtcNow.ToString("O"); report.gpu = SystemInfo.graphicsDeviceName; report.api = SystemInfo.graphicsDeviceType.ToString(); report.cpu = SystemInfo.processorType; report.gpuMemoryMiB = SystemInfo.graphicsMemorySize; report.systemMemoryMiB = SystemInfo.systemMemorySize; report.colorSpace = QualitySettings.activeColorSpace.ToString(); report.quality = QualitySettings.names[level]; report.shadowDistance = QualitySettings.shadowDistance; report.cascades = QualitySettings.shadowCascades; report.vSync = QualitySettings.vSyncCount; report.frameCap = Application.targetFrameRate; report.persistentDataPath = Application.persistentDataPath; report.frameTimingEnabled = FrameTimingManager.IsFeatureEnabled(); keyboard = InputSystem.AddDevice("LightingQaKeyboard"); mouse = InputSystem.AddDevice("LightingQaMouse"); game.Input.devices = new InputDevice[] { keyboard, mouse }; yield return new WaitForSecondsRealtime(2); yield return CaptureScreen("hangar"); game.StartTrial(); yield return new WaitForSecondsRealtime(2); report.dynamicResolution = game.Pilot.GetComponentInChildren().allowDynamicResolution; report.renderWidthScale = ScalableBufferManager.widthScaleFactor; report.renderHeightScale = ScalableBufferManager.heightScaleFactor; report.sunResolution = GameObject.Find("Sun").GetComponent().shadowCustomResolution; yield return VisualChecks(); if (Array.IndexOf(Environment.GetCommandLineArgs(), "-lighting-visual-only") < 0) { // Use the actual combat world, including the final-stage enemy population. game.EndSortie(true); game.BeginSortie(); game.Store.Commit(save => { save.run.seed = 1739; save.run.stage = 4; save.run.branch = report.branch; save.run.armor = 100000; return true; }); game.Resume(); benchmarkCamera = game.Pilot.GetComponentInChildren(); report.cameraWidth = benchmarkCamera.pixelWidth; report.cameraHeight = benchmarkCamera.pixelHeight; yield return new WaitForSecondsRealtime(2); foreach (var enemy in FindObjectsByType()) // Keep the same combatants alive throughout every performance sample. typeof(EnemyUnit).GetField("health", Private).SetValue(enemy, 1000000f); game.World.CallExtraction(); touring = true; waypoint = 0; double warmup = double.Parse(Arg("-lighting-warmup", "60"), CultureInfo.InvariantCulture); double duration = double.Parse(Arg("-lighting-seconds", "120"), CultureInfo.InvariantCulture); int repeats = int.Parse(Arg("-lighting-repeats", "3"), CultureInfo.InvariantCulture); report.warmupSeconds = warmup; report.sampleSeconds = duration; Debug.Log("Lighting QA warmup: " + warmup + " seconds"); yield return Wait(warmup); for (int repeat = 0; repeat < repeats; repeat++) { for (int view = 0; view < 2; view++) { View.SetValue(game.Pilot, view == 1); yield return Wait(2); samples.Clear(); renderedFrames = 0; segmentWaypoints = visitedWaypoints; segmentTravelled = travelled; previous = Time.realtimeSinceStartupAsDouble; measuring = true; yield return Wait(duration); measuring = false; string name = (view == 0 ? "shoulder" : "cockpit") + "-" + (repeat + 1); WriteSegment(name); yield return CaptureScreen(name); Debug.Log("Lighting QA completed " + name); } } touring = false; } report.width = Screen.width; report.height = Screen.height; report.finishedUtc = DateTime.UtcNow.ToString("O"); report.completed = true; report.routeCompleted = game.InField && game.Run.routeCompleted; SaveReport(); if (game.InField) game.EndSortie(true); Debug.Log("Lighting QA finished: " + Path.Combine(folder, "report.json")); if (!Application.isEditor) Application.Quit(); } static IEnumerator Wait(double seconds) { double until = Time.realtimeSinceStartupAsDouble + seconds; while (Time.realtimeSinceStartupAsDouble < until) yield return null; } IEnumerator VisualChecks() { var pilot = game.Pilot; var visual = pilot.transform.Find("MechVisual"); var renderers = visual.GetComponentsInChildren(true); var sun = GameObject.Find("Sun").GetComponent(); Pitch.SetValue(pilot, 25f); Yaw.SetValue(pilot, 0f); View.SetValue(pilot, false); yield return new WaitForSecondsRealtime(.25f); yield return CaptureScreen("shoulder"); View.SetValue(pilot, true); yield return new WaitForSecondsRealtime(.25f); yield return CaptureScreen("cockpit"); int activeCasters = 0; foreach (var renderer in renderers) if (renderer.enabled && renderer.gameObject.activeInHierarchy && renderer.shadowCastingMode == ShadowCastingMode.ShadowsOnly) activeCasters++; report.firstPersonShadowOnlyRenderers = activeCasters; // Hold the real pilot in first-person mode. The observer sees its shadow only. pilot.enabled = false; var cockpit = (GameObject)typeof(MechPilot).GetField("cockpit", Private).GetValue(pilot); cockpit.SetActive(false); var observerObject = new GameObject("LightingQaObserver"); var observer = observerObject.AddComponent(); observer.enabled = false; observer.cullingMask = ~(1 << 11); observer.fieldOfView = 52; observer.nearClipPlane = .1f; observer.farClipPlane = 140; observer.transform.position = new Vector3(0, 9, -23); observer.transform.LookAt(new Vector3(0, 0, -15)); yield return null; CaptureCamera(observer, "shadow-before-move"); var before = captured; var controller = pilot.GetComponent(); controller.enabled = false; pilot.transform.position += Vector3.right * 2; controller.enabled = true; yield return null; CaptureCamera(observer, "shadow-after-move"); report.movingShadowPixels = DifferentPixels(before, captured); var oldShadows = sun.shadows; sun.shadows = LightShadows.None; yield return null; CaptureCamera(observer, "sun-shadows-disabled"); var noShadows = captured; sun.shadows = oldShadows; yield return null; CaptureCamera(observer, "sun-shadows-enabled"); report.sunShadowPixels = DifferentPixels(noShadows, captured); var localObject = GameObject.Find("NavigationWorkLight"); if (localObject) { var local = localObject.GetComponent(); controller.enabled = false; pilot.transform.position = new Vector3(0, .1f, 18); controller.enabled = true; observer.transform.position = new Vector3(7, 10, 13); observer.transform.LookAt(new Vector3(0, .3f, 21)); yield return new WaitForSecondsRealtime(.25f); local.enabled = false; var manager = local.GetComponentInParent(); if (manager && manager.GetType().Name == "WorldLighting") manager.enabled = false; CaptureCamera(observer, "local-off"); var off = captured; local.enabled = true; CaptureCamera(observer, "local-on"); var on = captured; var blocker = GameObject.CreatePrimitive(PrimitiveType.Cube); blocker.name = "LightingQaTemporaryBlocker"; blocker.transform.position = local.transform.position + local.transform.forward * 1.1f; blocker.transform.localScale = new Vector3(1.4f, .15f, 1.4f); blocker.GetComponent().enabled = false; local.enabled = false; yield return null; CaptureCamera(observer, "local-blocked-off"); var blockedOff = captured; local.enabled = true; CaptureCamera(observer, "local-blocked-on"); int lit = 0, blocked = 0; for (int i = 0; i < on.Length; i++) { int direct = Brightness(on[i]) - Brightness(off[i]); int obstructed = Brightness(captured[i]) - Brightness(blockedOff[i]); if (direct > 30) { lit++; if (obstructed < direct * .45f) blocked++; } } report.localLitPixels = lit; report.localBlockedPixels = blocked; report.localLightCastsShadows = local.shadows != LightShadows.None; Destroy(blocker); if (manager) manager.enabled = true; } Destroy(observerObject); pilot.enabled = true; report.visualPassed = activeCasters > 0 && report.movingShadowPixels > 50 && report.sunShadowPixels > 50 && report.localLightCastsShadows && report.localBlockedPixels > 50; SaveReport(); } static int Brightness(Color32 color) => color.r + color.g + color.b; static int DifferentPixels(Color32[] first, Color32[] second) { int changed = 0; for (int i = 0; i < first.Length; i++) if (Math.Abs(first[i].r - second[i].r) + Math.Abs(first[i].g - second[i].g) + Math.Abs(first[i].b - second[i].b) > 30) changed++; return changed; } void CaptureCamera(Camera camera, string name) { var target = RenderTexture.GetTemporary(1920, 1080, 24, RenderTextureFormat.ARGB32); var oldTarget = camera.targetTexture; var oldActive = RenderTexture.active; camera.targetTexture = target; camera.Render(); RenderTexture.active = target; var image = new Texture2D(1920, 1080, TextureFormat.RGB24, false); image.ReadPixels(new Rect(0, 0, 1920, 1080), 0, 0); image.Apply(); captured = image.GetPixels32(); File.WriteAllBytes(Path.Combine(folder, "Screenshots", name + ".png"), image.EncodeToPNG()); camera.targetTexture = oldTarget; RenderTexture.active = oldActive; RenderTexture.ReleaseTemporary(target); Destroy(image); } IEnumerator CaptureScreen(string name) { yield return new WaitForEndOfFrame(); var image = ScreenCapture.CaptureScreenshotAsTexture(); File.WriteAllBytes(Path.Combine(folder, "Screenshots", name + ".png"), image.EncodeToPNG()); Destroy(image); } void Update() { if (!touring || !game || !game.InField) return; var pilot = game.Pilot; var goal = Route[waypoint]; var offset = goal - pilot.transform.position; offset.y = 0; if (offset.magnitude < 1.2f) { visitedWaypoints++; waypoint = (waypoint + 1) % Route.Length; offset = Route[waypoint] - pilot.transform.position; offset.y = 0; } Yaw.SetValue(pilot, Mathf.Atan2(offset.x, offset.z) * Mathf.Rad2Deg); Pitch.SetValue(pilot, 12f); var beforeMove = pilot.transform.position; pilot.GetComponent().Move(offset.normalized * (3.5f * Time.deltaTime)); travelled += Vector3.Distance(beforeMove, pilot.transform.position); var keys = new KeyboardState(); if ((int)Time.realtimeSinceStartup % 9 == 0) keys.Press(Key.Space); InputSystem.QueueStateEvent(keyboard, keys); var buttons = new MouseState(); if ((int)Time.realtimeSinceStartup % 3 == 0) buttons = buttons.WithButton(MouseButton.Left); InputSystem.QueueStateEvent(mouse, buttons); } void LateUpdate() { FrameTimingManager.CaptureFrameTimings(); if (!measuring) return; double now = Time.realtimeSinceStartupAsDouble; var sample = new Sample { wallMs = (now - previous) * 1000 }; previous = now; if (FrameTimingManager.GetLatestTimings(1, timing) > 0 && timing[0].frameStartTimestamp != lastTiming) { lastTiming = timing[0].frameStartTimestamp; sample.cpuMs = timing[0].cpuFrameTime; sample.gpuMs = timing[0].gpuFrameTime; sample.mainMs = timing[0].cpuMainThreadFrameTime; sample.renderMs = timing[0].cpuRenderThreadFrameTime; sample.presentWaitMs = timing[0].cpuMainThreadPresentWaitTime; } samples.Add(sample); } void CameraRendered(Camera camera) { if (measuring && camera == benchmarkCamera) renderedFrames++; } void WriteSegment(string name) { var wall = new List(samples.Count); var gpu = new List(samples.Count); var cpu = new List(samples.Count); var csv = new StringBuilder("wallMs,cpuMs,gpuMs,mainMs,renderMs,presentWaitMs\n"); double total = 0; foreach (var s in samples) { wall.Add(s.wallMs); total += s.wallMs; if (s.gpuMs > 0) gpu.Add(s.gpuMs); if (s.cpuMs > 0) cpu.Add(s.cpuMs); csv.AppendFormat(CultureInfo.InvariantCulture, "{0:F5},{1:F5},{2:F5},{3:F5},{4:F5},{5:F5}\n", s.wallMs, s.cpuMs, s.gpuMs, s.mainMs, s.renderMs, s.presentWaitMs); } wall.Sort(); gpu.Sort(); cpu.Sort(); File.WriteAllText(Path.Combine(folder, name + ".csv"), csv.ToString()); report.segments.Add(new Segment { name = name, frames = samples.Count, cameraRenderFrames = renderedFrames, meanFps = samples.Count * 1000.0 / total, p50Ms = Percentile(wall, .5), p95Ms = Percentile(wall, .95), p99Ms = Percentile(wall, .99), gpuP95Ms = Percentile(gpu, .95), cpuP95Ms = Percentile(cpu, .95), gpuSamples = gpu.Count, cpuSamples = cpu.Count, allocatedMiB = Profiler.GetTotalAllocatedMemoryLong() / 1048576.0, enemyCount = FindObjectsByType().Length, width = Screen.width, height = Screen.height, waypointsVisited = visitedWaypoints - segmentWaypoints, distanceTravelled = travelled - segmentTravelled, passed = Screen.width == 1920 && Screen.height == 1080 && Percentile(wall, .95) <= 16.7 && visitedWaypoints - segmentWaypoints >= 4 && renderedFrames >= samples.Count * .98 }); SaveReport(); } static double Percentile(List sorted, double fraction) => sorted.Count == 0 ? 0 : sorted[Math.Max(0, (int)Math.Ceiling(sorted.Count * fraction) - 1)]; void SaveReport() => File.WriteAllText(Path.Combine(folder, "report.json"), JsonUtility.ToJson(report, true)); void OnDestroy() { Camera.onPostRender -= CameraRendered; if (keyboard != null && keyboard.added) InputSystem.RemoveDevice(keyboard); if (mouse != null && mouse.added) InputSystem.RemoveDevice(mouse); } [Serializable] struct Sample { public double wallMs, cpuMs, gpuMs, mainMs, renderMs, presentWaitMs; } [Serializable] sealed class Segment { public string name; public int frames, width, height, gpuSamples, cpuSamples, enemyCount, waypointsVisited, cameraRenderFrames; public double meanFps, p50Ms, p95Ms, p99Ms, gpuP95Ms, cpuP95Ms, allocatedMiB, distanceTravelled; public bool passed; } [Serializable] sealed class Report { public string tag, startedUtc, finishedUtc, gpu, api, cpu, colorSpace, quality, persistentDataPath, routeVersion; public int width, height, gpuMemoryMiB, systemMemoryMiB, cascades, vSync, frameCap, firstPersonShadowOnlyRenderers, movingShadowPixels, sunShadowPixels, localLitPixels, localBlockedPixels, sunResolution, cameraWidth, cameraHeight, branch; public float shadowDistance, renderWidthScale, renderHeightScale; public double warmupSeconds, sampleSeconds; public bool completed, frameTimingEnabled, localLightCastsShadows, visualPassed, dynamicResolution, routeCompleted; public List segments = new List(); } } }