Each of these was temporary apparatus added to chase one bug, and each was supposed to be deleted in the commit that fixed it. Fourteen closed issues later they were still here: #337's support/wire-mesh trio, #171's sticky timeline, #119's viewer and entity dumps, #113's phantom probe, and a dozen more. 3,493 lines removed; the client now reads 144 environment variables instead of 161, and 47 temporary probes remain instead of 64. This is not only tidying. Every probe leaves a branch on its hot path when unset, several re-read the environment per call rather than caching, and the volume buries the diagnostics that are actually load-bearing. It is also a headless correctness matter: HeadlessStaticStateAudit reflects over PhysicsDiagnostics' flags to refuse a multi-session host when any is set, and cannot see probes that live outside that owner. Four files went entirely — WalkMissDiagnostic.cs, CollisionMeshWireframe.cs and two test files whose only subject was a deleted probe. TransitionTypes.SetContactPlane also sheds its CallerMemberName / CallerLineNumber parameters, which existed solely for #337's cpSrc= attribution and carried the instruction to strip them with the probe family; no call site passed them, so no behavior changes. F2's collision overlay survives and reverts to its proxy-cylinder form, which is what removing the ACDREAM_WIRE_MESH upgrade means. LaunchOptionsDocumentationTests earned its keep here: it refused the deletion until docs/launch-options.md moved the 17 rows into Retired and the frozen direct-read counts came down (PhysicsEngine.cs to zero, TransitionTypes.cs 3 to 2). The documentation could not drift during a cleanup this wide. The 14 probes that name no owning issue are deliberately NOT deleted. Nothing records when they became safe to remove, and guessing is how a future investigation loses apparatus it needed; #435 stays open for their attribution. Full hermetic suite 15,321 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
244 lines
10 KiB
C#
244 lines
10 KiB
C#
using System.Text.RegularExpressions;
|
|
|
|
namespace AcDream.App.Tests.Diagnostics;
|
|
|
|
/// <summary>
|
|
/// Keeps <c>docs/launch-options.md</c> honest: every environment variable the
|
|
/// shipped client reads must have a documented row, and every documented row
|
|
/// must name a variable something actually reads.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A hand-maintained list of ~170 flags goes stale within a week. #432 is what
|
|
/// that costs: <c>ACDREAM_AUTOMATION_ARTIFACT_DIR</c> read like an output-path
|
|
/// setting but also constructed a per-frame diagnostics referee worth ~6 MB and
|
|
/// ~14 ms every frame, and three days of measurements were taxed before anyone
|
|
/// noticed. The same audit found CLAUDE.md still advertising
|
|
/// <c>ACDREAM_RUN_SKILL</c> / <c>ACDREAM_JUMP_SKILL</c> after their read sites
|
|
/// were deleted. Both directions of drift are failures, so both fail here.
|
|
/// </para>
|
|
/// <para>
|
|
/// Scope is <c>src/</c> only. Test-owned variables, shader-compiler macro
|
|
/// tokens under <c>tools/</c>, and historical mentions in dated research
|
|
/// documents are deliberately out of scope — the doc describes what a launched
|
|
/// client reads today.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class LaunchOptionsDocumentationTests
|
|
{
|
|
private const string DocRelativePath = "docs/launch-options.md";
|
|
|
|
/// <summary>
|
|
/// Structure rule 5 wants runtime flags behind diagnostic owner classes and
|
|
/// rule 4 wants startup configuration in <c>RuntimeOptions</c>. These files
|
|
/// still read the environment directly, with the exact number of distinct
|
|
/// flags each one reads today.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The counts are FROZEN, not merely the file names: promoting a stray to
|
|
/// its subsystem's owner lowers a number (update it here), and adding a new
|
|
/// direct read raises one and fails. A file that reaches zero leaves the
|
|
/// table entirely.
|
|
/// </remarks>
|
|
private static readonly IReadOnlyDictionary<string, int> DirectReadDebt =
|
|
new Dictionary<string, int>(StringComparer.Ordinal)
|
|
{
|
|
["src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs"] = 2,
|
|
["src/AcDream.App/Physics/RemoteServerControlledVelocityCycle.cs"] = 1,
|
|
["src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs"] = 1,
|
|
["src/AcDream.App/Rendering/GameWindow.cs"] = 1,
|
|
["src/AcDream.App/Rendering/PortalVisibilityBuilder.cs"] = 1,
|
|
["src/AcDream.App/Rendering/Sky/SkyRenderer.cs"] = 1,
|
|
["src/AcDream.App/Rendering/TextureCache.cs"] = 1,
|
|
["src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs"] = 2,
|
|
["src/AcDream.Core/Physics/TransitionTypes.cs"] = 2,
|
|
["src/AcDream.Core/Vfx/PhysicsScriptRunner.cs"] = 1,
|
|
["src/AcDream.Core/World/SkyDescLoader.cs"] = 2,
|
|
["src/AcDream.Core.Net/GameEventWiring.cs"] = 1,
|
|
["src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs"] = 1,
|
|
["src/AcDream.Core.Net/Messages/UpdateMotion.cs"] = 1,
|
|
["src/AcDream.Core.Net/WorldSession.cs"] = 3,
|
|
["src/AcDream.Platform/ApplicationPathSet.cs"] = 3,
|
|
["src/AcDream.Platform/BakePublicationGuardPaths.cs"] = 1,
|
|
["src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs"] = 1,
|
|
["src/AcDream.UI.Abstractions/Settings/QualityPreset.cs"] = 6,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Any <c>ACDREAM_*</c> string literal in <c>src/</c>. Matching the literal
|
|
/// rather than a <c>GetEnvironmentVariable</c> call is deliberate: the
|
|
/// startup path reads through an injected <c>env</c> delegate
|
|
/// (<c>RuntimeOptions.Parse</c>) so a call-shaped pattern silently missed
|
|
/// ACDREAM_LIVE, ACDREAM_PAK_PATH and every other production flag. Comment
|
|
/// mentions (shader macros such as ACDREAM_SAMPLE_2D, prefix fragments)
|
|
/// carry no quotes and stay out.
|
|
/// </summary>
|
|
private static readonly Regex EnvironmentRead = new(
|
|
@"""(ACDREAM_[A-Z0-9_]+)""",
|
|
RegexOptions.Compiled);
|
|
|
|
private static readonly Regex DocumentedRow = new(
|
|
@"^\|\s*`(ACDREAM_[A-Z0-9_]+)`",
|
|
RegexOptions.Compiled | RegexOptions.Multiline);
|
|
|
|
[Fact]
|
|
public void EveryEnvironmentVariableTheClientReadsIsDocumented()
|
|
{
|
|
IReadOnlySet<string> read = ReadFlags();
|
|
IReadOnlySet<string> documented = DocumentedFlags();
|
|
|
|
List<string> undocumented = read.Except(documented).Order(StringComparer.Ordinal).ToList();
|
|
Assert.True(
|
|
undocumented.Count == 0,
|
|
$"{DocRelativePath} is missing a row for flags the client reads: "
|
|
+ string.Join(", ", undocumented)
|
|
+ ". Add the row in the same commit that adds the read site.");
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryDocumentedEnvironmentVariableStillExists()
|
|
{
|
|
IReadOnlySet<string> read = ReadFlags();
|
|
IReadOnlySet<string> documented = DocumentedFlags();
|
|
|
|
List<string> phantom = documented.Except(read).Order(StringComparer.Ordinal).ToList();
|
|
Assert.True(
|
|
phantom.Count == 0,
|
|
$"{DocRelativePath} documents flags nothing in src/ reads: "
|
|
+ string.Join(", ", phantom)
|
|
+ ". Delete the row (or move it to the retired section with its "
|
|
+ "removal commit) in the same commit that deletes the read site.");
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectEnvironmentReadsOutsideOwnerClassesDoNotGrow()
|
|
{
|
|
Dictionary<string, int> actual = SourceFiles()
|
|
.Where(file => !IsOwnerClass(file.RelativePath))
|
|
.Select(file => (
|
|
file.RelativePath,
|
|
Count: DistinctFlagsRead(File.ReadAllText(file.Path))))
|
|
.Where(file => file.Count > 0)
|
|
.ToDictionary(file => file.RelativePath, file => file.Count, StringComparer.Ordinal);
|
|
|
|
List<string> problems = [];
|
|
foreach ((string path, int count) in actual.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
|
{
|
|
if (!DirectReadDebt.TryGetValue(path, out int frozen))
|
|
{
|
|
problems.Add(
|
|
$"{path} reads {count} ACDREAM_* variable(s) directly but is "
|
|
+ "not an owner class. Add the property to the subsystem's "
|
|
+ "diagnostics owner (or RuntimeOptions) and read it there.");
|
|
}
|
|
else if (count > frozen)
|
|
{
|
|
problems.Add(
|
|
$"{path} grew from {frozen} to {count} direct reads. New "
|
|
+ "flags belong in an owner class, not here.");
|
|
}
|
|
}
|
|
|
|
foreach ((string path, int frozen) in DirectReadDebt.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
|
{
|
|
int count = actual.GetValueOrDefault(path, 0);
|
|
if (count < frozen)
|
|
{
|
|
problems.Add(
|
|
$"{path} is down to {count} direct read(s) from {frozen}. "
|
|
+ "Lower the frozen count (or drop the entry at zero) so the "
|
|
+ "debt cannot silently grow back.");
|
|
}
|
|
}
|
|
|
|
Assert.True(problems.Count == 0, string.Join("\n", problems));
|
|
}
|
|
|
|
private static bool IsOwnerClass(string relativePath)
|
|
{
|
|
// The intended homes for environment reads: one static diagnostics
|
|
// class per subsystem, the typed startup options objects, the
|
|
// executables' own entry points, and the single-purpose probe/capture
|
|
// owners (a file that exists only to own one probe already satisfies
|
|
// the rule the *Diagnostics.cs suffix encodes).
|
|
string name = Path.GetFileName(relativePath);
|
|
return name.EndsWith("Diagnostics.cs", StringComparison.Ordinal)
|
|
|| name.EndsWith("Options.cs", StringComparison.Ordinal)
|
|
|| name.EndsWith("Probe.cs", StringComparison.Ordinal)
|
|
|| name.EndsWith("Capture.cs", StringComparison.Ordinal)
|
|
|| name == "Program.cs";
|
|
}
|
|
|
|
private static int DistinctFlagsRead(string source)
|
|
{
|
|
var flags = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (Match match in EnvironmentRead.Matches(source))
|
|
flags.Add(match.Groups[1].Value);
|
|
return flags.Count;
|
|
}
|
|
|
|
private static IReadOnlySet<string> ReadFlags()
|
|
{
|
|
var flags = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach ((string path, _) in SourceFiles())
|
|
{
|
|
foreach (Match match in EnvironmentRead.Matches(File.ReadAllText(path)))
|
|
flags.Add(match.Groups[1].Value);
|
|
}
|
|
|
|
Assert.True(
|
|
flags.Count > 100,
|
|
$"Only found {flags.Count} environment reads in src/; the scanner "
|
|
+ "is probably broken rather than the codebase suddenly clean.");
|
|
return flags;
|
|
}
|
|
|
|
private static IReadOnlySet<string> DocumentedFlags()
|
|
{
|
|
string doc = Path.Combine(FindRepoRoot(), DocRelativePath.Replace('/', Path.DirectorySeparatorChar));
|
|
Assert.True(File.Exists(doc), $"{DocRelativePath} is missing.");
|
|
|
|
string text = File.ReadAllText(doc);
|
|
// Rows below the retired marker describe flags that are deliberately
|
|
// gone; they document history and must not resurrect the read-site
|
|
// requirement.
|
|
int retired = text.IndexOf("<!-- retired -->", StringComparison.Ordinal);
|
|
if (retired >= 0)
|
|
text = text[..retired];
|
|
|
|
var flags = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (Match match in DocumentedRow.Matches(text))
|
|
flags.Add(match.Groups[1].Value);
|
|
return flags;
|
|
}
|
|
|
|
private static IEnumerable<(string Path, string RelativePath)> SourceFiles()
|
|
{
|
|
string root = FindRepoRoot();
|
|
string src = Path.Combine(root, "src");
|
|
foreach (string path in Directory.EnumerateFiles(src, "*.cs", SearchOption.AllDirectories))
|
|
{
|
|
string relative = Path.GetRelativePath(root, path).Replace('\\', '/');
|
|
if (relative.Contains("/bin/", StringComparison.Ordinal)
|
|
|| relative.Contains("/obj/", StringComparison.Ordinal))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
yield return (path, relative);
|
|
}
|
|
}
|
|
|
|
private static string FindRepoRoot()
|
|
{
|
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
|
return directory.FullName;
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
|
}
|
|
}
|