diff --git a/ChibiRuby.slnx b/ChibiRuby.slnx
index 69362f64..83f5d06e 100644
--- a/ChibiRuby.slnx
+++ b/ChibiRuby.slnx
@@ -12,6 +12,7 @@
+
diff --git a/sandbox/ChibiRuby.Benchmark/ChibiRuby.Benchmark.csproj b/sandbox/ChibiRuby.Benchmark/ChibiRuby.Benchmark.csproj
index 58c2278a..951b631d 100644
--- a/sandbox/ChibiRuby.Benchmark/ChibiRuby.Benchmark.csproj
+++ b/sandbox/ChibiRuby.Benchmark/ChibiRuby.Benchmark.csproj
@@ -11,6 +11,7 @@
+
@@ -27,6 +28,7 @@
+
diff --git a/sandbox/ChibiRuby.Benchmark/OptcarrotAotCompiler.cs b/sandbox/ChibiRuby.Benchmark/OptcarrotAotCompiler.cs
new file mode 100644
index 00000000..11b9ffe2
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/OptcarrotAotCompiler.cs
@@ -0,0 +1,75 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using ChibiRuby;
+using ChibiRuby.JetPack;
+using ChibiRuby.JetPack.Mrb2Cs;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace ChibiRuby.Benchmark;
+
+// Runtime host for mrb2cs: generate the C# for an irep tree (Mrb2CsCompiler.Compile), Roslyn-compile it
+// into an assembly, and register each body by fingerprint so a (re)parse of the same bytecode
+// binds it. The source generation itself lives in ChibiRuby.JetPack (Mrb2Cs); this is just the
+// "load it into the running process" half (the build-time path would csc + reference instead).
+static class OptcarrotAotCompiler
+{
+ public static int CompileAndRegister(MRubyState state, Irep root)
+ {
+ var result = Mrb2CsCompiler.Compile(state, root, "OptcarrotAotGenerated");
+ if (result.Methods.Count == 0)
+ {
+ return 0;
+ }
+
+ if (Environment.GetEnvironmentVariable("AOT_DUMP") is { Length: > 0 } dumpPath)
+ {
+ File.WriteAllText(dumpPath, result.Source);
+ }
+
+ var asm = Compile(result.Source);
+ var type = asm.GetType("OptcarrotAotGenerated")!;
+ foreach (var (name, fingerprint) in result.Methods)
+ {
+ var method = type.GetMethod(name, BindingFlags.Public | BindingFlags.Static)!;
+ var body = (CompiledRubyMethodBody)method.CreateDelegate(typeof(CompiledRubyMethodBody));
+ state.RegisterCompiledMethod(fingerprint, body);
+ }
+
+ return result.Methods.Count;
+ }
+
+ static Assembly Compile(string source)
+ {
+ var tree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest));
+ var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
+ .Split(Path.PathSeparator)
+ .Where(p => p.Length > 0)
+ .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p))
+ .ToList();
+ refs.Add(MetadataReference.CreateFromFile(typeof(MRubyState).Assembly.Location));
+
+ var compilation = CSharpCompilation.Create(
+ "OptcarrotAotGenerated",
+ new[] { tree },
+ refs,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release, allowUnsafe: true));
+
+ using var ms = new MemoryStream();
+ var emit = compilation.Emit(ms);
+ if (!emit.Success)
+ {
+ File.WriteAllText("/tmp/aot_fail.cs", source);
+ var errors = string.Join("\n", emit.Diagnostics
+ .Where(d => d.Severity == DiagnosticSeverity.Error)
+ .Take(20)
+ .Select(d => d.ToString()));
+ throw new InvalidOperationException("mrb2cs generated code failed to compile:\n" + errors);
+ }
+
+ ms.Position = 0;
+ return Assembly.Load(ms.ToArray());
+ }
+}
diff --git a/sandbox/ChibiRuby.Benchmark/Program.cs b/sandbox/ChibiRuby.Benchmark/Program.cs
index dd2b711d..8094254f 100644
--- a/sandbox/ChibiRuby.Benchmark/Program.cs
+++ b/sandbox/ChibiRuby.Benchmark/Program.cs
@@ -95,6 +95,29 @@
return;
}
+if (args is ["--quick-optcarrot-aot", ..])
+{
+ var frames = args.Length >= 2 && int.TryParse(args[1], out var parsedAotFrames)
+ ? parsedAotFrames
+ : 180;
+ var warmupRuns = args.Length >= 3 && int.TryParse(args[2], out var parsedAotWarmup)
+ ? parsedAotWarmup
+ : 0;
+ using var loader = new RubyScriptLoader();
+ var compiled = loader.PreloadOptcarrotBenchmarkAot(frames, printResult: warmupRuns == 0);
+ Console.Error.WriteLine($"AOT-compiled methods: {compiled}");
+ for (var i = 0; i < warmupRuns; i++)
+ {
+ loader.RunChibiRuby();
+ }
+ if (warmupRuns > 0)
+ {
+ loader.PreloadOptcarrotRun(frames);
+ }
+ loader.RunChibiRuby();
+ return;
+}
+
if (args is ["--quick-optcarrot-mruby", ..])
{
var frames = args.Length >= 2 && int.TryParse(args[1], out var parsedFrames)
@@ -104,6 +127,33 @@
return;
}
+if (args is [ "--quick-variable-table-allocation", .. ])
+{
+ var objectCount = args.Length >= 2 && int.TryParse(args[1], out var parsed)
+ ? parsed
+ : 100_000;
+ VariableTableAllocationCounter.Run(objectCount);
+ return;
+}
+
+if (args is [ "--quick-script", var scriptName, .. ])
+{
+ var iterations = args.Length >= 3 && int.TryParse(args[2], out var parsedIterations)
+ ? parsedIterations
+ : 1;
+ RubyScriptLoader.RunQuickScript(scriptName, iterations);
+ return;
+}
+
+if (args is [ "--quick-script-aot", var aotScriptName, .. ])
+{
+ var iterations = args.Length >= 3 && int.TryParse(args[2], out var parsedAotIterations)
+ ? parsedAotIterations
+ : 1;
+ RubyScriptLoader.RunQuickScript(aotScriptName, iterations, aot: true);
+ return;
+}
+
BenchmarkSwitcher.FromAssembly(Assembly.GetEntryAssembly()!).Run(args);
[Config(typeof(BenchmarkConfig))]
diff --git a/sandbox/ChibiRuby.Benchmark/RubyScriptLoader.cs b/sandbox/ChibiRuby.Benchmark/RubyScriptLoader.cs
index 35b5111b..0083f410 100644
--- a/sandbox/ChibiRuby.Benchmark/RubyScriptLoader.cs
+++ b/sandbox/ChibiRuby.Benchmark/RubyScriptLoader.cs
@@ -1,9 +1,12 @@
using System;
+using System.Diagnostics;
using System.IO;
+using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using ChibiRuby.Compiler;
+using ChibiRuby.JetPack;
namespace ChibiRuby.Benchmark;
@@ -50,27 +53,100 @@ public RubyScriptLoader()
mrbStateNative = NativeMethods.MrbOpen();
}
- static void RegisterMathModule(MRubyState state)
+ public static void RunQuickScript(string scriptName, int iterations)
{
- state.DefineModule(state.Intern("Math"u8), mod =>
+ RunQuickScript(scriptName, iterations, aot: false);
+ }
+
+ public static void RunQuickScript(string scriptName, int iterations, bool aot)
+ {
+ if (iterations < 1)
{
- mod.DefineClassMethod(state.Intern("sqrt"u8), new MRubyMethod((s, self) =>
- {
- var value = s.GetArgumentAsFloatAt(0);
- return System.Math.Sqrt(value);
- }));
+ iterations = 1;
+ }
- mod.DefineClassMethod(state.Intern("cos"u8), new MRubyMethod((s, self) =>
- {
- var value = s.GetArgumentAsFloatAt(0);
- return System.Math.Cos(value);
- }));
+ using var loader = new RubyScriptLoader();
+ if (aot)
+ {
+ var compiled = loader.PreloadScriptFromFileAot(scriptName);
+ Console.Error.WriteLine($"AOT-compiled methods: {compiled}");
+ }
+ else
+ {
+ loader.PreloadScriptFromFile(scriptName);
+ }
- mod.DefineClassMethod(state.Intern("sin"u8), new MRubyMethod((s, self) =>
- {
- var value = s.GetArgumentAsFloatAt(0);
- return System.Math.Sin(value);
- }));
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ GC.Collect();
+
+ var allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
+ var gc0 = GC.CollectionCount(0);
+ var gc1 = GC.CollectionCount(1);
+ var gc2 = GC.CollectionCount(2);
+ var pauseBefore = GC.GetTotalPauseDuration();
+ var stopwatch = Stopwatch.StartNew();
+ var result = MRubyValue.Nil;
+ for (var i = 0; i < iterations; i++)
+ {
+ result = loader.RunChibiRuby();
+ }
+ stopwatch.Stop();
+ var allocatedBytes = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore;
+ var pauseMs = (GC.GetTotalPauseDuration() - pauseBefore).TotalMilliseconds;
+ var wallMs = stopwatch.Elapsed.TotalMilliseconds;
+
+ Console.WriteLine(
+ $"ChibiRuby quick script={scriptName} aot={aot} iterations={iterations} elapsedMs={wallMs:F3} allocatedBytes={allocatedBytes} allocatedBytesPerIteration={(double)allocatedBytes / iterations:F0} result={result}");
+ Console.WriteLine(
+ $"[gc] server={System.Runtime.GCSettings.IsServerGC} gen0={GC.CollectionCount(0) - gc0} gen1={GC.CollectionCount(1) - gc1} gen2={GC.CollectionCount(2) - gc2} pauseMs={pauseMs:F1} pausePct={(wallMs > 0 ? pauseMs / wallMs * 100 : 0):F1}%");
+ }
+
+ // AOT variant of PreloadScriptFromFile: compile the script, execute it once so the
+ // class hierarchy exists (lets the codegen resolve self-sends for devirtualize+inline),
+ // then AOT-compile every statically-compilable method to C#, register the bodies by
+ // fingerprint and bind them to the irep tree. Subsequent RunChibiRuby() calls hit the
+ // compiled bodies. Returns the number of methods compiled.
+ public int PreloadScriptFromFileAot(string fileName)
+ {
+ var source = ReadBytes(fileName);
+ currentChibiRubyIrep = CompileChibiRubySource(Encoding.UTF8.GetString(source));
+ mrubyCSState.Execute(currentChibiRubyIrep);
+ var compiled = OptcarrotAotCompiler.CompileAndRegister(mrubyCSState, currentChibiRubyIrep);
+ mrubyCSState.BindCompiledMethods(currentChibiRubyIrep);
+ return compiled;
+ }
+
+ static void RegisterMathModule(MRubyState state)
+ {
+ state.DefineModule(state.Intern("Math"u8), mod =>
+ {
+ mod.DefineClassMethod(state.Intern("sqrt"u8), new MRubyMethod(
+ (s, self) =>
+ {
+ var value = s.GetArgumentAsFloatAt(0);
+ return System.Math.Sqrt(value);
+ },
+ (s, self, argument) => System.Math.Sqrt(s.AsFloat(argument)),
+ (_, _, argument) => System.Math.Sqrt(argument)));
+
+ mod.DefineClassMethod(state.Intern("cos"u8), new MRubyMethod(
+ (s, self) =>
+ {
+ var value = s.GetArgumentAsFloatAt(0);
+ return System.Math.Cos(value);
+ },
+ (s, self, argument) => System.Math.Cos(s.AsFloat(argument)),
+ (_, _, argument) => System.Math.Cos(argument)));
+
+ mod.DefineClassMethod(state.Intern("sin"u8), new MRubyMethod(
+ (s, self) =>
+ {
+ var value = s.GetArgumentAsFloatAt(0);
+ return System.Math.Sin(value);
+ },
+ (s, self, argument) => System.Math.Sin(s.AsFloat(argument)),
+ (_, _, argument) => System.Math.Sin(argument)));
});
}
@@ -124,6 +200,22 @@ public void PreloadOptcarrotRun(int frames = 180, bool printResult = true)
currentChibiRubyIrep = CompileChibiRubySource(BuildOptcarrotRunSource(frames, printResult));
}
+ // Like PreloadOptcarrotBenchmark but AOT-compiles every statically-compilable
+ // optcarrot method to C# first, registering bodies by fingerprint and binding them
+ // to the definitions tree before it executes. Returns the number of methods compiled.
+ public int PreloadOptcarrotBenchmarkAot(int frames = 180, bool printResult = true)
+ {
+ var definitions = CompileChibiRubySource(BuildOptcarrotDefinitionsSource());
+ // Define the classes first so the AOT compiler can resolve self-sends against
+ // the real class hierarchy (for devirtualize+inline), then compile + bind.
+ mrubyCSState.Execute(definitions);
+ var compiled = OptcarrotAotCompiler.CompileAndRegister(mrubyCSState, definitions);
+ mrubyCSState.BindCompiledMethods(definitions);
+
+ PreloadOptcarrotRun(frames, printResult);
+ return compiled;
+ }
+
public MRubyValue RunChibiRuby()
{
return mrubyCSState.Execute(currentChibiRubyIrep!);
diff --git a/sandbox/ChibiRuby.Benchmark/VariableTableAllocationBenchmark.cs b/sandbox/ChibiRuby.Benchmark/VariableTableAllocationBenchmark.cs
new file mode 100644
index 00000000..cb2c053f
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/VariableTableAllocationBenchmark.cs
@@ -0,0 +1,56 @@
+using BenchmarkDotNet.Attributes;
+
+namespace ChibiRuby.Benchmark;
+
+[Config(typeof(BenchmarkConfig))]
+public class VariableTableAllocationBenchmark
+{
+ [Params(10_000)]
+ public int ObjectCount { get; set; }
+
+ [Benchmark(Baseline = true)]
+ public long FourIvarsInline() => VariableTableAllocationWorkload.FourIvarsInline(ObjectCount);
+
+ [Benchmark]
+ public long FiveIvarsPromoted() => VariableTableAllocationWorkload.FiveIvarsPromoted(ObjectCount);
+}
+
+static class VariableTableAllocationWorkload
+{
+ static readonly Symbol X = new(10_001);
+ static readonly Symbol Y = new(10_002);
+ static readonly Symbol Z = new(10_003);
+ static readonly Symbol W = new(10_004);
+ static readonly Symbol Extra = new(10_005);
+
+ public static long FourIvarsInline(int objectCount)
+ {
+ long sum = 0;
+ for (var i = 0; i < objectCount; i++)
+ {
+ var table = new VariableTable();
+ table.Set(X, new MRubyValue(i));
+ table.Set(Y, new MRubyValue(i + 1));
+ table.Set(Z, new MRubyValue(i + 2));
+ table.Set(W, new MRubyValue(i + 3));
+ sum += table.Get(W).IntegerValue;
+ }
+ return sum;
+ }
+
+ public static long FiveIvarsPromoted(int objectCount)
+ {
+ long sum = 0;
+ for (var i = 0; i < objectCount; i++)
+ {
+ var table = new VariableTable();
+ table.Set(X, new MRubyValue(i));
+ table.Set(Y, new MRubyValue(i + 1));
+ table.Set(Z, new MRubyValue(i + 2));
+ table.Set(W, new MRubyValue(i + 3));
+ table.Set(Extra, new MRubyValue(i + 4));
+ sum += table.Get(Extra).IntegerValue;
+ }
+ return sum;
+ }
+}
diff --git a/sandbox/ChibiRuby.Benchmark/VariableTableAllocationCounter.cs b/sandbox/ChibiRuby.Benchmark/VariableTableAllocationCounter.cs
new file mode 100644
index 00000000..d407ead9
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/VariableTableAllocationCounter.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Diagnostics;
+
+namespace ChibiRuby.Benchmark;
+
+static class VariableTableAllocationCounter
+{
+ public static void Run(int objectCount)
+ {
+ objectCount = objectCount <= 0 ? 100_000 : objectCount;
+
+ VariableTableAllocationWorkload.FourIvarsInline(1_000);
+ VariableTableAllocationWorkload.FiveIvarsPromoted(1_000);
+
+ var inline = Measure(() => VariableTableAllocationWorkload.FourIvarsInline(objectCount));
+ var promoted = Measure(() => VariableTableAllocationWorkload.FiveIvarsPromoted(objectCount));
+
+ Console.WriteLine($"VariableTable allocation counter, objects={objectCount}");
+ Print("FourIvarsInline", inline, objectCount);
+ Print("FiveIvarsPromoted", promoted, objectCount);
+ Console.WriteLine(
+ $"Delta: {promoted.AllocatedBytes - inline.AllocatedBytes:N0} B total, " +
+ $"{(double)(promoted.AllocatedBytes - inline.AllocatedBytes) / objectCount:N2} B/object");
+ }
+
+ static (long Result, long AllocatedBytes, long ElapsedTicks) Measure(Func action)
+ {
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ GC.Collect();
+
+ var before = GC.GetAllocatedBytesForCurrentThread();
+ var timestamp = Stopwatch.GetTimestamp();
+ var result = action();
+ var elapsedTicks = Stopwatch.GetTimestamp() - timestamp;
+ var allocatedBytes = GC.GetAllocatedBytesForCurrentThread() - before;
+ GC.KeepAlive(result);
+ return (result, allocatedBytes, elapsedTicks);
+ }
+
+ static void Print(
+ string name,
+ (long Result, long AllocatedBytes, long ElapsedTicks) measurement,
+ int objectCount)
+ {
+ var elapsed = TimeSpan.FromSeconds((double)measurement.ElapsedTicks / Stopwatch.Frequency);
+ Console.WriteLine(
+ $"{name}: allocated={measurement.AllocatedBytes:N0} B " +
+ $"({(double)measurement.AllocatedBytes / objectCount:N2} B/object), " +
+ $"elapsed={elapsed.TotalMilliseconds:N2} ms, checksum={measurement.Result}");
+ }
+}
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render.rb
index 23b3c547..5dc56656 100644
--- a/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render.rb
+++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render.rb
@@ -255,6 +255,7 @@ def ambient_occlusion(isect)
def render(w, h, nsubsamples)
nsf = nsubsamples.to_f
nsfs = nsf * nsf
+ sum = 0
h.times do |y|
w.times do |x|
rad = Vec.new(0.0, 0.0, 0.0)
@@ -293,11 +294,13 @@ def render(w, h, nsubsamples)
r = rad.x / nsfs
g = rad.y / nsfs
b = rad.z / nsfs
+ sum = sum + clamp(r) + clamp(g) + clamp(b)
# printf("%c", clamp(r))
# printf("%c", clamp(g))
# printf("%c", clamp(b))
end
end
+ sum
end
end
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render_checksum.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render_checksum.rb
new file mode 100644
index 00000000..cf933dd8
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render_checksum.rb
@@ -0,0 +1,296 @@
+# Checksummed AO render: same as bm_ao_render but accumulates clamp(r,g,b) into a global
+# so interpreted vs AOT output can be compared exactly (the plain bench discards pixels).
+IMAGE_WIDTH = 32
+IMAGE_HEIGHT = IMAGE_WIDTH
+NSUBSAMPLES = 2
+NAO_SAMPLES = 8
+
+$checksum = 0
+
+module Rand
+ @x = 123456789
+ @y = 362436069
+ @z = 521288629
+ @w = 88675123
+ BNUM = 1 << 29
+ BNUMF = BNUM.to_f
+ def self.rand
+ x = @x
+ t = x ^ ((x & 0xfffff) << 11)
+ w = @w
+ @x, @y, @z = @y, @z, w
+ w = @w = (w ^ (w >> 19) ^ (t ^ (t >> 8)))
+ (w % BNUM) / BNUMF
+ end
+end
+
+class Vec
+ def initialize(x, y, z)
+ @x = x
+ @y = y
+ @z = z
+ end
+
+ def x=(v); @x = v; end
+ def y=(v); @y = v; end
+ def z=(v); @z = v; end
+ def x; @x; end
+ def y; @y; end
+ def z; @z; end
+
+ def vadd(b)
+ Vec.new(@x + b.x, @y + b.y, @z + b.z)
+ end
+
+ def vsub(b)
+ Vec.new(@x - b.x, @y - b.y, @z - b.z)
+ end
+
+ def vcross(b)
+ Vec.new(@y * b.z - @z * b.y,
+ @z * b.x - @x * b.z,
+ @x * b.y - @y * b.x)
+ end
+
+ def vdot(b)
+ r = @x * b.x + @y * b.y + @z * b.z
+ r
+ end
+
+ def vlength
+ Math.sqrt(@x * @x + @y * @y + @z * @z)
+ end
+
+ def vnormalize
+ len = vlength
+ v = Vec.new(@x, @y, @z)
+ if len > 1.0e-17
+ v.x = v.x / len
+ v.y = v.y / len
+ v.z = v.z / len
+ end
+ v
+ end
+end
+
+class Sphere
+ def initialize(center, radius)
+ @center = center
+ @radius = radius
+ end
+
+ def center; @center; end
+ def radius; @radius; end
+
+ def intersect(ray, isect)
+ rs = ray.org.vsub(@center)
+ b = rs.vdot(ray.dir)
+ c = rs.vdot(rs) - (@radius * @radius)
+ d = b * b - c
+ if d > 0.0
+ t = - b - Math.sqrt(d)
+
+ if t > 0.0 and t < isect.t
+ isect.t = t
+ isect.hit = true
+ isect.pl = Vec.new(ray.org.x + ray.dir.x * t,
+ ray.org.y + ray.dir.y * t,
+ ray.org.z + ray.dir.z * t)
+ n = isect.pl.vsub(@center)
+ isect.n = n.vnormalize
+ end
+ end
+ end
+end
+
+class Plane
+ def initialize(p, n)
+ @p = p
+ @n = n
+ end
+
+ def intersect(ray, isect)
+ d = -@p.vdot(@n)
+ v = ray.dir.vdot(@n)
+ v0 = v
+ if v < 0.0
+ v0 = -v
+ end
+ if v0 < 1.0e-17
+ return
+ end
+
+ t = -(ray.org.vdot(@n) + d) / v
+
+ if t > 0.0 and t < isect.t
+ isect.hit = true
+ isect.t = t
+ isect.n = @n
+ isect.pl = Vec.new(ray.org.x + t * ray.dir.x,
+ ray.org.y + t * ray.dir.y,
+ ray.org.z + t * ray.dir.z)
+ end
+ end
+end
+
+class Ray
+ def initialize(org, dir)
+ @org = org
+ @dir = dir
+ end
+
+ def org; @org; end
+ def org=(v); @org = v; end
+ def dir; @dir; end
+ def dir=(v); @dir = v; end
+end
+
+class Isect
+ def initialize
+ @t = 10000000.0
+ @hit = false
+ @pl = Vec.new(0.0, 0.0, 0.0)
+ @n = Vec.new(0.0, 0.0, 0.0)
+ end
+
+ def t; @t; end
+ def t=(v); @t = v; end
+ def hit; @hit; end
+ def hit=(v); @hit = v; end
+ def pl; @pl; end
+ def pl=(v); @pl = v; end
+ def n; @n; end
+ def n=(v); @n = v; end
+end
+
+def clamp(f)
+ i = f * 255.5
+ if i > 255.0
+ i = 255.0
+ end
+ if i < 0.0
+ i = 0.0
+ end
+ i.to_i
+end
+
+def orthoBasis(basis, n)
+ basis[2] = Vec.new(n.x, n.y, n.z)
+ basis[1] = Vec.new(0.0, 0.0, 0.0)
+
+ if n.x < 0.6 and n.x > -0.6
+ basis[1].x = 1.0
+ elsif n.y < 0.6 and n.y > -0.6
+ basis[1].y = 1.0
+ elsif n.z < 0.6 and n.z > -0.6
+ basis[1].z = 1.0
+ else
+ basis[1].x = 1.0
+ end
+
+ basis[0] = basis[1].vcross(basis[2])
+ basis[0] = basis[0].vnormalize
+
+ basis[1] = basis[2].vcross(basis[0])
+ basis[1] = basis[1].vnormalize
+end
+
+class Scene
+ def initialize
+ @spheres = Array.new
+ @spheres[0] = Sphere.new(Vec.new(-2.0, 0.0, -3.5), 0.5)
+ @spheres[1] = Sphere.new(Vec.new(-0.5, 0.0, -3.0), 0.5)
+ @spheres[2] = Sphere.new(Vec.new(1.0, 0.0, -2.2), 0.5)
+ @plane = Plane.new(Vec.new(0.0, -0.5, 0.0), Vec.new(0.0, 1.0, 0.0))
+ end
+
+ def ambient_occlusion(isect)
+ basis = Array.new(3)
+ orthoBasis(basis, isect.n)
+
+ ntheta = NAO_SAMPLES
+ nphi = NAO_SAMPLES
+ eps = 0.0001
+ occlusion = 0.0
+
+ p0 = Vec.new(isect.pl.x + eps * isect.n.x,
+ isect.pl.y + eps * isect.n.y,
+ isect.pl.z + eps * isect.n.z)
+ nphi.times do
+ ntheta.times do
+ r = Rand::rand
+ phi = 2.0 * 3.14159265 * Rand::rand
+ x = Math.cos(phi) * Math.sqrt(1.0 - r)
+ y = Math.sin(phi) * Math.sqrt(1.0 - r)
+ z = Math.sqrt(r)
+
+ rx = x * basis[0].x + y * basis[1].x + z * basis[2].x
+ ry = x * basis[0].y + y * basis[1].y + z * basis[2].y
+ rz = x * basis[0].z + y * basis[1].z + z * basis[2].z
+
+ raydir = Vec.new(rx, ry, rz)
+ ray = Ray.new(p0, raydir)
+
+ occisect = Isect.new
+ @spheres[0].intersect(ray, occisect)
+ @spheres[1].intersect(ray, occisect)
+ @spheres[2].intersect(ray, occisect)
+ @plane.intersect(ray, occisect)
+ if occisect.hit
+ occlusion = occlusion + 1.0
+ end
+ end
+ end
+
+ occlusion = (ntheta.to_f * nphi.to_f - occlusion) / (ntheta.to_f * nphi.to_f)
+ Vec.new(occlusion, occlusion, occlusion)
+ end
+
+ def render(w, h, nsubsamples)
+ nsf = nsubsamples.to_f
+ nsfs = nsf * nsf
+ h.times do |y|
+ w.times do |x|
+ rad = Vec.new(0.0, 0.0, 0.0)
+
+ nsubsamples.times do |v|
+ nsubsamples.times do |u|
+ wf = w.to_f
+ hf = h.to_f
+ xf = x.to_f
+ yf = y.to_f
+ uf = u.to_f
+ vf = v.to_f
+
+ px = (xf + (uf / nsf) - (wf / 2.0)) / (wf / 2.0)
+ py = -(yf + (vf / nsf) - (hf / 2.0)) / (hf / 2.0)
+
+ eye = Vec.new(px, py, -1.0).vnormalize
+
+ ray = Ray.new(Vec.new(0.0, 0.0, 0.0), eye)
+
+ isect = Isect.new
+ @spheres[0].intersect(ray, isect)
+ @spheres[1].intersect(ray, isect)
+ @spheres[2].intersect(ray, isect)
+ @plane.intersect(ray, isect)
+ if isect.hit
+ col = ambient_occlusion(isect)
+ rad.x = rad.x + col.x
+ rad.y = rad.y + col.y
+ rad.z = rad.z + col.z
+ end
+ end
+ end
+
+ r = rad.x / nsfs
+ g = rad.y / nsfs
+ b = rad.z / nsfs
+ $checksum = $checksum + clamp(r) + clamp(g) + clamp(b)
+ end
+ end
+ end
+end
+
+Scene.new.render(IMAGE_WIDTH, IMAGE_HEIGHT, NSUBSAMPLES)
+$checksum
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_constret_test.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_constret_test.rb
new file mode 100644
index 00000000..48adfb54
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_constret_test.rb
@@ -0,0 +1,19 @@
+# Constant-returning-method devirt test: 0-arg methods that return an immediate constant,
+# called CROSS-OBJECT (cfg.w), including multi-level delegation (total -> w, deep -> total -> w).
+class Config
+ def w; 256; end # single-level fixnum constant
+ def h; 240; end
+ def scale; 2.5; end # float constant
+ def total; w; end # multi-level: total -> w -> 256
+ def deep; total; end # 3-level: deep -> total -> w -> 256
+ def enabled; true; end # bool constant
+end
+
+class Runner
+ def run(cfg)
+ cfg.w + cfg.h + cfg.total + cfg.deep + (cfg.scale * 4.0) + (cfg.enabled ? 100 : 0)
+ end
+end
+
+# 256 + 240 + 256 + 256 + 10.0 + 100 = 1118.0
+Runner.new.run(Config.new)
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_loop_adv.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_loop_adv.rb
new file mode 100644
index 00000000..b76472bd
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_loop_adv.rb
@@ -0,0 +1,42 @@
+class Adv
+ # arg actually Float -> entry IsFixnum guard must deopt, result still correct
+ def divloop(s)
+ x = 0; sum = 0.0
+ while x < 5
+ sum = sum + (2.0 * x / s)
+ x += 1
+ end
+ sum
+ end
+ # polymorphic loop var (sometimes Float, sometimes Fixnum) -> must stay boxed/correct
+ def poly(n)
+ i = 0; acc = 0.0
+ while i < n
+ v = (i % 2 == 0) ? 1.5 : 3
+ acc = acc + v
+ i += 1
+ end
+ acc
+ end
+ # int accumulator + float reads mixed
+ def mix(n)
+ i = 0; s = 0.0
+ while i <= n
+ s = s + i * 0.5 - 1
+ i += 1
+ end
+ s
+ end
+ # non-numeric touched in loop (string build) alongside numeric
+ def strnum(n)
+ i = 0; t = 0
+ while i < n
+ str = "x" * 1
+ t = t + str.length + i
+ i += 1
+ end
+ t
+ end
+end
+a = Adv.new
+[a.divloop(3), a.divloop(2.0), a.poly(6), a.mix(10), a.strnum(20)]
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_so_mandelbrot.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_so_mandelbrot.rb
index 4915e418..5bd66cc5 100644
--- a/sandbox/ChibiRuby.Benchmark/ruby/bm_so_mandelbrot.rb
+++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_so_mandelbrot.rb
@@ -4,62 +4,72 @@
# contributed by Karl von Laudermann
# modified by Jeremy Echols
# optimized: while loops instead of for..in to avoid closure overhead
+#
+# Wrapped in a method (returning a checksum of the bytes that would be printed)
+# so the while-loop body is AOT-compiled and invoked via method dispatch — the
+# AOT path only fires for methods, not the top-level script body. The checksum
+# also keeps the loop from being dead-code-eliminated.
-size = 600 # ARGV[0].to_i
+class Mandelbrot
+ def render(size)
+ # Cache constants in local variables to avoid repeated constant lookup
+ iter = 49
+ limit_squared = 4.0
-# puts "P4\n#{size} #{size}"
+ byte_acc = 0
+ bit_num = 0
-# Cache constants in local variables to avoid repeated constant lookup
-iter = 49
-limit_squared = 4.0
+ count_size = size - 1
+ sum = 0
-byte_acc = 0
-bit_num = 0
+ # Use while loops instead of for..in to avoid closure/upvalue overhead
+ y = 0
+ while y <= count_size
+ x = 0
+ while x <= count_size
+ zr = 0.0
+ zi = 0.0
+ cr = (2.0 * x / size) - 1.5
+ ci = (2.0 * y / size) - 1.0
+ escape = false
-count_size = size - 1
+ # Use while instead of for..in to avoid closure overhead
+ i = 0
+ while i <= iter
+ tr = zr * zr - zi * zi + cr
+ ti = 2 * zr * zi + ci
+ zr = tr
+ zi = ti
-# Use while loops instead of for..in to avoid closure/upvalue overhead
-y = 0
-while y <= count_size
- x = 0
- while x <= count_size
- zr = 0.0
- zi = 0.0
- cr = (2.0*x/size)-1.5
- ci = (2.0*y/size)-1.0
- escape = false
+ if (zr * zr + zi * zi) > limit_squared
+ escape = true
+ break
+ end
+ i += 1
+ end
- # Use while instead of for..in to avoid closure overhead
- i = 0
- while i <= iter
- tr = zr*zr - zi*zi + cr
- ti = 2*zr*zi + ci
- zr = tr
- zi = ti
+ byte_acc = (byte_acc << 1) | (escape ? 0b0 : 0b1)
+ bit_num += 1
- if (zr*zr+zi*zi) > limit_squared
- escape = true
- break
+ # Code is very similar for these cases, but using separate blocks
+ # ensures we skip the shifting when it's unnecessary, which is most cases.
+ if bit_num == 8
+ sum = (sum + byte_acc) & 0x3fffffff
+ byte_acc = 0
+ bit_num = 0
+ elsif x == count_size
+ byte_acc <<= (8 - bit_num)
+ sum = (sum + byte_acc) & 0x3fffffff
+ byte_acc = 0
+ bit_num = 0
+ end
+ x += 1
end
- i += 1
+ y += 1
end
- byte_acc = (byte_acc << 1) | (escape ? 0b0 : 0b1)
- bit_num += 1
-
- # Code is very similar for these cases, but using separate blocks
- # ensures we skip the shifting when it's unnecessary, which is most cases.
- if (bit_num == 8)
- # print byte_acc.chr
- byte_acc = 0
- bit_num = 0
- elsif (x == count_size)
- byte_acc <<= (8 - bit_num)
- # print byte_acc.chr
- byte_acc = 0
- bit_num = 0
- end
- x += 1
+ sum
end
- y += 1
-end
\ No newline at end of file
+end
+
+Mandelbrot.new.render(600)
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_stackobj_test.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_stackobj_test.rb
new file mode 100644
index 00000000..ad8061c1
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_stackobj_test.rb
@@ -0,0 +1,139 @@
+# Stack-allocation test: `p` is created in a compiled method's while-loop and passed to a
+# non-inlinable method that only READS it (non-retaining) -> p should be stack-allocated.
+class Pt
+ def initialize(x, y)
+ @x = x
+ @y = y
+ end
+ def x; @x; end
+ def y; @y; end
+end
+
+# By-REF mutated arg (A): Box is passed to a non-inlinable method that MUTATES it via setters.
+# It stays stack-allocated (passed by ref); reads after the call see the mutations.
+class Box
+ def initialize
+ @v = 1.0
+ end
+ def v; @v; end
+ def v=(x); @v = x; end
+end
+
+# Struct-RECEIVER variant: a stack object is the receiver of a non-accessor method that reads
+# self's ivars directly (`@x`) and doesn't retain/mutate self. Big body so it is NOT inlined.
+class V3
+ def initialize(x, y, z)
+ @x = x
+ @y = y
+ @z = z
+ end
+ def blend
+ a = @x * @x + @y * @y + @z * @z
+ b = @x - @y + @z * @x - @y * 0.5 + @z * 0.5
+ c = a + b - a * b + a * 0.25 + b * 0.75
+ d = c * c - c + c * a - c * b + a * b
+ e = d + d - d * 0.5 + a - b + c
+ f = a * b + c * d + e * a - b * c + d * e
+ a + b + c + d + e + f
+ end
+end
+
+# Nested struct field: @inner is a V3 built from literals inside initialize. Reading h.inner
+# yields the inner V3 as a stack value that cascades into a struct-receiver `blend` call.
+class Holder
+ def initialize
+ @inner = V3.new(2.0, 3.0, 6.0)
+ end
+ def inner; @inner; end
+end
+
+# Literal-initialized fields (no ctor args) — exercises the stack-layout literal path.
+class Lit
+ def initialize
+ @a = 10.0
+ @b = 20.0
+ end
+ def a; @a; end
+ def b; @b; end
+end
+
+class Calc
+ # Big body (> 48 IR instr) so it is NOT inlined; reads q.a/q.b only (does not retain q).
+ def litsum(q)
+ a = q.a * q.a + q.b * q.b
+ b = q.a - q.b + q.a * q.b - q.a * 0.5 + q.b * 0.5
+ c = a + b - a * b + a * 0.25 + b * 0.75
+ d = c * c - c + c * a - c * b + a * b
+ e = d + d - d * 0.5 + a - b + c
+ f = a * b + c * d + e * a - b * c + d * e
+ a + b + c + d + e + f
+ end
+
+ # Big body (> 48 IR instr) so it is NOT inlined; MUTATES b via setters (b passed by ref).
+ def bump(b)
+ b.v = b.v + 1.0
+ b.v = b.v * 2.0
+ b.v = b.v - 0.5
+ b.v = b.v * b.v
+ b.v = b.v + 3.0
+ b.v = b.v - 1.25
+ b.v = b.v * 0.5
+ b.v = b.v + 7.0
+ b.v = b.v - 2.5
+ b.v = b.v * 1.5
+ b.v
+ end
+
+ # Big body (> 48 IR instr) so it is NOT inlined; reads h.inner (a nested stack V3) and calls
+ # blend on it (struct-receiver via the nested-read cascade). Does not retain h.
+ def innerwork(h)
+ a = h.inner.blend
+ b = a * 0.5 + a - a * 0.25 + a * 2.0 - a * 3.0
+ c = b + a - b * 0.5 + a * 0.75 - b * 1.5
+ d = c * c - c + c * a - c * b + a * b
+ e = d + d - d * 0.5 + a - b + c
+ a + b + c + d + e
+ end
+
+ # Big body (> 48 IR instr) so it is NOT inlined; reads p.x/p.y only (does not retain p).
+ def dist2(p)
+ a = p.x * p.x + p.y * p.y
+ b = p.x - p.y + p.x * p.y - p.x * 0.5 + p.y * 0.5
+ c = a + b - a * b + a * 0.25 + b * 0.75
+ d = c * c - c + c * a - c * b + a * b
+ e = d + d - d * 0.5 + a - b + c
+ f = a * b + c * d + e * a - b * c + d * e
+ g = f * 0.5 + a - b * c + d - e * f
+ h = g * g - f * 0.25 + a * b - c * d + e
+ i = h + g - f + e - d + c - b + a * h
+ j = i * 0.5 + h * 0.25 + g * 0.125 + f - e
+ k = j * j - i + h * g - f * e + d * c
+ l = k + j - i * 0.5 + h - g * 0.75 + f
+ a + b + c + d + e + f + g + h + i + j + k + l
+ end
+end
+
+class Runner
+ # Loop-free (the AOT compiler bails on backward branches): create objects and pass them to a
+ # non-inlinable reader. Exercises the stack-allocation struct/variant/reify path.
+ def run
+ calc = Calc.new
+ p1 = Pt.new(1.0, 2.0)
+ p2 = Pt.new(3.0, 4.0)
+ p3 = Pt.new(5.0, 6.0)
+ l1 = Lit.new
+ l2 = Lit.new
+ v1 = V3.new(1.0, 2.0, 3.0)
+ v2 = V3.new(4.0, 5.0, 6.0)
+ h1 = Holder.new
+ h2 = Holder.new
+ b1 = Box.new
+ b2 = Box.new
+ r1 = calc.bump(b1)
+ r2 = calc.bump(b2)
+ calc.dist2(p1) + calc.dist2(p2) + calc.dist2(p3) + calc.litsum(l1) + calc.litsum(l2) +
+ v1.blend + v2.blend + calc.innerwork(h1) + calc.innerwork(h2) + r1 + r2 + b1.v + b2.v
+ end
+end
+
+Runner.new.run
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_while_test.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_while_test.rb
new file mode 100644
index 00000000..aefe0d63
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_while_test.rb
@@ -0,0 +1,54 @@
+# Synthetic while-loop AOT test. Exercises backward branches (while), nested loops,
+# break out of a loop, and mixed int arithmetic in a *method* (so it is AOT-compiled and
+# invoked via dispatch). Returns a checksum the harness compares between interp / AOT-on / AOT-off.
+class WhileBench
+ # Single counter loop with an accumulator.
+ def sum_to(n)
+ i = 0
+ acc = 0
+ while i < n
+ acc = (acc + i * 3 - 1) & 0x3fffffff
+ i += 1
+ end
+ acc
+ end
+
+ # Nested loops with an early break in the inner loop.
+ def grid(n)
+ y = 0
+ total = 0
+ while y < n
+ x = 0
+ while x < n
+ v = x * y + y - x
+ if v > 500
+ break
+ end
+ total = (total + v) & 0x3fffffff
+ x += 1
+ end
+ y += 1
+ end
+ total
+ end
+
+ # until-style loop (post-decrement) to cover the other backward-branch shape.
+ def countdown(n)
+ acc = 0
+ until n <= 0
+ acc = (acc * 31 + n) & 0x3fffffff
+ n -= 1
+ end
+ acc
+ end
+
+ def run
+ s = 0
+ s = (s + sum_to(1000)) & 0x3fffffff
+ s = (s + grid(60)) & 0x3fffffff
+ s = (s + countdown(2000)) & 0x3fffffff
+ s
+ end
+end
+
+WhileBench.new.run
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/dbg_rand.rb b/sandbox/ChibiRuby.Benchmark/ruby/dbg_rand.rb
new file mode 100644
index 00000000..3d499039
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/dbg_rand.rb
@@ -0,0 +1,24 @@
+module Rand
+ @x = 123456789
+ @y = 362436069
+ @z = 521288629
+ @w = 88675123
+ BNUM = 1 << 29
+ BNUMF = BNUM.to_f
+ def self.rand
+ x = @x
+ t = x ^ ((x & 0xfffff) << 11)
+ w = @w
+ @x, @y, @z = @y, @z, w
+ w = @w = (w ^ (w >> 19) ^ (t ^ (t >> 8)))
+ (w % BNUM) / BNUMF
+ end
+end
+
+acc = 0
+i = 0
+while i < 1000
+ acc = acc + (Rand::rand * 1e9).to_i
+ i = i + 1
+end
+acc
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/dbg_vec.rb b/sandbox/ChibiRuby.Benchmark/ruby/dbg_vec.rb
new file mode 100644
index 00000000..8834e03a
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/dbg_vec.rb
@@ -0,0 +1,20 @@
+class Vec
+ def initialize(x, y, z); @x = x; @y = y; @z = z; end
+ def x; @x; end
+ def y; @y; end
+ def z; @z; end
+ def vdot(b); @x * b.x + @y * b.y + @z * b.z; end
+ def vlength; Math.sqrt(@x * @x + @y * @y + @z * @z); end
+ def vcross(b)
+ Vec.new(@y * b.z - @z * b.y, @z * b.x - @x * b.z, @x * b.y - @y * b.x)
+ end
+end
+
+a = Vec.new(0.3, -0.7, 1.1)
+b = Vec.new(2.0, 3.5, -1.2)
+acc = 0
+acc = acc + (a.vdot(b) * 1e12).to_i
+acc = acc + (a.vlength * 1e12).to_i
+c = a.vcross(b)
+acc = acc + (c.x * 1e12).to_i + (c.y * 1e12).to_i + (c.z * 1e12).to_i
+acc
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_arith.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_arith.rb
new file mode 100644
index 00000000..cc23c54b
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_arith.rb
@@ -0,0 +1,14 @@
+class M
+ def calc(a, b, c)
+ a * b + c * a - b * c + a * a + b * b + a * c
+ end
+end
+
+m = M.new
+acc = 0.0
+i = 0
+while i < 4000000
+ acc = m.calc(1.5, 2.5, 3.5)
+ i = i + 1
+end
+acc
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_block.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_block.rb
new file mode 100644
index 00000000..ba58af7b
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_block.rb
@@ -0,0 +1,15 @@
+# Single-level times-block inline micro: sum_to news no objects but captures `s` (written by
+# the block via an upvar). Interp and AOT must agree, and AOT should run the block as a C# loop.
+def sum_to(n)
+ s = 0
+ n.times { |i| s = s + i }
+ s
+end
+
+acc = 0
+i = 0
+while i < 200000
+ acc = sum_to(100)
+ i = i + 1
+end
+acc
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_block_nested.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_block_nested.rb
new file mode 100644
index 00000000..19a13ce6
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_block_nested.rb
@@ -0,0 +1,19 @@
+# Nested times-block inline: the inner block writes `s` (a method local, depth-1 upvar) and
+# reads `j` (the outer block's param, depth-0). Exercises C2 cell pass-through.
+def grid(n)
+ s = 0
+ n.times do |i|
+ n.times do |j|
+ s = s + i * j
+ end
+ end
+ s
+end
+
+acc = 0
+k = 0
+while k < 100000
+ acc = grid(20)
+ k = k + 1
+end
+acc
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_branch.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_branch.rb
new file mode 100644
index 00000000..7f06d73f
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_branch.rb
@@ -0,0 +1,38 @@
+class Vec
+ def initialize(x, y, z)
+ @x = x
+ @y = y
+ @z = z
+ end
+ def x; @x; end
+ def y; @y; end
+ def z; @z; end
+ def vsub(b)
+ Vec.new(@x - b.x, @y - b.y, @z - b.z)
+ end
+ def vdot(b)
+ @x * b.x + @y * b.y + @z * b.z
+ end
+end
+
+class Driver
+ def run(a, b, flag)
+ rs = a.vsub(b) # rs virtual, created before the branch
+ x = rs.vdot(b) # use rs before branch
+ if flag > 0.0 # branch does NOT touch rs
+ x = x + 1.0
+ end
+ x + rs.vdot(rs) # use rs after the branch (rs lives across it)
+ end
+end
+
+a = Vec.new(1.5, 2.5, 3.5)
+b = Vec.new(4.5, 5.5, 6.5)
+d = Driver.new
+acc = 0.0
+i = 0
+while i < 2000000
+ acc = d.run(a, b, -1.0)
+ i = i + 1
+end
+acc
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_obj.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_obj.rb
new file mode 100644
index 00000000..3d772324
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_obj.rb
@@ -0,0 +1,34 @@
+class Vec
+ def initialize(x, y, z)
+ @x = x
+ @y = y
+ @z = z
+ end
+ def x; @x; end
+ def y; @y; end
+ def z; @z; end
+ def vsub(b)
+ Vec.new(@x - b.x, @y - b.y, @z - b.z)
+ end
+ def vdot(b)
+ @x * b.x + @y * b.y + @z * b.z
+ end
+end
+
+class Driver
+ def run(a, b)
+ rs = a.vsub(b)
+ rs.vdot(b) + rs.vdot(rs)
+ end
+end
+
+a = Vec.new(1.5, 2.5, 3.5)
+b = Vec.new(4.5, 5.5, 6.5)
+d = Driver.new
+acc = 0.0
+i = 0
+while i < 4000000
+ acc = d.run(a, b)
+ i = i + 1
+end
+acc
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_prefix_loop.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_prefix_loop.rb
new file mode 100644
index 00000000..0b3c90f6
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_prefix_loop.rb
@@ -0,0 +1,11 @@
+def prefix_loop(n)
+ i = 0
+ acc = 0
+ while i < n
+ acc += i
+ i += 1
+ end
+ acc
+end
+
+prefix_loop(200000)
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_scalar.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_scalar.rb
new file mode 100644
index 00000000..3fc19a4b
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_scalar.rb
@@ -0,0 +1,27 @@
+# Scalar-replacement micro-benchmark.
+# d2 news a Vec2 temporary that never escapes -> the AOT codegen must scalar-replace it
+# (zero allocation, accessor sends become field-local reads). Interpreted allocates one
+# Vec2 per call; AOT should allocate ~nothing. The hot while-loop runs interpreted and
+# just dispatches into the compiled d2.
+
+class Vec2
+ def initialize(x, y); @x = x; @y = y; end
+ def x; @x; end
+ def y; @y; end
+end
+
+class Calc
+ def d2(px, py)
+ v = Vec2.new(px - 0.5, py - 0.25)
+ v.x * v.x + v.y * v.y
+ end
+end
+
+c = Calc.new
+sum = 0.0
+i = 0
+while i < 3000000
+ sum = sum + c.d2(1.5, 2.0)
+ i = i + 1
+end
+sum
diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_vself.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_vself.rb
new file mode 100644
index 00000000..9427c336
--- /dev/null
+++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_vself.rb
@@ -0,0 +1,40 @@
+class Vec
+ def initialize(x, y, z)
+ @x = x
+ @y = y
+ @z = z
+ end
+ def x; @x; end
+ def y; @y; end
+ def z; @z; end
+ def vsub(b)
+ Vec.new(@x - b.x, @y - b.y, @z - b.z)
+ end
+ # Multi-statement with locals -> not an inline expression, so a call on a
+ # virtual receiver takes the virtual-self frame path instead of inlining.
+ def weighted(b)
+ s = @x * b.x
+ if s > 0.0
+ s = s + @y * b.y
+ end
+ s + @z * b.z
+ end
+end
+
+class Driver
+ def run(a, b)
+ rs = a.vsub(b)
+ rs.weighted(b) + rs.weighted(rs)
+ end
+end
+
+a = Vec.new(1.5, 2.5, 3.5)
+b = Vec.new(4.5, 5.5, 6.5)
+d = Driver.new
+acc = 0.0
+i = 0
+while i < 4000000
+ acc = d.run(a, b)
+ i = i + 1
+end
+acc
diff --git a/sandbox/SampleConsoleApp/ruby/test.rb b/sandbox/SampleConsoleApp/ruby/test.rb
index 656f5b4b..00ca7ef4 100644
--- a/sandbox/SampleConsoleApp/ruby/test.rb
+++ b/sandbox/SampleConsoleApp/ruby/test.rb
@@ -1,3 +1,8 @@
-puts $&
-puts $`
-puts $1
+x = 100
+
+case x
+in 0
+ puts "0"
+in 100..
+ puts "100"
+end
\ No newline at end of file
diff --git a/src/ChibiRuby.Cli/ChibiRuby.Cli.csproj b/src/ChibiRuby.Cli/ChibiRuby.Cli.csproj
index 25687bd6..2549c4bd 100644
--- a/src/ChibiRuby.Cli/ChibiRuby.Cli.csproj
+++ b/src/ChibiRuby.Cli/ChibiRuby.Cli.csproj
@@ -21,6 +21,7 @@
+
diff --git a/src/ChibiRuby.Cli/Program.cs b/src/ChibiRuby.Cli/Program.cs
index 9187ab52..3f93f740 100644
--- a/src/ChibiRuby.Cli/Program.cs
+++ b/src/ChibiRuby.Cli/Program.cs
@@ -150,6 +150,121 @@ public static class {{className ?? "MRubyBytecodeEmbedded"}}
writer.Write(sb.ToString());
writer.Flush();
}
+
+ ///
+ /// mrb2cs: ahead-of-time compile Ruby (.rb) or bytecode (.mrb) to C# source. Accepts a single
+ /// file or a glob (e.g. "lib/**/*.rb"; ** = recurse). One .cs is emitted per input, with the
+ /// class named after the file, and the source subdirectory structure recreated under the output
+ /// directory. A .rb input is compiled to bytecode first, then converted.
+ ///
+ /// Input .rb / .mrb file, or a glob like "lib/**/*.rb".
+ /// -o, Output directory; source subdirectories are recreated under it (default: the glob's base directory).
+ /// C# namespace to wrap each generated class in (default: none).
+ [Command("mrb2cs")]
+ public void Mrb2CsCommand(
+ [Argument] string input,
+ string? outputDir = null,
+ string? csharpNamespace = null)
+ {
+ var (baseDir, files) = ExpandGlob(input);
+ if (files.Count == 0)
+ {
+ Console.Error.WriteLine($"mrb2cs: no .rb/.mrb input matched '{input}'");
+ Environment.Exit(1);
+ }
+ outputDir ??= baseDir;
+
+ var totalMethods = 0;
+ foreach (var file in files)
+ {
+ try
+ {
+ // Fresh state per file so one file's class definitions don't leak into another's.
+ var state = MRubyState.Create();
+ var inputBytes = File.ReadAllBytes(file);
+ Irep irep;
+ if (IsBytecode(file, inputBytes))
+ {
+ irep = state.ParseBytecode(inputBytes);
+ }
+ else
+ {
+ // .rb: compile to mruby bytecode first, then convert to C#.
+ var compiler = MRubyCompiler.Create(state);
+ using var compilation = compiler.Compile(inputBytes);
+ irep = state.ParseBytecode(compilation.AsBytecode().ToArray());
+ }
+
+ // True AOT: statically register the program's classes/methods WITHOUT running it.
+ ChibiRuby.JetPack.Mrb2Cs.DefinitionLoader.Load(state, irep);
+ var className = ToClassName(Path.GetFileNameWithoutExtension(file));
+ var result = ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler.Compile(state, irep, className, csharpNamespace);
+
+ // Recreate the source's subdirectory (relative to the glob base) under outputDir.
+ // Auto-named output uses the `.g.cs` (generated) extension.
+ var relative = Path.GetRelativePath(baseDir, file);
+ var outPath = Path.Combine(outputDir, Path.ChangeExtension(relative, ".g.cs"));
+ var outFolder = Path.GetDirectoryName(outPath);
+ if (!string.IsNullOrEmpty(outFolder)) Directory.CreateDirectory(outFolder);
+ File.WriteAllText(outPath, result.Source);
+ totalMethods += result.Methods.Count;
+ Console.Error.WriteLine($"mrb2cs: {file} -> {outPath} (class {className}, {result.Methods.Count} methods)");
+ }
+ catch (MRubyCompileException ex)
+ {
+ Console.Error.WriteLine($"{file}: {ex.Message}");
+ Environment.Exit(1);
+ }
+ }
+ Console.Error.WriteLine($"mrb2cs: {files.Count} file(s), {totalMethods} methods total");
+ }
+
+ // Expand an input path/glob to a (baseDir, files) pair. baseDir is the leading wildcard-free
+ // prefix; output paths are made relative to it so the source subdirectory layout is preserved.
+ // Supports `*` (single segment) and `**` (recursive). Only .rb / .mrb files are returned.
+ static (string baseDir, List files) ExpandGlob(string pattern)
+ {
+ static bool IsSource(string f) =>
+ f.EndsWith(".rb", StringComparison.OrdinalIgnoreCase) ||
+ f.EndsWith(".mrb", StringComparison.OrdinalIgnoreCase);
+
+ if (!pattern.Contains('*'))
+ {
+ var dir = Path.GetDirectoryName(pattern);
+ return (string.IsNullOrEmpty(dir) ? "." : dir, File.Exists(pattern) ? new List { pattern } : new());
+ }
+
+ var segments = pattern.Replace('\\', '/').Split('/');
+ var baseParts = new List();
+ foreach (var seg in segments)
+ {
+ if (seg.Contains('*')) break;
+ baseParts.Add(seg);
+ }
+ var baseDir = baseParts.Count > 0 ? string.Join("/", baseParts) : ".";
+ if (!Directory.Exists(baseDir)) return (baseDir, new());
+
+ var fileGlob = segments[^1].Contains('*') ? segments[^1] : "*";
+ var option = pattern.Contains("**") ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
+ var files = Directory.EnumerateFiles(baseDir, fileGlob, option)
+ .Where(IsSource)
+ .OrderBy(f => f, StringComparer.Ordinal)
+ .ToList();
+ return (baseDir, files);
+ }
+
+ // A valid C# identifier from a file name (non-identifier chars -> '_', digit-leading -> '_'-prefixed).
+ static string ToClassName(string fileName)
+ {
+ var sb = new StringBuilder();
+ foreach (var c in fileName)
+ {
+ sb.Append(char.IsLetterOrDigit(c) || c == '_' ? c : '_');
+ }
+ var name = sb.ToString();
+ return name.Length == 0 || char.IsDigit(name[0]) ? "_" + name : name;
+ }
+
}
enum OutputFormat
diff --git a/src/ChibiRuby.JetPack/ChibiRuby.JetPack.csproj b/src/ChibiRuby.JetPack/ChibiRuby.JetPack.csproj
new file mode 100644
index 00000000..ac5a9a9c
--- /dev/null
+++ b/src/ChibiRuby.JetPack/ChibiRuby.JetPack.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0;net9.0;net10.0;netstandard2.1
+ enable
+ 13
+ true
+ ChibiRuby.JetPack
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/Analyzer.cs b/src/ChibiRuby.JetPack/Mrb2Cs/Analyzer.cs
new file mode 100644
index 00000000..4d227f29
--- /dev/null
+++ b/src/ChibiRuby.JetPack/Mrb2Cs/Analyzer.cs
@@ -0,0 +1,2079 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using ChibiRuby;
+namespace ChibiRuby.JetPack.Mrb2Cs;
+
+// Ruby/IR analysis: program-wide devirtualization registries + the trivial-accessor /
+// constant-return recognizers they are built from. Pure over the IR (produces facts; emits nothing).
+public static class Analyzer
+{
+ // Trivial getter (`def f; @f; end`) / setter (`def f=(v); @f = v; end`) recognizer over a
+ // method irep. Returns (field, isSetter, fingerprint) or null. Used both by scalar
+ // replacement (field access on a scalar object) and by cross-object accessor devirt.
+ public static (Symbol Field, bool IsSetter, ulong Fingerprint)? TryRecognizeTrivialAccessor(MRubyState state, Irep irep)
+ {
+ if (!Mrb2CsCompiler.TryReadMandatoryArgCount(irep, out var argc) || argc > 1) return null;
+ RubyIRMethod? exe;
+ try { exe = RubyIRBuilder.Build(irep, 0, out _); }
+ catch { return null; }
+ if (exe is null) return null;
+
+ var self = new HashSet { 0 };
+ foreach (var ii in exe.Instructions)
+ {
+ if (ii.OpCode == RubyIROpCode.LoadSelf) self.Add(ii.Dst);
+ }
+
+ Symbol field = default;
+ var found = false;
+ var isSetter = argc == 1;
+ foreach (var ii in exe.Instructions)
+ {
+ switch (ii.OpCode)
+ {
+ case RubyIROpCode.CheckArity:
+ case RubyIROpCode.LoadSelf:
+ case RubyIROpCode.Return:
+ case RubyIROpCode.ReturnValue:
+ case RubyIROpCode.ReturnSelf:
+ break;
+ case RubyIROpCode.GetInstanceVariable:
+ case RubyIROpCode.VirtualGetField:
+ if (isSetter || found || !self.Contains(ii.Src0)) return null;
+ field = exe.GetSymbol(ii.Aux); found = true;
+ break;
+ case RubyIROpCode.SetInstanceVariable:
+ case RubyIROpCode.VirtualSetField:
+ if (!isSetter || found || !self.Contains(ii.Src0) || ii.Src1 != 1) return null;
+ field = exe.GetSymbol(ii.Aux); found = true;
+ break;
+ default:
+ return null;
+ }
+ }
+ return found ? (field, isSetter, state.ComputeIrepFingerprint(irep)) : null;
+ }
+
+ // A 0-arg method whose body is a PURE return of an immediate constant — directly (`def n; 8; end`)
+ // or by delegating to another such method (`def n; m; end` where `m` returns a constant — multi-
+ // level, resolved on `klass`). Returns (the constant, THIS method's fingerprint for the call-site
+ // guard) or null. Strict: the body may contain only the prologue, the single value producer
+ // (a LoadValue immediate or one 0-arg self-send), and the return — anything else (a real
+ // computation / side effect / non-immediate literal like a string) disqualifies it.
+ public static (MRubyValue Value, ulong Fingerprint)? TryRecognizeConstantReturn(MRubyState state, RClass klass, Irep irep, int depth = 0)
+ {
+ if (depth > 8) return null; // delegation-cycle backstop
+ if (!Mrb2CsCompiler.TryReadMandatoryArgCount(irep, out var argc) || argc != 0) return null;
+ RubyIRMethod? exe;
+ try { exe = RubyIRBuilder.Build(irep, 0, out _); } catch { return null; }
+ if (exe is null) return null;
+ var ins = exe.Instructions;
+ var defIndex = new int[exe.ValueCount];
+ for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1;
+ for (var i = 0; i < ins.Length; i++) { var d = ins[i].Dst; if ((uint)d < (uint)defIndex.Length) defIndex[d] = i; }
+ var selfIds = new HashSet { 0 };
+ foreach (var u in ins) if (u.OpCode == RubyIROpCode.LoadSelf) selfIds.Add(u.Dst);
+
+ var retId = -1;
+ foreach (var u in ins)
+ if (u.OpCode is RubyIROpCode.Return or RubyIROpCode.ReturnValue) retId = u.Src0;
+ else if (u.OpCode == RubyIROpCode.ReturnSelf) return null;
+ if (retId < 0 || (uint)retId >= (uint)defIndex.Length || defIndex[retId] < 0) return null;
+ var prodIdx = defIndex[retId];
+
+ // Purity: every op is the prologue (CheckArity/LoadSelf), the value producer, or the return.
+ for (var i = 0; i < ins.Length; i++)
+ {
+ if (i == prodIdx) continue;
+ if (ins[i].OpCode is not (RubyIROpCode.CheckArity or RubyIROpCode.LoadSelf
+ or RubyIROpCode.Return or RubyIROpCode.ReturnValue)) return null;
+ }
+
+ var fp = state.ComputeIrepFingerprint(irep);
+ var prod = ins[prodIdx];
+ if (prod.OpCode == RubyIROpCode.LoadValue)
+ {
+ var lit = exe.GetLiteral(prod.Aux);
+ return Emitter.TryEmitLiteral(lit, out _) ? (lit, fp) : null; // immediates only (a string literal is a fresh object)
+ }
+ if (prod.OpCode is RubyIROpCode.Send or RubyIROpCode.SendSelf &&
+ (prod.OpCode == RubyIROpCode.SendSelf || selfIds.Contains(prod.Src0)) &&
+ exe.GetCallSiteArgumentCount(prod.Aux) == 0)
+ {
+ var sel2 = exe.GetCallSiteSymbol(prod.Aux);
+ if (state.TryFindMethod(klass, sel2, out var m2, out _) && m2.Proc is { } p2 &&
+ TryRecognizeConstantReturn(state, klass, p2.Irep, depth + 1) is { } inner)
+ {
+ return (inner.Value, fp); // inner's constant, THIS method's fp for the guard
+ }
+ }
+ return null;
+ }
+
+
+ // Program-wide map: method selector -> the trivial-accessor it denotes, for cross-object
+ // devirtualization. Built by walking every method on the class tree. A selector whose
+ // trivial-accessor reading is ambiguous (two classes resolve it to different field/body)
+ // is dropped — a guarded devirt would just deopt for one of them anyway. Selectors that
+ // are sometimes non-accessor methods stay registered; the per-site fingerprint guard
+ // sends those receivers down the slow path, so it remains correct.
+ public static Dictionary BuildAccessorRegistry(MRubyState state)
+ {
+ var map = new Dictionary();
+ var ambiguous = new HashSet();
+ state.EnumerateAotMethods((_, methodId, irep) =>
+ {
+ if (ambiguous.Contains(methodId)) return;
+ var acc = TryRecognizeTrivialAccessor(state, irep);
+ if (acc is null) return;
+ var target = new AccessorTarget(acc.Value.Field, acc.Value.Fingerprint, acc.Value.IsSetter);
+ if (map.TryGetValue(methodId, out var prev))
+ {
+ if (!prev.Equals(target))
+ {
+ map.Remove(methodId);
+ ambiguous.Add(methodId);
+ }
+ }
+ else
+ {
+ map[methodId] = target;
+ }
+ });
+ return map;
+ }
+
+ // Program-wide map: selector -> the immediate constant a 0-arg method of that name returns
+ // (directly or via delegation). Ambiguity (two classes' same-named methods return different
+ // constants) drops the selector; the per-site fingerprint guard sends non-matching receivers
+ // to the slow path, so a selector that is a constant-returner for one class and a real method
+ // for another stays correct.
+ public static Dictionary BuildConstReturnRegistry(MRubyState state)
+ {
+ var map = new Dictionary();
+ var ambiguous = new HashSet();
+ state.EnumerateAotMethods((cls, methodId, irep) =>
+ {
+ if (ambiguous.Contains(methodId)) return;
+ if (TryRecognizeConstantReturn(state, cls, irep) is not { } c) return;
+ var target = new ConstReturnTarget(c.Value, c.Fingerprint);
+ if (map.TryGetValue(methodId, out var prev))
+ {
+ if (!prev.Equals(target)) { map.Remove(methodId); ambiguous.Add(methodId); }
+ }
+ else
+ {
+ map[methodId] = target;
+ }
+ });
+ return map;
+ }
+
+ public static Dictionary BuildInlineSelectorRegistry(
+ MRubyState state,
+ IReadOnlyDictionary inlineRegistry)
+ {
+ var map = new Dictionary();
+ var ambiguous = new HashSet();
+ state.EnumerateAotMethods((definingClass, methodId, irep) =>
+ {
+ if (ambiguous.Contains(methodId)) return;
+ var fp = state.ComputeIrepFingerprint(irep);
+ if (!inlineRegistry.TryGetValue(fp, out var argc)) return;
+ if (TryRecognizeTrivialAccessor(state, irep) is not null) return;
+ if (!TryReadInlineSelectorShape(irep, out var returnsNew)) return;
+
+ var target = new InlineSelectorTarget(irep, argc, fp, definingClass, returnsNew);
+ if (map.TryGetValue(methodId, out var prev))
+ {
+ if (prev.Fingerprint != fp || prev.ArgCount != argc)
+ {
+ map.Remove(methodId);
+ ambiguous.Add(methodId);
+ }
+ }
+ else
+ {
+ map[methodId] = target;
+ }
+ });
+ return map;
+ }
+
+ // Build the struct layout for a stack-allocatable class, or null if its initialize isn't a
+ // straight-line sequence of `@f = ` / `@f = ` stores to self (no method
+ // calls, branches, or reads of other state). Every field starts Boxed/not-Mutated; ②/① fill
+ // FieldKinds/FieldNested/Mutated. The initialize fingerprint names the struct type.
+ //
+ // Dedicated to stack allocation (NOT ScalarContext.AnalyzeInitialize, which only models `@f=arg`
+ // and feeds scalar replacement's EmitScalarNew); broadening that would change scalar behavior.
+ static StackLayout? GetStackLayout(MRubyState state, RClass klass, Symbol constName)
+ {
+ if (!state.TryFindMethod(klass, state.Intern("initialize"u8), out var method, out _) ||
+ method.Proc is not { } proc ||
+ !Mrb2CsCompiler.TryReadMandatoryArgCount(proc.Irep, out var iargc))
+ {
+ return null;
+ }
+ RubyIRMethod? exe;
+ try { exe = RubyIRBuilder.Build(proc.Irep, 0, out _); }
+ catch { return null; }
+ if (exe is null) return null;
+ var ins = exe.Instructions;
+ var defIndex = new int[exe.ValueCount];
+ for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1;
+ for (var i = 0; i < ins.Length; i++) { var d = ins[i].Dst; if ((uint)d < (uint)defIndex.Length) defIndex[d] = i; }
+ var selfVids = new HashSet { 0 };
+ foreach (var li in ins) if (li.OpCode == RubyIROpCode.LoadSelf) selfVids.Add(li.Dst);
+
+ var fields = new List();
+ var fieldArg = new List();
+ var fieldLiteral = new List();
+ var fieldKinds = new List();
+ var fieldNested = new List();
+ foreach (var ii in ins)
+ {
+ switch (ii.OpCode)
+ {
+ case RubyIROpCode.CheckArity:
+ case RubyIROpCode.LoadSelf:
+ case RubyIROpCode.LoadValue: // a literal we may store below
+ case RubyIROpCode.GetConstant: // a class for a nested new we may store below
+ case RubyIROpCode.VirtualNew: // a nested object we may store below
+ case RubyIROpCode.Return:
+ case RubyIROpCode.ReturnValue:
+ case RubyIROpCode.ReturnSelf:
+ break;
+ case RubyIROpCode.SetInstanceVariable:
+ case RubyIROpCode.VirtualSetField:
+ if (!selfVids.Contains(ii.Src0)) return null;
+ var v = ii.Src1;
+ if (v >= 1 && v <= iargc) // @f = ctor arg
+ {
+ fields.Add(exe.GetSymbol(ii.Aux));
+ fieldArg.Add(v - 1); fieldLiteral.Add(default);
+ fieldKinds.Add(StackFieldKind.Boxed); fieldNested.Add(null);
+ }
+ else if ((uint)v < (uint)defIndex.Length && defIndex[v] >= 0 &&
+ ins[defIndex[v]].OpCode == RubyIROpCode.LoadValue) // @f = literal
+ {
+ fields.Add(exe.GetSymbol(ii.Aux));
+ fieldArg.Add(-1); fieldLiteral.Add(exe.GetLiteral(ins[defIndex[v]].Aux));
+ fieldKinds.Add(StackFieldKind.Boxed); fieldNested.Add(null);
+ }
+ else if (TryBuildNestedFill(state, exe, defIndex, v) is { } nested) // @f = Vec.new(literals)
+ {
+ fields.Add(exe.GetSymbol(ii.Aux));
+ fieldArg.Add(-1); fieldLiteral.Add(default);
+ fieldKinds.Add(StackFieldKind.Nested); fieldNested.Add(nested);
+ }
+ else
+ {
+ return null; // @f = computed value / non-literal nested new -> not yet
+ }
+ break;
+ default:
+ return null;
+ }
+ }
+ if (fields.Count == 0) return null;
+ var fp = state.ComputeIrepFingerprint(proc.Irep);
+ return new StackLayout
+ {
+ Cls = klass,
+ ClassFp = fp,
+ ConstName = constName,
+ NameSuffix = Emitter.NameSuffixFor(Encoding.UTF8.GetString(state.NameOf(constName))),
+ InitFingerprint = fp,
+ Fields = fields,
+ FieldArg = fieldArg,
+ FieldLiteral = fieldLiteral,
+ FieldKinds = fieldKinds,
+ FieldNested = fieldNested,
+ };
+ }
+
+ // A field initialized by `@f = Klass.new()`: returns a flattened inner layout whose
+ // fields are all literal-filled (so it nests as a value with no heap allocation), or null if
+ // the value isn't a nested `new` with literal args (computed args = ① ctor-arg nesting, later).
+ static StackLayout? TryBuildNestedFill(MRubyState state, RubyIRMethod exe, int[] defIndex, int valueId)
+ {
+ var ins = exe.Instructions;
+ if ((uint)valueId >= (uint)defIndex.Length || defIndex[valueId] < 0) return null;
+ var def = ins[defIndex[valueId]];
+ if (def.OpCode != RubyIROpCode.VirtualNew) return null;
+ // Resolve the constructed class from the .new receiver (a GetConstant), tracing Moves.
+ var classVid = def.Src0; Symbol cn = default; RClass? kc = null;
+ for (var hops = 0; hops < ins.Length; hops++)
+ {
+ if ((uint)classVid >= (uint)defIndex.Length || defIndex[classVid] < 0) break;
+ var d = ins[defIndex[classVid]];
+ if (d.OpCode == RubyIROpCode.Move) { classVid = d.Src0; continue; }
+ if (d.OpCode != RubyIROpCode.GetConstant) break;
+ cn = exe.GetSymbol(d.Aux);
+ if (state.TryGetConst(cn, out var cv) && cv.Object is RClass k) kc = k;
+ break;
+ }
+ if (kc is null) return null;
+ var inner = GetStackLayout(state, kc, cn);
+ if (inner is null) return null;
+ var argc = exe.GetCallSiteArgumentCount(def.Aux);
+ var litFill = new List();
+ for (var i = 0; i < inner.Fields.Count; i++)
+ {
+ if (inner.FieldKinds[i] != StackFieldKind.Boxed) return null; // nested-nested: defer
+ if (inner.FieldArg[i] >= 0) // inner field <- nested new arg (must be literal)
+ {
+ if (inner.FieldArg[i] >= argc) return null;
+ var argVid = exe.GetCallSiteArgumentValueId(def.Aux, inner.FieldArg[i]);
+ if ((uint)argVid >= (uint)defIndex.Length || defIndex[argVid] < 0 ||
+ ins[defIndex[argVid]].OpCode != RubyIROpCode.LoadValue) return null;
+ litFill.Add(exe.GetLiteral(ins[defIndex[argVid]].Aux));
+ }
+ else
+ {
+ litFill.Add(inner.FieldLiteral[i]); // inner field is itself a literal
+ }
+ }
+ // Same struct TYPE as the generic inner layout (Stk_); only the fill is literal.
+ return new StackLayout
+ {
+ Cls = inner.Cls,
+ ClassFp = inner.ClassFp,
+ ConstName = inner.ConstName,
+ NameSuffix = inner.NameSuffix, // same struct TYPE (Stk_...) -> same suffix
+ InitFingerprint = inner.InitFingerprint,
+ Fields = inner.Fields,
+ FieldArg = inner.Fields.ConvertAll(_ => -1),
+ FieldLiteral = litFill,
+ FieldKinds = inner.Fields.ConvertAll(_ => StackFieldKind.Boxed),
+ FieldNested = inner.Fields.ConvertAll(_ => (StackLayout?)null),
+ };
+ }
+
+ // Find VirtualNew sites whose object can be built on the stack (as a struct) instead of
+ // heap-allocated: it never escapes EXCEPT by being passed as a non-retained argument to a
+ // callee (per the interprocedural escape summary). Field reads/writes on it and being the
+ // receiver of its own accessor sends are fine. Any other use (returned, stored to the heap,
+ // put in an array, passed as a ctor arg, receiver of a non-accessor send, passed to a callee
+ // that retains the arg) disqualifies it. This is ScalarContext's escape analysis relaxed to allow
+ // the proven-non-retaining cross-call argument pass.
+ // Conservative, single-method check: does `sel` on `klass` use self ONLY for reads — direct
+ // ivar gets and trivial getter sends on self — never retaining it (return self / store it /
+ // pass it as an arg / receiver of a non-getter send) or mutating it (ivar set / setter)? Such a
+ // callee is safe to compile as a read-only struct-RECEIVER variant (self passed by `in`). The
+ // escape summary only tracks params 1..argc (not self), so this is a focused, sound check
+ // (anything uncertain -> false). The variant still has a reify+Send fallback on guard miss.
+ static bool CalleeSelfReadOnly(MRubyState state, RClass klass, Symbol sel)
+ {
+ if (!state.TryFindMethod(klass, sel, out var m, out _) || m.Proc is not { } proc) return false;
+ RubyIRMethod? exe;
+ try { exe = RubyIRBuilder.Build(proc.Irep, 0, out _); } catch { return false; }
+ if (exe is null) return false;
+ if (Mrb2CsCompiler.SsaEnabled && Mrb2CsCompiler.TryReadMandatoryArgCount(proc.Irep, out var ac)) exe = RubyIRSsaRenumber.Run(exe, ac);
+ var ins = exe.Instructions;
+ var selfIds = new HashSet { 0 };
+ foreach (var u in ins) if (u.OpCode == RubyIROpCode.LoadSelf) selfIds.Add(u.Dst);
+ var grew = true;
+ while (grew)
+ {
+ grew = false;
+ foreach (var u in ins) if (u.OpCode == RubyIROpCode.Move && selfIds.Contains(u.Src0) && selfIds.Add(u.Dst)) grew = true;
+ }
+ // self IS value-id 0, but unused IR operand slots also default to 0 — so the id-0
+ // ambiguity must be kept out of VALUE-slot checks. A genuine self-as-value always flows
+ // through a LoadSelf temp (id > 0; mruby loads self into a register before using it as a
+ // value), so excluding 0 from value checks stays sound. Receiver (Src0) and Return operands
+ // are always meaningful, so they use the full set (incl 0).
+ bool IsSelfValue(int id) => id != 0 && selfIds.Contains(id);
+ foreach (var u in ins)
+ {
+ var op = u.OpCode;
+ if (op is RubyIROpCode.ReturnSelf) return false; // returns self
+ if (op == RubyIROpCode.Return && selfIds.Contains(u.Src0)) return false; // returns self
+ if (IsSelfValue(u.Src1) || IsSelfValue(u.Src2)) return false; // self stored as a value
+ // Self as receiver: only the genuinely dangerous ops disqualify (mutate self / a call
+ // that may retain it). Reads (GetInstanceVariable) and ops that don't use Src0 as a
+ // receiver (CheckArity, LoadSelf, ... — Src0 spuriously 0, self's own id) are fine; the
+ // escape paths are covered by the value/arg/return checks above and below.
+ if (selfIds.Contains(u.Src0))
+ {
+ if (op is RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField or RubyIROpCode.SendSelf)
+ {
+ return false; // mutates self / self-context call that may retain self
+ }
+ if (op == RubyIROpCode.Send)
+ {
+ var s = exe.GetCallSiteSymbol(u.Aux);
+ if (!(state.TryFindMethod(klass, s, out var am, out _) && am.Proc is { } ap &&
+ TryRecognizeTrivialAccessor(state, ap.Irep) is { IsSetter: false }))
+ {
+ return false; // non-getter send on self (could retain/mutate)
+ }
+ }
+ }
+ if (RubyIROpInfo.IsSendOp(op) || op is RubyIROpCode.VirtualNew or RubyIROpCode.PureUnarySend)
+ {
+ var argc = exe.GetCallSiteArgumentCount(u.Aux);
+ for (var a = 0; a < argc; a++)
+ if (IsSelfValue(exe.GetCallSiteArgumentValueId(u.Aux, a))) return false; // self passed as arg
+ }
+ }
+ return true;
+ }
+
+ // `start` plus the value-ids that are (transitively) Move-copies of it — its alias set.
+ internal static HashSet MoveClosure(RubyIRMethod exe, int start)
+ {
+ var set = new HashSet { start };
+ var grew = true;
+ while (grew)
+ {
+ grew = false;
+ foreach (var u in exe.Instructions)
+ if (u.OpCode == RubyIROpCode.Move && set.Contains(u.Src0) && set.Add(u.Dst))
+ grew = true;
+ }
+ return set;
+ }
+
+ internal static Dictionary FindStackEligible(MRubyState state, RubyIRMethod exe, RubyIREscapeSummary.Summary summary)
+ {
+ var result = new Dictionary();
+ var ins = exe.Instructions;
+ var defIndex = new int[exe.ValueCount];
+ for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1;
+ for (var i = 0; i < ins.Length; i++) { var d = ins[i].Dst; if ((uint)d < (uint)defIndex.Length) defIndex[d] = i; }
+
+ for (var i = 0; i < ins.Length; i++)
+ {
+ if (ins[i].OpCode != RubyIROpCode.VirtualNew) continue;
+ var objId = ins[i].Dst;
+ // Resolve the constructed class from the .new receiver (a GetConstant), tracing Moves.
+ var classVid = ins[i].Src0;
+ Symbol constName = default;
+ RClass? klass = null;
+ for (var hops = 0; hops < ins.Length; hops++)
+ {
+ if ((uint)classVid >= (uint)defIndex.Length || defIndex[classVid] < 0) break;
+ var def = ins[defIndex[classVid]];
+ if (def.OpCode == RubyIROpCode.Move) { classVid = def.Src0; continue; }
+ if (def.OpCode != RubyIROpCode.GetConstant) break;
+ constName = exe.GetSymbol(def.Aux);
+ if (state.TryGetConst(constName, out var cv) && cv.Object is RClass kc) klass = kc;
+ break;
+ }
+ if (klass is null)
+ {
+ if (Environment.GetEnvironmentVariable("AOT_ESCAPE_DEBUG") == "1")
+ System.Console.Error.WriteLine($"[stackobj] v{objId}: class unresolved");
+ continue;
+ }
+
+ if (IsStackEligible(state, exe, defIndex, i, objId, klass, summary, out var aliases, out var mutated) &&
+ GetStackLayout(state, klass, constName) is { } layout)
+ {
+ layout.Mutated = mutated; // mutated by a callee -> passed by ref (A)
+ // Map every alias (the VirtualNew dst + its Move copies) to the layout so each
+ // becomes a struct local; Moves between them are struct value-copies (sound for a
+ // read-only object; a ref/Mutated object's aliases share the same struct local too).
+ foreach (var a in aliases) result[a] = layout;
+ }
+ else if (Environment.GetEnvironmentVariable("AOT_ESCAPE_DEBUG") == "1")
+ {
+ System.Console.Error.WriteLine($"[stackobj] v{objId} = new {state.NameOf(constName)}: REJECTED ({StackRejectReason})");
+ }
+ }
+
+ PropagateNestedReads(state, exe, defIndex, result, summary);
+ DropUndispatchableMultiStructSends(state, exe, result);
+ return result;
+ }
+
+ // Two lowerings dispatch a stack object at a Send: the RECEIVER (struct `self`, no struct args)
+ // via TryEmitStackReceiverSend, OR one-or-more struct ARGS (receiver not a struct) via
+ // TryEmitStackArgSend. A Send that is BOTH (struct receiver AND struct args) has no lowering, so
+ // drop its struct args, keeping the receiver. Fixpoint (dropping an object can relieve another
+ // Send). Multiple struct args with a non-struct receiver are fine (handled).
+ static void DropUndispatchableMultiStructSends(MRubyState state, RubyIRMethod exe, Dictionary result)
+ {
+ var ins = exe.Instructions;
+ var changed = true;
+ while (changed)
+ {
+ changed = false;
+ for (var j = 0; j < ins.Length; j++)
+ {
+ if (ins[j].OpCode is not (RubyIROpCode.Send or RubyIROpCode.SendSelf)) continue;
+ if (!result.ContainsKey(ins[j].Src0)) continue; // receiver not a struct -> arg path handles N args
+ var argc = exe.GetCallSiteArgumentCount(ins[j].Aux);
+ var victims = new List();
+ for (var a = 0; a < argc; a++)
+ if (result.TryGetValue(exe.GetCallSiteArgumentValueId(ins[j].Aux, a), out var alay)) victims.Add(alay);
+ if (victims.Count == 0) continue; // struct receiver, no struct args -> receiver path handles it
+ foreach (var victim in victims)
+ foreach (var id in new List(result.Keys))
+ if (ReferenceEquals(result[id], victim)) result.Remove(id);
+ changed = true;
+ break; // result mutated; restart the scan
+ }
+ }
+ }
+
+ // Cascade: reading a Nested field of a stack object yields the inner struct as a value
+ // (`ray.dir` -> a Stk_Vec). Track that result value-id as a stack object too (using the inner
+ // layout) when it is itself used stack-safely, so e.g. `ray.dir.vdot(@n)` lowers to a struct-
+ // receiver variant instead of forcing a reify. Fixpoint (a nested read can feed another). The
+ // read itself is the dst's "def" (skipped via newIndex in the use check). Run BOTH after
+ // FindStackEligible (method/block-local objects) AND after a variant's struct param is
+ // registered (the param's own nested fields), so a `param.inner.m()` chain cascades too.
+ internal static void PropagateNestedReads(MRubyState state, RubyIRMethod exe, int[] defIndex, Dictionary result, RubyIREscapeSummary.Summary summary)
+ {
+ var ins = exe.Instructions;
+ var grew = true;
+ while (grew)
+ {
+ grew = false;
+ for (var j = 0; j < ins.Length; j++)
+ {
+ if (ins[j].OpCode != RubyIROpCode.Send) continue;
+ if (!result.TryGetValue(ins[j].Src0, out var olay)) continue; // recv is a stack object
+ if (result.ContainsKey(ins[j].Dst)) continue; // already tracked
+ var fi = NestedFieldGetterIndex(state, olay, exe.GetCallSiteSymbol(ins[j].Aux));
+ if (fi < 0) continue; // not a nested-field getter
+ var innerLay = olay.FieldNested[fi]!;
+ // A nested-field read yields a COPY (`so90 = so30.f0`); mutating it would not write
+ // back to the parent, so only cascade read-only (non-mutated) inner values.
+ if (IsStackEligible(state, exe, defIndex, j, ins[j].Dst, innerLay.Cls, summary, out var caliases, out var cmut) && !cmut)
+ {
+ foreach (var a in caliases) result[a] = innerLay;
+ grew = true;
+ }
+ }
+ }
+ }
+
+ // The field index whose trivial getter is `sel` on `lay.Cls` AND which is a Nested field, or -1.
+ static int NestedFieldGetterIndex(MRubyState state, StackLayout lay, Symbol sel)
+ {
+ if (!state.TryFindMethod(lay.Cls, sel, out var m, out _) || m.Proc is not { } proc) return -1;
+ if (TryRecognizeTrivialAccessor(state, proc.Irep) is not { IsSetter: false } acc) return -1;
+ for (var i = 0; i < lay.Fields.Count; i++)
+ if (lay.FieldKinds[i] == StackFieldKind.Nested && lay.Fields[i] == acc.Field) return i;
+ return -1;
+ }
+
+ static string StackRejectReason = "";
+
+ static bool IsStackEligible(MRubyState state, RubyIRMethod exe, int[] defIndex, int newIndex, int objId, RClass klass, RubyIREscapeSummary.Summary summary, out HashSet aliases, out bool mutated)
+ {
+ bool Reject(string r) { StackRejectReason = r; return false; }
+ mutated = false;
+ var ins = exe.Instructions;
+ // Alias set via Move copies, assigned up front so it is valid on every return path. The
+ // caller maps EVERY alias id to the struct layout, so a `b = a` copy of a (read-only,
+ // Stage 1) stack object becomes a struct value-copy that shares the same fields.
+ aliases = new HashSet { objId };
+ foreach (var captured in exe.ClosureCapturedValueIds)
+ {
+ if (captured == objId) return Reject("captured");
+ }
+ var grew = true;
+ while (grew)
+ {
+ grew = false;
+ for (var j = 0; j < ins.Length; j++)
+ {
+ if (ins[j].OpCode == RubyIROpCode.Move && aliases.Contains(ins[j].Src0))
+ {
+ foreach (var captured in exe.ClosureCapturedValueIds) if (captured == ins[j].Dst) return false;
+ if (aliases.Add(ins[j].Dst)) grew = true;
+ }
+ }
+ }
+
+ for (var j = 0; j < ins.Length; j++)
+ {
+ if (j == newIndex) continue;
+ var u = ins[j];
+ var op = u.OpCode;
+ if (op == RubyIROpCode.Move) continue; // pure alias copy; dest already in `aliases`
+ var recvIsObj = aliases.Contains(u.Src0);
+ // Receiver of its own accessor send (getter/setter on klass): a field access, ok.
+ if (op == RubyIROpCode.Send && recvIsObj)
+ {
+ var sel = exe.GetCallSiteSymbol(u.Aux);
+ var acc = state.TryFindMethod(klass, sel, out var mm, out _) && mm.Proc is { } pr
+ ? TryRecognizeTrivialAccessor(state, pr.Irep) : null;
+ // A trivial accessor on the object lowers to a field access (a SETTER mutates the
+ // struct -> by ref); a non-accessor method that uses self read-only (per
+ // CalleeSelfReadOnly) lowers to a struct-RECEIVER variant call — both keep it stack.
+ if (acc is { IsSetter: true }) mutated = true;
+ if (acc is null && !CalleeSelfReadOnly(state, klass, sel))
+ {
+ return Reject("recv-nonaccessor-send:" + state.NameOf(sel));
+ }
+ // Accessor arg (a setter's value) must not BE the object (would store it into itself
+ // — that's a cycle we don't model). Checked by the arg scan below.
+ }
+ // Direct ivar op on the object: ok (a SET mutates the struct -> by ref).
+ else if (recvIsObj && op is RubyIROpCode.GetInstanceVariable or RubyIROpCode.VirtualGetField or
+ RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField)
+ {
+ if (op is RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField) mutated = true;
+ // SetInstanceVariable storing the object as a VALUE (Src1) is handled below.
+ }
+ else if (op == RubyIROpCode.GuardInlineClass && recvIsObj)
+ {
+ // no-op guard, ok
+ }
+ else if (recvIsObj)
+ {
+ // Receiver in any other op (non-accessor send, etc.) -> escape.
+ return Reject("recv-other-op:" + op);
+ }
+
+ // The object must not appear as Src1/Src2 (stored as a value / index / etc.).
+ if (op != RubyIROpCode.GuardInlineClass && (aliases.Contains(u.Src1) || aliases.Contains(u.Src2)))
+ {
+ return Reject("appears-src1/2:" + op);
+ }
+ // The object must not be put in a new array.
+ if (op is RubyIROpCode.NewArray or RubyIROpCode.NewArray2 or RubyIROpCode.NewHash)
+ {
+ var c = exe.GetOperandListCount(u.Aux);
+ for (var a = 0; a < c; a++) if (aliases.Contains(exe.GetOperandListValueId(u.Aux, a))) return Reject("into-array");
+ }
+ // Returned -> escape.
+ if (op == RubyIROpCode.Return && aliases.Contains(u.Src0)) return Reject("returned");
+ // Passed as a call/ctor argument: a ctor arg always retains; a send arg is ok only if
+ // the (polymorphic) callee provably does not retain that position for `klass`.
+ if (RubyIROpInfo.IsSendOp(op) || op is RubyIROpCode.PureUnarySend or RubyIROpCode.VirtualNew)
+ {
+ var sel = op == RubyIROpCode.VirtualNew ? default : exe.GetCallSiteSymbol(u.Aux);
+ var argc = exe.GetCallSiteArgumentCount(u.Aux);
+ for (var a = 0; a < argc; a++)
+ {
+ if (!aliases.Contains(exe.GetCallSiteArgumentValueId(u.Aux, a))) continue;
+ // Only a real Send/SendSelf can be proven non-retaining via the summary; a ctor
+ // arg or a pure-unary-send arg is assumed to retain.
+ if (op is not (RubyIROpCode.Send or RubyIROpCode.SendSelf)) return Reject("ctor/unary-arg:" + op);
+ if (summary.SelectorRetains(sel, a + 1, klass)) return Reject("callee-retains:" + state.NameOf(sel) + "#" + (a + 1));
+ // A callee that mutates the arg is fine — it is passed by `ref` and the layout
+ // is marked Mutated (the reify fallback snapshots + copies back). (A)
+ if (summary.SelectorMutates(sel, a + 1, klass)) mutated = true;
+ }
+ }
+ }
+ return true;
+ }
+
+ // Ivars the defining class's `initialize` sets to a fixnum literal (@x = 3) — statically int,
+ // so float speculation must skip them (else a guard misses every call and the method
+ // constant-deopts). Cheap one-shot scan of the lowered initialize body.
+ internal static HashSet CollectKnownFixnumIvars(MRubyState state, RClass? definingClass)
+ {
+ var result = new HashSet();
+ if (definingClass is null) return result;
+ if (!state.TryFindMethod(definingClass, state.Intern("initialize"u8), out var m, out _) ||
+ m.Proc is not { } proc)
+ {
+ return result;
+ }
+ RubyIRMethod? exe;
+ try { exe = RubyIRBuilder.Build(proc.Irep, 0, out _); }
+ catch { return result; }
+ if (exe is null) return result;
+ var ins = exe.Instructions;
+ var defIndex = new int[exe.ValueCount];
+ for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1;
+ for (var i = 0; i < ins.Length; i++) { var d = ins[i].Dst; if ((uint)d < (uint)defIndex.Length) defIndex[d] = i; }
+ for (var i = 0; i < ins.Length; i++)
+ {
+ if (ins[i].OpCode is not (RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField)) continue;
+ var v = ins[i].Src1; // value operand (Src0 is self)
+ if ((uint)v < (uint)defIndex.Length && defIndex[v] >= 0 &&
+ ins[defIndex[v]].OpCode == RubyIROpCode.LoadValue &&
+ exe.GetLiteral(ins[defIndex[v]].Aux).IsFixnum)
+ {
+ result.Add(exe.GetSymbol(ins[i].Aux));
+ }
+ }
+ return result;
+ }
+
+
+
+ internal static bool[] ComputeUnboxing(MRubyState state, RubyIRMethod exe, int argCount, ScalarContext? sc, out bool[] floatTaintOut, out bool[] isDoubleOut, out bool[] provesDoubleOut, out bool[] soundProvenOut, IReadOnlyDictionary? accessorRegistry = null, HashSet? knownFixnumIvars = null, bool classUsesFloat = true, RClass? definingClass = null)
+ {
+ var n = exe.ValueCount;
+ var isLong = new bool[n];
+ var hasDef = new bool[n];
+ var nonArithDef = new bool[n];
+ var boxedUse = new bool[n];
+ var floatTaint = new bool[n];
+ floatTaintOut = floatTaint;
+ var ins = exe.Instructions;
+ void MarkBoxed(int id)
+ {
+ if ((uint)id < (uint)n) boxedUse[id] = true;
+ }
+ void Taint(int id)
+ {
+ if ((uint)id < (uint)n) floatTaint[id] = true;
+ }
+ for (var i = 0; i < ins.Length; i++)
+ {
+ var op = ins[i].OpCode;
+ var d = ins[i].Dst;
+ if ((uint)d < (uint)n)
+ {
+ hasDef[d] = true;
+ if (!RubyIROpInfo.IsFixnumArith(op)) nonArithDef[d] = true;
+ }
+ // Float-taint seeds: a float literal, a float-valued constant, or a send whose
+ // result is always a float.
+ if (op == RubyIROpCode.LoadValue && exe.GetLiteral(ins[i].Aux).IsFloat)
+ {
+ Taint(d);
+ }
+ else if (op == RubyIROpCode.GetConstant && IsFloatConstantName(state, exe.GetSymbol(ins[i].Aux)))
+ {
+ Taint(d);
+ }
+ else if ((op is RubyIROpCode.Send or RubyIROpCode.SendSelf) &&
+ IsFloatReturningMethod(state, exe.GetCallSiteSymbol(ins[i].Aux)))
+ {
+ Taint(d);
+ }
+ // Arith/compare read their operands as fixnums -> not a boxed use.
+ if (RubyIROpInfo.IsFixnumArith(op) || RubyIROpInfo.IsFixnumCompare(op))
+ {
+ continue;
+ }
+ // Return reads its operand at a re-box boundary (BoxReadFull handles any unboxed kind),
+ // so it does NOT force the value boxed — letting a returned double accumulator stay a
+ // raw double across the method and re-box only here.
+ if (op is RubyIROpCode.Return or RubyIROpCode.ReturnValue)
+ {
+ continue;
+ }
+ // Any other instruction consumes its value operands boxed. Over-marking a
+ // non-operand source field is harmless (only loses an unboxing opportunity).
+ MarkBoxed(ins[i].Src0);
+ MarkBoxed(ins[i].Src1);
+ MarkBoxed(ins[i].Src2);
+ if (RubyIROpInfo.IsSendOp(op) || op == RubyIROpCode.PureUnarySend || op == RubyIROpCode.VirtualNew)
+ {
+ var argc = exe.GetCallSiteArgumentCount(ins[i].Aux);
+ for (var a = 0; a < argc; a++) MarkBoxed(exe.GetCallSiteArgumentValueId(ins[i].Aux, a));
+ }
+ else if (op is RubyIROpCode.NewArray or RubyIROpCode.NewArray2 or RubyIROpCode.NewHash)
+ {
+ var c = exe.GetOperandListCount(ins[i].Aux);
+ for (var a = 0; a < c; a++) MarkBoxed(exe.GetOperandListValueId(ins[i].Aux, a));
+ }
+ }
+ // Propagate float taint forward through Move + arith to a fixpoint. The IR has
+ // forward branches (loops via goto), so a single pass can miss back-edge feeders;
+ // iterate until stable (bounded by value count, in practice 2-3 passes). Scalar-
+ // replaced object fields are folded in: a field is float-typed if any value written
+ // to it (ctor arg / setter arg) is tainted, and a getter read of a float field taints
+ // its result — so arith over `o.x` is not wrongly unboxed to long and deopting on float.
+ bool Tainted(int id) => (uint)id < (uint)n && floatTaint[id];
+ var floatFields = new HashSet<(int Obj, int Field)>();
+ bool changed = true;
+ while (changed)
+ {
+ changed = false;
+ for (var i = 0; i < ins.Length; i++)
+ {
+ var op = ins[i].OpCode;
+ var d = ins[i].Dst;
+
+ if (sc is not null && op == RubyIROpCode.VirtualNew && sc.IsScalar(d))
+ {
+ var o = sc.GetScalarObject(d);
+ for (var f = 0; f < o.Fields.Count; f++)
+ {
+ var argVid = exe.GetCallSiteArgumentValueId(ins[i].Aux, o.FieldArg[f]);
+ if (Tainted(argVid) && floatFields.Add((d, f))) changed = true;
+ }
+ continue;
+ }
+ if (sc is not null && op == RubyIROpCode.Send &&
+ sc.TryGetAccessorSend(exe, ins[i], out var objId, out var fieldIdx, out var isSetter))
+ {
+ if (isSetter)
+ {
+ var valVid = exe.GetCallSiteArgumentValueId(ins[i].Aux, 0);
+ if (Tainted(valVid))
+ {
+ if (floatFields.Add((objId, fieldIdx))) changed = true;
+ if ((uint)d < (uint)n && !floatTaint[d]) { floatTaint[d] = true; changed = true; }
+ }
+ }
+ else if (floatFields.Contains((objId, fieldIdx)) && (uint)d < (uint)n && !floatTaint[d])
+ {
+ floatTaint[d] = true; changed = true;
+ }
+ continue;
+ }
+ if (sc is not null &&
+ sc.TryGetScalarFieldAccess(exe, ins[i], out var fieldObjId, out var scalarFieldIdx, out var isFieldSetter))
+ {
+ if (isFieldSetter)
+ {
+ if (Tainted(ins[i].Src1) && floatFields.Add((fieldObjId, scalarFieldIdx)))
+ {
+ changed = true;
+ }
+ }
+ else if (floatFields.Contains((fieldObjId, scalarFieldIdx)) &&
+ (uint)d < (uint)n &&
+ !floatTaint[d])
+ {
+ floatTaint[d] = true;
+ changed = true;
+ }
+ continue;
+ }
+
+ if ((uint)d >= (uint)n || floatTaint[d])
+ {
+ continue;
+ }
+ bool propagate = op switch
+ {
+ RubyIROpCode.Move => Tainted(ins[i].Src0),
+ _ when RubyIROpInfo.IsFixnumArith(op) => Tainted(ins[i].Src0) || Tainted(ins[i].Src1) || Tainted(ins[i].Src2),
+ _ => false,
+ };
+ if (propagate)
+ {
+ floatTaint[d] = true;
+ changed = true;
+ }
+ }
+ }
+ // ---- double-unboxing (the float twin of long-unboxing) ----
+ // provesDouble[id] = id is PROVABLY a Float at runtime (a MUST analysis): seeded by float
+ // literals / float constants / float-returning sends, then propagated through float arith
+ // ONLY when every operand is itself provably double (intersection, vs taint's union).
+ var provesDouble = new bool[n];
+ provesDoubleOut = provesDouble;
+ // soundProven[id] = provesDouble[id] was established by an UNGUARDED whole-program proof
+ // (a float literal/const, a proven-Float send, or — new in Stage 1 — a class-wide-proven
+ // Float ivar read), as opposed to the speculation block below which guesses + deopts. The
+ // float-speculation guard (EmitFloatSpeculationGuard) is suppressed for sound dsts.
+ var soundProven = new bool[n];
+ soundProvenOut = soundProven;
+ bool PD(int id) => (uint)id < (uint)n && provesDouble[id];
+ for (var i = 0; i < ins.Length; i++)
+ {
+ var op = ins[i].OpCode;
+ var d = ins[i].Dst;
+ if ((uint)d >= (uint)n) continue;
+ if ((op == RubyIROpCode.LoadValue && exe.GetLiteral(ins[i].Aux).IsFloat) ||
+ (op == RubyIROpCode.GetConstant && IsFloatConstantName(state, exe.GetSymbol(ins[i].Aux))) ||
+ ((op is RubyIROpCode.Send or RubyIROpCode.SendSelf) && IsFloatReturningMethod(state, exe.GetCallSiteSymbol(ins[i].Aux))))
+ {
+ provesDouble[d] = true;
+ soundProven[d] = true; // a proven/builtin Float source needs no runtime guard
+ }
+ }
+
+ // Stage 1 — SOUND Float unboxing from whole-program inference (env AOT_NOIVKIND, default ON).
+ // An ivar read whose (definingClass, @name) is proven Float across the WHOLE program is a
+ // Float at runtime WITHOUT a guard (vs the speculation block below, which guards + deopts).
+ // Seeding provesDouble here makes downstream float arith read it via .FloatValue / d{} with
+ // no `if(!v.IsFloat) return false`. Self ivar reads use definingClass (exactly right);
+ // sends are already covered by the proven-Float seed above (selectorReturn is receiver-class
+ // independent, so a per-class ivar lookup for accessor sends would be unsound — not done).
+ var rt = Mrb2CsCompiler.CurrentReturnTypes;
+ if (rt is not null && definingClass is not null && Environment.GetEnvironmentVariable("AOT_NOIVKIND") != "1")
+ {
+ for (var i = 0; i < ins.Length; i++)
+ {
+ var op = ins[i].OpCode;
+ var d = ins[i].Dst;
+ if ((uint)d >= (uint)n || provesDouble[d]) continue;
+ if (op is RubyIROpCode.GetInstanceVariable or RubyIROpCode.VirtualGetField &&
+ rt.IvarReturnsFloat(definingClass, exe.GetSymbol(ins[i].Aux)))
+ {
+ provesDouble[d] = true;
+ soundProven[d] = true;
+ }
+ }
+ }
+
+ // Q1.2 speculative float ivars (AOT_SPECFLOAT): ao's hot float arith is over UNTYPED ivars
+ // (@x*b.x+...), which carry no static float proof. Speculate that an ivar/accessor read
+ // feeding only float-capable arith is a Float — seed it provesDouble (a runtime IsFloat
+ // guard at the read; a miss deopts). A deopt re-runs the bytecode from the method's top,
+ // so it is safe ONLY if no observable side effect was committed before the read.
+ //
+ // Pre-side-effect window: instead of requiring the WHOLE method side-effect-free, speculate
+ // only reads BEFORE the method's first COMMITTED side effect. The IR is loop-free (forward
+ // branches only), so instruction-index order is execution order — a read at index i with no
+ // committed side effect at index < i is deopt-safe. Transparent ops (writes to a freshly-
+ // allocated scalar object, fresh allocations, pure float-Math sends, getters) don't commit
+ // and don't close the window; a write to self/a pre-existing object or an arbitrary call
+ // does. This reaches side-effecting hot methods (e.g. ao's intersect: the discriminant is
+ // all computed before the @t/@hit setters) once their float producers (vdot) are inlined.
+ if (knownFixnumIvars is not null && classUsesFloat && Environment.GetEnvironmentVariable("AOT_NOSPECFLOAT") != "1")
+ {
+ // The pre-side-effect WINDOW (SSA path) speculates prefixes of side-effecting methods,
+ // which pays once float producers are inlined. With AOT_NOSSA the legacy whole-method
+ // gate applies instead (any committed side effect disables speculation). Either way the
+ // outer class-float-evidence guard already kept this off all-integer classes.
+ int firstSideEffect;
+ if (Mrb2CsCompiler.SsaEnabled)
+ {
+ firstSideEffect = ins.Length;
+ for (var i = 0; i < ins.Length; i++)
+ {
+ if (IsCommittedSideEffect(state, exe, sc, accessorRegistry, ins[i])) { firstSideEffect = i; break; }
+ }
+ }
+ else
+ {
+ var sideEffectFree = true;
+ for (var i = 0; i < ins.Length && sideEffectFree; i++)
+ {
+ var op = ins[i].OpCode;
+ if (op is RubyIROpCode.Send or RubyIROpCode.SendSelf)
+ {
+ var sel = exe.GetCallSiteSymbol(ins[i].Aux);
+ if (!((accessorRegistry?.ContainsKey(sel) ?? false) || IsFloatReturningMethod(state, sel)))
+ sideEffectFree = false;
+ }
+ else if (!RubyIROpInfo.IsPureSpeculationOp(op))
+ {
+ sideEffectFree = false;
+ }
+ }
+ firstSideEffect = sideEffectFree ? ins.Length : 0;
+ }
+
+ // A read is speculatable only if every use is a float-capable arith/compare operand
+ // (not a fixnum-typed/bitwise/index op, not a boxed use) — so integer ivars used in
+ // `&`/`<<`/`[]` are never mis-speculated.
+ var nonFloatCapableUse = new bool[n];
+ void NFC(int id) { if ((uint)id < (uint)n) nonFloatCapableUse[id] = true; }
+ for (var i = 0; i < ins.Length; i++)
+ {
+ var op = ins[i].OpCode;
+ if (RubyIROpInfo.IsDoubleArith(op) || RubyIROpInfo.IsDoubleCompare(op)) continue;
+ NFC(ins[i].Src0); NFC(ins[i].Src1); NFC(ins[i].Src2);
+ if (RubyIROpInfo.IsSendOp(op) || op == RubyIROpCode.PureUnarySend || op == RubyIROpCode.VirtualNew)
+ {
+ var argc = exe.GetCallSiteArgumentCount(ins[i].Aux);
+ for (var a = 0; a < argc; a++) NFC(exe.GetCallSiteArgumentValueId(ins[i].Aux, a));
+ }
+ }
+ for (var i = 0; i < firstSideEffect; i++)
+ {
+ var op = ins[i].OpCode;
+ var d = ins[i].Dst;
+ if ((uint)d >= (uint)n || provesDouble[d] || boxedUse[d] || nonFloatCapableUse[d]) continue;
+ var isRead = op is RubyIROpCode.GetInstanceVariable or RubyIROpCode.VirtualGetField;
+ var isAccessor = (op is RubyIROpCode.Send or RubyIROpCode.SendSelf) &&
+ (accessorRegistry?.ContainsKey(exe.GetCallSiteSymbol(ins[i].Aux)) ?? false);
+ if (!isRead && !isAccessor) continue;
+ // Don't speculate an ivar the defining class's initialize sets to a fixnum
+ // literal (e.g. Point#@x = 3, or optcarrot counters @x = 0): those are int, so
+ // a float guard would miss every call and the method would constant-deopt.
+ var ivarName = isRead
+ ? exe.GetSymbol(ins[i].Aux)
+ : accessorRegistry![exe.GetCallSiteSymbol(ins[i].Aux)].Field;
+ if (knownFixnumIvars?.Contains(ivarName) ?? false) continue;
+ provesDouble[d] = true; // speculate Float; guard emitted at the read site
+ }
+ }
+
+ // Def counts (all Dst occurrences; branches/stores write Dst 0 == self, irrelevant). A
+ // value-id with exactly one def is single-assignment, so a Move into it is a pure copy.
+ var defCount = new int[n];
+ for (var i = 0; i < ins.Length; i++)
+ {
+ var d = ins[i].Dst;
+ if ((uint)d < (uint)n) defCount[d]++;
+ }
+
+ changed = true;
+ while (changed)
+ {
+ changed = false;
+ for (var i = 0; i < ins.Length; i++)
+ {
+ var op = ins[i].OpCode;
+ var d = ins[i].Dst;
+ if ((uint)d >= (uint)n || provesDouble[d]) continue;
+ bool prove;
+ if (RubyIROpInfo.IsDoubleArith(op))
+ {
+ prove = PD(ins[i].Src0) && PD(ins[i].Src1) && (!RubyIROpInfo.IsDoubleFused(op) || PD(ins[i].Src2));
+ }
+ else if (op == RubyIROpCode.Move && defCount[d] == 1)
+ {
+ // Single-def copy: provably double iff its source is (MUST holds — one def).
+ // Carries the proof across the copy chains SSA renumbering introduces, so an
+ // inlined float producer's result (e.g. ao's `b = rs.vdot(dir)`) stays proven
+ // double through the merge into `b` instead of relapsing to a fixnum isLong.
+ prove = PD(ins[i].Src0);
+ }
+ else
+ {
+ continue;
+ }
+ if (prove)
+ {
+ provesDouble[d] = true;
+ changed = true;
+ }
+ }
+ }
+
+ // isLong is computed AFTER provesDouble (incl. speculation) and excludes it: a provably/
+ // speculated Float value must never be treated as a fixnum long (else its arith deopts on
+ // the first float, or — under speculation — its accumulator is read as an uninitialized l{}).
+ for (var id = argCount + 1; id < n; id++)
+ {
+ isLong[id] = hasDef[id] && !nonArithDef[id] && !boxedUse[id] && !floatTaint[id] && !provesDouble[id];
+ }
+ // Registers captured by a descendant block are passed to the inlined block by ref, so
+ // they must be boxed MRubyValue locals (not unboxed longs).
+ foreach (var captured in exe.ClosureCapturedValueIds)
+ {
+ if ((uint)captured < (uint)n) isLong[captured] = false;
+ }
+
+ // A value is held as a raw `double` iff it is provably double, DEFINED by a double-arith op
+ // (seeds stay boxed and are read via .FloatValue), and used ONLY as an operand of a
+ // pure-double op (all operands provably double). Any other use (Move, immediate, fixnum-
+ // typed arith, Send arg, ivar store, return, ...) is "impure" and keeps the value boxed —
+ // mirroring long-unboxing's boxedUse rule, so non-pure emission never sees a `d{}` local.
+ var doubleDef = new bool[n];
+ var impureUse = new bool[n];
+ void Impure(int id) { if ((uint)id < (uint)n) impureUse[id] = true; }
+ for (var i = 0; i < ins.Length; i++)
+ {
+ var op = ins[i].OpCode;
+ var d = ins[i].Dst;
+ if (RubyIROpInfo.IsDoubleArith(op) && (uint)d < (uint)n && provesDouble[d]) doubleDef[d] = true;
+ if (!RubyIROpInfo.IsFixnumArith(op) && !RubyIROpInfo.IsFixnumCompare(op)) continue; // non-arith operands already boxedUse-marked
+ var fused = RubyIROpInfo.IsDoubleFused(op);
+ var pure = (RubyIROpInfo.IsDoubleArith(op) || RubyIROpInfo.IsDoubleCompare(op)) &&
+ PD(ins[i].Src0) && PD(ins[i].Src1) && (!fused || PD(ins[i].Src2));
+ if (!pure) { Impure(ins[i].Src0); Impure(ins[i].Src1); if (fused) Impure(ins[i].Src2); }
+ }
+ var isDouble = new bool[n];
+ isDoubleOut = isDouble;
+ for (var id = argCount + 1; id < n; id++)
+ {
+ isDouble[id] = provesDouble[id] && doubleDef[id] && !boxedUse[id] && !impureUse[id] && !isLong[id];
+ }
+ foreach (var captured in exe.ClosureCapturedValueIds)
+ {
+ if ((uint)captured < (uint)n) isDouble[captured] = false;
+ }
+ return isLong;
+ }
+
+ // --- Phase 2: sound numeric type inference for LOOPING methods (cyclic dataflow) ---
+ // The merge-slot loop IR gives each register ONE value-id with MULTIPLE defs (pre-loop init +
+ // back-edge update), so the acyclic ComputeUnboxing above is unsound here (it seeds provesDouble
+ // from a single def). This computes a per-id MUST type as a meet (over ALL defs) to a fixpoint,
+ // so a value is proven Float/Fixnum only if EVERY def produces that type. Mixed int/float arith
+ // is typed by Ruby semantics (Float op Numeric -> Float). Numerically-used method ARGS are
+ // speculated Fixnum (guarded at entry, before any side effect -> deopt-safe; see argGuardsOut).
+ //
+ // Lattice (meet = "could be either def's type", monotone toward UNK so it terminates):
+ // TOP(unseen) -> {FIX, FLT} -> NUM(fix|flt) -> UNK(boxed/non-numeric).
+ const byte TyTop = 0, TyFix = 1, TyFlt = 2, TyNum = 3, TyUnk = 4;
+
+ static byte MeetTy(byte a, byte b)
+ {
+ if (a == TyTop) return b;
+ if (b == TyTop) return a;
+ if (a == b) return a;
+ if (a == TyUnk || b == TyUnk) return TyUnk;
+ // a,b in {FIX,FLT,NUM}, a!=b -> all numeric, so NUM
+ return TyNum;
+ }
+
+ // Result type of `a OP b` (+,-,*,/) under Ruby coercion. Div is the same shape (Fixnum/Fixnum is
+ // integer division -> Fixnum; any Float operand -> Float).
+ static byte ArithTy(byte a, byte b)
+ {
+ if (a == TyTop || b == TyTop) return TyTop; // defer until operands known
+ if (a == TyUnk || b == TyUnk) return TyUnk; // a non-numeric operand poisons
+ if (a == TyFlt || b == TyFlt) return TyFlt; // float op numeric -> float
+ if (a == TyFix && b == TyFix) return TyFix; // fixnum op fixnum -> fixnum
+ return TyNum; // involves a NUM, no float forcing
+ }
+
+ // Sound MUST numeric typing for a looping method. Outputs provesDouble (always Float) and
+ // provesFixnum (always Fixnum, modulo fixnum-overflow->Bignum, matching the existing isLong
+ // stance). floatTaint/soundProven are set consistently (no speculation guard for proven floats).
+ // argGuardsOut: arg value-ids to guard `IsFixnum` at method entry (their typing assumed Fixnum).
+ internal static void ComputeLoopUnboxing(
+ MRubyState state, RubyIRMethod exe, int argCount, RClass? definingClass,
+ out bool[] provesDouble, out bool[] provesFixnum, out bool[] floatTaint,
+ out bool[] soundProven, out List argGuardsOut, bool speculateArgs = true)
+ {
+ var n = exe.ValueCount;
+ var ins = exe.Instructions;
+ var ty = new byte[n]; // TyTop initially (0)
+ var rt = Mrb2CsCompiler.CurrentReturnTypes;
+
+ // Args whose value REACHES a numeric-arith operand (directly, or through Move copies — the
+ // merge-slot IR loads operands into temps before arith) are speculated Fixnum (entry-guarded).
+ // feedsArith is seeded at arith operands and propagated BACKWARD through `dst = Move(src)`.
+ var feedsArith = new bool[n];
+ void Feed(int id) { if ((uint)id < (uint)n) feedsArith[id] = true; }
+ for (var i = 0; i < ins.Length; i++)
+ {
+ var op = ins[i].OpCode;
+ if (RubyIROpInfo.IsFixnumArith(op) || RubyIROpInfo.IsFixnumCompare(op))
+ {
+ Feed(ins[i].Src0);
+ Feed(ins[i].Src1);
+ if (RubyIROpInfo.IsDoubleFused(op)) Feed(ins[i].Src2);
+ }
+ }
+ bool feedChanged = true;
+ var feedGuard = 0;
+ while (feedChanged && feedGuard++ <= n + 4)
+ {
+ feedChanged = false;
+ for (var i = 0; i < ins.Length; i++)
+ {
+ if (ins[i].OpCode != RubyIROpCode.Move) continue;
+ var d = ins[i].Dst;
+ var s = ins[i].Src0;
+ if ((uint)d < (uint)n && feedsArith[d] && (uint)s < (uint)n && !feedsArith[s])
+ {
+ feedsArith[s] = true;
+ feedChanged = true;
+ }
+ }
+ }
+ var argGuards = new List();
+ for (var v = 1; v <= argCount && v < n; v++)
+ {
+ // Block bodies (speculateArgs=false) can't guard args at entry — a block deopt re-runs
+ // the PARENT method, double-applying prior loop iterations' side effects. So their args
+ // stay boxed (Unknown) rather than speculated Fixnum.
+ if (speculateArgs && feedsArith[v]) { ty[v] = TyFix; argGuards.Add(v); }
+ else ty[v] = TyUnk;
+ }
+ ty[0] = TyUnk; // self
+ var isArgOrSelf = new bool[n];
+ for (var v = 0; v <= argCount && v < n; v++) isArgOrSelf[v] = true;
+
+ // DefType: the type a single instruction's def produces (reads current operand types).
+ byte DefTy(in RubyIRInstruction ix)
+ {
+ var op = ix.OpCode;
+ switch (op)
+ {
+ case RubyIROpCode.LoadValue:
+ {
+ var lit = exe.GetLiteral(ix.Aux);
+ return lit.IsFixnum ? TyFix : lit.IsFloat ? TyFlt : TyUnk;
+ }
+ case RubyIROpCode.GetConstant:
+ return IsFloatConstantName(state, exe.GetSymbol(ix.Aux)) ? TyFlt : TyUnk;
+ case RubyIROpCode.Move:
+ return (uint)ix.Src0 < (uint)n ? ty[ix.Src0] : TyUnk;
+ case RubyIROpCode.Send:
+ if (IsFloatReturningMethod(state, exe.GetCallSiteSymbol(ix.Aux))) return TyFlt;
+ // Fixnum bitwise sends `& | ^ >>` produce a Fixnum when both operands are Fixnum.
+ // `<<` is EXCLUDED here — it can overflow to Bignum, so its result isn't a proven
+ // Fixnum (it still gets the boxed C# fast path, just not raw-long typing).
+ if (exe.GetCallSiteArgumentCount(ix.Aux) == 1 &&
+ Emitter.TryFixnumBitwiseOp(state, exe.GetCallSiteSymbol(ix.Aux), out var bop, out _) && bop != "<<")
+ {
+ var rcv = ix.Src0;
+ var arg = exe.GetCallSiteArgumentValueId(ix.Aux, 0);
+ var rt2 = (uint)rcv < (uint)n ? ty[rcv] : TyUnk;
+ var at2 = (uint)arg < (uint)n ? ty[arg] : TyUnk;
+ if (rt2 == TyTop || at2 == TyTop) return TyTop; // defer until operands known
+ var rfix = rt2 == TyFix || Mrb2CsCompiler.ConstFix(rcv, out _);
+ var afix = at2 == TyFix || Mrb2CsCompiler.ConstFix(arg, out _);
+ return rfix && afix ? TyFix : TyUnk;
+ }
+ return TyUnk;
+ case RubyIROpCode.SendSelf:
+ return IsFloatReturningMethod(state, exe.GetCallSiteSymbol(ix.Aux)) ? TyFlt : TyUnk;
+ case RubyIROpCode.GetInstanceVariable:
+ case RubyIROpCode.VirtualGetField:
+ return rt is not null && definingClass is not null &&
+ rt.IvarReturnsFloat(definingClass, exe.GetSymbol(ix.Aux)) ? TyFlt : TyUnk;
+ }
+ if (RubyIROpInfo.IsFixnumArith(op))
+ {
+ var a = (uint)ix.Src0 < (uint)n ? ty[ix.Src0] : TyUnk;
+ // Immediate ops carry their constant in Aux (a fixnum for AddImmediate; the float
+ // variants are explicitly float); a plain binary reads Src1.
+ byte b;
+ if (op is RubyIROpCode.AddImmediate or RubyIROpCode.SubImmediate
+ or RubyIROpCode.AddImmediateFixnum or RubyIROpCode.SubImmediateFixnum)
+ b = TyFix;
+ else if (op is RubyIROpCode.AddImmediateFloat or RubyIROpCode.SubImmediateFloat)
+ b = TyFlt;
+ else
+ b = (uint)ix.Src1 < (uint)n ? ty[ix.Src1] : TyUnk;
+ var r = ArithTy(a, b);
+ if (RubyIROpInfo.IsDoubleFused(op))
+ {
+ var c = (uint)ix.Src2 < (uint)n ? ty[ix.Src2] : TyUnk;
+ r = ArithTy(r, c);
+ }
+ return r;
+ }
+ // Comparisons produce a boolean (non-numeric); everything else is opaque.
+ return TyUnk;
+ }
+
+ // Fixpoint: ty[id] = meet over all defs of DefTy(def). Args/self are fixed (not re-meet).
+ bool changed = true;
+ var iterGuard = 0;
+ while (changed && iterGuard++ <= n + 4)
+ {
+ changed = false;
+ var acc = new byte[n]; // TyTop
+ var seen = new bool[n];
+ for (var i = 0; i < ins.Length; i++)
+ {
+ var d = ins[i].Dst;
+ if ((uint)d >= (uint)n || isArgOrSelf[d]) continue;
+ if (!Definecheck(ins[i].OpCode)) continue;
+ var dt = DefTy(ins[i]);
+ acc[d] = seen[d] ? MeetTy(acc[d], dt) : dt;
+ seen[d] = true;
+ }
+ for (var v = 0; v < n; v++)
+ {
+ if (isArgOrSelf[v]) continue;
+ var nt = seen[v] ? acc[v] : TyUnk; // no def -> boxed
+ if (nt != ty[v]) { ty[v] = nt; changed = true; }
+ }
+ }
+
+ provesDouble = new bool[n];
+ provesFixnum = new bool[n];
+ floatTaint = new bool[n];
+ soundProven = new bool[n];
+ for (var v = 0; v < n; v++)
+ {
+ if (ty[v] == TyFlt) { provesDouble[v] = true; floatTaint[v] = true; soundProven[v] = true; }
+ else if (ty[v] == TyFix) { provesFixnum[v] = true; }
+ }
+ argGuardsOut = argGuards;
+ }
+
+ // Phase 3: pick which proven Float/Fixnum loop value-ids can live in a RAW `double`/`long` local
+ // (FP/int register) instead of a boxed-but-guard-free MRubyValue. A value is raw-eligible iff it
+ // is never used in a boxed position (Send arg / ivar store / branch cond / index / non-numeric
+ // op), never used by a DUAL-path numeric op (whose float branch reads `v.FloatValue` and so needs
+ // the boxed form), and every def writes a raw form (fully-typed arith / immediate / Move /
+ // numeric LoadValue). Move and Return convert at the boundary, so they don't force boxing.
+ internal static void ComputeLoopRawLocals(
+ MRubyState state, RubyIRMethod exe, int argCount, bool[] provesDouble, bool[] provesFixnum,
+ out bool[] isLong, out bool[] isDouble)
+ {
+ var ins = exe.Instructions;
+ var nI = ins.Length;
+ var n = exe.ValueCount;
+ isLong = new bool[n];
+ isDouble = new bool[n];
+ var boxedUse = new bool[n];
+ var impureUse = new bool[n]; // operand of a dual-path numeric op -> needs boxed storage
+ var rawDefBad = new bool[n]; // a def writes a boxed v{} (so the id can't be a raw local)
+ var hasDef = new bool[n];
+
+ bool ProvenNum(int id)
+ {
+ if ((uint)id < (uint)n && (provesDouble[id] || provesFixnum[id])) return true;
+ return Mrb2CsCompiler.ConstFix(id, out _) || Mrb2CsCompiler.ConstFloat(id, out _);
+ }
+
+ var tmp = new ulong[(n + 63) >> 6];
+ for (var i = 0; i < nI; i++)
+ {
+ var op = ins[i].OpCode;
+ var d = ins[i].Dst;
+ var defines = Definecheck(op) && (uint)d < (uint)n;
+ if (defines) hasDef[d] = true;
+
+ if (RubyIROpInfo.IsFixnumArith(op) || RubyIROpInfo.IsFixnumCompare(op))
+ {
+ int o0 = ins[i].Src0, o1 = -1, o2 = -1;
+ var imm = op is RubyIROpCode.AddImmediate or RubyIROpCode.SubImmediate
+ or RubyIROpCode.AddImmediateFixnum or RubyIROpCode.SubImmediateFixnum;
+ if (!imm) o1 = ins[i].Src1;
+ if (RubyIROpInfo.IsDoubleFused(op)) o2 = ins[i].Src2;
+ var fullyTyped = ProvenNum(o0) && (o1 < 0 || ProvenNum(o1)) && (o2 < 0 || ProvenNum(o2));
+ if (!fullyTyped)
+ {
+ if ((uint)o0 < (uint)n) impureUse[o0] = true;
+ if (o1 >= 0 && (uint)o1 < (uint)n) impureUse[o1] = true;
+ if (o2 >= 0 && (uint)o2 < (uint)n) impureUse[o2] = true;
+ if (defines) rawDefBad[d] = true; // dual path writes a boxed v{d}
+ }
+ // A float-receiver immediate (`f + 1` -> Float) is emitted boxed: EmitFixnumImmediate
+ // reads its receiver via FloatRead (= v{}.FloatValue, NOT rep-aware) and writes a
+ // boxed v{dst}. So both the float operand and the dst must stay boxed (the fixnum
+ // immediate path IS rep-aware via FixRead/AssignFix, so isLong operands are fine).
+ else if (imm && (uint)o0 < (uint)n && provesDouble[o0])
+ {
+ impureUse[o0] = true;
+ if (defines) rawDefBad[d] = true;
+ }
+ continue;
+ }
+ if (op == RubyIROpCode.Move) continue; // rep-aware Move converts at the assignment
+ if (op is RubyIROpCode.Return or RubyIROpCode.ReturnValue or RubyIROpCode.ReturnSelf) continue;
+ if (op == RubyIROpCode.LoadValue)
+ {
+ var lit = exe.GetLiteral(ins[i].Aux);
+ if (!lit.IsFixnum && !lit.IsFloat && defines) rawDefBad[d] = true; // non-numeric -> boxed
+ continue; // no value operands
+ }
+ // Fixnum bitwise send (& | ^ >>): its operands are read via FixRead (rep-aware, under a
+ // guard) so they never force boxing; the dst is a raw long iff both operands are proven
+ // Fixnum (then the codegen emits l{} = ...), else it stays boxed.
+ if (op == RubyIROpCode.Send && exe.GetCallSiteArgumentCount(ins[i].Aux) == 1 &&
+ Emitter.TryFixnumBitwiseOp(state, exe.GetCallSiteSymbol(ins[i].Aux), out var bwOp, out _))
+ {
+ var rcv = ins[i].Src0;
+ var arg = exe.GetCallSiteArgumentValueId(ins[i].Aux, 0);
+ var rfix = ((uint)rcv < (uint)n && provesFixnum[rcv]) || Mrb2CsCompiler.ConstFix(rcv, out _);
+ var afix = ((uint)arg < (uint)n && provesFixnum[arg]) || Mrb2CsCompiler.ConstFix(arg, out _);
+ // A non-fixnum operand (Unknown / proven Float) must stay BOXED: FixRead reads it as
+ // v{}.FixnumValue (under a guard), which has no raw l{}/d{} form. Only proven-fixnum
+ // operands stay raw long.
+ if (!rfix && (uint)rcv < (uint)n) impureUse[rcv] = true;
+ if (!afix && (uint)arg < (uint)n) impureUse[arg] = true;
+ // `<<` result can be Bignum -> dst must stay boxed; `& | ^ >>` give a raw-long dst iff
+ // both operands are proven fixnum.
+ if ((bwOp == "<<" || !(rfix && afix)) && defines) rawDefBad[d] = true;
+ continue;
+ }
+ // Any other op consumes its value operands boxed and (if it defines) writes a boxed v{d}.
+ Array.Clear(tmp, 0, tmp.Length);
+ CollectUses(exe, ins[i], tmp, n);
+ for (var w = 0; w < tmp.Length; w++)
+ {
+ var b = tmp[w];
+ while (b != 0)
+ {
+ var bit = b & (ulong)(-(long)b);
+ var id = (w << 6) + Log2Floor(bit);
+ b ^= bit;
+ if ((uint)id < (uint)n) boxedUse[id] = true;
+ }
+ }
+ if (defines) rawDefBad[d] = true;
+ }
+
+ for (var id = argCount + 1; id < n; id++)
+ {
+ if (!hasDef[id] || boxedUse[id] || impureUse[id] || rawDefBad[id]) continue;
+ if (provesDouble[id]) isDouble[id] = true;
+ else if (provesFixnum[id]) isLong[id] = true;
+ }
+ // Captured registers are passed to inlined blocks by ref as boxed MRubyValue -> never raw.
+ foreach (var c in exe.ClosureCapturedValueIds)
+ if ((uint)c < (uint)n) { isLong[c] = false; isDouble[c] = false; }
+ }
+
+ // An opcode that defines a value (mirrors the producing cases the lattice models). A non-defining
+ // op (branch/store/return) contributes no def to the meet.
+ static bool Definecheck(RubyIROpCode op) => op switch
+ {
+ RubyIROpCode.Jump or RubyIROpCode.JumpIfTruthy or RubyIROpCode.JumpIfFalsy or
+ RubyIROpCode.JumpIfNil or RubyIROpCode.GuardInlineClass or RubyIROpCode.Return or
+ RubyIROpCode.ReturnSelf or RubyIROpCode.ReturnValue or RubyIROpCode.CheckArity or
+ RubyIROpCode.SetUpVar or RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField or
+ RubyIROpCode.ArraySet => false,
+ _ => true,
+ };
+
+ // --- generated-local coalescing (source-size only; the C# JIT register-allocates anyway) ---
+ // SSA renumbering gives every definition a fresh value-id, so a method can declare dozens of
+ // short-lived single-use temps. This maps each value-id to a representative "local id", reusing
+ // one C# local for value-ids that never live at the same time AND share a representation
+ // (boxed / long / double). Params (v0..vArg) and captured ids keep their own id (they are method
+ // parameters / by-ref cells). Returns id->repId, or null to leave naming unchanged.
+ internal static int[]? CoalesceLocals(RubyIRMethod ir, int argCount, bool[] isLong, bool[] isDouble,
+ Dictionary? scalarArrays = null,
+ Dictionary Keys)>? scalarHashes = null)
+ {
+ var ins = ir.Instructions;
+ var n = ins.Length;
+ var v = ir.ValueCount;
+ if (n == 0 || v == 0) return null;
+
+ var kept = new bool[v];
+ for (var i = 0; i <= argCount && i < v; i++) kept[i] = true;
+ foreach (var c in ir.ClosureCapturedValueIds) if (c < v) kept[c] = true;
+ // Scalar-replaced array value-ids never become a v{} local (they're element locals), so keep
+ // them out of the coalescing pool — they must never be chosen as a shared representative.
+ if (scalarArrays is not null) foreach (var id in scalarArrays.Keys) if ((uint)id < (uint)v) kept[id] = true;
+ if (scalarHashes is not null) foreach (var id in scalarHashes.Keys) if ((uint)id < (uint)v) kept[id] = true;
+
+ // Successor CFG (branch targets are instruction indices; index n is the trailing end label).
+ var succ = new List[n];
+ for (var i = 0; i < n; i++) succ[i] = new List();
+ for (var i = 0; i < n; i++)
+ {
+ var op = ins[i].OpCode;
+ switch (op)
+ {
+ case RubyIROpCode.Return:
+ case RubyIROpCode.ReturnSelf:
+ case RubyIROpCode.ReturnValue:
+ break;
+ case RubyIROpCode.Jump:
+ if (ins[i].Aux < n) succ[i].Add(ins[i].Aux);
+ break;
+ case RubyIROpCode.JumpIfTruthy:
+ case RubyIROpCode.JumpIfFalsy:
+ case RubyIROpCode.JumpIfNil:
+ case RubyIROpCode.GuardInlineClass:
+ if (ins[i].Aux < n) succ[i].Add(ins[i].Aux);
+ if (i + 1 < n) succ[i].Add(i + 1);
+ break;
+ default:
+ if (i + 1 < n) succ[i].Add(i + 1);
+ break;
+ }
+ }
+
+ var words = (v + 63) >> 6;
+ var use = new ulong[n][];
+ var defId = new int[n];
+ for (var i = 0; i < n; i++)
+ {
+ use[i] = new ulong[words];
+ defId[i] = Definecheck(ins[i].OpCode) && ins[i].Dst < v ? ins[i].Dst : -1;
+ CollectUses(ir, ins[i], use[i], v);
+ }
+
+ // Backward liveness to a fixpoint (reverse-order passes; loops converge in a few rounds).
+ var liveOut = new ulong[n][];
+ for (var i = 0; i < n; i++) liveOut[i] = new ulong[words];
+ var liveIn = new ulong[n][];
+ for (var i = 0; i < n; i++) liveIn[i] = new ulong[words];
+ var changed = true;
+ var guard = 0;
+ while (changed && guard++ <= n + 8)
+ {
+ changed = false;
+ for (var i = n - 1; i >= 0; i--)
+ {
+ var outI = liveOut[i];
+ foreach (var s in succ[i])
+ for (var w = 0; w < words; w++) outI[w] |= liveIn[s][w];
+ // liveIn = (liveOut \ def) | use
+ var inI = liveIn[i];
+ var d = defId[i];
+ for (var w = 0; w < words; w++)
+ {
+ var nv = outI[w];
+ if (d >= 0 && w == d >> 6) nv &= ~(1UL << (d & 63));
+ nv |= use[i][w];
+ if (nv != inI[w]) { inI[w] = nv; changed = true; }
+ }
+ }
+ }
+
+ // Interference: a def interferes with everything live just after it (its live-out).
+ var adj = new ulong[v][];
+ for (var i = 0; i < v; i++) adj[i] = new ulong[words];
+ for (var i = 0; i < n; i++)
+ {
+ var d = defId[i];
+ if (d < 0) continue;
+ var outI = liveOut[i];
+ for (var w = 0; w < words; w++)
+ {
+ var bits = outI[w];
+ while (bits != 0)
+ {
+ var bit = bits & (ulong)(-(long)bits); // lowest set bit
+ var t = (w << 6) + Log2Floor(bit);
+ bits ^= bit;
+ if (t != d) { adj[d][t >> 6] |= 1UL << (t & 63); adj[t][d >> 6] |= 1UL << (d & 63); }
+ }
+ }
+ }
+
+ // Representation class: a long/double local can't hold a boxed value and vice-versa.
+ byte Repr(int id) => isDouble[id] ? (byte)2 : isLong[id] ? (byte)1 : (byte)0;
+
+ // Greedy coloring within each representation: assign each colorable id the first existing
+ // representative whose members don't interfere with it, else open a new representative (=id).
+ var slot = new int[v];
+ for (var i = 0; i < v; i++) slot[i] = i;
+ var reps = new List<(int Rep, byte R, ulong[] Members)>();
+ var coalesced = false;
+ for (var id = argCount + 1; id < v; id++)
+ {
+ if (kept[id]) continue;
+ var r = Repr(id);
+ var placed = false;
+ foreach (var slotEntry in reps)
+ {
+ if (slotEntry.R != r) continue;
+ var conflict = false;
+ for (var w = 0; w < words; w++)
+ if ((adj[id][w] & slotEntry.Members[w]) != 0) { conflict = true; break; }
+ if (conflict) continue;
+ slot[id] = slotEntry.Rep;
+ slotEntry.Members[id >> 6] |= 1UL << (id & 63);
+ placed = true;
+ if (slotEntry.Rep != id) coalesced = true;
+ break;
+ }
+ if (!placed)
+ {
+ var members = new ulong[words];
+ members[id >> 6] |= 1UL << (id & 63);
+ reps.Add((id, r, members));
+ }
+ }
+ return coalesced ? slot : null;
+ }
+
+ // Value-id uses of an instruction, set into `bits` (mirrors RubyIRMethod.CountValueUses /
+ // RubyIRSsaRenumber.EnumerateUses: Src0 always; Src1/Src2 unless an index field; call-site args;
+ // array operands; captured ids for LoadBlock / block-descriptor sends).
+ internal static void CollectUses(RubyIRMethod ir, in RubyIRInstruction ix, ulong[] bits, int v)
+ {
+ void Add(int id) { if ((uint)id < (uint)v) bits[id >> 6] |= 1UL << (id & 63); }
+ var op = ix.OpCode;
+ Add(ix.Src0);
+ if (op is not (RubyIROpCode.GuardInlineClass or RubyIROpCode.SendBlockDescriptor or RubyIROpCode.SendSelfBlockDescriptor))
+ {
+ Add(ix.Src1);
+ Add(ix.Src2);
+ }
+ switch (op)
+ {
+ case RubyIROpCode.LoadBlock:
+ foreach (var c in ir.ClosureCapturedValueIds) Add(c);
+ break;
+ case RubyIROpCode.SendBlockDescriptor:
+ case RubyIROpCode.SendSelfBlockDescriptor:
+ foreach (var c in ir.ClosureCapturedValueIds) Add(c);
+ goto case RubyIROpCode.Send;
+ case RubyIROpCode.Send:
+ case RubyIROpCode.SendSelf:
+ case RubyIROpCode.SendBlock:
+ case RubyIROpCode.SendSelfBlock:
+ case RubyIROpCode.PureUnarySend:
+ case RubyIROpCode.VirtualNew:
+ {
+ var argc = ir.GetCallSiteArgumentCount(ix.Aux);
+ for (var a = 0; a < argc; a++) Add(ir.GetCallSiteArgumentValueId(ix.Aux, a));
+ break;
+ }
+ case RubyIROpCode.NewArray:
+ case RubyIROpCode.NewArray2:
+ case RubyIROpCode.NewHash:
+ {
+ var c = ir.GetOperandListCount(ix.Aux);
+ for (var a = 0; a < c; a++) Add(ir.GetOperandListValueId(ix.Aux, a));
+ break;
+ }
+ }
+ }
+
+ // Bit index of a power-of-two ulong (netstandard2.1 has no BitOperations). Used to iterate set
+ // bits of a live-set word. De Bruijn sequence lookup.
+ static readonly int[] DeBruijn64 =
+ {
+ 0, 1, 48, 2, 57, 49, 28, 3, 61, 58, 50, 42, 38, 29, 17, 4,
+ 62, 55, 59, 36, 53, 51, 43, 22, 45, 39, 33, 30, 24, 18, 12, 5,
+ 63, 47, 56, 27, 60, 41, 37, 16, 54, 35, 52, 21, 44, 32, 23, 11,
+ 46, 26, 40, 15, 34, 20, 31, 10, 25, 14, 19, 9, 13, 8, 7, 6,
+ };
+ static int Log2Floor(ulong powerOfTwo) => DeBruijn64[(powerOfTwo * 0x03f79d71b4cb0a89UL) >> 58];
+
+ internal static bool IsInlineCandidate(Irep calleeIrep)
+ {
+ try
+ {
+ var exe = RubyIRBuilder.Build(calleeIrep, 0, out _);
+ return exe is not null && exe.Instructions.Length <= 48;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ internal static bool TryReadInlineSelectorShape(Irep irep, out bool returnsNew)
+ {
+ returnsNew = false;
+ try
+ {
+ var exe = RubyIRBuilder.Build(irep, 0, out _);
+ if (exe is null) return false;
+ foreach (var ins in exe.Instructions)
+ {
+ if (ins.OpCode is RubyIROpCode.SendSelf or RubyIROpCode.SendSelfBlock or RubyIROpCode.SendSelfBlockDescriptor)
+ {
+ return false;
+ }
+ if (ins.OpCode == RubyIROpCode.VirtualNew)
+ {
+ returnsNew = true;
+ }
+ }
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ internal static HashSet? FindSpliceCandidatePcs(
+ RubyIRMethod exe,
+ IReadOnlyDictionary? inlineSelectorRegistry,
+ IReadOnlyDictionary? accessorRegistry)
+ {
+ var instructions = exe.Instructions;
+ var defIndex = new int[exe.ValueCount];
+ for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1;
+ for (var i = 0; i < instructions.Length; i++)
+ {
+ var d = instructions[i].Dst;
+ if ((uint)d < (uint)defIndex.Length) defIndex[d] = i;
+ }
+
+ HashSet? result = null;
+ for (var i = 0; i < instructions.Length; i++)
+ {
+ var ins = instructions[i];
+ if (ins.OpCode is not (RubyIROpCode.Send or RubyIROpCode.SendSelf)) continue;
+ var argc = exe.GetCallSiteArgumentCount(ins.Aux);
+ var pc = exe.SourceBytecodePc(i);
+ if (pc < 0) continue;
+
+ if (ins.OpCode == RubyIROpCode.SendSelf)
+ {
+ for (var a = 0; a < argc; a++)
+ {
+ if (ProducerReturnsNew(instructions, defIndex, exe, inlineSelectorRegistry, exe.GetCallSiteArgumentValueId(ins.Aux, a)))
+ {
+ result ??= [];
+ result.Add(pc);
+ break;
+ }
+ }
+ }
+ else if (ins.OpCode == RubyIROpCode.Send &&
+ inlineSelectorRegistry is not null &&
+ inlineSelectorRegistry.TryGetValue(exe.GetCallSiteSymbol(ins.Aux), out var target) &&
+ target.ArgCount == argc)
+ {
+ var candidate =
+ (target.ReturnsNew &&
+ HasVirtualProducerConsumer(instructions, exe, inlineSelectorRegistry, accessorRegistry, ins.Dst)) ||
+ ProducerReturnsNew(instructions, defIndex, exe, inlineSelectorRegistry, ins.Src0);
+ for (var a = 0; !candidate && a < argc; a++)
+ {
+ candidate = ProducerReturnsNew(instructions, defIndex, exe, inlineSelectorRegistry, exe.GetCallSiteArgumentValueId(ins.Aux, a));
+ }
+ if (candidate)
+ {
+ result ??= [];
+ result.Add(pc);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ static bool HasVirtualProducerConsumer(
+ ReadOnlySpan instructions,
+ RubyIRMethod exe,
+ IReadOnlyDictionary? inlineSelectorRegistry,
+ IReadOnlyDictionary? accessorRegistry,
+ int valueId)
+ {
+ if (IsClosureCaptured(exe, valueId)) return false;
+
+ var aliases = new HashSet { valueId };
+ var changed = true;
+ while (changed)
+ {
+ changed = false;
+ foreach (var u in instructions)
+ {
+ if (u.OpCode == RubyIROpCode.Move && aliases.Contains(u.Src0))
+ {
+ if (IsClosureCaptured(exe, u.Dst)) return false;
+ if (aliases.Add(u.Dst)) changed = true;
+ }
+ }
+ }
+
+ foreach (var u in instructions)
+ {
+ if (u.OpCode is not (RubyIROpCode.Send or RubyIROpCode.SendSelf)) continue;
+ var sym = exe.GetCallSiteSymbol(u.Aux);
+ var inlineConsumer = inlineSelectorRegistry is not null && inlineSelectorRegistry.ContainsKey(sym);
+ var accessorConsumer = accessorRegistry is not null && accessorRegistry.ContainsKey(sym);
+ if (aliases.Contains(u.Src0) && (inlineConsumer || accessorConsumer))
+ {
+ return true;
+ }
+
+ if (inlineConsumer)
+ {
+ var argc = exe.GetCallSiteArgumentCount(u.Aux);
+ for (var a = 0; a < argc; a++)
+ {
+ if (aliases.Contains(exe.GetCallSiteArgumentValueId(u.Aux, a))) return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ static bool ProducerReturnsNew(
+ ReadOnlySpan instructions,
+ int[] defIndex,
+ RubyIRMethod exe,
+ IReadOnlyDictionary? inlineSelectorRegistry,
+ int valueId)
+ {
+ if (IsClosureCaptured(exe, valueId)) return false;
+ if ((uint)valueId >= (uint)defIndex.Length) return false;
+ var d = defIndex[valueId];
+ if (d < 0) return false;
+ var def = instructions[d];
+ // Trace through copy chains: SSA-renumbered detection turns a reused merge id into a
+ // unique id reached via Move copies (`rs` = vsub result -> Move -> Move -> receiver), so
+ // the producer is behind one or more Moves. Bounded by instruction count (acyclic).
+ for (var hops = 0; def.OpCode == RubyIROpCode.Move && hops < instructions.Length; hops++)
+ {
+ var src = def.Src0;
+ if (IsClosureCaptured(exe, src) || (uint)src >= (uint)defIndex.Length) return false;
+ var sd = defIndex[src];
+ if (sd < 0) return false;
+ def = instructions[sd];
+ }
+ if (def.OpCode == RubyIROpCode.VirtualNew) return true;
+ return inlineSelectorRegistry is not null &&
+ def.OpCode is RubyIROpCode.Send or RubyIROpCode.SendSelf &&
+ inlineSelectorRegistry.TryGetValue(exe.GetCallSiteSymbol(def.Aux), out var target) &&
+ target.ReturnsNew;
+ }
+
+ static bool IsClosureCaptured(RubyIRMethod exe, int valueId)
+ {
+ foreach (var captured in exe.ClosureCapturedValueIds)
+ {
+ if (captured == valueId) return true;
+ }
+ return false;
+ }
+
+ // Ops that commit no side effect and cannot re-enter arbitrary code, so a speculation guard's
+ // deopt (which re-runs the whole method in the interpreter) is harmless. Sends are judged
+ // separately (only trivial accessors / Math float calls are pure). Conservative whitelist: an
+ // op not listed here marks the method non-speculatable.
+ // Does this instruction COMMIT an observable side effect — i.e. one that a deopt (which
+ // re-runs the bytecode from the method's top) would wrongly re-apply? Used to bound the
+ // pre-side-effect float-speculation window. NOT side effects (a deopt safely re-does them):
+ // fresh allocations (VirtualNew/MaterializeObject — re-created), writes to a freshly-allocated
+ // scalar-replaced object (a method-local — discarded on deopt), pure float-Math sends, and
+ // accessor getters / pure ops. ARE side effects: writes to self or a pre-existing object,
+ // index/upvar/array writes, and any arbitrary (non-accessor, non-float-Math) call.
+ internal static bool IsCommittedSideEffect(
+ MRubyState state,
+ RubyIRMethod exe,
+ ScalarContext? sc,
+ IReadOnlyDictionary? accessorRegistry,
+ in RubyIRInstruction ins)
+ {
+ var op = ins.OpCode;
+ switch (op)
+ {
+ case RubyIROpCode.VirtualNew:
+ case RubyIROpCode.MaterializeObject:
+ return false;
+ case RubyIROpCode.SetInstanceVariable:
+ case RubyIROpCode.VirtualSetField:
+ return !(sc?.IsScalar(ins.Src0) ?? false); // write to a fresh scalar object is transparent
+ case RubyIROpCode.Send:
+ case RubyIROpCode.SendSelf:
+ {
+ var sel = exe.GetCallSiteSymbol(ins.Aux);
+ if (IsFloatReturningMethod(state, sel)) return false;
+ if (accessorRegistry is not null && accessorRegistry.TryGetValue(sel, out var acc))
+ return acc.IsSetter && !(sc?.IsScalar(ins.Src0) ?? false); // getter pure; setter on fresh scalar transparent
+ return true; // arbitrary call
+ }
+ default:
+ return !RubyIROpInfo.IsPureSpeculationOp(op); // pure reads/arith/branches transparent; SetIndex/ArraySet/SetUpVar/... commit
+ }
+ }
+
+ // Float-valued constant NAMES across the whole program, cached per state (codegen visits
+ // each method's GetConstant ops and needs to know if a constant is a Float to seed taint).
+ // Without this, `someInt / FLOAT_CONST` reads as same-taint and would deopt mid-method,
+ // corrupting any side effect already committed. Names can collide across scopes; a name
+ // that is float anywhere is treated as float-seeding, which is conservative (at worst it
+ // forces a Send slow path that's always correct).
+ [ThreadStatic] static MRubyState? floatConstState;
+ [ThreadStatic] static HashSet? floatConstNames;
+
+ internal static bool IsFloatConstantName(MRubyState state, Symbol name)
+ {
+ if (!ReferenceEquals(floatConstState, state))
+ {
+ var names = new HashSet();
+ state.EnumerateConstants((sym, value) => { if (value.IsFloat) names.Add(sym); });
+ floatConstNames = names;
+ floatConstState = state;
+ }
+ return floatConstNames!.Contains(name);
+ }
+
+ // Cap on element count: `Array.new(1000)` shouldn't explode into 1000 locals.
+ const int ScalarArrayMaxSize = 32;
+
+ internal static Dictionary? FindScalarArrays(MRubyState state, RubyIRMethod exe)
+ {
+ var ins = exe.Instructions;
+ var n = exe.ValueCount;
+ Dictionary? result = null;
+ var tmp = new ulong[(n + 63) >> 6];
+ var defIndex = new int[n];
+ for (var k = 0; k < n; k++) defIndex[k] = -1;
+ for (var k = 0; k < ins.Length; k++) { var d = ins[k].Dst; if ((uint)d < (uint)n) defIndex[d] = k; }
+
+ for (var i = 0; i < ins.Length; i++)
+ {
+ int arr, size;
+ if (ins[i].OpCode == RubyIROpCode.NewArray) // [a, b, c] literal (NewArray2 splat not modeled)
+ {
+ arr = ins[i].Dst;
+ size = exe.GetOperandListCount(ins[i].Aux);
+ }
+ else if (ins[i].OpCode == RubyIROpCode.VirtualNew &&
+ exe.GetCallSiteArgumentCount(ins[i].Aux) == 1 &&
+ IsArrayClassReceiver(state, exe, ins, defIndex, ins[i].Src0) &&
+ Mrb2CsCompiler.ConstFix(exe.GetCallSiteArgumentValueId(ins[i].Aux, 0), out var anSize) &&
+ anSize > 0 && anSize <= ScalarArrayMaxSize) // Array.new(const) -> nil-filled
+ {
+ arr = ins[i].Dst;
+ size = (int)anSize;
+ }
+ else continue;
+ if ((uint)arr >= (uint)n || IsClosureCaptured(exe, arr)) continue;
+ if (size is 0 or > ScalarArrayMaxSize) continue;
+ // The array's value flows through Move copies (register reuse); follow them so accesses
+ // via any alias still count and the whole closure is replaced together.
+ var aliases = Analyzer.MoveClosure(exe, arr);
+ if (aliases.Contains(0)) continue; // would alias self/arg (shouldn't happen)
+
+ var eligible = true;
+ for (var u = 0; u < ins.Length && eligible; u++)
+ {
+ var op = ins[u].OpCode;
+ if (op == RubyIROpCode.NewArray && ins[u].Dst == arr)
+ {
+ // The defining literal: elements must not reference the array itself.
+ var c = exe.GetOperandListCount(ins[u].Aux);
+ for (var a = 0; a < c; a++) if (InAliases(exe.GetOperandListValueId(ins[u].Aux, a))) { eligible = false; break; }
+ continue;
+ }
+ if (op == RubyIROpCode.Move && InAliases(ins[u].Src0)) continue; // intra-alias copy
+ if (op == RubyIROpCode.GetIndex0 && InAliases(ins[u].Src0)) continue;
+ if (op == RubyIROpCode.GetIndex && InAliases(ins[u].Src0) &&
+ !InAliases(ins[u].Src1) && Mrb2CsCompiler.ConstFix(ins[u].Src1, out var gi) && gi >= 0 && gi < size) continue;
+ if (op == RubyIROpCode.SetIndex && InAliases(ins[u].Src0) &&
+ !InAliases(ins[u].Src1) && !InAliases(ins[u].Src2) &&
+ Mrb2CsCompiler.ConstFix(ins[u].Src1, out var si) && si >= 0 && si < size) continue;
+ // Any other appearance of an alias (non-const/oob index, Send arg, store, return,
+ // used as an index/element) -> escapes or dynamic -> not replaceable.
+ Array.Clear(tmp, 0, tmp.Length);
+ Analyzer.CollectUses(exe, ins[u], tmp, n);
+ foreach (var al in aliases)
+ if ((tmp[al >> 6] & (1UL << (al & 63))) != 0) { eligible = false; break; }
+ }
+ if (eligible)
+ {
+ result ??= new Dictionary();
+ foreach (var al in aliases) result[al] = (arr, size);
+ }
+
+ continue;
+
+ bool InAliases(int id) => aliases.Contains(id);
+ }
+ return result;
+ }
+
+ // True iff `recvId`'s definition is `GetConstant :Array` — i.e. the receiver of a `.new` is the
+ // core Array class, so `Array.new(n)` builds a plain n-element nil array we can scalar-replace.
+ static bool IsArrayClassReceiver(MRubyState state, RubyIRMethod exe, ReadOnlySpan ins, int[] defIndex, int recvId)
+ {
+ if ((uint)recvId >= (uint)defIndex.Length) return false;
+ var di = defIndex[recvId];
+ if (di < 0 || ins[di].OpCode != RubyIROpCode.GetConstant) return false;
+ return state.NameOf(exe.GetSymbol(ins[di].Aux)).AsSpan().SequenceEqual("Array"u8);
+ }
+
+ // Constant key -> identifier-safe, collision-free tag (symbol intern id / fixnum value).
+ static bool TryConstKeyTag(RubyIRMethod exe, int[] defIndex, int keyId, out string tag)
+ {
+ tag = "";
+ if ((uint)keyId >= (uint)defIndex.Length) return false;
+ var di = defIndex[keyId];
+ if (di < 0 || exe.Instructions[di].OpCode != RubyIROpCode.LoadValue) return false;
+ var lit = exe.GetLiteral(exe.Instructions[di].Aux);
+ if (lit.IsFixnum) { tag = "i" + lit.FixnumValue; return true; }
+ if (lit.IsSymbol) { tag = "s" + lit.SymbolValue.Value; return true; }
+ return false; // string/float/other keys not modeled
+ }
+
+ internal static Dictionary)>? FindScalarHashes(RubyIRMethod exe)
+ {
+ var ins = exe.Instructions;
+ var n = exe.ValueCount;
+ Dictionary)>? result = null;
+ var tmp = new ulong[(n + 63) >> 6];
+ var defIndex = new int[n];
+ for (var k = 0; k < n; k++) defIndex[k] = -1;
+ for (var k = 0; k < ins.Length; k++) { var d = ins[k].Dst; if ((uint)d < (uint)n) defIndex[d] = k; }
+
+ for (var i = 0; i < ins.Length; i++)
+ {
+ if (ins[i].OpCode != RubyIROpCode.NewHash) continue;
+ var hash = ins[i].Dst;
+ if ((uint)hash >= (uint)n || IsClosureCaptured(exe, hash)) continue;
+ var pairs = exe.GetOperandListCount(ins[i].Aux) / 2;
+ if (pairs == 0 || pairs > ScalarArrayMaxSize) continue;
+
+ var aliases = Analyzer.MoveClosure(exe, hash);
+ if (aliases.Contains(0)) continue;
+ bool InAliases(int id) => aliases.Contains(id);
+
+ var keys = new HashSet(); // keys that HOLD a value (literal + set)
+ var keyTags = new Dictionary(); // const-key value-id -> tag
+ var eligible = true;
+
+ // Literal keys must all be constant.
+ for (var p = 0; p < pairs && eligible; p++)
+ {
+ var keyId = exe.GetOperandListValueId(ins[i].Aux, 2 * p);
+ if (!TryConstKeyTag(exe, defIndex, keyId, out var tag)) { eligible = false; break; }
+ keys.Add(tag);
+ keyTags[keyId] = tag;
+ }
+
+ for (var u = 0; u < ins.Length && eligible; u++)
+ {
+ var op = ins[u].OpCode;
+ if (op == RubyIROpCode.NewHash && ins[u].Dst == hash) continue; // the literal itself
+ if (op == RubyIROpCode.Move && InAliases(ins[u].Src0)) continue;
+ if (op == RubyIROpCode.GetIndex0 && InAliases(ins[u].Src0)) continue; // h[0] -> tag i0
+ if (op == RubyIROpCode.GetIndex && InAliases(ins[u].Src0) && !InAliases(ins[u].Src1) &&
+ TryConstKeyTag(exe, defIndex, ins[u].Src1, out var gtag))
+ {
+ keyTags[ins[u].Src1] = gtag; // a get of an absent key reads nil (no local needed)
+ continue;
+ }
+ if (op == RubyIROpCode.SetIndex && InAliases(ins[u].Src0) && !InAliases(ins[u].Src1) &&
+ !InAliases(ins[u].Src2) && TryConstKeyTag(exe, defIndex, ins[u].Src1, out var stag))
+ {
+ keys.Add(stag); // a set adds/holds the key
+ keyTags[ins[u].Src1] = stag;
+ continue;
+ }
+ // Any other appearance (dynamic key, iteration .each/.keys/.size, escape) -> bail.
+ Array.Clear(tmp, 0, tmp.Length);
+ Analyzer.CollectUses(exe, ins[u], tmp, n);
+ foreach (var al in aliases)
+ if ((tmp[al >> 6] & (1UL << (al & 63))) != 0) { eligible = false; break; }
+ }
+ if (eligible)
+ {
+ result ??= new Dictionary)>();
+ foreach (var al in aliases) result[al] = (hash, keys);
+ Mrb2CsCompiler.CurrentHashKeyTags ??= new Dictionary();
+ foreach (var (kid, t) in keyTags) Mrb2CsCompiler.CurrentHashKeyTags[kid] = t;
+ }
+ }
+ return result;
+ }
+
+ internal static Dictionary? BuildConstLit(RubyIRMethod exe)
+ {
+ // Only a SINGLE-def LoadValue id is a stable constant — a register-reused id (non-SSA block
+ // IR) could be reassigned, so it must be excluded for soundness.
+ var defCount = new int[exe.ValueCount];
+ foreach (var u in exe.Instructions) if ((uint)u.Dst < (uint)defCount.Length) defCount[u.Dst]++;
+ Dictionary? map = null;
+ foreach (var u in exe.Instructions)
+ if (u.OpCode == RubyIROpCode.LoadValue && (uint)u.Dst < (uint)defCount.Length && defCount[u.Dst] == 1)
+ {
+ var lit = exe.GetLiteral(u.Aux);
+ if (lit.IsFixnum || lit.IsFloat) (map ??= new())[u.Dst] = lit;
+ }
+ return map;
+ }
+
+ // A send whose result is always a Float -> seeds float taint / provesDouble. True for builtin
+ // Float-returning methods (Math.* / to_f, which aren't in the AOT method set) OR a user method
+ // whose body was INFERRED to always return Float (RubyIRReturnTypes; sound, no name guessing).
+ internal static bool IsFloatReturningMethod(MRubyState state, Symbol sym) =>
+ IsBuiltinFloatMethod(state, sym) || (Mrb2CsCompiler.CurrentReturnTypes?.ReturnsFloat(sym) ?? false);
+
+ // Float-returning BUILTINS (not in the AOT method set, so they must be seeded): Math.* and
+ // to_f. Everything else is inferred from method bodies, not matched by name.
+ // Byte-exact symbol-name compare, the building block of the name-classification predicates below.
+ internal static bool Matches(ReadOnlySpan a, ReadOnlySpan b) => a.SequenceEqual(b);
+
+ internal static bool IsBuiltinFloatMethod(MRubyState state, Symbol sym)
+ {
+ var name = state.NameOf(sym).AsSpan();
+ return Matches(name, "to_f"u8) ||
+ Matches(name, "sqrt"u8) || Matches(name, "cbrt"u8) ||
+ Matches(name, "sin"u8) || Matches(name, "cos"u8) || Matches(name, "tan"u8) ||
+ Matches(name, "asin"u8) || Matches(name, "acos"u8) || Matches(name, "atan"u8) ||
+ Matches(name, "atan2"u8) || Matches(name, "hypot"u8) ||
+ Matches(name, "exp"u8) || Matches(name, "log"u8) ||
+ Matches(name, "log2"u8) || Matches(name, "log10"u8) ||
+ Matches(name, "sinh"u8) || Matches(name, "cosh"u8) || Matches(name, "tanh"u8) ||
+ Matches(name, "pow"u8);
+ }
+
+ internal static bool IsToFMethod(MRubyState state, Symbol sym) =>
+ Matches(state.NameOf(sym).AsSpan(), "to_f"u8);
+
+ // Methods that switch fibers/threads — unsafe to call from a compiled C# frame.
+ internal static bool IsContextSwitchingMethod(MRubyState state, Symbol sym)
+ {
+ var name = state.NameOf(sym).AsSpan();
+ return Matches(name, "yield"u8) || Matches(name, "resume"u8) ||
+ Matches(name, "transfer"u8) || Matches(name, "sleep"u8) ||
+ Matches(name, "pass"u8);
+ }
+}
diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/CompiledMethod.cs b/src/ChibiRuby.JetPack/Mrb2Cs/CompiledMethod.cs
new file mode 100644
index 00000000..c463d43a
--- /dev/null
+++ b/src/ChibiRuby.JetPack/Mrb2Cs/CompiledMethod.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+
+namespace ChibiRuby.JetPack.Mrb2Cs;
+
+// One Ruby method compiled to C#: the emitted method source plus the metadata the driver
+// (Compile) needs to assemble and bind it. Produced by Mrb2CsCompiler.TryCompileMethod.
+public sealed class CompiledMethod(string methodName, string source, int argCount, int instructionCount, bool isLeaf, List? auxiliaryMethods = null)
+{
+ public string MethodName { get; } = methodName;
+ public string Source { get; } = source;
+ public int ArgCount { get; } = argCount;
+ public int InstructionCount { get; } = instructionCount;
+ // No outbound Ruby calls (Send/SendSelf/block sends) -> safe to inline without
+ // growing the C# call stack per Ruby call depth (bounds depth to caller+1).
+ public bool IsLeaf { get; } = isLeaf;
+ // Extra C# methods this body needs emitted alongside it (inlined block bodies as
+ // `__blk` methods). The driver adds them to the generated class.
+ public IReadOnlyList AuxiliaryMethods { get; } = (IReadOnlyList?)auxiliaryMethods ?? [];
+}
diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/DefinitionLoader.cs b/src/ChibiRuby.JetPack/Mrb2Cs/DefinitionLoader.cs
new file mode 100644
index 00000000..30ce695d
--- /dev/null
+++ b/src/ChibiRuby.JetPack/Mrb2Cs/DefinitionLoader.cs
@@ -0,0 +1,201 @@
+using ChibiRuby;
+
+namespace ChibiRuby.JetPack.Mrb2Cs;
+
+// True-AOT front-end for mrb2cs: register a program's classes/modules/methods into the state
+// WITHOUT executing it. mruby's class/def are runtime ops, so the normal way to discover
+// definitions is to run the program (which also runs main — not AOT). This instead statically
+// walks the def-structural ops (CLASS/MODULE/EXEC/TDEF/DEF/METHOD/TCLASS/OCLASS, + the GETCONST/
+// LOADNIL/MOVE feeding outer/super) and drives the same DefineClass/DefineMethod APIs the VM
+// uses — never running method bodies or top-level main.
+//
+// Best-effort and CONSERVATIVE: it tracks only registers it can resolve to a class or a method
+// irep; any op it doesn't model clears its destination register, so an unresolved class/super/
+// method is simply SKIPPED (those methods stay interpreted — correct, just not AOT-compiled). It
+// never registers a method on a wrong class. EXT-widened operands abort the walk for that irep.
+public static class DefinitionLoader
+{
+ public static void Load(MRubyState state, Irep root)
+ {
+ Walk(state, root, state.ObjectClass);
+ }
+
+ static void Walk(MRubyState state, Irep irep, RClass currentClass)
+ {
+ var seq = irep.Sequence;
+ var n = irep.RegisterVariableCount < 1 ? 256 : irep.RegisterVariableCount + 1;
+ var regClass = new RClass?[n]; // register -> the class object it holds, if known
+ var regProc = new Irep?[n]; // register -> the method-body irep it holds (from OP_METHOD)
+
+ void Clear(int r) { if ((uint)r < (uint)n) { regClass[r] = null; regProc[r] = null; } }
+
+ var pc = 0;
+ while (pc < seq.Length)
+ {
+ var op = (OpCode)seq[pc];
+ // EXT prefixes widen the next op's operands; we don't model widened operands, so stop
+ // (the rest of this irep's defs are skipped — safe).
+ if (op is OpCode.EXT1 or OpCode.EXT2 or OpCode.EXT3) return;
+
+ var a = pc + 1 < seq.Length ? seq[pc + 1] : 0;
+ var b = pc + 2 < seq.Length ? seq[pc + 2] : 0;
+
+ switch (op)
+ {
+ case OpCode.Move:
+ if ((uint)a < (uint)n && (uint)b < (uint)n) { regClass[a] = regClass[b]; regProc[a] = regProc[b]; }
+ break;
+ case OpCode.LoadNil:
+ Clear(a);
+ break;
+ case OpCode.OClass:
+ if ((uint)a < (uint)n) { regClass[a] = state.ObjectClass; regProc[a] = null; }
+ break;
+ case OpCode.TClass:
+ if ((uint)a < (uint)n) { regClass[a] = currentClass; regProc[a] = null; }
+ break;
+ case OpCode.LoadSelf:
+ // At a class/module body scope self IS the class being defined.
+ if ((uint)a < (uint)n) { regClass[a] = currentClass; regProc[a] = null; }
+ break;
+ case OpCode.GetConst:
+ case OpCode.GetMCnst:
+ if ((uint)a < (uint)n)
+ {
+ regProc[a] = null;
+ regClass[a] = (uint)b < (uint)irep.Symbols.Length &&
+ state.TryGetConst(irep.Symbols[b], out var cv) && cv.Object is RClass kc
+ ? kc : null;
+ }
+ break;
+ case OpCode.Method:
+ if ((uint)a < (uint)n) { regProc[a] = (uint)b < (uint)irep.Children.Length ? irep.Children[b] : null; regClass[a] = null; }
+ break;
+ case OpCode.Class:
+ DefineClassAt(state, irep, currentClass, regClass, a, b, n);
+ break;
+ case OpCode.Module:
+ DefineModuleAt(state, irep, regClass, a, b, n);
+ break;
+ case OpCode.Exec:
+ // Run the class/module body irep statically (NOT via the VM) under its class.
+ if ((uint)a < (uint)n && regClass[a] is { } bodyClass && (uint)b < (uint)irep.Children.Length)
+ {
+ Walk(state, irep.Children[b], bodyClass);
+ }
+ break;
+ case OpCode.TDef:
+ // The normal `def name; ...; end` inside a class/module body (and at top level).
+ // Mirrors the VM's OP_TDEF (MRubyState.Vm.cs): BBB, target = the lexical scope's
+ // class (here `currentClass`), method symbol = B, proc body = child irep C.
+ DefineTDef(state, irep, currentClass, b, pc + 3 < seq.Length ? seq[pc + 3] : 0);
+ break;
+ case OpCode.Def:
+ // The `define_method`/proc-in-register form: target class in reg[A], proc in reg[A+1].
+ if ((uint)a < (uint)n && (uint)(a + 1) < (uint)n &&
+ regClass[a] is { } defClass && regProc[a + 1] is { } methodIrep &&
+ (uint)b < (uint)irep.Symbols.Length)
+ {
+ var proc = state.NewProc(methodIrep, defClass);
+ // Mirror the VM's OP_METHOD: a method proc is strict + scoped. Trivial
+ // accessor/setter detection requires these flags, so without them the
+ // accessor registry stays empty and no devirtualization happens.
+ proc.SetFlag(MRubyObjectFlags.ProcStrict | MRubyObjectFlags.ProcScope);
+ state.DefineMethod(defClass, irep.Symbols[b], MRubyMethod.CreateFromProc(proc));
+ }
+ break;
+ default:
+ // Any other op (the program's actual logic): not modeled — its destination
+ // register (operand A, for the ops that have one) no longer holds a tracked value.
+ Clear(a);
+ break;
+ }
+
+ pc += 1 + OperandBytes(op);
+ }
+ }
+
+ static void DefineClassAt(MRubyState state, Irep irep, RClass currentClass, RClass?[] regClass, int a, int b, int n)
+ {
+ if ((uint)a >= (uint)n || (uint)b >= (uint)irep.Symbols.Length) return;
+ var name = irep.Symbols[b];
+ var outer = regClass[a] ?? currentClass; // nil outer -> lexical scope
+ var super = (uint)(a + 1) < (uint)n ? regClass[a + 1] : null;
+ RClass cls;
+ if (state.TryGetConst(name, outer, out var existing) && existing.Object is RClass reopened)
+ {
+ cls = reopened; // reopening an existing class/module
+ }
+ else
+ {
+ cls = state.DefineClass(name, super ?? state.ObjectClass, outer: outer);
+ // DefineClass only links the class path; bind the constant in `outer` so the class
+ // is reachable from EnumerateAotMethods (which walks outer.InstanceVariables).
+ state.DefineConst(outer, name, new MRubyValue(cls));
+ }
+ regClass[a] = cls;
+ }
+
+ static void DefineTDef(MRubyState state, Irep irep, RClass target, int symbolIdx, int childIdx)
+ {
+ if ((uint)symbolIdx >= (uint)irep.Symbols.Length || (uint)childIdx >= (uint)irep.Children.Length) return;
+ var proc = state.NewProc(irep.Children[childIdx], target);
+ proc.SetFlag(MRubyObjectFlags.ProcStrict | MRubyObjectFlags.ProcScope);
+ state.DefineMethod(target, irep.Symbols[symbolIdx], MRubyMethod.CreateFromProc(proc));
+ }
+
+ static void DefineModuleAt(MRubyState state, Irep irep, RClass?[] regClass, int a, int b, int n)
+ {
+ if ((uint)a >= (uint)n || (uint)b >= (uint)irep.Symbols.Length) return;
+ var name = irep.Symbols[b];
+ var outer = regClass[a] ?? state.ObjectClass;
+ if (state.TryGetConst(name, outer, out var existing) && existing.Object is RClass reopened)
+ {
+ regClass[a] = reopened;
+ }
+ else
+ {
+ var mod = state.DefineModule(name, outer);
+ state.DefineConst(outer, name, new MRubyValue(mod));
+ regClass[a] = mod;
+ }
+ }
+
+ // Operand bytes AFTER the 1-byte opcode (mruby 4.0). Derived from the disassembler's per-op
+ // operand reads (MRubyState.Dump.cs): Z=0, B=1, S/BB=2, BS/BBB/W=3, BSS=5. Add/Sub are B
+ // (they `goto case EQ`). Anything not listed is treated as 0 (safe: a too-short advance just
+ // ends the walk early / mis-decodes into the conservative default path, never mis-registers).
+ static int OperandBytes(OpCode op) => op switch
+ {
+ OpCode.Nop or OpCode.Stop or OpCode.Call or OpCode.KeyEnd or
+ OpCode.EXT1 or OpCode.EXT2 or OpCode.EXT3 => 0,
+
+ OpCode.Mul or OpCode.Div or OpCode.EQ or OpCode.LT or OpCode.LE or OpCode.GT or OpCode.GE or
+ OpCode.Add or OpCode.Sub or OpCode.AryCat or OpCode.ArySplat or OpCode.Break or OpCode.Err or
+ OpCode.Except or OpCode.GetIdx or OpCode.HashCat or OpCode.Intern or OpCode.LoadF or
+ OpCode.LoadI_0 or OpCode.LoadI_1 or OpCode.LoadI_2 or OpCode.LoadI_3 or OpCode.LoadI_4 or
+ OpCode.LoadI_5 or OpCode.LoadI_6 or OpCode.LoadI_7 or OpCode.LoadI__1 or OpCode.LoadNil or
+ OpCode.LoadSelf or OpCode.LoadT or OpCode.MatchErr or OpCode.OClass or OpCode.RaiseIf or
+ OpCode.RangeExc or OpCode.RangeInc or OpCode.Return or OpCode.ReturnBlk or OpCode.SClass or
+ OpCode.SetIdx or OpCode.StrCat or OpCode.TClass or OpCode.Undef => 1,
+
+ OpCode.Jmp or OpCode.JmpUw or
+ OpCode.AddI or OpCode.SubI or OpCode.Alias or OpCode.Array or OpCode.AryPush or OpCode.BlkCall or
+ OpCode.Block or OpCode.Class or OpCode.Def or OpCode.GetCV or OpCode.GetConst or OpCode.GetGV or
+ OpCode.GetIV or OpCode.GetIdx0 or OpCode.GetMCnst or OpCode.GetSV or OpCode.Hash or OpCode.HashAdd or
+ OpCode.KArg or OpCode.KeyP or OpCode.Lambda or OpCode.LoadI8 or OpCode.LoadL or OpCode.LoadSym or
+ OpCode.Method or OpCode.Module or OpCode.Move or OpCode.Rescue or OpCode.SSend0 or OpCode.Send0 or
+ OpCode.SetCV or OpCode.SetConst or OpCode.SetGV or OpCode.SetIV or OpCode.SetMCnst or OpCode.SetSV or
+ OpCode.String or OpCode.Super or OpCode.Symbol or OpCode.Exec => 2,
+
+ OpCode.Enter or
+ OpCode.APost or OpCode.ARef or OpCode.ASet or OpCode.AddILV or OpCode.Array2 or OpCode.Debug or
+ OpCode.GetUpVar or OpCode.SDef or OpCode.SSend or OpCode.SSendB or OpCode.Send or OpCode.SendB or
+ OpCode.SetUpVar or OpCode.SubILV or OpCode.TDef or
+ OpCode.ArgAry or OpCode.BlkPush or OpCode.JmpIf or OpCode.JmpNil or OpCode.JmpNot or OpCode.LoadI16 => 3,
+
+ OpCode.LoadI32 => 5,
+
+ _ => 0,
+ };
+}
diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/DevirtTargets.cs b/src/ChibiRuby.JetPack/Mrb2Cs/DevirtTargets.cs
new file mode 100644
index 00000000..de987c57
--- /dev/null
+++ b/src/ChibiRuby.JetPack/Mrb2Cs/DevirtTargets.cs
@@ -0,0 +1,12 @@
+using ChibiRuby;
+
+namespace ChibiRuby.JetPack.Mrb2Cs;
+
+public readonly record struct AccessorTarget(Symbol Field, ulong Fingerprint, bool IsSetter);
+public readonly record struct ConstReturnTarget(MRubyValue Value, ulong Fingerprint);
+public readonly record struct InlineSelectorTarget(
+ Irep Irep,
+ int ArgCount,
+ ulong Fingerprint,
+ RClass DefiningClass,
+ bool ReturnsNew);
diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/Emitter.cs b/src/ChibiRuby.JetPack/Mrb2Cs/Emitter.cs
new file mode 100644
index 00000000..fcaa66a3
--- /dev/null
+++ b/src/ChibiRuby.JetPack/Mrb2Cs/Emitter.cs
@@ -0,0 +1,2619 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Linq;
+using static ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler;
+using static ChibiRuby.JetPack.Mrb2Cs.Analyzer;
+
+using static ChibiRuby.JetPack.RubyIROpInfo;
+namespace ChibiRuby.JetPack.Mrb2Cs;
+
+// C# emission: walks the analyzed RubyIR and emits the C# method body. Reads the per-method
+// analysis facts (unboxing, scalar/stack replacement, const literals) that Mrb2CsCompiler's
+// orchestration computes and stores in its (internal) per-method state, plus the shared pure
+// helpers/predicates. Holds the emit-only caches (RObject-cast CSE, ivar-read CSE, block-emit
+// state, struct-canonical aliasing). Mutual `using static` with Mrb2CsCompiler.
+public static class Emitter
+{
+ [ThreadStatic]
+ internal static BlockEmitState? BlockEmit;
+
+ // CSE for `(RObject)v.Object` casts: maps a value-id to the C# local that already holds its
+ // RObject cast, so repeated ivar ops on the same (unconditionally-RObject) receiver — self, or
+ // a freshly-`new`ed object — cast once. Invalidated when the value-id is reassigned or at a
+ // control-flow join (label), so the cached local always refers to the live value.
+ [ThreadStatic]
+ static Dictionary? AsObjCache;
+
+ [ThreadStatic]
+ static int AsObjCounter;
+
+ // Return the name of an RObject local holding `valueId`'s cast, emitting the one-time
+ // `RObject __roN = vK.As();` on first use. Only call for values known to be RObject.
+ internal static string AsRObject(int valueId, string boxedRead, StringBuilder body)
+ {
+ AsObjCache ??= new Dictionary();
+ if (AsObjCache.TryGetValue(valueId, out var name)) return name;
+ name = "__ro" + AsObjCounter++;
+ Line(body, $"global::ChibiRuby.RObject {name} = {boxedRead}.As();");
+ AsObjCache[valueId] = name;
+ return name;
+ }
+
+ internal static void InvalidateAsObj(int valueId) => AsObjCache?.Remove(valueId);
+ internal static void ClearAsObjCache() => AsObjCache?.Clear();
+
+ // CSE of self instance-variable reads: `(receiver value-id, field) -> a dedicated `__ivN` local
+ // holding the result of the first `.InstanceVariables.Get(field)`. A re-read of the same field on
+ // the same receiver (with no intervening write/call/branch) reuses that local instead of doing a
+ // second hash-table lookup — e.g. `@x * @x` reads @x once. The temp is a dedicated local (like
+ // asObjCache's `__roN`), never coalesced, so reusing it is sound regardless of value-id liveness.
+ [ThreadStatic] internal static Dictionary<(int Recv, Symbol Field), string>? ivarGetCache;
+ [ThreadStatic] internal static int ivCounter;
+ // Pre-pass result: read dsts that have a later valid re-read, so are worth caching into a `__ivN`
+ // temp. A read NOT in this set is emitted inline (no temp) to avoid bloating single-read fields.
+ [ThreadStatic] internal static HashSet? reusedIvarReads;
+
+ internal static void ClearIvarGetCache() => ivarGetCache?.Clear();
+
+ // Drop cached reads invalidated by instruction `ins`: any Ruby call or ivar write may change a
+ // field's value (conservatively clear all), and reassigning a receiver value-id stales its reads.
+ internal static void InvalidateIvarGet(in RubyIRInstruction ins)
+ {
+ if (ivarGetCache is not { Count: > 0 } cache) return;
+ switch (ins.OpCode)
+ {
+ case RubyIROpCode.Send:
+ case RubyIROpCode.SendSelf:
+ case RubyIROpCode.SendBlock:
+ case RubyIROpCode.SendSelfBlock:
+ case RubyIROpCode.SendBlockDescriptor:
+ case RubyIROpCode.SendSelfBlockDescriptor:
+ case RubyIROpCode.PureUnarySend:
+ case RubyIROpCode.VirtualNew:
+ case RubyIROpCode.SetInstanceVariable:
+ case RubyIROpCode.VirtualSetField:
+ cache.Clear();
+ return;
+ }
+ var d = ins.Dst;
+ if (d >= 0)
+ {
+ List<(int, Symbol)>? drop = null;
+ foreach (var k in cache.Keys) if (k.Recv == d) (drop ??= new()).Add(k);
+ if (drop is not null) foreach (var k in drop) cache.Remove(k);
+ }
+ }
+
+ // Pre-pass: which self-ivar read dsts are re-read later (same receiver+field, no intervening
+ // write/call/branch) — those are cached into a `__ivN` temp; one-shot reads stay inline.
+ internal static HashSet ComputeReusedIvarReads(RubyIRMethod ir, HashSet targets)
+ {
+ var reused = new HashSet();
+ if (Environment.GetEnvironmentVariable("AOT_NOIVARCSE") == "1") return reused; // diagnostic off-switch
+ var seen = new Dictionary<(int, Symbol), int>();
+ var ins = ir.Instructions;
+ for (var i = 0; i < ins.Length; i++)
+ {
+ if (targets.Contains(i)) seen.Clear(); // control-flow join: reads above may not reach here
+ ref readonly var x = ref ins[i];
+ if (x.OpCode is RubyIROpCode.GetInstanceVariable or RubyIROpCode.VirtualGetField)
+ {
+ var key = (x.Src0, ir.GetSymbol(x.Aux));
+ if (seen.TryGetValue(key, out var canon)) reused.Add(canon);
+ else seen[key] = x.Dst;
+ continue;
+ }
+ // Mirror InvalidateIvarGet's invalidation so the pre-pass agrees with emission.
+ switch (x.OpCode)
+ {
+ case RubyIROpCode.Send:
+ case RubyIROpCode.SendSelf:
+ case RubyIROpCode.SendBlock:
+ case RubyIROpCode.SendSelfBlock:
+ case RubyIROpCode.SendBlockDescriptor:
+ case RubyIROpCode.SendSelfBlockDescriptor:
+ case RubyIROpCode.PureUnarySend:
+ case RubyIROpCode.VirtualNew:
+ case RubyIROpCode.SetInstanceVariable:
+ case RubyIROpCode.VirtualSetField:
+ seen.Clear();
+ break;
+ default:
+ if (x.Dst >= 0)
+ {
+ List<(int, Symbol)>? drop = null;
+ foreach (var k in seen.Keys) if (k.Item1 == x.Dst) (drop ??= new()).Add(k);
+ if (drop is not null) foreach (var k in drop) seen.Remove(k);
+ }
+ break;
+ }
+ }
+ return reused;
+ }
+
+ // Assign a long-valued expression to dst in its representation (raw long or re-boxed).
+ static void AssignFix(bool[] isLong, StringBuilder body, int dst, string longExpr)
+ {
+ Line(body, isLong[dst]
+ ? $"l{Slot(dst)} = {longExpr};"
+ : $"v{Slot(dst)} = new global::ChibiRuby.MRubyValue({longExpr});");
+ }
+
+ // Emit `if (!(g0 && g1 && ...)) deopt;` over the non-null guard parts (none -> nothing).
+ static void EmitGuard(StringBuilder body, params string?[] guards)
+ {
+ var live = new List();
+ foreach (var g in guards)
+ {
+ if (g is not null) live.Add(g);
+ }
+ if (live.Count > 0)
+ {
+ Line(body, $"if (!({string.Join(" && ", live)})) {{ result = default; return false; }}");
+ }
+ }
+
+ internal static bool EmitInstruction(MRubyState state, RubyIRMethod exe, in RubyIRInstruction ins, SymbolCache sym, StringBuilder body, InlineContext? ic, ScalarContext? sc, bool[] isLong, bool[] floatTaint, bool[] isDouble, bool[] provesDouble)
+ {
+ switch (ins.OpCode)
+ {
+ case RubyIROpCode.CheckArity:
+ return true;
+
+ // `Const.new(...)` proven non-escaping -> no allocation; initialize is inlined as
+ // field-local assignments. Any other VirtualNew still bails (interpreter allocates).
+ case RubyIROpCode.VirtualNew:
+ {
+ // Array.new(const) scalar-replaced: nil-fill the element locals, no allocation.
+ if (TryScalarArray(ins.Dst, out var vnCanon, out var vnSize))
+ {
+ for (var k = 0; k < vnSize; k++)
+ Line(body, $"{ArrElem(vnCanon, k)} = global::ChibiRuby.MRubyValue.Nil;");
+ return true;
+ }
+ // Stack-allocated object: build the struct in place (no heap alloc).
+ if (CurrentStackObjects is { } cso && cso.TryGetValue(ins.Dst, out var stackLay))
+ {
+ EmitStackConstruct(exe, ins, stackLay, body);
+ return true;
+ }
+ if (sc is not null && sc.IsScalar(ins.Dst))
+ {
+ EmitScalarNew(sc, exe, ins, body);
+ return true;
+ }
+ // Escaping but inline-constructible: allocate + set ivars directly, skipping the
+ // :new + :initialize double dispatch.
+ if (sc is not null && sc.IsFastNew(ins.Dst))
+ {
+ EmitFastNew(sc, exe, ins, body);
+ return true;
+ }
+ if (Environment.GetEnvironmentVariable("AOT_NONEW") == "1") { LastBail = "vnew:gated"; return false; }
+ // Escaping allocation (returned / stored into another object): can't scalarize,
+ // so allocate for real via `Class.new(...)`. Emitting it as a Send keeps the
+ // rest of the method compiled (the whole body no longer bails to the
+ // interpreter just because it constructs an object). Same dispatch the
+ // interpreter would do; everything around it now runs as C#.
+ var newSym = exe.GetCallSiteSymbol(ins.Aux);
+ if (!TrySymbolStringLiteral(state, newSym, out var newName))
+ {
+ LastBail = "vnew:sym";
+ return false;
+ }
+ var newArgc = exe.GetCallSiteArgumentCount(ins.Aux);
+ if (newArgc > 4)
+ {
+ LastBail = "vnew:argc>4";
+ return false;
+ }
+ var newCall = new StringBuilder();
+ newCall.Append(Val(ins.Dst)).Append(" = state.Send(").Append(Val(ins.Src0))
+ .Append(", ").Append(sym.Reference(newName));
+ for (var i = 0; i < newArgc; i++)
+ {
+ newCall.Append(", ").Append(Val(exe.GetCallSiteArgumentValueId(ins.Aux, i)));
+ }
+ newCall.Append(");");
+ Line(body, newCall.ToString());
+ return true;
+ }
+
+ case RubyIROpCode.Move:
+ // A copy of a scalar-replaced array/hash alias is a no-op (src and dst share the
+ // element/key locals).
+ if (TryScalarArray(ins.Src0, out _, out _) || TryScalarHash(ins.Src0, out _, out _)) return true;
+ if (sc is not null && sc.TryEmitScalarMove(ins))
+ {
+ return true;
+ }
+ // Looping methods: representation-aware copy (the back-edge `zr = tr` and pre-loop
+ // `zr = 0.0` write the raw d{}/l{} local for raw loop-carried values). Reads convert
+ // from src's representation; a boxed dst re-boxes a raw src.
+ if (CurrentProvesFixnum is not null)
+ {
+ var md = ins.Dst;
+ var ms = ins.Src0;
+ var lhs = isDouble[md] ? "d" + Slot(md) : isLong[md] ? "l" + Slot(md) : Val(md);
+ var rhs = isDouble[md] ? DoubleRead(isLong, isDouble, ms)
+ : isLong[md] ? FixRead(isLong, ms)
+ : BoxReadFull(isLong, isDouble, ms);
+ if (lhs != rhs) Line(body, $"{lhs} = {rhs};");
+ return true;
+ }
+ // Canonicalized mutated-struct aliases render to the same local -> a `soX = soX`
+ // self-copy; skip it (and it must be skipped so the snapshot/copy ordering is right).
+ if (Val(ins.Dst) != Val(ins.Src0)) Line(body, $"{Val(ins.Dst)} = {Val(ins.Src0)};");
+ return true;
+
+ case RubyIROpCode.LoadValue:
+ {
+ // Looping methods: a raw loop local is initialized directly (e.g. `d{} = 0D` for a
+ // pre-loop `zr = 0.0`), no MRubyValue box.
+ if (CurrentProvesFixnum is not null)
+ {
+ if (isDouble[ins.Dst]) { Line(body, $"d{Slot(ins.Dst)} = {DoubleLitText(exe.GetLiteral(ins.Aux).FloatValue)};"); return true; }
+ if (isLong[ins.Dst]) { Line(body, $"l{Slot(ins.Dst)} = {exe.GetLiteral(ins.Aux).FixnumValue}L;"); return true; }
+ }
+ // Symbol literal (`:a`) — used as a hash key / symbol value. Intern once via the
+ // method's SymbolCache.
+ var lvLit = exe.GetLiteral(ins.Aux);
+ if (lvLit.IsSymbol && TrySymbolStringLiteral(state, lvLit.SymbolValue, out var lvSymName))
+ {
+ Line(body, $"{Val(ins.Dst)} = new global::ChibiRuby.MRubyValue({sym.Reference(lvSymName)});");
+ return true;
+ }
+ if (!TryEmitLiteral(lvLit, out var expr))
+ {
+ return false;
+ }
+ Line(body, $"{Val(ins.Dst)} = {expr};");
+ return true;
+ }
+
+ case RubyIROpCode.LoadSelf:
+ Line(body, $"{Val(ins.Dst)} = {Val(0)};");
+ return true;
+
+ case RubyIROpCode.GetConstant:
+ {
+ if (!TrySymbolStringLiteral(state, exe.GetSymbol(ins.Aux), out var cname))
+ {
+ return false;
+ }
+ // GetConstantUnsafe shares the interpreter's lexical resolution; the cached form
+ // skips the scope-chain walk while no constant has been (re)assigned. Both paths
+ // resolve identically on a miss/first call. AOT_NOCONSTCACHE forces the uncached form.
+ if (ic is not null && Environment.GetEnvironmentVariable("AOT_NOCONSTCACHE") != "1")
+ {
+ EmitGuardedConstantRead(ic, ins, cname, body);
+ }
+ else
+ {
+ Line(body, $"{Val(ins.Dst)} = GetConstantUnsafe(state, {sym.Reference(cname)});");
+ }
+ return true;
+ }
+
+ case RubyIROpCode.GetModuleConstant:
+ {
+ if (!TrySymbolStringLiteral(state, exe.GetSymbol(ins.Aux), out var cname))
+ {
+ return false;
+ }
+ // `Mod::Name` — resolve the constant in the module value (Src0), exactly
+ // as the interpreter's OP_GetMCnst does. GetConst(Symbol, RClass) is public.
+ Line(body, $"{Val(ins.Dst)} = state.GetConst({sym.Reference(cname)}, {Val(ins.Src0)}.As());");
+ return true;
+ }
+
+ case RubyIROpCode.GuardInlineClass:
+ {
+ if (sc is not null && TryEmitScalarInlineGuard(sc, exe, ins, body))
+ {
+ return true;
+ }
+ if (ic is null ||
+ !exe.TryGetGuardInline(ins.Src1, out var fp) ||
+ fp == 0)
+ {
+ LastBail = "guardinline:metadata";
+ return false;
+ }
+ if (!TrySymbolStringLiteral(state, exe.GetCallSiteSymbol(ins.Src1), out var mname))
+ {
+ return false;
+ }
+ EmitGuardInlineClass(ic, ins, mname, fp, body);
+ return true;
+ }
+
+ case RubyIROpCode.GetInstanceVariable or RubyIROpCode.VirtualGetField:
+ {
+ if (sc is not null && TryEmitScalarFieldAccess(sc, exe, ins, body))
+ {
+ return true;
+ }
+ // Self is a stack struct (struct-receiver variant): read the field directly.
+ if (CurrentStackObjects is { } gcso && gcso.TryGetValue(ins.Src0, out var glay) &&
+ glay.FieldIndexOf(exe.GetSymbol(ins.Aux)) is var gfi && gfi >= 0)
+ {
+ var gso = Val(ins.Src0);
+ var gread = glay.FieldKinds[gfi] switch
+ {
+ StackFieldKind.Double or StackFieldKind.Long => $"new global::ChibiRuby.MRubyValue({gso}.f{gfi})",
+ _ => $"{gso}.f{gfi}",
+ };
+ Line(body, $"{Val(ins.Dst)} = {gread};");
+ EmitFloatSpeculationGuard(body, provesDouble, ins.Dst);
+ return true;
+ }
+ if (!TrySymbolStringLiteral(state, exe.GetSymbol(ins.Aux), out var name))
+ {
+ return false;
+ }
+ // Direct ivar ops are always on self (cross-object goes via accessor sends), so the
+ // receiver is unconditionally an RObject — cast it once and reuse via the cache.
+ var ivKey = (ins.Src0, exe.GetSymbol(ins.Aux));
+ if (ivarGetCache is { } ivc && ivc.TryGetValue(ivKey, out var ivCached))
+ {
+ // Re-read of the same field on the same receiver -> reuse the cached value.
+ Line(body, $"{Val(ins.Dst)} = {ivCached};");
+ }
+ else if (reusedIvarReads is { } rir && rir.Contains(ins.Dst))
+ {
+ // First of several reads: stash into a dedicated temp the later reads can reuse.
+ var t = "__iv" + ivCounter++;
+ Line(body, $"global::ChibiRuby.MRubyValue {t} = {IvarGet(AsRObject(ins.Src0, Val(ins.Src0), body), sym.Reference(name))};");
+ Line(body, $"{Val(ins.Dst)} = {t};");
+ ivarGetCache![ivKey] = t;
+ }
+ else
+ {
+ // One-shot read: emit inline, no temp.
+ Line(body, $"{Val(ins.Dst)} = {IvarGet(AsRObject(ins.Src0, Val(ins.Src0), body), sym.Reference(name))};");
+ }
+ EmitFloatSpeculationGuard(body, provesDouble, ins.Dst);
+ return true;
+ }
+
+ case RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField:
+ {
+ if (sc is not null && TryEmitScalarFieldAccess(sc, exe, ins, body))
+ {
+ return true;
+ }
+ // Self is a stack struct (a by-ref struct-receiver variant): write the field
+ // directly. Only reachable for a `ref` (Mutated) layout; read-only `in` self never
+ // hits a SetInstanceVariable because CalleeSelfReadOnly rejects mutating callees.
+ if (CurrentStackObjects is { } scso && scso.TryGetValue(ins.Src0, out var slay) &&
+ slay.FieldIndexOf(exe.GetSymbol(ins.Aux)) is var sfi && sfi >= 0)
+ {
+ var sso = Val(ins.Src0);
+ var sval = slay.FieldKinds[sfi] switch
+ {
+ StackFieldKind.Double => Val(ins.Src1) + ".FloatValue",
+ StackFieldKind.Long => Val(ins.Src1) + ".IntegerValue",
+ _ => Val(ins.Src1),
+ };
+ Line(body, $"{sso}.f{sfi} = {sval};");
+ return true;
+ }
+ if (!TrySymbolStringLiteral(state, exe.GetSymbol(ins.Aux), out var name))
+ {
+ return false;
+ }
+ Line(body, $"{IvarSet(AsRObject(ins.Src0, Val(ins.Src0), body), sym.Reference(name), Val(ins.Src1))};");
+ return true;
+ }
+
+ // Generic (untyped) arith. The AOT lowering path never produces the typed
+ // Float opcodes, so int and float both arrive here; emit a runtime dual path
+ // (both-fixnum -> long, both-float -> double, else deopt -> interpreter handles
+ // mixed/coercion). Long-dst values are float-untainted by construction, so their
+ // path stays fixnum-only.
+ case RubyIROpCode.Add or RubyIROpCode.AddFixnum:
+ return EmitNumericBinary(sym, body, ins, "+", isLong, floatTaint, isDouble, provesDouble);
+ case RubyIROpCode.Sub or RubyIROpCode.SubFixnum:
+ return EmitNumericBinary(sym, body, ins, "-", isLong, floatTaint, isDouble, provesDouble);
+
+ // `reg +/- ` (AddI/SubI). Immediate is a fixnum in the
+ // literal pool; guard the receiver is fixnum and fold the constant in.
+ case RubyIROpCode.AddImmediate or RubyIROpCode.AddImmediateFixnum:
+ return EmitFixnumImmediate(sym, body, exe, ins, "+", isLong);
+ case RubyIROpCode.SubImmediate or RubyIROpCode.SubImmediateFixnum:
+ return EmitFixnumImmediate(sym, body, exe, ins, "-", isLong);
+ case RubyIROpCode.Mul or RubyIROpCode.MulFixnum:
+ return EmitNumericBinary(sym, body, ins, "*", isLong, floatTaint, isDouble, provesDouble);
+ case RubyIROpCode.Div or RubyIROpCode.DivFixnum:
+ // Fixnum division matches the interpreter (C# truncating /); a zero divisor
+ // takes the slow path (interpreter raises ZeroDivisionError). Float division
+ // follows IEEE (x/0.0 -> Infinity, as Ruby Float#/), no zero guard.
+ return EmitNumericBinary(sym, body, ins, "/", isLong, floatTaint, isDouble, provesDouble, isDiv: true);
+
+ // Fused multiply-add/sub from the arithmetic-fusion pass (generic int or float).
+ // Operands: src0*src1 combined with src2; dual fixnum/float path over all three.
+ case RubyIROpCode.MulAdd:
+ return EmitNumericFused(sym, body, ins, isLong, floatTaint, isDouble, provesDouble, "+", false);
+ case RubyIROpCode.MulSub:
+ return EmitNumericFused(sym, body, ins, isLong, floatTaint, isDouble, provesDouble, "-", false);
+ case RubyIROpCode.SubMul:
+ return EmitNumericFused(sym, body, ins, isLong, floatTaint, isDouble, provesDouble, "-", true);
+
+ case RubyIROpCode.Lt or RubyIROpCode.LtFixnum:
+ return EmitNumericCompare(sym, body, ins, "<", isLong, floatTaint, isDouble, provesDouble);
+ case RubyIROpCode.Le or RubyIROpCode.LeFixnum:
+ return EmitNumericCompare(sym, body, ins, "<=", isLong, floatTaint, isDouble, provesDouble);
+ case RubyIROpCode.Gt or RubyIROpCode.GtFixnum:
+ return EmitNumericCompare(sym, body, ins, ">", isLong, floatTaint, isDouble, provesDouble);
+ case RubyIROpCode.Ge or RubyIROpCode.GeFixnum:
+ return EmitNumericCompare(sym, body, ins, ">=", isLong, floatTaint, isDouble, provesDouble);
+ case RubyIROpCode.Eq:
+ return EmitNumericCompare(sym, body, ins, "==", isLong, floatTaint, isDouble, provesDouble);
+
+ // Nested method call -> public state.Send (full dispatch; if the callee is
+ // also AOT-compiled it uses its compiled body too). No-block sends only;
+ // explicit Send overloads cover 0..4 args.
+ case RubyIROpCode.Send or RubyIROpCode.SendSelf:
+ {
+ // In a stack-struct VARIANT: a trivial-accessor getter on the struct param lowers
+ // to a struct-field read (no devirt, no boxing for typed fields).
+ if (ins.OpCode == RubyIROpCode.Send && CurrentStackObjects is { Count: > 0 } &&
+ TryEmitStackAccessor(state, exe, ins, sym, body))
+ {
+ return true;
+ }
+ // Caller side: a non-accessor Send whose RECEIVER is a stack object -> call the
+ // callee's struct-`self` variant (class statically known), reify on miss/deopt.
+ if (CurrentStackObjects is { Count: > 0 } && TryEmitStackReceiverSend(ic, ins, body))
+ {
+ return true;
+ }
+ // Caller side: a Send whose argument is a stack object -> dispatch to the callee's
+ // specialized struct variant (guarded per receiver class), reify on miss/deopt.
+ if (CurrentStackObjects is { Count: > 0 } && TryEmitStackArgSend(ic, ins, body))
+ {
+ return true;
+ }
+ // Trivial accessor on a scalar-replaced object -> direct field-local access.
+ if (sc is not null && TryEmitAccessorSend(sc, exe, ins, body))
+ {
+ return true;
+ }
+ var methodSym = exe.GetCallSiteSymbol(ins.Aux);
+ // Context-switching sends (Fiber.yield/resume/transfer, sleep, Thread.pass)
+ // cannot run from a compiled C# frame — the fiber can't be suspended/
+ // resumed mid-C#-method ("resuming dead fiber"). Bail so they interpret.
+ if (IsContextSwitchingMethod(state, methodSym))
+ {
+ LastBail = "send:ctxswitch";
+ return false;
+ }
+ if (!TrySymbolStringLiteral(state, methodSym, out var mname))
+ {
+ LastBail = "send:sym";
+ return false;
+ }
+ var argc = exe.GetCallSiteArgumentCount(ins.Aux);
+ if (argc > 4)
+ {
+ LastBail = "send:argc>4";
+ return false;
+ }
+ // Monomorphic self-send -> guarded inline call to the callee's __inline form.
+ if (ins.OpCode == RubyIROpCode.SendSelf && ic is not null &&
+ TryInlineSelfSend(ic, ins, methodSym, mname, argc, body))
+ {
+ return true;
+ }
+ // Cross-object 0-arg send to a constant-returning method -> guarded constant.
+ if (ic is not null && TryEmitConstantDevirt(ic, ins, methodSym, mname, argc, body))
+ {
+ return true;
+ }
+ // Cross-object accessor (recv.getter / recv.setter=) -> guarded field access.
+ if (ic is not null && TryEmitAccessorDevirt(ic, ins, methodSym, mname, argc, body))
+ {
+ // A speculated float accessor read (provesDouble) gets its IsFloat guard after
+ // both the devirt-hit and Send-fallback branches have written the result.
+ EmitFloatSpeculationGuard(body, provesDouble, ins.Dst);
+ return true;
+ }
+ // `to_f` is hot in numeric loops (e.g. ao-bench render). Inline the common
+ // immediate Integer/Float cases and fall back for String/custom receivers.
+ if (argc == 0 &&
+ Environment.GetEnvironmentVariable("AOT_NOTOF") != "1" &&
+ IsToFMethod(state, methodSym))
+ {
+ var r = Val(ins.Src0);
+ var d = Val(ins.Dst);
+ Line(body, $"if ({r}.IsFixnum) {{ {d} = new global::ChibiRuby.MRubyValue((double){r}.FixnumValue); }} else if ({r}.IsFloat) {{ {d} = {r}; }} else {{ {d} = state.Send({r}, {sym.Reference(mname)}); }}");
+ return true;
+ }
+ // One-argument pure C# methods avoid building a Ruby call frame. Guard the
+ // argument to numeric immediates so non-numeric error paths keep Send's frame.
+ if (argc == 1 &&
+ Environment.GetEnvironmentVariable("AOT_NOPUREUNARY") != "1" &&
+ IsFloatReturningMethod(state, methodSym) &&
+ ic is not null)
+ {
+ return TryEmitPureUnarySend(ic, ins, mname, body);
+ }
+ // Fixnum bitwise-operator sends (&, |, ^, >>) have no mruby opcode, so they
+ // arrive as a full-dispatch Send — hot in integer code (NES masking/shifts).
+ // Inline the fixnum case (guarded) with a Send fallback. &|^ can't overflow;
+ // >> is guarded to a 0..63 shift (C# long >> is arithmetic, matching Ruby).
+ // << / % stay as Send (overflow / floor-semantics differ from C#).
+ if (argc == 1 && TryFixnumBitwiseOp(state, methodSym, out var binOp, out var isShift))
+ {
+ var recvId = ins.Src0;
+ var argId = exe.GetCallSiteArgumentValueId(ins.Aux, 0);
+ // Drop the IsFixnum guard / use the C# literal for a constant operand (e.g.
+ // `@x & 0xFFFFF` -> `v1.FixnumValue & 1048575L`, only v1 guarded).
+ var condParts = new List();
+ if (FixGuard(isLong, recvId) is { } gr) condParts.Add(gr);
+ if (FixGuard(isLong, argId) is { } gar) condParts.Add(gar);
+ if (isShift) condParts.Add($"(ulong){FixRead(isLong, argId)} <= 63UL");
+ var expr = isShift
+ ? $"{FixRead(isLong, recvId)} {binOp} (int){FixRead(isLong, argId)}"
+ : $"{FixRead(isLong, recvId)} {binOp} {FixRead(isLong, argId)}";
+ // & | ^ >> of fixnums always yield a fixnum, so a proven-fixnum dst lives in a raw
+ // long local (the else-Send is dead when operands are proven, but stays correct via
+ // .FixnumValue since the result is always a fixnum). Otherwise the dst stays boxed.
+ // The Send fallback needs boxed operands; BoxRead re-boxes a raw `long` operand.
+ var slowSend = $"state.Send({BoxRead(isLong, recvId)}, {sym.Reference(mname)}, {BoxRead(isLong, argId)})";
+ if (isLong[ins.Dst])
+ {
+ Line(body, condParts.Count == 0
+ ? $"l{Slot(ins.Dst)} = {expr};"
+ : $"if ({Cond(condParts)}) {{ l{Slot(ins.Dst)} = {expr}; }} else {{ l{Slot(ins.Dst)} = {slowSend}.FixnumValue; }}");
+ return true;
+ }
+ var d = Val(ins.Dst);
+ Line(body, $"if ({Cond(condParts)}) {{ {d} = new global::ChibiRuby.MRubyValue({expr}); }} else {{ {d} = {slowSend}; }}");
+ return true;
+ }
+ var call = new StringBuilder();
+ call.Append(Val(ins.Dst)).Append(" = state.Send(").Append(Val(ins.Src0))
+ .Append(", ").Append(sym.Reference(mname));
+ for (var i = 0; i < argc; i++)
+ {
+ call.Append(", ").Append(Val(exe.GetCallSiteArgumentValueId(ins.Aux, i)));
+ }
+ call.Append(");");
+ Line(body, call.ToString());
+ return true;
+ }
+
+ // Indexed access. GetIndexUnsafe/SetIndexUnsafe inline the interpreter's
+ // Array-fast-path (RArray + fixnum -> direct element) and fall back to
+ // :[] / :[]= for everything else, so hot array code avoids full dispatch.
+ case RubyIROpCode.GetIndex:
+ if (TryScalarArray(ins.Src0, out var gCanon, out _) && ConstFix(ins.Src1, out var gidx))
+ {
+ Line(body, $"{Val(ins.Dst)} = {ArrElem(gCanon, (int)gidx)};");
+ return true;
+ }
+ if (TryScalarHash(ins.Src0, out var ghCanon, out var ghKeys) && KeyTag(ins.Src1) is { } ghTag)
+ {
+ Line(body, $"{Val(ins.Dst)} = {(ghKeys.Contains(ghTag) ? HashElem(ghCanon, ghTag) : "global::ChibiRuby.MRubyValue.Nil")};");
+ return true;
+ }
+ Line(body, $"{Val(ins.Dst)} = GetIndexUnsafe(state, {Val(ins.Src0)}, {Val(ins.Src1)});");
+ return true;
+
+ case RubyIROpCode.GetIndex0:
+ if (TryScalarArray(ins.Src0, out var g0Canon, out _))
+ {
+ Line(body, $"{Val(ins.Dst)} = {ArrElem(g0Canon, 0)};");
+ return true;
+ }
+ if (TryScalarHash(ins.Src0, out var gh0Canon, out var gh0Keys))
+ {
+ Line(body, $"{Val(ins.Dst)} = {(gh0Keys.Contains("i0") ? HashElem(gh0Canon, "i0") : "global::ChibiRuby.MRubyValue.Nil")};");
+ return true;
+ }
+ Line(body, $"{Val(ins.Dst)} = GetIndexZeroUnsafe(state, {Val(ins.Src0)});");
+ return true;
+
+ // SetIdx writes the assigned value back to the receiver register (= Dst).
+ case RubyIROpCode.SetIndex:
+ if (TryScalarArray(ins.Src0, out var sCanon, out _) && ConstFix(ins.Src1, out var sidx))
+ {
+ // a[i] = x -> element local = x; (the op also yields x)
+ Line(body, $"{ArrElem(sCanon, (int)sidx)} = {Val(ins.Src2)};");
+ if (Val(ins.Dst) != Val(ins.Src2)) Line(body, $"{Val(ins.Dst)} = {Val(ins.Src2)};");
+ return true;
+ }
+ if (TryScalarHash(ins.Src0, out var shCanon, out _) && KeyTag(ins.Src1) is { } shTag)
+ {
+ Line(body, $"{HashElem(shCanon, shTag)} = {Val(ins.Src2)};");
+ if (Val(ins.Dst) != Val(ins.Src2)) Line(body, $"{Val(ins.Dst)} = {Val(ins.Src2)};");
+ return true;
+ }
+ Line(body, $"{Val(ins.Dst)} = SetIndexUnsafe(state, {Val(ins.Src0)}, {Val(ins.Src1)}, {Val(ins.Src2)});");
+ return true;
+
+ // Array literal [a, b, c]. Elements are value ids in the operand list at Aux;
+ // state.NewArray(ReadOnlySpan) is public and its RArray converts to MRubyValue.
+ case RubyIROpCode.NewArray:
+ {
+ var n = exe.GetOperandListCount(ins.Aux);
+ // Scalar-replaced literal: initialize the per-element locals, no RArray allocation.
+ if (TryScalarArray(ins.Dst, out var nCanon, out _))
+ {
+ for (var i = 0; i < n; i++)
+ Line(body, $"{ArrElem(nCanon, i)} = {Val(exe.GetOperandListValueId(ins.Aux, i))};");
+ return true;
+ }
+ var call = new StringBuilder();
+ call.Append(Val(ins.Dst)).Append(" = state.NewArray(");
+ if (n == 0)
+ {
+ call.Append("global::System.ReadOnlySpan.Empty");
+ }
+ else
+ {
+ // MRubyValue is a managed struct, so it can't be stackalloc'd; a heap
+ // temp is fine (NewArray copies the elements into the RArray anyway).
+ call.Append("new global::ChibiRuby.MRubyValue[] { ");
+ for (var i = 0; i < n; i++)
+ {
+ if (i > 0) call.Append(", ");
+ call.Append(Val(exe.GetOperandListValueId(ins.Aux, i)));
+ }
+ call.Append(" }");
+ }
+ call.Append(");");
+ Line(body, call.ToString());
+ return true;
+ }
+
+ // Hash literal {k0 => v0, ...} / scalar-replaced (constant-key) form.
+ case RubyIROpCode.NewHash:
+ {
+ var nh = exe.GetOperandListCount(ins.Aux); // 2 * pairs
+ var pairs = nh / 2;
+ if (TryScalarHash(ins.Dst, out var hCanon, out _))
+ {
+ for (var p = 0; p < pairs; p++)
+ Line(body, $"{HashElem(hCanon, KeyTag(exe.GetOperandListValueId(ins.Aux, 2 * p))!)} = {Val(exe.GetOperandListValueId(ins.Aux, 2 * p + 1))};");
+ return true;
+ }
+ var hb = new StringBuilder();
+ hb.Append("{ var _h = state.NewHash(").Append(pairs).Append(");");
+ for (var p = 0; p < pairs; p++)
+ hb.Append(" _h.Add(").Append(Val(exe.GetOperandListValueId(ins.Aux, 2 * p)))
+ .Append(", ").Append(Val(exe.GetOperandListValueId(ins.Aux, 2 * p + 1))).Append(");");
+ hb.Append(' ').Append(Val(ins.Dst)).Append(" = new global::ChibiRuby.MRubyValue(_h); }");
+ Line(body, hb.ToString());
+ return true;
+ }
+
+ case RubyIROpCode.Jump:
+ Line(body, $"goto L{ins.Aux};");
+ return true;
+ case RubyIROpCode.JumpIfTruthy:
+ Line(body, $"if ({Val(ins.Src0)}.Truthy) goto L{ins.Aux};");
+ return true;
+ case RubyIROpCode.JumpIfFalsy:
+ Line(body, $"if ({Val(ins.Src0)}.Falsy) goto L{ins.Aux};");
+ return true;
+ case RubyIROpCode.JumpIfNil:
+ Line(body, $"if ({Val(ins.Src0)}.IsNil) goto L{ins.Aux};");
+ return true;
+
+ case RubyIROpCode.Return or RubyIROpCode.ReturnValue:
+ Line(body, $"result = {BoxReadFull(isLong, isDouble, ins.Src0)}; return true;");
+ return true;
+ case RubyIROpCode.ReturnSelf:
+ Line(body, $"result = {Val(0)}; return true;");
+ return true;
+
+ // Closure variable access, only valid while emitting an inlined block body where
+ // the captured registers are ref-param cells. aux packs (register << 8) | depth.
+ case RubyIROpCode.GetUpVar:
+ {
+ if (!TryResolveUpvarCell(ins.Aux, out var cell)) { LastBail = "upvar:unresolved"; return false; }
+ Line(body, $"{Val(ins.Dst)} = {cell};");
+ return true;
+ }
+ case RubyIROpCode.SetUpVar:
+ {
+ if (!TryResolveUpvarCell(ins.Aux, out var cell)) { LastBail = "upvar:unresolved"; return false; }
+ Line(body, $"{cell} = {Val(ins.Src0)};");
+ return true;
+ }
+
+ // `count.times do |i| ... end` -> a C# for loop calling the block body's __blk
+ // method, with the block's captured method locals passed by ref.
+ case RubyIROpCode.SendBlockDescriptor or RubyIROpCode.SendSelfBlockDescriptor:
+ return TryEmitTimesLoop(state, exe, ins, sym, body);
+
+ default:
+ return false;
+ }
+ }
+
+ // Resolve an upvar (register = aux >> 8, lv = aux & 0xff) to its ref-param cell. In a block
+ // at level L, lv references the scope L-lv-1; the cell exists iff this block received it.
+ static bool TryResolveUpvarCell(int aux, out string cell)
+ {
+ cell = "";
+ if (BlockEmit is not { Cells: { } cells } || BlockEmit.CurrentLevel == 0) return false;
+ var register = aux >> 8;
+ var scope = BlockEmit.CurrentLevel - (aux & 0xff) - 1;
+ if (scope < 0 || !cells.Contains((scope, register))) return false;
+ cell = CellName(scope, register);
+ return true;
+ }
+
+ static string CellName(int scope, int register) => $"cell_{scope}_{register}";
+
+ static bool IsTimesSelector(MRubyState state, Symbol sym) =>
+ state.NameOf(sym).AsSpan().SequenceEqual("times"u8);
+
+ // `count.times do |i| ... end` -> a C# for loop. The block body becomes a separate __blk
+ // method (so its value-ids never collide with the caller's). Variables it (or a nested
+ // block) reads/writes from an enclosing scope are passed by ref as `cell__`.
+ // The receiver is guarded fixnum (Integer#times); the loop returns the receiver. Nesting is
+ // recursive: the child __blk's body re-enters this for its own nested times.
+ static bool TryEmitTimesLoop(MRubyState state, RubyIRMethod exe, in RubyIRInstruction ins, SymbolCache sym, StringBuilder body)
+ {
+ if (BlockEmit is null) { LastBail = "block:noctx"; return false; }
+ if (!IsTimesSelector(state, exe.GetCallSiteSymbol(ins.Aux))) { LastBail = "block:notTimes"; return false; }
+ if (exe.GetCallSiteArgumentCount(ins.Aux) != 0) { LastBail = "block:iterArgs"; return false; }
+
+ var level = BlockEmit.CurrentLevel; // scope emitting the times
+ var childLevel = level + 1;
+ var child = exe.GetChildIrep(ins.Src1);
+ if (!TryReadMandatoryArgCount(child, out var blockArgc) || blockArgc > 1) { LastBail = "block:argc"; return false; }
+
+ // Coordinates the child (and its descendants) reference above the child's own scope.
+ var needed = CollectBlockCells(state, child, childLevel);
+ if (needed is null) return false; // LastBail set inside
+
+ var n = BlockEmit.BlockCounter++;
+ var blockName = BlockEmit.OwnerName + "__blk" + n;
+ var src = TryGenerateBlockBody(state, child, blockName, needed, blockArgc, childLevel);
+ if (src is null) return false; // LastBail set inside
+ BlockEmit.AuxMethods.Add(src);
+
+ var recv = ins.OpCode == RubyIROpCode.SendSelfBlockDescriptor ? Val(0) : Val(ins.Src0);
+ var idx = "_bi" + n;
+ // Pass each needed coordinate by ref: a coordinate in the CURRENT scope is one of this
+ // body's own locals (v); a coordinate above it is one of the cells THIS body
+ // itself received (cell__).
+ var refs = new StringBuilder();
+ foreach (var (scope, register) in needed)
+ {
+ refs.Append(", ref ").Append(scope == level ? Val(register) : CellName(scope, register));
+ }
+ var argPass = blockArgc >= 1 ? $", new global::ChibiRuby.MRubyValue({idx})" : "";
+ // The receiver-fixnum guard runs before the loop (no iteration has committed a side
+ // effect yet), so deopting here is safe. A block body itself never deopts under
+ // ForceSend except via a nested receiver guard, which is likewise pre-loop; propagate
+ // it up so a (never-in-practice, Integer#times) miss can't silently skip iterations.
+ Line(body, $"if (!{recv}.IsFixnum) {{ result = default; return false; }}");
+ Line(body, $"for (long {idx} = 0; {idx} < {recv}.FixnumValue; {idx}++) {{ if (!{blockName}(state, {Val(0)}{argPass}{refs}, out var _bt{n})) {{ result = default; return false; }} }}");
+ Line(body, $"{Val(ins.Dst)} = {recv};");
+ return true;
+ }
+
+ // Collect the absolute coordinates (scope, register) that a block at `blockLevel` (and its
+ // nested blocks) reads/writes from a scope ABOVE its own — these become the block's ref-param
+ // cells. Returns null (and sets LastBail) if anything uninlinable appears (a non-times block,
+ // a block passed as a proc, an unlowerable body).
+ static SortedSet<(int Scope, int Register)>? CollectBlockCells(MRubyState state, Irep blockIrep, int blockLevel)
+ {
+ var result = new SortedSet<(int, int)>();
+ return Walk(blockIrep, blockLevel) ? result : null;
+
+ bool Walk(Irep irep, int level)
+ {
+ RubyIRMethod? exe;
+ try { exe = RubyIRBuilder.Build(irep, 0, out _); }
+ catch { exe = null; }
+ if (exe is null) { LastBail = "block:childLower"; return false; }
+ foreach (var bi in exe.Instructions)
+ {
+ switch (bi.OpCode)
+ {
+ case RubyIROpCode.GetUpVar or RubyIROpCode.SetUpVar:
+ var scope = level - (bi.Aux & 0xff) - 1;
+ if (scope < 0) { LastBail = "block:badUpvar"; return false; }
+ if (scope < blockLevel) result.Add((scope, bi.Aux >> 8));
+ break;
+ case RubyIROpCode.SendBlockDescriptor or RubyIROpCode.SendSelfBlockDescriptor:
+ if (!IsTimesSelector(state, exe.GetCallSiteSymbol(bi.Aux)) ||
+ exe.GetCallSiteArgumentCount(bi.Aux) != 0)
+ {
+ LastBail = "block:nestedNonTimes";
+ return false;
+ }
+ if (!Walk(exe.GetChildIrep(bi.Src1), level + 1)) return false;
+ break;
+ case RubyIROpCode.LoadBlock or RubyIROpCode.SendBlock or RubyIROpCode.SendSelfBlock:
+ LastBail = "block:escapingBlock";
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+
+ // Emit the block body as a standalone method: (state, self, [block param], ref cell_s_r...,
+ // out result). Runs with ForceSend (no deopt — the body executes in a loop and must never
+ // re-execute a partial iteration) and no unboxing/scalar replacement. Recurses for nested
+ // times. Returns the full source (static sym fields + the method) or null if uncompilable.
+ static string? TryGenerateBlockBody(MRubyState state, Irep blockIrep, string blockName, SortedSet<(int Scope, int Register)> cells, int blockArgc, int level)
+ {
+ RubyIRMethod? exe;
+ try { exe = RubyIRBuilder.Build(blockIrep, 0, out _); }
+ catch { exe = null; }
+ if (exe is null) { LastBail = "block:childLower"; return null; }
+
+ // Stack-allocate non-escaping objects constructed in THIS block body (ao's loop-local Ray
+ // lives here, not in the enclosing method). SSA-renumber first so each value-id is single-
+ // def (a clean struct-local mapping); Run keeps params (v0..vBlockArgc) and captured cell
+ // ids stable, preserving the block param/cell-ref convention. CurrentStackObjects is thread-
+ // static and shared with the enclosing method's emission (which continues after this block
+ // returns), and block value-ids are a different numbering space than the parent's, so it is
+ // saved here and restored before every return — never inherited into the block.
+ var savedStackObjects = CurrentStackObjects;
+ var savedCanonical = StructCanonical;
+ var savedConstLit = CurrentConstLit;
+ // Array scalar replacement is not wired into the block-body declaration path yet; disable it
+ // here (the parent's map must not leak into the block's distinct value-id space).
+ var savedScalarArrays = CurrentScalarArrays;
+ var savedScalarHashes = CurrentScalarHashes;
+ var savedHashKeyTags = CurrentHashKeyTags;
+ var savedBlkProvesFixnum = CurrentProvesFixnum;
+ var savedBlkSound = CurrentSoundProven;
+ var savedBlkSlot = CurrentLocalSlot;
+ CurrentScalarArrays = null;
+ CurrentScalarHashes = null;
+ CurrentLocalSlot = null; // no coalescing in block bodies (parent's slot map must not leak)
+ if (StackObjEnabled && SsaEnabled && CurrentEscapeSummary is { } blockEsc)
+ {
+ exe = RubyIRSsaRenumber.Run(exe, blockArgc);
+ CurrentStackObjects = FindStackEligible(state, exe, blockEsc);
+ if (Environment.GetEnvironmentVariable("AOT_ESCAPE_DEBUG") == "1" && CurrentStackObjects is { Count: > 0 } dbg)
+ foreach (var (objId, lay) in dbg)
+ System.Console.Error.WriteLine($"[stackobj] {blockName} v{objId} = new {state.NameOf(lay.ConstName)} -> {lay.StructType}");
+ }
+ else
+ {
+ CurrentStackObjects = null;
+ }
+ RebuildStructCanonical();
+ CurrentConstLit = BuildConstLit(exe); // for the (possibly SSA-renumbered) block exe
+
+ var instructions = exe.Instructions;
+ var targets = new HashSet();
+ foreach (var ins in instructions)
+ {
+ if (ins.OpCode is RubyIROpCode.Jump or RubyIROpCode.JumpIfTruthy
+ or RubyIROpCode.JumpIfFalsy or RubyIROpCode.JumpIfNil)
+ {
+ targets.Add(ins.Aux);
+ }
+ }
+
+ // Sound, deopt-free unboxing for the block body. It is forward-only and runs under ForceSend
+ // (numeric slow paths Send, never deopt); no speculation and no arg guards (a block deopt
+ // re-runs the parent). Math-returning sends + float literals become raw double — this is
+ // where ao's ambient_occlusion trig chain unboxes. Captured cells stay boxed (ref params).
+ ComputeLoopUnboxing(state, exe, blockArgc, null,
+ out var provesDouble, out var provesFixnum, out var floatTaint, out var soundProvenBlk, out _, speculateArgs: false);
+ CurrentProvesFixnum = provesFixnum;
+ CurrentSoundProven = soundProvenBlk;
+ ComputeLoopRawLocals(state, exe, blockArgc, provesDouble, provesFixnum, out var isLong, out var isDouble);
+ var bsym = new SymbolCache(blockName);
+
+ var savedCells = BlockEmit!.Cells;
+ var savedLevel = BlockEmit.CurrentLevel;
+ var savedForce = BlockEmit.ForceSend;
+ BlockEmit.Cells = new HashSet<(int, int)>(cells);
+ BlockEmit.CurrentLevel = level;
+ BlockEmit.ForceSend = true;
+ // Block bodies never self-inline other Ruby methods (fiber-unsafe), but they can still
+ // use guarded accessor devirt and pure-unary C# method caches.
+ var bic = new InlineContext(state, null, null, BlockEmit.AccessorRegistry, CurrentConstReturns, blockName, bsym, exe);
+ // The cast cache is per C# method; the block body is a separate method, so save the
+ // parent's and give the block a fresh one (restored after).
+ var savedAsObj = AsObjCache;
+ var savedAsCounter = AsObjCounter;
+ AsObjCache = new Dictionary();
+ AsObjCounter = 0;
+ // Same for the ivar-read CSE cache: the block is a separate C# method.
+ var savedIvarCache = ivarGetCache;
+ var savedIvCounter = ivCounter;
+ var savedReused = reusedIvarReads;
+ ivarGetCache = new Dictionary<(int, Symbol), string>();
+ ivCounter = 0;
+ reusedIvarReads = ComputeReusedIvarReads(exe, targets);
+ var body = new StringBuilder();
+ var ok = true;
+ for (var i = 0; i < instructions.Length; i++)
+ {
+ if (targets.Contains(i)) { body.Append(" L").Append(i).Append(": ;\n"); ClearAsObjCache(); ClearIvarGetCache(); }
+ if (!EmitInstruction(state, exe, instructions[i], bsym, body, ic: bic, sc: null, isLong, floatTaint, isDouble, provesDouble))
+ {
+ LastBail ??= "block-op:" + instructions[i].OpCode;
+ ok = false;
+ break;
+ }
+ InvalidateAsObj(instructions[i].Dst);
+ InvalidateIvarGet(instructions[i]);
+ }
+ AsObjCache = savedAsObj;
+ AsObjCounter = savedAsCounter;
+ ivarGetCache = savedIvarCache;
+ ivCounter = savedIvCounter;
+ reusedIvarReads = savedReused;
+ BlockEmit.Cells = savedCells;
+ BlockEmit.CurrentLevel = savedLevel;
+ BlockEmit.ForceSend = savedForce;
+ if (!ok) { CurrentStackObjects = savedStackObjects; StructCanonical = savedCanonical; CurrentConstLit = savedConstLit; CurrentScalarArrays = savedScalarArrays; CurrentScalarHashes = savedScalarHashes; CurrentHashKeyTags = savedHashKeyTags; CurrentProvesFixnum = savedBlkProvesFixnum; CurrentSoundProven = savedBlkSound; CurrentLocalSlot = savedBlkSlot; return null; }
+
+ var sb = new StringBuilder();
+ EmitSymbolFields(bsym, sb);
+ EmitInlineFields(bic, sb);
+ sb.Append("public static bool ").Append(blockName).Append("(global::ChibiRuby.MRubyState state, global::ChibiRuby.MRubyValue ").Append(Val(0));
+ if (blockArgc >= 1) sb.Append(", global::ChibiRuby.MRubyValue ").Append(Val(1));
+ foreach (var (scope, register) in cells) sb.Append(", ref global::ChibiRuby.MRubyValue ").Append(CellName(scope, register));
+ sb.Append(", out global::ChibiRuby.MRubyValue result)\n{\n");
+
+ // Locals for the block's own non-param value-ids (self is v0, params are v1..vBlockArgc).
+ // Captured variables are separate cell_s_r ref params, not value-ids. Stack objects are
+ // struct locals (declared separately below). Proven-numeric temps are raw long/double.
+ var blkBoxed = new List();
+ var blkLong = new List();
+ var blkDouble = new List();
+ for (var v = blockArgc + 1; v < exe.ValueCount; v++)
+ {
+ if (CurrentStackObjects is { } cso0 && cso0.ContainsKey(v)) continue;
+ (isDouble[v] ? blkDouble : isLong[v] ? blkLong : blkBoxed).Add(v);
+ }
+ if (blkBoxed.Count > 0)
+ {
+ sb.Append(" global::ChibiRuby.MRubyValue ");
+ for (var i = 0; i < blkBoxed.Count; i++) { if (i > 0) sb.Append(", "); sb.Append(Val(blkBoxed[i])).Append(" = default"); }
+ sb.Append(";\n");
+ }
+ if (blkLong.Count > 0)
+ {
+ sb.Append(" long ");
+ for (var i = 0; i < blkLong.Count; i++) { if (i > 0) sb.Append(", "); sb.Append('l').Append(blkLong[i]).Append(" = 0"); }
+ sb.Append(";\n");
+ }
+ if (blkDouble.Count > 0)
+ {
+ sb.Append(" double ");
+ for (var i = 0; i < blkDouble.Count; i++) { if (i > 0) sb.Append(", "); sb.Append('d').Append(blkDouble[i]).Append(" = 0"); }
+ sb.Append(";\n");
+ }
+ if (CurrentStackObjects is { } cso1)
+ {
+ var declaredStructs = new HashSet(); // canonical mutated aliases share one local
+ foreach (var (v, lay) in cso1)
+ if (v > blockArgc && declaredStructs.Add(Val(v)))
+ sb.Append(" ").Append(lay.StructType).Append(' ').Append(Val(v)).Append(" = default;\n");
+ }
+
+ EmitSymbolInit(bsym, sb);
+ sb.Append(body);
+ if (targets.Contains(instructions.Length)) sb.Append(" L").Append(instructions.Length).Append(": ;\n");
+ sb.Append(" result = default; return false;\n}\n");
+ CurrentStackObjects = savedStackObjects;
+ StructCanonical = savedCanonical;
+ CurrentConstLit = savedConstLit;
+ CurrentScalarArrays = savedScalarArrays;
+ CurrentScalarHashes = savedScalarHashes;
+ CurrentHashKeyTags = savedHashKeyTags;
+ CurrentProvesFixnum = savedBlkProvesFixnum;
+ CurrentSoundProven = savedBlkSound;
+ CurrentLocalSlot = savedBlkSlot;
+ return sb.ToString();
+ }
+
+ // Read value-id `id` as a double in a float context: the constant literal if known, else the
+ // boxed value's .FloatValue. (A Long-unboxed value never reaches a float path.)
+ static string FloatRead(int id) =>
+ ConstFloat(id, out var cv) ? DoubleLitText(cv) : "v" + Slot(id) + ".FloatValue";
+
+ // Guard asserting `id` is a float, or null for a known float constant (a non-float constant
+ // keeps `v.IsFloat`, which folds to false, so the float branch stays correctly unreachable).
+ static string? FloatGuard(int id) => ConstFloat(id, out _) ? null : "v" + Slot(id) + ".IsFloat";
+ static string FloatCond(params int[] ids)
+ {
+ var parts = new List();
+ foreach (var id in ids) if (FloatGuard(id) is { } g) parts.Add(g);
+ return Cond(parts);
+ }
+
+ // C# text for a double constant: a plain `ldc.r8` literal via round-trippable formatting, or the
+ // bit-exact reconstruction for NaN/Inf/non-roundtrippable values. (Shared with TryEmitLiteral.)
+ internal static string DoubleLitText(double d)
+ {
+ if (double.IsFinite(d))
+ {
+ var s = d.ToString("R", System.Globalization.CultureInfo.InvariantCulture);
+ if (double.TryParse(s, System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out var rt) &&
+ BitConverter.DoubleToInt64Bits(rt) == BitConverter.DoubleToInt64Bits(d))
+ {
+ return s + "D";
+ }
+ }
+ var bits = BitConverter.DoubleToInt64Bits(d);
+ return $"global::System.BitConverter.Int64BitsToDouble(unchecked((long)0x{(ulong)bits:x16}UL))";
+ }
+
+ // Read value-id `id` as a double in a PURE-double op (all operands provably Float). A raw
+ // `double` local reads directly; an unboxed long is a proven fixnum promoted to double; any
+ // other (provably-float but boxed, e.g. a literal/ivar seed) reads .FloatValue without a guard.
+ static string DoubleRead(bool[] isLong, bool[] isDouble, int id) =>
+ isDouble[id] ? "d" + Slot(id) : isLong[id] ? "(double)l" + Slot(id) : "v" + Slot(id) + ".FloatValue";
+
+ // Read value-id `id` as a boxed MRubyValue, re-boxing any unboxed (long/double) kind — for the
+ // return boundary, where a value of any representation crosses back into the boxed world.
+ static string BoxReadFull(bool[] isLong, bool[] isDouble, int id) =>
+ isDouble[id] ? Box("d" + Slot(id)) : isLong[id] ? Box("l" + Slot(id)) : Val(id);
+
+ // Q1.2: after a speculated float ivar/accessor read, assert it really is a Float; a miss deopts
+ // (safe — only seeded in side-effect-free methods, so re-running in the interpreter is harmless).
+ static void EmitFloatSpeculationGuard(StringBuilder body, bool[] provesDouble, int dst)
+ {
+ // Sound proofs (a proven-Float ivar read / send / literal) need no guard — only an actual
+ // SPECULATION (the pre-side-effect-window guess) does.
+ var sound = CurrentSoundProven;
+ if ((uint)dst < (uint)provesDouble.Length && provesDouble[dst] &&
+ !(sound is not null && (uint)dst < (uint)sound.Length && sound[dst]))
+ {
+ Line(body, $"if (!{Val(dst)}.IsFloat) {{ result = default; return false; }}");
+ }
+ }
+
+ static bool ProvesFixnum(int id) =>
+ CurrentProvesFixnum is { } pf && (uint)id < (uint)pf.Length && pf[id];
+
+ // Read id as a `double` in a guard-free mixed-numeric float expression: a proven Float via
+ // .FloatValue (or its float constant / raw d-local / (double)l-local), or a proven Fixnum via
+ // (double)v.FixnumValue (or (double) of its fixnum constant / l-local).
+ static string DoubleReadNumeric(bool[] isLong, bool[] isDouble, bool[] provesDouble, int id)
+ {
+ if (provesDouble[id]) return DoubleRead(isLong, isDouble, id);
+ if (ConstFix(id, out var cv)) return "(double)" + cv + "L";
+ if (isLong[id]) return "(double)l" + Slot(id);
+ return "(double)v" + Slot(id) + ".FixnumValue";
+ }
+
+ // True iff every operand is provably numeric (Float or Fixnum, in any representation) and at
+ // least one is provably Float — so `a OP b` is guard-free `double` arith under Ruby coercion.
+ static bool MixedNumericFloat(bool[] isLong, bool[] provesDouble, params int[] ids)
+ {
+ var anyFloat = false;
+ foreach (var id in ids)
+ {
+ var isFloat = provesDouble[id] || ConstFloat(id, out _);
+ var isFix = ProvesFixnum(id) || isLong[id] || ConstFix(id, out _);
+ if (!isFloat && !isFix) return false;
+ anyFloat |= isFloat;
+ }
+ return anyFloat;
+ }
+
+ static string True => "global::ChibiRuby.MRubyValue.True";
+ static string False => "global::ChibiRuby.MRubyValue.False";
+ static string Box(string expr) => $"new global::ChibiRuby.MRubyValue({expr})";
+
+ // Direct internal-runtime expressions the generated code emits (it has InternalsVisibleTo).
+ // Emit calls to the AotGeneratedMethods base-class helpers (inherited by the generated class),
+ // not raw internal access — so RObject.InstanceVariables can stay internal. The helpers are
+ // aggressively inlined, so this is free at runtime.
+ // recv must be an RObject-typed expression (a cached __ro local, a fresh `new RObject`, or
+ // `x.As()`). InstanceVariables is public, so emit the table access directly.
+ internal static string IvarGet(string recv, string symRef) =>
+ $"{recv}.InstanceVariables.Get({symRef})";
+ internal static string IvarSet(string recv, string symRef, string value) =>
+ $"{recv}.InstanceVariables.Set({symRef}, {value})";
+
+ // Read value-id `id` as a boxed MRubyValue (re-box an unboxed long) — for the slow-path
+ // Send, whose arguments must be boxed.
+ static string BoxRead(bool[] isLong, int id) => isLong[id] ? Box("l" + Slot(id)) : Val(id);
+
+ // C# string literal for an operator selector (+, -, *, /, <, ==, ...). All chars are
+ // printable ASCII with no quote/backslash, so no escaping is needed.
+ static string OpLit(string op) => $"\"{op}\"";
+
+ // Slow path for a numeric binary whose two fast branches (both-fixnum, both-float) missed.
+ // Two strategies, chosen by whether the op is genuinely mixed-type:
+ // - Send fallback (mixedType): a real Send to the operator. Keeps the method RUNNING — a
+ // deopt re-runs the whole method in the interpreter, double-applying any side effect
+ // already committed (e.g. an RNG that advanced its ivars before a `fixnum / float`).
+ // Used only when the operands have mismatched float-taint, i.e. the slow path actually
+ // fires every call. A Send is a call site, so emitting it on the HOT (always-fast)
+ // monomorphic ops would force the JIT to spill around a never-taken branch (~2x slower
+ // on optcarrot), hence:
+ // - deopt (return false) otherwise: the operands are same-typed (both fixnum, or both
+ // float), so this branch is cold/unreached in monomorphic code and re-execution is
+ // harmless. This is the original, fast behavior for integer/float-homogeneous methods.
+ static string NumericSlow(SymbolCache sym, bool[] isLong, bool mixedType, int dst, string op, int a, int b) =>
+ mixedType || (BlockEmit?.ForceSend ?? false)
+ ? $"{Val(dst)} = state.Send({BoxRead(isLong, a)}, {sym.Reference(OpLit(op))}, {BoxRead(isLong, b)});"
+ : "result = default; return false;";
+
+ // True when the op's operands carry mismatched float-taint — one is statically float, the
+ // other isn't — so the dual fast path provably misses every call (genuine fixnum/float mix).
+ static bool MixedTaint(bool[] floatTaint, int a, int b) => floatTaint[a] != floatTaint[b];
+ static bool MixedTaint(bool[] floatTaint, int a, int b, int c) =>
+ !(floatTaint[a] == floatTaint[b] && floatTaint[b] == floatTaint[c]);
+
+ // Generic numeric binary: runtime dual path. A Long-typed dst is float-untainted by
+ // construction, so it keeps the fixnum-only path. For a boxed dst, emit both-fixnum and
+ // both-float branches; the miss (mixed / non-numeric / fixnum div-by-zero) takes the slow
+ // path (Send for genuinely-mixed ops, deopt otherwise). The float branch is suppressed when
+ // any operand is an unboxed long (provably fixnum, so it can never pair with a float here).
+ static bool EmitNumericBinary(SymbolCache sym, StringBuilder body, in RubyIRInstruction ins, string op, bool[] isLong, bool[] floatTaint, bool[] isDouble, bool[] provesDouble, bool isDiv = false)
+ {
+ int a = ins.Src0, b = ins.Src1, d = ins.Dst;
+ // Pure-double: both operands provably Float -> guard-free raw-double arith. dst is a raw
+ // `double` if it too is double-unboxed, else re-boxed at this boundary.
+ if (provesDouble[a] && provesDouble[b])
+ {
+ var dexpr = $"{DoubleRead(isLong, isDouble, a)} {op} {DoubleRead(isLong, isDouble, b)}";
+ Line(body, isDouble[d] ? $"d{Slot(d)} = {dexpr};" : $"{Val(d)} = {Box(dexpr)};");
+ return true;
+ }
+ // Looping-method mixed numeric: one operand proven Float, the other proven Fixnum -> Ruby
+ // coerces to Float, so emit guard-free double arith (reading the fixnum operand as (double)).
+ if (CurrentProvesFixnum is not null && !isDiv && MixedNumericFloat(isLong, provesDouble, a, b))
+ {
+ var dexpr = $"{DoubleReadNumeric(isLong, isDouble, provesDouble, a)} {op} {DoubleReadNumeric(isLong, isDouble, provesDouble, b)}";
+ Line(body, isDouble[d] ? $"d{Slot(d)} = {dexpr};" : $"{Val(d)} = {Box(dexpr)};");
+ return true;
+ }
+ // Mixed numeric division: Float result iff a Float operand is present (Ruby), so the same
+ // guard-free double path applies — but only when NOT both-fixnum (integer division differs).
+ if (CurrentProvesFixnum is not null && isDiv && MixedNumericFloat(isLong, provesDouble, a, b))
+ {
+ var dexpr = $"{DoubleReadNumeric(isLong, isDouble, provesDouble, a)} / {DoubleReadNumeric(isLong, isDouble, provesDouble, b)}";
+ Line(body, isDouble[d] ? $"d{Slot(d)} = {dexpr};" : $"{Val(d)} = {Box(dexpr)};");
+ return true;
+ }
+ var fixExpr = $"{FixRead(isLong, a)} {op} {FixRead(isLong, b)}";
+ if (isLong[d])
+ {
+ EmitGuard(body, FixGuard(isLong, a), FixGuard(isLong, b),
+ isDiv ? $"{FixRead(isLong, b)} != 0" : null);
+ AssignFix(isLong, body, d, fixExpr);
+ return true;
+ }
+ var fixParts = new List();
+ if (FixGuard(isLong, a) is { } ga) fixParts.Add(ga);
+ if (FixGuard(isLong, b) is { } gb) fixParts.Add(gb);
+ if (isDiv) fixParts.Add($"{FixRead(isLong, b)} != 0");
+ var line = new StringBuilder(
+ $"if ({Cond(fixParts)}) {{ {Val(d)} = {Box(fixExpr)}; }}");
+ if (!isLong[a] && !isLong[b])
+ {
+ line.Append($" else if ({FloatCond(a, b)}) {{ {Val(d)} = {Box($"{FloatRead(a)} {op} {FloatRead(b)}")}; }}");
+ }
+ line.Append($" else {{ {NumericSlow(sym, isLong, MixedTaint(floatTaint, a, b), d, op, a, b)} }}");
+ Line(body, line.ToString());
+ return true;
+ }
+
+ // SubMul (reverse=true) is src2 - src0*src1; MulAdd/MulSub are src0*src1 +/- src2.
+ static bool EmitNumericFused(SymbolCache sym, StringBuilder body, in RubyIRInstruction ins, bool[] isLong, bool[] floatTaint, bool[] isDouble, bool[] provesDouble, string op, bool reverse)
+ {
+ int a = ins.Src0, b = ins.Src1, c = ins.Src2, d = ins.Dst;
+ // Pure-double fused multiply-add/sub: all three operands provably Float -> raw double.
+ if (provesDouble[a] && provesDouble[b] && provesDouble[c])
+ {
+ var prod = $"{DoubleRead(isLong, isDouble, a)} * {DoubleRead(isLong, isDouble, b)}";
+ var dexpr = reverse ? $"{DoubleRead(isLong, isDouble, c)} - {prod}" : $"{prod} {op} {DoubleRead(isLong, isDouble, c)}";
+ Line(body, isDouble[d] ? $"d{Slot(d)} = {dexpr};" : $"{Val(d)} = {Box(dexpr)};");
+ return true;
+ }
+ // Looping-method mixed numeric fused (>=1 Float, rest Fixnum) -> guard-free double, reading
+ // each fixnum operand as (double). `a*b OP c` / `c - a*b` is Float when any operand is Float.
+ if (CurrentProvesFixnum is not null && MixedNumericFloat(isLong, provesDouble, a, b, c))
+ {
+ var prod = $"{DoubleReadNumeric(isLong, isDouble, provesDouble, a)} * {DoubleReadNumeric(isLong, isDouble, provesDouble, b)}";
+ var cExpr = DoubleReadNumeric(isLong, isDouble, provesDouble, c);
+ var dexpr = reverse ? $"{cExpr} - {prod}" : $"{prod} {op} {cExpr}";
+ Line(body, isDouble[d] ? $"d{Slot(d)} = {dexpr};" : $"{Val(d)} = {Box(dexpr)};");
+ return true;
+ }
+ string Fixed()
+ {
+ var product = $"{FixRead(isLong, a)} * {FixRead(isLong, b)}";
+ return reverse ? $"{FixRead(isLong, c)} - {product}" : $"{product} {op} {FixRead(isLong, c)}";
+ }
+ if (isLong[d])
+ {
+ EmitGuard(body, FixGuard(isLong, a), FixGuard(isLong, b), FixGuard(isLong, c));
+ AssignFix(isLong, body, d, Fixed());
+ return true;
+ }
+ var fixParts = new List();
+ foreach (var id in new[] { a, b, c })
+ {
+ if (FixGuard(isLong, id) is { } g) fixParts.Add(g);
+ }
+ var line = new StringBuilder($"if ({Cond(fixParts)}) {{ {Val(d)} = {Box(Fixed())}; }}");
+ if (!isLong[a] && !isLong[b] && !isLong[c])
+ {
+ var fp = $"{FloatRead(a)} * {FloatRead(b)}";
+ var fexpr = reverse ? $"{FloatRead(c)} - {fp}" : $"{fp} {op} {FloatRead(c)}";
+ line.Append($" else if ({FloatCond(a, b, c)}) {{ {Val(d)} = {Box(fexpr)}; }}");
+ }
+ if (MixedTaint(floatTaint, a, b, c) || (BlockEmit?.ForceSend ?? false))
+ {
+ // Slow path: a*b then op c, both via Send (matches the unfused bytecode order).
+ var prod = $"state.Send({BoxRead(isLong, a)}, {sym.Reference(OpLit("*"))}, {BoxRead(isLong, b)})";
+ var slow = reverse
+ ? $"{Val(d)} = state.Send({BoxRead(isLong, c)}, {sym.Reference(OpLit(op))}, {prod});"
+ : $"{Val(d)} = state.Send({prod}, {sym.Reference(OpLit(op))}, {BoxRead(isLong, c)});";
+ line.Append($" else {{ {slow} }}");
+ }
+ else
+ {
+ line.Append(" else { result = default; return false; }");
+ }
+ Line(body, line.ToString());
+ return true;
+ }
+
+ static bool EmitFixnumImmediate(SymbolCache sym, StringBuilder body, RubyIRMethod exe, in RubyIRInstruction ins, string op, bool[] isLong)
+ {
+ var imm = exe.GetLiteral(ins.Aux);
+ if (!imm.IsFixnum)
+ {
+ // Float immediate (rare for AddI/SubI) -> let it bail so the method interprets.
+ return false;
+ }
+ int a = ins.Src0, d = ins.Dst;
+ var fixExpr = $"{FixRead(isLong, a)} {op} {imm.FixnumValue}L";
+ if (isLong[d])
+ {
+ EmitGuard(body, FixGuard(isLong, a));
+ AssignFix(isLong, body, d, fixExpr);
+ return true;
+ }
+ var fixParts = new List();
+ if (FixGuard(isLong, a) is { } ga) fixParts.Add(ga);
+ var line = new StringBuilder($"if ({Cond(fixParts)}) {{ {Val(d)} = {Box(fixExpr)}; }}");
+ if (!isLong[a])
+ {
+ // Float receiver + fixnum immediate -> Ruby coerces to float (e.g. 2.5 + 3 == 5.5).
+ line.Append($" else if ({FloatCond(a)}) {{ {Val(d)} = {Box($"{FloatRead(a)} {op} {imm.FixnumValue}d")}; }}");
+ }
+ // Both fixnum and float receivers are covered above, so the miss is non-numeric (rare).
+ // Under ForceSend (loop/block bodies) the miss must Send, not deopt: a deopt re-runs the
+ // whole method from the start, double-applying any side effect already committed earlier in
+ // the iteration. Otherwise deopt is fine (re-execution only matters when the miss fires).
+ if (BlockEmit?.ForceSend ?? false)
+ {
+ line.Append($" else {{ {Val(d)} = state.Send({BoxRead(isLong, a)}, {sym.Reference(OpLit(op))}, {Box($"{imm.FixnumValue}L")}); }}");
+ }
+ else
+ {
+ line.Append(" else { result = default; return false; }");
+ }
+ Line(body, line.ToString());
+ return true;
+ }
+
+ static bool EmitNumericCompare(SymbolCache sym, StringBuilder body, in RubyIRInstruction ins, string op, bool[] isLong, bool[] floatTaint, bool[] isDouble, bool[] provesDouble)
+ {
+ int a = ins.Src0, b = ins.Src1, d = ins.Dst; // dst is a bool result, never long
+ // Pure-double compare: both operands provably Float -> guard-free raw-double compare.
+ if (provesDouble[a] && provesDouble[b])
+ {
+ Line(body, $"{Val(d)} = ({DoubleRead(isLong, isDouble, a)} {op} {DoubleRead(isLong, isDouble, b)}) ? {True} : {False};");
+ return true;
+ }
+ // Looping-method mixed numeric compare (one Float, one Fixnum) -> guard-free double compare.
+ if (CurrentProvesFixnum is not null && MixedNumericFloat(isLong, provesDouble, a, b))
+ {
+ Line(body, $"{Val(d)} = ({DoubleReadNumeric(isLong, isDouble, provesDouble, a)} {op} {DoubleReadNumeric(isLong, isDouble, provesDouble, b)}) ? {True} : {False};");
+ return true;
+ }
+ var fixParts = new List();
+ if (FixGuard(isLong, a) is { } ga) fixParts.Add(ga);
+ if (FixGuard(isLong, b) is { } gb) fixParts.Add(gb);
+ var fixExpr = $"({FixRead(isLong, a)} {op} {FixRead(isLong, b)})";
+ var line = new StringBuilder(
+ $"if ({Cond(fixParts)}) {{ {Val(d)} = {fixExpr} ? {True} : {False}; }}");
+ if (!isLong[a] && !isLong[b])
+ {
+ line.Append($" else if ({FloatCond(a, b)}) {{ {Val(d)} = ({FloatRead(a)} {op} {FloatRead(b)}) ? {True} : {False}; }}");
+ }
+ line.Append($" else {{ {NumericSlow(sym, isLong, MixedTaint(floatTaint, a, b), d, op, a, b)} }}");
+ Line(body, line.ToString());
+ return true;
+ }
+
+ // Join guard parts with && for a branch condition; "true" when there are none (both
+ // operands unboxed long -> the fixnum branch is unconditional).
+ static string Cond(List parts) => parts.Count > 0 ? string.Join(" && ", parts) : "true";
+
+ // For a MUTATED (by-ref) stack object, its Move-copy aliases must share ONE struct local, or a
+ // mutation through one alias (`ref so90`) wouldn't be visible at a read through another (`so89`)
+ // — value copies don't alias. Map every mutated-object alias id to a canonical id (the min of
+ // the group, grouped by shared layout INSTANCE = object identity). Read-only objects keep
+ // separate value-copy aliases (cheaper, and correct since they're never mutated). Rebuilt
+ // whenever CurrentStackObjects is finalized.
+ [ThreadStatic] internal static Dictionary? StructCanonical;
+ internal static void RebuildStructCanonical()
+ {
+ StructCanonical = null;
+ if (CurrentStackObjects is not { Count: > 0 } cso) return;
+ var mut = new List();
+ foreach (var (id, lay) in cso) if (lay.Mutated) mut.Add(id);
+ if (mut.Count == 0) return;
+ var map = new Dictionary();
+ foreach (var id in mut)
+ {
+ var lay = cso[id];
+ var canon = id;
+ foreach (var other in mut) if (ReferenceEquals(cso[other], lay) && other < canon) canon = other;
+ map[id] = canon;
+ }
+ StructCanonical = map;
+ }
+
+ // Build a stack struct in place: each field <- its ctor arg, per FieldKind (Stage 1: all Boxed).
+ static void EmitStackConstruct(RubyIRMethod exe, in RubyIRInstruction ins, StackLayout lay, StringBuilder body)
+ {
+ // Any struct we BUILD needs its type declared, even if it is consumed by an inlined/spliced
+ // reader (no variant dispatch) rather than passed as an arg — TryEmitStackArgSend would
+ // otherwise be the only registrar and miss this object.
+ NeededStructs ??= new();
+ NeededStructs[lay.ClassFp] = lay;
+ var so = Val(ins.Dst);
+ for (var f = 0; f < lay.Fields.Count; f++)
+ {
+ // Nested field: build the inner struct in place (literal-filled, no heap alloc).
+ if (lay.FieldKinds[f] == StackFieldKind.Nested)
+ {
+ EmitNestedFill($"{so}.f{f}", lay.FieldNested[f]!, body);
+ continue;
+ }
+ // A literal-initialized field (FieldArg == -1) builds from the constant; otherwise
+ // from ctor arg FieldArg[f]. Boxed fields take the value as-is; ② unboxes.
+ var src = lay.FieldArg[f] < 0
+ ? (TryEmitLiteral(lay.FieldLiteral[f], out var lit) ? lit : "global::ChibiRuby.MRubyValue.Nil")
+ : Val(exe.GetCallSiteArgumentValueId(ins.Aux, lay.FieldArg[f]));
+ var rhs = lay.FieldKinds[f] switch
+ {
+ StackFieldKind.Double => src + ".FloatValue", // ②
+ StackFieldKind.Long => src + ".IntegerValue", // ②
+ _ => src,
+ };
+ Line(body, $"{so}.f{f} = {rhs};");
+ }
+ }
+
+ // Fill a (literal-only) nested struct field in place: `target.fj = ` for each inner
+ // field, recursing into deeper Nested fields. `lay` is a TryBuildNestedFill clone (all fields
+ // literal or Nested).
+ static void EmitNestedFill(string target, StackLayout lay, StringBuilder body)
+ {
+ for (var j = 0; j < lay.Fields.Count; j++)
+ {
+ if (lay.FieldKinds[j] == StackFieldKind.Nested)
+ {
+ EmitNestedFill($"{target}.f{j}", lay.FieldNested[j]!, body);
+ continue;
+ }
+ var lit = TryEmitLiteral(lay.FieldLiteral[j], out var l) ? l : "global::ChibiRuby.MRubyValue.Nil";
+ Line(body, lay.FieldKinds[j] switch
+ {
+ StackFieldKind.Double => $" {target}.f{j} = {lit}.FloatValue;",
+ StackFieldKind.Long => $" {target}.f{j} = {lit}.IntegerValue;",
+ _ => $" {target}.f{j} = {lit};",
+ });
+ }
+ }
+
+ // Materialize a stack struct into a real heap RObject (reify) — for escape/deopt fallback.
+ // Recursive: Nested fields reify inner structs, Double/Long re-box. Returns a C# MRubyValue
+ // expression; `rootLocal` is the top-level RObject local (for copy-back after a mutating Send).
+ internal static string EmitReify(MRubyState state, StackLayout lay, string structExpr, SymbolCache sym, StringBuilder body, ref int tmpCounter, out string rootLocal)
+ {
+ var tmp = "__reify" + tmpCounter++;
+ rootLocal = tmp;
+ Line(body, $"var {tmp} = new global::ChibiRuby.RObject(GetConstantUnsafe(state, {sym.Reference(StringLit(state, lay.ConstName))}).As());");
+ for (var f = 0; f < lay.Fields.Count; f++)
+ {
+ if (!TrySymbolStringLiteral(state, lay.Fields[f], out var fieldLit)) continue;
+ var fieldExpr = lay.FieldKinds[f] switch
+ {
+ StackFieldKind.Double => $"new global::ChibiRuby.MRubyValue({structExpr}.f{f})",
+ StackFieldKind.Long => $"new global::ChibiRuby.MRubyValue({structExpr}.f{f})",
+ StackFieldKind.Nested => EmitReify(state, lay.FieldNested[f]!, $"{structExpr}.f{f}", sym, body, ref tmpCounter, out _),
+ _ => $"{structExpr}.f{f}",
+ };
+ Line(body, $"{tmp}.InstanceVariables.Set({sym.Reference(fieldLit)}, {fieldExpr});");
+ }
+ return $"new global::ChibiRuby.MRubyValue({tmp})";
+ }
+
+ // Copy a (possibly mutated) heap RObject's fields back into a stack struct — the deopt/reify
+ // fallback for a by-`ref` mutated arg (A), so post-call reads of the struct see the Send's
+ // mutations. Mirror of EmitReify (heap -> struct). Nested fields recurse through the heap
+ // object's nested ivar. `roLocal` is an RObject-typed local.
+ internal static void EmitCopyBack(MRubyState state, StackLayout lay, string structExpr, string roLocal, SymbolCache sym, StringBuilder body, ref int tmpCounter)
+ {
+ for (var f = 0; f < lay.Fields.Count; f++)
+ {
+ if (!TrySymbolStringLiteral(state, lay.Fields[f], out _)) continue;
+ var get = $"{roLocal}.InstanceVariables.Get({sym.Reference(StringLit(state, lay.Fields[f]))})";
+ if (lay.FieldKinds[f] == StackFieldKind.Nested)
+ {
+ var inner = "__cb" + tmpCounter++;
+ Line(body, $"var {inner} = {get}.As();");
+ EmitCopyBack(state, lay.FieldNested[f]!, $"{structExpr}.f{f}", inner, sym, body, ref tmpCounter);
+ continue;
+ }
+ Line(body, lay.FieldKinds[f] switch
+ {
+ StackFieldKind.Double => $" {structExpr}.f{f} = {get}.FloatValue;",
+ StackFieldKind.Long => $" {structExpr}.f{f} = {get}.IntegerValue;",
+ _ => $" {structExpr}.f{f} = {get};",
+ });
+ }
+ }
+
+ static string StringLit(MRubyState state, Symbol s) => TrySymbolStringLiteral(state, s, out var lit) ? lit : "\"\"";
+
+ // In a variant: a trivial-accessor send on the struct param lowers to a struct-field read/write.
+ // A trivial accessor send whose RECEIVER is a stack object (a variant's struct param / its
+ // alias, or a block/method stack-allocated object) -> direct struct field read/write.
+ static bool TryEmitStackAccessor(MRubyState state, RubyIRMethod exe, in RubyIRInstruction ins, SymbolCache sym, StringBuilder body)
+ {
+ if (CurrentStackObjects is not { } cso || !cso.TryGetValue(ins.Src0, out var lay)) return false;
+ var sel = exe.GetCallSiteSymbol(ins.Aux);
+ if (!state.TryFindMethod(lay.Cls, sel, out var m, out _) || m.Proc is not { } pr) return false;
+ if (TryRecognizeTrivialAccessor(state, pr.Irep) is not { } acc) return false;
+ var fi = lay.FieldIndexOf(acc.Field);
+ if (fi < 0) return false;
+ var so = Val(ins.Src0); // the receiver's struct local
+ if (acc.IsSetter)
+ {
+ if (!lay.Mutated) return false; // read-only `in` param can't be written (Stage 1)
+ var arg = exe.GetCallSiteArgumentValueId(ins.Aux, 0);
+ if (lay.FieldKinds[fi] == StackFieldKind.Nested)
+ {
+ if (cso.ContainsKey(arg))
+ {
+ Line(body, $"{so}.f{fi} = {Val(arg)};"); // arg is itself a stack struct -> value copy
+ }
+ else
+ {
+ // arg is a heap object (e.g. a computed Vec) -> copy its fields into the nested
+ // struct field. Block-scoped temps so repeated setters don't collide.
+ Line(body, "{");
+ Line(body, $" var __ci = {Val(arg)}.As();");
+ var t = 0;
+ EmitCopyBack(state, lay.FieldNested[fi]!, $"{so}.f{fi}", "__ci", sym, body, ref t);
+ Line(body, "}");
+ }
+ Line(body, $"{Val(ins.Dst)} = {Val(arg)};");
+ return true;
+ }
+ var rhs = lay.FieldKinds[fi] switch
+ {
+ StackFieldKind.Double => Val(arg) + ".FloatValue",
+ StackFieldKind.Long => Val(arg) + ".IntegerValue",
+ _ => Val(arg),
+ };
+ Line(body, $"{so}.f{fi} = {rhs};");
+ Line(body, $"{Val(ins.Dst)} = {Val(arg)};");
+ return true;
+ }
+ if (lay.FieldKinds[fi] == StackFieldKind.Nested)
+ {
+ if (cso.ContainsKey(ins.Dst))
+ {
+ Line(body, $"{Val(ins.Dst)} = {so}.f{fi};"); // dst tracked as a stack struct -> value copy
+ }
+ else
+ {
+ // dst is boxed -> reify the nested struct into a heap object. Block-scoped temps.
+ Line(body, "{");
+ var t = 0;
+ var reified = EmitReify(state, lay.FieldNested[fi]!, $"{so}.f{fi}", sym, body, ref t, out _);
+ Line(body, $" {Val(ins.Dst)} = {reified};");
+ Line(body, "}");
+ }
+ return true;
+ }
+ var read = lay.FieldKinds[fi] switch
+ {
+ StackFieldKind.Double => $"new global::ChibiRuby.MRubyValue({so}.f{fi})",
+ StackFieldKind.Long => $"new global::ChibiRuby.MRubyValue({so}.f{fi})",
+ _ => $"{so}.f{fi}",
+ };
+ Line(body, $"{Val(ins.Dst)} = {read};");
+ return true;
+ }
+
+
+ // Assemble the full C# method source for one analyzed Ruby method: init the emit-only caches,
+ // walk the instructions (EmitInstruction) into the body, then wrap it in the entry/inline-form
+ // signature with arg marshalling + local declarations. Returns null when an op bails (LastBail
+ // set); isLeaf reports whether the body makes no outbound Ruby call. Reads the per-method
+ // analysis facts off Mrb2CsCompiler's state (CurrentScalarArrays/Hashes/StackObjects).
+ internal static string? EmitMethod(
+ MRubyState state, RubyIRMethod ir, Irep irep, string methodName,
+ IReadOnlyDictionary? inlineRegistry,
+ IReadOnlyDictionary? accessorRegistry,
+ SymbolCache sym, InlineContext ic, ScalarContext? sc,
+ Dictionary? structParams,
+ bool[] isLong, bool[] floatTaint, bool[] isDouble, bool[] provesDouble,
+ HashSet targets, List? loopArgGuards, bool looping, int argCount,
+ out bool isLeaf)
+ {
+ var instructions = ir.Instructions;
+ BlockEmit = new BlockEmitState { OwnerName = methodName, AccessorRegistry = accessorRegistry, ForceSend = looping };
+ AsObjCache = new Dictionary();
+ AsObjCounter = 0;
+ ivarGetCache = new Dictionary<(int, Symbol), string>();
+ ivCounter = 0;
+ reusedIvarReads = ComputeReusedIvarReads(ir, targets);
+ var body = new StringBuilder();
+ // Loop arg-type guards: assert each numerically-used arg is a Fixnum at method entry, before
+ // any instruction runs (no side effect committed yet -> a miss deopts safely to the
+ // interpreter). This lets the lattice type those args Fixnum, unboxing mixed int/float arith.
+ if (loopArgGuards is { Count: > 0 })
+ {
+ foreach (var argId in loopArgGuards)
+ body.Append(" if (!").Append(Val(argId)).Append(".IsFixnum) { result = default; return false; }\n");
+ }
+ isLeaf = true;
+ for (var i = 0; i < instructions.Length; i++)
+ {
+ // Accessor sends on a scalar-replaced object lower to field reads, not real calls,
+ // so they don't make the method non-leaf.
+ if (IsSendOp(instructions[i].OpCode) &&
+ !(sc?.IsAccessorSendOnScalar(instructions[i]) ?? false))
+ {
+ isLeaf = false;
+ }
+ if (targets.Contains(i))
+ {
+ body.Append(" L").Append(i).Append(": ;\n");
+ ClearAsObjCache(); // control-flow join: a cached cast may not reach here
+ ClearIvarGetCache(); // ...nor a cached ivar read
+ }
+ if (!EmitInstruction(state, ir, instructions[i], sym, body, ic, sc, isLong, floatTaint, isDouble, provesDouble))
+ {
+ LastBail ??= "op:" + instructions[i].OpCode;
+ return null;
+ }
+ InvalidateAsObj(instructions[i].Dst); // the value-id was just reassigned
+ InvalidateIvarGet(instructions[i]); // a write/call stales cached ivar reads
+ }
+
+ var sb = new StringBuilder();
+ EmitSymbolFields(sym, sb);
+ EmitInlineFields(ic, sb);
+ if (sc is not null) EmitScalarFields(sc, sb);
+
+ // Inline form: self (v0) + mandatory args (v1..vN) are parameters, so an inlining
+ // caller can call it frameless with values (no stack/CallInfo). Remaining value-ids
+ // are locals (default-init for goto / SSA-merge). The arity check lives in the wrapper.
+ // A stack-struct VARIANT (structParams set) is named directly (no __inline/wrapper) and
+ // takes its struct parameters as `in/ref ` — called C#->C# from the caller.
+ var isVariant = structParams is not null;
+ // The frameless `__inline` form (self+args as C# params, callable without a frame) is emitted
+ // only when some self-send actually inlines this method — i.e. its fingerprint is in the inline
+ // registry AND it isn't a trivial accessor (those devirtualize to direct field access at every
+ // call site, self or cross-object, so they're never `__inline`-called). Otherwise the irep
+ // wrapper IS the body (args marshalled into locals), with no redundant `__inline` + forward.
+ // `__inline` is a misnomer for "frameless direct-call form": the C# JIT, not this codegen, is
+ // what actually inlines it into a caller; emitting it for non-targets was pure dead weight.
+ var selfFp = isVariant ? 0UL : state.ComputeIrepFingerprint(irep);
+ var needsInlineForm = !isVariant && inlineRegistry is not null && inlineRegistry.ContainsKey(selfFp)
+ && !(AccessorFingerprints?.Contains(selfFp) ?? false);
+ var merged = !isVariant && !needsInlineForm;
+ var entryName = needsInlineForm ? methodName + "__inline" : methodName;
+ sb.Append("public static bool ").Append(entryName).Append("(global::ChibiRuby.MRubyState state");
+ if (merged)
+ {
+ // Single irep-bound method: check arity, marshal self/args off the frame into locals, run body.
+ sb.Append(", int sp, out global::ChibiRuby.MRubyValue result)\n{\n");
+ sb.Append(" if (ArgumentCountUnsafe(state) != ").Append(argCount).Append(") { result = default; return false; }\n");
+ for (var v = 0; v <= argCount; v++)
+ {
+ sb.Append(" global::ChibiRuby.MRubyValue ").Append(Val(v))
+ .Append(" = RegisterUnsafe(state, sp, ").Append(v).Append(");\n");
+ }
+ }
+ else
+ {
+ for (var v = 0; v <= argCount; v++)
+ {
+ if (isVariant && structParams!.TryGetValue(v, out var play))
+ {
+ sb.Append(play.Mutated ? ", ref " : ", in ")
+ .Append(play.StructType).Append(' ').Append(Val(v));
+ }
+ else
+ {
+ sb.Append(", global::ChibiRuby.MRubyValue ").Append(Val(v));
+ }
+ }
+ sb.Append(", out global::ChibiRuby.MRubyValue result)\n{\n");
+ }
+
+ // Non-arg value-ids: boxed ones as MRubyValue locals, unboxed ones as raw long.
+ // Scalar-replaced object value-ids are never materialized (their fields are locals).
+ // Stack objects are struct locals (declared separately below).
+ var boxedLocals = new List();
+ var longLocals = new List();
+ var doubleLocals = new List();
+ var stackLocals = new List();
+ for (var v = argCount + 1; v < ir.ValueCount; v++)
+ {
+ if (sc is not null && sc.IsScalar(v)) continue;
+ if (CurrentScalarArrays is { } sa && sa.ContainsKey(v)) continue; // replaced by element locals
+ if (CurrentScalarHashes is { } sh && sh.ContainsKey(v)) continue; // replaced by key locals
+ if (CurrentStackObjects is { } cso && cso.ContainsKey(v)) { stackLocals.Add(v); continue; }
+ if (Slot(v) != v) continue; // coalesced into another local's storage
+ (isDouble[v] ? doubleLocals : isLong[v] ? longLocals : boxedLocals).Add(v);
+ }
+ // Per-element locals for scalar-replaced array literals (boxed; array elements are dynamic).
+ // One declaration per CANONICAL array (aliases share its element locals).
+ if (CurrentScalarArrays is { Count: > 0 } scalarArrays)
+ {
+ var declaredArrays = new HashSet();
+ foreach (var (_, info) in scalarArrays)
+ {
+ if (!declaredArrays.Add(info.Canon)) continue;
+ sb.Append(" global::ChibiRuby.MRubyValue ");
+ for (var k = 0; k < info.Size; k++)
+ {
+ if (k > 0) sb.Append(", ");
+ sb.Append(ArrElem(info.Canon, k)).Append(" = default");
+ }
+ sb.Append(";\n");
+ }
+ }
+ // Per-key locals for scalar-replaced hashes (one per distinct key, init nil = Hash default).
+ if (CurrentScalarHashes is { Count: > 0 } scalarHashes)
+ {
+ var declaredHashes = new HashSet();
+ foreach (var (_, info) in scalarHashes)
+ {
+ if (!declaredHashes.Add(info.Canon) || info.Keys.Count == 0) continue;
+ sb.Append(" global::ChibiRuby.MRubyValue ");
+ var first = true;
+ foreach (var tag in info.Keys)
+ {
+ if (!first) sb.Append(", ");
+ first = false;
+ sb.Append(HashElem(info.Canon, tag)).Append(" = global::ChibiRuby.MRubyValue.Nil");
+ }
+ sb.Append(";\n");
+ }
+ }
+ if (boxedLocals.Count > 0)
+ {
+ sb.Append(" global::ChibiRuby.MRubyValue ");
+ for (var i = 0; i < boxedLocals.Count; i++)
+ {
+ if (i > 0) sb.Append(", ");
+ sb.Append(Val(boxedLocals[i])).Append(" = default");
+ }
+ sb.Append(";\n");
+ }
+ if (longLocals.Count > 0)
+ {
+ sb.Append(" long ");
+ for (var i = 0; i < longLocals.Count; i++)
+ {
+ if (i > 0) sb.Append(", ");
+ sb.Append("l").Append(longLocals[i]).Append(" = 0");
+ }
+ sb.Append(";\n");
+ }
+ if (doubleLocals.Count > 0)
+ {
+ sb.Append(" double ");
+ for (var i = 0; i < doubleLocals.Count; i++)
+ {
+ if (i > 0) sb.Append(", ");
+ sb.Append("d").Append(doubleLocals[i]).Append(" = 0");
+ }
+ sb.Append(";\n");
+ }
+
+ if (sc is not null) EmitScalarFieldLocals(sc, sb);
+
+ var declaredStructs = new HashSet(); // canonical mutated aliases share one local
+ // A struct param is declared in the signature; a local that canonicalizes to it (a mutated
+ // param's alias) must NOT be redeclared.
+ if (structParams is not null)
+ foreach (var pidx in structParams.Keys) declaredStructs.Add(Val(pidx));
+ foreach (var v in stackLocals)
+ {
+ var name = Val(v);
+ if (!declaredStructs.Add(name)) continue;
+ sb.Append(" ").Append(CurrentStackObjects![v].StructType).Append(' ').Append(name).Append(" = default;\n");
+ }
+
+ EmitSymbolInit(sym, sb);
+ sb.Append(body);
+
+ if (targets.Contains(instructions.Length))
+ {
+ sb.Append(" L").Append(instructions.Length).Append(": ;\n");
+ }
+ sb.Append(" result = default; return false;\n");
+ sb.Append("}\n");
+
+ // Only the split `__inline` form needs a separate wrapper. Variants are called directly
+ // C#->C# (never irep-keyed); merged methods already are the irep-bound entry.
+ if (needsInlineForm)
+ {
+ // Stack wrapper: bound to the irep by fingerprint (Irep.CompiledBody). Checks arity,
+ // marshals self/args off the frame, and calls the inline form.
+ sb.Append("public static bool ").Append(methodName)
+ .Append("(global::ChibiRuby.MRubyState state, int sp, out global::ChibiRuby.MRubyValue result)\n{\n");
+ sb.Append(" if (ArgumentCountUnsafe(state) != ").Append(argCount)
+ .Append(") { result = default; return false; }\n");
+ sb.Append(" return ").Append(methodName).Append("__inline(state");
+ for (var v = 0; v <= argCount; v++)
+ {
+ sb.Append(", RegisterUnsafe(state, sp, ").Append(v).Append(")");
+ }
+ sb.Append(", out result);\n}\n");
+ }
+
+ return sb.ToString();
+ }
+
+ // Assemble the generated source file: the auto-generated header, an optional block-scoped
+ // namespace, the AotGeneratedMethods-derived class, the stack struct declarations the methods
+ // use, then the method sources. `structs` is the transitive set the driver gathered from
+ // NeededStructs (nested field types included); `sources` are the per-method bodies + aux methods.
+ internal static string EmitProgram(string className, string? namespaceName,
+ IEnumerable structs, IReadOnlyList sources)
+ {
+ var sb = new StringBuilder();
+ // Roslyn/csc honor `// ` to suppress analyzers & StyleCop on the file.
+ sb.Append("// \n");
+ sb.Append("// Generated by ChibiRuby mrb2cs (ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler.Compile). Do not edit.\n");
+ // Block-scoped namespace (not file-scoped `namespace X;`, which is C# 10) so the generated
+ // source compiles under C# 9 — required for Unity / IL2CPP.
+ var hasNamespace = namespaceName is { Length: > 0 };
+ if (hasNamespace) sb.Append("namespace ").Append(namespaceName).Append("\n{\n");
+ sb.Append("public sealed class ").Append(className).Append(" : global::ChibiRuby.AotGeneratedMethods\n{\n");
+ // Stack struct types declared before the methods that use them.
+ foreach (var lay in structs)
+ {
+ sb.Append("public struct ").Append(lay.StructType).Append(" { ");
+ for (var f = 0; f < lay.Fields.Count; f++) sb.Append("public ").Append(lay.CsFieldType(f)).Append(" f").Append(f).Append("; ");
+ sb.Append("}\n");
+ }
+ foreach (var s in sources) sb.Append(s).Append('\n');
+ sb.Append("}\n");
+ if (hasNamespace) sb.Append("}\n");
+ return sb.ToString();
+ }
+
+ // Emit a Symbol's name as a C# string literal. Covers ivar names
+ // (@x) and method names incl. operators ([], +, !=, %). Bails on empty or any
+ // non-printable / non-ASCII byte.
+ internal static bool TrySymbolStringLiteral(MRubyState state, Symbol sym, out string stringLiteral)
+ {
+ var name = state.NameOf(sym).AsSpan();
+ if (name.Length == 0)
+ {
+ stringLiteral = "";
+ return false;
+ }
+ var sb = new StringBuilder("\"");
+ foreach (var b in name)
+ {
+ if (b < 0x20 || b > 0x7E)
+ {
+ stringLiteral = "";
+ return false;
+ }
+ if (b == (byte)'"' || b == (byte)'\\')
+ {
+ sb.Append('\\');
+ }
+ sb.Append((char)b);
+ }
+ sb.Append('"');
+ stringLiteral = sb.ToString();
+ return true;
+ }
+
+ // C# string literal for a class's registered name (used to re-resolve the class at
+ // runtime via a top-level constant in ResolveGuardClassUnsafe). Fails on non-printable bytes
+ // or a `::`-qualified path (the runtime resolver only walks top-level constants — a
+ // nested candidate simply isn't class-switched and falls through to reify+Send).
+ internal static bool TryClassNameLiteral(MRubyState state, RClass c, out string stringLiteral)
+ {
+ var name = state.NameOf(c).AsSpan();
+ if (name.Length == 0)
+ {
+ stringLiteral = "";
+ return false;
+ }
+ var sb = new StringBuilder("\"");
+ foreach (var b in name)
+ {
+ if (b < 0x20 || b > 0x7E || b == (byte)':')
+ {
+ stringLiteral = "";
+ return false;
+ }
+ if (b == (byte)'"' || b == (byte)'\\')
+ {
+ sb.Append('\\');
+ }
+ sb.Append((char)b);
+ }
+ sb.Append('"');
+ stringLiteral = sb.ToString();
+ return true;
+ }
+
+ internal static void Line(StringBuilder body, string text) => body.Append(" ").Append(text).Append('\n');
+
+ // ---- Name mangling: Ruby names/fingerprints -> C# identifiers (emitted into the source) ----
+ // Maps an arbitrary Ruby name (a symbol, method, or class name) to a snake_case C# identifier
+ // fragment: alphanumeric runs are kept as-is, and each character that isn't legal in a C#
+ // identifier is spelled out as a lowercase word, with segments joined by `_`
+ // (e.g. `@x` -> `at_x`, `empty?` -> `empty_q`, `<=>` -> `lt_eq_gt`). Returns the body only — no
+ // leading separator — so the caller attaches it (symbol slots / method / class names prefix `_`).
+ internal static string SanitizeToIdentifier(string raw)
+ {
+ var segments = new List();
+ var run = new StringBuilder();
+ foreach (var c in raw)
+ {
+ if (c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_')
+ {
+ run.Append(c);
+ continue;
+ }
+ FlushRun();
+ var word = c switch
+ {
+ '@' => "at",
+ '?' => "q",
+ '!' => "bang",
+ '=' => "eq",
+ '<' => "lt",
+ '>' => "gt",
+ '+' => "plus",
+ '-' => "minus",
+ '*' => "star",
+ '/' => "slash",
+ '%' => "percent",
+ '[' => "lbracket",
+ ']' => "rbracket",
+ '&' => "amp",
+ '|' => "pipe",
+ '^' => "caret",
+ '~' => "tilde",
+ ' ' => "",
+ _ => "u" + ((int)c).ToString("x2"),
+ };
+ if (word.Length > 0) segments.Add(word);
+ }
+ FlushRun();
+ return string.Join("_", segments);
+
+ void FlushRun()
+ {
+ if (run.Length > 0)
+ {
+ segments.Add(run.ToString()); run.Clear();
+
+ }
+ }
+ }
+
+ // A `_name` identifier suffix for a Ruby name, or "" when the name sanitizes to nothing
+ // (so an unnamed/anonymous entity just keeps its fingerprint-only base name).
+ internal static string NameSuffixFor(string raw)
+ {
+ var body = SanitizeToIdentifier(raw);
+ return body.Length == 0 ? "" : "_" + body;
+ }
+
+ // The C# name for a compiled method body, derived from its irep fingerprint plus (when known)
+ // the original Ruby method name. Used both where the body is DEFINED and at every inline/variant
+ // call site, so they always agree.
+ internal static string MethodCsName(ulong fp)
+ {
+ var name = "M_" + fp.ToString("x16");
+ if (MethodNameSuffixes is { } m && m.TryGetValue(fp, out var suffix)) name += suffix;
+ return name;
+ }
+
+ // Variant name suffix for a set of struct params: sorted `__s[r]_` per param.
+ internal static string StructParamSuffix(Dictionary structParams)
+ {
+ var sb = new StringBuilder();
+ foreach (var idx in new List(structParams.Keys).OrderBy(x => x))
+ sb.Append("__s").Append(idx).Append(structParams[idx].Mutated ? "r" : "")
+ .Append('_').Append(structParams[idx].ClassFp.ToString("x16"));
+ return sb.ToString();
+ }
+
+
+ // ---- SymbolCache emission: per-method interned-symbol static fields + the intern prologue.
+ // SymbolCache owns the data (slot -> field name / literal); these turn it into C# source.
+
+ // Class-scope: per-symbol UTF-8 name bytes + state cache field + one Symbol field per slot.
+ internal static void EmitSymbolFields(SymbolCache sym, StringBuilder sb)
+ {
+ if (sym.Count == 0) return;
+ // UTF-8 name bytes, statically initialized. We avoid both a UTF-16 string literal in
+ // the generated assembly and the per-state UTF-16->UTF-8 reencode `Intern(string)` does.
+ // (`"name"u8` would be ideal but doesn't work under Unity/IL2CPP, so emit an explicit
+ // `new byte[] { ... }`.)
+ for (var i = 0; i < sym.Count; i++)
+ {
+ sb.Append("static readonly byte[] ").Append(Utf8Field(sym.FieldNames[i])).Append(" = ")
+ .Append(Utf8ArrayLiteral(sym.Literals[i])).Append("; // ").Append(CommentText(RawName(sym.Literals[i]))).Append('\n');
+ }
+ sb.Append("static global::ChibiRuby.MRubyState ").Append(sym.MethodName).Append("__symState;\n");
+ sb.Append("static global::ChibiRuby.Symbol ");
+ for (var i = 0; i < sym.Count; i++)
+ {
+ if (i > 0) sb.Append(", ");
+ sb.Append(sym.FieldNames[i]);
+ }
+ sb.Append(";\n");
+ }
+
+ // Method-prologue: (re)intern all symbols when the state changed (once per state). Interns
+ // from the static UTF-8 bytes (ReadOnlySpan overload), not a UTF-16 string.
+ internal static void EmitSymbolInit(SymbolCache sym, StringBuilder sb)
+ {
+ if (sym.Count == 0) return;
+ sb.Append(" if (!object.ReferenceEquals(").Append(sym.MethodName).Append("__symState, state)) { ");
+ for (var i = 0; i < sym.Count; i++)
+ {
+ sb.Append(sym.FieldNames[i]).Append(" = state.Intern(").Append(Utf8Field(sym.FieldNames[i])).Append("); ");
+ }
+ sb.Append(sym.MethodName).Append("__symState = state; }\n");
+ }
+
+ static string Utf8Field(string fieldName) => fieldName + "_u8";
+
+ // `new byte[] { .. }` of the symbol's UTF-8 bytes, for `state.Intern(ReadOnlySpan)`.
+ static string Utf8ArrayLiteral(string stringLiteral)
+ {
+ var bytes = Encoding.UTF8.GetBytes(RawName(stringLiteral));
+ var sb = new StringBuilder("new byte[] { ");
+ for (var i = 0; i < bytes.Length; i++)
+ {
+ if (i > 0) sb.Append(", ");
+ sb.Append(bytes[i]);
+ }
+ return sb.Append(" }").ToString();
+ }
+
+ // The raw symbol name from a C# string literal: strip the outer quotes and undo the
+ // `\"`/`\\` escapes that TrySymbolStringLiteral introduced.
+ internal static string RawName(string stringLiteral)
+ {
+ var inner = stringLiteral is ['"', _, ..] && stringLiteral[^1] == '"'
+ ? stringLiteral.Substring(1, stringLiteral.Length - 2)
+ : stringLiteral;
+ var sb = new StringBuilder();
+ for (var i = 0; i < inner.Length; i++)
+ {
+ var c = inner[i];
+ if (c == '\\' && i + 1 < inner.Length) c = inner[++i]; // unescape \" and \\
+ sb.Append(c);
+ }
+ return sb.ToString();
+ }
+
+ // The original symbol text, made safe for a `//` line comment (no embedded newlines).
+ static string CommentText(string raw) => raw.Replace("\r", " ").Replace("\n", " ");
+
+ // ==== InlineContext emission (moved out of InlineContext; bodies unchanged, ic.* aliased) ====
+
+ // A constant read (`OP_GetConst`) cached per call site: resolve once, then re-resolve only
+ // when ConstCacheVersion changes. dst <- the constant's value.
+ internal static void EmitGuardedConstantRead(InlineContext ic, in RubyIRInstruction ins, string cname, StringBuilder body)
+ {
+ var state = ic.state;
+ var methodName = ic.methodName;
+ var sym = ic.sym;
+ ref var constReadCount = ref ic.constReadCount;
+ var n = constReadCount++;
+ var valField = methodName + "__gc" + n + "val";
+ var verField = methodName + "__gc" + n + "ver";
+ var stateField = methodName + "__gc" + n + "state";
+ Line(body, $"{Val(ins.Dst)} = GetConstantCachedUnsafe(state, {sym.Reference(cname)}, ref {valField}, ref {verField}, ref {stateField});");
+ }
+
+ // Caller side: a Send whose argument is a stack-allocated object. Dispatch to the callee's
+ // struct variant (guarded per receiver class) and reify on miss/deopt. Stage 1 handles a
+ // single struct arg (ao's ray into intersect); other shapes return false (normal path).
+ internal static bool TryEmitStackArgSend(InlineContext ic, in RubyIRInstruction ins, StringBuilder body)
+ {
+ var state = ic.state;
+ var methodName = ic.methodName;
+ var sym = ic.sym;
+ var exe = ic.exe;
+ ref var candGroupCount = ref ic.candGroupCount;
+ var candGroupSizes = ic.candGroupSizes;
+ if (CurrentStackObjects is not { } cso || CurrentEscapeSummary is not { } summary) return false;
+ if (ins.OpCode is not (RubyIROpCode.Send or RubyIROpCode.SendSelf)) return false;
+ // The receiver must not itself be a stack object (that is the struct-receiver path).
+ if (cso.ContainsKey(ins.Src0)) return false;
+ var argc = exe.GetCallSiteArgumentCount(ins.Aux);
+ // Gather ALL struct args (each lowers to a struct param of the variant; in/ref by Mutated).
+ var sargs = new List<(int Pos, int ObjId, StackLayout Lay)>();
+ for (var a = 0; a < argc; a++)
+ {
+ var aid = exe.GetCallSiteArgumentValueId(ins.Aux, a);
+ if (cso.TryGetValue(aid, out var alay)) sargs.Add((a, aid, alay));
+ }
+ if (sargs.Count == 0) return false;
+ var sel = exe.GetCallSiteSymbol(ins.Aux);
+ if (!TrySymbolStringLiteral(state, sel, out var selName)) return false;
+ var defs = summary.DefiningClasses(sel);
+ if (defs.Count == 0) return false;
+
+ // The variant's struct params: paramIndex (pos+1, v0 is self) -> layout.
+ var structParamsMap = new Dictionary();
+ foreach (var sa in sargs) structParamsMap[sa.Pos + 1] = sa.Lay;
+ var suffix = StructParamSuffix(structParamsMap);
+
+ var cands = new List<(RClass Cls, string NameLit, ulong Fp, string VariantName)>();
+ foreach (var c in defs)
+ {
+ if (!state.TryFindMethod(c, sel, out var m, out _) || m.Proc is not { } proc) continue;
+ if (!TryClassNameLiteral(state, c, out var nameLit)) continue;
+ var fp = state.ComputeIrepFingerprint(proc.Irep);
+ cands.Add((c, nameLit, fp, MethodCsName(fp) + suffix));
+ }
+ if (cands.Count == 0) return false;
+
+ NeededStructs ??= new Dictionary(); NeededVariants ??= new();
+ foreach (var sa in sargs) NeededStructs[sa.Lay.ClassFp] = sa.Lay;
+ foreach (var cand in cands)
+ NeededVariants[cand.VariantName] = (cand.Cls, sel, structParamsMap);
+
+ var recv = Val(ins.Src0);
+ var dst = Val(ins.Dst);
+ var symRef = sym.Reference(selName);
+ var pos2sa = new Dictionary();
+ foreach (var sa in sargs) pos2sa[sa.Pos] = sa;
+ var aux = ins.Aux; // can't capture the `in` param in the local function below
+ // Build the arg list: struct args use `structAt(sa)`, the rest are plain value reads.
+ string Args(System.Func<(int Pos, int ObjId, StackLayout Lay), string> structAt)
+ {
+ var b = new StringBuilder();
+ for (var a = 0; a < argc; a++)
+ b.Append(", ").Append(pos2sa.TryGetValue(a, out var sa) ? structAt(sa) : Val(exe.GetCallSiteArgumentValueId(aux, a)));
+ return b.ToString();
+ }
+
+ // Class-switch dispatch: resolve candidate classes once per method-cache version into
+ // per-site static fields, then pointer-compare the receiver's class.
+ var grp = candGroupCount++;
+ candGroupSizes.Add(cands.Count);
+ var verField = methodName + "__cand" + grp + "ver";
+ string ClsField(int i) => methodName + "__cand" + grp + "_" + i + "cls";
+
+ Line(body, $"if ({verField} != state.MethodCacheVersion) {{");
+ for (var i = 0; i < cands.Count; i++)
+ Line(body, $" {ClsField(i)} = ResolveGuardClassUnsafe(state, {sym.Reference(cands[i].NameLit)}, {symRef}, 0x{cands[i].Fp:x16}UL);");
+ Line(body, $" {verField} = state.MethodCacheVersion;");
+ Line(body, "}");
+
+ var done = "__sdone" + grp;
+ var rc = "__src" + grp;
+ // Snapshot each mutated (ref) struct arg, so the reify fallback can restore the pre-call
+ // state (a variant may partially mutate it, then deopt). (A)
+ foreach (var sa in sargs) if (sa.Lay.Mutated) Line(body, $"var __snap{grp}_{sa.ObjId} = {Val(sa.ObjId)};");
+ Line(body, $"bool {done} = false;");
+ Line(body, $"var {rc} = {recv}.Object?.Class;");
+ for (var i = 0; i < cands.Count; i++)
+ {
+ var kw = i == 0 ? "if" : "else if";
+ var callArgs = Args(sa => (sa.Lay.Mutated ? "ref " : "in ") + Val(sa.ObjId));
+ Line(body, $"{kw} ({rc} != null && {rc} == {ClsField(i)}) {{ if ({cands[i].VariantName}(state, {recv}{callArgs}, out {dst})) {done} = true; }}");
+ }
+ // Reify + normal Send on unknown class / variant deopt: restore ref args, reify each
+ // struct arg, Send, then copy back the mutated heap objects into their structs.
+ Line(body, $"if (!{done}) {{");
+ foreach (var sa in sargs) if (sa.Lay.Mutated) Line(body, $"{Val(sa.ObjId)} = __snap{grp}_{sa.ObjId};");
+ var tmp = 0;
+ var reifyAt = new Dictionary();
+ var refRos = new List<(StackLayout Lay, int ObjId, string Ro)>();
+ foreach (var sa in sargs)
+ {
+ reifyAt[sa.Pos] = EmitReify(state, sa.Lay, Val(sa.ObjId), sym, body, ref tmp, out var ro);
+ if (sa.Lay.Mutated) refRos.Add((sa.Lay, sa.ObjId, ro));
+ }
+ Line(body, $"{dst} = state.Send({recv}, {symRef}{Args(sa => reifyAt[sa.Pos])});");
+ foreach (var (rlay, roid, ro) in refRos) EmitCopyBack(state, rlay, Val(roid), ro, sym, body, ref tmp);
+ Line(body, "}");
+ return true;
+ }
+
+ // Caller side: a non-accessor Send whose RECEIVER is a stack object (`ray.dir.vdot(@n)`).
+ // The receiver class is statically the object's layout class, so there is no class-switch —
+ // guard that the class/method are unchanged, then call the callee's struct-`self` variant
+ // (structParamIndex 0); reify + normal Send on a guard/variant miss. Stage C-step1 keeps it
+ // read-only `in` and requires plain (non-struct) args; nested/ref args come later.
+ internal static bool TryEmitStackReceiverSend(InlineContext ic, in RubyIRInstruction ins, StringBuilder body)
+ {
+ var state = ic.state;
+ var methodName = ic.methodName;
+ var sym = ic.sym;
+ var exe = ic.exe;
+ ref var icCount = ref ic.icCount;
+ if (CurrentStackObjects is not { } cso) return false;
+ if (ins.OpCode is not RubyIROpCode.Send) return false;
+ if (!cso.TryGetValue(ins.Src0, out var lay)) return false;
+ var sel = exe.GetCallSiteSymbol(ins.Aux);
+ if (!state.TryFindMethod(lay.Cls, sel, out var m, out _) || m.Proc is not { } proc) return false;
+ if (TryRecognizeTrivialAccessor(state, proc.Irep) is not null) return false; // accessor -> field path
+ var argc = exe.GetCallSiteArgumentCount(ins.Aux);
+ for (var a = 0; a < argc; a++)
+ if (cso.ContainsKey(exe.GetCallSiteArgumentValueId(ins.Aux, a))) return false; // struct arg: beyond step1
+ if (!TrySymbolStringLiteral(state, sel, out var selName)) return false;
+ if (!TrySymbolStringLiteral(state, lay.ConstName, out var constLit)) return false;
+
+ var fp = state.ComputeIrepFingerprint(proc.Irep);
+ var structParamsMap = new Dictionary { [0] = lay }; // self is the struct (paramIndex 0)
+ var variantName = MethodCsName(fp) + StructParamSuffix(structParamsMap);
+ NeededStructs ??= new(); NeededVariants ??= new();
+ NeededStructs[lay.ClassFp] = lay;
+ NeededVariants[variantName] = (lay.Cls, sel, structParamsMap);
+
+ var recv = Val(ins.Src0);
+ var dst = Val(ins.Dst);
+ var symRef = sym.Reference(selName);
+ var selfKw = lay.Mutated ? "ref " : "in "; // method is read-only (CalleeSelfReadOnly), but the
+ var args = new StringBuilder(); // param decl is keyed on Mutated, so match it; no copy-back needed.
+ for (var a = 0; a < argc; a++) args.Append(", ").Append(Val(exe.GetCallSiteArgumentValueId(ins.Aux, a)));
+ var n = icCount++;
+ var icCls = methodName + "__ic" + n + "cls";
+ var icVer = methodName + "__ic" + n + "ver";
+ var done = "__rdone" + n;
+ Line(body, $"bool {done} = false;");
+ Line(body,
+ $"if (ClassMethodGuardUnsafe(state, {sym.Reference(constLit)}, {symRef}, 0x{fp:x16}UL, ref {icCls}, ref {icVer})) {{ if ({variantName}(state, {selfKw}{recv}{args}, out {dst})) {done} = true; }}");
+ Line(body, $"if (!{done}) {{");
+ var tmp = 0;
+ var reified = EmitReify(state, lay, recv, sym, body, ref tmp, out _);
+ Line(body, $"{dst} = state.Send({reified}, {symRef}{args});");
+ Line(body, "}");
+ return true;
+ }
+
+ internal static bool TryInlineSelfSend(InlineContext ic, in RubyIRInstruction ins, Symbol methodSym, string mname, int argc, StringBuilder body)
+ {
+ var state = ic.state;
+ var definingClass = ic.definingClass;
+ var registry = ic.registry;
+ var accessors = ic.accessors;
+ if (definingClass is null || registry is null) return false;
+ // A self-send to a trivial accessor devirtualizes to direct field access (handled by
+ // TryEmitAccessorDevirt, which now also accepts SendSelf) — never an `__inline` call.
+ if (accessors is not null && accessors.ContainsKey(methodSym)) return false;
+ // Resolve the target against the defining class (self's class). Only inline if
+ // it lands on an RProc method that the first pass marked safe (compiled + leaf +
+ // small) and whose arity matches the call site.
+ if (!state.TryFindMethod(definingClass, methodSym, out var method, out _) ||
+ method.Proc is not { } proc)
+ {
+ return false;
+ }
+ var fp = state.ComputeIrepFingerprint(proc.Irep);
+ if (!registry.TryGetValue(fp, out var calleeArgc) || calleeArgc != argc)
+ {
+ return false;
+ }
+
+ EmitGuardedInlineCall(ic, ins, mname, fp, argc, body);
+ return true;
+ }
+
+ // if (guard(recv class) && callee__inline(state, recv, args, out dst)) {} else dst = Send(...)
+ internal static void EmitGuardedInlineCall(InlineContext ic, in RubyIRInstruction ins, string mname, ulong fp, int argc, StringBuilder body)
+ {
+ var methodName = ic.methodName;
+ var sym = ic.sym;
+ var exe = ic.exe;
+ ref var icCount = ref ic.icCount;
+ var n = icCount++;
+ var icCls = methodName + "__ic" + n + "cls";
+ var icVer = methodName + "__ic" + n + "ver";
+ var calleeInline = MethodCsName(fp) + "__inline";
+ var recv = Val(ins.Src0);
+ var dst = Val(ins.Dst);
+ var symRef = sym.Reference(mname);
+ var args = new StringBuilder();
+ for (var i = 0; i < argc; i++)
+ {
+ args.Append(", ").Append(Val(exe.GetCallSiteArgumentValueId(ins.Aux, i)));
+ }
+ // Capture the receiver in a temp: register reuse can make dst == recv (e.g.
+ // `v = v.org; v = v.vsub(..)`). The callee's __inline writes `out dst` = nil before
+ // returning false on a guard miss, which would clobber the receiver the Send
+ // fallback then reads. The temp holds the original receiver across both paths.
+ var tmp = "_r" + n;
+ // Guard hit + inline body succeeds -> dst holds the inline result. Guard miss
+ // (polymorphic / overriding subclass / redefinition) or inline deopt -> Send.
+ Line(body,
+ $"{{ var {tmp} = {recv}; if (InlineGuardUnsafe(state, {tmp}, {symRef}, 0x{fp:x16}UL, ref {icCls}, ref {icVer}) && {calleeInline}(state, {tmp}{args}, out {dst})) {{ }} else {{ {dst} = state.Send({tmp}, {symRef}{args}); }} }}");
+ }
+
+ // `recv.getter` / `recv.setter=` -> guarded direct field access, for both cross-object (Send)
+ // and self (SendSelf) receivers. The guard (InlineGuardUnsafe) confirms recv's class still
+ // resolves the selector to the exact trivial-accessor body we devirtualized against; a miss
+ // falls back to a real Send (so a subclass override of the accessor still runs). We speculate
+ // the receiver is that class — a wrong receiver simply takes the Send path.
+ internal static bool TryEmitAccessorDevirt(InlineContext ic, in RubyIRInstruction ins, Symbol methodSym, string mname, int argc, StringBuilder body)
+ {
+ var state = ic.state;
+ var methodName = ic.methodName;
+ var sym = ic.sym;
+ var exe = ic.exe;
+ var accessors = ic.accessors;
+ ref var icCount = ref ic.icCount;
+ if (accessors is null || ins.OpCode is not (RubyIROpCode.Send or RubyIROpCode.SendSelf)) return false;
+ if (Environment.GetEnvironmentVariable("AOT_NODEVIRT") == "1") return false;
+ if (!accessors.TryGetValue(methodSym, out var target)) return false;
+ if (target.IsSetter ? argc != 1 : argc != 0) return false;
+ if (!TrySymbolStringLiteral(state, target.Field, out var fieldLit)) return false;
+
+ var n = icCount++;
+ var icCls = methodName + "__ic" + n + "cls";
+ var icVer = methodName + "__ic" + n + "ver";
+ var recv = Val(ins.Src0);
+ // The guard confirms recv's class, so inside the hit branch recv is an RObject.
+ var recvObj = $"{recv}.As()";
+ var dst = Val(ins.Dst);
+ var symM = sym.Reference(mname);
+ var symF = sym.Reference(fieldLit);
+ var guard = $"InlineGuardUnsafe(state, {recv}, {symM}, 0x{target.Fingerprint:x16}UL, ref {icCls}, ref {icVer})";
+ if (target.IsSetter)
+ {
+ var arg = Val(exe.GetCallSiteArgumentValueId(ins.Aux, 0));
+ Line(body,
+ $"if ({guard}) {{ {IvarSet(recvObj, symF, arg)}; {dst} = {arg}; }} else {{ {dst} = state.Send({recv}, {symM}, {arg}); }}");
+ }
+ else
+ {
+ Line(body,
+ $"if ({guard}) {{ {dst} = {IvarGet(recvObj, symF)}; }} else {{ {dst} = state.Send({recv}, {symM}); }}");
+ }
+ return true;
+ }
+
+ // A cross-object 0-arg send to a method that returns an immediate constant (multi-level via
+ // delegation) -> emit the constant directly, guarded by the callee's fingerprint; a guard
+ // miss (different class / redefinition) falls back to a normal Send.
+ internal static bool TryEmitConstantDevirt(InlineContext ic, in RubyIRInstruction ins, Symbol methodSym, string mname, int argc, StringBuilder body)
+ {
+ var state = ic.state;
+ var methodName = ic.methodName;
+ var sym = ic.sym;
+ var constReturns = ic.constReturns;
+ ref var icCount = ref ic.icCount;
+ if (constReturns is null || ins.OpCode is not RubyIROpCode.Send || argc != 0) return false;
+ if (Environment.GetEnvironmentVariable("AOT_NODEVIRT") == "1") return false;
+ if (!constReturns.TryGetValue(methodSym, out var target)) return false;
+ if (!TryEmitLiteral(target.Value, out var constExpr)) return false;
+ var n = icCount++;
+ var icCls = methodName + "__ic" + n + "cls";
+ var icVer = methodName + "__ic" + n + "ver";
+ var recv = Val(ins.Src0);
+ var dst = Val(ins.Dst);
+ var symM = sym.Reference(mname);
+ Line(body,
+ $"if (InlineGuardUnsafe(state, {recv}, {symM}, 0x{target.Fingerprint:x16}UL, ref {icCls}, ref {icVer})) {{ {dst} = {constExpr}; }} else {{ {dst} = state.Send({recv}, {symM}); }}");
+ return true;
+ }
+
+ internal static bool TryEmitPureUnarySend(InlineContext ic, in RubyIRInstruction ins, string mname, StringBuilder body)
+ {
+ var methodName = ic.methodName;
+ var sym = ic.sym;
+ var exe = ic.exe;
+ ref var pureUnaryCount = ref ic.pureUnaryCount;
+ var n = pureUnaryCount++;
+ var icCls = methodName + "__pu" + n + "cls";
+ var icVer = methodName + "__pu" + n + "ver";
+ var icMethod = methodName + "__pu" + n + "method";
+ var recv = Val(ins.Src0);
+ var arg = Val(exe.GetCallSiteArgumentValueId(ins.Aux, 0));
+ var dst = Val(ins.Dst);
+ var symRef = sym.Reference(mname);
+ Line(body, $"if ({arg}.IsFixnum || {arg}.IsFloat) {{ {dst} = PureUnarySendUnsafe(state, {recv}, {symRef}, {arg}, ref {icCls}, ref {icVer}, ref {icMethod}); }} else {{ {dst} = state.Send({recv}, {symRef}, {arg}); }}");
+ return true;
+ }
+
+ internal static void EmitGuardInlineClass(InlineContext ic, in RubyIRInstruction ins, string mname, ulong fp, StringBuilder body)
+ {
+ var methodName = ic.methodName;
+ var sym = ic.sym;
+ ref var icCount = ref ic.icCount;
+ var n = icCount++;
+ var icCls = methodName + "__ic" + n + "cls";
+ var icVer = methodName + "__ic" + n + "ver";
+ var symRef = sym.Reference(mname);
+ Line(body,
+ $"if (InlineGuardUnsafe(state, {Val(ins.Src0)}, {symRef}, 0x{fp:x16}UL, ref {icCls}, ref {icVer})) goto L{ins.Aux}; else {{ result = default; return false; }}");
+ }
+
+ internal static void EmitInlineFields(InlineContext ic, StringBuilder sb)
+ {
+ var methodName = ic.methodName;
+ var icCount = ic.icCount;
+ var pureUnaryCount = ic.pureUnaryCount;
+ var constReadCount = ic.constReadCount;
+ var candGroupSizes = ic.candGroupSizes;
+ for (var i = 0; i < icCount; i++)
+ {
+ sb.Append("static global::ChibiRuby.RClass ").Append(methodName).Append("__ic").Append(i).Append("cls;\n");
+ sb.Append("static int ").Append(methodName).Append("__ic").Append(i).Append("ver;\n");
+ }
+ for (var i = 0; i < pureUnaryCount; i++)
+ {
+ sb.Append("static global::ChibiRuby.RClass ").Append(methodName).Append("__pu").Append(i).Append("cls;\n");
+ sb.Append("static int ").Append(methodName).Append("__pu").Append(i).Append("ver;\n");
+ sb.Append("static global::ChibiRuby.MRubyMethod ").Append(methodName).Append("__pu").Append(i).Append("method;\n");
+ }
+ for (var g = 0; g < candGroupSizes.Count; g++)
+ {
+ // -1 sentinel: MethodCacheVersion is >= 0, so the first call always resolves
+ // (a 0 default would collide with the initial version and never resolve).
+ sb.Append("static int ").Append(methodName).Append("__cand").Append(g).Append("ver = -1;\n");
+ for (var i = 0; i < candGroupSizes[g]; i++)
+ {
+ sb.Append("static global::ChibiRuby.RClass ").Append(methodName).Append("__cand").Append(g).Append('_').Append(i).Append("cls;\n");
+ }
+ }
+ for (var i = 0; i < constReadCount; i++)
+ {
+ sb.Append("static global::ChibiRuby.MRubyValue ").Append(methodName).Append("__gc").Append(i).Append("val;\n");
+ // -1 sentinel as above: never equals a real ConstCacheVersion (>= 0).
+ sb.Append("static int ").Append(methodName).Append("__gc").Append(i).Append("ver = -1;\n");
+ sb.Append("static global::ChibiRuby.MRubyState ").Append(methodName).Append("__gc").Append(i).Append("state;\n");
+ }
+ }
+
+ // ==== ScalarContext emission (moved out of ScalarContext; bodies unchanged, sc.* aliased) ====
+
+ internal static void EmitScalarFieldLocals(ScalarContext sc, StringBuilder sb)
+ {
+ var objects = sc.objects;
+ var seen = new HashSet();
+ foreach (var o in objects.Values)
+ {
+ if (!seen.Add(o.ValueId)) continue;
+ for (var f = 0; f < o.Fields.Count; f++)
+ {
+ sb.Append(" global::ChibiRuby.MRubyValue ").Append(ScalarContext.FieldLocal(o.ValueId, f)).Append(" = default;\n");
+ }
+ }
+ }
+
+ // VirtualNew of a scalar object: guard validity, then set each field local from the
+ // matching ctor arg (initialize inlined). No allocation.
+ internal static void EmitScalarNew(ScalarContext sc, RubyIRMethod exe, in RubyIRInstruction ins, StringBuilder body)
+ {
+ var state = sc.state;
+ var objects = sc.objects;
+ var o = objects[ins.Dst];
+ // Guard: constant still resolves to this class and initialize + every accessor we
+ // inline still have the fingerprints we compiled against. Miss -> deopt.
+ EmitScalarGuardCall(sc, body, o, state.Intern("initialize"u8), o.InitFingerprint);
+ foreach (var acc in o.Accessors)
+ {
+ EmitScalarGuardCall(sc, body, o, acc.Key, acc.Value.Fingerprint);
+ }
+ for (var f = 0; f < o.Fields.Count; f++)
+ {
+ var argVid = exe.GetCallSiteArgumentValueId(ins.Aux, o.FieldArg[f]);
+ Line(body, $"{ScalarContext.FieldLocal(o.ValueId, f)} = {Val(argVid)};");
+ }
+ }
+
+ // Escaping `Const.new(args)` -> inline construction: allocate the object and store its
+ // fields directly, skipping the `:new` + `:initialize` double dispatch. Guarded so a
+ // redefined `:new`/`:initialize` deopts to the real dispatch (InlineNewGuardUnsafe).
+ internal static void EmitFastNew(ScalarContext sc, RubyIRMethod exe, in RubyIRInstruction ins, StringBuilder body)
+ {
+ var state = sc.state;
+ var methodName = sc.methodName;
+ var sym = sc.sym;
+ var fastNew = sc.fastNew;
+ ref var guardCount = ref sc.guardCount;
+ var o = fastNew[ins.Dst];
+ if (!TrySymbolStringLiteral(state, state.Intern("new"u8), out var newLit) ||
+ !TrySymbolStringLiteral(state, state.Intern("initialize"u8), out var initLit))
+ {
+ Line(body, $"{Val(ins.Dst)} = state.Send({Val(ins.Src0)}, {sym.Reference("\"new\"")});");
+ return;
+ }
+ var slot = guardCount++;
+ var cls = methodName + "__sc" + slot + "cls";
+ var ver = methodName + "__sc" + slot + "ver";
+ var classVal = Val(ins.Src0);
+ Line(body,
+ $"if (!InlineNewGuardUnsafe(state, {classVal}, {sym.Reference(newLit)}, {sym.Reference(initLit)}, 0x{o.InitFingerprint:x16}UL, ref {cls}, ref {ver})) {{ result = default; return false; }}");
+ Line(body, $"{Val(ins.Dst)} = new global::ChibiRuby.MRubyValue(new global::ChibiRuby.RObject({classVal}.As()));");
+ // The dst is the freshly-allocated RObject; cast it once for all field stores.
+ var newObj = o.Fields.Count > 0 ? AsRObject(ins.Dst, Val(ins.Dst), body) : Val(ins.Dst);
+ for (var f = 0; f < o.Fields.Count; f++)
+ {
+ if (!TrySymbolStringLiteral(state, o.Fields[f], out var fieldLit)) continue;
+ var argVid = exe.GetCallSiteArgumentValueId(ins.Aux, o.FieldArg[f]);
+ Line(body, $"{IvarSet(newObj, sym.Reference(fieldLit), Val(argVid))};");
+ }
+ }
+
+ internal static void EmitScalarGuardCall(ScalarContext sc, StringBuilder body, ScalarObject o, Symbol methodSym, ulong fp)
+ {
+ var state = sc.state;
+ var methodName = sc.methodName;
+ var sym = sc.sym;
+ var guardSlot = sc.guardSlot;
+ ref var guardCount = ref sc.guardCount;
+ if (!guardSlot.TryGetValue((o.ValueId, methodSym), out var slot))
+ {
+ slot = guardCount++;
+ guardSlot[(o.ValueId, methodSym)] = slot;
+ }
+ if (!TrySymbolStringLiteral(state, o.ConstName, out var constLit) ||
+ !TrySymbolStringLiteral(state, methodSym, out var methLit))
+ {
+ // Should not happen for resolved class/method names; be safe and never elide.
+ Line(body, "{ result = default; return false; }");
+ return;
+ }
+ var cls = methodName + "__sc" + slot + "cls";
+ var ver = methodName + "__sc" + slot + "ver";
+ Line(body,
+ $"if (!ClassMethodGuardUnsafe(state, {sym.Reference(constLit)}, {sym.Reference(methLit)}, 0x{fp:x16}UL, ref {cls}, ref {ver})) {{ result = default; return false; }}");
+ }
+
+ // Accessor send on a scalar object -> direct field-local read/write. Returns false if
+ // `ins` is not such a send (caller falls back to normal Send emission).
+ internal static bool TryEmitAccessorSend(ScalarContext sc, RubyIRMethod exe, in RubyIRInstruction ins, StringBuilder body)
+ {
+ var objects = sc.objects;
+ if (ins.OpCode is not RubyIROpCode.Send) return false;
+ if (!objects.TryGetValue(ins.Src0, out var o)) return false;
+ var msym = exe.GetCallSiteSymbol(ins.Aux);
+ if (!o.Accessors.TryGetValue(msym, out var acc)) return false;
+ var local = ScalarContext.FieldLocal(o.ValueId, acc.FieldIndex);
+ if (acc.IsSetter)
+ {
+ var arg = exe.GetCallSiteArgumentValueId(ins.Aux, 0);
+ Line(body, $"{local} = {Val(arg)};");
+ Line(body, $"{Val(ins.Dst)} = {Val(arg)};"); // o.x = v evaluates to v
+ }
+ else
+ {
+ Line(body, $"{Val(ins.Dst)} = {local};");
+ }
+ return true;
+ }
+
+ internal static bool TryEmitScalarFieldAccess(ScalarContext sc, RubyIRMethod exe, in RubyIRInstruction ins, StringBuilder body)
+ {
+ if (!sc.TryGetScalarFieldAccess(exe, ins, out var objId, out var fieldIndex, out var isSetter))
+ {
+ return false;
+ }
+
+ var local = ScalarContext.FieldLocal(objId, fieldIndex);
+ if (isSetter)
+ {
+ Line(body, $"{local} = {Val(ins.Src1)};");
+ }
+ else
+ {
+ Line(body, $"{Val(ins.Dst)} = {local};");
+ }
+ return true;
+ }
+
+ internal static bool TryEmitScalarInlineGuard(ScalarContext sc, RubyIRMethod exe, in RubyIRInstruction ins, StringBuilder body)
+ {
+ var objects = sc.objects;
+ if (ins.OpCode != RubyIROpCode.GuardInlineClass ||
+ !objects.TryGetValue(ins.Src0, out var o) ||
+ !exe.TryGetGuardInline(ins.Src1, out var fp) ||
+ fp == 0)
+ {
+ return false;
+ }
+
+ EmitScalarGuardCall(sc, body, o, exe.GetCallSiteSymbol(ins.Src1), fp);
+ Line(body, $"goto L{ins.Aux};");
+ return true;
+ }
+
+ internal static void EmitScalarFields(ScalarContext sc, StringBuilder sb)
+ {
+ var methodName = sc.methodName;
+ var guardCount = sc.guardCount;
+ for (var i = 0; i < guardCount; i++)
+ {
+ sb.Append("static global::ChibiRuby.RClass ").Append(methodName).Append("__sc").Append(i).Append("cls;\n");
+ sb.Append("static int ").Append(methodName).Append("__sc").Append(i).Append("ver;\n");
+ }
+ }
+
+ internal static bool TryEmitLiteral(MRubyValue v, out string expr)
+ {
+ if (v.IsFixnum)
+ {
+ expr = $"new global::ChibiRuby.MRubyValue({v.FixnumValue}L)";
+ return true;
+ }
+ if (v.IsFloat)
+ {
+ // Plain `ldc.r8` literal for finite values (folds with no JIT budget, unlike
+ // Int64BitsToDouble(const)); bit-exact reconstruction for NaN/Inf/non-roundtrippable.
+ expr = $"new global::ChibiRuby.MRubyValue({Emitter.DoubleLitText(v.FloatValue)})";
+ return true;
+ }
+ // Exact VType match only — symbols/strings are truthy immediates/objects
+ // too, so a loose check would mis-emit them as `true`. Bail on those.
+ switch (v.VType)
+ {
+ case MRubyVType.Nil:
+ expr = "global::ChibiRuby.MRubyValue.Nil";
+ return true;
+ case MRubyVType.True:
+ expr = "global::ChibiRuby.MRubyValue.True";
+ return true;
+ case MRubyVType.False:
+ expr = "global::ChibiRuby.MRubyValue.False";
+ return true;
+ default:
+ expr = "";
+ return false;
+ }
+ }
+
+ // Recognizes fixnum bitwise-operator method names with a safe C# equivalent.
+ internal static bool TryFixnumBitwiseOp(MRubyState state, Symbol sym, out string op, out bool isShift)
+ {
+ op = "";
+ isShift = false;
+ var name = state.NameOf(sym).AsSpan();
+ if (Matches(name, "&"u8)) { op = "&"; return true; }
+ if (Matches(name, "|"u8)) { op = "|"; return true; }
+ if (Matches(name, "^"u8)) { op = "^"; return true; }
+ if (Matches(name, ">>"u8)) { op = ">>"; isShift = true; return true; }
+ // Left shift: C# `long <<` matches Ruby for a 0..63 shift, modulo the fixnum-overflow->Bignum
+ // promotion the AOT already ignores for `*`. The 0..63 guard routes negative/large shifts
+ // (which Ruby treats as right-shift / Bignum) to the Send fallback.
+ if (Matches(name, "<<"u8)) { op = "<<"; isShift = true; return true; }
+ return false;
+ }
+}
diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/InlineContext.cs b/src/ChibiRuby.JetPack/Mrb2Cs/InlineContext.cs
new file mode 100644
index 00000000..4ccbd831
--- /dev/null
+++ b/src/ChibiRuby.JetPack/Mrb2Cs/InlineContext.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using ChibiRuby;
+namespace ChibiRuby.JetPack.Mrb2Cs;
+
+// Per-method emission context for block inlining, kept in a thread-static so it doesn't
+// have to thread through every EmitInstruction/arith helper. Inside an inlined block body:
+// ForceSend makes numeric slow paths Send (never deopt — a block body must not re-execute
+// a partially-applied loop iteration); UpvarCells maps a depth-0 upvar register to the C#
+// ref-param cell it reads/writes; AuxMethods collects the generated __blk method sources.
+sealed class BlockEmitState
+{
+ public bool ForceSend;
+ // Scope level of the body currently being emitted: 0 = the method, 1 = a block directly
+ // in the method, 2 = a block in a block, ... A variable is identified by an absolute
+ // coordinate (scopeLevel, register) — a register number alone is ambiguous across levels.
+ public int CurrentLevel;
+ // Coordinates the current block received as `cell__` ref params (the
+ // captured variables it or its descendants read/write above its own scope).
+ public HashSet<(int Scope, int Register)>? Cells;
+ public readonly List AuxMethods = [];
+ public int BlockCounter;
+ public string OwnerName = ""; // prefix for unique __blk names
+ // Program-wide accessor map so inlined block bodies can devirtualize their (very common)
+ // cross-object getter/setter sends to guarded field access. Only accessor devirt is
+ // enabled in blocks — never self-send / cross-object __inline calls, which are direct C#
+ // calls that would break fiber switching from inside a loop body.
+ public IReadOnlyDictionary? AccessorRegistry;
+}
+
+// Per-method inline-emission data. Holds the resolution inputs (definingClass/registry drive
+// self-send inlining; accessors drives cross-object getter/setter devirtualization; either may
+// be absent → that capability is off) plus the per-site counters the emitter increments and
+// EmitInlineFields reads back. Pure data — all C# emission lives in Emitter.
+sealed class InlineContext
+{
+ internal readonly MRubyState state;
+ internal readonly RClass? definingClass;
+ internal readonly IReadOnlyDictionary? registry;
+ internal readonly IReadOnlyDictionary? accessors;
+ internal readonly IReadOnlyDictionary? constReturns;
+ internal readonly string methodName;
+ internal readonly SymbolCache sym;
+ internal readonly RubyIRMethod exe;
+
+ internal int icCount;
+ internal int pureUnaryCount;
+ internal int candGroupCount;
+ internal int constReadCount;
+ internal readonly List candGroupSizes = []; // per class-switch site: number of candidate classes
+
+ public InlineContext(
+ MRubyState state,
+ RClass? definingClass,
+ IReadOnlyDictionary? registry,
+ IReadOnlyDictionary? accessors,
+ IReadOnlyDictionary? constReturns,
+ string methodName,
+ SymbolCache sym,
+ RubyIRMethod exe)
+ {
+ this.state = state;
+ this.definingClass = definingClass;
+ this.registry = registry;
+ this.accessors = accessors;
+ this.constReturns = constReturns;
+ this.methodName = methodName;
+ this.sym = sym;
+ this.exe = exe;
+ }
+}
diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/Mrb2CsCompiler.cs b/src/ChibiRuby.JetPack/Mrb2Cs/Mrb2CsCompiler.cs
new file mode 100644
index 00000000..c0ee5a71
--- /dev/null
+++ b/src/ChibiRuby.JetPack/Mrb2Cs/Mrb2CsCompiler.cs
@@ -0,0 +1,617 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Diagnostics.CodeAnalysis;
+
+namespace ChibiRuby.JetPack.Mrb2Cs;
+
+// Build-time Ruby -> C# codegen. Builds a method's Irep into RubyIR (via RubyIRBuilder.Build,
+// which also runs escape-analysis op rewriting + arithmetic fusion), runs analyses over that IR
+// (SSA renumbering, escape/scalar replacement, type inference / unboxing), then walks the IR
+// and emits a C# method matching CompiledRubyMethodBody: bool (MRubyState, int sp, out MRubyValue).
+//
+// The generated code targets only PUBLIC ChibiRuby APIs (it lives in the user's
+// assembly, no InternalsVisibleTo). Symbols (ivar names, send method ids) are interned
+// ONCE per state into per-method static fields (state-keyed so it stays correct across
+// multiple states) instead of per call. Control flow is labels + gotos mirroring the
+// IR's forward branches; all value-id locals are pre-declared (default-init) so gotos
+// never trip C# definite-assignment. Arithmetic/comparison is speculatively typed
+// (fixnum fast path + guard->deopt). Unhandled ops -> TryCompileMethod returns false.
+public static class Mrb2CsCompiler
+{
+ // Diagnostic: set to the reason TryCompileMethod last returned false (for coverage analysis).
+ [ThreadStatic]
+ internal static string? LastBail;
+
+ // SSA live-range splitting (+ its SSA-driven splice detection and pre-side-effect float
+ // window) is ON by default; set AOT_NOSSA=1 to fall back to the register-reused path.
+ internal static bool SsaEnabled => Environment.GetEnvironmentVariable("AOT_NOSSA") != "1";
+
+ // Diagnostic: devirtualize+inline surface. Counted across a compile run when a
+ // defining class is supplied. SelfSendSites = all SendSelf in compiled methods;
+ // ResolvedSelfSends = those resolving to an RProc method in the defining class;
+ // InlinableSelfSends = resolved + callee is a small leaf that itself AOT-lowers.
+ internal static int UnboxedLocals;
+
+ static void ResetInlineSurface()
+ {
+ UnboxedLocals = 0;
+ }
+
+
+ // Per-method irep fingerprint -> readable `_rubyname` suffix, so the C# method name carries the
+ // original Ruby method name (set by Compile; null for standalone TryCompileMethod). Read via MethodCsName.
+ [ThreadStatic]
+ internal static Dictionary? MethodNameSuffixes;
+
+ // Fingerprints of methods whose selector is a (deduped) trivial accessor: every call to them —
+ // self or cross-object — devirtualizes to direct field access, so they are never `__inline`-called
+ // and don't need the frameless `__inline` form. Set by Compile; null for standalone TryCompileMethod.
+ [ThreadStatic]
+ internal static HashSet? AccessorFingerprints;
+
+ // mrb2cs: compile every statically-compilable method in an irep tree to one C# class.
+ // Walks the tree, generates each method (dedup by fingerprint), and emits a single
+ // `class : ChibiRuby.AotGeneratedMethods` source. The caller compiles that
+ // source (Roslyn at runtime, or csc at build time) and binds Methods by fingerprint.
+ // `state` must already have executed the program so its classes/methods are defined.
+ public static ProgramResult Compile(MRubyState state, Irep root, string className = "Mrb2CsGenerated", string? namespaceName = null)
+ {
+ // Each method irep -> its defining class, so the codegen can resolve self-sends; and its
+ // fingerprint -> a readable `_rubyname` suffix, so the emitted C# method name carries the
+ // original Ruby method name (MethodCsName reads the latter via the thread-static below).
+ var classOf = new Dictionary();
+ var suffixes = new Dictionary();
+ state.EnumerateAotMethods((definingClass, methodId, irep) =>
+ {
+ classOf[irep] = definingClass;
+ suffixes[state.ComputeIrepFingerprint(irep)] = Emitter.NameSuffixFor(Encoding.UTF8.GetString(state.NameOf(methodId)));
+ });
+ MethodNameSuffixes = suffixes;
+
+ var accessorRegistry = Analyzer.BuildAccessorRegistry(state);
+ // Fingerprints of the methods those accessor selectors denote — every send to them devirts to
+ // field access, so they skip the frameless `__inline` form (read in TryCompileMethod's emit step).
+ var accFps = new HashSet();
+ state.EnumerateAotMethods((_, methodId, irep) =>
+ {
+ if (accessorRegistry.ContainsKey(methodId)) accFps.Add(state.ComputeIrepFingerprint(irep));
+ });
+ AccessorFingerprints = accFps;
+ // Constant-returning-method devirt registry (set thread-static; sound regardless of staleness
+ // because every emitted call site is fingerprint-guarded). Disable with AOT_NOCONSTRET=1.
+ CurrentConstReturns = Environment.GetEnvironmentVariable("AOT_NOCONSTRET") == "1" ? null : Analyzer.BuildConstReturnRegistry(state);
+
+ // Pass 1: which methods are small enough to inline, and their arg counts.
+ const int inlineMaxInstructions = 48;
+ var inlineRegistry = new Dictionary();
+ var seen1 = new HashSet();
+ Collect(root);
+
+ var inlineSelectorRegistry = Analyzer.BuildInlineSelectorRegistry(state, inlineRegistry);
+ var returnTypes = RubyIRReturnTypes.Build(state);
+ // Whole-program retention summary for stack allocation (set once; read per-method via the
+ // thread-static below). Off switch mirrors the other AOT_NO* gates.
+ CurrentEscapeSummary = Environment.GetEnvironmentVariable("AOT_NOSTACKOBJ") == "1"
+ ? null
+ : RubyIREscapeSummary.Build(state);
+ NeededStructs = new Dictionary();
+ NeededVariants = new Dictionary StructParams)>();
+
+ // Pass 2: emit final sources, inlining monomorphic self / selected cross-object sends.
+ var seen = new HashSet();
+ var sources = new List();
+ var methods = new List<(string, ulong)>();
+ var total = 0;
+ var bails = new Dictionary();
+ ResetInlineSurface();
+ Walk(root);
+
+ // Pass 3: generate the specialized struct-by-ref callee variants requested during Pass 2.
+ // (Worklist: a variant could request more; Stage 1's intersect doesn't, but loop safely.)
+ var emittedVariants = new HashSet();
+ while (NeededVariants.Count > emittedVariants.Count)
+ {
+ foreach (var (variantName, spec) in new List StructParams)>>(NeededVariants))
+ {
+ if (!emittedVariants.Add(variantName)) continue;
+ if (!state.TryFindMethod(spec.Callee, spec.Selector, out var m, out _) || m.Proc is not { } proc) continue;
+ CurrentStructParams = spec.StructParams;
+ try
+ {
+ if (TryCompileMethod(state, proc.Irep, variantName, spec.Callee, inlineRegistry, accessorRegistry, inlineSelectorRegistry, returnTypes, out var v))
+ {
+ sources.Add(v.Source);
+ sources.AddRange(v.AuxiliaryMethods);
+ }
+ }
+ finally { CurrentStructParams = null; }
+ }
+ }
+
+ if (Environment.GetEnvironmentVariable("AOT_COVERAGE") == "1")
+ {
+ Console.WriteLine($"mrb2cs coverage: {methods.Count}/{total} compiled, {total - methods.Count} bailed; {UnboxedLocals} unboxed long locals; {NeededStructs.Count} stack structs, {emittedVariants.Count} variants");
+ foreach (var kv in bails.OrderByDescending(x => x.Value))
+ {
+ Console.WriteLine($" bail {kv.Key}: {kv.Value}");
+ }
+ }
+
+ // Gather the stack struct types the methods use, transitively (a Stk_Ray with a Stk_Vec
+ // field needs Stk_Vec declared too); the emitter writes their declarations before the methods.
+ var allStructs = new Dictionary();
+ foreach (var lay in NeededStructs.Values) CollectStruct(lay);
+ return new ProgramResult(Emitter.EmitProgram(className, namespaceName, allStructs.Values, sources), methods);
+
+ void Walk(Irep irep)
+ {
+ var fp = state.ComputeIrepFingerprint(irep);
+ if (seen.Add(fp))
+ {
+ total++;
+ if (TryCompileMethod(state, irep, Emitter.MethodCsName(fp), classOf.GetValueOrDefault(irep), inlineRegistry, accessorRegistry, inlineSelectorRegistry, returnTypes, out var gen))
+ {
+ sources.Add(gen.Source);
+ methods.Add((Emitter.MethodCsName(fp), fp));
+ foreach (var aux in gen.AuxiliaryMethods) sources.Add(aux); // __blk bodies, called by name
+ }
+ else
+ {
+ var reason = LastBail ?? "?";
+ bails[reason] = bails.GetValueOrDefault(reason) + 1;
+ }
+ }
+ foreach (var child in irep.Children) Walk(child);
+ }
+
+ void CollectStruct(StackLayout l)
+ {
+ if (!allStructs.TryAdd(l.ClassFp, l)) return;
+ foreach (var n in l.FieldNested.OfType()) CollectStruct(n);
+ }
+
+ void Collect(Irep irep)
+ {
+ var fp = state.ComputeIrepFingerprint(irep);
+ if (seen1.Add(fp))
+ {
+ if (TryCompileMethod(state, irep, Emitter.MethodCsName(fp), classOf.GetValueOrDefault(irep), null, accessorRegistry, out var gen)
+ && gen.InstructionCount <= inlineMaxInstructions) inlineRegistry[fp] = gen.ArgCount;
+ }
+ foreach (var child in irep.Children) Collect(child);
+ }
+ }
+
+ // Compile one Ruby method's Irep to a C# method body. Returns false (and method = null) when the
+ // method can't be AOT-compiled (an unsupported op / shape bails — see LastBail); true with the
+ // emitted CompiledMethod otherwise.
+ public static bool TryCompileMethod(MRubyState state, Irep irep, string methodName,
+ [MaybeNullWhen(false)] out CompiledMethod method) =>
+ TryCompileMethod(state, irep, methodName, null, null, out method);
+
+ public static bool TryCompileMethod(MRubyState state, Irep irep, string methodName, RClass? definingClass,
+ IReadOnlyDictionary? inlineRegistry,
+ [MaybeNullWhen(false)] out CompiledMethod method) =>
+ TryCompileMethod(state, irep, methodName, definingClass, inlineRegistry, null, out method);
+
+ // inlineRegistry: fp -> argCount of methods safe to inline (compiled + leaf + small).
+ // When set (and definingClass known), monomorphic self-sends to those callees are
+ // emitted as a guarded direct call to the callee's __inline form (frameless), with a
+ // Send fallback. Built in a first pass; this is the second pass. accessorRegistry: selector
+ // -> trivial accessor, for guarded cross-object getter/setter devirtualization.
+ public static bool TryCompileMethod(MRubyState state, Irep irep, string methodName, RClass? definingClass,
+ IReadOnlyDictionary? inlineRegistry,
+ IReadOnlyDictionary? accessorRegistry,
+ [MaybeNullWhen(false)] out CompiledMethod method) =>
+ TryCompileMethod(state, irep, methodName, definingClass, inlineRegistry, accessorRegistry, null, out method);
+
+ public static bool TryCompileMethod(MRubyState state, Irep irep, string methodName, RClass? definingClass,
+ IReadOnlyDictionary? inlineRegistry,
+ IReadOnlyDictionary? accessorRegistry,
+ IReadOnlyDictionary? inlineSelectorRegistry,
+ [MaybeNullWhen(false)] out CompiledMethod method) =>
+ TryCompileMethod(state, irep, methodName, definingClass, inlineRegistry, accessorRegistry, inlineSelectorRegistry, null, out method);
+
+ // returnTypes: whole-program inferred numeric return kinds (RubyIRReturnTypes.Build), used to
+ // recognize Float/Integer-returning user methods instead of matching names. Null -> only
+ // builtin float methods are recognized.
+ static bool TryCompileMethod(
+ MRubyState state,
+ Irep irep,
+ string methodName,
+ RClass? definingClass,
+ IReadOnlyDictionary? inlineRegistry,
+ IReadOnlyDictionary? accessorRegistry,
+ IReadOnlyDictionary? inlineSelectorRegistry,
+ RubyIRReturnTypes.Registry? returnTypes,
+ [MaybeNullWhen(false)] out CompiledMethod method)
+ {
+ method = null;
+ // The RubyIR lowerer is not robust on every shape (it can throw rather than
+ // bail). Treat any lowering failure/exception as "not compilable".
+ CurrentReturnTypes = returnTypes;
+ LastBail = null;
+ RubyIRMethod? ir;
+ RubyIRBuildFailure failure;
+ try
+ {
+ ir = RubyIRBuilder.Build(irep, 0, out failure);
+ // Looping methods (a backward branch) skip the speculative splice/inline machinery:
+ // they compile fully boxed and deopt-free below (a partial loop iteration must never
+ // re-execute, so a guarded inline body's deopt-on-miss is unsafe inside a loop).
+ if (ir is not null && !ir.HasBackwardBranch && definingClass is not null && inlineRegistry is not null)
+ {
+ var effectiveInlineSelectorRegistry =
+ Environment.GetEnvironmentVariable("AOT_NOCROSSSPLICE") == "1"
+ ? null
+ : inlineSelectorRegistry;
+ // Splice detection traces each send's receiver/args to their producer via
+ // defIndex (the value's last def). On the register-reused IR a workhorse id
+ // (e.g. ao's `rs`, conflated into v10) points defIndex at a later redefinition,
+ // hiding that the receiver is a freshly-`new`ed object — so a consumer like
+ // `rs.vdot(dir)` is missed. SSA-renumbering first gives `rs` a unique id whose
+ // single def IS the producer, exposing the candidate. Detection is 1:1 over
+ // instructions, so the returned bytecode pcs still key the plan correctly.
+ var detectIr = ir;
+ if (SsaEnabled &&
+ TryReadMandatoryArgCount(irep, out var detectArgCount))
+ {
+ detectIr = RubyIRSsaRenumber.Run(ir, detectArgCount);
+ }
+ var candidatePcs = Analyzer.FindSpliceCandidatePcs(detectIr, effectiveInlineSelectorRegistry, accessorRegistry);
+ var splicePlan = candidatePcs is { Count: > 0 }
+ ? RubyIRBuilder.TryBuildSelfInlinePlan(
+ state,
+ irep,
+ definingClass,
+ inlineRegistry,
+ effectiveInlineSelectorRegistry,
+ candidatePcs)
+ : null;
+ if (splicePlan is { Count: > 0 })
+ {
+ var spliced = RubyIRBuilder.Build(irep, 0, splicePlan, out var splicedFailure);
+ if (spliced is not null)
+ {
+ ir = spliced;
+ failure = splicedFailure;
+ }
+ }
+ }
+ }
+ catch
+ {
+ LastBail = "lower-throw";
+ return false;
+ }
+ if (ir is null)
+ {
+ // Surface the lowerer's own reason (opcode it choked on / why) so coverage
+ // analysis can tell e.g. "backward branch" (loops) from an unsupported op.
+ LastBail = failure.OpCode is { } op
+ ? "lower:" + op + (failure.Reason.Length > 0 ? "(" + failure.Reason + ")" : "")
+ : "lower:" + (failure.Reason.Length > 0 ? failure.Reason : "null");
+ return false;
+ }
+
+ if (!TryReadMandatoryArgCount(irep, out var argCount))
+ {
+ LastBail = "argspec";
+ return false;
+ }
+
+ // Looping methods (a `while`/`until` back-edge) compile in a fully-boxed, deopt-free mode:
+ // SSA renumbering, unboxing, scalar replacement and stack allocation are all OFF, and numeric
+ // slow paths Send instead of deopting (ForceSend) — exactly the block-body contract, because
+ // a loop body must never re-execute a partial iteration after a mid-loop deopt. SSA + the
+ // cyclic-dataflow type inference needed to unbox loop-carried values is Phase 2.
+ var looping = ir.HasBackwardBranch;
+
+ // SSA-grade live-range splitting (ON by default; disable with AOT_NOSSA=1): split reused
+ // merge-slot value-ids into join-precise ids so per-range type inference / unboxing can
+ // fire. Renumbering only; emission + ComputeUnboxing are unchanged. Falls back internally
+ // to the original ir on any anomaly. Float-heavy code wins (ao ~-16%/-35% alloc) because
+ // the split float ranges unbox; integer code is neutral now that float speculation is gated
+ // on class-level float evidence (so it never mis-speculates int ivars). See jetpack-ssa-plan.md.
+ // SSA now handles back-edges (loop-aware reaching-defs fixpoint), so it runs for looping
+ // methods too: it splits the merge-slot temp workhorses into per-range ids (so an arith
+ // intermediate isn't conflated with a boolean compare result), which is what lets the sound
+ // loop lattice prove a clean Float/Fixnum type. Loop-carried values keep one id (their init
+ // and back-edge defs both reach the header use -> unioned).
+ if (SsaEnabled)
+ {
+ ir = RubyIRSsaRenumber.Run(ir, argCount);
+ }
+
+ var instructions = ir.Instructions;
+
+ var targets = new HashSet();
+ foreach (var ins in instructions)
+ {
+ if (ins.OpCode is RubyIROpCode.Jump or RubyIROpCode.JumpIfTruthy
+ or RubyIROpCode.JumpIfFalsy or RubyIROpCode.JumpIfNil
+ or RubyIROpCode.GuardInlineClass)
+ {
+ targets.Add(ins.Aux);
+ }
+ }
+
+ var sym = new SymbolCache(methodName);
+ CurrentConstLit = Analyzer.BuildConstLit(ir); // fixnum/float literals -> guard-free constant operands
+ // Scalar replacement / stack allocation deopt on a guard miss, which is unsafe inside a loop
+ // (a partial iteration would re-execute). Off for looping methods.
+ var sc = looping ? null : ScalarContext.TryBuild(state, ir, sym, methodName);
+ // Stack objects in THIS method (caller side): VirtualNew sites we build on the stack and
+ // pass by-ref to a specialized callee. Only when emission is enabled (opt-in for now).
+ var structParams = CurrentStructParams; // set by the variant generator (Pass 3)
+ CurrentStackObjects = !looping && StackObjEnabled && CurrentEscapeSummary is { } esc0
+ ? Analyzer.FindStackEligible(state, ir, esc0)
+ : null;
+ // Defer to scalar replacement: a fully-local object ScalarContext already eliminates is strictly
+ // better as a scalar (no struct). Stack allocation is only for objects that escape via a
+ // non-retaining call (which ScalarContext rejects). Drop any overlap.
+ if (CurrentStackObjects is { Count: > 0 } && sc is not null)
+ {
+ // Only scalar-replaced (fully-eliminated) objects are excluded. fastNew objects still
+ // heap-allocate, so stack allocation SHOULD take them over (the VirtualNew case checks
+ // CurrentStackObjects before fastNew).
+ foreach (var k in new List(CurrentStackObjects.Keys))
+ {
+ if (sc.IsScalar(k)) CurrentStackObjects.Remove(k);
+ }
+ }
+ // Variant body: the struct parameter and its Move-copies are struct locals too, so reads
+ // of `ray` aliased into another value-id (`v3 = ray`) stay struct (a struct copy) and
+ // their `ray.org`-style accessor sends lower to field reads. Register the whole alias
+ // closure (the param + copies) under the param layout in the same CurrentStackObjects map.
+ if (structParams is not null)
+ {
+ CurrentStackObjects ??= new Dictionary();
+ foreach (var (pidx, play) in structParams)
+ {
+ foreach (var a in Analyzer.MoveClosure(ir, pidx))
+ {
+ CurrentStackObjects[a] = play;
+ }
+ }
+ // The param is registered AFTER FindStackEligible ran, so cascade its own nested-field
+ // reads now (`param.inner.m()` -> struct-receiver on a stack copy).
+ if (CurrentEscapeSummary is { } pesc)
+ {
+ var pdef = new int[ir.ValueCount];
+ for (var i = 0; i < pdef.Length; i++) pdef[i] = -1;
+ for (var i = 0; i < instructions.Length; i++) { var d = instructions[i].Dst; if ((uint)d < (uint)pdef.Length) pdef[d] = i; }
+ Analyzer.PropagateNestedReads(state, ir, pdef, CurrentStackObjects, pesc);
+ }
+ }
+ Emitter.RebuildStructCanonical(); // mutated-object aliases share one struct local
+ if (Environment.GetEnvironmentVariable("AOT_ESCAPE_DEBUG") == "1" && CurrentStackObjects is { Count: > 0 } dbgso)
+ {
+ foreach (var (objId, lay) in dbgso)
+ {
+ Console.Error.WriteLine($"[stackobj] {methodName} v{objId} = new {state.NameOf(lay.ConstName)} -> {lay.StructType}");
+ }
+ }
+ // Float speculation needs class context (to know which ivars are statically int); without
+ // a defining class, pass null so ComputeUnboxing skips speculation entirely.
+ var knownFixnumIvars = definingClass is null ? null : Analyzer.CollectKnownFixnumIvars(state, definingClass);
+ // Float speculation only fires in classes that demonstrably use floats (per whole-program
+ // inference) — keeps it off all-integer classes (e.g. optcarrot) where it would mis-speculate
+ // int ivars and constant-deopt. No registry (unit tests) -> permit, preserving prior behavior.
+ var classUsesFloat = CurrentReturnTypes is null || CurrentReturnTypes.ClassUsesFloat(definingClass);
+ bool[] isLong, floatTaint, isDouble, provesDouble;
+ List? loopArgGuards = null;
+ CurrentProvesFixnum = null;
+ if (looping)
+ {
+ // Phase 2: sound MUST numeric typing over the cyclic loop IR. Storage stays boxed (loop-
+ // carried values are defined by the back-edge Move, which never writes a raw l/d local),
+ // but proven Float/Fixnum operands enable guard-free double arith (no per-op type
+ // dispatch). isLong/isDouble stay false (no raw locals); the win is provesDouble/
+ // provesFixnum + the mixed-numeric arith path. Speculation is OFF (deopt-in-loop unsafe);
+ // numerically-used args are Fixnum-guarded at entry (pre-side-effect, deopt-safe).
+ Analyzer.ComputeLoopUnboxing(state, ir, argCount, definingClass,
+ out provesDouble, out var provesFixnum, out floatTaint, out var soundProvenLoop, out loopArgGuards);
+ CurrentProvesFixnum = provesFixnum;
+ CurrentSoundProven = soundProvenLoop;
+ // Phase 3: promote purely-numeric loop values to raw double/long locals (FP/int
+ // registers) instead of boxed-but-guard-free MRubyValue. Move/LoadValue become
+ // representation-aware so the back-edge update and the pre-loop init write the raw local.
+ Analyzer.ComputeLoopRawLocals(state, ir, argCount, provesDouble, provesFixnum, out isLong, out isDouble);
+ }
+ else
+ {
+ isLong = Analyzer.ComputeUnboxing(state, ir, argCount, sc, out floatTaint, out isDouble, out provesDouble, out var soundProven, accessorRegistry, knownFixnumIvars, classUsesFloat, definingClass);
+ CurrentSoundProven = soundProven;
+ }
+ // Coalesce the many SSA temps into a minimal set of C# locals (source-size only; the JIT
+ // register-allocates regardless). Looping methods are where SSA produces the most temps;
+ // gated to them to keep non-looping output byte-identical. Scalar/stack-object emission has
+ // its own value->local naming, so skip coalescing when those are active.
+ // Array-literal scalar replacement: `[a,b,c]` that never escapes and is read/written only by
+ // constant index becomes per-element locals (no RArray alloc). Independent of object
+ // scalar/stack replacement (those are VirtualNew-only), so it composes with every path.
+ // Computed BEFORE coalescing so its value-ids (which become element locals, never a v{}) are
+ // excluded from the coalescing pool.
+ CurrentScalarArrays = ScalarArrayEnabled ? Analyzer.FindScalarArrays(state, ir) : null;
+ CurrentHashKeyTags = null;
+ CurrentScalarHashes = ScalarArrayEnabled ? Analyzer.FindScalarHashes(ir) : null;
+ CurrentLocalSlot = looping && sc is null && CurrentStackObjects is null && structParams is null
+ ? Analyzer.CoalesceLocals(ir, argCount, isLong, isDouble, CurrentScalarArrays, CurrentScalarHashes)
+ : null;
+ var ic = new InlineContext(state, definingClass, inlineRegistry, accessorRegistry, CurrentConstReturns, methodName, sym, ir);
+ var source = Emitter.EmitMethod(state, ir, irep, methodName, inlineRegistry, accessorRegistry,
+ sym, ic, sc, structParams, isLong, floatTaint, isDouble, provesDouble,
+ targets, loopArgGuards, looping, argCount, out var isLeaf);
+ if (source is null) return false; // an op bailed; LastBail is set
+ if (definingClass is not null) MeasureInlineSurface(state, ir, definingClass);
+ method = new CompiledMethod(methodName, source, argCount, instructions.Length, isLeaf, Emitter.BlockEmit.AuxMethods);
+ return true;
+ }
+
+ // Diagnostic only (no effect on emitted code): for each self-send in this compiled
+ // method, count whether it resolves to an RProc method on the defining class and
+ // whether that callee is a small splice candidate. Drives the inline build-out.
+ static void MeasureInlineSurface(MRubyState state, RubyIRMethod exe, RClass definingClass)
+ {
+ var instructions = exe.Instructions;
+ for (var i = 0; i < instructions.Length; i++)
+ {
+ if (instructions[i].OpCode != RubyIROpCode.SendSelf)
+ {
+ continue;
+ }
+
+ var methodSym = exe.GetCallSiteSymbol(instructions[i].Aux);
+ if (!state.TryFindMethod(definingClass, methodSym, out var method, out _) ||
+ method.Proc is not { } proc)
+ {
+ continue;
+ }
+
+ if (Analyzer.IsInlineCandidate(proc.Irep))
+ {
+ }
+ }
+ }
+
+ // Decide which value-ids can live as unboxed `long`. A value is Long iff it is (a) not
+ // self/an argument, (b) defined only by fixnum-arith ops (so it's provably fixnum given
+ // the deopt guards), (c) used ONLY as a fixnum operand of arith/comparison ops, and (d)
+ // NOT float-tainted. (c) guarantees a Long value never reaches a boxed context, so non-
+ // arith op emission is untouched and never needs to box it. (d) keeps known-float arith
+ // chains boxed so the dual fixnum/float emission handles them at runtime instead of the
+ // long path speculating fixnum and deopting on the first float. Conservative throughout.
+ //
+ // Float taint is a forward dataflow: seeds are float literals and float-returning sends
+ // (to_f / Math.sqrt etc.); it flows through Move and arith ops to their dst. Integer-only
+ // code (e.g. optcarrot) has no seeds, so nothing is tainted and long-unboxing is unchanged.
+ // Collect a coalesced local's representative id (set per method while emitting; null = identity).
+ [ThreadStatic] internal static int[]? CurrentLocalSlot;
+ internal static int Slot(int id) =>
+ CurrentLocalSlot is { } s && (uint)id < (uint)s.Length ? s[id] : id;
+
+ // --- array-literal scalar replacement (alloc elimination) ---
+ // A `[a, b, c]` (NewArray) value-id whose ONLY uses are constant-index `[]`/`[]=` in range and
+ // which never escapes (no Send arg/receiver, return, ivar/array store, Move, non-const index) is
+ // replaced by one boxed MRubyValue local PER ELEMENT (`av{id}_{k}`) — the RArray + its backing
+ // MRubyValue[] allocation are eliminated. Maps array value-id -> element count.
+ // alias value-id -> (canonical array id, element count). Every Move-alias of a scalar-replaced
+ // array maps to the same canonical id, whose element locals are `av{canon}_{k}`.
+ [ThreadStatic] internal static Dictionary? CurrentScalarArrays;
+ static bool ScalarArrayEnabled => Environment.GetEnvironmentVariable("AOT_NOSCALARARRAY") != "1";
+ internal static string ArrElem(int arrId, int index) => "av" + arrId + "_" + index;
+ internal static bool TryScalarArray(int id, out int canon, out int size)
+ {
+ if (CurrentScalarArrays is { } m && m.TryGetValue(id, out var info)) { canon = info.Canon; size = info.Size; return true; }
+ canon = 0; size = 0; return false;
+ }
+
+ // --- hash-literal scalar replacement (constant-key {k=>v}) ---
+ // A `{k0 => v0, ...}` whose keys are all CONSTANT (symbol/fixnum), accessed only by constant key,
+ // never escaping/iterated, is replaced by one boxed local per distinct key (`hv{canon}_{tag}`) —
+ // no RHash alloc, no [] / []= dispatch. A lookup of a key never present reads nil (Hash default).
+ [ThreadStatic] internal static Dictionary Keys)>? CurrentScalarHashes;
+ [ThreadStatic] internal static Dictionary? CurrentHashKeyTags; // const-key value-id -> tag
+ internal static string HashElem(int canon, string tag) => "hv" + canon + "_" + tag;
+ internal static bool TryScalarHash(int id, out int canon, out HashSet keys)
+ {
+ if (CurrentScalarHashes is { } m && m.TryGetValue(id, out var info)) { canon = info.Canon; keys = info.Keys; return true; }
+ canon = 0; keys = null!; return false;
+ }
+ // Emission-time tag of a constant key value-id (computed during detection).
+ internal static string? KeyTag(int keyId) =>
+ CurrentHashKeyTags is { } m && m.TryGetValue(keyId, out var t) ? t : null;
+
+ // Whole-program inferred return types (RubyIRReturnTypes), set per compile run. Null when no
+ // registry was supplied (e.g. unit tests) -> only the builtin float methods are recognized.
+ [ThreadStatic] internal static RubyIRReturnTypes.Registry? CurrentReturnTypes;
+ // Selector -> the immediate constant its 0-arg method returns (set per Compile run). Fingerprint-
+ // guarded at each call site, so it is sound even if stale across states.
+ [ThreadStatic] internal static IReadOnlyDictionary? CurrentConstReturns;
+
+ // Whole-program retention summary for AOT stack allocation (set per Compile run). Null when
+ // disabled (AOT_NOSTACKOBJ=1) or unavailable -> no stack-object classification.
+ [ThreadStatic] internal static RubyIREscapeSummary.Summary? CurrentEscapeSummary;
+
+ // Stack-object EMISSION (struct + specialized callee). Stage 1 verified end-to-end
+ // (ao -18% alloc / ~10% faster, checksum-identical on/off, 173/173 both ways), so it is ON by
+ // default now — gated by AOT_NOSTACKOBJ like CurrentEscapeSummary and the other AOT passes.
+ internal static bool StackObjEnabled => Environment.GetEnvironmentVariable("AOT_NOSTACKOBJ") != "1";
+
+ // Caller side: stack-allocated objects in the method being compiled (objId -> struct layout).
+ [ThreadStatic] internal static Dictionary? CurrentStackObjects;
+ // Variant side: when generating a specialized callee, the parameters passed as stack structs
+ // (paramIndex -> layout; in/ref decided by layout.Mutated). A send may pass several.
+ [ThreadStatic] static Dictionary? CurrentStructParams;
+ // Whole-program collectors (set per Compile run): struct types to declare + variants to emit.
+ [ThreadStatic] internal static Dictionary? NeededStructs; // ClassFp -> layout
+ [ThreadStatic] internal static Dictionary StructParams)>? NeededVariants; // variant name -> spec
+
+ // (Registry.ReturnsInteger is the symmetric "always returns Integer" proof — the foundation
+ // for unboxing a Send result to a long with no speculation/deopt — not wired into isLong yet.)
+
+ // value-id -> its LoadValue fixnum/float constant (set per emitted exe). A constant operand
+ // needs no `IsFixnum`/`IsFloat` guard and reads as a C# literal, not `.FixnumValue`/`.FloatValue`
+ // — so a `@x & 0xFFFFF` lowers to `v1.FixnumValue & 1048575L` with only v1 guarded. Sound: the
+ // emitted IR is single-def (SSA), so the id's only definition is that LoadValue.
+ [ThreadStatic] internal static Dictionary? CurrentConstLit;
+ internal static bool ConstFix(int id, out long v)
+ {
+ if (CurrentConstLit is { } m && m.TryGetValue(id, out var lit) && lit.IsFixnum) { v = lit.FixnumValue; return true; }
+ v = 0; return false;
+ }
+ internal static bool ConstFloat(int id, out double v)
+ {
+ if (CurrentConstLit is { } m && m.TryGetValue(id, out var lit) && lit.IsFloat) { v = lit.FloatValue; return true; }
+ v = 0; return false;
+ }
+
+ // Read value-id `id` as a long in a fixnum context: the constant literal if known, else l{id}
+ // if it lives unboxed, else the boxed value's FixnumValue (guarded separately by FixGuard).
+ internal static string FixRead(bool[] isLong, int id) =>
+ ConstFix(id, out var cv) ? cv + "L" : isLong[id] ? "l" + Slot(id) : "v" + Slot(id) + ".FixnumValue";
+
+ // Guard asserting `id` is a fixnum, or null when it needs none (a known constant, or unboxed long).
+ internal static string? FixGuard(bool[] isLong, int id) =>
+ ConstFix(id, out _) || isLong[id] ? null : "v" + Slot(id) + ".IsFixnum";
+
+
+ // Per-compile set of dsts whose provesDouble is a sound proof (no guard), set in TryCompileMethod
+ // alongside CurrentReturnTypes. Null in contexts that don't compute it (preserves the guard).
+ [ThreadStatic] internal static bool[]? CurrentSoundProven;
+
+ // Phase 2 (looping methods): per-id "always Fixnum" proof from the sound loop type lattice.
+ // Lets mixed Float/Fixnum arith read a boxed-but-proven-fixnum operand as `(double)v.FixnumValue`
+ // with no runtime type guard. Null for non-looping methods (no mixed-numeric fast path).
+ [ThreadStatic] internal static bool[]? CurrentProvesFixnum;
+
+ internal static bool TryReadMandatoryArgCount(Irep irep, out int argCount)
+ {
+ argCount = 0;
+ var seq = irep.Sequence;
+ if (seq.Length < 4 || (OpCode)seq[0] != OpCode.Enter)
+ {
+ return false;
+ }
+ var aspec = new ArgumentSpec(((uint)seq[1] << 16) | ((uint)seq[2] << 8) | seq[3]);
+ if (aspec.OptionalArgumentsCount != 0 || aspec.TakeRestArguments ||
+ aspec.MandatoryArguments2Count != 0 || aspec.KeywordArgumentsCount != 0 ||
+ aspec.TakeKeywordDict || aspec.TakeBlock)
+ {
+ return false;
+ }
+ argCount = aspec.MandatoryArguments1Count;
+ return true;
+ }
+
+ // A stack-allocated object's value-id refers to its struct local `so`; everything else is
+ // the boxed/unboxed local `v`. (An accidental boxed use of a stack obj -> `so` where an
+ // MRubyValue is expected -> a C# compile error, i.e. a loud failure, not silent miscompile.)
+ internal static string Val(int valueId) =>
+ Emitter.StructCanonical is { } sc && sc.TryGetValue(valueId, out var c) ? "so" + c :
+ CurrentStackObjects is { } so && so.ContainsKey(valueId) ? "so" + valueId : "v" + Slot(valueId);
+
+}
diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/ProgramResult.cs b/src/ChibiRuby.JetPack/Mrb2Cs/ProgramResult.cs
new file mode 100644
index 00000000..e95bceb4
--- /dev/null
+++ b/src/ChibiRuby.JetPack/Mrb2Cs/ProgramResult.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+
+namespace ChibiRuby.JetPack.Mrb2Cs;
+
+// The C# source for a whole compiled program, plus the (generated method name, irep
+// fingerprint) pairs a host binds to make a re-parse of the same bytecode hit the compiled body.
+public sealed class ProgramResult(string source, IReadOnlyList<(string Name, ulong Fingerprint)> methods)
+{
+ public string Source { get; } = source;
+ public IReadOnlyList<(string Name, ulong Fingerprint)> Methods { get; } = methods;
+}
diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/ScalarReplacement.cs b/src/ChibiRuby.JetPack/Mrb2Cs/ScalarReplacement.cs
new file mode 100644
index 00000000..4f97c2f4
--- /dev/null
+++ b/src/ChibiRuby.JetPack/Mrb2Cs/ScalarReplacement.cs
@@ -0,0 +1,414 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+namespace ChibiRuby.JetPack.Mrb2Cs;
+
+// A scalar-replaced allocation: a `Const.new(...)` whose object never escapes the
+// method, so it is not allocated — each of its fields becomes a C# local. initialize is
+// inlined (field local <- ctor arg) and trivial-accessor sends on the object (`o.x`,
+// `o.x=`) become direct field-local reads/writes. Validity (constant still the same
+// class, initialize + accessors not redefined) is guarded once at the new site.
+sealed class ScalarObject(int valueId, RClass klass, Symbol constName, ulong initFingerprint, int ctorArgCount)
+{
+ public int ValueId { get; } = valueId;
+ public RClass Klass { get; } = klass;
+ public Symbol ConstName { get; } = constName;
+ public ulong InitFingerprint { get; } = initFingerprint;
+ public int CtorArgCount { get; } = ctorArgCount;
+ public HashSet Aliases { get; } = [valueId];
+ // Field symbols in initialize order; FieldArg[i] = which ctor arg sets field i.
+ public List Fields { get; } = [];
+ public List FieldArg { get; } = [];
+ // method symbol -> (field index into Fields, isSetter, callee fingerprint).
+ public Dictionary Accessors { get; } = new();
+
+ public int FieldIndexOf(Symbol field)
+ {
+ for (var i = 0; i < Fields.Count; i++)
+ {
+ if (Fields[i] == field) return i;
+ }
+ return -1;
+ }
+}
+
+// Static escape analysis + scalar-replacement planning over one method's IR. Built
+// before emission; supplies field-local declarations and per-op emission for the
+// VirtualNew + accessor sends of every non-escaping, statically-typed allocation.
+sealed class ScalarContext
+{
+ internal readonly MRubyState state;
+ internal readonly string methodName;
+ internal readonly SymbolCache sym;
+ internal readonly Dictionary objects;
+ // Escaping `Const.new(args)` that can't be scalar-replaced but whose construction can be
+ // inlined (plain-Object class, simple `@f=arg` initialize): valueId -> initialize template.
+ internal readonly Dictionary fastNew;
+ internal int guardCount;
+ // Per (object,inlined-method) inline-cache fields for the validity guard.
+ internal readonly Dictionary<(int, Symbol), int> guardSlot = new();
+
+ ScalarContext(MRubyState state, string methodName, SymbolCache sym, Dictionary objects, Dictionary fastNew)
+ {
+ this.state = state;
+ this.methodName = methodName;
+ this.sym = sym;
+ this.objects = objects;
+ this.fastNew = fastNew;
+ }
+
+ public bool IsScalar(int valueId) => objects.ContainsKey(valueId);
+ public bool IsFastNew(int valueId) => fastNew.ContainsKey(valueId);
+
+ public ScalarObject GetScalarObject(int valueId) => objects[valueId];
+
+ // For a Send, report whether it is an accessor on a scalar object and which field.
+ public bool TryGetAccessorSend(RubyIRMethod exe, in RubyIRInstruction ins, out int objId, out int fieldIndex, out bool isSetter)
+ {
+ objId = -1; fieldIndex = -1; isSetter = false;
+ if (ins.OpCode is not RubyIROpCode.Send) return false;
+ if (!objects.TryGetValue(ins.Src0, out var o)) return false;
+ if (!o.Accessors.TryGetValue(exe.GetCallSiteSymbol(ins.Aux), out var acc)) return false;
+ objId = o.ValueId; fieldIndex = acc.FieldIndex; isSetter = acc.IsSetter;
+ return true;
+ }
+
+ // True when `ins` is an accessor send whose receiver is a scalar object (so it lowers
+ // to a field access, not a real call) — lets the caller keep the method leaf.
+ public bool IsAccessorSendOnScalar(in RubyIRInstruction ins) =>
+ (ins.OpCode is RubyIROpCode.Send) &&
+ objects.TryGetValue(ins.Src0, out var o) &&
+ o.Accessors.ContainsKey(SymbolOfSend(ins));
+
+ Symbol SymbolOfSend(in RubyIRInstruction ins) => sendSymbols![ins.Aux];
+ Symbol[]? sendSymbols;
+
+ public static ScalarContext? TryBuild(MRubyState state, RubyIRMethod exe, SymbolCache sym, string methodName)
+ {
+ if (Environment.GetEnvironmentVariable("AOT_NOSCALAR") == "1") return null;
+ var instructions = exe.Instructions;
+ // SSA single-definition map: value-id -> defining instruction index (-1 = arg/self).
+ var defIndex = new int[exe.ValueCount];
+ for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1;
+ for (var i = 0; i < instructions.Length; i++)
+ {
+ var d = instructions[i].Dst;
+ if ((uint)d < (uint)defIndex.Length) defIndex[d] = i;
+ }
+
+ var initCache = new Dictionary();
+ var objects = new Dictionary();
+ var fastNew = new Dictionary();
+ for (var i = 0; i < instructions.Length; i++)
+ {
+ if (instructions[i].OpCode != RubyIROpCode.VirtualNew) continue;
+ var obj = TryAnalyze(state, exe, instructions, defIndex, i, initCache);
+ if (obj is not null)
+ {
+ foreach (var alias in obj.Aliases)
+ {
+ objects[alias] = obj;
+ }
+ continue;
+ }
+ // Couldn't scalar-replace (the object escapes). If it's still a plain-Object
+ // class with a simple `@f=arg` initialize, inline the construction (alloc + ivar
+ // stores) to skip the `:new` + `:initialize` double dispatch.
+ var template = TryAnalyzeFastNew(state, exe, instructions, defIndex, i, initCache);
+ if (template is not null && !objects.ContainsKey(instructions[i].Dst))
+ {
+ fastNew[instructions[i].Dst] = template;
+ }
+ }
+ if (objects.Count == 0 && fastNew.Count == 0) return null;
+
+ var ctx = new ScalarContext(state, methodName, sym, objects, fastNew)
+ {
+ // Cache the per-callsite send symbols once for IsAccessorSendOnScalar.
+ sendSymbols = new Symbol[CallSiteUpperBound(instructions)]
+ };
+ foreach (var i in instructions)
+ {
+ if (RubyIROpInfo.IsSendOp(i.OpCode) || i.OpCode == RubyIROpCode.PureUnarySend)
+ {
+ ctx.sendSymbols[i.Aux] = exe.GetCallSiteSymbol(i.Aux);
+ }
+ }
+ return ctx;
+ }
+
+ static int CallSiteUpperBound(ReadOnlySpan ins)
+ {
+ var max = 0;
+ foreach (var i in ins)
+ {
+ if (RubyIROpInfo.IsSendOp(i.OpCode) || i.OpCode == RubyIROpCode.PureUnarySend)
+ {
+ if (i.Aux + 1 > max) max = i.Aux + 1;
+ }
+ }
+ return max;
+ }
+
+ static ScalarObject? TryAnalyze(
+ MRubyState state, RubyIRMethod exe, ReadOnlySpan ins,
+ int[] defIndex, int newIndex, Dictionary initCache)
+ {
+ var newIns = ins[newIndex];
+ var objId = newIns.Dst;
+ foreach (var captured in exe.ClosureCapturedValueIds)
+ {
+ if (captured == objId) return null;
+ }
+ // Receiver of `.new` must trace to a GetConstant we can resolve to a class now.
+ var classVid = newIns.Src0;
+ if ((uint)classVid >= (uint)defIndex.Length || defIndex[classVid] < 0) return null;
+ var classDef = ins[defIndex[classVid]];
+ if (classDef.OpCode != RubyIROpCode.GetConstant) return null;
+ var constSym = exe.GetSymbol(classDef.Aux);
+ if (!state.TryGetConst(constSym, out var classValue) ||
+ classValue.Object is not RClass klass)
+ {
+ return null;
+ }
+
+ // initialize must be a simple `@field_i = param_i` setter (cached per class).
+ var template = AnalyzeInitialize(state, klass, constSym, initCache);
+ if (template is null) return null;
+
+ var newArgc = exe.GetCallSiteArgumentCount(newIns.Aux);
+ if (newArgc != template.CtorArgCount) return null;
+
+ var obj = new ScalarObject(objId, klass, constSym, template.InitFingerprint, template.CtorArgCount);
+ obj.Fields.AddRange(template.Fields);
+ obj.FieldArg.AddRange(template.FieldArg);
+
+ // Escape analysis: every use of objId (and aliases created by SSA moves, such as
+ // an inlined callee's return slot) must be a trivial accessor/direct field access.
+ // Anything else escapes.
+ var changed = true;
+ while (changed)
+ {
+ changed = false;
+ for (var j = 0; j < ins.Length; j++)
+ {
+ if (j == newIndex) continue;
+ var u = ins[j];
+ var allowSrc0 = false;
+ switch (u.OpCode)
+ {
+ case RubyIROpCode.Move when obj.Aliases.Contains(u.Src0):
+ {
+ if ((uint)u.Dst >= (uint)defIndex.Length) return null;
+ foreach (var captured in exe.ClosureCapturedValueIds)
+ {
+ if (captured == u.Dst) return null;
+ }
+ if (obj.Aliases.Add(u.Dst)) changed = true;
+ allowSrc0 = true;
+ break;
+ }
+ case RubyIROpCode.Send when obj.Aliases.Contains(u.Src0):
+ {
+ var msym = exe.GetCallSiteSymbol(u.Aux);
+ var acc = RecognizeAccessor(state, klass, msym);
+ if (acc is null) return null;
+ var fieldIdx = obj.FieldIndexOf(acc.Value.Field);
+ if (fieldIdx < 0) return null;
+ var argc = exe.GetCallSiteArgumentCount(u.Aux);
+ if (acc.Value.IsSetter ? argc != 1 : argc != 0) return null;
+ obj.Accessors[msym] = (fieldIdx, acc.Value.IsSetter, acc.Value.Fingerprint);
+ allowSrc0 = true;
+ break;
+ }
+ default:
+ {
+ if (IsFieldAccessOnScalar(exe, u, obj) || u.OpCode == RubyIROpCode.GuardInlineClass && obj.Aliases.Contains(u.Src0))
+ {
+ allowSrc0 = true;
+ }
+
+ break;
+ }
+ }
+
+ if (ObjectAppears(exe, u, obj.Aliases, allowSrc0)) return null;
+ }
+ }
+
+ return obj;
+ }
+
+ static bool IsFieldAccessOnScalar(RubyIRMethod exe, in RubyIRInstruction ins, ScalarObject obj)
+ {
+ if (!obj.Aliases.Contains(ins.Src0) ||
+ ins.OpCode is not (
+ RubyIROpCode.GetInstanceVariable or
+ RubyIROpCode.VirtualGetField or
+ RubyIROpCode.SetInstanceVariable or
+ RubyIROpCode.VirtualSetField))
+ {
+ return false;
+ }
+
+ return obj.FieldIndexOf(exe.GetSymbol(ins.Aux)) >= 0;
+ }
+
+ // True if objId appears in any operand slot of `u` other than (optionally) Src0.
+ static bool ObjectAppears(RubyIRMethod exe, in RubyIRInstruction u, HashSet aliases, bool allowSrc0)
+ {
+ if (!allowSrc0 && aliases.Contains(u.Src0)) return true;
+ if (u.OpCode != RubyIROpCode.GuardInlineClass &&
+ (aliases.Contains(u.Src1) || aliases.Contains(u.Src2))) return true;
+ if (RubyIROpInfo.IsSendOp(u.OpCode) || u.OpCode is RubyIROpCode.PureUnarySend or RubyIROpCode.VirtualNew)
+ {
+ // VirtualNew carries ctor args in a callsite too: an object passed as a
+ // constructor argument escapes (the new object may retain it).
+ var argc = exe.GetCallSiteArgumentCount(u.Aux);
+ for (var a = 0; a < argc; a++)
+ {
+ if (aliases.Contains(exe.GetCallSiteArgumentValueId(u.Aux, a))) return true;
+ }
+ }
+ else if (u.OpCode is RubyIROpCode.NewArray or RubyIROpCode.NewArray2 or RubyIROpCode.NewHash)
+ {
+ var c = exe.GetOperandListCount(u.Aux);
+ for (var a = 0; a < c; a++)
+ {
+ if (aliases.Contains(exe.GetOperandListValueId(u.Aux, a))) return true;
+ }
+ }
+ return false;
+ }
+
+ // A `Const.new(args)` whose construction can be inlined even though the object escapes:
+ // the constant resolves to a plain-Object class with a simple `@f=arg` initialize and the
+ // arity matches. Returns the initialize template (shared/cached) or null.
+ static ScalarObject? TryAnalyzeFastNew(
+ MRubyState state, RubyIRMethod exe, ReadOnlySpan ins,
+ int[] defIndex, int newIndex, Dictionary initCache)
+ {
+ var newIns = ins[newIndex];
+ var classVid = newIns.Src0;
+ if ((uint)classVid >= (uint)defIndex.Length || defIndex[classVid] < 0) return null;
+ var classDef = ins[defIndex[classVid]];
+ if (classDef.OpCode != RubyIROpCode.GetConstant) return null;
+ var constSym = exe.GetSymbol(classDef.Aux);
+ if (!state.TryGetConst(constSym, out var classValue) ||
+ classValue.Object is not RClass klass ||
+ klass.InstanceVType != MRubyVType.Object)
+ {
+ return null;
+ }
+ var template = AnalyzeInitialize(state, klass, constSym, initCache);
+ if (template is null || exe.GetCallSiteArgumentCount(newIns.Aux) != template.CtorArgCount) return null;
+ return template;
+ }
+
+ // Recognize `def initialize(a, b, ...); @f1 = a; @f2 = b; ...; end` — every mandatory
+ // param stored to one field on self, nothing else. Returns a template (field order +
+ // arg mapping + fingerprint) or null. Cached per class.
+ // The struct layout for a stack-allocatable class: field symbols (initialize order), per
+ // field which ctor arg sets it, the initialize fingerprint, and arg count. Null if the
+ // class has no simple `@f=arg` initialize. Reuses AnalyzeInitialize.
+ internal static (System.Collections.Generic.List Fields, System.Collections.Generic.List FieldArg, ulong InitFingerprint, int CtorArgCount)?
+ GetStackLayout(MRubyState state, RClass klass, Symbol constName)
+ {
+ var o = AnalyzeInitialize(state, klass, constName, new Dictionary());
+ return o is null ? null : (o.Fields, o.FieldArg, o.InitFingerprint, o.CtorArgCount);
+ }
+
+ static ScalarObject? AnalyzeInitialize(MRubyState state, RClass klass, Symbol constName, Dictionary cache)
+ {
+ if (cache.TryGetValue(klass, out var cached)) return cached;
+ cache[klass] = null; // guard against initialize that re-news the same class (recursion)
+
+ ScalarObject? result = null;
+ if (state.TryFindMethod(klass, state.Intern("initialize"u8), out var method, out _) &&
+ method.Proc is { } proc &&
+ Mrb2CsCompiler.TryReadMandatoryArgCount(proc.Irep, out var iargc) &&
+ iargc > 0)
+ {
+ RubyIRMethod? exeI;
+ try { exeI = RubyIRBuilder.Build(proc.Irep, 0, out _); }
+ catch { exeI = null; }
+ if (exeI is not null)
+ {
+ var obj = new ScalarObject(0, klass, constName, state.ComputeIrepFingerprint(proc.Irep), iargc);
+ var selfVids = new HashSet { 0 };
+ foreach (var li in exeI.Instructions)
+ {
+ if (li.OpCode == RubyIROpCode.LoadSelf) selfVids.Add(li.Dst);
+ }
+ var ok = true;
+ foreach (var ii in exeI.Instructions)
+ {
+ switch (ii.OpCode)
+ {
+ case RubyIROpCode.CheckArity:
+ case RubyIROpCode.LoadSelf:
+ case RubyIROpCode.Return:
+ case RubyIROpCode.ReturnValue:
+ case RubyIROpCode.ReturnSelf:
+ break;
+ case RubyIROpCode.SetInstanceVariable:
+ case RubyIROpCode.VirtualSetField:
+ if (!selfVids.Contains(ii.Src0)) { ok = false; break; }
+ var valueId = ii.Src1;
+ if (valueId < 1 || valueId > iargc) { ok = false; break; }
+ obj.Fields.Add(exeI.GetSymbol(ii.Aux));
+ obj.FieldArg.Add(valueId - 1);
+ break;
+ default:
+ ok = false;
+ break;
+ }
+ if (!ok) break;
+ }
+ if (ok && obj.Fields.Count > 0) result = obj;
+ }
+ }
+ cache[klass] = result;
+ return result;
+ }
+
+ // Recognize a trivial getter/setter method `msym` on `klass`. Returns (field, isSetter,
+ // fingerprint) or null. Delegates to the irep-based recognizer.
+ static (Symbol Field, bool IsSetter, ulong Fingerprint)? RecognizeAccessor(MRubyState state, RClass klass, Symbol msym)
+ {
+ if (!state.TryFindMethod(klass, msym, out var method, out _) || method.Proc is not { } proc) return null;
+ return Analyzer.TryRecognizeTrivialAccessor(state, proc.Irep);
+ }
+
+ // Field-local name for field `fieldIndex` of scalar object `objId`.
+ internal static string FieldLocal(int objId, int fieldIndex) => "so" + objId + "_" + fieldIndex;
+
+ public bool TryEmitScalarMove(in RubyIRInstruction ins)
+ {
+ return ins.OpCode == RubyIROpCode.Move &&
+ objects.TryGetValue(ins.Src0, out var src) &&
+ objects.TryGetValue(ins.Dst, out var dst) &&
+ ReferenceEquals(src, dst);
+ }
+
+ public bool TryGetScalarFieldAccess(RubyIRMethod exe, in RubyIRInstruction ins, out int objId, out int fieldIndex, out bool isSetter)
+ {
+ objId = -1; fieldIndex = -1; isSetter = false;
+ if (ins.OpCode is not (
+ RubyIROpCode.GetInstanceVariable or
+ RubyIROpCode.VirtualGetField or
+ RubyIROpCode.SetInstanceVariable or
+ RubyIROpCode.VirtualSetField))
+ {
+ return false;
+ }
+ if (!objects.TryGetValue(ins.Src0, out var o)) return false;
+ var idx = o.FieldIndexOf(exe.GetSymbol(ins.Aux));
+ if (idx < 0) return false;
+ objId = o.ValueId;
+ fieldIndex = idx;
+ isSetter = ins.OpCode is RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField;
+ return true;
+ }
+
+}
diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/StackLayout.cs b/src/ChibiRuby.JetPack/Mrb2Cs/StackLayout.cs
new file mode 100644
index 00000000..bd8c35e8
--- /dev/null
+++ b/src/ChibiRuby.JetPack/Mrb2Cs/StackLayout.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Generic;
+using ChibiRuby;
+
+namespace ChibiRuby.JetPack.Mrb2Cs;
+
+// Per-field representation of a stack struct. Stage 1 emits all Boxed; ② fills Double/Long
+// (unboxed FP across calls), ① fills Nested (Vec-in-Ray etc.).
+internal enum StackFieldKind { Boxed, Double, Long, Nested }
+
+// The C# struct layout for a stack-allocatable class. Forward-compatible with ①(nested)/②(typed).
+internal sealed class StackLayout
+{
+ public required RClass Cls;
+ public required ulong ClassFp; // identity for the struct type name + variant name
+ public required Symbol ConstName;
+ public required ulong InitFingerprint;
+ public required List Fields; // ivar symbols, initialize order
+ public required List FieldArg; // field i <- ctor arg FieldArg[i], or -1 if literal
+ public required List FieldLiteral; // field i <- this literal when FieldArg[i] == -1
+ public required List FieldKinds;
+ public required List FieldNested; // non-null when FieldKinds[i]==Nested (②/①)
+ public bool Mutated; // a setter is called on it -> pass by ref (①)
+ public string NameSuffix = ""; // `_RubyClassName`, so the struct type reads back to source
+ public string StructType => "Stk_" + ClassFp.ToString("x16") + NameSuffix;
+ public int FieldIndexOf(Symbol f) { for (var i = 0; i < Fields.Count; i++) if (Fields[i] == f) return i; return -1; }
+ public string CsFieldType(int i) => FieldKinds[i] switch
+ {
+ StackFieldKind.Double => "double",
+ StackFieldKind.Long => "long",
+ StackFieldKind.Nested => FieldNested[i]!.StructType,
+ _ => "global::ChibiRuby.MRubyValue",
+ };
+}
diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/SymbolCache.cs b/src/ChibiRuby.JetPack/Mrb2Cs/SymbolCache.cs
new file mode 100644
index 00000000..b607e9ac
--- /dev/null
+++ b/src/ChibiRuby.JetPack/Mrb2Cs/SymbolCache.cs
@@ -0,0 +1,41 @@
+using System.Collections.Generic;
+namespace ChibiRuby.JetPack.Mrb2Cs;
+
+// Interns each distinct symbol literal once into a per-method static field. Pure data + slot
+// allocation: assigns each distinct literal a stable slot and a readable C# field name, and hands
+// that data back to callers. Turning the slots into actual C# field declarations / the intern
+// prologue is the Emitter's job (Emitter.EmitSymbolFields / EmitSymbolInit).
+sealed class SymbolCache(string methodName)
+{
+ readonly Dictionary slots = new();
+ readonly List literals = [];
+ readonly List fieldNames = []; // slot -> static field name (slot + readable name)
+
+ // Returns the static field expression for `stringLiteral`, allocating a slot.
+ public string Reference(string stringLiteral)
+ {
+ if (!slots.TryGetValue(stringLiteral, out var slot))
+ {
+ slot = literals.Count;
+ slots[stringLiteral] = slot;
+ literals.Add(stringLiteral);
+ // The slot index keeps the name unique (two symbols that sanitize to the same
+ // token still get distinct slots); the suffix just makes it readable.
+ fieldNames.Add(methodName + "__sym" + slot + SanitizedSuffix(stringLiteral));
+ }
+ return fieldNames[slot];
+ }
+
+ public int Count => literals.Count;
+
+ // Data exposed for the Emitter to turn into field declarations / the intern prologue.
+ public string MethodName => methodName;
+ public IReadOnlyList FieldNames => fieldNames;
+ public IReadOnlyList Literals => literals;
+
+ // Build a readable `_name` suffix from a C# string literal of a Ruby symbol, mapping
+ // characters that aren't legal in a C# identifier to lowercase words (e.g. `@x` -> `_at_x`,
+ // `empty?` -> `_empty_q`, `<=>` -> `_lt_eq_gt`). The slot index already guarantees uniqueness;
+ // an all-illegal name (e.g. `+`) still gets a readable token, never an empty suffix.
+ static string SanitizedSuffix(string stringLiteral) => "_" + Emitter.SanitizeToIdentifier(Emitter.RawName(stringLiteral));
+}
diff --git a/src/ChibiRuby.JetPack/RubyIR/RubyIRAuxData.cs b/src/ChibiRuby.JetPack/RubyIR/RubyIRAuxData.cs
new file mode 100644
index 00000000..6e28ea0c
--- /dev/null
+++ b/src/ChibiRuby.JetPack/RubyIR/RubyIRAuxData.cs
@@ -0,0 +1,39 @@
+namespace ChibiRuby.JetPack;
+
+struct RubyIRCallSite(int symbolIndex, int argumentStart, int argumentCount)
+{
+ public readonly int SymbolIndex = symbolIndex;
+ public readonly int ArgumentStart = argumentStart;
+ public readonly int ArgumentCount = argumentCount;
+ RClass? guardInlineReceiverClass;
+ int guardInlineMethodCacheVersion;
+ ulong guardInlineCalleeFingerprint;
+
+ // Guard metadata for an SSA-spliced inline body. The GuardInlineClass
+ // instruction reads this off the cold-path Send's call site to decide whether
+ // the receiver still matches the class/method shape the body was specialized
+ // for; on a miss it deopts to that same Send.
+ public void SetGuardInline(RClass receiverClass, int currentMethodCacheVersion, ulong calleeFingerprint = 0)
+ {
+ guardInlineReceiverClass = receiverClass;
+ guardInlineMethodCacheVersion = currentMethodCacheVersion;
+ guardInlineCalleeFingerprint = calleeFingerprint;
+ }
+
+ public bool TryGetGuardInline(out ulong calleeFingerprint)
+ {
+ if (guardInlineReceiverClass is { } guardedClass)
+ {
+ calleeFingerprint = guardInlineCalleeFingerprint;
+ return true;
+ }
+ calleeFingerprint = 0;
+ return false;
+ }
+}
+
+readonly struct RubyIROperandList(int operandStart, int operandCount)
+{
+ public readonly int OperandStart = operandStart;
+ public readonly int OperandCount = operandCount;
+}
diff --git a/src/ChibiRuby.JetPack/RubyIR/RubyIRBuildFailure.cs b/src/ChibiRuby.JetPack/RubyIR/RubyIRBuildFailure.cs
new file mode 100644
index 00000000..95e4abe0
--- /dev/null
+++ b/src/ChibiRuby.JetPack/RubyIR/RubyIRBuildFailure.cs
@@ -0,0 +1,11 @@
+using ChibiRuby;
+namespace ChibiRuby.JetPack;
+
+readonly struct RubyIRBuildFailure(OpCode? opCode, int programCounter, string reason)
+{
+ public readonly OpCode? OpCode = opCode;
+ public readonly int ProgramCounter = programCounter;
+ public readonly string Reason = reason;
+
+ public static RubyIRBuildFailure None => new(null, -1, string.Empty);
+}
diff --git a/src/ChibiRuby.JetPack/RubyIR/RubyIRBuilder.cs b/src/ChibiRuby.JetPack/RubyIR/RubyIRBuilder.cs
new file mode 100644
index 00000000..0637b3a9
--- /dev/null
+++ b/src/ChibiRuby.JetPack/RubyIR/RubyIRBuilder.cs
@@ -0,0 +1,2621 @@
+using System;
+using System.Collections.Generic;
+using ChibiRuby.Internals;
+
+using ChibiRuby;
+using ChibiRuby.JetPack.Mrb2Cs;
+namespace ChibiRuby.JetPack;
+
+// One planned inline at a caller Send site. `Callee` is the body to splice in.
+// When `GuardClass` is null the splice is unguarded (the receiver class is fixed
+// by construction — used by isolation tests). When it is set, the splice is
+// emitted behind a GuardInlineClass that deopts to the original Send on a class /
+// method-cache-version miss, so the inline is safe in profile-driven production.
+readonly struct RubyIRInlineSite(
+ Irep callee,
+ RClass? guardClass = null,
+ int guardMethodCacheVersion = 0,
+ ulong guardMethodFingerprint = 0,
+ bool guardMissDeopts = false)
+{
+ public readonly Irep Callee = callee;
+ public readonly RClass? GuardClass = guardClass;
+ public readonly int GuardMethodCacheVersion = guardMethodCacheVersion;
+ public readonly ulong GuardMethodFingerprint = guardMethodFingerprint;
+ public readonly bool GuardMissDeopts = guardMissDeopts;
+}
+
+// Builds RubyIR (a high-level, type-inference-oriented IR) from mruby bytecode. `Build` is the
+// entry point for the AOT codegen / analyses; it is NOT a high->low "lowering" and the result is
+// NOT for execution — it is an analyzable IR. The bytecode walk emits the analysis-friendly op
+// shapes directly (VirtualNew / VirtualGetField / VirtualSetField); the only post-build step is a
+// multiply+add peephole fusion.
+static class RubyIRBuilder
+{
+ // Build RubyIR from bytecode. Returns null (with `failure`) if the bytecode shape is
+ // unsupported. The bytecode walk already emits the analysis-friendly shapes directly
+ // (VirtualNew for `.new`, VirtualGetField/VirtualSetField for ivars); the only post-build
+ // step is the multiply+add peephole fusion.
+ public static RubyIRMethod? Build(Irep irep, int programCounter, out RubyIRBuildFailure failure)
+ {
+ var ir = TryCompile(irep, programCounter, out failure);
+ return ir is null ? null : FuseMultiplyAdd(ir);
+ }
+
+ // Build RubyIR with an inline plan (splice the named callees at their caller Send sites).
+ public static RubyIRMethod? Build(
+ Irep irep,
+ int programCounter,
+ Dictionary? inlinePlan,
+ out RubyIRBuildFailure failure)
+ {
+ var ir = TryCompile(irep, programCounter, inlinePlan, out failure);
+ return ir is null ? null : FuseMultiplyAdd(ir);
+ }
+
+ // Peephole: fuse `Mul` immediately followed by an `Add`/`Sub` that consumes it (single-use,
+ // not a branch target) into MulAdd / MulSub / SubMul. A pure optimization (fewer ops, and the
+ // double-unbox path emits it as one a*b±c expression); the codegen handles the unfused form too.
+ static RubyIRMethod FuseMultiplyAdd(RubyIRMethod ir)
+ {
+ var rewritten = FuseMultiplyAddInstructions(ir.Instructions.ToArray(), ir, out var oldToNew);
+ if (oldToNew is null)
+ {
+ return ir;
+ }
+
+ return ir.CreateVariant(
+ rewritten,
+ loweredSourceIrep: ir.SourceIrep,
+ loweredSourceBytecodePcs: RemapSourceBytecodePcs(ir, oldToNew));
+ }
+
+ static RubyIRInstruction[] FuseMultiplyAddInstructions(
+ RubyIRInstruction[] source,
+ RubyIRMethod ir,
+ out int[]? oldToNewInstructionIndexes)
+ {
+ oldToNewInstructionIndexes = null;
+ var useCounts = ir.CountValueUses(source);
+ var branchTargets = ComputeBranchTargetInstructionIndexes(source);
+ var changed = false;
+ var removed = new bool[source.Length];
+
+ for (var i = 0; i + 1 < source.Length; i++)
+ {
+ var multiply = source[i];
+ var consumer = source[i + 1];
+ if (multiply.OpCode != RubyIROpCode.Mul ||
+ branchTargets[i + 1] ||
+ useCounts[multiply.Dst] != 1 ||
+ consumer.OpCode != RubyIROpCode.Add &&
+ consumer.OpCode != RubyIROpCode.Sub ||
+ consumer.Src0 != multiply.Dst &&
+ consumer.Src1 != multiply.Dst)
+ {
+ continue;
+ }
+
+ var addend = consumer.Src0 == multiply.Dst
+ ? consumer.Src1
+ : consumer.Src0;
+ var fusedOpCode = consumer.OpCode == RubyIROpCode.Add
+ ? RubyIROpCode.MulAdd
+ : consumer.Src0 == multiply.Dst
+ ? RubyIROpCode.MulSub
+ : RubyIROpCode.SubMul;
+ source[i + 1] = new RubyIRInstruction(
+ fusedOpCode,
+ consumer.Dst,
+ multiply.Src0,
+ multiply.Src1,
+ addend,
+ consumer.Aux);
+ removed[i] = true;
+ changed = true;
+ }
+
+ if (!changed)
+ {
+ return source;
+ }
+
+ oldToNewInstructionIndexes = new int[source.Length + 1];
+ var newLength = 0;
+ for (var i = 0; i < source.Length; i++)
+ {
+ oldToNewInstructionIndexes[i] = newLength;
+ if (!removed[i])
+ {
+ newLength++;
+ }
+ }
+ oldToNewInstructionIndexes[source.Length] = newLength;
+
+ var rewritten = new RubyIRInstruction[newLength];
+ var rewrittenIndex = 0;
+ for (var i = 0; i < source.Length; i++)
+ {
+ if (removed[i])
+ {
+ continue;
+ }
+
+ rewritten[rewrittenIndex++] = RemapBranchTarget(source[i], oldToNewInstructionIndexes);
+ }
+
+ return rewritten;
+ }
+
+ static int[]? RemapSourceBytecodePcs(RubyIRMethod ir, int[] oldToNewInstructionIndexes)
+ {
+ if (ir.SourceIrep is null)
+ {
+ return null;
+ }
+
+ var remapped = new int[oldToNewInstructionIndexes[^1]];
+ for (var oldIndex = 0; oldIndex + 1 < oldToNewInstructionIndexes.Length; oldIndex++)
+ {
+ var newIndex = oldToNewInstructionIndexes[oldIndex];
+ if (oldToNewInstructionIndexes[oldIndex + 1] == newIndex)
+ {
+ continue;
+ }
+
+ remapped[newIndex] = ir.SourceBytecodePc(oldIndex);
+ }
+
+ return remapped;
+ }
+
+ static bool[] ComputeBranchTargetInstructionIndexes(ReadOnlySpan instructions)
+ {
+ var branchTargets = new bool[instructions.Length];
+ for (var i = 0; i < instructions.Length; i++)
+ {
+ var instruction = instructions[i];
+ if (instruction.OpCode is (
+ RubyIROpCode.Jump or
+ RubyIROpCode.JumpIfTruthy or
+ RubyIROpCode.JumpIfFalsy or
+ RubyIROpCode.JumpIfNil or
+ RubyIROpCode.GuardInlineClass) &&
+ (uint)instruction.Aux < (uint)branchTargets.Length)
+ {
+ branchTargets[instruction.Aux] = true;
+ }
+ }
+
+ return branchTargets;
+ }
+
+ static RubyIRInstruction RemapBranchTarget(
+ RubyIRInstruction instruction,
+ int[] oldToNewInstructionIndexes)
+ {
+ if (instruction.OpCode is not (
+ RubyIROpCode.Jump or
+ RubyIROpCode.JumpIfTruthy or
+ RubyIROpCode.JumpIfFalsy or
+ RubyIROpCode.JumpIfNil or
+ RubyIROpCode.GuardInlineClass) ||
+ (uint)instruction.Aux >= (uint)oldToNewInstructionIndexes.Length)
+ {
+ return instruction;
+ }
+
+ var target = oldToNewInstructionIndexes[instruction.Aux];
+ return target == instruction.Aux
+ ? instruction
+ : new RubyIRInstruction(
+ instruction.OpCode,
+ instruction.Dst,
+ instruction.Src0,
+ instruction.Src1,
+ instruction.Src2,
+ target);
+ }
+
+ public static RubyIRMethod? TryCompile(Irep irep, int entryPoint) =>
+ TryCompile(irep, entryPoint, out _);
+
+ public static bool ContainsSelfRecursiveSend(Irep irep, Symbol methodId)
+ {
+ if (methodId.Value == 0)
+ {
+ return false;
+ }
+
+ var sequence = irep.Sequence;
+ var symbols = irep.Symbols;
+ var pc = 0;
+ while (pc < sequence.Length)
+ {
+ var opCode = (OpCode)sequence[pc];
+ switch (opCode)
+ {
+ case OpCode.SSend:
+ if (sequence[pc + 2] < symbols.Length && symbols[sequence[pc + 2]] == methodId)
+ {
+ return true;
+ }
+ break;
+ case OpCode.SSend0:
+ if (sequence[pc + 2] < symbols.Length && symbols[sequence[pc + 2]] == methodId)
+ {
+ return true;
+ }
+ break;
+ }
+
+ if (!TryGetInstructionLength(sequence, pc, opCode, out var length))
+ {
+ return false;
+ }
+ pc += length;
+ }
+
+ return false;
+ }
+
+ static bool TryAnalyzeClosureCaptures(
+ Irep irep,
+ out bool[] closureCapturedRegisters,
+ out RubyIRBuildFailure failure)
+ {
+ closureCapturedRegisters = new bool[irep.RegisterVariableCount];
+ failure = RubyIRBuildFailure.None;
+
+ var sequence = irep.Sequence;
+ var pc = 0;
+ while (pc < sequence.Length)
+ {
+ var opCode = (OpCode)sequence[pc];
+ if (!TryGetInstructionLength(sequence, pc, opCode, out var length))
+ {
+ failure = new RubyIRBuildFailure(opCode, pc, "unsupported opcode");
+ return false;
+ }
+
+ if (opCode is OpCode.Block)
+ {
+ var childIndex = sequence[pc + 2];
+ if (childIndex >= irep.Children.Length)
+ {
+ failure = new RubyIRBuildFailure(opCode, pc, "child irep out of range");
+ return false;
+ }
+
+ MarkDescendantUpVars(irep.Children[childIndex], 0, closureCapturedRegisters);
+ }
+ pc += length;
+ }
+
+ return true;
+ }
+
+ static void MarkDescendantUpVars(Irep irep, int depthToAncestor, bool[] ancestorRegisters)
+ {
+ var sequence = irep.Sequence;
+ var pc = 0;
+ while (pc < sequence.Length)
+ {
+ var opCode = (OpCode)sequence[pc];
+ if (!TryGetInstructionLengthForControlScan(sequence, pc, opCode, out var length))
+ {
+ return;
+ }
+
+ if (opCode is OpCode.GetUpVar or OpCode.SetUpVar &&
+ sequence[pc + 3] == depthToAncestor)
+ {
+ var register = sequence[pc + 2];
+ if (register < ancestorRegisters.Length)
+ {
+ ancestorRegisters[register] = true;
+ }
+ }
+ else if (opCode is OpCode.Block)
+ {
+ var childIndex = sequence[pc + 2];
+ if (childIndex < irep.Children.Length)
+ {
+ MarkDescendantUpVars(
+ irep.Children[childIndex],
+ depthToAncestor + 1,
+ ancestorRegisters);
+ }
+ }
+
+ pc += length;
+ }
+ }
+
+ internal static bool BlockChildNeedsBytecodeBoundary(Irep irep, out string reason)
+ {
+ reason = string.Empty;
+ var sequence = irep.Sequence;
+ var pc = 0;
+ while (pc < sequence.Length)
+ {
+ var opCode = (OpCode)sequence[pc];
+ if (opCode is OpCode.Break or OpCode.ReturnBlk or OpCode.JmpUw)
+ {
+ reason = "non-local block control flow";
+ return true;
+ }
+
+ if (!TryGetInstructionLengthForControlScan(sequence, pc, opCode, out var length))
+ {
+ reason = "unsupported block child opcode";
+ return true;
+ }
+ pc += length;
+ }
+
+ foreach (var child in irep.Children)
+ {
+ if (BlockChildNeedsBytecodeBoundary(child, out reason))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ internal static bool BlockDescendantsContainNewObject(Irep[] children)
+ {
+ for (var i = 0; i < children.Length; i++)
+ {
+ if (IrepContainsNewObjectSend(children[i]))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ static bool IrepContainsNewObjectSend(Irep irep)
+ {
+ var sequence = irep.Sequence;
+ var symbols = irep.Symbols;
+ var pc = 0;
+ while (pc < sequence.Length)
+ {
+ var opCode = (OpCode)sequence[pc];
+ if (opCode is
+ OpCode.Send or
+ OpCode.SSend or
+ OpCode.SendB or
+ OpCode.SSendB or
+ OpCode.Send0 or
+ OpCode.SSend0)
+ {
+ var symbolIndex = sequence[pc + 2];
+ if ((uint)symbolIndex < (uint)symbols.Length &&
+ symbols[symbolIndex] == Names.New)
+ {
+ return true;
+ }
+ }
+ else if (opCode is OpCode.Block)
+ {
+ var childIndex = sequence[pc + 2];
+ if ((uint)childIndex < (uint)irep.Children.Length &&
+ IrepContainsNewObjectSend(irep.Children[childIndex]))
+ {
+ return true;
+ }
+ }
+
+ if (!TryGetInstructionLengthForControlScan(sequence, pc, opCode, out var length))
+ {
+ return true;
+ }
+ pc += length;
+ }
+
+ return false;
+ }
+
+ public static RubyIRMethod? TryCompile(Irep irep, int entryPoint, out RubyIRBuildFailure failure) =>
+ TryCompile(irep, entryPoint, (Dictionary?)null, out failure);
+
+ public static RubyIRMethod? TryCompile(
+ Irep irep,
+ int entryPoint,
+ Dictionary? inlinePlan,
+ out RubyIRBuildFailure failure)
+ {
+ Dictionary? sites = null;
+ if (inlinePlan is not null)
+ {
+ sites = new Dictionary(inlinePlan.Count);
+ foreach (var (pc, callee) in inlinePlan)
+ {
+ sites[pc] = new RubyIRInlineSite(callee);
+ }
+ }
+
+ return TryCompile(irep, entryPoint, sites, out failure);
+ }
+
+ public static RubyIRMethod? TryCompile(
+ Irep irep,
+ int entryPoint,
+ Dictionary? inlinePlan,
+ out RubyIRBuildFailure failure)
+ {
+ failure = RubyIRBuildFailure.None;
+
+ if (entryPoint != 0)
+ {
+ failure = new RubyIRBuildFailure(null, entryPoint, "non-zero entry point");
+ return null;
+ }
+
+ if (irep.CatchHandlers.Length != 0)
+ {
+ failure = new RubyIRBuildFailure(null, entryPoint, "catch handler");
+ return null;
+ }
+
+ if (!AnalyzeForwardBranches(irep, out var mergeSlotRegisters, out var hasBackwardBranch, out failure))
+ {
+ return null;
+ }
+ if (!TryAnalyzeClosureCaptures(irep, out var closureCapturedRegisters, out failure))
+ {
+ return null;
+ }
+ for (var i = 0; i < closureCapturedRegisters.Length; i++)
+ {
+ if (closureCapturedRegisters[i])
+ {
+ mergeSlotRegisters[i] = true;
+ }
+ }
+
+ var builder = new Builder(irep, mergeSlotRegisters, closureCapturedRegisters, hasBackwardBranch);
+ if (!TryLowerSequence(builder, irep, inlinePlan, out failure))
+ {
+ return null;
+ }
+
+ if (builder.InstructionCount == 0)
+ {
+ failure = new RubyIRBuildFailure(null, entryPoint, "empty method");
+ return null;
+ }
+
+ return builder.TryBuild(out var ir, out failure) ? ir : null;
+ }
+
+ // Lowers an irep's bytecode body into `builder`. Extracted from TryCompile so
+ // the same translation can re-lower a callee's body into a caller's builder
+ // when inlining (the builder applies register/pc offsets transparently).
+ static bool TryLowerSequence(
+ Builder builder,
+ Irep irep,
+ Dictionary? inlinePlan,
+ out RubyIRBuildFailure failure)
+ {
+ failure = RubyIRBuildFailure.None;
+ var sequence = irep.Sequence;
+ var symbols = irep.Symbols;
+ var pc = 0;
+
+ return LowerLoop(builder, irep, inlinePlan, sequence, symbols, ref pc, out failure);
+ }
+
+ // Re-lower `site.Callee` into `builder` as an inlined body bound to the
+ // caller's `receiverRegister`/args. Returns false (without emitting anything)
+ // when the callee is not inline-safe. v1 conservatively requires the callee
+ // to compile standalone and to contain no upvars/blocks/catch handlers, so
+ // re-lowering it inline cannot fail partway through.
+ //
+ // When `site.GuardClass` is set, the body is fenced by a GuardInlineClass that
+ // jumps to the spliced body on a class/method-version match and otherwise
+ // falls through to a cold copy of the original `methodId` Send. Both paths
+ // write the shared inline-result slot and merge at the continuation, so the
+ // inline is speculative-safe: a receiver of a different class deopts cleanly.
+ static bool TryInlineCallee(
+ Builder builder,
+ RubyIRInlineSite site,
+ int destinationRegister,
+ ushort receiver,
+ int firstArgumentRegister,
+ int argc,
+ Symbol methodId)
+ {
+ var calleeIrep = site.Callee;
+ if (calleeIrep.CatchHandlers.Length != 0 ||
+ calleeIrep.RegisterVariableCount <= argc ||
+ CalleeHasInlineUnsafeOpcodes(calleeIrep) ||
+ !TryReadCalleeArity(calleeIrep, out var mandatoryArgs, out var bodyPc) ||
+ mandatoryArgs != argc ||
+ TryCompile(calleeIrep, 0, out _) is null ||
+ !AnalyzeForwardBranches(calleeIrep, out var calleeMergeSlots, out var calleeHasBackwardBranch, out _) ||
+ calleeHasBackwardBranch)
+ {
+ return false;
+ }
+
+ Span args = argc == 0 ? default : stackalloc ushort[argc];
+ for (var i = 0; i < argc; i++)
+ {
+ args[i] = builder.Read(firstArgumentRegister + i);
+ }
+
+ var guarded = site.GuardClass is not null;
+ var resultValueId = builder.BeginInline(
+ calleeIrep.RegisterVariableCount,
+ calleeMergeSlots,
+ receiver,
+ args,
+ calleeIrep.Sequence.Length,
+ guarded);
+
+ if (guarded)
+ {
+ if (site.GuardMissDeopts)
+ {
+ // AOT scalar replacement can then ignore the miss path entirely:
+ // the generated method returns false and the VM interprets from
+ // the original bytecode, where all objects are materialized.
+ builder.EmitInlineGuardDeopt(
+ receiver,
+ methodId,
+ site.GuardClass!,
+ site.GuardMethodCacheVersion,
+ site.GuardMethodFingerprint);
+ }
+ else
+ {
+ // Emit guard + cold Send before the body so the guard, on a match,
+ // jumps over the cold path into the spliced body (callee pc 0).
+ builder.EmitInlineGuardAndColdSend(
+ receiver,
+ args,
+ methodId,
+ site.GuardClass!,
+ site.GuardMethodCacheVersion,
+ site.GuardMethodFingerprint,
+ resultValueId);
+ }
+ }
+
+ // Validated above, so this cannot fail; ignore failure.
+ TryLowerSequence(builder, calleeIrep, null, out _);
+ builder.EndInline(destinationRegister, resultValueId);
+ return true;
+ }
+
+ // Bytecode pc of the first plain (non-self) Send/Send0 to `methodId`, or -1.
+ // Used to key inline plans by call-site pc.
+ internal static int FindFirstSendPc(Irep irep, Symbol methodId)
+ {
+ var sequence = irep.Sequence;
+ var symbols = irep.Symbols;
+ var pc = 0;
+ while (pc < sequence.Length)
+ {
+ var opCode = (OpCode)sequence[pc];
+ if (opCode is OpCode.Send or OpCode.Send0 &&
+ sequence[pc + 2] < symbols.Length &&
+ symbols[sequence[pc + 2]] == methodId)
+ {
+ return pc;
+ }
+
+ if (!TryGetInstructionLength(sequence, pc, opCode, out var length))
+ {
+ return -1;
+ }
+ pc += length;
+ }
+
+ return -1;
+ }
+
+ internal static Dictionary? TryBuildSelfInlinePlan(
+ MRubyState state,
+ Irep callerIrep,
+ RClass definingClass,
+ IReadOnlyDictionary inlineRegistry,
+ IReadOnlyDictionary? inlineSelectorRegistry = null,
+ HashSet? candidatePcs = null)
+ {
+ if (Environment.GetEnvironmentVariable("AOT_NOSPLICE") == "1")
+ {
+ return null;
+ }
+
+ var noSelfSplice = Environment.GetEnvironmentVariable("AOT_NOSELFSPLICE") == "1";
+ var noCrossSplice = Environment.GetEnvironmentVariable("AOT_NOCROSSSPLICE") == "1";
+ Dictionary? plan = null;
+ var sequence = callerIrep.Sequence;
+ var symbols = callerIrep.Symbols;
+ var pc = 0;
+ while (pc < sequence.Length)
+ {
+ var opCode = (OpCode)sequence[pc];
+ if ((candidatePcs is null || candidatePcs.Contains(pc)) &&
+ opCode is OpCode.Send or OpCode.SSend or OpCode.Send0 or OpCode.SSend0 &&
+ sequence[pc + 2] < symbols.Length)
+ {
+ var hasArgs = opCode is OpCode.Send or OpCode.SSend;
+ var argc = hasArgs ? sequence[pc + 3] & 0xf : 0;
+ var kargc = hasArgs ? (sequence[pc + 3] >> 4) & 0xf : 0;
+ var methodId = symbols[sequence[pc + 2]];
+ if (!noSelfSplice && kargc == 0 && (opCode is OpCode.SSend or OpCode.SSend0) &&
+ state.TryFindMethod(definingClass, methodId, out var selfMethod, out _) &&
+ selfMethod.Proc is { } selfProc)
+ {
+ var fp = state.ComputeIrepFingerprint(selfProc.Irep);
+ if (inlineRegistry.TryGetValue(fp, out var calleeArgc) && calleeArgc == argc)
+ {
+ plan ??= new Dictionary();
+ plan[pc] = new RubyIRInlineSite(
+ selfProc.Irep,
+ definingClass,
+ guardMethodCacheVersion: 0,
+ guardMethodFingerprint: fp,
+ guardMissDeopts: true);
+ }
+ }
+ else if (!noCrossSplice && kargc == 0 && (opCode is OpCode.Send or OpCode.Send0) &&
+ inlineSelectorRegistry is not null &&
+ inlineSelectorRegistry.TryGetValue(methodId, out var target) &&
+ target.ArgCount == argc)
+ {
+ plan ??= new Dictionary();
+ plan[pc] = new RubyIRInlineSite(
+ target.Irep,
+ target.DefiningClass,
+ guardMethodCacheVersion: 0,
+ guardMethodFingerprint: target.Fingerprint,
+ guardMissDeopts: true);
+ }
+ }
+
+ if (!TryGetInstructionLength(sequence, pc, opCode, out var length))
+ {
+ return plan;
+ }
+ pc += length;
+ }
+
+ return plan;
+ }
+
+ static bool TryReadCalleeArity(Irep calleeIrep, out int mandatoryArgs, out int bodyPc)
+ {
+ mandatoryArgs = 0;
+ bodyPc = 0;
+ var sequence = calleeIrep.Sequence;
+ if (sequence.Length == 0 || (OpCode)sequence[0] != OpCode.Enter)
+ {
+ return false;
+ }
+
+ var aspec = new ArgumentSpec(ReadUInt24(sequence, 1));
+ if (aspec.OptionalArgumentsCount != 0 ||
+ aspec.TakeRestArguments ||
+ aspec.MandatoryArguments2Count != 0 ||
+ aspec.KeywordArgumentsCount != 0 ||
+ aspec.TakeKeywordDict ||
+ aspec.TakeBlock)
+ {
+ return false;
+ }
+
+ mandatoryArgs = aspec.MandatoryArguments1Count;
+ bodyPc = 4;
+ return true;
+ }
+
+ // Inlining re-binds the callee's self/args to caller values, so a callee that
+ // reads an enclosing scope (upvars) or creates closures cannot be inlined as
+ // a flat body without breaking those scope references.
+ // True if the callee body contains a (forward) branch. SSA inline splicing is
+ // restricted to such callees: a straight-line callee is already served by the
+ // trivial-getter / expression-tree InlineBody path or a guarded direct call,
+ // so splicing only adds the capability those paths lack — inlining branches.
+ internal static bool CalleeHasControlFlow(Irep irep)
+ {
+ var sequence = irep.Sequence;
+ var pc = 0;
+ while (pc < sequence.Length)
+ {
+ var opCode = (OpCode)sequence[pc];
+ if (opCode is OpCode.Jmp or OpCode.JmpIf or OpCode.JmpNot or OpCode.JmpNil)
+ {
+ return true;
+ }
+
+ if (!TryGetInstructionLengthForControlScan(sequence, pc, opCode, out var length))
+ {
+ return false;
+ }
+ pc += length;
+ }
+
+ return false;
+ }
+
+ static bool CalleeHasInlineUnsafeOpcodes(Irep irep)
+ {
+ var sequence = irep.Sequence;
+ var pc = 0;
+ while (pc < sequence.Length)
+ {
+ var opCode = (OpCode)sequence[pc];
+ if (opCode is OpCode.GetUpVar or OpCode.SetUpVar or OpCode.Block or
+ OpCode.SendB or OpCode.SSendB)
+ {
+ return true;
+ }
+
+ if (!TryGetInstructionLengthForControlScan(sequence, pc, opCode, out var length))
+ {
+ return true;
+ }
+ pc += length;
+ }
+
+ return false;
+ }
+
+ static bool LowerLoop(
+ Builder builder,
+ Irep irep,
+ Dictionary? inlinePlan,
+ byte[] sequence,
+ Symbol[] symbols,
+ ref int pc,
+ out RubyIRBuildFailure failure)
+ {
+ failure = RubyIRBuildFailure.None;
+
+ while (pc < sequence.Length)
+ {
+ builder.MarkBytecodePc(pc);
+ var opCode = (OpCode)sequence[pc];
+ switch (opCode)
+ {
+ case OpCode.Nop:
+ pc += 1;
+ break;
+ case OpCode.Enter:
+ {
+ var bits = ReadUInt24(sequence, pc + 1);
+ var aspec = new ArgumentSpec(bits);
+ if (aspec.OptionalArgumentsCount != 0 ||
+ aspec.TakeRestArguments ||
+ aspec.MandatoryArguments2Count != 0 ||
+ aspec.KeywordArgumentsCount != 0 ||
+ aspec.TakeKeywordDict ||
+ aspec.TakeBlock ||
+ aspec.MandatoryArguments1Count >= MRubyCallInfo.CallMaxArgs)
+ {
+ failure = new RubyIRBuildFailure(opCode, pc, "complex argument spec");
+ return false;
+ }
+ // When inlining, arguments are already bound to the caller's
+ // values, so the callee's arity check is unnecessary.
+ if (!builder.Inlining)
+ {
+ builder.Add(new RubyIRInstruction(
+ RubyIROpCode.CheckArity,
+ aux: aspec.MandatoryArguments1Count));
+ }
+ pc += 4;
+ break;
+ }
+ case OpCode.Move:
+ builder.Move(sequence[pc + 1], sequence[pc + 2]);
+ pc += 3;
+ break;
+ case OpCode.LoadL:
+ builder.DefineValue(sequence[pc + 1], irep.PoolValues[sequence[pc + 2]]);
+ pc += 3;
+ break;
+ case OpCode.LoadI8:
+ builder.DefineValue(sequence[pc + 1], new MRubyValue(sequence[pc + 2]));
+ pc += 3;
+ break;
+ case OpCode.LoadINeg:
+ builder.DefineValue(sequence[pc + 1], new MRubyValue(-sequence[pc + 2]));
+ pc += 3;
+ break;
+ case OpCode.LoadI__1:
+ case OpCode.LoadI_0:
+ case OpCode.LoadI_1:
+ case OpCode.LoadI_2:
+ case OpCode.LoadI_3:
+ case OpCode.LoadI_4:
+ case OpCode.LoadI_5:
+ case OpCode.LoadI_6:
+ case OpCode.LoadI_7:
+ builder.DefineValue(
+ sequence[pc + 1],
+ new MRubyValue((int)opCode - (int)OpCode.LoadI_0));
+ pc += 2;
+ break;
+ case OpCode.LoadI16:
+ builder.DefineValue(
+ sequence[pc + 1],
+ new MRubyValue(unchecked((short)ReadUInt16(sequence, pc + 2))));
+ pc += 4;
+ break;
+ case OpCode.LoadI32:
+ builder.DefineValue(
+ sequence[pc + 1],
+ new MRubyValue(unchecked((int)ReadUInt32FromTwoUInt16(sequence, pc + 2))));
+ pc += 6;
+ break;
+ case OpCode.LoadSym:
+ builder.DefineValue(sequence[pc + 1], new MRubyValue(symbols[sequence[pc + 2]]));
+ pc += 3;
+ break;
+ case OpCode.LoadNil:
+ builder.DefineValue(sequence[pc + 1], MRubyValue.Nil);
+ pc += 2;
+ break;
+ case OpCode.LoadSelf:
+ builder.DefineSelf(sequence[pc + 1]);
+ pc += 2;
+ break;
+ case OpCode.GetUpVar:
+ builder.DefineUpVar(
+ sequence[pc + 1],
+ sequence[pc + 2],
+ sequence[pc + 3]);
+ pc += 4;
+ break;
+ case OpCode.SetUpVar:
+ builder.SetUpVar(
+ sequence[pc + 1],
+ sequence[pc + 2],
+ sequence[pc + 3]);
+ pc += 4;
+ break;
+ case OpCode.GetConst:
+ builder.DefineSymbolOp(
+ RubyIROpCode.GetConstant,
+ sequence[pc + 1],
+ symbols[sequence[pc + 2]]);
+ pc += 3;
+ break;
+ case OpCode.GetMCnst:
+ builder.DefineSymbolOp(
+ RubyIROpCode.GetModuleConstant,
+ sequence[pc + 1],
+ symbols[sequence[pc + 2]],
+ builder.Read(sequence[pc + 1]));
+ pc += 3;
+ break;
+ case OpCode.LoadT:
+ builder.DefineValue(sequence[pc + 1], MRubyValue.True);
+ pc += 2;
+ break;
+ case OpCode.LoadF:
+ builder.DefineValue(sequence[pc + 1], MRubyValue.False);
+ pc += 2;
+ break;
+ case OpCode.GetIV:
+ builder.DefineSymbolOp(
+ RubyIROpCode.GetInstanceVariable,
+ sequence[pc + 1],
+ symbols[sequence[pc + 2]],
+ builder.Self);
+ pc += 3;
+ break;
+ case OpCode.SetIV:
+ builder.Add(new RubyIRInstruction(
+ RubyIROpCode.SetInstanceVariable,
+ src0: builder.Self,
+ src1: builder.Read(sequence[pc + 1]),
+ aux: builder.Symbol(symbols[sequence[pc + 2]])));
+ pc += 3;
+ break;
+ case OpCode.GetIdx:
+ builder.DefineBinaryOp(
+ RubyIROpCode.GetIndex,
+ sequence[pc + 1],
+ sequence[pc + 1],
+ sequence[pc + 1] + 1);
+ pc += 2;
+ break;
+ case OpCode.GetIdx0:
+ builder.DefineOp(
+ RubyIROpCode.GetIndex0,
+ sequence[pc + 1],
+ builder.Read(sequence[pc + 2]));
+ pc += 3;
+ break;
+ case OpCode.SetIdx:
+ builder.DefineTernaryOp(
+ RubyIROpCode.SetIndex,
+ sequence[pc + 1],
+ sequence[pc + 1],
+ sequence[pc + 1] + 1,
+ sequence[pc + 1] + 2);
+ pc += 2;
+ break;
+ case OpCode.Jmp:
+ // JmpUw (`break` out of a while loop) is a plain unconditional jump in a
+ // catch-handler-free method (TryCompile requires no catch handlers, so the VM's
+ // ensure-unwind branch never fires) — lower it identically to Jmp.
+ case OpCode.JmpUw:
+ {
+ // Backward targets (loops) are accepted: the codegen lowers them to a C# `goto`
+ // and AnalyzeForwardBranches has already forced every register to a merge slot
+ // (in-place boxed value-id) so loop-carried values round-trip correctly.
+ var targetPc = pc + 3 + unchecked((short)ReadUInt16(sequence, pc + 1));
+ builder.AddBranch(RubyIROpCode.Jump, targetPc);
+ pc += 3;
+ break;
+ }
+ case OpCode.JmpIf:
+ {
+ var targetPc = ResolveShortCircuitBranchTarget(
+ sequence,
+ opCode,
+ sequence[pc + 1],
+ pc + 4 + unchecked((short)ReadUInt16(sequence, pc + 2)));
+ builder.AddBranch(RubyIROpCode.JumpIfTruthy, targetPc, builder.Read(sequence[pc + 1]));
+ pc += 4;
+ break;
+ }
+ case OpCode.JmpNot:
+ {
+ var targetPc = ResolveShortCircuitBranchTarget(
+ sequence,
+ opCode,
+ sequence[pc + 1],
+ pc + 4 + unchecked((short)ReadUInt16(sequence, pc + 2)));
+ builder.AddBranch(RubyIROpCode.JumpIfFalsy, targetPc, builder.Read(sequence[pc + 1]));
+ pc += 4;
+ break;
+ }
+ case OpCode.JmpNil:
+ {
+ var targetPc = ResolveShortCircuitBranchTarget(
+ sequence,
+ opCode,
+ sequence[pc + 1],
+ pc + 4 + unchecked((short)ReadUInt16(sequence, pc + 2)));
+ builder.AddBranch(RubyIROpCode.JumpIfNil, targetPc, builder.Read(sequence[pc + 1]));
+ pc += 4;
+ break;
+ }
+ case OpCode.Send:
+ case OpCode.SSend:
+ if (opCode == OpCode.Send &&
+ inlinePlan is not null &&
+ inlinePlan.TryGetValue(pc, out var sendSite) &&
+ TryInlineCallee(
+ builder,
+ sendSite,
+ sequence[pc + 1],
+ builder.Read(sequence[pc + 1]),
+ sequence[pc + 1] + 1,
+ sequence[pc + 3] & 0xf,
+ symbols[sequence[pc + 2]]))
+ {
+ pc += 4;
+ break;
+ }
+ if (opCode == OpCode.SSend &&
+ inlinePlan is not null &&
+ inlinePlan.TryGetValue(pc, out var selfSendSite) &&
+ TryInlineCallee(
+ builder,
+ selfSendSite,
+ sequence[pc + 1],
+ builder.Self,
+ sequence[pc + 1] + 1,
+ sequence[pc + 3] & 0xf,
+ symbols[sequence[pc + 2]]))
+ {
+ pc += 4;
+ break;
+ }
+ builder.DefineSend(
+ opCode == OpCode.Send ? RubyIROpCode.Send : RubyIROpCode.SendSelf,
+ sequence[pc + 1],
+ sequence[pc + 3] & 0xf,
+ symbols[sequence[pc + 2]]);
+ pc += 4;
+ break;
+ case OpCode.SendB:
+ case OpCode.SSendB:
+ {
+ var register = sequence[pc + 1];
+ var callFlags = sequence[pc + 3];
+ var argc = callFlags & 0xf;
+ var kargc = (callFlags >> 4) & 0xf;
+ if (argc >= MRubyCallInfo.CallMaxArgs || kargc != 0)
+ {
+ failure = new RubyIRBuildFailure(opCode, pc, "complex block send");
+ return false;
+ }
+
+ var blockRegister = register + MRubyCallInfo.CalculateBlockArgumentOffset(argc, kargc);
+ builder.DefineSendBlock(
+ opCode == OpCode.SendB ? RubyIROpCode.SendBlock : RubyIROpCode.SendSelfBlock,
+ register,
+ argc,
+ blockRegister,
+ symbols[sequence[pc + 2]]);
+ pc += 4;
+ break;
+ }
+ case OpCode.Send0:
+ case OpCode.SSend0:
+ if (opCode == OpCode.Send0 &&
+ inlinePlan is not null &&
+ inlinePlan.TryGetValue(pc, out var send0Site) &&
+ TryInlineCallee(
+ builder,
+ send0Site,
+ sequence[pc + 1],
+ builder.Read(sequence[pc + 1]),
+ sequence[pc + 1] + 1,
+ 0,
+ symbols[sequence[pc + 2]]))
+ {
+ pc += 3;
+ break;
+ }
+ if (opCode == OpCode.SSend0 &&
+ inlinePlan is not null &&
+ inlinePlan.TryGetValue(pc, out var selfSend0Site) &&
+ TryInlineCallee(
+ builder,
+ selfSend0Site,
+ sequence[pc + 1],
+ builder.Self,
+ sequence[pc + 1] + 1,
+ 0,
+ symbols[sequence[pc + 2]]))
+ {
+ pc += 3;
+ break;
+ }
+ builder.DefineSend(
+ opCode == OpCode.Send0 ? RubyIROpCode.Send : RubyIROpCode.SendSelf,
+ sequence[pc + 1],
+ 0,
+ symbols[sequence[pc + 2]]);
+ pc += 3;
+ break;
+ case OpCode.Add:
+ builder.DefineBinaryOp(RubyIROpCode.Add, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1);
+ pc += 2;
+ break;
+ case OpCode.AddI:
+ builder.DefineImmediateOp(
+ RubyIROpCode.AddImmediate,
+ sequence[pc + 1],
+ sequence[pc + 1],
+ new MRubyValue(sequence[pc + 2]));
+ pc += 3;
+ break;
+ case OpCode.AddILV:
+ builder.DefineImmediateOp(
+ RubyIROpCode.AddImmediate,
+ sequence[pc + 1],
+ sequence[pc + 1],
+ new MRubyValue(sequence[pc + 3]));
+ pc += 4;
+ break;
+ case OpCode.Sub:
+ builder.DefineBinaryOp(RubyIROpCode.Sub, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1);
+ pc += 2;
+ break;
+ case OpCode.SubI:
+ builder.DefineImmediateOp(
+ RubyIROpCode.SubImmediate,
+ sequence[pc + 1],
+ sequence[pc + 1],
+ new MRubyValue(sequence[pc + 2]));
+ pc += 3;
+ break;
+ case OpCode.SubILV:
+ builder.DefineImmediateOp(
+ RubyIROpCode.SubImmediate,
+ sequence[pc + 1],
+ sequence[pc + 1],
+ new MRubyValue(sequence[pc + 3]));
+ pc += 4;
+ break;
+ case OpCode.Mul:
+ builder.DefineBinaryOp(RubyIROpCode.Mul, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1);
+ pc += 2;
+ break;
+ case OpCode.Div:
+ builder.DefineBinaryOp(RubyIROpCode.Div, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1);
+ pc += 2;
+ break;
+ case OpCode.EQ:
+ builder.DefineBinaryOp(RubyIROpCode.Eq, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1);
+ pc += 2;
+ break;
+ case OpCode.LT:
+ builder.DefineBinaryOp(RubyIROpCode.Lt, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1);
+ pc += 2;
+ break;
+ case OpCode.LE:
+ builder.DefineBinaryOp(RubyIROpCode.Le, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1);
+ pc += 2;
+ break;
+ case OpCode.GT:
+ builder.DefineBinaryOp(RubyIROpCode.Gt, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1);
+ pc += 2;
+ break;
+ case OpCode.GE:
+ builder.DefineBinaryOp(RubyIROpCode.Ge, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1);
+ pc += 2;
+ break;
+ case OpCode.Array:
+ builder.DefineArray(sequence[pc + 1], sequence[pc + 1], sequence[pc + 2]);
+ pc += 3;
+ break;
+ case OpCode.Array2:
+ builder.DefineArray(sequence[pc + 1], sequence[pc + 2], sequence[pc + 3]);
+ pc += 4;
+ break;
+ case OpCode.Hash:
+ // OP_HASH R B: build a hash from B key/value pairs at R..R+2B-1, result in R.
+ builder.DefineHash(sequence[pc + 1], sequence[pc + 2]);
+ pc += 3;
+ break;
+ case OpCode.ARef:
+ builder.DefineOp(
+ RubyIROpCode.ArrayRef,
+ sequence[pc + 1],
+ builder.Read(sequence[pc + 2]),
+ aux: sequence[pc + 3]);
+ pc += 4;
+ break;
+ case OpCode.ASet:
+ builder.Add(new RubyIRInstruction(
+ RubyIROpCode.ArraySet,
+ src0: builder.Read(sequence[pc + 2]),
+ src1: builder.Read(sequence[pc + 1]),
+ aux: sequence[pc + 3]));
+ pc += 4;
+ break;
+ case OpCode.Block:
+ if (sequence[pc + 2] >= irep.Children.Length)
+ {
+ failure = new RubyIRBuildFailure(opCode, pc, "child irep out of range");
+ return false;
+ }
+
+ var child = irep.Children[sequence[pc + 2]];
+ if (BlockChildNeedsBytecodeBoundary(child, out var boundaryReason))
+ {
+ failure = new RubyIRBuildFailure(opCode, pc, boundaryReason);
+ return false;
+ }
+
+ var nextPc = pc + 3;
+ if ((uint)(nextPc + 3) < (uint)sequence.Length &&
+ (OpCode)sequence[nextPc] is OpCode.SendB or OpCode.SSendB)
+ {
+ var sendOpCode = (OpCode)sequence[nextPc];
+ var sendRegister = sequence[nextPc + 1];
+ var sendCallFlags = sequence[nextPc + 3];
+ var sendArgc = sendCallFlags & 0xf;
+ var sendKargc = (sendCallFlags >> 4) & 0xf;
+ if (sendArgc < MRubyCallInfo.CallMaxArgs && sendKargc == 0)
+ {
+ var blockRegister = sendRegister + MRubyCallInfo.CalculateBlockArgumentOffset(sendArgc, sendKargc);
+ if (blockRegister == sequence[pc + 1])
+ {
+ builder.DefineSendBlockDescriptor(
+ sendOpCode == OpCode.SendB
+ ? RubyIROpCode.SendBlockDescriptor
+ : RubyIROpCode.SendSelfBlockDescriptor,
+ sendRegister,
+ sendArgc,
+ child,
+ symbols[sequence[nextPc + 2]]);
+ pc += 7;
+ break;
+ }
+ }
+ }
+
+ builder.DefineBlock(sequence[pc + 1], child);
+ pc += 3;
+ break;
+ case OpCode.Return:
+ builder.EmitReturn(sequence[pc + 1]);
+ pc += 2;
+ break;
+ case OpCode.RetSelf:
+ builder.EmitReturnSelf();
+ pc += 1;
+ break;
+ case OpCode.RetNil:
+ builder.EmitReturnLiteral(MRubyValue.Nil);
+ pc += 1;
+ break;
+ case OpCode.RetTrue:
+ builder.EmitReturnLiteral(MRubyValue.True);
+ pc += 1;
+ break;
+ case OpCode.RetFalse:
+ builder.EmitReturnLiteral(MRubyValue.False);
+ pc += 1;
+ break;
+ default:
+ failure = new RubyIRBuildFailure(opCode, pc, "unsupported opcode");
+ return false;
+ }
+ }
+
+ builder.MarkBytecodePc(sequence.Length);
+ return true;
+ }
+
+ static uint ReadUInt16(byte[] sequence, int offset) =>
+ (uint)((sequence[offset] << 8) | sequence[offset + 1]);
+
+ static uint ReadUInt24(byte[] sequence, int offset) =>
+ ((uint)sequence[offset] << 16) | ((uint)sequence[offset + 1] << 8) | sequence[offset + 2];
+
+ static uint ReadUInt32FromTwoUInt16(byte[] sequence, int offset) =>
+ (ReadUInt16(sequence, offset) << 16) | ReadUInt16(sequence, offset + 2);
+
+ static bool AnalyzeForwardBranches(
+ Irep irep,
+ out bool[] mergeSlotRegisters,
+ out bool hasBackwardBranch,
+ out RubyIRBuildFailure failure)
+ {
+ failure = RubyIRBuildFailure.None;
+ hasBackwardBranch = false;
+
+ var sequence = irep.Sequence;
+ var registerCount = irep.RegisterVariableCount;
+ mergeSlotRegisters = new bool[registerCount];
+ var pc = 0;
+ while (pc < sequence.Length)
+ {
+ var opCode = (OpCode)sequence[pc];
+ if (!TryGetInstructionLength(sequence, pc, opCode, out var length))
+ {
+ failure = new RubyIRBuildFailure(opCode, pc, "unsupported opcode");
+ return false;
+ }
+
+ var nextPc = pc + length;
+ switch (opCode)
+ {
+ case OpCode.Jmp:
+ case OpCode.JmpUw:
+ {
+ var targetPc = nextPc + unchecked((short)ReadUInt16(sequence, pc + 1));
+ // A backward target is a loop back-edge. The forward-range merge-slot analysis
+ // only models conditionally-skipped FORWARD ranges, so it can't run here; instead
+ // we flag the method as looping and (below) force every register to a merge slot,
+ // which gives each one a single in-place boxed value-id — exactly what a
+ // loop-carried register needs (header reads it, body rewrites it).
+ if (targetPc < nextPc)
+ {
+ hasBackwardBranch = true;
+ break;
+ }
+ if (!AnalyzeForwardBranchRange(
+ sequence,
+ registerCount,
+ mergeSlotRegisters,
+ nextPc,
+ targetPc,
+ opCode,
+ pc,
+ out failure))
+ {
+ return false;
+ }
+ break;
+ }
+ case OpCode.JmpIf:
+ case OpCode.JmpNot:
+ case OpCode.JmpNil:
+ {
+ var targetPc = ResolveShortCircuitBranchTarget(
+ sequence,
+ opCode,
+ sequence[pc + 1],
+ nextPc + unchecked((short)ReadUInt16(sequence, pc + 2)));
+ if (targetPc < nextPc)
+ {
+ hasBackwardBranch = true;
+ break;
+ }
+ if (!AnalyzeForwardBranchRange(
+ sequence,
+ registerCount,
+ mergeSlotRegisters,
+ nextPc,
+ targetPc,
+ opCode,
+ pc,
+ out failure))
+ {
+ return false;
+ }
+ break;
+ }
+ }
+
+ pc = nextPc;
+ }
+
+ if (hasBackwardBranch)
+ {
+ // Non-SSA, fully-boxed loop model: every register becomes a merge slot so the builder
+ // emits one value-id per register, reassigned in place across the back-edge.
+ for (var r = 0; r < mergeSlotRegisters.Length; r++)
+ {
+ mergeSlotRegisters[r] = true;
+ }
+ }
+
+ return true;
+ }
+
+ static int ResolveShortCircuitBranchTarget(
+ byte[] sequence,
+ OpCode opCode,
+ int conditionRegister,
+ int targetPc)
+ {
+ while (targetPc >= 0 &&
+ targetPc + 4 <= sequence.Length &&
+ (OpCode)sequence[targetPc] == opCode &&
+ sequence[targetPc + 1] == conditionRegister)
+ {
+ var nextTargetPc = targetPc + 4 + unchecked((short)ReadUInt16(sequence, targetPc + 2));
+ if (nextTargetPc <= targetPc)
+ {
+ break;
+ }
+
+ targetPc = nextTargetPc;
+ }
+
+ return targetPc;
+ }
+
+ static bool AnalyzeForwardBranchRange(
+ byte[] sequence,
+ int registerCount,
+ bool[] mergeSlotRegisters,
+ int startPc,
+ int targetPc,
+ OpCode branchOpCode,
+ int branchPc,
+ out RubyIRBuildFailure failure)
+ {
+ failure = RubyIRBuildFailure.None;
+ if (targetPc < startPc)
+ {
+ failure = new RubyIRBuildFailure(branchOpCode, branchPc, "backward branch");
+ return false;
+ }
+
+ if (targetPc > sequence.Length)
+ {
+ failure = new RubyIRBuildFailure(branchOpCode, branchPc, "branch target out of range");
+ return false;
+ }
+
+ if (targetPc == startPc)
+ {
+ return true;
+ }
+
+ var definedInSkippedRange = new bool[registerCount];
+ var pc = startPc;
+ while (pc < targetPc)
+ {
+ var opCode = (OpCode)sequence[pc];
+ if (!TryGetInstructionLength(sequence, pc, opCode, out var length) ||
+ pc + length > targetPc ||
+ !TryGetRegisterAccesses(sequence, pc, opCode, out _, out var write0, out var write1))
+ {
+ failure = new RubyIRBuildFailure(opCode, pc, "unsupported branch body");
+ return false;
+ }
+
+ MarkDefined(definedInSkippedRange, write0);
+ MarkDefined(definedInSkippedRange, write1);
+ pc += length;
+ }
+
+ for (var register = 0; register < definedInSkippedRange.Length; register++)
+ {
+ if (definedInSkippedRange[register] &&
+ IsRegisterReadBeforeWrite(sequence, targetPc, register, out _, out _))
+ {
+ mergeSlotRegisters[register] = true;
+ }
+ }
+
+ return true;
+ }
+
+ static bool IsRegisterReadBeforeWrite(
+ byte[] sequence,
+ int startPc,
+ int register,
+ out int readPc,
+ out OpCode readOpCode)
+ {
+ var pc = startPc;
+ while (pc < sequence.Length)
+ {
+ var opCode = (OpCode)sequence[pc];
+ if (!TryGetInstructionLength(sequence, pc, opCode, out var length) ||
+ !TryGetRegisterAccesses(sequence, pc, opCode, out var reads, out var write0, out var write1))
+ {
+ break;
+ }
+
+ if (ReadsRegister(reads, register))
+ {
+ readPc = pc;
+ readOpCode = opCode;
+ return true;
+ }
+
+ if (write0 == register || write1 == register)
+ {
+ break;
+ }
+
+ pc += length;
+ }
+
+ readPc = -1;
+ readOpCode = default;
+ return false;
+ }
+
+ static void MarkDefined(bool[] registers, int register)
+ {
+ if ((uint)register < (uint)registers.Length)
+ {
+ registers[register] = true;
+ }
+ }
+
+ static bool ReadsRegister(RegisterReads reads, int register)
+ {
+ for (var i = 0; i < reads.Count; i++)
+ {
+ if (reads[i] == register)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ static bool TryGetInstructionLength(byte[] sequence, int pc, OpCode opCode, out int length)
+ {
+ length = opCode switch
+ {
+ OpCode.Nop or
+ OpCode.Call or
+ OpCode.RetSelf or
+ OpCode.RetNil or
+ OpCode.RetTrue or
+ OpCode.RetFalse => 1,
+ OpCode.LoadI__1 or
+ OpCode.LoadI_0 or
+ OpCode.LoadI_1 or
+ OpCode.LoadI_2 or
+ OpCode.LoadI_3 or
+ OpCode.LoadI_4 or
+ OpCode.LoadI_5 or
+ OpCode.LoadI_6 or
+ OpCode.LoadI_7 or
+ OpCode.LoadNil or
+ OpCode.LoadSelf or
+ OpCode.LoadT or
+ OpCode.LoadF or
+ OpCode.GetIdx or
+ OpCode.SetIdx or
+ OpCode.Add or
+ OpCode.Sub or
+ OpCode.Mul or
+ OpCode.Div or
+ OpCode.EQ or
+ OpCode.LT or
+ OpCode.LE or
+ OpCode.GT or
+ OpCode.GE or
+ OpCode.Return => 2,
+ OpCode.Move or
+ OpCode.LoadL or
+ OpCode.LoadI8 or
+ OpCode.LoadINeg or
+ OpCode.LoadSym or
+ OpCode.GetConst or
+ OpCode.GetMCnst or
+ OpCode.GetIV or
+ OpCode.SetIV or
+ OpCode.GetIdx0 or
+ OpCode.AddI or
+ OpCode.SubI or
+ OpCode.Array or
+ OpCode.Block or
+ OpCode.Send0 or
+ OpCode.SSend0 or
+ OpCode.Hash or
+ OpCode.Jmp or
+ OpCode.JmpUw => 3,
+ OpCode.Enter or
+ OpCode.LoadI16 or
+ OpCode.GetUpVar or
+ OpCode.SetUpVar or
+ OpCode.Send or
+ OpCode.SSend or
+ OpCode.SendB or
+ OpCode.SSendB or
+ OpCode.AddILV or
+ OpCode.SubILV or
+ OpCode.Array2 or
+ OpCode.ARef or
+ OpCode.ASet or
+ OpCode.JmpIf or
+ OpCode.JmpNot or
+ OpCode.JmpNil => 4,
+ OpCode.LoadI32 => 6,
+ _ => 0
+ };
+
+ return length != 0 && pc + length <= sequence.Length;
+ }
+
+ static bool TryGetInstructionLengthForControlScan(byte[] sequence, int pc, OpCode opCode, out int length)
+ {
+ length = opCode switch
+ {
+ OpCode.Nop or
+ OpCode.Call or
+ OpCode.KeyEnd or
+ OpCode.RetSelf or
+ OpCode.RetNil or
+ OpCode.RetTrue or
+ OpCode.RetFalse or
+ OpCode.Stop or
+ OpCode.EXT1 or
+ OpCode.EXT2 or
+ OpCode.EXT3 => 1,
+ OpCode.LoadI__1 or
+ OpCode.LoadI_0 or
+ OpCode.LoadI_1 or
+ OpCode.LoadI_2 or
+ OpCode.LoadI_3 or
+ OpCode.LoadI_4 or
+ OpCode.LoadI_5 or
+ OpCode.LoadI_6 or
+ OpCode.LoadI_7 or
+ OpCode.LoadNil or
+ OpCode.LoadSelf or
+ OpCode.LoadT or
+ OpCode.LoadF or
+ OpCode.GetIdx or
+ OpCode.SetIdx or
+ OpCode.Add or
+ OpCode.Sub or
+ OpCode.Mul or
+ OpCode.Div or
+ OpCode.EQ or
+ OpCode.LT or
+ OpCode.LE or
+ OpCode.GT or
+ OpCode.GE or
+ OpCode.Return or
+ OpCode.ReturnBlk or
+ OpCode.Break or
+ OpCode.AryCat or
+ OpCode.ArySplat or
+ OpCode.Intern or
+ OpCode.StrCat or
+ OpCode.HashCat or
+ OpCode.RangeInc or
+ OpCode.RangeExc or
+ OpCode.OClass or
+ OpCode.SClass or
+ OpCode.TClass or
+ OpCode.Err or
+ OpCode.Except or
+ OpCode.RaiseIf or
+ OpCode.MatchErr or
+ OpCode.Undef => 2,
+ OpCode.Move or
+ OpCode.LoadL or
+ OpCode.LoadI8 or
+ OpCode.LoadINeg or
+ OpCode.LoadSym or
+ OpCode.GetGV or
+ OpCode.SetGV or
+ OpCode.GetSV or
+ OpCode.SetSV or
+ OpCode.GetIV or
+ OpCode.SetIV or
+ OpCode.GetCV or
+ OpCode.SetCV or
+ OpCode.GetConst or
+ OpCode.SetConst or
+ OpCode.GetMCnst or
+ OpCode.SetMCnst or
+ OpCode.GetIdx0 or
+ OpCode.AddI or
+ OpCode.SubI or
+ OpCode.Jmp or
+ OpCode.JmpUw or
+ OpCode.SSend0 or
+ OpCode.Send0 or
+ OpCode.BlkCall or
+ OpCode.Super or
+ OpCode.KeyP or
+ OpCode.KArg or
+ OpCode.BlkPush or
+ OpCode.Array or
+ OpCode.AryPush or
+ OpCode.Symbol or
+ OpCode.String or
+ OpCode.Hash or
+ OpCode.HashAdd or
+ OpCode.Lambda or
+ OpCode.Block or
+ OpCode.Method or
+ OpCode.Class or
+ OpCode.Module or
+ OpCode.Exec or
+ OpCode.Def or
+ OpCode.Alias => 3,
+ OpCode.Enter or
+ OpCode.LoadI16 or
+ OpCode.GetUpVar or
+ OpCode.SetUpVar or
+ OpCode.JmpIf or
+ OpCode.JmpNot or
+ OpCode.JmpNil or
+ OpCode.SSend or
+ OpCode.SSendB or
+ OpCode.Send or
+ OpCode.SendB or
+ OpCode.ArgAry or
+ OpCode.AddILV or
+ OpCode.SubILV or
+ OpCode.Array2 or
+ OpCode.ARef or
+ OpCode.ASet or
+ OpCode.APost or
+ OpCode.Rescue or
+ OpCode.TDef or
+ OpCode.SDef or
+ OpCode.Debug => 4,
+ OpCode.LoadI32 => 6,
+ _ => 0
+ };
+
+ return length != 0 && pc + length <= sequence.Length;
+ }
+
+ static bool TryGetRegisterAccesses(
+ byte[] sequence,
+ int pc,
+ OpCode opCode,
+ out RegisterReads reads,
+ out int write0,
+ out int write1)
+ {
+ reads = default;
+ write0 = -1;
+ write1 = -1;
+
+ switch (opCode)
+ {
+ case OpCode.Nop:
+ case OpCode.Enter:
+ case OpCode.Jmp:
+ // JmpUw (jump-with-unwind, emitted for `break` out of a while loop) is an
+ // unconditional jump with no register effects. In a catch-handler-free method
+ // (TryCompile requires that) it degenerates to a plain jump.
+ case OpCode.JmpUw:
+ case OpCode.RetNil:
+ case OpCode.RetTrue:
+ case OpCode.RetFalse:
+ return true;
+ case OpCode.Move:
+ reads.Add(sequence[pc + 2]);
+ write0 = sequence[pc + 1];
+ return true;
+ case OpCode.LoadL:
+ case OpCode.LoadI8:
+ case OpCode.LoadINeg:
+ case OpCode.LoadSym:
+ case OpCode.GetConst:
+ case OpCode.LoadI16:
+ case OpCode.LoadI32:
+ case OpCode.LoadI__1:
+ case OpCode.LoadI_0:
+ case OpCode.LoadI_1:
+ case OpCode.LoadI_2:
+ case OpCode.LoadI_3:
+ case OpCode.LoadI_4:
+ case OpCode.LoadI_5:
+ case OpCode.LoadI_6:
+ case OpCode.LoadI_7:
+ case OpCode.LoadNil:
+ case OpCode.LoadSelf:
+ case OpCode.LoadT:
+ case OpCode.LoadF:
+ write0 = sequence[pc + 1];
+ return true;
+ case OpCode.GetMCnst:
+ reads.Add(sequence[pc + 1]);
+ write0 = sequence[pc + 1];
+ return true;
+ case OpCode.GetIV:
+ reads.Add(0);
+ write0 = sequence[pc + 1];
+ return true;
+ case OpCode.GetUpVar:
+ write0 = sequence[pc + 1];
+ return true;
+ case OpCode.SetIV:
+ reads.Add(0);
+ reads.Add(sequence[pc + 1]);
+ return true;
+ case OpCode.SetUpVar:
+ reads.Add(sequence[pc + 1]);
+ return true;
+ case OpCode.GetIdx:
+ reads.Add(sequence[pc + 1]);
+ reads.Add(sequence[pc + 1] + 1);
+ write0 = sequence[pc + 1];
+ return true;
+ case OpCode.GetIdx0:
+ reads.Add(sequence[pc + 2]);
+ write0 = sequence[pc + 1];
+ return true;
+ case OpCode.SetIdx:
+ reads.Add(sequence[pc + 1]);
+ reads.Add(sequence[pc + 1] + 1);
+ reads.Add(sequence[pc + 1] + 2);
+ write0 = sequence[pc + 1];
+ return true;
+ case OpCode.Send:
+ case OpCode.SSend:
+ {
+ var register = sequence[pc + 1];
+ var argumentCount = sequence[pc + 3] & 0xf;
+ reads.Add(opCode == OpCode.SSend ? 0 : register);
+ for (var i = 0; i < argumentCount; i++)
+ {
+ reads.Add(register + 1 + i);
+ }
+ write0 = register;
+ return true;
+ }
+ case OpCode.SendB:
+ case OpCode.SSendB:
+ {
+ var register = sequence[pc + 1];
+ var argumentCount = sequence[pc + 3] & 0xf;
+ var keywordArgumentCount = (sequence[pc + 3] >> 4) & 0xf;
+ if (argumentCount >= MRubyCallInfo.CallMaxArgs || keywordArgumentCount != 0)
+ {
+ return false;
+ }
+
+ reads.Add(opCode == OpCode.SSendB ? 0 : register);
+ for (var i = 0; i < argumentCount; i++)
+ {
+ reads.Add(register + 1 + i);
+ }
+ reads.Add(register + MRubyCallInfo.CalculateBlockArgumentOffset(argumentCount, keywordArgumentCount));
+ write0 = register;
+ return true;
+ }
+ case OpCode.Send0:
+ case OpCode.SSend0:
+ {
+ var register = sequence[pc + 1];
+ reads.Add(opCode == OpCode.SSend0 ? 0 : register);
+ write0 = register;
+ return true;
+ }
+ case OpCode.Add:
+ case OpCode.Sub:
+ case OpCode.Mul:
+ case OpCode.Div:
+ case OpCode.EQ:
+ case OpCode.LT:
+ case OpCode.LE:
+ case OpCode.GT:
+ case OpCode.GE:
+ reads.Add(sequence[pc + 1]);
+ reads.Add(sequence[pc + 1] + 1);
+ write0 = sequence[pc + 1];
+ return true;
+ case OpCode.AddI:
+ case OpCode.SubI:
+ reads.Add(sequence[pc + 1]);
+ write0 = sequence[pc + 1];
+ return true;
+ case OpCode.AddILV:
+ case OpCode.SubILV:
+ reads.Add(sequence[pc + 1]);
+ write0 = sequence[pc + 1];
+ return true;
+ case OpCode.Array:
+ {
+ var start = sequence[pc + 1];
+ var count = sequence[pc + 2];
+ for (var i = 0; i < count; i++)
+ {
+ reads.Add(start + i);
+ }
+ write0 = start;
+ return true;
+ }
+ case OpCode.Array2:
+ {
+ var start = sequence[pc + 2];
+ var count = sequence[pc + 3];
+ for (var i = 0; i < count; i++)
+ {
+ reads.Add(start + i);
+ }
+ write0 = sequence[pc + 1];
+ return true;
+ }
+ case OpCode.ARef:
+ reads.Add(sequence[pc + 2]);
+ write0 = sequence[pc + 1];
+ return true;
+ case OpCode.ASet:
+ reads.Add(sequence[pc + 2]);
+ reads.Add(sequence[pc + 1]);
+ return true;
+ case OpCode.Block:
+ write0 = sequence[pc + 1];
+ return true;
+ case OpCode.Hash:
+ {
+ var start = sequence[pc + 1];
+ var pairs = sequence[pc + 2];
+ for (var i = 0; i < pairs * 2; i++) reads.Add(start + i);
+ write0 = start;
+ return true;
+ }
+ case OpCode.Return:
+ case OpCode.JmpIf:
+ case OpCode.JmpNot:
+ case OpCode.JmpNil:
+ reads.Add(sequence[pc + 1]);
+ return true;
+ case OpCode.RetSelf:
+ reads.Add(0);
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ struct RegisterReads
+ {
+ int count;
+ int value0;
+ int value1;
+ int value2;
+ int value3;
+ List? overflow;
+
+ public int Count => count;
+
+ public int this[int index]
+ {
+ get
+ {
+ return index switch
+ {
+ 0 => value0,
+ 1 => value1,
+ 2 => value2,
+ 3 => value3,
+ _ => overflow![index - 4],
+ };
+ }
+ }
+
+ public void Add(int register)
+ {
+ switch (count)
+ {
+ case 0:
+ value0 = register;
+ break;
+ case 1:
+ value1 = register;
+ break;
+ case 2:
+ value2 = register;
+ break;
+ case 3:
+ value3 = register;
+ break;
+ default:
+ overflow ??= new List();
+ overflow.Add(register);
+ break;
+ }
+
+ count++;
+ }
+ }
+
+ sealed class Builder
+ {
+ readonly List