diff --git a/src/tests/JIT/Directed/debugging/debuginfo/README.txt b/src/tests/JIT/Directed/debugging/debuginfo/README.txt
new file mode 100644
index 00000000000000..5f617336c94ae1
--- /dev/null
+++ b/src/tests/JIT/Directed/debugging/debuginfo/README.txt
@@ -0,0 +1,24 @@
+This directory contains tests for debugging information generated by the JIT.
+The tests are written in IL inside the tests.il file by creating a method in the
+DebugInfoMethods class and marking them with the ExpectedILMappings attribute.
+In that attribute, the IL offsets at which mappings are expected to be generated
+can be specified for both debug (DebuggableAttribute with
+DisableOptimizations) and optimized builds.
+
+To debug these tests, run the 'tester' project, which will JIT all methods in
+tests.il in both debug and release (you may need to turn off tiered
+compilation).
+
+* attribute.cs/csproj: Project containing ExpectedILMappingsAttribute, to avoid
+ circular dependencies
+
+* tests.il: File containing the tests marked with ExpectedILMappings
+
+* tests_d.ilproj/tests_r.ilproj: Both these projects just add tests.il, the only
+ difference is that the former has DebuggableAttribute with
+ DisableOptimizations and the latter does not.
+
+* tester.cs/csproj: The orchestrator of the tests, references tests_d and
+ tests_r and jits the test methods using RuntimeHelpers.PrepareMethod, collects
+ the IL mappings emitted using runtime events, and validates that the mappings
+ match the data in ExpectedILMappings.
diff --git a/src/tests/JIT/Directed/debugging/debuginfo/attribute.cs b/src/tests/JIT/Directed/debugging/debuginfo/attribute.cs
new file mode 100644
index 00000000000000..a1eb62d17c805f
--- /dev/null
+++ b/src/tests/JIT/Directed/debugging/debuginfo/attribute.cs
@@ -0,0 +1,11 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+
+[AttributeUsage(AttributeTargets.Method)]
+public class ExpectedILMappings : Attribute
+{
+ public int[] Debug { get; set; }
+ public int[] Opts { get; set; }
+}
diff --git a/src/tests/JIT/Directed/debugging/debuginfo/attribute.csproj b/src/tests/JIT/Directed/debugging/debuginfo/attribute.csproj
new file mode 100644
index 00000000000000..46f3565c362fea
--- /dev/null
+++ b/src/tests/JIT/Directed/debugging/debuginfo/attribute.csproj
@@ -0,0 +1,10 @@
+
+
+ Library
+ false
+ BuildOnly
+
+
+
+
+
diff --git a/src/tests/JIT/Directed/debugging/debuginfo/isdebug.il b/src/tests/JIT/Directed/debugging/debuginfo/isdebug.il
new file mode 100644
index 00000000000000..2998979d1ad194
--- /dev/null
+++ b/src/tests/JIT/Directed/debugging/debuginfo/isdebug.il
@@ -0,0 +1 @@
+#define DEBUG
\ No newline at end of file
diff --git a/src/tests/JIT/Directed/debugging/debuginfo/tester.cs b/src/tests/JIT/Directed/debugging/debuginfo/tester.cs
new file mode 100644
index 00000000000000..6f501c96f0020b
--- /dev/null
+++ b/src/tests/JIT/Directed/debugging/debuginfo/tester.cs
@@ -0,0 +1,149 @@
+extern alias tests_d;
+extern alias tests_r;
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.Tracing;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using Microsoft.Diagnostics.Tools.RuntimeClient;
+using Microsoft.Diagnostics.Tracing;
+using Microsoft.Diagnostics.Tracing.Parsers;
+using Microsoft.Diagnostics.Tracing.Parsers.Clr;
+using Tracing.Tests.Common;
+using DebugInfoMethodsD = tests_d::DebugInfoMethods;
+using DebugInfoMethodsR = tests_r::DebugInfoMethods;
+
+public unsafe class DebugInfoTest
+{
+ public static unsafe int Main()
+ {
+ var keywords =
+ ClrTraceEventParser.Keywords.Jit | ClrTraceEventParser.Keywords.JittedMethodILToNativeMap;
+
+ var dotnetRuntimeProvider = new List
+ {
+ new Provider("Microsoft-Windows-DotNETRuntime", eventLevel: EventLevel.Verbose, keywords: (ulong)keywords)
+ };
+
+ var config = new SessionConfiguration(1024, EventPipeSerializationFormat.NetTrace, dotnetRuntimeProvider);
+
+ return
+ IpcTraceTest.RunAndValidateEventCounts(
+ new Dictionary(),
+ JitMethods,
+ config,
+ ValidateMappings);
+ }
+
+ private static void JitMethods()
+ {
+ ProcessType(typeof(DebugInfoMethodsD));
+ ProcessType(typeof(DebugInfoMethodsR));
+ }
+
+ private static void ProcessType(Type t)
+ {
+ foreach (MethodInfo mi in t.GetMethods())
+ {
+ if (mi.GetCustomAttribute() != null)
+ {
+ RuntimeHelpers.PrepareMethod(mi.MethodHandle);
+ }
+ }
+ }
+
+ private static Func ValidateMappings(EventPipeEventSource source)
+ {
+ List<(long MethodID, OptimizationTier Tier, (int ILOffset, int NativeOffset)[] Mappings)> methodsWithMappings = new();
+ Dictionary methodTier = new();
+
+ source.Clr.MethodLoad += e => methodTier[e.MethodID] = e.OptimizationTier;
+ source.Clr.MethodLoadVerbose += e => methodTier[e.MethodID] = e.OptimizationTier;
+ source.Clr.MethodILToNativeMap += e =>
+ {
+ var mappings = new (int, int)[e.CountOfMapEntries];
+ for (int i = 0; i < mappings.Length; i++)
+ mappings[i] = (e.ILOffset(i), e.NativeOffset(i));
+
+ if (!methodTier.TryGetValue(e.MethodID, out OptimizationTier tier))
+ tier = OptimizationTier.Unknown;
+
+ methodsWithMappings.Add((e.MethodID, tier, mappings));
+ };
+
+ return () =>
+ {
+ int result = 100;
+ foreach ((long methodID, OptimizationTier tier, (int ILOffset, int NativeOffset)[] mappings) in methodsWithMappings)
+ {
+ MethodBase meth = s_getMethodBaseByHandle(null, (IntPtr)(void*)methodID);
+ ExpectedILMappings attrib = meth.GetCustomAttribute();
+ if (attrib == null)
+ {
+ continue;
+ }
+
+ string name = $"[{meth.DeclaringType.Assembly.GetName().Name}]{meth.DeclaringType.FullName}.{meth.Name}";
+
+ // If DebuggableAttribute is saying that the assembly must be debuggable, then verify debug mappings.
+ // Otherwise verify release mappings.
+ // This may seem a little strange since we do not use the tier at all -- however, we expect debug
+ // to never tier and in release, we expect the release mappings to be the "least common denominator",
+ // i.e. tier0 and tier1 mappings should both be a superset.
+ // Note that tier0 and MinOptJitted differs in mappings generated exactly due to DebuggableAttribute.
+ DebuggableAttribute debuggableAttrib = meth.DeclaringType.Assembly.GetCustomAttribute();
+ bool debuggableMappings = debuggableAttrib != null && debuggableAttrib.IsJITOptimizerDisabled;
+
+ Console.WriteLine("{0}: Validate mappings for {1} codegen (tier: {2})", name, debuggableMappings ? "debuggable" : "optimized", tier);
+
+ int[] expected = debuggableMappings ? attrib.Debug : attrib.Opts;
+ if (expected == null)
+ {
+ continue;
+ }
+
+ if (!ValidateSingle(expected, mappings))
+ {
+ Console.WriteLine(" Validation failed: expected mappings at IL offsets {0}", string.Join(", ", expected.Select(il => $"{il:x3}")));
+ Console.WriteLine(" Actual (IL <-> native):");
+ foreach ((int ilOffset, int nativeOffset) in mappings)
+ {
+ string ilOffsetName = Enum.IsDefined((SpecialILOffset)ilOffset) ? ((SpecialILOffset)ilOffset).ToString() : $"{ilOffset:x3}";
+ Console.WriteLine(" {0:x3} <-> {1:x3}", ilOffsetName, nativeOffset);
+ }
+
+ result = -1;
+ }
+ }
+
+ return result;
+ };
+ }
+
+ // Validate that all IL offsets we expected had mappings generated for them.
+ private static bool ValidateSingle(int[] expected, (int ILOffset, int NativeOffset)[] mappings)
+ {
+ return expected.All(il => mappings.Any(t => t.ILOffset == il));
+ }
+
+ private enum SpecialILOffset
+ {
+ NoMapping = -1,
+ Prolog = -2,
+ Epilog = -3,
+ }
+
+ static DebugInfoTest()
+ {
+ Type runtimeMethodHandleInternalType = typeof(RuntimeMethodHandle).Assembly.GetType("System.RuntimeMethodHandleInternal");
+ Type runtimeTypeType = typeof(RuntimeMethodHandle).Assembly.GetType("System.RuntimeType");
+ MethodInfo getMethodBaseMethod = runtimeTypeType.GetMethod("GetMethodBase", BindingFlags.NonPublic | BindingFlags.Static, new[] { runtimeTypeType, runtimeMethodHandleInternalType });
+ s_getMethodBaseByHandle = (delegate*