Add frontend production project
This commit is contained in:
13
engine/Age.Engine.Frontend/Age.Engine.Frontend.csproj
Normal file
13
engine/Age.Engine.Frontend/Age.Engine.Frontend.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Age.Engine\Age.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
72
engine/Age.Engine.Frontend/GodotTimelineLog.cs
Normal file
72
engine/Age.Engine.Frontend/GodotTimelineLog.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
/// <summary>Diagnostic-only synchronized JSONL stream for correlating VM execution, host waits/audio,
|
||||
/// and compositor object changes. All producers share one lock, so event order is unambiguous even though
|
||||
/// the VM and compositor run on different threads.</summary>
|
||||
public sealed class GodotTimelineLog : System.IDisposable
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly StreamWriter _writer;
|
||||
private long _sequence;
|
||||
private int _frame;
|
||||
private long _nowMs;
|
||||
private string _script = "<startup>";
|
||||
private int _offset = -1;
|
||||
private int _opcode = -1;
|
||||
private string _state = "starting";
|
||||
private bool _disposed;
|
||||
|
||||
public GodotTimelineLog(string path)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
_writer = new StreamWriter(path) { AutoFlush = true };
|
||||
Record("start", new() { ["transition"] = "surface-alpha-lifecycle" });
|
||||
}
|
||||
|
||||
public void SetFrame(int frame, long nowMs)
|
||||
{
|
||||
lock (_lock) { _frame = frame; _nowMs = nowMs; }
|
||||
}
|
||||
|
||||
public void Step(string script, int offset, int opcode, int depth)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_script = script; _offset = offset; _opcode = opcode; _state = "running";
|
||||
WriteLocked("step", new() { ["depth"] = depth });
|
||||
}
|
||||
}
|
||||
|
||||
public void State(string state, Dictionary<string, object?>? detail = null)
|
||||
{
|
||||
lock (_lock) { _state = state; WriteLocked(state, detail); }
|
||||
}
|
||||
|
||||
public void Event(string kind, Dictionary<string, object?>? detail = null)
|
||||
{
|
||||
lock (_lock) WriteLocked(kind, detail);
|
||||
}
|
||||
|
||||
private void Record(string kind, Dictionary<string, object?>? detail)
|
||||
{
|
||||
lock (_lock) WriteLocked(kind, detail);
|
||||
}
|
||||
|
||||
private void WriteLocked(string kind, Dictionary<string, object?>? detail)
|
||||
{
|
||||
if (_disposed) return;
|
||||
var row = new Dictionary<string, object?>
|
||||
{
|
||||
["seq"] = ++_sequence, ["kind"] = kind, ["frame"] = _frame, ["now_ms"] = _nowMs,
|
||||
["script"] = _script, ["offset"] = _offset < 0 ? null : $"0x{_offset:x}",
|
||||
["opcode"] = _opcode < 0 ? null : $"0x{_opcode:x}", ["vm_state"] = _state,
|
||||
};
|
||||
if (detail != null) foreach (var kv in detail) row[kv.Key] = kv.Value;
|
||||
_writer.WriteLine(JsonSerializer.Serialize(row));
|
||||
}
|
||||
|
||||
public void Dispose() { lock (_lock) { if (_disposed) return; _disposed = true; _writer.Dispose(); } }
|
||||
}
|
||||
129
engine/Age.Engine.Frontend/GodotTraceSink.cs
Normal file
129
engine/Age.Engine.Frontend/GodotTraceSink.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using Age.Engine.Diagnostics;
|
||||
|
||||
// Frontend-side trace consumer. Runs on the VM background thread, so it just queues the dispatched
|
||||
// call-script ids; the main thread drains them (Godot drops GD.Print from background threads). This
|
||||
// replaces the old IHost.CallScript -> GodotAdvHost.Dispatched hack: subroutine visibility is now an
|
||||
// engine fact delivered over the trace seam.
|
||||
public sealed class GodotTraceSink : ITraceSink
|
||||
{
|
||||
private const int RecentStepCapacity = 128;
|
||||
private readonly GodotTimelineLog? _timeline;
|
||||
private readonly PageLocatorState _locator;
|
||||
private readonly object _snapshotLock = new();
|
||||
private readonly Stack<string> _scripts = new();
|
||||
private readonly Queue<GodotTraceStepSnapshot> _recentSteps = new();
|
||||
private GodotTraceStepSnapshot? _latestStep;
|
||||
private GodotTraceSnapshot? _haltSnapshot;
|
||||
public GodotTraceSink(PageLocatorState locator, GodotTimelineLog? timeline = null)
|
||||
{ _locator = locator; _timeline = timeline; }
|
||||
// The page locator needs the exact script/offset even when the heavier timeline log is disabled.
|
||||
public bool TracingSteps => true;
|
||||
public readonly ConcurrentQueue<long> CallScripts = new();
|
||||
public void Emit(in TraceEvent e)
|
||||
{
|
||||
if (e.Kind == TraceEventKind.CallScript)
|
||||
{
|
||||
CallScripts.Enqueue(e.Id);
|
||||
_timeline?.Event("call-script", new()
|
||||
{
|
||||
["id"] = $"0x{e.Id:x}", ["resolved_name"] = e.Name,
|
||||
});
|
||||
}
|
||||
if (e.Kind == TraceEventKind.FrameEnter && e.Name != null)
|
||||
{
|
||||
string[] callStack;
|
||||
lock (_snapshotLock)
|
||||
{
|
||||
_scripts.Push(e.Name);
|
||||
callStack = CurrentCallStackLocked();
|
||||
}
|
||||
_locator.CallStack(callStack);
|
||||
_timeline?.Event("frame-enter", new()
|
||||
{
|
||||
["name"] = e.Name, ["depth"] = e.Depth,
|
||||
["cause"] = e.Cause.ToString(), ["call_id"] = $"0x{e.Id:x}",
|
||||
});
|
||||
}
|
||||
else if (e.Kind == TraceEventKind.FrameExit && _scripts.Count > 0)
|
||||
{
|
||||
_timeline?.Event("frame-exit", new()
|
||||
{
|
||||
["name"] = e.Name, ["depth"] = e.Depth, ["outcome"] = e.Text,
|
||||
});
|
||||
string[] callStack;
|
||||
lock (_snapshotLock)
|
||||
{
|
||||
if (e.Text == "Halted" && _haltSnapshot == null)
|
||||
_haltSnapshot = SnapshotLocked();
|
||||
if (_scripts.Count > 0) _scripts.Pop();
|
||||
callStack = CurrentCallStackLocked();
|
||||
}
|
||||
_locator.CallStack(callStack);
|
||||
}
|
||||
else if (e.Kind == TraceEventKind.Step && e.Ins != null)
|
||||
{
|
||||
string script;
|
||||
lock (_snapshotLock)
|
||||
{
|
||||
script = _scripts.Count > 0 ? _scripts.Peek() : "<unknown>";
|
||||
if (_recentSteps.Count == RecentStepCapacity) _recentSteps.Dequeue();
|
||||
var step = new GodotTraceStepSnapshot(script, e.Ins.Offset, e.Opcode, e.Depth);
|
||||
_latestStep = step;
|
||||
_recentSteps.Enqueue(step);
|
||||
}
|
||||
_locator.Step(script, e.Ins.Offset);
|
||||
_timeline?.Step(script, e.Ins.Offset, e.Opcode, e.Depth);
|
||||
}
|
||||
else if (e.Kind == TraceEventKind.Stub)
|
||||
_timeline?.Event("stub", new()
|
||||
{
|
||||
["stub_opcode"] = $"0x{e.Opcode:x}", ["pc_index"] = e.Pc,
|
||||
});
|
||||
else if (e.Kind == TraceEventKind.Halt)
|
||||
_timeline?.State("halted", new() { ["reason"] = e.Text, ["steps"] = e.Steps });
|
||||
}
|
||||
|
||||
public GodotTraceSnapshot Snapshot()
|
||||
{
|
||||
lock (_snapshotLock) return SnapshotLocked();
|
||||
}
|
||||
|
||||
/// <summary>The deepest still-active script stack captured before a halted frame unwinds.</summary>
|
||||
public GodotTraceSnapshot? HaltSnapshot
|
||||
{
|
||||
get { lock (_snapshotLock) return _haltSnapshot; }
|
||||
}
|
||||
|
||||
/// <summary>Allocation-free current coordinate for once-per-frame diagnostics.</summary>
|
||||
public GodotTraceStepSnapshot? LatestStep
|
||||
{
|
||||
get { lock (_snapshotLock) return _latestStep; }
|
||||
}
|
||||
|
||||
private string[] CurrentCallStackLocked()
|
||||
{
|
||||
var stack = _scripts.ToArray();
|
||||
System.Array.Reverse(stack);
|
||||
return stack;
|
||||
}
|
||||
|
||||
private GodotTraceSnapshot SnapshotLocked()
|
||||
{
|
||||
GodotTraceStepSnapshot? current = _latestStep;
|
||||
return new GodotTraceSnapshot(
|
||||
current?.Script ?? (_scripts.Count > 0 ? _scripts.Peek() : "<unknown>"),
|
||||
current?.Offset ?? -1,
|
||||
current?.Opcode ?? -1,
|
||||
current?.Depth ?? System.Math.Max(0, _scripts.Count - 1),
|
||||
CurrentCallStackLocked(),
|
||||
_recentSteps.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record GodotTraceStepSnapshot(string Script, int Offset, int Opcode, int Depth);
|
||||
|
||||
public sealed record GodotTraceSnapshot(string CurrentScript, int CurrentOffset, int CurrentOpcode,
|
||||
int CurrentDepth, IReadOnlyList<string> CallStack,
|
||||
IReadOnlyList<GodotTraceStepSnapshot> RecentSteps);
|
||||
13
engine/Age.Engine.Frontend/GodotVmOptions.cs
Normal file
13
engine/Age.Engine.Frontend/GodotVmOptions.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Age.Engine.Vm;
|
||||
|
||||
/// <summary>Separates bounded diagnostic runs from the persistent interactive AGE session.</summary>
|
||||
public static class GodotVmOptions
|
||||
{
|
||||
public const long DiagnosticMaxSteps = 20_000_000;
|
||||
|
||||
public static VmOptions Create(bool selftest, bool ignoreExitRequests)
|
||||
=> new(
|
||||
MaxSteps: selftest ? DiagnosticMaxSteps : long.MaxValue,
|
||||
IgnoreExitRequests: ignoreExitRequests,
|
||||
NoSaveDat: selftest);
|
||||
}
|
||||
135
engine/Age.Engine.Frontend/PageLocatorState.cs
Normal file
135
engine/Age.Engine.Frontend/PageLocatorState.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe bridge between the VM trace, the ADV host's page waits, and the Godot UI.
|
||||
/// A page number is run-relative; the script + wait offset is its canonical locator.
|
||||
/// </summary>
|
||||
public sealed class PageLocatorState : System.IDisposable
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly string _rootScene;
|
||||
private readonly StreamWriter? _writer;
|
||||
private string _script = "<startup>";
|
||||
private int _offset = -1;
|
||||
private string[] _callStack = System.Array.Empty<string>();
|
||||
private string? _pageStartScript;
|
||||
private int? _pageStartOffset;
|
||||
private string? _textScript;
|
||||
private int? _textOffset;
|
||||
private int? _textStringOffset;
|
||||
private string? _text;
|
||||
private string _currentDisplay;
|
||||
private bool _disposed;
|
||||
|
||||
public PageLocatorState(string rootScene, string? mapPath)
|
||||
{
|
||||
_rootScene = rootScene.ToUpperInvariant();
|
||||
_currentDisplay = _rootScene;
|
||||
if (mapPath == null) return;
|
||||
var dir = Path.GetDirectoryName(mapPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
_writer = new StreamWriter(mapPath, append: false) { AutoFlush = true };
|
||||
}
|
||||
|
||||
public string CurrentDisplay { get { lock (_lock) return _currentDisplay; } }
|
||||
|
||||
public void Step(string script, int offset)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_script = NormalizeScript(script);
|
||||
_offset = offset;
|
||||
if (_pageStartOffset == null)
|
||||
{
|
||||
_pageStartScript = _script;
|
||||
_pageStartOffset = offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void CallStack(string[] callStack)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_callStack = new string[callStack.Length];
|
||||
for (int i = 0; i < callStack.Length; i++) _callStack[i] = NormalizeScript(callStack[i]);
|
||||
}
|
||||
}
|
||||
|
||||
public void Text(int stringOffset, string text)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_textScript = _script;
|
||||
_textOffset = _offset;
|
||||
_textStringOffset = stringOffset;
|
||||
_text = text;
|
||||
}
|
||||
}
|
||||
|
||||
public void Wait(int page)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
string preview = Preview(_text);
|
||||
string textLocation = _textOffset is int textOffset
|
||||
? $"{_textScript}@0x{textOffset:x}"
|
||||
: "none";
|
||||
_currentDisplay = $"{_rootScene} P{page:000} · wait {_script}@0x{_offset:x} · text {textLocation}";
|
||||
if (preview.Length > 0) _currentDisplay += $" · {preview}";
|
||||
|
||||
if (_writer != null && !_disposed)
|
||||
{
|
||||
var row = new Dictionary<string, object?>
|
||||
{
|
||||
["root_scene"] = _rootScene,
|
||||
["page"] = page,
|
||||
["page_start_script"] = _pageStartScript,
|
||||
["page_start_offset"] = Hex(_pageStartOffset),
|
||||
["wait_script"] = _script,
|
||||
["wait_offset"] = Hex(_offset),
|
||||
["text_script"] = _textScript,
|
||||
["text_offset"] = Hex(_textOffset),
|
||||
["text_string_offset"] = Hex(_textStringOffset),
|
||||
["text"] = _text,
|
||||
["call_stack"] = _callStack,
|
||||
};
|
||||
_writer.WriteLine(JsonSerializer.Serialize(row));
|
||||
}
|
||||
|
||||
// The display remains parked on this page, but text/start observations for the next page must
|
||||
// not inherit stale values if a page contains no show-text operation of its own.
|
||||
_pageStartScript = null;
|
||||
_pageStartOffset = null;
|
||||
_textScript = null;
|
||||
_textOffset = null;
|
||||
_textStringOffset = null;
|
||||
_text = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeScript(string script)
|
||||
=> Path.GetFileNameWithoutExtension(script).ToUpperInvariant();
|
||||
|
||||
private static string? Hex(int? value) => value is int v ? $"0x{v:x}" : null;
|
||||
|
||||
private static string Preview(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return "";
|
||||
string oneLine = text.Replace('\r', ' ').Replace('\n', ' ').Trim();
|
||||
if (oneLine.Length > 36) oneLine = oneLine[..35] + "…";
|
||||
return $"「{oneLine}」";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_writer?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
322
engine/Age.Engine.Frontend/PerformanceFrameLog.cs
Normal file
322
engine/Age.Engine.Frontend/PerformanceFrameLog.cs
Normal file
@@ -0,0 +1,322 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Age.Engine.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Buffered, diagnostic-only CSV writer for real Godot frame and retained-compositor cost. The writer is
|
||||
/// deliberately independent of Godot types so its schema and clipping arithmetic can be unit tested.
|
||||
/// </summary>
|
||||
public sealed class PerformanceFrameLog : IDisposable
|
||||
{
|
||||
private const int FlushIntervalFrames = 120;
|
||||
private static readonly double MillisecondsPerTick = 1000.0 / Stopwatch.Frequency;
|
||||
private readonly StreamWriter _writer;
|
||||
private Frame _current = new();
|
||||
private bool _frameOpen;
|
||||
private bool _disposed;
|
||||
private int _framesSinceFlush;
|
||||
|
||||
public long FrameCount { get; private set; }
|
||||
public long RecompositeCount { get; private set; }
|
||||
public string Path { get; }
|
||||
|
||||
public PerformanceFrameLog(string path)
|
||||
{
|
||||
Path = path;
|
||||
var directory = System.IO.Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
_writer = new StreamWriter(path, append: false, Encoding.UTF8, 64 * 1024);
|
||||
// Presentation coordinates are appended separately so a VM thread released by PulseFrame can be
|
||||
// distinguished from the coordinate observed at _Process entry.
|
||||
_writer.WriteLine(
|
||||
"frame,now_ms,delta_ms,main_ms,pulse_ms,movie_ms,should_recomposite_ms,recomposite_ms," +
|
||||
"clear_ms,snapshot_ms,resolve_ms,source_prep_ms,raster_ms,set_data_ms,texture_update_ms,ui_ms," +
|
||||
"allocated_bytes,recompose_allocated_bytes,snapshot_allocated_bytes," +
|
||||
"composite_allocated_bytes,source_prep_allocated_bytes,set_data_allocated_bytes," +
|
||||
"ui_allocated_bytes,gen0,gen1,gen2,recomposited,render_backend,screen_transition," +
|
||||
"gpu_draw_items,gpu_texture_uploads,gpu_texture_upload_ms," +
|
||||
"present_host_request,present_screen_transition,present_retained_mutation," +
|
||||
"present_continuous_channel,present_discrete_cell,object_visits," +
|
||||
"time_varying_objects,draw_layers,fill_layers,transition_layers,skipped_layers," +
|
||||
"integer_layers,fractional_translation_layers,axis_aligned_scale_layers," +
|
||||
"general_affine_layers,affine_layers,singular_layers,dynamic_layers,opaque_layers,alpha_layers," +
|
||||
"additive_layers,source_pixels,candidate_pixels,full_screen_layers,script,offset,opcode," +
|
||||
"present_script,present_offset,present_opcode");
|
||||
}
|
||||
|
||||
public static long Timestamp() => Stopwatch.GetTimestamp();
|
||||
public static long AllocatedBytes() => GC.GetAllocatedBytesForCurrentThread();
|
||||
|
||||
public void BeginFrame(int frame, long nowMs, double deltaSeconds,
|
||||
string script, int offset, int opcode)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (_frameOpen) EndFrame();
|
||||
_current = new Frame
|
||||
{
|
||||
Number = frame,
|
||||
NowMs = nowMs,
|
||||
DeltaMs = deltaSeconds * 1000.0,
|
||||
Script = script,
|
||||
Offset = offset,
|
||||
Opcode = opcode,
|
||||
Started = Timestamp(),
|
||||
AllocatedStart = GC.GetAllocatedBytesForCurrentThread(),
|
||||
Gen0Start = GC.CollectionCount(0),
|
||||
Gen1Start = GC.CollectionCount(1),
|
||||
Gen2Start = GC.CollectionCount(2),
|
||||
};
|
||||
_frameOpen = true;
|
||||
}
|
||||
|
||||
public void RecordPulse(long ticks) => _current.PulseTicks += ticks;
|
||||
public void RecordMovies(long ticks) => _current.MovieTicks += ticks;
|
||||
public void RecordShouldRecomposite(long ticks) => _current.ShouldTicks += ticks;
|
||||
public void RecordPresentationReasons(int reasons)
|
||||
{
|
||||
_current.PresentHostRequest |= (reasons & 1) != 0;
|
||||
_current.PresentScreenTransition |= (reasons & 2) != 0;
|
||||
_current.PresentRetainedMutation |= (reasons & 4) != 0;
|
||||
_current.PresentContinuousChannel |= (reasons & 8) != 0;
|
||||
_current.PresentDiscreteCell |= (reasons & 16) != 0;
|
||||
}
|
||||
public void RecordUi(long ticks) => _current.UiTicks += ticks;
|
||||
public void RecordClear(long ticks) => _current.ClearTicks += ticks;
|
||||
public void RecordSnapshot(long ticks) => _current.SnapshotTicks += ticks;
|
||||
public void RecordResolve(long ticks) => _current.ResolveTicks += ticks;
|
||||
public void RecordSourcePrep(long ticks) => _current.SourcePrepTicks += ticks;
|
||||
public void RecordRecomposeAllocation(long bytes) => _current.RecomposeAllocatedBytes += Math.Max(0, bytes);
|
||||
public void RecordSnapshotAllocation(long bytes) => _current.SnapshotAllocatedBytes += Math.Max(0, bytes);
|
||||
public void RecordCompositeAllocation(long bytes) => _current.CompositeAllocatedBytes += Math.Max(0, bytes);
|
||||
public void RecordSourcePrepAllocation(long bytes) => _current.SourcePrepAllocatedBytes += Math.Max(0, bytes);
|
||||
public void RecordSetDataAllocation(long bytes) => _current.SetDataAllocatedBytes += Math.Max(0, bytes);
|
||||
public void RecordUiAllocation(long bytes) => _current.UiAllocatedBytes += Math.Max(0, bytes);
|
||||
public void RecordSetData(long ticks) => _current.SetDataTicks += ticks;
|
||||
public void RecordTextureUpdate(long ticks) => _current.TextureUpdateTicks += ticks;
|
||||
public void RecordGpu(int drawItems, int textureUploads, long textureUploadTicks)
|
||||
{
|
||||
_current.GpuBackend = true;
|
||||
_current.GpuDrawItems += drawItems;
|
||||
_current.GpuTextureUploads += textureUploads;
|
||||
_current.GpuTextureUploadTicks += textureUploadTicks;
|
||||
}
|
||||
|
||||
public void BeginRecomposite(bool screenTransition)
|
||||
{
|
||||
_current.Recomposited = true;
|
||||
_current.ScreenTransition |= screenTransition;
|
||||
_current.RecompositeStarted = Timestamp();
|
||||
}
|
||||
|
||||
public void RecordPresentationCoordinate(string script, int offset, int opcode)
|
||||
{
|
||||
_current.PresentScript = script;
|
||||
_current.PresentOffset = offset;
|
||||
_current.PresentOpcode = opcode;
|
||||
}
|
||||
|
||||
public void EndRecomposite()
|
||||
{
|
||||
if (_current.RecompositeStarted == 0) return;
|
||||
_current.RecompositeTicks += Timestamp() - _current.RecompositeStarted;
|
||||
_current.RecompositeStarted = 0;
|
||||
}
|
||||
|
||||
public void RecordObject(bool timeVarying)
|
||||
{
|
||||
_current.ObjectVisits++;
|
||||
if (timeVarying) _current.TimeVaryingObjects++;
|
||||
}
|
||||
|
||||
public void RecordFillLayer() => _current.FillLayers++;
|
||||
public void RecordTransitionLayer() => _current.TransitionLayers++;
|
||||
public void RecordSkippedLayer() => _current.SkippedLayers++;
|
||||
|
||||
public void RecordRaster(int sourceWidth, int sourceHeight, Affine2D localToDest,
|
||||
int destinationWidth, int destinationHeight, bool dynamic,
|
||||
BlendKind blend, long ticks)
|
||||
{
|
||||
_current.RasterTicks += ticks;
|
||||
RecordLayer(sourceWidth, sourceHeight, localToDest, destinationWidth, destinationHeight,
|
||||
dynamic, blend);
|
||||
}
|
||||
|
||||
public void RecordGpuLayer(int sourceWidth, int sourceHeight, Affine2D localToDest,
|
||||
int destinationWidth, int destinationHeight, bool dynamic,
|
||||
BlendKind blend)
|
||||
=> RecordLayer(sourceWidth, sourceHeight, localToDest, destinationWidth, destinationHeight,
|
||||
dynamic, blend);
|
||||
|
||||
private void RecordLayer(int sourceWidth, int sourceHeight, Affine2D localToDest,
|
||||
int destinationWidth, int destinationHeight, bool dynamic,
|
||||
BlendKind blend)
|
||||
{
|
||||
_current.DrawLayers++;
|
||||
_current.SourcePixels += Math.Max(0L, (long)sourceWidth * sourceHeight);
|
||||
long candidates = EstimateCandidatePixels(localToDest, sourceWidth, sourceHeight,
|
||||
destinationWidth, destinationHeight);
|
||||
_current.CandidatePixels += candidates;
|
||||
if (candidates >= (long)destinationWidth * destinationHeight) _current.FullScreenLayers++;
|
||||
if (IsIntegerTranslation(localToDest)) _current.IntegerLayers++;
|
||||
else if (!localToDest.TryInverse(out _)) _current.SingularLayers++;
|
||||
else
|
||||
{
|
||||
_current.AffineLayers++;
|
||||
if (IsTranslation(localToDest)) _current.FractionalTranslationLayers++;
|
||||
else if (IsAxisAligned(localToDest)) _current.AxisAlignedScaleLayers++;
|
||||
else _current.GeneralAffineLayers++;
|
||||
}
|
||||
if (dynamic) _current.DynamicLayers++;
|
||||
switch (blend)
|
||||
{
|
||||
case BlendKind.Opaque: _current.OpaqueLayers++; break;
|
||||
case BlendKind.Additive: _current.AdditiveLayers++; break;
|
||||
default: _current.AlphaLayers++; break;
|
||||
}
|
||||
}
|
||||
|
||||
public void EndFrame()
|
||||
{
|
||||
if (!_frameOpen || _disposed) return;
|
||||
long ended = Timestamp();
|
||||
_current.MainTicks = ended - _current.Started;
|
||||
_current.AllocatedBytes = Math.Max(0, GC.GetAllocatedBytesForCurrentThread() - _current.AllocatedStart);
|
||||
_current.Gen0 = GC.CollectionCount(0) - _current.Gen0Start;
|
||||
_current.Gen1 = GC.CollectionCount(1) - _current.Gen1Start;
|
||||
_current.Gen2 = GC.CollectionCount(2) - _current.Gen2Start;
|
||||
Write(_current);
|
||||
FrameCount++;
|
||||
if (_current.Recomposited) RecompositeCount++;
|
||||
_frameOpen = false;
|
||||
if (++_framesSinceFlush >= FlushIntervalFrames)
|
||||
{
|
||||
_writer.Flush();
|
||||
_framesSinceFlush = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsIntegerTranslation(Affine2D m)
|
||||
=> IsTranslation(m) &&
|
||||
m.TX == Math.Truncate(m.TX) && m.TY == Math.Truncate(m.TY) &&
|
||||
m.TX >= int.MinValue && m.TX <= int.MaxValue &&
|
||||
m.TY >= int.MinValue && m.TY <= int.MaxValue;
|
||||
|
||||
public static bool IsTranslation(Affine2D m)
|
||||
=> m.XX == 1 && m.XY == 0 && m.YX == 0 && m.YY == 1;
|
||||
|
||||
public static bool IsAxisAligned(Affine2D m)
|
||||
=> m.XY == 0 && m.YX == 0;
|
||||
|
||||
public static long EstimateCandidatePixels(Affine2D m, int width, int height,
|
||||
int destinationWidth, int destinationHeight)
|
||||
{
|
||||
if (width <= 0 || height <= 0 || destinationWidth <= 0 || destinationHeight <= 0) return 0;
|
||||
var a = m.Apply(0, 0);
|
||||
var b = m.Apply(width, 0);
|
||||
var c = m.Apply(0, height);
|
||||
var d = m.Apply(width, height);
|
||||
double left = Math.Min(Math.Min(a.X, b.X), Math.Min(c.X, d.X));
|
||||
double top = Math.Min(Math.Min(a.Y, b.Y), Math.Min(c.Y, d.Y));
|
||||
double right = Math.Max(Math.Max(a.X, b.X), Math.Max(c.X, d.X));
|
||||
double bottom = Math.Max(Math.Max(a.Y, b.Y), Math.Max(c.Y, d.Y));
|
||||
long x0 = Math.Max(0, ClampFloor(left));
|
||||
long y0 = Math.Max(0, ClampFloor(top));
|
||||
long x1 = Math.Min(destinationWidth, ClampCeiling(right));
|
||||
long y1 = Math.Min(destinationHeight, ClampCeiling(bottom));
|
||||
return x1 <= x0 || y1 <= y0 ? 0 : checked((x1 - x0) * (y1 - y0));
|
||||
}
|
||||
|
||||
private static long ClampFloor(double value)
|
||||
=> !double.IsFinite(value) ? 0 : value <= long.MinValue ? long.MinValue
|
||||
: value >= long.MaxValue ? long.MaxValue : (long)Math.Floor(value);
|
||||
|
||||
private static long ClampCeiling(double value)
|
||||
=> !double.IsFinite(value) ? 0 : value <= long.MinValue ? long.MinValue
|
||||
: value >= long.MaxValue ? long.MaxValue : (long)Math.Ceiling(value);
|
||||
|
||||
private void Write(Frame f)
|
||||
{
|
||||
var b = new StringBuilder(512);
|
||||
Append(b, f.Number); Append(b, f.NowMs); Append(b, f.DeltaMs);
|
||||
AppendTicks(b, f.MainTicks); AppendTicks(b, f.PulseTicks); AppendTicks(b, f.MovieTicks);
|
||||
AppendTicks(b, f.ShouldTicks); AppendTicks(b, f.RecompositeTicks); AppendTicks(b, f.ClearTicks);
|
||||
AppendTicks(b, f.SnapshotTicks); AppendTicks(b, f.ResolveTicks); AppendTicks(b, f.SourcePrepTicks);
|
||||
AppendTicks(b, f.RasterTicks);
|
||||
AppendTicks(b, f.SetDataTicks); AppendTicks(b, f.TextureUpdateTicks); AppendTicks(b, f.UiTicks);
|
||||
Append(b, f.AllocatedBytes); Append(b, f.RecomposeAllocatedBytes);
|
||||
Append(b, f.SnapshotAllocatedBytes); Append(b, f.CompositeAllocatedBytes);
|
||||
Append(b, f.SourcePrepAllocatedBytes); Append(b, f.SetDataAllocatedBytes);
|
||||
Append(b, f.UiAllocatedBytes); Append(b, f.Gen0); Append(b, f.Gen1); Append(b, f.Gen2);
|
||||
Append(b, f.Recomposited ? 1 : 0); Append(b, f.GpuBackend ? 1 : 0);
|
||||
Append(b, f.ScreenTransition ? 1 : 0);
|
||||
Append(b, f.GpuDrawItems); Append(b, f.GpuTextureUploads); AppendTicks(b, f.GpuTextureUploadTicks);
|
||||
Append(b, f.PresentHostRequest ? 1 : 0); Append(b, f.PresentScreenTransition ? 1 : 0);
|
||||
Append(b, f.PresentRetainedMutation ? 1 : 0); Append(b, f.PresentContinuousChannel ? 1 : 0);
|
||||
Append(b, f.PresentDiscreteCell ? 1 : 0);
|
||||
Append(b, f.ObjectVisits); Append(b, f.TimeVaryingObjects); Append(b, f.DrawLayers);
|
||||
Append(b, f.FillLayers); Append(b, f.TransitionLayers); Append(b, f.SkippedLayers);
|
||||
Append(b, f.IntegerLayers); Append(b, f.FractionalTranslationLayers);
|
||||
Append(b, f.AxisAlignedScaleLayers); Append(b, f.GeneralAffineLayers);
|
||||
Append(b, f.AffineLayers); Append(b, f.SingularLayers);
|
||||
Append(b, f.DynamicLayers); Append(b, f.OpaqueLayers); Append(b, f.AlphaLayers);
|
||||
Append(b, f.AdditiveLayers); Append(b, f.SourcePixels); Append(b, f.CandidatePixels);
|
||||
Append(b, f.FullScreenLayers); AppendEscaped(b, f.Script); Append(b, f.Offset);
|
||||
Append(b, f.Opcode); AppendEscaped(b, f.PresentScript); Append(b, f.PresentOffset);
|
||||
Append(b, f.PresentOpcode, last: true);
|
||||
_writer.WriteLine(b.ToString());
|
||||
}
|
||||
|
||||
private static void AppendTicks(StringBuilder b, long ticks)
|
||||
=> Append(b, ticks * MillisecondsPerTick);
|
||||
|
||||
private static void Append(StringBuilder b, long value, bool last = false)
|
||||
{
|
||||
b.Append(value.ToString(CultureInfo.InvariantCulture));
|
||||
if (!last) b.Append(',');
|
||||
}
|
||||
|
||||
private static void Append(StringBuilder b, double value)
|
||||
{
|
||||
b.Append(value.ToString("0.0000", CultureInfo.InvariantCulture));
|
||||
b.Append(',');
|
||||
}
|
||||
|
||||
private static void AppendEscaped(StringBuilder b, string value)
|
||||
{
|
||||
b.Append('"').Append(value.Replace("\"", "\"\"")).Append("\",");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (_frameOpen) EndFrame();
|
||||
_disposed = true;
|
||||
_writer.Dispose();
|
||||
}
|
||||
|
||||
private sealed class Frame
|
||||
{
|
||||
public int Number, Offset, Opcode;
|
||||
public long NowMs, Started, MainTicks, PulseTicks, MovieTicks, ShouldTicks, RecompositeTicks;
|
||||
public long RecompositeStarted, ClearTicks, SnapshotTicks, ResolveTicks, SourcePrepTicks, RasterTicks;
|
||||
public long SetDataTicks, TextureUpdateTicks, UiTicks, AllocatedStart, AllocatedBytes;
|
||||
public long RecomposeAllocatedBytes, SnapshotAllocatedBytes, CompositeAllocatedBytes;
|
||||
public long SourcePrepAllocatedBytes, SetDataAllocatedBytes, UiAllocatedBytes;
|
||||
public int Gen0Start, Gen1Start, Gen2Start, Gen0, Gen1, Gen2;
|
||||
public double DeltaMs;
|
||||
public string Script = "<unknown>";
|
||||
public string PresentScript = "<none>";
|
||||
public int PresentOffset = -1, PresentOpcode = -1;
|
||||
public bool Recomposited, GpuBackend, ScreenTransition;
|
||||
public bool PresentHostRequest, PresentScreenTransition, PresentRetainedMutation;
|
||||
public bool PresentContinuousChannel, PresentDiscreteCell;
|
||||
public long ObjectVisits, TimeVaryingObjects, DrawLayers, FillLayers, TransitionLayers, SkippedLayers;
|
||||
public long IntegerLayers, FractionalTranslationLayers, AxisAlignedScaleLayers;
|
||||
public long GeneralAffineLayers, AffineLayers, SingularLayers, DynamicLayers;
|
||||
public long OpaqueLayers, AlphaLayers, AdditiveLayers, SourcePixels, CandidatePixels, FullScreenLayers;
|
||||
public long GpuDrawItems, GpuTextureUploads, GpuTextureUploadTicks;
|
||||
}
|
||||
}
|
||||
46
engine/Age.Engine.Frontend/StepLimitDiagnosticFormatter.cs
Normal file
46
engine/Age.Engine.Frontend/StepLimitDiagnosticFormatter.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Age.Engine.Model;
|
||||
|
||||
/// <summary>Formats the bounded trace retained by the Godot frontend when the VM safety cap fires.</summary>
|
||||
public static class StepLimitDiagnosticFormatter
|
||||
{
|
||||
public static string Format(GodotTraceSnapshot snapshot, OpcodeTable table,
|
||||
int topSites = 8, int tailSteps = 16)
|
||||
{
|
||||
string Location(GodotTraceStepSnapshot step)
|
||||
{
|
||||
string script = Path.GetFileNameWithoutExtension(step.Script).ToUpperInvariant();
|
||||
string mnemonic = table.Label(step.Opcode);
|
||||
if (string.IsNullOrEmpty(mnemonic)) mnemonic = "unknown";
|
||||
return $"{script}@0x{step.Offset:x} op=0x{step.Opcode:x3} {mnemonic}";
|
||||
}
|
||||
|
||||
var output = new StringBuilder();
|
||||
var current = new GodotTraceStepSnapshot(
|
||||
snapshot.CurrentScript, snapshot.CurrentOffset, snapshot.CurrentOpcode, snapshot.CurrentDepth);
|
||||
output.AppendLine($"[step-limit] last: {Location(current)} depth={snapshot.CurrentDepth}");
|
||||
output.AppendLine($"[step-limit] frames: {string.Join(" > ",
|
||||
snapshot.CallStack.Select(name => Path.GetFileNameWithoutExtension(name).ToUpperInvariant()))}");
|
||||
output.AppendLine($"[step-limit] hot sites in final {snapshot.RecentSteps.Count} steps:");
|
||||
foreach (var site in snapshot.RecentSteps
|
||||
.GroupBy(step => (step.Script, step.Offset, step.Opcode))
|
||||
.OrderByDescending(group => group.Count())
|
||||
.ThenBy(group => group.Key.Script, StringComparer.Ordinal)
|
||||
.ThenBy(group => group.Key.Offset)
|
||||
.Take(Math.Max(0, topSites)))
|
||||
{
|
||||
var sample = new GodotTraceStepSnapshot(
|
||||
site.Key.Script, site.Key.Offset, site.Key.Opcode, 0);
|
||||
output.AppendLine($"[step-limit] {site.Count(),4}x {Location(sample)}");
|
||||
}
|
||||
|
||||
int tailStart = Math.Max(0, snapshot.RecentSteps.Count - Math.Max(0, tailSteps));
|
||||
output.AppendLine($"[step-limit] final {snapshot.RecentSteps.Count - tailStart} steps:");
|
||||
for (int index = tailStart; index < snapshot.RecentSteps.Count; index++)
|
||||
output.AppendLine($"[step-limit] {Location(snapshot.RecentSteps[index])}");
|
||||
return output.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
52
engine/Age.Engine.Frontend/WindowLaunchOptions.cs
Normal file
52
engine/Age.Engine.Frontend/WindowLaunchOptions.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-only window dimensions resolved from Godot user arguments. These never redefine the
|
||||
/// SYS4 logical canvas, VM coordinates, or AGE surface dimensions.
|
||||
/// </summary>
|
||||
public readonly record struct WindowLaunchOptions(
|
||||
int Width, int Height, bool WidthOverridden, bool HeightOverridden)
|
||||
{
|
||||
public const int MaximumDimension = Sys4LogicalCanvas.MaximumDimension;
|
||||
public bool IsOverridden => WidthOverridden || HeightOverridden;
|
||||
|
||||
public static WindowLaunchOptions Resolve(
|
||||
IReadOnlyList<string> arguments, Sys4LogicalCanvas logicalCanvas)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(arguments);
|
||||
int width = logicalCanvas.Width;
|
||||
int height = logicalCanvas.Height;
|
||||
bool widthOverridden = false;
|
||||
bool heightOverridden = false;
|
||||
|
||||
for (int index = 0; index < arguments.Count; index++)
|
||||
{
|
||||
string argument = arguments[index];
|
||||
if (argument is not ("--window-width" or "--window-height")) continue;
|
||||
if (index + 1 >= arguments.Count)
|
||||
throw new ArgumentException($"{argument} requires a pixel value");
|
||||
|
||||
string raw = arguments[++index];
|
||||
if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
|
||||
|| value <= 0 || value > MaximumDimension)
|
||||
throw new ArgumentException(
|
||||
$"{argument} must be an integer from 1 through {MaximumDimension}; got '{raw}'");
|
||||
|
||||
if (argument == "--window-width")
|
||||
{
|
||||
width = value;
|
||||
widthOverridden = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
height = value;
|
||||
heightOverridden = true;
|
||||
}
|
||||
}
|
||||
|
||||
return new WindowLaunchOptions(width, height, widthOverridden, heightOverridden);
|
||||
}
|
||||
}
|
||||
@@ -22,21 +22,15 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Age.Engine\Age.Engine.csproj" />
|
||||
<ProjectReference Include="..\Age.Engine.Frontend\Age.Engine.Frontend.csproj" />
|
||||
<ProjectReference Include="..\Age.Engine.Text.Windows\Age.Engine.Text.Windows.csproj" />
|
||||
<Compile Include="..\..\godot\IMovieDecoder.cs" Link="IMovieDecoder.cs" />
|
||||
<Compile Include="..\..\godot\MovieRuntime.cs" Link="MovieRuntime.cs" />
|
||||
<Compile Include="..\..\godot\MovieAudioTimeline.cs" Link="MovieAudioTimeline.cs" />
|
||||
<Compile Include="..\..\godot\FfmpegMovieNative.cs" Link="FfmpegMovieNative.cs" />
|
||||
<Compile Include="..\..\godot\FfmpegMovieDecoder.cs" Link="FfmpegMovieDecoder.cs" />
|
||||
<Compile Include="..\..\godot\PageLocatorState.cs" Link="PageLocatorState.cs" />
|
||||
<Compile Include="..\..\godot\GodotTimelineLog.cs" Link="GodotTimelineLog.cs" />
|
||||
<Compile Include="..\..\godot\GodotTraceSink.cs" Link="GodotTraceSink.cs" />
|
||||
<Compile Include="..\..\godot\StepLimitDiagnosticFormatter.cs" Link="StepLimitDiagnosticFormatter.cs" />
|
||||
<Compile Include="..\..\godot\GodotVmOptions.cs" Link="GodotVmOptions.cs" />
|
||||
<Compile Include="..\..\godot\PerformanceFrameLog.cs" Link="PerformanceFrameLog.cs" />
|
||||
<Compile Include="..\..\godot\MovieSurfaceRegistry.cs" Link="MovieSurfaceRegistry.cs" />
|
||||
<Compile Include="..\..\godot\RiffWaveSanitizer.cs" Link="RiffWaveSanitizer.cs" />
|
||||
<Compile Include="..\..\godot\WindowLaunchOptions.cs" Link="WindowLaunchOptions.cs" />
|
||||
<Compile Include="..\..\tools\movie-corpus-gate\MovieCorpusGate.cs" Link="MovieCorpusGate.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Age.Engine", "Age.Engine\Age.Engine.csproj", "{DD04FB21-F83D-4547-BBEC-97295F30623A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Age.Cli", "Age.Cli\Age.Cli.csproj", "{795573A0-A283-4A28-B26C-606C1AA5E1FE}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Age.Engine", "Age.Engine\Age.Engine.csproj", "{DD04FB21-F83D-4547-BBEC-97295F30623A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Age.Engine.Frontend", "Age.Engine.Frontend\Age.Engine.Frontend.csproj", "{2B018D3E-4057-465E-89CB-4CC77FDD60E2}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Age.Cli", "Age.Cli\Age.Cli.csproj", "{795573A0-A283-4A28-B26C-606C1AA5E1FE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Age.Engine.Tests", "Age.Engine.Tests\Age.Engine.Tests.csproj", "{2ED171C8-DA87-40FD-A6FB-3B3F6501029F}"
|
||||
EndProject
|
||||
@@ -22,8 +24,12 @@ Global
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{DD04FB21-F83D-4547-BBEC-97295F30623A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DD04FB21-F83D-4547-BBEC-97295F30623A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DD04FB21-F83D-4547-BBEC-97295F30623A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DD04FB21-F83D-4547-BBEC-97295F30623A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DD04FB21-F83D-4547-BBEC-97295F30623A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DD04FB21-F83D-4547-BBEC-97295F30623A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2B018D3E-4057-465E-89CB-4CC77FDD60E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2B018D3E-4057-465E-89CB-4CC77FDD60E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2B018D3E-4057-465E-89CB-4CC77FDD60E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2B018D3E-4057-465E-89CB-4CC77FDD60E2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{795573A0-A283-4A28-B26C-606C1AA5E1FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{795573A0-A283-4A28-B26C-606C1AA5E1FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{795573A0-A283-4A28-B26C-606C1AA5E1FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
Reference in New Issue
Block a user