Add frontend production project

This commit is contained in:
gamer147
2026-08-03 11:25:08 -04:00
parent ec6ba1c64c
commit 511badae6d
14 changed files with 56 additions and 16 deletions

View 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>

View 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(); } }
}

View 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);

View 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);
}

View 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();
}
}
}

View 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;
}
}

View 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();
}
}

View 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);
}
}