Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions .cursor/skills/memory-snapshot-report/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,42 @@ dotnet run --project Cli/MemorySnapshotDataTools.Cli.csproj -- export <path/to/s
- For SQLite add `--destination sqlite`.
- `--verbose` prints progress and timings (parse+extract vs. write).

### 2. Generate HTML report
**Batch export** every `.snap` in a directory to `<basename>.duckdb` alongside each file:

```bash
dotnet run --project Cli/MemorySnapshotDataTools.Cli.csproj -- batch-export <directory> \
--filter MyGame \
--skip-existing \
--verbose
```

- `--filter` is optional (case-insensitive substring on filenames).
- `--skip-existing` skips when the output DB is newer than the snap.
- Exit code `0` = all succeeded, `1` = one or more failures, `2` = cancelled.

### 2. Validate export against Unity golden JSON

The golden extractor lives in the `com.unity.memory-snapshot-data-tools` package under `UnityPackage/`
in this repo, imported into U6TestBed via a local `file:` path in `Packages/manifest.json`. After extracting
`*_golden.json` in Unity (**Tools → Memory Snapshot Validation → Extract Golden Values**):

```bash
dotnet run --project Cli/MemorySnapshotDataTools.Cli.csproj -- validate \
<path/to/snapshot_golden.json> \
<path/to/exported.duckdb>
```

- Compares AssetBundle, SerializedFile (Unity Subsystems native roots), and Remapper metrics.
- Also compares the MemoryProfiler **Summary** page metrics from the `summary_metrics` table:
- *Allocated Memory Distribution*: Total Allocated, Total Resident, Native, Managed,
Executables & Mapped, Graphics (Estimated), Untracked.
- *Managed Heap Utilization*: Virtual Machine, Objects, Empty Heap Space.
- Committed bytes use a 1% / 64 KB tolerance (5% / 1 MB for the estimated Graphics and Untracked rows);
resident bytes use 1% / 64 KB and are skipped for Graphics and Untracked (resident unavailable).
- Writes `*_validation_result.json` next to the golden file unless `--out` is set.
- Exit code `0` = pass, `1` = metric mismatch, `3` = error.

### 3. Generate HTML report

```bash
dotnet run --project Cli/MemorySnapshotDataTools.Cli.csproj -- report <path/to/output.duckdb> --out report.html --verbose
Expand All @@ -40,10 +75,10 @@ dotnet run --project Cli/MemorySnapshotDataTools.Cli.csproj -- report <path/to/o
- Use `--title "My Report"` to set the report title.
- Report works with either DuckDB or SQLite databases produced by the export command.

### 3. Optional
### 4. Optional

- Open the generated HTML file or DB in the user’s preferred viewer.
- For ad-hoc SQL, use the same DB path; tables include `snapshot_info`, `native_objects`, `managed_objects`, `connections`, `native_roots`, `memory_regions`, `native_allocations`.
- For ad-hoc SQL, use the same DB path; tables include `snapshot_info`, `native_objects`, `managed_objects`, `connections`, `native_roots`, `memory_regions`, `native_allocations`, `system_memory_regions`, and `summary_metrics` (MemoryProfiler Summary-page breakdown).

## Domain

Expand Down
13 changes: 13 additions & 0 deletions Cli/CliOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ namespace MemorySnapshotDataTools.Cli;
internal enum CommandKind
{
Export,
BatchExport,
Report,
MultiReport,
ValidateGolden,
Summary,
}

/// <summary>
Expand All @@ -19,6 +23,15 @@ internal sealed class CliOptions
public string ReportDbPath { get; set; } = string.Empty;
public string? ReportOutputPath { get; set; }
public string ReportTitle { get; set; } = "Memory Snapshot Report";
public string BatchExportDirectory { get; set; } = string.Empty;
public string? BatchExportFilter { get; set; }
public bool SkipExisting { get; set; }
public bool ContinueOnError { get; set; } = true;
public string MultiReportDirectory { get; set; } = string.Empty;
public string? MultiReportFilter { get; set; }
public string GoldenPath { get; set; } = string.Empty;
public string? ValidationOutputPath { get; set; }
public string SummaryInputPath { get; set; } = string.Empty;
public int BatchSize { get; set; } = 2048;
public int QueueCapacity { get; set; } = 256;
public ValidationMode Validate { get; set; } = ValidationMode.Minimal;
Expand Down
243 changes: 237 additions & 6 deletions Cli/CommandLineBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ namespace MemorySnapshotDataTools.Cli;
/// </summary>
internal static class CommandLineBuilder
{
public static RootCommand Build(Func<CliOptions, int> runExport, Func<CliOptions, int> runReport)
public static RootCommand Build(
Func<CliOptions, int> runExport,
Func<CliOptions, int> runBatchExport,
Func<CliOptions, int> runReport,
Func<CliOptions, int> runMultiReport,
Func<CliOptions, int> runValidateGolden,
Func<CliOptions, int> runSummary)
{
var root = new RootCommand("Export Unity memory snapshots to DuckDB or SQLite and generate HTML reports.");

Expand Down Expand Up @@ -54,11 +60,7 @@ public static RootCommand Build(Func<CliOptions, int> runExport, Func<CliOptions
Description = "Print progress updates.",
};

exportCmd.Add(batchSizeOpt);
exportCmd.Add(queueCapacityOpt);
exportCmd.Add(validateOpt);
exportCmd.Add(destinationOpt);
exportCmd.Add(verboseOpt);
AddExportOptions(exportCmd, batchSizeOpt, queueCapacityOpt, validateOpt, destinationOpt, verboseOpt);

exportCmd.SetAction((ParseResult parseResult) =>
{
Expand All @@ -83,6 +85,90 @@ public static RootCommand Build(Func<CliOptions, int> runExport, Func<CliOptions
return runExport(options);
});

// ---- batch-export ----
var batchExportCmd = new Command(
"batch-export",
"Export every .snap file in a directory to a .duckdb or .db file with the same basename.");
var batchDirectoryArg = new Argument<string>("directory")
{
Description = "Directory containing .snap files (top level only).",
Arity = ArgumentArity.ExactlyOne,
};
batchExportCmd.Add(batchDirectoryArg);

var batchFilterOpt = new Option<string?>("--filter")
{
Description = "Case-insensitive substring filter on snapshot filenames (e.g. MyGame).",
};
var skipExistingOpt = new Option<bool>("--skip-existing")
{
Description = "Skip when the output database exists and is newer than the .snap file.",
};
var continueOnErrorOpt = new Option<bool>("--continue-on-error")
{
Description = "Continue exporting after a single-file failure.",
DefaultValueFactory = _ => true,
};

var batchBatchSizeOpt = new Option<int>("--batch-size")
{
Description = "Rows per produced batch.",
DefaultValueFactory = _ => 2048,
};
var batchQueueCapacityOpt = new Option<int>("--queue-capacity")
{
Description = "Max queued batches.",
DefaultValueFactory = _ => 256,
};
var batchValidateOpt = new Option<string>("--validate")
{
Description = "Validation mode: none, minimal, or full.",
DefaultValueFactory = _ => "minimal",
};
batchValidateOpt.AcceptOnlyFromAmong("none", "minimal", "full");
var batchDestinationOpt = new Option<string>("--destination")
{
Description = "Export backend: duckdb or sqlite.",
DefaultValueFactory = _ => "duckdb",
};
batchDestinationOpt.AcceptOnlyFromAmong("duckdb", "sqlite");
var batchVerboseOpt = new Option<bool>("--verbose")
{
Description = "Print progress updates.",
};

batchExportCmd.Add(batchFilterOpt);
batchExportCmd.Add(skipExistingOpt);
batchExportCmd.Add(continueOnErrorOpt);
AddExportOptions(batchExportCmd, batchBatchSizeOpt, batchQueueCapacityOpt, batchValidateOpt, batchDestinationOpt, batchVerboseOpt);

batchExportCmd.SetAction((ParseResult parseResult) =>
{
var directory = ExpandPath(parseResult.GetValue(batchDirectoryArg)!);
if (!Directory.Exists(directory))
{
Console.Error.WriteLine($"Directory not found: {directory}");
return 1;
}

var options = new CliOptions
{
Command = CommandKind.BatchExport,
BatchExportDirectory = directory,
BatchExportFilter = parseResult.GetValue(batchFilterOpt),
SkipExisting = parseResult.GetValue(skipExistingOpt),
ContinueOnError = parseResult.GetValue(continueOnErrorOpt),
BatchSize = parseResult.GetValue(batchBatchSizeOpt),
QueueCapacity = parseResult.GetValue(batchQueueCapacityOpt),
Validate = ParseValidationMode(parseResult.GetValue(batchValidateOpt)!),
Destination = parseResult.GetValue(batchDestinationOpt)!.ToLowerInvariant() == "sqlite"
? DestinationKind.Sqlite
: DestinationKind.DuckDb,
Verbose = parseResult.GetValue(batchVerboseOpt),
};
return runBatchExport(options);
});

// ---- report ----
var reportCmd = new Command("report", "Generate an HTML report from an exported database.");
var databaseArg = new Argument<string>("database")
Expand Down Expand Up @@ -130,11 +216,156 @@ public static RootCommand Build(Func<CliOptions, int> runExport, Func<CliOptions
return runReport(options);
});

// ---- multi-report ----
var multiReportCmd = new Command("multi-report", "Generate an HTML report across multiple exported databases in a directory.");
var directoryArg = new Argument<string>("directory")
{
Description = "Directory containing .duckdb or .db snapshot databases.",
Arity = ArgumentArity.ExactlyOne,
};
multiReportCmd.Add(directoryArg);

var filterOpt = new Option<string?>("--filter")
{
Description = "Case-insensitive substring filter on database filenames (e.g. MyGame).",
};
var multiOutOpt = new Option<string?>("--out")
{
Description = "Output HTML file path (default: temp file + open in browser).",
};
var multiTitleOpt = new Option<string>("--title")
{
Description = "Report title.",
DefaultValueFactory = _ => "Multi-Snapshot Memory Report",
};
var multiVerboseOpt = new Option<bool>("--verbose")
{
Description = "Print progress and timings.",
};

multiReportCmd.Add(filterOpt);
multiReportCmd.Add(multiOutOpt);
multiReportCmd.Add(multiTitleOpt);
multiReportCmd.Add(multiVerboseOpt);

multiReportCmd.SetAction((ParseResult parseResult) =>
{
var directory = ExpandPath(parseResult.GetValue(directoryArg)!);
if (!Directory.Exists(directory))
{
Console.Error.WriteLine($"Directory not found: {directory}");
return 1;
}

var outPath = parseResult.GetValue(multiOutOpt);
var options = new CliOptions
{
Command = CommandKind.MultiReport,
MultiReportDirectory = directory,
MultiReportFilter = parseResult.GetValue(filterOpt),
ReportOutputPath = string.IsNullOrWhiteSpace(outPath) ? null : ExpandPath(outPath!),
ReportTitle = parseResult.GetValue(multiTitleOpt)!,
Verbose = parseResult.GetValue(multiVerboseOpt),
};
return runMultiReport(options);
});

// ---- validate ----
var validateCmd = new Command(
"validate",
"Compare an exported database against a Unity golden JSON file.");
var goldenArg = new Argument<string>("golden")
{
Description = "Path to *_golden.json from Unity GoldenValueExtractor.",
Arity = ArgumentArity.ExactlyOne,
};
var validateDatabaseArg = new Argument<string>("database")
{
Description = "Path to the exported .duckdb or .db file.",
Arity = ArgumentArity.ExactlyOne,
};
validateCmd.Add(goldenArg);
validateCmd.Add(validateDatabaseArg);

var validateOutOpt = new Option<string?>("--out")
{
Description = "Output validation result JSON path (default: next to golden file).",
};
validateCmd.Add(validateOutOpt);

validateCmd.SetAction((ParseResult parseResult) =>
{
var goldenPath = ExpandPath(parseResult.GetValue(goldenArg)!);
var databasePath = ExpandPath(parseResult.GetValue(validateDatabaseArg)!);
var options = new CliOptions
{
Command = CommandKind.ValidateGolden,
GoldenPath = goldenPath,
ReportDbPath = databasePath,
ValidationOutputPath = parseResult.GetValue(validateOutOpt),
};
return runValidateGolden(options);
});

// ---- summary ----
var summaryCmd = new Command(
"summary",
"Print a high-level memory-usage summary for a snapshot or exported database (no database is generated).");
var summaryInputArg = new Argument<string>("input")
{
Description = "Path to a .snap snapshot or an exported .duckdb/.db database.",
Arity = ArgumentArity.ExactlyOne,
};
summaryCmd.Add(summaryInputArg);

var summaryVerboseOpt = new Option<bool>("--verbose")
{
Description = "Print progress while decoding a snapshot.",
};
summaryCmd.Add(summaryVerboseOpt);

summaryCmd.SetAction((ParseResult parseResult) =>
{
var inputPath = ExpandPath(parseResult.GetValue(summaryInputArg)!);
if (!File.Exists(inputPath))
{
Console.Error.WriteLine($"Input file not found: {inputPath}");
return 1;
}

var options = new CliOptions
{
Command = CommandKind.Summary,
SummaryInputPath = inputPath,
Verbose = parseResult.GetValue(summaryVerboseOpt),
};
return runSummary(options);
});

root.Add(exportCmd);
root.Add(batchExportCmd);
root.Add(reportCmd);
root.Add(multiReportCmd);
root.Add(validateCmd);
root.Add(summaryCmd);
return root;
}

private static void AddExportOptions(
Command command,
Option<int> batchSizeOpt,
Option<int> queueCapacityOpt,
Option<string> validateOpt,
Option<string> destinationOpt,
Option<bool> verboseOpt)
{
command.Add(batchSizeOpt);
command.Add(queueCapacityOpt);
command.Add(validateOpt);
command.Add(destinationOpt);
command.Add(verboseOpt);
}

private static ValidationMode ParseValidationMode(string value)
{
return value.ToLowerInvariant() switch
Expand Down
2 changes: 1 addition & 1 deletion Cli/MemorySnapshotDataTools.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<LangVersion>latest</LangVersion>
<RootNamespace>MemorySnapshotDataTools.Cli</RootNamespace>
<AssemblyName>MemorySnapshotDataTools</AssemblyName>
<Version>0.1.0</Version>
<Version>0.2.0</Version>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
</PropertyGroup>
Expand Down
Loading
Loading